分散式唯一ID介紹
分散式系統全域性唯一的 id 是所有系統都會遇到的場景,往往會被用在搜尋,儲存方面,用於作為唯一的標識或者排序,比如全域性唯一的訂單號,優惠券的券碼等,如果出現兩個相同的訂單號,對於使用者無疑將是一個巨大的bug。
在單體的系統中,生成唯一的 id 沒有什麼挑戰,因為只有一臺機器一個應用,直接使用單例加上一個原子操作自增即可。而在分散式系統中,不同的應用,不同的機房,不同的機器,要想生成的 ID 都是唯一的,確實需要下點功夫。
一句話總結:
分散式唯一ID是為了給資料進行唯一標識。
分散式唯一ID的特徵
分散式唯一ID的核心是唯一性,其他的都是附加屬性,一般來說,一個優秀的全域性唯一ID方案有以下的特點,僅供參考:
- 全域性唯一:不可以重複,核心特點!
- 大致有序或者單調遞增:自增的特性有利於搜尋,排序,或者範圍查詢等
- 高效能:生成ID響應要快,延遲低
- 高可用:要是隻能單機,掛了,全公司依賴全域性唯一ID的服務,全部都不可用了,所以生成ID的服務必須高可用
- 方便使用:對接入者友好,能封裝到開箱即用最好
- 資訊保安:有些場景,如果連續,那麼很容易被猜到,攻擊也是有可能的,這得取捨。
分散式唯一ID的生成方案
UUID直接生成
寫過 Java 的朋友都知道,有時候我們寫日誌會用到一個類 UUID,會生成一個隨機的ID,去作為當前使用者請求記錄的唯一識別碼,只要用以下的程式碼:
String uuid = UUID.randomUUID();
用法簡單粗暴,UUID的全稱其實是Universally Unique IDentifier
,或者GUID(Globally Unique IDentifier)
,它本質上是一個 128 位的二進位制整數,通常我們會表示成為 32 個 16 進位制陣列成的字串,幾乎不會重複,2 的 128 次方,那是無比龐大的數字。
以下是百度百科說明:
UUID由以下幾部分的組合:
(1)UUID的第一個部分與時間有關,如果你在生成一個UUID之後,過幾秒又生成一個UUID,則第一個部分不同,其餘相同。
(2)時鐘序列。
(3)全域性唯一的IEEE機器識別號,如果有網路卡,從網路卡MAC地址獲得,沒有網路卡以其他方式獲得。
UUID的唯一缺陷在於生成的結果串會比較長。關於UUID這個標準使用最普遍的是微軟的GUID(Globals Unique Identifiers)。在ColdFusion中可以用CreateUUID()函式很簡單地生成UUID,其格式為:xxxxxxxx-xxxx- xxxx-xxxxxxxxxxxxxxxx(8-4-4-16),其中每個 x 是 0-9 或 a-f 範圍內的一個十六進位制的數字。而標準的UUID格式為:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12),可以從cflib 下載CreateGUID() UDF進行轉換。 [2]
(4)在 hibernate(Java orm框架)中, 採用 IP-JVM啟動時間-當前時間右移32位-當前時間-內部計數(8-8-4-8-4)來組成UUID
要想重複,兩臺完全相同的虛擬機器,開機時間一致,隨機種子一致,同一時間生成uuid,才有極小的概率會重複,因此我們可認為,理論上會重複,實際不可能重複!!!
uuid優點:
- 效能好,效率高
- 不用網路請求,直接本地生成
- 不同的機器個幹個的,不會重複
uuid 這麼好,難不成是銀彈?當然缺點也很突出:
- 沒辦法保證遞增趨勢,沒法排序
- uuid太長了,儲存佔用空間大,特別落在資料庫,對建立索引不友好
- 沒有業務屬性,這東西就是一串數字,沒啥意義,或者說規律
當然也有人想要改進這傢伙,比如不可讀性改造,用uuid to int64
,把它轉成 long 型別:
byte[] bytes = Guid.NewGuid().ToByteArray();
return BitConverter.ToInt64(bytes, 0);
又比如,改造無序性,比如 NHibernate
的 Comb
演算法,把 uuid 的前 20 個字元保留下來,後面 12 個字元用 guid
生成的時間,時間是大致有序的,是一種小改進。
點評:UUID不存在資料庫當索引,作為一些日誌,上下文的識別,還是挺香的,但是要是這玩意用來當訂單號,真是令人崩潰
資料庫自增序列
單機的資料庫
資料庫的主鍵本身就擁有一個自增的天然特性,只要設定ID為主鍵並且自增,我們就可以向資料庫中插入一條記錄,可以返回自增的ID,比如以下的建表語句:
CREATE DATABASE `test`;
use test;
CREATE TABLE id_table (
id bigint(20) unsigned NOT NULL auto_increment,
value char(10) NOT NULL default '',
PRIMARY KEY (id),
) ENGINE=MyISAM;
插入語句:
insert into id_table(value) VALUES ('v1');
優點:
- 單機,簡單,速度也很快
- 天然自增,原子性
- 數字id排序,搜尋,分頁都比較有利
缺點也很明顯:
- 單機,掛了就要提桶跑路了
- 一臺機器,高併發也不可能
叢集的資料庫
既然單機高併發和高可用搞不定,那就加機器,搞叢集模式的資料庫,既然叢集模式,如果有多個master,那肯定不能每臺機器自己生成自己的id,這樣會導致重複的id。
這個時候,每臺機器設定起始值和步長,就尤為重要。比如三臺機器V1,V2,V3:
統一步長:3
V1起始值:1
V2起始值:2
V3起始值:3
生成的ID:
V1:1, 4, 7, 10...
V2:2, 5, 8, 11...
V3:3, 6, 9, 12...
設定命令列可以使用:
set @@auto_increment_offset = 1; // 起始值
set @@auto_increment_increment = 3; // 步長
這樣確實在master足夠多的情況下,高效能保證了,就算有的機器當機了,slave 也可以補充上來,基於主從複製就可以,可以大大降低對單臺機器的壓力。但是這樣做還是有缺點:
- 主從複製延遲了,master當機了,從節點切換成為主節點之後,可能會重複發號。
- 起始值和步長設定好之後,要是後面需要增加機器(水平擴充),要調整很麻煩,很多時候可能需要停機更新
批量號段式資料庫
上面的訪問資料庫太頻繁了,併發量一上來,很多小概率問題都可能發生,那為什麼我們不直接一次性拿出一段id呢?直接放在記憶體裡,以供使用,用完了再申請一段就可以了。同樣也可以保留叢集模式的優點,每次從資料庫取出一個範圍的id,比如3臺機器,發號:
每次取1000,每臺步長3000
V1:1-1000,3001-4000,
V2:1001-2000,4001-5000
V3:2001-3000,5001-6000
當然,如果不搞多臺機器,也是可以的,一次申請10000個號碼,用樂觀鎖實現,加一個版本號,
CREATE TABLE id_table (
id int(10) NOT NULL,
max_id bigint(20) NOT NULL COMMENT '當前最大id',
step int(20) NOT NULL COMMENT '號段的步長',
version int(20) NOT NULL COMMENT '版本號',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
)
只有用完的時候,才會重新去資料庫申請,競爭的時候樂觀鎖保證只能一個請求成功,其他的直接等著別人取出來放在應用記憶體裡面,再取就可以了,取的時候其實就是一個update操作:
update id_table set max_id = #{max_id+step}, version = version + 1 where version = # {version}
重點:
- 批量獲取,減少資料庫請求
- 樂觀鎖,保證資料準確
- 獲取只能從資料庫中獲取,批量獲取可以做成非同步定時任務,發現少於某個閾值,自動補充
Redis自增
redis有一個原子命令incr
,原子自增,redis速度快,基於記憶體:
127.0.0.1:6379> set id 1
OK
127.0.0.1:6379> incr id
(integer) 2
當然,redis 如果單機有問題,也可以上叢集,同樣可以用初始值 + 步長,可以用 INCRBY
命令,搞幾臺機器基本能抗住高併發。
優點:
- 基於記憶體,速度快
- 天然排序,自增,有利於排序搜尋
缺點:
- 步長確定之後,增加機器也比較難調整
- 需要關注持久化,可用性等,增加系統複雜度
redis持久化如果是RDB,一段時間打一個快照,那麼可能會有資料沒來得及被持久化到磁碟,就掛掉了,重啟可能會出現重複的ID,同時要是主從延遲,主節點掛掉了,主從切換,也可能出現重複的ID。如果使用AOF,一條命令持久化一次,可能會拖慢速度,一秒鐘持久化一次,那麼就可能最多丟失一秒鐘的資料,同時,資料恢復也會比較慢,這是一個取捨的過程。
Zookeeper生成唯一ID
zookeeper其實是可以用來生成唯一ID的,但是大家不用,因為效能不高。znode有資料版本,可以生成32或者64位的序列號,這個序列號是唯一的,但是如果競爭比較大,還需要加分散式鎖,不值得,效率低。
美團的Leaf
下面均來自美團的官方文件:https://tech.meituan.com/2019/03/07/open-source-project-leaf.html
Leaf在設計之初就秉承著幾點要求:
- 全域性唯一,絕對不會出現重複的ID,且ID整體趨勢遞增。
- 高可用,服務完全基於分散式架構,即使MySQL當機,也能容忍一段時間的資料庫不可用。
- 高併發低延時,在CentOS 4C8G的虛擬機器上,遠端呼叫QPS可達5W+,TP99在1ms內。
- 接入簡單,直接通過公司RPC服務或者HTTP呼叫即可接入。
文件裡面講得很清晰,一共有兩個版本:
- V1:預分發的方式提供ID,也就是前面說的號段式分發,表設計也差不多,意思就是批量的拉取id
這樣做的缺點就是更新號段的時候,耗時比較高,還有就是如果這時候當機或者主從複製,就不可用。
優化:
-
1.先做了一個雙Buffer優化,就是非同步更新,意思就是搞兩個號段出來,一個號段比如被消耗10%的時候,就開始分配下一個號段,有種提前分配的意思,而且非同步執行緒更新
-
2.上面的方案,號段可能固定,跨度可能太大或者太小,那就做成動態變化,根據流量來決定下一次的號段的大小,動態調整
-
V2:Leaf-snowflake,Leaf提供了Java版本的實現,同時對Zookeeper生成機器號做了弱依賴處理,即使Zookeeper有問題,也不會影響服務。Leaf在第一次從Zookeeper拿取workerID後,會在本機檔案系統上快取一個workerID檔案。即使ZooKeeper出現問題,同時恰好機器也在重啟,也能保證服務的正常執行。這樣做到了對第三方元件的弱依賴,一定程度上提高了SLA。
snowflake(雪花演算法)
snowflake 是 twitter 公司內部分散式專案採用的 ID 生成演算法,開源後廣受歡迎,它生成的ID是 Long
型別,8個位元組,一共64位,從左到右:
- 1位:不使用,二進位制中最高位是為1都是負數,但是要生成的唯一ID都是正整數,所以這個1位固定為0。
- 41位:記錄時間戳(毫秒),這個位數可以用 $(2^{41}-1) / (1000 * 60 * 60 * 24 * 365) = 69$年
- 10位:記錄工作機器的ID,可以機器ID,也可以機房ID + 機器ID
- 12位:序列號,就是某個機房某臺機器上這一毫秒內同時生成的 id 序號
那麼每臺機器按照上面的邏輯去生成ID,就會是趨勢遞增的,因為時間在遞增,而且不需要搞個分散式的,簡單很多。
可以看出 snowflake 是強依賴於時間的,因為時間理論上是不斷往前的,所以這一部分的位數,也是趨勢遞增的。但是有一個問題,是時間回撥,也就是時間突然間倒退了,可能是故障,也可能是重啟之後時間獲取出問題了。那我們該如何解決時間回撥問題呢?
- 第一種方案:獲取時間的時候判斷,如果小於上一次的時間戳,那麼就不要分配,繼續迴圈獲取時間,直到時間符合條件。
- 第二種方案:上面的方案只適合時鐘回撥較小的,如果間隔過大,阻塞等待,肯定是不可取的,因此要麼超過一定大小的回撥直接報錯,拒絕服務,或者有一種方案是利用擴充位,回撥之後在擴充位上加1就可以了,這樣ID依然可以保持唯一。
Java程式碼實現:
public class SnowFlake {
// 資料中心(機房) id
private long datacenterId;
// 機器ID
private long workerId;
// 同一時間的序列
private long sequence;
public SnowFlake(long workerId, long datacenterId) {
this(workerId, datacenterId, 0);
}
public SnowFlake(long workerId, long datacenterId, long sequence) {
// 合法判斷
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
// 開始時間戳
private long twepoch = 1420041600000L;
// 機房號,的ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
private long datacenterIdBits = 5L;
// 機器ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
private long workerIdBits = 5L;
// 5 bit最多隻能有31個數字,就是說機器id最多隻能是32以內
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 5 bit最多隻能有31個數字,機房id最多隻能是32以內
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
// 同一時間的序列所佔的位數 12個bit 111111111111 = 4095 最多就是同一毫秒生成4096個
private long sequenceBits = 12L;
// workerId的偏移量
private long workerIdShift = sequenceBits;
// datacenterId的偏移量
private long datacenterIdShift = sequenceBits + workerIdBits;
// timestampLeft的偏移量
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
// 序列號掩碼 4095 (0b111111111111=0xfff=4095)
// 用於序號的與運算,保證序號最大值在0-4095之間
private long sequenceMask = -1L ^ (-1L << sequenceBits);
// 最近一次時間戳
private long lastTimestamp = -1L;
// 獲取機器ID
public long getWorkerId() {
return workerId;
}
// 獲取機房ID
public long getDatacenterId() {
return datacenterId;
}
// 獲取最新一次獲取的時間戳
public long getLastTimestamp() {
return lastTimestamp;
}
// 獲取下一個隨機的ID
public synchronized long nextId() {
// 獲取當前時間戳,單位毫秒
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
// 去重
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
// sequence序列大於4095
if (sequence == 0) {
// 呼叫到下一個時間戳的方法
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 如果是當前時間的第一次獲取,那麼就置為0
sequence = 0;
}
// 記錄上一次的時間戳
lastTimestamp = timestamp;
// 偏移計算
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) |
sequence;
}
private long tilNextMillis(long lastTimestamp) {
// 獲取最新時間戳
long timestamp = timeGen();
// 如果發現最新的時間戳小於或者等於序列號已經超4095的那個時間戳
while (timestamp <= lastTimestamp) {
// 不符合則繼續
timestamp = timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
SnowFlake worker = new SnowFlake(1, 1);
long timer = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
worker.nextId();
}
System.out.println(System.currentTimeMillis());
System.out.println(System.currentTimeMillis() - timer);
}
}
百度 uid-generator
換湯不換藥,百度開發的,基於Snowflake
演算法,不同的地方是可以自己定義每部分的位數,也做了不少優化和擴充:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
UidGenerator是Java實現的, 基於Snowflake演算法的唯一ID生成器。UidGenerator以元件形式工作在應用專案中, 支援自定義workerId位數和初始化策略, 從而適用於docker等虛擬化環境下例項自動重啟、漂移等場景。 在實現上, UidGenerator通過借用未來時間來解決sequence天然存在的併發限制; 採用RingBuffer來快取已生成的UID, 並行化UID的生產和消費, 同時對CacheLine補齊,避免了由RingBuffer帶來的硬體級「偽共享」問題. 最終單機QPS可達600萬。
秦懷の觀點
不管哪一種uid生成器,保證唯一性是核心,在這個核心上才能去考慮其他的效能,或者高可用等問題,總體的方案分為兩種:
- 中心化:第三方的一箇中心,比如 Mysql,Redis,Zookeeper
- 優點:趨勢自增
- 缺點:增加複雜度,一般得叢集,提前約定步長之類
- 無中心化:直接本地機器上生成,snowflake,uuid
- 優點:簡單,高效,沒有效能瓶頸
- 缺點:資料比較長,自增屬性較弱
沒有哪一種是完美的,只有符合業務以及當前體量的方案,技術方案裡面,沒有最優解。
【作者簡介】:
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java原始碼解析
,JDBC
,Mybatis
,Spring
,redis
,分散式
,劍指Offer
,LeetCode
等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。