分类:后端架构 · 标签:分布式事务 后端 架构设计 消息队列
适用读者:正在把单体拆成微服务、第一次遇到「两张表要一起改」的后端工程师;被 2PC、事务消息、Saga、TCC 这几个名词绕晕,想知道它们各自到底承诺什么的人 结论先行:没有一种方案能同时拿到 ACID 的全部四条。本地消息表与 Outbox 用「同库同事务写入 + 至少一次投递 + 消费端幂等」换最终一致;Saga 用「可补偿步骤 + 反向执行」换长流程的可用性,但没有隔离性;TCC 用「预留资源」换接近强一致的隔离,代价是每个参与者都要写三个接口。选型的关键不是选最强的,而是选你能承受哪一种中间态。 阅读时间:约 28 分钟 | 难度:进阶 | 收获:一张可直接用的 outbox 表、relay 与 CDC 两条投递路径、Saga 的补偿设计、TCC 三个经典异常的成因与修法,以及一组真正能报警的观测指标目录
def create_order(user_id, sku_id, amount):
order = Order.objects.create(
user_id=user_id, sku_id=sku_id, amount=amount, status="pending"
)
resp = requests.post(PAYMENT_URL, json={
"order_id": order.id, "amount": str(amount), "user_id": user_id,
}, timeout=3)
if resp.status_code == 200:
Order.objects.filter(id=order.id).update(status="paid")
publish_order_paid(order.id)
else:
order.delete()
update(status="paid") 之前被发布流程的 kill -9 打断了。重启之后没有任何补偿任务知道这笔支付对应的订单该不该补建。
更常见的一种变体是「订单建了,钱没扣」:订单先落库,调用支付超时,客户端重试,支付服务第二次请求才成功;订单库里却出现了两条订单,或者一条订单被扣了两次。反过来也有「消息发了,本地事务回滚了」:publish_order_paid 在 transaction.atomic() 内部被调用,消息已经进了 MQ,后面一个校验失败导致事务回滚,下游收到一条指向不存在订单的事件。
这三个方向看起来是三种 bug,本质是同一个:两个本地事务之间不存在原子性,而中间的远程调用又不可回滚。 你不能用「调用返回 200」推断「对方已经提交」,也不能用「我这边提交成功」推断「消息已经送达」。任何一个两步操作,只要中间有网络,就有窗口。
所以分布式事务要解决的从来不是「怎么让两个数据库一起提交」,而是「在一个窗口无法消除的系统里,怎么让最终结果可控」。下面四套方案,是四种不同的「可控」定义。
xa_start、xa_end、xa_prepare、xa_commit、xa_rollback、xa_recover 就是这套接口。2PC 可以不走 XA,XA 也只是 2PC 的一种实现载体。
在单体应用、同一个机房、两三个都支持 XA 的资源之间,它确实能工作。但搬到微服务里,会撞上四堵墙。
第一堵是同步阻塞。参与者 prepare 成功之后,必须持有资源锁直到 commit。这段时间里,行锁不放、连接不还、undo 不能清理。跨服务调用的事务持续时间等于「网络往返 + 下游业务耗时 + 重试等待」,没人能保证它是毫秒级。一个下游抖三秒,上游所有相关事务的连接就被按住三秒。
第二堵是协调者单点。prepare 之后协调者崩溃,参与者会停在 prepared 状态,既不能自己提交也不能自己回滚,只能等协调者恢复后读日志重放。协调者放在哪里?做成一个独立服务,它自己又成了需要高可用的单点;放进某个业务服务里,这个服务重启就会把一批 prepared 事务挂住。
第三堵是参与者长时间持锁。微服务里参与者往往不是数据库,而是一个 HTTP 服务。你很难让一个 HTTP 服务「prepare 一下,先别提交,等我通知」,除非它自己实现了一套事务管理器。而它一旦这么做,这个服务就不再是自治的,它的数据库连接、行锁、幂等状态都要为跨服务事务让路。
第四堵是异构资源。支付渠道、短信网关、对象存储、Redis,大多不支持 XA。你无法让第三方支付「prepare 后等待 commit」。即使只差一个参与者,整个 2PC 也退化成「大部分能锁、一个锁不住」,最后仍然要靠补偿。
还有一个经常被忽略的结果:commit 阶段如果部分参与者失败,系统会进入「混合结果」(heuristic outcome)。协调者只能记录,不能自动修复。这不是实现缺陷,是协议在分区下的必然。CAP 面前,2PC 选择一致性,代价就是可用性和恢复复杂度。
所以在微服务里,2PC 适合的场景非常窄:参与者少、事务短、同机房、全部支持 XA、并且你能接受协调者故障时的人工介入。绝大多数「下单扣库存发券」的长流程,都不在这个范围里。
CREATE TABLE outbox_event (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
locked_at TIMESTAMPTZ,
locked_by TEXT
);
CREATE INDEX outbox_ready_idx
ON outbox_event (status, available_at, id);
CREATE INDEX outbox_aggregate_idx
ON outbox_event (aggregate_type, aggregate_id, id);
event_id 是给消费端做幂等用的,必须在生产端生成,不能等消费端自己造。aggregate_id 决定分区键和顺序边界。available_at 让重试退避不需要额外的调度表。schema_version 是给未来留的退路:事件格式一定会变,消费端需要知道自己在读第几版。
from django.db import transaction
from django.utils import timezone
import uuid
class OutboxEvent(models.Model):
event_id = models.UUIDField(default=uuid.uuid4, unique=True)
aggregate_type = models.CharField(max_length=64)
aggregate_id = models.CharField(max_length=64)
event_type = models.CharField(max_length=64)
schema_version = models.PositiveIntegerField(default=1)
payload = models.JSONField()
occurred_at = models.DateTimeField(auto_now_add=True)
available_at = models.DateTimeField(default=timezone.now)
status = models.CharField(max_length=16, default="pending")
attempts = models.PositiveIntegerField(default=0)
last_error = models.TextField(blank=True)
locked_at = models.DateTimeField(null=True, blank=True)
locked_by = models.CharField(max_length=64, blank=True)
def create_order(user_id, sku_id, amount):
with transaction.atomic():
order = Order.objects.create(
user_id=user_id, sku_id=sku_id, amount=amount, status="pending"
)
OutboxEvent.objects.create(
aggregate_type="order",
aggregate_id=str(order.pk),
event_type="order.created",
payload={
"order_id": order.pk,
"user_id": user_id,
"sku_id": sku_id,
"amount": str(amount),
"currency": "CNY",
},
)
return order
transaction.atomic(),或者开了 ATOMIC_REQUESTS,内层的 outbox 写入会自动加入外层事务,不需要额外处理。反过来,如果你在事务里调用支付、发短信、写 Redis,那么事务的持续时间就被这些 IO 拉长,行锁和连接被一起拖住。outbox 的写入应该尽量靠近事务末尾,并且事务里只做数据库操作。
这里有一个很容易踩的坑:用 transaction.on_commit 发消息。on_commit 解决的是「事务提交后才执行副作用」,它确实能避免「事务回滚了消息却发出去」。但它不能避免「事务提交了,回调还没执行,进程就崩了」。回调只存在于当前进程的内存里,进程一死,消息就是丢了。on_commit 适合发邮件、清缓存这类丢了可以接受的副作用;不适合作为跨服务事件的唯一通道。
payload 也有几条纪律。不要把 ORM 对象、文件句柄、datetime 对象直接塞进去,JSON 序列化会失败或者产生不可控的格式;金额用字符串或整数分,不要用浮点;事件里只放消费端真正需要的字段,不要把整行数据复制过去,否则上游加一列、下游的 schema 就炸。事件是契约,不是数据库快照。
FOR UPDATE SKIP LOCKED。这是 PostgreSQL 9.5 引入、MySQL 8.0 也支持的语法:每个 relay 实例锁住自己抓到的那批行,其他实例直接跳过被锁的行,不会互相阻塞。
WITH picked AS (
SELECT id
FROM outbox_event
WHERE status = 'pending'
AND available_at <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 100
)
UPDATE outbox_event o
SET status = 'sending',
locked_at = now(),
locked_by = :worker_id
FROM picked p
WHERE o.id = p.id
RETURNING o.id, o.event_id, o.event_type, o.payload;
def relay_once(worker_id, batch_size=100):
with transaction.atomic():
rows = list(
OutboxEvent.objects.select_for_update(skip_locked=True)
.filter(status="pending", available_at__lte=timezone.now())
.order_by("id")[:batch_size]
)
ids = [row.id for row in rows]
if not ids:
return 0
OutboxEvent.objects.filter(id__in=ids).update(
status="sending",
locked_at=timezone.now(),
locked_by=worker_id,
)
for row in rows:
try:
body = dict(row.payload)
body["event_id"] = str(row.event_id)
producer.send(
topic="order-events",
key=row.aggregate_id,
value=body,
)
OutboxEvent.objects.filter(id=row.id).update(status="sent")
except Exception as exc:
attempts = row.attempts + 1
delay = min(pow(2, attempts), 3600)
OutboxEvent.objects.filter(id=row.id).update(
status="pending",
attempts=attempts,
last_error=str(exc)[:500],
available_at=timezone.now() + timedelta(seconds=delay),
)
return len(rows)
status='sending',进程在 producer.send 之前被杀,这一行就永远停在 sending。修法是租约:locked_at 超过一个阈值(比如五分钟)就视为过期,由一个清理任务把它改回 pending。代价是可能重复发送,但这正是「至少一次」的含义。你也可以把发送放在事务内,但那样会在持有行锁的同时等网络,并且一旦发送成功、事务回滚,消息就变成了幽灵。两害相权,重复发送比丢消息便宜。
轮询的延迟由轮询间隔决定,吞吐由「每批行数 / 每轮耗时」决定。如果事件到达速率是每秒 λ 条,单轮处理 b 条、耗时 t 秒,那么 relay 的服务率大约是 b/t;要保证积压不增长,必须让 b/t 明显大于 λ,并留出余量给下游抖动。这个估算不需要压测,用小学除法就够了,但它能提前告诉你「batch=10、每秒轮询一次」在峰值时一定会堆积。
CDC 是另一条路径。Debezium 的 PostgreSQL connector 通过 logical decoding 读 WAL,MySQL connector 读 binlog,把 outbox 表的每一行变化转成事件。业务代码不用写 relay,延迟接近实时,也不需要在数据库上反复扫表。
{
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.dbname": "shop",
"table.include.list": "public.outbox_event",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.id": "event_id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.payload": "payload"
}
SKIP LOCKED 抢行,谁先发出去没有保证;即使 outbox 表按 id 排序,并发发送也会乱序。要让同一个订单的事件有序,必须用 aggregate_id 作为 MQ 的 key,让同一聚合根的事件进同一个分区,并且每个分区只有一个消费者。跨聚合根的全局顺序,本来就不该是业务依赖的东西。
event_id 唯一,插入成功才执行业务:
class ProcessedEvent(models.Model):
event_id = models.UUIDField(unique=True)
consumer = models.CharField(max_length=64)
processed_at = models.DateTimeField(auto_now_add=True)
def handle_order_created(event):
with transaction.atomic():
_, created = ProcessedEvent.objects.get_or_create(
event_id=event["event_id"],
defaults={"consumer": "inventory-service"},
)
if not created:
return
Inventory.objects.filter(sku_id=event["sku_id"]).update(
reserved=F("reserved") + 1
)
get_or_create 内部会处理唯一键冲突:先查,查不到就插,插入撞唯一键就再查一次。它把「检查」和「写入」放在同一个事务里,但并发下仍可能两个请求都走到插入,其中一个拿到 IntegrityError 后重新查询,最终只有一个 created=True。这比「先查再写」的两段式可靠,因为两段式在查和写之间有窗口。
如果业务表本身就有天然唯一键,可以不用去重表。比如「支付成功」事件要改订单状态:
UPDATE "order"
SET status = 'paid', paid_at = now()
WHERE id = :order_id AND status = 'pending';
rowcount 就是幂等信号:第一次执行返回 1,重复执行返回 0。把状态机迁移本身当成幂等键,比额外维护一张表更省事,但它要求所有写入都走条件更新,不能有人绕过状态机直接 UPDATE status='paid'。
去重表会一直增长。保留多久取决于重投窗口:MQ 的最长重试时间、relay 的租约时间、CDC 的重放范围。超过这个窗口的重复基本不会发生,可以按 processed_at 定期归档。不要用「永远保留」来换安全感,那张表最终会比业务表还大。
还有一点:消费端的 offset 提交必须在业务事务之后。 如果先提交 offset 再处理业务,进程崩溃就永久丢消息;如果处理完业务、offset 还没提交就崩溃,消息会重投,由幂等兜住。Kafka 的手动提交、RabbitMQ 的手动 ack 都是这个顺序。更强的一种做法是把 offset 和业务结果写进同一个数据库事务,代价是消费者与消息中间件的耦合变深。
1. 创建订单(status=pending) 补偿:取消订单
2. 锁定库存 补偿:释放库存
3. 锁定优惠券 补偿:解锁优惠券
4. 支付扣款 补偿:退款
5. 发送发货通知 补偿:无法撤销
CREATE TABLE saga_instance (
id UUID PRIMARY KEY,
saga_type TEXT NOT NULL,
business_key TEXT NOT NULL,
state TEXT NOT NULL,
current_step TEXT,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (saga_type, business_key)
);
CREATE TABLE saga_step (
saga_id UUID REFERENCES saga_instance(id),
step_name TEXT NOT NULL,
seq INT NOT NULL,
state TEXT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
PRIMARY KEY (saga_id, step_name)
);
current_step 继续。每一步的业务接口必须用 (saga_id, step_name) 做幂等键,否则恢复时会把同一步做两遍。
协同没有中心状态机,各个服务监听事件、执行本地事务、再发下一个事件。订单服务发 order.created,库存服务扣减后发 inventory.reserved,支付服务收到后扣款。补偿也走事件:order.cancelled 触发库存释放。它的优点是服务之间松耦合,没有单点;缺点是全局状态不可见,出问题时你不知道整条 Saga 卡在哪,只能沿着事件日志一段一段拼。步骤少、团队边界清晰时它很好用;步骤多、补偿关系复杂时,编排更容易维护。
补偿动作有几个必须写进设计里的性质。它是语义补偿,不是数据库回滚:取消订单是把状态改成 cancelled,不是 DELETE;释放库存是加回去,不是把行删掉。它必须幂等,因为补偿也会重试。它可能失败,失败后要重试并告警,不能假设「补偿一定成功」。它还要考虑「补偿覆盖别人写入」的问题,这一点下一节展开。
status='pending' 过滤,它可能开始拣货;如果用户此时取消,Saga 进入补偿,仓库已经把货发出去了。
第二种更隐蔽,是「脏读导致业务误判」。假设库存只剩 1 件:
Saga A: 锁定库存 1 件 -> 库存变为 0 -> 后续步骤失败 -> 准备补偿
Saga B: 此时读到库存 0,判定无货,拒绝用户下单
Saga A: 补偿执行,库存回到 1
UPDATE inventory SET available = 10 WHERE sku_id = 'SKU-1';
UPDATE inventory
SET available = available + 1
WHERE sku_id = 'SKU-1';
status='confirmed' 才允许履约,pending 只是内部状态。读模型过滤掉中间态,用户就不会基于半成品做决策。pending -> paid -> shipped,禁止从 pending 直接发货;补偿也只能走 cancelled,不能删行。CREATE TABLE tcc_record (
business_id TEXT NOT NULL,
participant TEXT NOT NULL,
state TEXT NOT NULL,
amount NUMERIC(18, 2),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (business_id, participant)
);
INSERT INTO tcc_record (business_id, participant, state, amount)
VALUES (:business_id, :participant, 'TRIED', :amount)
ON CONFLICT (business_id, participant) DO NOTHING;
CANCELLED,说明 Cancel 先到了,直接拒绝这次 Try,这就是防悬挂;如果是 TRIED 或 CONFIRMED,按幂等成功返回。
Cancel 写成「插入墓碑或条件更新」:
INSERT INTO tcc_record (business_id, participant, state)
VALUES (:business_id, :participant, 'CANCELLED')
ON CONFLICT (business_id, participant)
DO UPDATE SET state = 'CANCELLED'
WHERE tcc_record.state = 'TRIED';
TRIED,释放冻结额度并置为 CANCELLED;如果已经是 CANCELLED 或 CONFIRMED,直接返回成功,这就是幂等。关键点是释放额度这一步必须和状态迁移在同一个事务里,用条件更新保证只有一行能成功,不能先释放再改状态。
Confirm 只允许 TRIED -> CONFIRMED。如果记录不存在,说明 Try 没成功,不能凭空确认;如果已经是 CONFIRMED,幂等返回。Confirm 失败要重试,不能直接放弃,因为资源已经被预留,不确认也不取消就会一直冻着。
这套状态机就是业务 fencing:用数据库的唯一约束和条件更新,把「谁先到」变成「谁的状态迁移合法」。它比在应用层加锁可靠,因为锁会随着进程崩溃消失,而状态行不会。
| 方案 | 一致性 | 隔离性 | 业务侵入 | 失败处理 | 适用场景 |
|---|---|---|---|---|---|
| 2PC / XA | 提交前强一致,提交后可能混合结果 | 有,靠锁 | 低,依赖中间件 | 协调者恢复、人工介入 | 同库多资源、短事务、参与者都支持 XA |
| 本地消息表 / Outbox | 最终一致 | 无 | 中,建表加 relay | 重试、退避、死信、消费幂等 | 异步解耦、事件驱动、允许秒级到分钟级延迟 |
| Saga | 最终一致 | 无,只有语义锁 | 中高,补偿接口加状态机 | 补偿重试、人工兜底 | 长流程、跨多个服务、参与者无法预留 |
| TCC | Confirm 后最终一致 | 资源预留级隔离 | 高,每个参与者三个接口 | 空回滚、幂等、防悬挂 | 资金、库存等不能超卖的关键资源 |
SELECT status, count(*) AS rows, min(occurred_at) AS oldest
FROM outbox_event
GROUP BY status;
SELECT count(*) AS stuck
FROM outbox_event
WHERE status = 'pending'
AND available_at < now() - interval '5 minutes';
now() - min(occurred_at) 做告警阈值,才和用户体验对得上。
relay 的吞吐要用「到达速率 vs 服务速率」比较。服务速率的粗估是 batch_size / 单轮耗时,单轮耗时包含抢锁、发送、更新状态。若到达速率接近服务速率,积压会线性增长;要留出余量给下游抖动。还可以看 attempts 的分布:如果大量事件停在 attempts=5 以上,说明下游有持续故障,不是偶发抖动。
死信要有独立的计数和年龄。不要把「重试到最大次数」当成处理完成,那只是把问题从 outbox 挪到了没人看的地方。死信队列的告警应该和业务告警同级,因为它意味着有一笔业务永远没有完成。
消费端看三个指标:消费 lag、去重命中数、业务失败数。去重命中数突然升高,通常意味着上游在重投,或者 relay 在重复发送;业务失败数升高,可能是事件 schema 变了,也可能是下游依赖挂了。
Saga 看「按状态分组的实例数」和「最老的 running 实例年龄」:
SELECT state, count(*), min(updated_at) AS oldest
FROM saga_instance
GROUP BY state;
SELECT id, saga_type, current_step, updated_at
FROM saga_instance
WHERE state = 'running'
AND updated_at < now() - interval '10 minutes';
aggregate_id 这个分区键上,并且消费端每个分区只能有一个消费者。跨分区谈全局顺序,是在跟物理约束较劲。
第五个误区是 outbox 表只写不清理。sent 的行会一直占着索引,relay 的扫描范围越来越大,备份越来越慢。按时间归档或删除,保留窗口覆盖审计和重放需求即可。清理任务本身也要幂等,避免和 relay 抢同一批行。
第六个误区是重试没有上限、没有退避。下游一挂,所有 relay 同时重试,把下游打得更挂。指数退避加抖动、最大尝试次数、死信队列,这三件套不是可选项。重试是恢复手段,不是免费的。
payment.succeeded 事件落在支付库的 outbox 表里,订单服务消费这条事件时用 payment_id 做幂等键建单;即使订单服务当时崩了,事件还在,重启后继续消费。它不能保证「钱和订单同时一致」,但能保证「只要支付成功,订单最终会被创建」,并且重复消费不会建出两条订单。
四套方案的边界可以压缩成一句话:2PC 用锁换强一致,但锁的代价在微服务里放大了;outbox 用一张同库表换「事件不丢」,再用消费幂等换「重复无害」;Saga 用补偿换长流程的可用性,但把中间态暴露给了世界;TCC 用预留换隔离,代价是三个接口和三个异常。
真正要设计的不是「选哪个框架」,而是「这笔业务能接受哪种失败形态」。能接受延迟,就 outbox;能接受中间态,就 Saga;不能接受超卖,就 TCC;只能在同一个数据库里短事务,才考虑 2PC。选了之后,把幂等、补偿、死信、观测这四件事补齐,方案才算落地。
专题:后端架构 — Django、并发、限流、灰度与线上部署