系统里至少有三种不同的时间问题:现在是什么日期,某段操作经过了多久,两个分布式事件是否存在因果关系。它们不能靠同一个裸时间戳解决。
挂钟与单调时钟:日期和耗时分开测量
挂钟用于日志日期、业务日历和跨系统时间标记,通常参考 UTC。它可能因为校时或人工调整发生跳变,所以单机上连续两次 gettimeofday() 也不保证能正确测量经过时间。
在 Linux 上,CLOCK_MONOTONIC 不受挂钟的离散跳变影响,适合测量本机间隔与截止时间,但会受到渐进频率调整影响,且通常不计入系统挂起时间;CLOCK_BOOTTIME 包括挂起时间。选择取决于“超时应否计入休眠”。这些值也不能直接拿来比较不同机器的绝对时刻。Linux clock_gettime 手册
即使本机计时可靠,进程也可能暂停很久后才执行下一条指令。租约“还有 1 秒”不保证进程接下来一定能在 1 秒内完成外部写入。
Happens-before:从程序顺序与消息构造偏序
定义 a→b:同一进程中 a 在 b 之前;或者 a 是消息发送、b 是对应接收;再加上传递关系。若既无 a→b,也无 b→a,就称两者在该模型中并发。
并发不是说两个事件物理上恰好同一纳秒发生,而是当前模型没有建立可用的因果顺序。建模漏掉了一个外部通信渠道,推导出的“无因果关系”也可能与完整系统不同。
Lamport 时钟:因果先后蕴含计数先后
每个进程维护计数 C。本地事件与发送事件递增后取时间戳;接收携带时间戳 m 的消息时,更新为 max(C,m)+1。发送只为该发送事件递增一次,不应在“本地事件”和“发送”两条规则里重复计数。
性质是 a→b ⇒ C(a)<C(b),反过来不成立。两个没有通信的进程可以分别积累到 100 和 3,100 不表示知道对方的前三个事件,也不代表实际发生得更晚。
把 (C, process_id) 按字典序排序,可以为事件构造与因果方向兼容的全序。这是确定性的排序规则,不是从数字中恢复真实世界唯一的完整先后。Lamport:Time, Clocks, and the Ordering of Events
向量时钟:保留各进程的已知进度
固定 N 个进程时,每个进程 i 保存向量 V。本地事件增加 V[i];发送携带该事件的向量;接收先逐分量取最大值,再增加自己的分量。
对两个事件向量 v、w,若所有分量 vᵢ≤wᵢ 且至少一个严格小于,则记为 v<w。在完整遵守该事件模型与更新规则时,这对应因果先后。若一些分量大、另一些小,则不可比较。
例如 [2,0] 与 [1,1] 不可比较;[2,0] 与 [3,1] 可比较。不能通过总和、最大分量或字典序检测并发,因为那些做法会丢掉各进程的独立历史信息。
对象版本向量与事件向量时钟关系密切,但计数的对象和因果上下文可能不同。对象存储还要定义生成新版本、合并上下文和保留 siblings 的规则,不能简化成“旧上下文写入必然拒绝”。见冲突解决。
沿两次消息传播,观察已知进度如何增长
令向量分量依次代表 A、B、C。A 的第二个事件 a2 向 B 发消息;B 在 b2 接收,随后在 b3 向 C 发消息,C 在 c2 接收。在这段时间里,A 还发生了未发送出去的本地事件 a3。
| 事件 | 直接前驱 | 向量 | Lamport 计数 |
| a1:A 本地事件 | 无 | (1,0,0) | 1 |
| a2:A 发送给 B | a1 | (2,0,0) | 2 |
| a3:A 本地事件 | a2 | (3,0,0) | 3 |
| b1:B 本地事件 | 无 | (0,1,0) | 1 |
| b2:B 接收 a2 的消息 | b1、a2 | (2,2,0) | 3 |
| b3:B 发送给 C | b2 | (2,3,0) | 4 |
| c1:C 本地事件 | 无 | (0,0,1) | 1 |
| c2:C 接收 b3 的消息 | c1、b3 | (2,3,2) | 5 |
c2 的第一个分量为 2,说明它的因果过去覆盖 A 的前两个事件;没有消息把 a3 纳入这条路径。虽然 C(a3)=3 < C(c2)=5,两者仍然并发。比较向量时,a3 的 A 分量更大,c2 的 B、C 分量更大,恰好暴露出这个区别。
下面同时从事件图求祖先集合、按前驱时间戳计算两种时钟,再检查所有事件对。输入按拓扑顺序列出,但列表中先出现不等于存在因果边;只有显式的程序顺序与消息边计入关系。
events = [
("a1", 0, ()), ("a2", 0, ("a1",)), ("a3", 0, ("a2",)),
("b1", 1, ()), ("b2", 1, ("b1", "a2")), ("b3", 1, ("b2",)),
("c1", 2, ()), ("c2", 2, ("c1", "b3")),
]
vectors, lamport, ancestors, last = {}, {}, {}, {}
for name, process, parents in events:
assert name not in vectors and all(p in vectors for p in parents)
assert process not in last or last[process] in parents
vector = [max((vectors[p][i] for p in parents), default=0) for i in range(3)]
vector[process] += 1
vectors[name] = tuple(vector)
lamport[name] = max((lamport[p] for p in parents), default=0) + 1
ancestors[name] = set(parents)
for parent in parents:
ancestors[name].update(ancestors[parent])
last[process] = name
def vector_before(a, b):
return all(x <= y for x, y in zip(a, b)) and a != b
for a in vectors:
for b in vectors:
causally_before = a in ancestors[b]
assert vector_before(vectors[a], vectors[b]) == causally_before
if causally_before:
assert lamport[a] < lamport[b]
assert vectors["c2"] == (2, 3, 2)
assert lamport["a3"] < lamport["c2"]
assert "a3" not in ancestors["c2"] and "c2" not in ancestors["a3"]
这里只模拟固定三个身份、不会丢失计数状态的进程。重启后把 A 的计数清零、复用旧身份,会破坏“分量代表该身份完整前缀”的解释。动态成员系统需要定义身份世代、状态恢复和元数据回收,不能仅把数组扩展或截断就认为问题已经解决。
消息携带的是发送时的知识
向量里的每个分量都有自己的归属,不能只看总和。最有辨识力的操作是:A 发送后再发生本地事件,然后把先前的消息递送给 B。B 得到的是发送时的快照,A 后来的事件并没有随同过去的消息一起传播。对照两边向量,能直接看出“数值更大”与“知道更多相关历史”的区别。
Vj←max(Vj,Vmsg),Vj[j]←Vj[j]+1
正在呈现知识画面
消息携带的是发送时的知识接收先逐分量取最大,再递增接收者分量。向量不可比较说明模型中并发;Lamport 数值较小不能反推因果先后。
{"id":"causal-knowledge","title":"消息携带的是发送时的知识","summary":"接收先逐分量取最大,再递增接收者分量。向量不可比较说明模型中并发;Lamport 数值较小不能反推因果先后。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"two\"><section class=\"card\"><header>A</header><p class=\"value\" id=\"v0\"></p><p class=\"mono\" id=\"l0\"></p><button id=\"local0\" data-i18n=\"local\"></button><button id=\"send0\" data-i18n=\"send\"></button></section><section class=\"card\"><header>B</header><p class=\"value\" id=\"v1\"></p><p class=\"mono\" id=\"l1\"></p><button id=\"local1\" data-i18n=\"local\"></button><button id=\"send1\" data-i18n=\"send\"></button></section></div><div class=\"packet\"><code id=\"packet0\"></code><button id=\"deliver0\" data-i18n=\"deliver\"></button></div><div class=\"packet\"><code id=\"packet1\"></code><button id=\"deliver1\" data-i18n=\"deliver\"></button></div><p id=\"relation\" class=\"readout\" role=\"status\"></p><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></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.card button+button{margin-top:8px}.packet{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:center;gap:10px;margin:8px 0}.packet code{font-size:12px;overflow-wrap:anywhere;min-height:4em}.value{font-size:21px}","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 v,l,q;function reset(){v=[[0,0],[0,0]];l=[0,0];q=[null,null];draw();}function draw(){for(let i=0;i<2;i++){set('v'+i,'['+v[i]+']');set('l'+i,'L = '+l[i]);set('packet'+i,q[i]?'AB'[i]+' → '+'AB'[1-i]+' ['+q[i].v+']; L='+q[i].l:t('empty'));$('#send'+i).disabled=!!q[i];$('#deliver'+i).disabled=!q[i];}const a=v[0],b=v[1],le=a.every((x,i)=>x<=b[i]),ge=a.every((x,i)=>x>=b[i]);const key=le&&ge?'same':le?'before':ge?'after':'concurrent';set('relation',t(key));$('#relation').dataset.relation=key;}for(let i=0;i<2;i++){$('#local'+i).onclick=()=>{v[i][i]++;l[i]++;draw();};$('#send'+i).onclick=()=>{v[i][i]++;l[i]++;q[i]={v:[...v[i]],l:l[i]};draw();};$('#deliver'+i).onclick=()=>{if(!q[i])return;const j=1-i;v[j]=v[j].map((x,k)=>Math.max(x,q[i].v[k]));v[j][j]++;l[j]=Math.max(l[j],q[i].l)+1;q[i]=null;draw();};}$('#reset').onclick=reset;reset();","audio":false,"strings":{"after":"B 的最新事件 → A 的最新事件","before":"A 的最新事件 → B 的最新事件","concurrent":"向量不可比较:两边最新事件并发","deliver":"递送消息","empty":"没有在途消息","heading":"消息携带的是发送时的知识","intro":"分别推进 A、B;发送只保存快照,点击递送才让接收者知道它。比较两边最新事件。","local":"本地事件","reset":"重置","same":"相同向量;初始状态没有事件先后","scope":"固定两个不会重启的进程,每个方向最多一条在途消息。发送与接收各算一次事件;不模拟物理时间。","scopeLabel":"模型范围与约定","send":"发送快照"}}
逻辑互斥:排序只是协议的一部分
Lamport 的经典互斥算法在可靠 FIFO 通道等假设下使用带时间戳的请求队列。请求者广播 REQUEST;接收者将请求排队并回复;请求者确认自己的请求位于队首且收到其他进程的相应消息后进入临界区;退出时广播 RELEASE,让各队列移除该请求。
这与“按优先级延迟回复、退出后才补发”的其他互斥算法不同,不能把延迟回复规则与 Lamport 的 RELEASE 队列流程随意拼在一起。等待所有成员的方案还需要处理成员故障,否则一个永久不响应的成员就会阻止进展。
物理时间的不确定区间
如果已知事件 A 的真实时间在 [10,12],事件 B 在 [13,15],可以判断 A 先于 B;若 B 在 [11,14],区间重叠,单凭这些边界无法判定。
NTP、PTP 的精度取决于同步源、路径不对称、硬件时间戳、负载和失联时间,没有普遍适用的“固定 1ms”或“固定 100ns”。软件能否给出可靠边界,取决于系统对延迟、振荡器漂移和故障的假设;不能笼统说软件永远无法建立边界。
同步停止后,误差预算不会停在最后一次测量
假设最后一次同步已经给出可靠的 ±2 ms 边界,之后振荡器相对理想时间的最大频率误差为 200 ppm,即每经过 1 秒最多额外积累 0.2 ms 误差。在没有再次校准、没有跳变且这些界限始终成立时,失联 Δt 秒后的保守边界是:
ε(Δt) = 2 ms + 0.2 ms/s × Δt。
因此 30 秒后是 ±8 ms,10 分钟后是 ±122 ms。它们是人为给定假设下的算例,不是 NTP 或任何具体晶振的默认性能。若两台机器各自具有 ±8 ms 误差,仅凭两个时钟相差 10 ms 还不能确定真实先后;两边的不确定性都要计入。
from fractions import Fraction
def error_ms(elapsed_s):
assert elapsed_s >= 0
return Fraction(2) + Fraction(200, 1000) * elapsed_s
def definitely_before(a_center, a_error, b_center, b_error):
return a_center + a_error < b_center - b_error
assert error_ms(30) == 8
assert error_ms(600) == 122
assert not definitely_before(100, 8, 110, 8) assert not definitely_before(100, 8, 116, 8) assert definitely_before(100, 8, 117, 8)
误差上界与时间戳显示精度也不同:打印到纳秒只说明数值格式细,不代表真实时刻误差小。失联、温度变化或同步源异常使原假设失效时,系统应扩大边界或停止依赖该边界作确定性判断,不能继续沿用旧的 ±2 ms。
TrueTime 把时间不确定性显式暴露为 [earliest,latest]。在其保证成立的条件下,系统通过等待 earliest 越过提交时间戳,使提交的实时顺序满足目标事务语义。等待与复制、事务并发控制共同作用,不是原子钟单独解决所有一致性问题。Spanner 与 TrueTime
跨地域本身不是“比线性一致性更强”的定义。Spanner 的外部一致性通常在事务层面讨论实时串行顺序;比较保证时应保持对象与事务的作用域一致。
混合逻辑时钟与选择速查
混合逻辑时钟 HLC 结合接近物理时间的分量与逻辑计数,帮助在保留因果方向的同时使时间戳易于与物理时间关联。它不自动提供全局真实时间的严格误差边界,也不替代共识或事务隔离。
| 任务 | 应考虑的时间工具 | 边界 |
| 日志日期与业务日历 | 挂钟与时区规则 | 可能校时、跳变与漂移 |
| 单机耗时、重试截止时间 | 合适的单调时钟 | 注意挂起语义与进程暂停 |
| 保留因果方向的排序 | Lamport / HLC | 计数较小不证明存在因果路径 |
| 检测完整模型里的并发 | 向量时钟 | 元数据随身份规模增长 |
| 依据物理时间判断确定先后 | 有可靠误差边界的区间 | 重叠时不能强行排序 |
参考
Keywords: wall clock, monotonic clock, Lamport clock, vector clock, happens-before, TrueTime, HLC