赞
踩
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
在增删改查4个操作中,尤为注意就是增加或者修改,
A: 查询操作,如:
select * from user where user_id = 1;
不管执行一次还是多次上面的查询语句,如果数据库记录没有变更的话,查询结果都是一样的。由此可见,select是天然的幂等操作。
B: 删除操作 ,如:
delte from user where user_id = 1;
不管是删除一次,还是删除多次,都是把数据删除,在不考虑返回结果的情况下,因此删除操作也是具有幂等性的。
C: 更新操作
如下例子:
把表中id为XXX的记录的A字段值设置为1,这种操作不管执行多少次都是幂等的。如:
update user set username = 'zhangsan' where user_id = 1
如上看见,将username字段改成一个固定的值,这种操作不管执行多少次,结果都是一样的,所以这种情况下更新操作时幂等的。
再看下面的例子。如:
update user set age = age + 1 where user_id = 1
这里将age字段进行递增操作,每执行一次,那么age就会加一,显然每次执行结果都不一样,所以这种情况下update操作就不是幂等操作。
D: 新增操作
增加在重复提交的场景下会出现幂等性问题,如用户下订单操作,如果网络卡顿,用户重复点击了多次购买按钮,那么就可能导致相同的订单被我们保存了多份。如:
insert into order(pkid, order_id, xx) values (1, '20210304020226953568', ...);
假设pkid是自增,如果order_id没有做唯一约束的话,那么就可能导致同一个订单保存多条数据,不具备幂等性;如果order_id做了唯一约束,那么这个新增就是幂等的。
对于业务中需要考虑幂等性的地方一般都是接口的重复请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:
用户在新增页面上快速点击多次,造成发了多次请求,后端重复保存了多条一模一样的数据。如用户提交订单,生成很多重复的订单;
消息重复消费,一般指的是消息中间件,如RabbitMQ,由于网络抖动,MQ Broker将消息发送给消费端消费,消费端进行了消费,在返回ack给MQ Broker时网络中断等原因,导致MQ Broker认为消费端没能正常消费消息,这时候MQ Broker会重复将这条消息发给消费端进行消费,如果没有做幂等,就会造成客户端重复消费同一条消息。
举个例子,用户购买商品的时候,如果第一次点击下单按钮后,提示下单成功,跳转到下单成功页面,这时候如果用户点击浏览器返回按钮,返回上一个下单页面,重新点击下单按钮,这时候如果没有做幂等的话,也会造成重复下单的问题。
分布式系统中,服务之间的通信一般都通过RPC或者Feign进行调用,难免网络会出小问题,导致此次请求失败,这时候这些远程调用,如feign都会触发重试机制,所以我们也需要保证接口幂等。
对于一些业务场景影响比较大的,比如支付交易等场景,必须要实现接口的幂等,否则出现重复扣了客户的钱,可想而知后果。
对于和web端交互的接口,我们可以在前端拦截一部分,例如防止表单重复提交,按钮置灰、隐藏、按钮不可点击等方式。
但是前端做控制实际效益不是很高,懂点技术的都会模拟请求调用你的服务,所以安全的策略还是需要从后端的接口层来做。
那么后端要实现分布式接口的幂等性有哪些策略方式呢?后端实现接口幂等的方式主要有下面几种,可以根据业务场景,结合起来一起使用,保证接口的幂等。
【a】数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单号就不可能有两条记录插入,我们在数据库层面防止重复。
这个机制是利用了数据库的主键唯一索引的特性,解决了在插入场景时的幂等问题。
- CREATE TABLE `tbs_idempotent` (
- `pkid` bigint(20) NOT NULL COMMENT '主键ID',
- `serial_no` bigint(20) NOT NULL COMMENT '唯一序列号',
- PRIMARY KEY (`pkid`),
- UNIQUE KEY `serial_no` (`serial_no`)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='幂等校验表'
如上,建立了一个唯一序列号是唯一索引,我们在进行业务操作的时候,往这张表插入一条数据,如果后面第二次提交【序列号还是一样,比如订单ID】,发现这张表的序列号已经在第一次插入进去了,那么第二次操作就什么都不进行,直接返回,保证幂等。
【b】redis set防重
很多数据需要处理,只能被处理一次,比如我们可以计算数据的MD5将其放入redis的set数据结构,每次处理数据,先看这个MD5是否已经存在,如果已经存在就不处理。
【c】防重数据表
使用订单号orderNo作为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且他们在同一事务操作中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避免了幂等问题。这里要注意的是,去重表和业务表应该在同一个数据库中,这样就保证了在同一个事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据的一致性。
【d】数据库锁
数据库常见的锁也就是乐观锁和悲观锁。
数据库悲观锁指的就是每次操作的时候,先把记录锁定起来,其他人无法操作这条记录。如下面的查询的时候添加for update锁定这行:
select * from user where user_id = 1 for update;
注意,数据库悲观锁使用时,一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
数据库乐观锁就是利用版本号的概念,在操作前先获取到操作记录的当前version版本号,然后操作的时候带上此版本号。
这种方法适合在更新的场景中:
update user set age = age + 1, version = version + 1 where user_id = 2 and version = 1
注意,乐观锁主要使用于处理读多写少的问题。
【e】业务层分布式锁
如果多个线程可能在同一时间处理相同的数据,比如多个线程在同一时刻都拿到了相同的数据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过。
【f】Token机制
这种token令牌机制应该是市面上用的比较多的一种保证幂等方式,简单理解,就是每次请求都拿着一张门票,这个门票是一次性的,用过一次就被毁掉了,不能重复利用。这个token令牌就相当于门票的概念,每次接口请求的时候带上token令牌,服务器第一次处理的时候去校验token,并且这个token只能用一次,如果用户使用相同的令牌请求二次,那么第二次就不处理,直接返回。
大体的流程如下:
流程图大体如下:
不过token这种方案有一定的危险性,其实就在于服务端我们到底该如何去验证令牌。
(a)、先删除可能导致,业务确实没有执行,重试还带上了之前的token,由于防重设计导致,请求还是不能执行;
(b)、后删除token问题很大,可能导致,业务处理成功,但是服务闪断,出现超时,没有删除token,别人继续重试,导致业务被执行两次;
(c)、我们最好设计为先删除token,如果业务调用失败,就重新获取token再次请求。
(a)、redis.get(token)【获取】、token.equals()【比较】、redis.del(token)【删除】、如果这三个操作不是原子的,可能导致高并发下,多个线程都获取到同样的数据,判断都成功,继续业务并发执行;
(b)、可以在redis中使用lua脚本完成这个操作,保证上述操作原子性。
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
针对前端重复连续多次点击的情况,例如用户购物提交订单,提交订单的接口就可以通过 Token 的机制实现防止重复提交。
【a】服务端生成token令牌,并保存在redis中
- @Autowired
- private StringRedisTemplate redisTemplate;
-
- public static final String USER_ORDER_TOKEN_PREFIX = "order:token:";
-
- //服务端发放token令牌,发送令牌可以在查询订单确认页的接口中进行生成,然后返回给前端,使用隐藏域保存在
- //form表单中,作为提交订单的一个参数提交到服务器进行token校验
- //防重令牌
- String orderToken = UUID.randomUUID().toString().replace("-", "");
- //order:token:{会员ID} 如: order:token:1
- //token过期时间设置为15分钟
- redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberId, orderToken,
- 15, TimeUnit.MINUTES);
- orderConfirmVo.setOrderToken(orderToken);
- return orderConfirmVo;提交到服务
【b】前端页面
提交订单的form中使用隐藏域把服务器生成的token作为form表单项,作为用户提交订单的一个参数,提交给服务器进行令牌校验。
- <form action="xxxxx" method="post">
- //.....
- <!--orderToken就是服务器返回给前端的令牌-->
- <input name="orderToken" th:value="${confirmOrderData.orderToken}" type="hidden"/>
- <button class="tijiao" type="submit">提交订单</button>
- </form>
【c】后端原子校验令牌
- //1.原子验证令牌
- //获取用户提交订单页面传递过滤的token令牌
- String orderToken = orderSubmitVo.getOrderToken();
- Long memberId = memberResponseVo.getId();
- //获取redis中的令牌【令牌的对比和删除必须保证原子性】
- //LUA脚本 返回0表示校验令牌失败 1表示删除成功,校验令牌成功
- String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
- //原子验证令牌和删除令牌
- Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberId),
- orderToken);
- if (result == 1) {
- //令牌验证成功
- //去创建、下订单、验令牌、验价格、锁定库存...
- } else {
- //令牌校验失败,返回失败信息
- }
说明:在处理提交上来的订单时,一定要通过lua脚本原子验证令牌和删除令牌机制,否则还会有漏洞。如果快速点击了两下提交按钮,这时两个请求几乎同时进来,第一个先去查询redis,然后再准备删除token的时候,第二个也查了redis,存在Token,这样还是会造成重复提交。如果使用原子验证令牌,就可以完美解决该问题。
通过以上的了解我们可以知道,针对不同的业务场景我们需要灵活的选择幂等性的实现方式。例如防止类似于前端重复提交、重复下单的场景就可以通过 Token 的机制实现,对于那些重复消费和接口重试的场景则使用数据库唯一索引的方式实现更合理。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。