分散式發號器架構設計

裝逼未遂的程式設計師發表於2018-05-25

版權宣告: 本文為博主原創文章,未經博主允許不得轉載。關注公眾號 技術匯(ID: jishuhui_2015) 可聯絡到作者。

一、需求介紹

1、分散式環境下,保證每個序列號(sequence)是全系統唯一的;

2、序列號可排序,滿足單調遞增的規律;

3、特定場景下,能生成無規則(或者看不出規則)的序列號;

4、生成的序列號儘量短

5、序列號可進行二次混淆,提供可擴充套件的interface,業務方自定義實現。

二、方案設計

為了滿足上述需求,發號器必須能夠支援不同的生成策略,最好是還能支援自定義的生成策略,這就對系統本身的可擴充套件性提出了要求。

目前,發號器設計了兩種比較通用的基礎策略,各有優缺點,但結合起來,能達到優勢互補的目的。

1、segment 第一種策略稱之為『分段』(segment),下文將對其進行詳細闡述:

整個segment發號器有兩個重要的角色:Redis和MongoDB,理論上MongoDB是可以被MySQL或其他DB產品所替代的。

segment發號器所產生的號碼滿足單調遞增的規律,短時間內產生的號碼不會有過長的問題(可根據實際需要,設定初始值,比如 100)。

Redis資料結構(Hash型別)

key: <string>,表示業務主鍵/名稱
value: {
  cur: <long>,表示當前序列號
  max: <long>,表示這個號段最大的可用序列號
}
複製程式碼

取號的大部分操作都集中在Redis,為了保證序列號遞增的原子性,取號的功能可以用Lua指令碼實現。

--[[
  由於RedisTemplate設定的HashValueSerializer是GenericToStringSerializer,故此處的HASH結構中的
  VALUE都是string型別,需要使用tonumber函式轉換成數字型別。
]]
local max = redis.pcall("HGET", KEYS[1], "max")  --獲取一段序列號的max
local cur = redis.pcall("HGET", KEYS[1], "cur")  --獲取當前發號位置
if tonumber(cur) >= tonumber(max) then  --沒有超過這段序列號的上限
    local step = ARGV[1]
    if (step == nil) then  --沒有傳入step引數
        step = redis.pcall("HGET", KEYS[1], "step")  --獲取這段序列號的step配置引數值
    end
    redis.pcall("HSET", KEYS[1], "max", tonumber(max) + tonumber(step))  --調整max引數值,擴充套件上限
end
return redis.pcall("HINCRBY", KEYS[1], "cur", 1)  --觸發HINCRBY操作,對cur自增,並返回自增後的值
複製程式碼

注意:在redis執行lua script期間,redis處於BUSY狀態,這個時候對redis的任何形式的訪問都會丟擲JedisBusyException異常,所以lua script中的處理邏輯不得太複雜。

值得一提的是,即使切換到一個新的database,或者開啟新執行緒執行lua script,都將會遇到同樣的問題,畢竟redis是單程式單執行緒的。

如果不幸遇到上述問題,需要使用redis-cli客戶端連上redis-server,向其傳送SCRIPT KILL命令,即可終止指令碼執行,

如果想避免上述問題,也可以直接使用Springboot提供的RedisTemplate,能支援絕不大部分redis command。

MongoDB資料結構

{
 bizTag: <string>,  表示業務主鍵/名稱
 max: <long>,  表示這個號段最大的可用序列號
 step: <int>, 每次分段的步長
 timestamp: <long>,  更新資料的時間戳(毫秒)
}
複製程式碼

MongoDB部分主要是對號段的分配進行管理,一個號段不能多發,也可以根據發號情況,適當放縮號段步長(step)。

到此為止,segment發號器的雛形已經形成了。

一個比較突出的問題是在兩個號段銜接的時間點,當一個segment派發完了後,會對MongoDB和Redis中的資料中的max擴容,I/O消耗比正常發號要稍多,會遇到“尖刺”,如下示意圖:

TCP尖刺
為了消除“尖刺”,可以使用雙Buffer模型。示意圖如下:
雙Buffer模型

這個模型的核心思想就是“預分配”。可以設定一個閾值(threshold),比如20%,當Buffer-1裡面的號段已經消耗了20%,那麼立刻根據Buffer-1的max和step,開闢Buffer-2。

當Buffer-1完全消耗了,可以無縫銜接Buffer-2,。如果Buffer-2的消耗也達到閾值了,又可以開闢Buffer-1,如此往復。

接下來,我們來討論一下異常/故障情況

① Redis當機。因為大部分發號工作都是依靠Redis完成的,所以發生了這種情況是非常糟糕的。如果想有效降低此風險,最行之有效的辦法是對Redis進行叢集化,通常是1主2從,這樣可以挺住非常高的QPS了。

當然也有退而求其次的辦法,就是利用上述提到的雙Buffer模型。不依賴Redis取號,直接通過程式控制,利用機器記憶體。所以當需要重啟發號服務之前,要確保依賴的元件是執行良好的,不然號段就丟失了。

② 要不要持久化的問題。這個問題主要是針對Redis,如果沒有記錄下當前的取號進度,那麼隨著Redis的當機,取號現場就變得難以恢復了;如果每次都記錄取號進度,那麼這種I/O高密度型的作業會對服務效能

造成一定影響,並且隨著取號的時間延長,恢復取號現場就變得越來越慢了,甚至到最後是無法忍受的。除了對Redis做高可用之外,引入MongoDB也是出於對Redis持久化功能輔助的考慮。

個人建議:如果Redis已經叢集化了,而且還開啟了雙Buffer的策略,以及MongoDB的加持,可以不用再開啟Redis的持久化了。

如果考慮到極端情況下,Redis還是當機了,我們可以使用MongoDB裡面存下來的max,就max+1賦值給cur(避免上個號段取完,正好當機了)。

③ MongoDB當機。這個問題不是很嚴重,只要將step適當拉長一些(至少取號能支撐20分鐘),利用Redis還在正常取號的時間來搶救MongoDB。不過,考慮到實際可能沒這麼快恢復mongo服務,可以在程式中採取

一些容錯措施,比如號段用完了,mongo服務無法到達,直接關閉取號通道,直到MongoDB能正常使用;或者程式給一個預設的step,讓MongoDB中的max延長到max+step*n(可能取了N個號段MongoDB才恢復過來),

這樣取號服務也可以繼續。依靠程式本身繼續服務,那麼需要有相關的log,這樣才有利於恢復MongoDB中的資料。

④ 取號服務當機。這個沒什麼好說的,只能儘快恢復服務執行了。

⑤ Redis,MongoDB都當機了。這種情況已經很極端了,只能利用雙Buffer策略,以及程式預設的設定進行工作了,同樣要有相關的log,以便恢復Redis和MongoDB。

⑥ 都當機了。我有一句mmp不知當講不當講……

2、snowflake

第二種策略是Twitter出品,演算法思想比較巧妙,實現的難度也不大。

snowflake
以上示意圖描述了一個序列號的二進位制組成結構。

第一位不用,恆為0,即表示正整數;

接下來的41位表示時間戳,精確到毫秒。為了節約空間,可以將此時間戳定義為距離某個時間點所經歷的毫秒數(Java預設是1970-01-01 00:00:00);

再後來的10位用來標識工作機器,如果出現了跨IDC的情況,可以將這10位一分為二,一部分用於標識IDC,一部分用於標識伺服器;

最後12位是序列號,自增長。

snowflake的核心思想是64bit的合理分配,但不必要嚴格按照上圖所示的分法。

如果在機器較少的情況下,可以適當縮短機器id的長度,留出來給序列號。

當然,snowflake的演算法將會面臨兩個挑戰:

① 機器id的指定。這個問題在分散式的環境下會比較突出,通常的解決方案是利用Redis或者Zookeeper進行機器註冊,確保註冊上去的機器id是唯一的。為了解決

強依賴Redis或者Zookeeper的問題,可以將機器id寫入本地檔案系統。

② 機器id的生成規則。這個問題會有一些糾結,因為機器id的生成大致要滿足三個條件:a. int型別(10bit)純數字,b. 相對穩定,c. 與其他機器要有所區別。至於優雅美觀,都是其次了。對於機器id的儲存,可以使用HASH結構,KEY的規則是“application-name.port.ip”,其中ip是通過演算法轉換成了一段長整型的純數字,VALUE則是機器id,

服務id,機房id,其中,可以通過服務id和機房id反推出機器id。

假設服務id(workerId)佔8bit,機房id(rackId)佔2bit,從1開始,workerId=00000001,rackId=01,machineId=00000000101

如果用Redis儲存,其表現形式如下:

這裡寫圖片描述
如果儲存在檔案中(建議properties檔案),則檔名是sequence-client:8112:3232235742.properties,檔案內容如下:
這裡寫圖片描述
如果發號服務上線,直接按照“application-name.port.ip”的規則取其內容。

③ 時鐘回撥。因為snowflake對系統時間是很依賴的,所以對於時鐘的波動是很敏感的,尤其是時鐘回撥,很有可能就會出現重複發號的情況。時鐘回撥問題解決策略通常是直接拒絕發號,直到時鐘正常,必要時進行告警。

三、程式設計

整個發號過程可以分成三個層次:

1、策略層(strategy layer):這個層面決定的是發號方法/演算法,涵蓋了上述所講的segment和snowflake兩種方式,當然,使用者也可以自己擴充套件實現其他發號策略。

策略層
最頂上定義Sequence實際上就是發號的結果。bizType是對發號業務場景的定義,比如訂單號,使用者ID,邀請好友的分享碼。

發號策略的init介面是發號前的初始化工作,而generate介面就是呼叫發號器的主入口了。

當然,考慮到各種異常情況,加入了拒絕發號的處理器(SequenceRejectedHandler),預設實現只是記錄日誌,使用者可根據需求去實現該處理器,然後用set方法設定發號策略的拒絕處理器。

2、外掛層(plugin layer):此處的外掛可以理解是一種攔截器,貫穿SequenceStrategy的發號全週期。引入外掛後,無疑是豐富了整個發號的操作過程,使用者可以從中干預到發號的整個流程,以便達到其他的目的,比如:記錄發號歷史,統計發號速率,發號二次混淆等。

外掛層
可以看出,外掛被設計成『註冊式』的,發號策略只有註冊了相關外掛之後,外掛才能生效,

當然,一個外掛能被多個發號策略所註冊,一個發號策略也能同時註冊多個外掛,所以兩者是多對多的關係,PluginManager的出現就是解決外掛的註冊管理問題。

從SequencePlugin的定義中可以發現,外掛是有優先順序(Order)的,通過getOrder()可以獲得,在這套發號系統裡,Order值越小,表示該外掛越優先執行。此外,外掛有三個重要的操作:

before,表示發號之前的處理。若返回了false,那麼該外掛後面的操作都失效了,否則繼續執行發號流程。

after,表示發號之後的處理。

doException,表示外掛發生異常的處理方法。

3、持久層(persistence layer):這個層面指代的是上述所提的MongoDB部分,如果不需要持久化的支援,可以不實現此介面,那麼整個發號器就變成純記憶體管理的了。

持久層
PersistRepository定義了基本的CRUD方法,其中persistId可以理解成上述提到的BizType。

一切的持久化物件都是從PersistModel開始的,上圖中的Segment、PersistDocument都是為了實現分段發號器而定義的。

四、總結

這篇文章詳細闡述了分散式發號器系統的設計,旨在能做出一個可擴充套件,易維護的發號系統。業界比較知名的發號演算法似乎也不多,整個發號系統不一定就按照筆者所做的設計,還是要立足於具體的業務需求。

關注我們

相關文章