28885 字
144 分钟
缓存系统基础设计:从 Revision 到 Outbox 的一致性实践

1. 背景#

这是一个针对单个业务提交后同步/异步触发大量下游写操作后,就缓存维护进行思考,然后尝试得出解决方案的一次实践。

比如说,现在有一个很常见的用户信息读取服务,它可能涉及到如下场景:

用户请求
-> 读取用户基础信息
-> 读取用户资料
-> 读取角色
-> 读取角色权限
-> 做权限 canonical 映射
-> 返回 UserInfo / Authentication

假如只用一个简单的 key 去维护用户信息缓存:

cache:user:info:{userId}

其中一次角色、权限、用户角色变更,会影响认证上下文、用户详情、权限列表、菜单、前端可见操作、Redis 缓存、还可能会扩散到多实例本地缓存。

如果这些副作用散落在不同 servicelistenerfiltercontroller 里单独处理,难免会有一天出现遗漏、时序错误或跨实例不一致的问题。

  • 角色权限变化影响一定数量的用户
  • 用户角色绑定变化也影响授权结果
  • 权限字典迁移也会影响授权结果
  • 删除权限会影响所有引用该权限的角色和用户
  • 删除 key 的逻辑容易散落在业务服务里

1.1 设计#

正式进入实践之前,将遇到的问题进行一个汇总并思考设计一套可用的解决方案。

1.1.1 缓存域#

一次业务变更影响的缓存需要进行一个统一的管理,结合上面的举例,假设将权限影响的一系列缓存统称为USER_AUTHORIZATION,这个影响的范围就被绑定了一个一致性策略

其它的变更也同理,总之就是将一个业务变动影响的一系列缓存变化的区域统称为缓存域且给予一个业务上的语义化唯一值

1.1.2 Revision#

影响缓存域变动的行为发生之后,该怎么让这个缓存更新呢?

这里引入一个叫做 revision 的概念,当缓存域变动后,对应缓存域的 revision 就进行版本的自增+1来表示某个缓存域当前的版本。

而缓存域里面的所有缓存节点,都可以依赖 revision 作为构成 key 的值,这样读取缓存的时候,就能按照实际的缓存版本读到一个准确的值。

那应该在哪里维护这个 revision 呢?考虑到可能会有的多实例或者未来的微服务影响,最终决定放在 DB

但问题又来了,放在 DB ,岂不是每次读取缓存都要去找 DB

缓存的价值之一,是将高频外部 I/O 访问转化为更低成本的内存访问,这样一来岂不是本末倒置了?

所以这里可以引入本地轻量级的缓存 Caffeine 短暂缓存缓存域 revision 值,实例可以避免每次拼 Redis key 之前都要查 DB

如果某个 revision 背后的缓存域一直被高频更新,很容易形成 UPDATE 行级锁热点。

但问题就在于,既然你需要被高频更新,甚至是一秒内好几个更新──为什么要做缓存呢?

超高频更新但允许读旧值的数据有很多,一般是展示型、统计型、趋势型数据,比如看板在线人数、文章阅读量、商品浏览量之类的。

这类数据更常见的做法是短 TTL + 定时刷新 + 异步聚合 + 内置增量计数器 + 物化视图周期重建 + 滑动窗口,当然,这也是一种缓存的手段,只不过跟目前讨论的不是同一类手段。

像是库存扣减、账户余额、支付状态、权限放行、额度校验之类的功能,不能依赖缓存快照做最终的判断,需要通过 Source of truth 获取到强一致性数据。

不过这种直查 Source of truth 的行为会在高并发下继续优化:

  • 热点 SKU 分段库存,把一行热点拆成多行做负载均衡,降低单行锁竞争。

  • Redis 预扣减 + DB最终确认,靠 Redis 快速挡住大部分请求,然后在一个时间窗口内统一写入 DB, 失败做恢复。

    虽然说着很简单,不过需要幂等、补偿、超卖防护和最终确认,这里只做一个简单的概括。

  • 下单先 reserve,支付成功 confirm,超时 cancel 释放。

  • 乐观锁

  • 也可能是缓存只做展示,下单扣减肯定会直接走 DB

也就是说,如果某个缓存域的 revision 更新频率高到足以形成行级锁竞争,首先考虑的不应是怎么优化 revision 表,而是这个域是否根本不该走缓存路径。

1.1.3 TTL#

新的 revision 来了后,旧的……

