使用.NET簡單實現一個Redis的高性能克隆版(二)
譯者注
該原文是Ayende Rahien大佬業餘自己在使用C# 和 .NET構建一個簡單、高性能兼容Redis協議的資料庫的經歷。
首先這個”Redis”是非常簡單的實現,但是他在優化這個簡單”Redis”路程很有趣,也能給我們在從事性能優化工作時帶來一些啟示。
原作者:Ayende Rahien
原鏈接://ayende.com/blog/197441-A/high-performance-net-building-a-redis-clone-analysis
另外Ayende大佬是.NET開源的高性能多範式資料庫RavenDB所在公司的CTO,不排除這些文章是為了以後會在RavenDB上兼容Redis協議做的嘗試。大家也可以多多支援,下方給出了鏈接
RavenDB地址://github.com/ravendb/ravendb
正文
在上一篇文章中,我用最簡單的方式寫了一個Redis克隆版本。它能夠在我們的測試實例上每秒命中近100萬個查詢(c6g.4xlarge,使用16個內核和64 GB記憶體)。在我們更深入地進行優化之前,值得了解CPU時間實際花費在哪裡。我在探查器下運行伺服器,以查看各種程式碼所耗費的成本。
我喜歡使用dotTrace作為探查器,同時使用它的跟蹤模式,因為它返回的數據中給了我各個模組、類和程式碼的執行時間以及調用次數。通常,我可以僅從這些細節中推斷出很多關於系統性能的原因。
看看下面的統計數據,這是連接實際處理過程中的成本細分:
展開耗費CPU最多的System code,如下所示:
您可以看到FlushAsync()
方法耗費的CPU做多。我們在這裡做一個假設,當我們調用StreamWriter
的FlushAsync()
方法時,同樣會刷新底層的流。深入研究下調用棧,似乎我們在TCP層面為每個命令都都進行了分包,這樣效率是很低的。
如果我們將StreamWriter
的AutoFlush
屬性改為true
,這將導致它立即向網路流中寫入數據,但不會在TCP流上調用flush
,這會讓TCP流更有效的利用緩衝空間。
涉及的程式碼更改是刪除FlushAsync()
調用並初始化StreamWiter
,如下所示:
using var writer = new StreamWriter(stream)
{
NewLine = "\r\n",
AutoFlush = true,
};
讓我們再次運行基準測試,這將給我們(在我的開發機器上):
- 138,979.57 QPS
[13.8w/s]
– 使用 AutoFlush = true - 139,653.98 QPS
[13.9w/s]
– 使用 FlushAsync
基本上,這兩種選擇都不怎麼樣。原因如下所示:
設置為True的AutoFlush不僅會刷新當前流,還會刷新基礎流,從而使Stream他們處於相同的Position。
問題是我們需要刷新流,否則我們在記憶體中緩衝的結果數據不會發送給客戶端。Redis基準測試在很大成都依賴管道(一次性發送多個命令),但是在實際過程中可能會收到一堆來自客戶端的命令,這堆命令會寫入(到輸入緩衝區),然後不向客戶端發送任何內容,因為輸出的緩衝區並沒有滿。我們可以使用以下程式碼更改輕鬆地優化它:
var line = await reader.ReadLineAsync();
await writer.FlushAsync();
// 修改為以下程式碼
var lineTask = reader.ReadLineAsync();
if(lineTask.IsCompleted == false)
{
await writer.FlushAsync();
}
var line = await lineTask
我在這裡所做的是直接寫入StreamWriter
,並且只有在沒有更多的輸入時才刷新緩衝區。這應該會大大減少包的發送次數,而且它確實做到了。再次運行基準測試可以得出以下結論:
- 229,783.30 QPS
[22.9w/s]
– 使用延時刷新
我們只修改幾行程式碼,卻得到了幾乎兩倍的性能提升,這是令人影響深刻的。我們的想法是,緩衝更多的寫入,並且不讓它延時太久。如果寫入足夠的數據到StreamWriter
緩衝區,它自己會自動的刷新。我們只會在沒有其它需要讀取的數據時手動刷新StreamWriter
,這個操作是和讀取並行進行的。
下圖是新的耗時統計:
實際方法調用如下:
如果我們將其與第一次分析結果進行比較,我們可以發現一些非常有趣的數字。以前,我們為每個命令調用FlushAsync
(請參閱ExecuteCommand&FlushAsync),現在我們更少調用它了。
您可以看到,現在大部分時間花費都在這個系統的「業務邏輯程式碼」中,從子系統的細分來看,現在很多時間都花費在處理集合中。
這裡的GC花費也大幅下降(~5%)。我相當確定這是因為我們使用了新的方式刷新TCP流,但我沒有仔細的去檢查它。
請注意,雖然字元串處理和GC需要花費大量時間,但是集合/ExecuteCommand還是佔用了更多的時間。
如果我們調查一下,我們會發現:
而且這非常有趣。
主要是因為主要成本在TryAddInternal
中。我們知道在這種情況下存在很高的爭用,但92%的時間直接花在了這個方法上嗎?讓我們看一下程式碼,它在做什麼就會很明顯:
ConcurrentDictionary
對鎖之間的調用進行分片。鎖的數量由我們默認擁有的CPU內核數量定義。我們的的並發越多,我們就越能從增加分片數量中獲益。我嘗試將其設置為1024,並在分析器下運行它,這給我帶來了幾個百分點的改進,但並不是很多。很有價值,但不是我期望的水平。
現在,我們需要找出如何在讓集合操作變得更快,但我們還必須考慮總體GC成本以及字元串處理細節。在下一篇文章中會有更多關於這一點的資訊。