高并发系统设计:如何保证消息仅仅被消费一次

消息为什么会丢失

如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列,到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实,主要存在三个场景:

  • 消息从生产者写入到消费者的过程
  • 消息在消息队列中的存储场景
  • 消息被消费者消费的过程

在这里插入图片描述

在消息生产的过程中丢失消息

在这个环节中主要有两种情况:

  • 首先,消息的生产者一般是我们的业务服务器,消息队列是独立部署在单独的服务器上的。两者之间的网络虽然是内网,但是也会存在抖动的可能,而一旦发生抖动,消息就有可能因为网络的错误而丢失
    • 针对这种情况,建议采用的方案是消息重传:也就是说当你发现发送超时后你就讲消息重新发一次,但是不能无限制的重传消息。一般来说,如果不是消息队列发生故障,或者是到消息队列的网络断开了,重试2~3次就可以了
    • 不过,这种方案可能造成消息的重复,从而导致在消费的时候会重复消费同样的消息。比如,消息生产时由于消息队列处理慢或者网络的抖动,导致虽然最终写入消息队列成功,但是在生产端却超时了,生产者重传这条消息就会形成重复的消息

那么消息发送到了消息队列之后是否就万无一失了呢?当然不是,在消息队列中消息仍然有丢失的风险。

在消息队列中丢失消息

那kafka举例,消息在kafka中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机IO,我们一般会将消息先写入到操作系统的page cache中,然后再找合适的时机刷新到磁盘上。

比如说,kafka可以配置当到达某个时间间隔,或者累积一定的消息数量的时候再刷盘,也就是异步刷盘

不过,如果发生机器掉电或者机器异常重启,那么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 三个步骤,对于消息生产的性能来说也是有比较大的影响的,所以在实际应用中需要仔细地权衡考量。建议:

  • 如果需要确保消息一条都不能丢失,那么建议不要开启消息队列的同步刷盘,而是需要使用集群的方式来解决,可以配置当所有ISR Follower都接收到消息才返回成功
  • 如果对消息的丢失有一定更得容忍度,那么建议不部署集群,即使以集群方式部署,也建议配置只发送给一个 Follower 就可以返回成功了。
  • 我们的业务系统一般对于消息的丢失有一定的容忍度,比如说以红包系统为例,如果红包消息丢失了,我们只要后续给没有发送红包的用户补发红包就好了。

在消费的过程中存在消息丢失的可能性

以Kafka为例,一个消费者消费消息的进度是记录在消息队列集群中的,而消费的过程分为三步:接收消息、处理消息、更新消息进度

这里接收消息和处理消息的过程都可能发生异常或者失败,比如说,消息接收时网络发生抖动,导致消息并没有被正确的接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,那么这条失败的消息就永远不会被处理了,也可以认为是丢失了。

所以,在这里需要注意的是,一定要等到消息接收和处理完之后才能更新消费进度,但是这也会造成消息重复的问题。比如说某一条消息在处理之后,消费者刚好宕机了,那么因为没有更新消费进度,所以当这个消费者重启之后,还会重复消费这条消息

如何保证消息只被消费一次

从上面分析可以发现,为了避免消息丢失,我们需要付出两方面的代价:一方面是性能的损耗,一方是是可能造成消息重复消费。

  • 性能的损耗我们还可以接受,因为一般业务系统只有在写请求时才会有发送消息队列的操作,而一般系统的写请求的量级并不高
  • 但是消息一旦被重复消费,就会造成业务逻辑处理的错误。那么我们要如何避免消息的重复呢?
  • 想要完全避免消息重复的发生时很难做到的,因为网络的抖动、机器的宕机和处理异常都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使消费到了重复的消息,从消费的最终结果来看和只消费一次是等同就好了,也就是保证在消费的生产和消费的过程是“幂等”的。

幂等:一件事儿无论做多少次都和做一次产生的结果是一样的,那么这件事儿就具有幂等性。

消费在生产和消费的过程中都可能会产生重复,所以我们要做的就是,在生产过程和消费过程中增加消息幂等性的保证,这样就可以认为从“最终结果上来看”,消息实际上是只被消费了一次的。

在消息生产时保证幂等性

在消息生产过程中,在 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 的时候带上版本号,类似于执行:

update user set amount = amount + 20, version=version+1 where userId=1 and version=1

我们在更新数据时给数据加了乐观锁,这样在消费第一条消息时,version 值为 1,SQL 可以执行成功,并且同时把 version 值改为了 2;在执行第二条相同的消息时,由于version 值不再是 1,所以这条 SQL 不能执行成功,也就保证了消息的幂等性。

总结

  • 消息的丢失可以通过生产端的重试、消息队列配置集群模式、以及消息端合理处理消费进度三个方式解决。
  • 为了解决消息的丢失通常会造成性能上的问题以及消息重复的问题
  • 通过保证消息处理的幂等性可以解决消息的重复问题

注意并不是所有的消息丢失都不能接受。比如像是日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发的丢失几条日志是可以接受的。

所以方案设计看场景,这是一切设计的原则,你不能把所有的消息队列都配置成防止消息丢失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外的负担。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值