From ec2a8daf4b069ae05fc87c9383cb1f6032c0ed98 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 25 Jun 2026 20:49:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20Redis=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=A2=9E=E5=BC=BA=E6=A8=A1=E6=9D=BF=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在现有 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) --- .../specs/2026-06-25-redis-cache-design.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-redis-cache-design.md diff --git a/docs/superpowers/specs/2026-06-25-redis-cache-design.md b/docs/superpowers/specs/2026-06-25-redis-cache-design.md new file mode 100644 index 0000000..eb9e94e --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-redis-cache-design.md @@ -0,0 +1,360 @@ +# 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,仅服务于缓存重建。