<header class="article-header">
<h1 class="article-title" itemprop="name">
幂等性
</h1>
<a href="/2019/01/05/幂等性/" class="archive-article-date">
<time datetime="2019-01-05T03:32:02.000Z" itemprop="datePublished"><i class="icon-calendar icon"></i>2019-01-05</time>
</header>
<div class="article-entry" itemprop="articleBody">
<p>現在稍具規模的網站和大型應用都不再是單機模式,而是分布式應用,基于多機的叢集建構的應用,這樣服務能力就可以基本實作橫向擴容(scale out),不會像單機模式下的縱向擴容(scale up)會受到單機服務能力上限的限制。另外,随着“微服務”概念的火爆,很多應用在建構之初就已經走在了分布式的路線上了,是以就目前行業的發展來看,基于分布式的應用會越來越普遍,甚至變成常态。加上docker這些容器技術的出現,應用分布式化的工具也越來越成熟。</p>
分布式的複雜性
衆所周知,建構分布式應用所面臨的複雜度遠遠超出集中式的單一應用,導緻複雜性的因素有很多,在此隻提其中一點:網絡的不可靠性。在單一程序内部,對一個函數的調用,結果隻有兩種——成功和失敗,失敗的情況下,調用者可以決定做一些事情彌補。但是在跨程序的調用中,對一個遠端(也可以在同一個節點上)程序上運作的函數調用除了會得到成功和失敗,還會有第三種的情況——逾時,這個現象被稱為分布式的三态。這也是困擾分布式應用建構的最核心因素之一,很多分布式應用的複雜度之是以上升這麼多也是因為三态之中的逾時引起的。
簡單看看逾時給我們帶來的困擾,程序A調用程序B上的函數f,對于成功和失敗的結果,相信和單機下一樣,程序A都可以進行很好地的處理,因為結果是很明确的。如果程序A調用f之後,在允許的等待最大時間内沒有傳回結果,就是調用逾時了,此時程序A能做什麼?其實程序A什麼都做不了,因為逾時是一個不明确的結果——成功和失敗都有可能。詳細解釋下可能的情況:
成功的情況:程序A把資料通過網絡傳輸到程序B上,f執行成功,通 網絡傳回執行結果給程序A,可是網絡不太好,傳輸失敗了,程序A并 未在指定時間内收到結果,認為逾時了。 失敗的情況:情況和成功的情況差不多,隻是f執行失敗了,但是結 果依然傳輸失敗,程序A也認為執行逾時了。 未執行的情況:程序A的資料發送到程序B所在的節點過程中網絡失敗 了,或者發送到了程序B所在的機器上,但是程序B沒有消費掉在TCP 網絡層的資料等等 由此可見,程序A對于逾時确實無能為力,有太多的可能存在的情況了。但是分布式協作過程中又必須解決這個問題,不然分布式應用是沒意義的,這種情況下,一般會采用讓程序A嘗試重試——即重複發起之前的調用。但是這樣也可能會帶來問題,因為逾時的那次調用可能已經成功了,再次以同樣的參數調用f會不會帶來額外的問題?這就引出本文的主角——幂等性。
幂等
幂等(idempotent、idempotence)是一個數學與計算機學概念,常見于抽象代數中。
在程式設計中,一個幂等操作的特點是其任意多次執行所産生的影響均與一次執行的影響相同。所謂“影響相同”,不是要求傳回值完全相同,而且是指後續多餘的調用對系統的資料一緻性不造成破壞。對于寫入類操作,如果第一次寫入是成功的,後續的寫入應該抛出異常或者空操作,或者執行了寫入但是未對資料造成變化。對于讀取類操作,需要保證其實作上是真正的讀取,不能在讀操作中夾帶寫操作。
幂等函數,或幂等方法,是指可以使用相同參數重複執行,并能獲得相同結果的函數。這些函數不會影響系統狀态,也不用擔心重複執行會對系統造成改變。例如,“getUsername()和setTrue()”函數就是一個幂等函數。
用通俗的話講:就是針對一個操作,不管做多少次,産生效果或傳回的結果都是一樣的。接口的幂等性實際上就是接口可重複調用,在調用方多次調用的情況下,接口最終得到的結果是一緻的。有些接口可以天然的實作幂等性,比如查詢接口,對于查詢來說,你查詢一次和兩次,對于系統來說,沒有任何影響,查出的結果也是一樣。
舉幾個例子:
| |
幂等場景
需要實作幂等性的典型場景有以下兩種:
- 用戶端發起的請求可能需要重試,請求的後端處理需要保證幂等
- 後端系統使用同步RPC調用或異步消息實作分布式事務,消息的消費者需要保證幂等
可能會發生重複請求或消費的場景,在微服務架構中是随處可見的。以下是幾個常見場景:
- 網絡波動:因網絡波動,可能會引起重複請求
- 分布式消息消費:任務釋出後,使用分布式消息服務來進行消費
- 使用者重複操作:使用者在使用産品時,可能會無意的觸發多筆交易,甚至沒有響應而有意觸發多筆交易
- 未關閉的重試機制:因開發人員、測試人員或運維人員沒有檢查出來,而開啟的重試機制(如Nginx重試、RPC通信重試或業務層重試等)
廣義上的RPC,包括用戶端對服務端的api調用、後端系統的内網調用、跨機房調用等。一次RPC大體上包括三個步驟:發送請求、執行過程、接收響應。由于網絡傳輸存在不确定性,導緻RPC調用存在一個陷阱,即有可能出現第一、第二步都成功、第三步失敗的情況,此時RPC的調用方由于接收不到結果,無法判斷被調用方是否已經完成過程調用,隻能按失敗處理。
通常RPC調用方會針對網絡失敗進行重試。在上述情況下,如果遠端代碼不具備幂等性,卻進行了重試,将導緻系統的資料一緻性遭到破壞,本該隻執行一次的事務被執行了兩次。
對于異步消息的消費者來講,也有同樣的問題。在手動ACK的情況下,消息的處理需要接收消息、處理消息、ACK三步,ACK的失敗也會導緻相同的問題。
在交易類的系統(比如電商、證券等)中,對非幂等的遠端過程進行重試,可能會導緻超買超賣,對客戶造成經濟損失。
網際網路應用一般都是提供7*24服務的,而網際網路應用本身又是快速疊代,後端系統是随時有可能需要進行釋出的。釋出等同于一次當機(程序被kill),這意味着對于網際網路應用的後端系統,當機是常态而非特例。這也是幂等性和重試的必要性來源之一。
幂等性适用領域
試想這樣的一種場景:在電商平台上支付後,因為網絡原因導緻系統提示你支付失敗,于是你又重新付款了一次,等完成後檢查網銀發現被系統扣了兩次款,這是一種什麼樣的體驗?
造成上述問題的原因可能有很多,比如第一次付款時實際支付成功,但是資訊傳回時網絡中斷導緻系統誤判;又比如第一次付款的确失敗了,但第二次付款時發生意外,導緻支付請求被重複發送等等。在一次支付的過程中,每個環節都有可能會發生問題,我們要如何規避這類問題引發的分險?
幂等性是解決這類問題的方案之一,是以在電商,銀行,網際網路金融等對資料準确性要求很高的領域中,這一特性具有十分重要的地位。
假設有一個從賬戶取錢的遠端API:
| |
withdraw的語義是從account_id對應的賬戶中扣除amount數額的錢;如果扣除成功則傳回true,賬戶餘額減少amount;如果扣除失敗則傳回false,賬戶餘額不變。
需要注意的是:和本地環境相比,我們不能輕易假設分布式環境的可靠性。
是以問題來了,一種典型的情況是withdraw請求已經被伺服器端正确處理,但伺服器端的傳回結果由于網絡等原因被掉丢了,導緻用戶端無法得知處理結果。如果是在網頁上,一些不恰當的設計可能會使使用者認為上一次操作失敗了,然後重新整理頁面,這就導緻了withdraw被調用兩次,賬戶也被多扣了一次錢。如圖所示:

解決方案一:采用分布式事務,通過引入支援分布式事務的中間件來保證withdraw功能的事務性。分布式事務的優點是對于調用者很簡單,複雜性都交給了中間件來管理。缺點則是一方面架構太重量級,容易被綁在特定的中間件上,不利于異構系統的內建;另一方面分布式事務雖然能保證事務的ACID性質,而但卻無法提供性能和可用性的保證。
解決方案二:幂等設計。我們可以通過一些技巧把withdraw變成幂等的,比如:
| |
createTicket的語義是擷取一個伺服器端生成的唯一的處理号ticket_id,它将用于辨別後續的操作。idempotentWithdraw和withdraw的差別在于關聯了一個ticket_id,一個ticket_id表示的操作至多隻會被處理一次,每次調用都将傳回第一次調用時的處理結果。這樣,idempotentWithdraw就符合幂等性了,用戶端就可以放心地多次調用。
基于幂等性的解決方案中一個完整的取錢流程被分解成了兩個步驟:1.調用createTicket()擷取ticket_id;2.調用idempotentWithdraw(ticket_id, account_id, amount)。雖然createTicket不是幂等的,但在這種設計下,它對系統狀态的影響可以忽略,加上idempotentWithdraw是幂等的,是以任何一步由于網絡等原因失敗或逾時,用戶端都可以重試,直到獲得結果。如圖所示:
除了查詢功能具有天然的幂等性之外,增加、更新、删除都要保證幂等性。那麼如何來保證幂等性呢?
CRUD分析
- 新增類請求(C)
- 資料庫自增主鍵,不具備幂等性
- 查詢類動作(R)
- 重複查詢不會産生或變更新的資料,是以查詢是天然具備幂等性
- 更新類請求(U)
- 基于主鍵的計算式Update,不具備幂等性,即:UPDATE goods SET number=number-1 WHERE id=1
- 基于主鍵的非計算式Update,具備幂等性,即:UPDATE goods SET number=newNumber WHERE id=1
- 基于條件查詢的Update,不一定具有幂等性(需要根據實際情況進行分析判斷)
- 删除類請求(D)
- 基于主建的Delete具備幂等性
- 一般業務層面都是邏輯删除(即Update操作),而基于主鍵的邏輯删除操作也是具有幂等性的
實作幂等性的技術方案
查詢操作
查詢一次和查詢多次,在資料不變的情況下,查詢結果是一樣的,select是天然的幂等操作。
删除操作
删除操作也是幂等的,删除一次和多次删除都是把資料删除。(注意可能傳回結果不一樣,删除的資料不存在,傳回0,删除的資料多條,傳回結果多個)。
去重表(唯一索引,防止新增髒資料)
利用資料庫表單的特性來實作幂等,常用的一個思路是在表上建構唯一性索引,保證某一類資料一旦執行完畢,後續同樣的請求再也無法成功寫入。
部落格點贊的例子,要想防止一個人重複點贊,可以設計一張表,将部落格id與使用者id綁定建立唯一索引,每當使用者點贊時就往表中寫入一條資料,這樣重複點贊的資料就無法寫入。
拿資金賬戶和使用者賬戶來說,每個使用者隻能有一個資金賬戶,怎麼防止給使用者建立資金賬戶多個,那麼給資金賬戶表中的使用者ID加唯一索引,在新增的時候隻有一個能請求成功,剩下都會抛出唯一索引重複異常。比如
org.springframework.dao.DuplicateKeyException
,這時候再查詢一次就可以了,資料存在,傳回結果。
token機制,防止頁面重複送出
這種機制就比較重要了,适用範圍較廣,有多種不同的實作方式。其核心思想是為每一次操作生成一個唯一性的憑證,也就是token。一個token在操作的每一個階段隻有一次執行權,一旦執行成功則儲存執行結果。對重複的請求,傳回同一個結果。
以電商平台為例子,電商平台上的訂單id就是最适合的token。當使用者下單時,會經曆多個環節,比如生成訂單,減庫存,減優惠券等等。
每一個環節執行時都先檢測一下該訂單id是否已經執行過這一步驟,對未執行的請求,執行操作并緩存結果,而對已經執行過的id,則直接傳回之前的執行結果,不做任何操作。這樣可以在最大程度上避免操作的重複執行問題,緩存起來的執行結果也能用于事務的控制等。
要求:頁面的資料隻能被點選送出一次
發生原因:由于重複點選或者網絡重發,或者nginx重發等情況會導緻資料被重複送出
解決辦法:
- 叢集環境:采用token加redis
- 單JVM環境:采用token加redis或token加jvm記憶體
處理流程:
- 資料送出前要向服務的申請token,token放到redis或jvm記憶體,token有效時間
- 送出後背景校驗token,同時删除token,生成新的token傳回
token特點:要申請,一次有效性,可以限流
注意:redis要用删除操作來判斷token,删除成功代表token校驗通過,如果用select+delete來校驗token,存在并發問題,不建議使用
悲觀鎖
擷取資料的時候加鎖擷取
| |
注意:id字段一定是主鍵或者唯一索引,不然是鎖表,會出事的。
悲觀鎖使用時一般伴随事務一起使用,資料鎖定時間可能會很長,根據實際情況選用。
樂觀鎖
樂觀鎖隻是在更新資料那一刻鎖表,其他時間不鎖表,是以相對于悲觀鎖,效率更高。
樂觀鎖的實作方式多種多樣可以通過version或者其他狀态條件:
1.通過版本号實作
MVCC,多版本并發控制,樂觀鎖的一種實作,在資料更新時需要去比較持有資料的版本号,版本号不一緻的操作無法成功。
| |
例如部落格點贊次數自動+1的接口:
| |
| |
每一個version隻有一次執行成功的機會,一旦失敗必須重新擷取。
2.通過條件限制
| |
要求:avai_amount-subAmount >=0
這個情景适合不用版本号,隻更新是做資料安全校驗,适合庫存模型,扣份額和復原份額,性能更高。
注意:樂觀鎖的更新操作,最好用主鍵或者唯一索引來更新,這樣是行鎖,否則更新時會鎖表,上面兩個sql改成下面的兩個更好。
| |
分布式鎖
還是拿插入資料的例子,如果是分布是系統,建構全局唯一索引比較困難,例如唯一性的字段沒法确定,這時候可以引入分布式鎖,通過第三方的系統(redis或zookeeper),在業務系統插入資料或者更新資料,擷取分布式鎖,然後做操作,之後釋放鎖,其實就是為了控制多線程并發的操作,也是分布式系統中經常用到的解決思路。
select + insert
并發不高的背景系統,或者一些任務JOB,為了支援幂等,支援重複執行,簡單的處理方法是,先查詢下一些關鍵資料,判斷是否已經執行過,在進行業務處理,就可以了。
注意:核心高并發流程不要用這種方法。
狀态機幂等
在設計單據相關的業務,或者是任務相關的業務,肯定會涉及到狀态機(狀态變更圖),就是業務單據上面有個狀态,狀态在不同的情況下會發生變更,一般情況下存在有限狀态機,這時候,如果狀态機已經處于下一個狀态,這時候來了一個上一個狀态的變更,理論上是不能夠變更的,這樣的話,保證了有限狀态機的幂等。
注意:訂單等單據類業務,存在很長的狀态流轉,一定要深刻了解狀态機,對業務系統設計能力提高有很大幫助。
對外提供接口的api如何保證幂等
如銀聯提供的付款接口:需要接入商戶提傳遞款請求時附帶:source來源,seq序列号
source+seq在資料庫裡面做唯一索引,防止多次付款,(并發時,隻能處理一個請求)。
重點:
對外提供接口為了支援幂等調用,接口有兩個字段必須傳,一個是來源source,一個是來源方序列号seq,這個兩個字段在提供方系統裡面做聯合唯一索引,這樣當第三方調用時,先在本方系統裡面查詢一下,是否已經處理過,傳回相應處理結果;沒有處理過,進行相應處理,傳回結果。注意,為了幂等友好,一定要先查詢一下,是否處理過該筆業務,不查詢直接插入業務系統,會報錯,但實際已經處理了。
HTTP的幂等性
HTTP協定本身是一種面向資源的應用層協定,但對HTTP協定的使用實際上存在着兩種不同的方式:一種是RESTful的,它把HTTP當成應用層協定,比較忠實地遵守了HTTP協定的各種規定;另一種是SOA的,它并沒有完全把HTTP當成應用層協定,而是把HTTP協定作為了傳輸層協定,然後在HTTP之上建立了自己的應用層協定。本文所讨論的HTTP幂等性主要針對RESTful風格的,不過正如上一節所看到的那樣,幂等性并不屬于特定的協定,它是分布式系統的一種特性;是以,不論是SOA還是RESTful的Web API設計都應該考慮幂等性。下面将介紹HTTP GET、DELETE、PUT、POST四種主要方法的語義和幂等性。
HTTP GET方法用于擷取資源,不應有副作用,是以是幂等的。比如:GET
http://www.bank.com/account/123456
,不會改變資源的狀态,不論調用一次還是N次都沒有副作用。請注意,這裡強調的是一次和N次具有相同的副作用,而不是每次GET的結果相同。GET
http://www.news.com/latest-news
這個HTTP請求可能會每次得到不同的結果,但它本身并沒有産生任何副作用,因而是滿足幂等性的。
HTTP DELETE方法用于删除資源,有副作用,但它應該滿足幂等性。比如:DELETE
http://www.forum.com/article/4231
,調用一次和N次對系統産生的副作用是相同的,即删掉id為4231的文章;是以,調用者可以多次調用或重新整理頁面而不必擔心引起錯誤。
比較容易混淆的是HTTP POST和PUT。POST和PUT的差別容易被簡單地誤認為“POST表示建立資源,PUT表示更新資源”;而實際上,二者均可用于建立資源,更為本質的差别是在幂等性方面。在HTTP規範中對POST和PUT是這樣定義的:
| |
POST所對應的URI并非建立的資源本身,而是資源的接收者。比如:
POST http://www.forum.com/articles
的語義是在
http://www.forum.com/articles
下建立一篇文章,HTTP響應中應包含文章的建立狀态以及文章的URI。兩次相同的POST請求會在伺服器端建立兩份資源,它們具有不同的URI;是以,POST方法不具備幂等性。而PUT所對應的URI是要建立或更新的資源本身。比如:
PUT http://www.forum/articles/4231
的語義是建立或更新ID為4231的文章。對同一URI進行多次PUT的副作用和一次PUT是相同的;是以,PUT方法具有幂等性。
在介紹了幾種操作的語義和幂等性之後,我們來看看如何通過Web API的形式實作前面所提到的取款功能。很簡單,用
POST /tickets
來實作createTicket;用
PUT /accounts/account_id/ticket_id?amount=xxx
來實作idempotentWithdraw。值得注意的是嚴格來講amount參數不應該作為URI的一部分,真正的URI應該是
/accounts/account_id/ticket_id
,而amount應該放在請求的body中。這種模式可以應用于很多場合,比如:論壇網站中防止意外的重複發帖。
擴充案例
如何防範表單 (POST) 重複送出
HTTP POST 操作既不是安全的,也不是幂等的(至少在HTTP規範裡沒有保證)。當我們因為反複重新整理浏覽器導緻多次送出表單,多次發出同樣的POST請求,導緻遠端伺服器重複建立出了資源。是以,對于電商應用來說,第一,對應的後端 WebService 一定要做到幂等性;第二,伺服器端收到 POST 請求,在操作成功後必須302跳轉到另外一個頁面,這樣即使使用者重新整理頁面,也不會重複送出表單。
把分布式事務分解為具有幂等性的異步消息處理
電商的很多業務,考慮更多的是 BASE(即Basically Available、Soft state、和Eventually consistent),而不是 ACID(Atomicity、Consistency、Isolation和 Durability)。即為了滿足高負載的使用者通路,我們可以容忍短暫的資料不一緻。那怎麼做呢?
- 不做分布式事務,代價太大
- 不一定需要實時一緻性,隻需要保證最終的一緻性即可
- 通過狀态機和嚴格的有序操作,來最大限度地降低不一緻性
- 最終一緻性(Eventually Consistent)通過異步事件做到
如果消息具有操作幂等性,也就是一個消息被應用多次與應用一次産生的效果是一樣的話,那麼把不需要同步執行的事務交給異步消息推送和訂閱者叢集來處理即可。假如消息處理失敗,那麼就消息重播,由于幂等性,應用多次也能産生正确的結果。
實際情況下,消息很難具有幂等性,解決方法是使用另一個表記錄已經被成功應用的消息,即消息隊列和消息應用狀态表一起來解決問題。
最後總結:
和分布式事務相比,幂等設計的優勢在于它的輕量級,容易适應異構環境,以及性能和可用性方面。在某些性能要求比較高的應用,幂等設計往往是唯一的選擇。
幂等性應該是合格程式員的一個基因,在設計系統時,是首要考慮的問題,尤其是在像第三方支付平台,銀行,網際網路金融公司等涉及的網上資金系統,既要高效,資料也要準确,是以不能出現多扣款,多打款等問題,這樣會很難處理,并會大大降低使用者體驗。
業務系統實作幂等性的方式基本确定。系統關鍵接口的幂等性為以後系統的長期發展,特别是往分布式方向發展打下了很好的根基,可以大大簡化分布式應用的建構複雜度。
</div>
<div class="article-info article-info-index">
<div class="article-tag tagcloud">
<i class="icon-price-tags icon"></i>
<ul class="article-tag-list">
<li class="article-tag-list-item">
<a href="javascript:void(0)" class="js-tag article-tag-list-link color1">計算機基礎</a>
</li>
</ul>
</div>
<div class="article-category tagcloud">
<i class="icon-book icon"></i>
<ul class="article-tag-list">
<li class="article-tag-list-item">
<a href="/categories/計算機基礎//" class="article-tag-list-link color1">計算機基礎</a>
</li>
</ul>
</div>
原文位址:http://byteliu.com/2019/01/05/%E5%B9%82%E7%AD%89%E6%80%A7/