分布式事务踩坑三年,我才敢说这几点真管用

Debug到怀疑人生
2026-06-01 13:43
阅读 2930

每天早上8点,北京地铁10号线挤成沙丁鱼罐头的时候,我就在想:今天会不会又有分布式事务的锅甩到我头上?

干了三年大数据开发,天天和Spark打交道,按理说事务这事儿应该离我很远——毕竟Spark本身是无状态的批处理引擎。但现实很骨感。去年开始,我们团队接了个实时数仓项目,既要对接Kafka流数据,又要往MySQL、PostgreSQL甚至MongoDB里写结果,还得保证“要么全成功,要么全失败”。产品经理一句轻飘飘的“数据不能丢也不能重复”,直接把我推进了分布式事务的深水区。

更惨的是,前端同事还时不时跑来问:“后端接口幂等性做好了吗?我们这边重复提交会炸吗?” 好家伙,这锅我背得明明白白。

为什么分布式事务这么难搞?

单机数据库里,一个BEGIN TRANSACTION + COMMIT/ROLLBACK就能搞定的事,在分布式系统里直接变成玄学。你调A服务成功了,调B服务超时了,调C服务返回“网络抖动请重试”……这时候你是回滚还是继续?回滚的话,A服务那边已经扣了用户余额,你咋退?不回滚,用户看到订单失败却扣了钱,分分钟投诉到客服爆满。

我第一次遇到这种问题是在双11前两周。一个促销活动需要同时更新库存、生成订单、发优惠券。结果因为网络波动,订单创建成功,库存没减,优惠券也没发。测试同学拿着截图来找我:“哥,这用户白嫖了。” 那天晚上我改到凌晨三点,咖啡当水喝,最后还是靠手动对账才补救回来。当时真的想砸电脑。

从那以后,我下定决心把分布式事务彻底搞明白。不是为了装逼,纯粹是不想再被半夜叫起来救火。

实战中我们怎么选方案?

市面上主流方案就那么几种:2PC(两阶段提交)、TCC(Try-Confirm-Cancel)、Saga、本地消息表、还有基于MQ的事务消息。每种都有适用场景,没有银弹。

我们最终采用了“本地消息表 + 幂等接口”的组合拳。原因很简单:稳、简单、可维护。作为注重代码可读性的老码农,我实在不想在核心链路里塞一堆复杂的协调器逻辑。

具体怎么做?举个Python的例子:

# 订单服务 - 创建订单并发送消息
def create_order(user_id, items):
    with db.transaction():
        # 1. 创建订单(状态为"处理中")
        order = Order.create(user_id=user_id, status="processing")
        
        # 2. 插入本地消息表
        LocalMessage.create(
            biz_type="inventory_deduct",
            biz_id=order.id,
            payload=json.dumps({"items": items}),
            status="pending"
        )
    
    # 3. 异步任务去消费本地消息,调库存服务
    trigger_inventory_task(order.id)

库存服务那边,则必须实现幂等:

# 库存服务 - 扣减库存(幂等)
@idempotent(key="inventory_deduct:{biz_id}")
def deduct_inventory(biz_id, items):
    if InventoryLog.exists(biz_id):  # 已处理过,直接返回
        return get_previous_result(biz_id)
    
    with db.transaction():
        # 扣库存逻辑
        for item in items:
            Stock.decrement(item["sku"], item["qty"])
        # 记录日志
        InventoryLog.create(biz_id=biz_id, result="success")
    
    return {"status": "ok"}

这个@idempotent装饰器是我自己写的,底层用Redis做去重,key带TTL。GitHub Copilot帮我省了不少样板代码——它现在连幂等装饰器都能猜个八九不离十,虽然有时候会把biz_id写成business_id让我翻白眼。

为什么不用Seata或RocketMQ事务消息?

