《数据库存:架构师进阶教程:围绕缓存同步建立改善缓存同步闭环》真正要解决的,不是“数据库更新后该不该删缓存”这样一个孤立问题,而是数据库、缓存、消息队列、读副本和监控系统之间如何形成一条可发现、可修复、可验证的同步链路。很多线上脏数据并不是缓存命中率低造成的,而是系统根本不知道缓存已经错了:数据库中的商品价格已经变更,缓存仍保留旧值;订单状态已经支付成功,页面却继续显示待支付;
权限已经撤销,某个服务仍依据旧缓存放行。缓存同步的终点也不是追求每一次读写都绝对同步,而是让业务明确“允许多旧的数据”,让系统在偏离后能够自动恢复,并且用指标证明恢复确实发生。
数据库存:架构师进阶教程:围绕缓存同步建立改善缓存同步闭环
在没有缓存的系统中,数据库通常是事实来源,应用只需要处理数据库连接、事务和读写隔离。引入缓存之后,同一份业务数据至少存在于数据库和缓存两个位置。如果数据库还有主从复制、搜索索引、数据仓库或本地进程缓存,实际副本数量会进一步增加。
因此,缓存并不是简单的“加速组件”,而是一个派生数据副本。只要存在副本,就会出现传播延迟、更新失败、消息重复、消息乱序和副本过期等问题。架构设计的重点不应停留在“把数据放进缓存”,而要继续回答四个问题:谁是权威数据源,数据如何传播,偏差如何发现,发现后如何修复。
我的核心判断是:缓存同步方案的质量,不由正常链路有多顺畅决定,而由异常链路能否收敛决定。正常情况下执行一次删除缓存并不难,真正拉开架构差距的是缓存删除超时、数据库读副本延迟、消息重复消费以及并发回填同时发生时,系统是否仍然能够回到正确状态。
不同业务对“缓存旧数据”的容忍度完全不同。商品详情页的描述晚几秒刷新,通常不会造成严重后果;账户余额、库存扣减、用户权限和支付状态则不能依赖一个可能陈旧的缓存直接做最终决策。
我通常会先要求业务方给出一个可量化的约束,而不是直接讨论 Redis、消息队列或延迟双删。例如:“商品价格最多允许陈旧 5 秒”“权限撤销后 1 秒内必须对所有新请求生效”“库存展示可以短暂不准,但库存扣减必须以数据库条件更新结果为准”。这类约束会直接决定缓存能否参与业务判断,以及同步链路需要达到什么可靠性。
| 业务目标 | 允许的数据状态 | 缓存可以承担的角色 | 推荐控制手段 |
|---|---|---|---|
| 允许短暂陈旧 | 数秒内可读旧值 | 展示加速、降低数据库压力 | Cache Aside、短 TTL、异步删除和定时校验 |
| 读己之写 | 当前用户写入后应读到自己的新值 | 读路径优化 | 写后本地绕过缓存、版本标记、会话级路由 |
| 最终一致 | 经过传播和补偿后必须收敛 | 派生副本 | 可靠事件、幂等消费、重试、死信和抽样校验 |
| 强一致决策 | 不能依据旧值做关键决策 | 只做展示或查询加速 | 关键写操作回源、数据库条件更新、版本校验 |

我把缓存同步闭环拆成六个环节:定义、写入、传播、检测、修复和复盘。定义是明确主数据源、最大陈旧时间和关键字段;写入是决定数据库与缓存的动作顺序;传播是通过删除、更新、消息或 CDC 将变更送达副本;检测是发现超时、失败和不一致;修复是重试、重建或强制回源;复盘则是把一次事故转化为监控、代码或流程上的改进。
缺少其中任何一个环节,都可能形成“看起来能运行、实际上不可治理”的缓存架构。尤其是检测和修复,常常被当作运维问题推迟处理,最后只能靠重启服务或清空整个缓存来解决,这种方式既不可控,也容易把局部问题扩大成数据库流量事故。
下面用一个常见的商品详情场景说明问题。商品表中的价格是 109 元,缓存 Key 为 product:10086。运营人员将价格改为 99 元,应用完成数据库更新后删除缓存。看上去流程非常简单,但线上可能同时存在一个正在执行的读请求。
这里最容易被忽略的地方是:删除缓存和读请求回填之间没有建立顺序保护。即使删除动作在 Redis 服务端已经成功,旧读请求也可能在稍后把旧值重新写进去。很多团队因此直接增加一次延迟删除,但延迟双删并不是绝对一致性方案,它只是针对某一类并发窗口降低脏数据回填概率。
另一类问题发生在数据库读写分离架构中。更新请求写入主库后立即删除缓存,随后一个缓存未命中的读请求被路由到只读副本。由于复制延迟,副本仍返回旧价格,应用又把旧价格回填到缓存中。
这时 Redis 的删除操作没有失败,应用的代码也没有明显错误,真正的问题是“删除缓存后,回源读到了不具备最新视图的数据”。如果排查人员只盯着缓存日志,很容易得出“缓存删除成功,问题无法复现”的错误结论。
这也是我在设计读写分离系统时坚持标注数据来源的原因:日志里不能只记录“查询商品成功”,还应该记录请求使用的是主库还是副本、当时副本延迟是多少、回填的业务版本是多少。没有这些字段,排查只能依靠猜测。

商品详情旧几秒,用户可能只是觉得页面没有刷新;订单状态旧几秒,则可能引发重复支付、重复发货或客服投诉。订单状态还常常会同时被订单服务、支付服务、通知服务和前端查询接口读取,缓存副本数量更多,传播路径也更长。
例如,支付服务已经将订单状态改为“已支付”,订单服务发布状态变更事件,通知服务消费成功,但查询接口所在服务的本地缓存没有清理。用户刷新页面时,API 网关命中本地缓存;绕过本地缓存后又命中分布式缓存;只有清空两层缓存才显示正确状态。
这个场景说明,缓存同步设计不能只画数据库到 Redis 的一条箭头。只要存在本地缓存、网关缓存、分布式缓存或搜索索引,就必须明确每一层副本的失效责任、最大延迟和兜底方式。
缓存命中率是性能指标,不是正确性指标。一个长期保存旧值的 Key,命中率可以非常高;如果系统没有版本比对或业务抽样校验,监控面板甚至会显示“缓存运行良好”。
因此,我不会单独用命中率评价缓存治理效果。至少应将命中率、缓存回源比例、同步延迟、删除失败率和抽样不一致率放在同一张监控视图中。命中率上升但不一致率也上升,通常不是优化成功,而是错误副本被更稳定地复用了。

