跳过正文

幂等设计

·125 字·1 分钟
Chuck Chan
作者
Chuck Chan
分享技术、思考与生活

幂等设计
#


什么是幂等?
#

幂等指的是对某个资源来说,一次请求与多次请求,具有相同的副作用。用数学的语言的来表示就是f(x) = f(f(x))。为什么需要幂等?当我们将系统节藕隔离后,服务间的调用可能有三种状态:成功失败超时。前两者是明确的状态,而超时则是一种不明确的状态,可能这个请求还没到达服务端就已经超时了,也有可能请求已经到服务端,但是在返回给客户端的过程中超时或者丢包了,客户端完全不知道服务端是否有接受到数据。举几个例子:

  • 订单支付时,第一次支付超时了,然后客户端再次调用,是否会多扣一次钱?
  • 在页面提交form表单时,保存按钮不小心快速按了两次,表中产生了两条重复的数据数据。
  • mq消费的时候,有时候会读取到重复的消息,造成重复消费。

接口性幂等是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

如何保证幂等?
#

1. insert前先select
#

在insert数据前,先用唯一id(例如订单id)查询一下数据库是否有这个订单记录,如果没有则insert,否则返回订单信息。这种方法很简单,但是很明显,在并发的情况下一样会有产生重复订单。

640

2. 悲观锁
#

当有多个重复请求到达时,只有一个请求能够进行操作。比如在支付场景中,用户A的账户中有150元,某个请求扣件100元后,此时账户还剩50元。如果出现多次重复的请求,那么用户的余额可能出现负数。为了解决这个问题,可以使用悲观锁先锁住用户的账号,MySQL中可以用如下sql语句锁住数据。

select * from user_account where id = 123 for update 

这种方案实现也简单,但弊端是如果持锁的时间过长(事务耗时过长),必然会引起大量的请求等待,影响接口性能。另外,这里的id字段必须确保是主键id或者唯一索引,否则会锁住全表。

3. 乐观锁
#

悲观锁有性能问题,那么我们使用乐观锁。在表中增加一个version字段,在使用前先查询数据。

select amount, version from user_account where id = 123 

然后再更新数据(包括version),判断update影响的行数。这里假设获取到的version是1。如果下面这条语句执行结果的RowsAffected等于0,那么更新失败,因为他当前的version已经不为1了。

update user_account set amount = amount+100 version = version+1 where id = 123 and version = 1

4. 唯一索引
#

大多数情况下,为了防止数据重复产生,我们会在表中加唯一索引,这是一个简单有效的方案。

alter table `order` add UNIQUE KEY `un_code` (`code`);

加入唯一索引后,后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code异常,表示唯一索引有冲突。在业务代码里,如果有这种错误可以对其进行捕获,并且返回成功。

640

5. 建立防重表
#

有时候表中并非所有场景都不允许产生重复的数据,只有某些特定的场景才不允许,这时候直接在表里加唯一索引,显然不太合适。针对这种情况,我们可以通过建立防重表来解决。该表可以只包含两个字段:id唯一索引,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。

640 (16)

6. 状态机
#

很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。

假如id=123的订单状态是已支付,现在要变成完成状态。

update `order` set status = 3 where id = 2 and status = 2

第一次请求时,该订单的状态是已支付,值是2,所以该update语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了3

后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了3,再用status=2作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。但为了保证接口幂等性,影响行数是0时,接口也可以直接返回成功。

![640 (17)](http://storage.chuckchan.top/uploads/640 (17).png)

主要特别注意的是,该方案仅限于要更新的表有状态字段,并且刚好要更新状态字段的这种特殊情况,并非所有场景都适用。

7. token
#

除了上述方案之外,还有最后一种使用token的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。

第一步先获取token

640 (18)

第二步做具体的业务操作

640 (19)

具体步骤:

  1. 用户访问页面时,浏览器自动发起获取token请求。
  2. 服务端生成token,保存到redis中,然后返回给浏览器。
  3. 用户通过浏览器发起请求时,携带该token。
  4. 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。
  5. 如果存在,说明是重复请求,则直接返回成功。
  6. 在redis中token会在过期时间之后,被自动删除。