幂等

定义

幂等概念来自数学,表示对数据源做 N 次变换和 1 次变换的结果是相同的。

在互联网项目中幂等性用来表示用户对同一操作发起的一次请求或者多次请求的结果是一致的。

要求幂等的场景

  1. 前端重复提交:用户快速重复点击多次,造成后端生成多个内容重复的数据。

  2. 接口超时重试:对于给第三方调用的接口,为了防止网络抖动或其他原因造成请求丢失,这样的接口一般都会设计成超时重试多次。HTTP,RPC 等在超时的情况下会有重试机制。
    如:订单接口,不能多次创建订单。支付接口,重复支付同一笔订单只能扣一次钱。支付宝回调接口,可能会多次回调,必须处理重复回调。

  3. 消息重复消费:MQ 消息中间件,消息重复消费。

MySQL 中的幂等

  1. select 查询天然幂等。
  2. delete 删除也是天然幂等,多次删除同一个数据,效果一样。
  3. update 直接更新某个值的,幂等。
  4. update 更新累加操作的,非幂等。
  5. insert 非幂等操作,每次新增一条。

重复提交跟幂等的区别

  1. 重复提交是在第一次请求已经成功的情况下,人为的进行多次操作,导致不满足幂等要求的服务多次改变状态。
  2. 幂等更多的情况是第一次请求不知道结果(比如超时)或者失败的异常情况下,发起多次请求,目的是多次确认第一次请求成功,且不会因多次的请求而出现多次的状态变化。

引入幂等性后会使得服务端逻辑更加复杂,满足幂等性的服务需要在逻辑中至少包含两点:

  1. 首先去查询上一次的执行状态,如果没有查到状态则认为是第一次请求。
  2. 保证 防重复提交 的逻辑不会造成数据不一致。

幂等性可以简化客户端逻辑处理,但却增加了服务提供者的逻辑和成本,所以是否要用,需根据具体场景具体分析。

我们在开发中主要操作也就是 CURD,其中读取操作和删除操作是天然幂等的,我们所关心的就是创建操作、更新操作。

初级方式保证 尽量幂等

插入前先判断数据是否存在

这种是最基础的,也是我们在开发中必须要做的。我们会在插入或者更新前先判断下,当前这个数据数据库中是否已经存在,如果不存在则不允许重复插入,不存在则可插入。

代码示例如下:

1
2
3
4
5
6
7
8
9
10
public void save(Goods goods) {
// 1、先通过商品唯一code,查询数据库属否存在
Goods goods = findGoods(goods.getCode);
// 2、如果这条数据在db里已经存在了,此时就直接返回了
if (goods != null) {
return;
}
// 3、如果要是这条数据在db里不存在,此时就会执行数据插入逻辑了
insertGoods(goods);
}

前端做一些交互控制

好比有个新增商品的功能,有个保存按钮,用户点击保存按钮后,立马按钮置灰,或者页面跳转到商品列表页面,这样可以防止很大部分的前端重复提交。

前端限制比较简单,但有个问题:用户可以通过模拟网页请求来重复提交请求,绕过了前端限制。

高并发下如何保证幂等

上面两种初级方法,在高并发下显然是无法保证接口幂等的,所以在高并发下,我们来如何保证接口的幂等呢,这里整理几种常见的解决办法。

唯一索引 – insert 幂等

一般来讲悲观锁、乐观锁、状态码作用于 update 操作来实现幂等,而唯一索引是针对 install 操作来保证幂等。

对于数据插入的场景来说,这是最常见的方案。如防止订单多次插入最简单的方法就是创建唯一索引。MySQL 插入语句有下面三种:

  1. 直接插入,如果插入时捕捉到了 DuplicateKeyException 则表明是重复插入导致的。
  2. ON DUPLICATE KEY UPDATE:不存在则插入,存在则更新的操作,该关键字不会删除原有的记录。
  3. replace intoreplace into 底层是先删除后插入数据,会破坏索引、重新维护索引。需注意必须要有主键或唯一索引才能有效,否则 replace into 就只新增了。

利用 MySQL 唯一索引的大致流程如下:

  1. 创建表,并根据请求的某个特殊字段建立唯一索引,或者主键索引。
  2. 客户端请求服务端,服务端先将这次的请求信息存入 MySQL 的去重表中。然后判断是否插入成功,如果插入成功,则继续做后续业务请求。如果插入失败,则代表已经执行过当前请求。

