使用.NET簡單實現一個Redis的高效能克隆版(二)

2022-08-04 12:03:14

譯者注

該原文是Ayende Rahien大佬業餘自己在使用C# 和 .NET構建一個簡單、高效能相容Redis協定的資料庫的經歷。
首先這個"Redis"是非常簡單的實現,但是他在優化這個簡單"Redis"路程很有趣,也能給我們在從事效能優化工作時帶來一些啟示。
原作者:Ayende Rahien
原連結:https://ayende.com/blog/197441-A/high-performance-net-building-a-redis-clone-analysis
另外Ayende大佬是.NET開源的高效能多正規化資料庫RavenDB所在公司的CTO,不排除這些文章是為了以後會在RavenDB上相容Redis協定做的嘗試。大家也可以多多支援,下方給出了連結
RavenDB地址:https://github.com/ravendb/ravendb

正文

上一篇文章中,我用最簡單的方式寫了一個Redis克隆版本。它能夠在我們的測試範例上每秒命中近100萬個查詢(c6g.4xlarge,使用16個核心和64 GB記憶體)。在我們更深入地進行優化之前,值得了解CPU時間實際花費在哪裡。我在探查器下執行伺服器,以檢視各種程式碼所耗費的成本。
我喜歡使用dotTrace作為探查器,同時使用它的跟蹤模式,因為它返回的資料中給了我各個模組、類和程式碼的執行時間以及呼叫次數。通常,我可以僅從這些細節中推斷出很多關於系統效能的原因。
看看下面的統計資料,這是連線實際處理過程中的成本細分:

展開耗費CPU最多的System code,如下所示:

您可以看到FlushAsync()方法耗費的CPU做多。我們在這裡做一個假設,當我們呼叫StreamWriterFlushAsync()方法時,同樣會重新整理底層的流。深入研究下呼叫棧,似乎我們在TCP層面為每個命令都都進行了分包,這樣效率是很低的。
如果我們將StreamWriterAutoFlush屬性改為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成本以及字串處理細節。在下一篇文章中會有更多關於這一點的資訊。

系列連結

使用.NET簡單實現一個Redis的高效能克隆版(一)