实习需求--多层级用户缓存组件
一、背景介绍
在政府财政云平台等ToB系统中,用户请求频繁依赖用户身份、权限、单位信息等上下文内容,而这些内容一般由统一认证平台提供。如果每次都调用认证平台将导致接口性能下降、网络依赖强、接口被打爆的风险。
因此,需要在本地内存 + Redis 分布式缓存中缓存用户登录态及上下文数据,实现高性能、低耦合、弱依赖的用户信息加载机制。
二.代码如下
创建一个静态final的Redis缓存的key模板,叫public static final String KEY_FORMAT = "df:fi:session:%s:userTs";
%s是占位符,会被替换为其他的token。
用static修饰的好处是所有对象都会用同一种格式,并且所有对象实例会共享同一份常量,static 的作用是:让这个变量属于类本身,而不是某个对象,方便调用、节省资源,是定义“全局常量”的标准做法
接下来创建两个,存储的值的List 列表的两个下标位置:private static final int INDEX_TS = 0;
private static final int INDEX_USER = 1;
然后再创建一个用于在当前线程中保存 HTTP 请求和响应对象private static final ThreadLocal<RequestResponse> requestResponse = new ThreadLocal<>();
详情参考另一篇文章,ThreadLocal详解
为什么使用 ThreadLocal 而不是一路通过方法参数把 request 和 response 传下去。
🧭 场景对比:参数传递 vs ThreadLocal
private static LoadingCache<String, UserTs> cache;声明了一个叫 cache 的变量,它是静态的(static),属于类的,不是某个对象的,类型是 LoadingCache<String, UserTs>。
LoadingCache 是 Google Guava 库提供的一个缓存接口:它是一个缓存容器,能存储键值对(key-value),这里的 key 类型是 String,value 类型是 UserTs。Loading” 的意思是:当缓存中没有对应的 key 时,它可以自动通过你定义的加载逻辑(load方法)去获取数据,自动放入缓存。
然后加了两个依赖,IIUsersLogin,StringRedisTemplate这两个
RequestResponse
代码作用:封装一下http请求
private static class RequestResponse{
HttpServletRequest request;
HttpServletResponse response;
public RequestResponse(HttpServletRequest request,HttpServletResponse response){
this.request = request;
this.response = response;
}
}
}UserTs
代码作用:表示一个用户及其时间戳的封装体,在项目中通常用于:缓存中存储用户信息时,顺带记录用户的登录时间。后续可以根据时间戳判断缓存是否过期或是否需要更新。
@ToString
private static class UserTs{
UserDTO user;
String timestamp;
public UserTs(UserDTO user, String timestamp){
Preconditions.checkArgument(Objects.nonNull(user),"UserDTO 不能为null");
this.user = user;
this.timestamp = timestamp;
}
}代码详解:Preconditions.checkArgument(...):来自 Google Guava,用于校验传参是否合法。如果 user == null,会抛出 IllegalArgumentException 并提示 "UserDTO 不能为null"。目的是保证这个类不会被错误地初始化为 "无用户信息"。this.user = user:把传入的用户赋值给当前对象。this.timestamp = timestamp:同理,设置时间戳。
compress
代码作用:代码是一个字符串压缩方法,它的作用是用 GZIP 算法对字符串进行压缩,然后把压缩后的二进制数据用 "ISO-8859-1" 编码转换成字符串返回
@SneakyThrows
private static String compress(String str){
if (str == null || str.length() == 0) {
return str;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(out);
gzip.write(str.getBytes());
gzip.close();
return out.toString("ISO-8859-1");
}代码详解:
使用@SneakyThrows抛出异常,然后定义一个compress方法,获取一个字符串str,然后返回也是一个字符串,代码逻辑如下, 先对str进行一个判空,如果为空直接返回不做任何处理,
然后通过ByteArrayOutputStream 创建一个可变大小的字节数组流,然后创建一个 GZIPOutputStream,它会把写入的数据压缩后写入底层的 out 流。这样写数据会自动被 gzip 压缩,然后把字符串转成字节数组(默认平台编码),写入 gzip 流中
然后关闭 gzip 流,会自动完成压缩并把数据写入 out。把 out 中的字节数据用 ISO-8859-1 编码转成字符串。为什么要用这个编码?因为它是单字节编码,1个字节对应1个字符,不会丢失数据,适合把二进制数据转成字符串存储或传输。如果用普通 UTF-8 编码可能会丢失压缩后的二进制数据,
场景解析:压缩的核心目的通常是:节省存储空间
代码里,压缩后的字符串会被存进 Redis 里Redis 存储是有容量限制的,压缩能减少占用的空间,提高存储效率。减少网络传输成本,提高访问性能
uncompress
代码作用:解压缩
@SneakyThrows
public static String uncompress(String str) {
if (str == null || str.length() == 0) {
return str;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteArrayInputStream in = new ByteArrayInputStream(str
.getBytes("ISO-8859-1"));
GZIPInputStream gunzip = new GZIPInputStream(in);
byte[] buffer = new byte[256];
int n;
while ((n = gunzip.read(buffer)) >= 0) {
out.write(buffer, 0, n);
}
// toString()使用平台默认编码,也可以显式的指定如toString("GBK")
return out.toString();
}代码详解:主要逻辑差不多,先对字符串判空,然后ByteArrayOutputStream out = new ByteArrayOutputStream();用来收集解压之后的数据,ByteArrayInputStream in = new ByteArrayInputStream(str.getBytes("ISO-8859-1"));把压缩字符串(原来是 byte[],被转成 ISO-8859-1 编码字符串)还原为原始 byte 流,供解压使用。然后
创建 GZIP 解压流,输入源是刚才的 ByteArrayInputStream,然后每次从 GZIP 流中读取最多 256 个字节,写入 ByteArrayOutputStream。循环直到读完。代码中的这个 buffer 是一个 中间缓冲区,目的是 一次读一点,不要全读到内存里,判断 n >= 0:read() 返回 -1 表示已经读到结尾了,没数据了。所以只要不是 -1,就说明还有数据,继续循环。out.write(buffer, 0, n):把读取到的 n 个字节写入输出流。注意:只写有效部分(0 ~ n),不要写满 256
getCache
方法代码详解
private synchronized LoadingCache<String, UserTs> getCache(){
if (Objects.nonNull(cache)) return cache;
cache = CacheBuilder.newBuilder()
.maximumSize(2000)
.expireAfterWrite(timeout, TimeUnit.HOURS)
.build(
new CacheLoader<String, UserTs>() {
@Override
public UserTs load(String token) {
return getFromRedisOrCloud(token);
}
});
return cache;
}这个方法用于 懒加载初始化一个全局缓存 cache,线程安全,只初始化一次,缓存的数据是用户的登录信息(UserTs)。
懒加载(Lazy Loading)就是:
等我真的需要这个资源的时候,再去加载它。如果一直用不到,就一直不加载,节省资源
总体作用
这段代码的含义是:
创建一个缓存对象(
LoadingCache),当你访问某个 key(如 token)不在缓存中时,系统就会自动调用你定义的load()方法,从别的地方(比如 Redis)去加载数据,并自动放进缓存里。
细节描述
先定义了一个synchronized修饰的 getCache()方法,返回的类型是LodingCache类型,然后进行判断,如果cache不为空直接返回cache,如果为空进入下面的逻辑,调用Guava提供的 CacheBuilder 创建缓存对象,设置缓存容量上限最多缓存 2000 个不同的 token。如果超过,就会按照 LRU(最近最少使用)原则清除旧的,然后设置过期时间,缓存中每一项数据在写入之后,最多保留 timeout 小时(这个值是类中的静态常量 12),到期就自动失效。然后进入加载逻辑,CacheLoader 是一个 抽象类,Guava 强制你必须实现 load() 方法才能构造出一个可用的缓存。所以我们必须 new 一个匿名子类,并重写它的抽象方法 load(),告诉缓存系统:“当 key 不存在时,应该如何加载 value?”然后返回所调用获得的cache
为什么要加 synchronized?
这个方法的目的是:保证 cache 只被初始化一次,即使多个线程同时调用 getCache() 方法。
场景:
多个用户并发访问,线程A 和 线程B 同时调用 getCache(),如果不加锁,它们可能都判断 cache == null 为 true,然后分别 new 出两个 cache 对象,就失去了共享缓存的意义。
所以要加锁确保:
✅ 同一时间只能一个线程创建 cache。
✅ 其他线程只能等待。
还可以用其他锁来实现,比如用 ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
if (cache == null) {
cache = ...; // 初始化
}
} finally {
lock.unlock();
}这是一种粗粒度锁。
后续对代码的思考
这种粗粒度锁,在时间方面表现可能比较差,我们可以用DCL(Double-Checked Locking)
public LoadingCache<String, UserTs> getCache() {
if (cache == null) {
synchronized (UserInfoLoadBO.class) {
if (cache == null) {
cache = ...
}
}
}
return cache;
}
直接进行判断快速返回
getFromRedisOrCloud
代码作用,根据传入的用户 token,优先从 Redis 缓存中读取用户信息;如果缓存没有,就从“云平台”登录接口取数据,并缓存进 Redis,最终返回用户数据(UserDTO + 时间戳)。
private UserTs getFromRedisOrCloud(String token){
// redis 使用list存储,0->时间戳,1->userDTO
// 先去redis里面取,如没有,去平台取,再放到redis中
String redisKey = String.format(KEY_FORMAT, token);
List<String> value = redisTemplate.opsForList().range(redisKey,0,1);
if (!value.isEmpty()){
String userJson = uncompress(value.get(INDEX_USER));
log.debug("getFromRedisOrCloud from redis value:{}",userJson);
return new UserTs(new Gson().fromJson(userJson, UserDTO.class),value.get(INDEX_TS));
} else {
Map<String, Object> loginInfo = userLogin.loginsendForCloud(requestResponse.get().request,requestResponse.get().response);
UserDTO user = (UserDTO) loginInfo.get("userDto");
log.debug("getFromRedisOrCloud from cloud user: {}",user);
Preconditions.checkArgument(Objects.nonNull(user),"loginsendForCloud 返回的 userDTO为 null");
String timestamp = String.valueOf(System.currentTimeMillis());
redisTemplate.opsForList().leftPushAll(redisKey,compress(new Gson().toJson(user)),timestamp);
redisTemplate.expire(redisKey,timeout,TimeUnit.HOURS);
UserTs userTs = new UserTs(user,timestamp);
log.debug("getFromRedisOrCloud from cloud userTs: {},",userTs);
return userTs;
}
}代码详解:
先把rediskey给初始化一下,然后根据redisTemplate.opsForList().range(redisKey,0,1);key来把对应的list给取出来,为什么没有直接存UserTs,只不过在大并发/高访问场景下效率会差一点,访问单字段成本高,灵活性差。然后对redis取到的value进行判空,如果为空开始下一部分的逻辑,如果不为空代表redis有数据,从 list 取第 1 个元素(即下标为 1),是压缩的用户信息,调用 uncompress() 进行解压得到 JSON 字符串,然后return这里做了两件事:uncompress(...) → 把压缩的字符串解压成 JSON 字符串。new Gson().fromJson(...) → 把 JSON 字符串转换成 Java 对象 UserDTO。为什么不能直接解压成对象?因为 压缩之前的内容 是 UserDTO 序列化后的 JSON 字符串,并不是 Java 对象
如果redis中没有缓存数据, Map<String, Object> loginInfo = userLogin.loginsendForCloud(requestResponse.get().request,requestResponse.get().response);
调用封装好的 userLogin.loginsendForCloud(...) 方法,用当前请求和响应去云平台登录拿到用户数据,然后从返回结果中提取出 userDto;如果为 null,就直接抛出异常,说明平台登录失败了,然后获取当前时间的时间戳字符串,用来记录这次登录的时间。把用户信息和时间戳存入 Redis 的 List 中(注意顺序:user 在 0 下标,timestamp 在 1);存入前,user 被转为 JSON 并 compress() 压缩,给这个 redisKey 设置一个过期时间,单位是小时。最后,封装成一个 UserTs 对象返回。
getUserDto
代码作用:根据 token 获取当前用户信息,并保证 Redis 缓存和本地缓存(Guava Cache)一致。
public UserDTO getUserDto(String token, HttpServletRequest request, HttpServletResponse response) throws Exception{
// redis 使用list存储,0->时间戳,1->userDTO
// cache->redis->loginsendForCloud
// cache<--时间戳--> redis,如果不一致则用redis的覆盖内存
// clearUserCloudContext 清理redis 和内存
requestResponse.set(new RequestResponse(request,response));
try {
synchronized (token.intern()) {
// 先判断cache中是否有
log.debug("cache token:{}", token);
UserTs userTs = getCache().getIfPresent(token);
log.debug("cache get if present userTs:{}", userTs);
if (Objects.isNull(userTs)) {
//如没有,取出来后不用校验redis中的时间戳,因为是直接从redis(平台)中取出来的
log.debug("cache get token", cache.get(token).user);
return cache.get(token).user;
} else {
String redisKey = String.format(KEY_FORMAT, token);
log.debug("redis key : {}", redisKey);
// 如果有,取出来需要校验时间戳,
if (StringUtils.equals(redisTemplate.opsForList().index(redisKey, INDEX_TS), userTs.timestamp)) {
//如果一致,直接返回
return userTs.user;
} else {
//如果不一致,清空cache里的,从cache里重新取(redis里,或者从平台)
log.debug("cache invalidate token: {}", token);
cache.invalidate(token);
return cache.get(token).user;
}
}
}
} finally{
requestResponse.remove();
}
}代码解释
传入:用户的 token,HTTP 请求和响应。返回:UserDTO(用户信息对象)
把当前请求对象包装后,放进 ThreadLocal,方便后面跨方法使用,然后通过给token.intern()加锁:保证对同一个 token 的访问是串行的(细粒度锁);防止多个线程同时处理同一个用户缓存,导致数据混乱然后优先查本地内存缓存,getIfPresent:不会触发加载,只返回已有的缓存;userTs 包含 UserDTO + 时间戳,如果本地缓存没有,就触发懒加载(去 Redis 或云平台查)
如果本地缓存有,则校验 Redis 中的时间戳一致性,如果 Redis 中的时间戳和本地缓存一致 → 返回本地的 UserDTO,如果 Redis 和本地缓存的时间戳不一致(说明数据更新了)清空本地缓存 → 强制触发重新从 Redis/云平台加载;每次请求结束后,一定要清理 ThreadLocal 中的值
为什么 cache.get(token) 会触发 load() 方法?
这是 Guava LoadingCache 的核心机制:
如果你调用
.get(key),而这个 key 不在缓存中,Guava 就会自动调用你提供的CacheLoader.load(key)方法去加载它,并把返回值放进缓存。
LoadingCache 继承自 Cache<K, V> 接口,并在 .get(key) 方法中封装了逻辑:若缓存中命中,直接返回;若未命中,调用你构造 LoadingCache 时传入的 CacheLoader 的 load(key) 方法,加载数据。
clearUserCloudContext
代码作用:是用来清理用户缓存和 Redis 中对应的用户信息,保证用户数据不再被缓存,通常用于用户登出、会话失效或者强制刷新用户数据场景。
public void clearUserCloudContext(String token){
Preconditions.checkArgument(StringUtils.isNoneBlank(token),"token can not be null");
log.debug("clearUserCloudContext token:{}",token);
redisTemplate.delete(String.format(KEY_FORMAT,token));
getCache().invalidate(token);
}检查 token 是否为空。打印日志,用于记录当前清除的是哪个用户的缓存删除 Redis 中以 token 为标识的键。调用本地缓存(LoadingCache<String, UserTs>)的 invalidate() 方法,删除该 token 的缓存值,