悲观锁

定义: 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。

这里以更新商品订单状态来举例:一般订单有订单创建订单确认订单支付订单完成取消订单等订单流程。

当我更新订单状态为订单完成的时候,我们首先通过判断该订单的状态是否是订单支付,如果是不是则直接返回,否则更新状态为已完成。

伪代码示例如下

1
2
3
4
5
6
7
8
9
10
11
begin; -- 1.开始事务
-- 查询订单,判断状态
select order_no,status from order where order_no='20200524-1'

ifstatus !=订单支付状态){
-- 非订单支付状态,不能更新为已完成;
return ;
}
-- 更新完成
update order set status='订单完成' order_no='20200524-1'
commit; -- 2.提交事务

这是我们常见的一种写法,但这种写法在高并发环境下,可能会造成一个业务被执行两次的情况发生:

同时有两个请求过来,大家几乎同时查数据库订单状态,都是订单支付状态,然后就支持接下来一系列操作,这就导致一个业务被执行了两次,如果接下来一系列操作不是幂等的

那么就会出现脏数据。这里我们就可以通过悲观锁实现,也就是添加 for update 字段。

伪代码示例如下

1
2
3
4
5
6
7
8
9
10
begin;  --  1.开始事务
-- 查询订单,判断状态
select order_no,status from order where order_no='20200524-1' for update
ifstatus !=订单支付状态){
-- 非订单状态,不能更新为已完成;
return ;
}
-- 更新完成
update order set status='完成' order_no='20200524-1'
commit; -- 2.提交事务
  1. 这里 order_no 需要添加 索引,否则会 锁表
  2. 悲观锁在同一事务操作过程中,锁住了一行数据。悲观锁性能不佳所以一般不建议用悲观锁做这个事情。

该模式的缺点是,如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,而 web 服务中的线程数量一般有限的,如果大量线程由于获取 for update 锁处于等待状态,不利于系统并发操作。

基于分布式锁

分布式锁实现幂等性的逻辑就是,请求过来时,先去尝试获得分布式锁,如果获得成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。

其实前面介绍过的悲观锁,本质是使用了数据库的分布式锁,都是将多个操作打包成一个原子操作,保证幂等。但由于数据库分布式锁的性能不太好,我们可以改用:Redis 或 zookeeper 来实现分布式锁。

乐观锁

定义:乐观锁就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制

所谓的乐观锁就是在表中新增一个version(版本号)字段。通过版本号的方式,来控制 update 的操作的幂等性。

用户查询出要修改的数据,系统将数据返回给页面,将数据版本号放入隐藏域,用户修改数据,点击提交,将版本号一同提交给后台,后台使用版本号作为更新条件。

1
2
select id,name,account,version from user where id = 1412; // 假设获得的 version = 10
update user set account = account + 10,version = version + 1 where id = 1412 and version = 10;

注意:乐观锁能够保证的是update的操作的幂等性,如果你的 update 本身就是幂等操作,或者 install 操作那就不能用乐观锁了。

token – 防止页面重复提交

token 分成两个阶段:申请 token 阶段和提交阶段。

  1. 第一阶段:客户端向系统发起一次申请 token 的请求,系统将 token 保存到 Redis 中。

  2. 第二阶段:客户端携带申请到的 token 发起提交请求,系统会检查 Redis 中是否存在该 token,如果存在表示是第一次发起的请求,删除缓存中 token 后开始进行相应处理;如果缓存中不存在,表示非法请求或者是重复请求,返回提示即可。

token 模式不足之处在于,需要系统间交互两次,流程较上述方法复杂。

服务端需要生成一个全局唯一的 id,(例如:snowflake 雪花算法,美团 Leaf 算法,滴滴 TinyID 算法,百度 Uidgenerator 算法,uuid,redis 等)。

验证 token 是否存在,不要用 redis.get(token) 之后,在用 redis.del(token),这样不是原子操作在高并发情况下依然会存在幂等问题。我们可以直接用 redis.del(token) 的方式。看返回是否大于0,就知道是否有数据了,而且因为 redis 命令操作是单线程的,所以不会出现同时返回1,所以是能够保证幂等的。

Reference


幂等
https://flepeng.github.io/架构-分布式-幂等/
作者
Lepeng
发布于
2022年12月1日
许可协议