最难不过二叉树

接口幂等性和ABA问题

2024-07-18

考虑以下这个真实场景:用户在浏览器页面点击“提交订单”按钮,浏览器向订单系统发送一条创建订单的请求,订单系统收到请求后向数据库插入一条新订单信息,此时订单创建成功。假设用户在点击“提交订单”时不小心点击了几次,结果是会创建多个订单吗?那必然不会。其实即使用户没有多次点击提交订单,接口重复调用的情况还是可能发生。比如在订单链路中,某个阶段可能设置了重传机制,当网络阻塞或者调用某个服务超时的情况下,可能就会对进行重试,其实第一次的调用已经成功,第二次的超时重试导致了创建订单请求的冗余调用。由此看来,重复请求这个情况是无法避免的,因此要在订单系统接口层面做防重工作,也就是我们的创建订单接口需要具备幂等性。

接口幂等

何为幂等性?幂等性,当任意多次执行所产生的影响,均与一次执行所产生的影响相同。从接口调用而言,使用相同参数来对相同接口进行多次调用和一次调用,对系统产生的效果是一样的。如果创建订单的接口具有幂等性,那么无论用户点击了多少次创建订单,在数据库中也只会有一条订单记录。那接口的幂等性从技术上怎么实施呢?

第一个考虑的问题:怎么判断当前请求是不是重复请求?

在插入订单前,先查一下数据库有没有这个订单号,因为订单号是全局唯一的,如果数据库中已经有这么一个订单号了,说明后面到来的请求都是重复请求。这个从SQL语法上看,就是先WHERE order_id=xxx,看一下没有该订单号,没有就INSERT一条新的订单数据,这是一个两步的操作,要用事务来执行。

实践上通常更喜欢通过指定唯一索引+insert来做插入判重。我们对订单号order_id添加唯一索引,在INSERT订单数据时,如果数据库中已有相同的order_id数据,就会报错,INSERT失败,因此,每个INSERT成功的订单数据一定是非重复的。

那订单号前端是在什么时候拿到的呢?在用户进入创建订单页面时,前端先调用获取订单号的接口获取到全局唯一的订单号,在用户点击提交订单时,请求会带上这个订单号送到订单系统进行处理。

ABA问题

在并发环境下,会存在ABA问题。用一个例子来解释下什么叫做ABA问题。

订单支付后,商家会发货,发货前需要填写快递单号。假设商家一开始时填写了快递单号888,发现填写错误了,马上进行修改,将快递单号改为666。对于订单系统而言,商家发来了两个修改请求,按正常逻辑,订单中的快递单号会首先被修改为888,再被修改为666。但是因为请求到达订单服务的顺序并不能保证888先到666后到,这是因为请求可能走不同的路由进到订单系统的各种实例,请求的处理顺序并不保序。所以有可能订单快递号先被修改为666,再被修改为888。这种情况就是ABA问题,这也是订单系统需要考虑解决的一个问题。

这种乱序问题,参考TCP的数据包保序的做法,就是给每个请求设置序号,在接收端维护一个保序的队列,接收端接收到请求后就放入保序队列里来排序,这样队列里的请求一定是有序的,订单服务从队列里依次取出请求处理即可,这就不会有ABA问题了。但是这样做复杂度太高了,维护一个有序队列需要额外的成本,另外要实现保序一定是有处理时间的消耗的,比如等待时间,seq=100的请求到达了队列,但是前面序号的请求还没到达,那么后面的请求只能干等,效率很低。

一个通用的解决方案是在数据库表里加一列version版本号,每次查询订单时,版本号需要随着订单数据返回给前端。页面进行数据更新时,需要把该版本号作为更新请求的参数,带给订单更新服务。订单服务在更新数据时,需要比较当前数据的版本号与消息中的版本号是否一致,不一致就拒绝更新,一致则更新数据且版本号加一。SQL语句是:

UPDATE orders set expressid = 666, version = version + 1 WHERE orderid = xxx AND version=? ; 

回过头来再看下这个方案下,是否还存在ABA问题。

  1. 用户打开订单页面,获取到当前订单数据对应的version = 0
  2. 用户填入快递单号888,和version=0一起请求后端
  3. 用户填入快递单号666,和version=0一起请求后端
  4. 订单系统先收到了修改快递单号666的请求,修改数据库UPDATE orders set expressid = 666, version = version + 1 WHERE orderid = xxx AND version=0; 修改成功,此时订单的快递单号为666,version=1。修改成功的回复到达前端,前端主动刷新页面,用户看到当前订单的快递单号是666。
  5. 订单系统接着收到了修改快递单号888的请求,修改数据库UPDATE orders set expressid = 888, version = version + 1 WHERE orderid = xxx AND version=0; 修改失败,因为数据库中订单的version=1。修改失败的回复到达前端,提示用户本次修改失败了,前端主动刷新页面,用户看到当前订单的快递单号是666。

从这个流程分析来看,已经不存在ABA问题了。

总结

因为网络、服务器等不确定因素,重试请求是普遍存在且不可避免的问题,具有幂等性服务可以克服由于重试问题导致的数据错误。而请求乱序到达的问题同样普遍存在,这将导致ABA问题,使用版本号机制能最简单地解决好这个问题。