冪等性問題是面試中常見的面試問題,也是分布式系統最常遇到的問題之一。在說冪等性之前,我們先來看一種情況,假如老王在某電商平臺進行購物,付款的時候不小心手抖了一下,連續點擊了兩次支付,但此時服務器沒做任何驗證,于是老王賬戶里面的錢被扣了兩次,這顯然對當事人造成了一定的經濟損失,并且還會讓用戶喪失對平臺的信任。而冪等性問題說的就是如何防止接口的重復無效請求。
我們本課時的面試題是,什么是冪等性?如何保證接口的冪等性?
#### 典型回答
冪等性最早是數學里面的一個概念,后來被用于計算機領域,用于表示任意多次請求均與一次請求執行的結果相同,也就是說對于一個接口而言,無論調用了多少次,最終得到的結果都是一樣的。比如以下代碼:
```
public class IdempotentExample {
// 變量
private static int count = 0;
/**
* 非冪等性方法
*/
public static void addCount() {
count++;
}
/**
* 冪等性方法
*/
public static void printCount() {
System.out.println(count);
}
}
```
對于變量 count 來說,如果重復調用 addCount() 方法的話,會一直累加 count 的值,因為 addCount() 方法就是非冪等性方法;而 printCount() 方法只是用來打印控制臺信息的。因此,它無論調用多少次結果都是一樣的,所以它是冪等性方法。
知道了冪等性的概念,那如何保證冪等性呢?
冪等性的實現方案通常分為以下幾類:
* 前端攔截
* 使用數據庫實現冪等性
* 使用 JVM 鎖實現冪等性
* 使用分布式鎖實現冪等性
下面我們分別來看它們的具體實現過程。
* [ ] 1. 前端攔截
前端攔截是指通過 Web 站點的頁面進行請求攔截,比如在用戶點擊完“提交”按鈕后,我們可以把按鈕設置為不可用或者隱藏狀態,避免用戶重復點擊。
執行效果如下圖所示:

按鈕點擊效果圖
核心的實現代碼如下:
```
<script>
function subCli(){
// 按鈕設置為不可用
document.getElementById("btn_sub").disabled="disabled";
document.getElementById("dv1").innerText = "按鈕被點擊了~";
}
</script>
<body style="margin-top: 100px;margin-left: 100px;">
<input id="btn_sub" type="button" value=" 提 交 " onclick="subCli()">
<div id="dv1" style="margin-top: 80px;"></div>
</body>
```
但前端攔截有一個致命的問題,如果是懂行的程序員或者黑客可以直接繞過頁面的 JS 執行,直接模擬請求后端的接口,這樣的話,我們前端的這些攔截就不能生效了。因此除了前端攔截一部分正常的誤操作之外,后端的驗證必不可少。
* [ ] 2. 數據庫實現
數據庫實現冪等性的方案有三個:
* 通過悲觀鎖來實現冪等性
* 通過唯一索引來實現冪等性
* 通過樂觀鎖來實現冪等性
* [ ] 3. JVM 鎖實現
JVM 鎖實現是指通過 JVM 提供的內置鎖如 Lock 或者是 synchronized 來實現冪等性。使用 JVM 鎖來實現冪等性的一般流程為:首先通過 Lock 對代碼段進行加鎖操作,然后再判斷此訂單是否已經被處理過,如果未處理則開啟事務執行訂單處理,處理完成之后提交事務并釋放鎖,執行流程如下圖所示:

JVM 鎖執行流程圖
JVM 鎖存在的最大問題在于,它只能應用于單機環境,因為 Lock 本身為單機鎖,所以它就不適應于分布式多機環境。
* [ ] 4. 分布式鎖實現
分布式鎖實現冪等性的邏輯是,在每次執行方法之前先判斷是否可以獲取到分布式鎖,如果可以,則表示為第一次執行方法,否則直接舍棄請求即可,執行流程如下圖所示:

分布式鎖執行流程圖
需要注意的是分布式鎖的 key 必須為業務的唯一標識,我們通常使用 Redis 或者 ZooKeeper 來實現分布式鎖;如果使用 Redis 的話,則用 set 命令來創建和獲取分布式鎖,執行示例如下:
```
127.0.0.1:6379> set lock true ex 30 nx
OK # 創建鎖成功
```
其中,ex 是用來設置超時時間的;而 nx 是 not exists 的意思,用來判斷鍵是否存在。如果返回的結果為“OK”,則表示創建鎖成功,否則表示重復請求,應該舍棄。更多關于 Reids 實現分布式的內容可以查看第 20 課時的內容。
考點分析
冪等性問題看似“高大上”其實說白了就是如何避免重復請求提交的問題,出于安全性的考慮,我們必須在前后端都進行冪等性驗證,同時冪等性問題在日常工作中又特別常見,解決的方案也有很多,但考慮到分布式系統情況,我們應該優先使用分布式鎖來實現。
和此知識點相關的面試題還有以下這些:
* 冪等性需要注意什么問題?
* 實現冪等性的關鍵步驟有哪些?
* 說一說數據庫實現冪等性的執行細節?
#### 知識擴展
* [ ] 1. 冪等性注意事項
冪等性的實現與判斷需要消耗一定的資源,因此不應該給每個接口都增加冪等性判斷,要根據實際的業務情況和操作類型來進行區分。例如,我們在進行查詢操作和刪除操作時就無須進行冪等性判斷。查詢操作查一次和查多次的結果都是一致的,因此我們無須進行冪等性判斷。刪除操作也是一樣,刪除一次和刪除多次都是把相關的數據進行刪除(這里的刪除指的是條件刪除而不是刪除所有數據),因此也無須進行冪等性判斷。
* [ ] 2. 冪等性的關鍵步驟
實現冪等性的關鍵步驟分為以下三個:
1. 每個請求操作必須有唯一的 ID,而這個 ID 就是用來表示此業務是否被執行過的關鍵憑證,例如,訂單支付業務的請求,就要使用訂單的 ID 作為冪等性驗證的 Key;
2. 每次執行業務之前必須要先判斷此業務是否已經被處理過;
3. 第一次業務處理完成之后,要把此業務處理的狀態進行保存,比如存儲到 Redis 中或者是數據庫中,這樣才能防止業務被重復處理。
* [ ] 3. 數據庫實現冪等性
使用數據庫實現冪等性的方法有三種:
* 通過悲觀鎖來實現冪等性
* 通過唯一索引來實現冪等性
* 通過樂觀鎖來實現冪等性
接下來我們分別來看這些實現方式的具體執行過程。
* ① 悲觀鎖
使用悲觀鎖實現冪等性,一般是配合事務一起來實現,在沒有使用悲觀鎖時,我們通常的執行過程是這樣的,首先來判斷數據的狀態,執行 SQL 如下:
```
select status from table_name where id='xxx';
```
然后再進行添加操作:
```
insert into table_name (id) values ('xxx');
```
最后再進行狀態的修改:
```
update table_name set status='xxx';
```
但這種情況因為是非原子操作,所以在高并發環境下可能會造成一個業務被執行兩次的問題,當一個程序在執行中時,而另一個程序也開始狀態判斷的操作。因為第一個程序還未來得及更改狀態,所以第二個程序也能執行成功,這就導致一個業務被執行了兩次。
在這種情況下我們就可以使用悲觀鎖來避免問題的產生,實現 SQL 如下所示:
```
begin; # 1.開始事務
select * from table_name where id='xxx' for update; # 2.查詢狀態
insert into table_name (id) values ('xxx'); # 3.添加操作
update table_name set status='xxx'; # 4.更改操作
commit; # 5.提交事務
```
在實現的過程中需要注意以下兩個問題:
* 如果使用的是 MySQL 數據庫,必須選用 innodb 存儲引擎,因為 innodb 支持事務;
* id 字段一定要是主鍵或者是唯一索引,不然會鎖表,影響其他業務執行。
* ② 唯一索引
我們可以創建一個唯一索引的表來實現冪等性,在每次執行業務之前,先執行插入操作,因為唯一字段就是業務的 ID,因此如果重復插入的話會觸發唯一約束而導致插入失敗。在這種情況下(插入失敗)我們就可以判定它為重復提交的請求。
唯一索引表的創建示例如下:
```
CREATE TABLE `table_name` (
`id` int NOT NULL AUTO_INCREMENT,
`orderid` varchar(32) NOT NULL DEFAULT '' COMMENT '唯一id',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_orderid` (`orderid`) COMMENT '唯一約束'
) ENGINE=InnoDB;
```
* ③ 樂觀鎖
樂觀鎖是指在執行數據操作時(更改或添加)進行加鎖操作,其他時間不加鎖,因此相比于整個執行過程都加鎖的悲觀鎖來說,它的執行效率要高很多。
樂觀鎖可以通過版本號來實現,例如以下 SQL:
```
update table_name set version=version+1 where version=0;
```
#### 小結
冪等性不但可以保證程序正常執行,還可以杜絕一些垃圾數據以及無效請求對系統資源的消耗。本課時我們講了冪等性的 6 種實現方式,包括前端攔截、數據庫悲觀鎖實現、數據唯一索引實現、數據庫樂觀鎖實現、JVM 鎖實現,以及分布式鎖的實現等方案,其中前端攔截無法防止懂行的人直接繞過前端進行模擬請求的操作。因此后端一定要實現冪等性處理,推薦的做法是使用分布式鎖來實現,這樣的解決方案更加通用。
#### 課后問答
* 1、本質上來說還是用業務來判斷,和鎖有什么關系呢?是不是說鎖只是保證每次執行的原子性
講師回復: 這相當于用唯一鎖的概念來實現了冪等性的業務。
- 前言
- 開篇詞
- 開篇詞:大廠技術面試“潛規則”
- 模塊一:Java 基礎
- 第01講:String 的特點是什么?它有哪些重要的方法?
- 第02講:HashMap 底層實現原理是什么?JDK8 做了哪些優化?
- 第03講:線程的狀態有哪些?它是如何工作的?
- 第04講:詳解 ThreadPoolExecutor 的參數含義及源碼執行流程?
- 第05講:synchronized 和 ReentrantLock 的實現原理是什么?它們有什么區別?
- 第06講:談談你對鎖的理解?如何手動模擬一個死鎖?
- 第07講:深克隆和淺克隆有什么區別?它的實現方式有哪些?
- 第08講:動態代理是如何實現的?JDK Proxy 和 CGLib 有什么區別?
- 第09講:如何實現本地緩存和分布式緩存?
- 第10講:如何手寫一個消息隊列和延遲消息隊列?
- 模塊二:熱門框架
- 第11講:底層源碼分析 Spring 的核心功能和執行流程?(上)
- 第12講:底層源碼分析 Spring 的核心功能和執行流程?(下)
- 第13講:MyBatis 使用了哪些設計模式?在源碼中是如何體現的?
- 第14講:SpringBoot 有哪些優點?它和 Spring 有什么區別?
- 第15講:MQ 有什么作用?你都用過哪些 MQ 中間件?
- 模塊三:數據庫相關
- 第16講:MySQL 的運行機制是什么?它有哪些引擎?
- 第17講:MySQL 的優化方案有哪些?
- 第18講:關系型數據和文檔型數據庫有什么區別?
- 第19講:Redis 的過期策略和內存淘汰機制有什么區別?
- 第20講:Redis 怎樣實現的分布式鎖?
- 第21講:Redis 中如何實現的消息隊列?實現的方式有幾種?
- 第22講:Redis 是如何實現高可用的?
- 模塊四:Java 進階
- 第23講:說一下 JVM 的內存布局和運行原理?
- 第24講:垃圾回收算法有哪些?
- 第25講:你用過哪些垃圾回收器?它們有什么區別?
- 第26講:生產環境如何排除和優化 JVM?
- 第27講:單例的實現方式有幾種?它們有什么優缺點?
- 第28講:你知道哪些設計模式?分別對應的應用場景有哪些?
- 第29講:紅黑樹和平衡二叉樹有什么區別?
- 第30講:你知道哪些算法?講一下它的內部實現過程?
- 模塊五:加分項
- 第31講:如何保證接口的冪等性?常見的實現方案有哪些?
- 第32講:TCP 為什么需要三次握手?
- 第33講:Nginx 的負載均衡模式有哪些?它的實現原理是什么?
- 第34講:Docker 有什么優點?使用時需要注意什么問題?
- 彩蛋
- 彩蛋:如何提高面試成功率?