diff --git a/docs/superpowers/plans/2026-06-25-redis-cache.md b/docs/superpowers/plans/2026-06-25-redis-cache.md new file mode 100644 index 0000000..c5bc99a --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-redis-cache.md @@ -0,0 +1,1796 @@ +# Redis 缓存增强模板 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在现有 `redis-stream-springboot-starter` 内新增编程式缓存模板 `RedisCacheEnhanceTemplate`,把 TTL、序列化、穿透/雪崩/击穿三防、显式 `expire` 与可选滑动过期全部封装进去。 + +**Architecture:** 新增独立包 `com.njcn.middle.cache`(与 `stream` 平行、零耦合)。底层复用 `StringRedisTemplate` 存字符串、fastjson 序列化。`getOrLoad` 内编排三防:空值标记(穿透)、TTL 抖动(雪崩)、`SET NX PX` 互斥锁 + Lua 安全释放 + double-check + 超时降级(击穿)。独立 `RedisCacheAutoConfiguration` 追加进 `spring.factories`。 + +**Tech Stack:** JDK 8、Spring Boot 2.3.12、spring-data-redis 2.3.x(Lettuce)、fastjson 1.2.83、hutool 5.7.9、lombok;测试 JUnit 5 + Mockito(均来自 `spring-boot-starter-test`)。 + +设计依据:`docs/superpowers/specs/2026-06-25-redis-cache-design.md`。 + +## Global Constraints + +- **JDK 8 / Spring Boot 2.3.12 / spring-data-redis 2.3.x**;**不新增任何第三方依赖**(只用 `StringRedisTemplate` / fastjson / hutool / lombok)。 +- 全部新增代码在 **`com.njcn.middle.cache`** 包下;**严禁修改 `com.njcn.middle.stream` 任何现有类与测试**(唯一允许改的现存文件是 `spring.factories` 与 `README.md`)。 +- **构建/测试命令(mvn 不在 PATH,必须用全路径,且不要带 `-q` 或 `-DskipTests`)**: + - 全量:`C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository test -f pom.xml` + - 单类:在上面基础上加 `-Dtest=类名`(放在 `test` 之前),例如 `... -Dmaven.repo.local=... -Dtest=CacheKeyBuilderTest test -f pom.xml` +- 集成测试(`com.njcn.middle.cache.integration` 包)需真实 Redis,默认连 `127.0.0.1:6379`,**探测不到自动 skip(不 fail)**;指向其它 Redis 追加 `-Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…`。 +- **提交时只 `git add` 本任务明确列出的文件,严禁 `git add .` / `git add -A`** —— 工作区存在一处与本功能无关的未提交改动 `M README.md`,绝不可裹挟进提交。 +- 单元测试不依赖 Redis(用 Mockito mock `StringRedisTemplate`)。 +- 当前分支:`feat/redis-cache`(spec 已提交于 `ec2a8da`)。 + +--- + +## File Structure + +主代码(`src/main/java/com/njcn/middle/cache/`): + +| 文件 | 职责 | +|---|---| +| `constant/CacheConstant.java` | 常量:空值标记 `NULL_SENTINEL`、锁后缀 `LOCK_SUFFIX` | +| `support/CacheKeyBuilder.java` | 业务 key → 实际 key(前缀 + 环境隔离),派生锁 key | +| `support/CacheValueCodec.java` | 对象 ↔ 字符串(fastjson),空值标记判定 | +| `autoconfig/RedisCacheProperties.java` | 绑定 `redis-cache.*` | +| `template/RedisCacheEnhanceTemplate.java` | 核心模板:get/set/delete/exists/expire/getOrLoad | +| `autoconfig/RedisCacheAutoConfiguration.java` | 自动装配 | +| `src/main/resources/META-INF/spring.factories` | 追加注册(修改) | + +测试(`src/test/java/com/njcn/middle/cache/`):`CacheKeyBuilderTest`、`CacheValueCodecTest`、`RedisCachePropertiesTest`、`RedisCacheEnhanceTemplateTest`、`autoconfig/RedisCacheAutoConfigurationTest`、`integration/{RedisAvailableCondition, CacheRoundTripTest, CachePenetrationTest, CacheExpireSlidingTest, CacheBreakdownConcurrencyTest}`。 + +任务顺序:1 常量+Key → 2 Codec → 3 Properties → 4 模板读写 → 5 模板 getOrLoad → 6 自动装配 → 7 集成 RoundTrip → 8 集成穿透 → 9 集成 expire/滑动 → 10 集成击穿并发 → 11 README + 全量回归。 + +--- + +### Task 1: 常量 + Key 构造器 + +**Files:** +- Create: `src/main/java/com/njcn/middle/cache/constant/CacheConstant.java` +- Create: `src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java` +- Test: `src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java` + +**Interfaces:** +- Produces: + - `CacheConstant.NULL_SENTINEL`(String)、`CacheConstant.LOCK_SUFFIX`(String) + - `new CacheKeyBuilder(String keyPrefix, boolean envIsolation, String env)` + - `String CacheKeyBuilder.build(String bizKey)`、`String CacheKeyBuilder.buildLock(String bizKey)` + +- [ ] **Step 1: 写失败测试** + +`src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java` +```java +package com.njcn.middle.cache.support; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CacheKeyBuilderTest { + + @Test + void buildWithPrefixOnly() { + CacheKeyBuilder kb = new CacheKeyBuilder("cache:", false, ""); + assertEquals("cache:user:1", kb.build("user:1")); + } + + @Test + void buildWithEnvIsolation() { + CacheKeyBuilder kb = new CacheKeyBuilder("cache:", true, "prod"); + assertEquals("cache:prod:user:1", kb.build("user:1")); + } + + @Test + void envIsolationButBlankEnvFallsBack() { + CacheKeyBuilder kb = new CacheKeyBuilder("cache:", true, ""); + assertEquals("cache:user:1", kb.build("user:1")); + } + + @Test + void buildLockDerivesFromDataKey() { + assertEquals("cache:user:1:__lock__", + new CacheKeyBuilder("cache:", false, "").buildLock("user:1")); + assertEquals("cache:prod:user:1:__lock__", + new CacheKeyBuilder("cache:", true, "prod").buildLock("user:1")); + } +} +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheKeyBuilderTest test -f pom.xml` +Expected: 编译失败(`CacheKeyBuilder` / `CacheConstant` 不存在)。 + +- [ ] **Step 3: 写实现** + +`src/main/java/com/njcn/middle/cache/constant/CacheConstant.java` +```java +package com.njcn.middle.cache.constant; + +/** + * Redis 缓存相关常量。 + */ +public final class CacheConstant { + + private CacheConstant() { + } + + /** + * 空值标记:回源结果为 null 时写入此值(穿透防护)。 + * 内含 NUL 控制字符(Java unicode 转义书写),fastjson 序列化任何对象都不会产出含 NUL 的裸文本,故无冲突。 + */ + public static final String NULL_SENTINEL = "\u0000NULL\u0000"; + + /** 击穿互斥锁 key 后缀(由数据 key 派生)。 */ + public static final String LOCK_SUFFIX = ":__lock__"; +} +``` + +`src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java` +```java +package com.njcn.middle.cache.support; + +import cn.hutool.core.util.StrUtil; +import com.njcn.middle.cache.constant.CacheConstant; + +/** + * 缓存 key 构造器:统一前缀 +(可选)环境隔离段。 + * + *

