17 | 消息队列:秒杀时如何处理每秒上万次的下单请求?
在课程一开始,我就带你了解了高并发系统设计的三个目标:性能、可用性和可扩展性,而
在提升系统性能方面,我们一直关注的是系统的查询性能。也用了很多的篇幅去讲解数据库
的分布式改造,各类缓存的原理和使用技巧。
究其原因在于,
我们遇到的大部分场景都是读
多写少,
尤其是在一个系统的初级阶段。
比如说,一个社区的系统初期一定是只有少量的种子用户在生产内容,而大部分的用户都
在“围观”别人在说什么。此时,整体的流量比较小,而写流量可能只占整体流量的百分之
一,那么即使整体的 QPS 到了 10000 次 / 秒,写请求也只是到了每秒 100 次,如果要对
写请求做性能优化,它的性价比确实不太高。
但是,随着业务的发展,你可能会遇到一些存在
高并发写请求的场景,其中秒杀抢购就是最
典型的场景。
假设你的商城策划了一期秒杀活动,活动在第五天的 00:00 开始,仅限前
200 名,那么秒杀即将开始时,后台会显示用户正在疯狂地刷新 APP 或者浏览器来保证自
己能够尽量早的看到商品。
这时,你面对的依旧是读请求过高,
那么应对的措施有哪些呢?
因为用户查询的是少量的商品数据,属于查询的热点数据,你可以采用缓存策略,将请求尽
量挡在上层的缓存中,能被静态化的数据,比如说商城里的图片和视频数据,尽量做到静态
化,这样就可以命中 CDN 节点缓存,减少 Web 服务器的查询量和带宽负担。Web 服务
器比如 Nginx 可以直接访问分布式缓存节点,这样可以避免请求到达 Tomcat 等业务服务
器。
当然,你可以加上一些限流的策略,比如,对于短时间之内来自某一个用户、某一个 IP 或
者某一台设备的重复请求做丢弃处理。
通过这几种方式,你发现自己可以将请求尽量挡在数据库之外了。
稍微缓解了读请求之后,00:00 分秒杀活动准时开始,用户瞬间向电商系统请求生成订单,
扣减库存,用户的这些写操作都是不经过缓存直达数据库的。1 秒钟之内,有 1 万个数据
库连接同时达到,系统的数据库濒临崩溃,寻找能够应对如此高并发的写请求方案迫在眉
睫。这时你想到了消息队列。
我所理解的消息队列
关于消息队列是什么,你可能有所了解了,所以有关它的概念讲解,就不是本节课的重点,
这里只聊聊我自己对消息队列的看法。在我历年的工作经历中,我一直把消息队列看作暂时
存储数据的一个容器,认为消息队列是一个平衡低速系统和高速系统处理任务时间差的工
具,
我给你举个形象的例子。
比方说,古代的臣子经常去朝见皇上陈述一些国家大事,等着皇上拍板做决策。但是大臣很
多,如果同时去找皇上,你说一句我说一句,皇上肯定会崩溃。后来变成臣子到了午门之后
要原地等着皇上将他们一个一个地召见进大殿商议国事,这样就可以缓解皇上处理事情的压
力了。你可以把午门看作一个暂时容纳臣子的容器,也就是我们所说的消息队列。
其实,你在一些组件中都会看到消息队列的影子:
在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理
这些任务;
操作系统中,中断的下半部分也会使用工作队列来实现延后执行;
我们在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个
工作线程来处理。
……
总之,队列是在系统设计时一种常见的组件。
那么我们如何用消息队列解决秒杀场景下的问题呢?接下来,我们来结合具体的例子来看看
消息队列在秒杀场景下起到的作用。
削去秒杀场景下的峰值写流量
刚才提到,在秒杀场景下,短时间之内数据库的写流量会很高,那么依照我们以前的思路应
该对数据做分库分表。如果已经做了分库分表,那么就需要扩展更多的数据库来应对更高的
写流量。但是无论是分库分表,还是扩充更多的数据库,都会比较复杂,原因是你需要将数
据库中的数据做迁移,这个时间就要按天甚至按周来计算了。
而在秒杀场景下,高并发的写请求并不是持续的,也不是经常发生的,而只有在秒杀活动开
始后的几秒或者十几秒时间内才会存在。为了应对这十几秒的瞬间写高峰,就要花费几天甚
至几周的时间来扩容数据库,再在秒杀之后花费几天的时间来做缩容,
这无疑是得不偿失
的。
所以,我们的思路是:
将秒杀请求暂存在消息队列中,然后业务服务器会响应用户“秒杀结
果正在计算中”,释放了系统资源之后再处理其它用户的请求。
我们会在后台启动若干个队列处理程序,消费消息队列中的消息,再执行校验库存、下单等
逻辑。因为只有有限个队列处理线程在执行,所以落入后端数据库上的并发请求是有限的。
而请求是可以在消息队列中被短暂地堆积,当库存被消耗完之后,消息队列中堆积的请求就
可以被丢弃了。