没有转转回收,我们得自己回收(雾

为什么不直接删掉旧 key 呢?

这其实就绕回之前的话题了,如果只靠能工智人本身,再怎么严苛的审查,总会有遗漏的时候。

而且旧 revision key 很难完整、低成本地枚举出来,主动删除将会重新陷入高扇出删除带来的问题。

这里的 TTL 分两类,一类是指 Redis 缓存的 TTL ,另一类是指上面提到的 revisionTTL

1.1.3.1 Redis TTL#

既然难做,那就让程序自己做吧,RedisTTL 其中的一个用法得以体现── TTL 过期后自动回收,这其实一种别样的兜底策略。

这样就可以避免高扇出删除带来的问题, Rediskey 值带来的微量内存占用则是必要的代价,对比解决的问题来说显得太小了。

假设 USER_AUTHORIZATION 有 10 万活跃用户,Redis TTL 为 30 分钟,revision 每小时更新 1 次。

最坏情况下,Redis 中同时存在 2 个 revision 版本的 key(旧版在 TTL 内,新版已生成),即 20 万个 key

每个 UserAuthorizationSnapshot 约 2KB,峰值内存约 400MB,可接受。

1.1.3.2 Caffeine TTL#

上面我们引入了 Caffeine 作为缓存,既然是缓存就要考虑 TTL

设置一个长效 TTL ?假设通知 Caffeine revision更新失败/延迟了,如果无法妥善处理,那么直到下一次成功更新前,本地 Caffeine 将会一直保留着旧的 revision ,这期间总不能让用户一直访问一个错误的数据吧?

所以这个 TTL 必然是短效的,这样可以限制旧 revision 被错误使用的时间。

它本身只是把同一秒内大量 revision 查询合并为一次.

根据业务的不同,这个 CaffeineTTL 也可以有所不同,不过一般来说,都是 1s 的短期 TTL

1.1.4 多实例 revision 同步#

如果我们的服务开了多个实例,当 DBrevision 变动之后,就会有实例之间的 revision 同步问题。

毕竟 TTL 本身只是兜底,我们还是要尽可能地让用户更快地访问到一个实时的数据。

所以也就有了Redis Pub/Sub 广播机制引入,在 DB 对应的缓存域 revision 发生变动之后,Pub通知实例,实例通过Sub的方式接收广播去实例中 Caffeine 维护的 revision

1.1.5 广播可靠性#

很遗憾,Redis Pub / Sub 机制本身并没有什么可靠性可言,它本身是在线广播模型,不提供离线消息保存、确认、重试和消费记录状态。

如果延迟或者失败,系统只能依赖 CaffeineTTL 回源 DB 来限制旧 revision 的使用时间。

Caffeine revision 的 短 TTL 机制面前,对于很多业务来说,其实也不要求这个Pub / Sub有多强的可靠性。

不过既然说到了,自然会有业务需要这种可靠性,假如 revision 变更后:

  • 这个行为后续引发的行为必须是可靠的,失败了也能恢复的:需要发送站内信写审计记录,某个业务生成归档 PDF 等等……
  • 失败必须可以追踪:权限字典迁移、运行配置变更、发布契约变更后,需要知道哪些读模型已经刷新,哪些失败,针对性处理。
  • 处理成本高:跑批、搜索索引、统计宽表、外部系统同步等。
  • 影响跨服务:多个微服务之间的信息同步,无法忽略其可靠性的。

而且到了 L6 这个层面,广播本身已经可以确保可靠,那么可以考虑将 revision 的更新异步化,避免占用主线程。

Java 的常用技术栈来说,跨服务可以直接使用 Redis Stream ,语义和运维要单独设计,也可以直接使用 KafkaRabbitMQ 之类的依赖。

如果只是多实例使用的话,就没有这些要求,以 Java 为例:

  • PostgreSQL:持久化 outbox
  • MyBatis-Plus:读写 outbox
  • Jackson:序列化事件 payload
  • Spring Scheduling:定时 relay worker,项目已启用 @EnableScheduling
  • RedisTemplate:继续发送 Pub/Sub
  • Spring Retry:已有,可选;也可以先用 outbox 表字段手写 retry

1.1.6 可观测性#

引入 revisionTTLPub/SubOutbox 后,还需要知道的是:项目运行时怎么其是否仍在设计边界内?

这里就需要埋点,查看后台日志,分析数据。

埋点不是新的缓存一致性方案,也不能让 Pub/Sub 变得可靠。

它的作用是会观察缓存命中,revision 回源、广播失败、广播延迟、outbox 堆积、dispatch 失败以及 dead-letter 等信号。

这些埋点将会用于判断:

  • 当前缓存域的情况是否符合其定位层级?
  • 当前缓存域是否需要升级?
  • outbox 是否需要进一步升级到 broker / CDC
  • 某些关键操作是否应该直接进入 L7 而不是走缓存?

1.2 查阅资料#

经过对缓存形态的汇总以及社区资料的搜索,得出以下表格:

缓存一致性可以按 L1-L7 理解。它们不是互斥方案,能力逐级叠加。

Level名称适用场景典型做法没解决的问题
L1TTL_ONLY展示配置、地区、低风险字典、允许短暂旧读Redis TTL / Spring Cache TTL写后不能立即失效
L2AFTER_COMMIT_EVICT单实体 detail、影响 key 可精确枚举afterCommit 后删除明确 key高扇出写入(一次业务写会同步或者异步触发大量下游写操作)会影响很多 key
L3REVISIONED一个写入影响大量读缓存,旧值可自然过期versioned key,例如 cache:user:authz:v{revision}:{userId}每次读 revision 会读取 DB,且 revision 可能会有高频写入域的情况。
这种情况不要使用缓存,请使用别的方案。
L4LOCAL_REVISION_TTLrevision 高频读,需要降低 DB 压力Caffeine 本地短 TTL 缓存 DB revision多实例得等 TTL 才能看到新 revision,想缩短这个时间的话,则需要广播。
L5AFTER_COMMIT_BROADCAST多实例下希望近实时失效本地读模型,能接受 TTL 兜底事务提交后 + Redis Pub/Sub 失效本地 revision cachePub/Sub 丢消息不可重试,如果广播丢了的话则需要等实例本地 TTL
L6OUTBOX_RELIABLE_EVENT事件可以审计、重试、补偿或跨服务消费outbox/event publication + relay + broker + 幂等consumer保证不了所有读路径即刻强一致。
L7STRONG_SOURCE_CHECK高危操作不能相信缓存执行动作前查 DB/授权 source of truth/风控服务性能成本最高,需要按操作局部使用

可以看到一般的业务止步于 L5 ,而且并不是能上 L5 就上 L5 ,而是最好是像表格这样,根据业务做不同的便捷性配置,集中式声明配置统一使用,而不是在业务代码中零散使用

L6 则是要准备一张 outbox 表去处理事件,先可靠地写进自己的数据库,再慢慢、可重试地发出去。

到最后 L7 级别后又返璞归真,因为总有缓存解决不了的事情,最好将直查的行为局部使用,影响面减小。

因此,本次实践不会追求所有读取都强一致,也不会把所有缓存都升级到最高等级。

更合理的做法是:按缓存域识别影响范围,根据业务风险选择一致性等级。

对于普通 detail 缓存,能精确枚举 key 时使用 afterCommit evict

对于权限这类高扇出派生缓存,使用 revision + TTL + 多实例广播;对于必须可靠处理的副作用,升级到 outbox / reliable event

对于高风险动作,则在执行前直接检查 source of truth

缓存治理的目标不是消灭所有旧读,而是让旧读窗口、失效方式、可靠性边界都变得明确、可 review、可验证。

1.3 决策链#

  1. 这个读取是否高频且结果构造昂贵? 否:不缓存。

  2. 写入后是否必须立即强一致? 是:需要强一致性的业务在关键部分查 Source of truth,缓存只做展示。

  3. 受影响 key 是否能精确、低成本枚举? 是:afterCommit evict

  4. 是否是低频写、高频读、高扇出派生缓存? 是:revision + TTL + broadcast

  5. 副作用是否不能丢、需要审计和重试? 是:outbox / reliable event

  6. revision 是否会因高频推进形成行级锁热点? 是:重新划分缓存域、降低 revision 粒度,或改用短 TTL / 聚合 / Source of truth 检查。

  7. 是否需要跨服务?

    是:L6 级别的缓存需要注意使用 Redis Stream / Kafka / RabbitMQ 等方式做广播。

2. 实践#

实践往往会引出新的问题,这些问题也能验证一些想法的局限性,先做 USER_AUTHORIZATION 缓存域落地吧。

选择这个主要是之前就已经介绍过了,并且功能也足够常见,同时满足:

  • 读频率高:认证链路以及信息获取都依赖缓存。
  • 写频率低:权限、角色、用户角色变更通常是后台管理操作,定下后基本没什么大变化。
  • 扇出高:一次角色权限变化会影响多个用户
  • key 枚举成本高:无法稳定知道所有已生成的用户信息缓存
  • 允许短暂旧读:普通展示与认证上下文可接受极短窗口,真的有强一致性需求可以升级到 Source of truth 检查。

USER_AUTHORIZATION 这个缓存域本身可以到 L6 也可以到 L5 ,当前项目会停留在 L5,当然,也会说 L6 该怎么做。

下面的代码中存在一些我项目中使用到的埋点以及说明,请根据自己实际情况埋点。

2.1 缓存域定义#

CacheDomain.java
package com.zwlh.powergrid.contract.infrastructure.cache;
public enum CacheDomain {
USER_AUTHORIZATION
}

2.2 策略定义#

CacheConsistencyLevel
package com.zwlh.powergrid.contract.infrastructure.cache;
public enum CacheConsistencyLevel {
// L1
TTL_ONLY,
// L2
AFTER_COMMIT_EVICT,
// L3
REVISIONED,
// L4
LOCAL_REVISION_TTL,
// L5 = L3(revisioned key) + L4(local revision cache) + L5(broadcast invalidation)
AFTER_COMMIT_BROADCAST,
// L6
OUTBOX_RELIABLE_EVENT,
// L7
STRONG_SOURCE_CHECK
}
CachePolicy
package com.zwlh.powergrid.contract.infrastructure.cache;
public record CachePolicy(
CacheConsistencyLevel level,
boolean revisioned,
boolean localRevisionCache,
boolean broadcastInvalidation
) {
public static CachePolicy ttlOnly() {
return new CachePolicy(CacheConsistencyLevel.TTL_ONLY, false, false, false);
}
public static CachePolicy afterCommitEvict() {
return new CachePolicy(CacheConsistencyLevel.AFTER_COMMIT_EVICT, false, false, false);
}
public static CachePolicy afterCommitBroadcast() {
return new CachePolicy(CacheConsistencyLevel.AFTER_COMMIT_BROADCAST, true, true, true);
}
public static CachePolicy reliableEvent() {
return new CachePolicy(CacheConsistencyLevel.OUTBOX_RELIABLE_EVENT, true, true, true);
}
}

2.3 策略注册#

CachePolicyRegistry
package com.zwlh.powergrid.contract.infrastructure.cache;
public final class CachePolicyRegistry {
private CachePolicyRegistry() {
}
public static CachePolicy policyOf(CacheDomain domain) {
return switch (domain) {
case USER_AUTHORIZATION -> CachePolicy.afterCommitBroadcast();
};
}
}

2.4 缓存 key 定义#

我这边有一个项目通用的 CacheKey 存放区域,请按照自己的项目情况定义:

CacheKey.java
public static final String USER_AUTHORIZATION_KEY = "cache:user:authz:v%s:%s";

格式化方法:

String.format(CacheKey.USER_AUTHORIZATION_KEY, revision, userId);

简单说明一下为什么将 revision 放到 userId 的前面,因为 revision 在当前的缓存构造中有这更高的聚合维度,放在前面让 RedisKEYS 或者 SCAN(请优先 SCAN)命令可以按 revision 前缀进行模式匹配,对于运维排查来说会好用很多。

2.5 对应缓存域 Redis 快照读写定义#

UserAuthorizationSnapshot.java
package com.zwlh.powergrid.contract.modules.access.pojo.vo;
import com.zwlh.powergrid.contract.modules.access.pojo.po.Permission;
import java.util.List;
public record UserAuthorizationSnapshot(
Long userId,
Long revision,
List<RoleVo> roleList,
List<Permission> permissionList
) {
}
package com.zwlh.powergrid.contract.modules.access.infrastructure.cache;
import com.zwlh.powergrid.contract.modules.access.pojo.vo.UserAuthorizationSnapshot;
import java.time.Duration;
public interface UserAuthorizationCacheService {
UserAuthorizationSnapshot get(Long userId, long revision);
boolean put(UserAuthorizationSnapshot snapshot, Duration ttl);
}
package com.zwlh.powergrid.contract.modules.access.infrastructure.cache;
import com.zwlh.powergrid.contract.common.constant.CacheKey;
import com.zwlh.powergrid.contract.common.util.RedisUtil;
import com.zwlh.powergrid.contract.modules.access.infrastructure.cache.observability.UserAuthorizationCacheMetrics;
import com.zwlh.powergrid.contract.modules.access.pojo.vo.UserAuthorizationSnapshot;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class UserAuthorizationCacheServiceImpl implements UserAuthorizationCacheService {
private final RedisUtil redisUtil;
private final UserAuthorizationCacheMetrics cacheMetrics;
@Override
public UserAuthorizationSnapshot get(Long userId, long revision) {
try {
UserAuthorizationSnapshot snapshot = redisUtil.get(buildKey(userId, revision), UserAuthorizationSnapshot.class);
// 埋点,记录 用户信息缓存域的 snapshot 为 miss 还是 hit
cacheMetrics.recordAuthzSnapshotRequest(snapshot == null ? "miss" : "hit");
return snapshot;
} catch (RuntimeException ex) {
// 埋点,记录用户信息缓存域的 snapshot 从 redis 中读取失败
cacheMetrics.recordAuthzSnapshotRequest("error");
throw ex;
}
}
// `put(...)` 返回 `boolean` 不是为了改变主流程的正确性,而是为了让回源重建指标能区分 Redis 写入成功与失败。
// 即使 Redis 写入失败,本次请求仍然可以返回 DB 组装出的 snapshot,但指标会暴露缓存写入异常。
@Override
public boolean put(UserAuthorizationSnapshot snapshot, Duration ttl) {
return redisUtil.set(buildKey(snapshot.userId(), snapshot.revision()), snapshot, ttl);
}
private String buildKey(Long userId, long revision) {
// revision 放在前面,便于运维排查和按版本前缀检索。
return String.format(CacheKey.USER_AUTHORIZATION_KEY, revision, userId);
}
}

这样就可以按 userId + revision 读取缓存:

UserAuthorizationSnapshot cached = userAuthorizationCacheService.get(userId, revision);

2.6 具体运作#

在这一节中,非缓存相关的部分不会提供很详细的代码,因为类似于传统的权限服务、角色服务,用户服务、用户角色服务本身在不同的系统实现也不一样,根据自身业务实现即可。

2.6.1 权限变化后:推进 revision 变化并发布领域事件#

基础事件定义:

DomainEvent.java
package com.zwlh.powergrid.contract.infrastructure.event;
import java.time.Instant;
import java.util.UUID;
public interface DomainEvent {
UUID eventId();
Instant occurredAt();
String eventName();
}

定义一个角色&权限变化后的领域事件

RolePermissionsChanged.java
package com.zwlh.powergrid.contract.modules.access.event;
import com.zwlh.powergrid.contract.infrastructure.event.DomainEvent;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record RolePermissionsChanged(
UUID eventId,
Instant occurredAt,
Long roleId,
List<Long> affectedUserIds,
String reason
) implements DomainEvent {
@Override
public String eventName() {
return "RolePermissionsChanged";
}
}

一旦角色权限发生变化后就执行下面两个函数:

RoleServiceImpl.java
.......
//角色权限绑定变化后,最后会调用这个方法
private void advanceAuthorizationRevisionAndPublishRoleEvent(Long roleId, List<Long> affectedUserIds, String reason) {
// 推进授权 revision,提交后会刷新当前实例的 revision cache,并广播 revision 变化。
cacheRevisionService.advanceRevision(RevisionSubject.USER_AUTHORIZATION, reason);
// 领域事件发布
domainEventPublisher.publish(new RolePermissionsChanged(
UUID.randomUUID(),
Instant.now(),
roleId,
affectedUserIds,
reason
));
}

角色分配变化后同理:

UserRoleServiceImpl.java
@Transactional(rollbackFor = Exception.class)
public void assignRoleToUsers(RoleAssignDTO roleAssignDTO) {
......// 这个RoleAssignDTO没必要在意是什么东西,知道这里在做角色变化的行为即可。
advanceAuthorizationRevisionAndPublishUserRolesEvent(
roleAssignDTO.getRoleId(),
affectedUserIds,
"user_roles_changed"
);
}
private void advanceAuthorizationRevisionAndPublishUserRolesEvent(Long roleId,
List<Long> affectedUserIds,
String reason) {
cacheRevisionService.advanceRevision(RevisionSubject.USER_AUTHORIZATION, reason);
domainEventPublisher.publish(new UserRolesChanged(
UUID.randomUUID(),
Instant.now(),
roleId,
affectedUserIds,
reason
));
}

其中,cacheRevisionService.advanceRevision(RevisionSubject.USER_AUTHORIZATION, reason) 是在影响权限/角色的行为后,主动更新 revision 版本,这还涉及到 CacheRevisionServiceImplrevision 表结构,以及广播行为,这将在后面详细说明。

domainEventPublisher 只是发布领域事件,不是缓存变更推送,真正决定“事务提交后再执行”的不是 publisher 本身,而是其背后的 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

这么说可能有点抽象,其实“领域事件发布”跟 revision 没有关系,并不是主线,它只是单纯方便其它模块,在订阅领域事件发布后执行自己的业务。

cacheRevisionService 本身只是 revision 服务,它里面不应该掺杂着别的业务逻辑,只管整个通用缓存链路的实现,因为其 revision 版本更迭也会改变数据库,所以也能随着事务的回滚而回滚。

欸,但是“事件发布”放在“事务”里面,“事务”失败后没办法回滚“事件发布”吧?这有问题啊?

2.6.2 领域事件#

注意,这并不是 revision 的主线,为什么我要单独拿出来说,因为这个东西也是值得说的,能跟 revision 一起拼凑出真实的业务场景。

当然,你也可以直接选择跳过 2.6.3直奔 2.6.4 revision,忽略掉前面的 domainEventPublisher.publish(...),也不影响阅读。

领域事件的发布,本质上只是方便其它模块中订阅对应事件的发生行为,从而做出自己的业务行为。

回顾前面的代码,你会发现有两个领域事件的发布,一个为 RolePermissionsChanged,另一个为 UserRolesChanged,你可以自己建立一个 listener 去监听这两个事件,在事件发布后做出对应的行为,下面的代码示例中会有说明。

至于事件发布放在事务里面,事务失败后没办法回滚发布的问题,还记得 L2 缓存的名称么?

AFTER_COMMIT_EVICT 就是在事务提交成功之后才执行。

那要求在事务提交成功后执行,怎么样才能做到呢?

Spring 官方给了个 annotation@TransactionalEventListener

这个注解可以通过 phase 指定:

  • BEFORE_COMMIT(事务提交之前)
  • AFTER_COMMIT(事务提交成功后)
  • AFTER_ROLLBACK(事务回滚之后,也就是提交失败后)
  • AFTER_COMPLETION(只要事务完成就会触发,提交/回滚都会触发)。

默认就是 commit phase,无活跃事务时默认不执行,除非设置 fallbackExecution = true

下面就是一个同时监听了 RolePermissionsChangedUserRolesChangedlistener,当前代码没有设置 fallbackExecution

DomainEventCacheChangeListener.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import com.zwlh.powergrid.contract.infrastructure.event.DomainEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.List;
import java.util.Optional;
@Slf4j
@Component
@RequiredArgsConstructor
public class DomainEventCacheChangeListener {
// 这里领域事件监听器不需要知道具体有多少个CacheChangeRequestMapper,只要是当前项目有的都会自动加载进该List中。
private final List<CacheChangeRequestMapper> mappers;
// handlers同理。
private final List<CacheChangeHandler> handlers;
// @TransactionalEventListener这个注释中,可以设置TransactionPhase.AFTER_COMMIT监听事务,事务成功提交后才会调用这个监听器处理事件。
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommit(DomainEvent event) {
Optional<CacheChangeRequest> request = toCacheChangeRequest(event);
if (request.isEmpty()) {
return;
}
dispatch(event, request.get());
}
// 根据不同的事件类型,返回带着不同缓存变更数据的CacheChangeRequest对象
private Optional<CacheChangeRequest> toCacheChangeRequest(DomainEvent event) {
return mappers.stream()
.map(mapper -> mapper.toCacheChangeRequest(event))
.flatMap(Optional::stream)
.findFirst();
}
private void dispatch(DomainEvent event, CacheChangeRequest request) {
// 遍历handler
for (CacheChangeHandler handler : handlers) {
// 如果该handler支持当前缓存域
if (handler.supports(request.domain(), request.action())) {
try {
// 让对应领域事件的handler处理缓存变更行为
handler.handle(request);
} catch (RuntimeException ex) {
log.warn("缓存变更处理失败: eventName={}, domain={}, action={}, reason={}",
event.eventName(), request.domain(), request.action(), request.reason(), ex);
}
}
}
}
}
2.6.2.1 事务成功提交&领域事件发布后#

接下来就是领域事件发布后的旁路处理。

回顾 handleAfterCommit,它负责把“业务领域事件”翻译成“缓存域动作”,再分发给对应 handler

这个 handler 并不复杂,只是一个薄薄的接口。

CacheChangeHandler
package com.zwlh.powergrid.contract.infrastructure.cache;
public interface CacheChangeHandler {
boolean supports(CacheDomain domain, CacheChangeAction action);
void handle(CacheChangeRequest request);
}

USER_AUTHORIZATION 这个缓存域其中之一的 handler 做了什么动作呢?

LegacyUserInfoCacheHandler.java
package com.zwlh.powergrid.contract.modules.access.infrastructure.cache;
import com.zwlh.powergrid.contract.common.constant.CacheKey;
import com.zwlh.powergrid.contract.common.util.RedisUtil;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheChangeAction;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheChangeHandler;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheChangeRequest;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class LegacyUserInfoCacheHandler implements CacheChangeHandler {
private final RedisUtil redisUtil;
@Override
public boolean supports(CacheDomain domain, CacheChangeAction action) {
return domain == CacheDomain.USER_AUTHORIZATION && action == CacheChangeAction.LEGACY_EVICT;
}
@Override
public void handle(CacheChangeRequest request) {
if (request.keys() == null || request.keys().isEmpty()) {
return;
}
// 删除原来CacheKey.USER_INFO_KEY不再使用
String[] keys = request.keys().stream()
.map(userId -> String.format(CacheKey.USER_INFO_KEY, userId))
.toArray(String[]::new);
redisUtil.del(keys);
}
}

绕来绕去就只是删除旧的完整用户信息缓存?

本来它的价值并不是只做这点事情,只是展示说它可以这么做,这里只是顺手给个演示

比如 RolePermissionsChanged / UserRolesChanged 不一定只能用于清理旧缓存,审计模块可以监听它写操作日志,通知模块可以监听它发送提醒,报表模块可以监听它更新统计数据。

不过这就不是 CacheChangeHandler 该管的事情了,需要创建一个 XXHandler 去做,不同的模块应有独立的 handler 放在对应的其目录下,不要不同的模块业务杂糅混合在一起。

比如当前这个 handler 就放在 access 模块下,而非 revision 目录。

咦,不是说让旧缓存自动随着 TTL 过期么?怎么我们又主动删除了?

不要搞混啰,这不是 USER_AUTHORIZATION 的主一致性机制,那个在 cacheRevisionService.advanceRevision(RevisionSubject.USER_AUTHORIZATION, reason) 内部解决。

当前只是迁移期清理旧的缓存机制 cache:user:info:{userId},这个行为实际上是跟缓存链路是没有关系的,这是旧缓存链路的行为清洗,如果你一次就做好了这个缓存系统,那可以不用考虑做这个迁移行为,这段代码等后续平稳后也会标注删除并说明原因。

主机制仍然是 revision + cache:user:authz:v{revision}:{userId}

2.6.3 revision#

新建一张 revision 表,表名按自己项目规范来,本文中以 contract_revision 相称。

2.6.3.1 Revision#

创建的时候顺便创建一个 USER_AUTHORIZATION 初始值。

contract_revision.sql
CREATE TABLE IF NOT EXISTS contract_revision (
contract_name VARCHAR(64) PRIMARY KEY,
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
updated_time TIMESTAMPTZ NOT NULL DEFAULT now(),
reason VARCHAR(128) NOT NULL DEFAULT 'bootstrap'
);
INSERT INTO contract_revision(contract_name, revision, reason)
VALUES ('USER_AUTHORIZATION', 1, 'bootstrap')
ON CONFLICT (contract_name) DO NOTHING;
2.6.3.2 创建枚举类 RevisionSubject,方便日后拓展不同缓存域的 revision#
RevisionSubject.java
package com.zwlh.powergrid.contract.infrastructure.revision;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import java.util.Arrays;
import java.util.Optional;
public enum RevisionSubject {
USER_AUTHORIZATION(CacheDomain.USER_AUTHORIZATION);
private final CacheDomain cacheDomain;
RevisionSubject(CacheDomain cacheDomain) {
this.cacheDomain = cacheDomain;
}
public CacheDomain cacheDomain() {
return cacheDomain;
}
public static Optional<RevisionSubject> fromCacheDomain(CacheDomain cacheDomain) {
if (cacheDomain == null) {
return Optional.empty();
}
return Arrays.stream(values())
.filter(subject -> subject.cacheDomain == cacheDomain)
.findFirst();
}
}

这个服务本身行为很窄,只需要通过 enum 中定义好的 缓存域去获取一个代表 revisionLong 值,并不需要 PO 对象去表达实体。

2.6.3.3 定义 Mapper#
CacheRevisionMapper.java
package com.zwlh.powergrid.contract.infrastructure.revision;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CacheRevisionMapper {
// 获取当前缓存域的revision
Long currentRevision(@Param("revisionSubject") RevisionSubject revisionSubject);
// 如果不存在,则新建
int insertIfAbsent(@Param("revisionSubject") RevisionSubject revisionSubject,
@Param("reason") String reason);
// 更新revision
long advanceRevision(@Param("revisionSubject") RevisionSubject revisionSubject,
@Param("reason") String reason);
}
CacheChangeRequestMapper.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import com.zwlh.powergrid.contract.infrastructure.event.DomainEvent;
import java.util.Optional;
public interface CacheChangeRequestMapper {
Optional<CacheChangeRequest> toCacheChangeRequest(DomainEvent event);
}

下面这个 .xml 需要注意 currentRevisionadvanceRevision 返回值的区别。

currentRevision 的结果允许返回 Null,是因为currentRevision是真的有可能查不到(还没有建这个缓存域的 revison

而我们要更新 revision的话,必须要找得到该缓存域,所以这个返回值不允许为 Null ,找不到就会报错,算是一种利用 Mybatis 行为的偷懒做法。

CacheRevisionMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zwlh.powergrid.contract.infrastructure.revision.CacheRevisionMapper">
<select id="currentRevision" resultType="java.lang.Long">
SELECT revision
FROM contract_revision
WHERE contract_name = #{revisionSubject}
</select>
<insert id="insertIfAbsent">
INSERT INTO contract_revision(contract_name, revision, reason)
VALUES (#{revisionSubject}, 1, #{reason})
ON CONFLICT (contract_name) DO NOTHING
</insert>
<select id="advanceRevision" resultType="long">
UPDATE contract_revision
SET revision = revision + 1,
updated_time = now(),
reason = #{reason}
WHERE contract_name = #{revisionSubject}
RETURNING revision
</select>
</mapper>
2.6.3.4 定义 CacheRevisionService#
CacheRevisionService.java
package com.zwlh.powergrid.contract.infrastructure.revision;
public interface CacheRevisionService {
long currentRevision(RevisionSubject revisionSubject);
long advanceRevision(RevisionSubject revisionSubject, String reason);
void invalidateLocalRevision(RevisionSubject revisionSubject);
void invalidateLocalRevisionIfBehind(RevisionSubject revisionSubject, long revision);
}
2.6.3.5 定义CacheRevisionServiceImpl#
CacheRevisionServiceImpl.java
package com.zwlh.powergrid.contract.infrastructure.revision;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheInvalidationBroadcaster;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheInvalidationMessage;
import com.zwlh.powergrid.contract.infrastructure.revision.observability.RevisionMetrics;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
@Service
public class CacheRevisionServiceImpl implements CacheRevisionService {
private static final long INITIAL_REVISION = 1L;
private static final String BOOTSTRAP_REASON = "bootstrap";
private static final Duration DEFAULT_LOCAL_CACHE_TTL = Duration.ofSeconds(1);
private final CacheRevisionMapper cacheRevisionMapper;
private final CacheInvalidationBroadcaster cacheInvalidationBroadcaster;
private final RevisionMetrics revisionMetrics;
private final boolean localRevisionCacheEnabled;
private final Cache<RevisionSubject, Long> revisionCache;
public CacheRevisionServiceImpl(
CacheRevisionMapper cacheRevisionMapper,
// 这是无效化缓存广播,后续会说
CacheInvalidationBroadcaster cacheInvalidationBroadcaster,
// 从配置中获取这些值,后面也会给出
@Value("${contract.revision.local-cache.ttl:1s}") Duration localCacheTtl,
CacheMetrics cacheMetrics
) {
this.cacheRevisionMapper = cacheRevisionMapper;
this.cacheInvalidationBroadcaster = cacheInvalidationBroadcaster;
this.revisionMetrics = revisionMetrics;
this.localRevisionCacheEnabled = isLocalRevisionCacheEnabled(localCacheTtl);
this.revisionCache = buildRevisionCache(localCacheTtl);
}
// 从当前缓存域中获取revision
@Override
public long currentRevision(RevisionSubject revisionSubject) {
long startNanos = System.nanoTime();
if (!localRevisionCacheEnabled) {
return loadAndRecordCurrentRevision(revisionSubject, "db", startNanos);
}
Long cachedRevision = revisionCache.getIfPresent(revisionSubject);
if (cachedRevision != null) {
// 埋点,记录 revision 读取来自本地 Caffeine
revisionMetrics.recordRevisionRequest(
revisionSubject.cacheDomain(),
"local",
"success",
System.nanoTime() - startNanos
);
return cachedRevision;
}
// 直接读DB
return loadAndCacheCurrentRevision(revisionSubject, startNanos);
}
//更新缓存域revision并且附上更新理由
@Override
public long advanceRevision(RevisionSubject revisionSubject, String reason) {
long startNanos = System.nanoTime();
try {
long revision = cacheRevisionMapper.advanceRevision(revisionSubject, reason);
// 埋点,判断 revision 迭代是否成功或变慢,System.nanoTime() - startNanos将会在Signoz中作为观察项。
// 如果变慢的话,先看是不是单个domain,如果只是某个domain慢,说明缓存域写推进率或者事务冲突高。
// 需要重点排查:contract_revision行级锁等待,慢SQL,DB CPU/IO,连接池耗尽,事务慢操作拖累,大量权限/角色后台操作同时发生,是否把高频写场景错误构建成 revision 缓存域的问题。
revisionMetrics.recordRevisionAdvance(
revisionSubject.cacheDomain(),
"success",
System.nanoTime() - startNanos
);
refreshCacheAfterCommit(revisionSubject, revision, reason);
return revision;
} catch (RuntimeException ex) {
// 埋点,判断 revision 迭代失败
// contract_revision是否缺行、mapper SQL是否失败、DB连接是否异常、事务是否已经被标记rollback-only,contract_name的枚举值是否与表内值一致,PostgreSQL的 RETURNING revision是否正常返回,是否有 schema 变更、权限问题、连接池问题。
revisionMetrics.recordRevisionAdvance(
revisionSubject.cacheDomain(),
"error",
System.nanoTime() - startNanos
);
throw ex;
}
}
// 无效化本地revision
@Override
public void invalidateLocalRevision(RevisionSubject revisionSubject) {
if (!localRevisionCacheEnabled) {
return;
}
revisionCache.invalidate(revisionSubject);
}
// 在失效之前需要做一下乱序/重复校验,避免高并发下因为Pub/Sub的乱序导致不必要的缓存失效与DB回源。
@Override
public void invalidateLocalRevisionIfBehind(RevisionSubject revisionSubject, long revision) {
if (!localRevisionCacheEnabled) {
return;
}
Long localRevision = revisionCache.getIfPresent(revisionSubject);
if (localRevision != null && revision <= localRevision) {
return;
}
if (localRevision != null) {
// 埋点,实例收到较新 revision 广播时,如果本地仍有旧 revision, 说明确实发生过跨实例旧 revision 窗口
// 这个指标要是上升的话,说明乱序现象正在逐步提高,确实存在实例本地 revision 落后情况。
// 检查 Redis 连接、应用实例负载、线程池、GC、网络延迟、订阅容器状态
// 乱序/延迟增加常见原因是消费端卡顿或网络抖动。
revisionMetrics.recordRevisionStaleDetected(revisionSubject.cacheDomain());
}
revisionCache.invalidate(revisionSubject);
}
// 在事务成功提交后刷新revision值并附上理由
private void refreshCacheAfterCommit(RevisionSubject revisionSubject, long revision, String reason) {
// 判断当前线程是否已经开启了Spring的事务同步机制(有没有一个可以注册事务回调的contxt)
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
// 不在事务中,直接发布 revision 变更广播
publishRevisionChanged(revisionSubject, revision, reason);
// 刷新当前实例本地 Caffeine revision cache
refreshLocalRevisionCache(revisionSubject, revision);
return;
}
// 如果当前在Spring事务同步上下文
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
// 注册afterCommit回调
@Override
public void afterCommit() {
// 事务提交后,再执行广播以及本地Caffeine缓存更新。
publishRevisionChanged(revisionSubject, revision, reason);
refreshLocalRevisionCache(revisionSubject, revision);
}
});
}
// 推送当前缓存域revision更新的事件
private void publishRevisionChanged(RevisionSubject revisionSubject, long revision, String reason) {
cacheInvalidationBroadcaster.publish(CacheInvalidationMessage.revisionChanged(
revisionSubject.cacheDomain(),
revision,
reason
));
}
// 从数据库中读取当前缓存域的Revision,如果没有则新建后返回
private Long loadCurrentRevision(RevisionSubject revisionSubject) {
Long revision = cacheRevisionMapper.currentRevision(revisionSubject);
if (revision != null) {
return revision;
}
cacheRevisionMapper.insertIfAbsent(revisionSubject, BOOTSTRAP_REASON);
Long initializedRevision = cacheRevisionMapper.currentRevision(revisionSubject);
return initializedRevision != null ? initializedRevision : INITIAL_REVISION;
}
// 用在本地 Caffeine revision cache 启用,但当前本地没有值的场景(Caffeine miss):
private long loadAndCacheCurrentRevision(RevisionSubject revisionSubject, long startNanos) {
try {
// 回源 DB
Long revision = revisionCache.get(revisionSubject, this::loadCurrentRevision);
// 埋点,记录 revision 来源为DB
// 如果这个指标变多,说明本地 cache 正在更多回源DB,可能是 TTL 兜底在发挥作用。
revisionMetrics.recordRevisionRequest(
revisionSubject.cacheDomain(),
"db",
"success",
System.nanoTime() - startNanos
);
return revision;
} catch (RuntimeException ex) {
// 埋点,记录revision读取失败及其来源为DB
revisionMetrics.recordRevisionRequest(
revisionSubject.cacheDomain(),
"db",
"error",
System.nanoTime() - startNanos
);
throw ex;
}
}
// 读取 revision 但本地 revision cache 被禁用的场景,比如 TTL 配成 0s:
private long loadAndRecordCurrentRevision(RevisionSubject revisionSubject, String source, long startNanos) {
try {
// 直接查 DB
long revision = loadCurrentRevision(revisionSubject);
// 记录 revision 来源为 DB,成功
revisionMetrics.recordRevisionRequest(
revisionSubject.cacheDomain(),
source,
"success",
System.nanoTime() - startNanos
);
return revision;
} catch (RuntimeException ex) {
// 记录 revision 来源为 DB,失败
revisionMetrics.recordRevisionRequest(
revisionSubject.cacheDomain(),
source,
"error",
System.nanoTime() - startNanos
);
throw ex;
}
}
// 创建缓存域的本地revision caffeine TTL缓存
private Cache<RevisionSubject, Long> buildRevisionCache(Duration ttl) {
// TTL为负数或者为null的时候,设置一个默认的TTL值。
Duration effectiveTtl = ttl == null || ttl.isNegative() ? DEFAULT_LOCAL_CACHE_TTL : ttl;
// 当前实例在本地设置caffeine缓存值。
Caffeine<Object, Object> builder = Caffeine.newBuilder();
if (!effectiveTtl.isZero()) {
// 非0 TTL才设置过期
builder.expireAfterWrite(effectiveTtl);
}
return builder.build();
}
private boolean isLocalRevisionCacheEnabled(Duration ttl) {
return ttl == null || ttl.isNegative() || !ttl.isZero();
}
}

2.6.4 revision 变更广播#

上一节的实现里头,已有 revision 提交后广播的代码。

