一:分布式事务

分布式系统环境下由 不同的服务之间 通过 网络远程协作 完成事务称之为分布式事务。分布式事务解决方案有很多,今天介绍一种常用的方式:可靠消息最终一致性。

工作中很多异步操作都是通过发消息的形式来实现的而不会同步调用微服务接口,如注册用户送积分,用户是一个微服务,积分是一个微服务,当用户在用户微服务中注册完账号时(用户数据保存到用户表中)就要发送一个异步消息来通知积分系统增加积分。可是怎么保证在注册用户操作数据库和异步发送消息的事务性呢?即 注册用户操作数据库和发送消息要么同时成功,要么同时失败?

思路一:先发消息后操作数据库

begin transaction
	// 1. 发送MQ
	// 2. 数据库操作
commit transaction

MQ发送失败,当然第二步也得不到执行,保证了事务一致性。
可是如果是发送MQ成功了,数据库操作失败了,已经发送的MQ就收不回来了,这种情况就不能保证事务的一致性。

思路二:先操作数据库后发消息

begin transaction
	// 1. 数据库操作
	// 2. 发送MQ
commit transaction
  • 先操作数据库,数据库操作成功后再发消息,如果消息发送成功了整个事务一致性没有问题。
  • 先操作数据库,数据库操作成功后再发消息,如果数据发送失败了整个事务也能保持一致性。
  • 可是当数据库操作成功了,发送MQ也成功了只是由于网络原因迟迟MQ没有响应,导致发送MQ超时,最终会被误认为发送失败了,最终数据库操作也回滚了,而此时消息实际已经发出去了,这就导致数据库操作和发消息不一致,所以这种方案也不行。那么问题来了,如何保证100%将消息发送成功了?如果这个问题解决了那么思路二就是可行的。

二:保证100%发送成功

由于网络波动等原因有可能消息是发送不成功到RocketMQ中的,或者发送成功但响应超时等,当遇到这种情况下我们就需要尝试重新发送,

  1. 首先将要发的消息持久化到数据库中。
  2. 然后发送消息到MQ中。
  3. 如果发送成功就删除数据库中的该条记录。
  4. 如果发送失败不要报错再尝试几次发送,如果都失败保存到数据库中,后面由定时器再去尝试发送。

无论是通过尝试多次发送,还是由定时任务继续补偿发送,都要保证该消息必须100%发送到MQ中。

CREATE TABLE `tbl_mq_producer_temp` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `topic` varchar(50) DEFAULT NULL COMMENT '主题',
  `tags` varchar(50) DEFAULT NULL COMMENT '标签',
  `keys` varchar(100) DEFAULT NULL COMMENT '消息体唯一标识',
  `body` text COMMENT '消息体',
  `exception` text COMMENT '异常原因',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '插入时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1354013274852601859 DEFAULT CHARSET=utf8

三:消息重试

当消费消息时如果抛出异常了,此时消费者还能消费到该消息,默认最多允许消费16次,每次消费的时间间隔越来越长。如果消息重试16次后仍然失败,消息不再投递,如果严格按照上述重试时间间隔计算,某条消息在一直消费失败的前提下将会在接下来的4小时46分钟只能进行16次重试,超过这个时间范围消息将不再重试投递,进入 死信队列。可以显式配置重试次数,如果设置次数小于16次那么间隔时间按照默认的配置,如果大于16次,后面的每一次间隔都是2小时。

RocketMQ入门教程(四):可靠消息最终一致性(本地消息表)_发送消息

消息重试注意事项:

  • 顺序消息:顺序消息如果消费出错后面的消息将会阻塞掉得不到消费,所以顺序消息必须严格监控确保有问题及时监控并处理掉。
  • 无序消息:只针对集群模式,广播模式不会重试。

四:死信队列

当消费队列消费最大次数时消息会被放入到死信队列,死信队列可以通过控制台 主题 - 死信 中查找。

  • 死信队列不会被消费者消费。
  • 一个死信队列是针对一个消费者组,所有该消费者组下的所有topic都会被放入到一个死信队列中。所以如果要监听死信队列注意每条消息的主题可能会是不同的。
  • 死信队列中消息有效期为3天,3天之后就会被删除。

如何处理死信队列:

方式一:可以在控制台重新将该消息发送来重新消费。

方式二:定义一个消费者专门监听某个死信队列去消费。

五:消费消息的幂等性

发送消息重复可能有以下几个原因:

  • 发送时会发送多次,由于网络原因生产者没有收到Broker的成果响应,生产者会认为发送失败还会再尝试发送。
  • 消息投递时重复投递,由于程序bug导致。
  • 负载均衡,当某个消费者突然宕机了,消费的负载均衡会重新分配,可能会造成重复消费。

消费消息的幂等性是指同一个消息无论消费多少次对最终的结果都是一致的。那么我们如何保障消费多次和消费一次的结果完全一样呢?那就是在消费者消费消息之前首先就要判断该消息是否已经消费了!那如何判断是否消费过呢?这就需要在发送消息时将该消息保存到数据库,数据库表中包含消费状态字段,如果查询数据库中改消息已经消费过了那就直接return了,后面的逻辑不需要再执行了。那查询数据库数据的唯一条件是什么呢?注意:RocketMQ不能保证msgId字段的唯一,所以通过msgId处理消费幂等性不靠谱,可以通过 topic + tag + key(业务key,业务唯一的标识,如订单号等)。

六:持久化消息

发送消息通常会将消息体保存到数据库中,可以通过封装一个公共的方法先保存到数据库再发送消息,也可以通过AOP在发送前将消息保存到数据库。

CREATE TABLE `tbl_mq_consumer_log` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `topic` varchar(50) DEFAULT NULL COMMENT '主题',
  `tags` varchar(50) DEFAULT NULL COMMENT '标签',
  `keys` varchar(100) DEFAULT NULL COMMENT '消息体唯一标识',
  `body` text COMMENT '消息体',
  `consume_times` tinyint(1) DEFAULT '0' COMMENT '消费次数',
  `exception` text COMMENT '失败原因',
  `status` tinyint(1) DEFAULT '0' COMMENT '状态:0未处理,1处理中,2处理失败, 3处理成功',
  `version` int(10) DEFAULT '1' COMMENT '乐观锁',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '插入时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8

发送消息一般都会放到线程池中异步发送,防止阻塞当前主线程。

@Bean
public ThreadPoolTaskExecutor getThreadPool() {

    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(100);
    executor.setKeepAliveSeconds(60);
    executor.setThreadNamePrefix("Pool-A");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();

    return executor;
}


@Autowired
private ThreadPoolTaskExecutor threadPoolTaskExecutor;

threadPoolTaskExecutor.submit(new Runnable() {
	public void run() {
		// 发送消息
	}
});

七:消费消息逻辑

  • 消费时一般先打印一下消息内容日志,以便于排错。
  • 消费消息需要注意幂等性,一般是先解析消息,然后根据消息的唯一条件等值去数据库中查询该消息的状态,如果是处理中或者处理完成就不需要继续处理了。
  • 如果是消费失败,注意判断消费的次数,如果已经3次消费失败了就不再处理了,记录错误日志,由人工干预。
  • 业务处理完了需要更新消息的状态。在更新状态的时候注意防止并发修改,使用数据库乐观锁。
  • 当消费消息时出现错误时需要将消息的状态更新为消费失败,并累加消费失败的次数。

八:基本实现

使用ThreadPoolTaskExecutor定义线程来异步发送消息。

@Configuration
public class CommonConfig {

    @Bean
    public ThreadPoolTaskExecutor getThreadPool() {

        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("Pool-A");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();

        return executor;
    }
}

定义一个公共的发送消息的方法。

@Slf4j
@Component
public class RocketMQService {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Autowired
    private MQConsumerLogMapper mqConsumerLogMapper;

    @Autowired
    private MQProducerTempMapper mqProducerTempMapper;


    public void send(Message message) throws UnsupportedEncodingException {
        // 1. 保证100%发送成功,持久化到数据库
        MQProducerTemp temp = new MQProducerTemp();
        temp.setTopic(message.getTopic());
        temp.setTags(message.getTags());
        temp.setKeys(message.getKeys());
        temp.setBody(new String(message.getBody(), "UTF-8"));
        temp.setCreateTime(new Date());

        MQConsumerLog consumeLog = new MQConsumerLog();
        BeanUtils.copyProperties(temp, consumeLog);

        // 2. 发送消息到RocketMQ
        SendResult sendResult = null;
        try {
            mqProducerTempMapper.insert(temp);
            mqConsumerLogMapper.insert(consumeLog);

            sendResult = rocketMQTemplate.getProducer().send(message);
            // 3. 发送成功删除临时消息
            if (sendResult.getSendStatus() == SendStatus.SEND_OK) {
                // 删除消息
                mqProducerTempMapper.deleteById(temp.getId());
            } else {
                // 可以尝试再次发送或者后面由定时任务定时发送
            }
        } catch (Exception e) {
            log.error("消息发送失败: {}", e);
            // 发送失败打记录错误原因
            MQProducerTemp entity = new MQProducerTemp();
            entity.setId(consumeLog.getId());
            entity.setException(e.getMessage());
            mqProducerTempMapper.updateById(entity);
        }
    }
}

