▶ Cinematic fable · Watch on YouTube ▶ 影片版寓言 · 在 YouTube 观看
陇西边隘的黑石关外,有一座由朝廷设下的巨大常平仓。
仓里堆积着百万石青稞与老麦,专管抵御西陲荒旱与边军犒饷。边关山高路险,每逢拔粮出库,仓令衙门必须同时履行两桩天职:第一桩,是将出粮的数额、批号、领粮官署,一笔不差地录入关仓那本重达三十斤的精铁包角大账;第二桩,是立刻吹响铜角,差遣轻骑快马出关,将盖了朱印的通报快折送往八百里外的京城户部。
京城要凭快折核验四方虚实,边仓要凭大账盘查实物盈亏。两处的数字,必须严丝合缝,毫厘不差。
这看似寻常的公事,却在边关折磨了整整三任仓令。
早年间,第一任仓令是个雷厉风行的直性子。他定下的规矩是“先落账,再发骑”:先把出库的大账写定、朱印落妥,再唤门外的信使翻身上马。
可关外戈壁风狂雪骤,狼盗出没。有一年深冬,犒军的三千石大麦刚记入铁账,信使驰出峡谷便遇上雪崩,连人带折翻落万丈深涧。信使既没,折子便再也没能送进京城。到了年底岁会,户部的账目上边关依然存粮百万,关仓的大账里却平白少了三千石。朝廷御史按律问罪,痛斥仓令私盗官粮、欺瞒君父。仓令百口莫辩,饮恨罢官。
第二任仓令吸取了前车之鉴,咬牙改了章程——“先发骑,再落账”。信使手捧快折策马出关,仓令站在烽火台上,亲眼看着信使的身影消失在官道烟尘里,这才转身回堂提笔录账。
偏偏那年秋天,信使前脚刚走,仓衙后堂的火盆被夜风掀翻,引发大火。浓烟呛得仓官昏厥,砚池掀翻,半页账纸化为飞灰,出库账目终究没能落笔。而千里之外的京城户部接到了快折,立刻在总册上勾掉了粮食。京城算准了粮已出库,关仓的实物却依旧堆在仓廒里动弹不得。上下两边再度两岔,仓令又因涉嫌“谎报军资、造册无据”被锁拿问讯。
等到第三任老仓令上任,满仓的书吏个个如履薄冰。有人提议“两桩事派两个人同一时辰办”,可凡胎肉眼,谁能保证仓里的笔锋刚点完,门外的马蹄恰好腾空?哪怕差上一弹指的功夫,若突遇地动雷击,依旧是一边成、一边毁。
这道死结,最终被仓衙里一位管了四十年装订的老书吏给解了。
老书吏捧来了一本特制的新账册。账册以厚重的生羊皮为底,最奇特的是,在每一页记账栏的右下角,都用生蚕丝结结实实地缝缀着一只硬纸封袋。封袋与羊皮内页连成一体,非刀剪不能分。
老书吏立下了一道极朴素的新法度:
“往后出粮,仓官不必扯嗓子催快马。你手里的紫毫蘸了墨,只管在这一页账上写定出粮批次;墨迹未干之前,顺手裁下一张同色同印的飞抄,插进页底这只缝死的夹袋里。”
“章程就一条:大账落定,飞抄必在袋中;若是笔悬未决、墨未沾纸,夹袋亦空。写账与入袋,本就同在大人一笔之下,绝无一成一败之理。”
仓官们大为惊疑:“那京城的通报谁来送?”
老书吏指向廊下的一间静室:
“信使再也不必在仓门前候着听差,全去静室歇息。自今日起,设立一个专司‘抽袋’的差役,每隔半个时辰提灯巡查大堂。巡差翻开大账,凡见夹袋里有盖印的飞抄,便将它取走,转交静室的信使起程出关。信使把折子送抵京城、换回回执后,账上的夹袋便用朱笔画上一道圆圈,权作消结。”
新规一出,关仓百年未解的顽疾烟消云散。
哪怕关外狂风席卷、信使中途折返,巡差翻开账本,见夹袋尚未画圈消结,便知此信未达,立刻另发一骑重新补送;哪怕巡差走神、一时怠慢,只要大账安然躺在铁柜中,夹袋里的飞抄便永远不会遗失,待巡差清醒,照样取件发马。
自此三十年,边关风雪依旧,狼烟不断。无论京城的大臣换了多少拨,黑石关的大账与户部的通报,再没有错漏过一升一斗。
——到这儿你大概已经认出来了:老书吏在羊皮账册底下缝出的那个小口袋,正是现代微服务架构与分布式事务中颠扑不破的黄金法则——The Transactional Outbox Pattern(事务性发件箱模式)。
这是什么
在现代分布式微服务架构中,一个业务操作往往需要同时完成两件事:
- 更新本地数据库(如:扣减商品库存、记录用户订单);
- 向消息队列或外部系统发布事件(如:向 Kafka/RabbitMQ 发送
OrderCreated消息,驱动下游物流与积分服务)。
这在架构设计中被称为双写问题(Dual Write Problem)。 在没有分布式事务协调(如重型且性能低下的 2PC)的情况下,两个异构系统(关系型数据库与消息中间件)之间无法保证原子性:
- 先写数据库,后发消息:如果数据库事务提交成功,但在调用消息队列网络发送的瞬间,应用进程崩溃、网络中断或 Broker 宕机,消息将永远丢失,下游系统毫不知情,导致跨服务数据永久不一致;
- 先发消息,后写数据库:如果消息成功发送并被下游消费,但随后本地数据库由于唯一约束冲突、死锁或断电导致事务回滚,下游系统已经执行了不可逆的操作,产生凭空生出的“幽灵事件”。
The Transactional Outbox Pattern(事务性发件箱模式) 提出了一个极其精妙的解法:将跨系统的分布式双写,退守为本地数据库内部的单机事务。
- 同库发件箱(Outbox Table):
在业务数据库中开辟一张专门的
outbox表(如同账册底部的夹页袋)。 - 同事务落盘(Atomic Local Write):
当业务逻辑发生时,应用在同一个本地数据库事务内,同时执行两张表的写入:
- 插入/更新业务数据表(如
orders); - 将准备发送给外部的事件载荷以 JSON 或 Avro 格式,插入本地
outbox表中。 由于身处同一个 ACID 事务,两者受底层数据库严格保障:要么业务变更与出库事件同时提交,要么同时回滚,绝无撕裂可能。
- 插入/更新业务数据表(如
- 独立中继分发(Message Relay / CDC):
一个完全解耦的独立进程(如同那位每隔半个时辰提灯巡查的差役),负责异步读取
outbox表中的待发事件,并将其推送到外部消息代理(Kafka、RabbitMQ 等)。常见的读取机制有两种:- 轮询发布(Polling Publisher):定时扫描
outbox表未发送的记录,发送成功后打上已发送标记或物理删除; - 事务日志尾随(Transaction Log Tailing / CDC):利用 Change Data Capture 技术(如 Debezium、Canal),直接监听并解析数据库底层的 WAL(Write-Ahead Log,如 MySQL Binlog、Postgres WAL),以毫秒级延迟捕获
outbox的变更并推向 Kafka。
- 轮询发布(Polling Publisher):定时扫描
为什么重要
Transactional Outbox 是微服务与事件驱动架构(EDA)中解决数据最终一致性的事实标准:
- 告别脆弱且笨重的分布式事务:它完全不需要侵入性极强、性能低下的两阶段提交(XA/2PC),仅凭普通单机关系型数据库的 ACID 特性,就坚不可摧地守护了事件发布的绝对可靠性。
- 天然的消峰与容灾隔离:即便外部 Kafka 集群全面瘫痪、遭遇维护或网络分区,上游的业务交易也完全不受影响——它们照样可以顺畅地提交订单并把事件写入本地 Outbox 表。待消息代理恢复后,Relay 进程会自动从上次断点处续传补发。
工程实现与避坑准则:
- 至少一次投递(At-Least-Once Delivery):Message Relay 进程可能在成功发送到 Kafka 后、未来得及更新 Outbox 状态的瞬间崩溃。重启后该记录会被重新发送。因此,下游消费者必须具备幂等性(Idempotent Consumer),通过唯一的事件 ID 进行去重。
- 严格事件保序(Order Preservation):若业务要求事件按顺序执行,Outbox 插入时应带有精确自增序号或版本号,CDC 推送时应使用相同的分片键(Kafka Partition Key),避免乱序消费。
- 发件箱防膨胀清理(Outbox Purging):在超高吞吐系统中,若采用物理删除清理已发记录,可能会造成数据库页碎片与高昂的 WAL 压力;若采用更新标记,表体会迅速膨胀数千万行。工业级实践常结合分区表轮换(Partition Dropping)或让 CDC 直接尾随无须持久存留的专用捕获日志。
隐喻对应表
- 常平仓的百万石存粮与领粮官署 → 本地业务数据与核心实体(Order/Account)
- 三十斤重的精铁包角大账 → 本地关系型数据库(ACID Database)
- 八百里外京城户部的总册 → 下游微服务或外部系统的最终视图
- 驰出峡谷飞传消息的轻骑信使 → 消息队列与网络传输通道(Kafka / RabbitMQ)
- 暴风雪导致信使坠崖而大账已写 → 先写 DB 后发消息,因 Broker 故障引发消息永久丢失
- 火灾烧毁仓案而信使早已出关 → 先发消息后写 DB,因事务回滚引发幽灵事件
- 缝在羊皮大账右下角的纸质夹页袋 → 同库持久化的发件箱表(Outbox Table)
- 仓官一笔之下同时落账并塞入飞抄 → 在同一个本地数据库事务中原子写入业务表与 Outbox 表
- 提灯巡堂、按袋取抄的专司差役 → 独立的事件中继或 CDC 进程(Message Relay / Debezium)
- 巡差向静室信使派发飞抄出关 → 将 Outbox 记录投递到消息中间件
- 京城验讫回执后在夹袋画圈销结 → 消息确认发送并标记/清理已完成的 Outbox 记录
- 风暴断道后巡差见未画圈即另发一骑 → 至少一次投递(At-Least-Once Delivery)机制
Beyond the crags of Black Rock Pass in Longxi stood a colossal imperial granary established by the court.
Within its vaults lay stored a million bushels of highland barley and aged wheat, held in reserve against drought and border uprisings. The frontier defiles were jagged and fraught with peril. Whenever grain was drawn from the reserves, the granary magistrate bore two sacred, simultaneous duties: first, to inscribe the exact measure, lot number, and receiving garrison into a thirty-pound iron-bound ledger; and second, to sound the bronze horn and dispatch a mounted courier with a vermillion-sealed memorial eight hundred leagues across the wastes to the Board of Revenue in the capital.
The capital needed the memorial to audit the kingdom’s stores; the granary needed the master ledger to balance the physical bins. The two records had to align without the divergence of a single grain.
Yet this ostensibly routine duty had claimed the careers of three successive magistrates.
The first magistrate was a soldierly man of action. His iron rule was: “Ledger first, courier second.” The ink had to dry and the seal had to press upon the page before the horseman was summoned to the saddle.
Yet the northern steppe was racked by wild blizzards and marauding horse-thieves. One bleak midwinter, immediately after three thousand bushels of winter barley were entered into the iron ledger, the courier rode into a mountain gorge and was swallowed by an avalanche, horse and parchment plunging into the abyss. With the courier lost, the memorial never reached the capital. At the year-end imperial audit, the ministry’s rolls still showed a full million bushels in reserve, while the granary’s own ledger recorded three thousand bushels gone. Imperial censors indicted the magistrate for illicit misappropriation and deceit, and he was stripped of rank in disgrace.
The second magistrate took heed of his predecessor’s downfall and inverted the rule: “Courier first, ledger second.” The messenger galloped out through the sally port with the dispatch, and only after the magistrate watched the dust settle upon the highway from his beacon tower did he return to his desk to ink the ledger.
That autumn, no sooner had the hooves receded than a sudden squall toppled a charcoal brazier in the hall. Smoke choked the clerks, inkwells shattered, and the registry table went up in flames before the stroke of a brush could fall. Miles away, the capital ministry received the rider’s scroll and faithfully struck the grain from the empire’s central ledger. The imperial court counted the harvest delivered, but the grain sat unrecorded, physically trapped within the granary vaults. The numbers parted ways once more, and the magistrate was hauled away in irons for falsifying imperial rolls.
When the third magistrate took office, the scribes walked upon eggshells. Some proposed sending both at the exact same heartbeat. Yet how could mortal men guarantee that the brush touched paper at the identical split-second a horse galloped through the gates? A hair’s breadth of delay amidst an earthquake or lightning strike would once again leave one side accomplished and the other ruined.
The deadlock was finally broken by an elderly head clerk who had bound ledgers in the outpost for forty years.
He produced a newly fashioned ledger bound in heavy cured sheepskin. Most curious of all was its design: along the lower margin of every page, firmly stitched into the sheepskin spine with raw silk thread, hung a stout paper pocket. Pocket and page were one body, inseparable save by the edge of a blade.
The old clerk laid down a simple rule:
“Henceforth, when grain is issued, let no official shout for horses. Take your brush and set down the entry upon the leaf; while the ink is still wet, slice off an exact duplicate voucher bearing the same seal, and slip it into the pocket stitched at the foot of that very page.”
“There is but one law: if the entry is recorded, the voucher must lie in the pocket. If your brush hesitates and no mark is made, the pocket remains bare. The writing of the ledger and the filling of the pocket occur under the identical stroke of your hand. Neither can succeed without the other.”
The scribes gasped: “Then who carries the word to the capital?”
The old clerk pointed toward an antechamber across the courtyard:
“Couriers shall no longer wait upon the magistracy steps; let them rest in the quarters. From this dawn, let one runner be charged with a lantern to inspect the ledger table every half-hour. When he opens the master book and finds a sealed voucher lying within a pocket, he shall take it and dispatch a resting courier out through the pass. When the courier returns with an imperial receipt from the Board of Revenue, let the clerk draw a vermillion circle upon the empty pocket to mark it closed.”
From that day forward, the ancient affliction of Black Rock Pass vanished.
If blinding storms turned a horseman back, the runner inspected the book, saw no vermillion circle upon the pocket, and promptly dispatched a fresh rider with another draft; if the runner fell indolent, the vouchers lay safely entombed within the pages, awaiting his return without loss.
For thirty years, frontier blizzards raged and beacon fires burned. Yet through all the changing of ministers in the capital, the iron ledger of Black Rock Pass and the master scrolls of the empire never diverged by a single measure of grain.
— By now, you have probably recognized the secret stitched into that sheepskin ledger: this is the celebrated cornerstone of microservice architecture and distributed transactions—The Transactional Outbox Pattern.
What it is
In modern distributed microservice architectures, a single business transaction frequently demands two simultaneous actions:
- Mutating the local database (e.g., deducting product inventory, committing an order);
- Publishing an event to a message broker (e.g., dispatching an
OrderCreatedevent to Kafka or RabbitMQ to drive shipping, payments, and notifications).
This dilemma is known throughout software engineering as the Dual Write Problem. Without heavy, brittle distributed coordination (such as 2PC/XA), two heterogeneous storage engines (an ACID database and an asynchronous message broker) cannot participate in a shared atomic transaction:
- Write Database First, Publish Message Second: If the local database commits, but the application crashes, the network severs, or the message broker fails during the outbound call, the event is permanently lost. Downstream microservices remain entirely unaware, inducing silent, irreversible state divergence.
- Publish Message First, Write Database Second: If the message successfully lands in Kafka and downstream consumers process it, but the local database subsequently rolls back due to a constraint violation or power failure, downstream services have already performed actions against a phantom transaction that never truly existed.
The Transactional Outbox Pattern provides an elegant resolution: it retreats from an uncoordinated distributed write into a single, bulletproof local ACID transaction.
- The Outbox Table:
A dedicated
outboxtable is provisioned within the same local business database (the paper pocket stitched into the page). - Atomic Local Write:
When a business operation executes, the application wraps both mutations in a single local database transaction:
- Mutating the domain table (e.g.,
INSERT INTO orders ...); - Inserting the outbound event payload (in JSON or Avro format) into the local
outboxtable. Because both records share a single ACID transaction, the database’s write-ahead logging guarantees atomicity: either both the business state and the outbound message commit together, or both roll back together. Inconsistency is mathematically impossible.
- Mutating the domain table (e.g.,
- Independent Message Relay / CDC:
A separate, asynchronously decoupled background worker (the lantern-bearing runner) reads unprocessed entries from the
outboxtable and delivers them to the external message broker:- Polling Publisher: Periodically querying the
outboxtable for unsent rows, publishing them, and subsequently deleting or flagging them; - Transaction Log Tailing (CDC): Employing Change Data Capture frameworks (such as Debezium) to tail the database’s native WAL (MySQL Binlog, Postgres WAL) directly, streaming mutations out to Kafka with sub-second latency and zero polling overhead on the database engine.
- Polling Publisher: Periodically querying the
Why it matters
The Transactional Outbox Pattern is the industry standard for achieving reliable eventual consistency across event-driven distributed systems:
- Banishing Distributed 2PC Overhead: It eliminates the need for heavyweight, slow, and fault-intolerant distributed two-phase commits (XA). Standard single-node ACID guarantees suffice to protect the entire distributed boundary.
- Fault-Tolerant Resilience: Even if the entire Kafka cluster suffers catastrophic downtime, the upstream service continues to process customer transactions uninterrupted, safely buffering outbound events inside the local database. When Kafka recovers, the relay smoothly catches up from the exact point of interruption.
Engineering Pitfalls & Tradeoffs:
- At-Least-Once Delivery: The Message Relay process may crash immediately after publishing to Kafka but prior to marking the outbox row as processed. Upon reboot, it will re-publish that same message. Therefore, downstream consumers must be strictly idempotent, utilizing unique event identifiers to discard duplicate arrivals.
- Event Ordering: If strict sequential processing is mandated, outbox records must be stamped with monotonically increasing sequence numbers, and published across consistent Kafka partition keys.
- Table Bloat & Cleanup: In high-throughput architectures, retaining millions of historical outbox rows exhausts storage and degrades query execution plans. Production implementations partition the outbox table for swift truncation or rely on CDC streaming to bypass table retention altogether.
Metaphor mapping
- Million bushels of grain in the mountain granary → Business domain entities and local state (Orders/Accounts)
- The thirty-pound iron-bound master ledger → The local ACID-compliant database (PostgreSQL / MySQL)
- The Board of Revenue in the distant capital → Downstream consumer microservices requiring eventual consistency
- Mounted couriers racing across the stormy wastes → The network transport and message brokers (Kafka / RabbitMQ)
- Blizzards killing the rider after the ledger was inked → Dual write failure: DB committed, message lost, downstream out-of-sync
- Brazier fire destroying the registry after the courier rode → Dual write failure: Message sent, DB rolled back, phantom event spawned
- The paper pocket stitched into the foot of the page → The collocated
outboxtable in the same database schema - Inking the ledger entry and slipping the voucher into the pocket in one breath → Atomic local database transaction writing business data and outbox row simultaneously
- The lantern-bearing runner inspecting the books every half-hour → The decoupled Message Relay or CDC worker (Debezium)
- Handing the voucher to couriers in the antechamber → Asynchronously dispatching messages to Kafka
- Vermillion circle drawn upon the pocket after receipt → Acknowledging publication and deleting/marking the outbox record
- Dispatching a second rider if the pocket remains uncircled → At-least-once delivery semantics requiring downstream idempotency