本文主要討論在資料庫中使用GUID類型作為主鍵時常見的弊端,以及解決辦法,希望您能有所收穫。 ...
背景
常見的一種資料庫設計是使用連續的整數為做主鍵,當新的數據插入到資料庫時,由資料庫自動生成。但這種設計不一定適合所有場景。
隨著越來越多的使用Nhibernate、EntityFramework等ORM(對象關係映射)框架,應用程式被設計成為工作單元(Unit Of Work)模式,需要在數據持久化之前生成主鍵,為了保證在多線程併發以及站點集群環境中主鍵的唯一性,最簡單最常見的方式是將主鍵設計成為GUID類型。
工作單元是資料庫應用程式經常使用的一種設計模式,簡單一點來說,就是對多個資料庫操作進行打包,記錄對象上的所有變化,併在最後提交時一次性將所有變化通過系統事務寫入資料庫。目的是為了減少資料庫調用次數以及避免資料庫長事務。關於工作單元的知識可以在園子裡面搜索到很多,在這裡就不做詳細的介紹了。
GUID(全球唯一標識符)也稱為UUID,是一種由演算法生成的二進位長度為128位的數字標識符。在理想情況下,任何電腦和電腦集群都不會生成兩個相同的GUID。GUID 的總數達到了2^128(3.4×10^38)個,所以隨機生成兩個相同GUID的可能性非常小,但並不為0。GUID一詞有時也專指微軟對UUID標準的實現。
RFC 41222描述了創建標準GUID,如今大多數GUID生成演算法通常是一個很長的隨機數,再結合一些像網路MAC地址這種隨機的本地組件信息。
GUID的優點允許開發人員隨時創建新值,而無需從資料庫伺服器檢查值的唯一性,這似乎是一個完美的解決方案。
很多資料庫在創建主鍵時,為了充分發揮資料庫的性能,會自動在該列上創建聚集索引。我們先來說一說什麼是聚集索引,。集索引確定表中數據的物理順序。聚集索引類似於電話簿,按姓氏排列數據。由於聚集索引規定數據在表中的物理存儲順序,因此一個表也只能包含一個聚集索引。它能夠快速查找到數據,但是如果插入資料庫的主鍵不在列表的末尾,向表中添加新行時就非常緩慢。例如,看下麵這個例子,在表中已經存在三行數據(例子來自Jeremy Todd的博客GUIDs as fast primary keys under multiple databases:
ID | Name |
---|---|
1 | Holmes, S. |
4 | Watson, J. |
7 | Moriarty, J. |
此時非常簡單:數據行按對應ID列的順序儲存。如果我們新添加一行ID為8的數據,不會產生任何問題,新行會追加的末尾。
ID | Name |
---|---|
1 | Holmes, S. |
4 | Watson, J. |
7 | Moriarty, J. |
8 | Lestrade, I. |
但如果我們想插入一行的ID為5的數據:
ID | Name |
---|---|
1 | Holmes, S. |
4 | Watson, J. |
5 | Hudson, Mrs. |
7 | Moriarty, J. |
8 | Lestrade, I. |
ID為7,8的數據行必須向下移動。雖然在這算什麼事兒,但當您的數據量達到數百萬行的級別之後,這就是個問題了。如果你還想要每秒處理上百次這種請求,那可真是難上加難了。
這就是GUID主鍵引發的問題:它是隨機產生的,所以在數據插入時,隨時都會涉及到數據的移動,導致插入會很緩慢,還會涉及大量不必要的磁碟活動。總結果有兩點:
- 空間的浪費以及由此帶來的讀寫效率的下降;
- 更主要的,存儲的碎片化以及由此帶來的讀寫效率嚴重下降。
- 備註:摘自Crazy.Liu博客SQL SERVER下有序GUID和無序GUID作為主鍵&聚集索引的性能表現
GUID最關鍵的問題就是它是隨機的。我們需要設計一種有規則的GUID生成方式,在之後生成的GUID類型總是比之前的要大,保證插入資料庫的主鍵是在列表末尾追加的,這種我們稱之為有序GUID。
GUID排序規則
在講解有序GUID之前,我們必須先瞭解一下GUID在.Net中以及各個資料庫中的排序規則,排序規則不一樣,生成有序GUID的規則也會隨之變化。
128位的GUID主要有4部分組成:Data1, Data2, Data3, and Data4,你可以看成下麵這樣:
11111111-2222-3333-4444-444444444444
Data1 占4個位元組, Data2 2個位元組, Data3 2個位元組加 Data4 8個位元組。我們分別的對個位元組編上序號:
序號 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Value | 11 | 11 | 11 | 11 | - | 22 | 22 | - | 33 | 33 | - | 44 | 44 | - | 44 | 44 | 44 | 44 | 44 | 44 |
GUID在.Net中的排序規則
在.Net中,GUID預設的排序過段規則是按左到右的,看下麵這個示例。
var list = new List<Guid> {
new Guid("00000000-0000-0000-0000-010000000000"),
new Guid("00000000-0000-0000-0000-000100000000"),
new Guid("00000000-0000-0000-0000-000001000000"),
new Guid("00000000-0000-0000-0000-000000010000"),
new Guid("00000000-0000-0000-0000-000000000100"),
new Guid("00000000-0000-0000-0000-000000000001"),
new Guid("00000000-0000-0000-0100-000000000000"),
new Guid("00000000-0000-0000-0010-000000000000"),
new Guid("00000000-0000-0001-0000-000000000000"),
new Guid("00000000-0000-0100-0000-000000000000"),
new Guid("00000000-0001-0000-0000-000000000000"),
new Guid("00000000-0100-0000-0000-000000000000"),
new Guid("00000001-0000-0000-0000-000000000000"),
new Guid("00000100-0000-0000-0000-000000000000"),
new Guid("00010000-0000-0000-0000-000000000000"),
new Guid("01000000-0000-0000-0000-000000000000")
};
list.Sort();
foreach (Guid guid in list) {
Console.WriteLine(guid.ToString());
}
輸出結果:
00000000-0000-0000-0000-000000000001
00000000-0000-0000-0000-000000000100
00000000-0000-0000-0000-000000010000
00000000-0000-0000-0000-000001000000
00000000-0000-0000-0000-000100000000
00000000-0000-0000-0000-010000000000
00000000-0000-0000-0010-000000000000
00000000-0000-0000-0100-000000000000
00000000-0000-0001-0000-000000000000
00000000-0000-0100-0000-000000000000
00000000-0001-0000-0000-000000000000
00000000-0100-0000-0000-000000000000
00000001-0000-0000-0000-000000000000
00000100-0000-0000-0000-000000000000
00010000-0000-0000-0000-000000000000
01000000-0000-0000-0000-000000000000
通過上面的輸出結果,我們可以得到排序的權重如下:
序號 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
權重 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ||||
Value | 11 | 11 | 11 | 11 | - | 22 | 22 | - | 33 | 33 | - | 44 | 44 | - | 44 | 44 | 44 | 44 | 44 | 44 |
這與數字排序規則一致,從右到左進行依次進行排序(數字越小,權重越高,排序的優先順序越高)。
GUID在各個資料庫中的排序規則
在SQL Server資料庫中,我們有一種非常簡單的方式來比較兩個GUID類型的大小值(其實在SQL Server資料庫中稱為UniqueIdentifier
類型):
With UIDs As (-- 0 1 2 3 4 5 6 7 8 9 A B C D E F
Select ID = 1, UID = cast ('00000000-0000-0000-0000-010000000000' as uniqueidentifier)
Union Select ID = 2, UID = cast ('00000000-0000-0000-0000-000100000000' as uniqueidentifier)
Union Select ID = 3, UID = cast ('00000000-0000-0000-0000-000001000000' as uniqueidentifier)
Union Select ID = 4, UID = cast ('00000000-0000-0000-0000-000000010000' as uniqueidentifier)
Union Select ID = 5, UID = cast ('00000000-0000-0000-0000-000000000100' as uniqueidentifier)
Union Select ID = 6, UID = cast ('00000000-0000-0000-0000-000000000001' as uniqueidentifier)
Union Select ID = 7, UID = cast ('00000000-0000-0000-0100-000000000000' as uniqueidentifier)
Union Select ID = 8, UID = cast ('00000000-0000-0000-0010-000000000000' as uniqueidentifier)
Union Select ID = 9, UID = cast ('00000000-0000-0001-0000-000000000000' as uniqueidentifier)
Union Select ID = 10, UID = cast ('00000000-0000-0100-0000-000000000000' as uniqueidentifier)
Union Select ID = 11, UID = cast ('00000000-0001-0000-0000-000000000000' as uniqueidentifier)
Union Select ID = 12, UID = cast ('00000000-0100-0000-0000-000000000000' as uniqueidentifier)
Union Select ID = 13, UID = cast ('00000001-0000-0000-0000-000000000000' as uniqueidentifier)
Union Select ID = 14, UID = cast ('00000100-0000-0000-0000-000000000000' as uniqueidentifier)
Union Select ID = 15, UID = cast ('00010000-0000-0000-0000-000000000000' as uniqueidentifier)
Union Select ID = 16, UID = cast ('01000000-0000-0000-0000-000000000000' as uniqueidentifier)
)
Select * From UIDs Order By UID, ID
例子來自Ferrari的博客How are GUIDs sorted by SQL Server?
查詢結果:
ID | UID |
---|---|
16 | 01000000-0000-0000-0000-000000000000 |
15 | 00010000-0000-0000-0000-000000000000 |
14 | 00000100-0000-0000-0000-000000000000 |
13 | 00000001-0000-0000-0000-000000000000 |
12 | 00000000-0100-0000-0000-000000000000 |
11 | 00000000-0001-0000-0000-000000000000 |
10 | 00000000-0000-0100-0000-000000000000 |
9 | 00000000-0000-0001-0000-000000000000 |
8 | 00000000-0000-0000-0010-000000000000 |
7 | 00000000-0000-0000-0100-000000000000 |
6 | 00000000-0000-0000-0000-000000000001 |
5 | 00000000-0000-0000-0000-000000000100 |
4 | 00000000-0000-0000-0000-000000010000 |
3 | 00000000-0000-0000-0000-000001000000 |
2 | 00000000-0000-0000-0000-000100000000 |
1 | 00000000-0000-0000-0000-010000000000 |
通過上面可以得於是如下結果:
- 先按每1-8從左到右進行排序;
- 接著按第9-10位從右到左進行排序;
- 最後按後11-16位從右到左進行排序;
通過分析,我們可得到如下權重列表:
序號 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
權重 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 7 | 8 | 1 | 2 | 3 | 4 | 5 | 6 | ||||
Value | 11 | 11 | 11 | 11 | - | 22 | 22 | - | 33 | 33 | - | 44 | 44 | - | 44 | 44 | 44 | 44 | 44 | 44 |
在Microsoft官方文檔中,有一篇文檔關於GUID與uniqueidentifier
的值比較:
Comparing GUID and uniqueidentifier Values。
不同的資料庫處理GUID的方式也是不同的。在SQL Server存在內置GUID類型,沒有原生GUID支持的資料庫通過模擬來方式來實現的。在Oracle保存為raw bytes類型,具體類型為raw(16);在MySql中通常將GUID儲存為char(36)的字元串形式。
關於Oracle、MySql資料庫的排序規則與.Net中排序規則,不過篇章的限制,這裡不再做具體的演示,不過我在github上提供了示例SQL語句:https://gist.github.com/tangdf/f0aed064ba10bfa0050e4344b9236889,您可以自己進行測試。我們在這裡只給出最終的結論:
小結:
- .Net中GUID的排序規則是從左到右依次進行排序,與數字排序規則一致;
- Sql Server資料庫提供對GUID類型的支持,在資料庫中稱為
UniqueIdentifier
類型,但是排序規則比較複雜:
- 先按每1-8從左到右進行排序;
- 接著按第9-10位從右到左進行排序;
- 最後按後11-16位從右到左進行排序;
- Oracle資料庫未提供對GUID類型的支持,使用的是raw bytes類型保存數據raw(16),具體類型為,排序規則與GUID在.Net中規則一致;
- MySql數據未提供對GUID類型的支持,使用的是字元串的類型保存數據,使用是的char(36)類型,由於使用的是字元串類型,排序規則與GUID在.Net中的規則一致。
有序GUID
有序GUID是有規則的生成GUID,保存在之後生成的GUID類型總是比之前的要大。不過在上一節中,已經提到過各個資料庫對GUID支持不一樣,而且排序的規則也不一樣,所以我們需要為每一個資料庫提供不一致的有序GUID生成規則。
UuidCreateSequential函數
我們都知道SQL Server資料庫有一個NewSequentialId()
函數,用於創建有序GUID。在創建表時,可以將它設置成為GUID類型欄位的預設值,在插入新增數據時自動創建主鍵的值(該函數只能做為欄位的預設值,不能直接在SQL中調用)。
示例:
Create Table TestTable
(
ID UniqueIdentifier Not Null Default ( NewSequentialId() ) ,
Number Int
);
NewSequentialId()
函數只能在資料庫使用,不過在 Microsoft 的 MSDN 文檔中有說明,NEWSEQUENTIALID 是對 Windows UuidCreateSequential 函數的包裝,https://msdn.microsoft.com/zh-cn/library/ms189786(v=sql.120).aspx。這樣我們可以在C#通過非托管方法調用:
[System.Runtime.InteropServices.DllImport("rpcrt4.dll", SetLastError = true)]
private static extern int UuidCreateSequential(out Guid guid);
public static Guid NewSequentialGuid()
{
const int RPC_S_OK = 0;
int result = UuidCreateSequential(out var guid);
if (result != RPC_S_OK) {
throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error());
}
return guid;
}
不這個方法也存在三個問題:
這個方法涉及到安全問題,
UuidCreateSequential
函數依賴的計算硬體,該方法的後12位其實是網卡的MAC地址。這是我電腦生成的一組有序GUID。{A2A93393-C8DC-11E7-B133-2C56DC497A97}
{A2A93394-C8DC-11E7-B133-2C56DC497A97}
{A2A93395-C8DC-11E7-B133-2C56DC497A97}
{A2A93396-C8DC-11E7-B133-2C56DC497A97}
{A2A93397-C8DC-11E7-B133-2C56DC497A97}
{A2A93398-C8DC-11E7-B133-2C56DC497A97}
{A2A93399-C8DC-11E7-B133-2C56DC497A97}
{A2A9339A-C8DC-11E7-B133-2C56DC497A97}
{A2A9339B-C8DC-11E7-B133-2C56DC497A97}
{A2A9339C-C8DC-11E7-B133-2C56DC497A97}這是我電腦的網卡的MAC地址:
由於
UuidCreateSequential
函數生成的有序GUID中包括MAC地址,所以如果在伺服器集群環境中,肯定存在一臺伺服器A上生成的有序GUID總比另一臺伺服器B生成要更小,伺服器A產生的數據插入到資料庫時,由於聚集索引的問題,總是會移動伺服器B已經持久化到資料庫中的數據。集群的伺服器越多,產生的IO問題更嚴重。在伺服器群集環境中,需要自行實現有序GUID。UuidCreateSequential
函數生成的GUID規則與SQL Server中排序的規則存在不一致,這樣仍然會導致嚴重的IO問題,所以需要將GUID重新排序後再持久化到資料庫。例如上面列出生成的GUID列表,依次生成的數據可以看出,是第4位位元組在自增長,在這與任何一個資料庫的排序規則都不一致;關於該函數生成的規則,可以見此鏈接:https://stackoverflow.com/questions/5585307/sequential-guids。
下麵的方法是將生成的GUID調整成為適合Sql Server使用的有序GUID(針對其它資料庫支持,您可以按排序規則自行修改):
[System.Runtime.InteropServices.DllImport("rpcrt4.dll", SetLastError = true)]
static extern int UuidCreateSequential(byte[] buffer);
static Guid NewSequentialGuid() {
byte[] raw = new byte[16];
if (UuidCreateSequential(raw) != 0)
throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error());
byte[] fix = new byte[16];
// reverse 0..3
fix[0x0] = raw[0x3];
fix[0x1] = raw[0x2];
fix[0x2] = raw[0x1];
fix[0x3] = raw[0x0];
// reverse 4 & 5
fix[0x4] = raw[0x5];
fix[0x5] = raw[0x4];
// reverse 6 & 7
fix[0x6] = raw[0x7];
fix[0x7] = raw[0x6];
// all other are unchanged
fix[0x8] = raw[0x8];
fix[0x9] = raw[0x9];
fix[0xA] = raw[0xA];
fix[0xB] = raw[0xB];
fix[0xC] = raw[0xC];
fix[0xD] = raw[0xD];
fix[0xE] = raw[0xE];
fix[0xF] = raw[0xF];
return new Guid(fix);
}
小結:
UuidCreateSequential
函數存在隱私的問題,不適合集群環境,並且需要重新排序後再提交到資料庫;
COMB解決方案
COMB 類型的GUID 是由Jimmy Nilsson
在他的“The Cost of GUIDs as Primary Keys”一文中設計出來的。
基本設計思路是這樣的:既然GUID數據生成是隨機的造成索引效率低下,影響了系統的性能,那麼能不能通過組合的方式,保留GUID的前10個位元組,用後6個位元組表示GUID生成的時間(DateTime),這樣我們將時間信息與GUID組合起來,在保留GUID的唯一性的同時增加了有序性,以此來提高索引效率(這是針對Sql Server資料庫來設計的)。
在NHibernate框架中已經實現該功能,可以在github上看到實現方式:https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs#L25-L72。
在EF以及EF Core也同樣實現了類似的解決方案,EF Core的實現方式:https://github.com/aspnet/EntityFrameworkCore/blob/f7f6d6e23c8e47e44a61983827d9e41f2afe5cc7/src/EFCore/ValueGeneration/SequentialGuidValueGenerator.cs#L25-L44
在這裡介紹一下使用的方式,由EF Core框架自動生成有序GUID的方式:
public class SampleDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GuidEntity>(b =>
{
b.Property(e => e.Id).HasValueGenerator<SequentialGuidValueGenerator>();
});
}
}
但是請註意,這兩個ORM的解決方案只針對Sql Server資料庫,因為只保證了最後幾位位元組是按順序來生成的。
SequentialGuid框架
SequentialGuid框架也是我要推薦給您,因為它提供了常見資料庫生成有序Guid的解決方案。
關於該框架的設計思路以及針對各個資料庫的性能測試,見鏈接:https://www.codeproject.com/Articles/388157/GUIDs-as-fast-primary-keys-under-multiple-database。
使用方式,建議您參考ABP框架,在ABP中使用SequentialGuid框架來生成有序GUID,關鍵代碼鏈接:https://github.com/aspnetboilerplate/aspnetboilerplate/blob/b36855f0c238c3592203f058c641862844a0614e/src/Abp/SequentialGuidGenerator.cs#L36-L51。
總結
我們來解決一下:
- 在資料庫中最好不要使用隨機的GUID,它會影響性能;
- 在SQL Server中提供了
NewSequentialId
函數來生成有序GUID; - 各個資料庫對GUID支持的不一樣,而且排序的規則也不一樣;
UuidCreateSequential
函數存在隱私的問題,不適合集群環境,並且需要重新排序後再提交到資料庫;- 各ORM框架提供了有序GUID的支持,但是其實只是針對Sql Server資料庫設計的;
- 推薦您使用SequentialGuid框架,它解決了多資料庫以及集群環境的問題。