前言

单机部署时,给一个查询接口加上 Redis 就够用了:查不到就读库,读完写回缓存,更新时删掉缓存。同样的代码拆成多个实例之后,事情开始变形,本地锁挡不住另一个进程,删掉的缓存可能被一个更早发出的读请求写回来,Redis 一旦变慢又会把压力成倍转给数据库。

这篇文章沿着”一次读请求”和”一次配置更新”两条主线,把一个可以实际运行的项目完整拆开,讲清楚缓存的每个设计决定在解决什么问题、付出了什么代价、边界在哪里。

核心结论

  • 缓存是可重建的副本:数据的角色决定故障策略;配置查询可以降级,安全状态必须回查事实源。
  • 读链路四道关卡:缓存命中 → 布隆三态 → 租约重建与二次检查 → 发布校验。
  • 三类问题三种防御:空值与布隆挡穿透,同键锁挡击穿,抖动、熔断与回源上限挡雪崩。
  • 旧快照用代际协议关闭:“读前取号、发布验号”,失效时原子地推进代际并删除缓存。
  • 失效可靠性交给 Outbox:至少一次执行,副作用必须可重复。

实验边界

  • 同一个 Spring Boot 应用启动两个 JVM,共享一个 Redis 实例和一份 H2 文件数据库,用来观察真正的跨进程竞争;
  • 外部平台调用用同一库中的 provider_delivery 表模拟,项目不发起真实 HTTP 请求;
  • 没有网关、注册中心、JWT 验签与完整权限系统,相关位置会明确说明哪些是协议、哪些是生产实现。

项目规模不大,但它让”共享状态出了问题会怎样”变得可观察、可复现,这正是学习缓存一致性最缺的东西。

项目中的共享状态与业务事实

缓存替我们承担了什么

从一条配置查询说起

系统里的业务对象是”某个租户下的应用配置”。demo 租户有一个编号为 1001 的应用,名称是”微信授权应用”;school 租户也可以有一个 1001,名称是”校边店应用”。两条记录编号相同、业务身份不同,因此一个对象的完整标识是 (tenant, appId)。这一点会一直影响到键的设计(3.1 节)。

查询接口是:

1
GET /api/tenants/{tenant}/apps/{id}

如果每次调用都直接查数据库,功能完全正确,代价是数据库承担了全部重复读:同一个热门应用,被不同租户下的不同用户反复打开,被多个实例的请求线程同时查询。把查询结果放进 Redis 之后,这些实例共享同一份副本,命中时只发生一次网络往返,数据库的压力从”请求量”降到”未命中量”。

缓存的本质是一份可重建的副本

在写任何代码之前,先回答两个问题,它们决定了后面所有的策略:

  1. 这份数据能不能从事实源重新得到? 能重建,缓存失效就可以降级到数据库;不能重建,它就不该只放在 Redis 里。
  2. 事实源与副本之间允许多大的偏差? 这决定了失效协议要做到多强:秒级可接受,和”必须读到刚刚写入的值”,是完全不同的两套设计。

按这个标准,可以把系统中的数据分成几类:

数据角色 例子 事实源 丢失或不可用时的策略
业务缓存 应用配置 JSON app_config 表 回源查询数据库,重建副本
协调元数据 布隆位图、失效代际、重建锁 数据库目录 + 构建流程 收紧语义:过滤器未就绪就不拦截;代际用持久化保护
安全状态 Token 吊销黑名单 token_session 表 保守处理:未命中黑名单仍然查库,查不到就拒绝
补偿任务 Outbox 事件 redis_outbox 表 数据库保留意图,Worker 恢复后继续执行

这张表解释了一个常见困惑:为什么缓存挂掉时配置查询可以继续服务,授权检查却返回 503。前者手里有可重建的副本,最坏结果是变慢;后者如果在”不知道是否被吊销”时放行,就是把安全判断交给了一次网络失败。故障策略应当由数据的角色决定。

微服务把线程问题变成了协议问题

单个 JVM 里,synchronized 能解决同步问题;两个 JVM 之间,实例 A 持有的锁对实例 B 没有任何约束。更麻烦的是,多个实例共享的不只是数据,还有一整套约定:

  • 键怎么写(命名空间、业务身份、格式版本);
  • 值怎么序列化(JSON 结构、空值标记);
  • 失败怎么表达(哪些异常可以降级,哪些必须上抛);
  • 失效怎么传递(谁负责推进代际,谁负责重试)。

这些约定构成了缓存的协议,才能在微服务环境下正确的传播。协议不统一时,代码看起来都在读写 Redis,实际却在各说各的:A 写入的 JSON 版本,B 解析不了;A 认为已经失效的键,B 还在往里回填。后文的每一个设计,租约、代际、三态布隆、Outbox 等,本质上都是在补全这份协议。

拆成多个服务之后,这份协议还要跨团队维护,键的命名、格式版本、失效语义要写进文档与测试,成为团队共享的约定

一条读请求的全链路

一次缓存未命中的读请求,会依次经过下面这些阶段:

阶段 动作 关键实现 这一层失败时
0 入口校验 校验租户格式与 ID 范围 KeySpace.tenant 400,不触达任何存储
1 读缓存 GET 缓存键 经 CacheRedis 熔断包装 熔断打开 → 降级到有限回源
2 命中判定 校验值里的租户与 ID decode 内容不一致或格式坏 → 按”缓存不可用”处理
3 布隆过滤 三态查询:明确不存在 / 可能存在 / 不知道 bloom-check.lua 原子执行 只有”明确不存在”才返回 404
4 抢重建锁 SET NX PX 3000 RedisLease 抢不到 → 短暂等待后重读缓存
5 二次检查 拿到锁后再读一次缓存 复用别人的结果 读到值就直接返回,不再查库
6 回源 查数据库 信号量限制并发(默认 8) 并发已满 → 503
7 发布 校验锁所有者与代际后写缓存 cache-publish.lua 校验失败 → 不写缓存,仍返回查到的结果
8 释放锁 比较所有者后删除 unlock.lua 失败只记指标,不覆盖已完成的业务结果
缓存读取、重建和降级路径

这条链路有三条设计原则:

  1. 每一层只承担一种失败语义。

    缓存层回答”副本能不能用”,布隆层回答”对象是否可能存在”,数据库层回答”事实是什么”。把它们混在一起,降级策略就没法写。

  2. 每层失效时,先算下一层会收到多少请求。

    缓存失效可能是几倍流量,布隆失效再加一批,锁失效再放大一批。设计时要回答的核心问题是:这一层倒下时,数据库能接住多少。

  3. 保护要落在真正的资源端。

    锁在 Redis,并发保护在数据库入口的信号量;缓存可以被删掉,数据库约束和接口的幂等记录才是最后一道防线。

整条链路的关键参数如下,后文会逐一解释它们的来由:

参数 默认值 回答的问题
正缓存 TTL 60 秒 + 0~15 秒抖动 副本最多可以陈旧多久
空值缓存 TTL 5 秒 不存在的结果缓存多久,兼顾新建对象可见性
重建锁租期 3000 毫秒 孤儿锁最多存活多久
等待重建预算 600 毫秒 没抢到锁的请求愿意等多久
熔断冷却 2000 毫秒 已知 Redis 故障时,多久允许探测一次
回源并发 8(每实例) 每个实例最多同时打几个数据库查询
数据库连接池 12,获取超时 1 秒 数据库侧的连接容量与等待上限

缓存协议的基础:Key、序列化与 TTL

Key 是跨服务协议

多个实例共享缓存,首先要统一的是键。示例项目把键集中放在 KeySpace 中,业务代码不直接拼接字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public String app(String tenant, long id) {
// prefix 来自 lab.namespace;完整业务身份包含租户和应用编号。
return prefix + "app:{" + tenant(tenant) + ":" + id + "}";
}
public String cache(String tenant, long id) {
return app(tenant, id) + ":cache:v1"; // v1 是缓存格式版本
}
public String generation(String tenant, long id) {
return app(tenant, id) + ":generation"; // 失效代际,与数据库 version 不同
}
public String rebuildLock(String tenant, long id) {
return app(tenant, id) + ":rebuild-lock";
}
public String uploadLock(String tenant, long id) {
return app(tenant, id) + ":upload-lock";
}
public String bloom(String tenant) {
return prefix + "bloom:{" + tenant(tenant) + "}:apps:v1";
}
public String deny(String tenant, String digest) {
return prefix + "deny:{" + tenant(tenant) + "}:" + digest;
}
public static String tenant(String tenant) {
if (tenant == null || !tenant.matches("[a-zA-Z0-9_-]{1,64}")) {
throw new IllegalArgumentException("非法租户标识");
}
return tenant;
}

这段短短的方法集合里藏着四个协议决定:

  • 第一,命名空间隔离环境。 rims:local 这样的前缀区分开发、测试、生产,也区分集成测试各自的数据。测试用随机 namespace 创建和清理自己的键,不需要 FLUSHALL,既不会互相污染,也不会误删别人的数据。
  • 第二,大括号把相关键归到一组。 单实例部署时它只是可读性;一旦上 Redis Cluster,{...} 内的内容决定哈希槽,缓存的键、代际键、锁键会落在同一个槽里,Lua 脚本对这些键的原子操作才能成立。把相关键放进同一个大括号是有意为之。布隆位图和吊销黑名单也采用同样的分组方式,第六章的四键激活脚本因此可以在同一槽内原子执行。
  • 第三,cache:v1 是格式版本。 缓存内容的结构会演进:加字段、改序列化方式。发布新版本时,新旧实例可能同时在线,旧实例写入的 JSON 新实例未必能读。用版本后缀把两代格式隔开,升级时启用新键、让旧键自然过期,比”上线后祈祷所有实例同时更新”可靠得多。
  • 第四,键里必须包含租户。 业务身份是 (tenant, appId),遗漏租户是最常见的越权与串数据来源。租户字段还要经过形状校验:它最后会拼进键,特殊字符可能破坏键结构,长度也不该无边界增长。

需要明确的是,租户正则只校验字符串形状,不证明调用者有权访问这个租户。真实系统从认证上下文解析租户,并在服务端检查权限;把这个实验接口暴露到公网之前,这是必须补的一层。

序列化:JSON 与一个专门的空值标记

缓存值使用 Jackson 写成 JSON。选 JSON 的理由很实际:redis-cli 里直接能看懂,跨语言可用,不依赖 Java 原生序列化。代价是体积略大、解析有开销,但是是赚的。

读到 JSON 之后,代码还会检查一件事:

1
2
3
4
AppView view = json.readValue(raw, AppView.class);
if (view == null || !tenant.equals(view.tenant()) || id != view.id()) {
throw new IllegalArgumentException(); // 值的身份与请求不一致
}

这次检查防的是”键对了、内容不对”的情况,键被错误覆盖、旧格式残留、别的系统写入了同名键,都会让一次命中变成错误结果,这就是事故。

比 JSON 更值得注意的是空值的表示。Redis 的 GET 对不存在的键返回 nil,对存在但内容为空的键返回空值——如果直接把 Java 的 null 写进去,这两种情况就分不开了:

  • nil 表示没查过,应该走回源;
  • 明确的空值标记表示查过了,数据库里确实没有,应该直接返回 404。

所以项目用一个不可能与正常 JSON 冲突的哨兵值 ~null:v1:

1
private static final String NULL = "~null:v1";

这层区分是空值缓存能起作用的前提:不存在的 ID 第一次查库后写入空值标记,之后 5 秒内的重复请求都在缓存层被挡住,不再打数据库。攻击者不断换新的不存在 ID 时,空值缓存帮不上忙,那就轮到布隆过滤器(第六章)。

TTL 与抖动

正缓存的存活时间是一个区间:

1
2
3
4
5
6
public long positiveTtl() {
// 默认 60000 + [0, 15000],单位为毫秒。
// nextLong 的上界不包含,因此加 1 后可以取到 15000。
return settings.getCacheTtlMs()
+ ThreadLocalRandom.current().nextLong(settings.getCacheJitterMs() + 1);
}

