1024程序员节,在此小航祝大家:
public static void main(String[] args) {
System.out.println("节日快乐!");
}
你好,我是小航,一个正在变秃、变强的文艺倾年。
本文讲解接口的幂等性,欢迎大家多多关注!
每天进步一点点,一起卷起来叭!
目录
- 前言
- 一、什么是接口幂等性?
- 二、常见场景
- 三、如何实现
- 前端拦截
- 数据库实现
- 唯一索引
- 乐观锁
- 悲观锁
- 分布式锁
- Token机制(常用)
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个最简单的例子,那就是支付
,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
对于业务中需要考虑幂等性的地方一般都是接口的重复请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:
前端重复提交
用户在新增页面上快速点击多次,造成发了多次请求,后端重复保存了多条一模一样的数据。如用户提交订单,生成很多重复的订单;
消息重复消费
消息重复消费,一般指的是消息中间件,如RabbitMQ,由于网络抖动,MQ Broker将消息发送给消费端消费,消费端进行了消费,在返回ack给MQ Broker时网络中断等原因,导致MQ Broker认为消费端没能正常消费消息,这时候MQ Broker会重复将这条消息发给消费端进行消费,如果没有做幂等,就会造成客户端重复消费同一条消息。
页面回退再次提交
举个例子,用户购买商品的时候,如果第一次点击下单按钮后,提示下单成功,跳转到下单成功页面,这时候如果用户点击浏览器返回按钮,返回上一个下单页面,重新点击下单按钮,这时候如果没有做幂等的话,也会造成重复下单
的问题。
微服务互相调用
分布式系统中,服务之间的通信一般都通过RPC或者Feign进行调用,难免网络会出小问题,导致此次请求失败,这时候这些远程调用,如feign都会触发重试机制
,所以我们也需要保证接口幂等。
对于一些业务场景影响比较大的,比如支付交易等场景,必须要实现接口的幂等,否则出现重复扣了客户的钱,可想而知后果。
前端拦截是指通过 Web 站点的页面进行请求拦截,比如在用户点击完“提交”按钮后,我们可以把按钮设置为不可用或者隐藏状态,避免用户重复点击。
核心代码:
<script>
function subCli(){
// 按钮设置为不可用
document.getElementById("btn_sub").disabled="disabled";
document.getElementById("dv1").innerText = "按钮被点击了~";
}
script>
<body style="margin-top: 100px;margin-left: 100px;">
<input type="button" value=" 提 交 " >
<div style="margin-top: 80px;">div>
body>
使用唯一索引可以避免脏数据的添加,当插入重复数据时数据库会抛异常,保证了数据的唯一性。
这里的乐观锁指的是用乐观锁的原理去实现,为数据字段增加一个version字段,当数据需要更新时,先去数据库里获取此时的version版本号
在获取数据时进行加锁,当同时有多个重复请求时其他请求都无法进行操作
幂等的本质是分布式锁的问题,分布式锁正常可以通过redis或zookeeper实现;在分布式环境下,锁定全局唯一资源,使请求串行化,实际表现为互斥锁,防止重复,解决幂等。
token机制的核心思想是为每一次操作生成一个唯一性的凭证,也就是token。一个token在操作的每一个阶段只有一次执行权,一旦执行成功则保存执行结果。对重复的请求,返回同一个结果。token机制的应用十分广泛。
举个例子:每次请求都拿着一张门票,这个门票是一次性的,用过一次就被毁掉了,不能重复利用。这个token令牌就相当于门票的概念,每次接口请求的时候带上token令牌,服务器第一次处理的时候去校验token,并且这个token只能用一次,如果用户使用相同的令牌请求二次,那么第二次就不处理,直接返回。
获取Token
,服务器会把Token保存到redis中;token携带
过去,一般作为请求参数或者请求头中传递;判断token
是否存在redis中,存在表示第一次请求,然后删除token,继续执行业务;校验token
时,redis中已经没有了刚刚被第一次删掉的token,就表示是重复操作,所以第二次请求会校验失败,不作处理,这样就保证了业务代码,不被重复执行;不过token这种方案有一定的危险性,我们分析一下:
1、先删除token【先删除token令牌,再执行业务】还是后删除token【先执行业务,再删除token令牌】;
(a)、先删除可能导致,业务确实没有执行,重试还带上了之前的token,由于防重设计导致,请求还是不能执行;
(b)、后删除token问题很大,可能导致,业务处理成功,但是服务闪断,出现超时,没有删除token,别人继续重试,导致业务被执行两次;
所以我们最好设计为先删除token,如果业务调用失败,就重新获取token再次请求。
2、在这里token获取,比较和删除必须保证原子性
redis.get(token)【获取】、token.equals()【比较】、redis.del(token)【删除】、如果这三个操作不是原子的,可能导致高并发下,多个线程都获取到同样的数据,判断都成功,继续业务并发执行,所以我们可以在redis中使用lua脚本完成这个操作,保证上述操作原子性。
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
实践:
后端生成令牌:
// 1.注入RedisTemplate
@Autowired
private StringRedisTemplate redisTemplate;
// 2.Token前缀
public static final String USER_ORDER_TOKEN_PREFIX = "order:token:";
// 3.通过UUID生成防重令牌
// order:token:{orderToken} 如: order:token:1
String orderToken = UUID.randomUUID().toString().replace("-", "");
// 4.将orderToken存入redis,并设置token过期时间设置为15分钟
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberId, orderToken,
15, TimeUnit.MINUTES);
// 5.向前端发送orderToken
return orderToken;
前端核心代码:
<form action="xxxxx" method="post">
<input name="orderToken" value="orderToken" type="hidden"/>
<button class="tijiao" type="submit">提交订单button>
form>
后端验证令牌:
// 1.原子验证令牌
// 获取用户提交订单页面传递过滤的token令牌
String orderToken = orderSubmitVo.getOrderToken();
Long memberId = memberResponseVo.getId();
// 2.原子验证令牌和删除令牌【令牌的对比和删除必须保证原子性】
// 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);
// 3.根据返回结果做业务处理
if (result == 1) {
//令牌验证成功
//去创建、下订单、验令牌、验价格、锁定库存...
} else {
//令牌校验失败,返回失败信息
}
附自定义redistemplate的配置:
@Configuration
public class RedisConfig {
//自定义的redistemplate
@Bean(name = "redisTemplate")
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory){
//创建一个RedisTemplate对象,为了方便返回key为string,value为Object
RedisTemplate<String,Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
//设置json序列化配置
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer=new
Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper=new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance);
//string的序列化
StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();
//key采用string的序列化方式
template.setKeySerializer(stringRedisSerializer);
//value采用jackson的序列化方式
template.setValueSerializer(jackson2JsonRedisSerializer);
//hashkey采用string的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
//hashvalue采用jackson的序列化方式
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
参考视频&&文章:
谷粒商城视频
接口幂等性常见的解决方案