分散式系統唯一ID生成方案彙總
系統唯一ID是我們在設計一個系統的時候常常會遇見的問題,也常常為這個問題而糾結。生成ID的方法有很多,適應不同的場景、需求以及效能要求。所以有些比較複雜的系統會有多個ID生成的策略。下面就介紹一些常見的ID生成策略。
1. 資料庫自增長序列或欄位
最常見的方式。利用資料庫,全資料庫唯一。
優點:
1)簡單,程式碼方便,效能可以接受。
2)數字ID天然排序,對分頁或者需要排序的結果很有幫助。
缺點:
1)不同資料庫語法和實現不同,資料庫遷移的時候或多資料庫版本支援的時候需要處理。
2)在單個數據庫或讀寫分離或一主多從的情況下,只有一個主庫可以生成。有單點故障的風險。
3)在效能達不到要求的情況下,比較難於擴充套件。
4)如果遇見多個系統需要合併或者涉及到資料遷移會相當痛苦。
5)分表分庫的時候會有麻煩。
優化方案:
1)針對主庫單點,如果有多個Master庫,則每個Master庫設定的起始數字不一樣,步長一樣,可以是Master的個數。比如:Master1 生成的是 1,4,7,10,Master2生成的是2,5,8,11 Master3生成的是 3,6,9,12。這樣就可以有效生成叢集中的唯一ID,也可以大大降低ID生成資料庫操作的負載。
2. UUID
常見的方式。可以利用資料庫也可以利用程式生成,一般來說全球唯一。
優點:
1)簡單,程式碼方便。
2)生成ID效能非常好,基本不會有效能問題。
3)全球唯一,在遇見資料遷移,系統資料合併,或者資料庫變更等情況下,可以從容應對。
缺點:
1)沒有排序,無法保證趨勢遞增。
2)UUID往往是使用字串儲存,查詢的效率比較低。
3)儲存空間比較大,如果是海量資料庫,就需要考慮儲存量的問題。
4)傳輸資料量大
5)不可讀。
3. UUID的變種
1)為了解決UUID不可讀,可以使用UUID to Int64的方法。及
/// <summary> /// 根據GUID獲取唯一數字序列 /// </summary> public static long GuidToInt64() { byte[] bytes = Guid.NewGuid().ToByteArray(); return BitConverter.ToInt64(bytes, 0); }
2)為了解決UUID無序的問題,NHibernate在其主鍵生成方式中提供了Comb演算法(combined guid/timestamp)。保留GUID的10個位元組,用另6個位元組表示GUID生成的時間(DateTime)。
/// <summary>
/// Generate a new <see cref="Guid"/> using the comb algorithm.
/// </summary>
private Guid GenerateComb()
{
byte[] guidArray = Guid.NewGuid().ToByteArray();
DateTime baseDate = new DateTime(1900, 1, 1);
DateTime now = DateTime.Now;
// Get the days and milliseconds which will be used to build
//the byte string
TimeSpan days = new TimeSpan(now.Ticks - baseDate.Ticks);
TimeSpan msecs = now.TimeOfDay;
// Convert to a byte array
// Note that SQL Server is accurate to 1/300th of a
// millisecond so we divide by 3.333333
byte[] daysArray = BitConverter.GetBytes(days.Days);
byte[] msecsArray = BitConverter.GetBytes((long)
(msecs.TotalMilliseconds / 3.333333));
// Reverse the bytes to match SQL Servers ordering
Array.Reverse(daysArray);
Array.Reverse(msecsArray);
// Copy the bytes into the guid
Array.Copy(daysArray, daysArray.Length - 2, guidArray,
guidArray.Length - 6, 2);
Array.Copy(msecsArray, msecsArray.Length - 4, guidArray,
guidArray.Length - 4, 4);
return new Guid(guidArray);
}
用上面的演算法測試一下,得到如下的結果:作為比較,前面3個是使用COMB演算法得出的結果,最後12個字串是時間序(統一毫秒生成的3個UUID),過段時間如果再次生成,則12個字串會比圖示的要大。後面3個是直接生成的GUID。
如果想把時間序放在前面,可以生成後改變12個字串的位置,也可以修改演算法類的最後兩個Array.Copy。
4. Redis生成ID
當使用資料庫來生成ID效能不夠要求的時候,我們可以嘗試使用Redis來生成ID。這主要依賴於Redis是單執行緒的,所以也可以用生成全域性唯一的ID。可以用Redis的原子操作 INCR和INCRBY來實現。
可以使用Redis叢集來獲取更高的吞吐量。假如一個叢集中有5臺Redis。可以初始化每臺Redis的值分別是1,2,3,4,5,然後步長都是5。各個Redis生成的ID為:
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
這個,隨便負載到哪個機確定好,未來很難做修改。但是3-5臺伺服器基本能夠滿足器上,都可以獲得不同的ID。但是步長和初始值一定需要事先需要了。使用Redis叢集也可以方式單點故障的問題。
另外,比較適合使用Redis來生成每天從0開始的流水號。比如訂單號=日期+當日自增長號。可以每天在Redis中生成一個Key,使用INCR進行累加。
優點:
1)不依賴於資料庫,靈活方便,且效能優於資料庫。
2)數字ID天然排序,對分頁或者需要排序的結果很有幫助。
缺點:
1)如果系統中沒有Redis,還需要引入新的元件,增加系統複雜度。
2)需要編碼和配置的工作量比較大。
5. Twitter的snowflake演算法
snowflake是Twitter開源的分散式ID生成演算法,結果是一個long型的ID。其核心思想是:使用41bit作為毫秒數,10bit作為機器的ID(5個bit是資料中心,5個bit的機器ID),12bit作為毫秒內的流水號(意味著每個節點在每毫秒可以產生 4096 個 ID),最後還有一個符號位,永遠是0。具體實現的程式碼可以參看https://github.com/twitter/snowflake。
C#程式碼如下:
/// <summary>
/// From: https://github.com/twitter/snowflake
/// An object that generates IDs.
/// This is broken into a separate class in case
/// we ever want to support multiple worker threads
/// per process
/// </summary>
public class IdWorker
{
private long workerId;
private long datacenterId;
private long sequence = 0L;
private static long twepoch = 1288834974657L;
private static long workerIdBits = 5L;
private static long datacenterIdBits = 5L;
private static long maxWorkerId = -1L ^ (-1L << (int)workerIdBits);
private static long maxDatacenterId = -1L ^ (-1L << (int)datacenterIdBits);
private static long sequenceBits = 12L;
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private long sequenceMask = -1L ^ (-1L << (int)sequenceBits);
private long lastTimestamp = -1L;
private static object syncRoot = new object();
public IdWorker(long workerId, long datacenterId)
{
// sanity check for workerId
if (workerId > maxWorkerId || workerId < 0)
{
throw new ArgumentException(string.Format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0)
{
throw new ArgumentException(string.Format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public long nextId()
{
lock (syncRoot)
{
long timestamp = timeGen();
if (timestamp < lastTimestamp)
{
throw new ApplicationException(string.Format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (lastTimestamp == timestamp)
{
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0)
{
timestamp = tilNextMillis(lastTimestamp);
}
}
else
{
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << (int)timestampLeftShift) | (datacenterId << (int)datacenterIdShift) | (workerId << (int)workerIdShift) | sequence;
}
}
protected long tilNextMillis(long lastTimestamp)
{
long timestamp = timeGen();
while (timestamp <= lastTimestamp)
{
timestamp = timeGen();
}
return timestamp;
}
protected long timeGen()
{
return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
}
}
測試程式碼如下:
private static void TestIdWorker()
{
HashSet<long> set = new HashSet<long>();
IdWorker idWorker1 = new IdWorker(0, 0);
IdWorker idWorker2 = new IdWorker(1, 0);
Thread t1 = new Thread(() => DoTestIdWoker(idWorker1, set));
Thread t2 = new Thread(() => DoTestIdWoker(idWorker2, set));
t1.IsBackground = true;
t2.IsBackground = true;
t1.Start();
t2.Start();
try
{
Thread.Sleep(30000);
t1.Abort();
t2.Abort();
}
catch (Exception e)
{
}
Console.WriteLine("done");
}
private static void DoTestIdWoker(IdWorker idWorker, HashSet<long> set)
{
while (true)
{
long id = idWorker.nextId();
if (!set.Add(id))
{
Console.WriteLine("duplicate:" + id);
}
Thread.Sleep(1);
}
}
snowflake演算法可以根據自身專案的需要進行一定的修改。比如估算未來的資料中心個數,每個資料中心的機器數以及統一毫秒可以能的併發數來調整在演算法中所需要的bit數。
優點:
1)不依賴於資料庫,靈活方便,且效能優於資料庫。
2)ID按照時間在單機上是遞增的。
缺點:
1)在單機上是遞增的,但是由於涉及到分散式環境,每臺機器上的時鐘不可能完全同步,也許有時候也會出現不是全域性遞增的情況。
6. 利用zookeeper生成唯一ID
zookeeper主要通過其znode資料版本來生成序列號,可以生成32位和64位的資料版本號,客戶端可以使用這個版本號來作為唯一的序列號。
很少會使用zookeeper來生成唯一ID。主要是由於需要依賴zookeeper,並且是多步呼叫API,如果在競爭較大的情況下,需要考慮使用分散式鎖。因此,效能在高併發的分散式環境下,也不甚理想。
7. MongoDB的ObjectId
MongoDB的ObjectId和snowflake演算法類似。它設計成輕量型的,不同的機器都能用全域性唯一的同種方法方便地生成它。MongoDB 從一開始就設計用來作為分散式資料庫,處理多個節點是一個核心要求。使其在分片環境中要容易生成得多。
其格式如下:
前4 個位元組是從標準紀元開始的時間戳,單位為秒。時間戳,與隨後的5 個位元組組合起來,提供了秒級別的唯一性。由於時間戳在前,這意味著ObjectId 大致會按照插入的順序排列。這對於某些方面很有用,如將其作為索引提高效率。這4 個位元組也隱含了文件建立的時間。絕大多數客戶端類庫都會公開一個方法從ObjectId 獲取這個資訊。 接下來的3 位元組是所在主機的唯一識別符號。通常是機器主機名的雜湊值。這樣就可以確保不同主機生成不同的ObjectId,不產生衝突。 為了確保在同一臺機器上併發的多個程序產生的ObjectId 是唯一的,接下來的兩位元組來自產生ObjectId 的程序識別符號(PID)。 前9 位元組保證了同一秒鐘不同機器不同程序產生的ObjectId 是唯一的。後3 位元組就是一個自動增加的計數器,確保相同程序同一秒產生的ObjectId 也是不一樣的。同一秒鐘最多允許每個程序擁有2563(16 777 216)個不同的ObjectId。
實現的原始碼可以到MongoDB官方網站下載。
8. 來自Flicker的解決方案 因為MySQL本身支援auto_increment操作,很自然地,我們會想到藉助這個特性來實現這個功能。 Flicker在解決全域性ID生成方案裡就採用了MySQL自增長ID的機制(auto_increment + replace into + MyISAM)。一個生成64位ID方案具體就是這樣的: 先建立單獨的資料庫(eg:ticket),然後建立一個表:
1 2 3 4 5 6 |
CREATE TABLE Tickets64 ( id bigint(20) unsigned NOT NULL auto_increment, stub char(1) NOT NULL default '', PRIMARY KEY (id), UNIQUE KEY stub (stub) ) ENGINE=MyISAM |
---|
當我們插入記錄後,執行SELECT * from Tickets64,查詢結果就是這樣的:
+-------------------+------+ | id | stub | +-------------------+------+ | 72157623227190423 | a | +-------------------+------+ 在我們的應用端需要做下面這兩個操作,在一個事務會話裡提交:
1 2 |
REPLACE INTO Tickets64 (stub) VALUES ('a'); SELECT LAST_INSERT_ID(); |
---|
這樣我們就能拿到不斷增長且不重複的ID了。 到上面為止,我們只是在單臺數據庫上生成ID,從高可用角度考慮,接下來就要解決單點故障問題:Flicker啟用了兩臺資料庫伺服器來生成ID,通過區分auto_increment的起始值和步長來生成奇偶數的ID。
1 2 3 4 5 6 7 |
TicketServer1: auto-increment-increment = 2 auto-increment-offset = 1 TicketServer2: auto-increment-increment = 2 auto-increment-offset = 2 |
---|
最後,在客戶端只需要通過輪詢方式取ID就可以了。
優點:充分藉助資料庫的自增ID機制,提供高可靠性,生成的ID有序。 缺點:佔用兩個獨立的MySQL例項,有些浪費資源,成本較高。