分布式事务踩坑三年,我才敢说这几点真管用
每天早上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,说阿里开源的,大厂背书。我也研究过,但发现几个问题:
- 侵入性强:要改数据源配置,加注解,对现有架构改动大;
- 运维复杂:得单独部署TC(事务协调器),还要考虑高可用;
- 性能开销: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