测试发送消息。

@RestController
@RequestMapping("/mq")
public class RocketMQController {

    @Autowired
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;

    @Autowired
    private RocketMQService rocketMQService;

    @RequestMapping("/send")
    public void send() {
        // 发送消息
        Message message = new Message("test-topic", "test-tag", "1", "msg boday".getBytes());
        threadPoolTaskExecutor.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    rocketMQService.send(message);
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}

消费消息。

@Slf4j
@Component
@RocketMQMessageListener(
        consumerGroup = "testConsumerGroup",
        topic = "test-topic"
)
public class TestTopicConsumerListener implements RocketMQListener<MessageExt> {

    @Autowired
    private MQConsumerLogMapper mqConsumerLogMapper;


    @Override
    @SneakyThrows
    public void onMessage(MessageExt messageExt) {
        String topic = messageExt.getTopic();
        String tags = messageExt.getTags();
        String keys = messageExt.getKeys();
        String body = new String(messageExt.getBody(), "UTF-8");
        log.info("开始消费:topic={}, tags={}, keys={}, body={}", topic, tags, keys, body);

        MQConsumerLog mqConsumerLog = null;
        try {
            // 查询消息
            mqConsumerLog = mqConsumerLogMapper.selectOne(Wrappers.<MQConsumerLog>lambdaQuery()
                    .eq(MQConsumerLog::getTopic, topic)
                    .eq(MQConsumerLog::getTags, tags)
                    .eq(MQConsumerLog::getKeys, keys)
                    .orderByDesc(MQConsumerLog::getCreateTime)
                    .last(" limit 1 ")
            );
            if (mqConsumerLog == null) {
                return;
            }

            // 判断消息状态
            Integer status = mqConsumerLog.getStatus();
            if (status == 1 || status == 3) {
                // 处理中或者处理成功都不需要重复处理(消息幂等性)
                return;
            } else if (status == 2) {
                // 2 处理失败
                if (mqConsumerLog.getConsumeTimes() >= 3) {
                    // 超过指定消费次数记录日志,人工干预
                    return;
                }
            }
            // 处理业务逻辑

            // 使用乐观锁修改消息状态为成功,如果update失败表示并发修改,打印日志即可
            MQConsumerLog entity = new MQConsumerLog();
            entity.setStatus(3);
            entity.setConsumeTimes(mqConsumerLog.getConsumeTimes() + 1);
            entity.setUpdateTime(new Date());
            entity.setVersion(mqConsumerLog.getVersion() + 1);

            int row = mqConsumerLogMapper.update(entity, Wrappers.<MQConsumerLog>lambdaUpdate()
                    .eq(MQConsumerLog::getId, mqConsumerLog.getId())
                    .eq(MQConsumerLog::getVersion, mqConsumerLog.getVersion())
            );
            if (row < 0) {
                log.info("并发修改");
            } else {
                log.info("消费成功:topic={}, tags={}, keys={}, body={}", topic, tags, keys, body);
            }
        } catch (Exception e) {
            // 标记消息状态为处理失败,并累加消费次数
            MQConsumerLog entity = new MQConsumerLog();
            entity.setId(mqConsumerLog.getId());
            entity.setStatus(2);
            entity.setConsumeTimes(mqConsumerLog.getConsumeTimes() + 1);
            entity.setException(e.getMessage());
            entity.setUpdateTime(new Date());
            entity.setVersion(mqConsumerLog.getVersion());
            mqConsumerLogMapper.updateById(entity);
        }
    }
}

九:使用AOP优化基本实现

消费时消息幂等性以及更新消息的状态都属于公共逻辑,如果在每个Listener中都写很是臃肿,这里将这些公共逻辑提取到AOP中。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@Slf4j
@Aspect
@Component
public class ConsumerListenerAspect {

    @Autowired
    private MQConsumerLogMapper mqConsumerLogMapper;

    private MQConsumerLog mqConsumerLog = null;

    @Pointcut("execution(public * com.example.rocketmq.listener.*.onMessage(..))")
    public void pointcut() { }


