RocketMQ在项目中的简单实践

原文:RocketMQ在项目中的简单实践

本文由一个特定的问题展开,讲述在项目中引入消息队列中间件的起因、过程与思路总结。

待解决的问题

在mytalk项目中,需要设计一个添加议题接口(由标题和内容等信息组成,相当于发一个微博话题),议题创建除了需要在数据库中添加记录外,还需要执行以下步骤:

  1. 通知议题相关的小组成员
  2. 通知搜索引擎该议题上线
  3. 给议题提出者增加积分
  4. 未来可能增加的新的需求

更具体一些,我们希望第1,2两点的执行结果不影响议题本身的创建结果,最好也不要拖累创建议题这个基本业务的执行。也就是说,无论是通知失败还是通知过程异常,都是通知模块的事情,与议题创建接口无关,接口在逻辑上异步调用通知服务,单向通信,不必接收回调。我们希望第3点“给议题提出者增加积分”的操作的执行结果与议题创建的结果绑定,要么同时失败,要么同时成功,整体看作一个事务,不可以出现添加议题成功但是积分没有增加的情况。接口在逻辑上同步或异步完成添加积分的业务,但是在处理响应时,需要检查是否达成一致的状态,如有必要,需进行事务回滚。我们还希望未来这个接口能支持更多的功能,可以在添加议题成功或者失败时处理一些自定义业务。

观察者模式

如果是解依赖,这让我联想到观察者模式。观察者模式的实现思路是,在发布者类中声明一个委托列表,委托是可回调的接口或方法对象,订阅者在初始化时向发布者申请注册一个回调事件,发布者将该订阅者注册到自己的委托列表中,在目标事件触发时,遍历委托列表,逐个调用委托方法,由此订阅者收到事件的回传通知,进入到自己的方法执行过程。这的确是实现了解依赖,而且订阅者在事件发生的第一时间就能做成反应,但问题是,这是基于单线程的设计模式,不适用多线程,更不用说是不同机器上的服务了。

如果利用RPC,可以访问远程服务,观察者模式还能不能应用于服务间的解耦呢?可以用,但不能解耦。因为线程内的方法调用远远比远程服务的方法调用更可靠,只要是访问远程服务,就不可避免的需要看每个服务的脸色行事,网络延迟、计算超时、服务宕机都可能影响到发布者,因为发布者直接调用了这些远程方法。线程中的方法调用,多是同一组程序员完成的,很容易约定事件接收者的执行时间、同步异步协作方式等,因此使用观察者这种低依赖的设计模式即可,但是不同的服务可能是不同的团队甚至是不同的公司完成,暴露出的接口风格各不相同,完全不能信赖。

这时候就需要一个中间人,接受发布者传递的消息,并推送给订阅者,不管发布者和订阅者如何实现,如何故障,只保证自己的高吞吐,高可用。这个中间人一定是值得信任的,否则就退回到观察者模式的问题中,更有甚者,可以直接认为当消息发送到了中间人手里,就代表已经完成任务了,这是“基于信任编程”,这种编程方式是零耦合的。

消息队列RocketMQ

Apache RocketMQ是一种低延迟、高并发、高可用、高可靠的分布式消息中间件。消息队列RocketMQ既可为分布式应用系统提供异步解耦和削峰填谷的能力,同时也具备互联网应用所需的海量消息堆积、高吞吐、可靠重试等特性。

消息队列核心概念:

  • Topic:消息主题,一级消息类型,生产者向其发送消息。
  • Broker:中间人/经纪人,消息队列集群的节点,负责保存和收发消息。
  • 生产者:也称为消息发布者,负责生产并发送消息至Topic。
  • 消费者:也称为消息订阅者,负责从Topic接收并消费消息。
  • Tag:消息标签,二级消息类型,表示Topic主题下的具体消息分类。
  • 消息:生产者向Topic发送并最终传送给消费者的数据和(可选)属性的组合。
  • 消息属性:生产者可以为消息定义的属性,包含Message Key和Tag。
  • Group:一类生产者或消费者,这类生产者或消费者通常生产或消费同一类消息,且消息发布或订阅的逻辑一致。

