注册

技术大佬问我 订单消息重复消费了 怎么办?

技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了?


佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都被揉捏的像一个麻花了嘛


技术大佬 :哦,这次又是遇到什么难题了?


佩琪: 简历上的技术栈里,不是写了熟悉kafka中间件嘛,然后就被面试官炮轰了里面的细节


佩琪: 其中面试官给我印象深刻的一个问题是:你们的kafka消息里会有重复消息的情况吗?如果有,是怎么解决重复消息的了?


技术大佬 :哦,那你是怎么回答的了?


佩琪:我就是个crud boy,根本不知道啥是重复消息,所以就回答说,没有


技术大佬 :哦,真是个诚实的孩子;然后了?


佩琪:然后面试官就让我回家等通知了,然后就没有然后了。。。。


佩琪:对了大佬,只是简单的接个订单消息,为啥还会有重复的订单消息了?


技术大佬 :向上抬了抬眼睛,清了清嗓子,面露自信的微笑回答道:以我多年的经验,这里面大概有2个原因:一个是重复发送,一个是重复消费


写作 (6).png


佩琪哦哦,那重复发送是啥了?


技术大佬 :重试发送是从生产端保证消息不丢失,但不保证消息不会重发;业界还有个专业术语定义这个行为叫做"at least once"。在重试的时候,如果发送消息成功,在记录成功前程序崩了/或者因为网络问题,导致消息中间件存储了消息,但是调用方失败了,调用方为了保证消息不丢,会再次重发这个失败的消息。具体详情可参见上篇文章《kafka 消息“零丢失”的配方》


佩琪重复消费又是啥了?


技术大佬 :重复消费,是指消费端重复消费。举个例子吧, 比如我们一般在消费消息时,都建议处理完业务后,手工提交offset;但是在提交offset的时候,因为某些原因程序崩了。再次重启消费者应用后,会继续消费上次未提交的消息,像下面这段代码


  while(true){
consumer.poll(); // ①拉取消息
XXX // ② 业务处理;
consumer.commit(); //③提交消费位移
}

在第三步,提交位移的时候,程序突然崩了(jvm crash)或者网络闪断了;再次消费时,会拉取未提交的消息重复执行第二步的业务处理。


佩琪:哦哦,原来是这样。我就写了个消费者程序,咋这么多的“技术坑”在这里面。请问大佬,解决重复消费的底层逻辑是啥了?


技术大佬 : 两个字:幂等。 即相同的请求参数,请求1次,和请求100W次,得到的结果和对业务的影响是一样的。比如:同一个订单消息消费一次,然后进行积分累加;和同一个订单的消息重复消费10次,进行积分累加,最后效果是一样的,总积分不能变,不能变。 如果同一个订单消费了10次,积分也给累加了10次,这种就不叫幂等了。


佩琪:哦哦。那实现幂等的核心思想和通用做法又是什么了?


技术大佬 :其实也挺简单 存储+唯一key 。在进行业务处理时,查询下是否已处理过这个唯一key的消息;如果存在就不进行后续业务处理;如果不存在就继续后续业务的处理吧。


佩琪摸了摸头,唯一key是个啥了?


技术大佬 :唯一key是消息里业务数据的唯一标识; 比如对于某些业务场景是订单号;某些业务场景是订单号+订单状态;


佩琪存储又是指什么了?


技术大佬 :一般指的就是存储引擎了;比如业界常用的mysql,redis;或者不怎么常用的mongo,hbase等。


佩琪对了大佬,目前业界有哪些幂等解决方案?


技术大佬常用的大概有两种方式:强校验和弱校验


佩琪强校验是什么了?能具体说说吗?


技术大佬 :强校验其实是以数据库做为存储;唯一key的存储+业务逻辑的处理放入一个事务里;要么同时成功,要么同时失败。举个例子吧,比如接收到 用户订单支付消息后;根据订单号+状态,累加用户积分;先查询一把流水表,发现有这个订单的处理记录了,直接忽略对业务的处理;如果没有则进行业务的操作 ,然后把订单号和订单状态做为唯一key,插入流水表,最后做为一个整体的事务进行提交;


整体流程图如下:


写作 (4).png
待做


佩琪大佬好强。能具体说说你的这种方案有什么优缺点吗?


技术大佬 :给你一张图,你学习下?


优点缺点
在并发情况下,只会严格执行一次。数据库唯一性+事务回滚能保证业务只执行一次; 不会存在幂等校验穿透的问题处理速度较慢: 处理性能上和后续的redis方案比起来,慢一个数量级。毕竟有事务加持;另外插入唯一数据时极大可能读磁盘数据,进行唯一性校验
可提供查询流水功能:处理流水记录的持久化,在某些异常问题排查情况下,还能较为方便的提供查询记录历史数据需要额外进行清理:如果采用mysql进行存储,历史记录数据的清理,需要自己单独考虑和处理,但这应该不是个事儿
实现简单:实现难度还是较为简单的,一个注解能包裹住事务+回滚
适用资金类业务:非常适合涉及资金类业务的防重;毕竟涉及到钱,不把数据持久化和留痕,心理总是不踏实
架构上简约:架构上也简单,大多数业务系统都需要依赖数据库中间件吧。

佩琪: 果然是大佬,请收下我的打火机。


佩琪弱弱的问下,那弱校验是什么了?


技术大佬 :其实是用redis进行唯一key存储和防重。比如订单消息,订单号是一条数据的唯一标识吧。然后使用lua脚本设置该消息正在消费中;此时重复消息来,进行相同的设置,发现该订单号,已经被标识为正在处理中,那这条消息放入延时队列中,延时重试吧;如果发现已经消费成功,则直接返回,不执行业务了;业务执行完,设置该key执行成功。
大概过程是这样的
写作 (5).png


