設計一個回撥要注意哪些事情
阿新 • • 發佈:2021-02-20
# 設計一個回撥要注意哪些事情
回撥是我們在設計系統的時候經常會使用到的, A服務呼叫B服務, 但是如果B服務提供的是一個較長時間的、非同步的介面, 那麼我們就會想到使用一個回撥, 讓B服務在非同步處理結束之後, 來呼叫A的一個回撥介面. 但是細品一下, 這一來一回的設計, 需要思考的點遠不是一個回撥介面這麼簡單.
![20210220182454](http://tuchuang.funaio.cn/md/20210220182454.png)
# 回撥是天生的併發
首先, 回撥是一個天生的併發操作. 如果你的A服務在呼叫B服務等待回撥的時候, 所有上下文都會保持原狀, 只有回撥才會修改, 那麼是不會有併發問題的. 但是如果A服務在等待回撥的過程中, 上下文會根據某個情況進行變化, 那麼這裡就有一個併發問題. 如果A的狀態變化在回撥之前? 如果A的狀態變化在回撥之後? 如果A的狀態變化與回撥同時進行?
前面兩種, 如果狀態變化和回撥在前後, 那麼唯一要注意的是需要做好防禦程式設計. 狀態的變更是有順序的, 不應該讓狀態有任何躍遷或者回退行為. 否則很容易產生髒資料.
後面那種情況, 如果狀態變化和回撥同時進行, 這種情況其實是非常難處理的. 一般回撥會是一個請求, 請求的鏈路是非常長的, 如何保證整個請求的原子性, 甚至於考慮日誌的原子性, 是一個不小的挑戰. 大致想了下, 可以有如下幾個方法:
1 粒度比較大的鎖, 這是通用性方法了. 將整個上下文鎖住, 鎖期間只有回撥或者狀態變化能進行操作. 當然, 如果不介意髒讀的話, 完全可以使用讀鎖來保證讀的高可用.
2 (讀狀態標記位 + 寫狀態標記位) + 防禦程式設計. 需要保證這個狀態標記位是原子性的, 這個還是比較好找的, 比如redis的某個key, mysql的某個欄位. 但是需要保證讀和寫是一個原子行為. 當一個行為已經修改了狀態標記位後, 另外一個行為會被防禦程式設計攔下來.
3 佇列化. 先使用佇列, 將某個狀態的變更都佇列化, 然後非同步一個個處理佇列. 這個也是一個很好用的方法. 將所有的操作序列化自然能解決併發問題. 如果像go這樣的天生協程的語言, 可以不依賴外部佇列, 不妨開一個協程使用channel來進行序列化.
# 回撥的超時時間
給一個回撥設定超時時間, 這往往是個很難的事情. 它難的地方有兩個: 第一, 被呼叫的B服務往往給不出這個時間. 既然是非同步, B需要考慮的鏈路一定很長. 加之既然是服務間呼叫, 基本上你們兩個服務會屬於兩個組織結構, 跨組織結構的溝通, 在所有公司都是一個不大不小的門檻. 第二, 超時之後的處理, 如果被呼叫方B服務給了一個超時時間, 那麼A在超時時間之後要做些什麼? B在超時時間之後還是否要傳送回撥呢? 這又涉及到了一個補刀機制.
但是反之思考, 如果不設定超時時間, 那麼程式的健壯性又會是個很大的問題. 不管由於什麼原因, 網路抖動, 程式bug, 回撥丟失了. 被呼叫方B以為已經發了回撥, 呼叫方A卻沒有收到回撥. 這種不一致性會是一個更大的災難, 它可能導致各種補刀策略失效.
所以還是建議需要給回撥設定一個超時時間的, 至於超時後的處理, 則可以再定義一個機制進行補償.
# 回撥需要心跳麼?
這也是一個很有意思的方案. 沿著回撥設定超時時間的思路, 可能就有一種解決方案是我設定心跳是否可行. A呼叫B之後, 在等待B回撥的過程中, B不斷髮送心跳給A, 告訴A我正在處理中, 一旦不傳送心跳了. 那麼就代表我死亡了.
首先這種就是一個有點悖論的方案. 這個心跳如果是從B到A, 那麼為何不調整一個方向, 從A到B進行狀態查詢呢? 這種狀態查詢也可以充當心跳的功能. 再進一步, 既然都有了這種狀態查詢的心跳, 那為何還需要回調呢? 狀態結束的時候, 這種狀態查詢心跳自然也就會檢測到的. 當然這裡可能唯一的差別就是實時性, 回撥是一種結束即通知的機制, 心跳是一種定期得到通知的機制. 這又是另外一個需要考量的點了. 在非同步絕大多數的場景下, 是可以容忍心跳時長的延遲的, 畢竟..都走非同步化了, 多等一個心跳時間又有何妨呢?
# 回撥的重試機制
被呼叫方B往往也是會知道回撥的重要性, 所以一般會進行重試. 但是這種重試,如果不注意的話, 在有的時候, 就是殺死A服務的最後一根稻草.
其實就一點, 我們需要防止B服務的回撥在短時間內堆積傳送給A. 但是往往這種情況又是很可能發生的. 因為發生的原因很多, 比如B服務的佇列堆積, 重啟之後的瘋狂傳送. 又比如A服務的服務對接, 同一時間給B服務傳送了很多工, B服務的任務處理時長基本恆定, 導致同一時間一堆任務需要回調通知. 而這個時候, A服務如果在扛不住的情況下, 又會又導致很多回調失敗, 觸發回撥重試機制.
當然, 這裡的回撥重試機制, 不是回撥特有的, 而是重試機制特有的. 好的重試機制應該是雜湊的, 重試時長遞增的. 這裡可以參考TCP的慢啟動機制.
# 能不用回撥就不用回撥
這個就是我整篇的觀點, 能不用回撥就不用回撥, 因為回撥要考慮的東西確實不少. 當然特定場景有特定的方法, 並不是所有場景都有併發,原子性的需求. 如果上述的理由還不夠, 我想從業務架構層面再叨叨幾個回撥缺點.
## 回撥使得鏈路變長且無向
我們最舒服的模組鏈路是A呼叫B再呼叫C. 但是一旦引入了回撥, 就有可能A呼叫B,B回撥A, A再呼叫B或者C, 如果頻繁使用回撥. 這個鏈路是一個很不舒服的鏈路. 即使服務只有少數幾個, 也能讓鏈路長度幾何性增長. 並且最致命還是鏈路的無向性. A和B可以互相呼叫, 會導致分層非常不合理.
## 主導權喪失
對於一個任務, A是發起方, 但是A不是結束的發起方, 而是結束的被呼叫方,其實這就把主動權丟失一部分給B服務了. 業務邏輯就不閉合在A服務了. 這也算是一種主導權的喪失把.
## 服務耦合性增加
A呼叫B, B回撥A, 這種設計就把A和B繫結在一起了. 耦合性增加的缺點一大堆, 這裡就不贅述了.
# 總結
當然, 如果你看了上面的那麼多回調的弊端, 還是在某個場景還是決定使用回撥, 那麼我相信, 這個場景一定有不得不用回撥的原因. 瑾告訴, 慎用之. 因為, 我就是這麼踩坑過