分布式存储不是把一致性哈希、Paxos、Gossip 等名词放在同一张图上。每种机制都有明确职责,系统的保证来自它们如何配合。本文比较 2007 年 Dynamo 与 2012 年 Spanner 论文的设计问题,理解它们为什么选择不同组合。
这里的 Dynamo 指论文中的内部系统,不等同于当前 DynamoDB 产品;Spanner 的论文机制也不代替当前服务版本和隔离级别文档。先读 分布式读写路径,可以更容易区分下文的复制与事务层。
从业务不变量开始比较
设有两类业务:购物车希望部分节点故障时仍能接受编辑,账户转账希望扣款与入账共同成立。第一类可能接受冲突后合并,第二类通常不能接受“先都成功,以后再决定钱从哪里来”。
这只是用于理解的业务模型,不意味着某个产品只能承载其中一类。真正的问题是:哪些操作可以独立接收,哪些操作需要在确认前协调;出现冲突后,谁有能力给出正确合并规则。
Dynamo:把并发版本留给后续协调
论文中的 Dynamo 使用 key-value 接口,通过分区与副本选择分散数据,读写等待可配置数量的响应;节点暂不可达时可以借助临时承接与提示回放。多个副本可能同时接受更新,因此对象版本和应用辅助冲突解决是架构的一部分。Dynamo 原论文 是这些机制的依据。
| 层次 | 需要回答的问题 | 机制职责 |
| 分区 | key 应由哪些节点负责 | 一致性哈希与副本放置 |
| 读写 | 哪些响应足以完成当前请求 | N、R、W 与可达节点选择 |
| 版本 | 两次更新是先后还是并发 | 因果元数据识别版本关系 |
| 冲突 | 并发值应该如何合并 | 存储规则与业务规则协作 |
| 恢复 | 临时差异怎样消除 | handoff、反熵与数据修复 |
向量时钟不是一个可以对所有版本排序的整数。若版本 A 覆盖 B,可以认为 B 的历史已被包含;若 A、B 并发,就必须保留冲突或执行合并。将“比较向量时钟”简写成“取最大的最新版”,会漏掉这套设计最重要的分支。
购物车合并可以举一个简单例子:客户端甲新增一本书,客户端乙新增一支笔,并集能保留两次新增;若乙的操作是删除书,并集却可能恢复被删条目。业务若要求删除优先,需要保留删除与因果信息,而不是只合并当前集合。版本检测不等于业务冲突已经解决。
“高可用”仍受可达节点数、存储容量、故障域和恢复条件限制。所有候选节点都不可达时,没有机制能凭空存储数据。无主也不代表运维简单,修复积压与冲突状态同样需要管理。
合并值以后,还要合并“看过哪些版本”的上下文
初始购物车有书,版本向量为 (A=1,B=0)。两个客户端都从这个版本出发:经 A 处理的更新增加笔,成为 (2,0);经 B 处理的更新删除书,成为 (1,1)。一个在 A 分量更大,一个在 B 分量更大,互不覆盖。
def dominates(a, b):
return all(x >= y for x, y in zip(a, b)) and any(x > y for x, y in zip(a, b))
base = (1, 0)
left, right = (2, 0), (1, 1)
assert dominates(left, base) and dominates(right, base)
assert not dominates(left, right) and not dominates(right, left)
left_cart, right_cart = {"book", "pen"}, set()
assert left_cart | right_cart == {"book", "pen"}
context = tuple(max(x, y) for x, y in zip(left, right))
merged_write = (context[0] + 1, context[1]) assert merged_write == (3, 1)
assert dominates(merged_write, left) and dominates(merged_write, right)
wrong_context_write = (left[0] + 1, left[1])
assert not dominates(wrong_context_write, right)
assert not dominates(right, wrong_context_write)
模型固定 A、B 两个分量,并假设协调节点为新写增加自己的计数,没有涉及向量截断或节点替换。若业务把合并后的值写回,却只携带左分支上下文,新版本 (3,0) 仍与右分支 (1,1) 并发,下一次读仍可能得到 sibling。合并值与合并因果上下文因此是两项工作。
这里没有替业务决定书应不应该存在。要实现删除优先,需要保留操作或删除的因果信息,并制定相应规则;把向量合并正确不等于把购物车合并正确。Dynamo 原论文第 4.1、4.4 节把读取的 context 带回后续 put,正是为了表达更新依赖的版本历史。接口与版本机制原文
Spanner:复制、事务与时间分别负责什么
Spanner 的关键在于区分三种协调:副本间确定持久操作顺序,多个数据分片共同决定事务结果,以及将事务顺序与外部观察到的时间顺序联系起来。经典设计使用 Paxos、两阶段提交和 TrueTime,各自解决不同问题。
| 机制 | 保证目标 | 单独不能替代什么 |
| Paxos 复制 | 某个复制组内的决定在故障后保留 | 不自动提交跨组事务 |
| 两阶段提交 | 参与组对提交/中止形成一致结果 | 不独立保证全局实时顺序 |
| 并发控制与 MVCC | 定义冲突、快照和事务可见性 | 不替代复制持久性 |
| TrueTime 与 commit wait | 约束提交时间戳与真实时间关系 | 不替代锁、日志或事务协调 |
经典跨组写事务中,参与组先准备并持久化必要状态,协调者确定提交决定和时间戳;事务还需要满足提交等待条件。日志复制与等待可以有重叠,不能机械地把所有阶段当成严格串行的固定 RTT 总和。详细协议见 Spanner 论文。
TrueTime 的关键是显式不确定区间
普通时钟 API 给出一个数,容易让调用者假装它就是精确真实时间。TrueTime 返回包含真实时间的不确定区间 [earliest, latest]。区间宽度可变化,某次测得几毫秒不意味着所有环境都有固定误差常数。
若提交时间戳为 s,只选择“足够大的时间戳”还不够。返回成功前需要等待到可以确定真实时间已经越过 s,例如满足 TT.now().earliest > s。这样,当另一个事务在观察到本事务完成之后再开始,其时间戳选择才可以与先前完成顺序相容。
把时间戳选到未来却立即返回,会留下问题:另一个节点可能随后开始操作,但它的时间区间仍落在先前时间戳之前。commit wait 正是在消除这个不确定窗口。区间越宽,可能需要等得越久;时钟基础设施因此同时影响正确性前提与延迟。
±5 ms 的不确定性,为什么可能需要越过 10 ms
做一个有意简化的算例:真实时间为 t,某节点始终返回 [t−5,t+5],单位为毫秒。在 t=100 时取提交时间戳 s=105,并假设参与者准备时间等其他约束不要求更大的 s。等待条件是 t−5>105,因此必须等到 t>110;不是从 100 再等 5 ms 就够了。
def tt(real_time, epsilon):
return real_time - epsilon, real_time + epsilon
s = tt(100, 5)[1]
assert s == 105
assert not tt(105, 5)[0] > s
assert not tt(110, 5)[0] > s assert tt(111, 5)[0] > s
s2_without_wait = tt(101, 3)[1]
assert s2_without_wait == 104 < s
s2_after_wait = tt(112, 3)[1]
assert s < 111 < 112 <= s2_after_wait
第二个节点的区间 [98,104] 同样包含真实时间 101,因而“各节点误差都有界”本身并不防止 105、104 这样的反序。提交等待建立的是一条可推导的链:T1 的时间戳早于 T1 返回时的真实时间;T2 在返回之后才开始,所以它随后选择的合法提交时间戳也必须更晚。
| 阶段 | 本例时间 | 满足了什么 |
| 选择 s | t=100,s=105 | 取当时区间上界 |
| 假设复制完成 | t=108 | 复制已就绪,earliest=103,时间条件仍未满足 |
| 检查等待条件 | t=110 | earliest=s,仍需等待 |
| 整数毫秒采样下再次检查 | t=111 | earliest=106>s,可以通过时间检查 |
若复制从 t=100 开始、t=108 完成,在这个整数采样模型中只需再等 3 ms,通过两项条件的总时间为 11 ms,不能把复制的 8 ms 和整段时间等待的 11 ms 相加。它也不是实际 Spanner 延迟预测:真实区间会变化,时间戳可能受其他约束抬高,还存在锁、协调和应用等阶段。论文说明了 commit wait 与 Paxos 通信可重叠的设计。Spanner 提交协议
区间上界选好以后,为什么还要等?
把“从选时间戳开始等多久”和“复制完成后还要等多久”放在同一时间轴上。下图在 t=100 取 s=100+ε,earliest=t−ε,因此时间条件要求 Δt>2ε。整数毫秒采样下首个满足时刻是 2ε+1 ms;它与复制耗时取较大值,而不是相加。这个等式来自固定对称误差模型,不能当作真实系统的固定延迟。
Δtreturn=max(Δtreplication,2ε+1ms)
正在呈现知识画面
区间上界选好以后,为什么还要等?提交时间戳确定后,需要等到 earliest>s;复制完成与时间等待可重叠,返回时两项都要满足。
{"id":"truetime-commit-wait","title":"区间上界选好以后,为什么还要等?","summary":"提交时间戳确定后,需要等到 earliest>s;复制完成与时间等待可重叠,返回时两项都要满足。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"error\" data-i18n=\"error\"></label><output for=\"error\" id=\"error-value\"></output><input id=\"error\" type=\"range\" min=\"1\" max=\"10\" value=\"5\" step=\"1\"></div><div class=\"control\"><label for=\"elapsed\" data-i18n=\"elapsed\"></label><output for=\"elapsed\" id=\"elapsed-value\"></output><input id=\"elapsed\" type=\"range\" min=\"0\" max=\"30\" value=\"8\" step=\"1\"></div><div class=\"control\"><label for=\"replica\" data-i18n=\"replica\"></label><output for=\"replica\" id=\"replica-value\"></output><input id=\"replica\" type=\"range\" min=\"0\" max=\"25\" value=\"8\" step=\"1\"></div><div id=\"timeplot\" class=\"plot\"><div class=\"plot-row\"><i id=\"interval\"></i><b id=\"bounds\"></b></div><div class=\"plot-row\"><i id=\"stampmark\"></i><b id=\"stamptext\"></b></div></div><div class=\"axis\"><span>90 ms</span><span>140 ms</span></div><div class=\"metrics\"><div><span data-i18n=\"earliest\"></span><strong id=\"earliest\"></strong></div><div><span data-i18n=\"stamp\"></span><strong id=\"stamp\"></strong></div><div><span data-i18n=\"response\"></span><strong id=\"response\"></strong></div></div><div id=\"gates\" class=\"track\"></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"ready\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"wait\"></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.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.metrics span{min-height:5em}.plot-row{height:40px}.plot-row b{line-height:40px}","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)));}function draw(){const e=+$('#error').value,dt=+$('#elapsed').value,r=+$('#replica').value,time=100+dt,s=100+e,early=time-e;for(const k of ['error','elapsed','replica'])set(k+'-value',$('#'+k).value);set('earliest',early);set('stamp',s);set('response',100+Math.max(2*e+1,r));set('bounds','['+early+', '+(time+e)+']');set('stamptext','s = '+s);$('#interval').style.left=(early-90)/50*100+'%';$('#interval').style.width=2*e/50*100+'%';$('#stampmark').style.left=(s-90)/50*100+'%';$('#stampmark').style.width='2px';cells('gates',[{text:'earliest > s: '+(early>s?'✓':'…'),cls:early>s?'on':'empty'},{text:'replication: '+(dt>=r?'✓':'…'),cls:dt>=r?'on':'empty'}]);const ready=early>s&&dt>=r;set('explanation',t(ready?'ready':'wait'));$('#gates').dataset.ready=String(ready);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"earliest":"当前 earliest","elapsed":"经过时间 Δt / ms","error":"不确定性半宽 ε / ms","heading":"区间上界选好以后,为什么还要等?","intro":"在 t=100 ms 取 s=100+ε。拖动经过时间,观察 earliest 是否严格越过 s,以及复制是否已经完成。","ready":"复制与时间条件均满足,可以通过这两道门槛。","replica":"复制完成需时 / ms","reset":"重置","response":"最早整数采样返回时间","scope":"固定对称区间 [t−ε,t+ε],s 仅由初始上界决定;整数毫秒采样。假设其他事务条件已满足。不是实际 Spanner 延迟预测。","scopeLabel":"模型范围与约定","stamp":"固定提交时间 s","wait":"仍有条件未满足;earliest=s 时也必须继续等待。"}}
外部一致性与快照读
外部一致性要求事务表现为一个串行执行,并尊重外界已经观察到的先后关系。若 T1 完成后 T2 才开始,不能让读者观察到等价于 T2 在 T1 之前的事务顺序。并发重叠事务则由系统确定一个合法顺序。
MVCC 允许选择历史时间戳读取一致快照。历史读返回旧状态是其明确语义,不等于副本随机滞后造成的不一致;强读与历史读也不能混为一个承诺。当前 Spanner 的外部一致性说明与隔离级别适用范围见 TrueTime and External Consistency。
如何比较,而不把系统压成两列标签
与其写“Dynamo 快、Spanner 慢”,更有意义的比较是:
- 操作跨几个 key、分区或地域,需要经过哪些协调边界。
- 成功响应前确认了哪些副本、事务状态和时间条件。
- 允许什么并发冲突,合并由谁负责,哪些业务不变量不能暂时打破。
- 故障时哪些读写仍可继续,恢复后如何处理临时版本和落后副本。
- 客户端是否能显式选择陈旧读、事务隔离与地理放置。
延迟取决于副本距离、负载和协议路径,运维成本取决于修复、容量、升级与故障处理。两篇论文提供可复用的设计思路,不是一张对所有现代数据库都成立的性能排名表。
参考
关键词:Dynamo、Spanner、并发版本、Paxos、2PC、MVCC、TrueTime、commit wait、外部一致性