“写数据库,再删除缓存”是 Cache Aside 中非常常见的策略,在许多读多写少场景下也确实实用。但它不是一个原子操作,数据库和缓存之间仍然存在时间窗口。数据库写成功而缓存删除失败时,旧值会继续被读取;删除成功后并发读请求回填旧值时,缓存仍可能重新变脏。
准确的表达应该是:先写数据库再删缓存可以降低缓存长期保存旧值的概率,但必须配合失败重试、幂等处理、版本控制和定期校验。如果业务要求极高,关键决策不能只依赖缓存。
延迟双删通常是先删除缓存,更新数据库,再等待一段时间后再次删除缓存。它的设计目标是清理并发读请求可能回填的旧值。这个方法在工程上有价值,但它依赖延迟时间的选择,也无法解决所有问题。
如果数据库读副本延迟超过设定的等待时间,第二次删除后仍可能有旧数据回填。如果删除任务本身失败,延迟双删也没有自动恢复能力。如果服务在两次删除之间宕机,第二次删除根本不会执行。因此,延迟双删最多是并发窗口治理手段,不应被描述成一致性保证。
消息队列解决的是事件传播问题,不会自动解决事件可靠性、消费幂等、乱序、积压和死信处理。数据库事务提交成功后,如果消息发送失败,缓存消费者永远收不到变更;消息发送成功后,如果消费者执行删除缓存超时,重试又可能产生重复操作。
我会把消息同步方案拆成三个问题单独评审:变更事件是否可靠产生,事件是否能够至少送达一次,消费者是否能够安全重复执行。只有这三个问题都得到回答,才有资格讨论“最终一致”。
TTL 只能限制脏数据最长存活时间,不能消除脏数据。对于高频访问的 Key,短 TTL 会带来频繁回源和缓存重建;对于权限、库存等敏感数据,即使旧值只存在 1 秒,也可能在关键窗口造成业务错误。
TTL 更适合作为最后一道保险,而不是同步方案本身。同步负责尽快传播变更,TTL 负责在同步链路完全失效时限制损失范围,两者的职责不能混淆。
清空缓存确实能够让数据重新从数据库加载,但它把局部一致性故障转化成全局回源风暴。尤其是在数据库本身已经承压、缓存集群刚发生网络抖动或热点数据非常集中的情况下,批量失效可能造成数据库连接池耗尽。
更稳妥的做法是先确定受影响的业务 Key,再按批次、按优先级、按访问量执行重建。对不能确定范围的故障,应先限流和保护数据库,再逐步恢复,而不是直接执行全量删除命令。
整体覆盖看起来简单,却容易产生字段级并发覆盖。例如,两个请求分别更新商品库存和商品描述,两个请求都读取旧对象后修改各自字段,最后写入缓存的请求可能覆盖前一个请求的更新。
如果业务对象的字段更新频率差异很大,我会优先考虑拆分缓存 Key、使用版本号或在数据库完成合并后再删除缓存。缓存结构越复杂,直接“读出对象、改一个字段、整体写回”的风险越高。

在评审缓存同步方案时,我不会先看代码,而是要求团队画一张数据流图。图中至少标出数据库主库、数据库副本、分布式缓存、本地缓存、消息队列、搜索系统以及每个服务的读写方向。
然后为每个节点写下三项信息:数据是否权威,允许延迟多久,出现不一致时由谁修复。很多争议并不是技术争议,而是团队没有明确某个节点到底是事实来源还是临时副本。
| 节点 | 是否权威 | 典型数据延迟 | 不一致后的责任 |
|---|---|---|---|
| 数据库主库 | 通常是 | 事务提交后可见 | 由数据库事务和数据修复流程负责 |
| 数据库只读副本 | 不是 | 毫秒至秒级,取决于复制状态 | 由读路由和延迟监控负责 |
| 分布式缓存 | 通常不是 | 毫秒至分钟级 | 由失效、更新、重建和补偿负责 |
| 本地进程缓存 | 不是 | 通常受 TTL 和进程生命周期影响 | 由广播失效或版本校验负责 |
| 搜索索引 | 通常不是 | 秒级至分钟级 | 由索引消费和重建任务负责 |
Cache Aside 的优点是应用对数据库和缓存都有明确控制,系统组件少,出现问题时容易理解。商品详情、文章内容、门店信息和低风险配置通常可以采用这种模式。
它的最小可靠实现应包含:缓存未命中时的回源保护,数据库更新成功后的缓存失效,缓存操作失败后的重试记录,以及周期性一致性抽样。只写三行代码“更新数据库、删除缓存、结束请求”,在低流量测试环境看不出问题,一旦进入并发和故障场景,缺口会迅速暴露。
Write Through 将写入动作封装到缓存层,由缓存组件负责同步数据库。它可以让应用代码更简洁,并减少业务方各自实现双写逻辑的机会,但系统会更加依赖缓存层的持久化能力、事务语义和故障处理。
如果团队没有成熟的缓存抽象层,不建议为了追求“写路径统一”而强行引入。对于多个服务共同修改同一业务实体的系统,Write Through 还需要解决字段合并、权限校验、事务回滚和事件发布问题,实际复杂度可能高于 Cache Aside。
Write Behind 先写缓存,再异步落库,可以显著降低写入延迟,适用于计数、行为收集、临时排行榜或允许延迟持久化的场景。但它的本质是把数据库提交从同步路径移到了异步路径,数据丢失和落库延迟必须被业务接受。
如果业务人员说“库存写入要快”,不能直接推导出 Write Behind。库存扣减的正确性通常比写入延迟更重要,更适合在数据库中使用条件更新或专用原子操作,缓存只用于展示可用库存。

当一个数据库变更需要同步到多个缓存、搜索索引、推荐特征或下游服务时,逐个调用下游系统会让主事务变得脆弱。此时可以使用 Outbox、事务消息或 CDC,将数据库变更转化为可靠事件,再由不同消费者独立处理。
需要强调的是,CDC 不是“自动一致性按钮”。它只能更可靠地捕获数据库变化,不能替代缓存 Key 设计、消息版本控制、消费幂等、失败重试和数据校验。引入 CDC 前,应先确认团队是否有能力运维日志位点、消费延迟、断点续传和重建流程。
以下案例是一个用于说明架构机制的情景模拟,不对应某个具体公司的生产数据。假设一个商品详情接口每天接收 300 万次查询,商品价格和库存由运营后台修改,详情对象缓存在分布式缓存中,数据库采用一主两从架构。
上线初期,团队只实现了“更新数据库后删除缓存”。在功能测试中,接口 P95 延迟约 42 毫秒,缓存命中率为 91%。但经过一周的日志抽样,发现价格字段不一致率约为 0.6%,其中约四分之一的异常持续超过 30 秒。
这个数字看起来不大,但如果每天有 300 万次查询,0.6% 并不是“偶尔发生”,而是可能影响约 1.8 万次请求。更重要的是,抽样只能看到部分异常,未被采样的热点 Key 可能造成更大的用户影响。
团队最初的直觉是把缓存 TTL 从 10 分钟降到 2 分钟,希望通过更快过期减少旧数据存活时间。结果是数据库回源 QPS 上升约 3.4 倍,接口 P95 延迟升至 77 毫秒,价格不一致率只从 0.6% 降到 0.45%。
这次调整说明,TTL 只是缩短错误的生命周期,没有解决错误产生的原因。继续排查日志后,异常主要来自三个路径:缓存删除超时、只读副本延迟造成旧值回填,以及本地缓存未及时失效。
| 异常来源 | 抽样异常占比 | 主要表现 | 修复方向 |
|---|---|---|---|
| 缓存删除超时 | 约 38% | 数据库版本已更新,缓存仍为旧版本 | 记录删除任务并可靠重试 |
| 只读副本延迟 | 约 34% | 缓存删除后回源读到旧版本 | 关键回填读主库或使用版本保护 |
| 本地缓存未失效 | 约 21% | 分布式缓存正确,应用进程仍返回旧对象 | 广播失效、短 TTL 和版本校验 |
| Key 或序列化异常 | 约 7% | 部分字段无法更新或删除目标错误 | 统一 Key 规范和结构化校验 |
表中的占比属于该情景的样本推演,重点不是这些数字能代表所有系统,而是说明排查时应把“不一致”拆成可验证的原因类别。只有知道哪一类占比最高,才有必要决定是改读路由、改缓存操作,还是改事件链路。

改造方案没有一开始就引入复杂的全链路 CDC,而是先对普通商品详情建立最小闭环。数据库仍作为权威数据源,更新事务提交后发布一个包含商品 ID、业务版本和更新时间的事件,缓存消费者执行幂等删除,本地缓存通过广播收到失效通知。
对于缓存未命中的回源请求,优先读取主库,或者读取带有版本约束的副本。如果只能读取副本,则回填时记录数据库版本,禁止低版本数据覆盖缓存中的高版本数据。对删除超时的任务,写入重试表,按照 1 秒、5 秒、30 秒和 5 分钟的退避策略执行,超过次数后进入死信队列。
代码层面,事件消费不应只依赖一个布尔返回值。需要区分“删除成功”“Key 不存在”“连接超时但结果未知”“参数错误”和“不可重试异常”。其中“Key 不存在”可以视为幂等成功,而“连接超时但结果未知”应进入重试流程。
public void handleProductChanged(ProductChangedEvent event) {
String key = "product:" + event.getProductId();
try {
CacheVersion current = cache.getVersion(key);
if (current != null && current.version() > event.getVersion()) {
// 旧事件不能覆盖新版本,记录后安全结束
auditLog.ignoreOutOfOrderEvent(event, current);
return;
}
cache.delete(key);
retryRepository.markSuccess(event.getEventId());
} catch (TimeoutException ex) {
// 结果未知,不能直接判定失败或成功
retryRepository.scheduleRetry(
event.getEventId(),
event.getProductId(),
event.getVersion(),
nextBackoff(event.getRetryCount())
);
} catch (InvalidKeyException ex) {
// 参数错误不适合无限重试,进入人工处理队列
deadLetterRepository.save(event, ex.getMessage());
}
}这段代码只是示意,不能直接复制到生产环境。它表达的重点是三个:事件必须带版本,消费必须幂等,超时必须区别于明确失败。很多重复消费问题并不是消息系统造成的,而是消费者把所有异常都简单处理成“失败重试”或“成功结束”。
在这个情景中,改造后缓存命中率从 91% 降到 89%,看起来性能指标略有下降,但数据库回源比例只从 9% 上升到 11%,接口 P95 从 42 毫秒升至 45 毫秒。与此同时,抽样不一致率从 0.6% 降到 0.08%,超过 30 秒的异常从每周约 1,200 次降到 90 次以内。
这组数据说明,缓存治理不能只追求命中率最大化。为了让缓存更快失效、降低脏数据存活时间,命中率可能略有下降;只要数据库压力、延迟和一致性指标处于业务可接受范围,这种下降是值得的。

很多监控只统计缓存操作错误,例如 Redis 命令失败次数,却没有统计业务数据最终是否一致。操作成功不等于结果正确,操作失败也不一定导致最终错误,因为后续重试可能已经修复。
我建议将监控分为三层。第一层是基础设施层,包括缓存连接错误、超时、内存、淘汰和主从状态;第二层是同步过程层,包括事件发送成功率、消费延迟、重试次数和死信数量;第三层是业务结果层,包括版本不一致率、脏读次数和修复耗时。
| 监控层 | 核心指标 | 告警价值 | 不能单独说明什么 |
|---|---|---|---|
| 基础设施层 | 连接错误率、命令延迟、内存使用率 | 发现缓存服务本身是否异常 | 不能证明业务数据一定正确 |
| 同步过程层 | 事件延迟、消费成功率、重试和死信 | 发现变更传播是否受阻 | 不能证明每个 Key 都已恢复 |
| 业务结果层 | 抽样不一致率、陈旧时长、脏读次数 | 直接衡量用户影响和一致性 | 需要设计合理采样和对账口径 |
| 修复运营层 | 平均修复耗时、人工介入量、重复故障率 | 判断闭环是否持续改善 | 不能替代根因分析 |
时间戳看起来容易实现,但在多机器、时钟漂移和高并发更新场景下,时间戳并不总能可靠表示新旧关系。两个服务生成的时间可能相同,时钟回拨也可能让旧事件看起来更新。
如果业务允许,我更倾向于使用数据库生成的递增版本号,或者使用同一行的变更序列号。缓存中保存值和版本,事件中携带同一版本。消费者收到事件时,如果发现当前缓存版本高于事件版本,就丢弃旧事件;如果事件版本更高,则执行更新或删除。
一致性校验不能简单地每分钟全量扫描数据库和缓存。这样做会产生额外数据库压力,甚至在业务高峰时和正常查询竞争资源。更稳妥的方式是分层抽样:热点 Key 提高抽样频率,低频 Key 降低频率;出现同步失败的 Key 优先检查;对关键业务字段采用更严格的校验。
校验时还要明确比较口径。是比较完整 JSON,还是只比较价格、状态、版本和更新时间?如果缓存中包含展示格式、计算字段或随机推荐内容,完整对象比较可能产生大量无意义差异。对账字段应优先选择能够代表业务正确性的核心字段。

业务说“最终一致”往往过于宽泛。架构师应将它转成最大陈旧时间,例如商品详情不超过 30 秒,订单状态不超过 3 秒,权限数据不超过 1 秒。监控就可以据此判断某个 Key 是否已经越过业务红线。
告警条件不应只设置为“有一次删除失败”。如果某个 Key 删除失败后在 200 毫秒内重试成功,可能无需触发高等级告警;如果消息堆积持续 2 分钟,或某个订单状态超过允许时限仍未同步,则应升级为业务告警。
缓存同步失败通常包括网络抖动、连接池耗尽、权限错误、Key 参数错误、序列化异常和数据版本冲突。网络超时可能适合退避重试,权限错误则需要立即报警,参数错误无限重试只会制造更多无效任务。
建议为异常分类建立处理策略:
当缓存集群或数据库发生故障时,大量同步任务会同时失败。如果所有任务在固定 5 秒后重试,就会形成第二次流量尖峰。重试策略应增加随机抖动,并根据业务优先级分层。
例如,权限失效和支付状态可以进入高优先级队列,商品推荐和浏览历史可以进入低优先级队列。重试时还应设置全局并发上限,确保补偿流量不会抢占正常业务所需的连接和 CPU。
死信队列的价值是保留无法自动处理的异常上下文,而不是把问题从主系统日志中隐藏起来。每条死信至少应包含业务 Key、事件 ID、版本、首次失败时间、最后失败原因、重试次数和当前数据摘要。
运维人员处理死信时,不能只点击“重新发送”。应先确认数据库当前版本和缓存当前版本,再决定删除缓存、重新构建、跳过事件或修正数据。否则,一个早已过时的死信事件可能在人工重放后覆盖新数据。
实时事件链路解决的是大部分正常变更,定时扫描解决的是事件丢失、消费者长时间宕机和历史脏数据。扫描可以只针对近期发生变更的记录、热点 Key、曾经失败的 Key 或业务风险较高的字段。
对于大型数据集,建议采用分片游标、限速、时间窗口和断点记录。扫描任务要能够暂停和恢复,并且把扫描产生的回源流量纳入数据库容量评估。没有限速的修复脚本,往往是第二次事故的起点。

很多团队只关注“谁最后写入”,却忽略了请求读取数据的时间。一个读请求可能在数据库更新之前拿到旧值,但在数据库更新之后才完成缓存回填。只看缓存写入完成时间,会误以为后写入的数据更可信,实际上它的业务版本可能更旧。
因此,缓存更新动作最好携带版本校验。无论采用直接更新还是删除后重建,都不应允许低版本事件覆盖高版本数据。对于无法在缓存层做版本判断的场景,删除缓存通常比直接写入旧对象更安全,因为下次回源可以重新获取权威数据。
分布式锁可以减少同一个热点 Key 的并发重建,但它不能保证数据库和缓存始终一致。锁可能过期,客户端可能在网络分区后失去锁状态,持锁进程也可能在完成数据库读取后长时间暂停。
我通常把锁的作用限定为“控制重建并发量”,而不是“保证数据正确”。数据是否正确,仍应由权威数据源、版本号和校验机制决定。锁的租期、续租、异常释放和数据库回源超时也需要纳入故障演练。
热点 Key 的问题不只是缓存击穿。一个高访问量 Key 在更新时可能产生大量并发回源请求;如果同步事件不断删除它,缓存重建频率会升高,数据库可能被短时间打穿。
对于热点数据,可以使用逻辑过期、后台刷新、请求合并或预热。逻辑过期允许在短时间内继续返回旧值,同时由一个后台任务刷新;这适用于展示型数据,不适用于余额和权限。关键是把“允许旧多久”写进业务规则,而不是让技术人员自行决定。
假设商品价格先从 109 元变为 99 元,再变为 89 元。对应事件 v18 和 v19 可能因为不同分区、重试或消费者负载先后顺序而反向到达。如果消费者无条件执行更新,v18 可能在 v19 之后覆盖 89 元。
解决方式包括按业务 Key 分区保证顺序、在缓存中保存版本、消费者检测版本后丢弃旧事件,或者发生冲突时重新读取数据库。不同方式的成本不同,但“默认消息会按发送顺序到达”不能作为设计前提。

