1. 程式人生 > >高併發分散式系統如何做到唯一Id

高併發分散式系統如何做到唯一Id

又一個多月沒冒泡了,其實最近學了些東西,但是沒有安排時間整理成博文,後續再奉上。最近還寫了一個發郵件的元件以及效能測試請看 《NET開發郵件傳送功能的全面教程(含郵件元件原始碼)》 ,還弄了個MSSQL引數化語法生成器,會在9月整理出來,有興趣的園友可以關注下我的部落格。

分享原由,最近公司用到,並且在找最合適的方案,希望大家多參與討論和提出新方案。我和我的小夥伴們也討論了這個主題,我受益匪淺啊……

博文示例:

今天分享的主題是:如何在高併發分散式系統中生成全域性唯一Id。

但這篇博文實際上是“半分享半討論”的博文:

1)         半分享是我將說下我所瞭解到的關於今天主題所涉及的幾種方案。

2)         半討論是我希望大家對各個方案都說說自己的見解,更加希望大家能提出更好的方案。(我還另外提問在此:http://q.cnblogs.com/q/53552/上面已有幾位園友回覆(感謝dudu站長的參與),若你們有見解和新方案就在本博文留言吧,方便我整理更新到博文中,謝謝!)

我瞭解的方案如下……………………………………………………………………

1、  使用資料庫自增Id

優勢:編碼簡單,無需考慮記錄唯一標識的問題。

缺陷:

1)         在大表做水平分表時,就不能使用自增Id,因為Insert的記錄插入到哪個分表依分表規則判定決定,若是自增Id,各個分表中Id就會重複,在做查詢、刪除時就會有異常。

2)         在對錶進行高併發單記錄插入時需要加入事物機制,否則會出現Id重複的問題。

3)         在業務上操作父、子表(即關聯表)插入時,需要在插入資料庫之前獲取max(id)用於標識父表和子表關係,若存在併發獲取max(id)的情況,max(id)會同時被別的執行緒獲取到。

4)         等等。

結論:適合小應用,無需分表,沒有高併發效能要求。

2、  單獨開一個數據庫,獲取全域性唯一的自增序列號或各表的MaxId

1)         使用自增序列號表

專門一個數據庫,生成序列號。開啟事物,每次操作插入時,先將資料插入到序列表並返回自增序列號用於做為唯一Id進行業務資料插入。

注意:需要定期清理序列表的資料以保證獲取序列號的效率;插入序列表記錄時要開啟事物。

使用此方案的問題是:每次的查詢序列號是一個性能損耗;如果這個序列號列暴了,那就杯具了,你不知道哪個表使用了哪個序列,所以就必須換另一種唯一Id方式如GUID。

2)         使用MaxId表儲存各表的MaxId值

專門一個數據庫,記錄各個表的MaxId值,建一個儲存過程來取Id,邏輯大致為:開啟事物,對於在表中不存在記錄,直接返回一個預設值為1的鍵值,同時插入該條記錄到table_key表中。而對於已存在的記錄,key值直接在原來的key基礎上加1更新到MaxId表中並返回key。

使用此方案的問題是:每次的查詢MaxId是一個性能損耗;不過不會像自增序列表那麼容易列暴掉,因為是擺表進行劃分的。

                   我擷取此文中的sql語法如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 第一步:建立表 create table table_key ( table_name   varchar(50) not null primary key, key_value    int         not null ) 第二步:建立儲存過程來取自增ID create procedure up_get_table_key ( @table_name     varchar(50), @key_value      int output ) as begin begin tran declare @key  int --initialize the key with 1 set @key=1 --whether the specified table is exist if not exists(select table_name from table_key where [email protected]_name) begin insert into table_key values(@table_name,@key)        --default key vlaue:1 end -- step increase else    begin select @key=key_value from table_key with (nolock) where [email protected]_name set @key[email protected]key+1 --update the key value by table name update table_key set [email protected]key where [email protected]_name end --set ouput value set @[email protected]key --commit tran commit tran if @@error>0 rollback tran end

