幂等操作看起来简单,其实有很多需要特别注意的地方。

我们先看看这样一个问题。

如果你去食堂吃饭。你冲食堂窗口里喊了一声:“师傅,俩包子!”等了半天,你还没见着你的包子。于是,你又冲食堂窗口里喊了一声:“师傅,俩包子!”

这时候,你希望食堂师傅怎么做?

  • A:给你俩包子。
  • B:给你四个包子。
  • C:答应一声“来了”,但什么也不给你。
  • D:端来一碗粥,扣在你脸上。

正常人应该都会选A。这就是幂等。

(说选B的那个,下回我说完两次“你欠我10块钱”后,请你记得还我20。)

什么是幂等

幂等的各种数学定义我们就不讨论了。一般来说,我们都会这样定义幂等:

幂等是指:在其它条件不变的情况下,用同样的数据、执行同样的方法,执行一次和执行一万次,都能得到同样的结果。

例如,在没有人修改数据库数据的情况下执行查询操作,用同样的SQL查询一次和查询一万次,都应该能得到同样的数据。

几个注意点

发生异常时,要不要幂等?

我们来看这样的场景:如果执行某方法时,方法内部抛出了异常;然后,我们修复了方法内部的问题;此时,调用方再来调用,如果按幂等的要求,我们难道要再给调用方抛出一个异常、或者返回对应的错误码吗?

这个问题,除了考虑幂等之外,我们还需要考虑一点:业务操作的事务性,即业务事务。

业务事务与数据库事务类似,同样应当有原子性、一致性、隔离性和持久性。因为这个缘故,我们常常会把一个业务事务绑定到一个数据库事务上:只要数据库事务提交成功了,这个业务事务就成功了。

不过,业务事务的范畴比数据库事务要更大一些:它不仅包括这个业务操作中的数据库操作,还可能包括三方接口调用、MQ消息发送、缓存写操作等其它操作。当然,要保证所有操作、尤其是分布式操作的事务性四要素,在具体实现上会变得非常复杂。但是,我们至少在设计层面上、至少在数据一致性上,要保证整个业务操作的事务性。

那么,从业务事务的数据一致性角度来考虑,当一个业务操作的中途抛出异常的时候,我们应当怎么办呢?

显然,我们应当回滚当前业务事务,撤销异常前的所有操作结果。否则的话,异常前半部分操作成功,写入了部分数据;异常后半部分没有执行,没有写入数据。此时,就会出现数据不一致问题。

回滚完成后,对我们的系统来说,这个请求的执行次数是0次。此时,调用方再用同样的数据来调用我们——虽然对调用方来说,这是第二次请求;但是对我们来说,从业务事务和幂等的角度来说,这就是第一次调用。

既然是第一次请求,那就直接放进来,正常操作就好了。

幂等的依据是什么?

幂等有三个条件:其它条件不变、同样的数据、同样的方法。这三个条件中,“同样的数据”是最难判断的:调用方传过来一个a=1,我们怎么知道这是一笔新的业务操作、还是某次操作的重放呢?

请求幂等:用流水号

在很多接口的请求报文中,我们都定义了“请求流水号”这样一个字段。大多数时候,我们就直接用这个字段来做幂等依据。

这种处理方式确实简单易行。但它其实是有隐患的:即使两次请求的业务数据完全不同,但只要流水号相同,那第二次请求就是幂等请求;即使两次请求的业务数据完全相同,但只要流水号不同,那第二次请求就是一次新的请求。

这个问题的直接原因是我们把重要的业务约束交给了很可能不可控、不可靠的调用方。根子上来说,这个问题的原因在于这个“请求流水号”是一个业务无关的“唯一键”:它并不能真正用来唯一的标识一笔“业务数据”。

业务幂等:用业务唯一键

所以,比较严谨的幂等方式是找出请求数据中的业务唯一键,以业务唯一键为依据来做幂等。有时候唯一键需要组合太多字段,做唯一判断时不好处理,我们也可以把这些字段拼接起来、计算一次MD5:这个MD5,虽然无法回溯到业务数据上,但它的确是一个业务相关的键值。

不过,这种业务唯一键确实不好找,有的时候业务逻辑确实没有唯一性:我确实可以在吃完俩包子后,又去食堂窗口喊一次“师傅,俩包子”;甚至吃完这俩之后还可以再来一次“师傅,俩包子”“。而在这种情况下,师傅确实应该给我总共六个包子。