这就是消息队列在秒杀系统中最主要的作用:
削峰填谷,
也就是说它可以削平短暂的流量高
峰,虽说堆积会造成请求被短暂延迟处理,但是只要我们时刻监控消息队列中的堆积长度,
在堆积量超过一定量时,增加队列处理机数量,来提升消息的处理能力就好了,而且秒杀的
用户对于短暂延迟知晓秒杀的结果,也是有一定容忍度的。
这里需要注意一下,
我所说的是“短暂”延迟,如果长时间没有给用户公示秒杀结果,那么
用户可能就会怀疑你的秒杀活动有猫腻了。所以,在使用消息队列应对流量峰值时,需要对
队列处理的时间、前端写入流量的大小,数据库处理能力做好评估,然后根据不同的量级来
决定部署多少台队列处理程序。
比如你的秒杀商品有 1000 件,处理一次购买请求的时间是 500ms,那么总共就需要 500s
的时间。这时,你部署 10 个队列处理程序,那么秒杀请求的处理时间就是 50s,也就是说
用户需要等待 50s 才可以看到秒杀的结果,这是可以接受的。这时会并发 10 个请求到达数
据库,并不会对数据库造成很大的压力。
通过异步处理简化秒杀请求中的业务流程
其实,在大量的写请求“攻击”你的电商系统的时候,消息队列除了发挥主要的削峰填谷的
作用之外,还可以实现
异步处理
来简化秒杀请求中的业务流程,提升系统的性能。
你想,在刚才提到的秒杀场景下,我们在处理购买请求时,需要 500ms。这时,你分析了
一下整个的购买流程,发现
这里面会有主要的业务逻辑,也会有次要的业务逻辑:
比如说,
主要的流程是生成订单、扣减库存;次要的流程可能是我们在下单购买成功之后会给用户发
放优惠券,会增加用户的积分。
假如发放优惠券的耗时是 50ms,增加用户积分的耗时也是 50ms,那么如果我们将发放优
惠券、增加积分的操作放在另外一个队列处理机中执行,那么整个流程就缩短到了
400ms,性能提升了 20%,处理这 1000 件商品的时间就变成了 400s。如果我们还是希望
能在 50s 之内看到秒杀结果的话,只需要部署 8 个队列程序就好了。
经过将一些业务流程异步处理之后,我们的秒杀系统部署结构也会有所改变:
解耦实现秒杀系统模块之间松耦合
除了异步处理和削峰填谷以外,消息队列在秒杀系统中起到的另一个作用是解耦合。
比如数据团队对你说,在秒杀活动之后想要统计活动的数据,借此来分析活动商品的受欢迎
程度、购买者人群的特点以及用户对于秒杀互动的满意程度等等指标。而我们需要将大量的
数据发送给数据团队,那么要怎么做呢?
一个思路是:
可以使用 HTTP 或者 RPC 的方式来同步地调用,也就是数据团队这边提供一
个接口,我们实时将秒杀的数据推送给它,
但是这样调用会有两个问题:
整体系统的耦合性比较强,当数据团队的接口发生故障时,会影响到秒杀系统的可用
性。
当数据系统需要新的字段,就要变更接口的参数,那么秒杀系统也要随着一起变更。
这时,我们可以考虑使用消息队列降低业务系统和数据系统的直接耦合度。
秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后数据团队再订
阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。
秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时,当数据系统
需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。
异步处理、解耦合和削峰填谷
是消息队列在秒杀系统设计中起到的主要作用,其中,异步处
理可以简化业务流程中的步骤,提升系统性能;削峰填谷可以削去到达秒杀系统的峰值流
量,让业务逻辑的处理更加缓和;解耦合可以将秒杀系统和数据系统解耦开,这样两个系统
的任何变更都不会影响到另一个系统,
如果你的系统想要提升写入性能,实现系统的低耦合,想要抵挡高并发的写流量,那么你就
可以考虑使用消息队列来完成。
课程小结
本节课,我结合自己的实际经验,主要带你了解了,消息队列在高并发系统设计中起到的作
用,以及一些注意事项,你需要了解的重点如下:
削峰填谷是消息队列最主要的作用,但是会造成请求处理的延迟。
异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消
息存在着丢失的风险,我们需要考虑如何确保消息一定到达。
解耦合可以提升你的整体系统的鲁棒性。
当然,你要知道,在使用消息队列之后虽然可以解决现有的问题,但是系统的复杂度也会上
升。比如上面提到的业务流程中,同步流程和异步流程的边界在哪里?消息是否会丢失,是
否会重复?请求的延迟如何能够减少?消息接收的顺序是否会影响到业务流程的正常执行?
如果消息处理流程失败了之后是否需要补发?
这些问题都是我们需要考虑的。
我会利用接下
来的两节课,针对最主要的两个问题来讲讲解决思路:一个是如何处理消息的丢失和重复,
另一个是如何减少消息的延迟。
引入了消息队列的同时也会引入了新的问题,需要新的方案来解决,这就是系统设计的挑
战,也是系统设计独有的魅力,而我们也会在这些挑战中不断提升技术能力和系统设计能
力。
18 |
消息投递:如何保证消息仅仅被消费一次?
经过上一节课,我们在电商系统中增加了消息队列,用它来对峰值写流量做削峰填谷,对次
要的业务逻辑做异步处理,对不同的系统模块做解耦合。因为业务逻辑从同步代码中移除
了,所以,我们也要有相应的队列处理程序来处理消息、执行业务逻辑,
这时,你的系统架
构变成了下面的样子:
这是一个简化版的架构图,实际上,随着业务逻辑越来越复杂,会引入更多的外部系统和服
务来解决业务上的问题。比如说,我们会引入 Elasticsearch 来解决商品和店铺搜索的问
题,也会引入审核系统,来对售卖的商品、用户的评论做自动的和人工的审核,你会越来越
多地使用消息队列与外部系统解耦合,以及提升系统性能。
比如说,你的电商系统需要上一个新的红包功能:用户在购买一定数量的商品之后,由你的
系统给用户发一个现金的红包,鼓励用户消费。由于发放红包的过程不应该在购买商品的主
流程之内,所以你考虑使用消息队列来异步处理。
这时,你发现了一个问题:
如果消息在投
递的过程中发生丢失,那么用户就会因为没有得到红包而投诉。相反,如果消息在投递的过
程中出现了重复,那么你的系统就会因为发送两个红包而损失。
那么我们如何保证,产生的消息一定会被消费到,并且只被消费一次呢?这个问题虽然听起
来很浅显,很好理解,但是实际上却藏着很多玄机,本节课我就带你深入探讨。
消息为什么会丢失
如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队
列,到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实,主要存
在三个场景:
消息从生产者写入到消息队列的过程。
消息在消息队列中的存储场景。
消息被消费者消费的过程。
接下来,我就针对每一个场景,详细地剖析一下,这样你可以针对不同的场景选择合适的,
减少消息丢失的解决方案。
1.
在消息生产的过程中丢失消息
在这个环节中主要有两种情况。
首先,消息的生产者一般是我们的业务服务器,消息队列是独立部署在单独的服务器上的。
两者之间的网络虽然是内网,但是也会存在抖动的可能,而一旦发生抖动,消息就有可能因
为网络的错误而丢失。
针对这种情况,我建议你采用的方案是消息重传:
也就是当你发现发送超时后你就将消息重
新发一次,但是你也不能无限制地重传消息。一般来说,如果不是消息队列发生故障,或者
是到消息队列的网络断开了,重试 2~3 次就可以了。
不过,这种方案可能会造成消息的重复,从而导致在消费的时候会重复消费同样的消息。比
方说,消息生产时由于消息队列处理慢或者网络的抖动,导致虽然最终写入消息队列成功,
但在生产端却超时了,生产者重传这条消息就会形成重复的消息,那么针对上面的例子,直
观显示在你面前的就会是你收到了两个现金红包。
那么消息发送到了消息队列之后是否就万无一失了呢?当然不是,
在消息队列中消息仍然有
丢失的风险。
2.
在消息队列中丢失消息
拿 Kafka 举例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的
随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中,然后再找合适的时机刷
新到磁盘上。
比如,Kafka 可以配置当达到某一时间间隔,或者累积一定的消息数量的时候再刷盘,
也就
是所说的异步刷盘。
来看一个形象的比喻:假如你经营一个图书馆,读者每还一本书你都要去把图书归位,不仅
工作量大而且效率低下,但是如果你可以选择每隔 3 小时,或者图书达到一定数量的时候
再把图书归位,这样可以把同一类型的书一起归位,节省了查找图书位置的时间,这样就可
以提高效率了。
不过,如果发生机器掉电或者机器异常重启,那么 Page Cache 中还没有来得及刷盘的消
息就会丢失了。
那么怎么解决呢?
你可能会把刷盘的间隔设置很短,或者设置累积一条消息就就刷盘,但这样频繁刷盘会对性
能有比较大的影响,而且从经验来看,出现机器宕机或者掉电的几率也不高,
所以我不建议
你这样做。
如果你的电商系统对消息丢失的容忍度很低,
那么你可以考虑以集群方式部署 Kafka 服
务,通过部署多个副本备份数据,保证消息尽量不丢失。
那么它是怎么实现的呢?
Kafka 集群中有一个 Leader 负责消息的写入和消费,可以有多个 Follower 负责数据的备
份。Follower 中有一个特殊的集合叫做 ISR(in-sync replicas),当 Leader 故障时,新
选举出来的 Leader 会从 ISR 中选择,默认 Leader 的数据会异步地复制给 Follower,这
样在 Leader 发生掉电或者宕机时,Kafka 会从 Follower 中消费消息,减少消息丢失的可
能。
由于默认消息是异步地从 Leader 复制到 Follower 的,所以一旦 Leader 宕机,那些还没
有来得及复制到 Follower 的消息还是会丢失。为了解决这个问题,Kafka 为生产者提供一
个选项叫做“acks”,当这个选项被设置为“all”时,生产者发送的每一条消息除了发给
Leader 外还会发给所有的 ISR,并且必须得到 Leader 和所有 ISR 的确认后才被认为发送
成功。这样,只有 Leader 和所有的 ISR 都挂了,消息才会丢失。
从上面这张图来看,当设置“acks=all”时,需要同步执行 1,3,4 三个步骤,对于消息
生产的性能来说也是有比较大的影响的,所以你在实际应用中需要仔细地权衡考量。
我给你
的建议是:
1. 如果你需要确保消息一条都不能丢失,那么建议不要开启消息队列的同步刷盘,而是需
要使用集群的方式来解决,可以配置当所有 ISR Follower 都接收到消息才返回成功。
2. 如果对消息的丢失有一定的容忍度,那么建议不部署集群,即使以集群方式部署,也建
议配置只发送给一个 Follower 就可以返回成功了。
3. 我们的业务系统一般对于消息的丢失有一定的容忍度,比如说以上面的红包系统为例,
如果红包消息丢失了,我们只要后续给没有发送红包的用户补发红包就好了。
3.
在消费的过程中存在消息丢失的可能
我还是以 Kafka 为例来说明。一个消费者消费消息的进度是记录在消息队列集群中的,而
消费的过程分为三步:接收消息、处理消息、更新消费进度。
这里面接收消息和处理消息的过程都可能会发生异常或者失败,比如说,消息接收时网络发
生抖动,导致消息并没有被正确的接收到;处理消息时可能发生一些业务的异常导致处理流
程未执行完成,这时如果更新消费进度,那么这条失败的消息就永远不会被处理了,也可以
认为是丢失了。
所以,在这里你需要注意的是,
一定要等到消息接收和处理完成后才能更新消费进度,但是
这也会造成消息重复的问题,比方说某一条消息在处理之后,消费者恰好宕机了,那么因为
没有更新消费进度,所以当这个消费者重启之后,还会重复地消费这条消息。
如何保证消息只被消费一次
从上面的分析中,你能发现,为了避免消息丢失,我们需要付出两方面的代价:一方面是性
能的损耗;一方面可能造成消息重复消费。
性能的损耗我们还可以接受,因为一般业务系统只有在写请求时才会有发送消息队列的操
作,而一般系统的写请求的量级并不高,但是消息一旦被重复消费,就会造成业务逻辑处理
的错误。那么我们要如何避免消息的重复呢?
想要完全的避免消息重复的发生是很难做到的,因为网络的抖动、机器的宕机和处理的异常
都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使
消费到了重复的消息,从消费的最终结果来看和只消费一次是等同的就好了,也就是保证在
消息的生产和消费的过程是“幂等”的。
1.
什么是幂等
幂等是一个数学上的概念,它的含义是多次执行同一个操作和执行一次操作,最终得到的结
果是相同的,说起来可能有些抽象,我给你举个例子:
比如,男生和女生吵架,女生抓住一个点不放,传递“你不在乎我了吗?”(生产消息)的
信息。那么当多次埋怨“你不在乎我了吗?”的时候(多次生产相同消息),她不知道的
是,男生的耳朵(消息处理)会自动把 N 多次的信息屏蔽,就像只听到一次一样,这就是
幂等性。
如果我们消费一条消息的时候,要给现有的库存数量减 1,那么如果消费两条相同的消息就
会给库存数量减 2,这就不是幂等的。而如果消费一条消息后,处理逻辑是将库存的数量设
置为 0,或者是如果当前库存数量是 10 时则减 1,这样在消费多条消息时,所得到的结果
就是相同的,
这就是幂等的。
说白了,你可以这么理解“幂等”:
一件事儿无论做多少次都和做一次产生的结果是一样
的,那么这件事儿就具有幂等性。
2.
在生产、消费过程中增加消息幂等性的保证
消息在生产和消费的过程中都可能会产生重复,所以你要做的是,在生产过程和消费过程中
增加消息幂等性的保证,这样就可以认为从“最终结果上来看”,消息实际上是只被消费了
一次的。
在消息生产过程中,
在 Kafka0.11 版本和 Pulsar 中都支持“producer idempotency”的
特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但
是最终在消息队列存储时只会存储一份。
它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消
息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新
的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致,就
认为是重复的消息,服务端会自动丢弃。
而在消费端,幂等性的保证会稍微复杂一些,你可以从通用层和业务层
两个层面来考虑。
在通用层面,你可以在消息被生产的时候,使用发号器给它生成一个全局唯一的消息 ID,
消息被处理之后,把这个 ID 存储在数据库中,在处理下一条消息之前,先从数据库里面查
询这个全局 ID 是否被消费过,如果被消费过就放弃消费。
你可以看到,无论是生产端的幂等性保证方式,还是消费端通用的幂等性保证方式,它们的
共同特点都是为每一个消息生成一个唯一的 ID,然后在使用这个消息的时候,先比对这个
ID 是否已经存在,如果存在,则认为消息已经被使用过。所以这种方式是一种标准的实现
幂等的方式,
你在项目之中可以拿来直接使用,
它在逻辑上的伪代码就像下面这样:
不过这样会有一个问题:
如果消息在处理之后,还没有来得及写入数据库,消费者宕机了重
启之后发现数据库中并没有这条消息,还是会重复执行两次消费逻辑,这时你就需要引入事
务机制,保证消息处理和写入数据库必须同时成功或者同时失败,但是这样消息处理的成本
就更高了,所以,如果对于消息重复没有特别严格的要求,可以直接使用这种通用的方案,
而不考虑引入事务。
boolean
isIDExisted = selectByID(ID);
//
判断
ID
是否存在
if
(isIDExisted) {
return
;
//
存在则直接返回
}
else
{
process(message);
//
不存在,则处理消息
saveID(ID);
//
存储
ID
}
在业务层面怎么处理呢?
这里有很多种处理方式,其中有一种是增加乐观锁的方式。比如,
你的消息处理程序需要给一个人的账号加钱,那么你可以通过乐观锁的方式来解决。
具体的操作方式是这样的:
你给每个人的账号数据中增加一个版本号的字段,在生产消息时
先查询这个账户的版本号,并且将版本号连同消息一起发送给消息队列。消费端在拿到消息
和版本号后,在执行更新账户金额 SQL 的时候带上版本号,类似于执行:
1
update user set
amount = amount +
20
,
version
=
version
+
1
where
userId=
1
and vers
你看,我们在更新数据时给数据加了乐观锁,这样在消费第一条消息时,version 值为 1,
SQL 可以执行成功,并且同时把 version 值改为了 2;在执行第二条相同的消息时,由于
version 值不再是 1,所以这条 SQL 不能执行成功,也就保证了消息的幂等性。
课程小结
本节课,我主要带你了解了在消息队列中,消息可能会发生丢失的场景,和我们的应对方
法,以及在消息重复的场景下,你要如何保证,尽量不影响消息最终的处理结果。我想强调
的重点是:
消息的丢失可以通过生产端的重试、消息队列配置集群模式,以及消费端合理处理消费
进度三个方式来解决。
为了解决消息的丢失通常会造成性能上的问题以及消息的重复问题。
通过保证消息处理的幂等性可以解决消息的重复问题。
虽然我讲了很多应对消息丢失的方法,但并不是说消息丢失一定不能被接受,毕竟你可以看
到,在允许消息丢失的情况下,消息队列的性能更好,方案实现的复杂度也最低。比如像是
日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发
的丢失几条日志是可以接受的。
所以方案设计看场景,这是一切设计的原则,
你不能把所有的消息队列都配置成防止消息丢
失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外
的负担。
19 |
消息队列:如何降低消息队列系统中消息的延迟?
学完前面两节课之后,相信你对在垂直电商项目中,如何使用消息队列应对秒杀时的峰值流
量已经有所了解。当然了,你也应该知道要如何做,才能保证消息不会丢失,尽量避免消息
重复带来的影响。
那么我想让你思考一下:
除了这些内容,你在使用消息队列时还需要关注
哪些点呢?
先来看一个场景:
在你的垂直电商项目中,你会在用户下单支付之后,向消息队列里面发送
一条消息,队列处理程序消费了消息后,会增加用户的积分,或者给用户发送优惠券。那么
用户在下单之后,等待几分钟或者十几分钟拿到积分和优惠券是可以接受的,但是一旦消息
队列出现大量堆积,用户消费完成后几小时还拿到优惠券,那就会有用户投诉了。
这时,你要关注的就是消息队列中,消息的延迟了,这其实是消费性能的问题,那么你要如
何提升消费性能,保证更短的消息延迟呢?
在我看来,
你首先需要掌握如何来监控消息的延
迟,因为有了数据之后,你才可以知道目前的延迟数据是否满足要求,也可以评估优化之后
的效果。然后,你要掌握使用消息队列的正确姿势,以及关注消息队列本身是如何保证消息
尽快被存储和投递的。
接下来,我们先来看看第一点:如何监控消息延迟。
如何监控消息延迟
在我看来,监控消息的延迟有两种方式:
使用消息队列提供的工具,通过监控消息的堆积来完成;
通过生成监控消息的方式来监控消息的延迟情况。
接下来,我带你实际了解一下。
假设在开篇的场景之下,电商系统中的消息队列已经堆积了大量的消息,那么你要想监控消
息的堆积情况,首先需要从原理上了解,在消息队列中消费者的消费进度是多少,因为这样
才方便计算当前的消费延迟是多少。比方说,生产者向队列中一共生产了 1000 条消息,某
一个消费者消费进度是 900 条,那么这个消费者的消费延迟就是 100 条消息。
在 Kafka 中,消费者的消费进度在不同的版本上是不同的。
在 Kafka0.9 之前的版本中,消费进度是存储在 ZooKeeper 中的,消费者在消费消息的时
候,先要从 ZooKeeper 中获取最新的消费进度,再从这个进度的基础上消费后面的消息。
在 Kafka0.9 版本之后,消费进度被迁入到 Kakfa 的一个专门的 topic
叫“__consumer_offsets”里面。所以,如果你了解 kafka 的原理,你可以依据不同的版
本,从不同的位置,获取到这个消费进度的信息。
当然,作为一个成熟的组件,Kafka 也提供了一些工具来获取这个消费进度的信息,帮助你
实现自己的监控,这个工具主要有两个:
首先,Kafka 提供了工具叫做“kafka-consumer-groups.sh”
(它在 Kafka 安装包的
bin 目录下)。
为了帮助你理解,我简单地搭建了一个 Kafka 节点,并且写入和消费了一些信息,然后我
来使用命令看看消息累积情况,具体的命令如下:
1
./bin/kafka-consumer-groups.sh
--bootstrap-server localhost:9092 --describe --g
结果如下:

图中的前两列是队列的基本信息,包括话题名和分区名;
第三列是当前消费者的消费进度;
第四列是当前生产消息的总数;
第五列就是消费消息的堆积数(也就是第四列与第三列的差值)。
通过这个命令你可以很方便地了解消费者的消费情况。
其次,第二个工具是 JMX。
Kafka 通过 JMX 暴露了消息堆积的数据,我在本地启动了一个 console consumer,然后
使用 jconsole 连接这个 consumer,你就可以看到这个 consumer 的堆积数据了(就是下
图中红框里的数据)。这些数据你可以写代码来获取,这样也可以方便地输出到监控系统
中,
我比较推荐这种方式。
除了使用消息队列提供的工具以外,你还可以通过生成监控消息的方式,来监控消息的延
迟。
具体怎么做呢?
你先定义一种特殊的消息,然后启动一个监控程序,将这个消息定时地循环写入到消息队列
中,消息的内容可以是生成消息的时间戳,并且也会作为队列的消费者消费数据。业务处理
程序消费到这个消息时直接丢弃掉,而监控程序在消费到这个消息时,就可以和这个消息的
生成时间做比较,如果时间差达到某一个阈值就可以向我们报警。
这两种方式都可以监控消息的消费延迟情况,
而从我的经验出来,我比较推荐两种方式结合
来使用。
比如在我的实际项目中,我会优先在监控程序中获取 JMX 中的队列堆积数据,做
到 dashboard 报表中,同时也会启动探测进程,确认消息的延迟情况是怎样的。
在我看来,消息的堆积是对于消息队列的基础监控,这是你无论如何都要做的。但是,了解
了消息的堆积情况,并不能很直观地了解消息消费的延迟,你也只能利用经验来确定堆积的
消息量到了多少才会影响到用户的体验;而第二种方式对于消费延迟的监控则更加直观,而
且从时间的维度来做监控也比较容易确定报警阈值。
了解了消息延迟的监控方式之后,我们再来看看如何提升消息的写入和消费性能,这样才会
让异步的消息得到尽快的处理。
减少消息延迟的正确姿势
想要减少消息的处理延迟,我们需要在
消费端和消息队列
两个层面来完成。
在消费端,我们的目标是提升消费者的消息处理能力,你能做的是:
优化消费代码提升性能;
增加消费者的数量(这个方式比较简单)。
不过,第二种方式会受限于消息队列的实现。比如说,如果消息队列使用的是 Kafka 就无
法通过增加消费者数量的方式,来提升消息处理能力。
因为在 Kafka 中,一个 Topic(话题)可以配置多个 Partition(分区),数据会被平均或
者按照生产者指定的方式,写入到多个分区中,那么在消费的时候,Kafka 约定一个分区只
能被一个消费者消费,为什么要这么设计呢?在我看来,如果有多个 consumer(消费
者)可以消费一个分区的数据,那么在操作这个消费进度的时候就需要加锁,可能会对性能
有一定的影响。
所以说,话题的分区数量决定了消费的并行度,增加多余的消费者也是没有用处的,那么你
可以通过增加分区来提高消费者的处理能力。