感謝園友的好建議:

  1. @輝_輝)建議給table_key中為每個表初始化一條key為1的記錄,這樣就不用每次if來判斷了。
  2. @樂活的CodeMonkey)建議給儲存過程中資料庫事物隔離級別提高一下,因為出現在CS程式碼層上使用如下事物程式碼會導致併發重複問題.
1 2 3 4 5 6 7 8 TransactionOptions option = new TransactionOptions(); option.IsolationLevel = IsolationLevel.ReadUncommitted; option.Timeout = new TimeSpan(0, 10, 0); using (TransactionScope transaction = new TransactionScope(TransactionScopeOption.RequiresNew, option)) { //呼叫儲存過程 }

在諮詢過DBA後,這個儲存過程提高資料庫隔離級別會加大資料庫訪問壓力,導致響應超時問題。所以這個建議我們只能在程式碼編寫宣導上做。

  1. @土豆烤肉)儲存過程中不使用事物,一旦使用到事物效能就急劇下滑。直接使用UPDATE獲取到的更新鎖,即SQL SERVER會保證UPDATE的順序執行。(已在使用者過千萬的併發系統中使用)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 create procedure [dbo].[up_get_table_key] ( @table_name     varchar(50), @key_value      int output ) as begin SET NOCOUNT ON; DECLARE @maxId INT UPDATE table_key SET @maxId = key_value,key_value = key_value + 1 WHERE [email protected]_name SELECT @maxId end

結論:適用中型應用,此方案解決了分表,關聯表插入記錄的問題。但是無法滿足高併發效能要求。同時也存在單點問題,如果這個資料庫cash掉的話……

我們目前正頭痛這個問題,因為我們的高併發常常出現數據庫訪問超時,瓶頸就在這個MaxId表。我們也有考慮使用分散式快取(eg:memcached)快取第一次訪問MaxId表資料,以提高再次訪問速度,並定時用快取資料更新一次MaxId表,但我們擔心的問題是:

a)         倘若快取失效或暴掉了,那快取的MaxId沒有更新到資料庫導致資料丟失,必須停掉站點來執行Select max(id)各個表來同步MaxId表。

b)         分散式快取不是一儲存下去,其他伺服器上就立馬可以獲取到的,即資料存在不確定性。(其實也是快取的一個誤用,快取應該用來存的是頻繁訪問並且很少改動的內容)

         改進方案:

整體思想:建立兩臺以上的資料庫ID生成伺服器,每個伺服器都有一張記錄各表當前ID的MaxId表,但是MaxId表中Id的增長步長是伺服器的數量,起始值依次錯開,這樣相當於把ID的生成雜湊到每個伺服器節點上。例如:如果我們設定兩臺資料庫ID生成伺服器,那麼就讓一臺的MaxId表的Id起始值為1(或當前最大Id+1),每次增長步長為2,另一臺的MaxId表的ID起始值為2(或當前最大Id+2),每次步長也為2。這樣就將產生ID的壓力均勻分散到兩臺伺服器上,同時配合應用程式控制,當一個伺服器失效後,系統能自動切換到另一個伺服器上獲取ID,從而解決的單點問題保證了系統的容錯。(Flickr思想)

但是要注意:1、多伺服器就必須面臨負載均衡的問題;2、倘若新增新節點,需要對原有資料重新根據步長計算遷移資料。

結論:適合大型應用,生成Id較短,友好性比較好。(強烈推薦)

3、  Sequence特性

這個特性在SQL Server 2012、Oracle中可用。這個特性是資料庫級別的,允許在多個表之間共享序列號。它可以解決分表在同一個資料庫的情況,但倘若分表放在不同資料庫,那將共享不到此序列號。(eg:Sequence使用場景:你需要在多個表之間公用一個流水號。以往的做法是額外建立一個表,然後儲存流水號)

