如何实现API接口的幂等性
幂等性
什么是幂等性
在实际的开发项目中,一个对外暴露的接口往往会面临很多次请求。这就需要考虑到一个幂等性问题。
幂等的数学概念
幂等是源于一种数学概念。其主要有两个定义
如果在一元运算中,x 为某集合中的任意数,如果满足 f(x) = f(f(x)) ,那么该 f 运算具有幂等性,比如绝对值运算 abs(a) = abs(abs(a)) 就是幂等性函数。
如果在二元运算中,x 为某集合中的任意数,如果满足 f(x,x) = x,前提是 f 运算的两个参数均为 x,那么我们称 f 运算也有幂等性,比如求大值函数 max(x,x) = x 就是幂等性函数。
幂等性在开发中的概念
在数学中幂等的概念或许比较抽象,但是在开发中幂等性是极为重要的。简单来说,对于同一个系统,在同样条件下,一次请求和重复多次请求对资源的影响是一致的,就称该操作为幂等的。比如说如果有一个接口是幂等的,当传入相同条件时,其效果必须是相同的。
特别是对于现在分布式系统下的 RPC 或者 Restful 接口互相调用的情况下,很容易出现由于网络错误等等各种原因导致调用的时候出现异常而需要重试,这时候就必须保证接口的幂等性,否则重试的结果将与第一次调用的结果不同,如果有个接口的调用链 A->B->C->D->E,在 D->E 这一步发生异常重试后返回了错误的结果,A,B,C也会受到影响,这将会是灾难性的。
在生活中常见的一些要求幂等性的例子:
- 博客系统同一个用户对同一个文章点赞,只能给这个文章 +1 赞
- 在微信支付的时候,一笔订单应当只能扣一次钱,那么无论是网络问题或者bug等而重新付款,都只应该扣一次钱
幂等性与并发安全
许多文章把幂等性和并发安全的问题混淆了。幂等性是系统接口对外的一种承诺,而不是实现,承诺多次相同的操作的结果都会是一样的。而并发安全问题是当多个线程同时对同一个资源操作时,由于操作顺序等原因导致结果不正确。
这两个实际上是完全独立的两个问题,比如说同一笔订单即使你不停的提交支付,如果扣除了多次钱,就说明该操作不幂等。而有多笔订单同时进行支付,最后扣除金额不是这多笔金额的总和,那么说明该操作有并发安全问题。所以幂等性和并发安全是完全两个维度的问题,要分开讨论解决。
在一些讨论幂等性的文章中看到中给出的解决方案为‘悲观锁’和‘乐观锁’,这两个方案可以很好的解决并发问题,但是却不应该是幂等性问题的解决方案,特别是悲观锁是用于防止多个线程同时修改一个资源的。倒是乐观锁的版本号机制可以勉强以 token
或者状态标识
作为版本号来实现幂等性(下文解释token
和状态标识
),勉强说的过去。
总结,幂等性的概念是:任意多次执行所产生的影响均与一次执行的影响相同,即无论你请求了多少次,对数据库的影响都只能有一次,不能重复处理。
所以,按照上面的理解,每次执行的结果都会发生变化,就是非幂等的。如下面三条SQL,只有第三条是非幂等的。
1 | SELECT col1 FROM tab1 WHER col2=2,无论执行多少次都不会改变状态,是天然的幂等。 |
HTTP协议与幂等性
如果把操作按照功能分类,那就是增删改查四种,在 http 协议中则表现为 Get、Post、Put、Delete 四种。
查询操作 (Get)
Get 方法用于获取资源,不应当对系统资源进行改变,所以是幂等的。注意这里的幂等提现在对系统资源的改变,而不是返回数据的结果,即使返回结果不相同但是该操作本身没有副作用,所以幂等。
删除操作 (Delete)
Delete 方法用于删除资源,虽然改变了系统资源,但是第一次和第N次删除操作对系统的作用是相同的,所以是幂等的。比如要删除一个 id 为 1234 的资源,可能第一次调用时会删除,而后面所有调用的时候由于系统中已经没有这个 id 的资源了,但是第一次操作和后面的操作对系统的作用是相同的,所以这也是幂等的,调用者可以多次调用这个接口不必担心错误。
修改操作 (Put)
Put操作必须为幂等的,即如果声明为Put协议时就相当于对外声明这个接口是幂等的。
新增操作 (Post)
Post 新增操作天生就不是一个幂等操作,其在 http 协议的定义如下:
The POST method is used to request that the origin server accept the entity enclosed in the request as a new subordinate of the resource identified by the Request-URI in the Request-Line.
在其定义中表明了 Post 请求用于创建新的资源,这意味着每次调用都会在系统中产生新的资源,所以该操作注定不是幂等操作。这时候想要幂等就必须在业务中实现,方案在下文会讨论。
什么情况下需要幂等性?
一个例子:比如说支付场景,用户购买了商品支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
哪些情况需要防止?
- 用户多次点击按钮
- 用户页面回退再次提交
- 微服务互相调用,由于网络问题,导致请求失败,触发重试机制
- 其他业务情况
读和写请求都需要做幂等吗?
- 读请求不需要做幂等(因为读请求不会对数据发生改变)。
- 写请求需要做幂等(对数据发生改变了就根据需要做幂等)。
如何实现幂等性?
数据库唯一约束
利用数据表唯一索引的特性,当并发时新增报错时,再查询一次,数据已经存在,就避免了脏数据的新增。但注意,不要将 uuid 作为索引字段,其大小和类型对于索引而言都会导致速度非常慢。
常见的场景,比如博客 / 微博系统点赞,一个用户对一个微博点赞,就把用户 id 与该博文 id 绑定,后续该用户再对该博文点赞就无法插入。再比如金融账户,可以通过在账户表中增加唯一索引来存储用户 id,即使重复操作一个用户也只能拥有一个账户。
去重表
利用数据库的特性来实现幂等。通常是在表上构建一个唯一索引,那么只要某一个数据构建完毕,后面再次操作也无法成功写入。
常见的业务就是博客系统点赞功能,一个用户对一个博文点赞后,就把用户 id 与 博文 id 绑定,后续该用户点赞同一个博文就无法插入了。或是在金融系统中,给用户创建金融账户,一个用户肯定不能有多个账户,就在账户表中增加唯一索引来存储用户 id,这样即使重复操作用户也只能拥有一个账户。
状态标识
状态标识是很常见的幂等设计方式,主要思路就是通过状态标识的变更,保证业务中每个流程只会在对应的状态下执行,如果标识已经进入下一个状态,这时候来了上一个状态的操作就不允许变更状态,保证了业务的幂等性。
状态标识经常用在业务流程较长,修改数据较多的场景里。最经典的例子就是订单系统,假如一个订单要经历 创建订单 -> 订单支付取消 -> 账户计算 -> 通知商户 这四个步骤。那么就有可能一笔订单支付完成后去账户里扣除对应的余额,消耗对应的优惠卷。但是由于网络等原因返回了错误信息,这时候就会重试再次去进行账户计算步骤造成数据错误。
所以为了保证整个订单流程的幂等性,可以在订单信息中增加一个状态标识,一旦完成了一个步骤就修改对应的状态标识。比如订单支付成功后,就把订单标识为修改为支付成功,现在再次调用订单支付或者取消接口,会先判断订单状态标识,如果是已经支付过或者取消订单,就不会再次支付了。
token 令牌机制
token 机制是适用范围最广泛的一种幂等设计。虽然实现方式有很多种,但核心思想就是每次操作都生成一个唯一 token 凭证,服务器通过这个唯一凭证确保同样的操作不会被执行多次。
具体可以分为两个阶段,获取 token 和使用 token。每次接口请求前先获取一个 token,然后在下次请求时在请求的 header 体中加上这个 token,后端进行校验,如果验证通过则删除 token,下次请求再次判断 token。如果在 redis 缓存的帮助下,流程图如下:
详细描述:
1、 服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。
2、然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
3、服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务。
4、如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样 就保证了业务代码,不被重复执行。
分布式锁
数据库防重表可以通过分布式锁代替,相比去重表,将放并发做到了缓存中,效率更高。局限性都是同一时间只能完成一次请求。
比如某些业务处理流程很长,要求不能并发执行,可以在流程执行之前根据某个标志 (用户 ID + 后缀等) 获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁。
幂等的优缺点
优点:
业务需要
缺点:
- 客户端处理逻辑得以简化,但服务端控制幂等逻辑变得更加复杂;
- 把并发执行变成改为串行执行,降低了执行效率。