正缓存随机落在 60~75 秒,空值缓存固定 5 秒。抖动解决的是 同一时刻批量重建:如果所有键的 TTL 都是整 60 秒,一次发布、一次重启、一次批量刷新可能让大量键在同一秒到期,流量会从缓存层整体位移到数据库层。随机化把到期时间摊开,峰值就被削平了。

空值缓存则要短:太长会让”新建的对象”迟迟不可见,太短又挡不住重复的无效查询。5 秒是一个工程折中值。

关于 TTL,最重要的认识是它的职责边界:

  • TTL 从写入缓存的时刻起算,与数据库提交时刻无关。读请求在数据库更新前拿到的旧快照,写回缓存后仍然会获得完整的 60~75 秒寿命。这正是第八章要解决的”旧快照回填”。
  • TTL 是陈旧度上限和内存回收策略,并不承担一致性承诺:更新后能多快看到新值,取决于失效链路的延迟(第九章的 Outbox),与 TTL 的大小无关。
  • TTL 也不负责容量。即使每个键只活一分钟,一分钟内创建数百万个不同的键,一样能把内存打满。容量要按”键数量 × 平均大小 + 元数据 + 布隆位图 + 无 TTL 的代际键”来估算。

后面几章会看到,缓存设计的难点几乎都集中在 写 这一侧,因为你redis读只需要快而且本身非常快,写就需要保证正确。

穿透、击穿、雪崩:三种失效形态

这三个名词经常被并列背诵,实际对应三种完全不同的失效形态,防御手段也不可互换:

问题 触发条件 现象 本质 主要防御 剩余风险
缓存穿透 查询的数据在缓存和数据库里都不存在 每次请求都穿过缓存打到数据库 缓存没有可命中的内容 参数校验、空值缓存、布隆过滤器、限流 攻击者不断更换不存在的 ID
缓存击穿 一个极热门的键到期或尚未构建 大量请求同时 miss,一起回源 单个对象的并发重建 同键重建锁、二次检查、逻辑过期 锁租期短于慢查询、等待超时后的 503
缓存雪崩 大量键同时失效,或 Redis 整体不可用 请求成片进入数据库,系统级过载 容量边界被突破 TTL 抖动、熔断、回源并发上限 数据库自身容量不足

三者的区别可以用一句话概括:穿透是”查不到”,击穿是”一起查”,雪崩是”全都来查”。

穿透:不存在的对象

一个不存在的 ID,缓存里没有,数据库里也没有。如果不做处理,每次请求都会先查一次缓存(miss),再查一次数据库(空),两次 I/O 全部浪费。它可能来自攻击扫描,也可能来自客户端 bug、过期链接、或者刚被删除的对象——不一定是恶意流量。

针对两种不同的形态,用两种手段:

  • 同一个 ID 被反复查询,用空值缓存。第一次查库确认不存在后,把 ~null:v1 写进缓存,5 秒内所有重复请求直接在缓存层得到 404。这是 3.2 节那个哨兵值真正的用途。
  • 大量不同的 ID 被查询,空值缓存只能挡住每个 ID 的第一次,挡不住 每次都要查一个 的流量。更前置的筛选是参数校验(id > 0,格式合法)和布隆过滤器,布隆能做确定不存在,直接拒绝,连数据库都不碰。

空值缓存会推迟新建对象的可见时间。对象在 5 秒内被创建出来,缓存里还留着它不存在的记录,读请求会继续收到 404。这 5 秒是防御无效查询的代价,业务如果无法接受,就要缩短空值 TTL,或者在新对象写入时主动删除对应的空值标记。

击穿:一个热点键到期

设想一个被高频访问的配置项,TTL 到期的那一瞬间,所有并发请求同时看到一个空的缓存。如果没有协调,它们会同时查数据库、同时写缓存,数据库在几毫秒内收到几十上百个相同查询。

解决思路是同一时刻只允许一个请求重建同一个键,其余请求等它建好之后复用结果。落到实现上就是一把按对象粒度的 Redis 锁:

  • 抢到锁的请求回源查询、发布缓存、释放锁;
  • 没抢到的请求短暂等待(15~35 毫秒),然后重新读缓存;
  • 等待超过预算(600 毫秒)仍然拿不到结果,返回 503,让调用方稍后重试。

锁必须放在 Redis。原因在前面已经说过:synchronized 只约束当前 JVM,两个实例会各自回源一次。击穿的本质是跨进程的并发重建,防御也必须在跨进程层面生效。

剩下的风险来自时间:数据库查询如果持续慢于锁租期(3000 毫秒),锁会提前过期,第二个重建者进入;等待预算耗尽后,用户拿到的是 503;旧数据没有作为降级结果返回。这两种情况属于”用确定性换可用性”的取舍,后文还会展开

雪崩:大面积失效与整体故障

雪崩有两种成因,严重程度差别很大。

第一种:大量键同时到期。 比如一次批量刷新让所有键在同一秒写入,60 秒后它们又同时到期。TTL 抖动(3.3 节)把到期时间摊开,可以显著削平这个峰值,但无法彻底消除——极度不均匀的访问分布下,仍可能有一批热点键同时过期。

第二种:Redis 整体不可用。 这时的处境完全不同,缓存读不到,布隆过滤器读不到,重建锁也拿不到,前面所有的 精细防御 同时失效。请求只能走数据库,而数据库的容量是按”正常情况下的回源量”设计的。

看一个具体的量级。假设接口每秒 2000 个请求,Redis 超时 300 毫秒:

  • 每个请求在缓存层阻塞 0.3 秒;
  • 按 Little 定律,稳定状态下同时在途的请求约为 2000 × 0.3 = 600 个;
  • 而 Tomcat 的最大线程数是 64。

线程池会被瞬时占满,后续请求排队,排队又进一步拉长响应时间。短超时减少了一次伤害,但挡不住持续故障。所以还需要第二层,熔断。已知 Redis 故障期间,新请求直接快速失败,不再逐次消耗 300 毫秒去重新发现同一个事实

熔断之后,流量转向数据库,还必须限制回源并发:示例用每个实例 8 个许可的信号量。两个实例就是 16 个并发查询,超出部分的请求立刻得到 503。用 503 换数据库不被打垮,是设计内的行为。

三类问题会叠加

把上面的防御放进一次真实故障里看。执行 CLIENT PAUSE 5000 ALL 让 Redis 停顿 5 秒,然后发起 48 个缓存查询:

  1. 前几个请求在 Redis 调用上超时,熔断器打开(冷却 2000 毫秒);
  2. 冷却期内的请求快速失败,降级到数据库;
  3. 布隆查询同样包在缓存熔断包装里,Redis 不可用时直接抛 CacheUnavailable,整条缓存链路降级,请求全部回源;
  4. 信号量只放行 8 个并发,其余立刻 503;
  5. 结果:16 个成功(200),32 个被拒绝(503)。

这个结果说明,缓存能降级,降级有容量上限。设计时要能回答两个问题,这一层失效后,下一层收到多少请求;下一层最多能接住多少。

读链路的四道关卡

这一章展开代码。读链路有四道关卡,每一道都只回答一个问题。

解决缓存不可用

1
2
3
4
5
6
7
8
9
10
public AppView get(String tenant, long id) {
KeySpace.tenant(tenant); // 访问任何存储之前先检查键空间输入
if (id <= 0) throw new IllegalArgumentException("ID 必须为正整数");
try {
return cached(tenant, id);
} catch (CacheUnavailable e) {
metrics.counter("lab.cache.result", "result", "degraded").increment();
return load(tenant, id); // 仍要经过 AppRepository 的并发保护
}
}

注意 catch 的只有 CacheUnavailable 一种异常。这个选择本身就是在表达设计意图:

异常类型 含义 外层的处理
CacheUnavailable 可重建的副本暂时没有提供有效服务 降级到有限回源
BusinessException 业务结论:404 不存在、503 重建超时、409 版本冲突 按业务语义返回,不降级
DataAccessException 数据库等存储操作失败 由对应层转换成 503,不递归查询
其他运行时异常 编程错误或未预期状态 交给统一异常处理,暴露出来

如果把这里写成 catch (Exception e) { return load(...) },会出现三种连锁反应:等待重建超时的请求会绕过锁直接查库;404 被当成缓存 miss 反复回源;数据库已经失败时还会再查一次。异常分类就是降级策略的边界。

先读缓存,再问布隆

1
2
3
4
5
6
7
8
9
10
String cache = keys.cache(tenant, id);
String lock = keys.rebuildLock(tenant, id);
String raw = read(cache); // 内部经 CacheRedis 熔断包装
if (raw != null) return decode(raw, tenant, id);

// 缓存没有结果时,才问布隆过滤器是否"确定不存在"。
if (!bloom.mightContain(tenant, id)) {
throw new BusinessException(404, "应用不存在");
}
metrics.counter("lab.cache.result", "result", "miss").increment();

这个顺序有两个理由:

  1. 正常的热点请求只需要一次 GET 就返回,不必为每个请求计算七个哈希、执行一次 Lua;
  2. 命中空值标记时也能直接返回 404,既不走布隆,也不抢锁。

布隆过滤器保护的是 miss 路径。它的职责边界很清楚:

  • 缓存记录具体内容,或者记录”查过一次,不存在”;
  • 布隆记录目录的成员信息,只回答”这个 ID 有没有可能存在于集合中”。

所以布隆说”可能存在”(1)时,仍然要查缓存或数据库;说”确定不存在”(0)时,才可以作为 404 的依据。它不提供对象名称、版本,也不能代替数据库确认。第六章会解释为什么它必须用三态返回。

拿到锁之后,为什么还要二次检查

为聚焦主流程,下面的代码省略了指标计数与异常包装,完整实现见 AppCacheService。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
long deadline = System.nanoTime() + settings.getLockWaitMs() * 1_000_000;
do {
String owner = access.call(() -> lease.acquire(lock, settings.getLockLeaseMs()));
if (owner != null) {
try {
raw = read(cache); // 关键:等待和抢锁期间,别人可能已经建好
if (raw != null) return decode(raw, tenant, id);

String generation = access.call(() ->
redis.opsForValue().get(keys.generation(tenant, id)));
String expected = generation == null ? "0" : generation; // 空代际按 "0" 校验
AppView view = repository.load(tenant, id).orElse(null);
publish(owner, expected, view); // 见 5.4;校验失败不影响本次返回值
if (view == null) throw new BusinessException(404, "应用不存在");
return view;
} finally {
releaseQuietly(lock, owner); // 解锁失败只记指标
}
}
metrics.counter("lab.cache.result", "result", "lock_contention").increment();
pause(); // 随机等待 15~35 毫秒,避免同步重试的尖峰
raw = read(cache); // 别人可能已经建好
if (raw != null) return decode(raw, tenant, id);
} while (System.nanoTime() < deadline);
throw new BusinessException(503, "缓存正在重建,请稍后重试");

代码里还有一个容易忽略的顺序:对象不存在时,先把空值标记发布进缓存,再抛 404。如果先抛异常,发布代码不会执行,负缓存就永远建不起来,不存在的 ID 每次都会回源。

二次检查的价值用一个时间线就能看清。A 和 B 同时看到缓存为空:

时刻 实例 A 实例 B
t0 看到 miss,尝试抢锁,成功 看到 miss,尝试抢锁,失败
t1 查数据库,拿到结果 等待 20 毫秒,重读缓存(仍空)
t2 写入缓存,释放锁 再次抢锁,成功
t3 — 二次检查命中缓存,直接返回

如果没有 t3 的二次检查,B 虽然遵守了”同一时刻只有一个重建者”的规则,却会再查一次数据库——互斥约束住了并发,但没有让 B 复用 A 已经完成的工作。二次检查把互斥变成了协作,把重复回源从”并发一次”降为”串行一次”。

等待循环里还有几个细节:

  • 使用 System.nanoTime() 测量经过时间,系统校时不会改变等待间隔;
  • do...while 至少执行一次,所以把等待预算配成 0 不代表完全不尝试抢锁;
  • 每轮都要付一次 Redis 往返和一次随机 pause,600 毫秒是循环的总预算,单次端到端耗时会略有出入;需要严格 deadline 时,要在每个下游调用前重新计算剩余时间;
  • 等待超时返回 503。绕过锁去查库会让几十个等待者同时回源,前面的互斥就白做了。

