接口幂等性解决方案实战---token机制
一 场景
在学习中刚接触到幂等性的时候,很多人都会觉得挺高大上的,是不是技术很牛逼的人才能搞得明白是啥东西,其实不然,像我这样的菜鸟也还是多少能理解一点的。而且这也确实是作为码农必须要花点时间思考的问题。很多时候一旦我们写的接口不能保证幂等性,是会出大问题的。
有这样一个场景:数据库idempotence有一张表account,里面有一个用户idempotence,中文名 爱•单婆•疼死, 账号有两万块钱,现在idempotence要买台电脑,电脑的价格是一万块钱。如下图
二 幂等性问题
我们用最简单的方法,传入账户id和要扣减的金额money,调用我们的扣减账户余额接口,项目结构及代码如下
-------------------------------------------------
IdempotenceApplication
-------------------------------------------------
package com.study.idempotence;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IdempotenceApplication {
public static void main(String[] args) {
SpringApplication.run(IdempotenceApplication.class, args);
}
}
-------------------------------------------------
AccountController
-------------------------------------------------
package com.study.idempotence.controller;
import com.study.idempotence.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Author guangwenyin
* @Date 2021/12/14
*/
@RestController
public class AccountController {
@Autowired
private AccountService accountService;
@RequestMapping("/decreaseAccount")
public String decreaseAccount(Integer id,Double money){
System.out.println("来扣钱了");
Integer result = accountService.minusAccount(id, money);
return result>0?"success":"failed";
}
}
-------------------------------------------------
AccountService
-------------------------------------------------
package com.study.idempotence.service;
import com.study.idempotence.dao.AccountDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* @Author guangwenyin
* @Date 2021/12/14
*/
@Service
public class AccountService {
@Autowired
AccountDao accountDao;
public Integer minusAccount(Integer id,Double money){
return accountDao.updateAccount(id,money);
}
}
-------------------------------------------------
AccountDao
-------------------------------------------------
package com.study.idempotence.dao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* @Author guangwenyin
* @Date 2021/12/14
*/
@Component
public class AccountDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 扣减金额的方法
* @param id 用户id
* @param money 扣减金额数目
* @return
*/
public Integer updateAccount(Integer id,Double money){
Map<String, Object> stringObjectMap = jdbcTemplate.queryForMap("select balance from account where id = ?", id);
Double balance= (Double) stringObjectMap.get("balance");
if(balance<money){
return 0;
}
return jdbcTemplate.update("update account set balance = balance - ? where id = ?", money, id);
}
}
-------------------------------------------------
application.properties
-------------------------------------------------
spring.datasource.url=jdbc:mysql://localhost:3306/idempotence?rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=123456
----------------------------------
pom.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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.study</groupId>
<artifactId>idempotence</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>idempotence</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
项目启动后,调用接口地址:http://localhost:8080/decreaseAccount?id=1&money=10000
正常情况:
似乎一切安好
but...
异常情况:由于网络原因支付页面点了支付没有及时响应,以为没点到,又点了几下
买一万块钱的东西,成功付款两次一万,商家很开心,顾客可要炸了
没错,这就要扯到接口幂等性问题 了
对此网上的定义有不少,以下是我觉得比较简单也容易理解的
同一个接口、相同的参数多次和一次请求,对系统状态产生的影响是一样的,就可以称为满足幂等性
那么,之所以出现前面的问题就是因为接口不满足幂等性,因为多次和一次请求接口对系统产生了不一样的影响,关于接口幂等解决方案非常多,下面我们以token机制为例
三 token 机制原理
1 服务端提供了获取token的接口。如果业务是存在幂等问题的,就在执行业务前,先去获取token,服务器会把token保存到Redis中
2 然后调用业务接口请求时,把token携带上
3 服务器判断token是否存在redis中,存在表示第一次请求,然后删除token,继续执行业务
4 如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给客户端, 这样就保证了业务代码不被重复执行
四 token 机制实战
1 安装并启动redis
2 添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3 添加配置
#Redis服务器连接地址
spring.redis.host=127.0.0.1
#Redis服务器连接端口
spring.redis.port=6379
4 修改 AccountController
package com.study.idempotence.controller;
import com.study.idempotence.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* @Author guangwenyin
* @Date 2021/12/14
*/
@RestController
public class AccountController {
@Autowired
private AccountService accountService;
@Autowired
private RedisTemplate redisTemplate;
@RequestMapping("/decreaseAccount")
public String decreaseAccount(Integer id,Double money,String token) {
System.out.println("来扣钱了");
//判断传入的token是否为空
if(StringUtils.isEmpty(token)){
return "token不能为空";
}
ValueOperations valueOperations = redisTemplate.opsForValue();
Object o = valueOperations.get(token);
//判断redis中是否存传入的token,存在说明是第一次访问接口,不存在说明不是第一次
if(o==null){
return "token不合法";
}
//扣减金额之前把redis中的token删除
valueOperations.getOperations().delete(token);
//进行金额扣减
Integer result = accountService.minusAccount(id, money);
return result>0?"success":"failed";
}
@RequestMapping("/getToken")
public String getToken() {
String token=UUID.randomUUID().toString();
ValueOperations valueOperations = redisTemplate.opsForValue();
valueOperations.set(token,token);
return token;
}
}
5 幂等性验证
1)先获取token
http://localhost:8080/getToken
返回 9df3e819-4bb0-4d21-8795-1763aae73ddc
2) 调用接口地址:http://localhost:8080/decreaseAccount?id=1&money=10000&token=9df3e819-4bb0-4d21-8795-1763aae73ddc
这样不管手贱再点多少次,只会扣减一次金额
五 token 机制存在的问题及解决
问题:
看似美好,似乎达到了目的,但是稍微想一下是有问题的,上面是把redis删除了再进行扣减金额的操作,
那么如果扣减金额的操作出现了异常会怎么样呢,接下来就别想支付成功了,再调100次扣减金额接口都没用。
那能不能先进行金额扣减,扣减成功之后再把redis里面的token删除?也不行,因为可能会出现扣减金额成功,
服务闪断导致超时,继续重试,一样又出现扣减多次。
解决方法:
方法A:还是先把redis里面的token删除,如果扣减金额失败了就重新获取token再次支付
方法B:在删除redis里面的token之前,加一个操作--到库里面看看有没有该token对应的支付成功记录,这样就需要在库里面保存一份支付成功记录,
通过token可以查到就行,
如果有则说明不是第一次支付,可以删除
如果没有要么是因为从来没支付过,要么就是之前支付失败了,就不要删除。
此外,在高并发场景下,还是需要进一步完善的,比如redis中token的判空和删除的原子操作问题,做个并发测试就看到问题了。
解决方法也很简单,就是对redis删除token的结果做个判断,删除成功才能往下进行扣减金额操作,否则直接返回,
而redis是可以保证只有一个线程删除成功的。
Boolean delete = valueOperations.getOperations().delete(token);
if(!delete){
return "token不合法";
}
学无止境,让学习成为一种习惯。
本人水平有限,有不对的地方请指教,谢谢。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· winform 绘制太阳,地球,月球 运作规律
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 上周热点回顾(3.3-3.9)