让分布式存储保持一致的基于法定人数的算法

etcd 通过选举单一领导让写入有序

etcd 是一份被复制的日志,Raft 就是让各副本保持一致的算法。每个任期里只有一名成员是领导者,也只有它可以提议新条目;当多数成员都拥有该条目时,它才算提交。领导者选举决定谁可以写,日志复制决定什么才算持久——这是两套机制,故障模式也各不相同。 两者都被同一个数字支配。仲裁数决定了每次写入要付多少确认、你能承受几台成员失效,以及发生网络分区时会怎样。成员数选错,你为同样的韧性付得更多;丢掉仲裁,集群就停止写入,却仍会回答一部分读请求。 每个任期只有一个领导者 成员在选举超时内没听到领导者的消息就会变成候选人,超时时间是随机的,以免所有人同时竞选。它递增任期、投自己一票,然后向其他成员拉票;一个成员在每个任期最多投一票,并且会拒绝任期更低的候选人。拿到多数票的候选人成为领导者。如果两个成员同时超时,票可能被瓜分——四成员集群里可能变成 2 比 2,够不到 3 这个仲裁数——这一轮就白费了,代价是再等一个选举超时。 选举期间集群无法处理写入:etcd 会把请求排队,等领导者产生后再处理。已经发给旧领导者但尚未提交的写入可能丢失,因为新领导者有权覆盖未提交的条目;已提交的写入永远不会丢。任期就是让这一切安全的时钟,也正是为什么一个任期偏高的孤立成员是隐患而不是趣闻。 仲裁数的算术,以及为什么奇数更好 仲裁数就是简单多数:floor(N/2) + 1,因此集群能承受的失效数是 N - quorum。三个成员承受一次失效、需要两个确认;四个成员同样只承受一次失效,却需要三个确认。五个成员承受两次失效、需要三个确认;六个成员同样承受两次,却需要四个。偶数成员数买不到额外韧性,只会让每次写入多付一个确认。 成员变更会立刻改变仲裁数,偶数集群的危险就在这里。文档里的例子是一个单成员集群:加一个成员会把仲裁数从 1 抬到 2,原来的领导者发现自己不再拥有活跃的仲裁,于是触发一次选举。把三成员扩到四成员会把仲裁数从 2 抬到 3,于是一次 2 对 2 的分区让两侧都选不出领导者——而三成员集群在这种情况下,多数一侧仍然完好。 learner 成员,以及两套机制的分工 learner 会接收领导者的日志但不投票,这正适合刚加入、还在追赶的节点:给三成员集群加一个 learner,仲裁数仍是 2,加入动作本身不会触发选举。learner 拒绝客户端的读写,领导者身份不能转移给它,它也永远不会自我提升。只有当它的日志追平之后,运维才能提升它;也只有到那时,它才开始计入仲裁数。 把选举与复制分开看,能解释大部分令人困惑的现象。领导者失效会让追加停止,直到选出新的领导者,哪怕每个成员本身都健康。一个健康的领导者如果联系不上跟随者,同样无法提交。而一个跟随者可以落后任意多,集群却照样提交,因为只需要多数拥有该条目——落后是复制问题,不是可用性问题,直到多数本身消失为止。 坏掉的样子 最经典的故障是丢掉仲裁。写入停止:多数成员失效的集群无法接受更多写入,只有多数重新可用它才能恢复,否则唯一的路是从备份做灾难恢复。陷阱在于读可能仍然能用。线性一致读是一次共识操作,会像写入一样失败;而串行化读由成员自己的状态直接提供,可能返回集群早已越过的数据——所以「它还能应答」并不是健康的证据。 第二种故障是被分区的少数派,它更安静。少数派选不出领导者,因此既没有仲裁也没有写入;由于成员变更同样需要多数,这里不会出现脑裂。在 Raft 层面,那些被隔离的成员会不断超时并抬高自己的任期,而多数一侧的任期原地不动;分区恢复时,更高的任期迫使领导者下台,于是重新跑一次选举。也就是说,分区恢复本身会带来一次短暂的写入中断。 ...

三月 9, 2028 · 1 分钟 · XB Field Notes
超过半数的成员认可同一位协调者。

选主必须有大多数

