分布式事务没那么可怕:一个老Vim党的实战复盘

林秀英
2025-12-27 15:26
阅读 721

上周五晚上九点半,我还在工位上盯着屏幕发呆。咖啡已经凉透,键盘上还沾着中午外卖的油渍——别问,问就是又双叒叕在处理分布式事务的坑。作为在这家公司摸爬滚打了三年多的老员工(准确说是“试用期员工”,别笑,这公司转正流程能拖到地老天荒),我早就习惯了这种节奏。最近团队在重构核心交易系统,产品经理拍脑袋说要支持“跨服务秒级到账”,结果我们后端组直接被推到了悬崖边上。

说实话,刚接到需求时我心里一万个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 基础设施,搞了一套 “本地消息表 + 可靠消息” 的方案。

核心思想就两点:

  1. 本地事务和消息发送必须原子:在同一个 DB 事务里,既更新业务状态,又插入一条“待发送消息”记录。
  2. 消息消费者必须幂等:同一条消息可能被消费多次,业务逻辑要能扛住重复。

具体流程如下:

  • 下单服务开启本地事务
    • 扣减库存(本地 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