以"秒杀券"业务为例,业务流程为Redis校验库存与一人一单合法性-->发送消息给RocketMQ消费者-->消费者取消息并入库创建订单。但是这个过程并不是原子性的,即使Redis可以通过Lua脚本保证原子性,由于它们分属于不同的数据源和进程,无法使用传统的数据库事务(ACID)来保证原子性。
为了解决这一问题,RocketMQ 提供了**事务消息(Transactional Message)**机制,通过"二次确认"和"状态回查"来确保本地操作与消息发送的最终一致性。
以下是该业务逻辑的深度解析。
分布式一致性的挑战
在秒杀场景下,如果按顺序执行"Redis 扣减 →\rightarrow→ 发送 MQ →\rightarrow→ 数据库下单",会面临两个无法回避的风险点:
- 执行成功但发送失败 :Redis 成功执行了 Lua 脚本(库存减 111),但由于网络波动,MQ 消息发送失败。此时库存被白白扣除,但数据库从未生成订单,导致数据缺失。
- 发送成功但执行失败:MQ 消息已发出,但随后的本地代码发生异常(如宕机)。消费者收到了消息并向数据库写入了订单,但 Redis 里的库存却并未真正扣除,导致超卖。
1.预备阶段------半消息(Half Message)
为了确保 MQ 服务器(Broker)在业务开始前是可用的,流程从 半消息 开始:
- 发送半消息:生产者在执行任何 Redis 逻辑之前,先向 RocketMQ 发送一条包含了"订单 ID"、"用户 ID"和"券 ID"的消息。
- 消息暂存(不可见):Broker 接收到该消息并持久化,但将其标记为"不可投递"状态。此时消费者(Consumer)完全感知不到这条消息的存在。
- 响应确认:只有当生产者收到了 Broker 的成功响应,才代表通信链路通畅,此时才会进入核心业务逻辑。
2.核心阶段------本地事务执行
在确认 MQ 已经接到了半消息后,生产者开始在本地执行关键操作:
- 执行 Lua 脚本:在 Redis 内部完成原子性校验(判断库存是否存在、判断该用户是否已下单)。
- 原子性写入标记 :如果校验通过,Lua 脚本会同时完成两件事:
DECR扣减库存。SADD将该用户 ID 加入"已购名单"集合。
- 提交状态给 MQ :
- 成功(COMMIT):如果 Lua 返回成功,生产者告知 Broker 提交消息。Broker 将该消息改为"可见",消费者开始异步入库。
- 失败(ROLLBACK):如果 Lua 返回失败(如库存不足),生产者告知 Broker 删除该消息。
3.补救机制------事务回查(Check Logic)
如果生产者在执行完 Lua 脚本后突然宕机,或者网络中断导致 COMMIT/ROLLBACK 信号未能到达 Broker,分布式事务将进入回查逻辑:
- 发起询查:Broker 发现某条半消息停留时间过长,会主动向生产者集群发起询问:"这条消息对应的本地业务到底成了没?"
- 查询 Redis 凭证 :生产者的回查监听器(Listener)被触发。它不需要重新跑一遍 Lua 脚本,而是去 Redis 的"已购名单"集合中查询:"该用户 ID 是否存在于对应的券 ID 集合中?"。
- 反馈结果 :
- 存在 :说明之前 Lua 脚本成功执行了,只是通知 MQ 时断了,返回
COMMIT。 - 不存在 :说明之前执行失败或根本没跑,返回
ROLLBACK。
- 存在 :说明之前 Lua 脚本成功执行了,只是通知 MQ 时断了,返回
4.最终阶段------消费者幂等入库
当消息最终变为"可见"后,消费者从队列拉取消息进行数据库写入:
- 异步持久化 :消费者将消息内容插入 MySQL 的
voucher_order表。 - 处理失败重试:如果数据库由于繁忙导致写入失败,RocketMQ 会根据梯度时间自动发起重试(阶梯退避)。
- 幂等性保证 :为了防止 MQ 的重复投递,消费者在写入前必须通过 数据库唯一索引 (如:用户 ID + 券 ID 为联合主键)或 前置查询 来确保:即便同一条消息被消费了多次,数据库里也只会有一条订单记录。
关键逻辑
为了保证 Redis 扣减与数据库下单的一致性,系统采用 RocketMQ 事务消息 逻辑:Java 先向 MQ 发送一条消费者不可见的 半消息(用于确认 MQ 服务可用);随后在本地执行 Redis Lua 脚本进行库存扣减与资格校验。
- 若 Lua 执行成功 :Java 向 MQ 发送 COMMIT 信号,使消息对消费者可见,驱动下游入库。
- 若 Lua 执行失败 :Java 向 MQ 发送 ROLLBACK 信号,MQ 直接丢弃该消息。
- 若执行后宕机(信号丢失) :MQ 会定期发起 回查(Check),Java 接收请求后通过 Redis 中的成功标记(如已购名单)判断业务实况,补发 COMMIT 或 ROLLBACK。
通过这种"先占位、后业务、崩了就回查"的机制,强制确保了 MQ 消息状态与 Redis 扣减结果的最终一致。
代码实现
第一步:Service 层触发事务消息
这是业务入口。注意:发送半消息失败会直接抛出异常,不会执行后续逻辑。
java
@Service
@RequiredArgsConstructor
public class VoucherOrderServiceImpl implements VoucherOrderService {
private final VoucherOrderProducer voucherOrderProducer;
private final RedisIdWorker redisIdWorker;
@Override
public VoucherOrderDO seckillVoucherLua(Long voucherId) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
Long orderId = redisIdWorker.nextId("voucher:order");
// 【步骤1】发送半消息并等待本地事务结果
// 若此处抛出异常,说明 MQ 挂了,业务中断,Redis 逻辑不会执行
TransactionSendResult result = voucherOrderProducer.sendSeckillOrderTransaction(orderId, voucherId, loginUserId);
// 【步骤4】处理最终一致性结果
// 若本地事务(Lua)执行失败或回滚,result 状态非 COMMIT,需抛异常给前端
if (result.getLocalTransactionState() != LocalTransactionState.COMMIT_MESSAGE) {
throw new ServiceException(500, "抢购失败,库存不足或重复下单");
}
return VoucherOrderDO.builder().id(orderId).voucherId(voucherId).userId(loginUserId).build();
}
}
第二步:Producer 封装发送逻辑
负责构建消息体并调用 RocketMQ 模板。
java
@Component
@RequiredArgsConstructor
public class VoucherOrderProducer {
private final RocketMQTemplate rocketMQTemplate;
public TransactionSendResult sendSeckillOrderTransaction(Long orderId, Long voucherId, Long loginUserId) {
VoucherOrderMessage message = VoucherOrderMessage.builder()
.orderId(orderId).voucherId(voucherId).userId(loginUserId).build();
// sendMessageInTransaction 会先发送 Half Message,随后自动触发监听器
return rocketMQTemplate.sendMessageInTransaction(
VoucherOrderMqConstants.TOPIC_SECKILL,
MessageBuilder.withPayload(message).build(),
null // 可传参给 executeLocalTransaction 的 arg 变量
);
}
}
第三步:Listener 实现核心闭环
这是保证数据一致性的"灵魂"所在,包含本地逻辑执行与宕机后的状态回查。
java
@RocketMQTransactionListener
@RequiredArgsConstructor
@Slf4j
public class VoucherOrderTransactionListener implements RocketMQLocalTransactionListener {
private final VoucherOrderRedisDAO voucherOrderRedisDAO;
private final StringRedisTemplate stringRedisTemplate;
/**
* 【步骤2】执行本地事务:操作 Redis Lua 脚本
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
VoucherOrderMessage payload = JSON.parseObject(new String((byte[]) msg.getPayload()), VoucherOrderMessage.class);
// 原子操作:校验库存、判重、扣减并 sadd 写入成功标记(用于回查)
Long result = voucherOrderRedisDAO.executeSeckillVoucherOrderLua(payload.getVoucherId(), payload.getUserId());
// 0 代表 Lua 脚本执行成功
return result == 0 ? RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
} catch (Exception e) {
log.error("本地事务异常(可能是 Redis 挂了),准备回滚消息", e);
return RocketMQLocalTransactionState.ROLLBACK;
}
}
/**
* 【步骤3】回查机制:处理网络抖动或 Java 后端意外宕机导致信号丢失
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
VoucherOrderMessage payload = JSON.parseObject(new String((byte[]) msg.getPayload()), VoucherOrderMessage.class);
// 直接从 Redis 查询 Lua 脚本写入的"成功名单"
Boolean isExisted = stringRedisTemplate.opsForSet().isMember(
"seckill:order:" + payload.getVoucherId(),
payload.getUserId().toString());
// 只要 Redis 里有记录,说明逻辑跑通了,补发 COMMIT
return Boolean.TRUE.equals(isExisted) ?
RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
}
}
第四步:Consumer 异步入库与幂等保障
java
@Component
@RocketMQMessageListener(
topic = VoucherOrderMqConstants.TOPIC_SECKILL,
consumerGroup = "g_hmdp_consumer",
consumeMode = ConsumeMode.CONCURRENTLY // 并发消费提升吞吐量
)
@RequiredArgsConstructor
@Slf4j
public class VoucherOrderConsumer implements RocketMQListener<VoucherOrderMessage> {
private final VoucherOrderMapper voucherOrderMapper;
@Override
public void onMessage(VoucherOrderMessage message) {
log.info("【步骤5】开始异步入库,订单信息:{}", message);
try {
// 1. 构造数据库实体对象
VoucherOrderDO order = VoucherOrderDO.builder()
.id(message.getOrderId())
.voucherId(message.getVoucherId())
.userId(message.getUserId())
.build();
// 2. 执行数据库插入
// 【关键点】数据库层必须建立 (user_id, voucher_id) 的唯一联合索引
// 如果消息重复投递,SQL 会触发 DuplicateKeyException,从而保证幂等
voucherOrderMapper.insert(order);
log.info("订单入库成功:{}", message.getOrderId());
} catch (DuplicateKeyException e) {
// 3. 幂等性兜底
// 出现此异常说明该用户已经下过单了(数据库已经有记录)
// 这种情况直接吞掉异常,返回成功(ACK),不再触发 MQ 重试
log.warn("检测到重复下单消息,已自动拦截。订单ID:{}", message.getOrderId());
} catch (Exception e) {
// 4. 重试机制
// 如果是非幂等类异常(如数据库宕机、连接超时),抛出异常
// RocketMQ 会根据阶梯时间(1s 5s 10s...)自动发起重试,直到最终一致
log.error("订单入库失败,准备重试。订单ID:{}", message.getOrderId(), e);
throw new RuntimeException("异步入库异常,等待重试", e);
}
}
}
代码实现关键逻辑
以下是该流程的逻辑拆解,重点揭示被封装在内部的执行顺序:
一、 执行流程:从发送到入库
- 发送半消息(Half Message) :
当你在代码中调用sendMessageInTransaction时,底层首先向 MQ Broker 发送一条半消息 。- 关键点 :此时本地业务逻辑(如 Redis 扣减)尚未执行。
- 状态:消息已到达 MQ,但对消费者不可见。这一步是为了确认 MQ 服务可用。
- 触发回调(executeLocalTransaction) :
只有当半消息成功到达 Broker 并返回确认信号(ACK)后,Java 客户端才会自动触发executeLocalTransaction方法。- 动作:在此方法内执行真正的 Redis 业务逻辑(判断库存、判重、扣减)。
- 反馈 :Java 执行完后,向 MQ 反馈结果。成功则发 COMMIT ,失败则发 ROLLBACK。
- 消息生效与消费 :
- 若 MQ 收到 COMMIT,将半消息转为普通消息,消费者(Consumer)立即拉取并执行 MySQL 入库。
- 若 MQ 收到 ROLLBACK,直接删除半消息,数据库入库动作永远不会发生。
二、 异常处理:信号丢失后的回查
这是解决"Java 服务器突然宕机"或"网络波动导致 COMMIT 信号丢失"的核心逻辑:
- 超时监控 :
MQ Broker 会监控所有半消息。如果某条消息在规定时间内(如 30 秒)既没收到 COMMIT,也没收到 ROLLBACK,它不会一直等待。 - 主动回查(checkLocalTransaction) :
MQ Broker 主动向 Java 后端集群发起请求,调用checkLocalTransaction方法。- 逻辑:由于原本执行任务的 Java 实例可能已宕机,回查请求会发送到同微服务的其他实例。
- 核实:Java 代码去 Redis 查询是否存在"下单成功标记"。
- 补偿:查到标记则补发 COMMIT,没查到则补发 ROLLBACK。