Leaf——來自美團點評的分佈式ID生成系統

背景介紹node

在複雜分佈式系統中,每每須要對大量的數據和消息進行惟一標識。如在美團點評的金融、支付、餐飲、酒店、貓眼電影等產品的系統中,數據日漸增加,對數據分庫分表後須要有一個惟一ID來標識一條數據或消息,數據庫的自增ID顯然不能知足需求;特別一點的如訂單、騎手、優惠券也都須要有惟一ID作標識。此時一個可以生成全局惟一ID的系統是很是必要的。歸納下來,那業務系統對ID號的要求有哪些呢?算法

  1. 全局惟一性:不能出現重複的ID號,既然是惟一標識,這是最基本的要求。數據庫

  2. 趨勢遞增:在MySQL InnoDB引擎中使用的是彙集索引,因爲多數RDBMS使用B-tree的數據結構來存儲索引數據,在主鍵的選擇上面咱們應該儘可能使用有序的主鍵保證寫入性能。緩存

  3. 單調遞增:保證下一個ID必定大於上一個ID,例如事務版本號、IM增量消息、排序等特殊需求。安全

  4. 信息安全:若是ID是連續的,惡意用戶的扒取工做就很是容易作了,直接按照順序下載指定URL便可;若是是訂單號就更危險了,競對能夠直接知道咱們一天的單量。因此在一些應用場景下,會須要ID無規則、不規則。網絡

上述123對應三類不一樣的場景,3和4需求仍是互斥的,沒法使用同一個方案知足。數據結構

同時除了對ID號碼自身的要求,業務還對ID號生成系統的可用性要求極高,想象一下,若是ID生成系統癱瘓,整個美團點評支付、優惠券發券、騎手派單等關鍵動做都沒法執行,這就會帶來一場災難。架構

由此總結下一個ID生成系統應該作到以下幾點:負載均衡

  1. 平均延遲和TP999延遲都要儘量低;框架

  2. 可用性5個9;

  3. 高QPS。

常見方法介紹

UUID

UUID(Universally Unique Identifier)的標準型式包含32個16進制數字,以連字號分爲五段,形式爲8-4-4-4-12的36個字符,示例:550e8400-e29b-41d4-a716-446655440000,到目前爲止業界一共有5種方式生成UUID,詳情見IETF發佈的UUID規範(http://www.ietf.org/rfc/rfc4122.txt)。

優勢:

性能很是高:本地生成,沒有網絡消耗。

缺點:

  • 不易於存儲:UUID太長,16字節128位,一般以36長度的字符串表示,不少場景不適用。

  • 信息不安全:基於MAC地址生成UUID的算法可能會形成MAC地址泄露,這個漏洞曾被用於尋找梅麗莎病毒的製做者位置。

  • ID做爲主鍵時在特定的環境會存在一些問題,好比作DB主鍵的場景下,UUID就很是不適用:

    ① MySQL官方有明確的建議主鍵要儘可能越短越好[4],36個字符長度的UUID不符合要求。

    ② 對MySQL索引不利:若是做爲數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引發數據位置頻繁變更,嚴重影響性能。

類snowflake方案

這種方案大體來講是一種以劃分命名空間(UUID也算,因爲比較常見,因此單獨分析)來生成ID的一種算法,這種方案把64-bit分別劃分紅多段,分開來標示機器、時間等,好比在snowflake中的64-bit分別表示以下圖(圖片來自網絡)所示:

41-bit的時間能夠表示(1L<<41)/(1000L*3600*24*365)=69年的時間,10-bit機器能夠分別表示1024臺機器。若是咱們對IDC劃分有需求,還能夠將10-bit分5-bit給IDC,分5-bit給工做機器。這樣就能夠表示32個IDC,每一個IDC下能夠有32臺機器,能夠根據自身需求定義。12個自增序列號能夠表示2^12個ID,理論上snowflake方案的QPS約爲409.6w/s,這種分配方式能夠保證在任何一個IDC的任何一臺機器在任意毫秒內生成的ID都是不一樣的。

這種方式的優缺點是:

優勢:

  • 毫秒數在高位,自增序列在低位,整個ID都是趨勢遞增的。

  • 不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是很是高的。

  • 能夠根據自身業務特性分配bit位,很是靈活。

缺點:

  • 強依賴機器時鐘,若是機器上時鐘回撥,會致使發號重複或者服務會處於不可用狀態。

應用舉例Mongdb objectID

MongoDB官方文檔 ObjectID能夠算做是和snowflake相似方法,經過「時間+機器碼+pid+inc」共12個字節,經過4+3+2+3的方式最終標識成一個24長度的十六進制字符。

數據庫生成

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增,每次業務使用下列SQL讀寫MySQL獲得ID號。

begin;
REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();
commit;

這種方案的優缺點以下:

優勢:

  • 很是簡單,利用現有數據庫系統的功能實現,成本小,有DBA專業維護。

  • ID號單調自增,能夠實現一些對ID有特殊要求的業務。

缺點:

  • 強依賴DB,當DB異常時整個系統不可用,屬於致命問題。配置主從複製能夠儘量的增長可用性,可是數據一致性在特殊狀況下難以保證。主從切換時的不一致可能會致使重複發號。

  • ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

對於MySQL性能問題,可用以下方案解決:在分佈式系統中咱們能夠多部署幾臺機器,每臺機器設置不一樣的初始值,且步長和機器數相等。好比有兩臺機器。設置步長step爲2,TicketServer1的初始值爲1(1,3,5,7,9,11...)、TicketServer2的初始值爲2(2,4,6,8,10...)。這是Flickr團隊在2010年撰文介紹的一種主鍵生成策略(Ticket Servers: Distributed Unique Primary Keys on the Cheap )。以下所示,爲了實現上述方案分別設置兩臺機器對應的參數,TicketServer1從1開始發號,TicketServer2從2開始發號,兩臺機器每次發號以後都遞增2。

TicketServer1:
auto-increment-increment = 2
auto-increment-offset = 1

TicketServer2:
auto-increment-increment = 2
auto-increment-offset = 2

假設咱們要部署N臺機器,步長需設置爲N,每臺的初始值依次爲0,1,2...N-1那麼整個架構就變成了以下圖所示:

這種架構貌似可以知足性能的需求,但有如下幾個缺點:

  • 系統水平擴展比較困難,好比定義好了步長和機器臺數以後,若是要添加機器該怎麼作?假設如今只有一臺機器發號是1,2,3,4,5(步長是1),這個時候須要擴容機器一臺。能夠這樣作:把第二臺機器的初始值設置得比第一臺超過不少,好比14(假設在擴容時間以內第一臺不可能發到14),同時設置步長爲2,那麼這臺機器下發的號碼都是14之後的偶數。而後摘掉第一臺,把ID值保留爲奇數,好比7,而後修改第一臺的步長爲2。讓它符合咱們定義的號段標準,對於這個例子來講就是讓第一臺之後只能產生奇數。擴容方案看起來複雜嗎?貌似還好,如今想象一下若是咱們線上有100臺機器,這個時候要擴容該怎麼作?簡直是噩夢。因此係統水平擴展方案複雜難以實現。

  • ID沒有了單調遞增的特性,只能趨勢遞增,這個缺點對於通常業務需求不是很重要,能夠容忍。

  • 數據庫壓力仍是很大,每次獲取ID都得讀寫一次數據庫,只能靠堆機器來提升性能。

Leaf 方案實現

Leaf這個名字是來自德國哲學家、數學家萊布尼茨的一句話:

There are no two identical leaves in the world

"世界上沒有兩片相同的樹葉"

綜合對比上述幾種方案,每種方案都不徹底符合咱們的要求。因此Leaf分別在上述第二種和第三種方案上作了相應的優化,實現了Leaf-segment和Leaf-snowflake方案。

Leaf-segment數據庫方案

第一種Leaf-segment方案,在使用數據庫的方案上,作了以下改變:

  • 原方案每次獲取ID都得讀寫一次數據庫,形成數據庫壓力大。改成利用proxy server批量獲取,每次獲取一個segment(step決定大小)號段的值。用完以後再去數據庫獲取新的號段,能夠大大的減輕數據庫的壓力。

  • 各個業務不一樣的發號需求用biz_tag字段來區分,每一個biz-tag的ID獲取相互隔離,互不影響。若是之後有性能需求須要對數據庫擴容,不須要上述描述的複雜的擴容操做,只須要對biz_tag分庫分表就行。

數據庫表設計以下:

重要字段說明:biz_tag用來區分業務,max_id表示該biz_tag目前所被分配的ID號段的最大值,step表示每次分配的號段長度。原來獲取ID每次都須要寫數據庫,如今只須要把step設置得足夠大,好比1000。那麼只有當1000個號被消耗完了以後纔會去從新讀寫一次數據庫。讀寫數據庫的頻率從1減少到了1/step,大體架構以下圖所示:

test_tag在第一臺Leaf機器上是1~1000的號段,當這個號段用完時,會去加載另外一個長度爲step=1000的號段,假設另外兩臺號段都沒有更新,這個時候第一臺機器新加載的號段就應該是3001~4000。同時數據庫對應的biz_tag這條數據的max_id會從3000被更新成4000,更新號段的SQL語句以下:

Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit

這種模式有如下優缺點:

優勢:

  • Leaf服務能夠很方便的線性擴展,性能徹底可以支撐大多數業務場景。

  • ID號碼是趨勢遞增的8byte的64位數字,知足上述數據庫存儲的主鍵要求。

  • 容災性高:Leaf服務內部有號段緩存,即便DB宕機,短期內Leaf仍能正常對外提供服務。

  • 能夠自定義max_id的大小,很是方便業務從原有的ID方式上遷移過來。

缺點:

  • ID號碼不夠隨機,可以泄露發號數量的信息,不太安全。

  • TP999數據波動大,當號段使用完以後仍是會hang在更新數據庫的I/O上,tg999數據會出現偶爾的尖刺。

  • DB宕機會形成整個系統不可用。

雙buffer優化

對於第二個缺點,Leaf-segment作了一些優化,簡單的說就是:

Leaf 取號段的時機是在號段消耗完的時候進行的,也就意味着號段臨界點的ID下發時間取決於下一次從DB取回號段的時間,而且在這期間進來的請求也會由於DB號段沒有取回來,致使線程阻塞。若是請求DB的網絡和DB的性能穩定,這種狀況對系統的影響是不大的,可是假如取DB的時候網絡發生抖動,或者DB發生慢查詢就會致使整個系統的響應時間變慢。

爲此,咱們但願DB取號段的過程可以作到無阻塞,不須要在DB取號段的時候阻塞請求線程,即當號段消費到某個點時就異步的把下一個號段加載到內存中。而不須要等到號段用盡的時候纔去更新號段。這樣作就能夠很大程度上的下降系統的TP999指標。詳細實現以下圖所示:

採用雙buffer的方式,Leaf服務內部有兩個號段緩存區segment。當前號段已下發10%時,若是下一個號段未更新,則另啓一個更新線程去更新下一個號段。當前號段所有下發完後,若是下個號段準備好了則切換到下個號段爲當前segment接着下發,循環往復。

  • 每一個biz-tag都有消費速度監控,一般推薦segment長度設置爲服務高峯期發號QPS的600倍(10分鐘),這樣即便DB宕機,Leaf仍能持續發號10-20分鐘不受影響。

  • 每次請求來臨時都會判斷下個號段的狀態,從而更新此號段,因此偶爾的網絡抖動不會影響下個號段的更新。

Leaf高可用容災

對於第三點「DB可用性」問題,咱們目前採用一主兩從的方式,同時分機房部署,Master和Slave之間採用半同步方式[5]同步數據。同時使用公司Atlas數據庫中間件(已開源,更名爲DBProxy)作主從切換。固然這種方案在一些狀況會退化成異步模式,甚至在很是極端狀況下仍然會形成數據不一致的狀況,可是出現的機率很是小。若是你的系統要保證100%的數據強一致,能夠選擇使用「類Paxos算法」實現的強一致MySQL方案,如MySQL 5.7前段時間剛剛GA的MySQL Group Replication。可是運維成本和精力都會相應的增長,根據實際狀況選型便可。

同時Leaf服務分IDC部署,內部的服務化框架是「MTthrift RPC」。服務調用的時候,根據負載均衡算法會優先調用同機房的Leaf服務。在該IDC內Leaf服務不可用的時候纔會選擇其餘機房的Leaf服務。同時服務治理平臺OCTO還提供了針對服務的過載保護、一鍵截流、動態流量分配等對服務的保護措施。

Leaf-snowflake方案

Leaf-segment方案能夠生成趨勢遞增的ID,同時ID號是可計算的,不適用於訂單ID生成場景,好比競對在兩天中午12點分別下單,經過訂單id號相減就能大體計算出公司一天的訂單量,這個是不能忍受的。面對這一問題,咱們提供了 Leaf-snowflake方案。

Leaf-snowflake方案徹底沿用snowflake方案的bit位設計,便是「1+41+10+12」的方式組裝ID號。對於workerID的分配,當服務集羣數量較小的狀況下,徹底能夠手動配置。Leaf服務規模較大,動手配置成本過高。因此使用Zookeeper持久順序節點的特性自動對snowflake節點配置wokerID。Leaf-snowflake是按照下面幾個步驟啓動的:

  1. 啓動Leaf-snowflake服務,鏈接Zookeeper,在leaf_forever父節點下檢查本身是否已經註冊過(是否有該順序子節點)。

  2. 若是有註冊過直接取回本身的workerID(zk順序節點生成的int類型ID號),啓動服務。

  3. 若是沒有註冊過,就在該父節點下面建立一個持久順序節點,建立成功後取回順序號當作本身的workerID號,啓動服務。

弱依賴ZooKeeper

除了每次會去ZK拿數據之外,也會在本機文件系統上緩存一個workerID文件。當ZooKeeper出現問題,剛好機器出現問題須要重啓時,能保證服務可以正常啓動,這樣作到了對三方組件的弱依賴,必定程度上提升了SLA。

解決時鐘問題

由於這種方案依賴時間,若是機器的時鐘發生了回撥,那麼就會有可能生成重複的ID號,須要解決時鐘回退的問題。

參見上圖整個啓動流程圖,服務啓動時首先檢查本身是否寫過ZooKeeper leaf_forever節點:

  1. 若寫過,則用自身系統時間與leaf_forever/${self}節點記錄時間作比較,若小於leaf_forever/${self}時間則認爲機器時間發生了大步長回撥,服務啓動失敗並報警。

  2. 若未寫過,證實是新服務節點,直接建立持久節點leaf_forever/${self}並寫入自身系統時間,接下來綜合對比其他Leaf節點的系統時間來判斷自身系統時間是否準確,具體作法是取leaf_temporary下的全部臨時節點(全部運行中的Leaf-snowflake節點)的服務IP:Port,而後經過RPC請求獲得全部節點的系統時間,計算sum(time)/nodeSize。

  3. 若abs( 系統時間-sum(time)/nodeSize ) < 閾值,認爲當前系統時間準確,正常啓動服務,同時寫臨時節點leaf_temporary/${self} 維持租約。

  4. 不然認爲本機系統時間發生大步長偏移,啓動失敗並報警。

  5. 每隔一段時間(3s)上報自身系統時間寫入leaf_forever/${self}。

因爲強依賴時鐘,對時間的要求比較敏感,在機器工做時NTP同步也會形成秒級別的回退,建議能夠直接關閉NTP同步。要麼在時鐘回撥的時候直接不提供服務直接返回ERROR_CODE,等時鐘追上便可。或者作一層重試,而後上報報警系統,更或者是發現有時鐘回撥以後自動摘除自己節點並報警,以下:

從上線狀況來看,在2017年閏秒出現那一次出現過部分機器回撥,因爲Leaf-snowflake的策略保證,成功避免了對業務形成的影響。

Leaf現狀

Leaf在美團點評公司內部服務包含金融、支付交易、餐飲、外賣、酒店旅遊、貓眼電影等衆多業務線。目前Leaf的性能在4C8G的機器上QPS能壓測到近5w/s,TP999 1ms,已經可以知足大部分的業務的需求。天天提供億數量級的調用量,做爲公司內部公共的基礎技術設施,必須保證高SLA和高性能的服務,咱們目前還僅僅達到了及格線,還有不少提升的空間。

相關文章
相關標籤/搜索