0%

幂等设计1 来看看错误的幂等

本着先理论再实践的指导思想,这篇先从理论上搞定幂等。

1.当我们谈幂等时我们谈些什么

我们先看看概念,其实不看也没关系,理解了之后自己就能总结出来。

幂等操作的特点:任意多次执行所产生的影响均与一次执行的影响相同。

1.1 误人子弟的SQL幂等

我们先拿一个网上随处可见的示例。

  • SQL1:update goods set num = num+10 where id = 1
  • SQL2:update goods set num = 10 where id = 1

SQL1每次执行都会+10,SQL2无论执行多少次最终都是10。

然后告诉大家这SQL2操作就是幂等。

说它误人子弟,不是说它错,只是:生产环境怎么可能只有这么简单的场景?

实际业务要比这个SQL复杂许多,我们需要的,也不只是一个幂等操作,而是一个幂等设计方案

所以我们讨论幂等,实际上讨论的是如何实现业务上的幂等设计一个幂等操作一套业务幂等设计之间的距离,还是差那么一点点点点点点点点的

1.2 表单重复提交

这应该是最简单的需要幂等设计的实际业务场景了,很难找到比这更简单的了。简单到我们就在这里提一下,后面就不会再提到了。

场景如下:用户发布一篇文章,点击发布按钮,loading半天,最后超时了……

那这算成功还是失败?要不要点第二次发布?

事实上一般人不仅会点第二次,而且是在loading的时候已经忍不住点无数次了。

如果最终发布出去好几份一毛一样的文章,很难要求用户不问候开发者全家。

面对这种场景,我们常做的是服务端生成一个唯一标识并下发,客户端请求的时候要带上这个唯一标识,如果服务端检查这个标识已经消耗过了,那说明这是重复提交,可以忽略。

能不能客户端每次请求随机生成一个唯一标识呢?

不太建议。

因为这种唯一标识无关业务,一般是随机生成。那么发起方可以很方便的构造/伪造,服务端无法起到拦截的作用。

1.3 业务重复处理

这类场景就有那么点意思了。接下来,甚至后面的几篇主要也是围绕这类场景来说幂等。

场景1:用户完成任务,恶意调用领奖接口,调用了一次又一次,怎么保证奖励不重发?

场景2:消费队列一般会有失败重试的机制。但是如果消费超时了,那这算成功吗?能重试吗?

场景3:调用接口给用户加经验,如果接口超时了,能重试吗?

问我哪来的那么多超时?比如FullGC了,比如网络拥塞了,比如突然服务挂掉了。

1.4 幂等设计解决重试问题

所以谈论幂等设计,其实是谈论如何应对重复操作

而我们做幂等的,就是不用你考虑是不是成功,请敞开了重试,最后肯定保证跟 “一次就成功” 的效果一样。

这才叫专业。

2. 幂等设计的核心

这时候再看幂等概念就能感觉其大有玄机了:“任意多次执行所产生的影响均与一次执行的影响相同”。

应用到业务上的幂等设计就是:一个事务无论重复触发多少次,只能被处理1次

往细节里分析:

  • 首先得能知道这个事务有没有被处理过。那就需要事务有一个唯一标识,还要为这个事务记录一个状态

  • 其次事务的处理和状态必须保证强一致性,不能出现处理成功但是状态没修改的情况。

归纳总结,幂等设计核心在于:

  1. 能唯一确定某个事务的标识
  2. 记录其被处理的状态
  3. 状态和处理必须是原子操作,要么都成功,要么都不成功

第3点原子操作尤为重要,这是实现幂等的关键。后面会在常见错误中展示为什么原子操作尤为重要。

2.1 思路延伸

  1. 在做幂等设计时须注意到:程序可能在任何情况,发生任何异常。如:断网、硬件故障、下游服务突发不可用。我们的设计要能保证在各种异常情况下的健壮性。
  2. 如果A操作是幂等的,B操作也是幂等的,那么一个业务里只包含A操作和B操作的话,该业务也是幂等的。

3. 对幂等的常见误解

幂等的理论略有抽象,因此需要结合具体事例

3.1 分布式锁就是幂等

分布式锁是用来处理并发情况的,最多算幂等设计的一部分。

有时候甚至连一部分都算不上,因为幂等设计可以无锁实现,所以分布式锁并不是幂等

如果尝试用分布式锁实现幂等,一般预想的操作步骤如下:

  1. 尝试获取锁。取不到则退出。
  2. 检查事务是否被处理
  3. 处理事务
  4. 修改事务处理状态
  5. 成功或出现异常后解锁

问题:处理事务和状态更新的操作完全没有原子性。

