目录
幂等性介绍
幂等性概念
幂等性是一个数学概念,f(f(x))=f(x),对一个函数多次作用后和第一次结果相同。引述到实际项目中,接口的幂等性就是无论此接口运行几次,运行结果都和运行一次结果一致。
使用接口幂等性场景
**不是所有的接口都需要幂等性,要根据业务场景而定,**大部分业务场景服务接口是不需要幂等性的。常用的场景如下:
1、重复提交的场景,如购物网站快速多次点击提交订单,防止生成多个订单
2、接口重试,在微服务架构下,服务接口重试,防止服务调用重复
各类操作接口幂等性
1、select 接口:查询接口天然支持幂等性,不需要额外增加控制幂等性的业务逻辑
2、delete接口:删除接口,删除一次资源和删除多次资源结果相同,满足幂等性
3、update接口:分情况而定,如存在累加更新次数场景就不满足幂等性,增加数据版本号,通过乐观锁实现幂等性
4、insert接口:可能重复创建资源,不满足幂等性。通过token+分布式锁保证接口幂等性
5、复杂混合操作接口:包含了各种insert、update、delete等操作的接口,操作逻辑参考4,使用token+分布式锁保证接口幂等性
代码实现接口幂等性
项目背景如下:
项目中有订单表t_order,结构说明及初始数据如下:


1、修改接口实现幂等性(增加数据版本号,通过乐观锁实现幂等性)
乐观锁是数据库并发控制中的一种机制,通过数据版本记录实现事务处理的非阻塞性访问。其核心原理是为数据表增加"version"字段记录版本号,读取数据时同步获取版本信息,更新时将提交版本号与数据库当前版本号比对,仅当两者一致时才执行更新操作。
修改接口如下:
java
package com.gingko.interfaceidempotence.service.impl;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.gingko.interfaceidempotence.entity.TOrder;
import com.gingko.interfaceidempotence.mapper.TOrderMapper;
import com.gingko.interfaceidempotence.service.TOrderService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
@Transactional
public class TOrderServiceImpl implements TOrderService {
@Resource
private TOrderMapper tOrderMapper;
@Override
public void update(TOrder tOrder) {
int orderUpdateCountOld = tOrder.getOrderUpdateCount();
tOrder.setOrderUpdateCount(orderUpdateCountOld + 1);//设置更新次数
int orderVersionOld = tOrder.getOrderVersion();
tOrder.setOrderVersion(orderVersionOld + 1);//设置更新版本
//update t_order set order_update_count = order_update_count + 1 ,order_version = order_version + 1
// where order_id = ? and order_version = ?
UpdateWrapper<TOrder> updateWrapper = Wrappers.update();
updateWrapper.eq("order_id",tOrder.getOrderId());
updateWrapper.eq("order_version",orderVersionOld);
tOrderMapper.update(tOrder, updateWrapper);
}
}
通过jmeter模拟5个线程快速重复提交请求,通过控制台sql发现,只有第一个线程请求更新了数据库记录,其他4个线程没有数据库更新,最终更新次数order_update_count 是1,数据版本是1,接口满足幂等性。




2、新增或复合接口实现幂等性(token+分布式锁保证接口幂等性)
场景说明:创建好订单后,购物网站点击【支付】,项目模拟生成支付记录,支付记录表如下:

没做幂等性接口如下:
java
package com.gingko.interfaceidempotence.controller;
import com.gingko.interfaceidempotence.entity.TPayRecord;
import com.gingko.interfaceidempotence.service.TPayRecordService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.UUID;
@RestController
@RequestMapping("payRecord")
public class PayRecordController {
@Resource
private TPayRecordService tPayRecordService;
@PostMapping("/genPayRecord")
public String genPayRecord(@RequestBody TPayRecord tPayRecord) {
String payRecordId = UUID.randomUUID().toString();
tPayRecord.setPayRecordId(payRecordId);
this.tPayRecordService.genPayRecord(tPayRecord);
return "success";
}
}
通过Jemter 5个线程模拟快速重复点击页面【支付】, 一个订单数据库生成了5笔支付记录,相当于支付了5次,显然不符合幂等性要求。


接口为了达到幂等性要求,实现的思想是:
1、后台生成一个业务唯一标识token(在分布式环境下,建议放在redis中保存)
2、前台请求后台支付接口时带上token
3、后台生成支付记录接口(即需要满足业务接口幂等性要求的接口)内部校验token的准确性和时效性,不满足直接抛出异常,认为是前台伪造的token
4、当前台请求带入的token满足准确性和时效性,通过分布式锁(本文基于Redisson实现分布式锁)控制重复提交,即控制只生成1条支付记录
代码修改如下:
1、项目增加redis和redission的支持

