单调读与单调写
你刚在电商 App 里下了订单,状态显示「已支付」。你刷新了一下,状态变成了「待支付」。再刷新,又变回「已支付」。
这不是网络抖动,这是时光倒流(Time Travel)——读操作返回了比之前更旧的 值。
最终一致性系统里,这种现象是可能的。但对于用户体验来说,这种「时光倒流」是无法接受的。我们需要单调保证(Monotonic Guarantees)。
问题场景
在这个场景中,用户第一次读到了新值(x=2),第二次却读到了旧值(x=1)。这在最终一致性下是完全合法的——两个节点的数据同步需要时间,而用户的两次请求恰好打到了不同的节点。
但这违反了用户对「系统应该越来越新」的直觉预期。
四种单调保证
最终一致性之上的保证有四种,它们的强度关系如下:
1. 单调读(Monotonic Read)
如果一个进程读取到了值 V,则后续所有读取操作都不会返回 V 之前的值。
换句话说:进程看到的值序列是非递减的。
2. 单调写(Monotonic Write)
同一进程的写操作按其顺序执行,不会乱序。
3. 读你所写(Read Your Writes)
进程写入的值,后续读操作一定能读到。
4. 写跟读(Writes Follow Reads)
读到的值的来源,不会被后续写入覆盖。
保证之间的关系
这四种保证之间的关系:
读你所写可以推出单调读:如果进程能读到自己的写入,那么后续的读操作看到的值一定「不旧于」自己之前看到的值(因为自己的写入一定在之前看到的值之后)。
但读你所写不能推出单调写(写操作之间没有保证),单调写也不能推出读你所写。
实现方式
基于版本号
最简单的实现方式是全局版本号:
基于会话标记
DynamoDB 使用会话标记(Session Token)实现读你所写:
Cassandra 的单调读实现
Cassandra 通过追踪协调节点实现单调读:
与其他一致性级别的对比
权衡矩阵
常见误区
单调读只保证不会看到比之前更旧的值,不保证一定能看到最新值。如果某个节点数据落后,系统可能路由你到其他节点,但如果所有节点都落后,你仍然只能读到旧值。
读你所写是会话级别的保证。如果用户退出登录再登录,之前的写入可能已经不可见了(取决于系统如何处理会话终止)。如果需要跨会话的保证,需要因果一致性或更强的级别。
它们不是独立的单调保证,而是一个强度递进的体系。读你所写可以推出单调读(你写的值一定在你的读之后),写跟读是读你所写的「反向」保证(读的来源不会被后续写入覆盖)。
真实案例
真实案例:某社交平台的评论顺序事故
- 现象:用户 A 发了评论 A1,然后看到评论 A1 存在。用户 B 刷新后,评论 A1 消失了。
- 原因:用户 A 的写请求路由到了节点 X,用户 B 的读请求路由到了节点 Y。节点 X 已经同步了评论 A1,但节点 Y 还没来得及同步。
- 解决方案:对评论列表的读取实现单调读,当发现版本回退时,自动重试到正确节点或等待同步完成
- 教训:社交类场景的「时间线」展示特别需要单调读,否则用户会困惑「为什么我的评论/动态消失了」
术语表
延伸思考
单调读、单调写、读你所写、写跟读,这四种保证是最终一致性和强一致性之间的「中间地带」。它们的实现代价比因果一致性低,但已经能够解决大多数用户体验问题。
但问题来了:在实际系统中,我们怎么知道应该用哪种保证?
这就需要系统性地对比所有一致性级别,理解它们的强度关系、实现代价、适用场景。这就是最后一节「一致性级别对比矩阵」要解决的问题。
当你掌握了完整的一致性光谱,你就能根据业务需求做出明智的选型:什么时候可以用最终一致性,什么时候必须上线性一致性,什么时候因果一致性是恰到好处的选择。