生产者发送消息到消息队列,最终发送到消费者的示意图如下:

image-20210112223820896

消息类型可以划分为

  • 普通消息。也称并发消息,没有顺序,生产消费都是并行的,拥有极高的吞吐性能
  • 事务消息。提供了保证消息一定送达到broker的机制。
  • 分区顺序消息。Topic分为多个分区,在一个分区内遵循先入先出原则。
  • 全局顺序消息。把Topic分区数设置为1,所有消息都遵循先入先出原则。
  • 定时消息。将消息发送到MQ服务端,在消息发送时间(当前时间)之后的指定时间点进行投递
  • 延迟消息。将消息发送到MQ服务端,在消息发送时间(当前时间)之后的指定延迟时间点进行投递

消费方式可以划分为:

  • 集群消费。任意一条消息只需要被集群内的任意一个消费者处理即可。
  • 广播消费。将每条消息推送给集群内所有注册过的消费者,保证消息至少被每个消费者消费一次。

消费者获取消息模式可以划分为:

  • Push。开启单独的线程轮询broker获取消息,回调消费者的接收方法,仿佛是broker在推消息给消费者。
  • Pull。消费者主动从消息队列拉取消息。
为什么要分Topic,分区

topic是逻辑概念。topic表示消息的主题,是消息系统中的抽象概念,生产者可以发布多个topic。

topic可能被大量消费者订阅,为了充分利用集群特性,topic将消息划分为多个分区(从实现角度来说是队列),再分布到多个集群节点上(broker),提供系统吞吐量。在广播消费模式中,一个队列将被所有消费者消费;在集群消费模式中,一个队列只被一个消费者线程消费,这保证了消费不会被重复消费。对于消费者端来说,可以消费多个分区的消息。

分区更倾向于是实现层面的概念,生产者和消费者并不需要关心分区,只需要关心Topic。但也注意到,分区只能被一个消费者实例消费,如果分区的设置数量小于消费者实例数,可能造成部分消费者线程空闲。

消费者组

消费者组是特定业务的服务集群,可以订阅一个topic,也可以订阅多个topic。官方文档上说明:消息队列 RocketMQ 的订阅关系主要由 Topic + Tag 共同组成,同一个消费者 Group ID 下所有 Consumer 实例所订阅的 Topic、Tag 必须完全一致。一开始我也有些疑惑,但后来发现这与订阅多个topic并不矛盾,只是一个Consumer 实例订阅Topic_A和Topic_B,那么同一组的其他Consumer也必须订阅Topic_A和Topic_B。这个规则只要不刻意违反其实很容易遵守,因为集群的原理之一就是一份代码多处布置,代码相同自然订阅关系相同。

订阅多个Topic的代码演示:

@Configuration
public class ConsumerClient {

    @Autowired
    private MqConfig mqConfig;

    @Autowired
    private IssueMessageListener messageListener;

    @Autowired
    private OtherMessageListener otherMessageListener;

    @Bean(initMethod = "start", destroyMethod = "shutdown")
    public ConsumerBean buildConsumer() {
        ConsumerBean consumerBean = new ConsumerBean();
        Properties properties = mqConfig.getMqPropertie();
        properties.setProperty(PropertyKeyConst.GROUP_ID, mqConfig.GROUP_ISSUE_TEAM_NOTICE_ID);
        properties.setProperty(PropertyKeyConst.ConsumeThreadNums, "10");
        consumerBean.setProperties(properties);

        Map<Subscription, MessageListener> subscriptionTable = new HashMap<>();
        Subscription subscription = new Subscription();
        subscription.setTopic(mqConfig.TOPIC_ISSUE);
        subscription.setExpression(mqConfig.TAG_ISSUE);
        subscriptionTable.put(subscription, messageListener);

        Subscription subscription2 = new Subscription();
        subscription2.setTopic(mqConfig.TOPIC_OTHER);
        subscription2.setExpression(mqConfig.TAG_OTHER);
        subscriptionTable.put(subscription2, otherMessageListener);

        consumerBean.setSubscriptionTable(subscriptionTable);
        return consumerBean;
    }
}
namesrv做了什么

