在一個分布式環境中,我們習慣使用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能滿足大部分需要,但如果想要我們的程序精益求精,也可以考慮使用本文提到的方法,感謝閱讀。