跳转到内容

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 给了一个哲学框架:让架构师有底气说”我不做分布式事务,是因为它本来就不该做”。

核心要点

论文提出五个新抽象,按层叠加:

  1. scale-agnostic 编程——假设系统可以无限扩。这一假设逼你立刻面对分片现实,不能再幻想「就一台机器」。

  2. entity(实体)——单台机器装得下的最小独立数据单元。entity 内部允许 ACID 事务,跨 entity 严禁。例如:一个用户账户是一个 entity;一个订单是一个 entity;用户余额和订单是两个不同 entity,不能在一个事务里同时改。

  3. message(消息)——entity 之间唯一通信方式。投递保证 at-least-once(至少一次,可能重复)。

  4. idempotent(幂等)——同一条消息处理 N 次效果与处理 1 次相同。这是替代「事务回滚」的关键武器:消息带唯一 ID,服务端记一张「已处理 ID 表」去重。

  5. 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 的可见形态。

踩过的坑

  1. entity 切错了:把「订单 + 用户余额」放一个 entity 看似省事,规模一上来这个 entity 就成热点。切分原则:按业务自然边界(一个用户、一笔订单、一台设备),不按表。

  2. 以为幂等=函数纯:幂等是对同一请求 ID 处理 N 次结果不变,需要存「已处理 ID + 上次结果」。光让函数无副作用没用——网络重发还是会触发重复扣款。

  3. 以为放弃事务=放弃正确性:错。Helland 没说「数据可以乱」,他说「正确性靠业务补偿(saga,saga-1987)+ 最终一致达成」。订单系统照样不会少扣钱,只是路径变了。

  4. 把 activity 当事务:activity 没有原子性,可能跑到一半留下中间状态。设计时必须明确每一步失败如何补偿,这是 saga 模式做的事。

  5. 忽视消息顺序与重排: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 的抽象成为业界默认词汇

学到什么

  1. scale 改变了什么是「正确」——单机时代「事务能解决一切」是真理;规模一变,这个真理失效,得换新抽象
  2. entity + message + idempotent 三件套就足以搭出今天 90% 的分布式业务系统
  3. 不确定性是一等公民,不是可以隐藏的实现细节;设计时就得让它出现在 API 和 UI 上
  4. 理论先于工程:CAP 给了下界,Helland 给了正面构造,Dynamo / Kafka / Spanner 是工程展开
  5. 承认局限是设计的开始——「这件事做不到」往往比「这件事一定要做到」更解放架构

延伸阅读