1、概述
目前主流的 MQ 中,只有 RocketMQ 提供了事务消息,用于支持消息发送与本地事务的最终一致。避免消息发送成功,但是本地事务缺失败的情况。
2、应用场景
例如订单子系统创建订单,需要将订单数据下发到其他系统。大部分时候会用 MQ 解耦,将订单数据写入 MQ,供其他系统消费。用 MQ 解耦的话,可能存在 订单数据入库 了,但是消息发送失败了的情况。此时可以用 事务消息 来确保消息发送和订单数据入库 2 个动作的最终一致性。
3、发送事务消息 demo
3.1、创建 TransactionListener
public class TransactionListenerImpl implements TransactionListener {
private AtomicInteger transactionIndex = new AtomicInteger(0);
private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// todo 执行本地事务,一般而言,业务代码都会放在这里
int value = transactionIndex.getAndIncrement();
int status = value % 3;
localTrans.put(msg.getTransactionId(), status);
// todo 不管如何执行本地事务,一般都是返回 UNKNOW.
// todo 本地事务执行成功与否,交由事务回查来判断
return LocalTransactionState.UNKNOW;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// todo 事务消息回查
Integer status = localTrans.get(msg.getTransactionId());
if (null != status) {
switch (status) {
case 0:
return LocalTransactionState.UNKNOW;
case 1:
return LocalTransactionState.COMMIT_MESSAGE;
case 2:
return LocalTransactionState.ROLLBACK_MESSAGE;
default:
return LocalTransactionState.COMMIT_MESSAGE;
}
}
return LocalTransactionState.COMMIT_MESSAGE;
}
}
3.2、生产者发送事务消息
public class TransactionProducer {
public static void main(String[] args) throws MQClientException, InterruptedException {
TransactionListener transactionListener = new TransactionListenerImpl();
TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");
// 一个 producer 只能设置一个 事务监听器
producer.setTransactionListener(transactionListener);
producer.start();
String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
for (int i = 0; i < 10; i++) {
try {
Message msg =
new Message("TopicTest1234", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
System.out.printf("%s%n", sendResult);
Thread.sleep(10);
} catch (MQClientException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}
for (int i = 0; i < 100000; i++) {
Thread.sleep(1000);
}
producer.shutdown();
}
}
4、事务消息原理
4.1、流程概览
4.2、producer 发送半消息
-
producer 发送事务消息时,会用同步发送。
-
消息属性 添加
TRAN_MSG=true, 标识该消息为事务消息; -
消息属性添加
PGROUP=${生产者}, 以方便broker消息回查 -
broker 端收到消息后,根据
TRAN_MSG值判断是事务消息。则将消息转存到topic: RMQ_SYS_TRANS_HALF_TOPICqueueId: 0下。
broker 端代码位置 SendMessageProcessor#sendMessage()
public class SendMessageProcessor extends AbstractSendMessageProcessor implements NettyRequestProcessor {
private RemotingCommand sendMessage(final ChannelHandlerContext ctx,
final RemotingCommand request,
final SendMessageContext sendMessageContext,
final SendMessageRequestHeader requestHeader) throws RemotingCommandException {
// todo 事务消息标识
String traFlag = oriProps.get(MessageConst.PROPERTY_TRANSACTION_PREPARED);
if (traFlag != null && Boolean.parseBoolean(traFlag)
&& !(msgInner.getReconsumeTimes() > 0 && msgInner.getDelayTimeLevel() > 0)) { //For client under version 4.6.1
if (this.brokerController.getBrokerConfig().isRejectTransactionMessage()) {
response.setCode(ResponseCode.NO_PERMISSION);
response.setRemark(
"the broker[" + this.brokerController.getBrokerConfig().getBrokerIP1()
+ "] sending transaction message is forbidden");
return response;
}
// TODO 转存 半消息
putMessageResult = this.brokerController.getTransactionalMessageService().prepareMessage(msgInner);
}
}
4.3、producer 执行本地事务
半消息成功发送到 broker 后,producer 会开始执行本地事务。
所谓的本地事务就是我们的业务逻辑,即 TransactionListener#executeLocalTransaction()
一般而言,该返回无特殊情况,都要返回 LocalTransactionState.UNKNOW。
本地事务最终的执行结果,应该由消息回查来确认
4.4、producer 结束事务
-
producer在执行完本地事务后,单向发送 消息给broker, 告诉broker本次事务的执行结果,即TransactionListener#executeLocalTransaction()的返回值。代码位置:
DefaultMQProducerImpl#sendMessageInTransaction() -
如果本地事务状态是
commita.
broker恢复原topic,queueId,consumer可以正常消费事务消息 b. 把消息放入RMQ_SYS_TRANS_OP_HALF_TOPIC并通过设置tags = d标识该消息已被删除 -
如果本地事务状态是
rollbacka. 把消息放入
RMQ_SYS_TRANS_OP_HALF_TOPIC并通过设置tags = d标识该消息已被删除 -
如果本地事务状态是
unknow, 不做任何处理
注: 2~4 步的代码位置:EndTransactionProcessor#processRequest()
4.5、broker 定时回查事务
在 《4.2 producer 发送半消息》 中,有提到, 本地事务的结果,正常都是返回 LocalTransactionState.UNKNOW, 然后通过 broker 端的回查,来决定消息是提交还是回滚。下面来看下 broker 端如何做定时回查事务的
代码位置: TransactionalMessageCheckService#onWaitEnd()
-
每隔
60s执行一次事务回查 -
拉取 半消息(
RMQ_SYS_TRANS_HALF_TOPIC), 并利用RMQ_SYS_TRANS_OP_HALF_TOPIC做消息去重,进行消息回查 -
(半消息存储时间 - 当前时间) 必须大于 事务超时时间(默认 6s), 且该消息的回查次数小于 15. 才允许执行事务消息回查
-
broker 将半消息再次放入半消息主题,然后向
producer单向发送事务回查消息 -
producer执行TransactionListenerImpl#checkLocalTransaction()然后单向发送消息通知broker本地事务的执行结果
Q&A
Q: 为什么第 4 步中,broker 要将半消息再次放入半消息主题?
A: 为了支持事务消息回查重试
Q: 为什么会将删除的或消费完的半消息写入 RMQ_SYS_TRANS_OP_HALF_TOPIC
A: 为了 broker 端对 半消息去重. 防止重复消费
Q: producer 端在哪里处理 broker 发送的事务回查消息
A: ClientRemotingProcessor#processRequest()
5、2PC 与 RocketMQ
RocketMQ 的事务消息,本质是采用了 2PC 协议。这里介绍 2PC 协议 与 RocketMQ 如何运用 2PC 协议。
5.1、2PC 前提
2PC 协议是有一定的前提条件的,我们来看下该协议的前提条件。
-
分布式系统中, 存在 1 个节点作为协调者, 多个节点作为 参与者,且节点之间可以互相通信
-
所有节点(
不是仅参与者节点?)采用 预写式日志(WAL), 日志不会因为节点挂掉,而导致丢失 -
节点不会永久损坏,可恢复
RocketMQ 中运用
| 描述 | Application | RocketMQ | Mysql |
|---|---|---|---|
| 角色 | 协调者 | 参与者 | 参与者 |
| 预写日志 | 无 | 预写 producer 发送的半消息 | 事务未提交,但日志已被写入到 redo, binlog |
| 可恢复 | 可恢复 | 可恢复 | 可恢复 |
5.2、基本算法
2PC 协议分为 提交请求阶段 和 提交阶段。
5.2.1、提交请求阶段
-
协调者向各个参与者询问是否可以执行提交操作, 并开始等待各个参与者的响应。
-
各个参与者收到请求, 预写日志
-
各个参与者响应协调者发起的询问。返回是同意还是不同意
5.2.2、提交阶段
成功
当协调者收到所有参与者节点的 '同意'
-
协调者向所有参与者发出 '提交' 请求
-
参与者提交事务
-
协调者收到所有参与者的反馈,完成事务.
失败
当协调者收到某个参与者节点的 '不同意'
-
协调者向所有参与者发出 '回滚' 请求
-
参与者回滚事务
5.2.3、RocketMQ 中运用
5.2.4、开发注意事项
producer同步发送半消息。如果broker是异步刷盘,可能出现broker返回成功,但消息写入失败. 因此broker端最好用同步刷盘策略
5.2.5、FAQ
Q: broker 端半消息写入失败, 会怎么办
A: 没怎么办, 程序不接着往下面执行
Q: 本地执行事务失败,会怎么半?
A: Application 返回 UNKONW 或者 ROLLBACK 给 Broker, 如果是 ROLLBACK, 会回滚事务(删除半消息)
Q: 在执行本地事务后,Application 一定是返回 UNKONW 给 broker, Mysql 在执行成功后奔溃怎么办?
A: Broker 端有消息回查机制,最多回查 15 次,可通过 transactionCheckMax 配置
Q: 本地事务比较耗时,可以另启一个线程跑吗?
A: 理论上可以. broker 端在进行消息回查时,如果事务消息存储时间与当前时间差 小于 事务的最大超时时间, (默认 6s, 可通过 transactionTimeOut 配置),则不会回查