rocketmq没有使用zookeeper等中间件,而是自己实现了一个namesrv用于服务发现与负载均衡,那么其实现原理是什么。

先想一想,如果我来做服务发现,我会怎么做。有多个broker实例在运行,并且每个broker都至少配备了一个slave,生产者要均匀地写消息到多个broker中,以实现负载均衡。在生产者或消费者启动时,初始化配置所有broker节点路由信息(包括主从),确保客户端可以访问每一个broker节点,与之建立连接。当写操作产生时,将数据写到第i++ % brokerCount个broker节点中,这样均匀接收数据,实现了写的负载均衡。如果当前的broker操作写失败,则生产者将其纳入故障队列,要等一段时间后重新归入写目标队列,这样一来,能恢复的broker则自动恢复,不能恢复的将定期重试。当读操作产生时,也是均匀的从各个broker中读取数据,如果broker还配置了主从实例,且允许读slave,则再从master和slave中均匀选择一个进行读取。broker读故障处理方法同上。

但是这样一来,客户端就需要很繁琐的配置,并且当slave永久性停用或者有新的slave加入时,客户端很难做出反应,因此就需要使用namesrv来做服务发现和调度。如大多数服务发现中间件的设计思路一样,broker启动时向namesrv注册自己,而客户端在初始配置时,只需要配置namesrv的地址即可,运行期间的broker路由列表都是借由namesrv动态获取的,每隔一段时间同步全部的broker信息,这样能即时发现下线与上线的broker。

一个有意思的问题是,namesrv实例要有几个呢?如果只有一个,那么namesrv宕机的话生产者和消费者就无法获取最新的broker列表了,如果有多个,那么又回到最初的问题,客户端怎么知道要连接哪个namesrv服务,又由谁来做服务发现?rocketmq的实现方案是,namesrv有多个,但是每一个namesrv实例都是互不知晓、相互冗余的,broker是向所有可用的namesrv都注册一次,生产者和消费者遍历固定的namesrv列表,只要发现一个namesrv可用,即退出循环返回这个namesrv。显然,namesrv只是单纯的部署多份冗余实例。namesrv职责清晰,设计简单,负载小,几乎不可能出现故障,其服务地址可以作为稳定的参数硬编码进程序代码中。

使用云产品

因为我们就一台服务器,在业务服务器上部署rocketmq没有实际意义,因此选择云产品,体验一下“基础设施上云”。

注意选择与业务服务器相同的地域,以便相互访问。

创建两个Topic,一个使用普通消息类型,名称为“Topic_Team”,如下图。一个使用事务消息,名称为“Topic_User”

image-20210113204113259

设计生产者

先定义Topic与Tag相关的枚举配置

//Topic枚举配置
public enum TopicEnum {
    TOPIC_TEAM("Topic_team"),

    ;

    private String topic;

    TopicEnum(String topic){this.topic = topic;}

    public String getTopic() {
        return topic;
    }
}
//Tag枚举配置
public enum TagEnum {
    TAG_ISSUE("issue")

    ;

    private String tag;

    TagEnum(String tag){this.tag = tag;}

    public String getTag() {
        return tag;
    }
}

然后定义生产者bean,根据密钥,namesrv地址创建生产者实例:

@Configuration
public class ProducerConfig {
    private final static String ACCESS_KEY = "access_key";

    private final static String SECRET_KEY = "secret_key";

    private final static String NAMESRV_ADDR = "namesrv_addr";

    @Bean(initMethod = "start", destroyMethod = "shutdown")
    public Producer buildProducer() {
        return ONSFactory.createProducer(getMqPropertie());
    }

    private Properties getMqPropertie() {
        Properties properties = new Properties();
        properties.setProperty(PropertyKeyConst.AccessKey, ACCESS_KEY);
        properties.setProperty(PropertyKeyConst.SecretKey, SECRET_KEY);
        properties.setProperty(PropertyKeyConst.NAMESRV_ADDR, NAMESRV_ADDR);
        return properties;
    }
}

然后定义生产者客户端组件,由Producer组件封装而来:

@Component
public class ProducerClient {
    @Autowired
    private Producer producer;

	//单向发送普通消息
    public void sendCommonMessage(TopicEnum topic, TagEnum tag, String content){
        Message message = new Message();
        message.setTopic(topic.getTopic());
        message.setTag(tag.getTag());
        message.setBody(content.getBytes(StandardCharsets.UTF_8));
        producer.sendOneway(message);
    }
}

最后设计议题创建接口,该接口当前只发送普通消息,即意图产生通知行为。

@RestController
public class IssueController {
    private final Logger LOG = LoggerFactory.getLogger(this.getClass());

    private IssueRepository issueRepository;

    private ProducerClient producer;

    //使用构造函数的IOC方式更推荐
    public IssueController(@Autowired IssueRepository issueRepository, @Autowired ProducerClient producer) {
        this.issueRepository = issueRepository;
        this.producer = producer;
    }

    @PostMapping("/issue")
    public String makeIssue(@RequestBody @Validated Issue issue) {
        //本地业务
        boolean isSuccess = issueRepository.addIssue(issue);

        if (isSuccess){
            //发送Topic普通消息
            producer.sendCommonMessage(TopicEnum.TOPIC_TEAM, TagEnum.TAG_ISSUE, JSONObject.fromObject(issue).toString());

            //TODO:发送Topic事务消息

            return ClientHttpResponseBody.buildSuccess().toString();
        }

        return ClientHttpResponseBody.buildError(RtnCodeEnum.SERVER_ERROR, "添加议题失败", null).toString();
    }
}
设计消费者

消费者组件放在autostart包下,说明这是自启动的组件,直观的表现出该程序的职责:启动消费者监听消息。

@Configuration
public class ConsumerClient {
    @Autowired
    private MqConfig mqConfig;

    @Autowired
    private IssueMessageListener messageListener;

    @Bean(initMethod = "start", destroyMethod = "shutdown")
    public ConsumerBean buildConsumer() {
        ConsumerBean consumerBean = new ConsumerBean();
        Properties properties = mqConfig.getMqPropertie();
        properties.setProperty(PropertyKeyConst.GROUP_ID, mqConfig.GROUP_ISSUE_TEAM_NOTICE_ID);
        properties.setProperty(PropertyKeyConst.ConsumeThreadNums, "10");
        consumerBean.setProperties(properties);

        //订阅议题消息
        Map<Subscription, MessageListener> subscriptionTable = new HashMap<>();
        Subscription subscription = new Subscription();
        subscription.setTopic(mqConfig.TOPIC_ISSUE);
        subscription.setExpression(mqConfig.TAG_ISSUE);
        subscriptionTable.put(subscription, messageListener);

        consumerBean.setSubscriptionTable(subscriptionTable);
        return consumerBean;
    }
}

IssueMessageListener类完成消息的处理,注意,通过new String(message.getBody())获取消息内容,直接对byte[]调用toString只会获得数组对象类型。

消费者的消息处理流程,一般是先处理再提交(Commit),如果先提交再处理,则可能因为处理失败而错过本次消息。ReconsumeLater表示稍后重新接收消息。

@Component
public class IssueMessageListener implements MessageListener {
    private NoticeService noticeService;

    public IssueMessageListener(@Autowired NoticeService noticeService) {
        this.noticeService = noticeService;
    }

