欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

接口empempency解决方案的实践--令牌机制

最编程 2024-05-05 12:44:08
...

一  场景

在学习中刚接触到幂等性的时候,很多人都会觉得挺高大上的,是不是技术很牛逼的人才能搞得明白是啥东西,其实不然,像我这样的菜鸟也还是多少能理解一点的。而且这也确实是作为码农必须要花点时间思考的问题。很多时候一旦我们写的接口不能保证幂等性,是会出大问题的。

有这样一个场景:数据库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不合法";
}

学无止境,让学习成为一种习惯。

本人水平有限,有不对的地方请指教,谢谢。