SpringBoot+RocketMQ分布式下的消息最终一致性

SpringBoot+RocketMQ分布式下的消息最终一致

示例划分说明

整个例子涉及模拟三个服务,eureka注册中心、pay-center支付中心服务和api-passenger核心业务服务。主要调用图如下:
在这里插入图片描述

  1. 首先第三方支付发起支付结果回调。
  2. 封装我们的生产者业务基础数据和消费者业务基础数据。
  3. 使用rocketmq提交半消息(发到broker上进行持久化但不能被消费者消费的消息)。
  4. 生产者实现RocketMQLocalTransactionListener监听半消息并处理生产者本地事务,记录事务执行日志。并返回执行结果。
  5. 消费者监听topic下的消息进行消费,校验幂等性,校验重试次数,进行业务数据处理并记录消息消费记录。
  6. 超过重试次数的消息直接记录消息消费记录,并发送警告通知邮件。将其放入redis事务池中等待回滚处理。
  7. 定时执行器拉取redis中的消息发起退款并对数据进行回滚操作。
  8. 对于定时器也解决不了的数据直接人工接口补偿。
生产者(第三方支付服务)

使用的表:

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执行一次。数据回滚后将和刚开始保持一致。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值