SQL數(shù)據(jù)庫使用號(hào)段模式實(shí)現(xiàn)分布式ID
當(dāng)前位置:點(diǎn)晴教程→知識(shí)管理交流
→『 技術(shù)文檔交流 』
在單體系統(tǒng)時(shí)代,程序常被部署在單個(gè)物理機(jī)中,數(shù)據(jù)被存儲(chǔ)在單個(gè)數(shù)據(jù)庫中,我們可以采取數(shù)據(jù)庫的自增 ID 來實(shí)現(xiàn) ID 的全局唯一。 現(xiàn)在,系統(tǒng)開始從單體系統(tǒng)演變?yōu)榉植际较到y(tǒng),當(dāng)業(yè)務(wù)量和數(shù)據(jù)量增長之后,我們會(huì)選擇分庫分表。同時(shí),隨著微服務(wù)的推廣與普及,我們的服務(wù)變得越來越多。 當(dāng)然,在復(fù)雜的分布式系統(tǒng)中,我們同樣需要對(duì)大量的數(shù)據(jù)進(jìn)行唯一標(biāo)識(shí),而數(shù)據(jù)庫的自增 ID 顯然已經(jīng)不能滿足需求了。此時(shí),我們就需要通過其他手段實(shí)現(xiàn)全局唯一 ID 了。 事實(shí)上,實(shí)現(xiàn)分布式全局唯一的 ID 有許多方案,包括基于 Redis 實(shí)現(xiàn)分布式 ID 方案、UUID、數(shù)據(jù)庫號(hào)段模式、雪花算法等。但今天我們學(xué)習(xí)如何通過號(hào)段模式實(shí)現(xiàn)分布式 ID?為什么選擇了“號(hào)段模式”。要回答這個(gè)問題,你需要先知道業(yè)務(wù)系統(tǒng)對(duì)分布式 ID 到底有要求? 在我看來,業(yè)務(wù)系統(tǒng)對(duì)分布式 ID 的要求,主要是 4 個(gè)包括:全局唯一性、趨勢(shì)遞增、單調(diào)遞增和信息安全。接下來,我就和你一一分析下。 第一, 全局唯一性。確保 ID 的全局唯一性,是最基本的要求。 第二, 趨勢(shì)遞增。 趨勢(shì)遞增指的是,我們的分布式 ID 是呈增長趨勢(shì)的,但是序列之間是不連續(xù)的。事實(shí)上,MySQL 的 InnoDB 引擎使用的是聚集索引,底層的數(shù)據(jù)結(jié)構(gòu)是 B+ 樹,使用有序的主鍵可以保證寫入性能。 這也是為什么我們不提倡使用 UUID(Universally Unique Identifier,通用唯一識(shí)別碼)作為 ID 的原因:UUID 的無序性,會(huì)導(dǎo)致新增數(shù)據(jù)的時(shí)候不是順序的,從而出現(xiàn)頻繁的頁分裂,嚴(yán)重影響性能。 第三, 單調(diào)遞增。我們要保證 ID 的增長不僅有序,而且還要單調(diào)遞增,即下一個(gè)新增的 ID 一定大于上一個(gè)存在的 ID,從而保證能支持事務(wù)版本號(hào)、排序等場(chǎng)景。 第四, 信息安全 。 在一些應(yīng)用場(chǎng)景下,我們需要 ID 有不規(guī)則性,確保它難以被猜測(cè)。例如,訂單號(hào),我們就需要確保它不是順序遞增的,不然,就很容易被競(jìng)爭(zhēng)對(duì)手猜測(cè)出我們一天的訂單量。 號(hào)段模式滿足全局唯一性、趨勢(shì)遞增、單調(diào)遞增三個(gè)要求,所以我選擇了號(hào)段模式。而信息安全的要求,例如訂單號(hào)場(chǎng)景,我們常常會(huì)采用雪花算法來實(shí)現(xiàn)。那么,如何通過號(hào)段模式實(shí)現(xiàn)分布式 ID? 使用號(hào)段模式如何實(shí)現(xiàn)分布式 ID?想一想,我們?cè)跀?shù)據(jù)庫中創(chuàng)建一張全局 ID 序列表。例如,這張表叫做 common_sequence,它有 id、name、value、gmt_modified 四個(gè)字段。需要注意的是,每個(gè)業(yè)務(wù)用 name 字段來區(qū)分,每個(gè) name 的 ID 獲取是相互隔離、互不影響的。
當(dāng)我們需要為某個(gè)表生成主鍵 ID 時(shí),就從序列表中分配全局主鍵 ID。 例如,我們新增一個(gè)客服工單,需要自增一個(gè) ID。在這里,我們?cè)谌?ID 序列表中,存入 name 等于 task 的記錄,它的值是 1,也就是說,這個(gè)業(yè)務(wù)表的自增 ID 的當(dāng)前值是 1。 但是,如果我們每次獲取 ID 都需要讀寫一次數(shù)據(jù)庫,就會(huì)對(duì)數(shù)據(jù)庫造成比較大的壓力。那么,有什么比較好的優(yōu)化方案呢? 事實(shí)上,我們可以做一個(gè)小優(yōu)化:每次向全局 ID 序列表獲取 一批 ID,然后存入 JVM 本地緩存中慢慢使用;當(dāng)這批 ID 被消耗完了,再向全局 ID 序列表重新發(fā)起一次讀寫請(qǐng)求。這里,從全局 ID 序列表中申請(qǐng)的一批可用的 ID,我們稱之為 ID 號(hào)段。 ID 分段之后,我們?cè)賮砜纯凑w流程。 在新增客服工單時(shí),我們會(huì)向全局 ID 序列表申請(qǐng)的可以使用的號(hào)段。假設(shè),我們需要預(yù)申請(qǐng) 5000 個(gè) ID。首先,客服工單服務(wù)會(huì)先查詢?nèi)?ID 序列表,獲取當(dāng)前 name 等于 task 的記錄的最新值是多少。這里,最新值是 1。 然后呢,全局 ID 序列表更新相對(duì)應(yīng)的記錄值。它把最新值 +5000,也就是 5001,存儲(chǔ)起來。 緊接著,客服工單服務(wù)將可以使用的號(hào)段存儲(chǔ)在 JVM 本地緩存中,即為[1, 5000]。客服工單服務(wù)在區(qū)間[1, 5000]中依次獲取 ID。 如果客服工單服務(wù)把區(qū)間的值用完了,再去請(qǐng)求全局 ID 序列表,獲取到可以用的[5001, 10000]區(qū)間的 ID。 通過這個(gè)方案,我們用完號(hào)段之后再去數(shù)據(jù)庫獲取新的號(hào)段,可以大大減輕對(duì)數(shù)據(jù)庫的依賴及給數(shù)據(jù)庫造成的壓力。 總結(jié)一下, 號(hào)段模式每次向全局 ID 序列表獲取一批可以使用的 ID 號(hào)段,然后存入 JVM 本地緩存中。 其中,我們需要預(yù)申請(qǐng) 5000 個(gè) ID 中的“5000”,我們稱為 步長。當(dāng)這批號(hào)段被消耗完了,我們?cè)傧蛉?ID 序列表重新發(fā)起一次讀寫請(qǐng)求。當(dāng) 5000 個(gè) ID 被消耗完了之后,才會(huì)重新讀寫一次數(shù)據(jù)庫。因此,讀寫數(shù)據(jù)庫的頻率從 1 減小到了 1/5000。 號(hào)段模式 不僅提升了數(shù)據(jù)庫讀寫性能,還很方便我們做橫向的線性擴(kuò)展。 假設(shè),我們部署 3 臺(tái)客服工單服務(wù),它們分別申請(qǐng)可用的[5001, 10000]、[10001, 15000]、[15001, 20000]號(hào)段。然后呢,全局 ID 序列表將該業(yè)務(wù)的自增 ID 可用值更新為 20001。多臺(tái)客服工單服務(wù)之間憑借號(hào)段生成算法的原子性,保證每臺(tái)服務(wù)上的可用號(hào)段不會(huì)重復(fù),從而使得 ID 全局唯一。 使用號(hào)段模式實(shí)現(xiàn)分布式 ID,有哪些常見問題?想一想,這個(gè)流程會(huì)不會(huì)存在什么潛在問題?事實(shí)上,會(huì)的。 服務(wù)重啟,可用號(hào)段浪費(fèi)我們遇到的第一個(gè)問題是,如果某臺(tái)客服工單服務(wù)重啟了,那么該號(hào)段就作廢了。因此,我們需要 特別注意步長的配置,盡可能減少可用 ID 的浪費(fèi)。 但是呢,減少步長的大小,間接的就會(huì)提升數(shù)據(jù)庫的性能壓力,因?yàn)閿?shù)據(jù)庫的讀寫數(shù)據(jù)庫的頻率是 1/步長。
因此,步長的配置需要一個(gè)折中的配置策略。我們可以用觀測(cè)平時(shí)的業(yè)務(wù)峰值,和大促時(shí)的業(yè)務(wù)峰值,來動(dòng)態(tài)配置步長。此外,由于重啟導(dǎo)致的可用 ID 的浪費(fèi),也會(huì)造成 ID 不是連續(xù)的,不過,這對(duì)于大部分業(yè)務(wù)都是可接受的。 并發(fā)安全:多態(tài)服務(wù)同時(shí)獲取 ID 區(qū)間段我們遇到的第二個(gè)問題是,如果是多臺(tái)服務(wù)同時(shí)獲取號(hào)段,可能會(huì)發(fā)生競(jìng)爭(zhēng)問題。 其實(shí)呢,我們可以 使用悲觀鎖來解決。最容易實(shí)現(xiàn)的方案就是,用數(shù)據(jù)庫自身的行鎖。數(shù)據(jù)庫行鎖在數(shù)據(jù)處理過程中,將數(shù)據(jù)處于鎖定狀態(tài),來保證數(shù)據(jù)訪問的排他性。 如果考慮到數(shù)據(jù)庫的悲觀鎖會(huì)阻塞等待,我們也可以考慮 給全局 ID 序列表加一個(gè)版本號(hào),通過樂觀鎖的方式來實(shí)現(xiàn)。也就是說,每次更新都加上版本號(hào),保證并發(fā)更新的正確性。 監(jiān)控大盤的毛刺:線程阻塞等待我們遇到的第三個(gè)問題是,當(dāng)服務(wù)消費(fèi)完號(hào)段之后,向全局 ID 序列表重新發(fā)起讀寫請(qǐng)求時(shí),在這個(gè)臨界點(diǎn)可能會(huì)發(fā)生線程阻塞在數(shù)據(jù)庫取回號(hào)段的等待,它帶來的表象就是監(jiān)控大盤上的偶爾會(huì)出現(xiàn)的毛刺。 對(duì)于這個(gè)問題,業(yè)界提出了 雙號(hào)段緩存方案 的思路是,在號(hào)段快用完的時(shí)候,我們異步加載下一個(gè)可以使用的號(hào)段,保證 JVM 本地緩存中始終有可用的號(hào)段。因此,我們就不需要等到號(hào)段用完的時(shí)候才去更新號(hào)段,以此來避免性能波動(dòng)。 事實(shí)上,雙號(hào)段緩存方案中,服務(wù)內(nèi)部的緩存區(qū)有兩個(gè)號(hào)段:號(hào)段 A 和號(hào)段 B。當(dāng)前號(hào)段 A 用到一定程度的時(shí)候,如果下一個(gè)號(hào)段 B 還未更新,則服務(wù)開啟一個(gè)線程異步更新下一個(gè)號(hào)段 B。 當(dāng)前號(hào)段 A 全部消耗完之后,同時(shí),下一個(gè)號(hào)段 B 準(zhǔn)備好了,那么把緩存區(qū)中的號(hào)段 A 與號(hào)段 B 切換,也就是說,當(dāng)前可用號(hào)段 A 變成了號(hào)段 B,如此反復(fù)循環(huán)切換。 單點(diǎn)故障我們遇到的第四個(gè)問題是,數(shù)據(jù)庫只有一個(gè)實(shí)例時(shí),會(huì)存在單點(diǎn)故障。也就是說,如果數(shù)據(jù)庫不可用,則獲取號(hào)段不可用。因此,我們還要支持多數(shù)據(jù)庫實(shí)例。 這個(gè)時(shí)候,我們還需要引入兩個(gè)新的概念, 外步長和內(nèi)步長:
這里,有一個(gè)公式來計(jì)算新值。這個(gè)新值,是用來計(jì)算號(hào)段的生成區(qū)間。
我舉一個(gè)案例。假設(shè)有兩個(gè)數(shù)據(jù)庫實(shí)例,我們?cè)O(shè)置外步長是 1000,內(nèi)步長也是 1000。客服工單服務(wù)向數(shù)據(jù)庫 1 申請(qǐng)可用的[1, 1000]號(hào)段。 當(dāng) 1000 個(gè) ID 被消耗完了之后,再重新讀寫一次數(shù)據(jù)庫,正好此時(shí)路由到了數(shù)據(jù)庫 2,然后呢,數(shù)據(jù)庫 2 分配可用的[1001, 2000]號(hào)段,然后根據(jù)計(jì)算公式把自己的值更新為 2001。 總結(jié)我們圍繞如何通過號(hào)段模式實(shí)現(xiàn)分布式 ID 進(jìn)行了討論。號(hào)段模式滿足全局唯一性、趨勢(shì)遞增、單調(diào)遞增三個(gè)要求。 首先,我們需要了解號(hào)段模式,它通過每次向全局 ID 序列表獲取一批可以使用的號(hào)段,然后存入 JVM 本地緩存中使用,當(dāng)這批號(hào)段被消耗完了,再向全局 ID 序列表重新發(fā)起一次讀寫請(qǐng)求。 在具體實(shí)現(xiàn)中,使用號(hào)段模式還有 4 個(gè)潛在問題:
該文章在 2024/10/23 9:57:19 編輯過 |
關(guān)鍵字查詢
相關(guān)文章
正在查詢... |