这次介绍的是脚手架中关于缓存类和消息队列的封装
Redis
配置类
lien-common-redis 里加了 RedisConfig,手动注册 RedisTemplate<String, Object>。key 用 StringRedisSerializer,value 用 GenericJackson2JsonRedisSerializer。序列化的 ObjectMapper 抽成单独方法,复用 JsonUtil 的配置,避免 Redis 序列化器调 activateDefaultTyping 污染 JsonUtil 全局 mapper。
自动装配
和统一异常处理一样,登记到 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
config.RedisConfig
service.RedisService
RedisService 用 @AutoConfiguration 注解。它和 @Service 的区别在:库模块的 service 包消费方扫不到,得靠自动装配导入。@AutoConfiguration 只是标记,真正加载靠 imports 文件那行登记,标记和登记缺一不可。
nacos 配置
Redis 连接信息单独抽了一个 share-redis-dev 配置文件放到 nacos,避免每个服务都写一份。mstemplate-service 的 bootstrap.yml 里用 shared-configs 引入:
yaml
shared-configs:
- data-id: share-redis-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
refresh: true
data-id 按环境拼成 share-redis-dev.yaml,refresh: true 支持热更新。
小细节
给 RedisService 封装 \[Zset类型\|ZSet] 方法时记下的注意点。
range 的 start/end 是排名下标。 ZSet 先按 score 排好序,range(key, 0, 9) 取的是排名第 1 到第 10 名的元素,和分数值本身无关。要按分数段过滤用 rangeByScore(key, min, max),排行榜取 Top N 用 reverseRange(key, 0, n-1)。
range 返回 Set<V>,并保留原有顺序。 声明类型是 Set,单看签名看不出有序,底层实现实际是 LinkedHashSet,运行时顺序就是排名顺序。像排行榜展示这种严格依赖名次顺序的场景,接收结果时要留意这一点;想让顺序在类型层面就有保证,可以把结果拷成 List(new ArrayList<>(set),迭代顺序即名次)。
WithScores 的选择与复盘。 当时出于对 score 数据保留,把 getCacheZSet 系列全换成了 rangeWithScores / reverseRangeWithScores。事后复盘发现,WithScores 返回的 Set<TypedTuple<V>> 同样靠 LinkedHashSet 保序,顺序保证和 range 完全一样,它真正多给的只是 score。而且它有个问题:每个元素是 value + score 的包装,并非只是 value。 如果单纯是想除了 score 之外的数据,那么用普通的 range 即可,想锁定顺序就拷成 List;真要 score 才上 WithScores,且记得从 tuple 里 getValue() / getScore() 自己拆。不要 score 的话就不要用,少一层包装。
分布式锁
锁这块先是手写,后来换成了 Redisson。换的原因记在这里。
手写那套在 RedisService 里,加锁是 SET key uuid NX EX,解锁靠 cad 方法,核心依赖一条 Lua 脚本:
java
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
KEYS[1] 是锁的 key,ARGV[1] 是加锁时写进去的 UUID。值相等才删,不相等返回 0。脚本自己不认识"身份",UUID 是调用方加锁时生成、自己存着的,解锁时原样传进来比对。手写方案的完整演进在 \[分布式锁] 里有记录。
写的时候列了四个问题,前两个最难:
误删别人的锁。 业务没跑完锁先过期,另一个线程把锁拿走了,原线程 finally 里直接 DEL,删的就是别人的锁。UUID 比对能防这一层,但要求调用方每步都不出错,漏存一次就失效。
过期时间两难。 设长了,服务宕机后锁迟迟不释放,后面的请求全堵着;设短了,业务没跑完锁先没了。固定值怎么定都不合适,真正需要的是"业务没跑完就续期"的能力,手写做不了。
加锁不原子。 SETNX 和 EXPIRE 是两条命令,中间宕机,锁就永不过期。SET key value NX EX 合成一条能解决,但等待、重试这些外围逻辑还是得自己写。
解锁不原子。 先 GET 判断是不是自己的锁再 DEL,两步之间锁可能正好过期易主。cad 的 Lua 脚本就是为这个写的,比对和删除在 Redis 里一次执行完。
后两个都有解,前两个靠手写治不了本,所以加入了 RedissonLockService:
java
public RLock acquire(String lockKey, long expire) {
final RLock lockInstance = redissonClient.getLock(lockKey);
lockInstance.lock(expire, TimeUnit.MILLISECONDS);
return lockInstance;
}
public boolean releaseLock(RLock lockInstance) {
if (lockInstance.isHeldByCurrentThread()) {
lockInstance.unlock();
return true;
}
return false;
}
Redisson 的锁值是 客户端UUID + threadId,加锁解锁通过已经封装好的内置 Lua 脚本,身份的生成、存储、比对全在框架里,解锁前 isHeldByCurrentThread() 先确认锁还在当前线程手上,误删的问题从根上堵住。
过期时间交给看门狗。acquire 传 expire = -1 时,lock() 不指定 leaseTime,默认锁 30 秒,后台每 10 秒(lockWatchdogTimeout 的三分之一)自动续期。业务跑多久锁就活多久,服务宕机后没人续,到期自动释放,两难就不存在了。
有个细节:tryLock 一旦指定了 leaseTime > 0 就不会续期,看 RedissonLock.tryAcquireAsync 的实现可以确认。
现在手写和封装两个方法并存:RedisService.cad 留着做"值匹配才删"的通用删键,完整的锁操作都依赖 RedissonLockService,另外提醒自己不要忘了要自动装配 😃
本地缓存
光有 Redis 还不够,对于高频读的数据,每次都走一次网络 IO 也浪费。所以引入了本地缓存 \[Caffeine 介绍\|Caffeine],在脚手架里做成了三层缓存结构:L1 是 Caffeine(本地内存),L2 是 Redis,L3 是 DB。查的时候 L1 没有就查 L2,L2 没有就落到 DB,上一级查到了会回填到 L1。
为什么选 Caffeine
本地缓存候选不多,JDK 的 ConcurrentHashMap 没有容量和过期概念,得手动管淘汰;Guava Cache 是 Caffeine 的前身,但已经不怎么维护。Caffeine 用 W-TinyLFU 算法做淘汰,命中率比 LRU 高,还提供弱引用、软引用、异步刷新这些能力,是目前本地缓存的事实标准。详细的机制在 \[Caffeine 介绍] 有记述
三层缓存实现
lien-common-cache 模块里两个东西:CaffeineConfig 负责建本地缓存对象的配置,CacheService 负责三业务逻辑。
CaffeineConfig 通过 @Value 把容量和过期时间做成可配置的,再 build 出一个 Cache<String, Object>:
java
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(initialCapacity)
.maximumSize(maximumSize)
.expireAfterWrite(expire, TimeUnit.SECONDS)
.build();
}
CacheService 里读逻辑是三层依次查:
java
public <T> T getCache(String key, TypeReference<T> valueTypeRef) {
T result = (T) caffeineCache.getIfPresent(key); // L1
if (result != null) return result;
result = redisService.getCacheObject(key, valueTypeRef); // L2
if (result != null) {
caffeineCache.put(key, result); // 回填 L1
return result;
}
// L3 是 DB,脚手架不兜,返回 null 让业务自己查
return null;
}
具体的业务流程如图:

同时还有独立的三个方法:setL1Cache 写入本地,setL2Cache 写入 Redis,setAllCache 两层都写。
自动装配
和 Redis 一样,需自动装配到登记到 imports 文件,这样微服务引用依赖才能使用:

config.CaffeineConfig
service.CacheService
RabbitMQ
在配置 RabbitMQ 写测试接口的时候,突然想到一个问题,我好像从来没有细想过:为什么通过一个 @AutoConfiguration 和一个 @Bean 注解还有自动装配,就能实现消息队列的配置?答案原来是这样...
Spring 是怎么知道我的 Bean 是给 RabbitMQ 的
因为我会注册很多的 Bean,有给 Redis 的,有 RabbitMQ 的,还有 Caffeine 的,但是都是怎么将合适的 Bean 来装配到对应的 Template 的呢?
答案是靠的是类型匹配 + 唯一性判断,就以 RabbitMQ 的为例子吧
ObjectProvider<MessageConverter>按类型从容器里找,所有实现了MessageConverter接口的 Bean 都是候选getIfUnique()只在恰好一个候选时才返回,0 个返回 null,多个直接抛NoUniqueBeanDefinitionException
所以逻辑是:容器里是不是刚好只有一个 MessageConverter?是就装到 RabbitTemplate 上,不是就不管。它不关心这个 Bean 是给谁的,类型即契约,唯一性即开关。如果注册了第二个 MessageConverter Bean,启动时会直接报错 NoUniqueBeanDefinitionException,意思是不是唯一的 Bean 定义异常。
另外 Spring 生态里叫 "MessageConverter" 的接口有好几个,互不干扰:
o.s.amqp.support.converter.MessageConverter:RabbitMQ 用的就是这个o.s.http.converter.HttpMessageConverter:HTTP 请求响应体o.s.messaging.converter.MessageConverter:WebSocket/STOMPo.s.kafka.support.converter.RecordMessageConverter:Kafka
类型不同,Spring 按类型区分就不会拿错~