多数票正是用来禁止脑裂的:集群被切开后,人数不足一半的那一侧无法凑够法定多数,也就不可能继续自封为主。 一个主、一帮多数 一个不能各执一词的复制系统,要为这组节点选出一个「主」。候选者向同伴征票,要成为主,它需要一个法定多数——在容错方案里,超过一半的多数在一届任期内点头。这步正是让唯一赢家敢放手的保证:没有少数派还能与它平起平坐,因为被分成两半的集群里,最多只有一半凑得齐多数。所以多数严格规定为大于一半,而不是刚刚一半——正是因为这一点,两个被切开的半边才不会同时都以为自己说了算。 法定人数挡掉脑裂 分区是领导者选举必须活过的经典险境。如果集群被切成两半,最多只有一半握得住多数;缺多数的那一侧必须等待,因为凑不齐法定人数,它就不能硬把写请求当成交付。这正是多数票的作用:把旧主从少数派一侧安全地换下,让两个断开的节点不会各拿一份因果上相互矛盾的写。多数派把「该谁主政」从一场互相指摘,变成一个结果唯一且安全确定的操作。 任期与心跳维持秩序 领导权要不断地被续,而不是一支永续。任期的时长由任期与心跳来度量:主靠心跳续租,当 peers 因它失联而越过任期,就会重启一次选举、开启一个新的任期。这个不断延续的节奏,让集群能在成员进出时依然有序,因为没有任何一位主的信任会依赖它自己永生。这一套「选主、竞选、失联检测、重选」的循环,让整群无需一台共同认可的时钟,也能一直保持诚实。 坏掉的样子 偶数个节点最先吃亏。四个节点被切成二对二,选主需要三票,两边谁也凑不够,整个集群只能静默,直到分区愈合。六个节点三对三僵持,道理完全一样。这正是生产环境常用三个或五个投票者的原因:任何一次切分,最多只有一侧还握着严格多数。 没有多数的一侧必须停写,这停顿正是安全的代价。被困在少数派一侧的旧主续不上法定人数,只能让位,于是粘在那一侧的客户端看到的是报错而不是进展。若把这些报错当成缺陷,强行在本地落写,就等于把多数派规则花钱买来的安全又亲手退掉,脑裂立刻回来。 即使网络完好,选举也可能活锁。候选者若在同一瞬间起跑,选票被瓜分,谁都到不了多数,任期空转,主位抖动,写延迟随之飙升,而每一轮投票都以失败告终。随机化选举超时正是为打破这种对称而设;若把超时配成完全相同的值,或节点过载拖延投票,就等于亲手复刻这场风暴。 最后一坑是任期回退。长期分区后归队的节点拿着过期任期拉票,或日志落后的候选者索要它本不配得的选票。投票者必须拒绝更低任期,并要求候选者日志足够新;一旦跳过这道新鲜度检查,新主上台就会覆盖上届多数派已经提交的条目,共识的根基就此动摇。 快速验证 把下面脚本保存为 majority.py,直接运行 python3 majority.py 即可。它只用标准库,模拟五个投票者、每个节点每任期一票,再用分区决定候选者实际能收到谁的票。脚本覆盖四组断言:五节点下多数侧当选、少数侧落选、四节点偶分两侧皆败、过期任期零票。若任一步语义走样,断言即抛错,不会悄悄打印通过: def quorum(n): """严格多数:超过全体投票者的一半,而不是某一侧的一半。""" return n // 2 + 1 class Cluster: """最小票箱:N 个投票者,每个节点每任期只能投一票。""" def __init__(self, size): self.size = size self.terms = [0] * size self.voted_for = [None] * size def request_votes(self, candidate, term, reachable): if term <= self.terms[candidate]: return [] # 过期任期:候选者自己都见过更新的任期 votes = [] for node in reachable: if term > self.terms[node]: self.terms[node] = term self.voted_for[node] = candidate votes.append(node) elif term == self.terms[node] and self.voted_for[node] in (None, candidate): votes.append(node) return votes def elect(self, candidate, term, reachable): votes = self.request_votes(candidate, term, reachable) return len(votes) >= quorum(self.size), votes assert quorum(3) == 2 and quorum(4) == 3 and quorum(5) == 3 majority = Cluster(5) won, votes = majority.elect(candidate=0, term=1, reachable=[0, 1, 2]) assert won and sorted(votes) == [0, 1, 2], votes print(f"5 节点中 3 节点侧:得票 {sorted(votes)}(需 {quorum(5)} 票),当选") minority = Cluster(5) won, votes = minority.elect(candidate=3, term=1, reachable=[3, 4]) assert not won and sorted(votes) == [3, 4], votes print(f"5 节点中 2 节点侧:得票 {sorted(votes)}(需 {quorum(5)} 票),无人当选") even = Cluster(4) for candidate, reachable in ((0, [0, 1]), (2, [2, 3])): won, votes = even.elect(candidate, term=1, reachable=reachable) assert not won, (candidate, votes) print(f"4 节点 2-2 分裂:两侧均到不了 {quorum(4)} 票,集群停摆") fresh = Cluster(5) won, _ = fresh.elect(candidate=0, term=2, reachable=[0, 1, 2, 3, 4]) assert won won, votes = fresh.elect(candidate=4, term=1, reachable=[0, 1, 2, 3, 4]) assert not won and votes == [], votes print("过期任期 1 在任期 2 之后:零票拒绝") print("PASS:多数侧当选,少数侧与偶分停摆,过期任期落败") 脚本逐条断言选举语义。三节点一侧拿到五票中的三票,顺利当选本任期主;两节点一侧只拿到两票,选不出任何人,只能等待。四节点被切成二对二时,两侧都到不了三票,于是没有主诞生,集群原地停摆。过期任期的选票纵使算术上够数,也会被零票拒绝。预期输出如下,五行缺一不可: ...

