如何用 RabbitMQ 实现延时队列

本人所在的公司在生产中使用 RabbitMQ 作为消息中间件,并在业务中用到了 RabbitMQ 的延时队列功能。RabbitMQ 原生并不直接支持消息的延时发送,但是通过安装插件就可以做到这一点。RabbitMQ 可以方便集成到 Spring 框架中,利用插件,也可以很方便地使用延时队列,实现延时处理某些业务逻辑的功能。因为配置和使用简单,在保证生产环境中不会有巨大量的消息积压的情况下,还是有一定的适用场景的。

当然,RabbitMQ 的这个插件虽然是官方插件,但是并不在团队的主要维护列表上,虽然官方说明这个插件有稳定运行的能力,但是仍有诸多缺点。例如,延时消息不能积压到百万级别以上,否则就会出现性能问题;另外,延时消息虽然会被持久化,但是数据库是单点的,在分布式系统中,单点总是不好的。

内容简介

本文首先介绍消息队列以及 RabbitMQ 中的一些基础概念,然后介绍两种可以通过 RabbitMQ 实现延时队列的方案。延时队列是指,消息在发布到队列后不立即被消费,而是经过一个固定的时间后再被发送给消费者消费。

需要特别指出的是,本文会特别注明这两种实现延时队列的方案的限制和不足,且本文中提供的方案应该作为一种思路被看待,类似“延时队列”的实现也可以通过其他中间件完成,例如 redis,或者甚至通过数据库加定时任务来实现,一切技术方案都有其适用场景、优势和劣势,需要根据实际情况做取舍。

基础知识

首先来复习一下消息队列以及 RabbitMQ 相关的基础知识,如果你对这部分内容已经很熟悉了,可以直接跳到“用 RabbitMQ 实现延迟队列的两种方案”一节继续阅读。

消息队列核心角色

消息生产和消费流程
  • publisher / producer:消息生产者
  • broker:消息队列服务器实体
    • exchange:消息交换机,指定消息按什么规则路由到哪个队列(航空公司)
    • binding:exchange 和 queue 按照路由规则绑定起来(航线)
    • routing key:binding 的属性,某些 exchange 类型下的消息路由的规则
    • queue:队列是消息的载体,每条消息都会被投入到一个或多个队列(目的地)
  • consumer:消息消费者

生产者不直接把消息发送给消费者,而是发送给 exchange,达到生产者和消费者的解耦。

RabbitMQ 中主要的 exchange 类型

1. fanout

忽略 routing key,消息会被发送到所有绑定到该 exchange 的所有队列。

fanout exchange

2. direct

消息会被发送到绑定到该 exchange 且 routing key 与其一致的队列。

direct exchange

3. topic

消息会被发送到绑定到该 exchange 且 routing key 与其匹配的队列。

  • routing key 是一系列由点隔开的单词
  • * 表示一个任意单词
  • # 表示 0 个或多个单词
topic exchange

例如上图中,*.*.rabbit 表示任意种类的兔子,*.orange.* 表示任意橙色的东西。

Dead Letter Exchange (DLX)

在某些异常情况下,例如一个消息不能被路由到任何一个队列,根据不同的策略,消息可以被返还给 publisher、丢弃或者加入 DLX。DLX 也是一个普通的 exchange,在以下几种情况下,一条消息会被重新发布到 DLX:

  • 消息被 consumer 拒收,并且 requeue 被设置为 false
  • 消息已经超时了(TTL)
  • 队列满了,无法接收新的消息

绑定到 DLX 的队列(死信队列)可用于存储死信,这样对于一些重要的消息即使消费失败也不会丢失。

DLX 还可以提供延时重试的机制,因为有的时候消息消费失败了(可能是业务系统网络延时等原因造成的),但并不想马上重试(例如将 requeue 设置为 true),而是隔一段时间后再重试。

结合消息的 TTL,利用 DLX 还可以实现消息的延时消费。

注:队列可以设置其上消息的超时时间(message-ttl),也可以设置队列自身的超时时间(expires),只有消息本身超时才会变为死信,队列超时后,不会导致其上的消息变为死信。

用 RabbitMQ 实现延迟队列的两种方案

1. 队列或者消息的 TTL 配合 DLX

实现流程如下图所示:

DLX 实现延迟队列

生产者将消息发布到“缓冲队列”(消息先发送到 exchange,这个 exchange 绑定了一个设置了 message-ttl 的队列,该队列没有任何消费者,因此消息会在一定时间后超时)。

消息超时后会被发布到“缓冲队列”绑定的 DLX,并被路由到对应的“实际消费队列”,最终被消费者消费。

队列中消息的 TTL 可以设置在队列上,有以下两种方法:

  • 设置队列的 policy(message-ttl)。如果需要修改,可以直接在服务上用命令修改,无需改动业务代码,无需删除原有队列。官方文档推荐用这种方法设置 TTL。具体命令如下:
rabbitmqctl set_policy expiry ".*" '{"expires":1800000}' --apply-to queues
  • 设置 optional queue arguments 的 x-message-ttl 值,由于属性是声明队列时设置的,因此如果需要修改,则必须删除原来的队列而建新的队列,并且修改相应的业务代码。这种方法可能适用于声明 auto-delete 为 true 的队列,由业务代码声明,可以灵活设置想要的 TTL。示例代码如下:
// 声明队列时设置 x-message-ttl 属性为 60000 毫秒
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 60000);
channel.queueDeclare("myqueue", false, false, false, args);

消息在发布时也可以单独设置 TTL。如果此时队列也设置了 TTL,则取两者较小的值起作用。示例代码如下:

byte[] messageBodyBytes = "Hello, world!".getBytes();
AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
    .expiration("60000")
    .build();
channel.basicPublish("my-exchange", "routing-key", properties, messageBodyBytes);

然而,根据官网文档的描述:

Only when expired messages reach the head of a queue will they actually be discarded (or dead-lettered).

超时的消息只有到达了队列的头部才会真正被移除或变为死信。

举例来说,如果队列设置了消息的超时时间为 10 秒,一个自身设置了超时时间为 5 秒的消息并不一定能保证其被加入队列 5 秒后被移除。因为这条消息之前可能已经有很多消息插入队列了,这些消息的超时时间都是 10 秒,那么这条消息就必须等前面的消息都被移除后才能被移除。因此,即使单独设置消息的超时时间,其行为也非常不可控,无法实现所谓任意时长的延时效果。

消息超时变为死信后,会被发布到队列配置的 DLX,DLX 也是一个再普通不过的 exchange,发布到 DLX 的消息同样会被路由到绑定到 DLX 的符合路由条件的队列,这样就实现了消息的延时消费。配置队列的 DLX 同样有两种方式:

  • 设置队列的 policy(dead-letter-exchange)。具体命令如下:
rabbitmqctl set_policy DLX ".*" '{"dead-letter-exchange":"my-dlx"}' --apply-to queues
  • 设置 optional queue arguments 的 x-dead-letter-exchange 值。示例代码如下:
// 声明一个 direct exchange
channel.exchangeDeclare("some.exchange.name", "direct");
// 声明一个队列,将前面声明的 exchange 设置为其 DLX
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-dead-letter-exchange", "some.exchange.name");
channel.queueDeclare("myqueue", false, false, false, args);

缓冲队列可以设置 x-dead-letter-routing-key 属性,如果不设置,消息进入 DLX 后,将仍用消息自身的 routing key 路由。

限制:

  • 由于延时消息实际存放在队列中,因此延时不易设置过大,否则可能会导致大量消息积压在队列中,占用 broker 资源。
  • 不支持发送任意 TTL 的消息,TTL 必须与延时队列绑定。

2. 使用 rabbitmq-delayed-message-exchange 插件

RabbitMQ v3.5.8 及以后版本支持该插件,首先来看一下官方文档上的一句话:

This plugin is considered to be experimental yet fairly stable and potential suitable for production use as long as the user is aware of its limitations.

该插件是实验性质的,然而其也相当稳定并具有在生产中使用的潜质,前提是使用者清楚它的局限性。

rabbitmq-delayed-message-exchange 插件会将消息持久化到数据库(Mnesia),再利用调度器调度消息的发布。因此延迟消息并不直接发布到任何 exchange,因此也不会直接保存到任何队列里,而是先保存在磁盘上,到了具体的需要发布的时间再发布,因此消息在发布时会对于实际的发布时间有一定的延迟。

限制:

  • 持久化的消息数据库是单点的,一旦某个节点的丢失或者禁用该节点上的 rabbitmq-delayed-message-exchange 插件会导致这个节点上的所有延迟消息都丢失掉。
  • 消息只会被尝试发布一次,如果发布消息时没有可以消费的队列存在,消息也不会被退回原发布者,因为不能保证此时原发布者还“活着”。
  • 同时存在的延迟消息总数不能超过百万级。具体参见 issue 72。其实说白了,我们不应该期待把 RabbitMQ 当做数据库来使用,每一个技术都有其适用条件,如果有大量延迟消息需要持久化,完全可以考虑用其他方法解决问题。

插件的安装和使用方法参见官方文档:RabbitMQ plugins

那么我们该如何使用这个插件实现延迟队列呢?具体步骤如下:

  • 使用 exchange 的扩展属性,将 exchange 设置为”延迟“的,代码如下:
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-delayed-type", "direct");
channel.exchangeDeclare("my-exchange", "x-delayed-message", true, false, args);
  • 发布消息时设置消息 header 的 x-delayed 属性,可以看到插件支持任意时长的消息延时。代码如下:
byte[] messageBodyBytes = "delayed payload".getBytes("UTF-8");
Map<String, Object> headers = new HashMap<String, Object>();
headers.put("x-delay", 5000);
AMQP.BasicProperties.Builder props = new AMQP.BasicProperties.Builder().headers(headers);
channel.basicPublish("my-exchange", "", props.build(), messageBodyBytes);

参考资料

  1. RabbitMQ 官方文档
  2. rabbitmq-delayed-message-exchange

实验代码

所有实验代码参见:github 源码注意,不同框架有不同的集成 RabbitMQ 的方案,请参阅相关文档,请不要在生产环境中直接使用实验代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • 什么叫消息队列 消息(Message)是指在应用间传送的数据。消息可以非常简单,比如只包含文本字符串,也可以更复杂...
    lijun_m阅读 1,335评论 0 1
  • 四种交换机类型 fanout   它会将发送到该交换机的消息路由到所有与该交互机存在binding的队列中,无视R...
    TomDwan阅读 575评论 0 0
  • http://liuxing.info/2017/06/30/Spring%20AMQP%E4%B8%AD%E6%...
    sherlock_6981阅读 15,864评论 2 11
  • 1.vhost(虚拟主机) 虚拟主机相当于一个隔离的空间,多个虚拟主机可以对不同的用户,不同的作用分割开来 2.P...
    MR_Hanjc阅读 765评论 0 0
  • 又是一副临摹Billy Showell的,很喜欢这种细致的风格,不知道是否因为学生物的关系,这类风格的画我看着特别...
    画师乔子阅读 965评论 1 6