接口冪等性解決方案實戰---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不合法";
}

學無止境,讓學習成為一種習慣。

本人水平有限,有不對的地方請指教,謝謝。




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM