← 返回首页BIGLV666 · 2026

三级缓存组件设计:实体元数据驱动的 Caffeine → Redis → DB

Caffeine→Redis→DB read-through、binlog 直连失效、single-flight 防击穿——对 MyBatis-Plus 用户零注解接入。

实体元数据驱动的三级缓存组件:Caffeine(L1)→ Redis(L2)→ DB(loader) read-through。 对 MyBatis-Plus 用户零注解接入;binlog 直连失效补齐"绕过应用的写"盲区; 一致性语义为最终一致——常态秒级(双删窗口内),最坏受 L2 TTL 上界约束(见"一致性机制")。

特性

快速开始

引入依赖

<dependency>
    <groupId>io.github.biglv666</groupId>
    <artifactId>cache-kit-spring-boot-starter</artifactId>
    <version>0.3.0</version>
</dependency>

要求 JDK 17+、Spring Boot 3.5.x。L2(Redis)与 MyBatis-Plus 适配按类路径自动启用。

MyBatis-Plus 项目(零注解)

@TableName("t_user")
public class User {
    @TableId
    private Long userId;
    private String userName;
}

userMapper.selectById(1L);    // 自动走三级缓存
userMapper.selectBatchIds(List.of(1L, 2L, 3L));  // per-ID 拆解:命中直接用,缺失才回源
userMapper.updateById(user);  // 自动失效 + 广播 + 延迟双删

批量语义:缓存键与单条查询完全相同(表名:主键),逐 ID 三态(命中值 / 已缓存空 / 未命中); "已确认不存在"缓存 null 占位(短 TTL),IN 查询里自动消失;结果按请求顺序输出、跳过不存在的 ID。

写覆盖范围(自动失效,无需注解): - updateById / deleteById / deleteByIds / insert:经 mapper 代理,切面直接拦截 - IService.saveBatch / updateBatchById / saveOrUpdateBatch(0.3.0+):这些方法在 MP 内部经 SqlSession 批量语句执行、绕过 mapper 代理,由独立的 service 切面覆盖(切面会给 IService 实现加 AOP 代理,Boot 默认 CGLIB) - 条件写(update(Wrapper) / delete(Wrapper) 等不带主键值的写)无法精确失效——开启 binlog 可覆盖,否则靠 TTL 兜底

非 MP 项目(实体注解)

@CacheEntity(ttl = 3600)
public class User {
    @CacheId
    private Long userId;
    private String userName;
}

@Mapper
public interface UserMapper {
    @CachedQuery
    User selectByUserId(@Param("userId") Long userId);

    @CacheInvalidate(entity = User.class)
    int updateStatus(@Param("userId") Long userId, @Param("status") int status);
}

主键推导(严格模式):① 参数中有实体实例(updateById(User))→ ② 标量参数且参数名与主键字段同名 (匹配 MyBatis @Param 值或 Java 参数名,需 -parameters 编译,Boot 父 POM 默认开启)。 两条路都走不通(如条件字段查询 selectByPhone(String phone))会打警告并直查 DB—— 猜测式推导会把条件值当主键回填 表:<条件值>,造成键空间混淆且行更新时失效链路断裂,因此被代码强制拒绝。

注入句柄(手动控制)

@CacheHandle(User.class)
private EntityCache<User> userCache;

User u = userCache.get(id, () -> mapper.selectByUserId(id));
userCache.evict(id);

binlog 直连失效(0.2.0+,覆盖"绕过应用的写")

广播失效的前提是写走应用路径。DBA 改库、其他服务写同一张表时,cache-kit 无法感知—— binlog 直连失效模块补上这个盲区:以 MySQL replica 协议直连 binlog(ROW 格式), 任何来源对已缓存实体表的写入都会按行提取主键并触发失效。

不需要部署 Canal Server:类路径引入 mysql-binlog-connector-java(本 starter 中声明为 optional, 仅启用 binlog 的服务需要自行添加该依赖)+ 开启开关即可。MySQL 需开启 log_bin、 binlog_format=ROW、binlog_row_image=FULL(MINIMAL 会让行镜像缺主键列,按行失效静默丢失), 且账号具备 REPLICATION SLAVE 权限。

cache-kit:
  binlog:
    enabled: true        # 缺省关闭
    # host/port/database 缺省从 spring.datasource.url 解析,账号缺省用数据源账号
    # server-id 缺省自动生成随机值;同一 MySQL 上多副本部署建议显式配置
    server-id: 18365     # 同一 MySQL 上必须唯一(与 MySQL server-id 及其他副本不同)

注意:MySQL 8.4 移除了 SHOW MASTER STATUS,需 connector 0.30.0+(本 starter 默认 0.31.0)。

实测:直写 → 失效 → 下次读到新值,端到端传播延迟 avg 10.3ms / p99 14.2ms / max 53.7ms(300 次直写零漏失效,含 5ms 轮询测量粒度)。

强一致读(豁免)

缓存是最终一致的。需要强一致的场景按档位选用:

档位 用法 适用
自定义方法不加注解 selectByIdFromDb(Long id) 已知某方法永远要 DB
ThreadLocal 旁路 CacheKit.withDb(() -> mapper.selectById(id)) 调用点临时决定
SpEL 条件 @CachedQuery(condition = "!forceDb") 由调用方传参决定

库存扣减、余额等强一致判断所依赖的字段不要进缓存——任何缓存方案(双删、binlog 均不例外)都是最终一致。

配置全参考(前缀 cache-kit.*)

cache-kit:
  enabled: true            # 总开关
  key-namespace:           # 键命名空间:未配置时自动取 spring.application.name(多服务共享 Redis 必要隔离)
  require-key-namespace: false  # true 时 key-namespace 与 spring.application.name 均为空 → 启动失败(键冲突零容忍场景)
  l1:                      # 本地缓存(Caffeine,当前版本不支持替换实现)
    max-entries: 65536     # 最大条目数(条目数上限,不限制单条大小)
    max-weight-kb: 0       # 权重上限(K 字符):>0 时取代 max-entries,防大实体撑爆堆内存。
                           # 实际内存上限 ≈ max-weight-kb × 2KB(BMP 字符 UTF-16 双字节,中文场景);
                           # 建议生产环境启用,例如 65536 ≈ 128MB 上限。单条 JSON 按长度/1024 计权重,最小 1
    ttl: 30s               # L1 TTL:必须显著小于 l2.ttl,是 pub/sub 丢消息时的脏读上界
  l2:                      # 远程缓存(Redis,类路径有 spring-data-redis 且存在 RedisConnectionFactory 时启用;
                           # 只有类没有工厂 Bean 时自动降级为 L1-only,不影响启动)
    ttl: 10m               # L2 TTL 基准;非正值(0/负数)= 跳过 L2 写入(等效禁用 Redis 缓存,启动打警告),
                           # 不是"永不过期"——组件的脏数据安全模型依赖 TTL 上界
    jitter: 60s            # TTL 随机抖动上限(防雪崩),0 关闭;基准 TTL 非正时不叠加抖动
    null-ttl: 30s          # null 占位的短 TTL(防穿透);非正值 = 不缓存 null 占位(关闭穿透防护)
    double-delete-delay: 1s  # 延迟双删间隔;写极热键时可调小或评估回源放大
  broadcast:               # 失效广播(多实例部署必须开启)
    enabled: true
    topic: cache-kit:invalidate
  mp:                      # MyBatis-Plus 适配
    auto-cache-base-methods: true  # 自动接入总开关:BaseMapper 六方法 + IService 批量写
                                   # (saveBatch/updateBatchById/saveOrUpdateBatch);条件写不在范围
  tx:                     # 事务感知失效(spring-tx 在类路径时生效)
    evict-after-commit: true       # 写方法处于活动事务时,失效延迟到 afterCommit;回滚不失效。
                                   # 关闭后若事务耗时超过 double-delete-delay,脏值存活至 L2 TTL(长事务勿关)
  binlog:                  # binlog 直连失效(0.2.0+,缺省关闭)
    enabled: false
    host:                  # 缺省从 spring.datasource.url 解析
    port:
    database:
    username:              # 缺省用数据源账号
    password:
    server-id:             # 缺省自动生成随机值;同一 MySQL 上必须唯一

一致性机制与性能实测

写路径固定先写 DB 后删缓存(Cache-Aside 标准序)。删除动作 = DEL L2 → 本地 L1 → pub/sub 广播 → 延迟双删。 顺序关键:必须先删 L2——若先删 L1,间隙内并发读会 L1 miss 后从 L2 读到旧值并回填 L1,制造可复现脏数据。

