1797 lines
68 KiB
Markdown
1797 lines
68 KiB
Markdown
|
|
# 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`。✅
|
|||
|
|
```
|