Files
redis-stream-springboot-sta…/docs/superpowers/specs/2026-06-25-redis-cache-design.md
root ec2a8daf4b docs: 新增 Redis 缓存增强模板设计文档
在现有 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>
2026-06-25 20:49:08 +08:00

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)

  1. 编程式缓存模板 RedisCacheEnhanceTemplate:get / set / delete / exists / expire / getOrLoad;其中 expire 为显式续期,并支持可选的读时自动续期(滑动过期,默认关闭)
  2. getOrLoad 内置三防:
    • 穿透:回源结果为 null 时缓存空值标记(短 TTL),后续直接命中空标记返回 null
    • 雪崩:写入 TTL 叠加随机抖动,避免大量 key 同时失效。
    • 击穿:热点 key 失效时用 Redis 分布式互斥锁,保证只有一个线程回源重建,其余等待后重读。
  3. 统一 Key 前缀 + 环境隔离(复用 stream 的环境隔离思路,缓存独立配置)。
  4. fastjson 序列化,支持泛型读回(Class<T> / TypeReference<T>)。
  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,全部可选)

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 静态类(参照现有 RedisStreamPropertiesAutoclaim/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)(见七)。
  • loaderjava.util.function.Supplier<T>,业务自定义回源逻辑(查 DB 等),返回 null 合法(表示数据不存在 → 触发空值缓存)。
  • expire(key, ttlSeconds):底层 EXPIRE,实际 TTL 同样走 applyJitter;key 不存在(已过期/从未写入)时返回 false

滑动过期(读时自动续期,默认关闭)

sliding-expire-enabled=true 时,getgetOrLoad 命中真实值会自动触发一次 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(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:setget 往返一致;TTL 生效(getExpire 在区间内);delete 后不存在。
  • CachePenetrationTest:getOrLoad 对不存在的数据回源返回 null → 二次调用命中空标记、loader 不再被调用(AtomicInteger 计数 == 1)。
  • CacheBreakdownConcurrencyTest:N 个线程并发 getOrLoad 同一未命中热点 key,loadersleep 模拟慢回源 → 断言 loader 仅被调用 1 次,N 个线程都拿到同一结果。
  • CacheExpireSlidingTest:显式 expiregetExpire 变长、对不存在 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,仅服务于缓存重建。