XML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="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.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gingko</groupId>
<artifactId>interface-idempotence</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>interface-idempotence</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.6</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.16</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.14.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.gingko.interfaceidempotence.InterfaceIdempotenceApplication</mainClass>
<skip>true</skip>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
2、配置增加redis的支持

XML
server:
port: 8080 #配置应用端口
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost/ds0?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai&allowMultiQueries=true
username: root
password: 123456
hikari:
connection-timeout: 30000 # 等待连接池分配连接的最大时长(毫秒),超过这个时长还没可用的连接则发生SQLException, 默认:30秒
minimum-idle: 10 # 最小连接数
maximum-pool-size: 50 # 最大连接数
auto-commit: true # 自动提交
idle-timeout: 600000 # 连接超时的最大时长(毫秒),超时则被释放(retired),默认:10分钟
pool-name: DateSourceHikariCP # 连接池名字
max-lifetime: 1800000 # 连接的生命时长(毫秒),超时而且没被使用则被释放(retired),默认:30分钟 1800000ms
connection-test-query: SELECT 1
redis: #redis config
host: 127.0.0.1
database: 0
port: 6379
# MyBatis plus 相关的配置
mybatis-plus:
mapper-locations: classpath:mappers/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启mybatis的日志实现,可以在控制台打印输入sql语句,debug使用,生产时需要关掉
map-underscore-to-camel-case: true #配置文件需要开启驼峰命名映射,只有这样,才能映射到字段,从而创建出不为空的对象
type-aliases-package: com.gingko.interfaceidempotence.entity # 所有数据库表逆向后所一一映射的实体类
# 通用mapper配置
mapper:
mappers: com.baomidou.mybatisplus.core.mapper.BaseMapper # 所有Mapper都需要实现的接口
not-empty: false # 在进行数据库操作的时候,判断一个属性是否为空的时候,是否需要自动追加部位空字符串的判断
identity: MYSQL
#日志配置
logging:
level:
root: info
file:
name: /springboot.log
pattern:
console: "%d{yyyy/MM/dd-HH:mm:ss} [%thread] %-5level %logger{50}- %msg%n"
file: "%d{yyyy/MM/dd-HH:mm:ss} ---- [%thread] %-5level %logger{50}- %msg%n"
3、 修改支付接口接口,让其支持幂等性

java
package com.gingko.interfaceidempotence.controller;
import com.gingko.interfaceidempotence.entity.TPayRecord;
import com.gingko.interfaceidempotence.service.TPayRecordService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("payRecord")
@Slf4j
public class PayRecordController {
//order token key:项目设计为:order_token + sessionid
private String orderTokenKey;
@Resource
private TPayRecordService tPayRecordService;
@Resource
private RedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
@PostMapping("/genToken")
public String genToken(HttpSession session) {
String sessionId = session.getId();
log.info("sessionId:{}",sessionId);
this.orderTokenKey = "order_token_" + sessionId;
String token = UUID.randomUUID().toString();
//放入redis中,10分钟后过期
redisTemplate.opsForValue().set(this.orderTokenKey,token,
600, TimeUnit.SECONDS);
return token;
}
@PostMapping("/genPayRecord")
public String genPayRecord(@RequestBody TPayRecord tPayRecord) {
//加入分布式锁
//唯一业务标识校验
String token = tPayRecord.getToken();//前台传递的token
RLock lock = redissonClient.getLock(token);
lock.lock(5,TimeUnit.SECONDS);//上锁5秒,根据业务情况定
//业务逻辑
try {
String tokenFromRedis = (String) redisTemplate.opsForValue().get(this.orderTokenKey);
if(tokenFromRedis == null) {
throw new RuntimeException("token为空");
}
if(!tokenFromRedis.equals(token)) {
throw new RuntimeException("token不正确");
}
log.info("token正确...");
/**
* 第一次执行完成删除redis中的token,其他并发请求再次进来后由于没有token抛出异常,
* 进而实现防止重复生成多笔支付记录,满足了接口幂等性
* 由于orderTokenKey包含了sessionid信息,所以能保证不影响其他用户生成支付记录
*/
redisTemplate.delete(this.orderTokenKey);
}finally {
lock.unlock();//释放锁
}
//生成支付记录逻辑
String payRecordId = UUID.randomUUID().toString();
tPayRecord.setPayRecordId(payRecordId);
this.tPayRecordService.genPayRecord(tPayRecord);
return "success";
}
}
4、Jemter模拟5个线程并发请求生成支付记录,最终结果如下:
最终只生成了1条支付记录,其他请求获得锁时,由于没有token导致支付记录不会重复生成,符合预期。


