故事

光绪年间,天津卫有个信局子叫"三元递",专做南北货商的汇票生意。商人在津门把银票交给三元递,局子誊抄一份留底,原件封火漆,发驿马送上海。上海分号验了火漆、对了暗号,把银票交给收款人,再发一封回执。

这生意最怕三件事:丢信、重复、乱序。

丢信好防——火漆 intact、暗号不对就不兑付,发回执查账。乱序也好办——每封信编"干支序号",上海按号入库,缺号就催。唯独"重复"最缠人:驿马跑疲了倒毙途中,替补马夫接了信继续跑,结果前马其实没死,两匹马先后到了上海,同一封汇票兑了两次。

三元递的掌柜姓冯,人称"冯三封"。他早年的法子笨:每封信到上海,分号把"已兑付"三个字用朱笔记在原件背面,回执里夹一份抄件。第二次来的马夫看见背面有字,就知道这封信已经兑过。可朱笔记在纸上,雨天洇墨、虫蛀脱落,总有看走眼的时候。更麻烦的是,有些商人故意把同一笔货发两批,两批汇票内容一模一样,冯三封的朱笔分不出"同一封信来了两次"和"两封不同的信凑巧一样"。

冯三封四十岁上改过一次规矩。他让天津总局在发信前给每封信编一个"三元戳"——戳上刻着局名、日期、顺序号、经办人花押,四样合一,天下无重。上海分号收到信,先查戳号,戳号见过的,原封退回。这法子治住了重复,却治不住"半路退回"。

有一回,驿马过德州时遇上捻子,马夫弃马逃回天津。总局以为信丢了,补发一封,戳号换新。结果捻子没抢信,马自己跑去了上海——两封信戳号不同,上海照兑两次,商人白得一笔。冯三封吃了官司,赔掉半年进项。

他六十大寿那天,把儿子叫到内室,取出一只檀木匣。匣里是三张纸,写着同一套规矩的三种写法。

"第一种,叫'至少一次'。信只管发,丢了就补,重复了靠上海查账追回。快,但累。"

"第二种,叫'至多一次'。发出去就不补,丢了算商人倒霉。省事,但商人不肯用你。"

"第三种,最难。"冯三封指着第三张纸,"叫'恰好一次'。信只生效一次,不多不少——但天上没有白掉的恰好,你得同时管住两头。"

他细说这套法子:天津发信前,先在上海的"未兑付簿"里占一行,写上戳号和预计到达日,这叫"预占"。信到了上海,分号查簿,戳号对、日期合,才兑付,兑付后把"预占"改成"已兑"。若信半路丢了,预占行到期自动作废,总局看见作废,再补发新戳。若两马同到,先到者占住预占行,后到者见行已改"已兑",原信退回。

"可预占行要是写重了怎么办?"儿子问。

"预占行本身带戳号,戳号全局唯一,重不了。"

"那预占簿丢了怎么办?"

冯三封从匣底摸出第四张纸:"所以预占簿要抄三份,天津一份、上海一份、通州中转一份。三份对不齐的时候,以多数为准——这叫'检查点',每隔十里驿铺对一次账,对上了才继续跑。"

儿子又问:"若三份簿子两份被水淹了,只剩一份呢?"

冯三封沉默良久,说:"那就退回'至少一次'。恰好一次是三个人抬轿子才抬得稳的轿子,两个人抬,轿子就晃。你记住,恰好一次不是'绝对恰好',是'在能控制的范围内恰好'——控制不住了,宁可让它退化成至少一次,也别假装它还稳着。"

三元递后来用了三十年这规矩,直到电报进了中国。


概念解析

流处理里的"恰好一次"(exactly-once)不是魔法,是在特定边界内把重复和丢失同时压住的工程契约。Kafka 与 Flink 的实现,拆开看是三个机制咬合。

幂等生产者:戳号全局唯一。Kafka 的幂等生产者给每条消息绑定一个 PID(Producer ID)+ 序列号,Broker 端记住最近每个 PID 的已确认序号。同一 PID 的重复序号自动去重——这就是冯三封的"三元戳"。它只解决"生产者重试导致的发送重复",不解决"消费者处理完又崩溃重读"的问题。

事务写入:预占与确认的原子性。Kafka 的事务 API 把一组消息的发送和一组 Offset 的提交包进同一个事务,协调器(Transaction Coordinator)用两阶段提交保证原子性。Flink 的 TwoPhaseCommitSinkFunction 把检查点的快照保存与外部系统的提交绑定——检查点成功才对外可见,失败就回滚到上一个一致状态。这是"预占簿"的现代版:流处理的结果先写进一个"预提交"的隔离区,检查点对齐后才真正刷出去。

检查点:分布式快照的定时对账。Flink 的 Chandy-Lamport 检查点周期性地给整个数据流图拍快照,Source 记偏移、Operator 记状态、Sink 记待提交事务。若作业崩溃,从最近检查点恢复,所有算子回到同一时刻的同一状态——相当于冯三封的"每隔十里对账"。检查点频率决定"恰好一次"的边界:两次检查点之间若发生不可恢复的多副本丢失,系统只能退回到"至少一次"或"至多一次"的语义。

三种保证的代价阶梯。"至少一次"(at-least-once)只开幂等生产者,不拍检查点,吞吐最高,下游自己去重。"恰好一次"(exactly-once)三机制全开,检查点间隔压短,协调器成为瓶颈,延迟上升。Flink 的 Unaligned Checkpoints 试图用 Barrier 越过反压队列来减少停顿,但本质仍是"用更多资源买更紧的边界"。

冯三封的第四张纸——"控制不住了,宁可退化"——对应的是工程现实:Kafka 事务超时、Flink 检查点连续失败、两阶段提交的协调器宕机,这些时候系统不会假装恰好一次还成立,而是抛异常、停消费、让人工介入。恰好一次不是消除失败,而是把失败变成可观测的、可回退的显式状态,而不是悄悄变成重复或丢失。

三元递的规矩用了三十年,因为它同时管住了"发信端不重发"和"收信端不重复兑"——但代价是十里一检查点、三份预占簿、一个全局戳号分配器。现代流处理系统省掉了驿马和火漆,但省不掉这套结构:唯一标识、预提交隔离、周期对账。恰好一次的"恰好",从来不是免费的。