高併發下如何保證接口的冪等性?

前言


不知道你有沒有遇到過這些場景:

  1. 有時我們在填寫某些form表單時,保存按鈕不小心快速點了兩次,表中竟然產生了兩條重複的數據,只是 id 不一樣。

  2. 我們在項目中爲了解決接口超時問題,通常會引入了重試機制。第一次請求接口超時了,請求方沒能及時獲取返回結果(此時有可能已經成功了),爲了避免返回錯誤的結果(這種情況不可能直接返回失敗吧?),於是會對該請求重試幾次,這樣也會產生重複的數據。

  3. mq 消費者在讀取消息時,有時候會讀取到重複消息(至於什麼原因這裏先不說,有興趣的小夥伴,可以找我私聊),如果處理不好,也會產生重複的數據。

沒錯,這些都是冪等性問題。

接口冪等性是指用戶對於同一操作發起的一次請求或者多次請求的結果是一致的,不會因爲多次點擊而產生了副作用。

這類問題多發於接口的:

那麼我們要如何保證接口冪等性?本文將會告訴你答案。

  1. insert 前先 select

通常情況下,在保存數據的接口中,我們爲了防止產生重複數據,一般會在insert前,先根據namecode字段select一下數據。如果該數據已存在,則執行update操作,如果不存在,才執行  insert操作。

該方案可能是我們平時在防止產生重複數據時,使用最多的方案。但是該方案不適用於併發場景,在併發場景中,要配合其他方案一起使用,否則同樣會產生重複數據。我在這裏提一下,是爲了避免大家踩坑。

  1. 加悲觀鎖

在支付場景中,用戶 A 的賬號餘額有 150 元,想轉出 100 元,正常情況下用戶 A 的餘額只剩 50 元。一般情況下,sql 是這樣的:

1update user amount = amount-100 where id=123;
2

如果出現多次相同的請求,可能會導致用戶 A 的餘額變成負數。這種情況,用戶 A 來可能要哭了。於此同時,系統開發人員可能也要哭了,因爲這是很嚴重的系統 bug。

爲了解決這個問題,可以加悲觀鎖,將用戶 A 的那行數據鎖住,在同一時刻只允許一個請求獲得鎖,更新數據,其他的請求則等待。

通常情況下通過如下 sql 鎖住單行數據:

1select * from user id=123 for update;
2

具體流程如下:

具體步驟:

  1. 多個請求同時根據 id 查詢用戶信息。

  2. 判斷餘額是否不足 100,如果餘額不足,則直接返回餘額不足。

  3. 如果餘額充足,則通過 for update 再次查詢用戶信息,並且嘗試獲取鎖。

  4. 只有第一個請求能獲取到行鎖,其餘沒有獲取鎖的請求,則等待下一次獲取鎖的機會。

  5. 第一個請求獲取到鎖之後,判斷餘額是否不足 100,如果餘額足夠,則進行 update 操作。

  6. 如果餘額不足,說明是重複請求,則直接返回成功。

需要特別注意的是:如果使用的是 mysql 數據庫,存儲引擎必須用 innodb,因爲它才支持事務。此外,這裏 id 字段一定要是主鍵或者唯一索引,不然會鎖住整張表。

悲觀鎖需要在同一個事務操作過程中鎖住一行數據,如果事務耗時比較長,會造成大量的請求等待,影響接口性能。

此外,每次請求接口很難保證都有相同的返回值,所以不適合冪等性設計場景,但是在防重場景中是可以的使用的。

在這裏順便說一下,防重設計 和 冪等設計,其實是有區別的。防重設計主要爲了避免產生重複數據,對接口返回沒有太多要求。而冪等設計除了避免產生重複數據之外,還要求每次請求都返回一樣的結果。

  1. 加樂觀鎖

既然悲觀鎖有性能問題,爲了提升接口性能,我們可以使用樂觀鎖。需要在表中增加一個timestamp或者version字段,這裏以version字段爲例。

在更新數據之前先查詢一下數據:

1select id,amount,version from user id=123;
2

如果數據存在,假設查到的version等於1,再使用idversion字段作爲查詢條件更新數據:

1update user set amount=amount+100,version=version+1
2where id=123 and version=1;
3

更新數據的同時version+1,然後判斷本次update操作的影響行數,如果大於 0,則說明本次更新成功,如果等於 0,則說明本次更新沒有讓數據變更。

由於第一次請求version等於1是可以成功的,操作成功後version變成2了。這時如果併發的請求過來,再執行相同的 sql:

1 update user set amount=amount+100,version=version+1
2where id=123 and version=1;
3

update操作不會真正更新數據,最終 sql 的執行結果影響行數是0,因爲version已經變成2了,where中的version=1肯定無法滿足條件。但爲了保證接口冪等性,接口可以直接返回成功,因爲version值已經修改了,那麼前面必定已經成功過一次,後面都是重複的請求。

具體流程如下:

具體步驟:

  1. 先根據 id 查詢用戶信息,包含 version 字段

  2. 根據 id 和 version 字段值作爲 where 條件的參數,更新用戶信息,同時 version+1

  3. 判斷操作影響行數,如果影響 1 行,則說明是一次請求,可以做其他數據操作。

  4. 如果影響 0 行,說明是重複請求,則直接返回成功。

  5. 加唯一索引


