幂等性场景:消息重消费问题解决方案 | 豆包MarsCode AI刷题

383 阅读24分钟

消息幂等背景

消息重复发送或者重复消费的解决思路基本一致,那就是把消费者设计成幂等的,也就是说,同一个消息不管消费了多少次,系统状态都是一致的。

另外一个经常和幂等联系在一起的话题就是重试,在微服务以及分布式的场景中,由于网络波动的缘故,一般会涉及到一个点,那就是网络抖动丢失,针对这种场景,最简单的就是重试,不过重试的前提是动作必须是幂等的。所以这个时候还是会涉及到幂等,很多时候第一反应就是唯一索引以及分布式锁,但是这个方案上面,还有对应的提升空间。

本文档针对消息重复消费的场景,来给出一个幂等的不错解决方案,从而保证在聊到幂等的时候可以全面深入。

理论背景——布隆过滤器

布隆过滤器简介

布隆过滤器(Bloom Fitler)相信大家非常熟悉,这个大家在说到缓存击穿的时候都会聊到这样的一个组件,其底层的数据结构就是位图(Bitset),在此基础上,加上多组哈希函数,就构成了我们非常熟悉的布隆过滤器。

其优点非常明显,因为其是基于位图进行存储的,所以其空间效率非常高,加上哈希的缘故,查询时间也非常快,但是缺点非常明显,就是由于 n 组哈希的缘故,所以其一般会存在假阳性问题,对于删除操作不是非常友好。

布隆过滤器的基本思路就是当集合中添加某个元素的时候,通过哈希算法把元素映射到位数组的 N 个点上面,将对应的比特位设置为 1。

在进行查找的时候,也非常简单,我们只需要查询元素对应的比特位是不是 1,就可以粗略地判断这个元素是否在集合中存在。

  • 如果查询的元素对应的 N 个点返回的结果都是 1,那么这个元素就有可能存在,但是也存在一种可能,就是这个元素存在,只是布隆过滤器以为他存在,实际上是不存在的,这个问题也就是我们常常说的假阳性问题

  • 如果其中一个点返回是 0 ,那么这个元素一定不存在。

到这里我们基本可以发现,就是布隆过滤器的优点非常明显,就是可以非常高效地判断一个元素是否存在,而且用的空间由于是基于位存储,所以使用的存储空间大小极低,如果存在 1 亿个元素,误判率设置为 0.001 也就是千分之一的时候,占用的内存空间仅仅 171 M 左右。

缺点我们上面说了一个,就是由于哈希冲突的问题,会存在假阳性问题,即可能存在误判,然后布隆过滤器本身还有一个问题,就是不能直接进行删除元素,因为直接删除一个元素可能会影响到其他元素的正确性,这里如果大家感兴趣的话,可以去了解一下布隆过滤器的孪生兄弟布谷鸟过滤器,其在布隆过滤器的基础上增添了一些优化的功能。

总的来说,布隆过滤器的性能还是非常不错的,在实际应用中,我们常常用来解决缓存击穿的场景,即针对访问的请求进行一个快速判断机制。通过使用布隆过滤器可以有效地减少对于底层存储系统的访问以及缓存系统的存储压力,提高系统的响应速度以及性能。

布隆过滤器的容量如何评估?

很多人说到这里,可能对于布隆过滤器已经有点心动了,那么要想使用布隆过滤器,我们要使用多大的布隆过滤器呢,这里我们就来简单说一下布隆过滤器容量如何进行评估?

评估网站:krisives.github.io/bloom-calcu…

创建布隆过滤器的时候,有两个比较核心的参数,一个是布隆过滤器的容量,另外一个就是布隆过滤器的误判率了。

  1. 容量参数

布隆过滤器的容量取决于系统数量需求。以用户名全局唯一不能重复功能举例,我们需要把已经注册的用户名放到布隆过滤器中,这样就可以直接通过布隆过滤器来判断用户名是否存在,而不需要和数据库交互。

在这种场景下,布隆过滤器的容量就取决于系统的用户数量,需要产品去评估系统已有用户的数量,以及未来很长一段时间的增长量,得出一个经验值,然后设置为布隆过滤器的容量。 建议可以适当设置大一些,不然的话,当用户数量接近布隆过滤器容量时,会出现较大可能性误判问题。当然也不能设置太大,会造成一定空间的浪费。最终设置的值需要在误判和空间之间做一个取舍。

  1. 误判率参数