那么,如何在不增加分区的前提下提升消费能力呢?
既然不能增加 consumer,那么你可以在一个 consumer 中提升处理消息的并行度,所以
可以考虑使用多线程的方式来增加处理能力:你可以预先创建一个或者多个线程池,在接收
到消息之后,把消息丢到线程池中来异步地处理,这样,原本串行的消费消息的流程就变成
了并行的消费,可以提高消息消费的吞吐量,在并行处理的前提下,我们就可以在一次和消
息队列的交互中多拉取几条数据,然后分配给多个线程来处理。

另外,你在消费队列中数据的时候还需要注意消费线程空转的问题。
我是最初在测试自己写的一个消息中间件的时候发现的。
当时,我发现运行消费客户端的进
程会偶发地出现 CPU 跑满的情况,于是打印了 JVM 线程堆栈,找到了那个跑满 CPU 的线
程。这个时候才发现,原来是消息队列中,有一段时间没有新的消息,于是消费客户端拉取
不到新的消息就会不间断地轮询拉取消息,这个线程就把 CPU 跑满了。
所以,你在写消费客户端的时候要考虑这种场景,拉取不到消息可以等待一段时间再来拉
取,等待的时间不宜过长,否则会增加消息的延迟。我一般建议固定的 10ms~100ms,也
可以按照一定步长递增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最长可以到
100ms,直到拉取到消息再回到 10ms。
说完了消费端的做法之后,
再来说说消息队列本身在读取性能优化方面做了哪些事情。
我曾经也做过一个消息中间件,在最初设计中间件的时候,我主要从两方面考虑读取性能问
题:
消息的存储;
零拷贝技术。
针对第一点,
我最初在设计的时候为了实现简单,使用了普通的数据库来存储消息,但是受
限于数据库的性能瓶颈,读取 QPS 只能到 2000,后面我重构了存储模块,使用本地磁盘
作为存储介质。Page Cache 的存在就可以提升消息的读取速度,即使要读取磁盘中的数
据,由于消息的读取是顺序的,并且不需要跨网络读取数据,所以读取消息的 QPS 提升了
一个数量级。
另外一个优化点是零拷贝技术,
说是零拷贝,其实,我们不可能消灭数据的拷贝,只是尽量
减少拷贝的次数。在读取消息队列的数据的时候,其实就是把磁盘中的数据通过网络发送给
消费客户端,在实现上会有四次数据拷贝的步骤:
1. 数据从磁盘拷贝到内核缓冲区;
2. 系统调用将内核缓存区的数据拷贝到用户缓冲区;
3. 用户缓冲区的数据被写入到 Socket 缓冲区中;
4. 操作系统再将 Socket 缓冲区的数据拷贝到网卡的缓冲区中。
操作系统提供了 Sendfile 函数,可以减少数据被拷贝的次数。使用了 Sendfile 之后,在内
核缓冲区的数据不会被拷贝到用户缓冲区,而是直接被拷贝到 Socket 缓冲区,节省了一次
拷贝的过程,提升了消息发送的性能。高级语言中对于 Sendfile 函数有封装,比如说在
Java 里面的 java.nio.channels.FileChannel 类就提供了 transferTo 方法提供了 Sendfile
的功能。
课程小结
本节课我带你了解了,如何提升消息队列的性能来降低消息消费的延迟,这里我想让你明确
的重点是:
我们可以使用消息队列提供的工具,或者通过发送监控消息的方式,来监控消息的延迟
情况;
横向扩展消费者是提升消费处理能力的重要方式;
选择高性能的数据存储方式,配合零拷贝技术,可以提升消息的消费性能。
其实,队列是一种常用的组件,只要涉及到队列,任务的堆积就是一个不可忽视的问题,
我
遇到过的很多故障都是源于此。
比如说,前一段时间处理的一个故障,前期只是因为数据库性能衰减有少量的慢请求,结果
这些慢请求占满了 Tomcat 线程池,导致整体服务的不可用。如果我们能对 Tomcat 线程
池的任务堆积情况有实时地监控,或者说对线程池有一些保护策略,比方说线程全部使用之
后丢弃请求,也许就会避免故障的发生。在此,我希望你在实际的工作中能够引以为戒,只
要有队列就要监控它的堆积情况,把问题消灭在萌芽之中。