CacheRevisionServiceImpl.java
……
// 推送当前缓存域revision更新的事件
private void publishRevisionChanged(RevisionSubject revisionSubject, long revision, String reason) {
cacheInvalidationBroadcaster.publish(CacheInvalidationMessage.revisionChanged(
revisionSubject.cacheDomain(),
revision,
reason
));
}
……
2.6.4.1 广播的定义#
CacheInvalidationBroadcaster.java
package com.zwlh.powergrid.contract.infrastructure.cache;
public interface CacheInvalidationBroadcaster {
void publish(CacheInvalidationMessage message);
}
CacheInvalidationMessage.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import java.time.Instant;
import java.util.UUID;
public record CacheInvalidationMessage(
// 缓存域
CacheDomain domain,
// 缓存变更行为
CacheChangeAction action,
// 缓存域版本
Long revision,
// 变更理由
String reason,
UUID eventId,
Instant occurredAt
) {
public static CacheInvalidationMessage revisionChanged(CacheDomain domain,
long revision,
String reason) {
return new CacheInvalidationMessage(
domain,
CacheChangeAction.BUMP_REVISION,
revision,
reason,
UUID.randomUUID(),
Instant.now()
);
}
}
CacheChangeAction.java
package com.zwlh.powergrid.contract.infrastructure.cache;
public enum CacheChangeAction {
EVICT,// 普通缓存删除动作,知道key可以直接删的场景
BUMP_REVISION,// revision 广播动作,不删除 Redis 授权snapshot,只通知其它实例失效本地 revision cache
REFRESH,// 重建/刷新缓存
LEGACY_EVICT// 迁移期使用,清理旧缓存key,不跟新的 revision + versioned key主链路混一块。
}
2.6.4.2 广播发送#
RedisCacheInvalidationBroadcaster.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import com.zwlh.powergrid.contract.infrastructure.cache.observability.CacheMetrics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisCacheInvalidationBroadcaster implements CacheInvalidationBroadcaster {
private final RedisTemplate<String, Object> redisTemplate;
private final CacheBroadcastProperties properties;
private final CacheMetrics cacheMetrics;
@Override
public void publish(CacheInvalidationMessage message) {
if (!properties.enabled()) {
cacheMetrics.recordInvalidationPublish(message.domain(), message.action(), "disabled");
return;
}
try {
redisTemplate.convertAndSend(properties.channel(), message);
// 埋点,记录广播发送成功
// publish success 正常,但 receive error 增多:说明消费端本地处理异常。
cacheMetrics.recordInvalidationPublish(message.domain(), message.action(), "success");
} catch (RuntimeException ex) {
// 埋点,记录广播发送失败
// 这个指标如果变多的话,说明旧读窗口会变大,其他实例收不到 BUMP_REVISION,不会马上清理本地的 revision cache,最终靠本地的Caffeine缓存过期后回源DB。
// 风险点主要是看TTL的持续时间,如果设计权限放行、审批状态、运行配置等高风险读路径,需要谨慎考虑。
// publish error 增多,receive accepted 下降:广播发送端或 Redis Pub/Sub 链路异常。
// 需要排查 Redis 连接以及 channel 配置
// 连接池、网络抖动、redisTemplate等,然后再看实例日志,如果只有某个实例有问题,再具体情况具体分析。
cacheMetrics.recordInvalidationPublish(message.domain(), message.action(), "error");
log.warn("缓存失效广播发送失败: domain={}, action={}, reason={}",
message.domain(), message.action(), message.reason(), ex);
}
}
}
2.6.4.3 配置项#

主要介绍 CacheBroadcastProperties是什么。

Redis Broadcast可以理解成一个电台,它可以有很多频道,不同的应用在不同的频道中推送广播以及订阅。

一般来说,一个项目不管有多少实例只会选择收听一个频道,这也是为什么频道的名称仅仅作为一个全局配置项。

CacheBroadcastProperties.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "cache.broadcast")
public record CacheBroadcastProperties(
boolean enabled,
String channel
) {
public CacheBroadcastProperties {
if (channel == null || channel.isBlank()) {
channel = "cache:invalidation";
}
}
}
application.yaml
cache:
broadcast:
enabled: ${CACHE_BROADCAST_ENABLED:true} # 默认广播行为开启
channel: ${CACHE_BROADCAST_CHANNEL:cache:invalidation} # 默认channel为invalidation,按照自己项目实际情况填写。
2.6.4.4 广播订阅#

广播发送完了,还得有人订阅这个频道。

当前项目中,Redis Pub/Sub 的订阅配置在 RedisPubSubConfig

@Configuration 标记的是配置类,Spring 启动时会扫描它。

配置类里的 @Bean 方法会被 Spring 调用,拿到方法返回的 RedisMessageListenerContainer交给 Spring 容器管理,成为一个Bean

RedisPubSubConfig.java
package com.zwlh.powergrid.contract.infrastructure.config.redis;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheBroadcastProperties;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheInvalidationSubscriber;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
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.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@Configuration
@EnableConfigurationProperties(CacheBroadcastProperties.class)
public class RedisPubSubConfig {
@Bean
MessageListenerAdapter cacheInvalidationMessageListenerAdapter(
CacheInvalidationSubscriber subscriber,
RedisTemplate<String, Object> redisTemplate
) {
// 创建了一个 MessageListenerAdapter ,就是说 Redis 收到消息后,把消息交给 CacheInvalidationSubscriber 的 handleMessage 方法进行处理
MessageListenerAdapter adapter = new MessageListenerAdapter(subscriber, "handleMessage");
// 发送方使用的是redisTemplate.convertAndSend(properties.channel(), message);
// Pub/Sub 的发送端和接收端使用同一套消息序列化规则,这里接收端要使用同一个 RedisTemplate 的 value serializer 反序列化消息。
// 这样 MessageListenerAdapter 才能把 Redis 消息转换成 CacheInvalidationMessage。
// 另外注意,这里要做专门的new Jackson2JsonRedisSerializer<>(objectMapper, CacheInvalidationMessage.class))反序列化到强类型handler,否则跨实例下会没有 receive/stale 指标,难以从压测中判断跨实例测试。
adapter.setSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, CacheInvalidationMessage.class));
return adapter;
}
@Bean
RedisMessageListenerContainer cacheInvalidationListenerContainer(
RedisConnectionFactory connectionFactory,
CacheBroadcastProperties properties,
MessageListenerAdapter cacheInvalidationMessageListenerAdapter
) {
// `RedisMessageListenerContainer` 是 Spring Data Redis 提供的消息监听容器,负责维护Redis Pub/Sub 的订阅连接,并把收到的消息分发给具体的 listener。
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
// 设置 Redis 连接来源。
container.setConnectionFactory(connectionFactory);
// 查看当前channel是否已经启用
if (properties.enabled()) {
// 订阅该channel
container.addMessageListener(
cacheInvalidationMessageListenerAdapter,
ChannelTopic.of(properties.channel())
);
}
return container;
}
}
2.6.4.5 广播消费#
CacheInvalidationMessage.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import java.time.Instant;
import java.util.UUID;
public record CacheInvalidationMessage(
CacheDomain domain,
CacheChangeAction action,
Long revision,
String reason,
UUID eventId,
Instant occurredAt
) {
public static CacheInvalidationMessage revisionChanged(CacheDomain domain,
long revision,
String reason) {
return new CacheInvalidationMessage(
domain,
CacheChangeAction.BUMP_REVISION,
revision,
reason,
UUID.randomUUID(),
Instant.now()
);
}
}

前面铺垫了那么多,其实最终也就是为了在 revision 广播结束后,让原来本地 Caffeine 缓存的 revision 失效。

下面的观测构建中:

  • invalid 增多,说明需要检查消息结构、序列化、发送方构造。
  • ignored 增多,说明需要检查是否有不该发到这个 channelaction / domain
  • error 以及 lag 在代码中会有说明。
CacheInvalidationSubscriber.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import com.zwlh.powergrid.contract.infrastructure.cache.observability.CacheMetrics;
import com.zwlh.powergrid.contract.infrastructure.revision.RevisionSubject;
import com.zwlh.powergrid.contract.infrastructure.revision.CacheRevisionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheInvalidationSubscriber {
private final CacheRevisionService cacheRevisionService;
private final CacheMetrics cacheMetrics;
public void handleMessage(CacheInvalidationMessage message) {
if (message == null) {
// 埋点:subscriber 收到的消息对象为空,通常是反序列化、适配器调用或测试构造异常。
cacheMetrics.recordInvalidationReceive("UNKNOWN", "UNKNOWN", "invalid");
return;
}
if (message.domain() == null || message.action() == null) {
// 埋点: 消息结构不完整,domain 或 action 缺失时仍记为 invalid。
// tagValue(...) 把缺失值转成 UNKNOWN,避免 metric tag 出现 null。
cacheMetrics.recordInvalidationReceive(
tagValue(message.domain()),
tagValue(message.action()),
"invalid"
);
return;
}
if (!CacheChangeAction.BUMP_REVISION.equals(message.action())) {
// 埋点:非 revision 广播,记录 ignored。
// 消息有效,但这个 subscriber 只处理 BUMP_REVISION,其它动作不在它的职责内。
cacheMetrics.recordInvalidationReceive(message.domain(), message.action(), "ignored");
return;
}
if (message.revision() == null) {
// 埋点:revision 缺失
// BUMP_REVISION 必须携带新的 revision,缺了就是无效消息
cacheMetrics.recordInvalidationReceive(message.domain(), message.action(), "invalid");
return;
}
Optional<RevisionSubject> revisionSubject = RevisionSubject.fromCacheDomain(message.domain());
if (revisionSubject.isEmpty()) {
// 埋点:domain 没有对应 RevisionSubject,记录 ignored。
// 这是 revision 类型消息,但当前 domain 不是 revision 管理域。
cacheMetrics.recordInvalidationReceive(message.domain(), message.action(), "ignored");
return;
}
handleRevisionChanged(revisionSubject.get(), message);
}
private String tagValue(Enum<?> value) {
return value == null ? "UNKNOWN" : value.name();
}
private void handleRevisionChanged(RevisionSubject revisionSubject, CacheInvalidationMessage message) {
try {
cacheRevisionService.invalidateLocalRevisionIfBehind(revisionSubject, message.revision());
// 埋点:当前实例已接收并执行本地 revision cache失效检查。
// 是否真的发现本地 revision 落后,由CacheRevisionServiceImpl 里的 cache.revision.stale.detected.total{domain} 表达。
// 注意:不能证明其它实例也收到了消息,也不能给 Redis Pub/Sub 增加 ack、重试或离线补偿。
// 如果advance是正常的,这里的指标变少了,那么说明订阅链路或者消费端异常。
cacheMetrics.recordInvalidationReceive(message.domain(), message.action(), "accepted");
recordLag(message);
} catch (RuntimeException ex) {
// 埋点:记录 error,
// 消息已到达 subscriber,但本地失效处理失败。
// error变多的时候,需要检查本地 revision cache 的失效逻辑
cacheMetrics.recordInvalidationReceive(message.domain(), message.action(), "error");
log.warn("缓存失效广播消费失败: domain={}, action={}, revision={}",
message.domain(), message.action(), message.revision(), ex);
}
}
// 记录lag,升高说明广播从创建到消费变慢
// 如果不能接受 TTL 窗口旧读,应考虑 L6 outbox
private void recordLag(CacheInvalidationMessage message) {
if (message.occurredAt() == null) {
return;
}
long lagNanos = java.time.Duration.between(message.occurredAt(), java.time.Instant.now()).toNanos();
cacheMetrics.recordInvalidationLag(message.domain(), message.action(), Math.max(lagNanos, 0L));
}
}

回顾 cacheRevisionService 的实现 CacheRevisionServiceImpl,可以看到调用的是 RevisionSubject.fromCacheDomain(message.domain()) 找到对应的 subject,再调用 invalidateLocalRevisionIfBehind(subject, message.revision())

这里没有直接无脑失效本地 revision cache,而是调用 invalidateLocalRevisionIfBehind(...)

它会先看当前实例本地是否已有 revision

  • 如果本地没有 revision cache,保守跳过或按当前实现返回;
  • 如果本地 revision 小于广播 revision,说明本地落后,需要失效;
  • 如果本地 revision 大于等于广播 revision,说明这是重复消息或旧消息,不需要处理。

真正的 revisionCache.invalidate(revisionSubject) 被封装在 CacheRevisionServiceImpl 内部, Subscriber 不直接访问 Caffeine

2.6.5 Revision 读取链路#

前面已经讲完了 revision 如何在写路径中推进,以及如何通过广播让其它实例尽快失效本地 revision cache,领域事件的发布以及其它模块的订阅。

读路径其实很简单,可以概括为:先拿当前 revision,再用 revision 拼缓存 key

2.6.5.1 获取当前缓存域 revision#
long revision = cacheRevisionService.currentRevision(RevisionSubject.USER_AUTHORIZATION);

回顾一下上面给出的代码:

CacheRevisionServiceImpl.java
……
// 从数据库中读取当前缓存域的Revision,如果没有则新建后返回
private Long loadCurrentRevision(RevisionSubject revisionSubject) {
Long revision = cacheRevisionMapper.currentRevision(revisionSubject);
if (revision != null) {
return revision;
}
cacheRevisionMapper.insertIfAbsent(revisionSubject, BOOTSTRAP_REASON);
Long initializedRevision = cacheRevisionMapper.currentRevision(revisionSubject);
return initializedRevision != null ? initializedRevision : INITIAL_REVISION;
}
// 从当前缓存域中获取revision
@Override
public long currentRevision(RevisionSubject revisionSubject) {
long startNanos = System.nanoTime();
if (!localRevisionCacheEnabled) {
return loadAndRecordCurrentRevision(revisionSubject, "db", startNanos);
}
Long cachedRevision = revisionCache.getIfPresent(revisionSubject);
if (cachedRevision != null) {
// 埋点,记录 revision 读取来自本地 Caffeine
cacheMetrics.recordRevisionRequest(
revisionSubject.cacheDomain(),
"local",
"success",
System.nanoTime() - startNanos
);
return cachedRevision;
}
// 直接读DB
return loadAndCacheCurrentRevision(revisionSubject, startNanos);
}
……

这一步会优先读取本地 Caffeine revision cache,如果没有,才会去读取 contract_revision 表中获取值。

2.6.5.2 通过 key + revision 读取 Redis 快照#
UserAuthorizationSnapshot cached = userAuthorizationCacheService.get(userId, revision);

回顾上面实现中给出的代码:

CacheKey.java
public static final String USER_AUTHORIZATION_KEY = "cache:user:authz:v%s:%s";
UserAuthorizationCacheServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserAuthorizationCacheServiceImpl implements UserAuthorizationCacheService {
private final RedisUtil redisUtil;
@Override
public UserAuthorizationSnapshot get(Long userId, long revision) {
return redisUtil.get(buildKey(userId, revision), UserAuthorizationSnapshot.class);
}
……
private String buildKey(Long userId, long revision) {
return String.format(CacheKey.USER_AUTHORIZATION_KEY, revision, userId);
}
}

实际上的 key 就是 String.format(CacheKey.USER_AUTHORIZATION_KEY, revision, userId),也就是 cache:user:authz:v{revision}:{userId}

2.6.6 USER_AUTHORIZATION 缓存域的数据读取与写入节选#

……
private UserAuthorizationSnapshot loadAuthorizationSnapshot(Long userId) {
long revision = cacheRevisionService.currentRevision(RevisionSubject.USER_AUTHORIZATION);
UserAuthorizationSnapshot cached = userAuthorizationCacheService.get(userId, revision);
// 如果有缓存,就直接返回。
if (cached != null) {
return cached;
}
// 没有缓存,就从数据库中获取角色列表、权限列表,这里仅为我项目中的演示,按照自己项目中实际情况来写。
long startNanos = System.nanoTime();
try {
List<RoleVo> rolesByUserId = roleService.getRolesByUserId(userId);
List<RoleVo> canonicalRoles = rolesByUserId.stream()
.peek(role -> role.setPermissions(PermissionContractMapper.toCanonicalPermissions(role.getPermissions())))
.toList();
List<Permission> permissions = canonicalRoles.stream()
.flatMap(roleVo -> {
List<Permission> rolePermissions = roleVo.getPermissions();
return rolePermissions == null ? Stream.<Permission>empty() : rolePermissions.stream();
})
.toList();
// 将从数据库中获取的数据返回组装成UserAuthorizationSnapshot
UserAuthorizationSnapshot snapshot = new UserAuthorizationSnapshot(
userId,
revision,
canonicalRoles,
PermissionContractMapper.toCanonicalPermissions(permissions)
);
// 将新的数据推送到缓存里面
boolean cacheWritten = userAuthorizationCacheService.put(snapshot, USER_AUTHORIZATION_CACHE_TTL);
// 埋点,统计重建缓存耗时。
userAuthorizationCacheMetrics.recordAuthzSnapshotLoad(cacheWritten ? "success" : "error", System.nanoTime() - startNanos);
// 返回snapshot
return snapshot;
} catch (RuntimeException ex) {
// 失败时埋点,暴露缓存写入异常。
userAuthorizationCacheMetrics.recordAuthzSnapshotLoad("error", System.nanoTime() - startNanos);
throw ex;
}
}
……

2.6.7 阶段性总结#

revision 广播链路完整走下来是这样的:

1. 某个实例完成角色权限/用户角色变更。
2. 事务提交成功后,`CacheRevisionServiceImpl` 发布 `BUMP_REVISION` 广播以及领域事件发布。
3. 广播通过 `Redis Pub/Sub` 发到 `cache:invalidation` 频道,领域事件发布后,其它模块可以按需订阅。
4. 其它实例订阅同一个广播频道,收到 `CacheInvalidationMessage`。
5. 如果消息是 `BUMP_REVISION`,`subscriber` 会根据 `message.domain()` 查找对应的 `RevisionSubject`。
找得到才执行本地 `revision cache` 失效检查,找不到则记为 `ignored`。
6. 下一次读取用户授权缓存时,重新查询数据库 `revision`。
7. 新 `revision` 会拼出新的 `Redis key`:`cache:user:authz:v{revision}:{userId}`。

但正如前面所说,Redis Pub/Sub 本身并不是可靠广播,如果还需要做广播的可靠性确认,我们还需要引入 Outbox 概念。

2.6.8 Outbox#

outbox 这一层解决的是:业务事务已经成功提交后,某些支线不能只靠一次即时调用完成,而是需要落库、可重试、可观测、可人工处理。

当前的链路可以理解为:

业务事务
-> 写业务表
-> 写 event_outbox(PENDING)
-> 事务提交后 wakeup worker
-> worker claim 到期事件,改为 PROCESSING
-> handler dispatch
-> CompletionStage<OutboxDispatchResult> 完成
-> 按 dispatch 结果改为 SUCCEEDED / FAILED / DEAD

这些名词后续都会有解释,当前大致理解这条链路在做什么即可。

这里要注意,handler 方法返回不代表投递成功。

对于 Redis Pub/Sub 这种同步 sender,可以在发送成功后返回一个已经完成的 CompletableFuture

对于 Kafka / RabbitMQ 这类 broker,更合理的是返回 producer send / publisher confirm 对应的 future,再由 worker 等这个 future 完成后更新 event_outbox 的最终状态。

因此,event_outboxSUCCEEDED 表示本应用侧的 dispatch 已确认成功,不表示所有下游 consumer 都已经处理完成。下游 consumer 仍然必须按 eventId 或业务幂等键做幂等。

2.6.8.1 整体链路#

副作用通道

PostgreSQL

否 当前 USER_AUTHORIZATION 仍是 L5

否 disabled 或 auto 无 producer

回滚

提交成功

accepted

rejected

成功

异常且未超限

异常且达到上限

更新命中

更新未命中

更新命中

更新未命中

更新命中

更新未命中

业务写请求

业务事务开始

修改业务数据

推进缓存域 revision

是否使用 L6 reliable event

事务提交后 Redis PubSub 广播

广播失败或丢失时依赖本地 revision TTL 回源 DB

outbox runtime 允许写入

记录 publish disabled 并拒绝写入

写入 event_outbox PENDING

事务提交结果

业务数据与 outbox row 一起回滚

记录 publish success

尝试 wakeup outbox worker

worker runtime 可运行

记录 wakeup disabled

提交 drainOnce 到 TaskExecutor

记录 wakeup accepted

记录 wakeup rejected 等待 fallback scan

定时 fallback scan

worker drainOnce

claim due pending failed expired processing

FOR UPDATE SKIP LOCKED

标记 processing 并设置 lock

记录 claimed total

按 event type 查找 handler

执行 handler side effect

mark succeeded

mark failed 设置 next attempt

mark dead

记录 dispatch success

记录 dispatch conflict

记录 dispatch failure

记录 dispatch conflict

记录 dead total 和 dispatch failure

记录 dispatch conflict

等待 backoff 后再次 claim

人工排查 修复后手动 replay

event_outbox

业务表

contract_revision

Redis PubSub strict sender

未来可替换为 MQ Stream CDC

2.6.8.2 局部状态流转图#

写入待处理事件

认领到期事件

next_attempt_at <= now()

locked_until < now() 后重新认领

handler 执行成功

handler 执行失败,增加 attempts 并设置退避时间

handler 执行失败,达到最大重试次数

PENDING

PROCESSING

FAILED

SUCCEEDED

DEAD

PROCESSING 不是终态。

worker 崩溃或超时后,

其他 worker 可以重新认领。

FAILED 会等待 next_attempt_at。

到期后由 worker 再次扫描处理。