有同事提议上Seata,说阿里开源的,大厂背书。我也研究过,但发现几个问题:

  1. 侵入性强:要改数据源配置,加注解,对现有架构改动大;
  2. 运维复杂:得单独部署TC(事务协调器),还要考虑高可用;
  3. 性能开销:2PC本身就有性能瓶颈,高峰期可能拖垮数据库。

至于RocketMQ的事务消息,其实挺香,但我们技术栈里没用RocketMQ(公司主推Kafka),硬切成本太高。而且Kafka本身不支持事务消息语义,虽然可以用“半消息”模拟,但可靠性不如原生支持。

所以,本地消息表虽然土,但胜在可控。所有逻辑都在业务代码里,debug方便,新人接手也快。我们团队有个不成文的规定:能不用中间件解决的问题,就别引入新中间件。毕竟半夜报警的时候,没人想对着一堆看不懂的日志发呆。

前端配合也很关键

很多人以为事务是后端的事,其实前端设计直接影响事务复杂度。

比如,我们要求前端:

  • 提交按钮点击后立即置灰,防止重复点击;
  • 请求带上唯一请求ID(request_id),后端用来做幂等;
  • 超时时间设合理(比如10秒),别让用户无限等待。

有一次,测试发现用户快速双击“支付”按钮,导致创建了两个订单。查日志发现前端没做防重,后端虽然有幂等,但因为两个请求几乎同时到达,Redis锁没生效。后来我们加了前端loading状态+后端分布式锁双重保障,才算彻底解决。

产品经理听不懂“幂等”是什么,但他说:“只要用户不骂就行。” 行吧,目标一致。

生产环境血泪教训

光有方案不够,还得经得起线上考验。分享几个真实踩过的坑:

坑1:消息表没索引,扫表慢成狗

初期本地消息表没建索引,定时任务每5秒扫一次“pending”状态的消息。数据量一上来,CPU直接飙到90%。后来加上(status, create_time)复合索引,世界清净了。

坑2:补偿任务没做失败重试上限

有个服务偶尔500错误,补偿任务一直重试,结果三天后还在疯狂调用,把对方服务打挂了。现在所有异步任务都加了最大重试次数(比如5次),失败后进死信队,人工介入。

坑3:跨时区时间戳乱序

国际业务上线后,发现某些地区的时间戳比服务器早,导致消息被误判为“未来消息”而跳过。解决方案:所有时间统一用UTC,别信客户端时间

下面是我们现在的消息表结构和状态流转:

字段 类型 说明
id BIGINT 主键
biz_type VARCHAR 业务类型,如"order_create"
biz_id VARCHAR 业务ID,用于幂等
payload TEXT JSON格式的业务数据
status ENUM pending/success/failed
retry_count INT 重试次数
next_retry_time DATETIME 下次重试时间
create_time DATETIME 创建时间

状态机很简单:

  • 初始状态:pending
  • 成功:success
  • 失败且未达重试上限:更新retry_count和next_retry_time
  • 失败且达上限:标记failed,告警

写在最后:适合的才是最好的

折腾了快一年,我们的分布式事务方案终于稳定下来。QPS 2000+的场景下,数据一致性达到99.99%,偶尔的异常也能通过监控告警快速发现。

回头看,其实没有所谓“最佳实践”,只有“最适合当前团队和业务的实践”。我们选择本地消息表,是因为团队熟悉SQL、讨厌复杂架构、重视可维护性。如果你团队有专职中间件工程师,上Seata也未尝不可。

最近我还用Copilot生成了个自动对账脚本,每天凌晨跑一遍,检查订单和库存是否一致。虽然它把datetime.utcnow()写成了datetime.now()(忘了时区),但改两行就行——AI终究是工具,人还是得兜底。

对了,下周又要上线新活动。希望这次别再半夜被电话吵醒。如果真出了事……嗯,咖啡已经囤好了。

(完)

评论 0

最热最新
暂无评论
Debug到怀疑人生Lv.1
0
影响力
0
文章
0
粉丝