34 共享状态是不正确的状态
识别共享可变数据造成的竞态窗口,用所有权、同步边界或不可变消息保护关键不变量。
学习目标
- 能解释两个执行者为何会把同一份数据读成不同结果,并指出第一个失真的交错点
- 能修改一段共享数据代码,选择所有权、同步边界或不可变消息来保护关键不变量
- 能回答:发生一次并发故障后,怎样从原始输入重放而不是手工修正最后结果?
为什么共享状态会翻车
想象一张放在柜台上的订单草稿。两个人同时看到“还剩一份”,都把自己的名字写上去;最后一笔覆盖了另一笔,纸面上却没有留下谁先写、谁被覆盖的证据。
软件也会遇到同样的问题:读取、等待和写回不是一个瞬间。没有明确的责任边界时,晴天测试可能全部通过,压力一上来就出现少扣库存、重复发货或把旧结果当成新结果。
这一章的目标不是把所有并发都锁起来,而是让每一次跨边界的读写都能回答三个问题:谁可以改它,什么时候改,失败后从哪里重放。
常见误区与回退
先把五个角色摆上桌
<Term def="多个执行者都能读取或改变、且生命周期跨过一次调用的数据。">共享状态</Term>不是“用了同一个类型”这么简单,而是多个执行者都能观察或改变同一个事实。它可以是内存里的余额、数据库里的一行,也可以是队列中的确认标记。
两个执行者从同一份快照出发,就会形成<Term def="从读取到写回之间,另一个执行者可以插入并改变前提的时间空档。">竞争窗口</Term>。窗口越长,越容易出现“都以为自己是最后一个写入者”的交错;所以要把读、判断、写回和提交的边界画出来。
解决方案先问谁能改,而不是先问哪一个库更快。<Term def="某个对象或执行者在一段时间内独占修改权,并承担交接、失败和恢复责任的约定。">所有权</Term>可以让状态只由一个执行者更新;若必须多人访问,就要再定义同步边界。
<Term def="让一段读取—判断—更新的操作在观察者看来要么全部发生,要么完全不发生的边界。">临界区</Term>是同步的一种具体表达。它不等于“整段程序都加锁”:范围应刚好覆盖不变量需要保护的读写,过大造成等待,过小仍会留下窗口。
另一条路线是传递<Term def="创建后不再原地修改、可以安全交给下一个拥有者处理的一份消息或快照。">不可变消息</Term>,让接收者处理自己的副本。无论选择哪条路线,都要用<Term def="在所有允许的交错和失败路径上都必须成立的事实,例如扣款后余额不能低于零。">不变量</Term>验收,而不是只看接口是否返回成功。
图中从共享事实到不变量的每一条边,都要注明传递的是快照、修改权还是提交确认。缺少其中任一责任说明,图只是流程装饰,不能帮助定位首个错误。
竞争窗口怎样制造错误
下面的示例只让两个提款请求共享一个余额;await 代表一次可能被调度切走的等待。问题不在异步语法本身,而在两个请求都可以拿着同一份旧快照继续判断。
let balance = 100;
async function withdraw(amount: number) {
const observed = balance;
await waitForBank();
if (observed >= amount) balance = observed - amount;
}如果两个请求都观察到 100,并各自申请 70,最终余额可能是 30,而系统却已经发出了两次成功确认。首个差异发生在第二个请求仍使用旧快照的时刻;把最后余额改成 0 不能证明两次扣款都被正确记录。
提示57:共享状态是不正确的状态
“共享状态是不正确的状态”要求我们把共享的修改权当成风险边界,而不是把偶发失败归咎于运气。对余额、库存或版本号这类事实,必须先写出允许的交错,再选择一个明确拥有者、临界区或消息接收者;如果无法说清谁能写回,就还没有完成设计。
验收时固定输入、请求顺序和观察点,只改变一种隔离方案。正常样本要证明不变量成立,边界样本要证明拒绝条件生效,故障样本要保存首差和恢复动作;三种记录缺一不可。
提示58:随机故障通常是并发问题
“随机故障通常是并发问题”不是说每个偶发错误都要加锁,而是提醒我们把调度交错纳入故障证据。若一次失败只在压力上升时出现,先记录哪个执行者读到了哪个版本,再缩小竞争窗口;没有版本、时间和输入身份的“偶尔成功”不能作为修复证明。
重放时从原始输入重新建立状态,先让错误候选停在第一个不变量破坏处,再验证所有权转移、临界区或不可变消息是否真的改变了交错。若恢复依赖手工改数据库,说明观测点或回退合同仍不完整。
三步实验:从共享读写到隔离边界
先预测:把“并发读取”切换成“所有权转移”后,哪一个节点会从竞争窗口变成单一写入?再逐步观察图示中的读取、修改权、提交和不变量;每一步都保留一个能被别人重放的判断。
1. 标出共享事实与旧快照
先写清对象、版本和两个请求各自读取的值。若两个请求都拥有写回权,图中必须显式标出竞争窗口;不能用“通常按顺序执行”替代证据。
可重置实验:选择隔离方式
猜一猜:把“并发读取”切换成“不可变消息”,最终结果会改变,还是只会改变谁负责确认?实验只显示状态边界和验收结论,不把正确性压成一个分数。
隔离方式实验台
只改变修改权边界,保持输入、版本和观察点不变。
两个请求都拿着旧快照写回,候选被拒绝。
实验记录应至少包含:原始输入、状态版本、隔离方式、首个差异、拒绝原因和重放动作。点击“重置”后,首态必须回到两个请求都能读取旧快照的基线,方便比较每一次改变。
怎样选择边界
| 场景 | 首选边界 | 必须验证 | 不要做什么 |
|---|---|---|---|
| 一个明确的写入者 | 所有权转移 | 交接时刻与旧拥有者失权 | 让旧拥有者继续写回 |
| 多个短操作需要同一份事实 | 临界区或事务边界 | 保护范围、超时和释放 | 把锁扩大到无关网络调用 |
| 跨服务传递结果 | 不可变消息 | 版本、幂等键与拒绝条件 | 共享可变对象引用 |
边界的大小应该由不变量决定。只锁住判断而不锁住更新,仍然会留下竞争窗口;把整个请求都锁住,则可能让慢网络调用阻塞所有写入。任何方案都应能说明“谁观察、谁修改、谁提交、谁在失败时重放”。
只改变一个条件
正常样本使用版本 7、余额 100 和两个 70 的请求;边界样本让第二个请求携带版本 6;失败记录只移除提交前的版本检查。若失败记录仍显示成功,先停止后续副作用,保存首差,再检查观察点是否真的位于提交边界。
本章回顾
- 共享状态要先写清谁能读取、谁能修改,以及状态版本。
- 竞争窗口发生在旧快照仍可用于写回的时间空档。
- 所有权、临界区和不可变消息是不同的隔离边界,不能混为一谈。
- 先记录首差并停止副作用,再从原始输入重放和验收不变量。
可验证练习
练习
问题 1:找出首个差异。 两个提款请求都读到余额 100,各自申请 70;请求 A 先等待,请求 B 先写回 30,然后 A 仍用旧快照写回 30。请指出首个破坏不变量的节点,并写出需要保存的两项证据。
问题 2:修改 Demo 代码。 改写 withdraw,让两个请求必须携带版本并在提交时拒绝旧版本;要求说明拒绝后如何从原始输入重放。
async function withdraw(amount: number, expectedVersion: number) {
const snapshot = await readBalance();
if (snapshot.version !== expectedVersion) return { ok: false };
return commit(snapshot.version, snapshot.balance - amount);
}问题 3:做场景选型。 一个服务要把库存变化通知三个下游;下游处理速度不同且可能重复收到通知。你会共享可变对象、让单一拥有者更新,还是发送不可变消息?写出一个拒绝条件。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 共享状态
多个执行者都能看到或改变的同一个事实,例如余额、库存或版本号。
- 竞争窗口
从读取到写回之间可以插入另一个执行者的那段空档。
- 所有权
一段时间内谁拥有修改权,也承担交接、失败和恢复责任的约定。
- 临界区
读、判断和更新必须作为一个整体观察的最小保护范围。
- 不可变消息
创建后不再原地修改、可以带着版本交给下一个处理者的快照。
- 不变量
所有允许的交错和失败路径都必须成立的事实,例如余额不能被扣成负数。
来源与改写范围
本章依据公开目录与版本页面独立重写;共享状态模型、代码、双图示、交互实验和练习均为本课程重新设计,不复制原书正文、插图或答案。