分布式事务-可靠消息最终一致性
技术线:Java、SpringBoot、SpringCloud
可靠消息最终一致性事务
指事务发起方执行完成本地事务后并发出一条消息,事务参与者(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。
上图方案是利用消息中间件完成。事务发起方(Producer)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件之间,事务参与方(Consumer)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事务问题。
因此可靠消息最终一致性方案要解决以下几个问题:
-
本地事务与消息发送的原子性问题
本地事务与消息发送的原子性问题即:事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。即实现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最终一致性方案的关键问题。
-
事务参与方接收消息的可靠性
事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息
-
消息重复消费问题
由于网络2的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重复消费。
要解决消息重复消费的问题就要实现事务参与方的方法幂等性。
解决方案
RocketMQ事务消息方案
RocketMQ事务消息设计主要是为了解决Producer端的消息发送与本地事务执行的原子性问题。RcoketMQ的设计中broker与producer端的双向通信能力,使得broker天生可以作为一个事务协调者存在;而RocketMQ本身提供的存储机制为事务消息提供了持久化能力;RocketMQ的高可用机制以及可靠消息设计则为事务消息在系统发生异常时仍然能够保证达成事务的最终一致性。
在RocketMQ4.3后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,解决Producer端的消息发送与本地事务执行的原子性问题。
执行流程:
-
Producer 发送事务消息
Producer(MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为Prepared(预备状态),注意此时这条消息消费者(MQ订阅方)是无法消费到的。
@Override
public void sendUpdateAccountBalance(AccountChangeEvent accountChangeEvent)
{
//1 AccountChangeEvent对象换成json,fastjson有安全问题,暂时省略。。。fastjson2
JSONObject jsonObject = new JSONObject();
jsonObject.put("accountChange",accountChangeEvent);
String jsonString = jsonObject.toJSONString();
//2 生产MQ需要的Message消息实体,和MQ数据传递对接。
Message<String> message = MessageBuilder.withPayload(jsonString).build();
//3 发送一条事务消息先通知MQ,我mysql需要干活update数据了,预先通知你一声,盼回复。
//String txProducerGroup, String destination, Message<?> message, Object arg
TransactionSendResult transactionSendResult = rocketMQTemplate.sendMessageInTransaction
("producer_group_txmsg_bank1", "topic_txmsg", message, null);
log.error("rocketMQTemplate:{},result{}","rocketMQTemplate模板调用结束。。。。。", transactionSendResult);
}
-
MQ Server 回应消息发送成功
MQ Server接收到Producer发送的消息则回应发送成功表示MQ已接收到消息。
@Component
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "producer_group_txmsg_bank1")
public class ProducerTxmsgListener implements RocketMQLocalTransactionListener
{
@Autowired
AccountInfoService accountInfoService;
@Autowired AccountInfoDao accountInfoDao;
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o)
{
try {
//1 json处理
String messageString = new String((byte[])message.getPayload());
log.error("收到事务消息【{}】", messageString);
JSONObject jsonObject = JSONObject.parseObject(messageString);
String accountChangeString = jsonObject.getString("accountChange");
//2 将accountChangeString 转换成为AccountChangeEvent消息实体
AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
//3 执行本地事务,通知mysql侧扣减转账金额,看mysql本地事务执行情况,
// 3.1 mysql ok,commit
// 3.2 mysql error,rollback;
accountInfoService.doUpdateAccountBalance(accountChangeEvent);
//4 当上一步执行成功,当返回RocketMQLocalTransactionState.COMMIT,自动向mq发送commit消息,mq将消息的状态改为可消费
log.error("本地数据库业务逻辑执行完毕,开始提交当前半消息");
return RocketMQLocalTransactionState.COMMIT;
// 如果需要测试mq发送确认消息到broker,可以讲提交状态改为COMMIT,就可以测试checkLocalTransaction,会调起回查功能
// return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
e.printStackTrace();
log.error("本地数据库业务逻辑执行失败,回滚当前半消息");
return RocketMQLocalTransactionState.ROLLBACK;
}
}
}
-
Producer执行本地事务
Producer端执行业务代码逻辑,通过本地数据库事务控制。
/**
* 被MQ通知,mysql开始执行本地事务,进行写操作,扣钱
* @param accountChangeEvent
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void doUpdateAccountBalance(AccountChangeEvent accountChangeEvent)
{
//1 幂等性判断,大于零就不执行了,error
// 当前demo通过mysql做幂等判断,生产建议用分布式锁+数据库唯一值
if(accountInfoDao.isExistTx(accountChangeEvent.getTxNo()) > 0) {
return;
}
//2 扣减金额
int updateAmount = accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(), accountChangeEvent.getAmount() * -1);
if (updateAmount != 1) {
log.error("扣减金额失败");
throw new RuntimeException("扣减金额失败");
}
//3 幂等性插入,向mysql里面的de_duplication表插入唯一流水,作为幂等判断初始数据
int addTxNo = accountInfoDao.addTx(accountChangeEvent.getTxNo());
if (addTxNo != 1) {
log.error("幂等插入失败");
throw new RuntimeException("幂等插入失败");
}
//4 异常测试,不是主流业务逻辑
if(accountChangeEvent.getAmount() == -33){
throw new RuntimeException("人为制造异常-33");
}
}
-
消息投递
若Producer本地事务执行成功则自动向MQ Server发送commit消息,MQ Server接收到commit消息后将“增加积分信息”状态标记为可消费,此时MQ订阅方(积分服务)即正常消费消息;
若Producer本地事务执行失败则自动向MQ Server发送Rollback消息,MQ Server接收到rollback消息后,将删除“增加积分消息”。
MQ订阅方(积分服务)消费消息,消费成功则向MQ回应Ack,否则将重复接收消息。这里ack默认自动开启。
@Component
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "producer_group_txmsg_bank1")
public class ProducerTxmsgListener implements RocketMQLocalTransactionListener
{
@Autowired
AccountInfoService accountInfoService;
@Autowired AccountInfoDao accountInfoDao;
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o)
{
try {
//1 json处理
String messageString = new String((byte[])message.getPayload());
log.error("收到事务消息【{}】", messageString);
JSONObject jsonObject = JSONObject.parseObject(messageString);
String accountChangeString = jsonObject.getString("accountChange");
//2 将accountChangeString 转换成为AccountChangeEvent消息实体
AccountChangeEvent accountChangeEvent = JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
//3 执行本地事务,通知mysql侧扣减转账金额,看mysql本地事务执行情况,
// 3.1 mysql ok,commit
// 3.2 mysql error,rollback;
accountInfoService.doUpdateAccountBalance(accountChangeEvent);
//4 当上一步执行成功,当返回RocketMQLocalTransactionState.COMMIT,自动向mq发送commit消息,mq将消息的状态改为可消费
log.error("本地数据库业务逻辑执行完毕,开始提交当前半消息");
// 以上本地事务执行成功,进行commit投递消息
return RocketMQLocalTransactionState.COMMIT;
// 如果需要测试mq发送确认消息到broker,可以讲提交状态改为COMMIT,就可以测试checkLocalTransaction,会调起回查功能
// return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
e.printStackTrace();
log.error("本地数据库业务逻辑执行失败,回滚当前半消息");
return RocketMQLocalTransactionState.ROLLBACK;
}
}
}
-
事务回查
如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其它Producer来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。以上主干流程已由RocketMQ实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此只需关注本地事务的执行状态即可。
/**
* 事务状态回查,查询bank1是否扣减金额成功,你那边数据库操作成功还是失败
* @param message
* @return
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
//解析message,转成AccountChangeEvent
String messageString = new String((byte[]) message.getPayload());
log.error("事务状态回查开始,消息【{}】", messageString);
JSONObject jsonObject = JSONObject.parseObject(messageString);
String accountChangeString = jsonObject.getString("accountChange");
//将accountChange(json)转成AccountChangeEvent
AccountChangeEvent accountChangeEvent =
JSONObject.parseObject(accountChangeString, AccountChangeEvent.class);
//MQ里面的事务id
String txNo = accountChangeEvent.getTxNo();
int existTx = accountInfoDao.isExistTx(txNo);
if(existTx>0){
log.error("回查发现流水号已存在,事务执行成功,提交当前半消息");
return RocketMQLocalTransactionState.COMMIT;
}else{
log.error("回查发现流水号不存在,事务执行失败,回滚当前半消息");
return RocketMQLocalTransactionState.UNKNOWN;
}
}
可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,RocketMQ主要解决了两个问题:
- 本地事务与消息发送的原子性问题。(回查)
- 事务参与方接收消息的可靠性。(ack)
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。
本地消息表方案
不是本文重点,后续出专门的介绍
源码
FAQ
1:RocketMq消费失败默认重试16次(一定要做幂等)
2:RocketMq事务消息失败默认回查15次