本文主要阐述,什么是幂等性,出现幂等问题的情况,怎么保证幂等,希望本文对读者有所帮助;
一、接口幂等性
所谓幂等就是多次调用方法或者接口不会改变业务状态,可以保证重复与单次调用的结果是一致的;
二、什么情况下需要考虑幂等
我们的开发中,主要操作就是CRUD,其中读取操作和删除操作是天然幂等的,我们主要着重关心的就是新增和更新操作;
幂等性的使用场景:
1.前端重复提交
如新增商品功能,点击保存按钮,前端连续多次点击保存,后端就会收到多次请求接口,如果没做好幂等就会重复创建多条记录,会出现脏数据;
这个也就是我们所说的如何防止前端重复提交的问题;
2.用户恶意进行刷单
如:用户投票功能,用户针对一个用户进行重复提交投票(通过调用接口),这样或导致接口接收到用户重复提交的投票信息,会导致投票结果与事实严重不符;
3.接口超时重试
当我们调去第三方接口的时候,有可能会因为网络等原因导致调用失败,所以我们会对接口调用添加失败重试机制(Spring可以通过@Retryable注解实现重试机制)
既然重试就可能出现重复调用接口。这时再次调用时如果没有做好幂等,就可能出现脏数据;
4.消息重复消费
使用MQ消息中间件时,在生产端和消费端都有重试机制,也就是说同一消息可能被重复消费
测试接口幂等的场景:
1.网络波动,可能会引起重复请求;
2.用户重复操作,在操作时候可能会无意出发多次下单交易;
3.使用了失效或者超时重试机制;
4.页面重复刷新
5.使用浏览器后退按钮重复之前的操作,导致重复提交表单;
6.使用浏览器历史记录重复提交表单;
7.浏览器重复的HTTP请求;
8.定时任务重复执行;
三、如何保证接口幂等性
初级方式:
1.插入前先判断数据是否存在;
这是最基础的,也是开发中必须要做的。会在插入或者更新前先判断下,当前这个数据库中是否已存在,如果存在则不允许重复插入,不存在则可插入。
2.前端做一些交互控制
如保存按钮,用户点击后,按钮置灰或者在loading中,不可再次点击,或者跳转到其他页面,可以防止很大部分的前端重复提交;
高并发下如何保证幂等?
1.基于悲观锁
2.基于乐观锁
3.基于状态码
4.基于唯一索引
5.基于分布式锁
6.基于token
等等方式,实际使用中基本分布式锁用的比较多;
下面我们一一介绍一下各个方法原理:
1.基于悲观锁
定义:当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发;
在高并发的情况下,会造成一个业务被执行两次的情况发生,我们可以通过悲观锁实现,也就是sql查询语句中添加for update字段。
这里要注意:
1)被锁的一行数据,某个字段要加索引,否则会锁表;如order_no,加索引;
2)悲观锁在同一事务操作过程中,锁住了一行数据。悲观锁性能不佳所以一般不建议用悲观锁做这个事情;
2.基于乐观锁
定义:乐观锁就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制。
所谓的乐观锁就是在表中新增一个version(版本号)字段。
通过版本号的方式,来控制update的操作的幂等性,用户查询出要修改的数据,系统将数据返回给页面,将数据版本号放入隐藏域,用户修改数据,点击提交,将版本号一通提交给后台,后台使用版本号作为更新条件;
注意:乐观锁能够保证的是update的操作的幂等性,如果你的update本身就是幂等操作,或者install操作那就不能用乐观锁了。
3.基于状态码
很多业务表,都是有状态的,比如订单表,一般订单有1-订单创建、2-订单确认、3-订单支付、 4-订单完成、5-取消订单等订单流程,当我们更新订单状态
第一个请求时,成功把 订单确认 状态修改成 订单支付,sql执行结果的影响行数是1。
第二个请求时,同样想把 订单确认 状态修改成 订单支付,但是sql执行结果的影响行数为0。如果是0,那么我们直接可以返回成功了。而不需要做接下来的业务操作,以此来保证接口的幂等性。
4.基于唯一索引
一般来讲悲观锁、乐观锁、状态码作用于update操作来实现幂等,而唯一索引是针对insert操作来保证幂等。
1) 创建订单时,前端先通过接口获取订单号,再请求后端时带入订单号,订单表中订单号添加唯一索引,如果存在插入相同订单号则直接报错。
2) 消费MQ消息时,messageId
是唯一的,我们可以新添加一种消费记录表,将messageId作为主键,如果重复消费那么就会存在相同的messageId,插入直接报错。
5.基于分布式锁
分布式锁实现幂等性的逻辑就是,请求过来时,先去尝试获得分布式锁,如果获得成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。
其实前面介绍过的悲观锁,本质是使用了数据库的分布式锁,都是将多个操作打包成一个原子操作,保证幂等。但由于数据库分布式锁的性能不太好,
我们可以改用:redis或zookeeper来实现分布式锁。
具体步骤:
用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。
使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。
判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。
如果设置失败,说明是重复请求,则直接返回成功。
6.基于 Token
token机制的核心思想是为每一次操作生成一个唯一性的凭证,也就是token。一个token在操作的每一个阶段只有一次执行权,一旦执行成功则保存执行结果。对重复的请求,返回同一个结果。token机制的应用十分广泛。
举个例子:每次请求都拿着一张门票,这个门票是一次性的,用过一次就被毁掉了,不能重复利用。这个token令牌就相当于门票的概念,每次接口请求的时候带上token令牌,服务器第一次处理的时候去校验token,并且这个token只能用一次,如果用户使用相同的令牌请求二次,那么第二次就不处理,直接返回。
token方案的特点就是:需要两次请求才能完成一次业务的操作。
一般包括两个请求阶段:
1)客户端请求申请获取token
,服务端生成token返回。
2)第二次请求带着这个token
,服务端验证token,完成业务操作。
具体步骤:
用户访问页面时,浏览器自动发起获取token请求。
服务端生成token,保存到redis中,然后返回给浏览器。
用户通过浏览器发起请求时,携带该token。
在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。
如果存在,说明是重复请求,则直接返回成功。
在redis中token会在过期时间之后,被自动删除。
主要token这种方案有一定的危险性,我们分析一下:
1、先删除token【先删除token令牌,再执行业务】还是后删除token【先执行业务,再删除token令牌】;
(a)、先删除可能导致,业务确实没有执行,重试还带上了之前的token,由于防重设计导致,请求还是不能执行;
(b)、后删除token问题很大,可能导致,业务处理成功,但是服务闪断,出现超时,没有删除token,别人继续重试,导致业务被执行两次;
所以我们最好设计为先删除token,如果业务调用失败,就重新获取token再次请求。
2、在这里token获取,比较和删除必须保证原子性
在验证token是否存在,不要用redis.get(token)之后,在用redis.del(token),这样不是原子操作在高并发情况下依然会存在幂等问题。
我们可以直接用redis.del(token)
的方式: