分布式事务解决方案:一个前端转全栈的血泪实践
大家好,我是一个纯前端出身、最近硬着头皮啃 Node.js 的“伪全栈”选手。坐标上海,住公司附近——不是因为我有多卷,纯粹是怕加班太晚打不到车(别问,问就是被产品经理深夜钉钉轰炸过)。平时除了写 Vue 组件,也喜欢扒一扒开源项目的源码,比如 axios 是怎么发请求的、express 中间件链是怎么跑的。但说真的,直到上个月,我对“分布式事务”这四个字的理解还停留在“听上去很牛逼,但我用不上”的阶段。
直到……我们组接了个新项目。
起因:一个看似简单的“下单”需求
事情是这样的。我们团队在做一套 B2B 电商平台,前端我负责(当然),后端原本是 Java 技术栈,由几位老哥维护。但最近公司搞“技术融合”,要求前后端都能顶上。领导拍了拍我肩膀:“小张啊,你不是学 Node 了吗?这次新模块你来搭个服务端原型,顺便看看能不能落地。”
需求很简单:用户下单时,要同时完成三件事:
- 扣减库存(库存服务)
- 创建订单(订单服务)
- 发送消息通知(消息服务)
听起来人畜无害对吧?但问题来了——这三个操作分别在三个不同的微服务里,各自连着自己的数据库。如果扣库存成功了,结果创建订单时崩了,那库存就白扣了;反过来,如果订单建好了,库存没扣,用户就能白嫖……这要是上线,财务部怕是要提刀来找我。
我当时第一反应是:“这不是该用数据库事务吗?”
然后后端老哥冷笑一声:“兄弟,这是跨服务、跨数据库,你 MySQL 的 BEGIN TRANSACTION 可管不到人家 PostgreSQL 头上。”
那一刻,我意识到:单体应用的幸福时光,一去不复返了。
初探:到底什么是分布式事务?
说实话,一开始我连“分布式事务”和“本地事务”的区别都搞不清。翻了几篇文档,又看了 Seata 的官方 demo,总算明白:分布式事务的核心问题,是如何保证多个独立系统在发生故障时,还能保持数据的一致性。
常见的解决方案有几种:
- 两阶段提交(2PC):经典但性能差,锁资源时间长
- TCC(Try-Confirm-Cancel):业务侵入强,但灵活
- 消息队列+本地消息表:最终一致性,适合非强一致场景
- Saga 模式:长事务拆解,支持补偿
我们这个下单场景,其实不需要强一致性——用户能接受几秒内库存显示延迟,只要最终一致就行。而且产品那边明确说了:“双11期间别崩就行,慢点可以忍。”(感谢产品大大开恩!)
于是,我们决定采用 “本地消息表 + 消息队列” 的方案。为什么?因为简单、可控、对现有 Java 项目改动小,而且我这个 Node 新手也能快速上手配合。
实战:在 Java 项目中落地本地消息表
我们的主服务是 Spring Boot 写的,数据库是 MySQL。思路如下:
- 在订单服务中,开启本地事务
- 先插入订单记录
- 同时插入一条“待发送”的消息到本地消息表
- 提交事务
- 后台任务轮询消息表,把状态为“待发送”的消息推到 RabbitMQ
- 消费者收到消息后,执行库存扣减等操作
关键点在于:订单和消息的写入必须在一个本地事务里完成。这样即使消息推送失败,也可以通过重试机制最终完成。
数据库设计
-- 订单表
CREATE TABLE `order` (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME
);
-- 本地消息表(重点!)
CREATE TABLE `local_message` (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL UNIQUE, -- 幂等ID
topic VARCHAR(100) NOT NULL,
payload TEXT NOT NULL, -- JSON 字符串
status TINYINT DEFAULT 0, -- 0: 待发送, 1: 已发送, 2: 失败
retry_count INT DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
INDEX idx_status (status),
INDEX idx_retry (retry_count)
);
注:
message_id用于幂等控制,避免重复消费。这个坑我踩过——测试时没加幂等,结果库存被扣了三次,差点背锅。
Java 代码示例(Spring Boot)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private LocalMessageMapper localMessageMapper;
@Transactional
public void createOrder(OrderRequest request) {
// 1. 创建订单
Order order = new Order();
order.setUserId(request.getUserId());
order.setStatus("CREATED");
orderMapper.insert(order);
// 2. 插入本地消息(与订单同事务!)
String messageId = UUID.randomUUID().toString();
String payload = JSON.toJSONString(Map.of(
"orderId", order.getId(),
"userId", request.getUserId(),
"items", request.getItems()
));
LocalMessage msg = new LocalMessage();
msg.setMessageId(messageId);
msg.setTopic("order.created");
msg.setPayload(payload);
msg.setStatus(0); // 待发送
localMessageMapper.insert(msg);
// 注意:这里不直接发MQ!事务提交后才安全
}
}
然后写一个定时任务(或者用 @Scheduled)扫描 local_message 表:
@Scheduled(fixedDelay = 5000) // 每5秒扫一次
public void sendPendingMessages() {
List<LocalMessage> pendingMsgs = localMessageMapper.selectPending(100);
for (LocalMessage msg : pendingMsgs) {
try {
rabbitTemplate.convertAndSend(msg.getTopic(), msg.getPayload());
msg.setStatus(1);
localMessageMapper.updateStatus(msg); // 标记已发送
} catch (Exception e) {
log.error("Send message failed, messageId={}", msg.getMessageId(), e);
msg.setRetryCount(msg.getRetryCount() + 1);
if (msg.getRetryCount() > 5) {
msg.setStatus(2); // 标记失败,人工介入
}
localMessageMapper.updateRetry(msg);
}
}
}
我的 Node.js 服务如何配合?
作为前端转来的“全栈仔”,我的任务是写一个库存服务(用 Node.js + MySQL)。当收到 order.created 消息时,扣减库存:
// inventory-service.js
const amqp = require('amqplib');
const db = require('./db');
async function handleOrderCreated(msg) {
const data = JSON.parse(msg.content.toString());
const { orderId, items } = data;
// 关键:这里也要做幂等!
const exists = await db.query(
'SELECT 1 FROM inventory_log WHERE order_id = ?',
[orderId]
);
if (exists.length > 0) {
console.log('Already processed order', orderId);
return; // 直接返回,避免重复扣减
}
try {
await db.beginTransaction();
// 扣减库存(简化逻辑)
for (const item of items) {
await db.query(
'UPDATE product_stock SET stock = stock - ? WHERE product_id = ? AND stock >= ?',
[item.quantity, item.productId, item.quantity]
);
}
// 记录日志(用于幂等)
await db.query(
'INSERT INTO inventory_log (order_id, status) VALUES (?, ?)',
[orderId, 'SUCCESS']
);
await db.commit();
channel.ack(msg); // 确认消息
} catch (err) {
await db.rollback();
console.error('Inventory deduction failed:', err);
channel.nack(msg, false, true); // 重回队列重试
}
}
血泪教训:幂等性不是可选项,是保命符! 上周测试时忘了加
inventory_log,结果 MQ 重试导致库存变成负数,运维大哥差点把我从工位上拎起来。
踩坑实录:那些让我想砸电脑的瞬间
消息重复消费
RabbitMQ 在网络抖动时可能重复投递。没做幂等?恭喜你,库存变负数,用户白拿货。本地事务和 MQ 发送时机
一开始我天真地在@Transactional方法里直接调rabbitTemplate.send(),结果事务回滚了,消息却发出去了——数据直接不一致。后来才明白:必须等事务提交后再发消息,所以用后台任务轮询。死信队列没配
消息一直失败也不进死信队列,监控告警全靠肉眼盯日志。后来加上了 TTL 和 DLX,终于能自动隔离异常消息。Node.js 服务的连接池泄漏
因为异步没处理好,MySQL 连接池爆了。await忘了加,try/catch嵌套太深……前端思维一时半会儿真改不过来。
方案对比:为什么我们没选 Seata?
其实团队也讨论过用 Seata(阿里开源的分布式事务框架)。它支持 AT 模式(自动补偿)、TCC 模式,看起来很香。但最后放弃了,原因很现实:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 本地消息表 + MQ | 简单、可控、对业务侵入小 | 需要维护消息表、重试逻辑 | 最终一致性,如订单、通知 |
| Seata (AT模式) | 自动回滚,开发省心 | 依赖全局锁,性能较差;需改造数据库 | 强一致性要求高,且能接受性能损耗 |
| TCC | 灵活、高性能 | 业务侵入极强,每个接口要写 Try/Confirm/Cancel | 金融级场景 |
我们评估后认为:在非核心链路(比如发通知、更新积分)用最终一致性完全够用,没必要为了“强一致”牺牲性能和复杂度。
而且……说实话,让一个刚学 Node 的前端去配 Seata 的 TC/TM/RM,怕不是要通宵三天还搞不定。领导也点头:“先跑起来,再优化。”
上线效果 & 心得
上周五晚上,我们灰度发布了这个方案。双11预演压测跑了 10w 单,数据一致性 100% 达标,消息平均延迟 < 800ms(99分位)。最爽的是,运维大哥终于不用半夜打电话骂我了!
这次经历让我深刻体会到:所谓“架构”,不是堆砌高大上的名词,而是在约束条件下做出最合理的权衡。
作为一个前端转全栈的“杂牌军”,我以前总觉得后端高深莫测。但现在明白:很多所谓的“高级方案”,底层逻辑其实很朴素——保证原子性、做好幂等、允许重试、留好退路。
如果你也和我一样,正在从前端走向全栈,别怕!分布式事务听着吓人,但拆开来看,无非就是:
- 本地事务 + 消息表 → 保证“要么全做,要么全不做”
- 幂等 ID + 重试机制 → 应对网络不确定性
- 监控告警 + 死信队列 → 给自己留条活路
最后,给同样在挣扎的你一句鼓励:“代码可以重构,但数据不能乱。” ——这是我贴在显示器边上的话。
共勉。
P.S. 产品今天又提了个新需求:“能不能在下单时顺便给用户发个优惠券?”
我默默打开了 local_message 表的设计文档……

评论 0