設計一個回撥要注意哪些事情

軒脈刃發表於2021-02-20

設計一個回撥要注意哪些事情

回撥是我們在設計系統的時候經常會使用到的, A服務呼叫B服務, 但是如果B服務提供的是一個較長時間的、非同步的介面, 那麼我們就會想到使用一個回撥, 讓B服務在非同步處理結束之後, 來呼叫A的一個回撥介面. 但是細品一下, 這一來一回的設計, 需要思考的點遠不是一個回撥介面這麼簡單.

20210220182454

回撥是天生的併發

首先, 回撥是一個天生的併發操作. 如果你的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繫結在一起了. 耦合性增加的缺點一大堆, 這裡就不贅述了.

總結

當然, 如果你看了上面的那麼多回撥的弊端, 還是在某個場景還是決定使用回撥, 那麼我相信, 這個場景一定有不得不用回撥的原因. 瑾告訴, 慎用之. 因為, 我就是這麼踩坑過來的...

相關文章