2 幂等性
在消费端,让消费消息的操作具备幂等性,对幂等方法,就无需担心重复执行的副作用。即消费多次等于消费一次。
对系统影响结果:At least once + 幂等消费 = Exactly once。
2.1 案例
不考虑并发:
- “将账户X余额设为100元”,执行一次后对系统的影响是,账户X的余额变成了100元。只要提供参数100元不变,执行多少次,账户X余额始终100,这就是幂等操作
- “将账户X余额加100元”,这操作就不是幂等,每执行次,账户余额增加100,执行多次和执行一次对系统的影响(即账户余额)不同
3 幂等实现方案
3.1 DB唯一约束
比如对于:将账户X余额加100。
可限制对每个转账单,每个账户只能执行一次变更操作。最简单的,在DB中建一张【转账流水表】:
- 转账单ID
- 账户ID
- 变更金额
然后给【转账单ID,账户ID】联合起来创建唯一约束,这样相同转账单ID、账户ID,表里至多只存在一条记录。
消费消息逻辑可变为:“在【转账流水表】增加一条转账记录,再根据转账记录,异步更新用户余额。” 在转账流水表加条转账记录操作中,由于【转账单ID,账户ID】唯一约束,对同一转账单,同一账户只能插一条记录,后续重复插入操作都会失败,这就实现了幂等。
所以,只要是支持类似“INSERT IF NOT EXIST”语义的存储系统都可实现幂等。 比如,可用
3.2 Redis.SETNX
替代DB的唯一约束,实现幂等消费。
该方案需Consumer基于消息类型,去感知此消息类型所要处理的业务,在业务上的唯一约束,不同业务的唯一约束不一样,对消费者实现幂等不友好。
但解决不了主动的重试问题,如插入流水,执行业务,返回MQ逻辑错误,触发重新消费,这时发现流水已存在。所以这里插流水和业务逻辑也得在一个事务里,和方法一按区别看来只是怎么去控制唯一性。只要流水正确写入了,后续根据流水计算余额的业务逻辑可不与写入流水在同一个事务,即使计算余额失败,也能根据流水重新计算。
3.2 为更新的数据设前置条件(类似CAS)
给数据变更设置一个前置条件:
-
满足条件就更新数据
-
否则拒绝更新数据
更新数据时,同时变更前置条件中需要判断的数据。于是,重复执行该操作时,由于第一次更新数据时,已变更前置条件中的判断数据,不满足前置条件,则不会再执行更新。
“将账户X的余额增加100元”,这操作加个前置条件,变为:“若账户X当前余额为500元,将余额加100元”就具备幂等性。对应到MQ消息,在消息体中带上当前余额,消费时判断DB中当前余额==消息中的余额,相等时才执行更新。
但要更新数据不是数值,或要做个复杂的更新操作咋办?前置判断条件是啥呢?
当余额为500时,执行加100,若当前消息被消费前,下一条消息到来时,数据库余额还是500,这时设置更新条件也是500,这种问题怎么解决? 这种场景就得保证消息的严格顺序。
MVCC乐观锁
更通用的,是给数据增加版本号version属性,每次更新数据前,比较
当前数据version == 消息中的version
- 不一致,拒绝更新
- 一致,更新数据同时将版本号+1,一样则可实现幂等更新
如何解决方案一和方案二日益增多的存储日志,有合适的删除策略吗?
这种流水一般不能删除,若数量太多影响查询消息,可考虑按照账户ID来分表存储。
3.3 记录并检查操作
若前两种方案都不适用,还有通用性最强、适用范围最广方案:记录并检查操作,也称“Token机制或GUID(全局唯一ID)机制”,执行数据更新操作前,先检查是否执行过这更新操作。
- 发消息时,给每条消息指定全局唯一ID
- 消费时,先根据ID检查消息是否被消费过,若没有,才更新数据并将消费状态置为已消费
但分布式系统下很难实现:
- 首先,给每个消息指定一个全局唯一ID,不好同时满足简单、高可用和高性能,或多或少都有牺牲
- 更麻烦的,“检查消费状态,然后更新数据并设置消费状态”,三个操作必须作为一组操作,保证原子性,才能真正实现幂等,否则就是Bug
比如对于同一消息:“全局ID为8,操作为:给ID为666账户增加100元”,可能出现这样情况:
- t0时刻:Consumer A 收到条消息,检查消息执行状态,发现消息未处理过,开始执行“账户增加100元”
- t1时刻:Consumer B 收到条消息,检查消息执行状态,发现消息未处理过,因这时刻,Consumer A还未来得及更新消息执行状态
- 这样就导致账户被错误地增加了两次100元,这是一个在分布式系统中非常容易犯的错误
对此,可以用事务实现,也可以锁,但在分布式系统下,分布式事务、分布式锁都会引入高复杂度。所以一般不推荐。
由生产者将不同业务的不同唯一约束(如A业务是a+b字段须唯一,B业务是a+c字段须唯一),统一处理成对消费者友好的全局唯一ID,如A业务是md5(a+b),B业务是md5(a+c),生成全局唯一ID,可以是上面举例的本地md5计算,也可以是包装成服务接口,但其本身也必须幂等,如此Con不管处理什么业务消息,都只需针对"全局唯一ID"保证幂等。
4 总结
这些幂等方案不仅可用于解决重复消息问题,也可解决重复请求或重复调用问题,如:
- HTTP服务设计成幂等的,解决前端或APP重复提交表单数据问题
- 微服务接口设计成幂等,解决RPC框架自动重试导致的重复调用问题
4.1 为何MQ只提供At least once,而非Exactly once
若MQ实现exactly once,会引发:
- 消费端pull时,需检测此消息是否被消费,这检测机制无疑拉低消息消费速度。随消息剧增,消费性能势必急剧下降,导致消息积压
- 检查机制还需业务端去配合实现,若一条消息长时间未返回ack,MQ需要去回调看下消费结果(类似事务消息的回查机制)。这就增加业务端的压力与未知因素。
- 为了确保消息没有被丢失或者重复,队列需采取一定的类似回查的手段,检测消费者是否有收到消息进行处理,在一定程度上会导致队列堆积等一系列问题,并且队列实现的复杂度上升
- 从消费者的角度而言,因为消费者端和Broker Service端都是会各自集群,消费者端可能会存在网络抖动,导致Broker Service为了确保消息不丢失和重复,需要一直进行回查类似的操作,但是由于网络问题,导致队列堆积
exactly once实现有性能损耗,并发高时易出现消息堆积;消息队列设计初衷是解决解耦,而解耦的对象往往是高并发,对性能要求较高的:
- 从产品需求层面讲,MQ设计更注重性能,而非精准(exactly once)
- 基础架构角度来说,关注点是占比大的需求(不能不发,但可以重发),占比极小的需求(敏感型,只能触发一次)可单独抽出来另外实现
所以,MQ不实现exactly once,而是at least once + 幂等性,而幂等性我们消费端业务代码自己处理。
MQ即使做到Exactly once级别,Con也要做幂等。因为Con从MQ取消息时,若Con消费成功,但ack失败,Con还是会取到重复消息,所以MQ费力做成Exactly once无法避免业务侧消息重复问题。
若队列实现At least once,为不丢消息,Broker Service会重试,但不可能一直重试,若就是一直重试还是失败咋办?
rabbitmq有个特殊队列保存这些总是消费失败的“坏消息”,然后继续消费之后的消息,避免这些坏消息卡死队列。这种坏消息一般不是因为网络原因或消费者宕机导致,大多都是因为消息数据本身有问题,消费者的业务逻辑无法处理。
只支持At least once和以下几种情况相关: 1.硬件异常或者系统异常导致的数据丢失:MQ为何不做成像DB一样用undo log和redo log避免硬件这种异常,出于性能考虑。
为何网络协议中一样TCP和UDP的区别:消息反馈可能不是每一个反馈一次,有时是一批反馈异常,传输中可能会出现丢包或者顺序不一致。 大部分MQ都是批量收发,但采用基于位置的确认机制,可保证顺序。
kafka就算用事务,也不能保证没有重复消费,它有可能发生rebalance时,消费了数据没有提交。
关于幂等的情况,像设置帐户余额为100元,或者给余额为500的加100,如果有中间状态的变更或者ABA问题,也能算是幂等操作吗? 确实这个例子解决不了ABA问题,如果要解决这个问题,只能使用版本号方式。
因为目前MQ在发送消息给客户端时,一般需客户端ack之后才能确定,这条消息是不是真的被消费:
-
若客户端设置自动ack,则MQ就能保证只发送一次,但这样会因为客户端消费消息不成功,导致消息丢失
-
若客户端设置手动ack,若MQ发消息给客户端成功,客户端也消费完成,就在准备ack时,和MQ失去联系,这时MQ不知道这条消息是否真的被消费,只能选择重发消息
所以若MQ保证了只发一次,则MQ就无法保证消息由于客户端消费失败而不丢失,就好像分布式系统中的CAP,只能保证其中两种。
“如果账户 X 当前的余额为 500 元,将余额加 100 元"和“检查消息执行状态,发现消息未处理过,开始执行账户增加 100”,这两者有啥区别,不都是消费端compareAndUpdate吗,都可以用普通数据库事务就能实现。 主要是检查的内容不一样:
- 前者检查余额,容易实现,但适用范围比较窄
- 后者检查消息执行状态,难实现,但适用范围更广泛