一、背景介绍

在政府财政云平台等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 而不是一路通过方法参数把 requestresponse 传下去


🧭 场景对比:参数传递 vs ThreadLocal

方式

原理

优点

缺点

方法参数传递

requestresponse 显式写在每一个方法签名里

明确、易懂

如果方法层级很深,要一层层传,很啰嗦、侵入性强

ThreadLocal

当前线程维护一个“私有变量”,任何地方都能随时取

隐式传递、灵活、解耦方法签名

增加理解门槛,需要管理生命周期(记得 remove()

private static LoadingCache<String, UserTs> cache;声明了一个叫 cache 的变量,它是静态的(static),属于类的,不是某个对象的,类型是 LoadingCache<String, UserTs>

LoadingCache 是 Google Guava 库提供的一个缓存接口:它是一个缓存容器,能存储键值对(key-value),这里的 key 类型是 String,value 类型是 UserTsLoading” 的意思是:当缓存中没有对应的 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 >= 0read() 返回 -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 时传入的 CacheLoaderload(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 的缓存值,