写缓存失败不该触发第二次查库

拿到数据库结果之后,写缓存这一步也可能失败。把上面的 publish(owner, expected, view) 展开,就是下面这段;expected 与 view 来自上文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String payload = view == null ? NULL : json.writeValueAsString(view);
long ttl = view == null ? settings.getNegativeTtlMs() : positiveTtl();
try {
Long published = access.call(() -> redis.execute(
Lua.script("cache-publish.lua"),
List.of(lock, keys.generation(tenant, id), cache),
owner, expected, payload, Long.toString(ttl)));
if (!Long.valueOf(1).equals(published)) {
metrics.counter("lab.cache.result", "result", "publish_rejected").increment();
}
} catch (CacheUnavailable e) {
// 已经拿到数据库结果,写缓存失败不应再查一次数据库。
metrics.counter("lab.cache.result", "result", "write_failure").increment();
}

这个位置的判断值得学习:异常应该在拥有足够上下文的最窄位置处理。结果已经来自事实源,写缓存只是顺便让下一次更快,而失败不影响本次正确性。如果让异常冒到最外层 get(),它会被当成缓存不可用,于是再 load() 一次,这样同一个请求查两次数据库,而这次查询毫无收益。

发布脚本的两个校验条件(锁所有者、失效代际)留到后面讲,那里才是它真正的用武之地。

空值、坏数据与 corrupt

这一部分处理,缓存里的值本身有问题,的情况:空值标记、格式损坏、身份不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private AppView decode(String raw, String tenant, long id) {
if (NULL.equals(raw)) {
metrics.counter("lab.cache.result", "result", "negative_hit").increment();
throw new BusinessException(404, "应用不存在");
}
try {
AppView view = json.readValue(raw, AppView.class);
if (view == null || !tenant.equals(view.tenant()) || id != view.id()) {
throw new IllegalArgumentException();
}
metrics.counter("lab.cache.result", "result", "hit").increment();
return view;
} catch (RuntimeException e) {
metrics.counter("lab.cache.result", "result", "corrupt").increment();
throw new CacheUnavailable(); // 坏值按"缓存不可用"处理,转有限回源
}
}

空值判断必须放在 JSON 解析之外:~null:v1 本身不是合法 JSON,放进 try 里会被当成格式错误,然后触发一次无意义的数据库查询,把一个正常的负缓存命中变成降级。

坏值(解析失败、身份不一致)转换成 CacheUnavailable,避免直接抛 500:数据质量问题和存储可用性问题都应该让请求自己处理,至少这次请求能拿到正确结果。代价是坏值不会被立即清除,在 TTL 到期之前,后续请求会持续走降级。所以 corrupt 指标持续增长时必须当成故障处理,接口返回 200 说明不了缓存是健康的。

一个值得做的改进是 比较后再删除:读取时发现坏值,用一个 Lua 脚本判断”当前值仍然等于刚才读到的坏值”才删除,避免把别的实例刚写入的新值误删。要注意,检查与修改必须放在同一个 Redis 原子步骤里,否则检查时的依据在修改时可能已经失效。

布隆过滤器

空值缓存只能挡住 同一个不存在 ID 的重复查询。要挡住 每次换一个不存在 ID 的流量,需要一个快速的能回答 这个 ID 有没有可能存在 的结构,此时采用布隆过滤器。

原理

布隆过滤器由一个长度为 m 的位数组和 k 个哈希函数组成:

  • 加入元素时,计算 k 个哈希下标,把它们全部置为 1;
  • 查询元素时,重新计算同样的下标:只要有一个位置是 0,这个元素就一定没有被加入过;如果全部是 1,它可能存在——因为这些位也可能是别的元素设置成 1 的。

示例把 k 个哈希实现为”同一个 SHA-256 + 不同盐值”,效果等价于 k 个相互独立的哈希函数,6.3 节会看到具体写法。

由此得到两个性质:

  1. 存在误判(false positive):不存在的元素可能被判为”可能存在”。后果只是多查一次数据库,不会返回错误数据。
  2. 没有假阴性(false negative):只要位图完整、算法一致、成员全部加入过,存在的元素不可能被判为”不存在”。

给定预期元素数量 n 和目标误判率 p,常用公式是:

1
2
m = ceil(-n × ln(p) / (ln(2)²))     # 位数组长度
k ≈ round((m / n) × ln(2)) # 哈希函数个数

哈希也是共享协议

1
2
3
4
5
6
7
8
9
10
11
public static List<String> offsets(String tenant, long id) {
MessageDigest hash = MessageDigest.getInstance("SHA-256");
List<String> offsets = new ArrayList<>();
for (int i = 0; i < HASHES; i++) {
byte[] bytes = (i + ":" + tenant + ":" + id).getBytes(StandardCharsets.UTF_8);
long number = ByteBuffer.wrap(hash.digest(bytes)).getLong();
// long 可能为负,floorMod 避免 Math.abs(Long.MIN_VALUE) 的陷阱。
offsets.add(Long.toString(Math.floorMod(number, BITS)));
}
return offsets;
}

这段代码里,每一个看起来不起眼的选择都是协议的一部分:

  • 租户参与哈希。不同租户使用相同的数字 ID 时,哈希结果完全不同,不会把别人的 ID 误判为存在。
  • 拼接格式固定。i:tenant:id 的分隔符、编码(UTF-8)、字节序都会影响结果,任何一个实例改了写法,就会和别人的位图对不上。
  • 用 floorMod 处理负数。Math.abs(Long.MIN_VALUE) 仍然是负数,会算出非法下标。
  • i 参与输入,用同一个 SHA-256 生成 k 组确定性哈希,省去实现七种不同哈希函数的麻烦——简单、可复现、跨语言可移植。

七个哈希意味着每次 miss 都要算七次 SHA-256。目录很大时,可以评估双重哈希(用两个哈希派生 k 个下标)、批量流水线计算等优化。但优化必须配一套固定的测试向量,保证升级前后对同一个 ID 得到相同下标,否则一次性能优化就会让新旧位图无法共用。

算法、容量、哈希次数最终被写成一个签名(SIGNATURE),随位图一起发布到 ready 键。查询时先比对签名,签名不一致就返回不知道。这让算法升级变成了一次显式的重建动作,避免新实例拿旧位图给出错误答案。

发布生命周期

最直接的更新方式是清空位图再逐个加入 ID。这个写法有一个致命窗口:重建过程中到来的查询会把”还没构建到”误读成”不存在”,于是存在的对象被返回 404。加锁解决不了问题——查询请求不会去获取构建锁。

正确做法是先构建候选位图,完整之后原子替换(代码省略了指标计数与异常包装,完整实现见 AppBloomFilter):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
String base = keys.bloom(tenant);
String lock = base + ":build-lock";
String owner = lease.acquire(lock, 30000);
if (owner == null) return false; // 别人正在构建,不等于自己构建成功

String candidate = base + ":build:" + UUID.randomUUID();
try {
List<Long> ids = repository.ids(tenant, CAPACITY + 1);
if (ids.size() > CAPACITY) {
redis.delete(base + ":ready"); // 禁用"确定不存在"的拦截
throw new IllegalStateException("布隆过滤器容量不足,需要扩容");
}
redis.opsForValue().set(candidate, "", Duration.ofMinutes(10));
redis.opsForValue().setBit(candidate, BITS - 1, false); // 预分配长度
for (long id : ids) {
redis.execute(Lua.script("bloom-add.lua"), List.of(candidate),
offsets(tenant, id).toArray());
}
return Long.valueOf(1).equals(redis.execute(Lua.script("bloom-activate.lua"),
List.of(base, base + ":ready", candidate, lock), owner, SIGNATURE));
} finally {
releaseQuietly(lock, owner);
}

发布脚本把”校验构建者身份、替换位图、写入签名”放在同一个原子步骤里:

1
2
3
4
5
6
if redis.call('GET', KEYS[4]) ~= ARGV[1] then return 0 end
if redis.call('EXISTS', KEYS[3]) == 0 then return 0 end
redis.call('RENAME', KEYS[3], KEYS[1]) -- 用完整候选替换正式位图
redis.call('PERSIST', KEYS[1]) -- 正式版本不再保留候选 TTL
redis.call('SET', KEYS[2], ARGV[2]) -- 写入算法与容量签名
return 1

这里有两个不同的时间,用途完全不同:

  • 候选键 10 分钟 TTL:构建失败留下的半成品会被自动清理;
  • 构建锁 30 秒租期:构建者崩溃后,孤儿锁最多存活 30 秒。

构建慢到锁过期时,旧构建者已经失去发布资格(bloom-activate.lua 会拒绝它),候选位图最终自动过期,系统回到”没有 ready 签名”的未就绪状态,查询放行到数据库。这个降级方向是安全的:宁可多查数据库,也不能用半成品位图拒绝真实对象。

候选布隆位图的构建与发布

逐 ID 执行脚本意味着构建要走很多次网络往返。容量只有一万时这没问题;目录更大,构建耗时会超过租期,需要分批流水线、进度记录,以及”租约过期后拒绝发布”的保护。把所有 ID 塞进一个巨大的 Lua 脚本并不可取——长脚本会长时间占用 Redis 的执行线程,影响其他所有业务请求。

6.5 三态查询:区分”不存在”和”不知道”

1
2
3
4
5
6
7
8
9
-- bloom-check.lua
if redis.call('GET', KEYS[2]) ~= ARGV[1]
or redis.call('EXISTS', KEYS[1]) == 0 then
return -1 -- 不知道,不能当成不存在
end
for i = 2, #ARGV do
if redis.call('GETBIT', KEYS[1], tonumber(ARGV[i])) == 0 then return 0 end
end
return 1 -- 可能存在

返回值有三种含义,Java 端只对 0 做拦截:

返回 含义 Java 行为
0 位图完整、签名匹配,且至少一位为 0 确定不存在,直接 404
1 所有位都是 1 可能存在,继续走缓存重建链路
-1 位图缺失、签名不匹配或尚未构建 不知道,放行到数据库(仍受并发上限保护)

-1 这个设计是整章最重要的工程点。它承认了一个事实:过滤器不可用时,“没有证据说不存在”和”确定不存在”必须分开表达。如果把 -1 也当成 0,一次重启、一次重建、一次签名升级,都会让真实存在的对象被大面积误判成 404。

顺带指出一个实现细节:脚本校验的是 ready 签名与位图键是否存在,并不检查位图字符串的实际长度。长度等于 BITS 由构建流程保证;如果位图被意外截断而 ready 签名还在,查询脚本不会发现,仍可能产生假阴性。这也是为什么”未就绪”的判定只能信脚本返回的 -1,不能靠”键存在”推断过滤器可用。

三态同时让监控变得可解释。lab.bloom.result 用 reject、maybe、unready、build_failure 四个结果标签分别计数:unready 持续增长,说明过滤器没有正常工作,无效请求正在进入重建链路,应该去检查位图、签名和构建状态;接口成功率不足以说明问题。

启动时 ApplicationRunner 会为已有租户触发一次构建,Redis 的 DataAccessException 被记录为构建失败,应用继续启动——过滤器没有就绪,业务可以退化为”多查数据库”。容量超限抛出的 IllegalStateException 不走这条捕获,会让启动直接失败:容量规划错误属于必须尽快暴露的问题,和临时网络故障不该共用同一种处理方式。

6.6 最重要的边界:目录必须是一份稳定的快照

前面所有机制的共同前提是:成员集合基本稳定、只更新属性。 示例项目只开放了修改应用名称,没有开放新增和删除应用的接口,因此构建快照的成员集合是稳定的。把这个实现搬到动态目录之前,先要回答新增和删除分别会发生什么。

如果把这个实现搬到动态目录上,会出现两类假阴性:

新增对象时,数据库已经提交,但新 ID 还没有加入过滤器,查询会得到”确定不存在”,返回 404。即使立刻重建,也还有一个窗口:快照读取完成之后、发布之前新增的成员,会被这次发布遗漏。