2.6.8.3 运行配置#
application.yaml
event:
outbox:
worker:
mode: ${EVENT_OUTBOX_WORKER_MODE:AUTO}
fallback-scan-delay: ${EVENT_OUTBOX_WORKER_FALLBACK_SCAN_DELAY:60s}
batch-size: ${EVENT_OUTBOX_WORKER_BATCH_SIZE:50}
lock-ttl: ${EVENT_OUTBOX_WORKER_LOCK_TTL:30s}
max-attempts: ${EVENT_OUTBOX_WORKER_MAX_ATTEMPTS:10}
initial-backoff: ${EVENT_OUTBOX_WORKER_INITIAL_BACKOFF:1s}
max-backoff: ${EVENT_OUTBOX_WORKER_MAX_BACKOFF:5m}
max-in-flight-dispatches: ${EVENT_OUTBOX_WORKER_MAX_IN_FLIGHT_DISPATCHES:50}
dispatch-timeout: ${EVENT_OUTBOX_WORKER_DISPATCH_TIMEOUT:30s}
配置项默认值说明
modeAUTOAUTO 时只有存在 L6 producerL6 缓存域 时才启用,ENABLED 强制启用,DISABLED 关闭。
fallback-scan-delay60s兜底扫描间隔。即时 wakeup 失败时,靠它继续处理。
batch-size50单轮最多领取的事件数。
lock-ttl30sPROCESSING 认领租约。worker 崩溃或 callback 丢失后,超过该时间可以被重新认领。
max-attempts10最大尝试次数。达到上限后进入 DEAD
initial-backoff1s首次失败后的基础再重试时间。
max-backoff5m指数再重试时间的最大值。
max-in-flight-dispatches50当前实例允许同时进行的 dispatch 数量上限。
dispatch-timeout30s单次 dispatch 等待结果的超时时间。
mode行为
AUTO默认模式。只有存在真实 L6 producer 时才运行 worker、允许写 outbox row、注册 wakeupfallback scan。没有 producer 时保持静默。
ENABLED强制启用 outbox worker。即使没有 L6 producer 也会运行;当前实现会记录 warn
DISABLED强制关闭 outbox worker。即使存在 L6 producer 也不允许运行;当前实现会记录 error,并且 publish(...) 会因为 runtime state 不允许写入而失败。
2.6.8.4 Outbox#
CREATE TABLE IF NOT EXISTS event_outbox (
event_id UUID PRIMARY KEY,
event_type VARCHAR(120) NOT NULL,
aggregate_type VARCHAR(120),
aggregate_id VARCHAR(120),
payload JSONB NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_by VARCHAR(120),
locked_until TIMESTAMPTZ,
dispatch_id UUID,
dispatched_at TIMESTAMPTZ,
last_error TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
created_time TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT event_outbox_status_check
CHECK (status IN ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'DEAD'))
);
CREATE INDEX IF NOT EXISTS idx_event_outbox_claim
ON event_outbox(status, next_attempt_at, created_time)
WHERE status IN ('PENDING', 'FAILED');
CREATE INDEX IF NOT EXISTS idx_event_outbox_processing_timeout
ON event_outbox(locked_until, created_time)
WHERE status = 'PROCESSING';
CREATE INDEX IF NOT EXISTS idx_event_outbox_aggregate
ON event_outbox(aggregate_type, aggregate_id, created_time);
COMMENT ON TABLE event_outbox IS 'Reliable event outbox for retryable side effects';
COMMENT ON COLUMN event_outbox.payload IS 'Serialized event payload; relay handlers deserialize by event_type';
COMMENT ON COLUMN event_outbox.dispatch_id IS 'Unique token for the current PROCESSING dispatch attempt; state updates must fence by event_id and dispatch_id';
COMMENT ON COLUMN event_outbox.dispatched_at IS 'Timestamp when the current dispatch attempt was claimed';
COMMENT ON INDEX idx_event_outbox_claim IS 'Worker claim lookup for due PENDING/FAILED events; mapper claim query must use FOR UPDATE SKIP LOCKED';
COMMENT ON INDEX idx_event_outbox_processing_timeout IS 'Worker timeout recovery lookup for expired PROCESSING events by locked_until';

关键字段说明:

字段职责
status表示事件当前状态:待处理、处理中、成功、失败、死信
attempts当前已经尝试投递的次数
next_attempt_at失败后下一次允许重试的时间
locked_by标记当前由哪个 worker 实例认领,主要用于排查和观测
locked_until当前认领的恢复窗口,过期后其它 worker 可以重新认领
dispatch_id当前这一轮 dispatch 的唯一令牌,用于状态更新 fencing
dispatched_at当前这一轮 dispatch 被认领的时间
last_error最近一次失败原因

status

状态含义
PENDING已落库,等待广播
PROCESSING已经被某个 worker 认领,正在广播
SUCCEEDED广播成功
FAILED广播失败,等待下次重试
DEAD超过最大重试次数,需要人工处理/补偿

lock-ttloutbox 行的恢复窗口,不是 broker confirm 的可靠等待上限。

confirm 晚于 locked_until 时,事件可能被重新 claim 并重复投递,旧 callback 会因为当前行的 dispatch_id 不匹配而更新失败。

对应下面的枚举类:

OutboxEventStatus.java
public enum OutboxEventStatus {
PENDING,
PROCESSING,
SUCCEEDED,
FAILED,
DEAD
}

对应的实体PO

OutboxEventRecord.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
import java.util.UUID;
@Data
@Accessors(chain = true)
public class OutboxEventRecord {
private UUID eventId;
private String eventType;
private String aggregateType;
private String aggregateId;
private String payload;
private OutboxEventStatus status;
private int attempts;
private OffsetDateTime nextAttemptAt;
private String lockedBy;
private OffsetDateTime lockedUntil;
private UUID dispatchId;
private OffsetDateTime dispatchedAt;
private String lastError;
private OffsetDateTime occurredAt;
private OffsetDateTime createdTime;
private OffsetDateTime updatedTime;
}
2.6.8.5 Outbox Mapper#

跟前面一样的流程,有了表就会有Mapper

OutboxEventMapper.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Mapper
public interface OutboxEventMapper {
int insertPending(OutboxEventRecord record);
List<OutboxEventRecord> claimPendingBatch(@Param("workerId") String workerId,
@Param("lockUntil") OffsetDateTime lockUntil,
@Param("limit") int limit);
int markSucceeded(@Param("eventId") UUID eventId,
@Param("dispatchId") UUID dispatchId);
int markFailed(@Param("eventId") UUID eventId,
@Param("dispatchId") UUID dispatchId,
@Param("nextAttemptAt") OffsetDateTime nextAttemptAt,
@Param("lastError") String lastError);
int markDead(@Param("eventId") UUID eventId,
@Param("dispatchId") UUID dispatchId,
@Param("lastError") String lastError);
}
2.6.8.6 XML#

有了Mapper就会有.xml文件。

OutboxEventMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventMapper">
<resultMap id="OutboxEventRecordMap" type="com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventRecord">
<id property="eventId" column="event_id" javaType="java.util.UUID" jdbcType="OTHER"
typeHandler="com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler"/>
<result property="eventType" column="event_type"/>
<result property="aggregateType" column="aggregate_type"/>
<result property="aggregateId" column="aggregate_id"/>
<result property="payload" column="payload"/>
<result property="status" column="status"/>
<result property="attempts" column="attempts"/>
<result property="nextAttemptAt" column="next_attempt_at"/>
<result property="lockedBy" column="locked_by"/>
<result property="lockedUntil" column="locked_until"/>
<result property="dispatchId" column="dispatch_id" javaType="java.util.UUID" jdbcType="OTHER"
typeHandler="com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler"/>
<result property="dispatchedAt" column="dispatched_at"/>
<result property="lastError" column="last_error"/>
<result property="occurredAt" column="occurred_at"/>
<result property="createdTime" column="created_time"/>
<result property="updatedTime" column="updated_time"/>
</resultMap>
<insert id="insertPending">
INSERT INTO event_outbox(
event_id,
event_type,
aggregate_type,
aggregate_id,
payload,
status,
attempts,
next_attempt_at,
occurred_at
)
VALUES (
#{eventId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler},
#{eventType},
#{aggregateType},
#{aggregateId},
CAST(#{payload} AS JSONB),
'PENDING',
0,
now(),
#{occurredAt}
)
</insert>
<select id="claimPendingBatch" resultMap="OutboxEventRecordMap">
WITH candidate AS (
SELECT event_id
FROM event_outbox
WHERE (
status IN ('PENDING', 'FAILED')
AND next_attempt_at &lt;= now()
)
OR (
status = 'PROCESSING'
AND locked_until &lt; now()
)
ORDER BY created_time
LIMIT #{limit}
FOR UPDATE SKIP LOCKED
)
UPDATE event_outbox e
SET status = 'PROCESSING',
locked_by = #{workerId},
locked_until = #{lockUntil},
dispatch_id = gen_random_uuid(),
dispatched_at = now(),
updated_time = now()
FROM candidate
WHERE e.event_id = candidate.event_id
RETURNING e.*
</select>
<update id="markSucceeded">
UPDATE event_outbox
SET status = 'SUCCEEDED',
locked_by = NULL,
locked_until = NULL,
dispatch_id = NULL,
last_error = NULL,
updated_time = now()
WHERE event_id = #{eventId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
AND status = 'PROCESSING'
AND dispatch_id = #{dispatchId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
</update>
<update id="markFailed">
UPDATE event_outbox
SET status = 'FAILED',
attempts = attempts + 1,
next_attempt_at = #{nextAttemptAt},
locked_by = NULL,
locked_until = NULL,
dispatch_id = NULL,
last_error = #{lastError},
updated_time = now()
WHERE event_id = #{eventId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
AND status = 'PROCESSING'
AND dispatch_id = #{dispatchId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
</update>
<update id="markDead">
UPDATE event_outbox
SET status = 'DEAD',
attempts = attempts + 1,
locked_by = NULL,
locked_until = NULL,
dispatch_id = NULL,
last_error = #{lastError},
updated_time = now()
WHERE event_id = #{eventId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
AND status = 'PROCESSING'
AND dispatch_id = #{dispatchId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
</update>
</mapper>

每次 worker 认领事件时,都会生成新的 dispatch_id

dispatchId = gen_random_uuid(),
dispatched_at = now(),

后续 markSucceededmarkFailedmarkDead 都必须带着 event_id + dispatch_id + status = 'PROCESSING' 更新:

WHERE event_id = #{eventId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typehandler.UuidTypeHandler}
AND status = 'PROCESSING'
AND dispatch_id = #{dispatchId,jdbcType=OTHER,typeHandler=com.zwlh.powergrid.contract.infrastructure.config.mybatis_plus.typeHandler.UuidTypeHandler}

如果一个事件已经因为 locked_until 过期被其它 worker 重新认领,晚到的 callback 持有的 dispatch_id 会被视为过期,更新会失败。

当前 mapper 使用PostgreSQLgen_random_uuid() 生成 dispatch_id,上线前需要确认数据库启用了 pgcrypto 扩展。

2.6.8.7 ReliableEventEnvelope声明#

定义一个轻量不可变数据类ReliableEventEnvelope

ReliableEventEnvelope.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import java.time.Instant;
import java.util.UUID;
public record ReliableEventEnvelope(
UUID eventId, // 事件id
String eventType, // 决定由哪个handler处理
String aggregateType, // 标记事件属于哪个缓存域,方便观测以及排查
String aggregateId,
Object payload, // 事件内容
Instant occurredAt
) {
}

主要是给对应的缓存域构造一个进入 outbox链路的对象,那么谁来做构造对象的载体呢?

2.6.8.8 producer#

producer负责把某个业务动作转换成 ReliableEventEnvelope

只负责构造事件,交给后续的 ReliableEventPublisher 写入 event_outbox

ReliableEventProducer.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
public interface ReliableEventProducer {
public void publishUserAuthorizationInvalidation(long revision)
}

因为当前缓存域并没有接入一个实际的 L6 OutBox Cache ,这里仅作测试用展示 producer

package com.zwlh.powergrid.contract.infrastructure.event.outbox.example;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheInvalidationMessage;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheInvalidationOutboxHandler;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheInvalidationType;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.ReliableEventEnvelope;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.ReliableEventProducer;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.ReliableEventPublisher;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.UUID;
@Profile("outbox-demo")
@Component
@RequiredArgsConstructor
public class ExampleCacheInvalidationProducer implements ReliableEventProducer {
private final ReliableEventPublisher reliableEventPublisher;
public void publishUserAuthorizationInvalidation(long revision) {
ReliableEventEnvelope envelope = new ReliableEventEnvelope(
UUID.randomUUID(),
CacheInvalidationOutboxHandler.EVENT_TYPE,
CacheDomain.USER_AUTHORIZATION.name(),
"USER_AUTHORIZATION",
new CacheInvalidationMessage(
CacheDomain.USER_AUTHORIZATION,
CacheInvalidationType.REVISION_CHANGED,
revision,
"outbox_demo"
),
Instant.now()
);
// 广播领域事件构造完之后,就要有一个publisher做推送。
reliableEventPublisher.publish(envelope);
}
}

上面说道,广播领域事件构造完后,要有一个publisher做推送。

不过推送之前,我们得知道实践过程中,我们可能会有很多个Producer,要做一下存在检测以及统一管理。

2.6.8.9 ReliableEventProducerRegistry 检查是否已有 producer#
DefaultReliableEventProducerRegistry.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheConsistencyLevel;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import com.zwlh.powergrid.contract.infrastructure.cache.CachePolicyRegistry;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
@Component
public class DefaultReliableEventProducerRegistry implements ReliableEventProducerRegistry {
private final List<ReliableEventProducer> reliableEventProducers;
public DefaultReliableEventProducerRegistry(List<ReliableEventProducer> reliableEventProducers) {
this.reliableEventProducers = reliableEventProducers;
}
@Override
public boolean hasReliableEventProducer() {
if (!reliableEventProducers.isEmpty()) {
return true;
}
return Arrays.stream(CacheDomain.values())
.map(CachePolicyRegistry::policyOf)
.anyMatch(policy -> policy.level() == CacheConsistencyLevel.OUTBOX_RELIABLE_EVENT);
}
}

注册完并且可以实际调用了,就要有一个 publisher 做推送,也就是ReliableEventPublisher

2.6.8.10 发布入口 ReliableEventPublisher声明#

注意!所有的 publish 成功后,只代表 outbox row 随业务事务提交,不代表 handler 已执行成功。

构造一个类,让前面构造好的广播事件对象走outbox流程以及唤醒线程做即时广播:

ReliableEventPublisher.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
public interface ReliableEventPublisher {
void publish(ReliableEventEnvelope envelope);
}
OutboxReliableEventPublisher.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.observability.OutboxMetrics;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.ZoneOffset;
@Component
@RequiredArgsConstructor
public class OutboxReliableEventPublisher implements ReliableEventPublisher {
private final ObjectMapper objectMapper;
private final OutboxEventMapper outboxEventMapper;
private final OutboxWakeupService outboxWakeupService;
// 负责判断outbox当前是否能运行的服务,后面会说。
private final OutboxWorkerRuntimeState runtimeState;
private final OutboxMetrics outboxMetrics;
@Override
public void publish(ReliableEventEnvelope envelope) {
// 判断outbox链路是否能运行
if (!runtimeState.shouldPublishOutboxRows()) {
// 埋点,主要是记录有业务代码调用了 ReliableEventPublisher.publish(...),但当前runtime state 不允许写 outbox。
// 翻日志的时候直接看对应的缓存域 `envelope.aggregateType()`,人工排查配置即可。
outboxMetrics.recordPublish(envelope.eventType(), envelope.aggregateType(), "disabled");
// 直接抛异常,不写 event_outbox,也不 wakeup。
throw new IllegalStateException("outbox publishing is disabled for the current runtime state");
}
try {
outboxEventMapper.insertPending(new OutboxEventRecord()
.setEventId(envelope.eventId())
.setEventType(envelope.eventType())
.setAggregateType(envelope.aggregateType())
.setAggregateId(envelope.aggregateId())
.setPayload(toJson(envelope.payload()))
.setStatus(OutboxEventStatus.PENDING)
.setAttempts(0)
.setOccurredAt(envelope.occurredAt().atOffset(ZoneOffset.UTC)));
// 记录当前广播推送成功
recordPublishSuccessAfterCommit(envelope.eventType());
// 唤醒线程即时推送广播
wakeupAfterCommit();
} catch (RuntimeException ex) {
// 埋点,记录广播推送失败
// outbox 事件没有可靠落库。可能是 DB 异常、SQL 约束、JSON 序列化失败等。
outboxMetrics.recordPublish(envelope.eventType(), envelope.aggregateType(), "error");
// 异常继续抛出,调用方事务会失败/回滚。
throw ex;
}
}
private void recordPublishSuccessAfterCommit(String eventType, String aggregateType) {
// 跟上面的事务一样,事务之外就直接执行,事务内就等事务提交成功后再执行。
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
// 埋点,记录outbox row已经随业务事务提交,但不代表 handler已经执行
outboxMetrics.recordPublish(eventType, aggregateType, "success");
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 埋点,记录广播推送成功
// 记录outbox row已经随业务事务提交,但不代表 handler已经执行
outboxMetrics.recordPublish(eventType, aggregateType, "success");
}
});
}
private void wakeupAfterCommit() {
// 跟之前CacheRevisionServiceImpl实现是差不多的道理
// 当前不在Spring事务里头的时候
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
// 直接使用outboxWakeup服务进行唤醒广播。
outboxWakeupService.requestWakeup();
return;
}
// 如果在事务里面,那就在afterCommit中注册回调,等事务执行成功后,再执行wakeup。
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
outboxWakeupService.requestWakeup();
}
});
}
private String toJson(Object payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException ex) {
throw new IllegalArgumentException("Failed to serialize reliable event payload", ex);
}
}
}

接下来,就要做唤醒线程推送广播的事情。

2.6.8.11 声明OutboxWakeupService#
OutboxWakeupService.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
public interface OutboxWakeupService {
void requestWakeup();
}
2.6.8.12 OutboxWakeupListener实现OutboxWakeupService#
OutboxWakeupServiceImpl.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.observability.OutboxMetrics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxWakeupListener implements OutboxWakeupService {
private final OutboxWorkerRuntimeState runtimeState;
private final OutboxWorker worker;
private final TaskExecutor taskExecutor;
private final OutboxMetrics outboxMetrics;
@Override
public void requestWakeup() {
if (!runtimeState.shouldRun()) {
// 埋点,记录当前实际不可用
outboxMetrics.recordWakeup("disabled");
return;
}
try {
// 把outboxWorker包装成一个动作让Spring的TaskExecutor去执行(线程池异步执行)
taskExecutor.execute(worker::drainOnce);
outboxMetrics.recordWakeup("accepted");
} catch (RuntimeException ex) {
// 埋点:线程池拒绝执行时记录
// 注意,这个指标主要是指即时调度唤醒失败,但主流程是没问题的,只是卡在了即时广播。
// 注意以下:
// 1. 线程池满、队列满
// 2. executor 正在 shutdown
// 3. outbox drain 变慢,导致唤醒任务堆积
// 4. 业务写入 outbox 的速率超过 worker 消费能力
// 观测的时候不要看rejected的绝对值,得看dead是否增长,表内PENDING、FAILED、过期PROCESSING是否积压、dispatch的周期是否变长、dispatch failure/conflict是否升高、rejected对比accept的比例是否升高,rejected在5min内的值是否持续大于0
outboxMetrics.recordWakeup("rejected");
log.warn("event outbox wakeup task was not accepted; fallback scan will retry", ex);
}
}
}

至此,一个显式的流程就已经走完了:

  • OutboxReliableEventPublisher 推送广播
  • OutboxWakeupService收到广播后,将任务扔给一个异步线程去执行

具体怎么执行呢?这个outbox当前能不能运行?能不能写入?能不能wakeup

这个判断就交给下面的OutboxWorkerRuntimeState,之前在OutboxReliableEventPublisher中也提到过。

2.6.8.13 OutboxWorkerRuntimeState判断outbox是否可执行#

不直接处理事件,也不写 event_outbox,只负责将两类信息合成运行态判断。

OutboxWorkerRuntimeState.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class OutboxWorkerRuntimeState {
// 配置请查看2.6.9.3的运行配置
private final OutboxWorkerProperties properties;
// 查看前面ProducerRegistry的声明及运作
private final ReliableEventProducerRegistry producerRegistry;
public OutboxWorkerRuntimeState(
OutboxWorkerProperties properties,
ReliableEventProducerRegistry producerRegistry
) {
this.properties = properties;
this.producerRegistry = producerRegistry;
logStartupMismatch();
}
// 通过配置项,判断当前 L6 广播是否应该执行
public boolean shouldRun() {
return switch (properties.mode()) {
case AUTO -> producerRegistry.hasReliableEventProducer();
case ENABLED -> true;
case DISABLED -> false;
};
}
public boolean shouldRegisterWakeup() {
return shouldRun();
}
public boolean shouldPublishOutboxRows() {
return shouldRun();
}
private void logStartupMismatch() {
// 查看是否已有启用的L6 outbox producer
boolean hasProducer = producerRegistry.hasReliableEventProducer();
// 如果当前L6已启用,但是没有producer
if (properties.mode() == OutboxWorkerMode.ENABLED && !hasProducer) {
log.warn("event outbox worker is enabled but no L6 producer is registered");
}
// 如果当前L6没有启用,但是有producer
if (properties.mode() == OutboxWorkerMode.DISABLED && hasProducer) {
log.error("event outbox worker is disabled while an L6 producer is registered");
}
}
}

整理成表格如下:

modehasReliableEventProducershouldRun / shouldPublishOutboxRows / shouldRegisterWakeup
AUTOtruetrue
AUTOfalsefalse
ENABLEDtruetrue
ENABLEDfalsetrue,但启动时记录 warn
DISABLEDtruefalse,并在启动时记录 error
DISABLEDfalsefalse

