【Java 脚手架】封装通用工具类-3

这次介绍的是脚手架中关于缓存类和消息队列的封装

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-servicebootstrap.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.yamlrefresh: 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 比对能防这一层,但要求调用方每步都不出错,漏存一次就失效。

过期时间两难。 设长了,服务宕机后锁迟迟不释放,后面的请求全堵着;设短了,业务没跑完锁先没了。固定值怎么定都不合适,真正需要的是"业务没跑完就续期"的能力,手写做不了。

加锁不原子。 SETNXEXPIRE 是两条命令,中间宕机,锁就永不过期。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() 先确认锁还在当前线程手上,误删的问题从根上堵住。

过期时间交给看门狗。acquireexpire = -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 的为例子吧

  1. ObjectProvider<MessageConverter> 按类型从容器里找,所有实现了 MessageConverter 接口的 Bean 都是候选
  2. 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/STOMP
  • o.s.kafka.support.converter.RecordMessageConverter:Kafka

类型不同,Spring 按类型区分就不会拿错~

相关推荐
VIP_CQCRE18 分钟前
在 WorkBuddy 里一键接入 Claude、GPT、Gemini、DeepSeek:Ace Data Cloud 让 AI 模型调用更简单
ai·大模型·openai·workbuddy·ace data cloud
学习星球24 分钟前
2026年AI Agent全栈开发实战——从Prompt到Production
开发语言·人工智能·prompt
边吃番茄边敲代码25 分钟前
从本地工具到 MCP:给 Agent 接入独立工具服务,并补齐自动化测试
人工智能·python·功能测试·ai·单元测试·pytest
菩提小狗41 分钟前
每日极客日报 · 2026年08月28日
ai·开源·极客日报·it热点·技术资讯
Fluxart.ai1 小时前
商品多角度图怎么做?Flux Art 从白底图到规格图、包装图的 10 步教程
开发语言·前端·javascript
边境悍匪1 小时前
springboot常用注解
java·spring boot·学习
李昊哲小课1 小时前
Spring Boot 4 旅游主题实战教程 阶段四:缓存与底层进阶
spring boot·redis·缓存·性能优化·log4j·旅游·性能
captain3761 小时前
文件与IO(2)
java·开发语言·windows·java-ee