相關Sequence特性資料:

結論:適用中型應用,此方案不能完全解決分表問題,而且無法滿足高併發效能要求。同時也存在單點問題,如果這個資料庫cash掉的話……

4、  通過資料庫叢集編號+叢集內的自增型別兩個欄位共同組成唯一主鍵

優點:實現簡單,維護也比較簡單。

缺點:關聯表操作相對比較複雜,需要兩個欄位。並且業務邏輯必須是一開始就設計為處理複合主鍵的邏輯,倘若是到了後期,由單主鍵轉為複合主鍵那改動成本就太大了。

結論:適合大型應用,但需要業務邏輯配合處理複合主鍵。

5、  通過設定每個叢集中自增 ID 起始點(auto_increment_offset),將各個叢集的ID進行絕對的分段來實現全域性唯一。當遇到某個叢集資料增長過快後,通過命令調整下一個 ID 起始位置跳過可能存在的衝突。

優點:實現簡單,且比較容易根據 ID 大小直接判斷出資料處在哪個叢集,對應用透明。缺點:維護相對較複雜,需要高度關注各個叢集 ID 增長狀況。

結論:適合大型應用,但需要高度關注各個叢集 ID 增長狀況。

GUID通常表示成32個16進位制數字(0-9,A-F)組成的字串,如:{21EC2020-3AEA-1069-A2DD-08002B30309D},它實質上是一個128位長的二進位制整數。

GUID制定的演算法中使用到使用者的網絡卡MAC地址,以保證在計算機叢集中生成唯一GUID;在相同計算機上隨機生成兩個相同GUID的可能性是非常小的,但並不為0。所以,用於生成GUID的演算法通常都加入了非隨機的引數(如時間),以保證這種重複的情況不會發生。

優點:GUID是最簡單的方案,跨平臺,跨語言,跨業務邏輯,全域性唯一的Id,資料間同步、遷移都能簡單實現。

缺點:

1)         儲存佔了32位,且無可讀性,返回GUID給客戶顯得很不專業;

2)         佔用了珍貴的聚集索引,一般我們不會根據GUID去查單據,並且插入時因為GUID是無需的,在聚集索引的排序規則下可能移動大量的記錄。

有兩位園友主推GUID,無須順序GUID方案原因如下:

@徐少俠           GUID無序在併發下效率高,並且一個數據頁內新增新行,是在B樹內增加,本質沒有什麼資料被移動,唯一可能的,是頁填充因子滿了,需要拆頁。而GUID方案導致的拆頁比順序ID要低太多了(資料庫不是很懂,暫時無法斷定,大家自己認識)

@無色                我們要明白id是什麼,是身份標識,標識身份是id最大的業務邏輯,不要引入什麼時間,什麼使用者業務邏輯,那是另外一個欄位乾的事,使用base64(guid,uuid),是通盤考慮,完全可以更好的相容nosql,key-value儲存。

(推薦),但是倘若你係統一開始沒有規劃一個業務Id,那麼將導致大量的改動,所以這個方案的最佳狀態是一開始就設計業務Id,當然業務Id的唯一性也是我們要考慮的。

結論:適合大型應用;生成的Id不夠友好;佔據了32位;索引效率較低。

改進:

1)         (@dudu提點)在SQL Server 2005中新增了NEWSEQUENTIALID函式。

在指定計算機上建立大於先前通過該函式生成的任何 GUID 的 GUID。 newsequentialid 產生的新的值是有規律的,則索引B+樹的變化是有規律的,就不會導致索引列插入時移動大量記錄的問題。

但一旦伺服器重新啟動,其再次生成的GUID可能反而變小(但仍然保持唯一)。這在很大程度上提高了索引的效能,但並不能保證所生成的GUID一直增大。SQL的這個函式產生的GUID很簡單就可以預測,因此不適合用於安全目的。

