一、什么是幂等性

幂等是一个数学与计算机学概念,在数学中某一元运算为幂等时,其作用在任一元素两次后会和其作用一次的成果相同。

在计算机中编程中,一个幂等操作的特点是其任意屡次履行所发生的影响均与一次履行的影响相同。幂等函数或幂等办法是指能够运用相同参数重复履行,并能取得相同成果的函数。这些函数不会影响体系状况,也不用忧虑重复履行会对体系造成改动。

二、什么是接口幂等性

在HTTP/1.1中,对幂等性进行了界说。它描绘了一次和屡次恳求某一个资源对于资源本身应该具有相同的成果(网络超时等问题在外),即第一次恳求的时分对资源发生了副作用,可是今后的屡次恳求都不会再对资源发生副作用。

这儿的副作用是不会对成果发生破坏或许发生不可意料的成果。也便是说,其任意屡次履行对资源本身所发生的影响均与一次履行的影响相同。

三、为什么需求完成幂等性

在接口调用时一般状况下都能正常回来信息不会重复提交,不过在遇见以下状况时能够就会出现问题,如:

  • 前端重复提交表单:在填写一些表格时分,用户填写完成提交,很多时分会因网络动摇没有及时对用户做出提交成功呼应,致运用户认为没有成功提交,然后一向点提交按钮,这时就会发生重复提交表单恳求。
  • 用户歹意进行刷单:例如在完成用户投票这种功用时,假如用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票成果与事实严峻不符。
  • 接口超时重复提交:很多时分 HTTP 客户端东西都默认敞开超时重试的机制,特别是第三方调用接口时分,为了避免网络动摇超时等造成的恳求失利,都会增加重试机制,导致一个恳求提交屡次。
  • 消息进行重复消费:当运用 MQ 消息中间件时分,假如发生消息中间件出现过错未及时提交消费信息,导致发生重复消费。

运用幂等性最大的优势在于使接口确保任何幂等性操作,免除因重试等造成体系发生的不知道的问题。

四、引进幂等性后对体系的影响

幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其首要是:

  • 把并行履行的功用改为串行履行,降低了履行效率
  • 增加了额定控制幂等的事务逻辑,复杂化了事务功用;

所以在运用时分需求考虑是否引进幂等性的必要性,依据实践事务场景具体分析,除了事务上的特殊要求外,一般状况下不需求引进的接口幂等性。

五、Restful API 接口的幂等性

现在盛行的 Restful 引荐的几种 HTTP 接口办法中,分别存在幂等行与不能确保幂等的办法,如下:

  • √ 满意幂等
  • x 不满意幂等
    • 或许满意也或许不满意幂等,依据实践事务逻辑有关
办法类型 是否幂等 描绘
Get Get 办法用于获取资源。其一般不会也不应当对体系资源进行改动,所所以幂等的。
Post Post 办法一般用于创立新的资源。其每次履行都会新增数据,所以不是幂等的。
Put Put 办法一般用于修正资源。该操作则分状况来判别是不是满意幂等,更新操作中直接依据某个值进行更新,也能坚持幂等。不过履行累加操作的更新是非幂等。
Delete Delete 办法一般用于删去资源。该操作则分状况来判别是不是满意幂等,当依据仅有值进行删去时,删去同一个数据屡次履行效果相同。不过需求留意,带查询条件的删去则就不必定满意幂等了。例如在依据条件删去一批数据后,这时分新增加了一条数据也满意条件,然后又履行了一次删去,那么将会导致新增加的这条满意条件数据也被删去。

六、如何完成幂等性

计划一:数据库仅有主键

计划描绘

数据库仅有主键的完成首要是利用数据库中主键仅有约束的特性,一般来说仅有主键比较适用于“刺进”时的幂等性,其能确保一张表中只能存在一条带该仅有主键的记载。

运用数据库仅有主键完成幂等性时需求留意的是,该主键一般来说并不是运用数据库中自增主键,而是运用分布式 ID 充任主键(能够参考 Java 中分布式 ID 的设计计划 这篇文章),这样才能能确保在分布式环境下 ID 的大局仅有性。

适用操作:

  • 刺进操作
  • 删去操作

运用约束:

  • 需求生成大局仅有主键 ID;

首要流程:

Spring Boot 实现接口幂等性的 4 种方案

