一致性模型规定客户端允许观察到哪些执行历史。讨论“读到新数据”之前,先明确对象、操作、客户端顺序与完成时间;讨论“可用”之前,先明确是在说数学模型里的终止性质,还是实际服务的成功率与延迟目标。
CAP:冲突发生在特定故障模型与保证之间
CAP 的 C 通常指原子读写寄存器的线性一致性;A 要求到达非故障节点的操作最终完成;P 表示模型允许节点之间的消息被分区阻断。A 没有统一的毫秒延迟上界,也不是随便返回一个错误页面就满足服务操作的完成契约。
若允许任意长的网络分区,就不能同时保证这样的 C 与 A。它不是让架构师任选两个字母,也不是说所有真实网络必然在某一天发生某种固定形式的分区。Gilbert 与 Lynch:CAP 的理论与实践视角
在 x 初始为 0、没有其他写入的前提下,A 上写 1 已成功,随后 B 收到读请求。B 无法通过消息区分“对面完成写 1”和“对面根本没写”两个世界,却要作出符合历史的回答。一直等也无法在永不恢复的分区中满足终止要求。
系统可以在多数侧继续完成某些强一致操作,在少数侧拒绝或等待;也可以对特定业务允许本地更新并在恢复后合并。CAP 并不要求整个产品在所有接口上永久选择同一个字母标签。
线性一致性:操作像在调用与返回之间瞬间生效
对一个对象的操作历史,若能把每个已完成操作放在自己的调用与返回之间的某一点,并得到符合对象规范的顺序执行,就具有线性一致性。若 A 已返回后 B 才调用,A 必须排在 B 之前;时间重叠的操作则可以按任一合法顺序排列。
例如 write(x=1) 返回后才调用 read(x),且没有其他写入,则读必须返回 1。如果读与写重叠,返回旧值 0 或新值 1 都可能合法,取决于线性化点的位置。因此“读永远返回墙上时钟意义的最新值”不够准确。
线性一致性是可观察行为约束,不规定一定用 leader、一定经过某个网络往返,也不要求所有物理副本在同一瞬间完成应用。实现可以通过不同复制和读取协议提供该行为。
顺序一致性:保留各客户端顺序,不额外保留跨客户端实时先后
顺序一致性要求存在一个符合对象语义、保留每个进程自身操作顺序的全局顺序,但不要求保留不同客户端之间仅由实时完成关系产生的先后。
例如客户端 A 依次写 x=1、x=2;客户端 B 随后在现实时间里读取到 1。若只看这段历史,它可能被解释为逻辑顺序“写 1、读 1、写 2”,从而满足顺序一致性,却违反第二次写已完成后读取仍旧的线性一致性要求。
这不是现实中的消息能穿越时间,也不意味着任意异步副本读取都满足顺序一致性。若同一个客户端先读到 2,再读到 1,而没有其他写入,就无法用保留其读顺序的上述单调写历史解释。
把定义变成一个小型历史检查器
为一个初始为 0 的寄存器记录四项信息:客户端、操作类型、写入值或读取结果、调用与返回的事件位置。这里的位置来自同一条已知事件序列,不是拿不同机器未经校正的墙钟时间直接排序。客户端一次只有一个未完成操作,所有记录都有成功返回。
检查时先排列操作,再做两道筛选:顺序是否满足必须保留的先后关系;按这个顺序执行时,每次读是否等于寄存器当前值。顺序一致性只加入同客户端的先后约束,线性一致性还加入所有跨客户端的不重叠约束。
from dataclasses import dataclass
from itertools import permutations
@dataclass(frozen=True)
class Op:
client: str
kind: str
value: int
start: int
end: int
def witness(history, linear=True):
assert all(o.kind in ("W", "R") and o.start < o.end for o in history)
for i, a in enumerate(history):
for b in history[i + 1:]:
if a.client == b.client:
assert a.end < b.start or b.end < a.start
edges = [(i, j) for i, a in enumerate(history)
for j, b in enumerate(history)
if a.end < b.start and (linear or a.client == b.client)]
for order in permutations(range(len(history))):
position = {op: rank for rank, op in enumerate(order)}
if any(position[i] >= position[j] for i, j in edges):
continue
value = 0
for i in order:
op = history[i]
if op.kind == "W":
value = op.value
elif op.value != value:
break
else:
return order
return None
stale = [Op("A", "W", 1, 0, 1), Op("A", "W", 2, 2, 3),
Op("B", "R", 1, 4, 5)]
assert witness(stale, linear=False) == (0, 2, 1)
assert witness(stale, linear=True) is None
for result in (0, 1):
overlap = [Op("A", "W", 1, 0, 5), Op("B", "R", result, 2, 3)]
assert witness(overlap) is not None
assert witness([Op("A", "W", 1, 0, 5), Op("B", "R", 2, 2, 3)]) is None
backward = stale[:2] + [Op("B", "R", 2, 4, 5), Op("B", "R", 1, 6, 7)]
assert witness(backward, linear=False) is None
assert witness([]) == ()
第一组返回 (0, 2, 1),就是把 B 的读放在 A 的两次写之间。它保留 A 的程序顺序,却越过了现实中第二次写已经返回的事实。最后一组即使允许这样调整,也无解:读 2 要排在写 2 后,之后的读 1 又要求回到写 2 前,约束形成了环。
这个检查器的排列数按阶乘增长,只用于理解几个操作的小历史。存在一个见证就足以说明这段完整历史满足所选约束;检查通过并不能证明实现的所有执行都正确。真实测试还需要处理超时后结果未知、尚未返回的操作、对象规范与历史采集误差。尤其不能把超时的写直接从历史中删掉,因为它可能已经生效。Herlihy 与 Wing:Linearizability
同一段历史,哪些顺序还能成立?
可以把“是否一致”改写成一个具体问题:有没有一种操作排列,同时满足先后约束和寄存器读写规则?下面保留 A 的两次串行写,允许修改 B 的读区间与返回值。顺序一致性的见证是对历史的合法解释,不是将真实事件搬回过去;切换约束时,实际时间条保持不变。
正在呈现知识画面
同一段历史,哪些顺序还能成立?线性一致性保留不重叠操作的实时先后;顺序一致性只强制保留各客户端的程序顺序。合法顺序中的读取必须等于此前最后一次写入。
{"id":"consistency-history","title":"同一段历史,哪些顺序还能成立?","summary":"线性一致性保留不重叠操作的实时先后;顺序一致性只强制保留各客户端的程序顺序。合法顺序中的读取必须等于此前最后一次写入。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"linear\" data-i18n=\"linear\"></button><button data-mode=\"seq\" data-i18n=\"sequential\"></button></div><div class=\"control\"><label for=\"start\" data-i18n=\"start\"></label><output for=\"start\" id=\"start-value\"></output><input id=\"start\" type=\"range\" min=\"0\" max=\"7\" value=\"6\" step=\"1\"></div><div class=\"control\"><label for=\"result\" data-i18n=\"result\"></label><output for=\"result\" id=\"result-value\"></output><input id=\"result\" type=\"range\" min=\"0\" max=\"2\" value=\"1\" step=\"1\"></div><div id=\"timeline\" class=\"plot\"></div><div class=\"axis\"><span>0</span><span>9</span></div><p class=\"hint\" data-i18n=\"witness\"></p><div id=\"order\" class=\"track\"></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"valid\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"invalid\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.plot-row{height:42px}.plot-row b{line-height:42px}.plot-row i{inset-block:6px}.track{min-height:62px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mode='linear';const orders=[[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]];function draw(){const start=+$('#start').value,result=+$('#result').value;set('start-value',start);set('result-value',result);const ops=[{c:'A',k:'W',v:1,s:0,e:0.8},{c:'A',k:'W',v:2,s:3,e:3.8},{c:'B',k:'R',v:result,s:start,e:start+0.8}];$('#timeline').replaceChildren(...ops.map(o=>{const row=document.createElement('div');row.className='plot-row';const bar=document.createElement('i');bar.style.left=o.s/9*100+'%';bar.style.width=(o.e-o.s)/9*100+'%';const lab=document.createElement('b');lab.textContent=o.c+' '+o.k+'('+o.v+') ['+o.s+','+o.e+']';row.append(bar,lab);return row;}));const witness=orders.find(order=>{for(let i=0;i<3;i++)for(let j=0;j<3;j++)if(ops[i].e<ops[j].s&&(mode==='linear'||ops[i].c===ops[j].c)&&order.indexOf(i)>order.indexOf(j))return false;let x=0;for(const i of order){if(ops[i].k==='W')x=ops[i].v;else if(x!==ops[i].v)return false;}return true;});cells('order',witness?witness.map(i=>({text:ops[i].c+' '+ops[i].k+'('+ops[i].v+')',cls:'on'})):[{text:t('none'),cls:'empty'}]);set('explanation',t(witness?'valid':'invalid'));$('#order').dataset.valid=String(!!witness);pressed(mode);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;draw();});draw();","audio":false,"strings":{"heading":"同一段历史,哪些顺序还能成立?","intro":"调整 B 的读取时间和结果。横条是调用到返回的区间;下方寻找符合所选约束的顺序。","invalid":"没有合法顺序:实时先后或读值与写入顺序冲突。","linear":"线性一致性","mode":"历史约束","none":"无解","reset":"重置","result":"B 返回的值","scope":"初始 x=0;A 串行写 1、写 2,B 只有一次读。所有操作已成功完成。穷举这三个操作不证明实现的所有执行都正确。","scopeLabel":"模型范围与约定","sequential":"顺序一致性","start":"B 开始读取的事件位置","valid":"存在合法顺序;这段完整历史满足所选约束。","witness":"合法顺序见证"}}
因果一致性:先看到依赖,再看到依赖它的结果
A 发布文章,B 读到文章后回复;回复依赖文章。观察者若能看到回复,系统应满足相应因果可见性要求,避免只看到回复却看不到它所依赖的文章版本。彼此没有因果关系的并发操作,不一定需要全体观察者采用同一顺序。
因果关系来自进程顺序、读到某次写入等依赖及其传递闭包。是否把跨服务消息或用户会话切换纳入因果上下文,是 API 与实现契约的一部分。单独附带 Lamport 时间戳或使用版本向量,不会自动形成完整的因果一致读取服务。
会话保证:先保护一个用户的连续体验
读己之写要求会话后续读取不遗漏自己已经完成的相关写入;单调读要求会话观察到的状态不要退回更旧历史;其他会话保证还会约束连续写入和读后写入的传播顺序。
这些保证有明确作用域。让一个浏览器会话读己之写,并不意味着所有其他用户都立即看到其修改。会话迁移到另一个节点时,可能需要携带进度 token 并等待目标追赶。
最终一致性:描述收敛,不承诺中间过程
在更新停止、必要信息能够继续传播并完成合并等条件下,副本最终收敛。单独这个承诺不规定多久收敛,也不规定此前读取是否倒退、并发如何取舍或业务更新是否会被覆盖。
最终一致性可以和会话保证、因果保证一起提供,不应把所有模型机械排成单一强弱刻度。是否采用 LWW、CRDT 或应用合并,也需要额外说明;参见冲突解决。
事务隔离与对象一致性是不同问题
Serializable(可串行化)要求多个事务的效果等价于某个串行事务顺序。Strict serializability 进一步要求该顺序尊重实时先后。单 key 操作线性一致,不会自动把跨 key 的“先读余额、再转账”组成一个原子事务。
同样,一个 API 选项恰好叫 serializable,也应阅读其产品定义。例如 etcd 的 serializable 读取选项允许陈旧读取,不能直接按 SQL 隔离级别的名字推断。
每个操作都线性一致,仍可能丢失一次递增
设两个客户端都要把计数器加 1,却用独立的 read、本地加法和 write 实现。服务完全可以按下表顺序执行所有单次操作,且每次返回都满足寄存器规范。
| 顺序 | 操作 | 返回或效果 |
| 1 | A 读取 | 读到 0,算出待写入 1 |
| 2 | B 读取 | 也读到 0,算出待写入 1 |
| 3 | A 写入 1 | 计数器变为 1 |
| 4 | B 写入 1 | 计数器仍为 1 |
这里没有违反单次读写的线性一致性,但两次业务递增只留下了 1。解决方法是把递增本身做成一个原子操作,或者使用带版本条件的 CAS/事务并在冲突后重新读取计算。给普通写增加重试次数不会把它自动变成原子递增。
如果使用 CAS,A、B 初次都携带版本 v0;A 成功后版本变成 v1,B 的 v0 条件必须失败。B 再读取值 1 和 v1,重算得到 2,再尝试提交。这也说明检查对象要与业务承诺对齐:只验证底层寄存器历史,还没有验证调用者拼成的复合操作。
速查与工程权衡
| 保证 | 核心约束 | 单独不保证 |
| 线性一致性 | 对象操作尊重实时的合法顺序 | 多对象业务事务自动原子化 |
| 顺序一致性 | 一个全局顺序保留进程顺序 | 跨进程实时顺序 |
| 因果一致性 | 保留因果依赖的可见顺序 | 并发更新的统一全序 |
| 读己之写 | 会话能观察自身写入 | 所有用户同时看到该写入 |
| 最终一致性 | 满足传播条件后收敛 | 有界延迟和中间读取行为 |
PACELC 提醒我们还要看无分区时的延迟与一致性选择,但不是“所有强一致操作固定多 1—2 RTT”的定律。地域布局、租约、缓存、批处理与读取路径都会改变实际代价。
评审时最好写出具体历史:哪个写已经成功,哪个读何时开始,故障发生在哪些连接,允许返回什么。它比给整个数据库贴 CP 或 AP 标签更能指导接口设计。
参考
Keywords: CAP, PACELC, linearizability, sequential consistency, causal consistency, eventual consistency, serializability