穿透 MQ 专栏 (五):【终局之战】MySQL 和 MQ 的世纪联姻:扒开“分布式事务”的遮羞布
读到这篇大结局你已经陪我走过了 MQ 架构中最泥泞的沼泽。我们用“削峰”保住了服务器的命用“ACK与落盘”防住了消息丢失用“状态机”杀死了重复扣款甚至在百万积压的灾难中完成了一场教科书般的救火。可以说在单节点系统和纯 MQ 领域你已经是无敌的存在了。但是当你回到工位看着自己写下的那段最核心的“支付回调”代码时你的背后突然感到一丝凉意。JavaTransactional public void paySuccess(String orderId) { // 1. 本地数据库扣减余额 accountDB.decreaseBalance(orderId); // 2. 发送 MQ 消息通知物流系统发货 mqTemplate.send(Fahuo_Topic, orderId); }你以为加上了Transactional这个神圣的注解就能保证这两行代码“要么全成功要么全失败”。大错特错在真实的分布式物理世界里Spring 的Transactional就是一张一捅就破的窗户纸今天作为本专栏的压轴大戏我们将直击整个后端架构的深水区扒开“分布式事务”的遮羞布看看大厂是如何解决本地 MySQL 与外部 MQ 之间那令人绝望的“数据一致性”难题的。一、 噩梦场景无解的“双写难题Dual-Write”为什么说上面的代码是车祸现场我们来做极其残酷的物理推演。Transactional的底层逻辑是等你的业务代码全部执行完Spring 才会去向 MySQL 发起Commit提交。 而外部的网络环境是极度不可靠的。车祸现场 1先写 DB后发 MQ推演余额扣减成功了正准备发 MQ 时服务器所在的机柜突然停电代码没走完Spring 的事务跟着服务器一起死了未提交MySQL 数据回滚。结果是钱没扣货没发。这很完美。致命的变数余额扣减成功了。MQ 也发送成功了就在 Spring 准备向 MySQL 发送终极Commit指令的那一秒数据库死锁报错了或者主键冲突报错了结局数据库悲愤地回滚了扣款操作。但是你的 MQ 消息已经像泼出去的水一样顺着网线飞到了物流系统。客诉客户一分钱没花你们公司把价值一万块的货给他发出去了。老板立刻让你卷铺盖走人。车祸现场 2那我先发 MQ后写 DB 行不行推演先把“发货消息”发给了 MQ。物流系统瞬间消费把货装车。紧接着准备写 DB 扣款结果网络卡了一下数据库报错。结局同上。钱没扣货发了。这就是臭名昭著的分布式双写难题Dual-Write Problem一个归本地数据库管一个归外部网络管。没有任何本地事务能同时罩住它们俩。二、 主流大厂的救命稻草本地消息表Outbox Pattern既然跨网络无法保证事务那我们能不能用魔法打败魔法架构师的破局思路把外部网络问题强行转化成本地数据库问题这就是目前业界使用最广泛、最稳如老狗的兜底方案——本地消息表Outbox Pattern。【工程实战推演】建表在你的业务数据库和扣款表在同一个 MySQL 实例中里新建一张表叫local_message_log本地消息表。神仙同框现在你的代码变成了这样JavaTransactional public void paySuccess(String orderId) { // 1. 本地数据库扣减余额 accountDB.decreaseBalance(orderId); // 2. 本地数据库写入一条消息日志状态为“待发送” messageLogDB.insert(new MessageLog(orderId, 待发送)); }奇迹发生了因为扣余额和写消息日志在同一个本地 MySQL 里所以Transactional完美生效。这两步绝对是同生共死绝对符合 ACID 强一致性扫表大军代码里再写一个定时任务或者用 Canal 监听 Binlog每隔 1 秒去疯狂扫描local_message_log里“待发送”的数据。死磕投递定时任务拿到数据后向 MQ 投递。如果发送失败没关系状态还是“待发送”下一秒接着扫接着发死磕到底。如果收到 MQ 的成功 ACK就把数据库里的状态改为“已发送”。评价这个方案极度稳健逻辑清晰。唯一的缺点是把业务数据库当成了消息中转站定时任务疯狂扫表会增加数据库的 I/O 压力。三、 极致黑科技RocketMQ 的“半消息”事务消息本地消息表虽然稳但不够优雅。 阿里的一群疯子架构师站了出来“如果每次都要在业务库里建一张破表这也太搓了我们能不能让 MQ 自己来承担事务协调者的角色”于是震惊业界的RocketMQ 事务消息黑科技Half Message诞生了。它巧妙地借鉴了支付宝“担保交易”的哲学。【大片级的底层推演】假设你的系统叫 A你要发消息给系统 B。第一步发送“半消息”支付宝打款系统 A 先向 RocketMQ 发送一条极其特殊的“半消息”Half Message。黑科技点RocketMQ 收到这条消息后会把它藏在一个内部的隐藏队列里。消费者系统 B此时绝对看不见这条消息第二步执行本地事务验货系统 A 收到 RocketMQ 的“半消息投递成功”回执后开始执行本地 MySQL 的扣款动作。第三步二次确认确认收货或退款如果本地扣款成功了系统 A 告诉 RocketMQ“我完事了你可以把那条半消息变可见Commit了” 系统 B 瞬间消费发货。如果本地扣款失败/抛异常了系统 A 告诉 RocketMQ“我炸了赶紧把那条半消息销毁Rollback”【终极杀招反查机制】你可能会问如果第三步系统 A 在向 MQ 发送Commit的时候网线被老鼠咬断了怎么办半消息岂不是永远悬在半空了 RocketMQ 微微一笑。如果一条半消息挂了很久没人理RocketMQ 会主动顺着网线发起反向查询Transaction Check它会来敲系统 A 的门“哥们你刚才发了半消息然后就不吱声了。你本地的扣款到底成功了没你查一下告诉我” 系统 A 查了一下本地对账表“哎呀不好意思刚才网断了其实我扣款成功了。”于是告诉 MQ“去 Commit 吧”这套机制彻底干掉了对本地消息表的依赖用 MQ 的高可用性完美反哺了分布式事务的一致性简直是极客审美的巅峰之作。 终局感言架构的本质是妥协写到这里我们《穿透 MQ》的五部曲终于正式完结。回头看看这条路为了防止系统被压垮我们牺牲了同步调用的实时性换来了大坝般的削峰。为了防丢失我们牺牲了极致的性能强制磁盘同步落盘。为了防重复我们在业务里加了啰嗦的状态机锁。为了全局高并发我们杀死了全局顺序只做局部路由。今天为了跨网络的一致性我们放弃了强一致的事务接受了最终一致性Eventual Consistency的异步反查机制。你会发现在分布式系统的高阶领域里没有任何一项技术是完美的“银弹”。架构师的日常根本不是在追求完美的架构而是在业务场景的逼迫下做出一系列极其痛苦、但又最适合当下的“妥协”。不管是 MySQL 的 B 树还是 MQ 的零拷贝与半消息它们都是一代代程序员为了对抗物理机器的瓶颈、对抗网络的不确定性而写下的人类智慧的结晶。希望这套专栏能帮你在面对满屏复杂的底层报错时不再只有深深的恐惧而是能像一位身经百战的老兵一样一眼看穿那些钢铁和网络背后的秩序。干杯愿你们的线上系统永远丝滑永不宕机