Skip to content
Go back

最终一致性:系统为何暂时读到旧数据

用户更换头像,页面提示保存成功;他立刻拿起手机刷新,却仍看到旧头像。几秒后再刷一次,新头像才出现。

这类现象经常被当作缓存故障或复制延迟随手处理。它也可能是一项明确的系统选择:写请求先在一个节点完成,其他副本、缓存和读模型稍后更新。系统接受一段短暂的不一致,换取更低的写入延迟、更高的吞吐量,或者在部分节点失联时继续服务。

这项选择叫最终一致性。它能让系统更灵活,也会把一部分正确性责任交给应用和业务设计。

「最终」承诺结果,不承诺时间

最终一致性的准确含义可以压成一句话:在没有新写入的前提下,所有副本经过传播和冲突处理后会收敛到一致状态。

一次典型写入大致如下:

客户端        节点 A        节点 B        节点 C
  |  写入 X     |             |             |
  | ----------> |             |             |
  |  成功       |             |             |
  | <---------- |             |             |
  |             | --复制 X--> |             |
  |             | --------复制 X----------> |
  |             |             |  收敛到 X    |
  |             |             |             | 收敛到 X

成功响应可以早于全部副本完成复制。此时从节点 A 读取能看到 X,从 B 或 C 读取仍可能得到旧值。

「最终」没有给出固定时限。它可能是几毫秒、几秒,也可能因为积压、故障或修复任务持续更久。要向用户或下游系统承诺“10 秒内可见”,还需要额外的服务目标、超时、重试与监控;仅靠最终一致性的定义无法得到这个保证。

还要注意前提中的“没有新写入”。同一数据不断被并发修改时,系统必须有明确的版本顺序和冲突解决规则,否则“收敛”本身就没有唯一答案。

CAP 只在网络分区时逼你选择

CAP 经常被简化成“从一致性、可用性和分区容错里任选两个”。这句话容易让人误以为设计数据库时要永久放弃其中一项。

真正关键的时刻是网络分区发生之后。节点之间已经无法可靠通信,系统必须决定如何响应:

网络正常时,系统可以同时提供一致响应和可用服务。分区持续期间,两项强保证无法同时覆盖每个请求。实际系统还会按数据、区域、操作甚至单次请求选择不同策略,很少需要给整个产品永久贴上 CP 或 AP 标签。

当前产品能力也证明了这一点。DynamoDB 的表和本地二级索引默认使用最终一致读取,也允许请求强一致读取;全局二级索引和流只支持最终一致读取。DynamoDB Global Tables 现在还提供默认的多区域最终一致模式和可选的多区域强一致模式。

Cassandra 同样允许每次操作选择一致性级别。复制因子为 3 时,读写都使用 QUORUM 会让读写副本集合至少重叠一个节点;改用 ONELOCAL_ONE,通常能降低等待并提高可用性,但读到旧值的机会随之增加。

讨论一致性时,应该问“这次读写需要什么保证、失败时如何表现”,数据库名称只能提供初步线索。

你早已在使用最终一致性

最终一致性并不局限于大型数据库集群。常见系统里到处都有它:

判断一个页面是否受最终一致性影响,可以顺着数据读取路径问:写入成功后,下一次读取是否可能经过另一个副本、缓存、索引或异步投影?只要答案为“会”,应用就要处理短暂旧读。

四类故障最常见

最终一致性相关问题通常集中在四个模式里。

用户读不到刚写的内容

用户保存资料后被路由到读副本,副本尚未追上主库,于是页面像是“撤销”了刚才的修改。这会直接破坏用户对成功提示的信任。

汇总值落后于明细

订单已经创建,订单列表能看到新记录,总数和金额报表仍是旧值。单项数据正确,异步维护的聚合视图暂时滞后。

并发写入互相覆盖

两名用户基于同一个旧版本编辑资料。若系统只采用最后写入获胜,先提交的修改可能无声消失。依赖物理时钟的最后写入规则还会受到时钟偏差影响。

旧数据触发不可逆副作用

消费者根据已经过期的订单状态发送邮件、扣减库存或触发付款。数据稍后可以收敛,已经发送的通知和外部支付却无法自动消失。

这些现象具有可预测性。只要系统允许复制延迟、异步传播或重复消息,就应该在设计阶段为它们准备处理方式。