误判率参数,意味着布隆过滤器判断某个元素存在时的错误概率。误判率越低,通常需要更大的位数组容量以及新增和查询元素时性能的降低。布隆过滤器增加和查询元素的时间复杂为 O(N),N 取自于布隆过滤器的 Hash 函数数量,误判率越低,就需要更多复杂的 Hash 函数,那新增和查询操作时自然就会变慢。

  1. 真实案例

如果初始化一个 1 亿元素误判率在千分之一的布隆过滤器,大概占据内存 171M 左右。

另外在对布隆过滤器进行初始化的时候,会一次性申请对应的内存,这个需要额外注意下,避免初始化超大容量布隆过滤器时内存不足问题。

布隆过滤器容量不够用怎么解决?

当系统运行过程中,布隆过滤器中的元素逐渐追平或超过设置的元素数量,就会引起误判率增加的风险。

为此,我们需要一种预警以及容量不够用的解决方案。我觉得可以通过定时任务扫描布隆 过滤器 的容量,判断当前容量距离最初设置峰值差量还有多少。

假如设置阈值是 20%,当布隆过滤器中元素满 80% 以上后,就触发告警发邮件或其它通信工具。然后人为或程序操作拉起一个新的布隆过滤器,新布隆过滤器的容量建议为旧容量的 1.5 倍,以此解决布隆过滤器容量问题。

因为布隆过滤器并没有数据同步方法,所以历史数据需要从源表(可能是 MySQL 等数据源)中重新读取并写入到新布隆过滤器。

  1. 如何判断布隆过滤器的容量是否达到阈值?

在 Java 中有一个常用的 Redis 客户端 Redisson,其中布隆过滤器的类为 RedissonBloomFilter,类中有一个 count 方法,可以返回布隆过滤器的数据容量。

这个方法底层的实现原理也比较简单,因为布隆过滤器的底层数据结构是位图BitMap,BitMap 是可以通过 BITCOUNT 命令统计元素数量的。

  1. 如何把历史数据快速同步到新布隆过滤器?

针对大数据量查询,我们可以使用 select * from table where id > xxx limit xxx; 每次查处一批数据后,把最大的 id 当作查询条件赋值给下一次查询。通过这种方式不断循环,直到查出全部数据并放到新布隆过滤器。 这里提一嘴,为什么不使用常规 limit xx xx

这是因为这会存在深分页问题,在大数据量场景下,分页越是到后面越是容易形成性能深渊。 另外,同步到 Redis 新布隆过滤器中会涉及到大量的网络 IO 操作,尽量使用管道命令节省性能,这里就不做过多赘述。

  1. 定时任务多久执行一次比较合适?

针对这种较为重要的判断,可以适当让扫描周期密集些,比如 1-5 分钟执行一次。触发设定阈值后,发起报警。

  1. 如何在代码中替换新旧布隆过滤器?

这里我们可以借鉴一下 Redis Hash 扩容的 rehash 的思想,假设布隆过滤器从初始就是两个,然后通过一个布尔类型的变量判断使用哪个布隆过滤器。

伪代码如下:


public RBloomFilter<String> getActualBloomFilter() {
    return bloomFilterFlag ? oldBloomFilter : newBloomFilter;
}

通过配置中心控制 bloomFilterFlag 参数,即可实现不停机更新。

另外,如果新的布隆过滤器容量又不够了,那我们只需要将旧布隆过滤器删掉重新创建一个更大的,再修改下 bloomFilterFlag 就可以无缝实现该功能。

重复消费分析

重复消费的原因

重消费从生产-消费模型来进行分析,可能的原因主要就 2 个。

  • 生产者重复发送。从生产者的角度考虑,主要是网络波动导致的,一般在生产情况中,为了保证消息传输的可靠,一般会设置对应的 ACK 机制,即在消费者收到消息之后,会向生产者发送一个确认收到消息的响应,然后长时间没有收到响应,就会触发超时重传的机制,关于重传机制,有指数退避、均匀重试等策略,这里不是我们关注的重点,在确认消息超时之后,很难确定消息是否已经发送出去了,就会触发生产者的超时重传机制,也就可能导致一个消息连续发送了很多次。
  • 消费者重复消费。从消费者的角度考虑,主要是从持久化的角度考虑,比如说在你处理完对应的消息之后,还没有将结果持久化,或者还来不及将确认的信息发送生产者,如果这个时候宕机了, 结果还没有提交,那么就会导致结果丢失,等恢复过来之后,就会再次消费同一条消息。

为了避免这种情况的发生,最好的解决方式就是将消费者的消费逻辑设置为幂等情况,即多次收到同一条消息的处理策略是一致的。然后在微服务中,如果涉及到上下游调用的情况,关于消费处理端最好也是设计成幂等的方式,这样上游就可以利用重试机制来提高系统的可用性。

