你好呀,我是歪歪。

前几天遇到一个生产问题,同一个数据在数据库里面被插入了两次,导致后续处理出现了一些问题。

当时我们首先检讨了自己,没有做好幂等校验。甚至还发现了一个低级错误:对应的表,针对订单号,这个业务上具有唯一属性的字段,连唯一索引都没有加。如果加了唯一索引,也不至于出现落库两次的情况。

然后拿着数据去问上游系统,为什么会出现同一个订单发起了两次的这种异常场景。

上游系统一听到我们的描述,立马就站出来解释:不可能,在没有人工介入的情况下,同一个单子,我们绝对不可能发送两次。在开发的过程中,我们还特意注意了这个场景。

但是我是不相信他们的“鬼话”,我更觉得这就是他们的一个 BUG。

既然争执不下了,那就拿事实说话。

日志就是事实。

于是我们一起查询了日志,最后的结果就更加奇怪了。

调用方确实只有一次调用日志。

但是我们接收方却收到了两次请求。

通过图片也能看出来,我们之间是通过 MQ 异步交互的。

所以,自然而然的就把目光放到 MQ 上。

我们使用的 MQ 是一个叫做 SofaMQ 的玩意,比较冷门,但是有蚂蚁金服背书。

在官方文档的“常见问题”部分,有这样的描述:

这句话你仔细品一下:可以保证消息不丢失,但是无法保证消息不重复。

言外之意是不是就是在说:为了保证消息不丢失,在我拿不准你到底有没有消费成功的情况下,我有可能针对的同一个消息再次发送。

再次发送,那不就是一个消息会被消费多次吗?

不就是我们遇到的这个问题吗?

然后我突然想起了一个曾经学过的东西:at least once。

在 MQTT 协议中,给出了三种传递消息时能够提供的服务质量标准,这三种服务质量从低到高依次是:

  • At most once: 至多一次。消息在传递时,最多会被送达一次。换一个说法就是,没什么消息可靠性保证,允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用,比如每分钟上报一次机房温度数据,可以接受数据少量丢失。
  • At least once:至少一次。消息在传递时,至少会被送达一次。也就是说,不允许丢消息,但是允许有少量重复消息出现。
  • Exactly once:恰好一次。消息在传递时,只会被送达一次,不允许丢失也不允许重复,这个是最高的等级

同时,在“消息幂等”部分,也特别进行了强调:

https://help.aliyun.com/document_detail/146983.html

为了防止消息重复消费导致业务处理异常,有必要根据业务上的唯一 Key 对消息做幂等处理。

虽然我用的这个玩意是一个冷门的 MQ ,但是这个问题和具体使用的哪个 MQ 关系不大,常见的 RabbitM、RocketMQ、Kafka 都有类似的问题,都需要消费端做好幂等处理。

本文就基于这个问题,来讨论一下,在“消息可能重复消费”这个场景下,有没有啥好的解决方案。

举个例子

前面说了,要处理消息重复消费的场景,最核心的逻辑是需要实现幂等机制。

幂等,这个概念大家应该是比较清晰了。

举个具体的例子。

比如在支付场景下,消费者消费扣款消息,对一笔订单执行扣款操作,扣款金额为 100 元。

如果因各种原因导致扣款消息重复投递,比如简单的一个场景,消费者接受到“扣款金额为 100 元”这个信息,完成消费,还没来得及告诉 MQ,“老哥,这个消息我已经收到了”,就重启了。

站在 MQ 的角度,没有收到回执,就代表这个消息并没有消费成功,基于“必须保证消息不丢失的指导思想”,它就会继续投递。

所以消费者会重复消费这个扣款消息。

但是,最终的业务结果是只扣款一次,扣费 100 元,且用户的扣款记录中对应的订单只有一条扣款流水,不会多次扣除费用。

那么这次扣款操作是符合要求的,整个消费过程实现了消费幂等。

在要求幂等的场景中,我们要找到一个抓手。

比如在这个案例里面,扣款一般来说都会对应一个业务上的唯一流水号,这个业务上的唯一流水号,就是抓手,我们可以基于这个流水号来做幂等。

最常规的方案就是在这个字段上加唯一索引,然后出现重复投递时,落库的时候会抛出主键冲突的异常。

不要觉得重复投递是一个小概率事件,就不上心了。我们敲代码的,不就是要多考虑这些正常流程之外的“小概率事件”吗,只写正常流程,谁都会写。

根据官方的说法,消息重复会发生在这些场景中:

  • 发送时消息重复。当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。
  • 投递时消息重复消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。为了保证消息至少被消费一次,消息队列的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也会收到相同的消息。
  • 负载均衡时消息重复(包括但不限于网络抖动、Broker 重启以及消费者应用重启)。当消息队列的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。

