Compare commits
25 Commits
373b488249
...
7101a1fa65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7101a1fa65 | ||
|
|
0657126f60 | ||
|
|
24422d0ce4 | ||
|
|
6a4f5e460e | ||
|
|
c40f153642 | ||
|
|
8b90fe099e | ||
|
|
1e673cdf83 | ||
|
|
0facafc745 | ||
|
|
ba2606ea46 | ||
|
|
8dc714b0ad | ||
|
|
da3b77e19c | ||
|
|
2d6a5be690 | ||
|
|
effa7bf38a | ||
|
|
6c512ba3b2 | ||
|
|
b5a649110e | ||
|
|
9abff913e5 | ||
|
|
c4ad3670fc | ||
|
|
037fdc4f9c | ||
|
|
d164b89067 | ||
|
|
9605dca962 | ||
|
|
ecbdfd8907 | ||
|
|
ce370afa83 | ||
|
|
40882ecfaa | ||
|
|
ec2a8daf4b | ||
|
|
b649d6151d |
140
README.md
140
README.md
@@ -222,18 +222,35 @@ public class OrderConsumer extends EnhanceStreamConsumerHandler<OrderMessage> {
|
|||||||
|
|
||||||
## 七、配置项
|
## 七、配置项
|
||||||
|
|
||||||
| 配置项 | 默认值 | 说明 |
|
> 全部可配项如下,前缀 `redis-stream`,**均为可选**——不配即用注释中的默认值。Spring 宽松绑定,`read-count` / `readCount` 等写法等价。
|
||||||
|---|---|---|
|
|
||||||
| `redis-stream.env-isolation` | `false` | 是否按环境隔离 stream 名 |
|
```yaml
|
||||||
| `redis-stream.env` | `""` | 环境标识,隔离开启且非空时作为 stream 名后缀(`topic_env`) |
|
redis-stream:
|
||||||
| `redis-stream.consumer-name` | `""` | 消费者名;为空时自动生成唯一名(**见下方注意事项**) |
|
# ===== 基础 =====
|
||||||
| `redis-stream.read-count` | `10` | 单次 `XREADGROUP` 拉取条数 |
|
env-isolation: false # 是否按环境隔离 stream 名:开启后名字追加 _env 后缀,多环境共用一套 Redis 时互不串扰
|
||||||
| `redis-stream.block-ms` | `2000` | `XREADGROUP` 阻塞毫秒 |
|
env: "" # 环境标识,env-isolation=true 且非空时作为 stream 名后缀(topic_env);收发双方须配一致
|
||||||
| `redis-stream.max-retry` | `3` | **全局**最大投递次数(含首投),超限落死信;可被 handler 的 `getMaxRetryTimes()` 单独覆写 |
|
consumer-name: "" # 消费者名,留空自动生成唯一名。多实例务必留空,写死会被当作同一消费者互相争抢、破坏负载均衡(见「八、注意事项」)
|
||||||
| `redis-stream.autoclaim.min-idle-ms` | `60000` | 消息 idle 多久后可被认领重投 |
|
read-count: 10 # 单次 XREADGROUP 拉取条数(一次最多取多少条来处理)
|
||||||
| `redis-stream.autoclaim.interval-ms` | `30000` | autoclaim 扫描周期 |
|
block-ms: 2000 # XREADGROUP 阻塞等待毫秒:无新消息时阻塞这么久再返回,影响拉取实时性与空转开销
|
||||||
| `redis-stream.trim.interval-ms` | `60000` | `XTRIM` 执行周期 |
|
max-retry: 3 # 全局最大投递次数(含首投),超限落死信;可被 handler 的 getMaxRetryTimes() 单独覆写
|
||||||
| `redis-stream.trim.maxlen` | `100000` | 近似保留的最大 stream 长度(`MAXLEN ~`) |
|
|
||||||
|
# ===== 崩溃恢复 · 自动认领:消费者宕机/超时未 ACK 的消息滞留 PEL,由后台定时认领重投(机制见「六、消费语义」) =====
|
||||||
|
autoclaim:
|
||||||
|
min-idle-ms: 60000 # 消息 idle(距上次投递)超过此值(ms)才可被其它消费者认领重投。太小会与正常重试争抢,太大则故障恢复慢
|
||||||
|
interval-ms: 30000 # autoclaim 扫描 PEL 的周期(ms)
|
||||||
|
|
||||||
|
# ===== Stream 长度裁剪:防无界增长,后台定时 XTRIM <stream> MAXLEN <maxlen>(精确裁剪),保留最新 maxlen 条 =====
|
||||||
|
trim:
|
||||||
|
maxlen: 100000 # 保留的最大条数(精确裁剪)。默认 10万;要 5万就设 50000,超出部分删最老条目
|
||||||
|
interval-ms: 60000 # 裁剪执行周期(ms)。调小可让实际长度更贴近 maxlen(代价是 XTRIM 执行更频繁)
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream 裁剪有两点**必须知道**:
|
||||||
|
|
||||||
|
1. **软上限,非实时硬卡。** 两次裁剪之间 stream 会短暂超过 `maxlen`,峰值 ≈ `maxlen + 生产速率 × interval-ms`。例:`maxlen=50000`、每秒进 1000 条、周期 60s ⇒ 下次裁剪前最高可达 `11 万`,随后拉回 5 万。要更紧就调小 `interval-ms`。
|
||||||
|
2. **按条数裁剪,不区分是否已消费。** `XTRIM MAXLEN` 只删最老条目,不管是否已 ACK。`maxlen` 设太小且消费积压超过它时,**未消费的老消息会被直接裁掉 = 丢消息**(被裁的 PEL 消息会被静默强制 ACK)。⚠️ `maxlen` 要留足:至少覆盖「峰值生产速率 × 消费者最长停机/滞后时间」。5 万 / 10 万对一般业务够用,单条消费慢或实例可能长停时需上调。
|
||||||
|
|
||||||
|
> 注:`spring-data-redis 2.3.x` 高级 API 仅支持精确裁剪,无近似裁剪(`MAXLEN ~`)重载,故走精确裁剪。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -242,7 +259,7 @@ public class OrderConsumer extends EnhanceStreamConsumerHandler<OrderMessage> {
|
|||||||
- **多实例部署不要写死 `consumer-name`**。留空时框架会为每个实例生成唯一消费者名(`consumer-xxx` / `claimer-xxx`),符合消费组负载均衡模型。若多个实例配置了相同的固定 `consumer-name`,它们会被视为同一个消费者而互相争抢,破坏负载均衡 —— `consumer-name` 仅建议用于单实例或本地调试。
|
- **多实例部署不要写死 `consumer-name`**。留空时框架会为每个实例生成唯一消费者名(`consumer-xxx` / `claimer-xxx`),符合消费组负载均衡模型。若多个实例配置了相同的固定 `consumer-name`,它们会被视为同一个消费者而互相争抢,破坏负载均衡 —— `consumer-name` 仅建议用于单实例或本地调试。
|
||||||
- **环境隔离**:多环境(dev/test/prod)共用一套 Redis 时,开启 `env-isolation` 并设置 `env`,各环境 stream 名互不串扰。**收发双方必须配置一致**,否则 topic 对不上。
|
- **环境隔离**:多环境(dev/test/prod)共用一套 Redis 时,开启 `env-isolation` 并设置 `env`,各环境 stream 名互不串扰。**收发双方必须配置一致**,否则 topic 对不上。
|
||||||
- **压缩契约**:仅 `body` 字段可压缩。`compress=true` ⇒ `enc=gzip`、`body=base64(gzip(json))`;否则 `enc=plain`、`body` 为明文 json。消费侧仅按 `enc` 解码,与 topic 无关,可与 C++ 等异构端互通。
|
- **压缩契约**:仅 `body` 字段可压缩。`compress=true` ⇒ `enc=gzip`、`body=base64(gzip(json))`;否则 `enc=plain`、`body` 为明文 json。消费侧仅按 `enc` 解码,与 topic 无关,可与 C++ 等异构端互通。
|
||||||
- **Stream 裁剪**:框架按 `trim.maxlen` 近似裁剪 stream 防止无界增长。请确保 `maxlen` 足够大,避免重试中的消息在达到 `max-retry` 前被裁掉(被裁掉的 PEL 消息会被静默强制 ACK)。
|
- **Stream 裁剪**:框架按 `trim.maxlen` 精确裁剪 stream 防止无界增长(详见「七、配置项 · Stream 长度裁剪」)。请确保 `maxlen` 足够大,避免重试中的消息在达到 `max-retry` 前被裁掉(被裁掉的 PEL 消息会被静默强制 ACK)。
|
||||||
- **无 handler 时容器空转**:未注册任何 `EnhanceStreamConsumerHandler` Bean 时,监听/认领/裁剪线程均不启动,仅 `RedisStreamEnhanceTemplate` 可用于纯发送场景。
|
- **无 handler 时容器空转**:未注册任何 `EnhanceStreamConsumerHandler` Bean 时,监听/认领/裁剪线程均不启动,仅 `RedisStreamEnhanceTemplate` 可用于纯发送场景。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -255,3 +272,100 @@ public class OrderConsumer extends EnhanceStreamConsumerHandler<OrderMessage> {
|
|||||||
docker run -d -p 6379:6379 redis:6.2
|
docker run -d -p 6379:6379 redis:6.2
|
||||||
mvn test
|
mvn test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、Redis 缓存(`RedisCacheEnhanceTemplate`)
|
||||||
|
|
||||||
|
除 Stream 收发外,starter 同样提供开箱即用的**编程式缓存**:把 TTL 管理、fastjson 序列化、**穿透/雪崩/击穿三防**与滑动续期,全部封装进一个 `getOrLoad`。**注入即用,无需任何注解**;缓存模块与 Stream 完全独立——只用缓存、不注册任何 handler 也能工作,反之亦然。
|
||||||
|
|
||||||
|
> 依赖与连接配置同 Stream:引入本 starter、配好 `spring.redis.*` 即自动装配出 `RedisCacheEnhanceTemplate` Bean(在 `RedisAutoConfiguration` 之后生效)。**若上下文内没有 `StringRedisTemplate` Bean(例如自定义了多个 `RedisConnectionFactory`、或 `exclude` 了 `RedisAutoConfiguration`),缓存自动装配会整体退场而非启动失败**;此时如需缓存请自行提供 `StringRedisTemplate`。
|
||||||
|
|
||||||
|
### 10.1 快速上手
|
||||||
|
|
||||||
|
注入 `RedisCacheEnhanceTemplate`,用 `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 并写回,自动防穿透/雪崩/击穿
|
||||||
|
// 第三参是 TTL(秒,必须 > 0);想用默认 TTL 可省略该参数
|
||||||
|
return cache.getOrLoad("user:" + id, User.class, 3600, () -> loadFromDb(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void evictUser(Long id) {
|
||||||
|
cache.delete("user:" + id); // 失效
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不配置任何 `redis-cache.*` 即用默认值(default-ttl 1 小时、抖动 10%、击穿互斥锁开启),开箱即用。需要直接读写时另有 `set` / `get` / `exists` / `expire`,见下。
|
||||||
|
|
||||||
|
### 10.2 API 一览与语义
|
||||||
|
|
||||||
|
| 方法 | 语义 / 边界 |
|
||||||
|
|---|---|
|
||||||
|
| `set(key, value[, ttlSeconds])` | 写缓存(fastjson 序列化)。真实值 TTL 叠加随机抖动(防雪崩);**`value` 为 `null` 时写空值标记并改用短 `null-ttl-seconds`**;不传 `ttlSeconds` 用 `default-ttl-seconds`。**`ttlSeconds` 必须 > 0,否则抛 `IllegalArgumentException`**。 |
|
||||||
|
| `get(key, Class<T>)`<br>`get(key, TypeReference<T>)` | 读缓存,支持泛型。未命中或命中空值标记 → 返回 `null`;**脏数据(无法反序列化)→ 抛 `IllegalStateException`**(显式暴露便于排查)。 |
|
||||||
|
| `exists(key)` | 是否存在**真实值**。**空值标记与未命中均返回 `false`**(与 `get` 口径一致,读值判断而非物理 `EXISTS`)。 |
|
||||||
|
| `delete(key)` | 删除,返回是否确实删除了该 key。 |
|
||||||
|
| `expire(key, ttlSeconds)` | 显式续期(走抖动)。key 不存在 → `false`;**`ttlSeconds` 必须 > 0,否则抛 `IllegalArgumentException`**(避免传 0/负被 Redis 当成「立即删除」)。 |
|
||||||
|
| `getOrLoad(key, type, loader)`<br>`getOrLoad(key, type, ttlSeconds, loader)`<br>`getOrLoad(key, TypeReference, ttlSeconds, loader)` | 读取或回源,三防齐全。`ttlSeconds` 缺省用 `default-ttl-seconds`,**显式传入时必须 > 0**;`loader` 返回 `null` 会被缓存为空值标记(短 `null-ttl`)。 |
|
||||||
|
|
||||||
|
> **两套反序列化语义**:公共 `get` 遇脏数据**抛** `IllegalStateException`(让坏数据显式暴露);`getOrLoad` 内部读到脏数据则**当未命中降级回源**并覆盖重建(自愈)。
|
||||||
|
|
||||||
|
### 10.3 三防与滑动续期(工作原理)
|
||||||
|
|
||||||
|
- **穿透**:`loader` 回源得到 `null` 时写入空值标记 + 短 `null-ttl-seconds`(不抖动);后续命中空标记直接返回 `null`,不再反复打到后端。
|
||||||
|
- **雪崩**:真实值写入时 TTL 叠加 `[0, ttl × jitter-ratio)` 的随机抖动(**半开区间,只增不减**),打散集中过期。`jitter-ratio = 0` 关闭抖动。
|
||||||
|
- **击穿**:未命中时用 `SET NX PX`(`setIfAbsent` + `lock-ttl-ms`)抢互斥锁:
|
||||||
|
- 抢到锁者先 **double-check**(再读一次,吃掉「刚有人写好」的窗口),仍未命中才回源、写回,`finally` 用 **Lua 脚本按 token 匹配**释放锁(只删自己的锁,不误删他人)。
|
||||||
|
- 未抢到锁者按 `wait-interval-ms` 自旋重读,读到值即返回;**超时(`wait-timeout-ms`)或线程被中断则降级直接 `loader.get()` 返回**。
|
||||||
|
- 关闭(`breakdown.enabled = false`)则退化为无锁直接回源。
|
||||||
|
- **滑动续期**(`sliding-expire-enabled = true`):**读命中真实值**时自动续 `default-ttl-seconds`;**空值标记不续**(保持穿透防护的短 TTL);回源新写入自带 TTL、不重复续。
|
||||||
|
|
||||||
|
> ⚠️ **降级取舍**:持锁者回源若慢于 `wait-timeout-ms`(默认 3s)或回源抛异常,等待者会各自降级回源——瞬时被放大为 N 次回源,且**降级路径不写缓存**(有意为之,优先可用性)。务必让 `loader` 回源耗时显著小于 `wait-timeout-ms`。
|
||||||
|
|
||||||
|
### 10.4 配置项(前缀 `redis-cache`,均可选)
|
||||||
|
|
||||||
|
不配即用注释中的默认值。Spring 宽松绑定,`null-ttl-seconds` / `nullTtlSeconds` 等写法等价。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis-cache:
|
||||||
|
key-prefix: "cache:" # 统一 key 前缀;须自带分隔符(如尾冒号),否则 cache + user → cacheuser
|
||||||
|
env-isolation: false # 按环境隔离 key:开启且 env 非空 → key 形如 cache:env:bizKey
|
||||||
|
env: "" # 环境标识,隔离开启且非空时拼入 key
|
||||||
|
default-ttl-seconds: 3600 # set/getOrLoad 不传 TTL 时的默认过期(秒);必须 > 0
|
||||||
|
null-ttl-seconds: 60 # 空值标记 TTL(秒)——穿透防护,宜短;必须 > 0
|
||||||
|
jitter-ratio: 0.1 # TTL 随机抖动比例(雪崩防护),取值 [0,1);0 关闭抖动
|
||||||
|
sliding-expire-enabled: false # 读命中真实值自动续 default-ttl(滑动过期;空值标记不续)
|
||||||
|
breakdown: # 击穿:互斥锁
|
||||||
|
enabled: true # 关闭则退化为无锁直接回源
|
||||||
|
lock-ttl-ms: 10000 # 锁持有 TTL(ms),防持锁线程崩溃死锁;必须 > 0
|
||||||
|
wait-timeout-ms: 3000 # 未抢到锁的最长等待(ms),超时降级直接回源;>= 0
|
||||||
|
wait-interval-ms: 50 # 未抢到锁的自旋重读间隔(ms);必须 > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
> 上述取值在**装配时即被校验**:TTL 非正、`jitter-ratio` 越界 `[0,1)`、`lock-ttl-ms` / `wait-interval-ms` 非正等会让上下文**启动即失败(fail-fast)**,而非留到运行期才暴露。
|
||||||
|
|
||||||
|
### 10.5 必读约定与坑
|
||||||
|
|
||||||
|
- **TTL 必须为正**:`set` / `expire` / `getOrLoad`(显式 TTL)传入 `ttlSeconds ≤ 0` 一律抛 `IllegalArgumentException`。Redis 对 `EXPIRE` 非正值的语义是「立即删除 key」,框架拦在入口避免「续期变删数据」。本模板面向带 TTL 的缓存,不支持「永不过期」。
|
||||||
|
- **`key-prefix` 需自带分隔符**:key 按 `前缀 + (env + ':') + bizKey` 直接拼接,前缀不带尾分隔符会粘连(`cache` + `user:1` → `cacheuser:1`)。默认 `cache:` 已自带冒号。`bizKey` 不可为空白,否则抛 `IllegalArgumentException`。
|
||||||
|
- **`exists` 不等于物理存在**:只在有**真实值**时返回 `true`,空值标记返回 `false`(与 `get` 一致)。
|
||||||
|
- **缺 Redis 自动退场**:无 `StringRedisTemplate` Bean 时缓存装配整体 back off、不报错;此时注入 `RedisCacheEnhanceTemplate` 会因无此 Bean 而失败。
|
||||||
|
- **脏数据两套语义**:公共 `get` 抛 `IllegalStateException`;`getOrLoad` 内部当未命中降级并重建。
|
||||||
|
- **覆盖默认 Bean**:`CacheKeyBuilder` / `CacheValueCodec` / `RedisCacheEnhanceTemplate` 均 `@ConditionalOnMissingBean`,自定义同类型 Bean 即可覆盖。
|
||||||
|
|
||||||
|
> 缓存模块与 Stream 完全独立:只用缓存不注册任何 handler 也能工作;反之亦然。
|
||||||
|
|||||||
1796
docs/superpowers/plans/2026-06-25-redis-cache.md
Normal file
1796
docs/superpowers/plans/2026-06-25-redis-cache.md
Normal file
File diff suppressed because it is too large
Load Diff
360
docs/superpowers/specs/2026-06-25-redis-cache-design.md
Normal file
360
docs/superpowers/specs/2026-06-25-redis-cache-design.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# Redis 缓存增强模板 设计文档
|
||||||
|
|
||||||
|
- 日期:2026-06-25
|
||||||
|
- 状态:已评审待实现
|
||||||
|
- 所属:`redis-stream-springboot-starter`(在现有 starter 内**新增**缓存能力,不改动 stream)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景与目标
|
||||||
|
|
||||||
|
当前 starter 只封装了 Redis Streams 收发,缺了最常用的**缓存**能力。本次在同一个 starter 内补齐一个**编程式缓存增强模板**,把缓存里最容易踩坑的三件事——**TTL 管理、序列化、三大经典问题(穿透/雪崩/击穿)防护**——全部封装进去,业务侧一行 `getOrLoad` 即可拿到带完整防护的缓存读取。
|
||||||
|
|
||||||
|
设计原则与现有 stream 端保持一致:
|
||||||
|
|
||||||
|
- **零业务依赖、开箱即用**:引入依赖 + 配好 Redis 连接即可注入使用,无需 `@EnableXxx`。
|
||||||
|
- **编程式注入模板**:对齐现有 `RedisStreamEnhanceTemplate` 的使用气质,提供 `RedisCacheEnhanceTemplate`。
|
||||||
|
- **与 stream 完全解耦**:独立包、独立配置前缀、独立自动装配类,互不影响;只用缓存不用 stream 时缓存仍可单独工作(反之亦然)。
|
||||||
|
- **复用既有依赖**:`StringRedisTemplate` + `fastjson` + `hutool`,不新增任何依赖。
|
||||||
|
|
||||||
|
### 目标(In Scope)
|
||||||
|
|
||||||
|
1. 编程式缓存模板 `RedisCacheEnhanceTemplate`:`get / set / delete / exists / expire / getOrLoad`;其中 `expire` 为显式续期,并支持**可选的读时自动续期(滑动过期,默认关闭)**。
|
||||||
|
2. `getOrLoad` 内置三防:
|
||||||
|
- **穿透**:回源结果为 `null` 时缓存空值标记(短 TTL),后续直接命中空标记返回 `null`。
|
||||||
|
- **雪崩**:写入 TTL 叠加随机抖动,避免大量 key 同时失效。
|
||||||
|
- **击穿**:热点 key 失效时用 Redis 分布式互斥锁,保证只有一个线程回源重建,其余等待后重读。
|
||||||
|
3. 统一 Key 前缀 + 环境隔离(复用 stream 的环境隔离思路,缓存独立配置)。
|
||||||
|
4. fastjson 序列化,支持泛型读回(`Class<T>` / `TypeReference<T>`)。
|
||||||
|
5. 独立配置项 `redis-cache.*`,全部可选、有合理默认。
|
||||||
|
6. 单元测试 + 集成测试(沿用现有"Redis 不可达自动 skip"约定)。
|
||||||
|
|
||||||
|
### 非目标(Out of Scope,YAGNI)
|
||||||
|
|
||||||
|
- 注解式缓存(`@Cacheable` 等)——Spring 自带,本次不做。
|
||||||
|
- 批量操作(`mget`/`mset`)、`increment`、分布式锁通用工具——本次不做,留待真实需求。
|
||||||
|
- 多级缓存(本地 + Redis)、缓存预热、监控指标——不做。
|
||||||
|
- 击穿的"逻辑过期"方案——已选定互斥锁方案,不实现逻辑过期。
|
||||||
|
- 改 `artifactId`/坐标——见「十二、备注」,本次不动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、整体设计与包结构
|
||||||
|
|
||||||
|
新增 `com.njcn.middle.cache` 包,与现有 `com.njcn.middle.stream` 平行:
|
||||||
|
|
||||||
|
```
|
||||||
|
com.njcn.middle.cache
|
||||||
|
├── autoconfig
|
||||||
|
│ ├── RedisCacheProperties.java # 配置属性,前缀 redis-cache
|
||||||
|
│ └── RedisCacheAutoConfiguration.java # 自动装配(独立于 stream)
|
||||||
|
├── support
|
||||||
|
│ ├── CacheKeyBuilder.java # key 拼装:前缀 + 环境隔离
|
||||||
|
│ └── CacheValueCodec.java # 值 <-> 字符串(fastjson + 空值标记)
|
||||||
|
├── constant
|
||||||
|
│ └── CacheConstant.java # 空值标记 sentinel、锁后缀等常量
|
||||||
|
└── template
|
||||||
|
└── RedisCacheEnhanceTemplate.java # 核心:get/set/delete/exists/getOrLoad
|
||||||
|
```
|
||||||
|
|
||||||
|
每个单元职责单一、可独立测试:
|
||||||
|
|
||||||
|
| 单元 | 做什么 | 依赖 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CacheKeyBuilder` | 业务 key → 实际 Redis key(加前缀,开隔离时加 env);派生锁 key | 配置(prefix/env) |
|
||||||
|
| `CacheValueCodec` | 对象 ↔ 字符串:`encode(obj)`、`decode(str, type)`;空值标记常量与判定 | fastjson |
|
||||||
|
| `RedisCacheEnhanceTemplate` | 对外 API;编排三防逻辑(锁、抖动、空值) | `StringRedisTemplate`、上面两者、配置 |
|
||||||
|
| `RedisCacheProperties` | 绑定 `redis-cache.*` | — |
|
||||||
|
| `RedisCacheAutoConfiguration` | 装配上述 Bean(均 `@ConditionalOnMissingBean`) | `StringRedisTemplate` |
|
||||||
|
|
||||||
|
`spring.factories` 追加 `RedisCacheAutoConfiguration`(与现有 `RedisStreamAutoConfiguration` 并列两行):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||||
|
com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration,\
|
||||||
|
com.njcn.middle.cache.autoconfig.RedisCacheAutoConfiguration
|
||||||
|
```
|
||||||
|
|
||||||
|
> 缓存模块**无后台线程**(纯模板),不像 stream 有监听/认领/裁剪线程,因此即使不调用也只是多一个空闲 Bean,零开销。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、配置项(前缀 `redis-cache`,全部可选)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis-cache:
|
||||||
|
# ===== Key =====
|
||||||
|
key-prefix: "cache:" # 统一 key 前缀,便于在 Redis 中归类/批量清理
|
||||||
|
env-isolation: false # 是否按环境隔离 key(与 stream 同思路,但独立配置)
|
||||||
|
env: "" # 环境标识,env-isolation=true 且非空时拼入 key
|
||||||
|
|
||||||
|
# ===== TTL =====
|
||||||
|
default-ttl-seconds: 3600 # set/getOrLoad 不传 TTL 时的默认过期(秒)
|
||||||
|
null-ttl-seconds: 60 # 空值标记的 TTL(秒)——穿透防护,宜短
|
||||||
|
jitter-ratio: 0.1 # TTL 随机抖动比例——雪崩防护;0 表示关闭抖动
|
||||||
|
|
||||||
|
# ===== 滑动过期(读时自动续期)=====
|
||||||
|
sliding-expire-enabled: false # 开启后:读命中真实值即自动续 default-ttl-seconds(走抖动);命中空值标记不续
|
||||||
|
|
||||||
|
# ===== 击穿:互斥锁 =====
|
||||||
|
breakdown:
|
||||||
|
enabled: true # 关闭后 getOrLoad 退化为"无锁直接回源"
|
||||||
|
lock-ttl-ms: 10000 # 锁持有 TTL(ms),防持锁线程崩溃导致死锁
|
||||||
|
wait-timeout-ms: 3000 # 未抢到锁的线程最长等待(ms),超时降级直接回源
|
||||||
|
wait-interval-ms: 50 # 未抢到锁时自旋重读缓存的间隔(ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
对应 `RedisCacheProperties`:顶层字段 + 内嵌 `Breakdown` 静态类(参照现有 `RedisStreamProperties` 的 `Autoclaim`/`Trim` 写法)。
|
||||||
|
|
||||||
|
> 说明:**数据 TTL 用秒**(缓存语义直觉),**锁相关用毫秒**(锁是亚秒级,毫秒更合适);命名后缀 `-seconds`/`-ms` 明确区分,避免单位混淆。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Key 设计(`CacheKeyBuilder`)
|
||||||
|
|
||||||
|
```
|
||||||
|
基础: keyPrefix + bizKey
|
||||||
|
开启环境隔离: keyPrefix + env + ":" + bizKey
|
||||||
|
击穿锁 key: <上面得到的 dataKey> + ":__lock__"
|
||||||
|
```
|
||||||
|
|
||||||
|
示例(`key-prefix=cache:`):
|
||||||
|
|
||||||
|
| 配置 | `build("user:1")` | 锁 key |
|
||||||
|
|---|---|---|
|
||||||
|
| 默认 | `cache:user:1` | `cache:user:1:__lock__` |
|
||||||
|
| `env-isolation=true, env=prod` | `cache:prod:user:1` | `cache:prod:user:1:__lock__` |
|
||||||
|
|
||||||
|
锁 key 由数据 key **派生**(同前缀、同环境),保证数据与其锁始终落在同一隔离域。
|
||||||
|
|
||||||
|
> 与 stream 的差异:stream 环境隔离用**后缀** `topic_env`;缓存用**前缀段** `prefix:env:key` 更符合 Redis key 的层级命名习惯,且便于 `SCAN cache:prod:*` 按环境清理。两者各自独立,不要求一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、序列化与空值标记(`CacheValueCodec`)
|
||||||
|
|
||||||
|
- 底层用 `StringRedisTemplate`(与 stream 端一致),value 一律存**字符串**。
|
||||||
|
- 非空值:`JSON.toJSONString(value)`(fastjson)。读回:`JSON.parseObject(str, Class<T>)` 或 `JSON.parseObject(str, TypeReference<T>)`,支持泛型(如 `List<User>`)。
|
||||||
|
- **空值标记(sentinel)**:选用一个正常 JSON 绝不可能产生的字符串常量,内含 NUL 控制字符:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// CacheConstant.java —— 用 Java unicode 转义书写,\u0000 为 NUL 控制字符
|
||||||
|
public static final String NULL_SENTINEL = "\u0000NULL\u0000";
|
||||||
|
```
|
||||||
|
|
||||||
|
fastjson 序列化任何对象都不会产出含 `\u0000` 的裸文本,故与真实值无冲突风险。判定空值即 `NULL_SENTINEL.equals(raw)`。
|
||||||
|
|
||||||
|
### 三态读取
|
||||||
|
|
||||||
|
`getOrLoad` 必须区分"**未命中**(要回源)"与"**命中空值标记**(直接返回 null,不回源)"。为此 codec/模板内部按三态处理,而**对外** `get` 接口仍只返回 `T`(两种 null 情形对调用方都表现为 `null`):
|
||||||
|
|
||||||
|
| Redis 原始返回 | 含义 | `get` 返回 | `getOrLoad` 行为 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `null` | 未命中 | `null` | 进入回源流程(抢锁/回源) |
|
||||||
|
| `NULL_SENTINEL` | 命中空值标记 | `null` | 直接返回 `null`,**不回源** |
|
||||||
|
| 其它字符串 | 命中真实值 | 反序列化后的 `T` | 直接返回 |
|
||||||
|
|
||||||
|
实现上,模板内部用一个轻量三态(如枚举 `MISS / HIT_NULL / HIT` + 值)承载读取结果,对外 API 不暴露。
|
||||||
|
|
||||||
|
### 反序列化失败的处理
|
||||||
|
|
||||||
|
- **`get(...)` 公共方法**:反序列化失败抛 `IllegalStateException`(脏数据应显式暴露,而非静默吞掉)。
|
||||||
|
- **`getOrLoad(...)` 内部读取**:捕获反序列化异常 → 记 `warn` 日志 → **当作未命中处理**(降级回源重建),避免一条脏缓存让该 key 永久读失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、核心 API
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class RedisCacheEnhanceTemplate {
|
||||||
|
|
||||||
|
// ---- 写 ----
|
||||||
|
void set(String key, Object value); // 用 default-ttl-seconds
|
||||||
|
void set(String key, Object value, long ttlSeconds); // 指定 TTL;内部叠加雪崩抖动
|
||||||
|
|
||||||
|
// ---- 读 ----
|
||||||
|
<T> T get(String key, Class<T> type);
|
||||||
|
<T> T get(String key, TypeReference<T> type);
|
||||||
|
|
||||||
|
// ---- 删 / 判存 ----
|
||||||
|
boolean delete(String key);
|
||||||
|
boolean exists(String key);
|
||||||
|
|
||||||
|
// ---- 续期 ----
|
||||||
|
boolean expire(String key, long ttlSeconds); // 仅对已存在 key 生效;TTL 走雪崩抖动
|
||||||
|
|
||||||
|
// ---- 读取或回源(三防核心)----
|
||||||
|
<T> T getOrLoad(String key, Class<T> type, Supplier<T> loader); // 用 default-ttl
|
||||||
|
<T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader);
|
||||||
|
<T> T getOrLoad(String key, TypeReference<T> type, long ttlSeconds, Supplier<T> loader);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set(key, value, ttlSeconds)`:写入时实际 TTL = `applyJitter(ttlSeconds)`(见七)。
|
||||||
|
- `loader` 用 `java.util.function.Supplier<T>`,业务自定义回源逻辑(查 DB 等),返回 `null` 合法(表示数据不存在 → 触发空值缓存)。
|
||||||
|
- `expire(key, ttlSeconds)`:底层 `EXPIRE`,实际 TTL 同样走 `applyJitter`;key 不存在(已过期/从未写入)时返回 `false`。
|
||||||
|
|
||||||
|
### 滑动过期(读时自动续期,默认关闭)
|
||||||
|
|
||||||
|
`sliding-expire-enabled=true` 时,`get` 与 `getOrLoad` **命中真实值**会自动触发一次 `expire(key, default-ttl-seconds)`(走抖动),实现"最近被访问就持续保活"的滑动窗口语义。约定:
|
||||||
|
|
||||||
|
- **只续真实值**;**命中空值标记不续期**——否则"不存在"的 key 被反复查询会长期占位,削弱穿透防护的短 TTL 设计。
|
||||||
|
- **回源新写入不额外续期**(刚 `set` 过,TTL 已是最新)。
|
||||||
|
- 续期时长统一为 `default-ttl-seconds`(滑动是全局行为,不感知各 key 当初的自定义 TTL);需要按 key 精确续期请用显式 `expire`。
|
||||||
|
- 续期失败(并发下 key 恰好过期)忽略,不影响本次读取返回值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、`getOrLoad` 算法(三防细节)
|
||||||
|
|
||||||
|
```
|
||||||
|
getOrLoad(key, type, ttlSeconds, loader):
|
||||||
|
dataKey = keyBuilder.build(key)
|
||||||
|
|
||||||
|
// 1) 先读(三态);滑动过期开启且命中真实值时,顺带续期(空值标记不续)
|
||||||
|
r = tryGet(dataKey, type)
|
||||||
|
if r == HIT or r == HIT_NULL: # 命中真实值或空值标记
|
||||||
|
if slidingEnabled and r == HIT:
|
||||||
|
expire(dataKey, default-ttl-seconds)
|
||||||
|
return r.value # HIT_NULL 时为 null
|
||||||
|
|
||||||
|
# 2) 未命中 → 击穿保护
|
||||||
|
if not breakdown.enabled:
|
||||||
|
return loadAndCache(dataKey, ttlSeconds, loader) # 无锁,直接回源
|
||||||
|
|
||||||
|
lockKey = dataKey + ":__lock__"
|
||||||
|
token = UUID # hutool IdUtil.fastSimpleUUID()
|
||||||
|
locked = SET lockKey token NX PX lock-ttl-ms
|
||||||
|
|
||||||
|
if locked:
|
||||||
|
try:
|
||||||
|
r2 = tryGet(dataKey, type) # double-check:抢锁期间可能已被他人写入
|
||||||
|
if r2 == HIT or r2 == HIT_NULL:
|
||||||
|
return r2.value
|
||||||
|
return loadAndCache(dataKey, ttlSeconds, loader)
|
||||||
|
finally:
|
||||||
|
releaseLock(lockKey, token) # Lua:token 匹配才 del,防误删
|
||||||
|
else:
|
||||||
|
# 没抢到锁:自旋等待重读
|
||||||
|
waited = 0
|
||||||
|
while waited < wait-timeout-ms:
|
||||||
|
sleep(wait-interval-ms); waited += wait-interval-ms
|
||||||
|
r3 = tryGet(dataKey, type)
|
||||||
|
if r3 == HIT or r3 == HIT_NULL:
|
||||||
|
return r3.value
|
||||||
|
# 超时降级:直接回源返回(不写缓存,避免与持锁者竞争写),记 warn
|
||||||
|
log.warn("cache breakdown wait timeout, fallback to loader, key={}", key)
|
||||||
|
return loader.get()
|
||||||
|
|
||||||
|
|
||||||
|
loadAndCache(dataKey, ttlSeconds, loader):
|
||||||
|
value = loader.get()
|
||||||
|
if value == null:
|
||||||
|
SET dataKey NULL_SENTINEL EX null-ttl-seconds # 穿透防护
|
||||||
|
else:
|
||||||
|
SET dataKey JSON(value) EX applyJitter(ttlSeconds) # 雪崩防护
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
applyJitter(ttlSeconds):
|
||||||
|
if jitter-ratio <= 0: return ttlSeconds
|
||||||
|
extra = floor(random() * ttlSeconds * jitter-ratio) # ThreadLocalRandom,[0, ttl*ratio)
|
||||||
|
return ttlSeconds + extra # 只增不减,不会提前失效
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- **抢锁原子性**:`SET lockKey token NX PX <ttl>` 单命令完成"不存在才设并带过期",无 `SETNX` + `EXPIRE` 两步竞态。
|
||||||
|
- **释放锁安全**:用 Lua 脚本 `if GET(lockKey)==token then DEL(lockKey)`,避免业务回源耗时超过 `lock-ttl-ms`、锁已过期被他人重设时误删别人的锁。
|
||||||
|
- **double-check**:抢到锁后再读一次,吃掉"等待抢锁的瞬间持锁者刚写完"的窗口,避免重复回源。
|
||||||
|
- **超时降级**:等待超过 `wait-timeout-ms` 仍无值 → 直接 `loader.get()` 返回(不写缓存),牺牲一次回源换取**不被锁永久阻塞**的可用性。
|
||||||
|
- **抖动只增不减**:`applyJitter` 在原 TTL 上加 `[0, ttl*ratio)`,保证不会因抖动让缓存比预期更早失效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、自动装配(`RedisCacheAutoConfiguration`)
|
||||||
|
|
||||||
|
参照 `RedisStreamAutoConfiguration` 写法:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnClass(StringRedisTemplate.class)
|
||||||
|
@AutoConfigureAfter(RedisAutoConfiguration.class) // 确保 StringRedisTemplate 就绪
|
||||||
|
@EnableConfigurationProperties(RedisCacheProperties.class)
|
||||||
|
public class RedisCacheAutoConfiguration {
|
||||||
|
|
||||||
|
@Bean @ConditionalOnMissingBean
|
||||||
|
CacheKeyBuilder cacheKeyBuilder(RedisCacheProperties p) { ... }
|
||||||
|
|
||||||
|
@Bean @ConditionalOnMissingBean
|
||||||
|
CacheValueCodec cacheValueCodec() { ... }
|
||||||
|
|
||||||
|
@Bean @ConditionalOnMissingBean
|
||||||
|
RedisCacheEnhanceTemplate redisCacheEnhanceTemplate(
|
||||||
|
StringRedisTemplate redis, CacheKeyBuilder kb, CacheValueCodec codec, RedisCacheProperties p) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 全部 `@ConditionalOnMissingBean`:业务可自定义覆盖。
|
||||||
|
- 与 stream 装配类**互不引用**;`spring.factories` 追加注册。
|
||||||
|
- 不依赖任何 `EnhanceStreamConsumerHandler`,无 `ObjectProvider` 收集逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、错误处理与降级小结
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `set` 序列化失败 | 抛 `IllegalStateException`(包装原因) |
|
||||||
|
| `get` 反序列化失败 | 抛 `IllegalStateException`(脏数据显式暴露) |
|
||||||
|
| `getOrLoad` 内部读缓存反序列化失败 | 记 `warn`,当未命中 → 降级回源重建 |
|
||||||
|
| `loader` 返回 `null` | 缓存空值标记(`null-ttl-seconds`),返回 `null` |
|
||||||
|
| `loader` 抛异常 | 异常上抛(不写缓存,不吞);锁在 `finally` 释放 |
|
||||||
|
| 抢锁等待超时 | 记 `warn`,降级直接 `loader.get()` 返回(不写缓存) |
|
||||||
|
| `expire`/滑动续期目标 key 不存在 | 不报错;`expire` 返回 `false`,滑动续期静默跳过 |
|
||||||
|
| Redis 连接异常 | 上抛由调用方处理(本期不做 Redis 故障本地降级,YAGNI) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、测试策略
|
||||||
|
|
||||||
|
延续现有约定:单元测试 mock,集成测试连真实 Redis 且**不可达自动 skip**(复用 `StreamRoundTripTest.RedisReachableCondition` 同款 `ExecutionCondition`,`-Dspring.redis.host/port/password` 覆盖连接)。
|
||||||
|
|
||||||
|
### 单元测试(无需 Redis)
|
||||||
|
|
||||||
|
- `CacheKeyBuilderTest`:默认前缀、环境隔离前缀段、锁 key 派生。
|
||||||
|
- `CacheValueCodecTest`:对象/基本类型/泛型 `TypeReference` 往返;空值标记 encode/判定;脏字符串 decode 抛异常。
|
||||||
|
- `RedisCachePropertiesTest`:默认值、宽松绑定(`default-ttl-seconds`/`defaultTtlSeconds`)、内嵌 `breakdown.*`。
|
||||||
|
- `RedisCacheEnhanceTemplateTest`(mock `StringRedisTemplate`/`ValueOperations`):
|
||||||
|
- `getOrLoad` 命中真实值 → 不回源;
|
||||||
|
- 命中空值标记 → 返回 null 且不回源;
|
||||||
|
- 未命中 → 回源一次并写缓存;
|
||||||
|
- `loader` 返回 null → 写空值标记(TTL=null-ttl);
|
||||||
|
- `applyJitter` 结果落在 `[ttl, ttl*(1+ratio))`;`ratio=0` 时等于原值;
|
||||||
|
- 内部读取反序列化失败 → 降级回源;
|
||||||
|
- `expire`:已存在 key 返回 `true` 并刷新 TTL(走抖动),不存在 key 返回 `false`;
|
||||||
|
- 滑动开启时:命中真实值触发一次续期、命中空值标记**不**续期、回源新写入**不**重复续期。
|
||||||
|
|
||||||
|
### 集成测试(需 Redis,skip-if-unreachable)
|
||||||
|
|
||||||
|
- `CacheRoundTripTest`:`set` → `get` 往返一致;TTL 生效(`getExpire` 在区间内);`delete` 后不存在。
|
||||||
|
- `CachePenetrationTest`:`getOrLoad` 对不存在的数据回源返回 null → 二次调用命中空标记、**loader 不再被调用**(`AtomicInteger` 计数 == 1)。
|
||||||
|
- `CacheBreakdownConcurrencyTest`:N 个线程并发 `getOrLoad` 同一未命中热点 key,`loader` 内 `sleep` 模拟慢回源 → 断言 `loader` **仅被调用 1 次**,N 个线程都拿到同一结果。
|
||||||
|
- `CacheExpireSlidingTest`:显式 `expire` 后 `getExpire` 变长、对不存在 key 返回 `false`;开启 `sliding-expire-enabled` 后重复 `get` 命中真实值持续刷新 TTL,而空值标记的 TTL 不被刷新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、对现有代码的影响
|
||||||
|
|
||||||
|
- **不修改** stream 任何现有类。
|
||||||
|
- 仅**新增** `com.njcn.middle.cache.*` 与对应测试。
|
||||||
|
- `spring.factories` 追加一行注册(并列,不影响 stream 装配)。
|
||||||
|
- 实现阶段在 `README.md` 增补「缓存」章节(用法与配置),与现有 stream 文档并列。
|
||||||
|
- 不新增第三方依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、备注
|
||||||
|
|
||||||
|
- **`artifactId` 维持 `redis-stream-springboot-starter` 不变**:已发布坐标,贸然改名破坏下游引用。缓存功能以独立包形式并入;若未来 starter 能力进一步泛化,再单独评估是否重命名为更通用的坐标(本期不做)。
|
||||||
|
- 击穿锁是一把**够用的轻量分布式锁**(SET NX PX + Lua 释放),不抽象成对外通用锁工具——保持 YAGNI,仅服务于缓存重建。
|
||||||
47
src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java
vendored
Normal file
47
src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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.ConditionalOnBean;
|
||||||
|
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(proxyBeanMethods = false)
|
||||||
|
@ConditionalOnClass(StringRedisTemplate.class)
|
||||||
|
@ConditionalOnBean(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java
vendored
Normal file
48
src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main/java/com/njcn/middle/cache/constant/CacheConstant.java
vendored
Normal file
19
src/main/java/com/njcn/middle/cache/constant/CacheConstant.java
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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__";
|
||||||
|
}
|
||||||
39
src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java
vendored
Normal file
39
src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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 (StrUtil.isBlank(bizKey)) {
|
||||||
|
throw new IllegalArgumentException("bizKey must not be blank");
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java
vendored
Normal file
38
src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package com.njcn.middle.cache.support;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.TypeReference;
|
||||||
|
import com.alibaba.fastjson.parser.Feature;
|
||||||
|
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)。关闭 @type 自动类型(纵深防御:禁止脏值据 @type 实例化任意类)。 */
|
||||||
|
public <T> T decode(String raw, Class<T> type) {
|
||||||
|
return JSON.parseObject(raw, type, Feature.IgnoreAutoType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字符串 → 对象(TypeReference,支持泛型)。同样关闭 @type 自动类型。 */
|
||||||
|
public <T> T decode(String raw, TypeReference<T> type) {
|
||||||
|
return JSON.parseObject(raw, type, Feature.IgnoreAutoType);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java
vendored
Normal file
317
src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java
vendored
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package com.njcn.middle.cache.template;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import com.alibaba.fastjson.TypeReference;
|
||||||
|
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.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 缓存增强模板。
|
||||||
|
*
|
||||||
|
* <p>底层 {@link StringRedisTemplate} 存字符串,fastjson 序列化。本类(Task 4)实现读写基础;
|
||||||
|
* {@code getOrLoad} 三防(穿透/雪崩/击穿)由 Task 5 追加。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class RedisCacheEnhanceTemplate {
|
||||||
|
|
||||||
|
/** 释放锁 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);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
validateProps(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) {
|
||||||
|
requirePositiveTtl(ttlSeconds);
|
||||||
|
// 空值(写入空标记)按短 null-ttl 处理,与回源 null 一致,避免空标记长期占位
|
||||||
|
long ttl = (value == null) ? props.getNullTtlSeconds() : applyJitter(ttlSeconds);
|
||||||
|
redis.opsForValue().set(keyBuilder.build(key), codec.encode(value), ttl, 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) {
|
||||||
|
// 与 get 口径一致:空值标记不算"存在"(物理 hasKey 会把空标记算存在,与 get→null 打架)
|
||||||
|
String raw = redis.opsForValue().get(keyBuilder.build(key));
|
||||||
|
return raw != null && !codec.isNullSentinel(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 续期 ----
|
||||||
|
|
||||||
|
public boolean expire(String key, long ttlSeconds) {
|
||||||
|
requirePositiveTtl(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TTL 必须为正:0/负会被 Redis 当作"立即删除"(EXPIRE)或非法过期时间(SET),显式 fail-fast。 */
|
||||||
|
private static void requirePositiveTtl(long ttlSeconds) {
|
||||||
|
if (ttlSeconds <= 0) {
|
||||||
|
throw new IllegalArgumentException("ttlSeconds must be positive, but was " + ttlSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造期校验配置项,误配在装配阶段即 fail-fast,而非运行期才暴露。 */
|
||||||
|
private static void validateProps(RedisCacheProperties props) {
|
||||||
|
if (props.getDefaultTtlSeconds() <= 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"redis-cache.default-ttl-seconds must be positive, but was " + props.getDefaultTtlSeconds());
|
||||||
|
}
|
||||||
|
if (props.getNullTtlSeconds() <= 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"redis-cache.null-ttl-seconds must be positive, but was " + props.getNullTtlSeconds());
|
||||||
|
}
|
||||||
|
double ratio = props.getJitterRatio();
|
||||||
|
if (ratio < 0 || ratio >= 1) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"redis-cache.jitter-ratio must be in [0,1), but was " + ratio);
|
||||||
|
}
|
||||||
|
RedisCacheProperties.Breakdown bd = props.getBreakdown();
|
||||||
|
if (bd == null) {
|
||||||
|
throw new IllegalArgumentException("redis-cache.breakdown must not be null");
|
||||||
|
}
|
||||||
|
if (bd.getLockTtlMs() <= 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"redis-cache.breakdown.lock-ttl-ms must be positive, but was " + bd.getLockTtlMs());
|
||||||
|
}
|
||||||
|
if (bd.getWaitTimeoutMs() < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"redis-cache.breakdown.wait-timeout-ms must be >= 0, but was " + bd.getWaitTimeoutMs());
|
||||||
|
}
|
||||||
|
if (bd.getWaitIntervalMs() <= 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"redis-cache.breakdown.wait-interval-ms must be positive, but was " + bd.getWaitIntervalMs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 读取或回源(三防核心)----
|
||||||
|
|
||||||
|
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) {
|
||||||
|
requirePositiveTtl(ttlSeconds);
|
||||||
|
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 = keyBuilder.buildLock(key); // 复用 keyBuilder,锁 key 规则单一来源
|
||||||
|
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 intervalMs = Math.max(1, bd.getWaitIntervalMs()); // 兜底:interval<=0 时不前进会空转
|
||||||
|
long deadlineNanos = System.nanoTime() + bd.getWaitTimeoutMs() * 1_000_000L;
|
||||||
|
while (System.nanoTime() - deadlineNanos < 0) { // 溢出安全的截止判断
|
||||||
|
if (!sleep(intervalMs)) {
|
||||||
|
break; // 被中断:停止等待、降级回源(中断标志已保留,响应协作式取消)
|
||||||
|
}
|
||||||
|
Lookup<T> r3 = tryGet(dataKey, decoder);
|
||||||
|
if (r3.hit) {
|
||||||
|
if (r3.realValue) {
|
||||||
|
slidingTouch(dataKey);
|
||||||
|
}
|
||||||
|
return r3.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn("[redis-cache] breakdown wait timeout/interrupted, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 休眠;返回 false 表示被中断(已重置中断标志,调用方应停止等待)。 */
|
||||||
|
private static boolean sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
return true;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取三态载体(对外不暴露)。 */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,8 @@ import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
|||||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.SmartLifecycle;
|
import org.springframework.context.SmartLifecycle;
|
||||||
import org.springframework.data.redis.core.RedisCallback;
|
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -20,7 +18,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* 定时裁剪 stream 长度,防止无界增长。
|
* 定时裁剪 stream 长度,防止无界增长。
|
||||||
*
|
*
|
||||||
* <p>按 {@code trim.intervalMs} 周期对每个 handler 的 stream 执行
|
* <p>按 {@code trim.intervalMs} 周期对每个 handler 的 stream 执行
|
||||||
* {@code XTRIM <stream> MAXLEN ~ <maxlen>}({@code ~} 近似裁剪,性能更好)。
|
* {@code XTRIM <stream> MAXLEN <maxlen>}(精确裁剪,经 spring-data 高级 API;
|
||||||
|
* 2.3.x 无近似裁剪重载,故不用 {@code ~})。
|
||||||
*
|
*
|
||||||
* <p>无 handler 时不启动。正确性由 C11 集成测试覆盖。
|
* <p>无 handler 时不启动。正确性由 C11 集成测试覆盖。
|
||||||
*/
|
*/
|
||||||
@@ -81,11 +80,7 @@ public class StreamTrimScheduler implements SmartLifecycle {
|
|||||||
long maxlen = props.getTrim().getMaxlen();
|
long maxlen = props.getTrim().getMaxlen();
|
||||||
for (String stream : distinctStreams()) {
|
for (String stream : distinctStreams()) {
|
||||||
try {
|
try {
|
||||||
redis.opsForStream().trim(stream,maxlen);
|
redis.opsForStream().trim(stream, maxlen);
|
||||||
// redis.execute((RedisCallback<Object>) connection -> connection.execute("XTRIM",
|
|
||||||
// stream.getBytes(StandardCharsets.UTF_8),
|
|
||||||
// "MAXLEN".getBytes(StandardCharsets.UTF_8),
|
|
||||||
// String.valueOf(maxlen).getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[redis-stream] trim failed stream={}", stream, e);
|
log.warn("[redis-stream] trim failed stream={}", stream, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||||
com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration
|
com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration,\
|
||||||
|
com.njcn.middle.cache.autoconfig.RedisCacheAutoConfiguration
|
||||||
|
|||||||
65
src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java
vendored
Normal file
65
src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backsOffWhenNoStringRedisTemplateBean() {
|
||||||
|
// 不装配 RedisAutoConfiguration,上下文内无 StringRedisTemplate Bean:
|
||||||
|
// 应整类 back off 优雅退场,而非因缺依赖启动失败。
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(RedisCacheAutoConfiguration.class))
|
||||||
|
.run(ctx -> assertThat(ctx)
|
||||||
|
.hasNotFailed()
|
||||||
|
.doesNotHaveBean(RedisCacheEnhanceTemplate.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class CustomConfig {
|
||||||
|
@Bean
|
||||||
|
RedisCacheEnhanceTemplate myTemplate() {
|
||||||
|
return new RedisCacheEnhanceTemplate(
|
||||||
|
new StringRedisTemplate(mock(RedisConnectionFactory.class)),
|
||||||
|
new CacheKeyBuilder("cache:", false, ""),
|
||||||
|
new CacheValueCodec(),
|
||||||
|
new RedisCacheProperties());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java
vendored
Normal file
44
src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java
vendored
Normal file
89
src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java
vendored
Normal file
102
src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java
vendored
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java
vendored
Normal file
59
src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java
vendored
Normal file
59
src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java
vendored
Normal file
24
src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java
vendored
Normal file
43
src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package com.njcn.middle.cache.support;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildRejectsBlankBizKey() {
|
||||||
|
CacheKeyBuilder kb = new CacheKeyBuilder("cache:", false, "");
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> kb.build(null));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> kb.build(""));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> kb.build(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java
vendored
Normal file
73
src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodeIgnoresAtTypeHint() {
|
||||||
|
// 含 @type 的脏值(如 Redis 值被外部篡改注入)应被忽略,安全解析为目标类型
|
||||||
|
String raw = "{\"@type\":\"java.util.HashMap\",\"id\":1,\"name\":\"a\"}";
|
||||||
|
Foo foo = codec.decode(raw, Foo.class);
|
||||||
|
assertEquals(1L, foo.getId());
|
||||||
|
assertEquals("a", foo.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
427
src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
vendored
Normal file
427
src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
vendored
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
package com.njcn.middle.cache.template;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.TypeReference;
|
||||||
|
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 org.springframework.data.redis.core.script.RedisScript;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
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.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.atMost;
|
||||||
|
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.initMocks(this);
|
||||||
|
lenient().when(redis.opsForValue()).thenReturn(valueOps);
|
||||||
|
template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setSerializesWithExactTtlWhenNoJitter() {
|
||||||
|
props.setJitterRatio(0);
|
||||||
|
User u = new User(1L, "a");
|
||||||
|
template.set("user:1", u, 100);
|
||||||
|
verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getHitDeserializes() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
||||||
|
User got = template.get("user:1", User.class);
|
||||||
|
assertEquals("a", got.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getMissReturnsNull() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||||
|
assertNull(template.get("user:1", User.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNullSentinelReturnsNull() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
||||||
|
assertNull(template.get("user:1", User.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDirtyDataThrowsIllegalState() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn("{not-json");
|
||||||
|
assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expireDelegatesWithJitterTtl() {
|
||||||
|
props.setJitterRatio(0);
|
||||||
|
when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true);
|
||||||
|
assertTrue(template.expire("user:1", 200));
|
||||||
|
verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyJitterWithinBounds() {
|
||||||
|
props.setJitterRatio(0.1);
|
||||||
|
long bound = (long) (100 * 0.1);
|
||||||
|
for (int i = 0; i < 500; i++) {
|
||||||
|
long ttl = template.applyJitter(100);
|
||||||
|
assertTrue(ttl >= 100 && ttl < 100 + bound, "ttl=" + ttl); // 半开区间 [100,110)
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #1+#2: ttl<=0 入口校验(fail-fast,不下发非法过期) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setRejectsNonPositiveTtl() {
|
||||||
|
User u = new User(1L, "a");
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> template.set("user:1", u, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> template.set("user:1", u, -1));
|
||||||
|
verify(valueOps, never()).set(anyString(), anyString(), anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expireRejectsNonPositiveTtl() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> template.expire("user:1", 0));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> template.expire("user:1", -5));
|
||||||
|
verify(redis, never()).expire(anyString(), anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadRejectsNonPositiveTtl() {
|
||||||
|
props.getBreakdown().setEnabled(false); // 避免跑红时走入等待自旋
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> template.getOrLoad("user:1", User.class, 0, () -> new User(1L, "a")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #3+#4: 击穿等待的中断响应与 interval 兜底 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadWaitIntervalZeroDoesNotSpinForever() {
|
||||||
|
props.getBreakdown().setWaitIntervalMs(0); // 误配 0:旧实现 waited 永不前进 → 无限循环
|
||||||
|
props.getBreakdown().setWaitTimeoutMs(50);
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||||
|
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
||||||
|
User got = assertTimeoutPreemptively(Duration.ofSeconds(2), () ->
|
||||||
|
template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb")));
|
||||||
|
assertEquals("fb", got.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadInterruptedDuringWaitStopsSpinning() {
|
||||||
|
props.getBreakdown().setWaitIntervalMs(20);
|
||||||
|
props.getBreakdown().setWaitTimeoutMs(10000); // 大超时:旧实现中断后空转数百次到超时
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||||
|
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
||||||
|
Thread.currentThread().interrupt(); // 当前线程预置中断,getOrLoad 同步执行可感知
|
||||||
|
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb"));
|
||||||
|
boolean wasInterrupted = Thread.interrupted(); // 读取并清除,避免污染后续测试
|
||||||
|
assertEquals("fb", got.getName());
|
||||||
|
assertTrue(wasInterrupted, "中断标志应被保留(协作式取消)");
|
||||||
|
// 中断后立即停止等待:仅首次 tryGet 读一次,而非空转数百次
|
||||||
|
verify(valueOps, atMost(2)).get("cache:user:1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #5: 构造期配置 fail-fast 校验 ----
|
||||||
|
|
||||||
|
private RedisCacheEnhanceTemplate newTemplateWith(Consumer<RedisCacheProperties> mut) {
|
||||||
|
RedisCacheProperties p = new RedisCacheProperties();
|
||||||
|
mut.accept(p);
|
||||||
|
return new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorRejectsInvalidProps() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setDefaultTtlSeconds(0)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setNullTtlSeconds(-1)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setJitterRatio(1.0)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setJitterRatio(-0.1)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.getBreakdown().setLockTtlMs(0)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.getBreakdown().setWaitIntervalMs(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorAcceptsDefaults() {
|
||||||
|
new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, new RedisCacheProperties());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #7: exists 排除空值标记(与 get 口径一致) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsTrueForRealValue() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
||||||
|
assertTrue(template.exists("user:1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsFalseForNullSentinel() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
||||||
|
assertFalse(template.exists("user:1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsFalseForMissing() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||||
|
assertFalse(template.exists("user:1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #9: 公共 set(key,null) 用短 null-ttl(与回源空值一致) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setNullUsesNullTtlNotDataTtl() {
|
||||||
|
props.setJitterRatio(0);
|
||||||
|
template.set("user:1", null, 3600); // 传入数据 TTL,但写空标记应改用短 null-ttl
|
||||||
|
verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL,
|
||||||
|
props.getNullTtlSeconds(), TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #11: 补关键路径覆盖(loader 异常 / double-check / TypeReference / sliding 回源 / 降级不写缓存) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadLoaderThrowsReleasesLockAndPropagates() {
|
||||||
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||||
|
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
RuntimeException boom = new RuntimeException("boom");
|
||||||
|
RuntimeException thrown = assertThrows(RuntimeException.class,
|
||||||
|
() -> template.getOrLoad("user:1", User.class, 60, () -> {
|
||||||
|
throw boom;
|
||||||
|
}));
|
||||||
|
assertSame(boom, thrown); // 异常原样透传
|
||||||
|
verify(redis).execute(any(RedisScript.class), anyList(), any()); // 锁在 finally 释放
|
||||||
|
verify(valueOps, never()).set(anyString(), anyString(), anyLong(), any()); // 未写缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadDoubleCheckHitSkipsLoader() {
|
||||||
|
props.setJitterRatio(0);
|
||||||
|
when(valueOps.get("cache:user:1"))
|
||||||
|
.thenReturn(null) // 首次 tryGet miss
|
||||||
|
.thenReturn(JSON.toJSONString(new User(1L, "a"))); // 抢锁后 double-check 命中
|
||||||
|
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
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()); // double-check 命中,未回源
|
||||||
|
verify(redis).execute(any(RedisScript.class), anyList(), any()); // 锁仍释放
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadTypeReferenceRoundTrip() {
|
||||||
|
props.setJitterRatio(0);
|
||||||
|
when(valueOps.get("cache:list:1")).thenReturn(null);
|
||||||
|
when(valueOps.setIfAbsent(eq("cache:list:1:__lock__"), anyString(), any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
List<Integer> loaded = Arrays.asList(1, 2, 3);
|
||||||
|
List<Integer> got = template.getOrLoad("list:1",
|
||||||
|
new TypeReference<List<Integer>>() {
|
||||||
|
}, 60, () -> loaded);
|
||||||
|
assertEquals(loaded, got);
|
||||||
|
verify(valueOps).set("cache:list:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadSlidingDoesNotRenewOnFreshLoad() {
|
||||||
|
props.setSlidingExpireEnabled(true);
|
||||||
|
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);
|
||||||
|
template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a"));
|
||||||
|
// 回源新写入已自带 TTL,不应再触发滑动续期 expire
|
||||||
|
verify(redis, never()).expire(eq("cache:user:1"), anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrLoadWaitTimeoutFallbackDoesNotWriteCache() {
|
||||||
|
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);
|
||||||
|
template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb"));
|
||||||
|
verify(valueOps, never()).set(anyString(), anyString(), anyLong(), any()); // 降级不写缓存
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user