分布式事务解决方案:最佳实践(一个被线上事故逼出来的安全工程师的血泪总结)
作者:某云原生安全团队远程打工人,日常和漏洞斗智斗勇,周末在家撸代码顺便给K8s集群“驱邪”。
去年双11前夜,我正躺在沙发上刷着《赛博朋克2077》,突然钉钉连环炸——支付模块出事了。用户付了钱,订单没生成;或者订单生成了,库存却没扣。更离谱的是,有用户居然重复下单三次,客服电话直接被打爆。
我们那个“优雅”的微服务架构,在分布式事务面前,彻底翻车了。
作为团队里唯一一个既懂安全又稍微摸过点事务源码的人(其实是因为没人敢碰这坨屎),我被迫从“防御型”安全工程师转型成了“救火型”后端开发。这一搞,就是整整三周。今天这篇不是教程,也不是八股文整理,而是我踩坑、填坑、再踩坑之后,用生产环境血泪换来的分布式事务最佳实践。如果你正在准备面试,或者刚接手一个微服务项目,建议收藏——因为这些坑,你迟早会踩。
为啥分布式事务这么难?别怪我,怪CAP
先说人话:单体应用里,一个数据库事务搞定一切。但拆成微服务后,你的“下单”操作可能涉及订单服务、库存服务、账户服务三个独立数据库。这时候,你怎么保证“要么全成功,要么全失败”?
理论上,XA协议能干这事,但现实是——性能差到亲妈都不认,而且大多数云数据库根本不支持(尤其Serverless DB)。所以我们只能在最终一致性的路上狂奔。
而作为一个每天和漏洞打交道的安全工程师,我最怕的就是“数据不一致”带来的安全风险:比如用户A的钱被扣了,但订单没生成,系统以为他没付,结果他白嫖了商品。这种漏洞,比SQL注入还致命——因为它不报错,悄无声息地亏钱。
方案选型:别听PPT吹,看落地效果
市面上主流方案无非就那几个:
- 两阶段提交(2PC):理论完美,实际慢如蜗牛,还容易锁表。
- TCC(Try-Confirm-Cancel):强一致性,但业务侵入性极高,每个接口都要写三套逻辑。
- Saga模式:事件驱动,适合长流程,但补偿逻辑复杂到想哭。
- 消息队列 + 本地消息表:最终一致性,性价比最高,适合大多数场景。
我们团队最后选了本地消息表 + RocketMQ事务消息的混合方案。原因很简单:快、稳、改得少。产品经理催上线,运维不想加新组件,测试只测主流程——在这种“既要又要还要”的环境下,你只能选最接地气的。
实战:用Java实现高可用的分布式事务
我们的技术栈是 Spring Boot + MyBatis + MySQL + RocketMQ。下面直接上干货。
步骤1:设计本地消息表
CREATE TABLE `local_transaction_msg` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`business_key` VARCHAR(64) NOT NULL COMMENT '业务唯一ID,如订单号',
`message_body` JSON NOT NULL,
`status` TINYINT DEFAULT 0 COMMENT '0-待发送, 1-已发送, 2-已消费',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_biz_key` (`business_key`)
);
💡 安全提醒:这个表千万别暴露给前端!曾经有个实习生把
business_key当成普通ID返回,结果被黑客遍历,直接重放消息——差点酿成资损事故。
步骤2:下单 + 发消息 = 一个本地事务
@Transactional
public void createOrder(OrderRequest request) {
// 1. 生成订单(本地DB)
Order order = new Order();
order.setOrderId(IdUtil.nextId());
order.setStatus("CREATED");
orderMapper.insert(order);
// 2. 扣库存(调用库存服务,同步HTTP)
inventoryClient.decreaseStock(request.getProductId(), request.getCount());
// 3. 插入本地消息(关键!和订单在同一事务)
LocalMsg msg = new LocalMsg();
msg.setBusinessKey(order.getOrderId());
msg.setMessageBody(JSON.toJSONString(Map.of("orderId", order.getOrderId(), "userId", request.getUserId())));
msg.setStatus(0); // 待发送
localMsgMapper.insert(msg);
}
这里有个致命细节:库存服务调用必须是同步阻塞的!如果用异步,万一库存扣失败,但订单已提交,那就裂开了。虽然性能差一点,但胜在可靠。
🤯 吐槽一句:当初有个“架构师”坚持要用Feign异步回调,结果上线第一天就出现100+订单无库存。我当场就想把他键盘泡面里。
步骤3:后台任务轮询 + RocketMQ事务消息
单独起个定时任务,扫描status=0的消息,尝试发MQ:
@Scheduled(fixedDelay = 5000)
public void sendPendingMessages() {
List<LocalMsg> pendingMsgs = localMsgMapper.selectByStatus(0);
for (LocalMsg msg : pendingMsgs) {
try {
// 发送RocketMQ事务消息
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
"ORDER_TOPIC",
MessageBuilder.withPayload(msg.getMessageBody()).build(),
msg.getBusinessKey()
);
if (result.getLocalTransactionState() == LocalTransactionState.COMMIT_MESSAGE) {
// 标记为已发送
localMsgMapper.updateStatus(msg.getId(), 1);
}
} catch (Exception e) {
log.error("发送消息失败, bizKey={}", msg.getBusinessKey(), e);
// 不更新状态,下次继续重试
}
}
}
RocketMQ的事务监听器负责确认本地事务是否真的成功:
@RocketMQTransactionListener
public class OrderTransactionListener implements RocketMQLocalTransactionListener {
@Autowired
private OrderMapper orderMapper;
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
String bizKey = (String) arg;
// 检查订单是否存在(防止消息重复)
Order order = orderMapper.selectByOrderId(bizKey);
if (order != null && "CREATED".equals(order.getStatus())) {
return RocketMQLocalTransactionState.COMMIT;
}
return RocketMQLocalTransactionState.ROLLBACK;
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
// 回查逻辑,同上
return executeLocalTransaction(msg, msg.getHeaders().get("bizKey"));
}
}
面试题挑战:你能答对几道?
最近帮公司面试,问分布式事务,90%的人只会背“TCC三阶段”,一问细节就露馅。以下是我常问的真题:
本地消息表如何防止消息重复消费?
- 答:业务方做幂等(比如用订单ID做唯一索引),消息表本身也有
business_key唯一约束。
- 答:业务方做幂等(比如用订单ID做唯一索引),消息表本身也有
如果MQ挂了,本地消息表堆积怎么办?
- 答:监控堆积量,告警;同时支持手动触发重试;极端情况下可导出CSV人工处理。
为什么不用Seata?
- 答:Seata的AT模式依赖全局锁,高并发下性能瓶颈明显;TCC又太重。我们QPS峰值5k,本地消息表+MQ完全扛得住。
如何保证消息100%送达?
- 答:不存在100%。但我们做到:至少一次(at-least-once)+ 消费端幂等 = 业务上等效于“恰好一次”。
生产环境避坑指南(血泪经验)
| 坑点 | 现象 | 解决方案 |
|---|---|---|
| 消息表没加索引 | 定时任务扫描慢,堆积严重 | business_key 和 status 必须建联合索引 |
| 补偿逻辑缺失 | 消息发送失败后无人处理 | 加企业微信机器人告警 + 自动重试3次后转人工 |
| MQ回查频率过高 | 数据库被打爆 | 调整RocketMQ的transactionTimeOut和checkImmunityTime |
| 业务ID重复 | 消息覆盖 | 用雪花ID或UUID,别用自增ID |
上周五晚上,我就因为没设checkImmunityTime,导致MQ每秒回查1000次,MySQL CPU飙到90%。运维在群里@我:“你再不修,明天就来公司睡机房”。吓得我立马爬起来改配置。
性能与安全的平衡术
作为安全工程师,我特别关注两点:
- 消息体加密:敏感字段(如用户ID、金额)不要明文存消息表。我们用AES-GCM加密后再存JSON。
- 审计日志:所有事务操作记录操作人、时间、IP,方便事后溯源。曾经靠这个日志抓到一个内部员工刷单。
另外,不要为了强一致性牺牲可用性。我们的SLA是99.95%,允许极少数订单延迟几分钟。只要最终一致,用户体验几乎无感,但系统稳定性大幅提升。
最后:别追求银弹,要追求“够用”
很多人一上来就想搞Seata、Atomikos、甚至自己造轮子。但现实是——80%的业务场景,本地消息表 + MQ 就够了。剩下的20%,用Saga或TCC兜底。
我现在远程办公,最怕半夜被叫起来修事务bug。所以我的原则是:简单、可监控、可回滚。代码越复杂,线上越危险。
如果你正在学分布式事务,别光看教程,去GitHub扒几个开源项目的源码(比如ShardingSphere的事务模块),看看人家怎么处理异常、重试、幂等的。这才是真·最佳实践。
彩蛋:我们团队现在用这套方案跑了一年多,0资损,0重大事故。上周团建,产品经理敬我一杯:“感谢你没让我们破产。” 我回他:“下次别在周五提需求就行。”
本文代码已脱敏,核心逻辑可直接用于生产。如有雷同,纯属你也被坑过。
—— 一个只想安静写代码的安全工程师,2024年夏

评论 0