删除对象时,七个位由多个元素共享,直接清零会把仍然存在的元素一起打成假阴性。普通布隆过滤器的处理方式是容忍已删除成员继续产生误判,等下一次重建时回收;需要频繁删除时,才考虑计数布隆等支持删除的结构,代价是内存、误判率和维护复杂度都会上升。

动态目录要安全使用布隆,必须额外设计可见性协议,例如:

  • 新增对象在数据库提交之前,确保过滤器成员已经准备好,失败则让过滤器转为未就绪;
  • 重建快照期间收集增量,发布前补齐;
  • 维护新旧两个世代,规定查询时如何选择。

每种方案都有自己的事务边界;“新增时补一次 SETBIT”覆盖不了这些窗口。布隆过滤器始终是减轻压力的辅助结构,事实查询和容量保护不能被它替代。

分布式锁

原子加锁:SET key owner NX PX lease

1
2
3
4
5
6
public String acquire(String key, long leaseMs) {
String owner = UUID.randomUUID().toString(); // 每次尝试都是新的所有者身份
boolean acquired = Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent(key, owner, Duration.ofMillis(leaseMs)));
return acquired ? owner : null;
}

锁必须用一条命令完成”不存在才创建”和”设置过期时间”两件事。如果先 SETNX 再单独 EXPIRE,客户端可能在两条命令之间退出,留下一把没有 TTL 的永久锁。SET NX PX 把这两个语义合并成原子操作。

所有者标识用 UUID,而且每次尝试都重新生成。

  • 用线程 ID 不够,不同 JVM 的线程 ID 可以相同,A 进程的线程 7 和 B 进程的线程 7 会撞在一起;
  • 只用实例 ID 不够,同一实例的两个任务先后竞争同一把锁时,无法区分”前任”和”后任”。

UUID 的作用是确认锁的归属,不承担任务排序和资源端版本号。这两件事需要不同的机制

解锁:必须比较所有者

1
2
3
4
5
-- unlock.lua:检查与删除不能拆成两条客户端命令。
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0

如果不比较所有者,会发生这样的事:

时刻 实例 A 实例 B
t0 获得锁,租期 3 秒 —
t1 发生长时间 GC / 线程暂停,超过 3 秒 —
t2 锁到期自动释放 获得同名锁,开始工作
t3 恢复执行,直接 DEL 锁被 A 删掉,互斥失效

即使把逻辑改成”先 GET 判断所有者,再 DEL“,两步之间仍可能发生”锁到期 + B 抢到锁”,A 的 DEL 还是删错了对象。比较和删除必须在同一个 Lua 脚本里完成,这是”检查与修改原子化”的又一个例子。

解锁脚本保证的是”不误删”,它并不负责让 A 停下来。Redis 不会因为键过期就中断 A 的 Java 线程,A 的数据库查询、HTTP 调用、文件处理都会继续执行。固定租约的本质是”给持有者一段有效时间”,超出这段时间之后,互斥承诺就失效了;这时要靠资源端的保护兜底。

租期、等待预算与续期

示例中缓存重建和上传锁的默认租期都是 3000 毫秒,但两者配套的等待策略不同:

场景 租期 拿不到锁时 设计意图
缓存重建 3000 ms 等待最多 600 ms,期间重读缓存 大概率很快能复用别人的结果
上传 3000 ms 立即返回 423 明确告诉调用方”这个应用正在发布”

租期回答的是”孤儿锁最多存活多久”,等待预算回答的是”没抢到锁的请求愿意等多久”,两者没有必然关系。

租期的选择要看数据库查询的尾延迟。如果 p99 查询时间是 2.5 秒,3 秒租期在正常路径上勉强够用,但一次 GC 或一次磁盘抖动就可能让重建者丢锁、第二个重建者进入。延长租期能减少这种丢锁,代价是进程崩溃后锁要更久才释放。

续期(watchdog)能提高长任务的存活率,但引入了新的复杂度:续期线程自己也可能被暂停,续期失败后要能安全退出,所有者变化后不能继续续期。成熟组件(如 Redisson)处理了这些细节,但使用成熟锁组件不等于获得了”恰好一次”的业务语义,资源端的幂等和版本约束仍然必须存在。示例项目没有实现续期、可重入、公平排队和阻塞唤醒,它是一把教学用的”有限租约锁”。

锁过期

锁过期、旧执行者继续运行,这是分布式系统里无法完全消除的事实。真正保护业务的是资源端对旧执行者的拒绝:

资源 资源端保护 效果
缓存写入 发布 Lua 脚本校验 owner 与代际 失去租约的旧重建者无法覆盖新缓存(第八章)
数据库更新 WHERE version = ? 条件更新、唯一约束 旧版本写入影响 0 行,返回 409
外部平台调用 平台幂等键 + 内容校验 同一业务操作重复提交返回同一结果
严格顺序场景 递增的 fencing token,由资源端校验 旧令牌的执行被资源端明确拒绝

fencing token 的要点:令牌必须由下游资源校验。只让 Redis 生成一个递增数字、记录在请求里,而数据库或存储端不看这个数字,安全效果为零。示例项目采用的是”缓存发布校验 + 业务幂等”这条路,没有实现完整的 fencing

旧快照回填与失效代际

前面几章解决的是”读得快”和”读不到时怎么办”。真正让缓存设计变难的是写:数据库更新之后,缓存怎样才能保证不再返回旧值。这一章是全文的核心。

一个不需要任何故障的竞态

考虑下面这个顺序,全程没有任何命令失败、没有网络抖动、没有进程崩溃:

时刻 读请求 R 写请求 W 数据库 / Redis
t0 GET 缓存,miss — 缓存为空
t1 查数据库,得到版本 1 — DB = v1
t2 — 更新数据库为版本 2 DB = v2
t3 — 删除缓存 缓存为空
t4 把版本 1 写回缓存 — 缓存 = v1,TTL 60~75 秒

删除动作执行成功了,缓存却在新版本之后重新变成了旧值。这就是旧快照回填:读请求在数据库更新之前取到旧数据,在失效动作之后才把旧数据发布出去。

这个问题与 先删缓存还是先更新数据库 的顺序无关。把顺序换过来,只是把窗口从回填覆盖删除变成删除之后读到旧库再回填。只要读和写是两个独立操作,就存在交错。缩短 TTL 只是缩短窗口:从最长 75 秒变成最长 5 秒,旧值依然会回来。

加锁和版本号为什么都不够

最直觉的补救是让重建也走锁。问题是:失效动作通常不参与重建锁。W 只做一次数据库更新和一次缓存删除,它没有理由去抢重建锁;就算让它抢,W 也得等 R 释放锁——那等于把数据库更新阻塞在一次可能很慢的查询后面。

还有一种想法是给缓存值带上数据库版本,读取时比较版本号。但缓存里只有值,没有”当前最新版本”的参照物;要拿到参照物,就得再查一次数据库——缓存的意义消失了一半。

要让这条竞态消失,需要一个所有参与者都认可的顺序标尺,并且让”发布”这个动作在写入之前能验证标尺没有变过。示例项目用的标尺是失效代际(generation)。

代际协议:读前取号,发布时验号

代际是每个对象一个的计数器,只在失效时递增。规则只有两条:

  1. 重建者:查数据库之前读取当前代际,作为”取号”;发布缓存时,要求代际仍然等于这个号,并且自己仍然持有重建锁;
  2. 失效者:在同一个原子步骤里,把代际加一,并删除缓存。

发布脚本把两个校验条件写在一起:

1
2
3
4
5
6
7
8
-- cache-publish.lua
-- KEYS: 重建锁、代际键、缓存键
-- ARGV: owner、查询前代际、payload、TTL 毫秒
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end -- 锁还是我的吗
local generation = redis.call('GET', KEYS[2]) or '0'
if generation ~= ARGV[2] then return 0 end -- 期间发生过失效吗
redis.call('SET', KEYS[3], ARGV[3], 'PX', ARGV[4])
return 1

为什么这样就安全?把之前描述的错误序补上代际,看两种可能:

情况 时序 结果
失效发生在发布之前 R 取号 gen=5 → W 失效(gen=6,删缓存)→ R 发布(期望 5) 代际不匹配,发布被拒绝
失效发生在发布之后 R 发布 v1(gen=5)→ W 失效(gen=6,删缓存) 刚发布的 v1 被删除,下次读取重建 v2

也就是说:任何一次失效之后,都不会再有更旧的快照被写回。 这是代际协议真正承诺的东西。它不承诺更新后立刻能读到新值,数据库提交与失效执行之间还有 Outbox 的延迟,那段时间里的读请求仍然可能拿到旧值。

区分这两件事,是理解这个方案的关键:

  • 失效之前:旧值可能被读到,这是最终一致性的固有窗口;
  • 失效之后:旧值不会再回来,这是代际协议关闭的竞态。
失效代际如何拒绝旧快照发布

发布被拒绝时,Java 代码记 publish_rejected 指标,但仍然把本次查到的数据库结果返回给调用者。这个结果对本次请求是真实的(它来自事实源),只是不适合作为共享缓存。两个决定要分开:

  • 不把不合格结果留成共享缓存,脚本负责;
  • 本次请求返回什么,业务负责,通常返回刚查到的值。

如果业务要求,读请求必须观察到某次已确认的写入,那需要的是版本门槛、直接读事实源或更强的读一致性协议

三个标识

系统里同时存在三个容易混淆的标识,混用会制造非常隐蔽的 bug:

标识 解决什么问题 谁维护 生命周期 能否出现在业务响应里
数据库 version 写写冲突(乐观并发控制) 业务更新事务 随业务记录持久化 可以,客户端用它提交更新
缓存 generation 拒绝”失效之前取号、失效之后发布”的旧快照 失效脚本 + 发布脚本 键存在期间持续递增,无 TTL 不可以,它是缓存协议内部状态
锁 owner 区分同一把锁的前任与后任 每次抢锁生成 只活到锁释放或租期结束 不可以

常见错误包括:把数据库 version 当作锁的 owner(版本相同的两个请求会互相误解锁)、把 generation 当作业务版本返回给前端(它只反映缓存失效次数,不反映对象内容)、用锁 owner 判断对象是否过期。一个字段只承担一种协议职责,这是分布式系统里最值得坚持的纪律之一。

失效需要推进代际与删除缓存必须一起做

1
2
3
4
-- cache-invalidate.lua
redis.call('INCR', KEYS[1]) -- 先推进失效代际
redis.call('DEL', KEYS[2]) -- 再移除当前缓存,整体原子执行
return 1

两个动作缺一不可:

  • 只删除缓存、不推进代际:8.1 的竞态原样保留——R 取号 gen=5,W 删缓存,R 发布时 gen 仍是 5,校验通过,旧值照样回来;
  • 只推进代际、不删除缓存:旧值继续被命中,直到 TTL 到期。代际只能让”新的发布”被拒绝,不能让”已经在缓存里的值”消失。

重复执行失效会让代际多加几次、把刚建好的缓存再删一次。它不算严格幂等,但业务效果可以重复:旧数据不会因为重试而重新生效,只是多付一次失效与重建的成本。第九章的 Outbox 恰好需要这种”可重复执行”的语义。

代际的生命周期与它自己的故障

代际键有一个反直觉的设计:没有 TTL。如果给代际设置过期时间,它可能在缓存还活着的时候先消失,读请求从”不存在”得到默认值 "0",发布校验就会在错误的基准上进行。

代价是每个发生过失效的对象都会留下一个持久键。规模上去之后,这些元数据的数量与内存需要纳入容量规划,并跟随对象生命周期清理(例如对象彻底删除时一起清理),不能简单地”给它配一个比缓存更短的 TTL”。

更隐蔽的风险是代际键丢失。考虑这个顺序:

  1. 读者 R 取号,读到默认值 "0"(此时代际键还不存在);
  2. W 失效:INCR 创建代际键为 1,删除缓存;
  3. 代际键因为淘汰、误删或实例恢复而消失;
  4. R 发布,期望 "0",脚本读到”不存在”按 "0" 处理,校验通过——旧快照在失效之后被发布。