脏数据的核心窗口是回填竞态(读线程查到旧值、写线程删除后、读线程把旧值回填进 L1/L2)。 延迟双删压缩该窗口;窗口之外(慢读/长 GC 跨过双删间隔、双删积压丢弃、Redis 宕机期间失效丢失) 旧值可能复活在 L2 并持续回填各节点 L1,此类场景的真实上界是 L2 TTL(默认 10m + 抖动), 而非秒级——这是 Cache-Aside 的固有竞态,本组件未用版本号/CAS 消除它,依赖 TTL 上界兜底。 对一致性敏感的字段请用 CacheKit.withDb 旁路或直接不进缓存。

以下为 PaperWise 宿主项目上的实测数据(单机 4~10 实例 + Docker Redis/MySQL,Java 17):

场景 实测
缓存命中读 微秒级;20,249 个 HTTP 请求期间 MySQL InnoDB 行读取零增长
应用内写失效 每写增加约 1~2ms(DEL L2 + 广播 + 双删调度);4.6 万次广播零丢失
广播最终一致性 10 实例、716 写/s 并发、4.6 万次广播:全实例最终版本校验 100% 通过
脏读窗口(应用内写) 最大 119ms~505ms(含单 JVM 调度噪声),远小于 L1 TTL 30s 设计上界
脏读窗口(binlog 直写) p99 14.2ms,300 次直写零漏失效
外部 HTTP 全链路 单实例饱和 ~1700 req/s;延迟基线由鉴权 Redis 往返主导,缓存命中本身微秒级
回源放大 热键上每次写约 3 次 DB 回源(广播删 + 双删删 + 在途回读),写极热键需评估
跨实例击穿放大(0.3.0 量化) single-flight 仅单 JVM 生效:N 实例同时击穿同一未热键 = N 次 DB 回源(每实例 1 次);
冷启动场景 N×键数 即回源峰值,DB 按 N × 每秒新增键数 估算容量;若需全局互斥可外挂分布式锁,组件刻意未内置(锁的开销与死锁面 > 冷启动回源放大)

适用边界

可观测性(Micrometer,自动装配)

宿主类路径有 micrometer-core(spring-boot-starter-actuator 自带)时自动注册 cache-kit.* 计数器, 无需任何配置;也可自行实现 CacheMetricsListener Bean 接管。

指标 含义
cache-kit.l1.requests{result=hit\|miss} L1 命中率
cache-kit.l2.requests{result=hit\|miss} L2 命中率
cache-kit.db.loads DB 回源(批量按 ID 数计)
cache-kit.null.placeholders null 占位写入(穿透防护触发)
cache-kit.evict.keys 失效键数(含双删第二次)
cache-kit.broadcast.sent / received{applied=true\|false} 广播收发——sent 持续大于 received 说明存在广播丢失(L1 TTL 兜底)

稳健性设计

FAQ

Mapper XML 打包后报 Invalid bound statement? 这是 MyBatis-Plus 路径大小写问题,与本组件无关但高频踩坑:XML 放在 resources/Mapper/(大写)时,classpath*:/mapper/*.xml 在 IDE 目录模式能碰巧匹配(NTFS 大小写不敏感),打 jar 后必失败。显式配置 mybatis-plus.mapper-locations: classpath*:/Mapper/*.xml。

binlog 连接失败? 依次检查:MySQL 是否开启 log_bin 且 binlog_format=ROW;binlog_row_image 是否为 FULL(MINIMAL 时行镜像缺主键列,按行失效会静默丢失);账号是否有 REPLICATION SLAVE 权限;server-id 是否与 MySQL 及其他副本冲突;MySQL 8.4 需要 connector 0.30.0+。

多 Redis 实例部署时广播失效? 广播 topic 活在单个 Redis 上,所有实例必须订阅同一个 Redis。用 Redis Cluster 分片扛 L2 流量时,失效 topic 需收敛在专用节点或使用 Redis 7 sharded pub/sub。

宿主没有 AOP 自动代理(切面不生效)? 正常 Spring Boot 应用默认启用。若用 ApplicationContextRunner 手动装配测试,记得加 AopAutoConfiguration。