这里提一嘴,就是现在大多数消息中间件都声明了自己实现了恰好一次(exactly once)语义,其底层也是依赖于重试和幂等来实现的。

背景描述

在进行方案设计的时候,我们需要考虑一些信息:

  • 负责的业务里面有没有接口或者消费者要求幂等处理,如果有,要怎么解决?
  • 如果依赖唯一索引来解决幂等问题,那么这部分的写流量又多大?
  • 如果依赖唯一索引来解决幂等问题,那么如果保证业务操作和数据插入到唯一索引的这个操作是同时成功还是同时失败的?

在确定好基本的背景之后,这个时候就可以来开始说说我们的方案和解决思路。

基本方案

首先,最简单的思路就是维护一张本地消息表,通过本地消息表的唯一性,也就是唯一索引来解决幂等问题。

这个方案的实现思路非常简单,就是利用唯一索引,即在处理业务的时候,先根据消息内容往消息业务表里面插入一条数据,这个业务表上面建立唯一索引,如果插入成功表示这条消息没有没处理过,可以继续处理,如果插入失败就表示消息已经处理过了,直接返回即可。

本地事务将数据插入到唯一索引

在这个方案里面,当第一次处理请求的时候,将数据插入到唯一索引成功了,后面的业务处理失败了怎么解决?

这个的解决方案其实非常简单,那就是利用事务,也就是说,在收到消息之后就开启一个本地事务,然后在这个本地事务里面会同时进行业务操作和将数据插入到唯一索引这个操作,然后进行事务提交。

在这个机制中,就只会出现一种情况,那就是事务提交了,但是提交消息失败了,这个时候就会再次消费同一条消息,但是这个时候由于事务已经提交了,所以问题不大,因为在下次消费的时候就会因为消息插入失败而结束流程。

但是这个时候就会有疑问了,如果本地事务没有提交,那么怎么处理?

这个问题也非常简单,因为我们将消息消费的逻辑放到本地事务里面了,如果本地事务提交失败,也就说明消息消费本身就失败了,这个时候应该做的,就是进行消息重试。

这里有一个点,就是如果要使用唯一索引,最好的方式就是把唯一索引和业务操作组合在一起,组成一个事务,也就是说,在收到消息之后,先开启事务,把数据插入到唯一索引,然后进行业务操作,最后提交事务。提交事务的时候,就任务业务已经消费成功了,这个时候就算消息提交失败也没有关系,因为后面消费的重复请求是会给唯一索引拦截下来的,这里有一个比较麻烦的操作,就是如果没有办法使用本地事务了,也就是说在分库分表的条件下,这个方案就会非常麻烦。

然后由于我们的项目是属于电商项目,一般情况下,分库分表是逃不了的,所以这个时候就需要考虑如何使用非本地事务实现数据插入的唯一性。

非本地事务将数据插入到唯一索引

如果没有本地事务,那么业务操作和数据插入到唯一索引的操作就不能看作是一个整体,那么就无法保证插入和消费要么都成功,要么都失败。这个时候根据 CAP 定理以及 Base 原则,我们只能牺牲性能,来保证数据的最终一致性,也就是依赖第三方组件来进行检测。

这个方案的执行步骤一共分为 3 步:

  1. 将消息插入到唯一索引,这个时候标记为初始状态,这个一定要执行,这是避免重消费的关键。
  2. 执行业务操作
  3. 将唯一索引对应的数据标记为完成状态。

这个方案有问题的地方就是如果第二步成功了,但是第三步执行失败了要如何处理,这个时候就需要使用一个异步检测系统,一般我们会使用定时任务,即定时扫描唯一索引的表,这个时候会出现两种情况:

  • 如果业务表的数据表示业务操作已经成功了,那么这个异步检测系统就会把唯一索引更新为成功状态。

暂时无法在飞书文档外展示此内容

  • 如果业务表的数据表明业务操作没有成功,则定时任务会直接触发重试机制。

暂时无法在飞书文档外展示此内容

这里我们着重说一下定时任务的逻辑。

在不能使用本地事务的时候,实现幂等就有点麻烦,因为我们不能将业务操作和数据插入到唯一索引这两步做成原子操作,所以我们的解决方案是追求最终一致性。

基本逻辑如上面两个图:

  1. 将数据插入到唯一索引表里面,避免重复消费,这个时候保持在初始化状态
  2. 执行业务操作,操作完成后,将唯一索引更新为成功状态。

