前言
上篇介绍了rocketmq-spring-boot-starter的使用过程,本篇文章介绍怎么用RocketMQ解决分布式环境下的事务问题
如果对 rocketmq-spring-boot-starter 不熟的,建议先看我上篇文章:Springboot整合RocketMQ使用教程
场景:假设我们现在有这样的业务:用户充值网费会获得积分,且1元=1积分,用户服务中充值100元,积分服务中要对该用户增加100积分
分析:像这种跨服务、跨库的操作,我们要保证这两个操作要么一起成功、要么一起失败,采用RocketMQ的方案就是:RocketMQ事务消息+本地事务+监听消费,来达到最终一致性
在实现之前,先得介绍一下RocketMQ的事务
1. 基本概念
(1)Half Message:也叫 Prepare Message,翻译为 “半消息”或“准备消息”,指的是暂时无法投递的消息,即消息成功发送到MQ服务器,暂时还不能给消费者进行消费,只有当服务器接收到生产者传来的二次确认时,才能被消费者消费
(2)Message Status Check:消息状态回查。网络断开连接或生产者应用程序重新启动可能会丢失对事务性消息的第二次确认,当MQ服务器发现某条消息长时间保持半消息状态时,它会向消息生产者发送一个请求,去检查消息的最终状态(“提交”或“回滚”)
- 生产者发送半消息到 MQ Server,暂时不能投递,不会被消费
- 半消息发送成功后,生产者这边执行本地事务
- 生产者根据本地事务执行结果,向 MQ Server 发送 commit 或 rollback 消息进行二次确认
- 如果 MQ Server 接收到的 commit,则将半消息标记为可投递状态,此时消费者就能进行消费;如果收到的是 rollback,则将半消息直接丢弃,不会进行消费
- 如果 MQ Server 未收到二次确认消息,MQ Server 则会定时(默认1分钟)向生产者发送回查消息,检查本地事务状态,然后生产者根据本地事务回查结果再次向 MQ Server 发送 commit 或 rollback消息
1. 建表
(1)用户表
CREATE TABLE `t_user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户表',
`name` varchar(16) NOT NULL COMMENT '姓名',
`id_card` varchar(32) NOT NULL COMMENT '身份证号',
`balance` int(11) NOT NULL DEFAULT '0' COMMENT '余额',
`state` tinyint(1) DEFAULT NULL COMMENT '状态(1在线,0离线)',
`vip_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'VIP用户标识(1是,0否)',
`create_time` datetime NOT NULL COMMENT '创建时间',
`last_login_time` datetime DEFAULT NULL COMMENT '最后一次登录时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4
(2)积分表
CREATE TABLE `t_credit` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '积分表',
`user_id` int(11) NOT NULL COMMENT '用户id',
`username` varchar(16) NOT NULL COMMENT '用户姓名',
`integration` int(11) NOT NULL DEFAULT '0' COMMENT '积分',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4
(3)事务日志表
CREATE TABLE `t_mq_transaction_log` (
`transaction_id` varchar(64) NOT NULL COMMENT '事务id',
`log` varchar(64) NOT NULL COMMENT '日志',
PRIMARY KEY (`transaction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
- 我是模拟,都是在一个库里,至于为什么还要建个事务日志表,后面你就知道了
- 建完表后,为了方便自己先手动在用户表和积分表写一条数据
- 项目结构搭建这里略过,包括实体类、mapper接口、xml文件等
2. 新建MQ事务生产者:MQTXProducerService
@Slf4j
@Component
public class MQTXProducerService {
private static final String Topic = "RLT_TEST_TOPIC";
private static final String Tag = "charge";
private static final String Tx_Charge_Group = "Tx_Charge_Group";
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 先向MQ Server发送半消息
* @param userCharge 用户充值信息
*/
public TransactionSendResult sendHalfMsg(UserCharge userCharge) {
// 生成生产事务id
String transactionId = UUID.randomUUID().toString().replace("-", "");
log.info("【发送半消息】transactionId={}", transactionId);
// 发送事务消息(参1:生产者所在事务组,参2:topic+tag,参3:消息体(可以传参),参4:发送参数)
TransactionSendResult sendResult = rocketMQTemplate.sendMessageInTransaction(
Tx_Charge_Group, Topic + ":" + Tag,
MessageBuilder.withPayload(userCharge).setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId).build(),
userCharge);
log.info("【发送半消息】sendResult={}", JSON.toJSONString(sendResult));
return sendResult;
}
}
- 这里我用的UUID生成事务id,就是上面的事务表的id
- 方法参数userCharge,额外加的,可理解为dto,就两个字段:userId、chargeAmount,代表用户id和充值金额
- 这里注意:发送半消息方法里有两个参数,参3和参4,看过上篇整合教程的应该知道,这个参3是给消费者的,而这个参4是给本地事务的,我这里是模拟写的是一样的,实际业务可能会不同
3、新建本地事务监听器:MQTXLocalService
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "Tx_Charge_Group") // 这里的txProducerGroup的值要与发送半消息时保持一致
public class MQTXLocalService implements RocketMQLocalTransactionListener {
@Autowired
private UserService userService;
@Autowired
private MQTransactionLogMapper mqTransactionLogMapper;
/**
* 用于执行本地事务的方法
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object obj) {
// 获取消息体里参数
MessageHeaders messageHeaders = message.getHeaders();
String transactionId = (String) messageHeaders.get(RocketMQHeaders.TRANSACTION_ID);
log.info("【执行本地事务】消息体参数:transactionId={}", transactionId);
// 执行带有事务注解的本地方法:增加用户余额+保存mq日志
try {
UserCharge userCharge = (UserCharge) obj;
userService.addBalance(userCharge, transactionId);
return RocketMQLocalTransactionState.COMMIT; // 正常:向MQ Server发送commit消息
} catch (Exception e) {
log.error("【执行本地事务】发生异常,消息将被回滚", e);
return RocketMQLocalTransactionState.ROLLBACK; // 异常:向MQ Server发送rollback消息
}
}
/**
* 用于回查本地事务执行结果的方法
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
MessageHeaders headers = message.getHeaders();
String transactionId = headers.get(RocketMQHeaders.TRANSACTION_ID, String.class);
log.info("【回查本地事务】transactionId={}", transactionId);
// 根据事务id查询事务日志表
MQTransactionLog mqTransactionLog = mqTransactionLogMapper.selectByPrimaryKey(transactionId);
if (null == mqTransactionLog) { // 没查到表明本地事务执行失败,通知回滚
return RocketMQLocalTransactionState.ROLLBACK;
}
return RocketMQLocalTransactionState.COMMIT; // 查到表明本地事务执行成功,提交
}
}
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private MQTransactionLogMapper mqTransactionLogMapper;
/**
* 用户增加余额+事务日志
*/
@Transactional(rollbackFor = Exception.class)
public void addBalance(UserCharge userCharge, String transactionId) {
// 1. 增加余额
userMapper.addBalance(userCharge.getUserId(), userCharge.getChargeAmount());
// 2. 写入mq事务日志
saveMQTransactionLog(transactionId, userCharge);
}
@Transactional(rollbackFor = Exception.class)
public void saveMQTransactionLog(String transactionId, UserCharge userCharge) {
MQTransactionLog transactionLog = new MQTransactionLog();
transactionLog.setTransactionId(transactionId);
transactionLog.setLog(JSON.toJSONString(userCharge));
mqTransactionLogMapper.insertSelective(transactionLog);
}
}
- 这里代码是主要关键的地方,本地事务是给用户增加余额后再插入mq事务日志,这两个操作只有成功了,才返回COMMIT,异常失败就返回ROLLBACK
- 回查方法不一定会执行,但是得有,回查就是根据我们之前生成穿过来的那个事务id(transactionId)来查询事务日志表,这样的好处是业务牵涉的表再多无所谓,我这个日志表也与你本地事务绑定,我只需查询这一张事务表就够了,能找到就代表本地事务执行成功了
4. 新建事务消息消费者:MQTXConsumerService
@Slf4j
@Component
@RocketMQMessageListener(topic = "RLT_TEST_TOPIC", selectorExpression = "charge", consumerGroup = "Con_Group_Four") // topic、tag保持一致
public class MQTXConsumerService implements RocketMQListener<UserCharge> {
@Autowired
private CreditMapper creditMapper;
@Override
public void onMessage(UserCharge userCharge) {
// 一般真实环境这里消费前,得做幂等性判断,防止重复消费
// 方法一:如果你的业务中有某个字段是唯一的,有标识性,如订单号,那就可以用此字段来判断
// 方法二:新建一张消费记录表t_mq_consumer_log,字段consumer_key是唯一性,能插入则表明该消息还未消费,往下走,否则停止消费
// 我个人建议用方法二,根据你的项目业务来定义key,这里我就不做幂等判断了,因为此案例只是模拟,重在分布式事务
// 给用户增加积分
int i = creditMapper.addNumber(userCharge.getUserId(), userCharge.getChargeAmount());
if (1 == i) {
log.info("【MQ消费】用户增加积分成功,userCharge={}", JSONObject.toJSONString(userCharge));
} else {
log.error("【MQ消费】用户充值增加积分消费失败,userCharge={}", JSONObject.toJSONString(userCharge));
}
}
}
- 消费者其实比较简单,和普通消费者差不多,注意属性配置就行了
- 这里你可能质疑,前面的发送和本地事务都没啥问题,要么commit要么rollback,但如果这里消费失败怎么办呢?其实这里会产生问题的几率几乎不存在,首先RocketMQ就是高可用的,要真的你系统很庞大很庞大,你可以集群;再者,这里消费成功与否,源码内部已做处理,只要没异常,就会进行消费,而且它也有重试机制;最后,这里消费逻辑你可以扩展,当消费不成功时,你可以把该记录保存下来,定时提醒或人工去处理
RocketMQController中添加:
@PostMapping("/charge")
public Result<TransactionSendResult> charge(UserCharge userCharge) {
TransactionSendResult sendResult = mqtxProducerService.sendHalfMsg(userCharge);
return Result.success(sendResult);
}
用postman调用:http://localhost:8080/rocketmq/charge
看到是正常的,再去看下数据库,发现从余额和积分都加了100,事务日志表也有记录,成功!
总结:当理解了事务的实现过程后会发现用RocketMQ解决分布式事务还是挺简单的,实际生产中得根据业务灵活运用。