如果步骤3成功但是步骤4失败,即处理事务后系统故障,没能将状态写入,等解锁后调用方重试,必然会导致事务被处理两次。

不甘的反抗1:有人会想将步骤3和步骤4互换顺序。但是如果设置了状态成功,但是处理事务失败……

不甘的反抗2:有人希望故障可以try-catch住后解锁,但如果遇到了硬件问题如网络故障,try-catch也是无能为力的……

可见仅仅依赖分布式锁,无论怎么换,只要两个步骤中间出问题,都保证不了幂等。

3.2 异步就能保证幂等

有人会考虑将待处理添加到消息队列。

考虑队列,大多是如下两个出发点:

  1. 避免并发
  2. 失败重试

对于出发点1,队列的消费一般是并发的,不能想当然地假设一个队列只有一个消费端。如果同样的事务入队两次,那并发的场景是避免不了的。

假设我们有办法避免了重复入队,那么就遇上了第2点:失败重试。

考虑第2点是先要考虑:如何界定消息消费失败

以Redis增加计数的操作为例:如果incr操作成功,但是因为网络原因没收到成功响应,这时应该会有异常。

一旦算作消费失败,就会重新消费,进而导致重复计数

所以仅依赖异步队列,依然无法实现幂等。

可见即便用异步队列,还是要面对幂等的灵魂拷问:是否能保证同样的事务只被正确的处理一次?

可见异步处理和同步处理在幂等性问题面前本质没有区别,如果同步无法实现幂等,异步也无法实现。

3.3 Redis很快,能保证幂等

对于只用Redis的操作,有人会觉得

Redis操作很快,我是纯Redis操作,记录个状态到Redis,然后处理逻辑。这么短的时间不可能出问题,就算出问题我也能try-catch然后回滚

当牢记“原子操作”这个关键点后,这种方案一看就很不可行。

Redis再快,也快不过光纤说断就断。挖掘机总是在极为巧妙的时候铲下。

问题不在于快慢,而是在于支不支持重复处理。

只要不是原子操作,无论是先发奖再记录状态,还是先记录状态再发奖,中间就可能出事故。

3.4 Redis 的事务能保证幂等

一听事务就想到了ACID,打头的就是原子性。但是Redis的事务还真不一定能保证原子性。无论是pipeline还是multi。

尤其是multi,有的人会觉得,“pipeline支持了批量发送指令,那后者长得不一样肯定是事务,既然是事务肯定是原子的”。

其实multi也是多个的意思。该指令执行有三个阶段:开启事务、入队、提交/取消事务。

用户执行exec时全部执行,如果遇到错误,就都不执行。

然而这个错误只是Redis能检查出来的错误(如命令不存在/除0/参数不对),这些错误在Redis真正执行前会检查出来,所以所有指令都不会被执行。

如果是诸如 “对hash类型的key进行zAdd操作” 此类操作是无法检查出来的,执行到这里会报错,但是后面的还是会执行

所以这个并不一定能实现我们要的幂等。

而且Redis的事务是批量执行,最后一次性批量返回结果,并不像MySQL那样哪怕开启了事务也能实时响应。

3.5 啥是个唯一标识啊

幂等一定要有一个能唯一确定某个事务的标识。那如何获取事务的唯一标识?

有人一听到唯一标识,立刻想到了随机数、毫秒时间戳、uuid、雪花算法:“雪花算法生成一个uuid来当做这个事务的id”。

但大多数情况下这种随机的唯一并不是幂等想要的唯一:幂等的唯一是指能唯一标识这个事务

假设用户抽奖,每次都随机生成一个流水号,那么根本无法确定用户抽奖多少次。

假如用户点击按钮领奖时,每次请求过来都生成一个 uuid,的确是唯一了,但是并没有任何作用。

这里的唯一,是指能唯一确定一个事务的几个元素,并不是真的需要一个不冲突的标识。

3.6 银样镴枪头的数据库分布式事务

提到原子性和分布式,有人会联想到数据库的分布式事务。

并不是要说分布式事务不能解决,只是往往想到分布式事务的人并不了解数据库的分布式事务如何实现,往往只是对两阶段提交、三阶段提交有个理论印象,并未实操落地。

MySQL5.7开始对分布式事务就有了支持。但是生产环境我们是没用过的。因为实际上数据库分布式事务太重了。

在注重并发的互联网业务里,单机事务都算略重,我们都尽量鼓励无锁无事务设计。一旦开启数据库的分布式事务,考虑事务隔离、投票、提交回滚,这绝对是一笔不菲的开支。

这时候我们可以用幂等设计,来实现分布式事务的思想。

以上总结了幂等是什么和幂等的常见误区,下一篇要结合场景写一下幂等的实践。