最近有一個朋友問我這樣一個問題:
我的業務依賴一些資料,因為資料庫存取慢,我把它放在Redis裡面,不過還是太慢了,有什麼其它的方案嗎?
其實這個問題比較簡單的是吧?Redis其實屬於網路儲存,我對照下面的這個表格,可以很容易的得出結論,既然網路儲存的速度慢,那我們就可以使用記憶體RAM儲存,把放Redis裡面的資料給放記憶體裡面就好了。
操作 | 速度 |
---|---|
執行指令 | 1/1,000,000,000 秒 = 1 納秒 |
從一級快取讀取資料 | 0.5 納秒 |
分支預測失敗 | 5 納秒 |
從二級快取讀取資料 | 7 納秒 |
使用Mutex加鎖和解鎖 | 25 納秒 |
從主記憶體(RAM記憶體)中讀取資料 | 100 納秒 |
在1Gbps速率的網路上傳送2Kbyte的資料 | 20,000 納秒 |
從記憶體中讀取1MB的資料 | 250,000 納秒 |
磁頭移動到新的位置(代指機械硬碟) | 8,000,000 納秒 |
從磁碟中讀取1MB的資料 | 20,000,000 納秒 |
傳送一個封包從美國到歐洲然後回來 | 150 毫秒 = 150,000,000 納秒 |
提出這個方案以後,接下來就遇到了另外一個問題:
但是資料比我應用的記憶體大,這怎麼辦呢?
筆者突然回想起來,似乎從來沒有考慮過資料比應用大是該怎麼處理,面對這種效能問題,最方便的方案就是直接擴容,在基礎設施完備的公司,一般只需要提交一個工單"8G->64G"就能解決這個問題,這種成本似乎不是該考慮的事情。
不過對於有一些朋友的公司,因為多個方面的原因(主要還是預算),沒有辦法擴容機器。或者體量非常大,每個範例擴容1GB記憶體,數萬個容器就是非常大的開銷。
於是我們可以採用一些記憶體+磁碟的快取方式,因為現在大多數都是SSD磁碟,伺服器NVME順序讀寫速度早已突破7GB/s,隨機讀寫早已突破100K IOPS,而且還可以通過RAID0進一步增加效能。
最簡單的就是我們在本地跑一個Sqlite,然後將資料快取到本地磁碟中,但是Sqlite並不是專業的KV Store,讀寫效能並不是特別好。KV-Store的話還有基於LSM-Tree的RocksDB、LevelDB等等。
不過那些KV都是C++的實現,在C#中整合需要Bind和P/Invoke,需要自己編譯比較麻煩;這讓我想起了多年前微軟開源FASTER專案。
專案如其名,FASTER是目前藍星最快的KV-Store(開源的專案中),根據論文中的效能表現,它可以實現1.6億次操作/秒,當然這一切也是有代價的,就是它目前只支援簡單的幾種操作,Read、Upser、RMW和Delete,不過這已經夠了,畢竟在快取場景這些操作就足夠了。
在它2018年開源和論文發表時,我就有關注,不過當時它的API易用性不夠,另外C#版本存在一些問題,所以一直都沒有體驗它,現在它經過幾年的迭代,易用性得到了很大的提高,一些之前存在的問題也已經修復。
筆者簡單的體驗了一下它,可以說這是我使用過比較複雜的的KV-Store了,從它的API使用風格來說,它的設計的目的只有一個,那就是效能。
具體的使用詳情大家可以直接看官方檔案,Github開源地址和檔案在文末給出,需要詳細瞭解的可以檢視檔案。首先就是安裝NuGet包:
<PackageReference Include="Microsoft.FASTER.Core" Version="2.0.22" />
然後下面簡單的幾行程式碼就可以把Demo執行起來了,它支援In-Memroy(記憶體模式)和混合模式。和對資料庫操作需要建立連結一樣,它的維度是session
,注意這個session
就代表一個執行緒對它進行讀寫,如果多執行緒場景,那麼每個執行緒對應的session
應該要不一致,要單獨建立,當然我們也可以把它池化。
// 記憶體模式
using var fasterKvSetting = new FasterKVSettings<string, string>(null);
// 混合模式
using var fasterKvSetting = new FasterKVSettings<long, byte[]>("./faster-query");
// 建立fasterKv Store
using var fasterKv = new FasterKV<long, byte[]>(fasterKvSetting);
var session = fasterKv.For(new SimpleFunctions<long, byte[]>()).NewSession<SimpleFunctions<long, byte[]>>();
// 準備一個utf-8字元
var str = "yyds"u8.ToArray();
// 寫入
session.Upsert(1024, str);
// 讀取
var result = session.Read(1024);
Console.WriteLine($"{Encoding.UTF8.GetString(result.output)}");
輸出結果就是yyds
。
另外也有豐富的引數可以調整記憶體佔用,以下列出了幾個相關的記憶體佔用引數,當然,更低的記憶體使用,意味著更多的使用磁碟空間,效能也就會下降的越多:
IndexSize
: 主Hash索引的大小,以位元組為單位(四捨五入為2的冪)。最小大小為64位元組。MemorySize
: 表示混合紀錄檔的記憶體部分的大小(四捨五入為2的冪)。注意,如果紀錄檔指向類鍵或值物件,則此大小僅包括對該物件的8位元組參照。紀錄檔的舊部分溢位到儲存中。LogSettings
: 這些是幾個與紀錄檔相關的設定,例如頁面大小的 PageSize。ReadCacheEnable
: 是否為儲存提供並啟用了單獨的讀快取。ReadCacheMemorySize
: 讀快取記憶體佔用大小,ReadCachePageSize
: 讀快取頁面大小那麼FASTER到底有多強呢?筆者構建了一個測試,和我們常用的ConcurrentDictionary
做比較,那是我能找到在.NET平臺上差不多的東西,按道理來說我們應該和RocksDB、LevelDB來比較。
ConcurrentDictionary
應該是.NET平臺上效能最好的純記憶體KV Store了,嚴格來說它和FASTER並不是不能相提並論,而且受制於筆電的效能,無法做大數量的測試,要知道FASTER的場景是大型資料集。
為了方便的統計記憶體佔用,我構建了一個結構體型別,如下所示,它應該佔用32位元組:
我分別構建了不同的場景來測試Add效能,測試的構建如下所示:
程式碼如下所示:
[GcForce]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[MemoryDiagnoser]
[HtmlExporter]
public class AddBench
{
private const int ThreadCount = 6;
private const int NumCount = 200_0000;
private ConcurrentDictionary<long, Data> _concurrent;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static async Task FasterInternal(double percent, bool inMemory = false, bool multi = false)
{
FasterKVSettings<long, Data> kvSetting;
if (inMemory)
{
kvSetting = new FasterKVSettings<long, Data>(null);
}
else
{
// 總計記憶體大小 總數 * (key + 每個Data需要佔用的記憶體)
var dataByte = NumCount * (Unsafe.SizeOf<Data>() + 8 + 8);
// 計算memorySize 計劃只使用{percent * 100}%的記憶體 需要是2的次冪
var memorySizeBits = (int) Math.Ceiling(Math.Log2(dataByte * percent));
// 根據數量計算IndexSize 需要是2的次冪
var numBucketBits = (int) Math.Ceiling(Math.Log2(NumCount));
kvSetting = new FasterKVSettings<long, Data>("./faster-add", deleteDirOnDispose: true)
{
IndexSize = 1L << numBucketBits,
MemorySize = 1L << memorySizeBits
};
// 不分頁
kvSetting.PageSize = kvSetting.MemorySize;
}
using var fkv = new FasterKV<long, Data>(kvSetting);
if (multi)
{
await FasterMultiThread(fkv);
}
else
{
FasterSingleThread(fkv);
}
kvSetting.Dispose();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void FasterSingleThread(FasterKV<long, Data> fkv)
{
using var session = fkv.For(new SimpleFunctions<long, Data>()).NewSession<SimpleFunctions<long, Data>>();
for (int i = 0; i < NumCount; i++)
{
session.Upsert(i, new Data());
}
session.CompletePending(true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static async Task FasterMultiThread(FasterKV<long, Data> fkv)
{
const int perCount = NumCount / ThreadCount;
var tasks = new Task[ThreadCount];
for (var i = 0; i < ThreadCount; i++)
{
var i1 = i;
var session = fkv.For(new SimpleFunctions<long, Data>())
.NewSession<SimpleFunctions<long, Data>>();
tasks[i] = Task.Run(() =>
{
var j = i1 * perCount;
var length = j + perCount;
for (; j < length; j++)
{
session.Upsert(j, new Data());
}
session.CompletePending(true);
});
}
await Task.WhenAll(tasks);
}
[Benchmark]
public async Task Faster_Hybrid_10per_Memory_Add()
{
await FasterInternal(0.10);
}
[Benchmark]
public async Task Faster_Hybrid_25per_Memory_Add()
{
await FasterInternal(0.25);
}
[Benchmark]
public async Task Faster_Hybrid_50per_Memory_Add()
{
await FasterInternal(0.50);
}
[Benchmark]
public async Task Faster_Hybrid_90per_Memory_Add()
{
await FasterInternal(0.90);
}
[Benchmark]
public async Task Faster_Hybrid_100per_Memory_Add()
{
await FasterInternal(1.0);
}
[Benchmark]
public async Task Faster_Default_InMemory_Add()
{
await FasterInternal(0, true);
}
[Benchmark]
public async Task Faster_Hybrid_10per_Memory_Multi_Add()
{
await FasterInternal(0.10, multi: true);
}
[Benchmark]
public async Task Faster_Hybrid_25per_Memory_Multi_Add()
{
await FasterInternal(0.25, multi: true);
}
[Benchmark]
public async Task Faster_Hybrid_90per_Memory_Multi_Add()
{
await FasterInternal(0.90, multi: true);
}
[Benchmark]
public async Task Faster_Hybrid_100per_Memory_Multi_Add()
{
await FasterInternal(1.0, multi: true);
}
[Benchmark]
public async Task Faster_Hybrid_50per_Memory_Multi_Add()
{
await FasterInternal(0.50, multi: true);
}
[Benchmark]
public async Task Faster_Default_InMemory_Multi_Add()
{
await FasterInternal(0, true, true);
}
[Benchmark]
public void Concurrent_Add()
{
_concurrent = new ConcurrentDictionary<long, Data>(1, NumCount);
for (long i = 0; i < NumCount; i++)
{
_concurrent.TryAdd(i, new Data());
}
}
[Benchmark]
public async Task Concurrent_Multi_Add()
{
const int perCount = NumCount / ThreadCount;
var tasks = new Task[ThreadCount];
_concurrent = new ConcurrentDictionary<long, Data>(1, NumCount);
for (var i = 0; i < ThreadCount; i++)
{
var i1 = i;
tasks[i] = Task.Run(() =>
{
var j = i1 * perCount;
var length = j + perCount;
for (; j < length; j++)
{
_concurrent.TryAdd(j, new Data());
}
});
}
await Task.WhenAll(tasks);
}
}
結果如下所示:
Query測試我一共建立了100W條記錄,然後測試瞭如下場景:
[GcForce]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[MemoryDiagnoser]
[HtmlExporter]
public class QueryBench
{
public const int Threads = 6;
public const int NumCount = 100_0000;
private static readonly Random Random = new(NumCount);
private static readonly long[] RandomIndex =
Enumerable.Range(0, 1000).Select(i => Random.NextInt64(0, (int)(NumCount * 0.10))).ToArray();
private static readonly ConcurrentDictionary<long, Data> Concurrent;
private static readonly FasterKV<long, Data> FasterKvHybrid;
private static readonly FasterKV<long, Data> FasterKvInMemory;
private static readonly ClientSession<long, Data, Data, Data, Empty, SimpleFunctions<long, Data>> HybridSession;
private static readonly ClientSession<long, Data, Data, Data, Empty, SimpleFunctions<long, Data>> InMemorySession;
static QueryBench()
{
// 初始化字典
GC.Collect();
var heapSize = GC.GetGCMemoryInfo().HeapSizeBytes;
Concurrent = new ConcurrentDictionary<long, Data>(Threads, NumCount);
for (long i = 0; i < NumCount; i++)
{
Concurrent.TryAdd(i, new Data());
}
Helper.PrintHeapSize("Concurrent", heapSize);
// 初始化混合FasterKv
heapSize = GC.GetGCMemoryInfo().HeapSizeBytes;
// 總計記憶體大小 總數 * (key + 每個Data需要佔用的記憶體)
var dataByte = NumCount * (Unsafe.SizeOf<Data>() + 8 + 8);
// 計算memorySize 計劃只使用50%的記憶體 需要是2的次冪
var memorySizeBits = (int) Math.Ceiling(Math.Log2(dataByte * 0.5));
// 根據數量計算IndexSize 需要是2的次冪
var numBucketBits = (int) Math.Ceiling(Math.Log2(NumCount));
var kvHybridSetting = new FasterKVSettings<long, Data>("./faster-query", deleteDirOnDispose: true)
{
IndexSize = 1L << numBucketBits,
MemorySize = 1L << memorySizeBits
};
// 32分頁
kvHybridSetting.PageSize = kvHybridSetting.MemorySize / 32;
Console.WriteLine($"memorySizeBits:{memorySizeBits},numBucketBits:{numBucketBits},{kvHybridSetting}");
FasterKvHybrid = new FasterKV<long, Data>(kvHybridSetting);
HybridSession = FasterKvHybrid.For(new SimpleFunctions<long, Data>()).NewSession<SimpleFunctions<long, Data>>();
for (long i = 0; i < NumCount; i++)
{
HybridSession.Upsert(i, new Data());
}
HybridSession.CompletePending(true);
Helper.PrintHeapSize("Faster Hybrid", heapSize);
// 初始化In Memory
GC.Collect();
heapSize = GC.GetGCMemoryInfo().HeapSizeBytes;
var inMemorySetting = new FasterKVSettings<long, Data>(null);
FasterKvInMemory = new FasterKV<long, Data>(inMemorySetting);
InMemorySession = FasterKvInMemory.For(new SimpleFunctions<long, Data>()).NewSession<SimpleFunctions<long, Data>>();
for (long i = 0; i < NumCount; i++)
{
InMemorySession.Upsert(i, new Data());
}
InMemorySession.CompletePending(true);
Helper.PrintHeapSize("Faster In Memory", heapSize);
}
[Benchmark]
public async Task Faster_Hybrid_Multi_Query()
{
var tasks = new Task[Threads];
for (int i = 0; i < Threads; i++)
{
var session = FasterKvHybrid.For(new SimpleFunctions<long, Data>())
.NewSession<SimpleFunctions<long, Data>>();
tasks[i] = Task.Run(() =>
{
Data data = default;
for (int j = 0; j < RandomIndex.Length; j++)
{
session.Read(ref RandomIndex[j], ref data);
}
});
}
await Task.WhenAll(tasks);
}
[Benchmark]
public void Faster_Hybrid_1Thread_Query()
{
Data data = default;
for (long j = 0; j < RandomIndex.Length; j++)
{
HybridSession.Read(ref RandomIndex[j], ref data);
}
}
[Benchmark]
public async Task Faster_InMemory_Multi_Query()
{
var tasks = new Task[Threads];
for (int i = 0; i < Threads; i++)
{
var session = FasterKvInMemory.For(new SimpleFunctions<long, Data>())
.NewSession<SimpleFunctions<long, Data>>();
tasks[i] = Task.Run(() =>
{
Data data = default;
for (int j = 0; j < RandomIndex.Length; j++)
{
session.Read(ref RandomIndex[j], ref data);
}
});
}
await Task.WhenAll(tasks);
}
[Benchmark]
public void Faster_InMemory_Query()
{
Data data = default;
for (long j = 0; j < RandomIndex.Length; j++)
{
InMemorySession.Read(ref RandomIndex[j], ref data);
}
}
[Benchmark]
public void Concurrent_Query()
{
for (long j = 0; j < RandomIndex.Length; j++)
{
Concurrent.TryGetValue(RandomIndex[j], out _);
}
}
[Benchmark]
public async Task Concurrent_Multi_Query()
{
var tasks = new Task[Threads];
for (int i = 0; i < Threads; i++)
{
tasks[i] = Task.Run(() =>
{
for (long j = 0; j < RandomIndex.Length; j++)
{
Concurrent.TryGetValue(RandomIndex[j], out _);
}
});
}
await Task.WhenAll(tasks);
}
}
結果如下所示,這裡是100%讀的場景:
由於我的測試結果不是很準確,我又繼續查詢有沒有其它的效能評測的結果,並沒有找到什麼有價值的。於是從論文和Wiki中找到了一些資料,和大家解讀一下我比較感興趣的部分。
這是在Faster 2018年的論文中提到的一些,如下所示:
https://www.microsoft.com/en-us/research/uploads/prod/2018/03/faster-sigmod18.pdf
上圖是單執行緒情況下,跑YCSB-A(uniform)資料集和YCSB-A(Zipf)資料集的結果。可以看到在單執行緒的場景,FASTER速度遠超於同類Intel TBB、MassTree、RocksDB等資料庫。
文中的0:100、50:50、100:0是代表全寫、50%寫50讀、全讀的場景。另外FASTER支援Read-Modify-Write,RMW就是代表進行這個操作的效能。
Yahoo! Cloud Serving Benchmark (YCSB) 是一個Java語言實現的主要用於雲端或者伺服器端的資料庫效能測試工具,其內部涵蓋了常見的NoSQL資料庫產品,如Cassandra、MongoDB、HBase、Redis等等。
在多執行緒的情況下,FASTER的讀效能達到了驚人的1.6億/s。
上圖是在單核和雙核的更新效能資料,可以看到FASTER藍色的線是遠超同類產品,特別是線上程數變多以後,其它都是下降趨勢,它是程上升趨勢。
上圖是表示,分別在使用5GB~45GB記憶體載入27GB資料時的吞吐量,分別是50%的讀寫,和100%的寫。可以看到寫效能幾乎不受記憶體大小的影響,這也佐證了我的測試結果。
這是翻閱微軟Github專案時,看到專門針對於C#的FasterKV和ConcurrentDictionary的測試。不過它只有純記憶體模式的測試,並不包含記憶體+硬碟混合模式。
https://github.com/microsoft/FASTER/wiki/Performance-of-FASTER-in-C%23
這裡它使用了一臺36核72執行緒的512GB伺服器進行測試。分別測試大型資料集(2.5億個鍵)和小型資料集(250萬個鍵)進行實驗。
上圖是大型資料集的載入速度,可以發現FASTER的載入速度確實很快,是ConcurrentDictionary的好10~50倍,效能還在上漲。
上圖是100%寫入時的場景,隨著執行緒數量的增加還在上漲,遠超ConcurrentDictionary,這和我們的測試結果相符合。
上圖是分別進行50%讀寫的場景,可以發現吞吐量還是非常的不錯的。
如果是100%純度的場景,還是ConcurrentDictionary會更好。不過這也不是FASTER的適用場景,因為在這樣的工作負載中不存在並行瓶頸,也不存在對記憶體的寫操作。這兩個系統都受到讀取快取失敗次數的限制,並具有相似的效能。
上圖顯示了來自上面72個執行緒的資料,以 x 軸上的讀取百分比表示。當您的工作負載中涉及到一小部分更新時,FASTER 提供了數量級更好的效能。隨著非常高的讀取百分比超過90% ,兩個系統的效能開始像預期的那樣趨於一致。
因為ConcurrentDictionary對(Int32、Int64)型別有特殊的優化,所以將Key的型別替換為Int64做了下面的測試。
可以看到(Int32、Int64)型別確實讓ConcurrentDictionary更快了,不過在有寫入操作的場景,還是FASTER更勝一籌。
這也解釋了一些我們上面的測試中,為什麼ConcurrentDictionary在讀場景那麼快的原因之一,就是我們用了Int64作為Key。
這個場景我就不解讀了,和巨量資料集場景表現基本一致。
通過對FASTER的測試和翻閱論文,從目前的結果來說,在以下單機場景比較適合使用FASTER:
另外FASTER也提供了Server版本,可以通過網路存取。另外在我的測試中,讀取效能和官方測試有較大的出入,感覺是使用方法和引數上出了問題,因為FASTER整體還是比較複雜,筆者需要更多的時間去了解原理和測試。
回到最開始的那個問題,FASTER可以作為記憶體+磁碟程序內快取使用嗎?
我的答案是可以,它雖然比不上純記憶體的ConcurrentDictionary,但是有著遠超RocksDB等同類KV Store的效能。不過它不適合100%讀的快取,最好是那些既有讀,又有寫的場景;如果需要100%讀,可能我們需要看看其它的工具是否能滿足我們的需求。