在微服務架構下,我們在完成一個訂單流程時經常遇到下面的場景:
1.一個訂單創建接口,第一次調用超時了,然后調用方重試了一次 2.在訂單創建時,我們需要去扣減庫存,這時接口發生了超時,調用方重試了一次 3.當這筆訂單開始支付,在支付請求發出之后,在服務端發生了扣錢操作,接口響應超時了,調用方重試了一次 4.一個訂單狀態更新接口,調用方連續發送了兩個消息,一個是已創建,一個是已付款。但是你先接收到已付款,然后又接收到了已創建 5.在支付完成訂單之后,需要發送一條短信,當一台機器接收到短信發送的消息之后,處理較慢。消息中間件又把消息投遞給另外一台機器處理
以上問題,就是在單體架構轉成微服務架構之后,帶來的問題。當然不是說單體架構下沒有這些問題,在單體架構下同樣要避免重復請求。但是出現的問題要比這少得多。
為了解決以上問題,就需要保證接口的冪等性,接口的冪等性實際上就是接口可重復調用,在調用方多次調用的情況下,接口最終得到的結果是一致的。有些接口可以天然的實現冪等性,比如查詢接口,對於查詢來說,你查詢一次和兩次,對於系統來說,沒有任何影響,查出的結果也是一樣。
除了查詢功能具有天然的冪等性之外,增加、更新、刪除都要保證冪等性。
一:那么如何來保證冪等性呢?
全局唯一ID
如果使用全局唯一ID,就是根據業務的操作和內容生成一個全局ID,在執行操作前先根據這個全局唯一ID是否存在,來判斷這個操作是否已經執行。如果不存在則把全局ID,存儲到存儲系統中,比如數據庫、redis等。如果存在則表示該方法已經執行。
從工程的角度來說,使用全局ID做冪等可以作為一個業務的基礎的微服務存在,在很多的微服務中都會用到這樣的服務,在每個微服務中都完成這樣的功能,會存在工作量重復。另外打造一個高可靠的冪等服務還需要考慮很多問題,比如一台機器雖然把全局ID先寫入了存儲,但是在寫入之后掛了,這就需要引入全局ID的超時機制。
使用全局唯一ID是一個通用方案,可以支持插入、更新、刪除業務操作。但是這個方案看起來很美但是實現起來比較麻煩,下面的方案適用於特定的場景,但是實現起來比較簡單。
去重表
這種方法適用於在業務中有唯一標的插入場景中,比如在以上的支付場景中,如果一個訂單只會支付一次,所以訂單ID可以作為唯一標識。這時,我們就可以建一張去重表,並且把唯一標識作為唯一索引,在我們實現時,把創建支付單據和寫入去去重表,放在一個事務中,如果重復創建,數據庫會拋出唯一約束異常,操作就會回滾。
插入或更新
這種方法插入並且有唯一索引的情況,比如我們要關聯商品品類,其中商品的ID和品類的ID可以構成唯一索引,並且在數據表中也增加了唯一索引。這時就可以使用InsertOrUpdate操作
多版本控制
這種方法適合在更新的場景中,比如我們要更新商品的名字,這時我們就可以在更新的接口中增加一個版本號,來做冪等
狀態機控制
這種方法適合在有狀態機流轉的情況下,比如就會訂單的創建和付款,訂單的付款肯定是在之前,這時我們可以通過在設計狀態字段時,使用int類型,並且通過值類型的大小來做冪等,比如訂單的創建為0,付款成功為100。付款失敗為99
二、如何理解冪等性
這是個高等代數中的概念。
簡而言之就是x^Y=x
x可能是任何元素,包括(數、矩陣等)
冪等的的意思就是一個操作不會修改狀態信息,並且每次操作的時候都返回同樣的結果。即:做多次和做一次的效果是一樣 的。
在web設計上即指多次HTTP請求返回值相同
簡單的說,純查詢,如SELECT,用GET。如果改變數據庫的內容,如UPDATE,INSERT,DELETE,用POST。
三、理解HTTP冪等性
根據HTTP標准,HTTP請求可以使用多種請求方式,HTTP/1.1協議中共定義了八種方法/動作,來表明Request-URL指定的資源不同的操作方式
HTTP1.0定義了三種請求方法:GET, POST 和 HEAD方法
HTTP1.1新增了五種請求方法:OPTIONS, PUT, DELETE, TRACE 和 CONNECT 方法
下面列舉四個常用的方法,來說明下各自是否滿足冪等要求
-
GET
經常使用的方式之一,用於獲取數據和資源,不會有副作用,所以是冪等的。
如:URL為http://localhost:8080/crm/get/1,無論調用多少次,數據庫數據是不會變 更的,只是每次返回的結果可能不一樣,所以是滿足冪等。
-
POST
也是經常使用的方式之一,用於往數據庫添加或修改數據,每調用一次
會產生新的數據,是數據經常發生變化,所以不是冪等的。
-
PUT
常用於創建和更新指定的一條數據,如果數據不存在則新建,如果存在則更新數據,多次和一次調用產生的副作用是一樣的,所以是滿足冪等。
-
DELETE
從單詞就能理解字面意思,用於刪除數據,一般根據ID,如URL:為http://localhost:8080/crm/delete/100,刪掉客戶ID為100的數據,調用一次或多次對系統產生的副作用是一樣的,所以是滿足冪等。
四、需要冪等的場景
冪等性問題在我們開發過程中、高並發、分布式、微服務架構中隨處可見的,具體舉例以下幾個經常遇到的場景
-
網絡波動
因網絡波動,可能會引起重復請求
-
MQ消息重復
生產者已把消息發送到mq,在mq給生產者返回ack的時候網絡中斷,故生產者未收到確定信息,生產者認為消息未發送成功,但實際情況是,mq已成功接收到了消息,在網絡重連后,生產者會重新發送剛才的消息,造成mq接收了重復的消息。
-
用戶重復點擊
用戶在使用產品時,可能會誤操作而觸發多筆交易,或者因為長時間沒有響應,而有意觸發多筆交易。
-
應用使用失敗或超時重試機制
為了考慮系統業務穩定性,開發人員一般設計系統時,會考慮失敗了如何進行下一步操作或等待一定時間繼續前面的動作的。
五、應該在哪一層進行冪等設計
目前互聯網技術架構基本都是分布式、微服務架構,層次分的也比較清晰,如
-
第一層:APP、H5、PC
-
第二層:負載均衡設備(Nginx)
-
第三層:網關層(GateWay)
-
第四層:業務層(Service)
-
第五層:持久層(ORM)
-
第六層:數據庫層(DB)
那到底在哪一層實現冪等呢?
一般網關層主要的任務是路由轉發、請求鑒權和身份認證、限流、跨域、流量監控、請求日志、ACL控制等。如果在網關層實現冪等性,那需要把業務代碼寫在網關層,這種做法一般在設計中是很少推薦的,所以不適合
業務層主要是處理業務邏輯,對查詢或新增的結果進行一些運算等,所以也不合適
持久層也叫數據訪問層,和數據庫打交道,這塊不做冪等性的話,可能對數據產生一定影響,所以這一層是需要作品冪等性校驗。
通過以上分析我們得知冪等性一般在持久層去實現。
六、談談解決方案
-
前端冪等性控制
1、按鈕只能點擊一次
如用戶點擊查詢或提交訂單號,按鈕變灰或頁面顯示loding狀態。防止用戶重復點擊。
2、token機制
產品允許重復提交,但要保證提交多次和一次產生的結果是一致的。
具體實現是進入頁面時申請一個token,然后后面所有請求都帶上這個token,根據token來避免重復請求。見下圖

