分布式事务解决方案:最佳实践(一个被线上事故逼出来的安全工程师的血泪总结)

队列在排队
2025-12-14 14:28
阅读 410

作者:某云原生安全团队远程打工人,日常和漏洞斗智斗勇,周末在家撸代码顺便给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三阶段”,一问细节就露馅。以下是我常问的真题:

  1. 本地消息表如何防止消息重复消费?

    • 答:业务方做幂等(比如用订单ID做唯一索引),消息表本身也有business_key唯一约束。
  2. 如果MQ挂了,本地消息表堆积怎么办?

    • 答:监控堆积量,告警;同时支持手动触发重试;极端情况下可导出CSV人工处理。
  3. 为什么不用Seata?

    • 答:Seata的AT模式依赖全局锁,高并发下性能瓶颈明显;TCC又太重。我们QPS峰值5k,本地消息表+MQ完全扛得住。
  4. 如何保证消息100%送达?

    • 答:不存在100%。但我们做到:至少一次(at-least-once)+ 消费端幂等 = 业务上等效于“恰好一次”。

生产环境避坑指南(血泪经验)

坑点 现象 解决方案
消息表没加索引 定时任务扫描慢,堆积严重 business_keystatus 必须建联合索引
补偿逻辑缺失 消息发送失败后无人处理 加企业微信机器人告警 + 自动重试3次后转人工
MQ回查频率过高 数据库被打爆 调整RocketMQ的transactionTimeOutcheckImmunityTime
业务ID重复 消息覆盖 用雪花ID或UUID,别用自增ID

上周五晚上,我就因为没设checkImmunityTime,导致MQ每秒回查1000次,MySQL CPU飙到90%。运维在群里@我:“你再不修,明天就来公司睡机房”。吓得我立马爬起来改配置。


性能与安全的平衡术

作为安全工程师,我特别关注两点:

  1. 消息体加密:敏感字段(如用户ID、金额)不要明文存消息表。我们用AES-GCM加密后再存JSON。
  2. 审计日志:所有事务操作记录操作人、时间、IP,方便事后溯源。曾经靠这个日志抓到一个内部员工刷单。

另外,不要为了强一致性牺牲可用性。我们的SLA是99.95%,允许极少数订单延迟几分钟。只要最终一致,用户体验几乎无感,但系统稳定性大幅提升。


最后:别追求银弹,要追求“够用”

很多人一上来就想搞Seata、Atomikos、甚至自己造轮子。但现实是——80%的业务场景,本地消息表 + MQ 就够了。剩下的20%,用Saga或TCC兜底。

我现在远程办公,最怕半夜被叫起来修事务bug。所以我的原则是:简单、可监控、可回滚。代码越复杂,线上越危险。

如果你正在学分布式事务,别光看教程,去GitHub扒几个开源项目的源码(比如ShardingSphere的事务模块),看看人家怎么处理异常、重试、幂等的。这才是真·最佳实践。


彩蛋:我们团队现在用这套方案跑了一年多,0资损,0重大事故。上周团建,产品经理敬我一杯:“感谢你没让我们破产。” 我回他:“下次别在周五提需求就行。”

本文代码已脱敏,核心逻辑可直接用于生产。如有雷同,纯属你也被坑过。
—— 一个只想安静写代码的安全工程师,2024年夏

评论 0

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