版權聲明:
本文爲博主原創文章,未經博主容許不得轉載。關注公衆號 技術匯(ID: jishuhui_2015) 可聯繫到做者。
一、分佈式環境下,保證每一個序列號(sequence)是全系統惟一的;redis
二、序列號可排序,知足單調遞增的規律;算法
三、特定場景下,能生成無規則(或者看不出規則)的序列號;服務器
四、生成的序列號儘可能短;數據結構
五、序列號可進行二次混淆,提供可擴展的interface,業務方自定義實現。app
爲了知足上述需求,發號器必須可以支持不一樣的生成策略,最好是還能支持自定義的生成策略,這就對系統自己的可擴展性提出了要求。分佈式
目前,發號器設計了兩種比較通用的基礎策略,各有優缺點,但結合起來,能達到優點互補的目的。函數
一、segment
第一種策略稱之爲『分段』(segment),下文將對其進行詳細闡述:性能
整個segment發號器有兩個重要的角色:Redis和MongoDB,理論上MongoDB是能夠被MySQL或其餘DB產品所替代的。ui
segment發號器所產生的號碼知足單調遞增的規律,短期內產生的號碼不會有過長的問題(可根據實際須要,設置初始值,好比 100)。lua
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消耗比正常發號要稍多,會遇到「尖刺」,以下示意圖:
爲了消除「尖刺」,可使用雙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不知當講不當講……
二、snowflake
第二種策略是Twitter出品,算法思想比較巧妙,實現的難度也不大。
以上示意圖描述了一個序列號的二進制組成結構。
第一位不用,恆爲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對系統時間是很依賴的,因此對於時鐘的波動是很敏感的,尤爲是時鐘回撥,頗有可能就會出現重複發號的狀況。時鐘回撥問題解決策略一般是直接拒絕發號,直到時鐘正常,必要時進行告警。
整個發號過程能夠分紅三個層次:
一、策略層(strategy layer):這個層面決定的是發號方法/算法,涵蓋了上述所講的segment和snowflake兩種方式,固然,用戶也能夠本身擴展實現其餘發號策略。
最頂上定義Sequence實際上就是發號的結果。bizType是對發號業務場景的定義,好比訂單號,用戶ID,邀請好友的分享碼。
發號策略的init接口是發號前的初始化工做,而generate接口就是調用發號器的主入口了。
固然,考慮到各類異常狀況,加入了拒絕發號的處理器(SequenceRejectedHandler),默認實現只是記錄日誌,用戶可根據需求去實現該處理器,而後用set方法設置發號策略的拒絕處理器。
二、插件層(plugin layer):此處的插件能夠理解是一種攔截器,貫穿SequenceStrategy的發號全週期。引入插件後,無疑是豐富了整個發號的操做過程,用戶能夠從中干預到發號的整個流程,以便達到其餘的目的,好比:記錄發號歷史,統計發號速率,發號二次混淆等。
能夠看出,插件被設計成『註冊式』的,發號策略只有註冊了相關插件以後,插件才能生效,
固然,一個插件能被多個發號策略所註冊,一個發號策略也能同時註冊多個插件,因此二者是多對多的關係,PluginManager的出現就是解決插件的註冊管理問題。
從SequencePlugin的定義中能夠發現,插件是有優先級(Order)的,經過getOrder()能夠得到,在這套發號系統裏,Order值越小,表示該插件越優先執行。此外,插件有三個重要的操做:
before,表示發號以前的處理。若返回了false,那麼該插件後面的操做都失效了,不然繼續執行發號流程。
after,表示發號以後的處理。
doException,表示插件發生異常的處理方法。
三、持久層(persistence layer):這個層面指代的是上述所提的MongoDB部分,若是不須要持久化的支持,能夠不實現此接口,那麼整個發號器就變成純內存管理的了。
PersistRepository定義了基本的CRUD方法,其中persistId能夠理解成上述提到的BizType。
一切的持久化對象都是從PersistModel開始的,上圖中的Segment、PersistDocument都是爲了實現分段發號器而定義的。
這篇文章詳細闡述了分佈式發號器系統的設計,旨在能作出一個可擴展,易維護的發號系統。業界比較知名的發號算法彷佛也很少,整個發號系統不必定就按照筆者所作的設計,仍是要立足於具體的業務需求。