Files
redis-stream-springboot-sta…/docs/superpowers/plans/2026-06-25-redis-cache.md
root 40882ecfaa docs: 新增 Redis 缓存增强模板实现计划
11 个 TDD 任务:常量/KeyBuilder → Codec → Properties → 模板读写 →
getOrLoad 三防 → 自动装配 → 4 个集成测试(RoundTrip/穿透/expire-滑动/击穿并发)
→ README + 全量回归。含完整代码、精确命令与对照 spec 的自检。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:01:19 +08:00

1797 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 构造器:统一前缀 +(可选)环境隔离段。
*
* <p>基础:{@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> T CacheValueCodec.decode(String raw, Class<T> type)`
- `<T> T CacheValueCodec.decode(String raw, com.alibaba.fastjson.TypeReference<T> 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<Integer> list = Arrays.asList(1, 2, 3);
String raw = codec.encode(list);
List<Integer> back = codec.decode(raw, new TypeReference<List<Integer>>() {
});
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),并承载空值标记。
*
* <p>解码失败直接抛 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> T decode(String raw, Class<T> type) {
return JSON.parseObject(raw, type);
}
/** 字符串 → 对象(TypeReference,支持泛型)。 */
public <T> T decode(String raw, TypeReference<T> 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> T get(String key, Class<T> type)` / `<T> T get(String key, TypeReference<T> type)`
- `boolean delete(String key)` / `boolean exists(String key)`
- `boolean expire(String key, long ttlSeconds)`
- 包级 `long applyJitter(long ttlSeconds)`(供测试与 Task 5 复用)
- 私有 `void slidingTouch(String dataKey)`、私有 `<T> T doGet(String key, java.util.function.Function<String,T> 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<String, String> 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 缓存增强模板。
*
* <p>底层 {@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> T get(String key, Class<T> type) {
return doGet(key, raw -> codec.decode(raw, type));
}
public <T> T get(String key, TypeReference<T> type) {
return doGet(key, raw -> codec.decode(raw, type));
}
private <T> T doGet(String key, Function<String, T> 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> T getOrLoad(String key, Class<T> type, java.util.function.Supplier<T> loader)`
- `<T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader)`
- `<T> T getOrLoad(String key, com.alibaba.fastjson.TypeReference<T> type, long ttlSeconds, Supplier<T> 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<User> 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<Long> 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> T getOrLoad(String key, Class<T> type, Supplier<T> loader) {
return getOrLoad(key, type, props.getDefaultTtlSeconds(), loader);
}
public <T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader) {
return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
}
public <T> T getOrLoad(String key, TypeReference<T> type, long ttlSeconds, Supplier<T> loader) {
return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
}
private <T> T doGetOrLoad(String key, Function<String, T> decoder, long ttlSeconds, Supplier<T> loader) {
String dataKey = keyBuilder.build(key);
Lookup<T> 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<T> 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<T> 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 <T> Lookup<T> tryGet(String dataKey, Function<String, T> 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> T loadAndCache(String dataKey, long ttlSeconds, Supplier<T> 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<T> {
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 <T> Lookup<T> miss() {
return new Lookup<>(false, false, null);
}
static <T> Lookup<T> nullHit() {
return new Lookup<>(true, false, null);
}
static <T> Lookup<T> 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。
*
* <p>连接默认 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<User> 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<User> 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<User> 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<Future<User>> 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<User> 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<T>)` / `get(key, TypeReference<T>)` | 读缓存,支持泛型;未命中或命中空值标记返回 `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`。✅
```