# 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) 1. 编程式缓存模板 `RedisCacheEnhanceTemplate`:`get / set / delete / exists / expire / getOrLoad`;其中 `expire` 为显式续期,并支持**可选的读时自动续期(滑动过期,默认关闭)**。 2. `getOrLoad` 内置三防: - **穿透**:回源结果为 `null` 时缓存空值标记(短 TTL),后续直接命中空标记返回 `null`。 - **雪崩**:写入 TTL 叠加随机抖动,避免大量 key 同时失效。 - **击穿**:热点 key 失效时用 Redis 分布式互斥锁,保证只有一个线程回源重建,其余等待后重读。 3. 统一 Key 前缀 + 环境隔离(复用 stream 的环境隔离思路,缓存独立配置)。 4. fastjson 序列化,支持泛型读回(`Class` / `TypeReference`)。 5. 独立配置项 `redis-cache.*`,全部可选、有合理默认。 6. 单元测试 + 集成测试(沿用现有"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`,全部可选) ```yaml 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)` 或 `JSON.parseObject(str, TypeReference)`,支持泛型(如 `List`)。 - **空值标记(sentinel)**:选用一个正常 JSON 绝不可能产生的字符串常量,内含 NUL 控制字符: ```java // 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 ```java public class RedisCacheEnhanceTemplate { // ---- 写 ---- void set(String key, Object value); // 用 default-ttl-seconds void set(String key, Object value, long ttlSeconds); // 指定 TTL;内部叠加雪崩抖动 // ---- 读 ---- T get(String key, Class type); T get(String key, TypeReference type); // ---- 删 / 判存 ---- boolean delete(String key); boolean exists(String key); // ---- 续期 ---- boolean expire(String key, long ttlSeconds); // 仅对已存在 key 生效;TTL 走雪崩抖动 // ---- 读取或回源(三防核心)---- T getOrLoad(String key, Class type, Supplier loader); // 用 default-ttl T getOrLoad(String key, Class type, long ttlSeconds, Supplier loader); T getOrLoad(String key, TypeReference type, long ttlSeconds, Supplier loader); } ``` - `set(key, value, ttlSeconds)`:写入时实际 TTL = `applyJitter(ttlSeconds)`(见七)。 - `loader` 用 `java.util.function.Supplier`,业务自定义回源逻辑(查 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 ` 单命令完成"不存在才设并带过期",无 `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` 写法: ```java @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`(mock `StringRedisTemplate`/`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,仅服务于缓存重建。