数据库存储架构真正难的地方,不是把数据写进表,也不是把热点数据放进缓存,而是当一次更新经过数据库、消息队列和缓存之后,架构师能否回答三个问题:最新数据有没有被可靠地传播,旧数据有没有被及时识别,出现偏差后能不能自动恢复。以商品价格为例,数据库更新成功并不代表用户一定能读到新价格;缓存删除失败、消息重复消费、旧请求回填,都可能让“数据库正确、页面错误”持续数分钟甚至更久。
数据库存:架构师怎么用:从数据校验到改善缓存同步
我处理数据库与缓存问题时,通常不会先问“应该使用哪种缓存同步方案”,而是先画出数据从产生到被读取的完整路径。因为缓存不一致很少是单点故障,更多是写入校验、事务提交、事件投递、消息消费、缓存更新和异常补偿之间出现了断点。
本文围绕数据库作为业务事实来源时的架构实践展开,重点讨论数据校验、数据库与缓存的职责边界、缓存旁路、消息驱动同步、幂等、版本控制、对账和自动修复。文中涉及的指标案例,除特别注明外,均为根据常见电商读写模型构造的情景模拟数据,用于说明判断方法,不代表某个公开项目的实际经营结果。
在大多数交易型系统中,我会把数据库定义为业务事实的主要承载层,把缓存定义为读取加速层。数据库需要承担事务、约束、追溯和恢复,缓存则承担减少查询压力、缩短响应时间和承接热点流量。
这并不意味着“数据库永远是真相”。有些系统采用缓存主存、异步落库或专门的状态存储,架构师不能只凭技术习惯下结论。更稳妥的做法是明确写出:某个字段到底由谁负责最终确认,谁拥有版本号,谁可以作为故障恢复时的重建依据。
如果团队没有定义事实来源,后续所有“同步”讨论都会陷入争论。有人认为缓存更新成功就算完成,有人认为数据库提交成功就算完成,最终监控也无法判断哪一边是错的。
一致性不是一个只有“正确”和“错误”的开关。商品名称允许短暂延迟,账户余额通常不允许读到过期值;推荐标签可以最终一致,权限状态则可能要求变更后尽快生效。
| 数据类型 | 典型容忍延迟 | 主要风险 | 优先方案 |
|---|---|---|---|
| 商品名称、图片 | 秒级到分钟级 | 页面短暂展示旧内容 | 缓存旁路、失效重建 |
| 商品价格 | 通常要求秒级以内 | 成交金额错误、促销规则失效 | 数据库事务、版本校验、失效补偿 |
| 库存数量 | 接近实时 | 超卖、少卖、库存显示错误 | 原子扣减、强约束、事件补偿 |
| 权限状态 | 通常要求快速生效 | 越权访问或权限未及时撤销 | 短 TTL、主动失效、版本控制 |
| 推荐标签 | 分钟级甚至小时级 | 推荐结果不够新 | 异步计算、批量更新、最终一致 |
这张表的判断重点不在“哪些数据必须强一致”,而在于把业务损失显性化。只有先估计旧数据造成的金额损失、权限风险或用户投诉,才知道是否值得引入可靠事件、顺序消费和专门对账。

我判断一条同步链路是否成熟,通常不看它正常运行时能否把数据写入缓存,而看它遇到异常后是否具备恢复能力。至少要回答:消息发送失败怎么办,重复消费怎么办,事件乱序怎么办,缓存服务不可用怎么办,对账发现差异后谁负责修复。
因此,缓存同步应被拆成五个环节:数据校验、变更记录、事件传播、缓存处理、差异修复。任何一个环节没有明确责任,系统都可能出现“平时看不出问题,出故障时无法定位”的状态。
我的核心判断是:最终一致性不是“允许不一致”,而是“允许短暂偏差,但必须知道偏差在哪里、持续多久,以及如何恢复”。
假设运营人员在后台把商品价格从 199 元调整到 179 元。应用首先执行数据库更新,事务成功提交;随后应用删除商品详情缓存。如果删除操作因为网络抖动失败,下一次读取仍然会命中旧缓存,用户看到的就是 199 元。
这类问题表面上是“删缓存失败”,但排查时还要继续追问:删除失败有没有记录,是否会重试,重试是否可能覆盖新版本,多久会触发对账,客服或运营能否手工清除指定商品缓存。
另一个更隐蔽的场景是并发回填。线程 A 读取到旧数据,线程 B 更新数据库并删除缓存,线程 A 随后把自己之前读到的旧数据重新写回缓存。此时即使删除动作成功,缓存仍然可能被旧请求重新污染。
订单状态从“待支付”变为“已支付”,再变为“已发货”。如果两个事件经过不同消费者或不同网络路径,先产生的支付事件可能晚于发货事件到达缓存服务。若消费者只按到达时间写入,缓存最终可能退回“已支付”。
这个问题不能依赖服务器时间简单解决。分布式系统的本机时钟可能存在偏差,消息到达时间也不等于业务发生时间。对同一个订单,应该使用数据库版本号、状态序列号或同一消息分区中的顺序来判断新旧。
很多团队重点测试新增和更新,却没有认真测试删除。实际上,删除事件一旦丢失,缓存里的旧对象可能长期存在;如果查询接口把缓存对象当成有效数据,已经删除的商品、已撤销的权限或已关闭的活动仍可能被读取。
删除事件还涉及缓存键设计。如果写入时使用的是 product:detail:1001,删除时却按照另一个环境变量生成了不同键名,那么代码看起来执行成功,实际上删除的是不存在的键。
我在排查缓存不一致时,会把证据分成数据库证据、事件证据和缓存证据。只看其中一组,通常只能证明“某一步发生了什么”,无法证明整条链路是否闭环。
如果数据库版本是 18,事件版本是 17,缓存版本是 16,问题就不再是模糊的“缓存可能不一致”,而是可以定位为事件传播和消费处理都落后。可观测性越细,修复动作越容易自动化。