这是典型的 ABA 问题。示例项目用 noeviction 和持久化来降低概率(第十四章),但丢失仍可能发生。扩展方向是把数据库版本纳入发布条件,或者使用可持久恢复的世代标识,并明确”恢复期间是否暂停缓存发布”。这类看起来不起眼的元数据,往往承载着协议正确性的前提。

数据库侧的写写冲突:乐观锁

代际解决的是”缓存与数据库之间”的竞态;同一对象被两个请求同时修改,是另一个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void update(String tenant, long id, String name, long expectedVersion) {
KeySpace.tenant(tenant);
tx.executeWithoutResult(status -> {
int changed = jdbc.update(
"UPDATE app_config SET name=?, version=version+1 " +
"WHERE tenant=? AND app_id=? AND version=?",
name, tenant, id, expectedVersion);
if (changed == 0) {
throw new BusinessException(409, "应用不存在或版本冲突,请重新读取");
}
// 与业务更新处在同一个数据库事务中。
outbox.add("CACHE_INVALIDATE", tenant, Long.toString(id), 0);
});
}

客户端先读到版本 1,提交时带上 expectedVersion=1。如果另一个请求已经把它更新成了版本 2,这条 UPDATE 影响 0 行,返回 409,后到的修改不会静默覆盖先到的修改。

这段代码把三件事放在了一个数据库事务里:

  1. 业务字段更新;
  2. 版本递增;
  3. 写入一条”待失效”的 Outbox 事件。

前两件是业务事实,第三件是”提交之后还必须做什么”的补偿意图。三者要么一起提交、要么一起回滚,这一层由数据库事务保证。但要看清事务的边界:TransactionTemplate 只覆盖同一个数据库事务管理器下的 JDBC 操作,后来在 Outbox Worker 里执行的 Redis 命令不属于这笔事务。跨 Redis 和数据库的原子提交是做不到的,能做的是”数据库提交后,补偿意图不会丢”。

接口返回 204,含义是”数据库更新与待执行的失效任务已被接受”;Redis 此刻可能还没失效。客户端看到 204 后立刻读,仍可能读到旧值——这就是 9.1 节 Outbox 要处理的窗口。

Outbox

双写为什么必然有窗口

直接写”更新数据库,再删除缓存”,有两种失败方式:

  • 数据库成功、Redis 失败:缓存留在旧值上,而且没有任何机制会再去删它;
  • 先删 Redis、再更新数据库,或者删除成功但回填并发进行:仍然可能出现旧值。

两个独立系统之间不存在同时提交。调整代码顺序只能改变窗口的位置,不能消除窗口。Outbox 的思路是换一个问法:不在请求路径上直接操作 Redis,而是先把”之后要做什么”作为一条数据库记录提交,再由后台任务可靠地执行。

表结构与事务边界

redis_outbox 保存事件 ID、类型、租户、业务引用、绝对到期时间、重试次数、下次执行时间和最后错误类型。事件类型目前有两种:CACHE_INVALIDATE(缓存失效)和 TOKEN_DENY(吊销黑名单补偿)。

1
2
3
4
5
6
7
public void add(String kind, String tenant, String ref, long expiresAt) {
// 此方法自身不开启事务,原子性来自调用者的业务事务。
jdbc.update("INSERT INTO redis_outbox" +
"(id,kind,tenant,ref,expires_at,next_at) VALUES(?,?,?,?,?,?)",
UUID.randomUUID().toString(), kind, tenant, ref,
expiresAt, System.currentTimeMillis());
}

add() 方法本身没有事务。它之所以可靠,是因为调用者把它放进了业务事务——业务更新和事件插入一起提交。如果某段代码脱离事务单独调用 add(),它就只是一条普通插入,失去了与业务状态绑定的保证。看代码要追调用链,不能只看到一张 Outbox 表就断言所有双写都可靠。

due() 查询用 next_at <= now 和 ORDER BY next_at LIMIT 50 取到期任务,next_at 上有索引。事件在成功之前不会删除,成功之后才删除。

至少一次执行,确认之后才删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
for (var event : store.due()) {
try {
Long applied;
if ("CACHE_INVALIDATE".equals(event.kind())) {
long id = Long.parseLong(event.ref());
applied = redis.execute(Lua.script("cache-invalidate.lua"),
List.of(keys.generation(event.tenant(), id), keys.cache(event.tenant(), id)));
} else if ("TOKEN_DENY".equals(event.kind())) {
applied = redis.execute(Lua.script("deny-extend.lua"),
List.of(keys.deny(event.tenant(), event.ref())),
Long.toString(event.expiresAt()));
} else {
throw new IllegalStateException("未知 outbox 类型");
}
if (!Long.valueOf(1).equals(applied)) {
throw new IllegalStateException("Redis 未确认任务");
}
store.done(event.id()); // 必须在副作用被确认之后删除
metrics.counter("lab.outbox.result", "result", "success").increment();
} catch (RuntimeException e) {
long backoff = Math.min(60000, 1000L << Math.min(event.attempts(), 6));
store.retry(event, e.getClass().getSimpleName(),
backoff + ThreadLocalRandom.current().nextLong(250));
metrics.counter("lab.outbox.result", "result", "retry").increment();
}
}

这里的关键顺序是:先执行副作用、拿到确认,再删除事件。反过来的话,进程在两步之间退出,事件已经消失,副作用却没有发生,缓存就永久停留在旧值。

即便如此,重复仍会发生:Redis 实际执行成功但响应超时,Worker 会保留事件并重试;响应成功但进程在 done() 之前退出,下次还会再执行一遍。所以语义是至少一次,消费逻辑必须允许重复。示例的两类事件天然可重复:

  • CACHE_INVALIDATE:多推进一次代际、多删一次缓存,业务效果不变,只增加一次重建;
  • TOKEN_DENY:脚本按数据库里的绝对到期时间写 PXAT,重复执行只是把同一个截止时间再写一次。

当前 Worker 没有任务认领、租约字段或 SELECT ... FOR UPDATE SKIP LOCKED,两个实例会读到同一批事件并各执行一遍。对这两类事件,重复执行的代价只是额外开销;如果以后加入”只能成功一次”的事件类型,就必须先补上认领与去重机制。

一次更新会在哪些位置中断

把故障注入到每个阶段,可以列出所有可能的中断位置:

中断位置 留下的状态 恢复后的结果
数据库事务提交前 业务更新和事件一起回滚 客户端重新确认业务操作
提交后、Worker 执行前 业务已是新值,事件待执行 Worker 继续补偿,缓存最终失效
Redis 执行后、收到确认前 Redis 可能已生效,事件还在 重复执行一次,结果不变
收到确认后、删除事件前 副作用已生效,事件还在 重复处理后删除
事件删除后 补偿完成 不再处理该行

这张表比一句”最终一致性”具体得多。它能回答线上排障时最常问的问题:现在系统处在哪个状态,下一步该做什么。

退避、积压与死信

指数退避从 1 秒开始,翻倍增长到最多 60 秒,再加 0~249 毫秒的抖动。它的作用是在 Redis 持续故障时,避免每个 Worker 每秒反复重试同一批事件;抖动则降低多个实例同步重试的概率。退避不提供公平性,也不保证事件按业务提交顺序执行。

Worker 用 fixedDelay 调度:上一轮结束后等待配置的间隔,再开始下一轮。一批 50 条事件顺序执行,每条都要等 Redis 超时的话,一轮本身就可能花很久。吞吐规划要看批大小、单条延迟、成功率和 Worker 数量,不能只看 1 秒的调度间隔。

还有两个容易被忽略的细节:

  • retry() 自身是数据库写操作,它也可能失败。外层定时方法捕获数据库异常并记录 db_failure,事件仍留在表里,但重试时间没有更新——下一轮会立刻再试,形成更紧的重试节奏。
  • lab.outbox.overdue.seconds 读取的是最早的 next_at,表示”最早的任务比计划时间晚多久”;它并不等于事件从创建起积压了多久。退避会把 next_at 推到未来,一个很老的事件也可能暂时显示零逾期。

当前项目没有最大重试次数、死信表、人工重放接口和成功历史归档。生产环境需要补上这些:事件创建时间、错误分类、毒性事件隔离、审计与重放。尤其是安全相关的事件(比如 Token 吊销),不能因为”重试次数太多”就删除,那会让积压指标看起来恢复,实际上安全状态没有落实。

Redis 故障时的自我保护

短超时为什么还不够

缓存客户端已经配置了 300 毫秒超时,这能避免单个请求无限等待,但对持续故障无能为力。用具体数字算一遍:接口每秒 2000 个请求,Redis 不可用,每个请求在缓存层消耗 300 毫秒。

  • 每个请求占用一个处理线程 0.3 秒;
  • 稳定状态下同时在途的请求数 ≈ 2000 × 0.3 = 600;
  • 示例配置的 Tomcat 最大线程数是 64。

几百个并发需求压在 64 个线程上,结果是线程池耗尽、请求排队、响应时间进一步拉长,而 Redis 那边的每一次尝试都注定失败。更糟的是,这些请求在超时之后会集体转向数据库,把故障从缓存层转移到事实源。

短超时只能减少”单次伤害”,还需要一个机制让系统记住刚才已经失败过,在冷却期内直接拒绝新的尝试。这就是熔断。

一个简化熔断器的状态机

熔断器的最小实现是一个三态状态机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public <T> T call(Supplier<T> work) {
long until = openUntil.get(), now = System.nanoTime();
boolean probe = until != 0; // 0 表示正常;非 0 表示之前失败过
if (probe && (now < until || !probing.compareAndSet(false, true))) {
throw new CacheUnavailable(); // 冷却未结束,或已有线程在探测
}
try {
T result = work.get();
if (probe) openUntil.compareAndSet(until, 0); // 恢复:清掉失败状态
return result;
} catch (DataAccessException e) {
openUntil.set(System.nanoTime() + settings.getRedisCooldownMs() * 1_000_000);
metrics.counter("lab.redis.failure", "usage", "cache").increment();
throw new CacheUnavailable();
} finally {
if (probe) probing.set(false); // 失败后也要释放探测资格
}
}

这段几十行的代码实现了一个完整的三态状态机:

状态 判定条件 行为
关闭(正常) openUntil == 0 所有调用直接执行 Redis 命令
打开(熔断) now < openUntil 快速失败,不再发 Redis 命令
半开(探测) 冷却结束,CAS(false, true) 成功 只放一个线程去试探,成功则关闭熔断,失败则重新打开

几个设计细节:

  • 冷却期内所有请求立即失败,不再各花 300 毫秒重新发现同一个事实。这就是熔断与超时的区别:超时限制单次等待,熔断共享失败信息。
  • 恢复探测用 CAS 抢占资格,保证冷却结束后只有一个线程去打 Redis,避免”冷却一结束,全部请求同时涌入”。
  • 恢复用 compareAndSet(until, 0),只清除自己观察到的那个失败状态,不会把一个更晚发生的失败覆盖掉。
  • finally 里释放探测资格,探测失败时也能让下一轮继续探测。

它的边界也很清楚:只根据单次异常开关,不做滑动窗口统计;一次成功就恢复,不验证整条缓存链路;状态按实例隔离,实例 A 熔断时实例 B 仍会尝试;已经发出的调用不会被取消。生产环境可以在此基础上补充失败率窗口、慢调用阈值、半开探测次数和恢复条件。

这个最小版本解决的核心问题只有一个:让一段时间内的新请求共享已经观察到的失败。

熔断只负责快速失败,不负责成功。它只是尽快宣布”副本暂时不可用”,把请求交给受保护的数据库路径。如果数据库容量不够,仍然要拒绝部分请求。

回源并发保护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public Optional<AppView> load(String tenant, long id) {
if (!permits.tryAcquire()) { // 不在这里无限排队
metrics.counter("lab.db.rejected").increment();
throw new BusinessException(503, "回源并发已达上限,请稍后重试");
}
Timer.Sample timer = Timer.start(metrics);
try {
loads.incrementAndGet();
metrics.counter("lab.db.load").increment();
if (settings.getDbDelayMs() > 0) Thread.sleep(settings.getDbDelayMs());
return jdbc.query("SELECT * FROM app_config WHERE tenant=? AND app_id=?",
(r, n) -> new AppView(r.getString("tenant"), r.getLong("app_id"),
r.getString("name"), r.getLong("version")), tenant, id)
.stream().findFirst();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException(503, "请求被中断");
} finally {
permits.release(); // 成功、失败、中断都要归还许可
timer.stop(metrics.timer("lab.db.duration"));
}
}

