SpringBoot+RocketMQ分布式下的消息最终一致
示例划分说明
整个例子涉及模拟三个服务,eureka注册中心、pay-center支付中心服务和api-passenger核心业务服务。主要调用图如下:
- 首先第三方支付发起支付结果回调。
- 封装我们的生产者业务基础数据和消费者业务基础数据。
- 使用rocketmq提交半消息(发到broker上进行持久化但不能被消费者消费的消息)。
- 生产者实现RocketMQLocalTransactionListener监听半消息并处理生产者本地事务,记录事务执行日志。并返回执行结果。
- 消费者监听topic下的消息进行消费,校验幂等性,校验重试次数,进行业务数据处理并记录消息消费记录。
- 超过重试次数的消息直接记录消息消费记录,并发送警告通知邮件。将其放入redis事务池中等待回滚处理。
- 定时执行器拉取redis中的消息发起退款并对数据进行回滚操作。
- 对于定时器也解决不了的数据直接人工接口补偿。
生产者(第三方支付服务)
使用的表:
CREATE TABLE `pay_waybill_data_info` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`waybill_id` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '运单id',
`pay_mount` decimal(16,2) NOT NULL COMMENT '支付金额',
`pay_status` enum('WAITE_PAY','FINISH_PAY','CANCEL_PAY','REFUND') CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT 'WAITE_PAY' COMMENT '支付状态(1待支付,2完成支付,3客户取消支付,4退款)',
`user_name` varchar(64) NOT NULL COMMENT '用户名称',
`user_id` varchar(20) NOT NULL COMMENT '用户id',
`id_card` varchar(32) NOT NULL COMMENT '用户身份证号',
`vip_flag` tinyint(1) NOT NULL COMMENT '是否vip用户(1是,2否)',
`create_time` datetime NOT NULL COMMENT '新增时间',
`update_time` datetime NOT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `waybillId_index` (`waybill_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COMMENT='运单支付信息';
CREATE TABLE `t_mq_transaction_log` (
`transaction_id` varchar(64) NOT NULL COMMENT '事务id',
`befor_log` varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '提交前日志',
`after_log` varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '提交后日志',
`op_type` enum('ADD','UPDATE','DELETE') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ADD' COMMENT '数据库操作类型()',
`msg_key` varchar(64) NOT NULL COMMENT '消息的key唯一',
PRIMARY KEY (`transaction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='事务日志表';
MQTXProducerService:生产者半消息发送
@Component
@Slf4j
public class MQTXProducerService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 发送事务半消息
*
* @param topic 主题
* @param tag 过滤
* @param transactionParam 本地事务参数
* @param consumerParam 消费者参数
* @return
*/
public TransactionSendResult sendHalfMessage(String topic, String tag, TransactionParam transactionParam,ConsumerParam consumerParam) {
if (StringUtils.isEmpty(topic)) {
log.error("【事务开始发送半消息】topic不能为空");
return null;
}
String destination = topic;
if (!StringUtils.isEmpty(tag)) {
destination = topic + ":" + tag;
}
String transactionId = IdUtil.simpleUUID();
String keys = IdUtil.simpleUUID();
consumerParam.setMsgKey(keys);
transactionParam.setMsgKey(keys);
log.info("【事务开始发送半消息】transactionId:{},transactionParam:{}", transactionId, JSON.toJSONString(transactionParam));
/**
* 参数1:指定发送的topic和过滤的tag(tag可以不指定默认为*表示不过滤)
* 参数2:message发送的消息体
* 参数3:交给本地事务的参数
*/
String msg = JSON.toJSONString(consumerParam);
TransactionSendResult transactionSendResult = rocketMQTemplate.sendMessageInTransaction(destination,
MessageBuilder.withPayload(msg).setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId).setHeader(RocketMQHeaders.KEYS, keys).build(),
transactionParam);
log.info("【事务发送半消息返回】transactionSendResult:{}", JSON.toJSONString(transactionSendResult));
return transactionSendResult;
}
}
MQTXListenerService:生产者本地事务监听处理器
@Slf4j
@RocketMQTransactionListener
public class MQTXListenerService implements RocketMQLocalTransactionListener, ApplicationContextAware {
private ApplicationContext applicationContext;
@Autowired
private TmqTransactionLogMapper tmqTransactionLogMapper;
/**
* 执行器,执行本地事务
*
* @param message
* @param o
* @return
*/
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
try {
String transactionId = message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID, String.class);
TransactionParam transactionParam = (TransactionParam) o;
log.info("【执行本地事务】消息参数 transactionId:{},transactionParam:{}", transactionId, JSON.toJSONString(transactionParam));
Object bean = applicationContext.getBean(transactionParam.getBeanName());
TXLocalTransaction transaction = null;
if (!(bean instanceof TXLocalTransaction)) {
log.error("【执行本地事务】分布式事务没有匹配到合法的父类处理器,请检查子类是否实现TXLocalTransaction。{}", JSON.toJSONString(transactionParam));
return RocketMQLocalTransactionState.ROLLBACK;
}
transaction = (TXLocalTransaction) bean;
switch (transactionParam.getMethodName()) {
case TransactionContans.ADD:
transaction.add(transactionParam, transactionId);
return RocketMQLocalTransactionState.COMMIT;
case TransactionContans.UPDATE:
transaction.update(transactionParam, transactionId);
return RocketMQLocalTransactionState.COMMIT;
case TransactionContans.DELETE:
transaction.delete(transactionParam, transactionId);
return RocketMQLocalTransactionState.COMMIT;
default:
log.error("【开始执行本地事务】分布式事务没有匹配到合法的方法名称,请检查执行事务方法名称是否正确。{}", JSON.toJSONString(transactionParam));
return RocketMQLocalTransactionState.ROLLBACK;
}
} catch (Exception e) {
log.error("【执行本地事务】分布式事务之本地事务发生异常。", e);
return RocketMQLocalTransactionState.ROLLBACK;
}
}
/**
* 回查:用于回查本地事务结果(解决长时间不响应问题)
*
* @param message
* @return
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
String transactionId = message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID, String.class);
TMQTransactionLogPO logByTransactionId = tmqTransactionLogMapper.findLogByTransactionId(transactionId);
log.error("【执行本地事务回查】分布式事务回查事务日志。transactionId:{},TMQTransactionLogPO:{}", transactionId, JSON.toJSONString(logByTransactionId));
if (Objects.isNull(logByTransactionId)) {
return RocketMQLocalTransactionState.ROLLBACK;
}
return RocketMQLocalTransactionState.COMMIT;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
TXLocalTransaction:接口封装自适应实现接口重写方法即可接入生产者本地事务
public interface TXLocalTransaction {
/**
* 新增
* @param transactionParam
* @param transactionId
* @return
*/
TaoTuMasterResult add(TransactionParam transactionParam,String transactionId);
/**
* 修改
* @param transactionParam
* @param transactionId
* @return
*/
TaoTuMasterResult update(TransactionParam transactionParam,String transactionId);
/**
* 删除
* @param transactionParam
* @param transactionId
* @return
*/
TaoTuMasterResult delete(TransactionParam transactionParam,String transactionId);
}
PayCallbackServiceImpl:本地业务及本地调用
@Service
public class PayCallbackServiceImpl implements PayCallbackService, TXLocalTransaction {
private static final String Topic = "RLT_PAY_TRANS_TOPIC";
private static final String Tag = "Pay";
@Autowired
private MQTXProducerService mqtxProducerService;
@Autowired
private PayWaybillDataInfoMapper payWaybillDataInfoMapper;
@Autowired
private TmqTransactionLogMapper tmqTransactionLogMapper;
@Override
public TaoTuMasterResult getCallback(PayWaybillDataInfoPO payWaybillDataInfoPO) {
PayWaybillDataInfoPO payWaybillDataInfoByWaybillId = payWaybillDataInfoMapper.findPayWaybillDataInfoByWaybillId(payWaybillDataInfoPO.getWaybillId());
TransactionParam<PayWaybillDataInfoPO> transactionParam = new TransactionParam<>("payCallbackServiceImpl", TransactionContans.UPDATE, payWaybillDataInfoPO);
ConsumerParam consumerParam = new ConsumerParam(payWaybillDataInfoPO);
transactionParam.setOldParam(payWaybillDataInfoByWaybillId);
TransactionSendResult transactionSendResult = mqtxProducerService.sendHalfMessage(Topic, Tag, transactionParam, consumerParam);
return TaoTuMasterResultUtil.result(transactionSendResult);
}
@Override
public TaoTuMasterResult add(TransactionParam transactionParam, String transactionId) {
return null;
}
@Override
@Transactional(rollbackFor = Exception.class)
public TaoTuMasterResult update(TransactionParam transactionParam, String transactionId) {
PayWaybillDataInfoPO newPayWaybillDataInfoPO = (PayWaybillDataInfoPO) transactionParam.getNewParam();
PayWaybillDataInfoPO oldPayWaybillDataInfoPO = (PayWaybillDataInfoPO) transactionParam.getOldParam();
int i = payWaybillDataInfoMapper.update(newPayWaybillDataInfoPO);
if (i <= 0) {
throw new RuntimeException("更新失败");
}
TMQTransactionLogPO tmqTransactionLogPO = new TMQTransactionLogPO();
tmqTransactionLogPO.setTransactionId(transactionId);
tmqTransactionLogPO.setOpType(OpTypeEnum.UPDATE);
tmqTransactionLogPO.setMsgKey(transactionParam.getMsgKey());
tmqTransactionLogPO.setAfterLog(JSON.toJSONString(newPayWaybillDataInfoPO));
tmqTransactionLogPO.setBeforLog(JSON.toJSONString(oldPayWaybillDataInfoPO));
int count = tmqTransactionLogMapper.add(tmqTransactionLogPO);
if (count <= 0) {
throw new RuntimeException("新增事务日志失败");
}
return TaoTuMasterResultUtil.ok();
}
@Override
public TaoTuMasterResult delete(TransactionParam transactionParam, String transactionId) {
return null;
}
}
PayRefundServiceImpl:消费者异常下的数据回退
@Service
@Slf4j
public class PayRefundServiceImpl implements PayRefundService {
@Autowired
private TaoTuRedisUtil taoTuRedisUtil;
@Autowired
private TmqTransactionLogMapper tmqTransactionLogMapper;
@Autowired
private PayWaybillDataInfoMapper payWaybillDataInfoMapper;
@Autowired
@Qualifier("delayExecutor")
private ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
/**
* 执行一次定时任务
*/
@Override
@PostConstruct
public void scheduledRefund() {
scheduledThreadPoolExecutor.scheduleAtFixedRate(new DeadHandler(payWaybillDataInfoMapper,tmqTransactionLogMapper,taoTuRedisUtil),0,60, TimeUnit.SECONDS);
}
}
DeadHandler:数据回退执行器
@Slf4j
public class DeadHandler implements Runnable {
private static final String PAY_TRANS = "PAY_TRANS";
private PayWaybillDataInfoMapper payWaybillDataInfoMapper;
private TmqTransactionLogMapper tmqTransactionLogMapper;
private TaoTuRedisUtil taoTuRedisUtil;
public DeadHandler(PayWaybillDataInfoMapper payWaybillDataInfoMapper, TmqTransactionLogMapper tmqTransactionLogMapper, TaoTuRedisUtil taoTuRedisUtil) {
this.payWaybillDataInfoMapper = payWaybillDataInfoMapper;
this.tmqTransactionLogMapper = tmqTransactionLogMapper;
this.taoTuRedisUtil = taoTuRedisUtil;
}
@Override
public void run() {
Map<String, Object> map = taoTuRedisUtil.hmGet(PAY_TRANS);
for (Map.Entry<String, Object> entry : map.entrySet()) {
String msg = (String) entry.getValue();
String msgKey = entry.getKey();
log.info("【发起退款】参数信息:{} msgKey:{}", msg, msgKey);
TMQTransactionLogPO tmqTransactionLogPO = tmqTransactionLogMapper.findLogByMsgKey(msgKey);
log.info("【事务日志】查询结果为{}",JSON.toJSONString(tmqTransactionLogPO));
if (tmqTransactionLogPO == null) {
log.error("【事务日志】查询异常,查询结果为空");
return;
}
if (OpTypeEnum.ADD.equals(tmqTransactionLogPO.getOpType())) {
log.info(" ********************** 操作支付数据回滚删除newParam数据操作 **********************");
} else if (OpTypeEnum.UPDATE.equals(tmqTransactionLogPO.getOpType())) {
// 用这个为例
// 发起第三方支付退款业务调用
log.info(" ********************** 发起第三方支付退款业务调用 **********************");
/**
* 。。。。。。省略退款业务
*/
// 第三方退款失败(几率小,首先要保证我们这边支付服务的稳定)万一失败数据在redis中,后续使用接口进行人工补偿。补偿数据原理同这里,只不过是接口触发,从redis中读取数据处理。
boolean refundResult = true;
if (!refundResult) {
// 退款失败--->自动回退数据失败后将一直保留在redis中,后续可直接从redis中取出并人工补偿
log.error("【发起退款】第三方支付退款失败,请进行人工数据补偿:msgKey{}", msgKey);
return;
}
// 退款成功数据回退
log.info("【开始发起退款】操作支付数据回滚修改为oldParam操作");
// 从事务日志表中拿到之前的数据日志信息进行回滚
String beforLog = tmqTransactionLogPO.getBeforLog();
PayWaybillDataInfoPO oldPayWaybillDataInfoPO = JSON.parseObject(beforLog, PayWaybillDataInfoPO.class);
payWaybillDataInfoMapper.update(oldPayWaybillDataInfoPO);
// 正常结束的清除redis数据
taoTuRedisUtil.hDel(PAY_TRANS, msgKey);
} else if (OpTypeEnum.DELETE.equals(tmqTransactionLogPO.getOpType())) {
log.info(" ********************** 操作支付数据回滚增加oldParam操作 **********************");
}
}
}
}
消费者(核心业务服务)
用到的表:
CREATE TABLE `waybill_info_data` (
`id` varchar(20) NOT NULL COMMENT '运单id',
`waybill_status` tinyint(1) NOT NULL COMMENT '1未完成 2已完成',
`create_time` datetime NOT NULL COMMENT '新增时间',
`update_time` datetime NOT NULL COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='运单信息';
CREATE TABLE `msg_consumer_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '消息id',
`consumer_status` tinyint(1) NOT NULL COMMENT '消费状态(1未消费 2已消费)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='消息消费日志';
WaybillDataServiceImpl:消费者监听消息
@Slf4j
@Service
@RocketMQMessageListener(topic = "RLT_PAY_TRANS_TOPIC", consumerGroup = "PAY_TRANS_GROUP", selectorExpression = "Pay")
public class WaybillDataServiceImpl implements RocketMQListener<MessageExt>, RocketMQPushConsumerLifecycleListener {
private static final String PAY_TRANS = "PAY_TRANS";
@Value("${spring.application.name}")
private String instanceName;
@Autowired
private MsgConsumerLogMapper msgConsumerLogMapper;
@Autowired
private WaybillInfoDataMapper waybillInfoDataMapper;
@Autowired
private TaoTuRedisUtil taoTuRedisUtil;
@Autowired
private JavaMailSender javaMailSender;
@Override
@Transactional(rollbackFor = Exception.class)
public void onMessage(MessageExt msgExt) {
if (msgExt == null) {
return;
}
String msg = new String(
msgExt.getBody()
);
JSONObject object = JSON.parseObject(msg);
String msgKey = object.get("msgKey").toString();
// 1.先检查幂等性
MsgConsumerLogPO msgConsumerLogPO = msgConsumerLogMapper.findByMsgId(msgKey);
if (msgConsumerLogPO != null) {
log.info("消息已经消费过了");
return;
}
// 2.判断触发重试次数
if (msgExt.getReconsumeTimes() > 3) {
log.info("【消费者事务异常】超过最大重试次数 consumerParam:{}", msg);
// (事务出现异常之后)发邮件通知并将死信消息放入redis中用于后续数据补偿
// 2-1.发邮件通知《用于通知及时处理业务报错问题,防止代码问题导致业务操作不一致问题》
Map<String,Object> map = new HashMap<>(16);
map.put(msgKey, JSON.toJSONString(msg));
SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
simpleMailMessage.setTo("");
simpleMailMessage.setSubject("分布式事务异常通知");
simpleMailMessage.setText("【消费者事务异常】消费者接收信息msgKey为:" + msgKey + ",请及时处理!!!");
simpleMailMessage.setFrom("");
// 2-2.组装并插入消费记录,发送邮件-加入redis
msgConsumerLogPO = new MsgConsumerLogPO();
msgConsumerLogPO.setMsgId(msgKey);
msgConsumerLogPO.setConsumerStatus(2);
msgConsumerLogMapper.add(msgConsumerLogPO);
javaMailSender.send(simpleMailMessage);
taoTuRedisUtil.hmSet(PAY_TRANS, map);
return;
}
log.info("【事务消费消息】consumerParam:{}", JSON.toJSONString(msg));
Object param = object.get("param");
String paramStr = JSON.toJSONString(param);
JSONObject jsonObject = JSON.parseObject(paramStr);
// 3.查询并更新业务数据
WaybillInfoDataPO waybillInfoDataPO = waybillInfoDataMapper.findByWaybillId(jsonObject.get("waybillId").toString());
if (waybillInfoDataPO == null) {
log.error("单子不存在");
return;
}
waybillInfoDataPO.setWaybillStatus(2);
waybillInfoDataPO.setUpdateTime(new Date());
int i = waybillInfoDataMapper.update(waybillInfoDataPO);
if (i <= 0) {
throw new RuntimeException("数据表未更新成功");
}
// 制造消费端异常
//int s = 1/0;
// 4.保存消息消费记录
msgConsumerLogPO = new MsgConsumerLogPO();
msgConsumerLogPO.setMsgId(msgKey);
msgConsumerLogPO.setConsumerStatus(2);
msgConsumerLogMapper.add(msgConsumerLogPO);
}
@SneakyThrows
@Override
public void prepareStart(DefaultMQPushConsumer defaultMQPushConsumer) {
// 设置最大重试次数
//defaultMQPushConsumer.setMaxReconsumeTimes(3);
defaultMQPushConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
defaultMQPushConsumer.setInstanceName(instanceName);
//defaultMQPushConsumer.setConsumeTimestamp(UtilAll.timeMillisToHumanString3(System.currentTimeMillis()));
}
}
测试
执行前的数据结果:
正常执行后:
消费端异常:
。。。。。。。。。。自己测试,消息失败重试超过3次后会进入redis,执行器每60s执行一次。数据回滚后将和刚开始保持一致。