在大多数业务系统中,数据库应优先完成事务提交,缓存只承担派生副本更新。应用不能因为缓存写入失败就回滚已经成功的数据库事务,除非缓存本身被定义为事务的一部分,并且具备可验证的事务语义。
数据库事务成功后,应用需要可靠地产生同步事件。直接在事务提交后调用消息服务存在进程崩溃窗口:数据库已经提交,应用还没来得及发送消息就宕机。Outbox Pattern 的思路是将待发送事件和业务数据写入同一个数据库事务,再由后台投递器发送事件,从而避免“数据成功但事件丢失”。
Outbox 表中的事件可以包含事件 ID、业务类型、业务 Key、版本号、操作类型、创建时间和投递状态。投递器发送成功后更新状态,发送超时则保留待重试。消费者以事件 ID 或业务版本实现幂等。
CREATE TABLE product_change_outbox (
event_id BIGINT PRIMARY KEY,
product_id BIGINT NOT NULL,
data_version BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_product_version (product_id, data_version)
);这类设计增加了数据库写入和投递维护成本,但它解决了一个非常具体的问题:业务数据提交和同步事件产生之间不再是两个完全独立的动作。对于低风险、低价值缓存,可以不必引入;对于订单状态、权限和多个下游副本,可靠事件通常更值得。
Outbox 的另一面是数据生命周期治理。事件表如果没有归档和清理策略,最终会影响索引、扫描和写入性能。建议将“待投递”“重试中”“已完成”“死信”分开管理,已完成事件按照保留周期归档,死信则保留足够的审计信息并定期转存。
在高并发系统中,投递器不应对整张表执行无条件排序和扫描。可以使用状态、下一次重试时间、分片键和索引游标,控制每次批量大小。同步链路的可靠性不能以拖慢主库为代价。
CDC 可以从数据库日志中捕获变更,减少业务代码漏发事件的风险。它尤其适合多个服务都需要感知同一数据变化的场景。不过,CDC 链路一般会引入日志位点、消费组、表结构变更、全量初始化和增量切换等运维问题。
如果团队目前连缓存删除失败率、消费延迟和死信数量都没有监控,直接引入 CDC 往往只是把问题从应用代码转移到数据同步平台。正确的顺序通常是先建立事件模型和观测指标,再评估是否需要用 CDC 替代手工事件。
商品详情、文章内容、门店介绍和低风险配置,可以从简单方案开始。数据库作为权威数据源,读请求采用 Cache Aside,写请求提交数据库后删除缓存,失败任务进入重试队列,TTL 作为兜底,定期对热点 Key 做抽样校验。
这类业务不需要一开始就追求强一致。更重要的是明确最大陈旧时间,控制缓存击穿和重建风暴,并把删除失败纳入监控。如果数据变更频率很低,可靠删除加定时校验往往比复杂的实时同步平台更经济。
订单状态通常适合使用可靠事件传播,但查询接口不能完全依赖缓存作为最终事实。支付回调更新数据库后,应产生带版本的状态事件,订单查询可以使用缓存加速,但在关键状态转换、支付确认和发货判断处必须回到权威数据源或使用带版本的条件判断。
对用户展示而言,可以允许极短时间的异步刷新;对内部决策而言,不能因为缓存显示“待支付”就重复发起支付,也不能因为缓存显示“已支付”就跳过数据库校验。展示一致性和决策一致性应当分开设计。
库存展示可以使用缓存,但库存扣减不应依赖缓存中的普通数值。正确的扣减动作通常需要数据库条件更新、原子计数器或专门的库存服务,缓存只用于降低查询压力。
如果库存缓存与数据库短暂不一致,页面显示多几个库存并不一定是严重问题;但如果业务根据缓存判断“还有库存”并直接创建订单,就可能出现超卖。因此,必须把展示链路和扣减链路拆开,不能因为缓存命中率高就让缓存承担交易约束。
权限撤销、黑名单和风控拦截属于错误代价很高的数据。此时可以使用短 TTL、广播失效、版本校验和关键请求强制回源等组合策略。对于高风险接口,即使缓存命中,也应在数据库或专门的权限服务中进行最终确认。
权限缓存的修复优先级通常高于商品详情。告警阈值应更严格,不能等到定时扫描才发现。只要存在撤销后仍然放行的可能,就要进行针对性的故障演练,包括缓存集群不可用、消息延迟和本地缓存未清理。
当系统同时存在浏览器缓存、网关缓存、本地缓存和分布式缓存时,最先要解决的不是同步速度,而是失效协议。每一层都应有版本、过期时间和失效方式,至少要知道某一层返回旧值时,如何让请求继续向权威数据源前进。
多级缓存不建议全部采用相同 TTL。可以根据数据风险和缓存层位置设置不同策略,但必须评估失效传播的最坏延迟。若本地缓存的 TTL 是 10 分钟,即使分布式缓存已经删除,用户仍可能在本地进程中看到旧数据。

删除缓存的优点是逻辑简单,缓存不需要复制复杂的业务更新逻辑,适合对象结构复杂或字段变化频繁的场景。缺点是下次请求需要回源,热点 Key 可能产生瞬时重建压力。
直接更新缓存可以减少回源,适合数据结构稳定、更新逻辑简单且缓存对象与数据库字段高度一致的业务。但它要求应用同时维护两套写入逻辑,任何字段遗漏都可能形成隐性不一致。
| 选择 | 优点 | 代价 | 更适合的场景 |
|---|---|---|---|
| 删除缓存 | 实现简单,降低业务逻辑重复 | 需要回源和重建保护 | 复杂对象、读多写少、低风险展示 |
| 更新缓存 | 读取延迟稳定,减少回源 | 需要维护双写逻辑和版本顺序 | 结构稳定、访问极热、更新逻辑简单 |
| 软失效 | 可保留旧值进行降级 | 必须明确陈旧数据边界 | 允许短暂旧数据的展示型业务 |
| 强制回源 | 正确性更容易控制 | 数据库压力和延迟上升 | 权限、余额、交易决策 |
实时消息适合缩短传播延迟,但系统复杂度高,需要处理投递、消费和重试。定时校验实现成本较低,适合作为最后一道防线,但它无法保证短时间内发现问题。
二者不是互相替代的关系。成熟方案通常是“实时同步负责快速传播,定时校验负责发现遗漏,重试和死信负责处理异常”。如果只能先做一个,我会根据业务的最大陈旧时间决定:允许分钟级陈旧的展示数据可以先做定时校验;支付、权限和订单状态则应优先建立可靠事件。
强一致通常意味着更多回源、更少缓存参与决策、更严格的事务和版本控制。它会增加延迟、数据库压力和系统实现成本,但在错误代价高的场景中,这些成本是必要的。
最终一致并不是低质量方案。对于商品描述、推荐结果和非关键统计,最终一致可以用较低成本换取较高吞吐。真正不专业的做法,是在没有业务授权的情况下默认所有数据都可以旧,也没有任何机制证明旧数据最终会被修复。
不是所有系统都需要 CDC、事务消息、分布式锁、版本控制和全量校验同时上场。复杂方案会增加部署、运维、排障和人才成本。对于只有一个服务、一个缓存集群、低频更新的系统,一套带重试和抽样的 Cache Aside 可能已经足够。
但简单不等于省略关键环节。即使不引入消息平台,也应至少保留删除失败记录、重试机制、TTL 兜底、关键字段校验和告警。架构取舍应该建立在业务风险和故障成本上,而不是建立在技术名词的数量上。