“先更新数据库,再删除缓存”是常见的缓存旁路模式,适用于大量读多写少的业务,但它不是一致性定理。删除动作可能失败,删除后可能被旧请求回填,多个缓存副本之间也可能存在传播延迟。
我更倾向于把它称为低复杂度的默认方案,而不是万能方案。它的优点是写入路径简单、缓存可以自然重建;它的缺点是必须承认删除失败窗口,并为这个窗口设计重试或对账。
应用先提交数据库事务,再发送消息,可能出现数据库成功、消息发送失败;应用先发送消息,再提交数据库,则可能出现消费者提前读取不到新数据,或者数据库最终回滚。
这就是典型的双写问题。解决它不能只依赖开发人员记得“发送成功后再返回”,而需要使用 Outbox、事务消息或基于数据库日志的变更捕获等机制,把业务变更和可传播记录建立可靠关系。
不过,Outbox 也不是自动解决一切。它解决的是“变更记录不容易丢”的问题,仍然需要处理事件重复发布、发布状态更新、历史事件清理和消费者幂等。
消息队列只能提供某种投递和消费能力,不能替业务定义哪些事件有效,也不能自动处理乱序、重复和错误数据。消费者如果没有版本判断,同一条消息重放多次仍可能产生错误结果。
我见过一种常见实现:消费者收到更新事件后直接执行缓存覆盖,消费失败就无限重试。这样做会带来两个问题:永久错误占满重试队列,旧事件重试时又可能覆盖新数据。
更稳妥的策略是把错误分类。网络超时、缓存连接短暂失败属于可重试错误;字段格式不兼容、业务对象不存在、事件版本非法属于需要隔离和人工处理的错误。
时间戳可以作为辅助信息,但不适合在高一致性场景中充当唯一顺序依据。应用服务器时间不一定严格同步,数据库提交时间、事件产生时间和消息到达时间也可能各不相同。
对于同一实体的状态变化,我会优先使用数据库自增版本、状态序列号或事件序列号。时间戳适合做排查和延迟统计,版本号更适合做覆盖决策。
延迟双删通常指数据库更新后先删除缓存,等待一段时间再删除一次。它可以降低旧请求回填造成脏数据长期存在的概率,但延迟时间并没有一个跨系统通用的标准。
如果数据库查询耗时、网络抖动或线程排队时间超过预设延迟,第二次删除仍可能早于旧请求回填。它更适合当作风险降低手段,而不是一致性保证手段。
TTL 能让脏数据最终过期,却不能保证过期前不会被读取。如果价格缓存设置为 30 分钟,删除事件丢失后,用户可能在半小时内持续看到旧价格。
TTL 的主要价值是兜底,而不是同步。关键数据应同时具备主动失效、版本校验、失败重试和对账机制。非关键数据则可以接受更长 TTL,以换取更低的更新成本。

如果数据模型本身没有版本字段、更新时间不可信、删除状态不清晰,缓存同步很难稳定。同步系统需要知道一条数据是新增、更新还是删除,也需要判断一个事件是否比缓存中的状态更新。
我建议核心实体至少具备以下字段,具体名称可以按团队规范调整:
id
status
data_version
updated_at
deleted_at
updated_by
source_event_id
data_version用于覆盖判断,deleted_at用于保留删除语义,source_event_id用于审计和幂等追踪。很多“删除后又出现”的故障,本质上是删除没有被建模为一种可传播的状态。
写入前校验负责阻止明显错误进入数据库,包括字段类型、必填项、长度范围、枚举值、唯一约束和业务状态转换。数据库约束应守住不可违反的底线,应用层则负责处理跨字段和跨对象规则。
例如,订单状态不能从“已取消”直接改为“已支付”,库存扣减不能接受负数,用户权限不能引用不存在的角色。把这些规则留给缓存层处理,往往已经太晚。
事件进入消息链路后,应检查事件 ID 是否存在、实体 ID 是否合法、操作类型是否受支持、版本是否为空、载荷是否符合当前协议。事件结构错误应该尽快进入隔离队列,而不是无限重试。
缓存写入完成后,要能够回答缓存中的版本是多少、最后更新时间是什么、数据摘要是否与数据库一致。对于大规模数据,不必每次都比较完整载荷,可以比较主键集合、字段摘要或版本号。
| 校验阶段 | 主要校验对象 | 发现的问题 | 推荐处理动作 |
|---|---|---|---|
| 写入前 | 字段、约束、状态机 | 非法数据、重复数据、越权变更 | 拒绝写入并返回明确错误 |
| 事件产生时 | 事件 ID、版本、操作类型 | 事件缺字段、版本缺失、删除语义丢失 | 阻止投递或进入隔离队列 |
| 消费时 | 幂等状态、版本顺序、实体存在性 | 重复、乱序、过期事件 | 跳过、重排、重试或人工处理 |
| 结果侧 | 缓存版本、摘要、过期时间 | 缓存落后、键错误、字段不完整 | 失效、重建或进入修复任务 |
一个只包含业务对象载荷的消息不够用于生产治理。至少应包含事件 ID、实体 ID、操作类型、版本号、发生时间、来源和必要载荷。
{
"event_id": "evt-20260916-000001",
"entity_type": "product",
"entity_id": "1001",
"operation": "UPDATE",
"version": 18,
"occurred_at": "2026-09-16T10:30:12Z",
"source": "product-service",
"payload": {
"price": 179.00,
"stock": 42
}
}
事件设计要避免两个极端。只传 ID 会导致消费者必须回源查询,容易在回源时读到难以解释的状态;把整行数据无差别塞进消息,又会增加消息体积和字段兼容成本。关键是根据消费者需要选择“事件通知”还是“事件载荷”。
同一实体的消息最好按实体 ID 路由到同一分区,但这只能改善顺序,不能覆盖重试、跨集群复制和人工重放造成的乱序。消费者最终仍应依据版本号做条件判断。
if event.version ignore(event)
elif event.version == cache.version:
record_as_duplicate(event)
else:
write_cache(event.entity_id, event.payload, event.version)
如果缓存系统支持带版本的条件写入,可以把“读取当前版本、判断、写入”尽量合并为原子操作。否则多个消费者并发处理同一对象时,仍然可能出现检查通过后互相覆盖。

缓存旁路的典型流程是先更新数据库,再删除缓存;读取时缓存命中就返回,未命中则查询数据库并回填。它的最大优势是边界清晰,缓存写入失败不会直接改变数据库事实。
我会优先在商品基础信息、城市列表、组织架构展示数据等场景使用这种方案。前提是数据可以短暂延迟,缓存能够重新构建,且缓存失效失败有重试或定时对账。
不要为了一个普通详情页引入跨服务事务和复杂事件编排。架构复杂度本身也是故障来源,能用简单方案配合可靠兜底解决的问题,不应过度设计。
先写缓存再写数据库的好处是读请求可以更快看到新值,但数据库写入失败时,缓存可能领先于事实来源。若缓存中的新值被下游业务继续使用,错误会从展示层扩散到交易层。
这种模式更适合明确采用缓存主存、异步持久化的系统。此时必须配套写入日志、持久化队列、恢复机制和数据丢失边界,不能只是把 Redis 当作临时数据库使用。
Write Through 把写入路径集中到缓存层,由缓存负责同步写入数据库,便于统一管理,但会增加访问路径和组件耦合。缓存层故障时,写入可用性也可能受到影响。
Write Behind 先完成缓存写入,再异步落库,通常能够获得更高写入吞吐,但数据丢失窗口、进程崩溃恢复和持久化顺序都需要额外设计。账户、订单和库存等关键数据不应仅凭吞吐量指标选择这种模式。
当商品变更不仅要刷新缓存,还要通知搜索索引、推荐系统、数据分析和风控服务时,消息驱动的价值就比较明显。它能把数据库写入和下游处理解耦,也能通过重放机制重新构建部分派生数据。
但消息驱动不是免费午餐。团队需要维护消息协议、消费状态、重试策略、死信队列、积压监控和回放工具。如果只有一个消费者、数据量也不大,简单缓存旁路可能更划算。
CDC 可以从数据库日志捕获新增、更新和删除变化,减少业务代码到处发送消息的负担。它对遗留系统尤其有价值,因为有些旧应用无法快速改造写入逻辑。
不过 CDC 通常知道“哪一行发生了变化”,不一定知道“这次变化代表什么业务动作”。例如状态从 2 变成 3,CDC 可以捕获字段差异,但未必能直接表达“订单已发货”这一业务语义。
我的判断是:数据同步和索引刷新可以优先考虑 CDC;涉及业务规则、权限传播或跨域动作时,应用事件往往更适合。两者也可以并存,但必须避免同一变化被两条链路重复处理。