九月 21, 2027 · 2 分钟 · XB Field Notes
节点每轮把消息随机转告给邻居,最终人人皆知。

流言协议最终会收敛

节点不断与随机伙伴交换并转发状态。最终状态一致,也容忍消息在中途被丢弃。 各转各、互转告 每一轮每个节点挑一个随机的同伴交换已知状态,唯一的事实靠反复传播抵达所有人,而不是借一次单点广播。因为每次甩到的伙伴不同,信息像几何级数一样铺开:一轮往往能让知道的人数约翻一倍,于是一个小型集群里一条新成员条目——往往只要几轮就能扩散到每个人。交换通常是双向的,两条互为镜像的对话一次带两边,这让网络的成本对称又划算。它换来的是「最终一定周到」的随机保证,而不是「这一条必须按序送到」的强承诺。 没有中心也收敛 收敛不是由某个协调者来保证,而是全体随机接触涌现出来的结果,所以系统没有一个必须存活的单点。成员数和集合规模随时可变也能扛:节点加入、离开,无需有人更新一本中心目录。代价是「最终」而不是「立即」:足够轮次之后大家才看到同一份状态,而收敛时间大致取决于一轮消息来返的耗时。集群越大,越要保持每一轮的接触成本跟集群规模近似成对数而不是线性,才不至于把传播变成内耗。 丢一条消息并不致命 丢一条消息不会终止协议——这份事实还会在之后的随机配对里被另一个节点重新拾起,并再次向前转。一次失败只是把收敛拖延到下一个握手轮,而不是把状态的传播掐灭。因为状态是被不断往前复制,而不是被某个点消耗掉,所以单个节点的失陪无损全局。这也解释了为什么平时把心跳轮做得足够短很有用:收敛更快,而且一个迟到加入的节点,很快就能被任意一个熟知的邻居重新带起来。 坏掉的样子 网络分区不会让流言停下来,只会把它劈成两半。两侧各自在内部继续交换、各自收敛出不同的成员视图,于是两边都相信自己才是健康的那一半。链路恢复时,真正决定哪些写入能活下来的是合并规则:如果只比物理时钟、谁新听谁的,在失败侧接受的写入会被悄悄丢掉。生产系统因此要用版本向量或墓碑(tombstone)来配合流言:分区愈合时做调和,而不是截肢。 另一种坏法正好相反:网络没有分区,但一个慢节点看起来像死了。一次长时间的垃圾回收停顿,或者被打满的链路,会让心跳迟到超过怀疑超时,集群于是把活节点判成死节点,把它负责的数据范围复制一遍,等它再次应答又要把这些活儿撤销。SWIM 式的 suspicion 机制正是为此而生:先扩散怀疑、迟宣布死亡,并给被怀疑者用更高的 incarnation 编号反驳的机会,而不是把一次错过的心跳当成判决。 第三种坏法是修复流量本身。当大批节点同时重新加入,或者心跳轮间隔设得太激进,每个节点都向所有人拉全量状态,修复流量就会变成反熵风暴。正确做法是先对摘要:先比较版本摘要,只补缺失的条目,再加上抖动和有界扇出。从磁盘镜像恢复的老节点绝不能带着清零的计数器重新加入:版本号更小的旧副本会正确落败,但计数器被重置的旧副本反而可能覆盖更新的状态,所以版本号在重启之间必须单调递增。 快速验证 把下面程序保存为 check_gossip.py,运行 python3 check_gossip.py。它模拟 20 个节点,每个节点维护一份带版本号的键值映射;更新先写入 0 号节点,之后每一轮每个节点随机挑选一个对等节点做 push-pull 交换,双方按 key 保留版本号更高的一方。固定随机种子只让每次运行的配对可复现,不改变协议本身。程序逐轮打印知道更新的节点数,并在 12 轮后断言全部收敛。 import random NODES = 20 ROUNDS = 12 SEED = 7 random.seed(SEED) # Each node holds a versioned map: key -> (version, value). nodes = [{} for _ in range(NODES)] nodes[0]["config"] = (1, "blue") def merge(a, b): merged = dict(a) for key, (version, value) in b.items(): if key not in merged or version > merged[key][0]: merged[key] = (version, value) return merged history = [] for round_no in range(1, ROUNDS + 1): for i in range(NODES): peer = random.randrange(NODES) if peer == i: continue # Push-pull: both sides adopt the union, highest version wins. united = merge(nodes[i], nodes[peer]) nodes[i] = dict(united) nodes[peer] = dict(united) informed = sum(1 for state in nodes if state.get("config") == (1, "blue")) history.append(informed) print(f"round {round_no}: {informed}/{NODES} nodes know the update") assert history[-1] == NODES, f"gossip did not converge: {history}" assert all(state.get("config") == (1, "blue") for state in nodes) print(f"PASS: all {NODES} nodes converged within {ROUNDS} rounds: {history}") 在这个种子下,传播接近几何级数:第一轮约四分之一节点知道,第三轮接近全部,第四轮 20 个节点全部收敛。如果把每轮配对改成只联系固定邻居,收敛会明显变慢甚至停滞,这正好对应分区或配置错误的集群。上述断言在 macOS、CPython 3.12 下运行通过。这个模型省略了 suspicion 超时、墓碑过期和带宽限制,只能用来理解收敛,不能当作成员关系的实现。 ...

