幂等操作
一、幂等概念
1、幂等的数学概念
如果在一元运算中,x 为某集合中的任意数,如果满足 f(x) = f(f(x)) ,那么该 f 运算具有幂等性。
绝对值运算 abs(a) = abs(abs(a)) 就是幂等性函数
如果在二元运算中,x 为某集合中的任意数,如果满足 f(x,x) = x,前提是 f 运算的两个参数均为 x,那么我们称 f 运算也有幂等性。
求大值函数 max(x,x) = x 就是幂等性函数
2、幂等的业务概念
就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
-
场景1:支付场景
用户购买商品使用支付宝支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了。因此需要对于每一笔订单,操作多次,也只能扣一次钱。
-
场景2:一键三连
小破站有一个一键三连的功能,长按可以对up主进行激励,每个人对每个视频只有一个一键三连的机会。就算再喜欢某个视频,多次操作,也只能有一键三连一次。
-
场景3:统计DAU/MAU
DAU/MAU,又叫日活/月活,是用于反映网站、互联网应用或网络游戏的运营情况的统计指标。所以一个用户当天或者当月登录多次(或者达到某种活跃用户判断机制多次),也只能看作一个活跃用户,不能重复计算。
在增删改查4个操作中,尤为注意就是增加和修改,
(1) 查询对于结果是不会有改变的,
(2) 删除只会进行一次,用户多次点击产生的结果一样,
(3) 修改在大多场景下结果一样
(4) 增加在重复提交的场景下会出现
3、幂等概述
生产环境经常出现过重复的数据?在排查问题的时候,数据又是正常的。这个是何解呢?怎么会出现这种情况,而且还很难排查问题。
原因 :产生重复数据或数据不一致(假定程序业务代码没问题),绝大部分就是发生了重复的请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:(本质上:多次请求)
1)微服务场景,在我们传统应用架构中调用接口,要么成功,要么失败。但是在微服务架构下,会有第三个情况【未知】,也就是超时。如果超时了,微服务框架会进行重试。
2)用户交互的时候多次点击。如:快速点击按钮多次。
3)MQ消息中间件,消息重复消费。
4)第三方平台的接口(如:支付成功回调接口),因为异常也会导致多次异步回调。
5)其他中间件/应用服务根据自身的特性,也有可能进行重试。
接口幂等:接口的幂等性实际上就是 接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。更准确的讲:多次调用对系统的产生的影响是一样的,即对资源的作用是一样的,但是返回值允许不同。
4、幂等场景
-
查询,select * from user where xxx,不会对数据产生任何变化,具备幂等性
-
新增,insert into user(userid, name) values(1, ‘a’)
如 userid 为唯一主键,即重复操作上面的业务,只会插入一条用户数据,具备幂等性 如 userid 不是主键,可以重复,那上面业务多次操作,数据都会新增多条,不具备幂等性
-
修改,区分直接赋值和计算赋值
直接赋值,update user set point = 20 where userid = 1,不管执行多少次,point都一样,具备幂等性 计算赋值,update user set point = point + 20 where userid = 1,每次操作 point 数据都不一样,不具备幂等性
-
删除,delete from user where userid = 1,多次操作,结果一样,具备幂等性
上面场景中,我们发现新增没有唯一主键约束的数据,和修改计算赋值型操作都不具备幂等性
二、使用乐观锁实现幂等
1、锁的定义
不管是互斥锁、自旋锁、重入锁、读写锁、行锁、表锁等等等等这些概念,我把他们都归纳为两种类型,乐观锁和悲观锁。
-
乐观锁
乐观锁就是持比较乐观态度的锁。就是在操作数据时非常乐观,认为别的线程不会同时修改数据,所以不会上锁,但是在更新的时候会判断在此期间别的线程有没有更新过这个数据。
-
悲观锁
悲观锁就是持悲观态度的锁。就在操作数据时比较悲观,每次去拿数据的时候认为别的线程也会同时修改数据,所以每次在拿数据的时候都会上锁,这样别的线程想拿到这个数据就会阻塞直到它拿到锁
举个例子,有时候我们上公共厕所的时候要排队。如果你蹲马桶的时候开着门,外面有人排着队看着你。你会这么做吗?当然,如果在自己家里,有可能会这么干,这就是乐观锁。虽然,能进到房间,但是有人占着坑位,该排队还是得排队。比如数据库提供的类似于write_condition机制,Java API 并发工具包下面的原子变量类就是使用了乐观锁的CAS来实现的。
悲观锁就不同了,就相当于是进房间之后,第一件事就是把门锁上,那在门外排队等候的人不知道里面发生了什么,又着急但是又只能干等着,这就是悲观锁。比如行锁、表锁、读锁、写锁,都是在操作之前先上锁,Java API中的synchronized和ReentrantLock等独占锁都是悲观锁思想的实现。
-
锁的应用场景
根据前面对两种锁的介绍,总结一下两种锁的应用场景: 乐观锁,它适用于写少读多的情况,也就是说减少操作冲突,这样可以省去锁竞争的开销,提高系统的吞吐量。 悲观锁,它适用于写多读少的情况。因为,如果还使用乐观锁的话,会经常出现操作冲突,这样会导致应用层会不断地Retry,反而会降低系统性能。
2、使用乐观锁实现幂等
-
在要进行添加和修改的表中增加一个版本号列
-
进行修改操作
在执行update命令前,需要先将要修改的数据对象查出来(不是点击列表中的查询按钮,在进后页面后自动查一次),将查到的版本号做为修改的条件。
查询代码略
update users set money=money+300,version=version+1 where id=5 and version=1 -- version=1中的1为查询出来的版本号
加上了版本号后,就让此计算赋值型业务,具备了幂等性
缺点:就是在操作业务前,需要先查询出当前的version版本。
三、使用token+redis实现幂等【日常使用】
token + redis 的幂等方案,适用于绝大部分场景。token 模式主要是为了防重的。需要前后端进行一定程度的交互来完成。需要利用到 Redis。
具体流程步骤:
a、客户端会先发送一个请求去获取token,服务端会生成一个全局唯一的ID作为token保存在redis中,同时把这个ID返回给客户端客户端
b、第二次调用业务请求的时候必须携带这个token
c、服务端会校验这个token,如果校验成功,则执行业务,并删除redis中的token
d、如果校验失败,说明redis中已经没有对应的token,则表示重复操作,直接返回指定的结果给客户端
1、案例实现
a、创建项目
创建一个页面,用来模拟修改数据的过程
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<h3>提交修改后的数据</h3>
<form action="/update" method="get">
<p>姓名:<input type="text" name="username" value="tom"> </p>
<p>性别:<input type="text" name="sex" value="sex"> </p>
<!--实际开发中将返回的唯一id保存到隐藏表单中-->
<p>获得的token:<input type="text" id="token" name="token" value=""> </p>
<button type="submit">提交修改数据</button>
</form>
<script type="text/javascript">
//修改页面一刷新就请求一次token
axios.get('/getToken',{})
.then(result=>{
document.getElementById("token").value=result.data.msg;
}).catch(error=>{
console.info(error);
})
</script>
</body>
</html>
b、java代码
@RestController
public class UserController {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 生成唯一id
* @return
*/
@GetMapping("/getToken")
public ResponseResult<String> getUserById(){
//生成唯一id
String powerid= UUID.randomUUID().toString();
//将唯一id保存到redis
ValueOperations<String, String> operations =redisTemplate.opsForValue();
operations.set(powerid, powerid,2,TimeUnit.MINUTES); //键保存2分钟
//将id返回到页面
return new ResponseResult<>(200, powerid);
}
/**
* 根据id查询用户信息
* @return
*/
@GetMapping("/update")
public ResponseResult<String> updateUser(@RequestParam("username") String username,
@RequestParam("sex") String sex,
@RequestParam("token") String token){
//判断redis中是否有表单提交中的id
if(!redisTemplate.hasKey(token)){
//为true说明是第一次提交,唯一id还在redis中
//为false说明不是第一次提交,唯一id已在redis中被删除了
return new ResponseResult<>(1001, "不能进行重复提交");
}
//删除当前token
redisTemplate.delete(token);
//执行修改流程
//返回结果
return new ResponseResult<>(200, "修改成功");
}
}
return new ResponseResult<>(1001, "不能进行重复提交");
}
//删除当前token
redisTemplate.delete(token);
//执行修改流程
//返回结果
return new ResponseResult<>(200, "修改成功");
}
}