问题主要出现在这里,如果第二步成功了,第三步失败了怎么处理?

这个时候就需要利用我们的定时任务机制,通过定时任务定时扫描初始状态的唯一索引数据,然后检测唯一索引的数据和业务数据,判断是否一致,如果不一致,则表示此时业务已经操作成功了,那么就把唯一索引标注为成功,如果这个时候业务失败了,则触发重试即可。

最终方案:布隆过滤器 + Redis + 唯一索引

在以上方案的基础上,我们总结出了一个最终方案,即布隆过滤器 + Redis + 唯一索引的方式,这个思路来说,就是通过 Redis 以及布隆过滤器限流的方式,减少到达数据库的流量,实现数据流量最小化。

前面的方案我们已经说了唯一索引,但是这个方案的主要依靠是数据库的唯一索引来实现的,当系统流量过大的时候,你就会发现,这个方案存在一定的缺陷,也就是系统的瓶颈取决于数据库的承受压力以及性能。很多时候我们的数据库都不会使用阿里云那种魔改的 Hint 数据库,因为那种数据库的成本太高了,所以我们需要想一些办法,让尽可能少的流量到达数据库。

这个场景就可以类比缓存穿透了,即减少到达数据库的流量来提高系统的整体性能,而这里需要使用一个高效的数据结构,即布隆过滤器,通过布隆过滤器快速判断一个请求是否已经被处理过了,可以说布隆过滤器非常适合解决这个问题,但是布隆过滤器本身存在假阳性的问题,也就是说,如果一个消息没有处理过,布隆过滤器可能误判已经处理过了。所以我们可以在布隆过滤器后面再加一个 Redis,存储近期处理过的业务 key,然后加上 MySQL 唯一索引作底层兜底。

流程图如下:

暂时无法在飞书文档外展示此内容

基本流程总结:

  1. 一个请求过来之后,首先利用布隆过滤器判断是否已经被处理过了,如果布隆过滤器返回没,则表示没有处理过,那就真的没有处理过,直接处理就行,如果布隆过滤器表示处理过了(可能是假阳性),那就需要进一步判断。
  2. 利用 Redis 存储一些近期处理过的 Key,如果 Redis 里面有这个 Key,则说明这个 Key 确实被处理过,直接返回结果就可以了,否则就进入第三步,这一步的关键在于 Key 的过期时间是多久?
  3. 利用唯一索引,如果唯一索引通过冲突了,就表示已经处理过了,这一步就回到了一开始的唯一索引的逻辑,这里的唯一索引一般就是业务的唯一索引,并不需要额外创建一个索引。

到这里流程基本就差不多了,接下来是这个方案的一些技术细节。

更新顺序

这里第一个问题,就是业务方第一次处理完这个请求,怎么更新系统?先更新布隆过滤器还是更新 Redis,还是先更新数据库唯一索引?

答案是先更新数据库唯一索引,因为数据库是兜底方案,其数据也是最准确的。

如果业务方是第一次执行这个请求,它需要把更新数据库的操作放到自己的业务本地事务里面,等待业务方提交的时候,一起提交。在数据库事务提交之后,再去更新布隆过滤器和 Redis,这个时候即使失败了影响也不大,因为最终重复请求处理的时候,会因为唯一索引冲突而报错,这个时候我们就知道这个请求是重复的。

然后之后无论是先更新布隆过滤器,还是先更新 Redis 或者并发更新,其实问题不大,都是可以的,因为我们兜底方案主要是数据库的唯一索引,这两步只是一个限流的作用,相对来说没有那么重要。

Redis Key 的过期时间

这个我们上面有说到一个关键点,就是 Redis 的 Key 过期时间应该设置多少,简单来说,Redis 的作用就是布隆过滤器之后可以进一步削减流量,而这个 Kedy 的过期时间就决定了削减流量的效果,所以只需要保证重复请求过来的时候,这个 Key 还没有过期就可以了。

这里的关键是要确保重复请求过来的时候 Key 还没有过期,这里举一个例子,比如一个业务的重试逻辑是 10 分钟一次,那么我们就可以把过期时间设置为 12-15 分钟的样子,多出来的时间就是空闲的重试逻辑。

简化方案

这个时候如果并发非常高,以至于 Key 非常多,那么还需要考虑 Redis 是否放得下那么多 Key,另外一个就是有些业务的重试间隔非常长,比如说一小时,那么就不太适合引入 Redis,可以考虑这个简化版本的方案。

简化版本的方案分别有两个:

  • 布隆过滤器 + 唯一索引
  • Redis + 唯一索引

