分布式事务解决方案:最佳实践
上周五晚上十点半,我坐在工位上啃着已经凉透的炸鸡,盯着屏幕上一行又一行的报错日志,心里只有一个念头:“这破系统怎么又崩了?”
别误会,我不是在抱怨公司——虽然确实挺想吐槽产品经理又临时改需求。但说到底,还是自己技术没到位。事情是这样的:我们组正在做一个新业务,需要同时更新用户账户余额和订单状态。听起来很简单对吧?但当你把这两个操作拆到两个不同的微服务里、跑在不同数据库实例上时,问题就来了。
作为一个背着房贷、刚在北京安家落户的北漂程序员,我可不想因为线上事故被叫去“喝茶”。所以最近这段时间,除了白天写业务代码、晚上刷 LeetCode 准备跳槽之外,我还得抽空研究分布式事务的各种方案。今天这篇技术分享,就是我在踩了一堆坑之后总结出来的一点经验,希望能帮到同样在挣扎的你。
被现实毒打:从单体到分布式的“甜蜜陷阱”
一年前我们还在用单体架构,所有逻辑都在一个数据库里跑,事务管理简单粗暴:BEGIN、COMMIT、ROLLBACK 三板斧搞定一切。但随着业务增长(其实是老板画的大饼越来越大),我们被迫拆分成多个微服务。于是,原本在一个事务里的操作,现在要跨服务、跨库执行。
举个具体例子:用户下单时,要扣减库存、创建订单、冻结账户余额。这三个动作分别由 inventory-service、order-service 和 account-service 处理。如果其中任何一个失败,其他两个必须回滚。否则轻则数据不一致,重则用户白拿商品、公司血亏——这可不是闹着玩的,毕竟我每个月还得还房贷呢。
一开始我以为加个 try-catch 就行了:
// 别学我!这是反面教材
try {
await inventoryService.deduct(itemId, count);
await orderService.create(order);
await accountService.freeze(userId, amount);
} catch (e) {
// 回滚?
}
但问题来了:你怎么回滚?
inventoryService已经扣了库存,怎么“反扣”?orderService创建了订单,难道要删掉?那如果删的时候又失败了呢?- 更别提网络超时、服务宕机这些“日常惊喜”了。
这时候我才意识到:本地事务的思维在分布式世界里根本行不通。
方案选型:不是越高级越好,而是越合适越好
市面上常见的分布式事务方案有好几种:2PC(两阶段提交)、TCC、Saga、本地消息表、MQ 事务消息等。每种都有优缺点,关键看你的业务场景和团队技术栈。
我们团队主要用 Node.js(对,就是 JavaScript 后端),数据库是 MySQL + Redis,消息队列用的是 Kafka。考虑到开发效率、维护成本和现有技术栈,我最终排除了 2PC(太重,性能差)和 TCC(侵入性强,改造成本高),重点评估了 Saga 模式 和 基于本地消息表的最终一致性方案。
为什么没选 TCC?
TCC 要求每个业务操作都提供 Try、Confirm、Cancel 三个接口。比如扣库存,你得先 Try 预占库存,成功后再 Confirm 真正扣减,失败则 Cancel 释放预占。听起来很严谨,但实际落地时:
- 每个接口都要写三倍代码
- 测试复杂度爆炸(要考虑各种中间状态)
- 我们的产品经理连需求文档都写不清,更别说配合设计这种精细流程了
结论:理想很丰满,现实很骨感。
最终选择:本地消息表 + 异步补偿
这个方案的核心思想是:把分布式事务拆成一系列本地事务 + 异步消息驱动。
以用户下单为例:
- 在
order-service中,开启本地事务:- 创建订单(状态为“待支付”)
- 同时插入一条“待处理”的消息到本地消息表(
outbox表) - 提交事务
- 后台有个轮询任务(或通过 binlog 监听),发现新消息后,发送到 Kafka
inventory-service和account-service消费消息,执行各自逻辑- 如果某个服务失败,消息会重试,直到成功(或人工介入)
关键点在于:消息的生产和业务操作在同一个本地事务中完成,保证了原子性。
-- outbox 表结构示例
CREATE TABLE outbox (
id BIGINT PRIMARY KEY,
event_type VARCHAR(50),
payload JSON,
status ENUM('pending', 'sent', 'failed'),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
在 Node.js 中,我们可以这样写:
// order.service.js
async function createOrder(orderData) {
const transaction = await db.beginTransaction();
try {
// 1. 创建订单
const orderId = await Order.create(orderData, { transaction });
// 2. 插入消息到 outbox
await OutboxMessage.create({
event_type: 'ORDER_CREATED',
payload: { orderId, userId: orderData.userId, amount: orderData.amount }
}, { transaction });
await transaction.commit();
// 3. 触发消息发送(异步,不影响主流程)
messagePublisher.publishPendingMessages();
return orderId;
} catch (err) {
await transaction.rollback();
throw err;
}
}
这个方案的好处很明显:
- 侵入性小:只需要加一张表,业务代码改动不大
- 可靠性高:消息持久化在 DB,不怕进程挂掉
- 兼容现有架构:我们本来就在用 Kafka,无缝集成
当然也有缺点:最终一致性,不是强一致性。但对于我们的电商场景(非金融级),几秒内的延迟是可以接受的。
踩坑实录:那些让我深夜加班的“惊喜”
理论很美好,落地全是坑。以下是我在生产环境踩过的几个大雷:
坑 1:消息重复消费
Kafka 不保证 exactly-once,消费者可能重复收到同一条消息。结果某天凌晨三点,运维打电话告诉我:“用户账户被扣了三次钱!”
解决方案:幂等性设计!每个消息带唯一 ID,消费者端做去重。
// account.service.js
async function handleOrderCreated(event) {
const { messageId, orderId, userId, amount } = event;
// 检查是否已处理
if (await ProcessedMessage.exists(messageId)) {
return; // 已处理,直接返回
}
await db.transaction(async (tx) => {
await Account.freeze(userId, amount, tx);
await ProcessedMessage.record(messageId, tx); // 标记为已处理
});
}
坑 2:消息堆积导致雪崩
有一次 inventory-service 数据库慢查询,导致消息消费速度跟不上。几小时内积压了 50w+ 消息,整个 Kafka 集群差点挂掉。
教训:必须加监控 + 自动扩缩容。
- 监控 outbox 表中
pending状态的消息数量 - 消费者线程池动态调整
- 设置消息 TTL,超时自动告警
坑 3:本地事务和消息发送的“伪原子”
最初我是在事务提交后再发消息:
await transaction.commit();
await sendMessage(); // 危险!
结果有一次 commit 成功了,但发消息时进程 OOM 挂了,消息丢了。
正确做法:要么用 binlog 监听(如 Debezium),要么确保消息发送是异步触发但不依赖当前进程存活。我们现在用的是定时任务轮询 outbox 表,即使应用重启也能恢复。
性能与资源权衡:别让“完美方案”拖垮系统
作为关注架构设计的程序员,我深知:没有银弹,只有权衡。
本地消息表方案虽然简单,但会增加数据库写压力。我们在压测时发现,高并发下 outbox 表成为瓶颈。于是做了几点优化:
- 批量处理:轮询任务一次取 100 条消息,批量发送
- 分表:按业务类型分表,避免单表过大
- 异步归档:处理成功的消息定期归档到历史表
下面是优化前后的对比(模拟 1000 TPS):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| DB CPU 使用率 | 85% | 45% |
| 消息平均延迟 | 800ms | 120ms |
| 错误率 | 0.5% | 0.02% |
另外,不要为了分布式事务而过度设计。我们有个内部工具服务,数据一致性要求不高,直接用“尽力而为”模式:失败就记日志,人工补单。省下的开发时间,够我多刷两道 LeetCode 了(跳槽要紧啊)。
给 fellow 北漂程序员的建议
写这篇文章的时候,窗外是北京五环外的夜色,房贷还款日还有三天。技术再酷,也得先保证系统稳、饭碗稳。
关于分布式事务,我想说:
- 别盲目追新:Seata、DTF 这些框架固然强大,但如果你的团队只有 3 个人,维护成本可能远大于收益。
- 监控比代码更重要:再完美的方案也会出问题,关键是能快速发现、快速恢复。
- 和产品沟通清楚:很多“必须强一致”的需求,其实是产品经理拍脑袋想的。问问他:“如果延迟 5 秒,用户会跑吗?” 通常答案是否定的。
- 留好退路:所有分布式事务方案都要有人工干预入口。当自动化失效时,运营同学能手动修复数据,比你通宵 debug 强多了。
最后,分享一句我贴在显示器边的话:“一致性是目标,可用性是底线。”
希望这篇技术分享能帮你少熬几个夜。毕竟,咱们北漂不容易,省下的时间,多陪陪家人,或者——多刷几道题准备跳槽涨薪,早日还清房贷,它不香吗?
注:本文所有方案均已在生产环境稳定运行 6 个月+,支撑日均 200w+ 订单。代码细节因涉及公司资产未完全公开,但核心思路通用。如有疑问,欢迎留言讨论(别问为啥不用 RocketMQ,问就是历史包袱 😅)。

评论 0