絕大數情況下,爲了防止重複數據的產生,我們都會在表中加唯一索引,這是一個非常簡單,並且有效的方案。

1alter table `order` add UNIQUE KEY `un_code` (`code`);
2

加了唯一索引之後,第一次請求數據可以插入成功。但後面的相同請求,插入數據時會報Duplicate entry '002' for key 'order.un_code異常,表示唯一索引有衝突。

雖說拋異常對數據來說沒有影響,不會造成錯誤數據。但是爲了保證接口冪等性,我們需要對該異常進行捕獲,然後返回成功。

如果是java程序需要捕獲:DuplicateKeyException異常,如果使用了spring框架還需要捕獲:MySQLIntegrityConstraintViolationException異常。

具體流程圖如下:

具體步驟:

  1. 用戶通過瀏覽器發起請求,服務端收集數據。

  2. 將該數據插入 mysql

  3. 判斷是否執行成功,如果成功,則操作其他數據(可能還有其他的業務邏輯)。

  4. 如果執行失敗,捕獲唯一索引衝突異常,直接返回成功。

  5. 建防重表


有時候表中並非所有的場景都不允許產生重複的數據,只有某些特定場景纔不允許。這時候,直接在表中加唯一索引,顯然是不太合適的。

針對這種情況,我們可以通過建防重表來解決問題。

該表可以只包含兩個字段:id唯一索引,唯一索引可以是多個字段比如:name、code 等組合起來的唯一標識,例如:susan_0001。

具體流程圖如下:

具體步驟:

  1. 用戶通過瀏覽器發起請求,服務端收集數據。

  2. 將該數據插入 mysql 防重表

  3. 判斷是否執行成功,如果成功,則做 mysql 其他的數據操作(可能還有其他的業務邏輯)。

  4. 如果執行失敗,捕獲唯一索引衝突異常,直接返回成功。

需要特別注意的是:防重表和業務表必須在同一個數據庫中,並且操作要在同一個事務中。

  1. 根據狀態機

很多時候業務表是有狀態的,比如訂單表中有:1 - 下單、2 - 已支付、3 - 完成、4 - 撤銷等狀態。如果這些狀態的值是有規律的,按照業務節點正好是從小到大,我們就能通過它來保證接口的冪等性。

假如 id=123 的訂單狀態是已支付,現在要變成完成狀態。

1update `order` set status=3 where id=123 and status=2;
2

第一次請求時,該訂單的狀態是已支付,值是2,所以該update語句可以正常更新數據,sql 執行結果的影響行數是1,訂單狀態變成了3

後面有相同的請求過來,再執行相同的 sql 時,由於訂單狀態變成了3,再用status=2作爲條件,無法查詢出需要更新的數據,所以最終 sql 執行結果的影響行數是0,即不會真正的更新數據。但爲了保證接口冪等性,影響行數是0時,接口也可以直接返回成功。

具體流程圖如下:

具體步驟:

  1. 用戶通過瀏覽器發起請求,服務端收集數據。

  2. 根據 id 和當前狀態作爲條件,更新成下一個狀態

  3. 判斷操作影響行數,如果影響了 1 行,說明當前操作成功,可以進行其他數據操作。

  4. 如果影響了 0 行,說明是重複請求,直接返回成功。

主要特別注意的是,該方案僅限於要更新的表有狀態字段,並且剛好要更新狀態字段的這種特殊情況,並非所有場景都適用。

  1. 加分佈式鎖

其實前面介紹過的加唯一索引或者加防重表,本質是使用了數據庫分佈式鎖,也屬於分佈式鎖的一種。但由於數據庫分佈式鎖的性能不太好,我們可以改用:rediszookeeper

鑑於現在很多公司分佈式配置中心改用apollonacos,已經很少用zookeeper了,我們以redis爲例介紹分佈式鎖。

目前主要有三種方式實現 redis 的分佈式鎖:

  1. setNx 命令

  2. set 命令

  3. Redission 框架

每種方案各有利弊,具體實現細節我就不說了,有興趣的朋友可以加我微信找我私聊。

具體流程圖如下:

具體步驟:

  1. 用戶通過瀏覽器發起請求,服務端會收集數據,並且生成訂單號 code 作爲唯一業務字段。

  2. 使用 redis 的 set 命令,將該訂單 code 設置到 redis 中,同時設置超時時間。

  3. 判斷是否設置成功,如果設置成功,說明是第一次請求,則進行數據操作。

  4. 如果設置失敗,說明是重複請求,則直接返回成功。

需要特別注意的是:分佈式鎖一定要設置一個合理的過期時間,如果設置過短,無法有效的防止重複請求。如果設置過長,可能會浪費redis的存儲空間,需要根據實際業務情況而定。

  1. 獲取 token

除了上述方案之外,還有最後一種使用token的方案。該方案跟之前的所有方案都有點不一樣,需要兩次請求才能完成一次業務操作。

  1. 第一次請求獲取token

  2. 第二次請求帶着這個token,完成業務操作。

具體流程圖如下:

第一步,先獲取 token。

第二步,做具體業務操作。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/v4CamZlqHP8gEmubzPme4g