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>
This commit is contained in:
360
docs/superpowers/specs/2026-06-25-redis-cache-design.md
Normal file
360
docs/superpowers/specs/2026-06-25-redis-cache-design.md
Normal file
@@ -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<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`,全部可选)
|
||||||
|
|
||||||
|
```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<T>)` 或 `JSON.parseObject(str, TypeReference<T>)`,支持泛型(如 `List<User>`)。
|
||||||
|
- **空值标记(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> 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` 写法:
|
||||||
|
|
||||||
|
```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,仅服务于缓存重建。
|
||||||
Reference in New Issue
Block a user