这种情况下就没什么办法了,虽然不是最佳方案,但使用接口传入的流水号、唯一键等特殊字段作为幂等依据总归也是一种方案。只不过这种时候,我们必须严格要求调用方按约束传值。

幂等时只需要返回返回码吗?

大多数接口的返回报文中,都有code和msg字段,用来标志本次操作的状态。对这种接口来说,幂等时显然只返回code和msg就够了。

需要注意的是,这里的code和msg应当和第一次正常请求时的一样。如果正常请求时响应code=0,幂等请求时响应code=1,那这两次请求得到的就不是“同样的结果”了——就跟你冲食堂喊两次“师傅,俩包子”之后,师傅先给你俩包子、再往你脸上扣一碗粥是一回事儿。

但是,也有很多接口,除了返回code和msg之外,还需要返回业务数据。对这种接口来说,仅仅返回code显然就不够了——否则,不就等于是你冲食堂窗口喊“师傅,俩包子”,而师傅只答应“来了”却不给你包子吗?

无论哪种情况,核心都是要保证幂等请求和正常请求返回同样的结果。

幂等判断需要控制并发吗?

大多数情况下,我们都需要通过根据请求唯一键查询某个数据来判断幂等:如果已有数据,说明是幂等请求;如果没有数据,说明不是幂等请求。

这个逻辑基本可行,除了发生并发请求时:此时,对并发进来的两个请求来说,用请求中的唯一键都查不到数据,因而他们都会被判定为幂等请求。这样一来,就等同于一笔请求执行了两次,也就宣告了幂等判断失败了。

所以,如果用这种查询的方式来判断幂等,请一定要注意控制并发。

其它

还有什么问题,大家可以一并提出来讨论。

常用实践

利用分布式锁+缓存做幂等

针对请求中的唯一键,先加分布式锁,后判断缓存中是否有值。如果没有值,则执行正常请求,并把正常返回结果(无论成功还是失败)存入缓存;如果有值,则直接返回缓存结果。

这种方式最高效、最简单。但是,这种方式有两个问题。

首先,如果缓存失效了,那么幂等判断也就失效了。当然,这种情况比较可控、概率较小。

第二,如果“其它条件不变”这个前提被打破了,缓存数据往往就会“过时”。此时,幂等结果应当返回“其它条件不变”之前的结果,还是返回之后的结果?这是一个值得认真考虑的问题。

例如,query方法天然幂等;但这是建立在数据库数据没有被更新的前提之下的。如果query操作有缓存,而且数据库被更新后没有即使更新缓存,那么query操作应该返回缓存里的结果?还是应该返回数据库中的实际结果?

同理,如果操作A上有缓存,它的幂等建立在其基础数据没有变更的前,操作B会更新操作A的基础数据、但不会更新操作A上的缓存;此时如果做过操作A之后、再做一次操作B,然后再做操作A,操作A应该返回缓存中没有被操作B修改过的结果、还是返回数据库中被操作B修改过的结果?

利用分布式锁+数据库做幂等

与缓存相比,数据库是更可靠的持久化工具。并且,绝大多数业务操作最终都要把结果存入数据库中。因此,使用数据库来判断幂等,也比使用缓存更加可靠一些。

只不过在这种情况下,查到数据库中有数据、并判定为幂等之后,往往还需要我们手动把库中的业务数据再组装为接口返回结果。而且,这种方法会带来额外的一次数据库查询操作,如果接口压力太大,对数据库性能的影响也不可小觑。

利用数据库唯一键做幂等

数据库的正常增删改查操作中,只有增是天然不幂等的(“累计型”的update不算“正常”改操作)。但是,我们可以通过为数据库表增加唯一索引来把它转化为幂等操作。

不过,使用这种方式时,我们需要捕获数据库抛出的唯一键冲突异常,并把这个异常处理为幂等结果——有时也需要再组装一次返回结果。

这种方式不需要做分布式锁处理,而且比第二种方式少一次数据库操作。不过也需要手动转一次结果,另外对数据库的性能也友好不到哪儿去——唯一索引也会拖累insert的性能。

其它

如果还有其它方式,不妨提出来一起讨论下。