有序 guid

【轉載】https://www.cnblogs.com/CameronWu/p/guids-as-fast-primary-keys-under-multiple-database.html

 

使用有序GUID:提升其在各數據庫中作爲主鍵時的性能

原文出處:https://www.codeproject.com/articles/388157/guids-as-fast-primary-keys-under-multiple-database ,避免今後忘記了再去閱讀原英文。【】是感覺理解有問題的地方

正確的使用有序GUID在大部分數據庫中可以獲得和 整型作爲主鍵 時相媲美的性能。

介紹

這篇文章概述了一種方法去規避 當使用GUID作爲主鍵/聚焦索引時一些常見的弊端,借鑑了 Jimmy Nilsson 的文章 GUID作爲主鍵的成本 。儘管這一基本實現已被集成在衆多庫和框架中(包括 NHibernate,譯者注:ABP框架中集成有 SequentialGuidGenerator),但大多數只針對 Microsoft SQL Server。這裏將嘗試適配其他一些常見的數據庫如 Oracle, PostgreSQL, 及 MySQL 解決一些.NET Framework中不尋常的詭譎問題。

背景說明

從以往來看,一種很常見的數據庫設計是使用連續的整型作爲標識,該字段通常由服務器本身在插入新數據行時自動生成。這種簡介的方法適用與很多應用程序。

然而在某些情況並不是很理想。隨着越來越多的使用Nhibernate和EntityFramework等對象關係映射(ORM)框架,依賴服務器自動生成主鍵的這種方式引發了許多 大多數人都希望避免的 併發問題。同樣,複製方案的時候這種依靠【單一認證機構源(single authoritative source)】生成的鍵值也會產生問題——因此,關鍵點是減少【單一權利機構的作用(the role of a single authority)】。

一種很誘人的選擇是使用GUID作爲唯一鍵值。GUID(全局唯一標識符),也稱爲UUID,長128-bit的值,能保證在所有時間和空間上獨一無二。RFC 41222 描述了創建標準GUID,如今大多數GUID生成算法通常是一個很長的隨機數,再結合一些像網絡mac地址這種隨機本地組件信息。

GUID允許開發人員自行創建而不需要服務器去檢查是否已被他人使用導致衝突,咋一看,這是一種很好的解決方案。

