Life Beyond Distributed Transactions — 大规模系统下放弃跨机事务的宣言
是什么
Pat Helland 在 2007 年写的一篇思想宣言:在「大到一台机器装不下」的系统里,跨机器的事务(distributed transaction)根本不该用。承认这一点,重新设计抽象。
副标题”An Apostate’s Opinion”(叛教者的观点)——Helland 自己在 Tandem、Microsoft、Amazon 做了几十年事务系统,他亲口说”我以前的信仰错了”。
日常类比:传统数据库事务像婚礼上同时锁住所有客人——新郎、新娘、牧师、所有亲友必须同时举手才能继续,少一个人就全场冻结。Helland 说:在 1000 人的大场子里这玩不下去,得改成传纸条——每个人只管自己面前那张桌子,桌子之间靠服务员送便条传消息,便条可能送两次(幂等防重复)。
为什么重要
不理解这篇论文,下面这些事都没法解释:
- 为什么 2010 年之后微服务普遍不用跨服务事务,而用「消息 + 补偿」
- 为什么订单系统下单后会有几秒「处理中」,看不到立即一致的库存数
- 为什么 Kafka、消息队列、event sourcing、Saga、Outbox 这些模式在过去 15 年集体爆发
- 为什么 Amazon 同年发表 Dynamo(dynamo),主动放弃 ACID 选最终一致
Helland 给了一个哲学框架:让架构师有底气说”我不做分布式事务,是因为它本来就不该做”。
核心要点
论文提出五个新抽象,按层叠加:
-
scale-agnostic 编程——假设系统可以无限扩。这一假设逼你立刻面对分片现实,不能再幻想「就一台机器」。
-
entity(实体)——单台机器装得下的最小独立数据单元。entity 内部允许 ACID 事务,跨 entity 严禁。例如:一个用户账户是一个 entity;一个订单是一个 entity;用户余额和订单是两个不同 entity,不能在一个事务里同时改。
-
message(消息)——entity 之间唯一通信方式。投递保证 at-least-once(至少一次,可能重复)。
-
idempotent(幂等)——同一条消息处理 N 次效果与处理 1 次相同。这是替代「事务回滚」的关键武器:消息带唯一 ID,服务端记一张「已处理 ID 表」去重。
-
uncertainty(不确定性)——跨 entity 时,你能看到的永远是过去或最终状态,不存在「此时此刻全局一致」。要么乐观假设接受补偿,要么明确等到最终。
四个抽象搭起来,就是后来所有「微服务 + 事件驱动」的骨架。
实践案例
案例 1:电商下单(论文里反复用的例子)
传统做法(Helland 反对):
BEGIN TRANSACTION; UPDATE inventory SET stock = stock - 1 WHERE sku = 'X'; UPDATE balance SET amount = amount - 100 WHERE user_id = 42; INSERT INTO orders ...;COMMIT;如果 inventory、balance、orders 在三台机器上,需要 2PC(两阶段提交),任何一台慢就全卡。
Helland 做法:
1. 订单服务(entity)本地写一条 PENDING 订单2. 发消息「扣库存请求 #msg-uuid-001」给库存服务(entity)3. 库存服务收到,先查「已处理表」,没见过就扣库存 + 记 ID;见过就忽略4. 回消息「已扣 / 没货」5. 订单状态推进到 CONFIRMED 或 CANCELLED每步只动一个 entity。整个流程叫 activity——不是事务,是一组带因果的消息。
案例 2:幂等的实现细节
# 库存服务伪码def handle_deduct_stock(msg): if processed_ids.contains(msg.id): # 已处理表去重 return last_result_for(msg.id) with local_transaction(): # entity 内部允许 ACID if stock[msg.sku] < msg.qty: result = "OUT_OF_STOCK" else: stock[msg.sku] -= msg.qty result = "OK" processed_ids.add(msg.id) save_result(msg.id, result) return result关键:已处理 ID 表 + 本地事务。重复消息进来直接返上次结果,业务无感。
案例 3:uncertainty 怎么落地
用户看商品页面,库存显示 30 件。等他点下单,可能已经被别人买光。
- 传统做法:下单时再次锁库存检查
- Helland 做法:乐观下单,订单进入 PENDING;后台 activity 去库存 entity 真扣;扣不到就发「取消」消息回订单 entity;用户看到「下单失败,已退款」
界面上几秒「处理中」就是 uncertainty 的可见形态。
踩过的坑
-
entity 切错了:把「订单 + 用户余额」放一个 entity 看似省事,规模一上来这个 entity 就成热点。切分原则:按业务自然边界(一个用户、一笔订单、一台设备),不按表。
-
以为幂等=函数纯:幂等是对同一请求 ID 处理 N 次结果不变,需要存「已处理 ID + 上次结果」。光让函数无副作用没用——网络重发还是会触发重复扣款。
-
以为放弃事务=放弃正确性:错。Helland 没说「数据可以乱」,他说「正确性靠业务补偿(saga,saga-1987)+ 最终一致达成」。订单系统照样不会少扣钱,只是路径变了。
-
把 activity 当事务:activity 没有原子性,可能跑到一半留下中间状态。设计时必须明确每一步失败如何补偿,这是 saga 模式做的事。
-
忽视消息顺序与重排:at-least-once 投递 + 多消费者,消息可能乱序到达。entity 必须能处理「先收到取消、再收到下单」这种逆序——通常靠版本号或状态机拒绝非法转换。
适用 vs 不适用场景
适用:
- 数据量超过单库容量,必须分片的系统(电商、社交、IoT、SaaS 多租户)
- 跨服务调用频繁,2PC 延迟不可接受的微服务架构
- 业务允许「最终一致」的场景(订单、库存、账户余额、消息推送)
不适用:
- 强一致硬要求(银行转账、证券交易核心账本)→ 用 spanner-2012 那种全球时钟方案,或老老实实单库
- 数据规模远未到瓶颈、单库扛得住 → 别提前优化,传统 ACID 简单可靠
- 业务无法定义补偿动作(无法回退的物理动作,比如发火箭)→ 需要更强协议
历史小故事(可跳过)
- 2000 年:Eric Brewer 提出 CAP 猜想(brewer-cap-2000)
- 2002 年:Gilbert-Lynch 形式化证明 CAP(gilbert-lynch-2002)
- 2007 年:Helland 这篇 + Amazon Dynamo(dynamo)同年发表,工业界开始系统性放弃 2PC
- 2009 年:Werner Vogels 写《Eventually Consistent》(vogels-eventual-2009),Amazon CTO 官方背书
- 2012 年:Google Spanner(spanner-2012)反向证明:投巨资 + 原子钟,跨洲事务也能做。但代价惊人,反衬 Helland 当年判断的合理性
- 2014 年起:微服务、event sourcing、CQRS、Saga 模式集体出圈,Helland 的抽象成为业界默认词汇
学到什么
- scale 改变了什么是「正确」——单机时代「事务能解决一切」是真理;规模一变,这个真理失效,得换新抽象
- entity + message + idempotent 三件套就足以搭出今天 90% 的分布式业务系统
- 不确定性是一等公民,不是可以隐藏的实现细节;设计时就得让它出现在 API 和 UI 上
- 理论先于工程:CAP 给了下界,Helland 给了正面构造,Dynamo / Kafka / Spanner 是工程展开
- 承认局限是设计的开始——「这件事做不到」往往比「这件事一定要做到」更解放架构
延伸阅读
- 论文 PDF(10 页,叙事式没多少公式):Helland 2007 CIDR
- Helland 后续访谈:InfoQ — Pat Helland on Memories, Guesses, and Apologies(把”unc 不确定性”讲得更白)
- Martin Kleppmann《Designing Data-Intensive Applications》第 11、12 章——用 Helland 框架重讲事件驱动
- brewer-cap-2000 —— CAP 定理的源头
- dynamo —— Amazon 同年的工程对照
- vogels-eventual-2009 —— 最终一致性的官方背书
- saga-1987 —— 长事务拆补偿的早期工作
- spanner-2012 —— Google 给出的反例
- kafka-2011 —— 事件驱动消息基础设施