ok,能不能做 outbox 流程的检测完毕,开始说明对应的 outbox 链路的调度执行器:worker

2.6.8.14 OutboxWorker#

outbox 的流程消费的调度执行器。

负责把已经落库且到了可处理时间的事件,从 event_outbox中认领出来,交给对应的 outboxEventHandler执行,根据执行结果将表中的事件改为 SUCCEEDEDFAILED以及DEAD

OutboxWorker.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.observability.OutboxMetrics;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class OutboxWorker {
private static final int MAX_ERROR_LENGTH = 2_000;
private final OutboxEventMapper outboxEventMapper;
private final List<OutboxEventHandler> handlers;
private final OutboxWorkerProperties properties;
private final OutboxMetrics outboxMetrics;
// 用于执行 dispatch 完成后的数据库状态更新,避免直接在 broker callback 线程里写 DB。
private final Executor dispatchCompletionExecutor;
// 限制当前实例内同时进行的 dispatch 数量,避免 broker confirm 慢时堆积过多 future。
private final Semaphore inFlightDispatches;
private final String workerId = buildWorkerId();
// 这个构造器主要方便单元测试,Spring 会使用下面带 @Autowired 的构造器。
public OutboxWorker(
OutboxEventMapper outboxEventMapper,
List<OutboxEventHandler> handlers,
OutboxWorkerProperties properties,
OutboxMetrics outboxMetrics
) {
this(outboxEventMapper, handlers, properties, outboxMetrics, Runnable::run);
}
@Autowired
public OutboxWorker(
OutboxEventMapper outboxEventMapper,
List<OutboxEventHandler> handlers,
OutboxWorkerProperties properties,
OutboxMetrics outboxMetrics,
@Qualifier("taskExecutor") Executor dispatchCompletionExecutor
) {
this.outboxEventMapper = outboxEventMapper;
this.handlers = handlers;
this.properties = properties;
this.outboxMetrics = outboxMetrics;
this.dispatchCompletionExecutor = dispatchCompletionExecutor;
this.inFlightDispatches = new Semaphore(properties.maxInFlightDispatches());
}
// 核心入口
public void drainOnce() {
// 先根据 batchSize 和当前剩余并发容量预留 permit,避免并发 drain 超额认领。
int claimLimit = reserveClaimPermits();
if (claimLimit <= 0) {
return;
}
int claimedCount = 0;
// 设定 now + lockTtl 为 PROCESSING 事件的过期时间,简单地来说就是锁的过期时间。
OffsetDateTime lockUntil = OffsetDateTime.now(ZoneOffset.UTC).plus(properties.lockTtl());
try {
// 从 outbox 表中找到当前可以处理的行:
// PENDING、到期 FAILED、过期 PROCESSING。
// 如果已经有别的 worker 认领,那么 FOR UPDATE SKIP LOCKED 可以避开别的 worker 已经锁住的行。
// 命中的行会统一改成 PROCESSING,并写入 locked_by、locked_until、dispatch_id、dispatched_at。
List<OutboxEventRecord> records = outboxEventMapper.claimPendingBatch(
workerId,
lockUntil,
claimLimit
);
claimedCount = Math.min(records.size(), claimLimit);
// 把本轮申领到的 records 按指标维度分组:
// eventType:事件类型,比如 CACHE_INVALIDATION
// aggregateType:聚合类型或缓存域,比如 USER_AUTHORIZATION
// 最后统计每组有多少条认领的事件。
records.stream()
.collect(java.util.stream.Collectors.groupingBy(
OutboxWorker::metricKey,
java.util.stream.Collectors.summingInt(record -> 1)
))
.forEach((key, count) -> outboxMetrics.recordClaimed(key.eventType(), key.aggregateType(), count));
for (int i = 0; i < claimedCount; i++) {
// 发起 dispatch。这里不再把 handler 方法返回当作成功,而是等待 CompletionStage 完成。
dispatchReserved(records.get(i));
}
if (records.size() > claimLimit) {
log.warn(
"event outbox mapper returned {} records for claim limit {}; extra records remain locked until timeout",
records.size(),
claimLimit
);
}
} finally {
// 如果本轮预留了 permit,但实际没有 claim 到对应数量的记录,需要把多余 permit 还回去。
int unusedPermits = claimLimit - claimedCount;
if (unusedPermits > 0) {
inFlightDispatches.release(unusedPermits);
}
}
}
private int reserveClaimPermits() {
int reserved = 0;
// 单轮最多认领 properties.batchSize 条,同时不能超过当前还剩下的 dispatch 并发容量。
int claimLimit = Math.min(properties.batchSize(), inFlightDispatches.availablePermits());
for (int i = 0; i < claimLimit; i++) {
if (!inFlightDispatches.tryAcquire()) {
break;
}
reserved++;
}
return reserved;
}
private void dispatchReserved(OutboxEventRecord record) {
long startNanos = System.nanoTime();
try {
// 找到对应事件类型的 handler。
// handler 必须幂等,因为 outbox 是 at-least-once delivery。
OutboxEventHandler handler = findHandler(record.getEventType());
// 埋点,记录 dispatch 已经发起。注意,这不代表 dispatch 已经成功。
outboxMetrics.recordDispatchStarted(record.getEventType(), record.getAggregateType());
// handler.dispatch(record) 返回 CompletionStage<OutboxDispatchResult>。
// Redis Pub/Sub 这种同步 sender 可以返回已完成的 CompletableFuture。
// Kafka / RabbitMQ 这种异步 broker sender 应该返回 producer confirm future 转换后的 CompletionStage。
withTimeout(handler.dispatch(record), properties.dispatchTimeout().toMillis())
.whenCompleteAsync((result, ex) -> {
try {
if (ex != null) {
// dispatch 抛错或超时,进入失败/死信处理。
String updateResult = markFailedOrDead(record, ex);
recordDispatch(record, updateResult, startNanos);
return;
}
if (result != null && result.success()) {
// dispatch 成功后标记 SUCCEEDED。
// 这里使用 dispatch_id,而不是 workerId,避免旧 callback 覆盖新一轮认领。
int updated = outboxEventMapper.markSucceeded(record.getEventId(), record.getDispatchId());
recordDispatch(record, updated > 0 ? "success" : "conflict", startNanos);
return;
}
// CompletionStage 正常完成,但结果表示失败,也按失败重试处理。
String error = result == null
? "outbox dispatch result is null"
: result.errorMessage();
String updateResult = markFailedOrDead(record, new RuntimeException(error));
recordDispatch(record, updateResult, startNanos);
} finally {
// 无论成功、失败还是冲突,本轮 dispatch 完成后都要释放 permit。
inFlightDispatches.release();
}
}, dispatchCompletionExecutor);
} catch (RuntimeException ex) {
try {
// handler.dispatch(record) 本身抛错,也按普通投递失败处理。
String result = markFailedOrDead(record, ex);
recordDispatch(record, result, startNanos);
} finally {
inFlightDispatches.release();
}
}
}
private CompletionStage<OutboxDispatchResult> withTimeout(
CompletionStage<OutboxDispatchResult> dispatchStage,
long timeoutMillis
) {
// 不直接调用 dispatchStage.orTimeout(...),避免修改 handler 返回的原始 future。
// 这里创建一个独立的 result future,把 dispatch 完成和 timeout 完成都汇聚到 result 上。
CompletableFuture<OutboxDispatchResult> result = new CompletableFuture<>();
CompletableFuture<OutboxDispatchResult> timeout = new CompletableFuture<>();
CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS, dispatchCompletionExecutor)
.execute(() -> timeout.completeExceptionally(new TimeoutException("outbox dispatch timed out")));
dispatchStage.whenComplete((value, ex) -> {
if (ex != null) {
result.completeExceptionally(ex);
return;
}
result.complete(value);
});
timeout.whenComplete((ignored, ex) -> {
if (ex != null) {
result.completeExceptionally(ex);
}
});
return result;
}
private OutboxEventHandler findHandler(String eventType) {
return handlers.stream()
.filter(handler -> handler.supports(eventType))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported outbox event type: " + eventType));
}
private String markFailedOrDead(OutboxEventRecord record, Throwable ex) {
// 将 exception 转换成一个具体的 error string。
String error = errorMessage(ex);
// 累计尝试次数。
int nextAttempt = record.getAttempts() + 1;
// 大于最大尝试次数的话。
if (nextAttempt >= properties.maxAttempts()) {
// 标记为 DEAD。
// 这里使用 dispatch_id 做 fencing,旧 callback 更新不到新一轮 claim 后的记录。
int updated = outboxEventMapper.markDead(record.getEventId(), record.getDispatchId(), error);
if (updated > 0) {
// 埋点,按事件类型+聚合类型记录 DEAD。
outboxMetrics.recordDead(record.getEventType(), record.getAggregateType());
return "failure";
}
// 更新失败的话,可能是 locked_until 过期后被别的 worker 重新认领了,返回 conflict。
return "conflict";
}
// 标记推送失败,如果写入成功就返回 failure。
// 写入不成功可能是锁过期后被别的 worker 重新认领了,返回 conflict。
int updated = outboxEventMapper.markFailed(
record.getEventId(),
record.getDispatchId(),
nextAttemptAt(nextAttempt),
error
);
return updated > 0 ? "failure" : "conflict";
}
private void recordDispatch(OutboxEventRecord record, String result, long startNanos) {
// 埋点,记录 outbox dispatch 结果以及耗时。
outboxMetrics.recordDispatch(
record.getEventType(),
record.getAggregateType(),
result,
System.nanoTime() - startNanos
);
}
// 下一次重试时间
private OffsetDateTime nextAttemptAt(int nextAttempt) {
// 计算指数退避倍率:1,2,4,8,16,32……
// 最大位移到 30,1L << n 就是 2 的 n 次方。
long multiplier = 1L << Math.min(Math.max(nextAttempt - 1, 0), 30);
// 计算最终延迟毫秒。
// 基础的退避时间 * 上面算的倍率,就是最终的重试时间点,
// 但不能超过 properties.maxBackoff().toMillis(),超过的话取最大值。
long delayMillis = Math.min(
properties.initialBackoff().toMillis() * multiplier,
properties.maxBackoff().toMillis()
);
// 返回下一次可以重试的绝对时间点,就是当前时间 + 刚刚的延迟的 UTC 表示。
return OffsetDateTime.now(ZoneOffset.UTC).plusNanos(delayMillis * 1_000_000L);
}
private String errorMessage(Throwable ex) {
String message = ex.getMessage();
if (message == null || message.isBlank()) {
message = ex.getClass().getName();
}
// 超过最大的错误长度就取 0 ~ 最大长度。
return message.length() > MAX_ERROR_LENGTH ? message.substring(0, MAX_ERROR_LENGTH) : message;
}
private static String buildWorkerId() {
try {
// 给 worker 分配一个由网络地址 + 主机名 + UUID 构成的唯一标识。
return InetAddress.getLocalHost().getHostName() + "-" + UUID.randomUUID();
} catch (UnknownHostException ex) {
// 获取不到网络地址 + 主机名就返回 unknown-host-UUID。
return "unknown-host-" + UUID.randomUUID();
}
}
// 根据 OutboxEventRecord 返回由 eventType 以及 aggregateType 构造的 MetricKey。
private static MetricKey metricKey(OutboxEventRecord record) {
return new MetricKey(record.getEventType(), record.getAggregateType());
}
// record 类型是 Java 里面一个轻量的不可变数据类型,
// 会按照形参自动生成 equals、hashCode、toString、构造器、字段读取方法。
private record MetricKey(String eventType, String aggregateType) {
}
}

CompletionStage 来自:

import java.util.concurrent.CompletionStage;

这里引入 Semaphore 是为了限制当前实例同时进行的 dispatch 数量。

drainOnce() 会先预留 permit,再去认领数据库里的事件,避免并发 wakeup 时一次性认领太多事件,导致事件进入 PROCESSING 后却没有实际 dispatch

whenCompleteAsync(..., dispatchCompletionExecutor) 用于避免在 broker callback 线程里直接做数据库状态更新。状态更新统一回到应用自己的 executor 中执行。

2.6.8.15 OutboxEventHandler#
OutboxDispatchResult.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
public record OutboxDispatchResult(
boolean success,
String errorMessage
) {
public static OutboxDispatchResult succeeded() {
return new OutboxDispatchResult(true, null);
}
public static OutboxDispatchResult failure(String errorMessage) {
String message = errorMessage == null || errorMessage.isBlank()
? "outbox dispatch failed"
: errorMessage;
return new OutboxDispatchResult(false, message);
}
}

CompletionStage<OutboxDispatchResult> 表示一个未来才会完成的 dispatch 结果。

OutboxEventHandler.java
package com.zwlh.powergrid.contract.infrastructure.event.outbox;
import java.util.concurrent.CompletionStage;
/**
* 处理outbox的事件内容. 其实现必须遵循幂等性。
* 也就是说,执行多次但需要结果一致。
* 抛错需要重试。
* 返回的CompletionStage会在localSender / broker 在side effect后进行confirmed
*/
public interface OutboxEventHandler {
boolean supports(String eventType);
CompletionStage<OutboxDispatchResult> dispatch(OutboxEventRecord record);
}

同步 sender 可以返回 CompletableFuture.completedFuture(OutboxDispatchResult.succeeded())

异步 broker sender 应该把 producer confirm future 转换成 CompletionStage<OutboxDispatchResult>

当前 Redis Pub/Sub handler 可以这样适配:

CacheInvalidationOutboxHandler.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxDispatchResult;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventHandler;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@Component
@RequiredArgsConstructor
public class CacheInvalidationOutboxHandler implements OutboxEventHandler {
public static final String EVENT_TYPE = "CACHE_INVALIDATION";
private final ObjectMapper objectMapper;
private final CacheInvalidationSender sender;
// 说明当前handler支持该事件类型
@Override
public boolean supports(String eventType) {
return EVENT_TYPE.equals(eventType);
}
@Override
// 这是同步的,所以直接completedFuture即可。
public CompletionStage<OutboxDispatchResult> dispatch(OutboxEventRecord record) {
try {
sender.send(toMessage(record.getPayload()));
return CompletableFuture.completedFuture(OutboxDispatchResult.succeeded());
} catch (RuntimeException ex) {
CompletableFuture<OutboxDispatchResult> future = new CompletableFuture<>();
future.completeExceptionally(ex);
return future;
}
}
private CacheInvalidationMessage toMessage(String payload) {
try {
// CacheInvalidationMessage 这个static Class前面有,熟面孔了,在2.6.4.5 广播消费章节
return objectMapper.readValue(payload, CacheInvalidationMessage.class);
} catch (JsonProcessingException ex) {
throw new IllegalArgumentException("Failed to deserialize cache invalidation payload", ex);
}
}
}

sender背后对应的只是简单的redisTemplate方法:

CacheInvalidationSender.java
package com.zwlh.powergrid.contract.infrastructure.cache;
public interface CacheInvalidationSender {
void send(CacheInvalidationMessage message);
}
RedisCacheInvalidationSender.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class RedisCacheInvalidationSender implements CacheInvalidationSender {
private final RedisTemplate<String, Object> redisTemplate;
private final CacheBroadcastProperties properties;
@Override
public void send(CacheInvalidationMessage message) {
if (!properties.enabled()) {
return;
}
// 将消息发送到指定channel,走的是Redis Pub/Sub
// 其实本质上还是走Redis Pub/Sub,只不过有了Outbox这么一套东西去维护其可靠性。
redisTemplate.convertAndSend(properties.channel(), message);
}
}
2.6.8.16 OutboxWorkerScheduler#

上面只介绍了线程 wakeup后的运作,但既然要确保其可靠性,即时推送有可能会失败,需要有兜底措施。

这个兜底措施在目前项目中就是一个轻量级的 Scheduler,继承 SmartLifecycle做周期性扫描。

跟普通的@Schedule不一样的是,SmartLifecycle可以做到不用就不注册fallback scan,避免后台有个线程一直在扫。

OutboxWorkerScheduler.java
com.zwlh.powergrid.contract.infrastructure.event.outbox;
import lombok.RequiredArgsConstructor;
import org.springframework.context.SmartLifecycle;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Component;
import java.util.concurrent.ScheduledFuture;
@Component
@RequiredArgsConstructor
public class OutboxWorkerScheduler implements SmartLifecycle {
private final OutboxWorkerRuntimeState runtimeState;
private final OutboxWorker worker;
private final TaskScheduler taskScheduler;
private final OutboxWorkerProperties properties;
private ScheduledFuture<?> scheduledFuture;
private volatile boolean running;
@Override
public void start() {
// 如果不符合runtime state的启用标准,直接不注册fallback scan
if (!runtimeState.shouldRun()) {
return;
}
// 注册调度,使用前面2.6.8.3中的properties中的fallbackScanDelay配置做周期性扫描执行worker
scheduledFuture = taskScheduler.scheduleWithFixedDelay(
worker::drainOnce,
properties.fallbackScanDelay()
);
running = true;
}
@Override
public void stop() {
// 如果scheduledFuture为null的话就不执行。
if (scheduledFuture != null) {
scheduledFuture.cancel(false);
}
running = false;
}
//返回schedule是否正在执行
@Override
public boolean isRunning() {
return running;
}
}
2.6.8.17 阶段性总结#

ReliableEventPublisher.publish(...) 负责把可靠事件写入 event_outbox,这一步和业务事务在同一个事务边界内。

事务提交后,OutboxWakeupService 会尝试即时唤醒 OutboxWorker

OutboxWorkerRuntimeState 负责判断当前运行时是否允许写 outbox、是否允许 wakeup、是否启动 fallback scan

OutboxWorker 每次认领到期事件时,会把事件改为 PROCESSING,写入 locked_bylocked_untildispatch_iddispatched_at

locked_by 用于排查是哪一个 worker 认领了事件。

dispatch_id 用于判断 callback 是否属于当前这轮 dispatch。所有最终状态更新都要带着 event_id + dispatch_id + PROCESSING 条件。

OutboxEventHandler.dispatch(...) 返回 CompletionStage<OutboxDispatchResult>worker 不会在 handler 方法返回后立即标记成功,而是等待这个 stage 完成后,再把事件改为 SUCCEEDEDFAILEDDEAD

如果 dispatch 超时或失败,事件会按指数退避进入下一次重试。超过最大尝试次数后进入 DEAD,需要人工排查、补偿或重试。

如果 worker 崩溃,或者 callback 永远没有回来,locked_until 到期后其它 worker 可以重新认领这条事件,并生成新的 dispatch_id

如果旧 callback 后来才回来,它会因为 dispatch_id 不匹配而更新不到记录。这种情况应该记录为 conflict,而不是覆盖新一轮 dispatch 的状态。

Outboxat-least-once delivery

它只能保证事件不会因为进程崩溃、线程池拒绝、临时失败而静默丢失,不能保证下游只收到一次。因此 handlerconsumer 都必须按 eventId 或业务幂等键做幂等。

OutboxMetrics 记录 publishwakeupclaimeddispatchdead 等指标,用于在 SigNoz 等平台观察 outbox 是否积压、失败或进入死信。

2.6.8.18 接入 Kafka / RabbitMQ 示例#

实现OutboxEventHandler以进行拓展。

不要把 broker 异步发送用 .get(timeout) 包成同步调用后再接入 outbox

应该是 handler 返回 producer confirm future 转换后的 CompletionStage<OutboxDispatchResult>

2.6.8.18.1 Kafka示例#
package com.zwlh.powergrid.contract.infrastructure.event.outbox.example;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxDispatchResult;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventHandler;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventRecord;
import lombok.RequiredArgsConstructor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.springframework.kafka.core.KafkaTemplate;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletionStage;
@RequiredArgsConstructor
public class KafkaOutboxEventHandler implements OutboxEventHandler {
private final KafkaTemplate<String, String> kafkaTemplate;
private final String topic;
@Override
public boolean supports(String eventType) {
return "CACHE_INVALIDATION".equals(eventType);
}
@Override
public CompletionStage<OutboxDispatchResult> dispatch(OutboxEventRecord record) {
ProducerRecord<String, String> message = new ProducerRecord<>(
topic,
record.getAggregateId(),
record.getPayload()
);
message.headers().add(
"eventId",
record.getEventId().toString().getBytes(StandardCharsets.UTF_8)
);
// 确认消息发送成功后,再调用OutboxDispatchResult的方法返回状态
return kafkaTemplate.send(message)
.thenApply(result -> OutboxDispatchResult.succeeded())
.exceptionally(ex -> OutboxDispatchResult.failure(ex.getMessage()));
}
}
2.6.8.18.2 RabbitMQ 示例:#
package com.zwlh.powergrid.contract.infrastructure.event.outbox.example;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxDispatchResult;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventHandler;
import com.zwlh.powergrid.contract.infrastructure.event.outbox.OutboxEventRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import java.util.concurrent.CompletionStage;
@RequiredArgsConstructor
public class RabbitOutboxEventHandler implements OutboxEventHandler {
private final RabbitTemplate rabbitTemplate;
private final String exchange;
private final String routingKey;
@Override
public boolean supports(String eventType) {
return "CACHE_INVALIDATION".equals(eventType);
}
@Override
public CompletionStage<OutboxDispatchResult> dispatch(OutboxEventRecord record) {
CorrelationData correlation = new CorrelationData(record.getDispatchId().toString());
rabbitTemplate.convertAndSend(
exchange,
routingKey,
record.getPayload(),
message -> {
message.getMessageProperties().setMessageId(record.getEventId().toString());
return message;
},
correlation
);
// 同上,apply取得ack结果后,再对OutboxDispatchResult做出标记。
return correlation.getFuture()
.thenApply(confirm -> confirm.isAck()
? OutboxDispatchResult.succeeded()
: OutboxDispatchResult.failure(confirm.getReason()))
.exceptionally(ex -> OutboxDispatchResult.failure(ex.getMessage()));
}
}