先定义每类数据需要的保证

一致性选择应该从业务不变量开始。可以先给每类读写回答四个问题:

  1. 旧数据最多能接受多久?毫秒、秒、分钟,还是完全不能接受?
  2. 用户是否必须立即读到自己的写入?
  3. 两次读取能否出现时间倒退,即第二次读到比第一次更旧的版本?
  4. 并发写入冲突时,可以自动合并、最后写入获胜、拒绝提交,还是必须人工决定?

很多业务不需要全局强一致,却需要比最终一致更具体的会话保证:

例如 Azure Cosmos DB 的会话一致性使用 session token 帮助 Web 应用跨节点读取自己的写入。这个中间层级常常足以修复用户体验,无需让所有用户、所有区域为全局强一致付出协调成本。

写入路径要同时处理重复、丢失与冲突

接受最终一致性后,应用至少需要覆盖下面几项。

让重试保持幂等

超时只表示客户端没有收到结果,无法证明服务端没有完成操作。客户端或消息系统重试时,同一命令可能被处理多次。

给创建、付款、扣库存等命令附带 idempotency key,并在业务状态旁记录已处理的键。同一个键再次到达时返回已有结果,避免重复产生副作用。消费者也应按消息 ID 去重,因为至少一次投递允许同一事件重复出现。

用版本检测并发冲突

为记录增加版本号、ETag 或序列号。更新时携带客户端读到的版本,并让存储层只在版本仍匹配时提交:

UPDATE profile
SET name = ..., version = 18
WHERE id = ... AND version = 17

影响行数为 0 表示数据已经被别人修改。此时可以重新读取并合并,也可以把冲突交给用户。静默覆盖通常是风险最高的默认行为。

原子保存状态和待发事件

业务数据提交成功、消息发布失败,会让下游永远不知道这次变化。先发消息再提交数据库,也可能让下游看到一项最终回滚的操作。

Transactional Outbox 把业务变化和待发事件写入同一个本地事务,由后台任务稍后发布。发布任务仍可能重复发送,因此下游必须保持幂等;它解决的是“状态已提交但事件丢失”,不会自动解决重复和乱序。

为跨服务失败设计补偿

订单、库存、付款分别拥有自己的数据库时,很难把它们放进一个普通 ACID 事务。Saga 把业务流程拆成一组本地事务,并为失败后的步骤定义补偿操作,例如释放库存或退款。

补偿也会失败,需要记录进度、允许安全重试,并为无法自动恢复的情况发出可追踪告警。退款等业务补偿还可能有费用和时限,不能简单理解为恢复数据库旧值。

用户界面要诚实表达中间状态

乐观更新可以让操作看起来即时完成:用户点击保存后,界面先显示新内容,再异步等待服务器确认。它适合失败概率低、容易撤销的操作。

界面仍需区分至少三种状态:

如果接口已经返回成功,而读模型仍可能滞后,可以在响应中返回刚写入的资源版本,让客户端暂时使用该版本;也可以把后续读取绑定到同一会话,提供 read-your-writes。仅靠刷新页面碰运气,会把复制细节直接暴露给用户。

关键不变量应留在强一致边界内

最终一致性适合动态流、搜索、推荐、缓存、分析和可重建投影。对以下约束要更谨慎:

常见设计是缩小强一致核心:用事务、条件写入或共识维护订单、余额、库存预留等权威状态,再异步生成列表、搜索索引、通知和统计。这样可以让展示层容忍延迟,同时保护真正不能破坏的业务规则。

没有可观测性,就不知道何时收敛

生产系统需要把“不一致窗口”变成可以测量的量。至少观察:

还应定期执行 reconciliation(核对与修复),比较权威数据和派生状态,补发遗漏事件或重建投影。传播流程只要可能永久失败,系统就需要一条主动发现差异的路径。

最终一致性提供的是收敛方向。可接受的时间、冲突结果、失败恢复和用户体验仍由系统设计决定。下一次准备把写入异步化时,先写下业务不变量、旧数据时限和用户必须获得的会话保证,再选择数据库参数、缓存策略和消息模式。

Aide Hub 会继续分享 AI 助手、开发工具和软件工程实践。

参考


Tags


Next

.NET 10 GC Handle:更安全的原生互操作