作者:小牛呼嚕嚕 | https://xiaoniuhululu.com
計算機內功、原始碼解析、科技故事、專案實戰、面試八股等更多硬核文章,首發於公眾號「小牛呼嚕嚕」
什麼是冪等性?
大家好,我是呼嚕嚕,所謂冪等性就是:任意次數請求 同一個資源,對資源的狀態產生的影響和執行一次請求是相同的。
比如對於介面來說,無論呼叫多少次同一個介面,對資源的狀態都只產生一次影響
為什麼需要保證冪等性?
為什麼需要做介面的冪等性?如果不做會發生什麼事情?我們在實際企業開發過程中,如果僅是對資料庫進行查詢、刪除指定記錄操作,重複提交是沒啥問題的。但是如果是新增或者修改操作,就需要考慮重複提交的問題。
比如,如果一個訂單支付的時候,因各種原因重複提交多次,那如果沒有冪等性處理的話,這個訂單將會被支付多次的錢,這種和錢有關的錯誤是絕對不能容忍的。
經常發生重複提交的場景:
- 當我們在公司的系統裡面,提交表格,前端沒有對儲存按鈕的做控制,可以多次點選,然後我們又不小心快速點了多次,或者是網路卡頓, 還是其他原因,以為沒有成功提交,就一直點選儲存按鈕,這樣都會產生重複提交表單請求。
- 在實際開發過程中,網路波動是常有的事,所以很多時候 HTTP 客戶端工具都預設開啟超時重試的機制,這樣就無法避免產生重複的請求。
- 還有就是專案可能使用一些中介軟體,比如kafka消費生產者產生的訊息時,可能讀到重複的訊息,這樣也會產生重複的請求。
- ......
介面冪等設計和防止重複提交可以等同嗎?
介面冪等和防止重複提交有交集,但是嚴格來說並不完全等同
- 防重設計,主要從客戶端/前端的角度來解決,主要為了避免重複提交,對每次請求的返回結果無限制,前端常見的手段:點選提交按鈕變灰、點選後跳轉結果頁、每次頁面初始化生成隨機碼,提交時隨機碼快取,後續重複的隨機碼請求直接不提交
- 冪等設計,強調更多地是當重複提交請求無法避免的時候,還能保證每次請求都返回一樣的結果。像我們上面對前端做的限制,是能繞過去的,抓包是能直接把介面給抓出來的,比如惡意批次呼叫介面,所以企業級系統,前後端都需要做限制,特別是涉及到錢的業務。絕不能偷懶,後面我們來詳細講講對介面冪等的限制。
常用保證冪等性的措施
先select再insert
新手小白,在往資料庫插入資料時,為了防止重複插入,一般會在insert前,透過關鍵字去先select一下,如果查不到記錄就執行insert操作,否則就不插入
但如果併發場景下,這個就不行了。比如執行緒2,線上程1插入資料前,執行select,最終它也會去執行插入操作,這樣就會產生2條記錄。所以在實際開發過程中,是不建議如此操作的。
資料庫設定唯一索引或唯一組合索引
資料庫設定唯一索引是我們最常用的方式,一個非常簡單,並且有效的方案。當記錄多次插入資料庫,會由於Id或者關鍵欄位索引唯一的限制,導致後續記錄插入失敗
--建立唯一索引
alter table `order` add UNIQUE KEY `索引名` (`欄位`);
第一條記錄插入到資料庫中,當後面其他相同的請求,再插入時,資料庫會報異常Duplicate entry 'xx' for key 'xx_name'
,這個異常不會對資料庫中既有的資料有影響,我們只需對異常進行捕獲就行,直接返回,代表已經執行過當前請求。
筆者這裡介紹一個騷操作:INSERT IGNORE
insert ignore INTO tableName VALUES ("id","xxx")
咦,會有讀者覺得,這樣哪怕索引衝突了,資料庫會忽略錯誤返回影響行數0,這樣就不用再在程式碼中,手動捕捉異常了,又方便又省事!
但事實真這樣嗎???
如果希望在每次插入新記錄時,自動地建立主鍵欄位的值。一般會將主鍵id的屬性設為AUTO_INCREMENT
,
如果我們使用INSERT IGNORE
時,沒有成功新增記錄,但是AUTO_INCREMENT
會自動+1
,binlog中也沒有 INSERT IGNORE
語句日誌。這個會導致主從資料一致性問題,如果線上環境資料庫是主從架構,從庫該欄位的AUTO_INCREMENT
值會和主庫不一致,切庫(從庫變成總庫)的時候會衝突。
當然,查詢Mysql官方手冊,發現innodb_autoinc_lock_mode
用於平衡效能與主從資料一致性,令 innodb_autoinc_lock_mode=0可以解決這個問題,將其設為0後, 所有的insert語句都要在語句開始的時候得到一個表級的auto_inc鎖,在語句結束的時候才釋放這把鎖。也就是說在INSERT未成功執行時AUTO_INCREMENT
不會自增,但是其也有缺點,會影響到資料庫的併發插入效能。
Mysql官方手冊明確指出,The setting innodb_autoinc_lock_mode=0 should not be used except for compatibility purposes.
除非出於相容性目的,否則不應設定innodb_autoinc_lock_mode=0
。所以我們還是老老實實手動捕捉異常,慎用insert ignore
**innodb_autoinc_lock_mode: **在MySQL8中, 預設值為 2 (輕量級鎖) , 在MySQL8之前, 5.1之後, 預設值為 1(混合使用這2種鎖), 在更早的版本是 0(auto_inc鎖)
去重表
去重表,其實也是唯一索引方案的一個變種,原表不太適合再新建唯一索引了,且資料量不大的話。我們可以再新建一張去重表,把唯一標識作為唯一索引,然後把對原表的操作和同時新增去重表 ,放在一個事務中,如果重複建立,去重表會丟擲唯一約束異常,事務裡所有的操作就會回滾。
insert中加入exist條件判斷
有時候我們會遇到非常複雜的表,表結構確定了,比如已經有了許多索引欄位,不太適合再新建索引的時候,呼嚕嚕 在這裡再提供一個"騷操作":可以透過insert中加入exist來解決重複插入的問題。
比如:
insert into order(id,code,password)
select ${id},${code},${password}
from order
where not exists(select 1 from order where code = ${code}) limit 0,1;
上面的sql注意思路就是將查詢和插入寫在同一個sql中,需要注意的是limit 0,1
最後一定要加上,不然可能會出現重複插入的情況
悲觀鎖
悲觀鎖,顧名思義就是,對資料被外界或者內部修改處理時,持"悲觀"態度,總認為會發生併發衝突,所以會在整個資料處理過程中,將資料鎖定。
悲觀鎖的實現,通常依靠資料庫提供的鎖機制實現,在這裡以mysql為例,最典型的就是"for update"。
select * from order where id = "xxxx" for update;
需要注意的是:使用悲觀鎖,需要先關閉mysql的自動提交功能,將 set autocommit = 0;
for update
僅適用於Mysql中lnnoDB引擎,預設是行級鎖,如果sql中有明確指定的主鍵時候,是行級鎖,如果沒有,會鎖表(非常危險的操作)。for update
一般和事務配合使用,一旦使用者對某個行施加了行級加鎖,則該使用者可以查詢也可以更新被加鎖的資料行,其它使用者只能查詢但不能更新被加鎖的資料行。直到顯示提交事務(由於關閉了mysql的自動提交)時,for update
獲取的鎖會自動釋放。
悲觀鎖雖然保證了資料處理的安全性,但會嚴重影響併發效率,降低系統吞吐量。適用於併發量不大、又對資料一致性比較高的場景。
樂觀鎖
樂觀鎖,和悲觀鎖相反,對資料被外界或者內部修改處理時,持"樂觀"態度,總認為不會發生併發衝突,所以不會上鎖,只需在更新的時候會去判斷一下在此期間有沒有去更新這個資料。
一般是使用版本號或者時間戳,比如
- 我們在資料庫中,給訂單表增加一個version 欄位
- select資料時,將version一起讀出,當提交資料更新時,判斷版本號是否和取出來的是否一致。如果不一致就代表,已更新,那就不更新。如果一致就繼續執行更新操作。
- 每次更新時,除了更新指定的欄位,也要將version進行+1操作
update order set name=#{xxx},version=#{version} where id=#{id} and version < ${version}
不加鎖就能保證冪等性,又增加了系統吞吐量,如果頻繁觸發版本號不一致的情況,反而降低了效能。
狀態機
狀態機也是樂觀鎖的一種,比如企業級貨品管理系統中,訂單的轉單流程,將訂單的狀態,設定為有限的幾個(1-下單、2-已支付、3-完成、4-發貨、5-退貨),透過各個狀態依次執行轉換,來控制訂單轉單的流程,是非常好的選擇。
分散式鎖
上面介紹了許多方案,在單體應用中是沒啥問題的,但是隨著時代的發展,現在微服務大行其道,以上方法就不太適應了。
在分散式系統中,上面唯一索引對於全域性來說,是無法確定的,我們可以引入第三方分散式鎖來保證冪等性設計。分散式鎖,主要是用來 當多個程式不在同一個系統中,用分散式鎖控制多個程式對資源的訪問
實現分散式鎖常見的方法有:基於redis實現分散式鎖,基於 Consul 實現分散式鎖,基於 zookeeper實現分散式鎖等等,本文重點介紹最常見的基於redis實現分散式鎖,set NX PX + Lua
- 在分散式系統中,插入或者更新的請求,業務邏輯中先獲取唯一業務欄位,比如訂單id之類的,接著需要獲取分散式鎖,對redis執行下述命令
SET key value NX PX 30000
各引數的含義:
- SET: 在Redis 2.6.12之後,
set命令
整合了setex命令
的功能,支援了原子命令加鎖和設定過期時間的功能 - key:業務邏輯中先獲取唯一業務欄位,比如訂單id,code之類,也可以在前面加一些系統引數當字首,這個完全可以自定義
- value: 填入是一串隨機值,必須保證全域性唯一性(在釋放鎖時,我們需要對value進行驗證,防止誤釋放),一般用uuid來實現
- NX: 表示key不存在時才設定,如果存在則返回 null。還有另一個引數XX,表示key存在時才設定,如果不存在則返回NULL
- PX 30000: 表示過期時間30000毫秒,指到30秒後,key將被自動刪除。這個非常的重要,如果設定過短,無法有效的防止重複請求,過長的話會浪費redis的空間
- 然後執行插入或者更新,或者其他相關業務邏輯,在釋放鎖之前,如果有其他中心的服務來請求,由於key是一樣的,無法獲取鎖,就代表這些是重複請求,不操作,直接返回
- 執行完插入或者更新後,需要釋放鎖,一定要判斷釋放的鎖的value和與Redis記憶體儲的value是否一致,不然如果直接刪除的話,會把其他中心服務的鎖釋放調。
這種先查再刪的2步操作,我們可以使用lua指令碼,把他們變成一個"原子操作"
Lua 是一種輕量小巧的指令碼語言,Redis會將整個指令碼作為一個整體執行,中間不會被其他命令打斷插入(l類似與事務),可以減少網路開銷,方便複用
以下是Lua指令碼,透過 Redis 的 eval/evalsha 命令來執行:
if redis.call('get', KEYS[1]) == ARGV[1] //判斷value是否一致
then
return redis.call('del', KEYS[1])//刪除key
else
return 0
end
這樣依靠單體的redis實現的分散式鎖能夠很好的解決,微服務系統的冪等問題。但是有些公司的微服務更加龐大,redis也是叢集的話,set NX PX + Lua
就不夠看了,這裡介紹Redis作者推薦的方法-Redlock演算法,這裡就先不展開講了,不然文章篇幅過長。先挖個坑,後面有空填一下:)
token機制
最後再補充一個方案利用token機制,每次呼叫介面時,使用token來標識請求的唯一性。token也叫令牌,天然適合微服務。基於token+redis來設計冪等的思路還是比較簡單的,和分散式鎖類似:
- 客戶端傳送請求,得去服務端獲取一個全域性唯一的一串隨機字串作為Token 令牌(每次請求獲取到的都是一個全新的令牌),把令牌儲存到 redis 中,需要有過期時間,同時把這個 ID 返回給客戶端
- 客戶端第二次呼叫業務請求的時候必須攜帶這個 token,服務端會去校驗redis中是否有該token。如果存在,表示這是第一次請求,刪除快取中的token(這邊還是建議用lua指令碼,保證操作的原子性);如果快取中不存在,表示重複請求,直接返回。
尾語
冪等性是系統服務對外一種承諾,特別業務中涉及的錢的部分,一定要慎重再慎重。雖然前端做限制會更容易點,但前後端都需要做努力,除了本文介紹的常見的方案,大家也可以集思廣益,畢竟技術在發展,單體到叢集分散式,還會繼續發展,還有有新的問題產生。
本文雖然通篇在將冪等的重要性和如何實現冪等,但不可否認,冪等肯定導致系統吞吐量、併發能力的下降,企業級應用還是得根據業務,權衡利弊,感謝大家的閱讀。
參考資料:
https://www.cnblogs.com/linjiqin/p/9678022.html
全文完,感謝您的閱讀,如果我的文章對你有所幫助的話,還請點個免費的贊,你的支援會激勵我輸出更高質量的文章,感謝!