幂等最佳实践

1. 为什么需要幂等

分布式场景下,多个业务系统间实现强一致的协议是极其困难的。一个最简单和可实现的假设就是保证最终一致性,这要求服务端在处理一个重复的请求时需要给出相同的回应,同时不会对持久化数据产生副作用(即多次操作与单次操作的结果需要是业务角度一致的)。

一个API拥有幂等能力的话,调用发起方就可以很安全的进行重试。这符合我们普遍的假设。

提供幂等能力是服务提供方需要做的事,所以本文是站在服务提供者的角度来写的,即下文的“我们”通常指的是服务提供方。

2. 怎么做幂等判断

那么我们怎么对一次请求做出幂等的判断呢?首先我们需要有方法来区分什么是“一次”请求,其次,针对API实现逻辑中持久化数据方式的不同,还需要不同的判断方法。

2.1 请求唯一标识

通常我们的API需要增加bizId这样的能够标识请求唯一性的属性,调用发起方使用它来标识一次业务上认为的独特的业务需求。一定要注意它是“业务唯一性标识”,而不是技术唯一性标识,这两者是有本质区别的。

事实1:不过大多数情况下,调用发起方可能并不太知道应该如何生成一个有效的请求唯一标识,这需要我们在业务对接的过程中有更多的交流。

2.2 需要幂等多久

能够幂等代表我们肯定做了某些数据的持久化,但是任何数据都不可能永久存在,都会要求有一个有效期。

对于大多数的请求,建议幂等的有效期是三个月。一些特殊的场景甚至可以是一年。但是几乎不需要更久了。

有效期是一个明确的契约,这代表我们可以定期对持久化数据做一些数据治理的工作,同时,超过有效期的请求基本上会幂等失败,那么它的后果就是“同样的事,在几个月后又做了一次”。

2.3 写入型

如果我们的业务逻辑是在持久化存储中写入什么,那么最好的做法是增加一个unique key,它通常由 user_id, 操作类型, biz_id组成。操作类型是我们系统设计上自行定义的业务操作类型,加上它是为了避免不同的操作类型之间互相干扰。加上user_id是因为通常情况下我们都会分库分表。

针对既会写入,还会更新数据的场景,需要把插入放到前面,不然幂等很可能无法工作。比如如下的库存操作的场景,如果两条SQL倒过来写,在库存售磬的场景下就无法幂等了:

insert 库存扣减流水;
update 库存 set 库存可用数=库存可用数-1 where 库存id=? and 库存可用数>0;
在unique key冲突时,我们需要捕获它,这大概率是幂等了。为什么说是大概率而不是一定呢?这是因为前述提前的“事实1”。调用方很可能错误的重用了请求唯一号,并且可能在重试时就一些核心的参数进行了改动。

所以,我们需要在发生unique key冲突时做如下的事情:

  • 根据唯一键,将之前写入的数据查询出来。
  • 进行关键信息的核对。比如我们提供的API是发放优惠券,那么我们需要至少校验这些信息:接收用户Id,券模板,券面额,有效期等。在关键信息不一致时,返回类似于“DUPLICATE_BUT_DIFFERENT_REQUEST”的错误码;在校验通过时,返回发送成功的券Id。这非常重要,不进行关键信息的校验就返回幂等成功是大多数场景下故障的根源。
    2.4 更新型
    更新型最大的挑战在于我们没有地方来存储对于一条数据的多次更新行为,所以大多数情况下需要使用状态机来推测某个更新行为是否发生过了,更复杂的情况可能需要我们增加专用的幂等表。
2.4.1 使用状态机

业务上的数据处理大多都是有状态的,比如交易订单。这个时候首选的方法是通过状态机的序列来判断某个更新行为是否已经做过了。不过这并不简单,我们需要非常小心的去看业务上的各种规则和限制,同时,除了更新状态之外,大多数情况下还会伴随着更新一些别的附加信息,我们还需要去检查这些附加信息是否如请求要求的那样更新过了。

2.4.2 增加幂等表

通过增加幂等表,就把更新型转化为了2.3节描述的写入型。不过需要注意幂等表需要和当前更新的表在同一个事务中,不然就是无效的。

幂等表需要增加表明数据写入/更新时间的字段,这便于我们定期进行过期数据的清理。

2.5 删除型

建议在存储上使用逻辑删除而不是物理删除,这样我们就有办法判断删除操作是否已经做过了。删除往往意味着数据进行终态,所以在幂等上也是最好处理的;如果业务的设计上删除不是数据的终态,那么就需要相当小心,因为这违反了通用的设计原则。

3. 总结

幂等可以说是分布式应用的基石,如你所见,实现它并不像大多数人一开始想像的那么简单。做好它需要我们站在业务语义的角度来设计与思考。