接口幂等性
目录
幂等性介绍
代码实现接口幂等性
幂等性介绍
幂等性概念
幂等性是一个数学概念,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”字段记录版本号,读取数据时同步获取版本信息,更新时将提交版本号与数据库当前版本号比对,仅当两者一致时才执行更新操作。
修改接口如下:
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 {@Resourceprivate TOrderMapper tOrderMapper;@Overridepublic 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+分布式锁保证接口幂等性)
场景说明:创建好订单后,购物网站点击【支付】,项目模拟生成支付记录,支付记录表如下:
没做幂等性接口如下:
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 {@Resourceprivate 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 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的支持
server:port: 8080 #配置应用端口
spring:datasource:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost/ds0?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai&allowMultiQueries=trueusername: rootpassword: 123456hikari: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分钟 1800000msconnection-test-query: SELECT 1redis: #redis confighost: 127.0.0.1database: 0port: 6379
# MyBatis plus 相关的配置
mybatis-plus:mapper-locations: classpath:mappers/*.xmlconfiguration: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: infofile:name: /springboot.logpattern: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、 修改支付接口接口,让其支持幂等性
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 + sessionidprivate String orderTokenKey;@Resourceprivate TPayRecordService tPayRecordService;@Resourceprivate RedisTemplate redisTemplate;@Autowiredprivate 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();//前台传递的tokenRLock 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导致支付记录不会重复生成,符合预期。