第一阶段不急于改架构,先补齐数据。为缓存操作、数据库写入和同步事件增加统一的业务 Key、请求 ID、事件 ID、版本号和来源标记。没有这些字段,后续无法把一次数据库更新和一次缓存读取关联起来。
同时建立基础面板,至少包含缓存命中率、缓存操作延迟、删除失败率、事件消费延迟、重试数量、死信数量、数据库回源 QPS 和抽样不一致率。先观察一到两个业务周期,区分偶发错误和持续性错误。
第二阶段处理最常见的失败路径。所有缓存删除和更新动作都要明确返回结果;超时要保留上下文并进入重试;重试必须幂等;超过阈值进入死信;死信需要有处理入口和审计记录。
这一步通常能解决大量“数据库已变更、缓存一直不变”的问题,而且改动范围比引入完整数据同步平台小。对低风险业务而言,做到这一步可能已经能满足目标。
当系统存在并发回填、多服务更新或消息乱序时,需要引入业务版本。版本可以来自数据库递增字段,也可以来自统一的变更序列。所有更新、删除、重建任务都要传递版本,消费者不得无条件接受旧事件。
版本控制会增加数据结构和测试成本,但它能够把“凭时间猜新旧”变成“依据明确序列判断新旧”。对于订单、权限和库存等业务,版本控制的收益通常高于实现成本。
第四阶段让系统具备自我发现和自我恢复能力。校验任务按风险分层抽样,对发现的差异自动执行删除或重建,并记录修复前后版本。对于连续失败的 Key,进入人工处理队列,避免自动化系统无限重试。
修复任务必须有流量保护。建议设置每秒最大回源数、单批次最大 Key 数、热点 Key 并发上限和数据库保护阈值。当数据库延迟超过基线时,修复任务应自动降速或暂停。
真正上线前,至少应模拟以下故障:缓存删除超时、消息发送失败、消费者宕机、消息重复、消息乱序、只读副本延迟、本地缓存未失效以及数据库短时不可用。
演练不只看系统是否报错,还要记录四个时间:异常发生时间、被发现时间、自动修复开始时间和业务恢复时间。没有这些时间点,就无法判断闭环是否真的改善。

排查第一步不是执行删除缓存,而是确认数据库主库当前值、事务提交状态和业务版本。要确认看到的不是未提交事务、错误租户、错误分片或错误环境数据。
如果数据库主库本身的数据就不对,清除缓存只会让错误值重新被加载。数据库事实错误和缓存副本错误必须分开处理,不能把所有页面显示异常都归因于缓存。
缓存问题中有一类非常隐蔽:应用删除的 Key 和读取的 Key 并不是同一个。租户 ID、语言、渠道、版本后缀、大小写或序列化方式不同,都可能导致删除动作“成功”,但目标数据仍然存在。
建议把 Key 生成逻辑集中封装,并在日志中打印结构化的 Key 组成字段,而不是只打印一串不可读的最终字符串。生产环境可以对敏感信息脱敏,但不能完全丢失定位所需的维度。
事件排查要从产生、发送、入队、消费、执行到确认逐段核对。常见断点包括数据库事务已提交但 Outbox 未生成、事件已生成但投递器未发送、消息已发送但消费者过滤错误、消费者执行成功但状态未更新。
每段链路都应使用同一个事件 ID。没有统一事件 ID 的系统,只能通过时间和业务 Key 进行模糊关联,在高并发环境下非常容易把多个相似请求混在一起。
如果缓存曾经被删除,仍然显示旧值,应检查是否有读请求将旧数据回填。重点查看回源数据库类型、回源开始时间、回源完成时间、数据库版本和缓存写入版本。
对于热点 Key,还要查看是否存在多个实例同时重建、锁提前过期或旧请求在锁释放后继续写入。很多“删了又出现”的问题,本质上不是删除失败,而是旧读请求晚到。
缓存值不要只保存业务对象本身。至少可以考虑加入业务版本、写入时间、来源和逻辑过期时间。这样出现异常时,运维人员能够判断缓存值来自哪一次变更,以及它已经陈旧多久。
{
"data": {
"productId": 10086,
"price": 99.00,
"stockDisplay": 18
},
"meta": {
"version": 18,
"source": "product-service",
"writtenAt": "2026-09-16T10:20:31Z",
"logicalExpireAt": "2026-09-16T10:25:31Z"
}
}
并非所有场景都适合把元数据和业务对象一起序列化。如果缓存空间非常敏感,可以把版本单独放在 Key 或哈希字段中。但无论采用哪种方式,都要确保排查和校验能够读取版本信息。
“删除缓存返回 true”并不等于“所有副本都已失效”。如果系统存在本地缓存、分布式缓存和网关缓存,删除结果必须明确对应哪一层。统一封装时,应把每层执行结果分别记录,并允许某一层失败后单独重试。
对于更新操作,也要规定低版本事件的处理方式。是丢弃、延迟、重新读取数据库,还是将 Key 标记为需要重建,必须在协议中写清楚。否则不同服务会按照各自理解处理同一个事件。
一个稳妥的读路径通常包括:先查缓存,未命中后进行单 Key 并发控制,再访问权威数据源,写入缓存时携带版本和过期时间。若回源失败,可以根据业务决定返回空、返回短期旧值或降级结果。
展示型数据可以允许短时间使用逻辑过期旧值,但订单状态和权限数据不宜使用无条件旧值。所有降级行为都应记录指标,否则团队可能长期不知道系统已经处于“带病运行”状态。
平均延迟很容易掩盖少数严重请求。缓存同步改造后,应重点比较 P95、P99、最大陈旧时长、数据库回源峰值和修复耗时。某个方案平均延迟下降,但 P99 因缓存重建风暴上升,用户体验未必真的改善。
一致性也要看分布,而不是只看平均值。平均陈旧时间 300 毫秒,可能意味着大多数请求正确、少量请求陈旧 10 分钟;对权限和支付场景来说,后者才是决定风险的指标。
| 指标 | 观察意义 | 改善方向 | 注意事项 |
|---|---|---|---|
| 缓存命中率 | 衡量读取加速效果 | 优化 Key、TTL 和预热 | 不能代表数据正确 |
| 数据库回源比例 | 衡量缓存对数据库的保护 | 减少无效失效和重建风暴 | 关键数据主动回源是合理成本 |
| 同步延迟 P95 | 衡量变更传播速度 | 优化队列、消费者和批量策略 | 需按业务类型分组 |
| 抽样不一致率 | 衡量副本正确性 | 改进版本、失效和校验 | 要说明采样范围和字段口径 |
| 最大陈旧时长 | 衡量极端风险 | 加强告警和死信处理 | 比平均值更适合风险控制 |
| 平均修复耗时 | 衡量闭环恢复能力 | 增加自动重试和重建 | 应区分自动和人工修复 |
不同业务的缓存命中率和一致性阈值差异很大。一个高频商品接口命中率 90% 可能已经不错,而一个低频权限接口命中率 60% 也许完全可以接受。不要脱离访问模式和数据风险制定统一目标。
更合理的做法是先建立七天或十四天基线,再观察改造后的变化。例如,改造前同步延迟 P95 为 8 秒,改造后下降到 1.2 秒;改造前最大陈旧时长为 20 分钟,改造后控制在 45 秒以内。这样的对比比直接引用一个脱离场景的“命中率应达到 95%”更有决策价值。