这段代码用 8 个许可给数据库入口加了一道闸门,有三个要点:

第一,用 tryAcquire 立即拒绝,避免阻塞排队。 排队只是把压力从数据库挪到线程池,最终仍会耗尽线程。立即拒绝并返回 503,是明确、可观测的容量信号。

第二,信号量只覆盖本方法的回源读取。 它只覆盖应用配置的回源读取;布隆目录构建、Token 注册与刷新、Outbox 操作走的是别的路径,不共享这 8 个许可。上传校验应用是否存在时调用 apps.load,会共享这份容量。做容量估算时要把这些入口都算进去。

第三,要按实例总数换算数据库的全局并发。 每个 JVM 各有 8 个许可,两个实例就是 16 个并发查询,实例扩容会线性放大。数据库连接池(Hikari 最大 12、获取超时 1 秒)是另一层限制:信号量放行之后,请求还可能在连接池里继续等待。连接获取超时管的是”拿到连接”,不等于 SQL 执行超时,生产环境还需要查询超时、索引与慢 SQL 治理。

演示配置里的 db-delay-ms 用来放大并发窗口,观察限流效果,正常情况下为 0。它只服务于实验,与真实数据库的成本无关。

熔断与数据库容量边界

不同失败用不同状态码

状态码是接口契约的一部分,选择哪个码,决定了客户端应该怎么处理:

状态 含义 客户端建议
400 参数不合法 修正请求,不重试
401 会话已吊销或无效(由授权检查返回) 重新登录或刷新凭据
404 对象确定不存在 不重试,展示空状态
409 版本冲突 / 同一业务版本内容不同 重新读取或修改请求后再提交
423 资源正在处理(上传锁竞争) 延迟后重试同一个幂等请求
503 容量不足 / 依赖暂不可用 有上限、带抖动地重试,并遵守总时间预算

重试必须贯穿整条调用链来设计。服务端已经把回源并发限到 8,客户端如果立刻并发重试十次,等于把刚建好的闸门冲垮;网关、SDK、业务代码如果各自加一层重试,实际放大倍数会相乘。

还有一个容易混淆的指标语义:缓存里读到坏 JSON 时,请求会降级回源,但不会触发 Redis 网络熔断,因为解析发生在 Redis 调用成功返回之后。所以 degraded 计数上去了,不一定代表 Redis 宕机,要结合 corrupt、lab.redis.failure 和数据库指标一起判断原因。

缓存故障不必让实例退出

示例的 readiness 分组只包含 readinessState 和数据库,不包含 Redis。理由很直接:配置查询在 Redis 故障时可以受限降级,如果所有实例都因为共享缓存故障而从流量入口消失,反而会把原本能降级的查询变成全面不可用。

这是一个有意识的取舍:实例 ready 表示”我可以提供承诺的基本服务”,不表示”所有依赖都正常”。 上传新操作和授权检查在 Redis 故障时会返回失败,这部分能力损失应该通过业务接口和指标表达;把实例整体摘除并不能恢复这些能力。不同接口共享一个进程时,“一个总体的 UP”本来就无法描述所有能力,运维需要知道的是哪些功能降级了。

管理端口(默认 9081)只绑定本机,show-details: always 方便实验,但生产环境必须限制管理面的访问,避免暴露依赖与配置信息。鉴权和租户权限在这个实验应用里没有完整实现,本地学习可以放开,部署时必须补齐。

客户端连接

最后补充一个容易被忽略的环节:一条 Redis 命令从应用到服务端,连接是怎么被复用的。项目使用 Lettuce,Spring Data Redis 默认共享原生连接,普通操作复用同一条连接上的多个请求。由此可以澄清三个常见误解:

  • max-active: 8 限制的是连接池里可借出的独占连接数量,不代表”普通 GET 最多 8 个并发”。连接池主要服务于需要独占连接的操作,例如部分阻塞或事务场景;
  • 启动后不一定立刻建立 8 条连接,池按需创建;
  • StringRedisTemplate 的同步调用依然会让调用线程等待结果。底层 Netty 使用异步 I/O,这提升的是网络线程的效率,不改变”请求线程可能阻塞 300 毫秒”这个事实。

客户端还配置了一层主动防御:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
LettuceClientConfigurationBuilderCustomizer boundedClient() {
return builder -> builder.clientOptions(ClientOptions.builder()
.autoReconnect(true) // 允许恢复连接,但恢复不等于业务可以无限排队
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.requestQueueSize(128) // 限制断连和拥塞时客户端内存中的命令积压
.timeoutOptions(TimeoutOptions.enabled(Duration.ofMillis(300)))
.socketOptions(SocketOptions.builder()
.connectTimeout(Duration.ofMillis(300))
.keepAlive(true)
.build())
.build());
}

自动重连负责恢复连接,REJECT_COMMANDS 让已断开的连接尽快拒绝新命令,requestQueueSize 防止应用把大量命令堆在内存里。128 是本地实验值,需要结合命令大小、并发量、断连时长和可接受的排队延迟来评估。

这些参数控制单次通信与客户端积压,业务熔断控制”是否还值得继续尝试”,两者互补。连接健康也不能只看 PING:网络正常但命令持续变慢、内存写入被拒、Lua 执行时间过长,都会影响真实请求,需要用不同的指标分别解释。容量评估要把实例数、请求线程数、客户端排队和 Redis 执行能力放在一起看,单纯调大连接池可能让更多请求同时压向同一个服务端。

上传场景:锁减少竞争,幂等保护结果

业务幂等键与锁粒度

上传的业务身份是 (tenant, appId, releaseVersion),内容用 contentHash 表示。业务约定有两条:

  • 同一版本、同一内容重复提交,返回第一次的平台结果,不重复发布;
  • 同一版本、不同内容提交,返回 409,不允许悄悄覆盖。

锁的粒度是应用(upload-lock 只包含 tenant 和 appId),而幂等键的粒度是版本。两者粒度不同,各有业务原因:

  • 锁要表达”这个应用正在发布,别的发布先等等”,所以按应用加锁,同一应用的版本 1 和版本 2 也会竞争;
  • 幂等键要表达”这个版本的成功结果可以复用”,所以必须包含版本,版本 A 的成功不能当成版本 B 的成功。

contentHash 在这个实验接口里由调用者提供,服务端只校验它是 64 位小写十六进制字符串。真正接入上传时,应由服务端读取文件字节、计算摘要,并限制文件大小与格式——只校验字符串形状,证明不了内容没有被替换。

先查结果,再加锁,再查结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Result existing = find("upload_result", tenant, appId, version);
if (existing != null) return samePayload(existing, contentHash);

String key = keys.uploadLock(tenant, appId);
String owner = lease.acquire(key, settings.getLockLeaseMs()); // 不经过缓存熔断降级
if (owner == null) {
throw new BusinessException(423, "应用正在上传,请稍后重试");
}
try {
existing = find("upload_result", tenant, appId, version); // 等锁期间可能已完成
if (existing != null) return samePayload(existing, contentHash);
if (apps.load(tenant, appId).isEmpty()) {
throw new BusinessException(404, "应用不存在");
}
Result delivered = deliverIdempotently(tenant, appId, version, contentHash);
try {
jdbc.update("INSERT INTO upload_result VALUES(?,?,?,?,?)",
tenant, appId, version, contentHash, delivered.providerId());
} catch (DuplicateKeyException e) {
// 租约丢失后,另一个执行者可能先写入结果,读取并校验。
return samePayload(Objects.requireNonNull(
find("upload_result", tenant, appId, version)), contentHash);
}
return delivered;
} finally {
releaseQuietly(key, owner);
}

这个流程里有三层保护,分别处理不同的问题:

  1. 第一次查询:已经成功过的上传不需要抢锁。Redis 故障期间,重复的相同上传仍然可以从数据库返回结果,这是”安全路径不依赖缓存”的一个正面例子。
  2. 第二次查询:第一次查询到拿到锁之间,别的请求可能已经完成了上传。所以需要查一下
  3. 唯一约束冲突处理:租约可能在处理过程中过期,另一个执行者先写入了结果。数据库唯一约束拒绝第二次插入,代码捕获冲突后读取已有结果并校验内容。锁是正常路径的竞争优化,唯一约束才是资源的最终边界。

Token 吊销

JWT 的优势与撤销的代价

JWT 的卖点是”无状态”:签名验证通过后,服务端在一段时间内不需要查询会话状态。但用户退出、管理员封禁、账号风险处置都要求提前撤销一个尚未过期的 Token。常见的加速方案是用 Redis 维护吊销黑名单:命中就拒绝,未命中就放行。

问题恰恰出在”未命中就放行”上。黑名单是异步补偿的(数据库先记录事实,再由 Outbox 写 Redis),补偿尚未执行时,一个已被吊销的 Token 仍然会 miss;Redis 故障时,黑名单整体不可用。如果把这些情况都解释成”未吊销”,安全策略就被一次网络失败绕过了。

所以这个示例把会话数据库作为事实源,Redis 只是加速结构。

会话表:先有事实,再有缓存

会话表记录 (tenant, digest)、过期时间和 refresh_blocked。原始 jti 先经 SHA-256 转成摘要,再进入键和日志:

  • 摘要避免原始 Token 标识直接出现在键名、日志和监控里;
  • 摘要只做标识,不做加密,知道输入的人仍能算出相同摘要,它不替代签名验证;
  • 真实系统必须先完成 JWT 验签、发行方与受众校验,再使用其中可信的 jti 和 exp。

注册采用 INSERT,遇到重复主键返回 409,避免覆盖式写入把 refresh_blocked 改回 false。这个选择防的是”重复注册同一个 jti 把已吊销的会话复活”。演示还限制有效期必须在未来 24 小时内,用来控制实验数据规模,不是通用身份系统的标准。

单位是一个隐蔽的坑:JWT 的 exp 通常是秒级时间戳,而示例的 expiresAt 明确用毫秒。对接真实 Token 时必须在输入边界做转换,并考虑时钟偏差。把秒当作毫秒会立即过期,把毫秒当作秒会得到极长的生命周期。

吊销:先提交事实,再异步补偿黑名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void revoke(String tenant, String jti) {
validate(tenant, jti);
String digest = KeySpace.digest(jti);
tx.executeWithoutResult(status -> {
Session session = session(tenant, digest);
if (session == null) throw new BusinessException(404, "会话不存在");
jdbc.update("UPDATE token_session SET refresh_blocked=TRUE " +
"WHERE tenant=? AND digest=?", tenant, digest);
if (session.expiresAt() > System.currentTimeMillis()) {
outbox.add("TOKEN_DENY", tenant, digest, session.expiresAt());
}
});
metrics.counter("lab.token.result", "result", "revoke_accepted").increment();
}

吊销返回 202,含义是”吊销事实与补偿任务已经被接受”;Redis 黑名单可能还没写好。此时数据库已经标记 blocked,刷新检查会拒绝;Redis 故障期间吊销依然可以提交,因为写入动作不需要同步访问 Redis。重复吊销会产生额外事件,但 blocked 不会回到 false——业务效果可重复。

授权检查:两层判断,未知不放行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void authorize(String tenant, String jti) {
validate(tenant, jti);
String digest = KeySpace.digest(jti);
if (Boolean.TRUE.equals(redis.hasKey(keys.deny(tenant, digest)))) {
reject("redis_denied"); // 命中立即拒绝,省一次数据库读取
}
if (!permits.tryAcquire()) {
throw new BusinessException(503, "授权状态校验繁忙");
}
try {
Session session = session(tenant, digest);
if (session == null || session.blocked()
|| session.expiresAt() <= System.currentTimeMillis()) {
reject("db_denied"); // 未命中黑名单,仍然要查持久事实
}
metrics.counter("lab.token.result", "result", "allowed").increment();
} finally {
permits.release();
}
}