在同一个数据库事务中同时写业务表和事件表,是 Outbox 的核心思想。业务数据提交成功时,待发送事件也一并提交;后台发布器再把事件发送到消息系统。
这样即使消息系统短暂不可用,事件仍然保存在数据库里,可以稍后重试。它不要求数据库事务和消息系统组成一个昂贵的分布式事务,但要求发布器具备扫描、抢占、重试和状态更新能力。
BEGIN;
UPDATE product
SET price = 179.00,
data_version = data_version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1001;
INSERT INTO outbox_event
(event_id, entity_id, operation, version, payload, status)
VALUES
('evt-20260916-000001', 1001, 'UPDATE', 18,
'{"price":179.00}', 'PENDING');
COMMIT;这里需要注意事件表的生命周期。事件发送成功后不能无限保留,否则会挤压主库;但也不能立即删除,否则出现争议时没有审计依据。通常应结合保留周期、归档策略和业务追溯要求处理。
记录已处理的事件 ID是一种基础做法,但它会引入幂等表膨胀、并发插入竞争和历史重放限制。对于缓存更新,我更建议同时使用事件 ID和业务版本。
例如,版本 18 的缓存删除操作执行两次,结果应该与执行一次相同;版本 17 的更新事件在版本 18之后到达,应该被记录为过期事件,而不是重新写入。
缓存连接超时、服务端限流和短时网络抖动通常可以重试;字段无法反序列化、事件协议版本不兼容、实体 ID不存在,则未必适合继续重试。
| 错误类型 | 是否重试 | 建议间隔 | 最终动作 |
|---|---|---|---|
| 连接超时 | 是 | 指数退避 | 超过次数进入死信并告警 |
| 缓存限流 | 是 | 按服务端提示退避 | 控制并发,避免重试风暴 |
| 字段解析失败 | 通常否 | 不重复轰炸 | 隔离事件并修复协议 |
| 实体不存在 | 视业务决定 | 可延迟重试一次 | 判断是否为乱序或脏事件 |
| 版本过期 | 否 | 无需重试 | 记录并确认最新版本已存在 |
补偿不是承认系统失败,而是承认分布式系统不可能永远没有异常。一个可操作的补偿任务,至少需要支持按实体、按事件、按时间范围和按错误类型重放。
对于缓存数据,最安全的修复动作往往不是直接拼装旧消息,而是回到数据库读取当前版本后重建缓存。这样可以避免历史事件缺失或顺序混乱继续污染缓存。
不过,回源重建也要控制并发。大规模缓存失效时,如果所有请求同时回源,数据库可能被瞬间打穿。应使用互斥锁、请求合并、分批预热和限流,避免一次修复引发第二次故障。

假设一个电商商品详情页需要展示商品名称、主图、售价、库存和促销状态。它们看起来属于同一页面,但一致性要求并不相同。
商品名称和主图更新后延迟几秒通常可接受;售价关系到成交金额,要求更快失效;库存不仅影响展示,还可能影响下单校验;促销状态要考虑开始时间、结束时间和规则版本,不能只靠详情缓存的更新时间判断。
因此,我不会把整个商品详情对象作为一个无差别缓存整体治理。更合理的做法是根据字段风险拆分数据或至少拆分版本,避免低风险字段拖累高风险字段,也避免高风险字段被普通缓存策略掩盖。
这个流程的关键不是组件数量,而是每一步都有可验证的结果。数据库事务完成不等于事件完成,事件投递成功不等于缓存完成,缓存写入成功也不等于业务读请求已经看到新值。
| 异常场景 | 可能结果 | 优先处理方式 | 长期改进 |
|---|---|---|---|
| 数据库成功,Outbox 写入失败 | 数据已变更但没有事件 | 同事务提交,失败则整体回滚 | 检查事务边界和连接池 |
| 事件重复消费 | 缓存被重复写入 | 事件 ID 去重,操作设计为幂等 | 完善重试确认机制 |
| 旧事件晚到 | 新数据被旧数据覆盖 | 按版本拒绝过期事件 | 优化分区策略和消费者并发 |
| 缓存服务不可用 | 读请求回源,数据库压力上升 | 限流、降级、延迟重试 | 缓存集群高可用和热点保护 |
| 删除事件丢失 | 已删除商品仍出现在缓存 | 对账发现后按数据库状态强制失效 | 把删除作为正式事件建模 |
下面用一组情景模拟说明对账如何改变运维结果。假设每天有 100 万次商品变更,正常情况下少量消息会因连接、重试和服务发布产生延迟。没有对账时,团队只能依靠用户投诉发现问题;有对账时,可以按版本差异主动修复。
| 治理方式 | 每日发现方式 | 平均发现延迟 | 人工处理对象 | 主要短板 |
|---|---|---|---|---|
| 仅依赖 TTL | 用户反馈或自然过期 | 分钟级到小时级 | 不稳定 | 无法知道哪些对象已经错误 |
| 消息重试 | 消费失败告警 | 秒级到分钟级 | 失败消息 | 无法覆盖已确认但结果错误的情况 |
| 消息重试加增量对账 | 版本差异扫描 | 分钟级 | 差异对象 | 需要设计扫描范围和数据库负载控制 |
| 消息重试加全量对账 | 周期性全量比较 | 小时级以内 | 异常对象和历史脏数据 | 扫描成本高,需分片和错峰 |

