GUID做主鍵真的合適嗎
- 2019 年 10 月 3 日
- 筆記
在一個分散式環境中,我們習慣使用GUID做主鍵,來保證全局唯一,然後,GUID做主鍵真的合適嗎?
其實GUID做主鍵本身沒有問題,微軟的很多項目自帶DB都是使用GUID做主鍵的,顯然,這樣做是沒有問題的。然而,SQL Server默認會將主鍵設置為聚集索引,使用GUID做聚集索引就有問題了。很多時候程式設計師容易接受SQL Server這一默認設置,但無序GUID做聚集索引顯然是低效的。
那麼,我們在項目中如何避免這一問題呢?
主要的思路還是兩方面——方案一,選擇合適的列作為聚集索引;方案二,使用有序的主鍵。
1 方案一,選擇合適的列做聚集索引
選擇原則很簡單——欄位值盡量唯一,欄位佔用位元組盡量小,欄位值很少修改,欄位多用於查詢範圍數據或排序的數據。
之所以是根據以上原則選擇,主要還是基於B+樹數據索引問題,這部分內容都比較基礎,這裡就不舉例驗證了,以上原則還是比較公認的,即便讀者不太理解其中原理,也請記住這一選擇規則。
常見的備選項——自增列(Id)和時間列(CreateTime)。
聚集索引的最大用處就是幫助範圍查詢快速定位,從而減小資料庫IO的消耗來提升查詢效率。對於範圍查詢我們更多的應用在自增列和時間列上,因為這兩列本身反應了數據的創建順序,符合多數範圍查詢的場景需要。
大部分時候,我們仍然可以使用GUID做主鍵,只需要重新設置聚集索引就行。
2 方案二,有序的主鍵
對於一個分散式環境,保證唯一和有序性,實際上有多種方法,各有利弊。
2.1 分散式資料庫
對於分散式資料庫,簡單使用自增主鍵即可,比如Tidb。
TiDB 中,自增列只保證自增且唯一,並不保證連續分配。TiDB 目前採用批量分配 ID 的方式,所以如果在多台 TiDB 上同時插入數據,分配的自增 ID 會不連續。TiDB 實現自增 ID 的原理是每個 tidb-server 實例快取一段 ID 值用於分配(目前會快取 30000 個 ID),用完這段值再去取下一段。
優點:簡單好用
缺點:不能設置ID,需要使用資料庫的;ID不保證連續分配,也無法根據ID來判斷數據創建的先後;負載不均勻,有數據熱點問題
2.2 基於Redis等中間件的
根據資料庫分片方式不同,又有兩種情形。
方式一,取模分片
思路:Redis初始化當前最大ID值,之後進行自增,分散式數據訪問層根據取模進行路由
優點:資料庫負載比較均勻
缺點:需要盡量保證Redis和資料庫的一致性;Redis不穩定會影響系統;在增加資料庫後,需要大批量移動數據,且需要成倍增加DB
方式二,按範圍分片
思路:每台伺服器負責一個號段,不夠用了就增加伺服器,Redis初始化當前最大ID值,之後進行自增,分散式數據訪問層根據號段進行路由
優點:增加資料庫可以不遷移數據,可以一個一個的增加資料庫
缺點:需要盡量保證Redis和資料庫的一致性;Redis不穩定會影響系統;數據分布嚴重不均勻,嚴重的熱點問題
2.3 基於演算法實現
這裡介紹下Twitter的Snowflake演算法——snowflake,它把時間戳,工作機器id,序列號組合在一起,以保證在分散式系統中唯一性和自增性。
snowflake生成的ID整體上按照時間自增排序,並且整個分散式系統內不會產生ID碰撞,在同一毫秒內最多可以生成 1024 X 4096 = 4194304個全局唯一ID。
優點:不依賴資料庫,完全記憶體操作速度快
缺點:不同伺服器需要保證系統時鐘一致
snowflake的C#版本的簡單實現:
public class SnowflakeIdWorker { /// <summary> /// 開始時間截 /// 1288834974657 是(Thu, 04 Nov 2010 01:42:54 GMT) 這一時刻到1970-01-01 00:00:00時刻所經過的毫秒數。 /// 當前時刻減去1288834974657 的值剛好在2^41 里,因此佔41位。 /// 所以這個數是為了讓時間戳佔41位才特地算出來的。 /// </summary> public const long Twepoch = 1288834974657L; /// <summary> /// 工作節點Id佔用5位 /// </summary> const int WorkerIdBits = 5; /// <summary> /// 數據中心Id佔用5位 /// </summary> const int DatacenterIdBits = 5; /// <summary> /// 序列號佔用12位 /// </summary> const int SequenceBits = 12; /// <summary> /// 支援的最大機器Id,結果是31 (這個移位演算法可以很快的計算出幾位二進位數所能表示的最大十進位數) /// </summary> const long MaxWorkerId = -1L ^ (-1L << WorkerIdBits); /// <summary> /// 支援的最大數據中心Id,結果是31 /// </summary> const long MaxDatacenterId = -1L ^ (-1L << DatacenterIdBits); /// <summary> /// 機器ID向左移12位 /// </summary> private const int WorkerIdShift = SequenceBits; /// <summary> /// 數據標識id向左移17位(12+5) /// </summary> private const int DatacenterIdShift = SequenceBits + WorkerIdBits; /// <summary> /// 時間截向左移22位(5+5+12) /// </summary> public const int TimestampLeftShift = SequenceBits + WorkerIdBits + DatacenterIdBits; /// <summary> /// 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095) /// </summary> private const long SequenceMask = -1L ^ (-1L << SequenceBits); /// <summary> /// 毫秒內序列(0~4095) /// </summary> private long _sequence = 0L; /// <summary> /// 上次生成Id的時間截 /// </summary> private long _lastTimestamp = -1L; /// <summary> /// 工作節點Id /// </summary> public long WorkerId { get; protected set; } /// <summary> /// 數據中心Id /// </summary> public long DatacenterId { get; protected set; } /// <summary> /// 構造器 /// </summary> /// <param name="workerId">工作ID (0~31)</param> /// <param name="datacenterId">數據中心ID (0~31)</param> public SnowflakeIdWorker(long workerId, long datacenterId) { WorkerId = workerId; DatacenterId = datacenterId; if (workerId > MaxWorkerId || workerId < 0) { throw new ArgumentException(String.Format("worker Id can't be greater than {0} or less than 0", MaxWorkerId)); } if (datacenterId > MaxDatacenterId || datacenterId < 0) { throw new ArgumentException(String.Format("datacenter Id can't be greater than {0} or less than 0", MaxDatacenterId)); } } private static readonly object _lockObj = new Object(); /// <summary> /// 獲得下一個ID (該方法是執行緒安全的) /// </summary> /// <returns></returns> public virtual long NextId() { lock (_lockObj) { //獲取當前時間戳 var timestamp = TimeGen(); //如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常 if (timestamp < _lastTimestamp) { throw new InvalidOperationException(String.Format( "Clock moved backwards. Refusing to generate id for {0} milliseconds", _lastTimestamp - timestamp)); } //如果是同一時間生成的,則進行毫秒內序列 if (_lastTimestamp == timestamp) { _sequence = (_sequence + 1) & SequenceMask; //毫秒內序列溢出 if (_sequence == 0) { //阻塞到下一個毫秒,獲得新的時間戳 timestamp = TilNextMillis(_lastTimestamp); } } //時間戳改變,毫秒內序列重置 else { _sequence = 0; } //上次生成ID的時間截 _lastTimestamp = timestamp; //移位並通過或運算拼到一起組成64位的ID return ((timestamp - Twepoch) << TimestampLeftShift) | (DatacenterId << DatacenterIdShift) | (WorkerId << WorkerIdShift) | _sequence; } } /// <summary> /// 生成當前時間戳 /// </summary> /// <returns>毫秒</returns> private static long GetTimestamp() { return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; } /// <summary> /// 生成當前時間戳 /// </summary> /// <returns>毫秒</returns> protected virtual long TimeGen() { return GetTimestamp(); } /// <summary> /// 阻塞到下一個毫秒,直到獲得新的時間戳 /// </summary> /// <param name="lastTimestamp">上次生成Id的時間截</param> /// <returns></returns> protected virtual long TilNextMillis(long lastTimestamp) { var timestamp = TimeGen(); while (timestamp <= lastTimestamp) { timestamp = TimeGen(); } return timestamp; } }
測試:
[TestClass] public class SnowflakeTest { [TestMethod] public void MainTest() { SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0); for (int i = 0; i < 1000; i++) { Trace.WriteLine(string.Format("{0}-{1}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:ffffff"), idWorker.NextId())); } } }
結果:
總之,GUID能滿足大部分需要,但如果想要我們的程式精益求精,也可以考慮使用本文提到的方法,感謝閱讀。