3、使用重定向機制(Post-Redirect-Get模式)
當用戶提交了表單,后端處理完成后,跳轉到另外一個成功或失敗的頁面,這樣避免用戶按F5刷新瀏覽器導致重復提交。
4、在Session存放唯一標識
用戶進入頁面時,服務端生成一個唯一的標識值,存到session中,同時將它寫入表單的隱藏域中,用戶在輸入信息后點擊提交,在服務端獲取表單的隱藏域字段的值來與session中的唯一標識值進行比較,相等則說明是首次提交,就處理本次請求,然后刪除session唯一標識,不相等則標識重復提交,忽略本次處理。
因前端涉及到多設備,兼容性等問題,以上方案不一定非常可靠。
-
后端冪等性控制
1、使用數據庫唯一索引
開發的同學對數據庫肯定不陌生,對數據庫的約束也應該比較熟悉,
如MySQL有五大約束,主鍵、外鍵、非空、唯一、默認約束。我們可以使用數據庫提供的唯一約束來保證數據重復插入,避免臟數據產生。這種做法比較簡單粗暴,直接拋出異常信息即可。
2、token+redis
這種方式分成兩個階段:獲取token和業務操作階段。
我們以支付為例
第一階段,在進入到提交訂單頁面之前,需要在訂單系統根據當前用戶信息向支付系統發起一次申請token請求,支付系統將token保存到redis中,作為第二階段支付使用
第二階段,訂單系統拿着申請到的token發起支付請求,支付系統會檢查redis中是否存在該token,如果有,表示第一次請求支付,開始處理支付邏輯,處理完成后刪除redis中的token
當重復請求時候,檢查redis中token是否存在,不存在,則表示非法請求。可以見下圖