3. L1 的缓存穿透保护#

该节只是对L1作为补充,如果不需要这方面的内容,可以直接看下面的可观测性总览章节。

缓存穿透防护解决的是另一个问题:外部可控、高基数、可枚举或可随机探测的 key,不断绕过 Redis 命中路径,直接打到 DB

它不属于前面提到的缓存一致性等级,也不会替代 afterCommit evictrevisionPub/Suboutbox

一致性治理关注的是“数据变了之后缓存怎么失效”,而缓存穿透防护关注的是读取一个根本不存在的资源时,怎么避免每次都请求 DB

因为当前系统是邀请制,或者说面对的主要是B端用户,Bloom Filter太重了,没有必要上全套的缓存防护系统。

所以当前实践里只是先做一层轻量 guard

  1. 输入边界:非法 id空值超长 key格式错误的业务数据直接拦截,不进入 Redis / DB

  2. 局部限流:复用已有 @RateLimit,对高风险详情查询按用户接口target 维度限流,为了不影响主线篇幅,这里只给出接口,实现在后续给出。

    RateLimit.java
    package com.zwlh.powergrid.contract.infrastructure.framework.annotation;
    import java.lang.annotation.*;
    /**
    * 自定义限流注解,用于方法上。
    * 支持在同一个方法上配置多个,实现多维度限流。
    */
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Repeatable(RateLimits.class) // 允许重复使用
    public @interface RateLimit {
    /**
    * 限流的 Key (支持 SpEL 表达式)。
    * 当 type = CUSTOM 时,例如 '#phone' 或 '#request.userId'。
    * 当 type = IP 时,此字段可留空或作为备注。
    */
    String spEL();
    /**
    * 限流的时间窗口(单位:秒)
    */
    int time();
    /**
    * 在 time 时间窗口内允许的最大请求数
    */
    int count();
    /**
    * 限流类型:IP 或 用户自定义
    */
    RateLimitType type() default RateLimitType.CUSTOM;
    /**
    * 触发限流时的提示信息
    */
    String message() default "操作过于频繁,请稍后再试";
    }
  3. TTL negative cache:合法格式但实际数据不存在时,直接在 Redis 中写入一个短 TTL 值,后续相同的 key 命中 negative cache时,业务方直接返回不存在,不会去请求 DB

  4. 主动清理:主要是解决3的问题,真实数据创建成功后,清理对应negative cache,避免新数据被旧的 negative key 影响。

  5. 低基数指标:只按 domainkey_typeresult 观察,不把 userId业务 idtoken邀请码明文放进指标标签。

    注意,这个指标并不是指@RateLimit中的指标,指的是 CachePenetrationGuard自己的 Metrics,用于观察非法 keynegative hit/miss、回源耗时以及negative cache清理效果。

当然,当前对negative cache的处理很简单,如果攻击者做 key 的递增操作,比如说一个自增id的表最大id只迭代到了 88,但是攻击者持续输入 89,90,91,92 这样请求下去,这套系统就无能为力,毕竟理论上它就是合理的查询值,只不过每次都会打到 DB查询。

对于一个B端系统来说,不值得花费那么大的精力去上 Bloom FIlter,毕竟用户量撑死就这么点,实际上目前的缓存模块对于这么个B端系统来说也是展示意义大于实际意义。

L1级别的缓存穿透保护也是顺手的事,解决类似于自增 id 攻击的事情,交给 Metrics 报警以及@RateLimit功能即可,@RateLimit解决关键资源的请求速率避免因攻击产生不必要资源开销,Metrics 报警后直接封号后通知人工排查。

但请注意边界,缓存穿透保护做不了成本类接口防刷,比如说某个人拿着成吨的 ip 池子来做注册短信发送骚扰,这类案例不在缓存系统以及缓存穿透保护模块的边界内,要做也是新开一个叫做《成本类接口防刷》的文章了。

这里拿我项目中的售电协议业务缓存域来展示缓存穿透保护,注意,这里仅作为 L1 级别的缓存域定义,并不是说在缓存域里面就要参与revision或者outbox的链路:

CacheDomain.java
public enum CacheDomain {
USER_AUTHORIZATION,
// 拿售电协议业务缓存域来展示缓存穿透保护
SERVICE_AGREEMENT
}
3.1.1 职责分工:#
1. `CachePenetrationGuard` 负责缓存穿透保护的通用编排:
* 判断开关是否启用
* 做通用输入边界检查,比如空值、空白字符串、超长 `key`
* 委托 `CacheKeyNormalizerRegistry` 做具体 `key` 归一化
* 查询、写入、删除 `negative cache`
* 记录 `Metrics`
* 包装真实回源查询的耗时与结果指标
2. `CacheKeyNormalizerRegistry` 负责从所有 `CacheKeyNormalizer` 中找到支持当前 `CacheDomain + CacheKeyType` 的实现。
3. `CacheKeyNormalizer` 负责具体 `key` 的格式规则和归一化。
这部分不能放进 `CachePenetrationGuard`,否则基础设施组件会混入业务规则。
4. 业务读取路径负责决定什么时候回源 `DB`、什么时候写入 `negative cache`。
`CachePenetrationGuard` 不判断业务数据是否存在,它只告诉调用方:当前 `key` 非法、`negative cache` 命中、`negative cache` 未命中,或者 `Redis` 访问异常。
3.1.2 整体流程:#
1. 调用方把 `domain`、`keyType`、`keyValue` 传给 `CachePenetrationGuard check`。
2. `CachePenetrationGuard` 先做通用输入边界检查。
空值、空白字符串、超长 `key` 会直接返回 `INVALID_KEY`,不进入 `Redis / DB`。
3. 对通过通用检查的 `key`,`CachePenetrationGuard` 委托 `CacheKeyNormalizerRegistry` 找到对应的 `CacheKeyNormalizer`。
比如 `CacheKeyType.ID` 由通用的 `PositiveLongCacheKeyNormalizer` 处理,只接受可表示为正 `Long` 的数字型 `ID`。
具体业务编码类 `key`,则由对应业务模块提供自己的 `normalizer`。
4. `normalizer` 返回归一化后的 `key` 后,`CachePenetrationGuard` 拼出对应的 `negative cache key` 查询 `Redis`。
如果存在,返回 `NEGATIVE_HIT`,调用方可以直接返回不存在,不再请求 `DB`。
如果不存在,返回 `NEGATIVE_MISS`,表示防穿透层没有拦截,调用方继续走正常读取流程。
5. 调用方在 `NEGATIVE_MISS` 时回源 `DB`。
如果 `DB` 查到了真实数据,直接返回。
如果 `DB` 仍然查不到,调用 `putNegative` 写入`短 TTL` 的 `negative cache`,避免短时间内相同 `key` 反复打到 `DB`。
6. 真实数据创建成功后,业务写路径不直接散落 `Redis` 删除逻辑,而是发布领域事件。
缓存治理层把领域事件映射成 `CacheChangeRequest / CacheChangePlan`,再统一调用 `evictNegative` 或 `evictNegativeNowAndAfterCommit` 删除对应的 `negative cache`。
3.1.3 CacheKeyNormalizerRegistryCacheKeyNormalizer#

首先我们来做缓存 key 的前置处理。

做成Registry的形式主要是方便业务做不同的处理,毕竟不同业务 context下,其对 cache key 的限制也会有所不同,我们应该让其能够自由配置而不是大包大揽。

CacheKeyNormalizerRegistry.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
public class CacheKeyNormalizerRegistry {
// 所有的CacheKeyNormalizer实现
private final List<CacheKeyNormalizer> normalizers;
public CacheKeyNormalizerRegistry(List<CacheKeyNormalizer> normalizers) {
this.normalizers = normalizers == null ? List.of() : List.copyOf(normalizers);
}
public Optional<String> normalize(CacheDomain domain, CacheKeyType keyType, String keyValue) {
// 找出对应支持该缓存域的normalizer,并将key塞给它处理
return normalizers.stream()
.filter(normalizer -> normalizer.supports(domain, keyType))
.findFirst()
.flatMap(normalizer -> normalizer.normalize(keyValue));
}
}

OK,聚合器有了,那么处理器该怎么写呢?

需要的功能其实上面聚合器中已经揭晓,无非就是support以及normalize

CacheKeyNormalizer.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import java.util.Optional;
public interface CacheKeyNormalizer {
// 探测是否支持当前缓存域
boolean supports(CacheDomain domain, CacheKeyType keyType);
// 将 key 规范化的function
Optional<String> normalize(String keyValue);
}

这里以一个通用的 主键类 id 处理来举例:

Long数字型 ID Key,比如说自增、雪花、数据库 bigint主键。

PositiveLongCacheKeyNormalizer.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Optional;
import java.util.regex.Pattern;
@Component
public class PositiveLongCacheKeyNormalizer implements CacheKeyNormalizer {
private static final Pattern POSITIVE_LONG = Pattern.compile("[1-9]\\d{0,18}");
@Override
public boolean supports(CacheDomain domain, CacheKeyType keyType) {
return keyType == CacheKeyType.ID;
}
@Override
public Optional<String> normalize(String keyValue) {
if (!StringUtils.hasText(keyValue)) {
return Optional.empty();
}
String trimmed = keyValue.trim();
if (!POSITIVE_LONG.matcher(trimmed).matches()) {
return Optional.empty();
}
try {
long id = Long.parseLong(trimmed);
return id > 0 ? Optional.of(Long.toString(id)) : Optional.empty();
} catch (NumberFormatException ex) {
return Optional.empty();
}
}
}
CacheKeyType.java
package com.zwlh.powergrid.contract.infrastructure.cache;
public enum CacheKeyType {
// id类key
ID,
// 业务编码类 key
CODE
}
3.1.4 CachePenetrationGuard#

主要作用:

  • check:通用输入检查,委托 normalizer 归一化 key,最后拼出 negative cache key 查询 Redis,具体返回值的含义查看下面的代码。
  • evictNegative:删除对应的 Negative Cache
  • evictNegativeNowAndAfterCommit:取消 Negative Cache并在事务成功后再次取消其余并发线程导致的事务成功前Negative Cache重新写入。
  • isNegativeHit:判断是否命中Negative Cache
  • putNegative:写入为Negative Cache
  • loadFromSource:埋点,记录查询DB的执行时间以及其相关信息作为指标。
CachePenetration.java
package com.zwlh.powergrid.contract.infrastructure.cache;
import com.zwlh.powergrid.contract.common.constant.CacheKey;
import com.zwlh.powergrid.contract.common.util.RedisUtil;
import com.zwlh.powergrid.contract.infrastructure.cache.observability.CachePenetrationMetrics;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
import java.util.function.Supplier;
@Component
@RequiredArgsConstructor
public class CachePenetrationGuard {
private static final String SENTINEL = "1";
private final RedisUtil redisUtil;
// 缓存穿透防护的配置项
private final CachePenetrationProperties properties;
// 观测
private final CachePenetrationMetrics metrics;
// Key规范化
private final CacheKeyNormalizerRegistry normalizerRegistry;
// 返回缓存穿透保护Guard的判断结果:
// CachePenetrationDecision.DISABLED = 当前配置没有开启穿透保护
// CachePenetrationDecision.INVALID_KEY = 无效 key
// CachePenetrationDecision.NEGATIVE_HIT = Negative Cache命中
// CachePenetrationDecision.NEGATIVE_MISS = Negative Cache未命中,算是正常流程。
// CachePenetrationDecision.ERROR = 访问 Redis 异常
public CachePenetrationDecision check(CacheDomain domain, CacheKeyType keyType, String keyValue) {
// 先做配置判断,如果没有开启的话,返回缓存穿透防护服务没有开启。
if (!properties.enabled()) {
metrics.recordGuardRequest(domain, keyType, "disabled");
return CachePenetrationDecision.DISABLED;
}
// key规范化,看有没有返回值
String normalizedKey = normalizeKey(domain, keyType, keyValue);
// 没有返回值说明这是一个无效key,直接按CachePenetrationDecision.INVALID_KEY返回。
if (normalizedKey == null) {
// 埋点,记录无效key,该指标要关注。
metrics.recordGuardRequest(domain, keyType, "invalid_key");
return CachePenetrationDecision.INVALID_KEY;
}
try {
// 直接拼Negative Cache看是否有命中
boolean hit = redisUtil.hasKeyOrThrow(buildKey(domain, keyType, normalizedKey));
// 埋点,记录Negative Cache命中指标
metrics.recordGuardRequest(domain, keyType, hit ? "negative_hit" : "negative_miss");
return hit ? CachePenetrationDecision.NEGATIVE_HIT : CachePenetrationDecision.NEGATIVE_MISS;
} catch (RuntimeException ex) {
// 埋点,记录访问Redis异常指标
metrics.recordGuardRequest(domain, keyType, "error");
return CachePenetrationDecision.ERROR;
}
}
// negative cache是否命中
public boolean isNegativeHit(CacheDomain domain, CacheKeyType keyType, String keyValue) {
return check(domain, keyType, keyValue) == CachePenetrationDecision.NEGATIVE_HIT;
}
// 推送 negative cache,一般在业务查DB之后发现没有给予标记。
public void putNegative(CacheDomain domain, CacheKeyType keyType, String keyValue) {
if (!properties.enabled()) {
metrics.recordNegativeWrite(domain, keyType, "disabled");
return;
}
String normalizedKey = normalizeKey(domain, keyType, keyValue);
if (normalizedKey == null) {
metrics.recordNegativeWrite(domain, keyType, "invalid_key");
return;
}
boolean written = redisUtil.set(buildKey(domain, keyType, normalizedKey), SENTINEL, negativeTtl());
metrics.recordNegativeWrite(domain, keyType, written ? "success" : "error");
}
public void evictNegative(CacheDomain domain, CacheKeyType keyType, String keyValue) {
evictNegative(domain, keyType, keyValue, false);
}
public void evictNegativeNowAndAfterCommit(CacheDomain domain, CacheKeyType keyType, String keyValue) {
evictNegative(domain, keyType, keyValue, true);
}
// 取消negative cache。
// 一般在业务新建值后,要是查询到有negative cache的话,就取消掉这个negative cache。
private void evictNegative(CacheDomain domain, CacheKeyType keyType, String keyValue, boolean immediate) {
// 先判断是否为无效key
String normalizedKey = normalizeKey(domain, keyType, keyValue);
if (normalizedKey == null) {
// 埋点,记录无效key,该指标要关注。
metrics.recordNegativeEviction(domain, keyType, "invalid_key");
return;
}
String negativeKey = buildKey(domain, keyType, normalizedKey);
evictAfterCommit(domain, keyType, negativeKey, immediate);
}
// 执行loader里面的真实查询,记录这次查询的指标。
public <T> T loadFromSource(
CacheDomain domain,
CacheKeyType keyType,
Supplier<T> loader,
Predicate<T> foundPredicate
) {
long startNanos = System.nanoTime();
try {
T result = loader.get();
// 埋点,记录当前真实查询所消耗的时间,隶属的缓存域,有没有找到。
metrics.recordSourceLoad(
domain,
keyType,
foundPredicate.test(result) ? "found" : "not_found",
System.nanoTime() - startNanos
);
return result;
} catch (RuntimeException ex) {
// 埋点,记录当前loader执行失败的信息,并记录执行时间。
metrics.recordSourceLoad(domain, keyType, "error", System.nanoTime() - startNanos);
throw ex;
}
}
// 在事务成功后剔除原有的Negative Cache
private void evictAfterCommit(CacheDomain domain, CacheKeyType keyType, String negativeKey, boolean immediate) {
// 写入真实数据后,同一个事务还没提交前,就要马上调用受当前缓存穿透保护的读方法去读取这个数据
// 比如说某个人查业务表 id=100,DB里面没有,写入了negative cache,保存后立刻调用返回详情,但事务还没有提交。
// 如果等afterCommit的话,negative key还在,所以需要马上删掉。
if (immediate) {
deleteNegativeKey(domain, keyType, negativeKey);
}
// 不在事务中的时候
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
// 前面已经删了,这里就不用再删一次
if (!immediate) {
deleteNegativeKey(domain, keyType, negativeKey);
}
return;
}
// 在事务中的时候。
// 注意,因为事务还没有提交,其它请求要是查不到对应的id也可能会触发Negative Cache。
// 所以在当前事务新增成功后,稳妥起见,需要再删一次Negative Cache。
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
deleteNegativeKey(domain, keyType, negativeKey);
}
});
}
// 直连Redis删除Negative Cache的方法,需要前置判断后再调用。
private void deleteNegativeKey(CacheDomain domain, CacheKeyType keyType, String negativeKey) {
try {
redisUtil.del(negativeKey);
metrics.recordNegativeEviction(domain, keyType, "success");
} catch (RuntimeException ex) {
metrics.recordNegativeEviction(domain, keyType, "error");
}
}
// 预处理 key,空值、空白字符串、超长 key 直接直接判为非法
// 然后再交给对应的Normalizer做具体格式以及归一化
private String normalizeKey(CacheDomain domain, CacheKeyType keyType, String keyValue) {
if (!StringUtils.hasText(keyValue)) {
return null;
}
String trimmed = keyValue.trim();
if (trimmed.length() > properties.maxKeyLength()) {
return null;
}
return normalizerRegistry.normalize(domain, keyType, trimmed).orElse(null);
}
// 拼Nagetive cache key
private String buildKey(CacheDomain domain, CacheKeyType keyType, String normalizedKey) {
return String.format(CacheKey.NEGATIVE_CACHE_KEY, domain.name(), keyType.name(), normalizedKey);
}
/**
* 计算写入 negative cache 时实际使用的 TTL。
* cache:
* penetration:
* negative-ttl: 60s
* negative-ttl-jitter: 30s
**/
private Duration negativeTtl() {
// TTL持续事件
Duration ttl = properties.negativeTtl();
// 抖动时间,避免大量key同一时间失效
Duration jitter = properties.negativeTtlJitter();
// jitter没有设置或者为负数,只返回ttl
if (jitter == null || jitter.isZero() || jitter.isNegative()) {
return ttl;
}
// 生成0~jitter.toSeconds()的随机值,由线程本身直接提供,减少多个线程共享一个Random带来的竞争。
long jitterSeconds = ThreadLocalRandom.current().nextLong(jitter.toSeconds() + 1);
// 返回两值相加
return ttl.plusSeconds(jitterSeconds);
}
}

关于ThreadLocalRandom,这是一个性能导向的线程生成随机数工具类。

本地 Java 21 + R7 8845HS + 32G DDR5 5600 双通道机器演示:

16 线程,总计 3200 万次 nextLong(31)

实现方式耗时吞吐量
shared Random2.502s12.79 M ops/s
new Random each op0.679s47.11 M ops/s
ThreadLocalRandom0.055s581.79 M ops/s

单线程,总计 2000 万次 nextLong(31)

实现方式耗时吞吐量
single Random instance0.139s143.48 M ops/s
new Random each op0.703s28.44 M ops/s
ThreadLocalRandom0.051s390.47 M ops/s

单线程时,每一轮都创建一个 Random 实例是最慢的,这个很好理解,毕竟比起访问与改变,在内存中开了个新空间,初始化seed,生成随机数,最后等待 GC 销毁,整个流程要长一些。

其次就是共享 Random 实例,共享实例为什么慢主要还是堆内存 Random 中的 seed 状态要被三个线程竞争改变后获取,简单地来说就是某个线程要等上一个线程的工作结束后获取。

线程自己改自己的 seed,获取自己 seed 产生的随机数值,也不用销毁,线程访问下一个随机数就推进一下 seed 再计算随机数。简单地说就是线程自给自足,不需要等别的线程,所以快。

16线程的时候共享Random对象是最慢的,新建实例反而因为不需要参与线程竞争而显得比较快。

当然,ThreadLocalRandom 只是面向多线程性能场景,如果有安全需求,那就需要使用 SecureRandom 做,比如说本地提供的登录六位数验证码简易访问限制。

虽然本质都是伪随机数不过可被预测的难度不一,但安全性的差异很大。

3.1.5 CachePenetrationDecisionCachePenetrationProperties#
CachePenetrationDecision.java
package com.zwlh.powergrid.contract.infrastructure.cache;
public enum CachePenetrationDecision {
DISABLED,
INVALID_KEY,
NEGATIVE_HIT,
NEGATIVE_MISS,
ERROR
}
application.yaml
cache:
penetration:
enabled: ${CACHE_PENETRATION_ENABLED:true}
negative-ttl: ${CACHE_PENETRATION_NEGATIVE_TTL:60s}
negative-ttl-jitter: ${CACHE_PENETRATION_NEGATIVE_TTL_JITTER:30s}
max-key-length: ${CACHE_PENETRATION_MAX_KEY_LENGTH:128}
3.1.6 业务读取路径接入#

缓存穿透保护不应该散落在 Controller 里,也不应该让每个调用方自己拼 Redis key

当前实现里,业务读取路径只需要按固定顺序调用 CachePenetrationGuard

1. 把查询目标转换成 `CacheDomain + CacheKeyType + keyValue`
2. 调用 `check`
3. 如果返回 `INVALID_KEY` 或 `NEGATIVE_HIT`,直接返回不存在
4. 如果返回 `NEGATIVE_MISS / DISABLED / ERROR`,继续走正常 `DB` 查询
5. `DB` 查不到时,调用 `putNegative`
6. `DB` 查到时,返回真实数据

比如说这是我业务中的一段调用示例:

String keyValue = String.valueOf(id);
// 判断key的状态
CachePenetrationDecision decision = cachePenetrationGuard.check(
CacheDomain.SERVICE_AGREEMENT,
CacheKeyType.ID,
keyValue
);
// 如果key不合规,直接打回,不经过DB
if (decision == CachePenetrationDecision.INVALID_KEY
|| decision == CachePenetrationDecision.NEGATIVE_HIT) {
throw new BusinessException(ResponseCode.SIGN_NOT_FOUND);
}
// 正常查询,并记录对应指标,虽然当前功能没有正向缓存,为了方便起见,还是起了个CacheDomain
ServiceAgreement serviceAgreement = cachePenetrationGuard.loadFromSource(
CacheDomain.SERVICE_AGREEMENT,
CacheKeyType.ID,
() -> this.getById(id),
Objects::nonNull
);
// 查询如果为空,直接放入Negative Cache,下一次再请求的时候就会直接打回去,避免访问DB。
if (serviceAgreement == null) {
cachePenetrationGuard.putNegative(
CacheDomain.SERVICE_AGREEMENT,
CacheKeyType.ID,
keyValue
);
throw new BusinessException(ResponseCode.SIGN_NOT_FOUND);
}

4. 可观测性总览#

前面的设计一直在讨论一致性边界:

  • 哪些读取允许短暂旧读
  • 哪些变更需要广播
  • 哪些 side effect 必须进入 outbox
  • 哪些关键动作应该回到 source of truth

上线之后,真正需要确认的是这些边界有没有被运行时流量击穿。

比如 revision 是否被推进得太频繁、本地缓存是否频繁回源、广播延迟是否拉长、outbox 是否开始积压、dead sign是否需要人工介入。

所以这里的可观测性不是新的缓存一致性方案,也不会让 Redis Pub/Sub 变得可靠。

它的作用是把缓存系统的运行状态变成证据,让我们能判断:

  • 当前缓存域是否仍然适合它声明的缓存层级
  • 某个缓存域是否需要拆分、降级或升级
  • outbox 是否需要进一步升级到 broker / CDC
  • 某些关键操作是否应该直接进入 L7,而不是继续相信缓存快照

下面按读取快照、revision、广播、outbox 和缓存穿透防护分组整理。每组先看它回答的问题,再看代码埋点、指标表和排查路径。

4.1 Snapshot 指标#

Snapshot 指标回答的是业务读取结果:授权快照到底是 hitmiss 还是 error,以及 miss 之后回源并重建快照的成本有多高。

这类指标属于业务 Metrics,不建议混进通用的 revisionoutbox 指标里。

以文中提到的 UserAuthorizationCacheMetrics 为例,重点看两个地方:快照读取结果,以及 miss 后重建耗时。

UserAuthorizationCacheMetrics.java
12 collapsed lines
package com.zwlh.powergrid.contract.modules.access.infrastructure.cache.observability;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class UserAuthorizationCacheMetrics {
private static final CacheDomain DOMAIN = CacheDomain.USER_AUTHORIZATION;
private static final String SNAPSHOT_REQUESTS = "cache.authz.snapshot.requests";
private static final String SNAPSHOT_LOAD_DURATION = "cache.authz.snapshot.load.duration";
2 collapsed lines
private final MeterRegistry meterRegistry;
public void recordSnapshotRequest(String result) {
meterRegistry.counter(SNAPSHOT_REQUESTS,
"domain", DOMAIN.name(),
"result", result)
.increment();
}
public void recordSnapshotLoad(String result, long durationNanos) {
meterRegistry.timer(SNAPSHOT_LOAD_DURATION,
"domain", DOMAIN.name(),
"result", result)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
}
指标关注点异常时先看
cache.authz.snapshot.requests授权快照 hit / miss / errorrevision 推进频率、Redis 读异常、缓存写入是否失败
cache.authz.snapshot.load.durationmiss 后回源 DB 并重建快照耗时角色权限查询、DBSQLRedis 写入、序列化
cache.authz.snapshot.requests#

这个指标用于观察授权快照读取结果。

常见标签:

  • domain:缓存域,例如 USER_AUTHORIZATION
  • resulthitmisserror

miss 突然升高时,常见原因包括:

  • revision 被频繁推进,导致新 key 不断生成;
  • Redis keyTTL 太短;
  • 应用实例刚重启,局部缓存还没热起来;
  • 快照重建后写 Redis 失败。

排查时先对比:

  • cache.revision.advance.total
  • Redis key TTL
  • 应用发布/重启时间
  • cache.authz.snapshot.load.duration
  • Redis 写入错误日志

error 持续出现时,优先检查 Redis 连接、序列化配置、RedisTemplate 配置,以及 UserAuthorizationSnapshot 是否发生了不兼容结构变更。

cache.authz.snapshot.load.duration#

这个指标用于观察 cache miss 后,系统回源 DB 并重建授权快照的耗时。

耗时升高时,常见原因包括:

  • roleService.getRolesByUserId(...) 查询变慢;
  • 用户绑定角色过多;
  • 权限数量过大;
  • canonical 映射成本升高;
  • DBSQL 或连接池压力。

如果 result=error 升高,需要区分是 DB 回源失败、权限数据异常、Redis 写入失败,还是序列化失败。

4.2 Revision 指标#

Revision 指标回答的是基础设施层的两个问题:读取 revision 时,本地 Caffeine 是否仍然有效;推进 revision 时,写路径是否已经被行锁、慢事务或过粗的缓存域拖慢。

这里和广播指标共用 CacheMetrics。代码完整保留,默认折叠样板代码和广播部分,先突出 revision 相关常量、读取记录、推进记录和本地落后检测。

CacheMetrics.java
15 collapsed lines
package com.zwlh.powergrid.contract.infrastructure.cache.observability;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheChangeAction;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class CacheMetrics {
// 业务相关的缓存让业务模块自行管理,不要塞进来
// revision 读取、自增与落后检测。
private static final String REVISION_REQUESTS = "cache.revision.requests";
private static final String REVISION_DURATION = "cache.revision.duration";
private static final String REVISION_ADVANCE_TOTAL = "cache.revision.advance.total";
private static final String REVISION_ADVANCE_DURATION = "cache.revision.advance.duration";
private static final String REVISION_STALE_DETECTED_TOTAL = "cache.revision.stale.detected.total";
7 collapsed lines
// 广播发布、消费与延迟。
private static final String INVALIDATION_PUBLISH_TOTAL = "cache.invalidation.publish.total";
private static final String INVALIDATION_RECEIVE_TOTAL = "cache.invalidation.receive.total";
private static final String INVALIDATION_LAG = "cache.invalidation.lag";
private final MeterRegistry meterRegistry;
public void recordRevisionRequest(CacheDomain domain, String source, String result, long durationNanos) {
meterRegistry.counter(REVISION_REQUESTS,
"domain", domain.name(),
"source", source,
"result", result)
.increment();
meterRegistry.timer(REVISION_DURATION,
"domain", domain.name(),
"source", source,
"result", result)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
public void recordRevisionAdvance(CacheDomain domain, String result, long durationNanos) {
meterRegistry.counter(REVISION_ADVANCE_TOTAL,
"domain", domain.name(),
"result", result)
.increment();
meterRegistry.timer(REVISION_ADVANCE_DURATION,
"domain", domain.name(),
"result", result)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
30 collapsed lines
public void recordInvalidationPublish(CacheDomain domain, CacheChangeAction action, String result) {
meterRegistry.counter(INVALIDATION_PUBLISH_TOTAL,
"domain", domain.name(),
"action", action.name(),
"result", result)
.increment();
}
public void recordInvalidationReceive(CacheDomain domain, CacheChangeAction action, String result) {
recordInvalidationReceive(domain.name(), action.name(), result);
}
public void recordInvalidationReceive(String domain, String action, String result) {
meterRegistry.counter(INVALIDATION_RECEIVE_TOTAL,
"domain", domain,
"action", action,
"result", result)
.increment();
}
public void recordInvalidationLag(CacheDomain domain, CacheChangeAction action, long durationNanos) {
meterRegistry.timer(INVALIDATION_LAG,
"domain", domain.name(),
"action", action.name())
.record(durationNanos, TimeUnit.NANOSECONDS);
}
public void recordRevisionStaleDetected(CacheDomain domain) {
meterRegistry.counter(REVISION_STALE_DETECTED_TOTAL, "domain", domain.name()).increment();
}
}
指标关注点异常时先看
cache.revision.requestsrevision 来自 local 还是 DBCaffeine TTL、广播失效、实例重启
cache.revision.durationrevision 读取耗时DB 慢查询、连接池、锁等待
cache.revision.advance.totalrevision 推进次数是否把高频写场景做成了 revision
cache.revision.advance.durationrevision 推进耗时contract_revision 行锁、慢事务、DB 压力
cache.revision.stale.detected.total本地 revision 落后次数广播延迟、实例负载、GC、网络
cache.revision.requests#

这个指标用于观察 revision 的读取来源。

常见标签:

  • source=local:命中本地 Caffeine
  • source=db:回源 DB
  • result=success/error:读取结果。

如果 source=db 占比升高,通常说明本地 revision cache 正在频繁失效或过期。

常见原因包括:

  • Caffeine TTL 配置太短;
  • 广播频繁触发本地失效;
  • 实例刚重启;
  • 某个缓存域写入过于频繁。

排查时对比:

  • cache.invalidation.receive.total{result="accepted"}
  • cache.revision.stale.detected.total
  • 应用重启时间
  • contract.revision.local-cache.ttl
cache.revision.duration#

这个指标用于观察 revision 读取耗时。

如果 source=db 的耗时升高,优先检查:

  • contract_revision 查询是否慢;
  • DB 连接池是否耗尽;
  • 数据库 CPU / IO
  • 是否存在锁等待;
  • 是否出现大量实例同时回源 DB
cache.revision.advance.total#

这个指标用于观察某个缓存域的 revision 推进频率。

如果某个 domain 的推进次数异常升高,需要重点判断这个缓存域是否仍适合 revision 方案。

常见原因包括:

  • 后台批量操作频繁修改角色/权限;
  • 高频写业务被错误归入 revision 域;
  • 缓存域粒度过粗,一次小变更推进了过大的缓存域。

如果推进频率高到形成行级锁竞争,优先考虑重新划分缓存域、降低 revision 粒度,或改用短 TTL / 聚合 / source of truth 检查,而不是继续优化 revision 表。

cache.revision.advance.duration#

这个指标用于观察 revision 推进耗时。

耗时升高时,优先检查:

  • contract_revision 行级锁等待;
  • 当前事务是否包含慢操作;
  • DB 连接池;
  • PostgreSQLSQL
  • 是否有大量权限、角色后台操作并发执行。
cache.revision.stale.detected.total#

这个指标表示实例收到较新的 revision 广播时,发现本地 revision 仍然落后。

它升高说明多实例之间确实出现了旧 revision 窗口。

排查时重点看:

  • cache.invalidation.lag
  • Redis Pub/Sub 连接状态
  • 订阅容器是否正常
  • 应用实例 CPU / GC
  • 网络延迟

4.3 Broadcast 指标#

Broadcast 指标回答的是多实例同步链路是否健康:

  • 发布端有没有成功把失效消息发出去
  • 订阅端有没有真正消费到
  • 消息从创建到消费之间的延迟是否已经扩大旧读窗口

这里复用 CacheMetrics 中的广播部分。

代码完整保留,默认折叠包声明、依赖和类样板,突出发布、接收和延迟三个入口。

CacheMetrics.java
13 collapsed lines
package com.zwlh.powergrid.contract.infrastructure.cache.observability;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheChangeAction;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class CacheMetrics {
private static final String INVALIDATION_PUBLISH_TOTAL = "cache.invalidation.publish.total";
private static final String INVALIDATION_RECEIVE_TOTAL = "cache.invalidation.receive.total";
private static final String INVALIDATION_LAG = "cache.invalidation.lag";
private final MeterRegistry meterRegistry;
public void recordInvalidationPublish(CacheDomain domain, CacheChangeAction action, String result) {
meterRegistry.counter(INVALIDATION_PUBLISH_TOTAL,
"domain", domain.name(),
"action", action.name(),
"result", result)
.increment();
}
public void recordInvalidationReceive(CacheDomain domain, CacheChangeAction action, String result) {
recordInvalidationReceive(domain.name(), action.name(), result);
}
public void recordInvalidationReceive(String domain, String action, String result) {
meterRegistry.counter(INVALIDATION_RECEIVE_TOTAL,
"domain", domain,
"action", action,
"result", result)
.increment();
}
public void recordInvalidationLag(CacheDomain domain, CacheChangeAction action, long durationNanos) {
meterRegistry.timer(INVALIDATION_LAG,
"domain", domain.name(),
"action", action.name())
.record(durationNanos, TimeUnit.NANOSECONDS);
}
}
指标关注点异常时先看
cache.invalidation.publish.total广播发布成功、失败、禁用Redis 连接、channel、序列化
cache.invalidation.receive.total广播消费 accepted / invalid / ignored / error订阅容器、消息结构、domain / action
cache.invalidation.lag广播创建到消费的延迟Redis 网络、实例负载、GC、订阅线程
cache.invalidation.publish.total#

这个指标用于观察广播发布结果。

常见 result

  • success:发布成功;
  • error:发布失败;
  • disabled:广播被配置关闭。

error 升高时,优先检查:

  • Redis 是否可用;
  • Redis 连接池是否耗尽;
  • cache.broadcast.channel 是否配置正确;
  • RedisTemplate 序列化是否正常;
  • 应用错误日志中是否有 convertAndSend 异常。

disabled 非预期出现时,检查:

  • CACHE_BROADCAST_ENABLED
  • 当前环境配置
  • 配置中心或部署变量是否覆盖了默认值
cache.invalidation.receive.total#

这个指标用于观察广播消费结果。

常见 result

  • accepted:当前实例收到并处理;
  • invalid:消息结构无效;
  • ignored:消息有效,但不归当前 subscriber 处理;
  • error:消费端处理失败。

如果 publish 正常但 accepted 下降,优先检查:

  • RedisMessageListenerContainer 是否启动;
  • 发送端和订阅端 channel 是否一致;
  • 当前实例是否启用了广播订阅;
  • Redis serializer 是否一致;
  • 是否刚发布过不兼容的消息结构。

invalid 升高时,通常说明消息缺少 domain / action / revision,或者发送方和接收方的消息结构不兼容。

ignored 升高时,检查是否有其它 action 误发到同一个 channel,或者该 domain 没有对应的 RevisionSubject

error 升高时,重点检查 CacheRevisionService.invalidateLocalRevisionIfBehind(...) 和本地 subscriber 异常日志。

cache.invalidation.lag#

这个指标用于观察广播从创建到消费的延迟。

lag 升高时,常见原因包括:

  • Redis 网络抖动;
  • 订阅线程阻塞;
  • 实例负载过高;
  • GC 停顿;
  • Redis Pub/Sub 消费端异常。

如果 lag 升高同时 cache.revision.stale.detected.total 也升高,说明多实例旧读窗口正在变大。

4.4 Outbox 指标#

Outbox 指标回答的是可靠事件链路有没有断:事件是否成功落库,worker 是否被唤醒并认领事件,dispatch 是否发起、完成、失败、超时或进入死信。

这组指标需要同时关注“流量是否进入系统”和“系统是否有能力把事件排出去”。所以代码里不仅有计数器,也有 in_flight 这种运行时状态 gauge。完整代码保留,默认折叠依赖和普通样板,突出事件生命周期的记录入口。

OutboxMetrics.java
12 collapsed lines
package com.zwlh.powergrid.contract.infrastructure.event.outbox.observability;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.function.ToIntFunction;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class OutboxMetrics {
private static final String PUBLISH_TOTAL = "event.outbox.publish.total";
private static final String WAKEUP_TOTAL = "event.outbox.wakeup.total";
private static final String CLAIMED_TOTAL = "event.outbox.claimed.total";
private static final String DISPATCH_STARTED_TOTAL = "event.outbox.dispatch.started.total";
private static final String DISPATCH_IN_FLIGHT = "event.outbox.dispatch.in_flight";
private static final String DISPATCH_FAILURE_REASON_TOTAL = "event.outbox.dispatch.failure.reason.total";
private static final String DISPATCH_TOTAL = "event.outbox.dispatch.total";
private static final String DISPATCH_DURATION = "event.outbox.dispatch.duration";
private static final String DEAD_TOTAL = "event.outbox.dead.total";
private final MeterRegistry meterRegistry;
public void recordPublish(String eventType, String aggregateType, String result) {
meterRegistry.counter(PUBLISH_TOTAL,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType),
"result", result)
.increment();
}
public void recordWakeup(String result) {
meterRegistry.counter(WAKEUP_TOTAL, "result", result).increment();
}
public void recordClaimed(String eventType, String aggregateType, int count) {
if (count <= 0) {
return;
}
meterRegistry.counter(CLAIMED_TOTAL,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType))
.increment(count);
}
public void recordDispatchStarted(String eventType, String aggregateType) {
meterRegistry.counter(DISPATCH_STARTED_TOTAL,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType))
.increment();
}
public <T> void registerInFlightDispatches(T state, ToIntFunction<T> inFlightSupplier) {
meterRegistry.gauge(DISPATCH_IN_FLIGHT, state, inFlightSupplier::applyAsInt);
}
public void recordDispatchFailureReason(String eventType, String aggregateType, String reason) {
meterRegistry.counter(DISPATCH_FAILURE_REASON_TOTAL,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType),
"reason", tagValue(reason))
.increment();
}
public void recordDispatch(String eventType, String aggregateType, String result, long durationNanos) {
meterRegistry.counter(DISPATCH_TOTAL,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType),
"result", result)
.increment();
meterRegistry.timer(DISPATCH_DURATION,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType),
"result", result)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
public void recordDead(String eventType, String aggregateType) {
meterRegistry.counter(DEAD_TOTAL,
"event_type", eventType,
"aggregate_type", tagValue(aggregateType))
.increment();
}
5 collapsed lines
private String tagValue(String value) {
return value == null || value.isBlank() ? "UNKNOWN" : value;
}
}
指标关注点异常时先看
event.outbox.publish.totaloutbox row 是否成功写入DBJSON 序列化、runtime mode
event.outbox.wakeup.totalworker 是否被即时唤醒线程池、runtime stateworker 耗时
event.outbox.claimed.totalworker 是否认领到事件表内状态、next_attempt_atlocked_until
event.outbox.dispatch.started.totaldispatch 是否已经发起handler 匹配、worker 是否卡住
event.outbox.dispatch.in_flight当前 worker 正在等待完成的 dispatch下游确认速度、max-in-flightdispatch 超时
event.outbox.dispatch.failure.reason.totaldispatch 失败原因分类timeoutexceptionfailed_result
event.outbox.dispatch.totaldispatch 最终落库结果下游依赖、payloadhandler 异常、状态冲突
event.outbox.dispatch.durationdispatch 从发起到完成落库的耗时下游延迟、网络、payload 大小、confirm 延迟
event.outbox.dead.total是否出现死信last_errorpayload、人工补偿
event.outbox.publish.total#

这个指标用于观察 ReliableEventPublisher.publish(...) 是否成功写入 event_outbox

常见 result

  • successoutbox row 已随事务提交;
  • error:写入失败;
  • disabledruntime state 不允许写入。

error 升高时,优先检查:

  • event_outbox 表是否存在;
  • migration 是否执行;
  • JSON payload 是否能序列化;
  • event_id 是否冲突;
  • DB 写入是否失败。

disabled 非预期出现时,检查:

  • EVENT_OUTBOX_WORKER_MODE
  • 是否处于 AUTO 但没有 producer
  • ReliableEventProducerRegistry
  • Spring profile 是否启用了对应 producer
event.outbox.wakeup.total#

这个指标用于观察 outbox worker 的即时唤醒情况。

常见 result

  • accepted:唤醒任务已提交给线程池;
  • rejected:线程池拒绝执行;
  • disabledruntime 不允许运行。

rejected 升高时,常见原因包括:

  • TaskExecutor 线程池满;
  • worker 单轮执行太慢;
  • outbox 写入速率超过消费能力;
  • 应用正在 shutdown

排查时不要只看 rejected,还要对比:

  • event.outbox.claimed.total
  • event.outbox.dispatch.duration
  • event.outbox.dispatch.total{result="failure"}
  • 表内 PENDING / FAILED / PROCESSING 数量
event.outbox.claimed.total#

这个指标用于观察 worker 每轮认领了多少事件。

