首先來假設這樣一個業務場景,大家對於飛機票應該不陌生,大家在購買機票時,首先是選擇您期望的起抵城市和時間,然後選擇艙等(公務艙、經濟艙),點擊查詢以後就會出現航班列表,隨意的點擊一個航班,可以發現有非常多組價格,因爲機票和火車票不一樣,它的權益、規則更加的複雜,比如有機票中有針對年齡段的優惠票,有針對學生的專享票,有不同的免托運行李額、餐食、有不同的退改簽規則,甚至買機票還能送茅臺返現等等。
在中國有幾十個航司、幾百個機場、幾千條航線、幾萬個航班,每個航班有幾十上百種產品類型,這是一天的數據,機票可以提前一年購買,總計應該有數十億,而且它們在實時的變動,沒有任何一種數據庫能解決這樣量級下高併發進行實時搜索的問題。
業內的解決方案都是加載數據到內存進行計算,但是內存計算也是有挑戰的,如何在短短的幾十毫秒內處理數十億數據將搜索結果呈現在客戶面前呢?
其中有很多可以聊的地方,今天主要聊大規模實時搜索引擎技術的一個小的優化點;通過這個簡單的場景,看如何使用.NET構建內存位圖索引優化搜索引擎計算速度。
聲明:爲簡化知識和方便理解,本文場景與解決方案均爲虛構,如有雷同純屬巧合。
由於篇幅問題,本系列文章一共分爲四篇:
-
介紹什麼是位圖索引,如何在.NET中構建和使用位圖索引
-
位圖索引的性能,.NET BCL庫源碼解析,如何通過SIMD加速位圖索引的計算
-
CPU SIMD就走到盡頭了嗎?下一步方向是什麼?
-
構建高效的Bitmap內存索引庫並實現可觀測性(待定,現在沒有那麼多時間整理)
什麼是位圖索引
要回答這樣一個問題,我們首先來假設一個案例,我們將航班規則抽象成下面的record
類型,然後有如下這樣一些航班的規則數據被加載到了內存中:
/// <summary>
/// 艙等
/// </summary>
public enum CabinClass {
// 頭等艙
F,
// 經濟艙
Y
}
/// <summary>
/// 航班規則
/// </summary>
/// <param name="Airline">航司</param>
/// <param name="Class">艙等</param>
/// <param name="Origin">起飛機場</param>
/// <param name="Destination">抵達機場</param>
/// <param name="DepartureTime">起飛時間</param>
public record FlightRule(string Airline, CabinClass Class, string Origin, string Destination, string FlightNo, DateTime DepartureTime);
var flightRules = new FlightRule[]
{
new ("A6", CabinClass.F, "PEK", "SHA", "A61234", DateTime.Parse("2023-10-11 08:00:00")),
new ("CA", CabinClass.Y, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-13 08:00:00")),
new ("CA", CabinClass.Y, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-14 08:00:00")),
new ("CA", CabinClass.Y, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-15 08:00:00")),
new ("CA", CabinClass.F, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-15 08:00:00")),
new ("MU", CabinClass.F, "PEK", "CSX", "MU1234", DateTime.Parse("2023-10-16 08:00:00")),
new ("9C", CabinClass.Y, "PEK", "CSX", "9C1234", DateTime.Parse("2023-10-17 08:00:00")),
};
然後有一個搜索表單record
類型,如果說要針對這個record
編寫一個搜索方法,用於過濾得出搜索結果,相信大家很快就能實現一個代碼,比如下方就是使用簡單的for
循環來實現這一切。
// 搜索方法 condition爲搜索條件
FlightRule[] SearchRule(FlightRuleSearchCondition condition)
{
var matchRules = new List<FlightRule>();
foreach (var rule in flightRules)
{
if (rule.Airline == condition.Airline &&
rule.Class == condition.Class &&
rule.Origin == condition.Origin &&
rule.Destination == condition.Destination &&
rule.DepartureTime.Date == condition.DepartureTime.Date)
{
matchRules.Add(rule);
}
}
return matchRules.ToArray();
}
這個解決方案的話再數據量小的時候非常完美,不過它的時間複雜度是O(N),大家可以回憶之前文章如何快速遍歷List集合的結論,我們知道就算是空循環,面對動輒十幾萬、上百萬的數據量時,也需要幾秒鐘的時間。
數據庫引擎在面對這個問題的時候,就通過各種各樣的索引算法來解決這個問題,比如B+樹、哈希、倒排、跳錶等等,當然還有我們今天要提到的位圖索引。
我們先來看一下位圖索引的定義:位圖索引是一種數據庫索引方式,針對每個可能的列值,建立一個位向量。每個位代表一行,如果該行的列值等於位向量的值,位爲1,否則爲0。特別適用於處理具有少量可能值的列。聽起來比較抽象是嘛?沒有關係,我們通過後面的例子大家就能知道它是一個什麼了。
構建位圖索引
還是上面提到的航班規則數據,比如第一個Bit數組就是航司爲CA的行,那麼第0位就代表航班規則數組中的第0個元素,它的航司是CA,所以這個Bit位就爲True,賦值爲1;同樣的,第1位就代表航班規則數據中的第1個元素,它航司不是CA,所以就賦值爲0。
new ("A6", CabinClass.F, "PEK", "SHA", "A61234", DateTime.Parse("2023-10-11 08:00:00")),
new ("CA", CabinClass.Y, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-13 08:00:00")),
new ("CA", CabinClass.Y, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-14 08:00:00")),
new ("CA", CabinClass.Y, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-15 08:00:00")),
new ("CA", CabinClass.F, "SHA", "PEK", "CA1234", DateTime.Parse("2023-10-15 08:00:00")),
new ("MU", CabinClass.F, "PEK", "CSX", "MU1234", DateTime.Parse("2023-10-16 08:00:00")),
new ("9C", CabinClass.Y, "PEK", "CSX", "9C1234", DateTime.Parse("2023-10-17 08:00:00")),
特徵 | 規則0 | 規則1 | 規則2 | 規則3 | 規則4 | 規則5 | 規則6 |
---|---|---|---|---|---|---|---|
航司CA | 0 | 1 | 1 | 1 | 1 | 0 | 0 |
根據這個規則,我們可以根據它的不同維度,構建出好幾個不同維度如下幾個Bit數組,這些數組組合到一起,就是一個Bitmap。
規則序號 | 航司CA | 航司A6 | 航司MU | 航司9C | 經濟艙 | 起飛機場PEK | 起飛機場SHA | 起飛機場CSX | 抵達機場PEK | 抵達機場SHA | 抵達機場CSX |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 |
1 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
2 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
3 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
4 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 |
5 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 |
6 | 0 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 1 |
現代CPU的字長都是64bit,它能在一次循環中處理64bit的數據,按照一個不嚴謹的算法,它比直接for
搜索要快64倍(當然這並不是極限,在後面的文章中會解釋原因)。
位圖索引邏輯運算
位圖索引已經構建出來了,那麼如何進行搜索操作呢?
與運算
比如我們需要查詢航司爲CA
,起飛機機場爲SHA
到PEK
的航班,就可以通過AND
運算符,分別對它們進行AND
操作。
就能得出如下的Bit數組,而這個Bit數組中爲1
的位對應的位下標就是符合條件的規則,可以看到下標1~4都是符合條件的規則。
規則序號 | 航司CA | 起飛機場SHA | 抵達機場PEK | AND結果 |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
1 | 1 | 1 | 1 | 1 |
2 | 1 | 1 | 1 | 1 |
3 | 1 | 1 | 1 | 1 |
4 | 1 | 1 | 1 | 1 |
5 | 0 | 0 | 0 | 0 |
6 | 0 | 0 | 0 | 0 |
或運算
如果想搜索10月13號
和10月15號
起飛的航班,那應該怎麼做呢?其實也很簡單,就是通過OR
運算符,先得出在10月13號
和10月15號
的規則(請注意,在實際項目中對於時間這種高基數的數據不會對每一天創建索引,而是會使用BSI、REBSI等方式創建;或者使用B+ Tree這種更高效的索引算法):
規則序號 | 起飛日期10月13日 | 起飛日期10月15日 | OR結果 |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | 1 | 0 | 1 |
2 | 0 | 0 | 0 |
3 | 0 | 1 | 1 |
4 | 0 | 1 | 1 |
5 | 0 | 0 | 0 |
6 | 0 | 0 | 0 |
然後再AND
上文中的出的結果數組即可,可以看到只有規則1、3和4符合要求了。
規則序號 | 上次運算結果 | OR結果 | 本次結果 |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | 1 | 1 | 1 |
2 | 1 | 0 | 0 |
3 | 1 | 1 | 1 |
4 | 1 | 1 | 1 |
5 | 0 | 0 | 0 |
6 | 0 | 0 | 0 |
非運算
那麼用戶不想坐經濟艙應該怎麼辦?我們這裏沒有構建非經濟艙的Bit數組;解決其實很簡單,我們對經濟艙進行NOT
操作:
規則序號 | 經濟艙 | NOT結果 |
---|---|---|
0 | 0 | 1 |
1 | 1 | 0 |
2 | 1 | 0 |
3 | 1 | 0 |
4 | 0 | 1 |
5 | 0 | 1 |
6 | 1 | 0 |
然後AND
上文中的結果即可,就可以得出符合上面條件,但不是經濟艙的航班列表,可以發現僅剩下規則4可以滿足需求:
規則序號 | 上次運算結果 | NOT結果 | 本次結果 |
---|---|---|---|
0 | 0 | 1 | 0 |
1 | 1 | 0 | 0 |
2 | 0 | 0 | 0 |
3 | 1 | 0 | 0 |
4 | 1 | 1 | 1 |
5 | 0 | 1 | 0 |
6 | 0 | 0 | 0 |
代碼實現
請注意,本文中代碼爲AI生成,僅供演示和參考,不可用於實際生產環境,請使用其它更成熟實現(如:BitArray)。
那麼如何實現一個Bitmap索引呢?其實非常的簡單,在.NET中已經自帶了BitArray
類,將多個BitArray
使用Dictionary
組合在一起就可以實現Bitmap索引。
在這裏爲了詳細的講述原理,我們不使用官方提供的BitArray
,自己實現一個簡單的,其實就是一個存放的數組和簡單的位運算。
public class MyBitArray
{
private long[] _data;
// 每個long類型有64位
private const int BitsPerLong = 64;
public int Length { get; }
public MyBitArray(int length)
{
Length = length;
// 計算存儲所有位需要多少個long
_data = new long[(length + BitsPerLong - 1) / BitsPerLong];
}
public bool this[int index]
{
// 獲取指定位的值
get => (_data[index / BitsPerLong] & (1L << (index % BitsPerLong))) != 0;
set
{
// 設置指定位的值
if (value)
_data[index / BitsPerLong] |= (1L << (index % BitsPerLong));
else
_data[index / BitsPerLong] &= ~(1L << (index % BitsPerLong));
}
}
public void And(MyBitArray other, MyBitArray result)
{
// 對兩個MyBitArray進行AND操作
for (int i = 0; i < _data.Length; i++)
result._data[i] = _data[i] & other._data[i];
}
public void Or(MyBitArray other, MyBitArray result)
{
// 對兩個MyBitArray進行OR操作
for (int i = 0; i < _data.Length; i++)
result._data[i] = _data[i] | other._data[i];
}
public void Xor(MyBitArray other, MyBitArray result)
{
// 對兩個MyBitArray進行XOR操作
for (int i = 0; i < _data.Length; i++)
result._data[i] = _data[i] ^ other._data[i];
}
public void Not(MyBitArray result)
{
// 對MyBitArray進行NOT操作
for (int i = 0; i < _data.Length; i++)
result._data[i] = ~_data[i];
}
}
然後我們可以使用Dictionary<string, MyBitArray>
來實現一個多維度的BitMap:
//定義一個名爲MyBitmap的類
public class MyBitmap
{
//定義一個字典來存儲字符串和MyBitArray的映射
private readonly Dictionary<string, MyBitArray> _bitmaps;
//定義一個整數來存儲位圖的長度
private readonly int _length;
//構造函數,接收一個整數作爲參數,並初始化字典和長度
public MyBitmap(int length)
{
_bitmaps = new Dictionary<string, MyBitArray>();
_length = length;
}
//定義一個索引器,通過字符串key來獲取或設置MyBitArray
public MyBitArray this[string key]
{
get
{
//如果字典中存在key,則返回對應的MyBitArray
//如果不存在,則創建一個新的MyBitArray,添加到字典中,並返回
if (_bitmaps.TryGetValue(key, out MyBitArray? value)) return value;
value = new MyBitArray(_length);
_bitmaps[key] = value;
return value;
}
set
{
//設置字典中key對應的MyBitArray
_bitmaps[key] = value;
}
}
//定義一個And方法,接收一個字符串key,一個MyBitArray和一個結果MyBitArray作爲參數
//將key對應的MyBitArray和傳入的MyBitArray進行And操作,結果存入結果MyBitArray
public void And(string key, MyBitArray bitArray, MyBitArray result)
{
this[key].And(bitArray, result);
}
//定義一個Or方法,接收一個字符串key,一個MyBitArray和一個結果MyBitArray作爲參數
//將key對應的MyBitArray和傳入的MyBitArray進行Or操作,結果存入結果MyBitArray
public void Or(string key, MyBitArray bitArray, MyBitArray result)
{
this[key].Or(bitArray, result);
}
//定義一個Xor方法,接收一個字符串key,一個MyBitArray和一個結果MyBitArray作爲參數
//將key對應的MyBitArray和傳入的MyBitArray進行Xor操作,結果存入結果MyBitArray
public void Xor(string key, MyBitArray bitArray, MyBitArray result)
{
this[key].Xor(bitArray, result);
}
//定義一個Not方法,接收一個字符串key和一個結果MyBitArray作爲參數
//將key對應的MyBitArray進行Not操作,結果存入結果MyBitArray
public void Not(string key, MyBitArray result)
{
this[key].Not(result);
}
}
然後寫一個Build
方法,用於將FlightRule[]
創建成MyBitmap
,這一過程可以採用代碼生成自動去做,無需手動編寫,我們這裏演示一下:
MyBitmap Build(FlightRule[] rules)
{
var bitmap = new MyBitmap(rules.Length);
for (int i = 0; i < rules.Length; i++)
{
// 將bitmap索引維度構建
// 在實際項目中不用這麼寫,可以使用代碼生成技術自動構建,這裏只是舉例
bitmap["Airline-A6"][i] = rules[i].Airline == "A6";
bitmap["Airline-CA"][i] = rules[i].Airline == "CA";
bitmap["Airline-MU"][i] = rules[i].Airline == "MU";
bitmap["Airline-9C"][i] = rules[i].Airline == "9C";
bitmap["CabinClass-F"][i] = rules[i].Class == CabinClass.F;
bitmap["CabinClass-Y"][i] = rules[i].Class == CabinClass.Y;
bitmap["Origin-PEK"][i] = rules[i].Origin == "PEK";
bitmap["Origin-SHA"][i] = rules[i].Origin == "SHA";
bitmap["Destination-CSX"][i] = rules[i].Destination == "CSX";
bitmap["Destination-PEK"][i] = rules[i].Destination == "PEK";
// ....... 其它維度
}
return bitmap;
}
調用Build
方法,簡單的進行一下運算查詢(航司爲CA、頭等艙),代碼和運行結果如下所示:
var flightRuleBitmap = Build(flightRules);
// 搜索CA 頭等艙航班
var result = new MyBitArray(flightRules.Length);
flightRuleBitmap.And("Airline-CA", flightRuleBitmap["CabinClass-F"], result);
// 輸出result中爲true的索引
for (int i = 0; i < result.Length; i++)
{
if (result[i])
Console.WriteLine(i);
}
在實際項目中,大多數字段都可以建立Bitmap索引,對於那些不能建立的也沒有關係,可以在Bitmap索引初篩以後,再使用for
循環遍歷精細篩選想要的數據。
位圖索引的優劣
當然位圖索引有它自身的優劣勢,我們要在合適的場景使用它,把它的優勢發揮到最大,儘量避免它的劣勢。
優勢
- 高效的集合操作:位圖索引可以使用位運算(如AND、OR和NOT等)高效地處理複雜的查詢條件,這在其他類型的索引中往往難以實現。
- 空間效率:對於低基數的數據,位圖索引通常比其他類型的索引更加空間有效。因爲每一行只需要一個位,而不是一個完整的鍵值和指針(可以很簡單的算一下,一個維度1億數據只需要12MB的空間,就算有300個維度,那也僅僅3.5GB的空間。另外有很多位圖索引壓縮算法(如BBC、RLE、Roaring等),空間佔用會變得更低。)。
- 範圍查詢:位圖索引可以高效地處理範圍查詢,只需要對相關的位圖進行OR運算即可(比如上文中提到的幾種構建方法,BSI、REBSI等)。
劣勢
- 更新開銷:如果數據經常變動,維護位圖索引的成本可能會很高。每次數據變動都可能需要更新多個位圖。因此,位圖索引通常用於數據倉庫和其他主要用於讀取的環境,而不是用於需要頻繁更新的在線事務處理(OLTP)環境。
- 高基數數據:對於高基數的數據,即可能的值很多的數據,位圖索引可能會佔用大量的空間。每個可能的值都需要一個位圖,因此如果數據的可能值非常多,位圖索引可能不是最好的選擇。
- 併發問題:位圖索引在處理大量併發寫入時可能會遇到問題,因爲每次更新都需要鎖定和修改位圖。這在高併發的OLTP環境中可能會成爲性能瓶頸(一般會使用Copy On Write解決)。
總結
在本次的分享中,我們通過一個機票搜索的業務場景,探討了位圖索引的原理與應用。位圖索引作爲一種高效的數據索引方式,能夠在大規模數據量下優化搜索引擎的計算速度,降低內存佔用並提升性能。我們詳細介紹了位圖索引的構建,以及如何通過邏輯運算進行搜索操作。同時,我們也實現了一個簡單的位圖索引,並通過實例進行了演示。最後,我們還探討了位圖索引的優劣,讓我們更全面地瞭解了位圖索引的特性和適用場景。
儘管位圖索引在處理大規模數據時具有顯著的優勢,但在數據頻繁更新、高基數數據以及併發寫入的場景下可能存在問題。因此,如何在這些場景下優化位圖索引,使其更好地適應不同的業務需求,將是我們未來需要進一步探討的問題。此外,如何結合其他的索引算法,如B+樹、哈希、倒排、跳錶等,以及如何利用現代CPU的特性,如SIMD,以進一步提升位圖索引的性能,也是我們未來的研究方向。
下一期預告
在下一期中,我們將深入探討位圖索引的性能問題,包括.NET BCL庫源碼的解析,以及如何通過SIMD加速位圖索引的計算。希望大家能夠繼續關注後面的分享,一起探討.NET高性能開發的相關知識。