    @Override
    public Action consume(Message message, ConsumeContext context) {
        try {
            JSONObject object = JSONObject.parseObject(new String(message.getBody()));
            noticeService.noticeTeam(object.getString("teamId"), object.getString("issue"));
            return Action.CommitMessage;
        } catch (Exception e) {
            return Action.ReconsumeLater;
        }
    }
}
发布与测试

消费者与生产者是在不同模块或不同项目之中创建的,两者从属不同进程:

image-20210114005159551

测试:

从接口请求成功:

image-20210114004803112

到消费者收到消息:

image-20210114005342452

这就完成了一条消息的周转,通知搜索引擎上线也是相同的逻辑,只是用户组不同,就不再重复了,其他类型的消息,也只是在这个流程上稍作调整。

事务消息

现如今,连转账这种业务做成只满足最终一致性就可以了,我们经常见到,支付成功但第三方订单还未收到的情况,一般等几秒钟就会完成同步,异常情况下需要几分钟,我们越来越可以接受这种情况,或者被塑造为可以接受这种情况,因为我们信任系统最终可以达成一致性。事务消息就是基于这种信任出现的一种消息发布方式,它保证了“本地事务”与“消息发布”这两者的同步性,也就是所谓的“要么同时成功,要么同时失败”,是一种非常可靠的消息发布方式。

为什么说事务消息是基于“信任”呢?它主要包含三个方面:

一是对生产者的信任。生产者需要按照约定先提交Half消息,然后执行本地事务,最后提交消息,如果执行失败,还需要放弃消息,最重要的是,还需要提供回查接口,每一步都不能错。rocketmq的回查有次数限制,如果回查接口异常,并且连续异常,那么就可能造成消息丢失。也就是说,如果生产者的代码逻辑或服务器有问题,事务消息同样会丢失消息!

二是对消息队列的信任。同最开始的讨论一样,broker的设计不能有问题,broker的服务器不能有问题,如果没有配置主从冗余实例,那么broker节点宕机,这部分的数据就耽搁了,如果broker节点爆炸,这部分的数据就没了。因此需要选择可靠的消息中间件,搭建可靠的集群架构。

三是对消费者的信任。消费者先接收消息,在执行本地任务,最后提交消息,这样的一般逻辑可以让消费者做到面对任何消息都能确保消息被确实消费,不会出现收到但没处理的情况(前提是不要异步执行本地任务,否则不能在提交前确认结果)。看似消费者很简单,其实它被RocketMQ的设计方针甩了个大麻烦,不论是普通消息还是事务消息,RocketMQ从未保证消息不会被重复消费,比如消费者消费成功但是提交失败,那么它下次还会再收到该消息。消费者需要独自实现幂等性,而且还是分布式幂等性。

Rocketmq消息队列只是提供一个数据传输的平台,事务消息只是规范了传输协议,而具体实现需要生产者和消费者的共同努力。(补充说明一下,生产者指生产者服务器与开发人员,消费者指消费者服务器与开发人员,消息中间件指中间件服务器与运维人员)

事务消息需要三方的信任,言外之意是其他消息不需要这种程度的信任,这部分的消息从业务上看,是可以允许丢失或能承担丢失的风险的。

基于“信任”的事务消息,与其他分布式事务处理机制完全不同,它不需要加锁或回滚(不是回滚消息,实际上,这也不算回滚,只是放弃Half消息而已)就能实现事务性。假如一个事务要完成A+B,常规做法是B失败了回滚A,如果回滚出现异常了就比较麻烦,还有分布式锁,会极大地限制并发数。事务消息的做法是完成A就宣布成功了,不把B当作自己需要做的事务之一,它相信B会执行成功,或者就算失败也会不断爬起来重试,最终会达成目的,不需要回头。最优雅的解决方案就是不解决。

事务消息Bean配置:

@Configuration
public class TransactionProducerConfig {

    @Autowired
    private MqConfig mqConfig;