很多方案只描述写路径,却没有描述缓存未命中时的读路径。缓存重建必须考虑并发请求、回源超时、数据库只读副本延迟和缓存写入失败。
如果更新已经提交主库,但回源请求读取的是存在复制延迟的只读副本,缓存可能重新写入旧值。对高风险字段,重建时应优先读取主库,或者比较读取结果版本,只有确认版本不低于待重建版本时才允许写入。
这也是我不建议把“删除缓存后立即回源”当成完整方案的原因。删除只是让旧值消失,真正安全的重建还要确认读取来源和数据版本。
数量对账比较数据库中某个时间窗口的变更数量、事件数量和消费成功数量。它不能定位每一条错误数据,却很适合快速判断某个分区、某个时间段或某次发布是否出现大面积损耗。
例如,数据库变更 50000 条,Outbox 事件 50000 条,消息消费成功 49998 条,说明有两条事件需要继续追踪。如果缓存对账差异达到数千条,则可能是消费者故障、缓存集群异常或键规则变更。
对于商品、权限和配置对象,可以比较数据库有效主键集合与缓存索引集合。数据库中不存在、缓存中却仍存在的主键,通常对应删除事件丢失或过期清理失败;数据库中存在但缓存索引中没有的主键,则可能只是尚未预热,不一定是错误。
因此,对账规则必须区分“应该存在缓存”和“允许缓存未命中”。缓存不是数据库的完整副本,不能把所有缓存未命中都视为不一致。
版本对账是我更推荐的日常手段。数据库记录版本 20,缓存记录版本 18,可以直接判定缓存落后;如果缓存版本 21而数据库版本 20,则应检查是否存在错误提前写入或事实来源定义错误。
修复时不一定需要比较完整字段,只要数据库版本高于缓存版本,就可以把实体加入重建队列。重建任务读取当前数据库状态,而不是简单重放所有历史事件。
版本相同并不代表内容一定相同。如果有人绕过正常流程直接修改了数据库,或者缓存序列化时漏掉了字段,版本可能保持一致但内容已经不同。
对关键对象,可以对选定字段计算稳定摘要,例如将价格、库存、状态和有效期按固定顺序拼接后计算摘要。摘要不能替代版本,但可以作为版本对账之外的抽样或关键字段校验。
canonical = product_id + "|" + price + "|" + stock + "|" + status
checksum = SHA256(canonical)
摘要字段必须固定顺序、固定格式和固定精度。金额使用浮点数直接拼接容易产生精度差异,应该采用明确的小数位或最小货币单位。
高风险数据可以做分钟级增量对账,普通详情数据可以按小时或天做抽样对账。全量对账不应在高峰期直接扫描主库,否则为了发现缓存问题反而给数据库制造压力。

缓存命中率只能说明请求拿到了缓存值,不能说明这个值是最新的。一个长期没有失效的脏缓存,反而可能带来很高的命中率。
我会把命中率和缓存版本落后量、对账差异数、事件处理延迟一起看。命中率上升但版本落后对象也上升,可能意味着缓存正在稳定地返回旧数据。
指标命名也要统一。比如“同步延迟”必须明确是数据库提交到事件产生、事件产生到消费完成,还是数据库提交到缓存可读。没有口径的延迟指标无法用于容量评估和故障定位。
每次业务变更都应该能够通过业务主键找到对应的数据库版本、事件 ID、消费者处理记录和缓存版本。否则发生线上问题时,工程师只能分别登录数据库、消息系统和缓存服务,靠时间猜测关联关系。
建议在日志中保留以下字段:
trace_id
entity_id
event_id
data_version
operation
producer
consumer
retry_count
cache_key
processing_result
error_type
日志不应把完整敏感载荷全部打印出来。对于价格、权限和账户相关数据,可以记录字段摘要、版本和结果状态,既满足排查需要,也降低敏感数据泄露风险。
“死信数量超过阈值”只是一个事实,不是完整的告警策略。告警应明确影响范围、负责人和处理动作。例如,非关键商品详情死信持续增加,可以进入工作日处理队列;权限同步死信出现一条,就可能需要立即阻断相关访问或强制失效。

如果系统只有一个主库、一个缓存集群和少量服务,不建议一开始就搭建复杂的数据平台。先明确数据库事实来源,统一缓存键生成逻辑,采用数据库更新后删除缓存,并增加失败重试和定期对账。
小型系统最常见的问题不是技术能力不够,而是没有留下版本、事件和错误记录。哪怕暂时不使用消息队列,也应在业务表中保留更新时间或版本号,并记录缓存删除失败。
当一个数据库变更需要通知多个下游,或者缓存失效失败已经开始影响业务,就应考虑 Outbox、消息队列和专门消费者。此时重点不是追求极低延迟,而是建立可追踪、可重试、可回放的传播链路。
建议先选择一个高频但风险可控的实体试点,例如商品基础信息。验证事件字段、版本逻辑、重试和对账都稳定后,再扩展到价格、库存和权限等高风险对象。
大型系统通常拥有多个缓存集群、多个消费组和跨地域部署。此时不能假设全局有序,应按实体 ID分区,保证同一对象的事件尽量进入相同顺序链路。
不同风险等级的数据应隔离队列和资源。商品图片刷新不应和权限失效共用无法区分优先级的消费通道,否则低风险大流量任务可能拖慢高风险小流量任务。
如果应用代码分散、写入入口很多,逐个改造发送事件的成本可能很高。可以先通过 CDC捕获数据库变化,建立缓存失效和对账链路,再逐步为关键业务补充应用层事件。
遗留系统改造时要特别注意绕过应用直接修改数据库的脚本、批处理和人工修复操作。它们可能没有完整业务事件,却会改变缓存需要反映的事实。
账户余额、库存扣减和订单支付状态,缓存可以用于展示或加速查询,但最终扣减、授权和状态确认应回到具备事务或原子约束的存储层。
如果业务允许用缓存做预扣,也必须明确预扣超时、回滚、重复请求和数据库落库失败的处理方式。不能因为缓存响应快,就把交易正确性完全交给一个可淘汰的数据层。

