SpringBoot实现接口幂等性的4种方案
一、什么是幂等性
幂等是一个数学与计算机学概念,在数学中某一元运算为幂等时,其作用在任一元素两次后会和其作用一次的结果相同。
在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
二、什么是接口幂等性
在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
三、为什么需要实现幂等性
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:
- 前端重复提交表单:在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
- 用户恶意进行刷单:例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
- 接口超时重复提交:很多时候HTTP客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
- 消息进行重复消费:当使用MQ消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
四、引入幂等性后对系统的影响
幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:
- 把并行执行的功能改为串行执行,降低了执行效率。
- 增加了额外控制幂等的业务逻辑,复杂化了业务功能;
所以在使用时候需要考虑是否引入幂等性的必要性,根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性。
五、RestfulAPI接口的幂等性
现在流行的Restful推荐的几种HTTP接口方法中,分别存在幂等行与不能保证幂等的方法,如下:
- √满足幂等
- x不满足幂等
- -可能满足也可能不满足幂等,根据实际业务逻辑有关
方法类型 | 是否幂等 | 描述 |
---|---|---|
Get | √ | Get方法用于获取资源。其一般不会也不应当对系统资源进行改变,所以是幂等的。 |
Post | × | Post方法一般用于创建新的资源。其每次执行都会新增数据,所以不是幂等的。 |
Put | - | Put方法一般用于修改资源。该操作则分情况来判断是不是满足幂等,更新操作中直接根据某个值进行更新,也能保持幂等。不过执行累加操作的更新是非幂等。 |
Delete | - | Delete方法一般用于删除资源。该操作则分情况来判断是不是满足幂等,当根据唯一值进行删除时,删除同一个数据多次执行效果一样。不过需要注意,带查询条件的删除则就不一定满足幂等了。例如在根据条件删除一批数据后,这时候新增加了一条数据也满足条件,然后又执行了一次删除,那么将会导致新增加的这条满足条件数据也被删除。 |
六、如何实现幂等性
方案一:数据库唯一主键
方案描述
数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式ID充当主键(可以参考Java中分布式ID的设计方案这篇文章),这样才能能保证在分布式环境下ID的全局唯一性。
适用操作:
- 插入操作
- 删除操作
使用限制:
需要生成全局唯一主键ID;
主要流程:
主要流程:
①客户端执行创建请求,调用服务端接口。
②服务端执行业务逻辑,生成一个分布式ID,将该ID充当待插入数据的主键,然后执数据插入操作,运行对应的SQL语句。
③服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。
方案二:数据库乐观锁
方案描述:
数据库乐观锁方案一般只能适用于执行“更新操作”的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值。
适用操作:
- 更新操作
使用限制:
需要数据库对应业务表中添加额外字段;
描述示例:
例如,存在如下的数据表中:
id | name | price |
---|---|---|
1 | 小米手机 | 1000 |
2 | 苹果手机 | 2500 |
3 | 华为手机 | 1600 |
为了每次执行更新时防止重复更新,确定更新的一定是要更新的内容,我们通常都会添加一个version字段记录当前的记录版本,这样在更新时候将该值带上,那么只要执行更新操作就能确定一定更新的是某个对应版本下的信息。
id | name | price | version |
---|---|---|---|
1 | 小米手机 | 1000 | 10 |
2 | 苹果手机 | 2500 | 21 |
3 | 华为手机 | 1600 | 5 |
这样每次执行更新时候,都要指定要更新的版本号,如下操作就能准确更新version=5的信息:
UPDATEmy_tableSETprice=price+50,version=version+1WHEREid=1ANDversion=5
上面WHERE后面跟着条件id=1ANDversion=5被执行后,id=1的version被更新为6,所以如果重复执行该条SQL语句将不生效,因为id=1ANDversion=5的数据已经不存在,这样就能保住更新的幂等,多次更新对结果不会产生影响。
方案三:防重Token令牌
方案描述:
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用Token的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局ID(Token),请求的时候携带这个全局ID一起请求(Token最好将其放到Headers中),后端需要对这个Token作为Key,用户信息作为Value到Redis中进行键值内容校验,如果Key存在且Value匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的Key或Value不匹配就返回重复执行的错误信息,这样来保证幂等操作。
适用操作:
- 插入操作
- 更新操作
- 删除操作
使用限制:
- 需要生成全局唯一Token串;
- 需要使用第三方组件Redis进行数据效验;
主要流程:
①服务端提供获取Token的接口,该Token可以是一个序列号,也可以是一个分布式ID或者UUID串。
②客户端调用接口获取Token,这时候服务端会生成一个Token串。
③然后将该串存入Redis数据库中,以该Token作为Redis的键(注意设置过期时间)。
④将Token返回到客户端,客户端拿到后应存到表单隐藏域中。
⑤客户端在执行提交表单时,把Token存入到Headers中,执行业务请求带上该Headers。
⑥服务端接收到请求后从Headers中拿到Token,然后根据Token到Redis中查找该key是否存在。
⑦服务端根据Redis中是否存该key进行判断,如果存在就将该key删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。
注意,在并发情况下,执行Redis查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用Lua表达式来注销查询与删除操作。
方案四、下游传递唯一序列号
方案描述:
所谓请求序列号,其实就是每次向服务端请求时候附带一个短时间内唯一不重复的序列号,该序列号可以是一个有序ID,也可以是一个订单号,一般由下游生成,在调用上游服务端接口时附加该序列号和用于认证的ID。
当上游服务器收到请求信息后拿取该序列号和下游认证ID进行组合,形成用于操作Redis的Key,然后到Redis中查询是否存在对应的Key的键值对,根据其结果:
- 如果存在,就说明已经对该下游的该序列号的请求进行了业务处理,这时可以直接响应重复请求的错误信息。
- 如果不存在,就以该Key作为Redis的键,以下游关键信息作为存储的值(例如下游商传递的一些业务逻辑信息),将该键值对存储到Redis中,然后再正常执行对应的业务逻辑即可。
适用操作:
- 插入操作
- 更新操作
- 删除操作
使用限制:
- 要求第三方传递唯一序列号;
- 需要使用第三方组件Redis进行数据效验;
主要流程:
主要步骤:
①下游服务生成分布式ID作为序列号,然后执行请求调用上游接口,并附带“唯一序列号”与请求的“认证凭据ID”。
②上游服务进行安全效验,检测下游传递的参数中是否存在“序列号”和“凭据ID”。
③上游服务到Redis中检测是否存在对应的“序列号”与“认证ID”组成的Key,如果存在就抛出重复执行的异常信息,然后响应下游对应的错误信息。如果不存在就以该“序列号”和“认证ID”组合作为Key,以下游关键信息作为Value,进而存储到Redis中,然后正常执行接来来的业务逻辑。
上面步骤中插入数据到Redis一定要设置过期时间。这样能保证在这个时间范围内,如果重复调用接口,则能够进行判断识别。如果不设置过期时间,很可能导致数据无限量的存入Redis,致使Redis不能正常工作。
七、实现接口幂等示例
这里使用防重Token令牌方案,该方案能保证在不同请求动作下的幂等性,实现逻辑可以看上面写的”防重Token令牌”方案,接下来写下实现这个逻辑的代码。
1、Maven引入相关依赖
这里使用Maven工具管理依赖,这里在pom.xml中引入SpringBoot、Redis、lombok相关依赖。
4.0.0 org.springframework.boot spring-boot-starter-parent 2.3.4.RELEASE mydlq.club springboot-idempotent-token 0.0.1 springboot-idempotent-token IdempotentDemo 1.8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-data-redis org.apache.commons commons-pool2 org.projectlombok lombok org.springframework.boot spring-boot-maven-plugin
2、配置连接Redis的参数
在application配置文件中配置连接Redis的参数。SpringBoot基础就不介绍了,最新教程推荐看下面的教程。
github.com/javastacks/…
如下:
spring: redis: ssl:false host:127.0.0.1 port:6379 database:0 timeout:1000 password: lettuce: pool: max-active:100 max-wait:-1 min-idle:0 max-idle:20
3、创建与验证Token工具类
创建用于操作Token相关的Service类,里面存在Token创建与验证方法,其中:
- Token创建方法:使用UUID工具创建Token串,设置以“idempotent_token:“+“Token串”作为Key,以用户信息当成Value,将信息存入Redis中。
- Token验证方法:接收Token串参数,加上Key前缀形成Key,再传入value值,执行Lua表达式(Lua表达式能保证命令执行的原子性)进行查找对应Key与删除操作。执行完成后验证命令的返回结果,如果结果不为空且非0,则验证成功,否则失败。
importjava.util.Arrays; importjava.util.UUID; importjava.util.concurrent.TimeUnit; importlombok.extern.slf4j.Slf4j; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.data.redis.core.StringRedisTemplate; importorg.springframework.data.redis.core.script.DefaultRedisScript; importorg.springframework.data.redis.core.script.RedisScript; importorg.springframework.stereotype.Service; @Slf4j @Service publicclassTokenUtilService{ @Autowired privateStringRedisTemplateredisTemplate; /** *存入Redis的Token键的前缀 */ privatestaticfinalStringIDEMPOTENT_TOKEN_PREFIX="idempotent_token:"; /** *创建Token存入Redis,并返回该Token * *@paramvalue用于辅助验证的value值 *@return生成的Token串 */ publicStringgenerateToken(Stringvalue){ //实例化生成ID工具对象 Stringtoken=UUID.randomUUID().toString(); //设置存入Redis的Key Stringkey=IDEMPOTENT_TOKEN_PREFIX+token; //存储Token到Redis,且设置过期时间为5分钟 redisTemplate.opsForValue().set(key,value,5,TimeUnit.MINUTES); //返回Token returntoken; } /** *验证Token正确性 * *@paramtokentoken字符串 *@paramvaluevalue存储在Redis中的辅助验证信息 *@return验证结果 */ publicbooleanvalidToken(Stringtoken,Stringvalue){ //设置Lua脚本,其中KEYS[1]是key,KEYS[2]是value Stringscript="ifredis.call('get',KEYS[1])==KEYS[2]thenreturnredis.call('del',KEYS[1])elsereturn0end"; RedisScriptredisScript=newDefaultRedisScript<>(script,Long.class); //根据Key前缀拼接Key Stringkey=IDEMPOTENT_TOKEN_PREFIX+token; //执行Lua脚本 Longresult=redisTemplate.execute(redisScript,Arrays.asList(key,value)); //根据返回结果判断是否成功成功匹配并删除Redis键值对,若果结果不为空和0,则验证通过 if(result!=null&&result!=0L){ log.info("验证token={},key={},value={}成功",token,key,value); returntrue; } log.info("验证token={},key={},value={}失败",token,key,value); returnfalse; } }
4、创建测试的Controller类
创建用于测试的Controller类,里面有获取Token与测试接口幂等性的接口,内容如下:
importlombok.extern.slf4j.Slf4j; importmydlq.club.example.service.TokenUtilService; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.web.bind.annotation.*; @Slf4j @RestController publicclassTokenController{ @Autowired privateTokenUtilServicetokenService; /** *获取Token接口 * *@returnToken串 */ @GetMapping("/token") publicStringgetToken(){ //获取用户信息(这里使用模拟数据) //注:这里存储该内容只是举例,其作用为辅助验证,使其验证逻辑更安全,如这里存储用户信息,其目的为: //-1)、使用"token"验证Redis中是否存在对应的Key //-2)、使用"用户信息"验证Redis的Value是否匹配。 StringuserInfo="mydlq"; //获取Token字符串,并返回 returntokenService.generateToken(userInfo); } /** *接口幂等性测试接口 * *@paramtoken幂等Token串 *@return执行结果 */ @PostMapping("/test") publicStringtest(@RequestHeader(value="token")Stringtoken){ //获取用户信息(这里使用模拟数据) StringuserInfo="mydlq"; //根据Token和与用户相关的信息到Redis验证是否存在对应的信息 booleanresult=tokenService.validToken(token,userInfo); //根据验证结果响应不同信息 returnresult?"正常调用":"重复调用"; } }
5、创建SpringBoot启动类
创建启动类,用于启动SpringBoot应用。基础教程就不介绍了,建议看下下面的教程,很全了。
importorg.springframework.boot.SpringApplication; importorg.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication publicclassApplication{ publicstaticvoidmain(String[]args){ SpringApplication.run(Application.class,args); } }
6、写测试类进行测试
写个测试类进行测试,多次访问同一个接口,测试是否只有第一次能否执行成功。
importorg.junit.Assert; importorg.junit.Test; importorg.junit.runner.RunWith; importlombok.extern.slf4j.Slf4j; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.boot.test.context.SpringBootTest; importorg.springframework.http.MediaType; importorg.springframework.test.context.junit4.SpringRunner; importorg.springframework.test.web.servlet.MockMvc; importorg.springframework.test.web.servlet.request.MockMvcRequestBuilders; importorg.springframework.test.web.servlet.setup.MockMvcBuilders; importorg.springframework.web.context.WebApplicationContext; @Slf4j @SpringBootTest @RunWith(SpringRunner.class) publicclassIdempotenceTest{ @Autowired privateWebApplicationContextwebApplicationContext; @Test publicvoidinterfaceIdempotenceTest()throwsException{ //初始化MockMvc MockMvcmockMvc=MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); //调用获取Token接口 Stringtoken=mockMvc.perform(MockMvcRequestBuilders.get("/token") .accept(MediaType.TEXT_HTML)) .andReturn() .getResponse().getContentAsString(); log.info("获取的Token串:{}",token); //循环调用5次进行测试 for(inti=1;i<=5;i++){ log.info("第{}次调用测试接口",i); //调用验证接口并打印结果 Stringresult=mockMvc.perform(MockMvcRequestBuilders.post("/test") .header("token",token) .accept(MediaType.TEXT_HTML)) .andReturn().getResponse().getContentAsString(); log.info(result); //结果断言 if(i==0){ Assert.assertEquals(result,"正常调用"); }else{ Assert.assertEquals(result,"重复调用"); } } } }
显示如下:
[main]IdempotenceTest: 获取的Token串:980ea707-ce2e-456e-a059-0a03332110b4
[main]IdempotenceTest: 第1次调用测试接口
[main]IdempotenceTest: 正常调用
[main]IdempotenceTest: 第2次调用测试接口
[main]IdempotenceTest: 重复调用
[main]IdempotenceTest: 第3次调用测试接口
[main]IdempotenceTest: 重复调用
[main]IdempotenceTest: 第4次调用测试接口
[main]IdempotenceTest: 重复调用
[main]IdempotenceTest: 第5次调用测试接口
[main]IdempotenceTest: 重复调用
八、最后总结
幂等性是开发当中很常见也很重要的一个需求,尤其是支付、订单等与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:
对于下单等存在唯一主键的,可以使用“唯一主键方案”的方式实现。
对于更新订单状态等相关的更新场景操作,使用“乐观锁方案”实现更为简单。
对于上下游这种,下游请求上游,上游服务可以使用“下游传递唯一序列号方案”更为合理。
类似于前端重复提交、重复下单、没有唯一ID号的场景,可以通过Token与Redis配合的“防重Token方案”实现更为快捷。
上面只是给与一些建议,再次强调一下,实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统的正常运行。最后做一个简单总结,然后本博文到此结束,如下:
方案名称 | 适用方法 | 实现复杂度 | 方案缺点 |
---|---|---|---|
数据库唯一主键 | 插入操作删除操作 | 简单 | 只能用于插入操作;-只能用于存在唯一主键场景; |
数据库乐观锁 | 更新操作 | 简单 | 只能用于更新操作;-表中需要额外添加字段; |
请求序列号 | 插入操作更新操作删除操作 | 简单 | 需要保证下游生成唯一序列号;-需要Redis第三方存储已经请求的序列号; |
防重Token令牌 | 插入操作更新操作删除操作 | 适中 | 需要Redis第三方存储生成的Token串; |
到此这篇关于SpringBoot实现接口幂等性的4种方案的文章就介绍到这了,更多相关SpringBoot接口幂等性内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。