在现有 starter 内补齐编程式缓存能力的设计 spec:RedisCacheEnhanceTemplate 提供 get/set/delete/exists/expire/getOrLoad;getOrLoad 内置穿透/雪崩/击穿三防, 支持显式 expire 与可选滑动过期;独立 com.njcn.middle.cache 包、配置前缀 redis-cache, 不改动 stream、不新增依赖。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
Redis 缓存增强模板 设计文档
- 日期:2026-06-25
- 状态:已评审待实现
- 所属:
redis-stream-springboot-starter(在现有 starter 内新增缓存能力,不改动 stream)
一、背景与目标
当前 starter 只封装了 Redis Streams 收发,缺了最常用的缓存能力。本次在同一个 starter 内补齐一个编程式缓存增强模板,把缓存里最容易踩坑的三件事——TTL 管理、序列化、三大经典问题(穿透/雪崩/击穿)防护——全部封装进去,业务侧一行 getOrLoad 即可拿到带完整防护的缓存读取。
设计原则与现有 stream 端保持一致:
- 零业务依赖、开箱即用:引入依赖 + 配好 Redis 连接即可注入使用,无需
@EnableXxx。 - 编程式注入模板:对齐现有
RedisStreamEnhanceTemplate的使用气质,提供RedisCacheEnhanceTemplate。 - 与 stream 完全解耦:独立包、独立配置前缀、独立自动装配类,互不影响;只用缓存不用 stream 时缓存仍可单独工作(反之亦然)。
- 复用既有依赖:
StringRedisTemplate+fastjson+hutool,不新增任何依赖。
目标(In Scope)
- 编程式缓存模板
RedisCacheEnhanceTemplate:get / set / delete / exists / expire / getOrLoad;其中expire为显式续期,并支持可选的读时自动续期(滑动过期,默认关闭)。 getOrLoad内置三防:- 穿透:回源结果为
null时缓存空值标记(短 TTL),后续直接命中空标记返回null。 - 雪崩:写入 TTL 叠加随机抖动,避免大量 key 同时失效。
- 击穿:热点 key 失效时用 Redis 分布式互斥锁,保证只有一个线程回源重建,其余等待后重读。
- 穿透:回源结果为
- 统一 Key 前缀 + 环境隔离(复用 stream 的环境隔离思路,缓存独立配置)。
- fastjson 序列化,支持泛型读回(
Class<T>/TypeReference<T>)。 - 独立配置项
redis-cache.*,全部可选、有合理默认。 - 单元测试 + 集成测试(沿用现有"Redis 不可达自动 skip"约定)。
非目标(Out of Scope,YAGNI)
- 注解式缓存(
@Cacheable等)——Spring 自带,本次不做。 - 批量操作(
mget/mset)、increment、分布式锁通用工具——本次不做,留待真实需求。 - 多级缓存(本地 + Redis)、缓存预热、监控指标——不做。
- 击穿的"逻辑过期"方案——已选定互斥锁方案,不实现逻辑过期。
- 改
artifactId/坐标——见「十二、备注」,本次不动。
二、整体设计与包结构
新增 com.njcn.middle.cache 包,与现有 com.njcn.middle.stream 平行:
com.njcn.middle.cache
├── autoconfig
│ ├── RedisCacheProperties.java # 配置属性,前缀 redis-cache
│ └── RedisCacheAutoConfiguration.java # 自动装配(独立于 stream)
├── support
│ ├── CacheKeyBuilder.java # key 拼装:前缀 + 环境隔离
│ └── CacheValueCodec.java # 值 <-> 字符串(fastjson + 空值标记)
├── constant
│ └── CacheConstant.java # 空值标记 sentinel、锁后缀等常量
└── template
└── RedisCacheEnhanceTemplate.java # 核心:get/set/delete/exists/getOrLoad
每个单元职责单一、可独立测试:
| 单元 | 做什么 | 依赖 |
|---|---|---|
CacheKeyBuilder |
业务 key → 实际 Redis key(加前缀,开隔离时加 env);派生锁 key | 配置(prefix/env) |
CacheValueCodec |
对象 ↔ 字符串:encode(obj)、decode(str, type);空值标记常量与判定 |
fastjson |
RedisCacheEnhanceTemplate |
对外 API;编排三防逻辑(锁、抖动、空值) | StringRedisTemplate、上面两者、配置 |
RedisCacheProperties |
绑定 redis-cache.* |
— |
RedisCacheAutoConfiguration |
装配上述 Bean(均 @ConditionalOnMissingBean) |
StringRedisTemplate |
spring.factories 追加 RedisCacheAutoConfiguration(与现有 RedisStreamAutoConfiguration 并列两行):
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration,\
com.njcn.middle.cache.autoconfig.RedisCacheAutoConfiguration
缓存模块无后台线程(纯模板),不像 stream 有监听/认领/裁剪线程,因此即使不调用也只是多一个空闲 Bean,零开销。
三、配置项(前缀 redis-cache,全部可选)
redis-cache:
# ===== Key =====
key-prefix: "cache:" # 统一 key 前缀,便于在 Redis 中归类/批量清理
env-isolation: false # 是否按环境隔离 key(与 stream 同思路,但独立配置)
env: "" # 环境标识,env-isolation=true 且非空时拼入 key
# ===== TTL =====
default-ttl-seconds: 3600 # set/getOrLoad 不传 TTL 时的默认过期(秒)
null-ttl-seconds: 60 # 空值标记的 TTL(秒)——穿透防护,宜短
jitter-ratio: 0.1 # TTL 随机抖动比例——雪崩防护;0 表示关闭抖动
# ===== 滑动过期(读时自动续期)=====
sliding-expire-enabled: false # 开启后:读命中真实值即自动续 default-ttl-seconds(走抖动);命中空值标记不续
# ===== 击穿:互斥锁 =====
breakdown:
enabled: true # 关闭后 getOrLoad 退化为"无锁直接回源"
lock-ttl-ms: 10000 # 锁持有 TTL(ms),防持锁线程崩溃导致死锁
wait-timeout-ms: 3000 # 未抢到锁的线程最长等待(ms),超时降级直接回源
wait-interval-ms: 50 # 未抢到锁时自旋重读缓存的间隔(ms)
对应 RedisCacheProperties:顶层字段 + 内嵌 Breakdown 静态类(参照现有 RedisStreamProperties 的 Autoclaim/Trim 写法)。
说明:数据 TTL 用秒(缓存语义直觉),锁相关用毫秒(锁是亚秒级,毫秒更合适);命名后缀
-seconds/-ms明确区分,避免单位混淆。
四、Key 设计(CacheKeyBuilder)
基础: keyPrefix + bizKey
开启环境隔离: keyPrefix + env + ":" + bizKey
击穿锁 key: <上面得到的 dataKey> + ":__lock__"
示例(key-prefix=cache:):
| 配置 | build("user:1") |
锁 key |
|---|---|---|
| 默认 | cache:user:1 |
cache:user:1:__lock__ |
env-isolation=true, env=prod |
cache:prod:user:1 |
cache:prod:user:1:__lock__ |
锁 key 由数据 key 派生(同前缀、同环境),保证数据与其锁始终落在同一隔离域。
与 stream 的差异:stream 环境隔离用后缀
topic_env;缓存用前缀段prefix:env:key更符合 Redis key 的层级命名习惯,且便于SCAN cache:prod:*按环境清理。两者各自独立,不要求一致。
五、序列化与空值标记(CacheValueCodec)
-
底层用
StringRedisTemplate(与 stream 端一致),value 一律存字符串。 -
非空值:
JSON.toJSONString(value)(fastjson)。读回:JSON.parseObject(str, Class<T>)或JSON.parseObject(str, TypeReference<T>),支持泛型(如List<User>)。 -
空值标记(sentinel):选用一个正常 JSON 绝不可能产生的字符串常量,内含 NUL 控制字符:
// CacheConstant.java —— 用 Java unicode 转义书写,\u0000 为 NUL 控制字符 public static final String NULL_SENTINEL = "\u0000NULL\u0000";fastjson 序列化任何对象都不会产出含
\u0000的裸文本,故与真实值无冲突风险。判定空值即NULL_SENTINEL.equals(raw)。
三态读取
getOrLoad 必须区分"未命中(要回源)"与"命中空值标记(直接返回 null,不回源)"。为此 codec/模板内部按三态处理,而对外 get 接口仍只返回 T(两种 null 情形对调用方都表现为 null):
| Redis 原始返回 | 含义 | get 返回 |
getOrLoad 行为 |
|---|---|---|---|
null |
未命中 | null |
进入回源流程(抢锁/回源) |
NULL_SENTINEL |
命中空值标记 | null |
直接返回 null,不回源 |
| 其它字符串 | 命中真实值 | 反序列化后的 T |
直接返回 |
实现上,模板内部用一个轻量三态(如枚举 MISS / HIT_NULL / HIT + 值)承载读取结果,对外 API 不暴露。
反序列化失败的处理
get(...)公共方法:反序列化失败抛IllegalStateException(脏数据应显式暴露,而非静默吞掉)。getOrLoad(...)内部读取:捕获反序列化异常 → 记warn日志 → 当作未命中处理(降级回源重建),避免一条脏缓存让该 key 永久读失败。
六、核心 API
public class RedisCacheEnhanceTemplate {
// ---- 写 ----
void set(String key, Object value); // 用 default-ttl-seconds
void set(String key, Object value, long ttlSeconds); // 指定 TTL;内部叠加雪崩抖动
// ---- 读 ----
<T> T get(String key, Class<T> type);
<T> T get(String key, TypeReference<T> type);
// ---- 删 / 判存 ----
boolean delete(String key);
boolean exists(String key);
// ---- 续期 ----
boolean expire(String key, long ttlSeconds); // 仅对已存在 key 生效;TTL 走雪崩抖动
// ---- 读取或回源(三防核心)----
<T> T getOrLoad(String key, Class<T> type, Supplier<T> loader); // 用 default-ttl
<T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader);
<T> T getOrLoad(String key, TypeReference<T> type, long ttlSeconds, Supplier<T> loader);
}
set(key, value, ttlSeconds):写入时实际 TTL =applyJitter(ttlSeconds)(见七)。loader用java.util.function.Supplier<T>,业务自定义回源逻辑(查 DB 等),返回null合法(表示数据不存在 → 触发空值缓存)。expire(key, ttlSeconds):底层EXPIRE,实际 TTL 同样走applyJitter;key 不存在(已过期/从未写入)时返回false。
滑动过期(读时自动续期,默认关闭)
sliding-expire-enabled=true 时,get 与 getOrLoad 命中真实值会自动触发一次 expire(key, default-ttl-seconds)(走抖动),实现"最近被访问就持续保活"的滑动窗口语义。约定:
- 只续真实值;命中空值标记不续期——否则"不存在"的 key 被反复查询会长期占位,削弱穿透防护的短 TTL 设计。
- 回源新写入不额外续期(刚
set过,TTL 已是最新)。 - 续期时长统一为
default-ttl-seconds(滑动是全局行为,不感知各 key 当初的自定义 TTL);需要按 key 精确续期请用显式expire。 - 续期失败(并发下 key 恰好过期)忽略,不影响本次读取返回值。
七、getOrLoad 算法(三防细节)
getOrLoad(key, type, ttlSeconds, loader):
dataKey = keyBuilder.build(key)
// 1) 先读(三态);滑动过期开启且命中真实值时,顺带续期(空值标记不续)
r = tryGet(dataKey, type)
if r == HIT or r == HIT_NULL: # 命中真实值或空值标记
if slidingEnabled and r == HIT:
expire(dataKey, default-ttl-seconds)
return r.value # HIT_NULL 时为 null
# 2) 未命中 → 击穿保护
if not breakdown.enabled:
return loadAndCache(dataKey, ttlSeconds, loader) # 无锁,直接回源
lockKey = dataKey + ":__lock__"
token = UUID # hutool IdUtil.fastSimpleUUID()
locked = SET lockKey token NX PX lock-ttl-ms
if locked:
try:
r2 = tryGet(dataKey, type) # double-check:抢锁期间可能已被他人写入
if r2 == HIT or r2 == HIT_NULL:
return r2.value
return loadAndCache(dataKey, ttlSeconds, loader)
finally:
releaseLock(lockKey, token) # Lua:token 匹配才 del,防误删
else:
# 没抢到锁:自旋等待重读
waited = 0
while waited < wait-timeout-ms:
sleep(wait-interval-ms); waited += wait-interval-ms
r3 = tryGet(dataKey, type)
if r3 == HIT or r3 == HIT_NULL:
return r3.value
# 超时降级:直接回源返回(不写缓存,避免与持锁者竞争写),记 warn
log.warn("cache breakdown wait timeout, fallback to loader, key={}", key)
return loader.get()
loadAndCache(dataKey, ttlSeconds, loader):
value = loader.get()
if value == null:
SET dataKey NULL_SENTINEL EX null-ttl-seconds # 穿透防护
else:
SET dataKey JSON(value) EX applyJitter(ttlSeconds) # 雪崩防护
return value
applyJitter(ttlSeconds):
if jitter-ratio <= 0: return ttlSeconds
extra = floor(random() * ttlSeconds * jitter-ratio) # ThreadLocalRandom,[0, ttl*ratio)
return ttlSeconds + extra # 只增不减,不会提前失效
要点:
- 抢锁原子性:
SET lockKey token NX PX <ttl>单命令完成"不存在才设并带过期",无SETNX+EXPIRE两步竞态。 - 释放锁安全:用 Lua 脚本
if GET(lockKey)==token then DEL(lockKey),避免业务回源耗时超过lock-ttl-ms、锁已过期被他人重设时误删别人的锁。 - double-check:抢到锁后再读一次,吃掉"等待抢锁的瞬间持锁者刚写完"的窗口,避免重复回源。
- 超时降级:等待超过
wait-timeout-ms仍无值 → 直接loader.get()返回(不写缓存),牺牲一次回源换取不被锁永久阻塞的可用性。 - 抖动只增不减:
applyJitter在原 TTL 上加[0, ttl*ratio),保证不会因抖动让缓存比预期更早失效。
八、自动装配(RedisCacheAutoConfiguration)
参照 RedisStreamAutoConfiguration 写法:
@Configuration
@ConditionalOnClass(StringRedisTemplate.class)
@AutoConfigureAfter(RedisAutoConfiguration.class) // 确保 StringRedisTemplate 就绪
@EnableConfigurationProperties(RedisCacheProperties.class)
public class RedisCacheAutoConfiguration {
@Bean @ConditionalOnMissingBean
CacheKeyBuilder cacheKeyBuilder(RedisCacheProperties p) { ... }
@Bean @ConditionalOnMissingBean
CacheValueCodec cacheValueCodec() { ... }
@Bean @ConditionalOnMissingBean
RedisCacheEnhanceTemplate redisCacheEnhanceTemplate(
StringRedisTemplate redis, CacheKeyBuilder kb, CacheValueCodec codec, RedisCacheProperties p) { ... }
}
- 全部
@ConditionalOnMissingBean:业务可自定义覆盖。 - 与 stream 装配类互不引用;
spring.factories追加注册。 - 不依赖任何
EnhanceStreamConsumerHandler,无ObjectProvider收集逻辑。
九、错误处理与降级小结
| 场景 | 行为 |
|---|---|
set 序列化失败 |
抛 IllegalStateException(包装原因) |
get 反序列化失败 |
抛 IllegalStateException(脏数据显式暴露) |
getOrLoad 内部读缓存反序列化失败 |
记 warn,当未命中 → 降级回源重建 |
loader 返回 null |
缓存空值标记(null-ttl-seconds),返回 null |
loader 抛异常 |
异常上抛(不写缓存,不吞);锁在 finally 释放 |
| 抢锁等待超时 | 记 warn,降级直接 loader.get() 返回(不写缓存) |
expire/滑动续期目标 key 不存在 |
不报错;expire 返回 false,滑动续期静默跳过 |
| Redis 连接异常 | 上抛由调用方处理(本期不做 Redis 故障本地降级,YAGNI) |
十、测试策略
延续现有约定:单元测试 mock,集成测试连真实 Redis 且不可达自动 skip(复用 StreamRoundTripTest.RedisReachableCondition 同款 ExecutionCondition,-Dspring.redis.host/port/password 覆盖连接)。
单元测试(无需 Redis)
CacheKeyBuilderTest:默认前缀、环境隔离前缀段、锁 key 派生。CacheValueCodecTest:对象/基本类型/泛型TypeReference往返;空值标记 encode/判定;脏字符串 decode 抛异常。RedisCachePropertiesTest:默认值、宽松绑定(default-ttl-seconds/defaultTtlSeconds)、内嵌breakdown.*。RedisCacheEnhanceTemplateTest(mockStringRedisTemplate/ValueOperations):getOrLoad命中真实值 → 不回源;- 命中空值标记 → 返回 null 且不回源;
- 未命中 → 回源一次并写缓存;
loader返回 null → 写空值标记(TTL=null-ttl);applyJitter结果落在[ttl, ttl*(1+ratio));ratio=0时等于原值;- 内部读取反序列化失败 → 降级回源;
expire:已存在 key 返回true并刷新 TTL(走抖动),不存在 key 返回false;- 滑动开启时:命中真实值触发一次续期、命中空值标记不续期、回源新写入不重复续期。
集成测试(需 Redis,skip-if-unreachable)
CacheRoundTripTest:set→get往返一致;TTL 生效(getExpire在区间内);delete后不存在。CachePenetrationTest:getOrLoad对不存在的数据回源返回 null → 二次调用命中空标记、loader 不再被调用(AtomicInteger计数 == 1)。CacheBreakdownConcurrencyTest:N 个线程并发getOrLoad同一未命中热点 key,loader内sleep模拟慢回源 → 断言loader仅被调用 1 次,N 个线程都拿到同一结果。CacheExpireSlidingTest:显式expire后getExpire变长、对不存在 key 返回false;开启sliding-expire-enabled后重复get命中真实值持续刷新 TTL,而空值标记的 TTL 不被刷新。
十一、对现有代码的影响
- 不修改 stream 任何现有类。
- 仅新增
com.njcn.middle.cache.*与对应测试。 spring.factories追加一行注册(并列,不影响 stream 装配)。- 实现阶段在
README.md增补「缓存」章节(用法与配置),与现有 stream 文档并列。 - 不新增第三方依赖。
十二、备注
artifactId维持redis-stream-springboot-starter不变:已发布坐标,贸然改名破坏下游引用。缓存功能以独立包形式并入;若未来 starter 能力进一步泛化,再单独评估是否重命名为更通用的坐标(本期不做)。- 击穿锁是一把够用的轻量分布式锁(SET NX PX + Lua 释放),不抽象成对外通用锁工具——保持 YAGNI,仅服务于缓存重建。