佩琪那用redis来进行防重,会存在什么问题吗?


技术大佬 : 可能会存在防重数据的丢失吧,最后带来防不了重。


佩琪redis为什么会丢防重数据了?


技术大佬 : 数据丢失一部分是因为redis自身的原因,因为它把数据放入了内存中;虽然提供了异步复制机制+高可用方案,但还是不能100%保证数据不丢失。


技术大佬 : 另外一个原因是,数据过期清理后,可能还有极低的概率处理相同的消息,此时就防不了重了。


佩琪那能不设置过期时间吗?


技术大佬 : 额,除非家里有矿。


技术大佬 : redis毕竟是用内存进行存储,存储容量比起硬盘来小很多,存储单位是G(硬盘线上存储单位是T开始),而且价格比起硬盘来又贵多个数量级;属于骄贵性存储;所以为了节约存储空间,一般都会设置一个较短的过期时间,进行数据的淘汰;而这个较短过期时间,是根据业务情况进行定义,有5分钟的,有10分钟的。


技术大佬 : 在说了,这些防重key是不太具备业务属性和高频率访问特性的,不属于热点数据,为啥还要一直放到缓存里了???


佩琪:果然是大佬,请再次收下我的打火机。用redis来做防重,缺点这么的多,那为什么还要用redis来进行防重了?


技术大佬 :你不觉得它的优点也很多吗。用它,主要是利用redis操作数据速度快,性能高;并且还能自动清理过期数据的特性,简直不要太方便;另外做防重的目标是啥了?还不是为了那些少数异常情况下产成的重复数据对其过滤;所以引入redis做防重,是为了大多数情况下,降低对业务性能的伤害;从而在性能和数据准确性中间取了个平衡。


技术大佬 : 建议对处理的及时性有一定要求,并且非资金类业务;比如消费下单消息,然后发送通知等业务使用吧。


技术大佬 :我知道你想要问什么了?我这里画了图,列了下优缺点,你拿去看看?


优点缺点
处理速度快因为数据有过期时间和redis自身特性;防重数据有丢失可能性,结果就是有不能防重的风险
无需自动清理唯一key记录实现上比起数据库,稍显复杂,需要写lua脚本;但学过编程的,相信我半天时间熟悉语法+写这个lua脚本应该是没问题的
消息一定能消费成功架构上稍显复杂,为了保证一定能消费成功,引入了延时队列

佩琪:嘿嘿大佬,我听说防重最好的是用布隆过滤器,占用空间小,速度很快,为啥不用布隆过滤器了?


技术大佬 :不使用redis布隆过滤器,主要是 redis 布隆过滤器特性会导致,某些消息会被漏掉。因为布隆过滤器底层逻辑是,校验一个key如果不存在,绝对不会存在;但是某个key如果存在,那么他是可能存在,又可能不存在。所以这会导致防重查询不准确,最终导致漏消息,这太不能接受了。


技术大佬 :还有个不算原因的原因,是redis 4.0之前的版本还都不支持布隆过滤器了。


佩琪大佬 redis我用过, redis 有个setnx,既能保证并发性,又能进行唯一key存储,你为啥不用了?


技术大佬 :不使用它,主要是redis的 setnx操作和后续的业务执行,不是一个事务单元;即可能setnx成功了,后续业务执行时进程崩溃了,然后在消息重试的时候,又发现setnx里有值了,最终会导致消费失败的消息重试时,会被过滤,造成消息丢失情况。所以才引入了redis lua+延时消息。在lua脚本里记录消费业务的执行状态,延时消息保证消息一定不会丢失。


佩琪我想偷个懒有现成的框架吗?


技术大佬 :有的。实现核心的幂等key的设置和校验lua脚本。



  1. lua代码如下:

local status = redis.call('get',KEYS[1]);
if status == nil //不存在,则redis放入唯一key和过期时间
then
redis.call('SETEX',KEYS[1],ARGV[1],1)
return "2" //设置成功
else //存在,返回处理状态
return status
end


  1. 消费者端的使用,伪代码如下

//调用lua脚本,获得处理状态
String key = null; //唯一id
int expiredTimeInSeconds = 10*60; //过期时间
String status = evalScript(key,expiredTimeInSeconds);

if(status.equals("2")){//设置成功,继续业务处理
//业务处理
}

if(status.equals("1")){ //已在处理中
//发送到延时队列吧
}

if(status.equals("3")){ //已处理成功
//什么都不做了
}


总结




  1. 生产端的重复发送和消费端的重复消费导致消息会重




  2. 解决消息重复消费的底层逻辑是幂等




  3. 实现幂等的核心思想是:唯一key+存储




  4. 有两种实现方式:基于数据库强校验和基于redis的弱校验。




感悟


太难了


为了保证上下游消息数据的完整性;引入了重试大法和手工提交offerSet等保证数据完整性解决手段;
可引入了这些解决手段后;又带来了数据重复的问题,数据重复的问题,是可以通过幂等来解决的。


太难了


作为应用层开发的crud boy的我,深深的叹了口气,开发的应用要在网络,主机,操作系统,中间件,开发人员写个bug等偶发性问题出现时,还需要保证上层应用数据的完整性和准确性。


此时佩琪头脑里突然闪现过一道灵光,业界有位大佬曾说过:“无论什么技术方案,都有好的一面,也有坏的一面。而且,每当引入一个新的技术方案解决一个已有的技术问题时,这个新的方案会带来更多的问题,问题就像一个生命体一样,它们会不断的繁殖和进化”。在消息防丢+防重的解决方案里,深感到这句话的哲理性。


原创不易,请 点赞,留言,关注,转载 4暴击^^


作者:程序员猪佩琪
来源:juejin.cn/post/7302023698721570857

0 个评论

要回复文章请先登录注册