基础:{@code keyPrefix + bizKey};开启隔离且 env 非空:{@code keyPrefix + env + ":" + bizKey}。 + * 锁 key 由数据 key 派生,保证数据与其锁落在同一隔离域。 + */ +public class CacheKeyBuilder { + + private final String keyPrefix; + private final boolean envIsolation; + private final String env; + + public CacheKeyBuilder(String keyPrefix, boolean envIsolation, String env) { + this.keyPrefix = keyPrefix == null ? "" : keyPrefix; + this.envIsolation = envIsolation; + this.env = env; + } + + /** 业务 key → 实际数据 key。 */ + public String build(String bizKey) { + if (envIsolation && StrUtil.isNotBlank(env)) { + return keyPrefix + env + ":" + bizKey; + } + return keyPrefix + bizKey; + } + + /** 数据 key 派生锁 key。 */ + public String buildLock(String bizKey) { + return build(bizKey) + CacheConstant.LOCK_SUFFIX; + } +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheKeyBuilderTest test -f pom.xml` +Expected: `Tests run: 4, Failures: 0`。 + +- [ ] **Step 5: 提交** + +```bash +git add src/main/java/com/njcn/middle/cache/constant/CacheConstant.java \ + src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java \ + src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java +git commit -m "feat(cache): 缓存常量与 key 构造器(前缀 + 环境隔离 + 锁 key 派生)" +``` + +--- + +### Task 2: 值编解码器 `CacheValueCodec` + +**Files:** +- Create: `src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java` +- Test: `src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java` + +**Interfaces:** +- Consumes: `CacheConstant.NULL_SENTINEL`(Task 1) +- Produces: + - `String CacheValueCodec.encode(Object value)` — null → `NULL_SENTINEL`,否则 `JSON.toJSONString` + - `boolean CacheValueCodec.isNullSentinel(String raw)` + - ` T CacheValueCodec.decode(String raw, Class type)` + - ` T CacheValueCodec.decode(String raw, com.alibaba.fastjson.TypeReference type)` + - 解码失败抛 fastjson 的 `RuntimeException`(由调用方决定包装) + +- [ ] **Step 1: 写失败测试** + +`src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java` +```java +package com.njcn.middle.cache.support; + +import com.alibaba.fastjson.TypeReference; +import com.njcn.middle.cache.constant.CacheConstant; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CacheValueCodecTest { + + private final CacheValueCodec codec = new CacheValueCodec(); + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class Foo { + private Long id; + private String name; + } + + @Test + void encodeNullReturnsSentinel() { + assertEquals(CacheConstant.NULL_SENTINEL, codec.encode(null)); + assertTrue(codec.isNullSentinel(codec.encode(null))); + assertFalse(codec.isNullSentinel("{\"id\":1}")); + } + + @Test + void encodeDecodePojoRoundTrip() { + Foo foo = new Foo(1L, "alice"); + String raw = codec.encode(foo); + Foo back = codec.decode(raw, Foo.class); + assertEquals(foo, back); + } + + @Test + void encodeDecodeStringRoundTrip() { + String raw = codec.encode("abc"); + assertEquals("abc", codec.decode(raw, String.class)); + } + + @Test + void decodeGenericWithTypeReference() { + List list = Arrays.asList(1, 2, 3); + String raw = codec.encode(list); + List back = codec.decode(raw, new TypeReference>() { + }); + assertEquals(list, back); + } + + @Test + void decodeDirtyStringThrows() { + assertThrows(RuntimeException.class, () -> codec.decode("{not-json", Foo.class)); + } +} +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheValueCodecTest test -f pom.xml` +Expected: 编译失败(`CacheValueCodec` 不存在)。 + +- [ ] **Step 3: 写实现** + +`src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java` +```java +package com.njcn.middle.cache.support; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.TypeReference; +import com.njcn.middle.cache.constant.CacheConstant; + +/** + * 缓存值编解码:对象 ↔ 字符串(fastjson),并承载空值标记。 + * + *

解码失败直接抛 fastjson 异常({@link RuntimeException}),由调用方决定显式暴露(public get) + * 还是当未命中降级(getOrLoad 内部)。 + */ +public class CacheValueCodec { + + /** 对象 → 字符串:null → 空值标记;否则 fastjson 序列化。 */ + public String encode(Object value) { + if (value == null) { + return CacheConstant.NULL_SENTINEL; + } + return JSON.toJSONString(value); + } + + /** 是否空值标记。 */ + public boolean isNullSentinel(String raw) { + return CacheConstant.NULL_SENTINEL.equals(raw); + } + + /** 字符串 → 对象(Class)。 */ + public T decode(String raw, Class type) { + return JSON.parseObject(raw, type); + } + + /** 字符串 → 对象(TypeReference,支持泛型)。 */ + public T decode(String raw, TypeReference type) { + return JSON.parseObject(raw, type); + } +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheValueCodecTest test -f pom.xml` +Expected: `Tests run: 5, Failures: 0`。 + +- [ ] **Step 5: 提交** + +```bash +git add src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java \ + src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java +git commit -m "feat(cache): 值编解码器(fastjson 序列化 + 空值标记)" +``` + +--- + +### Task 3: 配置属性 `RedisCacheProperties` + +**Files:** +- Create: `src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java` +- Test: `src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java` + +**Interfaces:** +- Produces(lombok `@Data` 生成 getter/setter): + - `String keyPrefix=cache:`、`boolean envIsolation=false`、`String env=""` + - `long defaultTtlSeconds=3600`、`long nullTtlSeconds=60`、`double jitterRatio=0.1` + - `boolean slidingExpireEnabled=false` + - `Breakdown breakdown`,内嵌:`boolean enabled=true`、`long lockTtlMs=10000`、`long waitTimeoutMs=3000`、`long waitIntervalMs=50` + - 注意 `boolean` 字段的 getter 形如 `isEnvIsolation()` / `isSlidingExpireEnabled()` / `breakdown.isEnabled()` + +- [ ] **Step 1: 写失败测试** + +`src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java` +```java +package com.njcn.middle.cache.autoconfig; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RedisCachePropertiesTest { + + @Test + void defaults() { + RedisCacheProperties p = new RedisCacheProperties(); + assertEquals("cache:", p.getKeyPrefix()); + assertFalse(p.isEnvIsolation()); + assertEquals("", p.getEnv()); + assertEquals(3600, p.getDefaultTtlSeconds()); + assertEquals(60, p.getNullTtlSeconds()); + assertEquals(0.1, p.getJitterRatio()); + assertFalse(p.isSlidingExpireEnabled()); + assertTrue(p.getBreakdown().isEnabled()); + assertEquals(10000, p.getBreakdown().getLockTtlMs()); + assertEquals(3000, p.getBreakdown().getWaitTimeoutMs()); + assertEquals(50, p.getBreakdown().getWaitIntervalMs()); + } + + @Test + void relaxedBinding() { + MockEnvironment env = new MockEnvironment() + .withProperty("redis-cache.default-ttl-seconds", "1800") + .withProperty("redis-cache.sliding-expire-enabled", "true") + .withProperty("redis-cache.breakdown.lock-ttl-ms", "5000") + .withProperty("redis-cache.breakdown.enabled", "false"); + RedisCacheProperties p = Binder.get(env) + .bind("redis-cache", RedisCacheProperties.class).get(); + assertEquals(1800, p.getDefaultTtlSeconds()); + assertTrue(p.isSlidingExpireEnabled()); + assertEquals(5000, p.getBreakdown().getLockTtlMs()); + assertFalse(p.getBreakdown().isEnabled()); + } +} +``` + +> 注:`MockEnvironment` 来自 `spring-test`(已随 `spring-boot-starter-test` 引入);`ConfigurationPropertySources` import 保留以确保 `Binder.get(Environment)` 走宽松绑定。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCachePropertiesTest test -f pom.xml` +Expected: 编译失败(`RedisCacheProperties` 不存在)。 + +- [ ] **Step 3: 写实现** + +`src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java` +```java +package com.njcn.middle.cache.autoconfig; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Redis 缓存配置属性,前缀 {@code redis-cache}。全部可选,有合理默认。 + */ +@Data +@ConfigurationProperties("redis-cache") +public class RedisCacheProperties { + + /** 统一 key 前缀。 */ + private String keyPrefix = "cache:"; + + /** 是否按环境隔离 key。 */ + private boolean envIsolation = false; + + /** 环境标识(隔离开启且非空时拼入 key)。 */ + private String env = ""; + + /** set/getOrLoad 不传 TTL 时的默认过期(秒)。 */ + private long defaultTtlSeconds = 3600; + + /** 空值标记 TTL(秒)——穿透防护,宜短。 */ + private long nullTtlSeconds = 60; + + /** TTL 随机抖动比例——雪崩防护;0 关闭抖动。 */ + private double jitterRatio = 0.1; + + /** 读命中真实值时自动续期(滑动过期),默认关闭。 */ + private boolean slidingExpireEnabled = false; + + private Breakdown breakdown = new Breakdown(); + + /** 击穿:互斥锁。 */ + @Data + public static class Breakdown { + /** 关闭后 getOrLoad 退化为无锁直接回源。 */ + private boolean enabled = true; + /** 锁持有 TTL(ms),防持锁线程崩溃死锁。 */ + private long lockTtlMs = 10000; + /** 未抢到锁的线程最长等待(ms),超时降级直接回源。 */ + private long waitTimeoutMs = 3000; + /** 未抢到锁时自旋重读间隔(ms)。 */ + private long waitIntervalMs = 50; + } +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCachePropertiesTest test -f pom.xml` +Expected: `Tests run: 2, Failures: 0`。 + +- [ ] **Step 5: 提交** + +```bash +git add src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java \ + src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java +git commit -m "feat(cache): 配置属性 redis-cache.*(TTL/抖动/滑动/击穿锁)" +``` + +--- + +### Task 4: 模板读写基础(set/get/delete/exists/expire + 抖动 + 滑动续期) + +**Files:** +- Create: `src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java` +- Test: `src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java` + +**Interfaces:** +- Consumes: `CacheKeyBuilder`(Task 1)、`CacheValueCodec`(Task 2)、`RedisCacheProperties`(Task 3)、`CacheConstant` +- Produces(本任务实现这些;`getOrLoad` 由 Task 5 追加): + - 构造 `new RedisCacheEnhanceTemplate(StringRedisTemplate, CacheKeyBuilder, CacheValueCodec, RedisCacheProperties)` + - `void set(String key, Object value)` / `void set(String key, Object value, long ttlSeconds)` + - ` 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)` + - 包级 `long applyJitter(long ttlSeconds)`(供测试与 Task 5 复用) + - 私有 `void slidingTouch(String dataKey)`、私有 ` T doGet(String key, java.util.function.Function decoder)` +- 关键语义:`get` 命中真实值时若开启滑动则续期;`get` 反序列化失败抛 `IllegalStateException`;`set`/`expire` 的 TTL 走 `applyJitter`。 + +- [ ] **Step 1: 写失败测试** + +`src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java` +```java +package com.njcn.middle.cache.template; + +import com.alibaba.fastjson.JSON; +import com.njcn.middle.cache.autoconfig.RedisCacheProperties; +import com.njcn.middle.cache.constant.CacheConstant; +import com.njcn.middle.cache.support.CacheKeyBuilder; +import com.njcn.middle.cache.support.CacheValueCodec; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RedisCacheEnhanceTemplateTest { + + @Mock + StringRedisTemplate redis; + @Mock + ValueOperations valueOps; + + CacheValueCodec codec = new CacheValueCodec(); + CacheKeyBuilder keyBuilder = new CacheKeyBuilder("cache:", false, ""); + RedisCacheProperties props = new RedisCacheProperties(); + RedisCacheEnhanceTemplate template; + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class User { + private Long id; + private String name; + } + + @BeforeEach + void init() { + MockitoAnnotations.openMocks(this); + lenient().when(redis.opsForValue()).thenReturn(valueOps); + template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props); + } + + @Test + void setSerializesWithExactTtlWhenNoJitter() { + props.setJitterRatio(0); + User u = new User(1L, "a"); + template.set("user:1", u, 100); + verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS); + } + + @Test + void getHitDeserializes() { + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + User got = template.get("user:1", User.class); + assertEquals("a", got.getName()); + } + + @Test + void getMissReturnsNull() { + when(valueOps.get("cache:user:1")).thenReturn(null); + assertNull(template.get("user:1", User.class)); + } + + @Test + void getNullSentinelReturnsNull() { + when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); + assertNull(template.get("user:1", User.class)); + } + + @Test + void getDirtyDataThrowsIllegalState() { + when(valueOps.get("cache:user:1")).thenReturn("{not-json"); + assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class)); + } + + @Test + void expireDelegatesWithJitterTtl() { + props.setJitterRatio(0); + when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true); + assertTrue(template.expire("user:1", 200)); + verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS); + } + + @Test + void applyJitterWithinBounds() { + props.setJitterRatio(0.1); + long bound = (long) (100 * 0.1); + for (int i = 0; i < 50; i++) { + long ttl = template.applyJitter(100); + assertTrue(ttl >= 100 && ttl <= 100 + bound, "ttl=" + ttl); + } + props.setJitterRatio(0); + assertEquals(100, template.applyJitter(100)); + } + + @Test + void slidingOffDoesNotRenewOnGet() { + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + template.get("user:1", User.class); + verify(redis, never()).expire(any(), anyLong(), any()); + } + + @Test + void slidingOnRenewsRealValueOnGet() { + props.setSlidingExpireEnabled(true); + props.setJitterRatio(0); + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + when(redis.expire(eq("cache:user:1"), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true); + template.get("user:1", User.class); + verify(redis).expire("cache:user:1", props.getDefaultTtlSeconds(), TimeUnit.SECONDS); + } + + @Test + void slidingOnDoesNotRenewNullSentinel() { + props.setSlidingExpireEnabled(true); + when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); + assertNull(template.get("user:1", User.class)); + verify(redis, never()).expire(any(), anyLong(), any()); + } +} +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml` +Expected: 编译失败(`RedisCacheEnhanceTemplate` 不存在)。 + +- [ ] **Step 3: 写实现** + +`src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java` +```java +package com.njcn.middle.cache.template; + +import com.alibaba.fastjson.TypeReference; +import com.njcn.middle.cache.autoconfig.RedisCacheProperties; +import com.njcn.middle.cache.support.CacheKeyBuilder; +import com.njcn.middle.cache.support.CacheValueCodec; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * Redis 缓存增强模板。 + * + *

底层 {@link StringRedisTemplate} 存字符串,fastjson 序列化。本类(Task 4)实现读写基础; + * {@code getOrLoad} 三防(穿透/雪崩/击穿)由 Task 5 追加。 + */ +@Slf4j +public class RedisCacheEnhanceTemplate { + + private final StringRedisTemplate redis; + private final CacheKeyBuilder keyBuilder; + private final CacheValueCodec codec; + private final RedisCacheProperties props; + + public RedisCacheEnhanceTemplate(StringRedisTemplate redis, CacheKeyBuilder keyBuilder, + CacheValueCodec codec, RedisCacheProperties props) { + this.redis = redis; + this.keyBuilder = keyBuilder; + this.codec = codec; + this.props = props; + } + + // ---- 写 ---- + + public void set(String key, Object value) { + set(key, value, props.getDefaultTtlSeconds()); + } + + public void set(String key, Object value, long ttlSeconds) { + redis.opsForValue().set(keyBuilder.build(key), codec.encode(value), + applyJitter(ttlSeconds), TimeUnit.SECONDS); + } + + // ---- 读 ---- + + public T get(String key, Class type) { + return doGet(key, raw -> codec.decode(raw, type)); + } + + public T get(String key, TypeReference type) { + return doGet(key, raw -> codec.decode(raw, type)); + } + + private T doGet(String key, Function decoder) { + String dataKey = keyBuilder.build(key); + String raw = redis.opsForValue().get(dataKey); + if (raw == null || codec.isNullSentinel(raw)) { + return null; + } + T value; + try { + value = decoder.apply(raw); + } catch (RuntimeException e) { + throw new IllegalStateException("cache decode failed, key=" + dataKey, e); + } + slidingTouch(dataKey); + return value; + } + + // ---- 删 / 判存 ---- + + public boolean delete(String key) { + return Boolean.TRUE.equals(redis.delete(keyBuilder.build(key))); + } + + public boolean exists(String key) { + return Boolean.TRUE.equals(redis.hasKey(keyBuilder.build(key))); + } + + // ---- 续期 ---- + + public boolean expire(String key, long ttlSeconds) { + return Boolean.TRUE.equals( + redis.expire(keyBuilder.build(key), applyJitter(ttlSeconds), TimeUnit.SECONDS)); + } + + // ---- 内部 ---- + + /** 滑动过期:开启时对已存在的真实值 key 续期(直接对 dataKey 操作,失败忽略)。 */ + private void slidingTouch(String dataKey) { + if (!props.isSlidingExpireEnabled()) { + return; + } + try { + redis.expire(dataKey, applyJitter(props.getDefaultTtlSeconds()), TimeUnit.SECONDS); + } catch (RuntimeException e) { + log.warn("[redis-cache] sliding expire failed key={}", dataKey, e); + } + } + + /** 在原 TTL 上叠加 [0, ttl*ratio] 的随机抖动(只增不减);ratio<=0 或 ttl<=0 时原样返回。 */ + long applyJitter(long ttlSeconds) { + double ratio = props.getJitterRatio(); + if (ratio <= 0 || ttlSeconds <= 0) { + return ttlSeconds; + } + long bound = (long) (ttlSeconds * ratio); + if (bound <= 0) { + return ttlSeconds; + } + return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1); + } +} +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml` +Expected: `Tests run: 10, Failures: 0`。 + +- [ ] **Step 5: 提交** + +```bash +git add src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java \ + src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java +git commit -m "feat(cache): 模板读写基础(set/get/delete/exists/expire + TTL 抖动 + 滑动续期)" +``` + +--- + +### Task 5: 模板 `getOrLoad` 三防(穿透 + 雪崩 + 击穿互斥锁) + +**Files:** +- Modify: `src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java` +- Modify: `src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`(追加测试) + +**Interfaces:** +- Consumes: Task 4 的全部私有/包级成员(`applyJitter`、`slidingTouch`、`codec`、`keyBuilder`、`props`、`redis`)、`CacheConstant.LOCK_SUFFIX` +- Produces: + - ` T getOrLoad(String key, Class type, java.util.function.Supplier loader)` + - ` T getOrLoad(String key, Class type, long ttlSeconds, Supplier loader)` + - ` T getOrLoad(String key, com.alibaba.fastjson.TypeReference type, long ttlSeconds, Supplier loader)` +- 锁实现:`opsForValue().setIfAbsent(lockKey, token, Duration)` 抢锁;Lua `if get==token then del` 释放。 + +- [ ] **Step 1: 追加失败测试** + +在 `RedisCacheEnhanceTemplateTest` 类中追加以下导入与测试方法(导入加到文件顶部已有 import 区): +```java +// 追加 import: +import org.springframework.data.redis.core.script.RedisScript; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.junit.jupiter.api.Assertions.assertEquals; +``` +```java + @Test + void getOrLoadHitDoesNotCallLoader() { + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + AtomicInteger calls = new AtomicInteger(); + Supplier loader = () -> { + calls.incrementAndGet(); + return new User(9L, "x"); + }; + User got = template.getOrLoad("user:1", User.class, 60, loader); + assertEquals("a", got.getName()); + assertEquals(0, calls.get()); + } + + @Test + void getOrLoadNullSentinelHitReturnsNullNoLoader() { + when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); + AtomicInteger calls = new AtomicInteger(); + assertNull(template.getOrLoad("user:1", User.class, 60, () -> { + calls.incrementAndGet(); + return new User(1L, "a"); + })); + assertEquals(0, calls.get()); + } + + @Test + void getOrLoadMissAcquiresLockLoadsAndCaches() { + props.setJitterRatio(0); + when(valueOps.get("cache:user:1")).thenReturn(null); + when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class))) + .thenReturn(true); + User loaded = new User(1L, "a"); + User got = template.getOrLoad("user:1", User.class, 60, () -> loaded); + assertEquals("a", got.getName()); + verify(valueOps).set("cache:user:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS); + // 释放锁:执行了 Lua 脚本 + verify(redis).execute(any(RedisScript.class), anyList(), any()); + } + + @Test + void getOrLoadLoaderReturnsNullWritesSentinel() { + when(valueOps.get("cache:user:1")).thenReturn(null); + when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class))) + .thenReturn(true); + assertNull(template.getOrLoad("user:1", User.class, 60, () -> null)); + verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL, + props.getNullTtlSeconds(), TimeUnit.SECONDS); + } + + @Test + void getOrLoadNotLockedWaitsThenReadsValue() { + props.getBreakdown().setWaitIntervalMs(1); + props.getBreakdown().setWaitTimeoutMs(1000); + // 初始 tryGet 返回 null,未抢到锁,随后等待中读到值 + when(valueOps.get("cache:user:1")) + .thenReturn(null) + .thenReturn(null) + .thenReturn(JSON.toJSONString(new User(1L, "a"))); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); + AtomicInteger calls = new AtomicInteger(); + User got = template.getOrLoad("user:1", User.class, 60, () -> { + calls.incrementAndGet(); + return new User(9L, "x"); + }); + assertEquals("a", got.getName()); + assertEquals(0, calls.get()); + } + + @Test + void getOrLoadWaitTimeoutFallsBackToLoader() { + props.getBreakdown().setWaitIntervalMs(1); + props.getBreakdown().setWaitTimeoutMs(5); + when(valueOps.get("cache:user:1")).thenReturn(null); // 始终未命中 + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); + AtomicInteger calls = new AtomicInteger(); + User got = template.getOrLoad("user:1", User.class, 60, () -> { + calls.incrementAndGet(); + return new User(7L, "fallback"); + }); + assertEquals("fallback", got.getName()); + assertEquals(1, calls.get()); + } + + @Test + void getOrLoadBreakdownDisabledLoadsWithoutLock() { + props.getBreakdown().setEnabled(false); + when(valueOps.get("cache:user:1")).thenReturn(null); + User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a")); + assertEquals("a", got.getName()); + verify(valueOps, never()).setIfAbsent(anyString(), anyString(), any(Duration.class)); + } + + @Test + void getOrLoadInternalDecodeFailureFallsBackToReload() { + // 初始读到脏数据(当未命中降级),抢到锁后回源重建 + when(valueOps.get("cache:user:1")).thenReturn("{dirty").thenReturn("{dirty"); + when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class))) + .thenReturn(true); + User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "fresh")); + assertEquals("fresh", got.getName()); + } +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml` +Expected: 编译失败(`getOrLoad` 不存在)。 + +- [ ] **Step 3: 写实现** + +在 `RedisCacheEnhanceTemplate` 顶部追加 import: +```java +import cn.hutool.core.util.IdUtil; +import com.njcn.middle.cache.constant.CacheConstant; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import java.time.Duration; +import java.util.Collections; +import java.util.function.Supplier; +``` +在类内(`applyJitter` 之后)追加以下成员;并在类顶部字段区加入脚本常量: +```java + /** 释放锁 Lua:token 匹配才删,防误删他人锁。 */ + private static final DefaultRedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + Long.class); +``` +```java + // ---- 读取或回源(三防核心)---- + + public T getOrLoad(String key, Class type, Supplier loader) { + return getOrLoad(key, type, props.getDefaultTtlSeconds(), loader); + } + + public T getOrLoad(String key, Class type, long ttlSeconds, Supplier loader) { + return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader); + } + + public T getOrLoad(String key, TypeReference type, long ttlSeconds, Supplier loader) { + return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader); + } + + private T doGetOrLoad(String key, Function decoder, long ttlSeconds, Supplier loader) { + String dataKey = keyBuilder.build(key); + + Lookup r = tryGet(dataKey, decoder); + if (r.hit) { + if (r.realValue) { + slidingTouch(dataKey); + } + return r.value; + } + + RedisCacheProperties.Breakdown bd = props.getBreakdown(); + if (!bd.isEnabled()) { + return loadAndCache(dataKey, ttlSeconds, loader); + } + + String lockKey = dataKey + CacheConstant.LOCK_SUFFIX; + String token = IdUtil.fastSimpleUUID(); + boolean locked = Boolean.TRUE.equals(redis.opsForValue() + .setIfAbsent(lockKey, token, Duration.ofMillis(bd.getLockTtlMs()))); + + if (locked) { + try { + Lookup r2 = tryGet(dataKey, decoder); // double-check + if (r2.hit) { + if (r2.realValue) { + slidingTouch(dataKey); + } + return r2.value; + } + return loadAndCache(dataKey, ttlSeconds, loader); + } finally { + releaseLock(lockKey, token); + } + } + + // 未抢到锁:自旋等待重读,超时降级直接回源 + long waited = 0; + while (waited < bd.getWaitTimeoutMs()) { + sleep(bd.getWaitIntervalMs()); + waited += bd.getWaitIntervalMs(); + Lookup r3 = tryGet(dataKey, decoder); + if (r3.hit) { + if (r3.realValue) { + slidingTouch(dataKey); + } + return r3.value; + } + } + log.warn("[redis-cache] breakdown wait timeout, fallback to loader, key={}", key); + return loader.get(); + } + + /** 读取三态:未命中 / 命中空值标记 / 命中真实值;反序列化失败当未命中降级。 */ + private Lookup tryGet(String dataKey, Function decoder) { + String raw = redis.opsForValue().get(dataKey); + if (raw == null) { + return Lookup.miss(); + } + if (codec.isNullSentinel(raw)) { + return Lookup.nullHit(); + } + try { + return Lookup.hit(decoder.apply(raw)); + } catch (RuntimeException e) { + log.warn("[redis-cache] decode failed, treat as miss key={}", dataKey, e); + return Lookup.miss(); + } + } + + /** 回源并写缓存:null → 空值标记(穿透);非 null → 抖动 TTL(雪崩)。 */ + private T loadAndCache(String dataKey, long ttlSeconds, Supplier loader) { + T value = loader.get(); + if (value == null) { + redis.opsForValue().set(dataKey, CacheConstant.NULL_SENTINEL, + props.getNullTtlSeconds(), TimeUnit.SECONDS); + } else { + redis.opsForValue().set(dataKey, codec.encode(value), + applyJitter(ttlSeconds), TimeUnit.SECONDS); + } + return value; + } + + private void releaseLock(String lockKey, String token) { + try { + redis.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token); + } catch (RuntimeException e) { + log.warn("[redis-cache] release lock failed lockKey={}", lockKey, e); + } + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** 读取三态载体(对外不暴露)。 */ + private static final class Lookup { + final boolean hit; // 命中(真实值或空值标记) + final boolean realValue; // 命中且为真实值 + final T value; + + private Lookup(boolean hit, boolean realValue, T value) { + this.hit = hit; + this.realValue = realValue; + this.value = value; + } + + static Lookup miss() { + return new Lookup<>(false, false, null); + } + + static Lookup nullHit() { + return new Lookup<>(true, false, null); + } + + static Lookup hit(T v) { + return new Lookup<>(true, true, v); + } + } +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml` +Expected: `Tests run: 18, Failures: 0`(Task 4 的 10 + Task 5 的 8)。 + +- [ ] **Step 5: 提交** + +```bash +git add src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java \ + src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java +git commit -m "feat(cache): getOrLoad 三防(空值穿透 + TTL 抖动雪崩 + 互斥锁击穿 + 超时降级)" +``` + +--- + +### Task 6: 自动装配 + spring.factories 注册 + +**Files:** +- Create: `src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java` +- Modify: `src/main/resources/META-INF/spring.factories` +- Test: `src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: `RedisCacheProperties`、`CacheKeyBuilder`、`CacheValueCodec`、`RedisCacheEnhanceTemplate` +- Produces: 三个 `@ConditionalOnMissingBean` Bean(`CacheKeyBuilder`、`CacheValueCodec`、`RedisCacheEnhanceTemplate`),并经 `spring.factories` 自动加载。 + +- [ ] **Step 1: 写失败测试** + +`src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java` +```java +package com.njcn.middle.cache.autoconfig; + +import com.njcn.middle.cache.support.CacheKeyBuilder; +import com.njcn.middle.cache.support.CacheValueCodec; +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class RedisCacheAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + RedisAutoConfiguration.class, RedisCacheAutoConfiguration.class)) + .withPropertyValues("spring.redis.host=127.0.0.1", "spring.redis.port=6379"); + + @Test + void registersCacheBeans() { + runner.run(ctx -> { + assertThat(ctx).hasSingleBean(RedisCacheEnhanceTemplate.class); + assertThat(ctx).hasSingleBean(CacheKeyBuilder.class); + assertThat(ctx).hasSingleBean(CacheValueCodec.class); + }); + } + + @Test + void backsOffWhenUserDefinesOwnTemplate() { + runner.withUserConfiguration(CustomConfig.class).run(ctx -> { + assertThat(ctx).hasSingleBean(RedisCacheEnhanceTemplate.class); + assertThat(ctx.getBean(RedisCacheEnhanceTemplate.class)) + .isSameAs(ctx.getBean("myTemplate")); + }); + } + + @Configuration + static class CustomConfig { + @Bean + RedisCacheEnhanceTemplate myTemplate() { + return new RedisCacheEnhanceTemplate( + new StringRedisTemplate(mock(RedisConnectionFactory.class)), + new CacheKeyBuilder("cache:", false, ""), + new CacheValueCodec(), + new RedisCacheProperties()); + } + } +} +``` + +> ApplicationContextRunner 启动上下文但 Lettuce 懒连接,不会真的连 Redis,故本测试无需真实 Redis。 + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheAutoConfigurationTest test -f pom.xml` +Expected: 编译失败(`RedisCacheAutoConfiguration` 不存在)。 + +- [ ] **Step 3: 写实现** + +`src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java` +```java +package com.njcn.middle.cache.autoconfig; + +import com.njcn.middle.cache.support.CacheKeyBuilder; +import com.njcn.middle.cache.support.CacheValueCodec; +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * Redis 缓存自动装配。装配在 {@link RedisAutoConfiguration} 之后,确保 {@link StringRedisTemplate} 就绪。 + * 与 stream 装配互不引用;纯模板、无后台线程。 + */ +@Configuration +@ConditionalOnClass(StringRedisTemplate.class) +@AutoConfigureAfter(RedisAutoConfiguration.class) +@EnableConfigurationProperties(RedisCacheProperties.class) +public class RedisCacheAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public CacheKeyBuilder cacheKeyBuilder(RedisCacheProperties props) { + return new CacheKeyBuilder(props.getKeyPrefix(), props.isEnvIsolation(), props.getEnv()); + } + + @Bean + @ConditionalOnMissingBean + public CacheValueCodec cacheValueCodec() { + return new CacheValueCodec(); + } + + @Bean + @ConditionalOnMissingBean + public RedisCacheEnhanceTemplate redisCacheEnhanceTemplate(StringRedisTemplate redis, + CacheKeyBuilder cacheKeyBuilder, + CacheValueCodec cacheValueCodec, + RedisCacheProperties props) { + return new RedisCacheEnhanceTemplate(redis, cacheKeyBuilder, cacheValueCodec, props); + } +} +``` + +修改 `src/main/resources/META-INF/spring.factories` 为(追加 cache 装配,逗号续行): +``` +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration,\ +com.njcn.middle.cache.autoconfig.RedisCacheAutoConfiguration +``` + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheAutoConfigurationTest test -f pom.xml` +Expected: `Tests run: 2, Failures: 0`。 + +- [ ] **Step 5: 提交** + +```bash +git add src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java \ + src/main/resources/META-INF/spring.factories \ + src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java +git commit -m "feat(cache): 自动装配 + spring.factories 注册" +``` + +--- + +### Task 7: 集成测试 — 收发闭环 RoundTrip(含 Redis 探测脚手架) + +**Files:** +- Create: `src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java` +- Create: `src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java` + +**Interfaces:** +- Consumes: 自动装配出的 `RedisCacheEnhanceTemplate`(Task 6) +- Produces: `RedisAvailableCondition`(供 Task 8/9/10 复用的 JUnit5 `ExecutionCondition`) + +- [ ] **Step 1: 写测试(集成测试天然是"行为测试",无 Redis 时自动 skip)** + +`src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java` +```java +package com.njcn.middle.cache.integration; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.net.InetSocketAddress; +import java.net.Socket; + +/** Redis 不可达时禁用整个测试类(skipped,非 failed),保证无 Redis 环境 mvn test 仍全绿。 */ +public class RedisAvailableCondition implements ExecutionCondition { + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + String host = System.getProperty("spring.redis.host", "127.0.0.1"); + int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379")); + try (Socket s = new Socket()) { + s.connect(new InetSocketAddress(host, port), 500); + return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port); + } catch (Exception e) { + return ConditionEvaluationResult.disabled( + "no redis at " + host + ":" + port + ", skip integration test"); + } + } +} +``` + +`src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java` +```java +package com.njcn.middle.cache.integration; + +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 集成测试:真实 Redis 下 set/get/exists/delete 闭环。Redis 不可达自动 skip。 + * + *

连接默认 127.0.0.1:6379;覆盖用 {@code -Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…}。 + */ +@ExtendWith(RedisAvailableCondition.class) +@SpringBootTest(classes = CacheRoundTripTest.TestApp.class, + properties = {"redis-cache.key-prefix=it-cache:"}) +class CacheRoundTripTest { + + @Autowired + private RedisCacheEnhanceTemplate cache; + + @Test + void setGetExistsDelete() { + String key = "rt:user:1"; + cache.delete(key); + + cache.set(key, new User(1L, "alice"), 60); + User got = cache.get(key, User.class); + assertEquals("alice", got.getName()); + assertTrue(cache.exists(key)); + + assertTrue(cache.delete(key)); + assertNull(cache.get(key, User.class)); + assertFalse(cache.exists(key)); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApp { + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class User { + private Long id; + private String name; + } +} +``` + +- [ ] **Step 2: 运行,确认行为(有 Redis 则 PASS;无 Redis 则 skipped)** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheRoundTripTest test -f pom.xml` +Expected: 有本地 Redis → `Tests run: 1, Failures: 0`;无 Redis → 该类 skipped、构建仍成功。(本地起 Redis:`docker run -d -p 6379:6379 redis:6.2`) + +- [ ] **Step 3: 提交** + +```bash +git add src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java \ + src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java +git commit -m "test(cache): 集成测试 set/get/exists/delete 闭环 + Redis 探测脚手架" +``` + +--- + +### Task 8: 集成测试 — 穿透(空值缓存,loader 仅调用一次) + +**Files:** +- Create: `src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java` + +**Interfaces:** +- Consumes: `RedisCacheEnhanceTemplate`、`RedisAvailableCondition`(Task 7) + +- [ ] **Step 1: 写测试** + +`src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java` +```java +package com.njcn.middle.cache.integration; + +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** 集成测试:回源返回 null 时缓存空值标记,二次查询命中空标记、loader 不再被调用。 */ +@ExtendWith(RedisAvailableCondition.class) +@SpringBootTest(classes = CachePenetrationTest.TestApp.class, + properties = {"redis-cache.key-prefix=it-cache:", "redis-cache.null-ttl-seconds=30"}) +class CachePenetrationTest { + + @Autowired + private RedisCacheEnhanceTemplate cache; + + @Test + void nullResultCachedAndLoaderCalledOnce() { + String key = "pen:missing:1"; + cache.delete(key); + + AtomicInteger calls = new AtomicInteger(); + Supplier loader = () -> { + calls.incrementAndGet(); + return null; // 数据不存在 + }; + + assertNull(cache.getOrLoad(key, User.class, 60, loader)); + assertNull(cache.getOrLoad(key, User.class, 60, loader)); // 命中空值标记 + assertEquals(1, calls.get()); + + cache.delete(key); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApp { + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class User { + private Long id; + private String name; + } +} +``` + +- [ ] **Step 2: 运行,确认行为** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CachePenetrationTest test -f pom.xml` +Expected: 有 Redis → `Tests run: 1, Failures: 0`;无 Redis → skipped。 + +- [ ] **Step 3: 提交** + +```bash +git add src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java +git commit -m "test(cache): 集成测试穿透防护(空值缓存,loader 仅调用一次)" +``` + +--- + +### Task 9: 集成测试 — 显式 expire 续期 + 滑动过期 + +**Files:** +- Create: `src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java` + +**Interfaces:** +- Consumes: `RedisCacheEnhanceTemplate`、`CacheKeyBuilder`(用于算实际 key 读 TTL)、`StringRedisTemplate`、`RedisAvailableCondition` +- 本类配置 `redis-cache.sliding-expire-enabled=true`。 + +- [ ] **Step 1: 写测试** + +`src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java` +```java +package com.njcn.middle.cache.integration; + +import com.njcn.middle.cache.support.CacheKeyBuilder; +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 集成测试:显式 expire 续期、滑动过期续真实值、空值标记不被续。 + * default-ttl=3600,故滑动续期会把 TTL 拉到远大于初始 60s。 + */ +@ExtendWith(RedisAvailableCondition.class) +@SpringBootTest(classes = CacheExpireSlidingTest.TestApp.class, + properties = { + "redis-cache.key-prefix=it-cache:", + "redis-cache.default-ttl-seconds=3600", + "redis-cache.null-ttl-seconds=60", + "redis-cache.jitter-ratio=0", + "redis-cache.sliding-expire-enabled=true" + }) +class CacheExpireSlidingTest { + + @Autowired + private RedisCacheEnhanceTemplate cache; + @Autowired + private StringRedisTemplate redis; + @Autowired + private CacheKeyBuilder keyBuilder; + + private long ttl(String bizKey) { + Long e = redis.getExpire(keyBuilder.build(bizKey), TimeUnit.SECONDS); + return e == null ? -1 : e; + } + + @Test + void explicitExpireExtendsTtl() { + String key = "exp:1"; + cache.set(key, new User(1L, "a"), 60); + assertTrue(ttl(key) <= 60 && ttl(key) > 0); + assertTrue(cache.expire(key, 600)); + assertTrue(ttl(key) > 100, "expire 应把 TTL 续到 600 量级"); + cache.delete(key); + } + + @Test + void expireOnMissingKeyReturnsFalse() { + String key = "exp:missing"; + cache.delete(key); + assertFalse(cache.expire(key, 600)); + } + + @Test + void slidingRenewsRealValueOnGet() { + String key = "sld:1"; + cache.set(key, new User(1L, "a"), 60); // 初始 ~60s + assertTrue(ttl(key) <= 60 && ttl(key) > 0); + cache.get(key, User.class); // 命中真实值 → 滑动续到 default 3600 + assertTrue(ttl(key) > 1000, "滑动过期应把 TTL 续到 default 量级"); + cache.delete(key); + } + + @Test + void slidingDoesNotRenewNullSentinel() { + String key = "sld:missing"; + cache.delete(key); + Supplier loader = () -> null; + assertNull(cache.getOrLoad(key, User.class, 60, loader)); // 写空标记 ttl=60 + long before = ttl(key); + assertTrue(before <= 60 && before > 0); + assertNull(cache.get(key, User.class)); // 命中空标记,不续 + assertTrue(ttl(key) <= 60, "空值标记不应被滑动续期"); + cache.delete(key); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApp { + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class User { + private Long id; + private String name; + } +} +``` + +- [ ] **Step 2: 运行,确认行为** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheExpireSlidingTest test -f pom.xml` +Expected: 有 Redis → `Tests run: 4, Failures: 0`;无 Redis → skipped。 + +- [ ] **Step 3: 提交** + +```bash +git add src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java +git commit -m "test(cache): 集成测试显式 expire 续期与滑动过期(真实值续、空标记不续)" +``` + +--- + +### Task 10: 集成测试 — 击穿并发(loader 仅回源一次) + +**Files:** +- Create: `src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java` + +**Interfaces:** +- Consumes: `RedisCacheEnhanceTemplate`、`RedisAvailableCondition`;默认击穿锁配置(enabled=true)。 + +- [ ] **Step 1: 写测试** + +`src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java` +```java +package com.njcn.middle.cache.integration; + +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 集成测试:N 个线程并发 getOrLoad 同一未命中热点 key,慢回源放大竞争窗口, + * 互斥锁应保证 loader 仅被调用 1 次,且所有线程拿到同一结果。 + */ +@ExtendWith(RedisAvailableCondition.class) +@SpringBootTest(classes = CacheBreakdownConcurrencyTest.TestApp.class, + properties = {"redis-cache.key-prefix=it-cache:"}) +class CacheBreakdownConcurrencyTest { + + @Autowired + private RedisCacheEnhanceTemplate cache; + + @Test + void onlyOneLoaderUnderConcurrency() throws Exception { + String key = "bd:hot:1"; + cache.delete(key); + + AtomicInteger calls = new AtomicInteger(); + java.util.function.Supplier loader = () -> { + calls.incrementAndGet(); + try { + Thread.sleep(300); // 慢回源 + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + return new User(1L, "hot"); + }; + + int n = 10; + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch ready = new CountDownLatch(n); + CountDownLatch go = new CountDownLatch(1); + List> futures = new ArrayList<>(); + for (int i = 0; i < n; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + go.await(); + return cache.getOrLoad(key, User.class, 60, loader); + })); + } + ready.await(); + go.countDown(); + + for (Future f : futures) { + assertEquals("hot", f.get(10, TimeUnit.SECONDS).getName()); + } + assertEquals(1, calls.get(), "并发下 loader 应只回源一次"); + + pool.shutdownNow(); + cache.delete(key); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApp { + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class User { + private Long id; + private String name; + } +} +``` + +- [ ] **Step 2: 运行,确认行为** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheBreakdownConcurrencyTest test -f pom.xml` +Expected: 有 Redis → `Tests run: 1, Failures: 0`;无 Redis → skipped。 + +- [ ] **Step 3: 提交** + +```bash +git add src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java +git commit -m "test(cache): 集成测试击穿并发(互斥锁下 loader 仅回源一次)" +``` + +--- + +### Task 11: README 缓存章节 + 全量回归 + +**Files:** +- Modify: `README.md`(在文末追加「缓存」章节,**不改动现有 stream 内容**) + +**Interfaces:** 无新代码。仅文档 + 全量测试。 + +- [ ] **Step 1: 在 README 末尾追加缓存章节** + +> 先 `git status` 确认 `README.md` 既有的未提交改动是否还在;追加内容到文件**末尾**,不要触碰现有任何行。 + +在 `README.md` 末尾追加: +```markdown + +--- + +## 十、Redis 缓存(`RedisCacheEnhanceTemplate`) + +除 Stream 收发外,starter 同样提供开箱即用的**编程式缓存**,把 TTL、序列化、穿透/雪崩/击穿三防封装进一个 `getOrLoad`。注入即用,无需额外注解。 + +### 发送/读取 + +```java +import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate; +import org.springframework.stereotype.Service; + +@Service +public class UserCache { + + private final RedisCacheEnhanceTemplate cache; + + public UserCache(RedisCacheEnhanceTemplate cache) { + this.cache = cache; + } + + public User getUser(Long id) { + // 命中即返回;未命中则回源 loadFromDb 并写缓存,自动防穿透/雪崩/击穿 + return cache.getOrLoad("user:" + id, User.class, 3600, () -> loadFromDb(id)); + } + + public void evict(Long id) { + cache.delete("user:" + id); + } +} +``` + +核心方法: + +| 方法 | 说明 | +|---|---| +| `set(key, value[, ttlSeconds])` | 写缓存,TTL 叠加随机抖动(防雪崩);不传 TTL 用 `default-ttl-seconds` | +| `get(key, Class)` / `get(key, TypeReference)` | 读缓存,支持泛型;未命中或命中空值标记返回 `null`;脏数据抛 `IllegalStateException` | +| `delete(key)` / `exists(key)` | 删除 / 判存 | +| `expire(key, ttlSeconds)` | 显式续期(走抖动);key 不存在返回 `false` | +| `getOrLoad(key, type[, ttlSeconds], loader)` | 读取或回源;内置穿透(空值标记)+ 雪崩(TTL 抖动)+ 击穿(互斥锁)三防 | + +### 配置项(前缀 `redis-cache`,均可选) + +```yaml +redis-cache: + key-prefix: "cache:" # 统一 key 前缀 + env-isolation: false # 按环境隔离 key(开启且 env 非空 → cache:env:key) + env: "" + default-ttl-seconds: 3600 # 默认 TTL(秒) + null-ttl-seconds: 60 # 空值标记 TTL(秒,穿透防护) + jitter-ratio: 0.1 # TTL 随机抖动比例(雪崩防护;0 关闭) + sliding-expire-enabled: false # 读命中真实值自动续 default-ttl(滑动过期,空值标记不续) + breakdown: # 击穿:互斥锁 + enabled: true # 关闭则退化为无锁直接回源 + lock-ttl-ms: 10000 # 锁持有 TTL + wait-timeout-ms: 3000 # 未抢到锁的最长等待,超时降级直接回源 + wait-interval-ms: 50 # 未抢到锁的自旋重读间隔 +``` + +> 缓存模块与 Stream 完全独立:只用缓存不注册任何 handler 也能工作;反之亦然。 +``` + +- [ ] **Step 2: 全量回归(确认整库全绿,且未破坏 stream 任何测试)** + +Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository test -f pom.xml` +Expected: `BUILD SUCCESS`;所有单元测试 PASS;集成测试在有 Redis 时 PASS、无 Redis 时 skipped;**stream 既有测试不受影响**。 + +- [ ] **Step 3: 提交(只提交 README)** + +```bash +git add README.md +git commit -m "docs(cache): README 增补缓存章节(用法与配置)" +``` + +> ⚠️ 仍只 `git add README.md`;若 `git status` 显示 README 还有本功能之外的旧改动,先与维护者确认再决定是否分离提交,**不要**用 `git add -A`。 + +--- + +## Self-Review(本计划对照 spec 的自检结果) + +- **spec §一/§六 API**:`set/get/delete/exists/expire/getOrLoad` → Task 4(读写)+ Task 5(getOrLoad)。✅ +- **spec §五 序列化/空值标记**:`CacheValueCodec` + `CacheConstant.NULL_SENTINEL` → Task 1/2。✅ +- **spec §四 Key 设计**:`CacheKeyBuilder.build/buildLock` → Task 1。✅ +- **spec §三 配置项**:`RedisCacheProperties` 全字段 + `sliding-expire-enabled` → Task 3。✅ +- **spec §七 三防算法**:空值穿透 + TTL 抖动 + 互斥锁 + double-check + 超时降级 → Task 5。✅ +- **spec 滑动过期**:`slidingTouch`(只续真实值)→ Task 4(get)+ Task 5(getOrLoad);空值不续 → 单测 + 集成(Task 9)。✅ +- **spec §八 自动装配**:`RedisCacheAutoConfiguration` + spring.factories → Task 6。✅ +- **spec §九 错误处理**:get 抛 `IllegalStateException`(Task 4)、getOrLoad 内部解码失败降级(Task 5)、续期失败忽略(Task 4/5)。✅ +- **spec §十 测试策略**:单测(Task 1-6)+ 集成 RoundTrip/穿透/expire-滑动/击穿(Task 7-10),复用不可达 skip。✅ +- **类型一致性**:`build/buildLock`、`encode/decode/isNullSentinel`、`applyJitter`、`getOrLoad` 重载签名在各任务间一致;`Lookup` 三态、`UNLOCK_SCRIPT` 仅在 Task 5 内部。✅ +- **无新依赖 / 不改 stream**:仅改 `spring.factories`、`README.md`。✅ +```