    @SneakyThrows
    @Before("pointcut()")
    public void doBefore(JoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        log.info("Before :" + method.getName());

        MessageExt messageExt = (MessageExt)joinPoint.getArgs()[0];
        String topic = messageExt.getTopic();
        String tags = messageExt.getTags();
        String keys = messageExt.getKeys();
        String body = new String(messageExt.getBody(), "UTF-8");
        log.info("开始消费:topic={}, tags={}, keys={}, body={}", topic, tags, keys, body);

        // 查询消息
        mqConsumerLog = mqConsumerLogMapper.selectOne(Wrappers.<MQConsumerLog>lambdaQuery()
                .eq(MQConsumerLog::getTopic, topic)
                .eq(MQConsumerLog::getTags, tags)
                .eq(MQConsumerLog::getKeys, keys)
                .orderByDesc(MQConsumerLog::getCreateTime)
                .last(" limit 1 ")
        );
        if (mqConsumerLog == null) {
            return;
        }

        // 判断消息状态
        Integer status = mqConsumerLog.getStatus();
        if (status == 1 || status == 3) {
            // 处理中或者处理成功都不需要重复处理(消息幂等性)
            return;
        } else if (status == 2) {
            // 2 处理失败
            if (mqConsumerLog.getConsumeTimes() >= 3) {
                // 超过指定消费次数记录日志,人工干预
                return;
            }
        }
    }


    @SneakyThrows
    @After("pointcut()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("After : ----------------");
        MessageExt messageExt = (MessageExt)joinPoint.getArgs()[0];
        String topic = messageExt.getTopic();
        String tags = messageExt.getTags();
        String keys = messageExt.getKeys();
        String body = new String(messageExt.getBody(), "UTF-8");


        // 使用乐观锁修改消息状态为成功,如果update失败表示并发修改,打印日志即可
        MQConsumerLog entity = new MQConsumerLog();
        entity.setStatus(3);
        entity.setConsumeTimes(mqConsumerLog.getConsumeTimes() + 1);
        entity.setUpdateTime(new Date());
        entity.setVersion(mqConsumerLog.getVersion() + 1);

        int row = mqConsumerLogMapper.update(entity, Wrappers.<MQConsumerLog>lambdaUpdate()
                .eq(MQConsumerLog::getId, mqConsumerLog.getId())
                .eq(MQConsumerLog::getVersion, mqConsumerLog.getVersion())
        );
        if (row < 0) {
            log.info("并发修改");
        } else {
            log.info("消费成功:topic={}, tags={}, keys={}, body={}", topic, tags, keys, body);
        }
    }

    @AfterThrowing(throwing = "ex", pointcut = "pointcut()")
    public void doAfterThrowing(JoinPoint joinPoint, Throwable ex) {
        log.info("AfterThrowing 只是用来拦截异常并不处理异常,异常最终还会继续往上抛给JVM");

        // 标记消息状态为处理失败,并累加消费次数
        MQConsumerLog entity = new MQConsumerLog();
        entity.setId(mqConsumerLog.getId());
        entity.setStatus(2);
        entity.setConsumeTimes(mqConsumerLog.getConsumeTimes() + 1);
        entity.setException(ex.getMessage());
        entity.setUpdateTime(new Date());
        entity.setVersion(mqConsumerLog.getVersion());
        mqConsumerLogMapper.updateById(entity);
    }
}

此时消费者监听器变的洁爽很多。

@Slf4j
@Component
@RocketMQMessageListener(
        consumerGroup = "testConsumerGroup",
        topic = "test-topic"
)
public class TestTopicConsumerListener implements RocketMQListener<MessageExt> {
    
    @Override
    @SneakyThrows
    public void onMessage(MessageExt messageExt) {
        log.info("业务逻辑处理中...");
        // 处理业务逻辑
        String body = new String(messageExt.getBody(), "UTF-8");
    }
}

十:可靠消息最终一致性

可靠消息最终一致性是指事务发起方执行完本地事务后发出一条消息,事务的参与方(消费者)一定能够接收到消息并处理事务成功。

RocketMQ入门教程(四):可靠消息最终一致性(本地消息表)_数据库操作_02

可靠消息最终一致性主要有以下两个特点:

  1. 生产者必须保证将消息100%发送出去。发送失败你去尝试,尝试失败再由定时器补偿再次发送,不管怎么样都要保证这个消息必须发出去。
  2. 消费者必须成功的消费掉消息。消费失败了就会尝试再次消费多次,多次消费失败了就去走人工处理,不管怎么处理必须给处理成功。

如果我们能够保证消息100%能发出去,而且消费者100%能处理成功(不管是尝试再次消费或者走人工处理),这就保证了最终的一致性,也就保证了事务的一致性。

注意:此方案强调的是最终一致性,可能会涉及到尝试,可能会涉及到人工处理,所以此方案虽然能够保证数据最终一致性,但是这个过程可能会耗时比较长(只是针对个别特殊消息,不会整体都耗时长)。所以如果某些场景不能容忍较长时间最终结果一致性的此方案就不适合。