如果长期为 0,但表内存在可处理事件,优先检查:

  • OutboxWorkerScheduler 是否启动;
  • OutboxWorkerRuntimeState 是否允许运行;
  • next_attempt_at <= now() 是否满足;
  • PROCESSING.locked_until < now() 是否满足;
  • claim SQL 是否正常执行。

如果持续很高,说明 worker 一直在处理大量事件。需要进一步看是正常高吞吐,还是失败重试导致的积压。

event.outbox.dispatch.started.total#

这个指标用于观察 worker 是否已经发起 dispatch

如果 claimed 上升,但 started 不上升,优先检查:

  • 是否没有匹配的 OutboxEventHandler
  • worker 是否在 dispatch 前异常;
  • max-in-flight 是否已经被打满;
  • 线程池或调度是否异常。
event.outbox.dispatch.in_flight#

这个指标表示当前 worker 中已经认领、已经发起、但还没有完成确认的 dispatch 数。

它升高不一定是错误,但如果长期贴近 max-in-flight,说明 outbox 消费能力正在被下游确认速度限制。

排查时重点看:

  • broker confirm 是否变慢;
  • Redis / MQ / Kafka 是否延迟;
  • event.outbox.dispatch.duration 是否同步升高;
  • event.outbox.dispatch.failure.reason.total{reason="timeout"} 是否升高;
  • event.outbox.claimed.total 是否下降。
event.outbox.dispatch.failure.reason.total#

这个指标用于区分 dispatch 失败来源。

常见 reason:

  • timeoutCompletionStage 没有在 dispatchTimeout 内完成;
  • exceptionhandler 或下游 client 抛异常;
  • failed_resultCompletionStage 正常完成,但返回的 OutboxDispatchResult.success() 为 false。

如果 timeout 升高,优先看下游确认延迟和 dispatchTimeout 配置。

如果 exception 升高,优先看 handler 日志、payload 反序列化和下游 client 异常。

如果 failed_result 升高,说明 handler 显式认为投递失败,要看 OutboxDispatchResult.failure(...) 携带的错误信息。

event.outbox.dispatch.total#

这个指标表示 dispatch 完成后,workeroutbox 表状态更新的最终结果。

常见 result

  • successhandler 已确认投递成功,outbox row 标记为 SUCCEEDED
  • failurehandler 失败、超时或返回失败结果,outbox row 进入 FAILEDDEAD
  • conflict:状态更新冲突,通常表示当前 rowdispatch_id 已经不是本次 dispatch,或者锁过期后被其它 worker 重新认领;
  • 等待 CompletionStage<OutboxDispatchResult> 完成后再更新状态。
event.outbox.dispatch.duration#

这个指标用于观察 handler 执行耗时。

耗时升高时,常见原因包括:

  • 下游 Redis / MQ / HTTP 变慢;
  • 网络抖动;
  • payload 太大;
  • handler 内部阻塞;
  • 线程池资源不足。

如果 dispatch.duration 升高同时 dispatch.conflict 升高,要重点检查 lock-ttl 是否小于实际处理耗时。

event.outbox.dead.total#

这个指标表示事件超过最大重试次数后进入 DEAD

只要它大于 0,就不应该只靠自动恢复。

排查时需要查看:

  • event_outbox.last_error
  • payload
  • event_type
  • aggregate_type
  • 对应 handler 日志
  • 下游依赖状态

修复原因后,再决定是否人工 replay、补偿,或直接标记处理完成。

4.5 CachePenetrationGuard 指标#

CachePenetrationGuard 指标回答的是防穿透层有没有真正减轻 DB 压力:非法 key 是否被拦住,负缓存是否命中,查空后的负缓存写入是否稳定,真实数据创建后负缓存是否被清理。

这组指标的重点不是单纯看请求数,而是把 guard 判断、负缓存写入、负缓存清理和回源耗时放在一起看。完整代码保留,默认折叠类样板,突出四类指标和四个记录入口。

CachePenetrationMetrics.java
12 collapsed lines
package com.zwlh.powergrid.contract.infrastructure.cache.observability;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheDomain;
import com.zwlh.powergrid.contract.infrastructure.cache.CacheKeyType;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class CachePenetrationMetrics {
private static final String GUARD_REQUESTS = "cache.penetration.guard.requests";
private static final String NEGATIVE_WRITE_TOTAL = "cache.penetration.negative.write.total";
private static final String NEGATIVE_EVICT_TOTAL = "cache.penetration.negative.evict.total";
private static final String SOURCE_LOAD_DURATION = "cache.penetration.source.load.duration";
private final MeterRegistry meterRegistry;
public void recordGuardRequest(CacheDomain domain, CacheKeyType keyType, String result) {
meterRegistry.counter(GUARD_REQUESTS,
"domain", domain.name(),
"key_type", keyType.name(),
"result", result)
.increment();
}
public void recordNegativeWrite(CacheDomain domain, CacheKeyType keyType, String result) {
meterRegistry.counter(NEGATIVE_WRITE_TOTAL,
"domain", domain.name(),
"key_type", keyType.name(),
"result", result)
.increment();
}
public void recordNegativeEviction(CacheDomain domain, CacheKeyType keyType, String result) {
meterRegistry.counter(NEGATIVE_EVICT_TOTAL,
"domain", domain.name(),
"key_type", keyType.name(),
"result", result)
.increment();
}
public void recordSourceLoad(CacheDomain domain, CacheKeyType keyType, String result, long durationNanos) {
meterRegistry.timer(SOURCE_LOAD_DURATION,
"domain", domain.name(),
"key_type", keyType.name(),
"result", result)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
}
指标关注点异常时先看
cache.penetration.guard.requests非法 key、负缓存 hit / missRedis 异常key 归一化规则、攻击探测、Redis 读状态
cache.penetration.negative.write.total负缓存写入是否稳定Redis 写入异常、TTL 配置、调用方是否误写非法 key
cache.penetration.negative.evict.total真实数据创建后的负缓存清理是否稳定领域事件、CacheChangePlan mapperRedis 删除异常
cache.penetration.source.load.duration负缓存 miss 后回源耗时和查空比例DB 慢查询、索引、异常高基数探测、限流策略
cache.penetration.guard.requests#

这个指标用于观察 CachePenetrationGuard check 的判断结果。

常见标签:

  • domain:缓存域,例如 SERVICE_AGREEMENTWORK_ORDERFILEINVITATION_CODE
  • key_typekey 类型,例如 IDCODE
  • resultinvalid_keynegative_hitnegative_missdisablederror

invalid_key 升高时,通常说明有大量非法输入被拦截,例如空值、超长 key、非正整数 ID、格式错误的业务编码。

排查时先看:

  • 对应接口是否被随机探测;
  • CacheKeyNormalizer 规则是否过严;
  • 调用方是否把未清洗的参数直接传入;
  • cache.penetration.max-key-length 是否符合当前业务 key 长度。

negative_hit 升高说明负缓存正在拦截重复的不存在 key。 这通常是防护生效,但如果某个真实资源创建后仍持续 negative_hit,要检查负缓存清理链路。

优先看:

  • 写路径是否发布了对应领域事件;
  • CacheChangeRequestMapper 是否生成 NEGATIVE_EVICT
  • NegativeCacheEvictionHandler 是否被执行;
  • cache.penetration.negative.evict.total{result="error"} 是否升高。

negative_miss 升高说明大量合法 key 没有命中负缓存,会继续回源 DB。 如果同时 source.load.duration{result="not_found"} 升高,通常表示存在大量“格式合法但实际不存在”的探测,例如递增 ID 扫描。

error 升高时,优先检查 Redis 连接、认证、命令权限和 RedisUtil.hasKeyOrThrow(...) 调用链。

cache.penetration.negative.write.total#

这个指标用于观察负缓存写入结果。

常见标签:

  • domain
  • key_type
  • resultsuccesserrordisabledinvalid_key

success 升高通常说明 DB 回源后查不到的数据变多。

需要结合:

  • cache.penetration.guard.requests{result="negative_miss"}
  • cache.penetration.source.load.duration{result="not_found"}
  • 接口访问量
  • @RateLimit 命中情况

error 升高时,优先检查 Redis 写入异常、TTL 配置、序列化配置和连接池压力。

invalid_key 升高通常说明调用方在 putNegative 阶段传入了不符合 normalizer 规则的 key。 这类问题一般是调用路径不统一,或者读取时和写入负缓存时使用了不同的 key 表达。

cache.penetration.negative.evict.total#

这个指标用于观察负缓存清理结果。

常见标签:

  • domain
  • key_type
  • resultsuccesserrorinvalid_key

success 代表执行过负缓存删除,不代表 Redis 中一定存在该 key。 因为删除不存在的 key 本身也是幂等成功。

error 升高时,优先检查:

  • Redis 删除命令是否失败;
  • Redis 连接是否异常;
  • 缓存治理事件是否集中触发;
  • evictNegativeNowAndAfterCommit 是否在高并发写路径下造成删除压力。

如果真实数据创建后仍然返回不存在,先看:

  • 是否发布了领域事件;
  • mapper 是否生成了 NEGATIVE_EVICT
  • CacheEvictionTiming 是否符合当前场景;
  • cache.penetration.negative.evict.total{result="error"} 是否升高。
cache.penetration.source.load.duration#

这个指标用于观察负缓存 miss 后的回源耗时。

常见标签:

  • domain
  • key_type
  • resultfoundnot_founderror

found 表示回源查到了真实数据。 not_found 表示回源后仍然不存在,通常随后会写入负缓存。 error 表示回源执行异常。

耗时升高时,优先检查:

  • 对应详情查询 SQL 是否走索引;
  • DB 连接池是否紧张;
  • 是否存在大量合法但不存在的 ID 探测;
  • 是否需要对关键接口补充 @RateLimit
  • 是否存在上游批量重试或爬取行为。

如果 not_found 占比异常升高,同时 negative.write.total{result="success"} 也升高,说明系统正在为大量不存在 key 写负缓存。 这时不要先盲目加长 TTL,应先确认是不是接口被枚举探测,再结合 @RateLimit、账号封禁或网关策略处理。

4.6 组合#

单个指标通常只能说明现象,不能直接说明根因。实际排查时,更有价值的是把同一条链路上的指标放在一起看:读取是否失效、回源是否变慢、广播是否延迟、事件是否积压。

现象组合更可能说明
snapshot miss 升高 + revision.advance.total 升高写入频繁推进 revision,新 key 不断生成
snapshot miss 升高 + snapshot.load.duration 升高缓存失效同时 DB 回源变慢
revision source=db 升高 + invalidation.receive.accepted 升高广播频繁让本地 revision cache 失效
publish success 正常 + receive accepted 下降订阅端、channel 或序列化异常
publish error 升高 + receive accepted 下降Redis Pub/Sub 发布链路异常
invalidation.lag 升高 + revision.stale.detected 升高多实例旧读窗口变大
outbox publish success 正常 + claimed 为 0事件已落库,但 worker 没有运行或 claim 条件不满足
claimed 升高 + dispatch failure 升高worker 在处理,但 handler 或下游依赖失败
dispatch conflict 升高 + dispatch.duration 升高handler 处理超过 lock-ttl,事件可能被重新认领
dead.total 大于 0自动重试已经失败,需要人工介入

缓存穿透防护则要看另一条链路:输入是否被拦截、负缓存是否写入、写入之后是否形成命中、真实数据创建后是否正确清理。

现象组合更可能说明
guard.requests result=invalid_key 升高外部输入存在大量非法 key,或 normalizer 规则与调用方传参不一致
guard.requests result=negative_hit 升高 + source.load.duration result=not_found 下降负缓存开始拦截重复不存在 keyDB 回源压力下降
guard.requests result=negative_miss 升高 + source.load.duration result=not_found 升高大量格式合法但实际不存在的 key 正在回源 DB,常见于递增 ID 枚举或随机探测
guard.requests result=negative_miss 升高 + source.load.duration result=found 升高大量合法真实 key 请求进入回源流程;如果没有正向缓存,这是正常读放大,需要结合接口 QPS 判断
source.load.duration result=not_found 升高 + negative.write.total result=success 升高系统正在为大量不存在 key 写入负缓存,需判断是否被枚举探测
source.load.duration result=not_found 升高 + negative.write.total result=error 升高DB 已经查空,但负缓存写入失败,相同 key 后续仍会继续打 DB
negative.write.total result=success 升高 + 后续 guard.requests result=negative_hit 没有上升写入了负缓存但没有形成命中,可能是 key 归一化不一致、TTL 太短,或攻击请求 key 高度离散
真实数据创建后仍出现 guard.requests result=negative_hit真实 key 对应的负缓存没有被清理,先查领域事件、CacheChangeRequestMapper 和负缓存删除链路
真实数据创建后仍出现 negative.evict.total result=error 升高负缓存删除失败,优先检查 Redis 删除命令、连接、权限和超时
negative.evict.total result=success 正常 + 创建后仍 negative hit删除动作执行了,但可能删的不是同一个 key,重点检查 domain + key_type + normalizedKey 是否一致
guard.requests result=error 升高 + negative.write.total result=error 升高Redis 读写链路整体异常,防穿透层退化为直接回源或写入失败
guard.requests result=disabled 升高缓存穿透保护被配置关闭,当前链路不会进行负缓存拦截

5. 压测#

压测最主要的是实验设计,这是基本指导思想。

像工具的参数配置都是可以现查文档学习,并非重点。

如果不学实验设计,很容易出现跑出一个你压根不明白含义的数据,也不知道它说了什么,只是跑了数据,仅此而已。

5.1 要用数据证明什么?#

  • /user/me 在授权snapspt cache命中后,是否减少 DB 回源?
  • revision 本地 Caffeine cache后是否减少高频 DB 查询?
  • 跨实例权限变更后,另一个实例是否能及时失效本地缓存?
  • 固定 RPS 下,P95、错误率、DB 回源是否稳定?

注意,P95本身不是通用固定标准,需要结合接口链路判断。

比如说 /user/me 如果是热缓存命中,那么链路上就只有鉴权、读 Redis Snapspot、本地 revision 判断,然后直接返回读取的 Redis Snapspot,这个 P95 值在十几毫秒到几十毫秒都是合理的。

但如果频繁地访问 DB、重建权限Snapspot、走多表查询,那么 P95 上升是正常的(不过也得看这到底是不是预期场景)

大致可以这么看待:接口目标耗时 = 网络 + 应用逻辑 + Redis + DB + 序列化 + 线程/连接等待。

同时,要避免过渡结论,比如说:

在本地 prod-like 环境(2h 刷新一次 access token触发 /user/me )、固定 1000 用户数据集、300 RPS、60s 条件下。

/user/me P95 为 7.319ms,HTTP 失败率为 0,跨实例 revision publish/receiveDB 回源闭环成立。

但不能说当前项目支持 216万 用户。(300 RPS 约等于 216w 活跃会话在 2小时 窗口内均匀刷新一次,2h = 7200s, 300 * 7200 = 216w但实际上我们知道其实不能这么算)

5.2 Smoke#

正式压测前,我们先执行 Smoke 测试,确认:

  • 测试用户可登录
  • /user/me (也就是用户资料获取,对应USER_AUTHORIZATION缓存域)能触发授权Snapspot读取。

这里用到的工具为K6,请求tag如下:

标签当前值作用
flowcache-authz业务流
operationread-current-user业务操作
endpointGET /user/me路由模板
phaseload正式压测阶段
cache_modesmoke标记为smoke场景

测试环境为:dev,数据库为压测专用的loadtest环境,表结构同步正式表,数据集为10个候选loadtest用户。

请求链路:登录->获取token->请求 /user/me

以每秒一次的速度请求10秒,仅为链路验证,不追求高并发,请求量很小。

所以K6的执行资源配置为:PERALLOCATED_VUS=5MAX_VUS=20RATE=1DURATION=10sCACHE_MODE=smokeSETUP_USERS_LIMIT=10

通过条件:/user/me 返回200http_req_failed < 1%、缓存指标出现。

执行结果:

  • load iterations: 11(完成了11次请求,constant-arrival-rate是按达到率调度,10秒窗口附近可能出现边界调度偏差,所以执行了11次。)

  • HTTP 请求总数: 31,包含 setup 的验证码和登录(10次验证码请求,10次登录请求,11次/user/me请求。)

  • checks(请求结果是否符合预期的断言检查): 62 / 0

    check(response, {
    'user me status is 200': (res) => res.status === 200,
    'user id exists': (res) => Boolean(res.json('data.base.id')),
    });
  • failed rate: 0

  • /user/me P95(http_req_duration{flow:cache-authz,operation:read-current-user,phase:load}): 18.954ms

    排除 setup 阶段的验证码、登录请求,只看正式 load 阶段的 /user/me

  • 缓存指标 :

    • cache.authz.snapshot.requests +32
    • snapshot load count +10
    • revision source=db +9
    • revision source=local +23

测试时注意 token 脱敏,不同项目有不同做法,就不多赘述。

可以看到 Smoke 链路没有任何问题,接下来进入 Baseline Matrix,做压测后的数据对照。

5.3 短时Baseline Matrix#

当前为 1m~3m的短时 BaseLine,建立初步参照,验证缓存读取链路以及运行没有问题。

这一节主要是验证 revision 本地缓存是否有效减少 DB 回源。

在可观测性章节中,我们预留了以下指标埋点:

  • cache.revision.requests
    • source=local:命中本地 Caffeine
    • source=db/local:回源 DB
    • result=success/error:读取结果
  • cache.authz.snapshot.requests
    • domain:缓存域,例如 USER_AUTHORIZATION
    • resulthitmisserror
组别含义
CACHE_REVISION_LOCAL_TTL=0s关闭本地 revision cache,每次都查 DB
CACHE_REVISION_LOCAL_TTL=1s本地缓存 revision 1 秒,高频读取优先走进程内缓存

跟之前差不多的测试方式。

RPS 设置为 300 / 600,查看不同 RPS 下的差异,测试用户增加到 1000,两次对照看是否有问题。

R300PREALLOCATED_VUS=100MAX_VUS=300 或者 500

R600PREALLOCATED_VUS=200MAX_VUS=600

场景/user/me 200revision localrevision dbsnapshot hitsnapshot miss
R300 TTL=1s warm540011087542481080021000
R300 TTL=0s warm5400101090021090020
R600 TTL=1s warm1080012167292732170020
R600 TTL=0s warm10800002170002170000

600 RPS 下,TTL=1srevision DB 回源从 217000 次降到 273 次,下降约 99.87%。

同时 /user/me P957.881ms 降到 7.030ms

300 RPS 下首次 snapshot miss 是正常现象,1000 个用户首次需要构建 1000 次缓存,合理。

因为最终线上机器与当前开发机器不一样,就不在开发机器中验证稳态Baseline以及Soak测试,保证逻辑正确即可。

5.4 跨实例写扰动验证广播实例失效#

单实例里本地 revision cache 能减少 DB 回源,但多实例下会有一个问题:

  • A 实例修改了权限 / 角色
  • B 实例本地还缓存着旧 revision
  • B 怎么知道自己要失效?

跨实例验证要证明:

B 不是等本地 TTL 到期才更新,而是收到 A 的 Redis Pub/Sub 广播后主动失效。

实例角色端口
A写侧实例localhost:9901
B读侧实例localhost:9902

两者共享:

服务地址
PostgreSQLlocalhost:55432
Redislocalhost:7001

设置 CACHE_REVISION_LOCAL_TTL=60s

如果 TTL 还是 1s,B 可能很快自然过期并查 DB,这样压测出来的数据在做二次辨别的时候有点麻烦,不好分辨是 Redis Pub/Sub 失效生效,还是 TTL 自然过期。

后续则是

A 实例执行角色权限修改:POST /role/edit

B 实例持续请求:GET /user/me

实例指标期望
Acache.revision.advance.total增加
Acache.invalidation.publish.total增加
Bcache.invalidation.receive.total增加
Bcache.revision.duration{source=db}增加
BGET /user/me请求成功,无明显失败

cache.revision.stale.detected.total 是辅助指标。

它只表示收到更高 revision 时本地仍持有旧 revision,读写并发下可能小于 receive,所以最终判定以 publish/receive/source=db 闭环为主。

最终验证结果如下:

A 侧写扰动:每 10s 修改一次角色权限,共 6 次

B 侧读压:300 RPS / 60s / 1000 tokens

对象指标delta
实例 AA revision advance+6
实例 AA invalidation publish+6
实例 BB invalidation receive+6
实例 BB revision source=db+6
实例 BB revision source=local+35994
K6k6 /user/me HTTP 失败0
P95GET /user/me7.319ms

CACHE_REVISION_LOCAL_TTL=60s 的双实例环境中,A 实例每 10 秒推进一次 USER_AUTHORIZATION revisionB 实例在持续 300 RPS/user/me 读压下完整接收到 6 次失效广播,并出现 6 次 revision DB 回源。

由于观察窗口远小于 60 秒 TTL,足以证明 B 的本地 revision cache 不是依靠自然过期收敛,而是通过 Redis Pub/Sub 广播主动失效。

6. Outbox 测试#

状态机测试故障注入测试幂等性测试端到端集成测试吞吐与积压测试,并不像上面单纯的压测能够证明。

未完待续……

缓存系统基础设计:从 Revision 到 Outbox 的一致性实践
https://blog.astro777.cfd/posts/guide/cache-system-foundation-design/
作者
ASTRO
发布于
2026-06-03
许可协议
CC BY-NC-SA 4.0