一 場景
在學習中剛接觸到冪等性的時候,很多人都會覺得挺高大上的,是不是技術很牛逼的人才能搞得明白是啥東西,其實不然,像我這樣的菜鳥也還是多少能理解一點的。而且這也確實是作為碼農必須要花點時間思考的問題。很多時候一旦我們寫的接口不能保證冪等性,是會出大問題的。
有這樣一個場景:數據庫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不合法";
}
學無止境,讓學習成為一種習慣。
本人水平有限,有不對的地方請指教,謝謝。