搞搞具体方案

还是顺着前面扣款的例子说。

收到消息之后,我们第一步一般来说是保存信息到数据库。

save(扣款信息);

现在我们要做幂等,已经找到了扣款唯一流水号这个抓手,那我们的代码应该怎么写呢?

扣款信息 = select(扣款唯一流水号);
if(扣款信息 == null){
    save(扣款信息);
}

先查询,再判断,最后保存。

这个方案,在一般的情况下,能达到幂等的效果。

但是,由于是三步,在并发场景下,立马就扛不住了。

而且,消息重复投递的场景,本来就是在极短的时间内产生的两条信息。

所以,上面这个方案会出现什么场景呢?

两个请求,在 select(扣款唯一流水号) 的时候都没有查询到数据,击穿了校验逻辑,然后两个请求就都会去落库。

这个时候怎么办呢?

很简单,前面说了,在扣款唯一流水号上加唯一索引,即使两个请求都去落库,但是由于有唯一索引,一定只会落一笔数据到数据库。

另外一个怎么办?

抛出唯一索引冲突的异常,在程序里面通过捕获这个异常来控制流程上的后续运转。

这个方案,很常见,很常用,实话实说我们用的就是这个方案。

但是既然已经有唯一索引了,那是不是前面的 select 都显得没啥卵用了?

我们要从辩证的角度去看待这个问题。

所以,是,也不是。

是的原因是因为前面这一层 select 相当于过滤层,能在一些非并发的场景下让程序不抛出唯一索引冲突的异常,显得更加优雅。

不是的原因是因为优雅的程度还不够高,毕竟是通过“异常”来控制了程序的走向。

有没有不抛出异常的方案呢?

也有,也很简单,上锁就行了:

扣款信息 = select(扣款唯一流水号);//select *** for update

这样确实能保证不抛出唯一索引冲突的异常,但是关键是一旦涉及到上锁,性能就拉胯了,为了解决这个偶发的问题,牺牲了接口的性能,这个路线就走的有点远了。

所以,上锁也不够优雅。

什么是真正的优雅?

我也不知道,但是我试图去思考一个相对优雅的方案。

思考一波

首先,我觉得上面的方案,不管是唯一索引,还是上锁,不够优雅的原因是因为,它们都是在基于业务表搞事情。

业务表干得事儿,应该就是业务上的事儿。

那我问你:消息重复投递,需要保持幂等,这个属于业务上的事儿吗?

我认为是不属于的,这是属于技术上的事情,任何业务都是可能遇到的。只不过,在前面的方案里面,我们想借用业务表的能力,来帮我们做一个它可以做,但是本来不该它做的事情。

首先,我们必须要在这一点上达成一致,不然后面的论述就不能展开了。

如果你不这样认为,那么你可以不用往下看。

我想到的方案是什么呢?

我相信你听过这样一句话:计算机领域中的所有问题都可以通过增加一个中间层来解决。

所以,我也想着抽一层。

我还是需要数据库通过唯一索引来帮我保证只有一条数据被成功落库,所以我想着抽一个专门的表出来,比如叫做消息消费记录表。

只要数据插入了这个表,就代表消息被消费了。后续即使重发,也不会插入成功。

那么怎么来保证这个机制呢?

前面提到的抓手又可以用上了:业务唯一流水号。

这个消息消费记录表里面最重要的一个字段,可以叫做“消息唯一标识”,并且作为唯一索引。

这里的这个“消息唯一标识”就是对应业务唯一流水号。

如果你要基于这个表来实现消息幂等,那么你必须具备这样的一个业务唯一流水号,当重复的时候,还是会抛出主键冲突异常。

我知道着听起来就像是脱裤子放屁。

但是,你想想,这个表是完全脱离于业务的存在。

在前面的解决方案中,你要问别人,你有没有一张业务表来做这个事情。

在现在的方案中,你会给别人说,我这里有一个解决方案,你只需要执行我给你的 SQL,生成一张消息消费记录表就行。

这张表是完全独立于业务的存在,它只是为了解决消息重复投递这个共性问题。

从你问别人要,到别人按照你说的做,就这么轻轻的抽一小层,攻守易形了啊,朋友。

它是一种通用的解决方案,一种策略,甚至可以叫做一个框架。

现在,我们可以给它取一个新的名字。

比如:一种基于数据库唯一索引实现消息幂等的解决方案。

或者:一种分布式系统中数据唯一性的保障策略。

再或者:一个由数据库约束驱动的消息幂等保护性框架。

好,现在我们有了这么一个“高大上”的通用解决方案了。

到底怎么用呢?

名字很厉害,但是用起来其实也就那么回事儿。

回到前面转账的例子,很简单:

if(保存数据到消息消费记录表){//出现主键冲突就返回false
    save(扣款信息);    
}

这样,消息防重,由消息消费记录表来保证。

业务表,不感知“消息是否重复”的场景。

看起来似乎是优雅了那么一点点。

但是,同时带来了另外一个问题。

又回到了之前“先校验,再保持”的非原子性的逻辑。

我们想想一个极端场景,如果保存数据到消息消费记录表成功,还没来得及 save(扣款信息) ,服务重启了,怎么办?

其实换句话说,这两个信息需要保持一致性。

所以可以加入事务嘛,把这两步绑定到一起:

开启事务;
if(保存数据到消息消费记录表){//出现主键冲突就返回false
    save(扣款信息);    
}
提交事务;

这样,如果保存数据到消息消费记录表成功,还没来得及 save(扣款信息) ,服务重启,事务回滚,消息消费记录表就不会真的插入成功。

而 MQ 没有收到这个消息的回执,也会再次进行投递。

由于消息消费记录表里没有这个数据,所以会再次进行消费。

在上面的这个过程中,MQ 再次投递,是为了 at least once。

而我们引入了消息消费记录表,通过唯一索引来保证不重复消费,这个玩意加上 at least once,在业界有另外一个叫法:
exactly-only。

现在,我们通过引入事务来解决了“非原子性”的问题,但是又带来另外一个问题:事务。

一般来说,大家都是能不使用事务的地方就尽量不使用事务,通过最终一致性来保证数据的完整性。

那现在有没有不基于事务的解决方案呢?

我想到的是可以在消息消费记录表里面再引入一个“状态字段”,这个字段有三个取值:未消费、消费中、消费完成。

通过维护状态的流转,来代替事务的逻辑。

这个思路来源于我实习的时候,给老师做外包项目。当时我是真的不知道 Spring 的事务怎么用,但是我知道结合当时我开发的业务场景,一个数据的状态很重要,处理之前把数据的状态修改了,但是如果出了异常,应该把状态给它恢复回去。

于是我手动写了这样的一坨代码,四处散落在我写的模块里面。

后来一个师兄看了我的代码,提出了应该用事务来保证这样的逻辑,并给我做了演示,我才去了解了事务相关的东西。

但是有一说一,我后来也思考了,在我那个特定的业务场景下,通过状态的流转,确实是可以代替事务的存在的。

好,回到我们现在的这个场景中。

一个消息过来的时候,首先根据唯一消息标识获取对应的数据。

如果没有获取到,就初始化为“未消费”状态落库,然后去执行具体的业务逻辑。在业务逻辑执行之前,把状态修改为“消费中”,然后在执行完成之后,把状态修改为“消费完成”。

如果这个消息被重新投递了,那么根据唯一消息标识就能获取到对应的数据,接着检查这个消息的状态。如果是“消费完成”,直接就丢掉。

但是上面的描述只是描述了最简单的场景,一些复杂场景下状态的流转和判断应该怎么做,我确实还没想好。

所以就当是个课后习题吧,你去推一推,看看用状态流转代替事务的方式是否能成功落地。

学会了记得回来教我。

哎,被这个叫做at least once的玩意坑麻了。的更多相关文章

  1. 避免重复造轮子的UI自动化测试框架开发

    一懒起来就好久没更新文章了,其实懒也还是因为忙,今年上半年的加班赶上了去年一年的加班,加班不息啊,好了吐槽完就写写一直打算继续的自动化开发 目前各种UI测试框架层出不穷,但是万变不离其宗,驱动PC浏览 ...

  2. H5实现摇一摇技术总结

    摇一摇遇到的问题 一.如何对摇晃效果进行反馈 刚开始的处理方式是,摇晃过程中不做任何处理,但后来反馈说这种效果不好,好像就没有摇动一样,如果声音也不响的话,就真的和什么都没发生一样. 后来想了想,加入 ...

  3. ABP入门系列(1)——学习Abp框架之实操演练

    作为.Net工地搬砖长工一名,一直致力于挖坑(Bug)填坑(Debug),但技术却不见长进.也曾热情于新技术的学习,憧憬过成为技术大拿.从前端到后端,从bootstrap到javascript,从py ...

  4. 关于CryptoJS中md5加密以及aes加密的随笔

    最近项目中用到了各种加密,其中就包括从没有接触过得aes加密,因此从网上各种查,官方的一种说法: 高级加密标准(英语:Advanced Encryption Standard,缩写:AES),在密码学 ...

  5. 如何一步一步用DDD设计一个电商网站(九)—— 小心陷入值对象持久化的坑

    阅读目录 前言 场景1的思考 场景2的思考 避坑方式 实践 结语 一.前言 在上一篇中(如何一步一步用DDD设计一个电商网站(八)—— 会员价的集成),有一行注释的代码: public interfa ...

  6. 如何一步一步用DDD设计一个电商网站(八)—— 会员价的集成

    阅读目录 前言 建模 实现 结语 一.前言 前面几篇已经实现了一个基本的购买+售价计算的过程,这次再让售价丰满一些,增加一个会员价的概念.会员价在现在的主流电商中,是一个不大常见的模式,其带来的问题是 ...

  7. 前端极易被误导的css选择器权重计算及css内联样式的妙用技巧

    记得大学时候,专业课的网页设计书籍里面讲过css选择器权重的计算:id是100,class是10,html标签是5等等,然后全部加起来的和进行比较... 我只想说:真是误人子弟,害人不浅! 最近,在前 ...

  8. NodeJs在Linux下使用的各种问题

    环境:ubuntu16.04 ubuntu中安装NodeJs 通过apt-get命令安装后发现只能使用nodejs,而没有node命令 如果想避免这种情况请看下面连接的这种安装方式: 拓展见:Linu ...

  9. 异步任务队列Celery在Django中的使用

    前段时间在Django Web平台开发中,碰到一些请求执行的任务时间较长(几分钟),为了加快用户的响应时间,因此决定采用异步任务的方式在后台执行这些任务.在同事的指引下接触了Celery这个异步任务队 ...

  10. 用dubbo时遇到的一个序列化的坑

    首先,这是标题党,问题并不是出现在序列化上,这是报错的一部分: Caused by: com.alibaba.dubbo.remoting.RemotingException: Failed to s ...