    @Autowired
    private IssueTransactionChecker issueTransactionChecker;

    @Bean(initMethod = "start", destroyMethod = "shutdown")
    public TransactionProducerBean buildTransactionProducer() {
        TransactionProducerBean producer = new TransactionProducerBean();
        producer.setProperties(mqConfig.getMqPropertie());
        producer.setLocalTransactionChecker(issueTransactionChecker);
        return producer;
    }
}

回查接口实现:(先提交Half消息就是为了帮助broker知道哪些需要回查)

@Component
public class IssueTransactionChecker implements LocalTransactionChecker {
    private IssueRepository issueRepository;

    public IssueTransactionChecker(@Autowired IssueRepository issueRepository) {
        this.issueRepository = issueRepository;
    }

    @Override
    public TransactionStatus check(Message msg) {
        //检查议题是否入库
        try {
            JSONObject issue = JSONObject.fromObject(new String(msg.getBody()));
            return issueRepository.checkIssueExist(issue.getString("issue")) ? TransactionStatus.CommitTransaction : TransactionStatus.RollbackTransaction;
        }finally {
            return TransactionStatus.RollbackTransaction;
        }
    }
}

完善后的创建议题接口:同时发布了两个Topic消息,一个是普通消息,一个是事务消息。

send方法用来发送一条事务型消息. 一条事务型消息发送分为三个步骤:

  • 服务实现类首先发送一条半消息到消息服务器;
  • 通过executer执行本地事务;在当前线程内完成。
  • 根据上一步骤执行结果, 决定发送提交或者回滚第一步发送的半消息;
@RestController
@Validated
public class IssueController {
    private final IssueRepository issueRepository;

    private final ProducerClient producer;

    public IssueController(@Autowired IssueRepository issueRepository, @Autowired ProducerClient producer) {
        this.issueRepository = issueRepository;
        this.producer = producer;
    }

    @PostMapping("/issue")
    public String makeIssue(@RequestBody @Validated Issue issue) {
        //发送Topic事务消息
        boolean isSuccess = producer.sendTransactionIssueMessage(JSONObject.fromObject(issue).toString(), (msg, arg) -> {
            if (issueRepository.addIssue(issue)){
                return TransactionStatus.CommitTransaction;
            }

            return TransactionStatus.RollbackTransaction;
        });

        if (isSuccess){
            //发送Topic普通消息
            producer.sendCommonMessage(TopicEnum.TOPIC_TEAM, TagEnum.TAG_ISSUE, JSONObject.fromObject(issue).toString());
            return ClientHttpResponseBody.buildSuccess().toString();
        }

        return ClientHttpResponseBody.buildError(RtnCodeEnum.SERVER_ERROR, "添加议题失败", null).toString();
    }
}

消费者的消息监听器:(发布于不同的进程)

@Component
public class IssueMessageListener implements MessageListener {
    private final PointService pointService;

    public IssueMessageListener(@Autowired PointService pointService) {
        this.pointService = pointService;
    }

    @Override
    public Action consume(Message message, ConsumeContext context) {
        try {
            JSONObject object = JSONObject.parseObject(new String(message.getBody()));
            pointService.addPointForIssue(object.getString("posterId"));
            return Action.CommitMessage;
        } catch (Exception e) {
            return Action.ReconsumeLater;
        }
    }
}

测试:

image-20210115005359217

接收:

image-20210115005548427

全部完成目标O(∩_∩)O。

ion consume(Message message, ConsumeContext context) {
try {
JSONObject object = JSONObject.parseObject(new String(message.getBody()));
pointService.addPointForIssue(object.getString(“posterId”));
return Action.CommitMessage;
} catch (Exception e) {
return Action.ReconsumeLater;
}
}
}


测试:

[外链图片转存中...(img-VXmwUKw6-1615102534033)]

接收:

[外链图片转存中...(img-m9ogBvrY-1615102534037)]

全部完成目标O(∩_∩)O。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值