# 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.initMocks(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`。✅ ```