一、介绍
Caffeine 是一个高性能的本地缓存框架,专为Java设计,旨在提供优于其他同类解决方案的吞吐量和延迟表现。它是基于Java 8及以上版本的新特性构建的,利用了如StampedLock乐观锁机制来显著提升在高并发环境下的缓存操作性能。尽管Caffeine被归类为本地缓存,意味着它不涉及跨进程或网络的分布式存储,但它在单一JVM应用中的表现非常出色,适用于那些需要快速数据访问且数据可以在单个应用实例中管理的场景。
验证操作的幂等性是指确认某个操作或函数具备幂等性特性,即不论对该操作执行多少次,其对系统状态的影响都与仅执行一次时相同。这意味着执行一次和多次操作后,系统的最终状态保持不变,不会因为重复执行而产生不同的结果或副作用。
二、实现
以下代码用于验证登录操作是否是重复的,通过查询一个缓存(假设为快速访问存储)来判断某个操作是否已经执行过。如果缓存中不存在给定的键,则认为该操作是首次执行,并将其添加到缓存中。如果缓存中已经存在该键,则认为该操作是重复的。
Xml引入
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
CaffeineConfig类
@Configuration
@EnableCaching
public class CaffeineConfig {
@Bean
public Cache<String, Integer> caffeine() {
Cache<String, Integer> cache = Caffeine.newBuilder()
.maximumSize(1000)
.build();
return cache;
}
}
CaffeineUtils类
@Component
public class CaffeineUtils {
@Autowired
Cache<String, Integer> cache;
/**
* 登录前缀
*/
public static String LOGIN_PREFIX = "login:";
/**
* 验证操作是否是重复的。
* 该方法通过查询一个缓存(假设为快速访问存储)来判断某个操作是否已经执行过。如果缓存中不存在给定的键,
* 则认为该操作是首次执行,并将其添加到缓存中。如果缓存中已经存在该键,则认为该操作是重复的。
* 这种方法常用于防止重复提交、重复请求等场景。
*
* @param key 缓存中的键。
*/
public boolean verifyIdempotent(String key) {
// 从缓存中获取键对应的值,如果键不存在,则返回null
Integer value = cache.getIfPresent(key);
// 如果缓存中不存在该键,则认为操作是首次执行,将其添加到缓存中,并返回false
if (value == null) {
cache.put(key, 1);
return false;
}
// 如果缓存中存在该键,则认为操作是重复的,返回true。
return true;
}
/**
* 获取缓存值
*
* @param key 缓存键
* @return 缓存值
*/
public Integer get(String key) {
return cache.getIfPresent(key);
}
/**
* 设置缓存值
*
* @param key 缓存键
* @param value 缓存值
*/
public void put(String key, Integer value) {
cache.put(key, value);
}
/**
* 设置缓存值附带过期时间
*
* @param key 缓存键
* @param value 缓存值
* @param expireTime 过期时间
*/
public void put(String key, String value, Duration expireTime) {
Cache<String, Object> cache = Caffeine.newBuilder()
.expireAfterWrite(expireTime)
.build();
cache.put(key, value);
}
/**
* 移除缓存项
*
* @param key 缓存键
*/
public void remove(String key) {
cache.invalidate(key);
}
/**
* 清空缓存
*/
public void removeAll() {
cache.invalidateAll();
}
}
Login类
/**
* 登录请求
*
* @param username 用户名
* @param password 用户密码
* @return Ajax请求结果
*/
@PostMapping("/login")
@ResponseBody
public AjaxResult login(String username, String password, String host) {
// 对用户名进行简单的格式化,确保其安全地用于构建key
String formattedUsername = username.replaceAll("[^a-zA-Z0-9]", "_");
//避免重复提交,此处使用Caffeine缓存实现幂等性校验
boolean isPresent = caffeineUtils.verifyIdempotent(CaffeineUtils.LOGIN_PREFIX + formattedUsername);
if (isPresent) {
return new AjaxResult(AjaxResult.Type.WARN, "请勿重复提交");
}
// 避免将用户名作为密码的默认值,增强安全性
if (StringUtils.isBlank(password)) {
password = "default"; // 使用一个默认值代替用户名,此为示例,实际中应考虑安全策略
}
// 优化密码处理逻辑的注释,实际处理可能在UsernamePasswordToken内部
UsernamePasswordToken token = new UsernamePasswordToken(formattedUsername, password, host);
Subject subject = SecurityUtils.getSubject();
try {
subject.login(token);
} catch (AuthenticationException e) {
// 返回通用错误消息,而不是直接使用异常消息
return new AjaxResult(AjaxResult.Type.WARN, "用户名或密码不正确");
}
return new AjaxResult(AjaxResult.Type.SUCCESS, "登陆成功");
}
以上示例的问题
缺乏过期机制:如果用户已经完成登录,但缓存中的数据(CaffeineConfig类)没有设置过期时间,这可能导致缓存中的数据长时间不更新。
应用场景不明确:上面代码只有放入缓存操作,而没有清除缓存操作。一是只限制用户登录一个设备,用户退出才情况缓存;二是不限制用户登录的设备个数,用户完成登录即清除缓存。
三、优化方案
场景一:限制用户登录一个设备,用户退出才清除缓存
登录成功后,缓存中存储一个与用户相关的登录凭证(如token),并设置一个合理的过期时间。
同一用户再次尝试登录时,首先检查是否存在有效的登录凭证,如果存在,则拒绝新登录。
用户主动登出时,清除该用户的登录凭证缓存。
Login类
public AjaxResult ajaxLogin(String username, String password, String host) {
// 格式化用户名以构建幂等性校验的key
String cacheKey = "LOGIN:" + username.replaceAll("[^a-zA-Z0-9]", "_");
// 检查用户是否已登录(根据场景一需求)
if (caffeineUtils.exists(cacheKey)) {
return new AjaxResult(AjaxResult.Type.WARN, "您已在其他设备登录,请先登出");
}
// ...(原有登录逻辑)
// 登录成功后,存储登录凭证到缓存,并设置过期时间(例如,一天)
caffeineUtils.put(formattedUsername, generateLoginToken(), Duration.ofDays(1));
// 返回登录成功信息
}
@PostMapping("/logout")
@ResponseBody
public AjaxResult ajaxLogout(String username) {
// 构建缓存key
String formattedUsername = username.replaceAll("[^a-zA-Z0-9]", "_");
// 清除登录凭证
caffeineUtils.remove(formattedUsername);
return new AjaxResult(AjaxResult.Type.SUCCESS, "登出成功");
}
/**
* 生成唯一的登录令牌
*/
private String generateLoginToken() {
// 实现生成唯一的登录令牌逻辑,例如基于UUID
return UUID.randomUUID().toString();
}
场景二:不限制用户登录设备个数,用户完成登录即清除幂等性缓存
登录成功后立即清除幂等性校验的缓存,允许用户在不同设备上多次登录。
不需要在用户退出时特别处理幂等性缓存,因为每次成功登录后都会自动清理。
CaffeineConfig类
@Configuration
@EnableCaching
public class CaffeineConfig {
@Bean
public Cache<String, Integer> caffeine() {
Cache<String, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(1000)
.build();
return cache;
}
}
Login类
@PostMapping("/login")
@ResponseBody
public AjaxResult ajaxLogin(String username, String password, String host) {
// 格式化用户名以构建幂等性校验的key
String formattedUsername = username.replaceAll("[^a-zA-Z0-9]", "_");
if (caffeineUtils.verifyIdempotent(formattedUsername)) {
return new AjaxResult(AjaxResult.Type.WARN, "请勿重复提交");
}
// ...(原有登录逻辑)
// 登录成功后,清除幂等性校验缓存
caffeineUtils.remove(formattedUsername);
// 返回登录成功信息
}
上面的优化方案中,因为涉及到防止登录重复提交,缓存清除时间还需要配合session的过期时间,以及考虑异常退出处理的情况,读者可根据实际自行实践。
四、淘汰策略
以下是Caffeine支持的主要缓存过期淘汰策略:
1.基于时间的过期策略:
expireAfterWrite:设置数据在写入缓存后多久过期。
Cache<String, Object> cache = Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES) // 数据写入后30分钟过期
.build();
expireAfterAccess:依据数据最后一次被访问的时间来决定过期,如果数据在指定时间内没有被访问,则过期。
Cache<String, Object> cache = Caffeine.newBuilder()
.expireAfterAccess(30, TimeUnit.MINUTES) // 数据最后访问后30分钟过期
.build();
2.基于大小的过期策略:
使用maximumSize
方法限制缓存的最大容量。当缓存的条目数量超过设定值时,Caffeine会根据其内部策略(通常是LRU或TinyLFU)自动淘汰旧的条目。
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1000) // 缓存最多1000个条目
.build();
3.基于引用的过期策略:
支持通过引用类型(强引用、软引用、弱引用、虚引用)来控制数据的生命周期,其中软引用(.softValues()
)是一种常用的策略,允许在内存紧张时由垃圾回收器回收这些引用所指向的对象。
Cache<String, Object> cache = Caffeine.newBuilder()
.softValues() // 使用软引用,当内存不足时可被回收
.build();
4.手动过期策略:
提供invalidate
方法允许开发者手动从缓存中移除特定条目。
Cache<String, Object> cache = Caffeine.newBuilder().build();
// ...
cache.invalidate("key"); // 手动移除指定键的数据
5.定时刷新策略:
使用refreshAfterWrite
方法可以在数据写入缓存后按指定时间间隔自动刷新数据,以确保数据的时效性。
Cache<String, Object> cache = Caffeine.newBuilder()
.refreshAfterWrite(1, TimeUnit.HOURS) // 数据写入后每小时自动刷新
.build();