九月 20, 2027 · 1 分钟 · XB Field Notes
顺时针哈希环中,点位加入或离开只改变受影响区间的归属。

一致性哈希让搬数据变少

一致性哈希限制成员变化时的数据放置变化,但它本身不搬运数据、不让副本达成一致,也不把单个热键的请求自动分散。经典顺时针哈希环把键交给哈希位置之后遇到的第一个节点点位;如果位置恰好相等,就归该点,超过最大点位则绕回最小点位。理解这个查找方向,是判断迁移范围的前提。 先把归属区间画对 一个点拥有的是前驱之后到自身为止的区间,即 (前驱, 当前点]。假设 A 位于 20,B 位于 50,C 位于 80,那么 B 负责大于 20 且不大于 50 的位置;超过 80 的键绕回 A。这不是从 B 到其后继 C 的弧段,左右端点是否包含也不是可以随意改变的细节。 在 35 加入 D,它从顺时针后继 B 手中取得大于 20 到 35 的位置;移除 B,则将 B 原来负责的区间交给 C。未受影响的点位保持原位置及归属,这才是算法有用的性质。仅说“移动一小段弧”还不够,因为原来某段弧可能就占据整个环的很大一部分。 作为对照,直接用 hash(key) 对节点数量取模来选择数组下标,新增节点会改变除数,很多键随之选到别的下标。四个等容量且平衡的节点扩到五个时,理想哈希环大约需要迁移五分之一的键,但真实迁移比例取决于点位与键分布,不能把这个比例当成所有部署都成立的保证。 ...

九月 19, 2027 · 3 分钟 · XB Field Notes
记录每节点计数,用来判断两个更新的冲突关系。

向量时钟给并发写入排序

每台机器给每个参与方记一个计数。比较向量就能看出因果先后还是并发冲突,从而决定直接合并还是要格外小心。 单一时间戳并不够 单个时间戳没法给同时发生的事件定出先后——因为时间并不等于因果,两台各自写了点东西的节点哪怕盖上相同的时钟,依然说不清谁先谁后。向量时钟的解法是给每个参与者一个自己的计数器:进程发生动作时给自己那格加一,收到消息时把发送方的向量并入自己的。于是每个事件都带上一列持续成长的计数,比较两个向量,就能分辨「谁确实发生在谁之前」,或者「二者根本无法比较」。 比较向量看清因果 比较向量时逐个元素看:如果 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
带作用域的操作键,在一个事务中关联业务效果与保留下来的结果记录。

