java通过redis完成幂等性操作
4 幂等
产生 “重复数据或数据不一致”( 假定程序业务代码没问题 ),绝大部分就是发生了重复的请求,重复请求是指"同一个请求因为某些原因被多次提交"。导致这个情况会有几种场景:
- 微服务场景,在我们传统应用架构中调用接口,要么成功,要么失败。但是在微服务架构下,会有第三个情况『未知』,也就是超时。如果超时了,微服务框架会进行重试;
- 用户交互的时候多次点击。如:快速点击按钮多次;
- MQ 消息中间件,消息重复消费;
- 第三方平台的接口(如:支付成功回调接口),因为异常也会导致多次异步回调;
- 其他中间件/应用服务根据自身的特性,也有可能进行重试。
接口的幂等性实际上就是『接口可重复调用』,在调用方多次调用的情况下,接口『最终得到的结果是一致的』。
接口幂等:接口的幂等性实际上就是 接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。更准确的讲:多次调用接口对系统的产生的影响是一样的,即对资源的作用是一样的,但是返回值允许不同。
幂(mi)等概念
幂等 是一个数学与计算机科学概念,英文 idempotent [aɪˈdempətənt]。幂等这个词源自数学,幂等性是数学中的一个概念,常见于抽象代数中。表达的是N次变换与1次变换的结果相同;
幂等的数学概念
在数学中,幂等用函数表达式就是:f(x) = f(f(x))
如果在一元运算中,x 为某集合中的任意数,如果满足 f(x) = f(f(x)) ,那么该 f 运算具有幂等性。
绝对值运算 abs(a) = abs(abs(a)) 就是幂等性函数
如果在二元运算中,x 为某集合中的任意数,如果满足 f(x,x) = x,前提是 f 运算的两个参数均为 x,那么我们称 f 运算也有幂等性。
求最大值函数 max(x,x) = x 就是幂等性函数
幂等函数或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。幂等性本身是一个数学概念。在计算机的各个领域都有涉及和借用。
幂等的计算机概念
幂等表示一次和多次请求某一个资源应该具有同样的作用。
简单来说就是,如果同一个方法传入相同的参数情况下,调用一次和多次产生的效果是相同的,它就具有幂等性。
幂等的业务概念
幂等性问题在我们的开发中,分布式、微服务架构中是随处可见的:
日常开发中,需要考虑幂等性的场景:
- 前端重复提交:比如提交 form 表单时,如果快速点击提交按钮,就可能产生两条一样的数据。
- 用户恶意刷单:例如在用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,会使投票结果与事实严重不符。
- 接口超时重复提交:很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口的时候,为了防止网络波动等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
- MQ重复消费:消费者读取消息时,有可能会读取到重复消息。
- 因网路波动,可能会引起用户的重复请求
- 用户重复操作,用户在使用产品时可能会无意的触发多次下单多次交易,甚至因为没有响应而有意触发多笔交易;
- 应用使用了失败或超时重试机制(如Nginx重试、RPC重试或业务层重试等)
- 第三方平台的接口(如:支付成功回调接口),因为异常导致多次异步回调
- 中间件/应用服务根据自身的特性,也可能进行重试
- 使用浏览器后退按钮重复之间的操作,导致重复提交表单
- 使用浏览器历史记录重复提交表单
- 浏览器重复的HTTP请求
案例如下:
就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
- 场景1:支付场景
用户购买商品使用支付宝支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了。因此需要对于每一笔订单,操作多次,也只能扣一次钱。 - 场景2:一键三连
小破站有一个一键三连的功能,长按可以对up主进行激励,每个人对每个视频只有一个一键三连的机会。就算再喜欢某个视频,多次操作,也只能有一键三连一次。 - 场景3:统计DAU/MAU
DAU/MAU,又叫日活/月活,是用于反映网站、互联网应用或网络游戏的运营情况的统计指标。所以一个用户当天或者当月登录多次(或者达到某种活跃用户判断机制多次),也只能看作一个活跃用户,不能重复计算。
幂等作用
用户执行同一个功能多次【不知情】,保证多次执行结果一致的。
幂等场景
- 查询,select * from user where id=1,不会对数据产生任何变化,天然具备幂等性
- 新增,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,多次操作,结果一样,天然具备幂等性
上面场景中,我们发现新增没有唯一主键约束的数据,和修改计算赋值型操作都不具备幂等性
- 以『增删改查』四大操作来看,『删除』和『查询』操作天然是幂等的,没有( 或不在乎 )重复提交/重复请求问题。因此,幂等需求通常是用在『新增』和『修改』类型的业务上。
- 而『修改』类型的业务通过 SQL 改造和 last_upated_at 字段的结合,也可以实现幂等,不强求必须使用我们这里的 token 和去重表方案。
- 因此,幂等性的处理重点集中在『新增』型业务上。
保证幂等性的方案
前端幂等性的实现【不是可靠的】
按钮只操作一次
思路:一般是提交后把按钮置灰或loading状态,消除用户因为重复点击而产生的副作用,比如:添加操作,因为点击两次而产生两条记录
token机制【日常常用】
页面上允许重复提交,但要保证重复提交不会产生副作用,比如点击n次按钮只产生一条记录;
思路:进入页面时申请一个token,然后页面后面处理的所有请求带着这个token,根据token来避免重复操作
其他幂等实现方式:数据库的乐观锁、悲观锁
使用token+redis实现幂等
token + redis 的幂等方案,适用于绝大部分场景。这种方式的实现过程划分为两个阶段:申请token的阶段和业务操作阶段。
我们在分析业务的时候,哪些业务是存在幂等问题,就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。
以注册为例:
第一阶段,在进入到注册页面之前,需要注册页面根据用户信息向后台控制器发送一次请求,申请token,后台控制器会将生成的token回送给客户端,同时也会将token存入redis缓存中,为第二阶段的注册业务使用
第二阶段:注册页面拿着申请到的token发起注册请求,控制器会获取客户端发送的token,然后去检查redis中有没有该token,如果存在,则表示是一次发出注册请求,则开始处理注册逻辑,处理完毕后并从redis中删除当前token;当重复请求时,检查缓存中的token不存在,则表示非法请求,服务器响应错误消息给客户端。
需要注意的是
- 这个幂等 token 本质上就是一个字符串,但是它必须是具有唯一性的。例如,UUID 。
- 这个幂等 token 要存取 redis 中。此时存入,是因为在未来需要再从 redis 中删除它
案例实现:注册
reg.html:用户进入页面时,就利用vue钩子函数向后台控制器发送请求,申请token
created(){
//幂等性操作:所有用户第一次进入本网页时,为当前这个用户生成一个唯一标识,分别存在两端:1.redis 2.浏览器
axios.get("/user/get-token")
.then(resp=>{
let token=resp.data.data;//服务器分发的token
localStorage.setItem("reg-token",token);
})
}
UserController:生成token,保存到redis中,并将token响应客户端保存
@RequestMapping("/get-token")
@ResponseBody
public ResponseResult<String> getRegToken(){
String token = UUID.randomUUID().toString();
//1.redis保存token
redisMapper.setKey(token,token);
return new ResponseResult<>(200,"",token);
}
reg.html:当用户提交注册请求时,除了需要携带用户信息,还需要将第一步申请的token一起发送给控制器
onSubmit() {
//注册需要带x-token,没有token.
axios.post("/user/register",this.user,{
headers:{
"reg-token":localStorage.getItem("reg-token")
}
})
.then(resp=>{
this.$message({
showClose:true,
message:resp.data.msg,
type:'success'
});
if(resp.data.status===200){
//成功,自动跳到登录页面
location.href="/login.html";
}
})
},
UserController.java:处理请求前,需要验证客户端提交的token是否在redis中存在,存在就处理业务,否则响应错误消息
@PostMapping("/register")
@ResponseBody
public ResponseResult<Void> doReg(@RequestBody RegUserFo userFo, @RequestHeader("reg-token") String token){
//验证当前这个用户是否已经注册过了,不能出现重复注册的情况
/*
* 如果这个用户第一次来注册,redis保存以他手持token命名的标识,注册成功,redis删除表示
* 如果这个用户第二次来注册,redis不存在与他手持token相关的记录,不允许再注册
*/
if(!redisMapper.existKey(token)){
return new ResponseResult<>(505,"重复操作,请稍后再试....");
}
UserDto userDto=new UserDto();
//将userFo的属性复制到userDto
BeanUtils.copyProperties(userFo,userDto);
//输出语句
log.info("userDto转换后的结果:{}",userDto);
userService.register(userDto);
//如果执行成功,删除redis保存凭证
redisMapper.delKey("reg:token:"+token);
return new ResponseResult<>(200,"注册成功");
}
值得注意的是:Controller 在调用 Service 层( 执行业务逻辑 )之前,需要从 redis 中将前一个功能中存入 redis 的幂等 token 删除。删除成功,才能继续向下执行。
小细节:控制器会在哪些情况下,返回错误消息呢?
控制器处理完业务功能后,执行删除token操作,逻辑上起的是一个「判断」的作用:判断当前请求是否是合法的第一次请求。
那么上述图例中,造成token删除失败的情况,可能有以下三种:
非法情况一
没有申请过token,因此,没有携带幂等 token;
对于这种情况,因为缺少必要参数,所以 Controller 应该返回失败信息;
非法情况二
携带了幂等 token,但是不是上一步生成、返回的。例如,是自己伪造的;
对于这种情况,因为 redis 种没有这个伪造的幂等 token,所以会删除失败,因此,Controller 应该返回失败信息;
非法情况三
携带的是合法的幂等token,但是是重复请求。
对于这种情况,虽然 redis 中曾经有过这个幂等 token,但是因为已经被「用掉」了,所以,本次请求仍然应该返回失败信息。
扩展:如果用户注册成功,而执行redis删除幂等token时操作失败,怎么解决?
上述案例代码中,我们是先执行注册业务逻辑操作,再执行redis删除。如果出现上述请求,当我们再次提交注册请求时,就会出现重复注册的情况。怎么解决呢?
改良方案:我们可以先删除 redis ,删除成功后,而后再执行业务逻辑代码。
不过这种方案有一个缺点:无论业务逻辑执行成功还是失败,redis 中的幂等 token 都被删除掉了,这是,用户如果需要重试,那么必须重新获得幂等 token 。当然,你可以基于补偿的思维来改进这个方案:去 try-catch service 抛出的异常,一旦 service 执行失败,那么在 catch 代码块中再将幂等 token 重新存回 redis 。
参考代码如下:
@PostMapping("/do-register")
public ResponseResult<Map<String,String>> doRegister(@RequestBody @Validated RegUserFo userFo,
BindingResult result,
@RequestHeader("reg-token")String regToken){
log.info("do-post用户提交的数据:{}", userFo);
//判断这个注册请求是否重复提交了
/**
* redis+token保证幂等性,第二阶段
* 1,验证redis中是否有幂等token
* 2.如果没有,直接响应错误消息
* 如果有,执行注册功能,注册执行完毕后,将幂等token删除掉
*/
if(!redisMapper.delKey("reg:"+regToken)){
return new ResponseResult<>(5005,"请求次数过多,请稍后再试....");
}
try{
RegUserDto userDto=new RegUserDto();
BeanListUtils.copyProperties(userFo,userDto);
userService.register(userDto);
return new ResponseResult<>(2000,"注册成功");
}catch(Exception e){
//将“误删除”的幂等token再存入redis中
redisMapper.setKey("reg:"+token,token);
throw new RuntimeException(e);
}
}