該方案唯一的缺點就是需要與系統進行兩次交互
3、基於狀態控制
如:購物下單,邏輯是當訂單狀態為已付款,才允許發貨
在設計時候最好只支持狀態的單向改變(不可逆),這樣在更新的時候where條件里可以加上status = 已付款
如:update table set status=下一種狀態 where id =1 and status=已付款
4、基於樂觀鎖來實現
如果更新已有數據,可以進行加鎖更新,也可以設計表結構時使用version來做樂觀鎖,這樣既能保證執行效率,又能保證冪等。
樂觀鎖version字段在更新業務數據時值要自增。
也可以采用update with condition更新帶條件來實現樂觀鎖。
具體看下version如何定義
![]()
sql為:update table set q =q,version = version + 1 where id =1 and version =#{version }
5、防重表
需要增加一個表,這個表叫做防重表(防止數據重復的表)
使用唯一主鍵如:uuid去做防重表的唯一索引,每次請求都往防重表中插入一條數據。第一次請求由於沒有記錄插入成功,成功后進行后續業務處理,處理完后(無論成功或失敗)刪除去重表中的數據,如果在處理過程中,有新的相同uuid請求過來,插入的時候因為表中唯一索引而插入失敗,則返回操作失敗。可以看出防重表作用是加鎖的功能。
6、分布式鎖
在進入方法時,先獲取鎖,假如獲取到鎖,就繼續后面流程。假設沒有獲取到鎖,就等待鎖的釋放直到獲取鎖,當執行完方法時,釋放鎖,當然,鎖要設個超時時間,防止意外沒有釋放到鎖,它可以用來解決分布式系統的冪等性;
常用的分布式鎖實現方案是redis 和 zookeeper 等工具。
使用分布式鎖類似於防重表,將防重並發放到了緩存中,較為高效,同一時間只能完成一次操作。
zk實現分布式鎖的流程如下

redis 分布式鎖工具類
@Component public class RedisLock { private static final Long RELEASE_SUCCESS = 1L; private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; // 當前設置 過期時間單位, EX = seconds; PX = milliseconds private static final String SET_WITH_EXPIRE_TIME = "EX"; //lua private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; @Autowired private StringRedisTemplate redisTemplate; /** * 該加鎖方法僅針對單實例 Redis 可實現分布式加鎖 * 對於 Redis 集群則無法使用 * <p> * 支持重復,線程安全 * * @param lockKey 加鎖鍵 * @param clientId 加鎖客戶端唯一標識(采用UUID) * @param seconds 鎖過期時間 * @return */ public boolean tryLock(String lockKey, String clientId, long seconds) { return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> { // Jedis jedis = (Jedis) redisConnection.getNativeConnection(); Object nativeConnection = redisConnection.getNativeConnection(); RedisSerializer<String> stringRedisSerializer = (RedisSerializer<String>) redisTemplate.getKeySerializer(); byte[] keyByte = stringRedisSerializer.serialize(lockKey); byte[] valueByte = stringRedisSerializer.serialize(clientId); // lettuce連接包下 redis 單機模式 if (nativeConnection instanceof RedisAsyncCommands) { RedisAsyncCommands connection = (RedisAsyncCommands) nativeConnection; RedisCommands commands = connection.getStatefulConnection().sync(); String result = commands.set(keyByte, valueByte, SetArgs.Builder.nx().ex(seconds)); if (LOCK_SUCCESS.equals(result)) { return true; } } // lettuce連接包下 redis 集群模式 if (nativeConnection instanceof RedisAdvancedClusterAsyncCommands) { RedisAdvancedClusterAsyncCommands connection = (RedisAdvancedClusterAsyncCommands) nativeConnection; RedisAdvancedClusterCommands commands = connection.getStatefulConnection().sync(); String result = commands.set(keyByte, valueByte, SetArgs.Builder.nx().ex(seconds)); if (LOCK_SUCCESS.equals(result)) { return true; } } if (nativeConnection instanceof JedisCommands) { JedisCommands jedis = (JedisCommands) nativeConnection; String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds); if (LOCK_SUCCESS.equals(result)) { return true; } } return false; }); } /** * 與 tryLock 相對應,用作釋放鎖 * * @param lockKey * @param clientId * @return */ public boolean releaseLock(String lockKey, String clientId) { DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(RELEASE_LOCK_SCRIPT); redisScript.setResultType(Integer.class); // Integer execute = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), clientId); Object execute = redisTemplate.execute((RedisConnection connection) -> connection.eval( RELEASE_LOCK_SCRIPT.getBytes(), ReturnType.INTEGER, 1, lockKey.getBytes(), clientId.getBytes())); if (RELEASE_SUCCESS.equals(execute)) { return true; } return false; } }
6、緩存隊列
將請求快速的接收下來,放入緩沖隊列中,后續使用異步任務處理隊列的數據,過濾掉重復請求,我們可以用LinkedList來實現隊列,一個HashSet來實現去重。此方法優點是異步處理、高吞吐,不足是不能及時返回請求結果,需要后續輪詢處理結果。
7、全局唯一ID
比如通過source來源+seq組成ID來判斷請求是否重復,在並發時,只能處理一個請求,其它要么並發請求那么返回請求重復,那么等待前面的請求執行完成后 在執行。具體我們可以將請求關鍵性數據或者請求的全部數據組合生成md5碼,這樣的話,重復請求都是一個相同ID;如果所有請求包括重復請求都要唯一ID,那么可以用UUID或者用雪花算法生成唯一ID。
六、保證冪等性總結
冪等性應該是合格程序員的一個基因,在設計系統時,是首要考慮的問題,尤其是在像支付寶,銀行,互聯網金融公司等涉及的網上資金系統,既要高效,
數據也要准確,所以不能出現多扣款,多打款等問題,這樣會很難處理,並會大大降低用戶體驗。
