# bb-cache **Repository Path**: RemoteControl/bb-cache ## Basic Information - **Project Name**: bb-cache - **Description**: 一个基于官方 spring-data-redis的对象缓存增强,口号是 "一切皆缓存":让 Spring 的声明式注解(@Cacheable/@CachePut/@CacheEvict)能直接用于关系型实体、条件查询、分页结果。 叫它"自动化缓存"是贴切的——核心卖点就是自动维持一致性:实体一变,所有引用它的列表/分页缓存下次读自动失效自愈,你不用手动去枚举清哪些列表。 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-07-09 - **Last Updated**: 2026-07-27 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Redis Cache Plus 基于**官方 spring-data-redis**(4.1.x / Spring Boot 4.1,Spring Framework 7 / JDK 17+)的对象缓存增强,核心理念**"一切皆缓存"**。 以扩展点方式实现,**不改上游源码**,因此能跟随官方版本升级。 这是原 `spring-data-redis` fork(改源码版)的重构继任者。设计与开发计划见 `docs/`: `design.md`(设计)、`development-plan.md`(P0–P5)、`phase1.md`(Writer 脱钩)、`test-plan.md`(测试矩阵)。 --- ## 一、这个库解决什么问题(特色) 普通 `@Cacheable` 把「一条实体」和「一个列表」当成两块互不相干的缓存。于是: - 同一个 `UserBank` 被 10 个列表缓存各存一份 → **占空间、且难保证一致**; - 改了一条数据,你得**人肉记住**「这条数据出现在哪些列表 key 里」,逐个 `@CacheEvict` → 极易漏清、脏读。 Redis Cache Plus 用四个机制系统性解决它: | 机制 | 作用 | 关键类 | |---|---|---| | **① 引用归一化**(Hibernate Query Cache 式) | 实体只按主键存**一份**;列表/分页缓存只存 `LinkObject`(主键引用)。读时重组,**任一引用实体缺失 → 整个列表判 miss 并自愈** | `PlusRedisCache`、`EntityLinker`、`LinkObject` | | **② 模糊失效(二级索引 SPI)** | 写入时按字段建反向索引;失效时用通配 `&userId=1&*` 反查真实 key。索引存储可插拔(Set / RediSearch / None) | `CacheKeyIndex`、`CacheKeyCodec` | | **③ 事件驱动索引清理** | 订阅 `__keyevent@*__:expired`,key 过期时 O(1) 精确清索引;低频 `sweep()` 兜底。集群天然按节点分片,无分布式锁 | `KeyspaceNotificationIndexCleaner` | | **④ 数据变更失效** | 把 DB 行变更(注解 / MQ / Canal binlog)翻译成精确失效动作,业务侧尽量零样板 | `CacheInvalidator`、`RowChangeInvalidator` | **归一化带来的最大红利**:绝大多数「改一条数据」的场景,你**只需失效那条实体的主键缓存**,所有引用它的列表下次读会重组失败并自愈 —— 无需枚举列表。只有「新增行」「改了过滤列」才需要模糊失效列表。 ### 核心优势 - **一致性自动化**:改一条实体只需失效主键,所有引用它的列表自愈刷新;无需人肉记「改哪列清哪个 key」。 - **易变数据的列表也能缓存**:改展示列用 `@CachePut` 只刷一个实体,所有列表 `MGET` 到新值、列表不 miss、零回源 —— 这是普通缓存做不到的(订单类数据的主场)。 - **省内存**:实体全局存一份,列表/分页只存主键引用(`LinkObject`),天然去重。 - **失效四档可选**:手动 `@CacheEvict` → 代码 `CacheInvalidator` → 全自动 `@CacheEvictAuto` → MQ/Canal 事件驱动,按自动化程度自由选。 - **精确模糊失效**:二级索引反查 `&userId=1&*`,O(命中数) 而非全库 `SCAN`;索引存储可插拔(Set / RediSearch / None)。 - **不改上游**:全走官方 `spring-data-redis` 扩展点,可跟随官方版本升级。 ### 性能与成本初评(🤖 AI 初评) > ⚠️ 以下为 AI 基于代码路径的**参数化估算,非实测数据**,仅供量级参考。完整模型(假设、三方成本拆解、天花板推导)见 **[`docs/performance.md`](docs/performance.md)**。 以订单类服务为例,对比三方:**A**=只缓存 id、列表不缓存;**B**=本方案;**C**=DB+ES(+Redis)。(列表命中率按 ≈90%;写主库三方共有、不计入差异——缓存只帮读不帮写。) | 维度 | A 只缓存 id | **B 本方案** | C DB+ES+Redis | |---|---|---|---| | 同硬件峰值吞吐 | ~**5K** QPS | ~**50K** QPS(≈**10×**,命中率更高可达 ~100K) | ~**50K** QPS | | 达 50K QPS 基建/月 | ~$10,000(**≈5.5×**) | **~$1,800(1×)** | ~$4,800(**≈2.7×**) | | 扩展边际(每 +50K) | ~$10,000(加 DB 副本) | **~$800(加 Redis 分片)** | ~$2,000(加 ES 节点) | | 研发 + 运维 | 低(吞吐锁死) | **低(几人天)** | 高(数人月 + 持续运维) | | 查询能力 | 全 SQL | **仅参数化查询** | 全文 / 聚合 / 自由筛选(最强) | - **系统天花板**:DB 只承担「写 + miss」,峰值 ≈ `DB列表查上限 ÷ (列表占比 × miss率)`;到线时是不可压缩的 DB 流量顶穿 DB,**再加 Redis 无意义**——真正的杠杆是**命中率**(miss 10%→5% 峰值翻倍)。 - **甜点区**:**MySQL(主从) + Redis(主从) + 本方案** 用最便宜的组合覆盖绝大多数中小系统(~50K QPS),基建约 A 的 1/5、C 的 1/3。**代价**:不含 ES 的强搜索——需全文 / 自由检索 / 聚合时 ES 不可替代。 --- ## 二、准备工作 ```groovy implementation 'com.bb.cache:redis-cache-plus-spring-boot-starter:1.0.0-SNAPSHOT' ``` ```properties # 索引插件:set(默认,原生 Redis Set) | redisearch(需模块) | none(关闭模糊失效) redis.cache.plus.index=set redis.cache.plus.cleaner-enabled=true ``` Redis 必须开启键空间事件: ``` notify-keyspace-events Ex ``` **为什么要开**:二级索引和缓存本体是 Redis 里**两个独立的 key**(索引本身不带 TTL)。缓存本体因 TTL 自然过期时,Redis 只删本体、不碰索引 —— 索引里会留下一条指向"已不存在的 key"的死记录。开启 `Ex` 后,缓存一过期 Redis 就发过期事件,机制③订阅它来精确清掉对应索引项,保证两者同步。详见下节「索引清理:主监控 + 兜底」。 实体参与归一化的**两个前提**(见 `EntityLinker`):类上加 `@CachePlus`,且存在 `@Id`/`@EmbeddedId` 主键。 ```java @CachePlus @Entity @Table(name = "user_bank") public class UserBank implements Serializable { @Id private Long id; private Long userId; // 列表查询维度(模糊失效按它反查) private Integer status; // 会改变列表结果集的「关键列」 // ... } ``` > 下面所有示例都对应可运行的 `redis-cache-plus-demo`(`UserBankDemoController` 暴露 `/userBank/{v1|v2|v3}/...`,由 `DemoCacheVariantsTest` 端到端验证,内嵌 Redis,无 Redis 自动跳过)。 --- ## 三、怎么「存」 归一化对上层是**透明**的 —— 你照常用官方 `@Cacheable`,库在 Writer/Cache 层自动把实体拆成「主键一份 + 列表存引用」。你唯一要决定的是 **key 怎么来**。 ### 存法 A:手写 key(最原始,样板最多) 用 SpEL 手拼查询 key。能用,但值里含 `&`/`=` 会污染 key,且要人肉保证读写 key 一致。 ```java @Service @CacheConfig(cacheNames = "UserBankV1") public class UserBankManualService { @Cacheable(key = "#id") // 主键 → 实体本体缓存 public UserBank getBank(Long id) { ... } @Cacheable(key = "'&userId='+#userId") // 列表 → &userId= public List getMyBanks(Long userId) { ... } @Cacheable(key = "'&userId='+#userId+'&page='+#pageable.pageNumber+'&size='+#pageable.pageSize") public Page getMyPageBanks(Long userId, Pageable pageable) { ... } } ``` ### 存法 B:自动生成 key(推荐) 用内置 `plusKeyGenerator` + 参数上的 `@KeyField`。值经 codec **自动转义**,天然规避 `&`/`=` 污染;`Pageable` 自动展开为 `&page&size`。 ```java @Cacheable(cacheNames = "UserBankV2", key = "#id") public UserBank getBank(Long id) { ... } // 自动生成 &userId= @Cacheable(cacheNames = "UserBankV2", keyGenerator = "plusKeyGenerator") public List getMyBanks(@KeyField("userId") Long userId) { ... } // 自动生成 &userId=&page=&size= @Cacheable(cacheNames = "UserBankV2", keyGenerator = "plusKeyGenerator") public Page getMyPageBanks(@KeyField("userId") Long userId, Pageable pageable) { ... } ``` > **cache key body 契约**:`&field=value&field2=value2`;含 `*` 即表示模糊失效。值务必经 `CacheKeyCodec` 转义,读写两侧必须用同一 codec 才对称。 --- ## 四、怎么「释放」(失效) 这是本库的重点。按**自动化程度**从低到高有四种方案,可按项目情况自由选择或混用。 先记住归一化带来的**统一失效语义**(四种方案共用): - **UPDATE 非关键列**(改昵称/手机号…)→ 只失效实体主键缓存,列表内容靠引用+自愈自动刷新; - **UPDATE 关键列 / DELETE**(改 status、删行…)→ 失效实体主键;引用它的列表下次读自愈为 miss; - **INSERT / 改过滤列**(新增行、userId 变了…)→ 新行不被任何现有引用指向,自愈感知不到,**必须按维度模糊失效**相关列表(`&userId=..&*`)。 --- ### 方案 ①:手动 —— 注解式 `@CacheEvict`(写死字符串) **原理**:纯官方 Spring Cache 注解,你自己用 SpEL 拼失效 key / 模糊 pattern。库只在底层负责「模糊 pattern → 反查真实 key 批量删」。 **适用**:改造成本最低,适合已有 `@CacheEvict` 代码的项目直接接归一化红利。 ```java // 新增:影响列表 → 手写模糊清除该用户所有列表 &userId=..&* @CacheEvict(key = "'&userId='+#bank.userId+'&*'") public UserBank add(UserBank bank) { return repo.save(bank); } // 改非关键列:只刷主键缓存,列表靠自愈 @CachePut(key = "#bank.id") public UserBank updatePlain(UserBank bank) { return repo.save(bank); } // 改关键列 status:刷主键 + 手写模糊清列表 @Caching( put = @CachePut(key = "#bank.id"), evict = @CacheEvict(key = "'&userId='+#bank.userId+'&*'")) public UserBank updateStatus(UserBank bank) { return repo.save(bank); } ``` **缺点**:失效 key 手拼易错、值含分隔符会污染、维护时得人肉记「改哪列清哪个 key」。 --- ### 方案 ②:代码 —— 注入 `CacheInvalidator`(免手拼字符串) **原理**:注入 `CacheInvalidator`,用语义化方法代替手写 SpEL;失效 key 由库内部按同一 codec 拼装,**读写天然对称**。 **适用**:失效点在非 AOP 位置(如批处理、事务回调、外部触发),或想要显式可控但不想手拼字符串。 ```java @Service public class UserBankAutoKeyService { private static final String CACHE = "UserBankV2"; private final CacheInvalidator invalidator; // 注入即可 // 新增:按维度模糊失效该用户列表(内部自动拼 &userId=v&*) public UserBank add(UserBank bank) { UserBank saved = repo.save(bank); invalidator.evictByFields(CACHE, Map.of("userId", saved.getUserId())); return saved; } // 改非关键列:只失效实体主键,列表靠自愈 public UserBank updatePlain(UserBank bank) { UserBank saved = repo.save(bank); invalidator.evictEntity(CACHE, saved.getId()); return saved; } // 改关键列:失效主键 + 按维度模糊失效列表 public UserBank updateStatus(UserBank bank) { UserBank saved = repo.save(bank); invalidator.evictEntity(CACHE, saved.getId()); invalidator.evictByFields(CACHE, Map.of("userId", saved.getUserId())); return saved; } } ``` `CacheInvalidator` 全部方法: | 方法 | 语义 | |---|---| | `evict(cache, key)` | 精确失效一个 key | | `evictEntity(cache, id)` | 失效实体主键缓存(UPDATE/DELETE 常用,列表自愈) | | `evictByFields(cache, {f:v,...})` | 按维度模糊失效,等价 `&f=v&*`(INSERT / 过滤列变更) | | `evictByPattern(cache, "&userId=1&*")` | 按已构建的模糊 pattern 失效 | | `clear(cache)` | 清空整个缓存空间 | | `handle(CacheChangeEvent)` | 传入一条变更事件,按 `Op` 自动路由到以上操作(见方案 ④) | **缺点**:仍需**显式**决定「何时清、清哪个维度」。把这一步也自动化的是方案 ③。 --- ### 方案 ③:全自动 —— `@CacheEvictAuto`(写方法零失效代码) **原理**(read-before-write,无需 Canal): 1. **启动扫描**:`CacheDimensionScanner` 反射所有 `@Cacheable`+`@KeyField`,把「表 `user_bank` → 被哪些缓存、按哪些列缓存」登记进 `CacheDimensionRegistry`; 2. **拦截取旧值**:写方法加 `@CacheEvictAuto`,拦截器在**执行前**经你提供的 `OldValueLoader` 取旧行,执行后拿到新值; 3. **大脑 diff**:`RowChangeInvalidator` **只 diff 注册表登记的关键列** —— 关键列(userId)变了就清新旧两个维度;只改了非关键列(status/realName)则仅失效实体主键,列表靠自愈。 于是业务侧**不必再记「改哪列清哪个 key」**;新增查询维度只需在读方法加 `@KeyField`,失效自动跟上。 ```java @Service public class UserBankAutoEvictService { @Cacheable(cacheNames = "UserBankV3", keyGenerator = "plusKeyGenerator") public List getMyBanks(@KeyField("userId") Long userId) { ... } // 一个注解搞定:无主键→INSERT(按维度失效列表);有主键→UPDATE(diff 关键列)。方法体内零 evict。 @CacheEvictAuto public UserBank save(UserBank bank) { return repo.save(bank); } // 删除显式声明;实体主键失效后,引用它的列表读时自愈。 @CacheEvictAuto(op = CacheEvictAuto.Op.DELETE) public void remove(UserBank bank) { repo.deleteById(bank.getId()); } } ``` **激活条件(关键)**:必须提供一个 `OldValueLoader` bean,starter 才会织入 `@CacheEvictAuto` 拦截器(自动配置对它是 `@ConditionalOnBean(OldValueLoader.class)`)。它返回变更**之前**的行(至少含各维度关键列): ```java @Component public class DemoOldValueLoader implements OldValueLoader { @Override public Map loadOld(String table, Object id) { UserBank old = repo.findById(Long.valueOf(String.valueOf(id))); if (old == null) return null; // 取不到 → 交由 MissPolicy 兜底 return Map.of("userId", old.getUserId(), "status", old.getStatus()); } } ``` > 真实项目推荐 `OldValueLoader` 走「缓存命中 → miss 回源 DB」。取不到旧值时按 `redis.cache.plus.invalidation.miss-policy` 兜底:`safe-clear`(默认,清空该缓存空间保安全)或 `skip`(跳过,有脏读风险)。 --- ### 方案 ④:基于 MQ / Canal —— 事件驱动失效 **原理**:`@CacheEvictAuto` 只能拦到**走本应用**的写;跨服务写、手写 SQL、批量刷库它拦不到。终极方案是订阅 **DB 变更流**: - **Canal / binlog**:UPDATE 的 binlog **天然带 before + after 整行镜像**,`before` 永不缺失,零侵入、能捕获任何来源的写 —— 最推荐; - **MQ**:业务在写库后发一条变更消息,消费者转成事件。 有两个入口,按你能拿到的数据丰富度选: #### 4a. 只有「变更了哪些字段」→ 用 `CacheInvalidator.handle(CacheChangeEvent)` 事件里带 `op`/`id`/维度字段,库内置路由(UPDATE/DELETE→失效实体主键;INSERT→按维度模糊失效): ```java @Autowired CacheInvalidator invalidator; // MQ / Canal 消费者把一行变更转成 CacheChangeEvent 即可 invalidator.handle(CacheChangeEvent.update("UserBank", row.getId(), Map.of("userId", row.getUserId()))); invalidator.handle(CacheChangeEvent.insert("UserBank", Map.of("userId", row.getUserId()))); invalidator.handle(CacheChangeEvent.delete("UserBank", row.getId(), null)); ``` #### 4b. 有「变更前后整行镜像」→ 用 `RowChangeInvalidator.handle(RowChange)`(推荐配 Canal) 带 before/after 的 `RowChange` 交给注册表驱动的「大脑」,它**只 diff 关键列**、**新旧维度都清**(避免旧分组列表漏清脏读),精度最高: ```java @Autowired RowChangeInvalidator rowInvalidator; // 需存在 CacheDimensionRegistry(扫描开启时自动装配) // Canal 适配器:把一条 binlog 转成 RowChange rowInvalidator.handle(RowChange.update( "user_bank", id, before, // 变更前整行(binlog 天然带,before 永不缺失) after)); // 变更后整行 rowInvalidator.handle(RowChange.insert("user_bank", after)); rowInvalidator.handle(RowChange.delete("user_bank", id, before)); ``` > `RowChangeInvalidator` 在开启 `redis.cache.plus.invalidation.scan`(默认开)、存在 `CacheDimensionRegistry` 时自动装配。它与方案 ③ 共用同一个「大脑」—— 区别只是事件来源:③ 从 AOP 拦截取 before,④ 从 binlog 拿 before。下游只认 before/after + 注册表,不关心事件从哪来。 --- ### 四种方案怎么选 | 方案 | 失效代码量 | 能拦到的写 | 何时用 | |---|---|---|---| | ① `@CacheEvict` | 多(手拼字符串) | 走本应用的写 | 已有注解代码,最小改造接归一化 | | ② `CacheInvalidator` | 中(语义方法) | 走本应用的写 | 非 AOP 位置、想显式可控 | | ③ `@CacheEvictAuto` | **零** | 走本应用的写 | 单体应用、追求业务零样板 | | ④ MQ / Canal | 零(写在适配器一处) | **任何来源**(含跨服务/手写 SQL) | 多写入源、要求最强一致 | --- ## 五、二级索引:清理机制 + 两种插件对比 模糊失效(机制②)靠一份**二级索引**(字段 → key 的反查)。它和缓存本体是 Redis 里**两个独立的 key,且索引本身不带 TTL** —— 所以缓存过期后,索引里的对应项需要被主动清掉,否则会残留"指向已不存在 key"的死记录。 ### 5.1 清理:主监控 + 兜底(两层) | 层 | 谁 | 怎么清 | 代价 | |---|---|---|---| | **主:事件驱动** | `KeyspaceNotificationIndexCleaner` 订阅 `__keyevent@*__:expired` | 缓存一过期就 `onExpire`,**O(1) 精确**删对应索引项 | 需 Redis 开 `notify-keyspace-events Ex` | | **兜底:低频全扫** | `CacheKeyIndex.sweep()`,默认每 30 分钟 | 扫一遍索引,剔除"指向已不存在 key"的死项 | **O(n)** 全库扫描 | - **为什么要兜底**:keyspace 通知是 **best-effort** —— 没订阅者的瞬间、断连、重启窗口期的事件会丢;丢了主监控就漏清,靠 `sweep()` 最终补上。 - 两层都归 `redis.cache.plus.cleaner-enabled` **一个开关**管,一起开一起关。 - **不开 `Ex` 会怎样**:不会出错、不会爆库(有 `sweep()` 兜底),但退化成"每 30 分钟一次 O(n) 全库扫",期间索引常态虚高。**若连 `cleaner-enabled` 也关**,则索引无 TTL 又无人清 → 无限膨胀、真泄漏。 > **关键前提:脏索引不影响正确性。** 二级索引**只用于失效(找出要删哪些 key)、从不用于读数据**。一条指向已过期 key 的死记录,最坏后果只是失效时对一个早已不存在的 key 发一次无害 `DEL`,**绝不会返回脏数据**。所以"多久清一次"纯是空间/扫描开销的权衡,不是对错问题 —— 这就是下面"每天清"方案成立的基础。 ### 5.2 清理策略选型(含"每天凌晨清") `sweep()` 用 `SCAN` 游标增量遍历(**非阻塞、不用 `KEYS`**),但整轮仍是 O(n) 量级,把它放在业务低峰跑、而非营业时间反复跑,往往更划算。按你的**日过期量**(≈ 一天内过期的 key 总数,不是存量)选: | 策略 | 配置 | 适合 | 代价 | |---|---|---|---| | **实时 + 30 分钟兜底**(默认) | `cleaner-enabled=true` + 开 `Ex` | 高频短 TTL、要求索引始终贴近真实 | 营业时间每 30 分钟一次 O(n) 扫 | | **✅ 推荐:只每天凌晨清** | `cleaner-enabled=false` + 自建 `@Scheduled` 每天调 `sweep()` | **中低频过期**(每天过期几千~几万),多数业务 | 白天索引虚高(有界,≈ 一天过期量),凌晨一次 O(n) 扫 | | **实时 + 每天兜底** | 覆盖 `indexCleaner` bean 传 `backstopSweepSeconds=0` 关内置兜底,保留 `Ex`,另加每天 `sweep()` | 想要实时清、又不想营业时间跑全扫 | 需自定义 bean | **推荐方案(多数业务)——只每天凌晨清:** ```properties redis.cache.plus.cleaner-enabled=false # 关掉内置 Ex 监听 + 30 分钟兜底(可连 notify-keyspace-events Ex 都不必开) ``` ```java @Component public class DailyIndexSweep { private final CacheKeyIndex index; // Set / RediSearch 实现都已带 sweep() DailyIndexSweep(CacheKeyIndex index) { this.index = index; } @Scheduled(cron = "0 0 3 * * *") // 每天凌晨 3 点低峰 public void sweep() { index.sweep(); } } ``` > 先估算你的日过期量:若某缓存 TTL 短、写入猛(每天过期百万级),白天索引会显著虚高,这类缓存建议留默认的实时+30 分钟;否则"每天清"最省心。 ### 5.3 两种索引插件对比(Set vs RediSearch) 先给结论:**就本库用途(按字段组合做模糊失效)而言,两者功能几乎等价**;真正的差别在**依赖、写入开销、分页和规模上限**。 #### 功能差异 | 维度 | **Redis Set**(默认) | **RediSearch**(可选) | |---|---|---| | 索引结构 | 每个 `field=value` 一个 Set,成员是真实 key 名(`rcp:idx:{cache}:userId=1`);另有 `rcp:all:{cache}` 支持 `&*` 全清 | 每个 key 一份 hash 影子文档(`rcp:ft:...`)+ 每 cache 一个 `FT` 索引 | | 反查方式 | 多字段 Set 求交集 `SINTER` | 倒排检索 `FT.SEARCH @field:{...}` | | 查询模型 | 只支持「字段=值 AND 字段=值」等值取交 | 倒排索引;**当前插件同样只用等值 TAG 取交**,但引擎可扩展到范围/前缀/全文/OR/排序/聚合 | | **本库能力(模糊失效 `&userId=1&*`)** | ✅ 完全够用 | ✅ 完全够用 —— **与 Set 一模一样** | | 扩展天花板 | 到顶(只能等值 AND) | 高得多(改 schema 可上范围/全文/聚合,但已超出"缓存失效"范畴) | | 模块依赖 | 无,**任何 Redis 可用**(含最小化/托管实例) | **必须加载 search 模块**(redis-stack 或自装),很多托管 Redis 没有 | | 集群 | `{cacheName}` hash-tag 同槽,`SINTER` 可用(已适配、未实测) | 当前插件**仅 standalone** | #### 性能差异 | 维度 | **Redis Set** | **RediSearch** | |---|---|---| | 写入(建索引) | **N+1 次 `SADD`**(N 字段 + 全量集),逐个下发 → N+1 次 RTT | **1 次 `HSET`** → 1 次 RTT(模块内部再异步建倒排) | | 精确删除 | N+1 次 `SREM` | 1 次 `DEL` | | 模糊反查 | `SINTER` 取交,小结果集(一个用户几页列表)**很快**;字段基数极大、集合很大时更吃力 | 倒排检索,**大基数/复杂查询更抗压** | | 分页 | 应用侧 `TreeSet` 排序截取(每翻页取全量) | **`FT.SEARCH LIMIT` 服务端原生分页** | | 内存 | key 字符串在 N+1 个集合各存一份,重复度高;**小规模更轻** | 影子 hash + 倒排结构,模块有基础开销;**大规模每单位查询更省** | | 兜底 `sweep()` | ✅ `SCAN`(非阻塞)+ `SMEMBERS` + 逐 `EXISTS` | ✅ `FT._LIST` + 分页 `FT.SEARCH` + 逐 `EXISTS` | | 成熟度 | **已充分测试,默认** | **standalone 已用真实 redis-stack 实测通过**(含 sweep);集群未适配 | > **读法**:写路径 RediSearch 更省(1 次 vs N+1 次 RTT),高写入场景差距累积;读路径(模糊失效)在典型规模下两者都亚毫秒,只有字段基数极大时 RediSearch 倒排才明显更抗压;内存小规模 Set 更轻、大规模 RediSearch 更省。 #### 怎么选 | 场景 | 选谁 | |---|---| | 绝大多数(参数化列表的模糊失效) | **Set** —— 零依赖、任何 Redis 可用、已充分测试、够用 | | 已在跑 redis-stack + 写入极猛 / 字段基数极大 / 想要原生分页 | **RediSearch** | | 想把索引扩成范围查 / 全文 / 聚合 | RediSearch(但已不只是"缓存失效"了) | | 只用主键缓存、不需要按字段清列表 | `none`(`NoOpCacheKeyIndex`),**完全关闭模糊失效** | 一句话:**功能上就本库用法两者等价;性能上 RediSearch 写更省、大规模更抗压,Set 零依赖更轻更通用 —— 所以默认 Set,撞到规模/基数瓶颈或已有 redis-stack 时才换 RediSearch。** --- ## 六、模块与依赖方向 严格单向:`starter → 索引插件 → core`;`core` 只依赖官方 SDR。**可更换插件的边界 = `CacheKeyIndex` SPI**。 | 模块 | 作用 | |---|---| | `redis-cache-plus-core` | 归一化、`CacheKeyIndex` SPI、codec、Writer/Cache/Manager 扩展、事件清理、失效引擎 | | `redis-cache-plus-index-set` | 默认索引插件:原生 Redis Set(`SINTER`),无模块依赖 | | `redis-cache-plus-index-redisearch` | 可选索引插件:RediSearch(`FT.SEARCH`),需模块 | | `redis-cache-plus-spring-boot-starter` | 自动装配,按配置选插件,缺失优雅降级 | | `redis-cache-plus-demo` | 可运行示例:UserBank 三种写法(手动 / 自动 key / 全自动清理),`DemoCacheVariantsTest` 端到端验证 | ## 七、配置项(前缀 `redis.cache.plus`) ```properties redis.cache.plus.index=set # set(默认) | redisearch | none redis.cache.plus.cleaner-enabled=true # 事件驱动索引清理 redis.cache.plus.invalidation.enabled=true # 注册表驱动自动失效 redis.cache.plus.invalidation.scan=true # 启动反射扫描 @Cacheable/@KeyField redis.cache.plus.invalidation.miss-policy=safe-clear # safe-clear(默认) | skip ``` 所有 bean 均 `@ConditionalOnMissingBean`,任意 bean 可被应用覆盖。配 `redisearch` 但无对应 bean 时会 warn 并降级为 Set。 ## 八、测试与支持范围 - **单机 Redis**:已通过内嵌 Redis 集成测试(归一化 / 自愈 / 模糊失效 / 分页)。 - **两个索引插件均已真机实测**(standalone redis-stack,全绿): - **Set 索引**(`RedisSetCacheKeyIndexTest`,5 例):存 / 单字段 / 多字段 `SINTER` 反查 / 精确删 / `onExpire` / `sweep`; - **RediSearch 索引**(`RediSearchCacheKeyIndexTest`,4 例):存 / 单/多字段反查 / 精确删 / `sweep`; - **过期检测链路**(`KeyspaceNotificationIndexCleanerTest`,端到端):开 `Ex` → 短 TTL key 过期 → 断言事件驱动清掉索引项。 - 三者都支持 `-Drcp.redis.host/port/password` 连外部实例,否则用 Testcontainers(`redis:7` / `redis-stack-server`),都没有则跳过。 - **Redis 分片集群(多 slot)**:核心已适配(`mSetNx` 逐 key 写、读重组遍历、Set 索引 `{cacheName}` 同槽)但**尚未实测**;**RediSearch 插件当前仅 standalone**。生产用集群前请先按 `docs/test-plan.md` B1 验证。 - **生产命令合规**:模糊失效走二级索引(`SINTER` / `FT.SEARCH`)**不扫 keyspace**;唯一的兜底 `sweep()` 用 `SCAN`(非阻塞),**不用被托管 Redis 常禁用的 `KEYS`**。 ## 九、构建 ```bash mvn clean test # 全模块(Boot 4.1.0 / JDK 21) mvn -pl redis-cache-plus-core test # 核心契约单测(快,无需 Redis) mvn verify # 全量(含集成测试,无 Docker 自动跳过) ```