幂等键归一到一次逻辑行动

一个幂等键只能在服务定义的作用域与保留窗口内标记一次逻辑行动。重复同一段字符串,不是放之四海皆准的“恰好执行一次”保证。服务需要把身份关联到正确的主体与操作,协调竞争中的请求,并保存足够权威的信息,以便稍后认出已经处理的行动。键只是入口,真实保证取决于这条处理链。 作用域是身份的一部分 如果全局只按客户端提交的键查找,就可能把不同租户的行动合并,甚至把别人的已存结果返回给当前调用方。查找或重放之前仍须完成认证与授权。应把可信作用域、稳定的操作标识、客户端行动键一起作为查找身份,并保留各组件边界,避免直接拼接字符串产生歧义。 究竟哪些维度属于作用域,要由 API 契约决定。EC2 明确区分地域级与可用区级幂等;按其文档规则,相同客户端令牌在不同地域或可用区可能允许执行不同的行动。因此,把重试切换到另一个地域,并不自动保留全局去重保证,不能只检查令牌文本是否一致。 请求含义也需要约束。同一个作用域内,原键配上不同参数时,必须按照已经声明的冲突规则处理。下面的实验只对一个正整数量生成比较表示,不比较任意 HTTP 正文,不执行认证,也不认为对正文取个哈希,就能自动推断业务语义是否相同。 让业务效果与结果记录一起提交 先查记录、再独立写入,会出现竞争:两个工作进程都没看见旧记录,于是都执行了业务。如果两份业务效果已经发生,之后只有一个结果记录插入成功,那么结果表上的唯一键本身并不能消除之前的重复效果。需要保护的是业务修改与记忆结果之间的完整边界。 两者都位于同一个事务数据库时,可以放在一个事务中提交。示例先执行 SQLite BEGIN IMMEDIATE,再查询记录,并在同一事务里写入业务行与结果记录。SQLite 同时只允许一个写事务,因此竞争连接会被串行处理;复合主键也明确表达了去重所依赖的身份组成。 事务保护止于这个数据库。外部支付、邮件发送或消息投递,不会因为本地事务失败就自动撤销;这些效果需要自己的端到端投递与去重契约。把整个流程命名为“恰好一次”,并不能凭空补出跨越多个系统的原子性,评审时必须沿真实副作用检查边界。 保留期也是正确性边界 如果旧请求仍可能回来,而记住它的键已经过期,那次请求就可能重新执行。Stripe 文档明确说明,记录清理后的同键请求会成为新请求;其至少二十四小时的保留说明属于该提供方,并不是所有服务通用的计时器,更不能解释成永久去重。 恢复窗口应与保留期一致,还要区分临时去重记录和长期业务唯一性规则。删除结果记录,不会撤销原来的订单。如果业务禁止这笔订单在任何时候被重新创建,就需要一个比短期重试缓存更持久的权威约束,不能把希望都放在键还没过期这件事上。 恢复较旧的记录数据库、清空状态或切换到独立存储,也可能忘记已经完成的行动。恢复方案应保留业务数据与操作记录之间的关系,不能只证明各自的文件还能读取,却没有验证它们是否对应同一批已提交事实。 坏掉的样子 提交成功后丢失响应,会让客户端不知道结果,但不会自动删除已存记录。提交前事务失败,则应同时不留下业务效果与成功记录。这是两种不同情况,需要不同证据,不能把“客户端看到了错误”直接等同于“服务器没有执行”。 数据库竞争、存储不可用或未知结果,也不能被当成“查不到旧记录”。看到操作仍在执行,不应自动放行第二份业务效果。需要明确重复请求是等待、得到执行中响应,还是走另一条已有文档的恢复路径;成功结果重放只是可能状态之一,不覆盖所有中间阶段。 快速验证 保存为 scope_check.py,用 Python 3.10 以上版本执行 python3 scope_check.py。实验已在 Python 3.14.4、SQLite 3.53.0 上运行。标准库代码在私有临时目录创建数据库,结束后清理;每次尝试都打开自己的连接。租户与操作参数代表已经完成身份验证和路由解析的测试输入,不是客户端请求头的替代品。 from concurrent.futures import ThreadPoolExecutor from contextlib import closing import json from pathlib import Path import sqlite3 from tempfile import TemporaryDirectory from threading import Barrier with TemporaryDirectory(prefix="idempotency-scope-") as directory: database = Path(directory) / "orders.sqlite" with closing(sqlite3.connect(database)) as db: db.executescript(""" CREATE TABLE effects ( id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, operation TEXT NOT NULL, quantity INTEGER NOT NULL CHECK (quantity > 0) ); CREATE TABLE receipts ( tenant TEXT NOT NULL, operation TEXT NOT NULL, request_key TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (tenant, operation, request_key) ); """) def execute(tenant, operation, key, quantity, now, fail_before_receipt=False): # Tenant/operation are already authenticated/routed fixture identifiers. assert type(quantity) is int and quantity > 0 fingerprint = json.dumps({"quantity": quantity}, separators=(",", ":")) scope = (tenant, operation, key) with closing(sqlite3.connect(database, timeout=5)) as db: with db: db.execute("BEGIN IMMEDIATE") previous = db.execute( "SELECT fingerprint, result, expires_at FROM receipts " "WHERE tenant=? AND operation=? AND request_key=?", scope, ).fetchone() if previous is not None and previous[2] > now: if previous[0] != fingerprint: return "conflict", None return "replay", json.loads(previous[1]) if previous is not None: db.execute("DELETE FROM receipts WHERE tenant=? AND operation=? AND request_key=?", scope) cursor = db.execute( "INSERT INTO effects (tenant, operation, quantity) VALUES (?, ?, ?)", (tenant, operation, quantity), ) result = {"effect_id": cursor.lastrowid, "quantity": quantity} if fail_before_receipt: raise RuntimeError("injected failure after effect insertion, before receipt insertion") db.execute("INSERT INTO receipts VALUES (?, ?, ?, ?, ?, ?)", (*scope, fingerprint, json.dumps(result), now + 60)) return "created", result def counts(): with closing(sqlite3.connect(database)) as db: return db.execute("SELECT (SELECT count(*) FROM effects), (SELECT count(*) FROM receipts)").fetchone() gate = Barrier(8) def attempt(_): gate.wait(timeout=5) return execute("north", "web-order", "intent-a", 2, 0) with ThreadPoolExecutor(max_workers=8) as pool: outcomes = list(pool.map(attempt, range(8))) assert [kind for kind, _ in outcomes].count("created") == 1 assert [kind for kind, _ in outcomes].count("replay") == 7 original = outcomes[0][1] assert all(result == original for _, result in outcomes) and counts() == (1, 1) print("eight independent connections produce one effect, one receipt, and seven replays") assert execute("north", "web-order", "intent-a", 3, 1) == ("conflict", None) assert counts() == (1, 1) assert execute("south", "web-order", "intent-a", 2, 1)[0] == "created" assert execute("north", "batch-order", "intent-a", 2, 1)[0] == "created" assert execute("north", "web-order", "intent-b", 2, 1)[0] == "created" assert counts() == (4, 4) print("changed parameters conflict; a different tenant, operation, or intent key has separate scope") try: execute("north", "web-order", "rolled-back", 1, 2, fail_before_receipt=True) except RuntimeError as error: assert str(error).startswith("injected failure") else: raise AssertionError("expected the transaction to fail") assert counts() == (4, 4) assert execute("north", "web-order", "rolled-back", 1, 2)[0] == "created" assert counts() == (5, 5) print("a raised exception rolls back the effect and receipt together; retry then creates one effect") _, committed = execute("north", "web-order", "committed-no-reply", 1, 3) # Discard the reply, then query through a fresh connection; no network loss is simulated here. assert execute("north", "web-order", "committed-no-reply", 1, 3) == ("replay", committed) assert counts() == (6, 6) print("a fresh connection replays a committed result without adding another effect") assert execute("north", "web-order", "intent-a", 2, 59) == ("replay", original) kind, after_expiry = execute("north", "web-order", "intent-a", 2, 60) assert kind == "created" and after_expiry["effect_id"] != original["effect_id"] assert counts() == (7, 6) print("at the explicit 60-second retention boundary, the same key creates a new effect") print("SQLite", sqlite3.sqlite_version, "verified counts: 7 effects, 6 retained receipts") 八条竞争连接最终产生一条业务记录、一条结果记录和七次重放,所有返回结果一致。改变数量会冲突;改变租户、操作或行动键,则按已经声明的作用域创建独立工作,使计数达到四条业务记录与四条结果记录。 ...

十月 12, 2026 · 3 分钟 · XB Field Notes