记录每节点计数,用来判断两个更新的冲突关系。

向量时钟给并发写入排序

每台机器给每个参与方记一个计数。比较向量就能看出因果先后还是并发冲突,从而决定直接合并还是要格外小心。 单一时间戳并不够 单个时间戳没法给同时发生的事件定出先后——因为时间并不等于因果,两台各自写了点东西的节点哪怕盖上相同的时钟,依然说不清谁先谁后。向量时钟的解法是给每个参与者一个自己的计数器:进程发生动作时给自己那格加一,收到消息时把发送方的向量并入自己的。于是每个事件都带上一列持续成长的计数,比较两个向量,就能分辨「谁确实发生在谁之前」,或者「二者根本无法比较」。 比较向量看清因果 比较向量时逐个元素看:如果 A 的每一项都小于等于 B 的相应项,那么 A 发生在 B 之前;否则这两个向量就是并发的。因为每逢两条路径交汇、先后关系就被收进向量,所以向量同时记下了直接与跨越多级的先后,这是单个标量永远做不到的。每当一条消息从 A 抵达 B,B 的向量里便同时写着「B 自己的进展」和「A 当时所知道的一切」,于是因果靠账本,而不是靠猜。 并发就是一次冲突 「并发」在向量意义上并不指出谁对谁错,它只说两件事既不相依、也需要靠外力定序。定序往往通过比较键值或向量自身来裁决,关键是必须可复现,好让每一份副本都同意。设计上很诚实:向量时钟绝不为本来就无关的事件硬造一个总体顺序,它只是如实地指出「这里本来就没有顺序」;至于把顺序画下去的笔,应该交给上层的业务规则。 坏掉的样子 向量时钟最先坏在长大。每个参与者都要占一项,同一个键被几百个短命客户端写过,向量就带几百个计数,每次读都要背回来,每次写又要送回去。Dynamo 早就记录过这种压力:调和元数据随写者集合一起膨胀;Riak 运维看到的是同一症状,因果上下文过大拖慢读取,还会繁殖出更多兄弟版本。把最老的表项逐出看似解脱,实际是把因果一起删掉。 一旦把 [A:3, B:2, C:5] 里的 C 裁掉,后续比较会把缺席的槽位当成零,于是 C 早就见过的事件又显得像全新事件,本该被包含的写入也会复活成假的兄弟版本。危险在于截断后的向量依然能干净地比较,不会抛出任何错误:因果历史被静默缩短,存储被迫保留两个明明可以用一项保留记录排好顺序的版本。 第二种坏法是把并发误读为先后。只比总和、只比最大值,或者先看向量旁边的墙钟,就会在 [A:2, B:1] 与 [A:1, B:2] 之间硬选一个赢家,但逐项看谁也不支配谁,诚实的答案是并发,不是先也不是后。被丢掉的兄弟版本,就是一次被时钟正确标出却又被弄丢的更新。反方向的错误是把某一项相等当成历史相等:[A:2] 收到两次是同一段历史又重放了一遍,但 [A:2] 对上 [A:2, B:1] 却是严格的先于关系,用更短的向量覆盖更长的向量等于把历史倒转。 ...

九月 18, 2027 · 2 分钟 · XB Field Notes
一个在严格读取与并发写入之间取舍的刻度。

按业务不变量选择事务隔离

选择隔离级别,应从操作必须维持的不变量出发。一致快照很有用,但两个事务可能分别作出看似合理的决定,却在一起提交后得到错误结果。PostgreSQL 的可重复读保持事务读取快照稳定,仍允许部分序列化异常。可串行化则增加依赖检测,拒绝无法解释为任何串行执行顺序的并发组合,同时要求应用处理相应重试。关键是知道业务依赖哪项保证,而不是单纯选择一个听起来更强的名称。 稳定视图与正确决策分开判断 假设有两名值班医生,要求至少一名留岗。每名医生只有在看到可用人数大于一时才允许离开。两个可重复读事务可以都看到两人,然后分别把不同医生标记为离岗。它们没有覆盖彼此的同一行,因此都可能提交,最后值班人数变成零,即使每个事务都使用了内部稳定的快照。 这叫写偏差,不是对同一行发生丢失更新。分别检查每个医生布尔字段是否合法,并没有表达“总人数至少为一”的全局规则。只保护自己正在修改的那一行,也会漏掉两个决定之间的联系。适用的序列化协议需要覆盖整个不变量,而不只是其中某个局部字段。 具体数据库实现也很重要。PostgreSQL 把读未提交映射为读已提交;它的可重复读会阻止幻读,尽管 SQL 标准允许这一等级存在幻读。这些特性仍不会消除写偏差。因此,应根据引擎文档解释隔离名称,不能假定所有数据库同名级别的实现完全一样,更不能把某一种异常被排除当成所有并发问题都解决。 坏掉的样子 把可串行化理解为数据库实际每次只执行一个事务,会误导性能判断。PostgreSQL 仍并发运行事务并跟踪依赖,某个事务可能在执行途中或提交时收到 40001。应用需要用新的读取重做完整决定,不能只重新提交,也不能继续使用被拒绝快照中计算出来的值。否则虽然换了一次事务,决策前提仍然是旧的。 可串行化也不能修复一个独自运行时就违反业务规则的事务。所有相关写入路径都必须遵守既定契约;如果另外一条不受限制的更新允许最后一名医生离岗,不变量仍会被破坏。更高隔离提供并发保证,不会自动实现尚未编写的业务逻辑。实验中的条件判断与隔离级别必须一起成立。 快速验证 在同一个可丢弃的 PostgreSQL 18 数据库中,打开甲、乙两个新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色,显式事务外保留正常自动提交,并选用尚未使用的模式名称。严格遵守下面顺序;先让两边读取重叠,才能建立要观察的竞争决策。 在甲中创建初始值班表。 CREATE SCHEMA isolation_demo; CREATE TABLE isolation_demo.doctors (id integer PRIMARY KEY,on_call boolean NOT NULL); INSERT INTO isolation_demo.doctors VALUES (1,true),(2,true); 先在甲中开始事务并读取,再在乙中执行对应操作。两边人数都应为二。 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; 在甲中更新第一名医生,再在乙中更新第二名。每条更新也会在自己的快照中检查人数规则。 UPDATE isolation_demo.doctors SET on_call=false WHERE id=1 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; 先提交甲,再提交乙。两边结束后,在甲中查看已经失效的不变量并重置值班表。 COMMIT; \set ON_ERROR_STOP off COMMIT; \echo commit_state :SQLSTATE \set ON_ERROR_STOP on SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=true; 使用可串行化重复这组重叠决策:开始甲、开始乙、更新甲、更新乙、提交甲,最后尝试提交乙。 BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=false WHERE id=1 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; COMMIT; \set ON_ERROR_STOP off COMMIT; \echo commit_state :SQLSTATE \set ON_ERROR_STOP on 在甲中检查人数,在乙中以新事务重做完整决定,最后在甲中核对并清理。 SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; COMMIT; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; DROP SCHEMA isolation_demo CASCADE; PostgreSQL 18.6 实测中,可重复读的两个提交都成功,可用人数变成零。在受控的可串行化顺序下,甲提交成功,乙提交返回 40001,仍有一名医生留岗。代码只在需要观察的提交周围关闭遇错停止,让 psql 能打印 SQLSTATE;如果出现其他错误,不能把它当成预期的序列化拒绝。 ...

八月 23, 2026 · 2 分钟 · XB Field Notes