冪等性
1.1冪等性定義:
在計算機領域中,冪等(Idempotence)是指任意一個操作的多次執行總是能獲得相同的結果,不會對系統狀態產生額外影響。在Java后端開發中,冪等性的實現通常通過確保方法或服務調用的結果具有確定性,無論調用次數如何,結果都是可預期的。
1.2注意:
在實際的互聯網服務開發中,冪等性的理論定義與業務邏輯間的沖突是常見的。
例如,考慮查詢操作,當A系統調用B系統的查詢接口時,如果首次調用由于B系統中的程序錯誤而導致業務邏輯失敗,即使在程序修復后系統A重新使用相同參數進行重試,B系統可能仍然返回相同的失敗響應。盡管這符合冪等性的定義,卻與實際業務邏輯不符。同樣,以訂單支付為例,首次調用由于賬戶余額不足而返回“余額不足”提示,用戶充值后再次使用相同參數發起支付請求,服務仍然返回“余額不足”響應,也符合冪等性的定義,但同樣不符合業務邏輯。
因此,在實現冪等性方案時,應該遵循冪等性方案的目標,而不僅僅是嚴格遵循冪等性的定義。尤其是涉及寫操作的服務,應當更關注防止重復請求帶來的不良副作用,例如重復扣款或退款。
1.3 什么情況下會出現冪等?
在微服務和分布式架構中,一個請求可能需要多個服務協作才能完成。在這個過程中,網絡抖動、系統運行異常等不確定因素使得請求的成功率不可能達到100%,一旦發生失敗或未知異常,最常見的處理方式就是重試,而重試必然會導致重復請求問題。
冪等設計主要是為了處理重復請求而生的,好的冪等方案可以保證重復請求獲得預期結果,而不產生副作用。
用戶不可靠: 用戶通過客戶端發起請求,由于手抖或有意重復點擊,很容易造成導致極短時間內發起多次重復請求。
網絡不可靠:網絡抖動、網關內部抖動有可能觸發重試機制,這個在使用消息隊列投遞消息時經常會遇到。MQ 消息中間件,消息重復消費;
服務不可靠: 在需要保證數據一致性的場景中,如果調用下游服務超時,在無法確認執行結果的情況下,常用的處理方法是重試。比如:前端調用后端接口發起支付超時,然后再次發起重試,可能會導致多次支付。
1.3 冪等與并發的關系
在具有并發寫操作的場景下,通常需要考慮冪等問題。例如,當用戶在極短時間內多次提交表單或者使用特殊手段同時提交多個表單時,這就是典型的并發場景,需要進行冪等性處理。為了防止重復請求被執行,服務端需要實施冪等性控制,以避免產生不符合預期的結果。
雖然并發場景大都存在冪等問題,但冪等問題卻并非并發場景所特有。冪等設計是為了識別并處理重復請求,而并發僅僅是重復請求的一種特殊情況。 事實上,只要重復請求涉及寫操作,無論是否并發,都需要做好冪等處理。舉個例子,用戶在pc端同時開了兩個窗口,間隔10分鐘分別提交表單,所有參數完全相同,這顯然不屬于并發,但仍需要進行冪等處理。
二、冪等性解決方案
這些方案的技術路線可以總結成三條:唯一索引、唯一數據、狀態機約束。
- 唯一索引是指數據庫主鍵、唯一索引,唯一索引大部分是基于業務流水表建立,也可單獨建表實現;
- 唯一數據是指悲觀鎖、樂觀鎖、分布式鎖等機制;
- 狀態機約束,對于存在狀態流轉的業務,通過狀態機的流轉約束,可以實現有限狀態機的冪等。
在實際開發中,單獨使用這些方法往往效果有限,需要根據具體的業務場景靈活選擇、合理的運用上述實現方法。
- 數據庫:樂觀鎖、悲觀鎖、唯一主鍵、唯一索引
- 業務層:分布式鎖、下游傳遞唯一序列號
- Token令牌
- 狀態機
2.1 方案一:數據庫唯一主鍵實現冪等性
缺點:無法使用change buffer,InnoDB為了進行唯一性檢查,必須有一次磁盤IO讀頁
2.1.1 方案一延伸:唯一索引方案機制
唯一索引方案依賴于數據庫表中不允許存在具有相同索引值的重復行。這種策略在關系型數據庫中廣泛支持,并且能有效利用唯一性約束來確保冪等性。 在高并發場景中,唯一索引能保證當多個線程嘗試同時插入相同記錄時,只有一個線程能成功執行,而其他線程將會因違反唯一性約束而拋出異常。
通常,業務流水表的建立是基于以下核心字段:
id(bigint 類型):作為主鍵,唯一標識每條記錄。
gmt_create(datetime 類型):記錄的創建時間。
gmt_modified(datetime 類型):記錄的最后修改時間。
user_id(varchar(32) 類型):用戶ID,這個字段也可以作為分表的依據。
out_biz_no(varchar(64) 類型):外部業務流水號,即調用方的冪等號。
biz_no(varchar(64) 類型):內部業務流水號,用于系統內部追蹤。
status(char(1) 類型):記錄執行狀態。
在這種設計中,user_id和out_biz_no通常會組合成一個聯合索引,這樣做能有效避免在并發情況下的數據重復插入問題,從而保障了業務操作的冪等性。
2.2 方案二:數據庫樂觀鎖實現冪等性
樂觀鎖主要依靠帶條件更新 來確保多次外部請求的一致性。在系統設計中,可以在數據表中添加版本號字段,用于標識當前數據的版本。每次對該數據表的記錄進行更新時,都需要提供上一次更新的版本號,示例操作如下:
//1. 取出要更新的對象,帶有版本versoin
select * from tablename where id = xxx//2. 更新數據
update tableName set sq = sq-#{quantity},version = #{version}+1 where id = xxx and version=#{version}
特點:樂觀鎖主要適用于更新場景,確保多次更新不會影響結果的一致性。
缺點:操作業務前,需要先查詢出當前的version版本。會增加操作
2.3方案三:數據庫悲觀鎖機制
悲觀鎖依賴數據庫提供的鎖機制來實現,整個數據處理過程中,數據處于鎖定狀態,并與事務機制配合,能夠有效實現業務冪等性。操作示例如下:
// 1. 開啟事務
begin;
// 2. 基于冪等號查詢
record = select * from tbl_xxx where out_biz_no = 'xxx' for update;
// 3. 根據狀態進行決策
if(record.getStatus() != 預期狀態){return;
}
// 4. 更新記錄
update tbl_xxx set status = '目標狀態' where out_biz_no = 'xxx';
// 5. 提交事務
commit;
特點:
select for update,整個執行過程中鎖定該條記錄
缺點:
在DB讀大于寫的情況下盡量少用。悲觀鎖主要適用于更新場景,通過串行化請求處理來確保冪等性,但需要小心使用,因為在并發場景下,重復請求可能會導致線程長時間處于等待狀態,浪費資源且降低性能。
2.4 方案四:業務層采用分布式鎖機制
分布式鎖與悲觀鎖本質上相似,都通過串行化請求處理來實現冪等性。與悲觀鎖不同的是,分布式鎖更輕量。在系統接收請求后,首先嘗試獲取分布式鎖。如果成功獲取鎖,則執行業務邏輯;如果獲取失敗,則立即拒絕請求。
分布式鎖的核心是識別重復請求,實現串行化處理。但要注意,獲取鎖成功后,業務邏輯的執行并沒有可靠保證。因此,在實際應用中,分布式鎖需要結合事務機制和重試機制,以形成完整的冪等性解決方案。
2.5方案五:防重 Token 令牌實現冪等性
2.5.1 流程:
1)當用戶訪問表單頁面時,客戶端請求服務端接口以獲取唯一的Token(可以是UUID或全局ID),服務端生成的Token會被存儲在Redis或數據庫中。
2)用戶首次提交表單時,將Token與表單一起發送至服務端,服務端會驗證Token的存在性,如果Token存在,則執行業務邏輯,并在完成后銷毀Token。
3)用戶再次提交表單時,同樣攜帶Token一起發送至服務端。但由于Token已被銷毀,服務端無法找到對應的Token,從而拒絕重復提交請求。
2.5.2實現:
(1)集群環境:token+redis
(2)單jvm環境:token+redis 或者token+jvm內存
2.5.3 Token特點
== 要申請,一次有效性,可以限流 ==
2.5.4缺點:
(1)產生過多額外請求
(2)先刪除token,如果業務處理出現異常但token已經刪除掉了,再來請求會被認定為重復請求
后刪除token,如果刪除redis中的token失敗了,再來請求不會攔截,發生了重復請求
無論是先刪除token還是后刪除token,都會導致每次業務請求都產生一個額外的請求去獲取token。然而,在生產環境中,業務失敗或超時的情況并不多見,大多數請求都能成功完成。因此,為了處理這少數失敗的請求,讓絕大多數請求都產生額外的請求也算是一種資源的浪費。
2.5.5 存在問題:刪除token時,是先完成業務操作后刪除token,還是先刪除token后執行業務操作呢?
答案:要先刪除 token ,再執行業務代碼 。『后刪除 token』的缺陷太致命
(1)先執行業務操作再刪除token
情況:在高并發下,可能出現第一次訪問時token存在,完成具體業務操作,但在還沒有刪除token時,客戶端又攜帶token發起請求。此時,因為token還存在,第二次請求也會驗證通過,執行具體業務操作。
對于這個問題有如下兩種解決方案:
第一種方案: 對于業務代碼執行和刪除token整體加線程鎖,使得后續線程阻塞排隊,但可能造成一定性能損耗與吞吐量降低。
第二種方案: 借助Redis單線程和INCR原子性特性,在獲取token時對其進行自增操作。當客戶端攜帶token訪問執行業務代碼時,繼續對其進行自增,如果自增后的返回值為2,則是一個合法請求允許執行,否則認為是非法請求,直接返回。
(2)先刪除token再執行業務
如果業務執行超時或失敗,沒有向客戶端返回明確結果,客戶端就會進行重試,但此時之前的token已經被刪除,導致被認為是重復請求,不再進行業務處理。
這種方案無需額外處理,一個token只能代表一次請求。一旦業務執行出現異常,則讓客戶端重新獲取令牌,重新發起一次訪問即可。
先刪除token,再執行業務邏輯,中間如果出現宕機,可能會導致業務調用失敗,對于這種情況,大不了就重新獲取token再次請求
2.6 方案六:狀態機機制
在許多業務單據中,存在有限數量的狀態,并且這些狀態之間的流轉順序是固定的。如果狀態已經處于下一個狀態,那么再次應用上一個狀態的變更邏輯是不會產生任何效果的,這就確保了有限狀態機的冪等性。
例如,庫存狀態通常包括"預扣中"、“扣減中”、“占用中"和"已釋放"等狀態。如果系統重復調用扣減接口,而庫存狀態已經是"扣減中”,則可以直接返回結果。
狀態機可以與樂觀鎖機制結合使用,示例操作如下:
update tableName set sq=sq-#{quantity},status=#{udpate_status} where id =#{id} and status=#{status}
特點:和任務、狀態相關的業務,肯定會涉及狀態機,業務的一個屬性狀態,可以作為冪等的一個根據
2.7方案七:下游傳遞唯一序列號實現冪等性
缺點:無法控制下游唯一序列號的生成規則,如果序列號由時間戳生成,那么無法攔截類似重復點擊這種情況下的重復請求
3.1 補充 redis冪等性
3.1.1 redis冪等性
在Redis中,冪等性是指相同的操作可以被多次執行而不會產生額外的影響或副作用。簡而言之,就是無論執行多少次相同的操作,結果都是一樣的。 在Redis中,可以通過以下幾種方式來實現redis的冪等性:
(1)使用Redis的原子性操作:Redis提供了一些原子性操作,如SETNX、INCR、SADD等。 這些操作在執行時是原子性的,即是一個操作的結果要么成功執行,要么沒有執行。通過使用這些原子性操作,可以保證相同的操作在執行時只會生效一次。
(2)使用Redis的事務:Redis的事務可以將一系列的操作包裝在一個事務中,然后一起執行。在事務執行期間,其他客戶端的請求不會干擾到事務的執行。通過將冪等操作放在一個事務中執行,可以保證這些操作只會被執行一次。
(3)使用Redis的分布式鎖:通過使用Redis的分布式鎖,可以保證同一時間只有一個客戶端可以執行特定的操作。當一個客戶端獲取到鎖后,其他客戶端嘗試獲取鎖的操作會被阻塞,直到鎖被釋放。通過使用分布式鎖,可以保證相同的操作只會被執行一次。
總結起來,Redis中可以通過原子性操作、事務和分布式鎖等方式來實現redis的冪等性。 這樣可以保證相同的操作在執行時不會產生額外的影響或副作用。
3.1.2 redis SETNX分布鎖詳解
1.SETNX:向Redis中添加一個key,只用當key不存在的時候才添加并返回1,存在則不添加返回0。并且這個命令是原子性的。
2. 使用SETNX作為分布式鎖時,添加成功表示獲取到鎖,添加失敗表示未獲取到鎖。至于添加的value值無所謂可以是任意值(根據業務需求),只要保證多個線程使用的是同一個key,所以多個線程添加時只會有一個線程添加成功,就只會有一個線程能夠獲取到鎖。而釋放鎖鎖只需要將鎖刪除即可。
3.設置過期時間防止死鎖
在添加時存在則添加,不存在則不添加。同時設置過期時間,單位秒
示例:
/*** 計算結果寫入到kafka冪等性的實現** @param json* @return*/public boolean idempotent(String json) {try {RedisPool redisPool = RedisPool.instance(properties);Jedis jedis = redisPool.getResource();String value = "1";if (jedis.setnx(json, value) > 0) {jedis.expire(json, 24 * 3600);redisPool.returnResource(jedis);return true;}redisPool.returnResource(jedis);} catch (Exception e) {System.err.println("redis pool get redis failed: " + json);}return false;
}
2.防止死鎖
SET key value NX EX time//通過java代碼實現SETNX同時設置過期時間 //key--鍵 value--值 time--過期時間 TimeUnit--時間單位枚舉
stringRedisTemplate.opsForValue().setIfAbsent(key, value , time, TimeUnit);
優點:
1.程序可以分組,可以分布式,亦可以用于數據恢復程序
2.減少了對redis操作頻度,提高了程序的并發性.
3.2 參考文章:
https://mp.weixin.qq.com/s/7YDtl8EfYvre49Al9yVZIw
https://blog.csdn.net/sinat_32023305/article/details/119610885
https://blog.csdn.net/q7w8e9r4/article/details/132533849