a)         只能做為資料庫列的DEFAULT VALUE,不能執行類似SELECT NEWSEQUENTIALID()的語句.

b)         如何獲得生成的GUID.

如果生成的GUID所在欄位做為外來鍵要被其他表使用,我們就需要得到這個生成的值。通常,PK是一個IDENTITY欄位,我們可以在INSERT之後執行 SELECT SCOPE_IDENTITY()來獲得新生成的ID,但是由於NEWSEQUENTIALID()不是一個INDETITY型別,這個辦法是做不到了,而他本身又只能在預設值中使用,不可以事先SELECT好再插入,那麼我們如何得到呢?有以下兩種方法:

1 2 3 4 5 6 7 8 9 10 11 12 --1. 定義臨時表變數 DECLARE @outputTable TABLE(ID uniqueidentifier) INSERT INTO TABLE1(col1, col2) OUTPUT INSERTED.ID INTO @outputTable VALUES('value1', 'value2') SELECT ID FROM @outputTable --2. 標記ID欄位為ROWGUID(一個表只能有一個ROWGUID) INSERT INTO TABLE1(col1, col2) VALUES('value1', 'value2') --在這裡,ROWGUIDCOL其實相當於一個別名 SELECT ROWGUIDCOL FROM TABLE1

結論:適合大型應用,解決了GUID無序特性導致索引列插入移動大量記錄的問題。但是在關聯表插入時需要返回資料庫中生成的GUID;生成的Id不夠友好;佔據了32位。

2)         “COMB”(combined guid/timestamp,意思是:組合GUID/時間截)

COMB資料型別的基本設計思路是這樣的:既然GUID資料因毫無規律可言造成索引效率低下,影響了系統的效能,那麼能不能通過組合的方式,保留GUID的10個位元組,用另6個位元組表示GUID生成的時間(DateTime),這樣我們將時間資訊與GUID組合起來,在保留GUID的唯一性的同時增加了有序性,以此來提高索引效率。

在NHibernate中,COMB型主鍵的生成程式碼如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 /// <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); }

結論:適合大型應用。即保留GUID的唯一性的同時增加了GUID有序性,提高了索引效率;解決了關聯表業務問題;生成的Id不夠友好;佔據了32位。(強烈推薦)

3)         長度問題,使用Base64或Ascii85編碼解決。(要注意的是上述有序性方案在進行編碼後也會變得無序)

如:

GUID:{3F2504E0-4F89-11D3-9A0C-0305E82C3301}

當需要使用更少的字元表示GUID時,可能會使用Base64或Ascii85編碼。Base64編碼的GUID有22-24個字元,如:

7QDBkvCA1+B9K/U0vrQx1A

7QDBkvCA1+B9K/U0vrQx1A==

Ascii85編碼後是20個字元,如:

5:$Hj:Pf\4RLB9%kU\Lj

                   程式碼如:

         Guid guid = Guid.NewGuid();

         byte[] buffer = guid.ToByteArray();

         var shortGuid = Convert.ToBase64String(buffer);

                   結論:適合大型應用,縮短GUID的長度。生成的Id不夠友好;索引效率較低。

7、  GUID TO Int64

對於GUID的可讀性,有園友給出如下方案:(感謝:@黑色的羽翼

1 2 3 4 5 6 7 8 /// <summary> /// 根據GUID獲取19位的唯一數字序列 /// </summary> public static long GuidToLongID() { byte[] buffer = Guid.NewGuid().ToByteArray(); return BitConverter.ToInt64(buffer, 0); }

即將GUID轉為了19位數字,數字反饋給客戶可以一定程度上緩解友好性問題。EG:

GUID: cfdab168-211d-41e6-8634-ef5ba6502a22    (不友好)

Int64: 5717212979449746068                                      (友好性還行)

不過我的小夥伴說ToInt64後就不唯一了。因此我專門寫了個併發測試程式,後文將給出測試結果截圖及程式碼簡單說明。

(唯一性、業務適合性是可以權衡的,這個唯一性肯定比不過GUID的,一般程式上都會安排錯誤處理機制,比如異常後執行一次重插的方案……)

結論:適合大型應用,生成相對友好的Id(純數字)------因簡單和業務友好性而推薦。

8、  自己寫編碼規則

優點:全域性唯一Id,符合業務後續長遠的發展(可能具體業務需要自己的編碼規則等等)。

缺陷:根據具體編碼規則實現而不同;還要考慮倘若主鍵在業務上允許改變的,會帶來外來鍵同步的麻煩。

我這邊寫兩個編碼規則方案:(可能不唯一,只是個人方案,也請大家提出自己的編碼規則)

1)         12位年月日時分秒+5位隨機碼+3位伺服器編碼  (這樣就完全單機完成生成全域性唯一編碼)---共20位

缺陷:因為附帶隨機碼,所以編碼缺少一定的順序感。(生成高唯一性隨機碼的方案稍後給給出程式)

2)         12位年月日時分秒+5位流水碼+3位伺服器編碼 (這樣流水碼就需要結合資料庫和快取)---共20位   (將影響順序權重大的“流水碼”放前面,影響順序權重小的伺服器編碼放後)

缺陷:因為使用到流水碼,流水碼的生成必然會遇到和MaxId、序列表、Sequence方案中類似的問題

(為什麼沒有毫秒?毫秒也不具備業務可讀性,我改用5位隨機碼、流水碼代替,推測1秒內應該不會下99999[五位]條語法)

結論:適合大型應用,從業務上來說,有一個規則的編碼能體現產品的專業成度。(強烈推薦)

GUID生成Int64值後是否還具有唯一性測試

測試環境

主要測試思路:

  1. 根據核心數使用多執行緒併發生成Guid後再轉為Int64位值,放入集合A、B、…N,多少個執行緒就有多少個集合。
  2. 再使用Dictionary字典高效查key的特性,將步驟1中生成的多個集合全部加到Dictionary中,看是否有重複值。

示例註解:測了 Dictionary<long,bool> 最大容量就在5999470左右,所以每次併發生成的唯一值總數控制在此範圍內,讓測試達到最有效話。

主要程式碼:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 for (int i = 0; i <= Environment.ProcessorCount - 1; i++) { ThreadPool.QueueUserWorkItem( (list) => { List<long> tempList = list as List<long>; for (int j = 1; j < listLength; j++) { byte[] buffer = Guid.NewGuid().ToByteArray(); tempList.Add(BitConverter.ToInt64(buffer, 0)); } barrier.SignalAndWait(); }, totalList[i]); }

測試資料截圖:                                                                           

資料一(迴圈1000次,測試數:1000*5999470)

資料二(迴圈5000次,測試數:5000*5999470)--跑了一個晚上……

的專業回答:(大家分析下,我數學比較差,稍後再說自己的理解)

GUID桶數量:(2 ^ 4) ^ 32 = 2 ^ 128

Int64桶數量: 2 ^ 64

倘若每個桶的機會是均等的,則每個桶的GUID數量為:

(2 ^ 128) / (2 ^ 64) = 2 ^ 64 = 18446744073709551616

也就是說,其實重複的機會是有的,只是概率問題。

樓主測試數是29997350000,發生重複的概率是:

1 - ((1 - (1 / (2 ^ 64))) ^ 29997350000) ≈ 1 - ((1 - 1 / (2 ^ 64)) ^ (2 ^ 32)) < 1 - 1 + 1 / (2 ^ 32) = 1 / (2 ^ 32) ≈ 2.3283064e-10

(唯一性、業務適合性是可以權衡的,這個唯一性肯定比不過GUID的,一般程式上都會安排錯誤處理機制,比如異常後執行一次重插的方案……)