判断顺序是”先 Redis,再数据库”:命中黑名单是最快的拒绝路径;未命中时必须查数据库,因为黑名单可能还没补偿。Redis 调用异常会发生在信号量与数据库查询之前,由统一异常处理返回 503——项目选择了保守策略,不把”不知道”解释为”没吊销”。

删除黑名单键后再授权,数据库仍然会拒绝,说明安全性不依赖 Redis 键的存活。代价是正常 Token 每次未命中都要查库,授权吞吐不再是纯 Redis 吞吐;这份数据库读取由 8 个许可保护,它是 Token 服务自己的信号量,与 AppRepository 的回源许可相互独立。refreshAllowed 直接查数据库,没有经过同一个信号量,生产环境应该统一评估所有安全状态入口,不能只保护其中一个方法。

吊销也无法追回”已经通过检查、正在执行”的请求。需要更强撤销语义时,要在关键业务提交点再次检查状态,或者明确会话撤销的生效边界。如果生产数据库使用滞后的只读副本,立即拒绝的承诺也会被打折扣,这属于读一致性问题,示例没有覆盖。

绝对到期时间

1
2
3
4
5
6
7
8
9
10
-- deny-extend.lua:ARGV[1] 是数据库中的绝对到期时间,单位毫秒。
local now = redis.call('TIME')
local millis = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
local ttl = tonumber(ARGV[1]) - millis
if ttl <= 0 then return 1 end -- Token 已过期,无需建立键
local current = redis.call('PTTL', KEYS[1])
if current < ttl then
redis.call('SET', KEYS[1], 'revoked', 'PXAT', ARGV[1])
end
return 1

如果每次补偿都写 PX 60000,一个原本 60 秒后过期的 Token 会在每次重试时被重新计时,生命周期被不断延长。用数据库里的绝对截止时间,5 秒后重试只剩 55 秒,10 秒后重试只剩 50 秒,截止时间不随重试漂移。

脚本的细节:

  • 用 Redis 的 TIME 计算当前毫秒时间,以 PXAT 写入绝对到期时间(SET ... PXAT 从 Redis 6.2 起可用,本项目运行在 7.2 上);
  • PTTL 对不存在的键返回 -2,对没有 TTL 的键返回 -1,都小于正的剩余时间,因此会为它建立有边界的过期状态;
  • 已经保留更久的键不会被缩短;
  • Token 已过期时直接返回 1,表示”补偿目的已不再需要”,避免产生一次 SET。Java Worker 因此可以正常删除这条事件,数据库的过期校验仍会拒绝它。
Token 吊销与授权检查的事实来源

Lua 的工程化:小脚本、固定参数、明确返回

脚本对象缓存与执行机制

脚本的第一件事是交付:文件放在 classpath 的 lua/ 目录,Java 侧缓存脚本对象,执行时再传入明确的参数。

1
2
3
4
5
6
7
8
9
10
11
private static final ConcurrentHashMap<String, DefaultRedisScript<Long>> SCRIPTS
= new ConcurrentHashMap<>();

public static DefaultRedisScript<Long> script(String file) {
return SCRIPTS.computeIfAbsent(file, name -> {
var script = new DefaultRedisScript<Long>();
script.setLocation(new ClassPathResource("lua/" + name));
script.setResultType(Long.class); // 与 Lua 的 0 / 1 / -1 返回协议一致
return script;
});
}

项目把脚本文件放在 classpath 的 lua/ 目录,Java 侧缓存 DefaultRedisScript 对象,每次调用传入明确的 KEYS 和 ARGV,不为每个请求拼接不同的脚本文本。

这样做有三个好处:脚本可复用、便于审查、参数协议固定。

执行层面上,Spring Data Redis 会先按脚本摘要尝试 EVALSHA,服务端没有这份脚本时回退到 EVAL 加载再执行。由此可知,Java 侧缓存了脚本对象,不代表 Redis 永远记得这份脚本。脚本缓存只存在于服务端内存,Redis 重启后需要重新加载。脚本文件必须随应用一起交付,恢复流程不能依赖管理员手工预加载。

七个脚本,各负责一件事

项目一共七个 Lua 脚本,每一个都只承担一个很小的原子动作:

脚本 作用 返回协议
unlock.lua 比较所有者后删除锁 1 删除成功,0 不是自己的锁
cache-publish.lua 校验锁所有者与失效代际后写缓存 1 发布成功,0 被拒绝
cache-invalidate.lua 推进代际并删除缓存 固定 1
bloom-add.lua 一个元素的七个位置置位 固定 1
bloom-check.lua 校验签名与位图后查询成员 0 拒绝,1 可能,-1 不知道
bloom-activate.lua 校验构建者后原子替换位图 1 成功,0 被拒绝
deny-extend.lua 按绝对到期时间写黑名单 固定 1

它们共同遵守三条工程纪律:

  1. 脚本要短。 脚本执行期间 Redis 的主线程被占用,长脚本会阻塞所有其他命令。数据库查询、HTTP 调用、大规模业务计算都不应该放进 Lua,脚本里也不能访问本地 Java 服务。
  2. 参数走 KEYS 和 ARGV。 键名和参数通过参数传入,脚本文本保持稳定,摘要稳定、可审查。
  3. 返回协议简单明确。 统一使用 Long,用 0/1/-1 表达结果,Java 侧对每个值有确定的处理分支。

原子边界只覆盖 Redis 内部

前面几章反复出现同一个模式:客户端先读、再判断、再修改,两步之间状态可能变化。把它们塞进同一个 Lua 脚本,检查和修改之间就不会插入其他客户端的命令:

  • 解锁时”读 owner 再 DEL”;
  • 发布时”读 owner、读代际、再 SET”;
  • 失效时”INCR 代际、DEL 缓存”;
  • 激活布隆时”校验构建者、RENAME、写签名”。

这就是 Lua 在缓存设计里的核心价值:把判断依据和修改动作绑定在同一次 Redis 执行中。

但它不等同于数据库事务:脚本执行到一半出错时,已经执行的 Redis 写操作不会自动回滚;脚本原子性也只在 Redis 内部成立,无法让数据库与 Redis 一起提交。所以脚本应该”先做完所有检查,再做少量有边界的修改”,把可能失败的逻辑放在前面,把副作用控制在很小的范围里。

持久化与内存策略

先分类,再持久化

持久化配置不能一刀切,因为不同数据丢失后的后果完全不同:

数据 丢失后的后果 合理的保护方式
应用配置缓存 性能下降,可重建 可以不持久化
布隆位图 过滤器未就绪,必须停止”确定不存在”的判断 可重建,重建期间降级
失效代际 旧快照可能被发布(8.6 节的 ABA) 持久化 + 不淘汰
Token 吊销黑名单 拒绝变慢,但数据库仍是事实源 持久化,数据库兜底
上传锁、重建锁 孤儿锁最多存活一个租期 不需要持久化

示例提供两份配置:redis-state.conf 作为主实验配置(包含状态元数据),redis-cache.conf 作为纯缓存对照(关闭持久化、允许淘汰)。它们是两个独立实例的模板,应用并没有配置两个连接工厂自动路由——主应用默认连接 6380 的 state 实例,所有键共享同一套策略。

还有一点,Redis 的逻辑数据库编号不能隔离持久化和内存策略。把缓存放在 DB 0、黑名单放在 DB 1,不会让两边拥有独立的 AOF 配置或 maxmemory 上限。要按数据角色使用不同策略,必须部署独立实例并分别配置客户端。

RDB、AOF 与 fsync

三种机制解决三个不同的问题:

机制 做什么 恢复时的代价
RDB 某个时间点的全量快照 两次快照之间的写入可能丢失
AOF 记录重建数据所需的写命令,恢复时重放 文件更大,恢复更慢,需要重写整理
fsync 控制 AOF 何时真正同步到存储介质 决定故障时最多丢多少写入

appendfsync everysec 用后台线程每秒同步一次,通常可以获得较小的丢失窗口,但它无法承诺”任何机器、任何磁盘、任何故障下都严格不超过 1 秒”。操作系统缓存、磁盘缓存和虚拟化层都会影响真实落盘时间。

主配置的关键项如下:

1
2
3
4
5
6
7
8
9
10
save 900 1
save 300 10
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
no-appendfsync-on-rewrite no
maxmemory 128mb
maxmemory-policy noeviction

几个容易误读的地方:

  • save 900 1 表示”900 秒内至少有 1 次变化”才触发快照,到点但没有变化时不会拍照;
  • AOF 重写(rewrite)用更紧凑的表示整理日志,控制恢复成本和文件增长,它和 fsync 是两件事,不能把”正在重写”理解为”已经更持久”;
  • no-appendfsync-on-rewrite no 表示重写期间不跳过原有的 fsync 策略;
  • Redis 7 的 AOF 由基础文件、增量文件和 manifest 组成,恢复和备份要按实际目录结构处理,不能只找一个旧教程里的 appendonly.aof;
  • AOF 的基础文件可以是 RDB 格式(aof-use-rdb-preamble),这不代表关闭了 AOF。

排查持久化状态时,应该结合配置、数据目录和 INFO persistence 一起看,只凭文件扩展名容易猜错。

noeviction 的取舍

主配置选择 noeviction:内存达到上限后,不主动淘汰键,而是拒绝需要分配内存的写操作。这保护了代际、锁、黑名单这些”丢了会破坏协议”的元数据,代价是写操作可能失败:

  • 缓存发布失败会被记录,本次请求仍返回数据库结果;
  • Outbox 写 Redis 失败会重试,任务可能逐渐积压;
  • 数据库里的权威状态不受影响,但任务时效性和系统性能会下降。

纯缓存对照配置则使用 allkeys-lfu,允许按访问频率近似淘汰对象。它只适合可重建的纯缓存数据;如果把这套配置直接用于代际和协调状态,元数据被淘汰可能破坏 8.6 节的前提。项目保留这份模板是为了做对照实验,不建议不加分析地整体切换。

最后把三个维度分开记住:

  • 过期由键的 TTL 决定;
  • 淘汰由内存压力和淘汰策略决定;
  • 持久化解决进程重启后的数据恢复。

三者互相独立,需要分别配置。

常见误区与延伸设计

主线讲完,最后处理几组经常被混为一谈的概念。它们都值得单独成篇,这里只讲清边界,避免在设计时选错工具。

为什么没有直接用 @Cacheable

Spring Cache 的注解适合表达简单的读缓存、写缓存和失效规则,但本项目的读链路需要显式处理:布隆三态、跨 JVM 重建、短空值、代际发布校验、锁等待拒绝、Outbox 补偿。把这些全部藏进注解后面,异常分支和协议边界会变得不可见,也不利于逐个验证。

这并不说明注解不适合生产。更常见的做法是分层:稳定的序列化、键规范、简单查询用 Spring Cache;需要特殊一致性协议的路径保留显式实现。使用注解时仍要理解代理调用与自调用、缓存管理器配置、空值策略,以及框架的同步加载是否真的覆盖跨进程——sync=true 解决的是单 JVM 内的并发;两个实例之间的竞争仍需另外处理。

逻辑过期与返回旧值

另一种热点防护是逻辑过期:缓存里保存一个业务过期时间,物理 TTL 设置得更长。请求发现逻辑上已过期时,先返回旧值,同时让一个受限的后台任务去刷新。它把”到期瞬间的等待和 503”换成了”短暂返回旧值”,对新闻、商品详情这类允许短暂陈旧的场景很实用。

代价同样明确:用户可能看到旧数据;后台刷新需要线程池、刷新锁、失败重试,还要定义”旧值最多能旧多久”。权限配置、风控策略这类数据不适合这个方案。示例项目选择了物理 TTL + 有限等待 + 同步重建,是因为教学目标是展示协议本身;两种方案没有绝对优劣,区别在于把不确定性放在等待上,还是放在新鲜度上。

本地缓存与多级失效

