你好呀,我是歪歪。
我想再讨论一下上次的这篇文章《哎,被这个叫做at least once的玩意坑麻了》
因为有些朋友看完之后再评论区给出了自己的思考,也有朋友和我私聊,分享了自己的看法,我觉得有些想法很好,所以我决定一鱼两吃,再聊聊这个问题。
假设,我们是一场面试,面试官给你抛出了这样一个问题:
如果一个消费队列由于某些原因,对于某个消息发起了两次。导致一样的数据落库两条,请问你会怎么处理这个问题?
这题你一拿到手上,应该就立马能分析出是在问如何实现一个幂等机制。
想着这玩意我熟啊,张口就能给出方案:
业务消息 = select(业务唯一流水号);
if(业务消息 == null){
save(业务消息);
}
面试官一听,提示道:你这个方案在多线程的情况下会不会有什么问题呢?
于是你的小脑瓜子立刻开始转了起来:先查询,再判断,最后保存。
如果两个线程同时过来,都查不到数据,那么就能都走到保存的逻辑里面去,确实拦不住。
于是你扣了一下脑壳,想起了你上家公司针对这个问题,就是在数据库的表结构里面,对业务唯一流水号做了唯一索引,所以不会出现重复插入的情况。
然后你给出了"加唯一索引"的方案,准备绝杀这个问题。
没想到面试官非常不懂事,还在继续追问:我想尽量不要让程序抛出异常,还有没有其他的方案呢?
Redis
你抱着自己的左手,边啃指甲边思考:唯一索引是数据库帮我们保证的逻辑,现在面试官这个老登不想让我用数据库来做这件事情。那就必须要控制在并发的场景下,只有一个请求能抵达数据库。
锁!这不就是锁干的事儿吗?
于是你飞快的又想到了一个方法:
flag = redis(业务唯一流水号,过期时间);
if(flag){
save(业务消息);
}
可以利用业务唯一流水号结合 Redis 来做一个锁,加锁成功的请求才能走到 save 逻辑中。
这样就能解决并发场景下,多个请求穿透到 save 逻辑这一步的问题。
面试官听到你这个方案之后,立马就启动了追问技能:如果放 Redis 成功了,但是还没来得及 save,服务重启了。
这个请求理论上是应该能再次发起的,但是由于 Redis 锁的存在,导致不会走到 save 的逻辑去,怎么办呢?
于是你又扣了一下脑壳,想起你在上家公司的时候,好像也遇到过这个情况。
当时的解决方案就是人工介入,分析了一波数据,确认了这个消息确实应该被继续处理,于是你找 DBA 帮忙删除了 Redis 对应的 key,流程就通了。
然而这个回答面试官并不满意:人工就显得不优雅了,要不再想想?
你又抱着自己的右手,边啃指甲边思考:这个老登考虑的确实挺多的,感觉应该在一个很厉害的团队,我得加把劲儿,再想想。
现在要人工介入的原因,是因为我们把第二次的请求拦截住并丢弃了。
如果不丢弃,那么理论上在"过期时间"到了,锁被释放后,第二次的请求拿到锁,就能接着往下走。
所以,这里需要在 Redis 这里加一个加锁失败则等待的逻辑:
flag = redis(业务唯一流水号,过期时间,获取不到则等待);
if(flag){
save(业务消息);
}
但是你一看这个逻辑又不对了:由于有锁等待的逻辑,那么如果两个请求过来,还是有可能会都放入到 Redis 里面,flag 都会为 true,那么 save 方法还是会走两遍。
所以,还得在获取锁成功之后加上一个查询数据库的逻辑:
flag = redis(业务唯一流水号,过期时间,获取不到则等待);
if(flag){
业务消息 = select(业务唯一流水号);
if(业务消息 == null){
save(业务消息);
}
}else{
//等待结束后还是未获取到锁,发送预警
monitor(预警信息);
}
第一层的 Redis 相当于让请求排队,确保只有一个请求进来。
第二层的 select 才是真正的防止重复的业务逻辑。
同时,如果等待结束后还是未获取到锁,出现这种低概率情况,就预警出来,人工兜底嘛,一旦人工介入,那就是能解决任何问题。
你心想这波应该是稳了,应该是可以换题了。
然而面试官并不打算在这个回合上轻易放过你:这个方案确实是可以解决这个问题,但是在技术实现上引入了 Redis 框架,如果我不使用 Redis,单纯的靠 MySQL 呢?
回到 MySQL
听到这个问题的时候你觉得不对啊,最开始的时候不就是说了"加唯一索引"就可以解决这个问题吗?
于是面试官补充了一下描述:
最开始的加唯一索引是基于业务表来做的,如果出现问题就让其抛出主键冲突异常,这个方案确实是可以实现需求。但是我现在想让你给我设计一个通用的技术组件,不需要基于某个具体的业务场景去设计。我想听听你的思路。
拿到新的题目,你开始觉得这是***难,看着面试官求知的眼神,你又开始怀疑:这个老登不会是来套方案的吧?
看着自己已经被咬秃了的左右大拇指指甲,感觉自己的灵感和指甲一样都光秃秃的。
开始后悔前面几个回合咬得太快了,原以为可以秒杀这个面试,没想到面试官还在缠斗。你动了使用必杀技来结束战斗的念想。
于是从帽子的缝隙中插进入一根指甲已经秃了的手指,在差不多秃了的头顶,用指腹画圈,给自己头皮按摩,医生说这样的有助于毛囊发育,你想着头发还会长出来,就思如泉涌,这就是必杀技。
你陷入了思考,Redis 在前面的方案中是为了防止有多条数据穿透到 save 方法中去,如果不让用 Redis。MySQL 怎么实现类似的效果呢?
也加锁吗?for update?
业务消息 = select(业务唯一流水号);//select *** for update
if(业务消息 == null){
save(业务消息);
}
这玩意一看上去就是性能就拉胯了,为了解决这个偶发的问题,牺牲了接口的性能,这个路线就走的有点远了。
而且这个上锁的逻辑隐藏的有点深,容易留下后患,面试官肯定不会满意的。
那还有什么办法,能把 MySQL 当作锁来用,确保并发情况下只有一个请求能穿过这个锁呢?
那还是得靠唯一索引的约束才行。
但是这个唯一索引面试官不让用业务表的,那就只能直接搞个"消息消费记录表",里面有个"消息唯一标识"的字段,这个字段是唯一索引。
这张表面试官问起来,我就说这张表是完全独立于业务的存在,只是为了解决消息幂等这个存粹的技术问题而出现的,基于它,我们就可以设计出一个通用的技术组件,这样应该说的过去。
表有了,技术方法大概的雏形就有了。
然而你还不能开始答题,现在思路还不是特别清晰,你要把方案捋清楚了再张口。
在不知不觉间,你的指腹已经摩擦的有点麻木了,于是你换了一个手,穿过帽子,接着按摩着自己的头顶。
这个表我怎么用呢?
if(保存数据到消息消费记录表){//出现主键冲突就返回false
save(业务消息);
}
先校验,再保存,非原子性,这样肯定不行啊,
我们想想一个场景,如果保存数据到消息消费记录表成功,还没来得及 save(业务消息) ,服务重启了,怎么办?
所以为了保证原子性,我们可以加入事务,把这两步绑定到一起:
开启事务;
if(保存数据到消息消费记录表){//出现主键冲突就返回false
save(业务消息);
}
提交事务;
这样,如果保存数据到消息消费记录表成功,还没来得及 save(扣款信息) ,服务重启,事务回滚,消息消费记录表就不会真的插入成功。
而 MQ 没有收到这个消息的回执,也会再次进行投递。
由于消息消费记录表里没有这个数据,所以会再次进行消费。
现在你觉得似乎没啥问题了,刚想给面试官说你这个思路,但是立马又想到了另外一个问题:通过引入事务来解决了"非原子性"的问题,但是事务这玩意,一般来说,大家都是能不使用事务的地方就尽量不使用事务,通过最终一致性来保证数据的完整性。
这个老登肯定会在这个地方继续穷追猛打的,我先预判了他,想想这个问题怎么解决。
我们可以在消息消费记录表里面再引入一个"状态"字段,这个字段有两个取值:消费中、消费完成。
同时把唯一索引改成"消息唯一标识+状态"。
首先,MQ 发起请求,数据往消息消费记录表插的时候,状态直接就是"消费中"。
如果插入成功,则说明是第一次消费,进入到业务逻辑中去。
- 如果业务逻辑执行成功,则更新消息消费记录表对应数据为"消费完成"。
- 如果业务逻辑执行失败,则删除消息消费记录表对应数据,把消息仍回 MQ,等待下次重试。
如果插入失败,则说明是重复消费,直接扔掉。
画成流程图上大概是这样的:
顺便提一嘴,上面这个流程图我是用这个网站直接生成的,我觉得这个网站画图还挺舒服的:
你感觉这波应该稳了,于是给面试官说出了自己的方案,并在白字上画了流程图。
面试官拿着你的流程图,看了一眼,立马就看出了一个问题:如果一个消息插入失败,你的逻辑是扔掉。那假设这条消息的状态是消费中,业务逻辑执行失败,是不是应该重新消费才对呢?
于是你立马反映过来,如果插入失败,则说明是重复消费,还需要判断数据的状态。
- 如果状态是"消费成功",则说明重复请求,直接返回成功
- 如果状态是"消费中",则说明还未处理完成,为了确保成功,需要把请求再次仍回到 MQ。
修改了流程图:
面试官拿着这个流程图,微微一笑:
倘若我业务执行完之后,状态更新之前,服务挂了,阁下又该如何应对?
巧了,这个问题上一篇文章的评论区也提到了:
所以,还需要针对长时间在"消费中"的数据进行一个监控,人工兜底一下。
此外,为了防止"消费完成"的数据量过多,还应该对于这个状态的数据做一个定时清理的任务。
终于,你看到了面试官脸上那一闪而过的满意表情,在你觉得面试官应该会放过你了的时候,他又提出了另外的问题:
你这个通用组件理论上确实是可行的。
但是,这张表放在哪个库的哪个表里呢?
是统一放在一个库里呢还是就放在业务服务的库里呢?
统一放一个库的话太大了怎么办呢是不是要按日期分表?
万一跟业务库用的数据库不是一个数据库产品那事务不生效咋办呢?
放在业务库里的话万一业务服务连好几个库那我具体放哪一个呢?
是不是所有业务库我都得加这么一张表强制绑架他们的数据库?
...
这一部分问题,也来自上一篇文章评论区。
听到这些问题,你开始觉得这个面试官是在胡搅蛮缠,一气之下,准备拿回简历,结束面试。
但是手上动作稍微大了一点,一不小心掀起了自己的帽子,漏出了"资深的发型"。
面试官也愣住了,看着你"资深的发型",当即就握住了你的手:你就是我要找的人才。不面了,就你了,明天来报道!
入职
入职之后你第一件事情就是看看这个公司的代码。
当你看第一个接口的时候,发现根本没有做幂等。
当你看第二个接口的时候,发现就是靠业务表的唯一索引做的幂等。
当你看第三个接口的时候,Redis 的方案跃然纸上。
突然一个哥们气喘吁吁的跑过来找昨天面试你的老登,说:快,又出问题了,帮忙删除一个 Redis key。
于是,你抽过去准备看一下怎么操作。
不经意间看到了老登正在写一个文档,题目叫做《一种分布式系统中数据唯一性的消息幂等保障策略》。
老登看到你过来了,说:正好,你来写这个文档,我已经把名字给你想好了,你就按照这个写,把你昨天的思路写清楚,到时候我去汇报。
你兴奋的问:汇报过了之后我们要按照这个方案落地吗?
老登说:不不不,落地干啥啊,多麻烦啊,方案汇报嘛,体现一下我们在技术方面的时刻,在领导面前去刷个脸,所以你要多用一些高大上的词,越晦涩难懂越好。哦,对了,我顺便教教你怎么"删除 Redis key",以后就让他们找你了。这帮老登,大半夜的,老是给我打电话。