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

譯者注

該原文是Ayende Rahien大佬業餘自己在使用C# 和 .NET構建一個簡單、高性能兼容Redis協議的數據庫的經歷。
首先這個"Redis"是非常簡單的實現,但是他在優化這個簡單"Redis"路程很有趣,也能給我們在從事性能優化工作時帶來一些啓示。
原作者:Ayende Rahien
原鏈接:
https://ayende.com/blog/197569-B/high-performance-net-building-a-redis-clone-skipping-strings

另外Ayende大佬是.NET開源的高性能多範式數據庫RavenDB所在公司的CTO,不排除這些文章是爲了以後會在RavenDB上兼容Redis協議做的嘗試。大家也可以多多支持,下方給出了鏈接
RavenDB地址:https://github.com/ravendb/ravendb

構建Redis克隆版-字符串處理

我克隆版Redis目前代碼中高成本的地方就是字符串的處理,下面的分析器圖表實際上有一些誤導:

字符串佔用了運行時的12.57%的時間,另外就是GC Wait, 我們需要清理掉這些開銷。這意味着我們之前寫的代碼是非常低效的。

我們的測試場景現在也只涉及 GET 和 SET 請求,沒有刪除、過期等。我提到這一點是因爲我們正在考慮用什麼來替換字符串。

最簡單的選擇是用字節數組替換它,但它仍然是託管內存,並且會產生與 GC 相關的成本。我們可以池化這些字節數組,但是我們還有一個重要的問題要回答,我們如何知道什麼時候不再使用池化的數組,也就是說,什麼什麼把它歸還到池中?

考慮以下一組事件流程:

在上面的例子中,線程2訪問了值緩衝區,但是在Time-3中我們使用SET abc命令替換了原來的數據,導致線程2訪問的不再是原來的數據。

我們需要找一個方法,將值緩衝區保留到沒有任何對象引用它的時候,另外在銷燬它時我們要將它歸還到池中。

我們可以通過手動管理內存的方式來實現這個,這是很可怕的。實際上我們可以使用一些不同的方式,比如利用GC來達到我們的目的。


public class ReusableBuffer
{
    public byte[] Buffer;
    public int Length;

    public Span<byte> Span => new Span<byte>(Buffer, 0, Length);

    public ReusableBuffer(byte[] buffer, int length)
    {
        Buffer = buffer;
        Length = length;
    }

    public override bool Equals(object? obj)
    {
        if (obj is not ReusableBuffer o)
            return false;
        return o.Span.SequenceEqual(Span);
    }

    public override int GetHashCode()
    {
        var hc = new HashCode();
        hc.AddBytes(Span);
        return hc.ToHashCode();
    }

    // 關鍵是這裏,聲明一個析構函數
    // 當GC需要釋放它的時候會調用
    ~ReusableBuffer()
    {
        ArrayPool<byte>.Shared.Return(Buffer);
    }
}

想法很簡單。我們有一個持有緩衝區的類,當 GC 注意到它不再被使用時,它將把它的緩衝區歸還到池中。這個想法是我們依靠 GC 來爲我們解決這個(真正困難的)問題。雖然這會將一些成本轉移到終結器,但是目前來說我們不必擔心這個問題。不然,你就得經歷很多困難來編寫手動管理內存的代碼。

ReusableBuffer類還實現了GetHashCode()/Equals(),它允許我們將其用作字典中的Key。

現在我們有了鍵和值的後臺存儲,讓我們看看如何從網絡讀寫。現在我將回到 ConcurrentDictionary 實現,一次只處理一個事情。

以前,我們使用 StreamReader/StreamWriter 來完成工作,現在我們將使用 System.IO.Pipelines 中的 PipeReader/PipeWriter。這將使我們能夠輕鬆地直接處理原始字節數據,並且這是爲高性能場景設計的。

我編寫了兩次代碼,一次使用可重用的緩衝區模型,一次使用 PipeReader/PipeWriter 並分配字符串。我驚訝地發現,我的可重用緩衝區的性能差距只有字符串實現的1% (簡單得多)。順便說一句,那是1%的錯誤方向。

在我的機器上,基於可重用的緩衝區是16.5w/s,而基於字符串的系統是每秒16.6w/s。

下面是基於可重用緩衝區的完整方法源代碼。比較一下,這是基於字符串的。基於字符串的代碼行比基於字符串的代碼行短50%左右。

我猜測是因爲我們這個場景的分配模式非常適合GC所做的那種啓發式處理。我們要麼有長期對象(在緩存中),麼有非常短期的對象。

值得指出的是,網絡中命令的實際解析並不使用字符串。只有實際的鍵和值實際上被轉換爲字符串。其餘部分使用原始字節數據。

下面是對字符串版本的代碼進行分析的結果:

使用可重用緩衝區也如下所示:

這裏有一些有趣的事情值得注意。ExecCommand 的成本幾乎是基於字符串版本嘗試的兩倍。深入挖掘,我相信錯誤就在這裏:

var buffer = ArrayPool<byte>.Shared.Rent((int)cmds[2].Length);
cmds[2].CopyTo(buffer);
var val = new ReusableBuffer(buffer, (int)cmds[2].Length);
Item newItem;
ReusableBuffer key;
if (_state.TryGetValue(_reusable, out var item))
{
    // can reuse key buffer
    newItem = new Item(item.Key, val);
    key = item.Key;
}
else
{
    var keyBuffer = ArrayPool<byte>.Shared.Rent((int)cmds[1].Length);
    cmds[1].CopyTo(keyBuffer);
    key = new ReusableBuffer(keyBuffer, (int)cmds[1].Length);
    newItem = new Item(key, val);
}
_state[key] = newItem;
WriteMissing();

這段代碼負責在字典中設置項。但是,請注意,我們正在對每個寫操作執行讀操作?這裏的想法是,如果我們現在_state中已經存在了這個值,那麼我們就避免再次爲它分配緩衝區,而是重用它。

但是,這段代碼處於這個基準測試的關鍵路徑中,代價相當高昂。我修改了這段代碼,不再重用,總是new對象進行分配,我們得到了一個比字符串版本快1~3%的版本。這看起來是這樣的:

換句話說,這是當前每次操作對應的性能表(在探查器下):

  • 1.57 ms - 基於字符串
  • 1.79 ms - 基於可重用緩衝區(減少內存使用量)
  • 1.04 ms - 基於可重用緩衝區(優化查找)

得出的那些結果都在我計算機使用分析器運行的。讓我們看看當我在生產實例上運行它們時,最終的結果是怎麼樣的?

  • 基於字符串 – 16.0w次/秒
  • 可重用緩衝區(減少內存代碼)– 18.6w次/秒
  • 可重用緩衝區(優化查找)– 17.5w次/秒

這些結果與我們在開發機器中看到的結果並不匹配。可能的原因是併發和請求數量足夠高,負載足夠大,以至於我們看到大規模內存優化的效果要好很多。

這是我能得出的唯一結論,減少分配內存,能夠在這樣的高負載場景下處理更多的請求。

系列鏈接

使用.NET簡單實現一個Redis的高性能克隆版(一)
使用.NET簡單實現一個Redis的高性能克隆版(二)
使用.NET簡單實現一個Redis的高性能克隆版(三)
使用.NET簡單實現一個Redis的高性能克隆版(四、五)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章