那麼問題是什麼呢?性能。爲了得到最好的新能,很多數據庫都使用聚焦索引,意味着表中的行的順序(通常基於主鍵)也是對應存儲在磁盤中的的順序。這使得其能通過索引快速查找,但它也導致在插入 【主鍵不是在列表的末尾的(their primary key doesn't fall at the end of the list)】 新行時變得很慢。例如下面的數據:

ID Name
1 Holmes, S.
4 Watson, J.
Moriarty, J.

此時很簡單:數據行對應ID列順序儲存。如果我們新添加一行ID爲8,也不會產生問題,新行會附加的末尾。

ID  Name 
Holmes, S. 
Watson, J. 
Moriarty, J. 
8  Lestrade, I. 

但如果我們想插入一行的ID爲5: 

ID  Name 
1   Holmes, S. 
Watson, J. 
5 Hudson, Mrs.
Moriarty, J. 
Lestrade, I. 

 ID7,8行必須移動。雖然在這裏不算什麼事兒,但當你的數據量達到數百萬行的級別之後,這就是個問題了。如果你還想要每秒處理上百次這種請求,那可真是難上加難了。

這就是GUID主鍵引發的問題:它可能是真正隨機生成的,至少表面看起來很像是隨機的,因爲它們通常不會產生任何特定的順序。正因爲如此,使用一個 GUID 值作爲任何規模的數據庫中的主鍵的一部分被認爲是非常糟糕的做法。 導致插入會很慢,還會涉及大量的不必要的磁盤活動。

有規則的GUID

 所以,GUID最關鍵的問題就是它缺乏規則。那麼,就讓我們來制定一個規則。COMB 方法(這裏聯合GUID/時間戳)使用一個保持增長(或至少不會減少)的值去替換GUID的某一組成部分。顧名思義,這是一個根據當前日期時間生成的值。

舉個栗子,考慮下面這組GUID值:

fda437b5-6edd-42dc-9bbd-c09d10460ad0
2cb56c59-ef3d-4d24-90e7-835ed5968cdc
6bce82f3-5bd2-4efc-8832-986227592f26
42af7078-4b9c-4664-ba01-0d492ba3bd83 

請注意這組值沒有任何特定順序且本質上是隨機生成的。插入100萬行以這種類型爲主鍵的值就會非常慢。

現在考慮這種假定的特殊GUID值:   

00000001-a411-491d-969a-77bf40f55175
00000002-d97d-4bb9-a493-cad277999363
00000003-916c-4986-a363-0a9b9c95ca52
00000004-f827-452b-a3be-b77a3a4c95aa 

其中的第一部分已用遞增的序列替換——設想從項目啓動開始按毫秒計算。插入100萬條數據不是很糟糕,新行會依次追加到末尾不會對現有數據造成影響。

現在我們有了基本的概念,還需要進一步瞭解 在各種不同的數據庫系統中 GUID是如何構造的。

128-bit 的GUID主要有4部分組成,Data1, Data2, Data3, and Data4,你可以看成下面這樣:

11111111-2222-3333-4444-444444444444

Data1 佔4個字節, Data2 兩個字節, Data3 兩個字節加 Data4 8個字節(其中Data3和Data4的第一部分或多或少保留有一些版本信息).  

現如今使用的有很多GUID算法,特別是在.NET 平臺, 像是被當作了一個花哨的隨機數生成器 (微軟在過去曾將本機mac地址加入到生成GUID的運算當中, 但由於涉及到隱私問題這種方法在幾年前就停止使用了).  這對我們來說是件好事, 意味着,在它(GUID)值的某部分動點手腳不太可能會影響到它的唯一性.  

但不幸的是,不同的數據庫處理GUID的方式也是不同的。有些(Microsoft SQL Server, PostgreSQL) 具有內置GUID類型可以儲存和直接操作GUID。沒有原生GUID支持的數據庫通過模擬而有不同的約定。例如MySQL, 通常將GUID儲存爲char(36)的字符串表現形式.  Oracle保存爲raw bytes類型,一個GUID值爲raw(16). 

讀取的時候的處理則更加複雜, 因爲Microsoft SQL Server一個比較另類之處是它按照 GUID末尾最不重要的那6個字節來排序(即. Data4中最後6個字節)。所以,如果想在SQL Server中創建有序的GUID,我們不得不將有序部分放在最後面。大部分其他數據庫會把它放在開頭. 

算法  

從不同數據庫GUID的處理方式來看,顯然沒有一個通用的有序GUID生成算法,我們針對不同應用程序分別對待。經過一番實驗之後, 我確定了大部分爲下面3中情況:

  • 生成的GUID 按照字符串順序排列
  • 生成的GUID 按照二進制的順序排列
  • 生成的GUID 像SQL Server, 按照末尾部分排列

(爲什麼字符串順序和二進制順序有區別?因爲在 little-endian system 環境中.NET處理GUID和string的方式並不是你想象的和其他運行.net的環境中的那樣。)    

 ‘我’根據這些區別定義瞭如下的枚舉:

public enum SequentialGuidType
{
  SequentialAsString,
  SequentialAsBinary,
  SequentialAtEnd
}

現在我們可以定義一個接受參數爲上面枚舉類型的方法去生成我們的GUID:

public Guid NewSequentialGuid(SequentialGuidType guidType)
{
  ... 
}

但是具體要怎麼來創建我們想要的有序GUID呢?就是(GUID)的哪一部分我們要保留其“隨機性”、哪一部分要替換成時間戳?按照最初的 COMB 規範,針對 SQL Server,用時間戳的值替換Data4的最後6個字節。這是最簡單的因爲 SQL Server 就是按照GUID那6個字節的值進行排序的,通常情況這6個字節就已經足夠了(這裏應該是值唯一性),再加上其他10字節的隨機值。

最重要的還是GUID的隨機性。就像我剛所說的,我們還需要構建10字節的隨機值:

var rng = new System.Security.Cryptography.RNGCryptoServiceProvider();
byte[] randomBytes = new byte[10];
rng.GetBytes(randomBytes);  

 我們使用了 RNGCryptoServiceProvider 來生成其中的隨機部分因爲 System.Random 在這裏有一些不足之處(它根據一些可識別的模式生成數字,例如它會在超過232 次迭代後循環而出現重複)。我們依賴其隨機性就需要 RNGCryptoServiceProvider 來產生保密性強的隨機數據。 

(然而,這樣還是相對較慢,如果你想追求極致性能的話可以考慮另一種做法——例如直接使用 Guid.NewGuid() 來產生 byte[] 數據。‘我’通常不這樣做 因爲 Guid.NewGuid()本身並不能保證其隨機性,還是使用據我所知比較可靠做法。)

Okay,隨機值部分我們已經有了,剩下的就是用時間戳去替換排序的部分。我們使用6字節的時間戳,它根據什麼得來呢?很明顯可以使用 DateTime.Now (或者如 Rich Andersen 指出的使用 DateTime.UtcNow 有更好的性能)轉換爲6字節的整型。它的Ticks屬性很誘人:能得到一個從January 1, 0001 A.D.至今的100毫微秒間隔數。但它仍然有一些問題。

首先,因爲其Ticks屬性返回一個64-bit的值但我們只需要48bit,我們必須丟掉2字節,剩下的48bit有用的100毫微秒的時間間隔週期不到一年就會溢出。很多應用程序的使用壽命會超過一年 這樣會毀了我們的最初願望,我們需要使用時間不太精確的來測量。

另一個難題是 DateTime.UtcNow 的精確度太低。如這篇文章所描述的,它的值可能更新間隔爲10毫秒。(可能在某些系統中會稍微頻繁點,但我們依賴這個。)

好消息是,將這兩個結合起來問題可以相互抵消:由於長度限制不能使用整個Ticks值,所以我們拿來除以1000然後將末尾48bits作爲時間戳。‘我’使用毫秒是因爲,即使 DateTime.UtcNow 在某些系統中的準確度只有10毫秒左右,將來會提高,期待。減少精確度之後我的時間戳溢出重複大約會在公元5800年之後,相信對大多數應用程序來說這已經足夠了。

在我們繼續之前提醒一下:使用精確度爲1毫秒的時間戳意味着在GUID生成非常快的時候時間戳是有可能重複的,這時候就不會有順序。這在某些程序中可能很常見,事實上‘我’嘗試了一些替代方法,像使用高準確度的 System.Diagnostics.Stopwatch ,或者將時間戳和一個‘計數器’結合來試圖保證在時間戳未更新是也能有順序。然而經測試發現這樣做並沒有明顯差異,即使一次性生成10個甚至100個guid也可能會呈現出同樣的時間戳。這也符合 Jimmy Nilsson 在他的COMB中的測試結果。考慮到這一點,就不再在這裏糾結了.

代碼如下:

long timestamp = DateTime.UtcNow.Ticks / 10000L;
byte[] timestampBytes = BitConverter.GetBytes(timestamp);     

現在時間戳我們有了,然而我們是從一個數字中通過 BitConverter 獲得字節的,還需要考慮字節順序:

if (BitConverter.IsLittleEndian)
{
  Array.Reverse(timestampBytes); 
}

我們有了生成GUID需要的隨機字節部分和時間戳字節部分。剩下的就是拼接它們了。在支持的數據庫類型 SequentialGuidType 數量這一點上,我們得量力而爲。 SequentialAsBinary 和 SequentialAsString的時候時間戳排在前面, SequentialAtEnd 相反。

複製代碼

byte[] guidBytes = new byte[16];

switch (guidType)
{
  case SequentialGuidType.SequentialAsString:
  case SequentialGuidType.SequentialAsBinary:
    Buffer.BlockCopy(timestampBytes, 2, guidBytes, 0, 6);
    Buffer.BlockCopy(randomBytes, 0, guidBytes, 6, 10);
    break; 

  case SequentialGuidType.SequentialAtEnd:
    Buffer.BlockCopy(randomBytes, 0, guidBytes, 0, 10);
    Buffer.BlockCopy(timestampBytes, 2, guidBytes, 10, 6);
    break;
}    

複製代碼

到目前爲止一切順利;但現在我們走進了.net framework的一個怪癖區域,它不僅是將GUID當成一個byte序列組,出於某種原因,它還認爲GUID是一個包含一個Int32、兩個Int16及8byte的struct。換句話說,它認爲Data1是Int32、Data2和Data3是Int16、Data4是Byte[8]。

這對我們來說意味着什麼?..主要問題有字節得重新排序,由於.net認爲它處理的是數字,我們得補償little-endian system ——但是!——時間戳在前面的時候,只爲應用程序將GUID值轉換爲一個字符串(時間戳在末尾的不重要[因爲Data4不會被當作數字]不需要作任何處理)

這就是上面提到的將GUID作爲字符串和二進制有區別的原因。[將GUID]作爲字符串存儲的數據庫,ORM框架或應用程序會使用  ToString() 來生成 SQL INSERT 語句,意思是我們需要改進字節存儲順序問題。[將GUID]當作二進制數據存儲的數據庫,可能會使用 Guid.ToByteArray() 插入,我們不需要修正。因此,我們最後再加上:

if (guidType == SequentialGuidType.SequentialAsString && 
    BitConverter.IsLittleEndian)
{
  Array.Reverse(guidBytes, 0, 4);
  Array.Reverse(guidBytes, 4, 2);
}

現在使用參數爲byte[]的構造函數返回GUID:

return new Guid(guidBytes);

使用

要使用我們的方法,首先得確定我們使用的是那種數據庫及ORM框架,爲了方便,下面是一些常見的數據庫類型,可能與你應用程序的實際情況有出入:

Database  GUID Column  SequentialGuidType Value 
Microsoft SQL Server  uniqueidentifier  SequentialAtEnd
MySQL  char(36)  SequentialAsString 
Oracle  raw(16)  SequentialAsBinary 
PostgreSQL  uuid  SequentialAsString 
SQLite   varies   varies 

(SQLite沒有GUID類型字段,但有類似功能的擴展,然而根據 BinaryGUID 傳參的不同,內部可能存儲爲16字節長度的二進制數據或長度36的text,所以這裏沒有一個統一方案)。

這裏有一些該方法生成的樣本數據。

第一個 NewSequentialGuid(SequentialGuidType.SequentialAsString) :

39babcb4-e446-4ed5-4012-2e27653a9d13
39babcb4-e447-ae68-4a32-19eb8d91765d
39babcb4-e44a-6c41-0fb4-21edd4697f43
39babcb4-e44d-51d2-c4b0-7d8489691c70

如上,其中前6個字節(前2部分)是排序部分,剩下的是隨機值。將這樣的值插入將GUID當作字符串的數據庫(如MySQL)時相對與無序GUID性能會有顯著提升。

接下來是 NewSequentialGuid(SequentialGuidType.SequentialAtEnd) :

a47ec5e3-8d62-4cc1-e132-39babcb4e47a
939aa853-5dc9-4542-0064-39babcb4e47c
7c06fdf6-dca2-4a1a-c3d7-39babcb4e47d
c21a4d6f-407e-48cf-656c-39babcb4e480

排序的末尾的6個字節,其餘是隨機部分。‘我’不明白爲什麼SQL Server的 uniqueidentifier 是這個樣子,但這樣做同樣也沒什麼問題。

最後是 NewSequentialGuid(SequentialGuidType.SequentialAsBinary) :

b4bcba39-58eb-47ce-8890-71e7867d67a5
b4bcba39-5aeb-42a0-0b11-db83dd3c635b
b4bcba39-6aeb-4129-a9a5-a500aac0c5cd
b4bcba39-6ceb-494d-a978-c29cef95d37f

當我們 ToString() 之後再看是發現某些東西出錯了:前面的兩部分‘調換’了(因爲前面提到字符串二進制問題),如果插入的是字符串字段(MySQL),性能不會有任何提升。

問題產生的原因是使用了 ToString()方法。上面4組值如果是使用 Guid.ToByteArray() 轉換爲16字符串的話:

39babcb4eb5847ce889071e7867d67a5
39babcb4eb5a42a00b11db83dd3c635b
39babcb4eb6a4129a9a5a500aac0c5cd
39babcb4eb6c494da978c29cef95d37f

這是大多數ORM框架針對Oracle數據庫所作的處理,你會發現,這樣做之後,排序功能又有作用了。

回顧一下,我們現在有一個方法可以針對3中不同的數據庫(將GUID儲存爲字符串MySQL, 可能有SQLite;作爲二進制數據的Oracle, PostgreSQL;以及有另類儲存方案的Microsoft SQL Server)生成有序的GUID值。

後面我們還可以擴展我們的方法,自動檢測數據庫類型,或者通過 DbConnection 判斷,但可能會取決於我們應用程序所使用的ORM框架,留給你們自由發揮。

對比數據【如今各數據庫版本變遷,實際數據效果可能相差很大,個人對這些性能測試不感興趣】

選了4個常用的數據庫(Microsoft SQL Server 2008, MySQL 5.5, Oracle XE 11.2, and PostgreSQL 9.1)在windows7桌面系統中進行測試。

測試是通過每個數據的命令插入 GUID主鍵和100字符長度的text【譯者注:text類型已經被nvarchar(max)代替】的 2百萬行數據,首先是測試我們方法的3中不同算法,接着是 Guid.NewGuid() 最後比較int類型,圖中還對比了前100w行及後面100w行分別使用的時長(毫秒):

在SQL Server中,SequentialAtEnd方式效果最好,性能很接近int,對比NewGuid()無序生成性能提升了接近75%;後面100w行稍微慢點【which is consistent with a lot of page-shuffling to maintain the clustered index as more rows get added in the middle】,其他兩種方式與無序方式相差不大,說明SequentialAtEnd的方式工作良好。

如圖,無序GUID在MySql中的性能非常低,慢到我不得不砍掉圖中後100w行的具體性能顯示(後100w行插入比前面慢了一半);使用SequentialAsString的算法是性能接近與int,說明達到了排序的目的。

 

Oracle中就不那麼明顯了,使用raw(16)來儲存GUID。我們使用 SequentialAsBinary 時最快,但即使使用無序GUID也並沒有慢很多;此外有序GUID的插入還比int稍快,至少這的測試結果是這樣的,我不得不懷疑Oracle是否也有某種怪癖。,,

最後是 PostgreSQL,和Oracle一樣也沒有明顯的提升,只是最快的是使用SequentialAsString,只比int慢了7.8左右,但相比無序GUID節省了近一半的時間。

其他

這裏有幾點需要考慮。這裏重點關注了插入有序guid的性能;但是對比 Guid.NewGuid() ,創建GUID的性能消耗該怎麼算呢?好吧,它確實很慢:在‘我’的系統,生成100w無序GUID需要140毫秒,但生成有序GUID需要2800毫秒——慢了20倍。

一些快速測試表明慢的主要原因是使用了 RNGCryptoServiceProvider 來生成隨機數據;使用 System.Random 能降到400毫秒左右。但‘我’還是不推薦這樣做 System.Random 在這裏仍然有問題。當然可能會有其他比這更好的算法——誠然‘我’不是很懂隨機數生成。

這點很值得注意嗎?個人覺得在可接受範圍。除非你的應用涉及非常頻繁的插入(這種情況GUID主鍵不是很理想),相比有序GUID帶來的好處,這點問題微不足道。

另一個問題是:6字節的時間戳佔用意味着僅有10個字節的隨機數據,這可能會危機唯一性。包括時間戳,是保證創建 間隔相隔幾毫秒 的兩個GUID唯一性——這是一個約定,即使是完全隨機的GUID(如Guid.NewGuid()創建的),如果兩個GUID創建時間太過接近會怎麼樣呢?10-byte的強加密型隨機數意味着有280,1,208,925,819,614,629,174,706,176 種可能的組合。這樣一來,生成重複GUID的概率微不足道。

最後一點,這種方式生成的GUID的格式並不滿足 RFC 4122 規範——它們缺少版本號(通常在bit48-51位),‘我’不認爲這很必要;‘我’不知道是否有數據庫去真正在意這一結構,省略它多出了4位作爲隨機部分。當然要加上這個也很容易。

完整代碼

這是這個方法的完整實現代碼。有一些小的修改(如抽象靜態隨機生成器實例以及重構switch塊):

複製代碼

using System;
using System.Security.Cryptography;

public enum SequentialGuidType
{
  SequentialAsString,
  SequentialAsBinary,
  SequentialAtEnd
} 

public static class SequentialGuidGenerator
{
  private static readonly RNGCryptoServiceProvider _rng = new RNGCryptoServiceProvider();

  public static Guid NewSequentialGuid(SequentialGuidType guidType)
  {
    byte[] randomBytes = new byte[10];
    _rng.GetBytes(randomBytes);

    long timestamp = DateTime.UtcNow.Ticks / 10000L;
    byte[] timestampBytes = BitConverter.GetBytes(timestamp);

    if (BitConverter.IsLittleEndian)
    {
      Array.Reverse(timestampBytes);
    }

    byte[] guidBytes = new byte[16];

    switch (guidType)
    {
      case SequentialGuidType.SequentialAsString:
      case SequentialGuidType.SequentialAsBinary:
        Buffer.BlockCopy(timestampBytes, 2, guidBytes, 0, 6);
        Buffer.BlockCopy(randomBytes, 0, guidBytes, 6, 10);

        // If formatting as a string, we have to reverse the order
        // of the Data1 and Data2 blocks on little-endian systems.
        if (guidType == SequentialGuidType.SequentialAsString && BitConverter.IsLittleEndian)
        {
          Array.Reverse(guidBytes, 0, 4);
          Array.Reverse(guidBytes, 4, 2);
        }
        break;

      case SequentialGuidType.SequentialAtEnd:
        Buffer.BlockCopy(randomBytes, 0, guidBytes, 0, 10);
        Buffer.BlockCopy(timestampBytes, 2, guidBytes, 10, 6);
        break;
    }

    return new Guid(guidBytes);
  }
} 

複製代碼

最終代碼和演示項目 https://github.com/jhtodd/SequentialGuid[^]

結論

如開頭所說的,這種通用實現已經集成到各種重量級框架中;這裏已經不是最新的,‘我’的目的是說明實現的方法及原理,根據不同的數據庫環境加以調整以適應具體需求。

一點點的不斷努力嘗試,就有可能實現適用於任何數據庫的有序GUID生成方法。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章