随机推荐

  1. KubeDL 0.4.0 - Kubernetes AI 模型版本管理与追踪

    ​简介:欢迎更多的用户试用 KubeDL,并向我们提出宝贵的意见,也期待有更多的开发者关注以及参与 KubeDL 社区的建设! 作者:陈裘凯( 求索) 前言 KubeDL 是阿里开源的基于 Kuber ...

  2. [Go] 选择 Beego 的三个理由

    1. 项目支持角度较其它框架考虑的多一些,比如:目录结构的简单约定,内置项目配置读取,内置bee脚手架,热重载特性 等. (实际这些 feature 都可以找到 golang 专精的组件引入起来,效果 ...

  3. 学习 Avalonia 框架笔记 如何创建一个全屏置顶的 X11 应用窗口

    本文记录我从 Avalonia 框架里面学到如何创建一个全屏置顶的 X11 应用窗口的方法 开始之前,先从 Avalonia 或 CPF 里面拷贝足够的代码,这部分代码可以从本文末尾找到下载方法 设置 ...

  4. WPF 开源二维绘画小工具 GeometryToolDemo 项目

    这是一个演示 WPF 进行二维绘画的小工具 Demo 项目,基于 MIT 协议在 GitHub 上完全开源 源作者是 YuWeiCong 我只是帮助开源的工具人 软件运行界面效果: 开源地址: htt ...

  5. Ubuntu 通过本机代理修复 NuGet 还原 error NU1301 失败

    在国内垃圾的网络环境下,我在虚拟机里面安装了 Ubuntu 系统,准备用来测试 MAUI 在 Linux 上的行为,然而使用 dotnet restore 构建时,提示 NU1301 失败.我通过配置 ...

  6. 【经典爬虫案例】用Python爬取微博热搜榜!

    目录 一.爬取目标 二.编写爬虫代码 2.1 前戏 2.2 获取cookie 2.3 请求页面 2.4 解析页面 2.5 转换热搜类别 2.6 保存结果 2.7 查看结果数据 三.获取完整源码 一.爬 ...

  7. gin-vue-admin开发教程 02 了解项目目录结构和代码执行的流程

    学习目标: 介绍项目 项目目录(2.0) 后台的启动过程 前台的启动过程 前后台交互的流程 视频学习地址: https://www.bilibili.com/video/BV1Rz4y1X7Zr (海 ...

  8. WebKit Inside: CSS 样式表的解析

    CSS 全称为层叠样式表(Cascading Style Sheet),用来定义 HTML 文件最终显示的外观. 为了理解 CSS 的加载与解析,需要对 CSS 样式表的组成,尤其是 CSS Sele ...

  9. three.js教程7-PBR材质与环境贴图CubeTextureLoader

    1.PBR材质 PBR是基于物理的渲染(physically-based rendering).模拟物体表面的反射算法. Three.js提供了两个PBR材质相关的类MeshStandardMater ...

  10. toLua中Lua调用C#中的类

    toLua中Lua调用C#: [7]Lua脚本调用C#中的class 准备工作:打算在Lua脚本中使用Debug,使用lua调用C#脚本,需要绑定LuaState和自定义添加Debug --- --- ...