缓存旁路没有消息队列、事件表和消费者,系统组件更少,开发和运维成本更低。对于可以接受短暂旧数据的业务,它往往是更好的工程选择。
但简单方案必须诚实面对边界:删除失败如何补偿,缓存重建是否会打穿数据库,多个实例是否会重复回源。没有这些答案,所谓简单只是把复杂度推迟到故障发生之后。
Outbox、消息队列、CDC、版本控制和对账,可以让变化更可追踪、失败更可恢复,但也增加了存储、监控、发布和排障成本。系统复杂度上升后,团队必须有能力维护事件协议、死信队列和重放工具。
如果团队没有专人维护消息链路,却把所有数据都改成异步同步,可能会出现“架构看起来先进,故障时没人知道如何回放”的反效果。
把同步延迟从分钟级压到秒级,需要更高的消费者并发、更严格的顺序控制、更细的监控和更快速的故障响应。缓存集群、消息系统和数据库都可能需要预留峰值容量。
因此我建议先定义服务目标,例如“价格变更后 99%的缓存对象在 3 秒内完成失效”,再根据目标设计容量和告警,而不是笼统地宣传“实时同步”。
| 方案 | 实现复杂度 | 故障恢复能力 | 适合场景 | 不适合场景 |
|---|---|---|---|---|
| 更新数据库后删缓存 | 低 | 中低 | 普通详情、低风险展示 | 账户、权限、强实时库存 |
| 延迟双删 | 中 | 中 | 存在旧请求回填的高并发读场景 | 需要严格顺序保证的交易状态 |
| Outbox 加消息 | 中高 | 高 | 多下游传播、需要重放的场景 | 极小规模且无异步运维能力的系统 |
| CDC 加缓存消费者 | 中高 | 高 | 遗留系统、多写入入口、数据同步 | 强依赖业务语义的复杂动作 |
| 缓存主存加异步落库 | 高 | 取决于持久化设计 | 高吞吐、可接受异步持久化的数据 | 不能接受丢失或回滚的核心交易数据 |
当错误成本远高于建设成本时,应投入版本、补偿和对账;当错误成本很低且缓存可快速重建时,优先选择简单方案。架构师的价值不是把每个系统都设计成同一种复杂架构,而是让投入和风险相匹配。

不要一上来改中间件。先选择一个核心实体,建立一张数据契约表,写清楚字段来源、版本规则、缓存键、失效方式和一致性目标。
| 检查项 | 需要回答的问题 | 不清晰时的风险 |
|---|---|---|
| 事实来源 | 数据库、缓存还是其他存储最终确认数据? | 出现争议时无法判断哪一边正确 |
| 版本来源 | 谁生成版本,版本是否单调递增? | 旧事件可能覆盖新事件 |
| 缓存键 | 读、写、删是否使用同一套生成逻辑? | 删除成功但删错对象 |
| 删除语义 | 删除是物理删除、逻辑删除还是状态变更? | 已删除对象长期残留 |
| 失败动作 | 发送、消费、缓存写入失败后谁来重试? | 异常只能依赖人工排查 |
| 对账入口 | 能否按实体和时间范围重新校验并修复? | 发现差异却无法恢复 |
正常流程跑通,只能证明系统在理想条件下可用。缓存同步真正需要测试的是异常路径:数据库成功后杀掉发布器、消息重复投递、事件乱序、缓存写入超时、消费者确认失败、数据库只读副本延迟等。
我建议至少做以下测试:
很多项目把“消息成功消费”作为验收结束,但消息消费成功只是中间结果。更完整的验收应该检查数据库版本、缓存版本、字段摘要和用户实际读取结果。
对于关键字段,可以设计端到端测试:提交价格变更,等待同步目标时间,读取缓存并验证版本;如果故意制造失败,再验证系统能否在约定时间内完成修复。
缓存策略改造可能影响数据库压力、接口延迟和业务读数。不要一次性把所有实体都切换到新方案,应该先选择一个业务域,保留旧链路开关,灰度观察消息积压、回源次数、版本差异和缓存命中率。
如果新链路出现异常,应能快速切换为“强制失效后回源”或暂时关闭异步更新。一个没有回滚路径的同步改造,即使设计文档写得完整,也不适合直接全量上线。

缓存通常不是业务事实,而是根据数据库状态生成的派生结果。既然是派生结果,就应该具备生成来源、版本、失效规则、重建方式和质量校验。
这个视角比“数据库和缓存如何保持一致”更有用。因为它允许缓存短暂落后,却不允许系统不知道它落后;允许缓存被清空,却要求它能够安全重建;允许消息重试,却要求重复执行不会改变最终结果。
成熟系统不是完全没有异常,而是异常发生后能够解释:哪条数据变了,哪个版本是最新的,事件走到哪一步,为什么没有写入缓存,何时完成修复。
如果工程师只能说“可能是缓存问题”,说明系统缺少可观测的状态模型。相比继续增加中间件,先补齐事件 ID、版本号、缓存键、失败类型和修复记录,往往更能改善真实故障率。
如果你正在改造现有系统,我建议按照以下顺序推进:
数据库存储架构的核心,不是让所有数据永远同时出现在所有层,而是让每一层的职责、版本和恢复路径都清楚。数据库负责保存可追溯事实,消息负责传播变化,缓存负责提高读取效率,对账负责发现遗漏,补偿负责把系统拉回可接受状态。架构师真正要建设的,正是这条能够被验证、被监控、被修复的数据链路。
我在设计商品、订单和权限数据时,最初也尝试过让缓存承担更多写入职责,以减少数据库压力。结果是数据库事务回滚、缓存写入成功时,很难判断哪个值才是最终状态。后来我想确认:数据库是否应该永远作为唯一事实源?在不同业务场景下,架构师到底该如何划分数据库、缓存和消息系统的职责?
数据库通常应作为业务事实源,但“数据库永远是唯一真相”并不是绝对规则。关键在于先定义数据的权威来源,再决定缓存和消息系统如何传播变化。对于订单状态、账户余额、库存数量和权限状态,我通常会把数据库作为最终裁决者;对于搜索索引、推荐标签和统计结果,则可能由专门的数据服务承担读取职责。
我在一次商品详情改造中,将商品数据拆成三类:商品名称允许短暂延迟,价格要求秒级同步,库存则要求在扣减事务中直接受数据库约束。三类数据没有共用同一种缓存策略,否则会出现“商品名称还能读旧值,但库存旧值会造成超卖”的问题。
数据类型权威来源可接受延迟推荐策略 商品名称数据库数秒更新数据库后删除缓存 商品价格数据库通常不超过1秒事务事件加版本号更新或失效缓存 库存数量数据库或库存服务尽量接近实时数据库原子扣减、幂等事件和对账 推荐标签推荐计算结果分钟级甚至更长异步刷新和定时重建 缓存的职责是降低读取成本,不是替代事务。
缓存可能被淘汰、重启或清空,因此任何关键数据都必须能够从权威来源重新构建。消息系统也不是事实源,它更像变化传播链路:负责把“数据库发生了什么变化”通知给缓存、搜索或下游服务。我的判断标准不是“数据库是否更快”,而是“出现冲突时谁有能力裁决”。
如果一个数据对象无法从数据库或其他明确的权威存储恢复,缓存就不应承担唯一持久化职责。架构设计时,先画出数据权威关系,再讨论缓存命中率和同步延迟,通常比先选中间件更可靠。
我曾经在线上遇到过这样的情况:后台已经把商品价格从99元改成89元,数据库查询结果是89元,但部分用户在几十秒内仍看到99元。代码看起来只是“更新数据库,然后删除缓存”,重试日志也显示执行成功。我想知道,问题究竟来自删除失败,还是并发读取和旧请求回填造成的?
“先更新数据库,再删除缓存”是实用的缓存旁路模式,但它只解决了主流程,不等于自动消除所有并发窗口。最容易被忽略的一种情况是:线程A更新数据库后准备删除缓存,线程B在删除动作前读到旧缓存;线程B随后把旧值重新写回缓存,最终缓存又恢复成旧数据。另一个常见问题是旧请求晚于新请求返回。
假设线程B在数据库更新前读取了旧值,线程A完成数据库更新并删除缓存后,线程B才把旧值写入缓存。如果没有版本判断,缓存并不知道这个值已经过时。
方案优点主要风险我的使用建议 更新数据库后删除缓存简单、成本低删除失败、并发旧值回填普通详情数据的默认方案 更新数据库后延迟再次删除降低旧请求回填概率延迟参数依赖访问耗时,无法理论上彻底解决问题作为补充,不作为一致性保证 消息驱动缓存失效支持重试、追踪和补偿消息重复、乱序、积压下游较多或需要可恢复时采用 带版本号的缓存写入能阻止旧事件覆盖新值需要改造缓存结构和更新逻辑价格、配置、权限等高价值数据优先考虑 我更倾向于把缓存内容设计成“值加版本”的结构,例如保存value、version和updated_at。
消费者收到事件后,只有事件版本大于等于缓存版本时才允许覆盖。这里优先使用数据库递增版本或业务序列号,不建议单独依赖机器时间,因为分布式环境中的时钟并不一定严格有序。排查这类问题时,不要只看删除缓存是否返回成功。我会同时检查数据库提交时间、缓存删除时间、旧请求开始时间、缓存回填时间和事件版本。
如果缓存最终版本小于数据库版本,问题就不是“缓存有没有删掉”这么简单,而是整条读写传播链路缺少顺序控制。
我以前只监控缓存命中率和接口响应时间,直到一次缓存序列化字段变更后,才发现大量缓存内容已经缺字段,但接口并没有报错。数据库和缓存数量看起来都正常,真正的问题只有抽样比对后才暴露出来。我现在想建立一套不依赖人工抽查的数据校验机制,应该从哪些层次开始?
数据校验不应只放在写入数据库之前,而应覆盖写入前、同步中和结果侧三个阶段。只做字段校验,只能保证“数据能写进去”;只做消息消费成功率,只能说明“事件被处理过”;只有结果对账,才能判断数据库和缓存是否真的达到了预期状态。
写入前校验主要解决脏数据进入系统的问题,包括字段类型、必填项、长度范围、枚举值、唯一性和状态转换。例如订单不能从“已取消”直接跳转到“已完成”,库存扣减也不能只依赖应用层读取后再写回,而应使用数据库条件更新或原子操作。同步过程校验关注事件是否完整、是否重复和是否乱序。
一个可排查的事件至少应包含事件编号、业务主键、操作类型、版本号、发生时间、来源和必要载荷。缺少事件编号时,很难判断重复消费;缺少版本号时,无法阻止旧事件覆盖新状态。
校验层检查内容典型指标发现问题后的动作 写入前字段、约束、状态机校验失败数、非法状态转换数拒绝写入并返回明确原因 同步中事件完整性、重复、乱序、延迟消费延迟、重试数、死信数重试、隔离或人工重放 结果侧缓存值、版本、字段摘要差异数量、版本落后数量删除缓存、重新加载或回放事件 对账时,我不建议一开始就对所有字段做全量逐条比较,这会给数据库和缓存带来额外压力。
更稳妥的做法是分层:先比较记录数量和主键集合,再比较版本号,最后对价格、库存、权限等关键字段做精确比对。非关键字段可以按时间窗口抽样,发现异常后再扩大范围。在一个约数百万条商品数据的测试环境中,我会把全量对账放在业务低峰期,把增量校验放在事件消费之后。
增量校验负责及时发现问题,全量对账负责捕捉漏消息、历史脏数据和代码升级造成的结构不兼容。两者缺一不可:只依赖实时链路,容易漏掉偶发故障;只依赖定时对账,又无法控制短期风险。
我曾经把同步失败简单交给消息重试,结果消费者恢复后,同一条更新事件被执行了多次,甚至出现旧事件覆盖新事件的情况。后来我发现,重试解决的是“再执行一次”,却没有解决“重复执行是否安全”和“失败后能否定位修复”。架构师应该怎样设计一条真正可恢复的同步链路?
可靠同步的核心不是让每条消息只执行一次,而是让消息执行多次也不会破坏最终结果。现实系统中,消费者处理成功但确认失败、网络超时、服务重启和消息重复投递都很常见,因此“只消费一次”通常不是可以简单依赖的前提。我会把幂等设计分成三层。第一层是事件幂等:记录已经处理过的事件编号;
第二层是版本幂等:只有新版本不低于当前缓存版本时才允许写入;第三层是操作幂等:删除缓存、刷新缓存和重建缓存即使重复执行,也不会产生错误状态。重试机制需要区分临时故障和永久故障。缓存服务短暂不可用适合指数退避重试,字段反序列化失败则更可能是结构兼容问题,继续无限重试只会造成消息堆积。
我通常会设置有限次数的自动重试,超过阈值后进入死信队列,并保留业务主键、事件编号、版本、错误类型和最近一次失败时间。
故障类型是否适合自动重试建议处理方式 缓存连接超时适合指数退避,限制最大次数 消费者实例重启适合恢复消费并依靠幂等机制防重复 事件字段缺失不适合无限重试进入死信队列,修复生产逻辑后重放 版本低于缓存当前版本不需要重试记录为过期事件并直接跳过 数据库记录已删除视业务而定执行缓存删除,并记录删除事件结果 对于关键数据,我会增加Outbox或等价的可靠事件记录机制。
数据库事务提交时,同时写入一条待发布事件;后台发布程序持续扫描未发送事件,成功投递后更新发送状态。这样可以降低“数据库已经提交,但应用在发送消息前宕机”造成的丢事件风险。自动修复必须有明确入口,而不是让运维直接登录缓存执行命令。
修复任务应能根据业务主键重新读取数据库、重建缓存、记录修复前后版本,并支持重复执行。对于无法自动修复的记录,系统应提供人工审核列表,否则死信队列只是故障的存放处,不是恢复机制。最后要建立可观测指标:事件生产成功率、消费延迟、重试次数、死信数量、缓存重建次数、数据库与缓存差异数,以及端到端同步耗时。
我的经验是,缓存命中率只能反映性能,不能证明一致性;真正需要关注的是“有多少数据落后、落后多久、能否自动恢复”。


读者评论
文章把数据库、消息队列和缓存之间的责任边界讲得比较清楚,尤其是用数据库版本号判断新旧事件,比单纯依赖时间戳更符合分布式系统实际。
商品价格、库存和权限对一致性的要求确实不同,不能用同一套缓存策略处理。文中按业务损失划分优先级,这个思路对方案评估比较有参考价值。
Outbox、重试和对账机制都提到了,但实际落地时还需要补充监控指标和告警阈值,否则发现缓存落后后,仍可能主要依赖人工排查。
延迟双删和TTL被明确定位为风险降低或兜底手段,而不是一致性保证,这个判断比较客观。并发回填问题也提醒了开发者不能只关注删除是否成功。
文中的漏斗数据属于情景模拟,作者已经说明了数据来源,这一点较严谨。整体内容偏架构实践,适合用于梳理缓存同步方案,但具体实现仍需结合业务规模和容错要求。