大家好,我是一安,最近在整理一个电商项目时,里面有提到接口的幂等性,故特意整理一番,希望对大家有所帮助
什么是幂等性
就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
比如你在某宝或某东上买东西,选好以后提交订单,支付时稍微卡顿一下,你点了两下支付,结果你莫名其妙的被扣了两次款,你是不是很郁闷,所以在实际业务中有很多情况都需要保证接口的幂等性,那都有哪些常见的场景呢?
-
前端重复提交:提交订单,用户重复点击多次,造成后端生成多个内容重复的订单。 -
接口超时重试:调用第三方接口,为了防止网络抖动或其他原因造成请求丢失,这样的接口一般都会设计成超时重试多次。 -
消息重复消费:MQ消息中间件,消息重复消费。 -
用户页面回退再次提交。 -
微服务互相调用:由于网络问题,导致请求失败,触发feign重试机制。
对于一些业务场景影响比较大的,比如支付交易等场景,必须要实现接口的幂等,否则出现重复扣了客户的钱
常见的解决方案
前端防重
在前端拦截一部分,例如防止表单重复提交,按钮置灰、隐藏、按钮不可点击等方式。
但是前端做控制实际效果不是很好,别人模拟请求调用你的服务,所以安全的策略最好还是通过后端的接口层来做。
后端防重
token机制
-
1、如果业务存在幂等问题的,则先去服务端获取token,服务器把 token 保存到redis 中,前端一般隐藏在页面中。 -
2、在调用业务接口请求时会 携带token 过去,一般放在请求头部。 -
3、业务接口首先判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务。 -
4、如果判断 token 不存在 redis 中,就表示是重复操作,直接返给前端重复提交。
使用token一定要保证读和删的原子性,不然会出现以下问题:
-
先删除可能导致,业务没有执行,重试还带上之前 token,但实际已经删除导致业务无法正常执行成功 -
后删除可能导致,业务处理成功,但由于某种原因出现超时,没有执行删除 token,重试导致业务被执行两边
解决方法,利用redis的lua脚本,也可以适用于分布式环境下:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
数据库悲观锁

通常情况下通过如下sql锁住单行数据:
select * from user id=123 for update;
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表。
数据库乐观锁
update table set count = count -1 , version = version + 1 where id=2 and version = 1
这种方法适合在更新的场景中,根据 version 版本,也就是在操作库存前先获取当前商品的 version 版本号,然后操作的时候带上此 version 号。
即我们第一次操作库存时,得到 version 为 1,调用库存服务 version 变成了 2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,但订单服务传的 version 还是 1,再执行上面的 sql 语句时,就不会执行;因为 version 已经变 为 2 了,where 条件就不成立。这样就保证了不管调用几次,只会处理一次。乐观锁主要使用于处理读多写少的问题
业务层分布式锁
如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数 据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过。
有关分布式锁介绍,请查看实现Redis分布式锁,你会几种方式?
数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。我们在数据库层面防止重复。这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键的要求不是自增的主键,这样就需要业务生成全局唯一的主键。如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
防重表
使用订单号 orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且 他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个 事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。
全局请求唯一 id
调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。可以使用 nginx 设置每一个请求的唯一 id;
proxy_set_header X-Request-Id $request_id;
基于token伪代码
伪代码示例来自尚硅谷公开课:谷粒商城高级篇
生成订单:
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
// 构建OrderConfirmVo
OrderConfirmVo confirmVo = new OrderConfirmVo();
// 获取当前用户登录的信息
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
//TODO :获取当前线程请求头信息(解决Feign异步调用丢失请求头问题)
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
// 开启第一个异步任务
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(()->{
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
// 1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberResponseVo.getId());
confirmVo.setAddress(address);
}, threadPoolExecutor);
//开启第二个异步任务
CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> {
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();
confirmVo.setItems(currentCartItems);
//feign在远程调用之前要构造请求,调用很多的拦截器
}, threadPoolExecutor).thenRunAsync(() -> {
List<OrderItemVo> items = confirmVo.getItems();
//获取全部商品的id
List<Long> skuIds = items.stream()
.map((itemVo -> itemVo.getSkuId()))
.collect(Collectors.toList());
//远程查询商品库存信息
R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);
List<SkuStockVo> skuStockVos = skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {});
if (skuStockVos != null && skuStockVos.size() > 0) {
//将skuStockVos集合转换为map
Map<Long, Boolean> skuHasStockMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
confirmVo.setStocks(skuHasStockMap);
}
},threadPoolExecutor);
// 3、查询用户积分
Integer integration = memberResponseVo.getIntegration();
confirmVo.setIntegration(integration);
// 4、价格数据自动计算
//TODO 5、防重令牌(防止表单重复提交)
//为用户设置一个token,三十分钟过期时间(存在redis)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(), token,30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);
// 异步编排
CompletableFuture.allOf(addressFuture,cartInfoFuture).get();
return confirmVo;
}
有关异步编排介绍,请查看复杂业务下,如何实现多任务的异步编排
提交订单:
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
confirmVoThreadLocal.set(vo);
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
//去创建、下订单、验令牌、验价格、锁定库存...
//获取当前用户登录的信息
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
responseVo.setCode(0);
//1、验证令牌是否合法【令牌的对比和删除必须保证原子性】
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//通过lua脚本原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
orderToken);
if (result == 0L) {
//令牌验证失败
responseVo.setCode(1);
return responseVo;
} else {
//令牌验证成功
//1、创建订单、订单项等信息
OrderCreateTo order = createOrder();
//后边的代码省略...
}
}
号外!号外!
如果这篇文章对你有所帮助,或者有所启发的话,帮忙点赞、在看、转发、收藏,你的支持就是我坚持下去的最大动力!
原文始发于微信公众号(一安未来):一文教你如何实现接口的幂等性
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/44947.html