布隆过滤器 + 唯一索引

这个方案的实现逻辑也非常简单,并且比较适合重试时间比较长的业务。

暂时无法在飞书文档外展示此内容

Redis + 唯一索引

这个方案主要是 Redis 的资源比较充足,然后考虑到布隆过滤器的假阳性问题,数据库的性能也比较差,就可以使用这个方案。

暂时无法在飞书文档外展示此内容

总结

这里我们稍微总结一下,如果业务的并发不是很高,或者不需要很复杂的方案,就可以使用简化方案。

  • 布隆过滤器 + 唯一索引:这个方案适用于 Redis 资源不足,然后重复请求间隔时间太长,这样就会导致 Redis 的效果不好,那么就适合使用这个方案。
  • Redis + 唯一索引:Redis 资源多,担心布隆过滤器假阳性问题比较严重,就可以考虑这个方案。

这里比较建议一开始使用第一个方案,然后后面发现布隆过滤器假阳性问题非常严重了,那么就可以考虑引入 Redis 来实现第二个方案。

本地布隆过滤器

在没有使用 Redis 的时候,布隆过滤器基本就是使用本地形式了,那么这个时候有没有什么解决方案来提升性能呢,答案是有的,那就是使用本地内存的方式,整体思路也非常简单,就是用一致性哈希等类型的负载均衡算法,来确保同一个 Key 落在同一个实例上面,那么就可以在这台机器上面使用基于本地内存的布隆过滤器了。

暂时无法在飞书文档外展示此内容

一般生产者消费都会涉及到消息队列的场景,这个时候就可以继续扩展,即在发消息的时候把同一个业务的消息发到同一个分区,这样就可以在消费者身上使用基于本地内存的布隆过滤器了。

暂时无法在飞书文档外展示此内容

在使用了本地布隆过滤器之后,也可以考虑把 Redis 替换为本地内存,不过本地内存空间一般不多,所以还是使用 Redis 好一点,因为可以通过布隆过滤器的请求已经是少数了,不需要浪费本地内存去换取一点性能的提升。

这里的关键是本地布隆过滤器

在性能要求非常苛刻的情况下,可以考虑使用本地布隆过滤器,但是这个需要和负载均衡结合一起使用,比如在消息消费的场景下,应该要求生产者把同一个 key 的消息都发到同一个分区上面,这样对应的消费者就可以使用本地布隆过滤器了。

紧接着一个关键点就是重建本地布隆过滤器

本地布隆过滤器在服务器重新启动之后,重建一般比较简单,这里有两种思路,第一种就是不重建,直接处理新请求,这种适合时效性比较强的请求,比如说今天不可能再收到昨天的请求。

第二种就是利用过去一段时间的数据,比如我今天预计收到的重复请求最多来自三天前,那么我就利用这三天内处理过的请求来重构布隆过滤器。

因为布隆过滤器我们上面说过,主要是起到限流的作用,准不准其实关系不大,主要还是依靠我们的唯一索引来做兜底,不要让太多的流量落到唯一索引上面。

布隆过滤器的替代品——位数组

如果你表示布隆过滤器还不够快,还可以使用一个更加高效的数据结构——位数组,如果 Key 本身就是数字,比如某张表的自增主键,这种情况下位数组的性能可以更高,而且可以更加节省内存。

这里再补充说明一下,布隆过滤器是可以自由切换到位数组, 因为布隆过滤器底层就是位数组,只是多了若干层哈希函数而已,所以基本上可以做到无痛切换。

这里我们可以针对布隆过滤器做一个抽象,也就是说,对于一些业务,我们可以将布隆过滤器换成位数组的形式,比如某个业务要求判断幂等,用的是业务的自增主键,那么就可以利用位数组的方式来实现幂等性判断,这里有一个小技巧,就是为了防止竞争对手猜到自己的业务量,自增主键不是从 0 开始或者使用的是分布式 ID 的时候,比如说如果最小的 ID 是 30000,这个时候位数组的第一个比特位就可以表示为 30000,第二个比特位就可以是 30001.

总结

总的来说,解决重复消费的思路就一个:让消费者幂等, 问题是怎么实现幂等,最简单的方式就是唯一索引,然后通过本地事务或者非本地事务两种方式实现数据的插入

然后在唯一索引的基础上,引入布隆过滤器 + Redis + 唯一索引的方案,这个方案如果可以的话,经过实践,大概可以顶住千万 TPS 的幂等方案架构,然后如果感觉链路太长,也可以根据自己的实际业务情况,进行改造。