在 get 前面加一层 Caffeine 能减少 Redis 往返,但会引入一个更难处理的问题:多个 JVM 各自保留本地副本,Redis 里的失效不会自动删除它们。 广播失效消息可能丢失,本地 TTL 又会让不同实例在不同时刻看到不同值。如果要做 L1 本地 + L2 Redis + 数据库,需要重新定义:

  • 本地副本的失效传播机制(消息、版本号、短 TTL);
  • 命中指标的口径(本地命中会绕过 Redis 代际读取);
  • 容量分配和更新窗口。

“在最前面加一个 Map”不会让原协议自动成立。本项目没有实现 Caffeine,因此文中两 JVM 的验证结果不包含本地缓存一致性。

限流、防重放、分布式锁、业务幂等

这四个概念都可能用 Redis 实现,但状态模型完全不同:

概念 控制什么 典型实现 失败时的语义
限流 一段时间内允许多少请求 固定/滑动窗口、令牌桶 超出配额直接拒绝
防重放 同一个可信请求是否被再次接受 nonce + 时间窗口 + 原子消费 重复请求拒绝
分布式锁 一段时间内谁占用资源 SET NX PX + 所有者校验 抢不到等待或失败
业务幂等 重复执行是否得到一致结果 业务唯一键 + 结果记录 返回第一次的结果

上传幂等没有限制”一秒内能提交多少不同版本”,回源信号量与用户级频控无关。如果业务需要限流,应按租户、接口、用户设计配额,并明确 Redis 故障时的策略;防重放需要可信签名、时间窗口和原子消费,只拿用户可随意填写的 requestId 做 SETNX,并不能解决安全问题。

参数能不能在线改

LabSettings 的字段可变,不代表运行中的组件会随之重建。最典型的是 AppRepository:信号量在构造时按 fallbackConcurrency 创建,之后修改配置字段不会改变已有的许可数量,而 Gauge 又按配置字段计算占用,可能和实际许可数对不上。测试保持参数不变,避免了这种偏差。

生产中要把参数分成三类:可以热更新的(超时、日志级别)、必须重建组件的(信号量、线程池、连接池)、必须走协议升级的(布隆容量与哈希次数)。第三类尤其危险,它们属于数据协议,修改必须伴随版本与重建,一次配置推送并不够。

用一组问题检查是否真正掌握

先不看源码回答,再回到对应章节验证。重点在于说清触发条件、执行顺序和失败后的结果,名词是次要的。

  1. 为什么空值缓存和布隆过滤器要同时存在? 空值缓存挡住”同一个不存在 ID”的重复查询,布隆挡住”大量不同不存在 ID”的流量;过滤器未就绪或误判时,仍需要负缓存和有限回源兜底。

  2. 重建锁和失效代际为什么不能互相替代? 锁约束”同一时刻谁来重建”,代际拒绝”查询期间发生过失效的旧快照”;发布时还要校验锁所有者,三者解决三个不同问题。

  3. 更新成功后立刻读取,一定是新值吗? 不一定。Outbox 是异步补偿,204/202 只表示意图已接受,提交与失效执行之间存在可见性窗口。协议是带补偿的最终一致。

  4. 为什么用 Lua 而不是”先 GET 再 SET”? 检查与修改之间不能插入其他客户端命令。脚本提供 Redis 内部的原子执行边界,但它只覆盖 Redis,出错时不会回滚已执行的写操作,也无法让数据库与 Redis 一起提交。

  5. 锁过期后,旧执行者还会继续运行吗? 会。Redis 不会中断 Java 线程。缓存靠发布脚本校验 owner 与代际,上传靠唯一约束和平台幂等,严格顺序还需要资源端校验 fencing token。

  6. Redis 故障时,为什么缓存可以回源,授权却返回 503? 数据的业务角色不同:缓存是可重建副本,授权状态的事实源在数据库,而”黑名单未命中”在 Redis 不可用时无法解释为”未吊销”。未知状态不能放行。

  7. Outbox 为什么不是恰好一次? Redis 执行成功与事件删除之间存在中断窗口,多个 Worker 也可能重复领取。副作用必须设计成可重复执行。

  8. 布隆理论上没有假阴性,为什么位图丢失后必须放行? “没有假阴性”依赖结构完整与算法一致;结构丢失时,用三态返回把”不知道”和”不存在”分开。

  9. 连接池配了 8,为什么普通 GET 的连接数不是 8? Lettuce 默认共享原生连接,多个命令复用同一条连接;连接池主要用于需要独占连接的场景(阻塞操作、事务)。池大小也不等于服务端执行线程数。

  10. AOF everysec 能让 Outbox 不再必要吗? 不能。持久化解决 Redis 自身的数据保存与恢复,Outbox 解决跨系统”提交之后还要做什么”的补偿意图,两者针对不同失败窗口。

  11. pending 为零能证明所有更新都强一致吗? 只能说明当前没有未删除的补偿任务,无法证明历史读窗口、已返回的旧值或未被监测的路径。

  12. 这个实践适合在面试里怎么讲? 讲业务背景、自己补的实验、实际代码和本机证据;区分哪些是实习中参与的工作、哪些是后来补做的验证;不要把本地结果说成线上指标,也不要省略适用条件。

附录 A:动手复现

以下命令从项目根目录执行。Redis 使用项目专用目录和 6380 端口,不会覆盖安装目录的全局配置;端口被占用时脚本会拒绝启动。

1
2
3
4
5
6
7
Set-Location 'D:\WorkSpace\PersonalLearn\DBLearn\RedisLearn\demo\RedisInMicroservices'

# 终端一:启动项目自己的 Redis 状态实验实例
.\scripts\start-redis.ps1 -Mode state -Port 6380

# 终端二:启动应用(脚本选择 JDK 21)
.\scripts\run.ps1 -Port 8080 -ManagementPort 9081 -RedisPort 6380

观察读链路时,记录操作前后的计数差值,而不是只看计数是否大于零:

1
2
3
4
5
6
$base = 'http://127.0.0.1:8080/api/tenants/demo'
Invoke-RestMethod "$base/apps/1001"
Invoke-RestMethod "$base/apps/1001"
Invoke-RestMethod 'http://127.0.0.1:9081/actuator/metrics/lab.db.load'
.\scripts\redis-cli.ps1 -Port 6380 GET 'rims:local:app:{demo:1001}:cache:v1'
.\scripts\redis-cli.ps1 -Port 6380 GET 'rims:local:app:{demo:1001}:generation'

更新一次配置,观察 Outbox 与代际推进:

1
2
3
4
5
6
7
$current = Invoke-RestMethod "$base/apps/1001"
$body = @{ name = '学习实验中的新应用名称'; expectedVersion = $current.version } | ConvertTo-Json
Invoke-WebRequest "$base/apps/1001" -Method Put -ContentType 'application/json' -Body $body

# 204 不承诺缓存已失效,多观察几次直到任务完成
Invoke-RestMethod "$base/apps/1001"
Invoke-RestMethod 'http://127.0.0.1:9081/actuator/metrics/lab.outbox.pending'

上传与 Token 实验:

1
2
3
4
5
6
7
8
9
10
11
12
$hash = 'a' * 64   # 演示用声明值,不是对文件计算出的摘要
$upload = @{ releaseVersion = 'learn-1'; contentHash = $hash } | ConvertTo-Json
$first = Invoke-RestMethod "$base/apps/1001/uploads" -Method Post -ContentType 'application/json' -Body $upload
$again = Invoke-RestMethod "$base/apps/1001/uploads" -Method Post -ContentType 'application/json' -Body $upload
$first.providerId -eq $again.providerId # 应为 True

$jti = 'learn-' + [guid]::NewGuid().ToString('N')
$exp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + 60000
Invoke-RestMethod "$base/tokens/register" -Method Post -ContentType 'application/json' -Body (@{jti=$jti; expiresAt=$exp} | ConvertTo-Json)
Invoke-RestMethod "$base/tokens/check" -Method Post -ContentType 'application/json' -Body (@{jti=$jti} | ConvertTo-Json)
Invoke-RestMethod "$base/tokens/revoke" -Method Post -ContentType 'application/json' -Body (@{jti=$jti} | ConvertTo-Json)
# 再调用 check 会得到 401,PowerShell 会抛异常,应看 HTTP 状态而不是脚本报错

自动验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 需要 Redis 已启动
.\scripts\run.ps1 -Test -RedisPort 6380

# 双 JVM 验证脚本会直接 java -jar 启动两个进程,因此先打包
$prevJava = $env:JAVA_HOME
$prevPath = $env:PATH
try {
$env:JAVA_HOME = 'D:\Java Setting\jdk\jdk-21'
$env:PATH = (Join-Path $env:JAVA_HOME 'bin') + ';' + $env:PATH
.\mvnw.cmd -B package
if ($LASTEXITCODE -ne 0) { throw '打包失败,请检查 Maven 输出' }
} finally {
$env:JAVA_HOME = $prevJava
$env:PATH = $prevPath
}

python .\scripts\verify_http.py

# 持久化对照,默认使用 6382/6383,应提前确认端口空闲
.\scripts\verify-persistence.ps1

一个真实的排查经验:在 Windows 的 MSYS 版 redis-cli 里,{demo} 可能被 shell 展开成 demo,导致命令行查到的键和 Java 写入的键不一致。项目用包装脚本临时设置 MSYS=noglob,因此上面的命令统一走 redis-cli.ps1。查不到键不等于没有写入:先核对地址、端口、逻辑 DB、命名空间、完整键、编码与参数变换,再怀疑 TTL 和时间窗口。位图也不要当 JSON 用 GET 后分析,应该用位操作、STRLEN 和 ready 签名确认状态。

附录 B:源码索引

路径相对于 markdown/ 目录,与文章同级的 demo/RedisInMicroservices 是项目根目录。

内容 位置
完整配置 application.yaml
数据表与种子数据 schema.sql
参数绑定 LabSettings
键协议 KeySpace
Redis 客户端配置 RedisClientConfiguration
Lua 加载与执行 Lua 与 脚本目录
查询主链路 AppCacheService、AppView
有限事实读取 AppRepository
布隆生命周期 AppBloomFilter
更新事务 AppCommandService
有限租约锁 RedisLease
熔断与异常 CacheRedis、CacheUnavailable
补偿存储与执行 OutboxStore、OutboxWorker
上传与 Token UploadService、TokenStateService
HTTP 入口与异常 AppController、StateController、ErrorHandler
集成断言 ProductionScenariosTests
双 JVM 验证 verify_http.py
持久化对照 verify-persistence.ps1
验证记录与复盘 docs/07-验证记录与面试复盘.md

附录 C:设计决策速查

问题 采用的方案 付出的代价 不适用的情况
不存在的对象被反复查询 空值缓存 + 布隆三态 新对象最多延迟一个空值 TTL 可见;需要维护目录 目录频繁增删
热点键到期 按对象的租约锁 + 二次检查 + 等待预算 部分请求等待后 503;租期要覆盖查询尾延迟 查询远慢于租期
大量键同时失效 TTL 抖动 缓存寿命不完全可控 需要精确同时失效
Redis 整体故障 熔断 + 回源并发上限 + 明确 503 部分请求失败,本地状态各自为政 数据库容量不足
旧快照回填 代际 + 锁所有者双条件发布 每个失效对象一个无 TTL 元数据键;代际丢失有 ABA 风险 需要严格实时一致的读
更新后的缓存失效 Outbox + 至少一次重试 可见性延迟、补偿治理、死信处理 需要同步强一致
重复上传 应用级锁 + 版本级幂等键 + 唯一约束 锁竞争返回 423;平台需支持幂等 外部平台没有幂等能力
Token 吊销 数据库事实源 + Redis 黑名单加速 正常请求每次 miss 都查库 极高吞吐 + 可接受风险
进程重启后的状态 noeviction + AOF everysec 写入可能被拒;极端故障仍有丢失窗口 纯缓存数据

附录 D:官方资料


一句话总结:缓存是”用一份可重建的副本换取性能”,而这份副本靠一套协议维持正确——键与格式统一、失效可推进、旧快照可拒绝、故障可观测、结论可验证。把协议想清楚,缓存才会从”加上去的性能”变成”可控的工程”。


至此,Redis 系列,完结了