首要流程:

  • ① 客户端履行创立恳求,调用服务端接口。
  • ② 服务端履行事务逻辑,生成一个分布式 ID,将该 ID 充任待刺进数据的主键,然后执数据刺进操作,运转对应的 SQL 句子。
  • ③ 服务端将该条数据刺进数据库中,假如刺进成功则表明没有重复调用接口。假如抛出主键重复反常,则表明数据库中现已存在该条记载,回来过错信息到客户端。

计划二:数据库达观锁

计划描绘:

[数据库达观锁计划一般只能适用于履行“更新操作”的过程,咱们能够提前在对应的数据表中多增加一个字段,充任当时数据的版别标识。这样每次对该数据库该表的这条数据履行更新时,都会将该版别标识作为一个条件,值为上次待更新数据中的版别标识的值。]

适用操作:

  • 更新操作

运用约束:

  • 需求数据库对应事务表中增加额定字段;

描绘示例:

Spring Boot 实现接口幂等性的 4 种方案

例如,存在如下的数据表中:

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=1 AND version=5 被履行后,id=1 的 version 被更新为 6,所以假如重复履行该条 SQL 句子将不收效,由于 id=1 AND version=5 的数据现已不存在,这样就能保住更新的幂等,屡次更新对成果不会发生影响。

计划三:防重 Token 令牌

计划描绘:

[针对客户端接连点击或许调用方的超时重试等状况,例如提交订单,此种操作就能够用 Token 的机制完成避免重复提交。简略的说便是调用方在调用接口的时分先向后端恳求一个大局 ID(Token),恳求的时分带着这个大局 ID 一起恳求(Token 最好将其放到 Headers 中),后端需求对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,假如 Key 存在且 Value 匹配就履行删去指令,然后正常履行后边的事务逻辑。假如不存在对应的 Key 或 Value 不匹配就回来重复履行的过错信息,这样来确保幂等操作。]

适用操作:

  • 刺进操作
  • 更新操作
  • 删去操作

运用约束:

  • 需求生成大局仅有 Token 串;
  • 需求运用第三方组件 Redis 进行数据效验;

首要流程:

Spring Boot 实现接口幂等性的 4 种方案

  • ① 服务端提供获取 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 进行数据效验;

首要流程:

Spring Boot 实现接口幂等性的 4 种方案

首要过程:

  • ① 下流服务生成分布式 ID 作为序列号,然后履行恳求调用上游接口,并顺便“仅有序列号”与恳求的“认证凭据ID”。
  • ② 上游服务进行安全效验,检测下流传递的参数中是否存在“序列号”和“凭据ID”。
  • ③ 上游服务到 Redis 中检测是否存在对应的“序列号”与“认证ID”组成的 Key,假如存在就抛出重复履行的反常信息,然后呼应下流对应的过错信息。假如不存在就以该“序列号”和“认证ID”组合作为 Key,以下流要害信息作为 Value,从而存储到 Redis 中,然后正常履行接来来的事务逻辑。

上面过程中刺进数据到 Redis 必定要设置过期时刻。这样能确保在这个时刻范围内,假如重复调用接口,则能够进行判别识别。假如不设置过期时刻,很或许导致数据无限量的存入 Redis,致使 Redis 不能正常作业。

七、完成接口幂等示例

这儿运用防重 Token 令牌计划,该计划能确保在不同恳求动作下的幂等性,完成逻辑能够看上面写的”防重 Token 令牌”计划,接下来写下完成这个逻辑的代码。

1、Maven 引进相关依靠

这儿运用 Maven 东西办理依靠,这儿在 pom.xml 中引进 SpringBoot、Redis、lombok 相关依靠。

<?xmlversion="1.0"encoding="UTF-8"?>
<projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
</parent>
<groupId>mydlq.club</groupId>
<artifactId>springboot-idempotent-token</artifactId>
<version>0.0.1</version>
<name>springboot-idempotent-token</name>
<description>IdempotentDemo</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--springbootweb-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--springbootdataredis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

2、装备衔接 Redis 的参数

在 application 装备文件中装备衔接 Redis 的参数。Spring Boot 基础就不介绍了,最新教程引荐看下面的教程。

如下:

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";
RedisScript<Long>redisScript=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,"重复调用");
}
}
}
}

阿里面试考题 | 冲击一线大厂,可下载 pdf

显示如下:

[main] IdempotenceTest:获取的 Token 串:560ea707-ce2e-456e-a059-0a03332222vx
[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 串;

来源:mydlq.club/article/94