(唯一性、業務適合性是可以權衡的,這個唯一性肯定比不過GUID的,一般程式上都會安排錯誤處理機制,比如異常後執行一次重插的方案……)

結論:GUID轉為Int64值後,也具有高唯一性,可以使用與專案中。

Random生成高唯一性隨機碼

我使用了五種Random生成方案,要Random生成唯一主要因素就是種子引數要唯一。(這是比較久以前寫的測試案例了,一直找不到合適的博文放,今天終於找到合適的地方了)

不過該測試是在單執行緒下的,多執行緒應使用不同的Random例項,所以對結果影響不會太大。

  1. 使用Environment.TickCount做為Random引數(即Random的預設引數),重複性最大。
  2. 使用DateTime.Now.Ticks做為Random引數,存在重複。
  3. 使用unchecked((int)DateTime.Now.Ticks)做為Random引數,存在重複。
  4. 使用Guid.NewGuid().GetHashCode()做為random引數,測試不存在重複(或存在性極小)。
  5. 使用RNGCryptoServiceProvider做為random引數,測試不存在重複(或存在性極小)。

即:

        static int GetRandomSeed()

        {

            byte[] bytes = new byte[4];

            System.Security.Cryptography.RNGCryptoServiceProvider rng

= new System.Security.Cryptography.RNGCryptoServiceProvider();

            rng.GetBytes(bytes);

            return BitConverter.ToInt32(bytes, 0);

        }

測試結果:

結論:隨機碼使用RNGCryptoServiceProvider或Guid.NewGuid().GetHashCode()生成的唯一性較高。

一些精彩評論(部分更新到原博文對應的地方)

一、

資料庫檔案體積只是一個參考值,可水平擴充套件系統性能(如nosql,快取系統)並不和檔案體積有高指數的線性相關。

如taobao/qq的系統比拼byte系統慢,關鍵在於索引的命中率,快取,系統的水平擴充套件。

如果資料庫很少,你搞這麼多byte能提高效能?

如果資料庫很大,你搞這麼多byte不相容索引不相容快取,不是害自已嗎?

如果資料庫要求伸縮性,你搞這麼多byte,需要不斷改程式,不是自找苦嗎?

如果資料庫要求移植性,你搞這麼多byte,移植起來不如重新設計,這是不是很多公司不斷加班的原因?

不依賴於資料儲存系統是分層設計思想的精華,實現戰略效能最大化,而不是追求戰術單機效能最大化。

不要迷信資料庫效能,不要迷信三正規化,不要使用外來鍵,不要使用byte,不要使用自增id,不要使用儲存過程,不要使用內部函式,不要使用非標準sql,儲存系統只做儲存系統的事。當出現系統性能時,如此設計的資料庫可以更好的實現遷移資料庫(如mysql->oracle),實現nosql改造((mongodb/hadoop),實現key-value快取(redis,memcache)。

二、

很多程式設計師有對效能認識有誤區,如使用儲存過程代替正常程式,其實使用儲存過程只是追求單伺服器的高效能,當需要伺服器水平擴充套件時,儲存過程中的業務邏輯就是你的噩運。

三、

除數字日期,能用字串儲存的欄位儘量使用字串儲存,不要為節省那不值錢的1個g的硬碟而使用類似位元組之類的欄位,進而大幅犧牲系統可伸縮性和可擴充套件性。

不要為了追求所謂的效能,引入byte,使用byte註定是短命和難於移植,想想為什麼html,email一直流行,因為它們使用的是字串表示法,只要有人類永遠都能解析,如email把二進位制轉成base64儲存。除了實時系統,視訊外,建議使用字串來儲存資料,系統性能的關鍵在於分散式,在於水平擴充套件。

本次博文到此結束,希望大家對本次主題“如何在高併發分散式系統中生成全域性唯一Id”多提出自己寶貴的意見。另外看著感覺舒服,還請多幫推薦…推薦……