第一,缓存同步不是一个删除命令,而是一套副本治理机制。只要数据在数据库和缓存之间传播,就必须考虑失败、延迟、重复、乱序和修复。
第二,命中率不是缓存系统的最终成绩。高命中率只能说明请求更常读到缓存,不能说明读到的是正确数据。同步延迟、不一致率、最大陈旧时间和修复耗时,才是闭环治理的核心指标。
第三,复杂技术不等于成熟架构。对于普通展示数据,Cache Aside 加可靠重试、TTL 和抽样校验可能已经足够;对于订单、权限和库存,则应让缓存退回加速角色,并用数据库或专用服务承担最终决策。
如果你正在治理一套已经上线的缓存系统,建议不要从重写代码开始,而是先选择一个最容易出现脏数据、同时影响范围可控的业务对象,完成一次小范围闭环建设。
我更愿意把缓存看成一个需要持续维护的派生数据系统,而不是一个“放进去就能加速”的黑盒。真正成熟的缓存架构,不是永远没有不一致,而是能够知道哪里不一致、为什么不一致、影响了什么、什么时候恢复,以及下一次如何避免同类问题再次发生。围绕这个目标建立检测、修复、验证和复盘闭环,才是数据库存储架构从“能用”走向“可治理”的关键一步。
我以前一直认为“先更新数据库,再删除缓存”已经足够安全,直到遇到缓存被旧值重新覆盖的问题。我想知道,明明删除动作已经执行成功,旧数据为什么还会再次回到缓存里?
“写数据库再删缓存”只是一个较稳妥的基础策略,不是原子操作。数据库和缓存属于两个独立系统,中间任何一步出现延迟、失败或并发交错,都可能导致缓存重新变脏。最容易被忽略的是旧值回填场景。假设商品原价为 109 元,更新请求把数据库改成 99 元;与此同时,一个正在执行的读请求已经拿到了旧值 109 元。
更新请求删除缓存后,这个读请求才完成查询并把 109 元重新写入缓存,最终缓存又恢复成了旧数据。另一个常见原因是从库延迟。数据库主库已经写入 99 元,但读请求访问了尚未同步完成的只读副本,读取到 109 元后再次回填缓存。此时问题不在缓存删除命令,而在读链路选择了一个落后的数据源。
我建议先画出完整时序,而不是直接加“延迟双删”。至少要标记数据库提交时间、缓存删除时间、读请求开始和结束时间、缓存重建来源以及数据库副本延迟。没有这些信息,延迟双删往往只是碰巧降低问题出现概率。
故障场景表面现象更合适的治理方式 删除缓存失败旧值持续命中可靠重试、失败队列、告警 并发旧值回填删除后缓存再次变旧版本号、互斥重建、延迟校验 从库延迟缓存被旧副本覆盖关键回源走主库或进行版本校验 因此,判断方案是否可靠,不能只问“缓存删没删”,还要问“删除后是否可能被旧数据重新写入”“失败后谁负责补偿”“系统如何证明缓存已经恢复正确”。
这三个问题才是缓存同步闭环的核心。
我在设计缓存同步时看过很多方案:有人推荐延迟双删,有人建议使用消息队列,也有人直接上数据库变更捕获。我不想只按技术流行度选型,想知道这些方案分别解决什么问题,以及什么时候会把系统变得更复杂。
这三种方案并不是同一层面的替代品。延迟双删主要用于降低并发读写造成的旧值回填概率;消息队列负责把变更事件可靠地传播给异步消费者;CDC 则负责从数据库变更日志中捕获事实变化。选型前应先确认问题发生在哪一段链路。
在一个读多写少的普通业务中,我通常先采用缓存旁路模式:数据库提交成功后删除缓存,删除失败进入重试队列,再用定时校验兜底。这样做的优点是写路径简单,缺点是需要自己补足失败重试、幂等和一致性检测。延迟双删适合处理“读请求可能在删除前拿到旧值,并在删除后完成回填”的竞态。
它不是精确算法,延迟时间应参考数据库查询 P99、网络抖动和并发读链路耗时,而不是机械地设置为几百毫秒。延迟过短覆盖不了慢请求,延迟过长又会增加缓存短暂不可用和数据库回源压力。消息队列更适合多个服务或多个缓存副本都需要响应同一数据变化的场景。
但消息队列本身不能自动保证一致性,还必须处理消息发送失败、重复投递、消费乱序、重试堆积和死信。消费者应使用业务 Key 加版本号实现幂等,不能假设每条消息只会到达一次。CDC 适合不希望在大量业务代码中手工维护同步事件的系统,尤其是多个写入口、遗留系统或跨服务同步场景。
它的代价是引入日志解析、位点管理、字段映射和变更格式兼容问题。若只是一个简单的商品详情缓存,直接上 CDC 可能是过度设计。
方案主要解决的问题适合场景不适合单独解决的问题 延迟双删并发旧值回填单体或少量服务、读多写少删除失败、消息丢失、跨系统传播 消息队列异步传播与解耦多服务、多副本同步数据库变更与消息原子关联 CDC捕获数据库事实变更多写入口、遗留系统、数据分发业务语义判断和缓存重建策略 我的判断是:先用最小方案覆盖真实故障,再逐步升级。
只有当写入口变多、同步对象扩展到多个系统,或者人工补偿成本已经超过基础设施成本时,才值得引入消息驱动或 CDC。
我曾经把缓存命中率从 82% 做到 94%,但线上仍然出现用户看到旧价格的投诉。这让我困惑:缓存命中率明明提升了,为什么数据质量没有同步变好?架构评审时到底应该看哪些指标?
缓存命中率只能说明请求是否命中了缓存,不能说明命中的内容是否正确。甚至在缓存长期保存旧值的情况下,命中率越高,错误数据被稳定传播的范围可能越大。因此,缓存同步治理必须把性能指标和一致性指标分开看。
以一组脱敏工程样本为例,某商品服务在调整策略后,缓存命中率从 82% 提升到 94%,数据库读 QPS 下降约 38%,但抽样比对发现缓存与数据库的版本不一致率仍为 0.07%。进一步排查后发现,消息消费延迟并不高,真正的问题是部分读请求从延迟副本读取旧值并回填缓存。我建议至少建立四组指标。
第一组是性能指标,包括缓存命中率、回源比例、数据库 QPS、P95 和 P99 延迟。第二组是同步链路指标,包括删除失败率、事件发送成功率、消费延迟、重试次数和死信数量。第三组是数据质量指标,包括数据库与缓存版本不一致率、关键字段脏读次数、缓存陈旧时间以及修复成功率。
第四组是业务影响指标,例如旧价格曝光次数、错误库存判断次数、受影响用户数和故障平均恢复时间。
指标能回答什么问题不能单独说明什么 缓存命中率请求是否减少了回源数据是否最新 消息消费延迟变更传播是否及时消费结果是否正确 版本不一致率副本是否存在偏差偏差是否已经影响业务 修复平均耗时异常能否快速恢复是否能阻止问题再次发生 闭环是否有效,要看一次故障从发现到恢复的完整路径:系统能否发现异常,能否定位是哪一段失步,能否自动补偿,补偿后能否验证版本一致,复盘后能否改进监控或流程。
只有命中率、延迟和不一致率同时向目标改善,才说明治理真正有效。
我知道缓存能降低数据库压力,但库存、账户余额和权限状态一旦读旧,可能直接造成业务事故。我想知道,这类数据是不是应该完全禁止缓存,还是可以通过版本校验、短过期和强制回源来安全使用?
高一致性数据不一定要完全禁止缓存,但不能把缓存当作最终事实来源。更准确的原则是:缓存可以负责加速读取和展示,关键决策必须由具备一致性保障的数据源或原子操作完成。例如商品详情中的名称、图片和营销文案通常允许短暂陈旧,可以使用缓存旁路模式;但库存扣减不能只依据缓存中的库存数字做最终判断。
缓存显示还有 10 件,并不代表数据库或库存服务在当前事务中仍然有 10 件可扣。我通常会把数据分成三类。第一类是允许陈旧的数据,例如内容标题、地区名称和非关键统计值,可以使用较长 TTL。第二类是需要较快收敛的数据,例如商品价格和活动状态,应配置主动失效、版本校验和异常回源。
第三类是不能接受错误判断的数据,例如余额、库存扣减结果和权限校验,关键操作必须回到权威服务执行。
数据类型缓存职责关键操作建议 内容与展示信息主要负责加速允许短暂陈旧,依靠 TTL 和异步刷新 价格与活动状态加速读取、降低回源主动失效、版本号校验、异常时快速回源 库存与余额只做辅助查询或预估扣减、支付和结算必须由权威服务校验 权限状态降低重复查询高风险操作重新校验,权限变更主动失效 如果业务确实需要缓存高一致性数据,至少应增加数据版本、短暂有效期、关键请求回源、失效失败告警和定时抽样校验。
版本号比单纯比较更新时间更可靠,因为多个更新请求可能具有相同时间精度,或者消息到达顺序与数据库提交顺序不同。最终的判断标准不是“有没有缓存”,而是“错误缓存是否有机会直接改变业务结果”。如果答案是肯定的,缓存就只能作为性能层,不能作为决策层;如果答案是否定的,才可以用更激进的缓存策略换取吞吐量。


读者评论
文章把缓存一致性从“删缓存”扩展到定义、传播、检测、修复和复盘,思路比较完整。尤其是读副本延迟导致旧值回填的案例,对排查线上问题很有参考价值。
延迟双删和消息队列并不能自动保证最终一致,这个判断比较客观。实际落地时还需要结合业务容忍度、消息幂等、重试和死信机制,不能只依赖单一方案。
文中强调缓存命中率不等于数据正确性,这一点容易被忽略。若能进一步补充版本号设计、校验任务的实现示例,以及不同一致性指标的告警阈值,实操性会更强。