分布式事务没那么可怕:一个老Vim党的实战复盘
上周五晚上九点半,我还在工位上盯着屏幕发呆。咖啡已经凉透,键盘上还沾着中午外卖的油渍——别问,问就是又双叒叕在处理分布式事务的坑。作为在这家公司摸爬滚打了三年多的老员工(准确说是“试用期员工”,别笑,这公司转正流程能拖到地老天荒),我早就习惯了这种节奏。最近团队在重构核心交易系统,产品经理拍脑袋说要支持“跨服务秒级到账”,结果我们后端组直接被推到了悬崖边上。
说实话,刚接到需求时我心里一万个MMP。分布式事务?那不是面试官最爱问的“送命题”吗?结果现在真要在生产环境里搞,而且还是用 JavaScript 写的微服务(没错,我们是个 Node.js 技术栈为主的团队)。作为一个重度 Vim 用户、对 VS Code 深恶痛绝的“复古派”,我连调试器都懒得开,全靠 console.log 和直觉排查问题——但这次,光靠直觉可不行。
为什么我们非得搞分布式事务?
先说背景。我们原来的订单系统是单体架构,所有操作都在一个数据库事务里完成:扣库存、生成订单、扣用户余额、发通知……一套行云流水。但随着业务膨胀,系统越来越臃肿,去年双11差点把 DB 干崩。于是架构组拍板拆微服务:库存服务、账户服务、订单服务、消息服务……各司其职。
理想很丰满,现实很骨感。拆完之后,一个下单操作要跨 4 个服务,每个服务都有自己的数据库。如果中间某个环节失败了(比如账户服务扣款超时),前面的操作怎么回滚?总不能让用户付了钱却没收到货,或者库存扣了但订单没生成吧?这就是典型的分布式事务问题。
更扎心的是,隔壁 Java 组早就用上了 Seata,Go 组也在试 TCC,而我们 Node.js 组还在手搓 promise.all + try-catch。面试题挑战里经常问“如何保证分布式一致性”,结果我们自己连基础方案都没落地。
从两阶段提交到 Saga:踩过的坑比写过的代码还多
一开始,我们天真地想用 2PC(两阶段提交)。逻辑很简单:协调者先问所有参与者“准备好了吗?”,大家都说 OK,再统一提交。听起来完美,对吧?
但上线第一天就炸了。
原因?2PC 的同步阻塞特性在高并发下简直是灾难。库存服务响应慢了 200ms,整个链路就卡住,连接池瞬间打满。运维小哥半夜打电话骂我:“你这代码是不是故意的?CPU 飙到 90%!”
我当时真想砸电脑。
后来我们转向 Saga 模式。Saga 的核心思想是“补偿”:每个正向操作都配一个反向补偿操作。比如“扣库存”的补偿是“加库存”,“扣余额”的补偿是“加余额”。一旦某步失败,就按相反顺序执行补偿。
听起来很优雅,但在 JavaScript 异步环境下实现起来简直反人类。
举个例子:
// 伪代码:下单流程
async function createOrder(userId, productId) {
const stockResult = await deductStock(productId);
if (!stockResult.success) throw new Error('库存不足');
const balanceResult = await deductBalance(userId, amount);
if (!balanceResult.success) {
// 得补偿库存!
await compensateStock(productId);
throw new Error('余额不足');
}
const orderResult = await createOrderRecord(...);
if (!orderResult.success) {
// 得补偿库存和余额!
await compensateStock(productId);
await compensateBalance(userId, amount);
throw new Error('订单创建失败');
}
return orderResult;
}
看到没?每加一个步骤,补偿逻辑就得手动维护。而且如果补偿本身也失败了(比如网络抖动),那就彻底乱套。我们 QA 小姐姐测出一个 case:补偿库存成功了,但补偿余额失败了,结果用户钱没扣,货也没发——两边都亏!
更可怕的是,这种代码根本没法单元测试全覆盖。你永远不知道线上哪个补偿会突然失联。
最终选择:基于事件驱动的可靠消息模式
痛定思痛,我们决定换思路。参考了阿里 RocketMQ 的事务消息机制,结合我们现有的 Kafka 基础设施,搞了一套 “本地消息表 + 可靠消息” 的方案。
核心思想就两点:
- 本地事务和消息发送必须原子:在同一个 DB 事务里,既更新业务状态,又插入一条“待发送消息”记录。
- 消息消费者必须幂等:同一条消息可能被消费多次,业务逻辑要能扛住重复。
具体流程如下:
- 下单服务开启本地事务
- 扣减库存(本地 DB)
- 插入“待发送:扣余额”消息到本地消息表
- 提交事务
- 后台有个“消息投递服务”,定期扫描本地消息表,把消息发到 Kafka
- 账户服务消费 Kafka 消息,执行扣余额(自身也是幂等操作)
- 如果成功,回复 ACK;如果失败,消息会重试(最多 N 次,然后进死信队列人工处理)
这个方案最大的好处是:解耦了业务逻辑和事务协调。每个服务只关心自己的本地事务,通过消息异步推进全局状态。
而且特别适合 JavaScript 的异步非阻塞风格!我们用 TypeORM 的事务装饰器轻松搞定本地事务:
@Transactional()
async handleOrderCreation(orderData: OrderData) {
// 1. 扣库存(本地操作)
await this.stockRepo.decrement(orderData.productId);
// 2. 记录待发送消息(同一事务)
await this.outboxRepo.save({
eventType: 'BALANCE_DEDUCT',
payload: { userId: orderData.userId, amount: orderData.amount },
status: 'PENDING'
});
// 3. 返回,事务自动提交
}
消息投递服务用 Bull 队列 + 定时任务轮询,轻量又稳定。半年来线上零事故,连运维都说“你们组最近省心多了”。
性能与可观测性:生产环境的真实考量
当然,光有方案不够,还得考虑性能和运维。
我们做了几件事:
- 本地消息表分片:按 user_id hash 分 16 个表,避免单表过大
- 消息去重:消费者端用 Redis 记录已处理的消息 ID,TTL 24 小时
- 死信监控:失败消息自动进 Grafana 告警,值班群机器人 @ 相关人
- 延迟容忍:业务允许最终一致性(比如 5 秒内到账即可),所以不追求强实时
性能测试数据也很漂亮(压测环境,8 核 16G):
| 方案 | TPS | 平均延迟 | 失败率 |
|---|---|---|---|
| 手动补偿 (旧) | 120 | 850ms | 0.8% |
| Saga (尝试) | 180 | 620ms | 0.5% |
| 可靠消息 (当前) | 450 | 210ms | 0.02% |
最关键的是,代码可读性和可维护性大幅提升。以前那个嵌套十层的 try-catch 补偿逻辑,现在变成了清晰的事件流。作为一个注重代码整洁的人,看到现在的 Git diff 我都能笑出声。
给想跳槽的兄弟们一点建议
写这篇文章的时候,其实我正悄悄更新简历。三年多没换工作,技术栈有点固化,想看看外面的世界。但这段分布式事务的实战经历,绝对是我简历上的亮点。
如果你也在准备面试,别光背“2PC vs 3PC vs TCC”的理论。面试官更想知道你在线上怎么解决实际问题。比如:
- 你怎么处理消息重复消费?
- 补偿操作失败了怎么办?
- 如何保证本地事务和消息发送的原子性?
这些问题,我在项目里都踩过坑,也找到了符合 JavaScript 生态的解法。技术分享的意义,不就是把踩过的坑变成别人的垫脚石吗?
最后说句心里话:分布式事务没有银弹,只有最适合你业务场景的方案。别迷信大厂方案,也别被面试题吓住。代码写出来能跑、能维护、能扛住流量,就是好代码——哪怕你是用 Vim 写的,而不是 IDEA。
哦对了,今天 HR 又问我“转正材料准备好了吗?”。我笑了笑,继续敲我的 :wq。

评论 0