該原文是Ayende Rahien大佬業餘自己在使用C# 和 .NET構建一個簡單、高效能相容Redis協定的資料庫的經歷。
首先這個"Redis"是非常簡單的實現,但是他在優化這個簡單"Redis"路程很有趣,也能給我們在從事效能優化工作時帶來一些啟示。
原作者:Ayende Rahien
原連結:https://ayende.com/blog/197412-B/high-performance-net-building-a-redis-clone-naively
我遇到了這個專案,它的目標是成為一個比Redis有著更好效能和更易用的克隆版。我發現它很有趣,因為它主要的賣點之一就是它是在多執行緒模式下執行(而不是像Redis那樣是單執行緒)。他們使用memtier_benchmark(Redis專案的一部分)來測試效能。所以我很好奇,如果我使用C#來構建自己的Redis克隆版,會有怎麼樣的效能?
我構建的第一個版本非常簡單。我的想法是使用高抽象的API來編寫它,看看它的效能到底怎麼樣。為了使事情變得有趣,下面是它的測試方案:
memtier_benchmark –s $SERVER_IP -t 8 -c 16 --test-time=30 --distinct-client-seed -d 256 --pipeline=30
上面的命令說明我們將使用8個執行緒(使用者端範例上的CPU核心數),每個執行緒建立32個連結,20%的場景寫入,80的場景讀取,資料大小為256位元組,將不斷的把更多的資料推播到測試的範例中。
伺服器端使用以下命令執行:
dotnet run –c Release
以下是此測試在伺服器的範例:
我選擇30秒作為測試的持續時間,以收集更多的資訊讓我們感受正在發生的事情(比如GC週期),同時保持測試的持續時間足夠短,這樣我不會感覺到無聊。
以下是簡單版本的測試結果:
因此,使用C#構建的簡單版本,即使什麼優化都不做,也有幾乎100w/s的效能。從另外的角度來說,延時並不是那麼的好。P99延時將近100ms。
現在我用數位和漂亮的圖表引起了你的注意,讓我向你展示我正在執行的實際程式碼。這是一個不到100行程式碼的「Redis克隆」。
using System.Collections.Concurrent;
using System.Net.Sockets;
var listener = new TcpListener(System.Net.IPAddress.Any, 6379);
listener.Start();
var redisClone = new RedisClone();
while (true)
{
var client = listener.AcceptTcpClient();
var _ = redisClone.HandleConnection(client); // run async
}
public class RedisClone
{
ConcurrentDictionary<string, string> _state = new();
public async Task HandleConnection(TcpClient client)
{
using var _ = client;
using var stream = client.GetStream();
using var reader = new StreamReader(stream);
using var writer = new StreamWriter(stream)
{
NewLine = "\r\n"
};
try
{
var args = new List<string>();
while (true)
{
args.Clear();
var line = await reader.ReadLineAsync();
if (line == null) break;
if (line[0] != '*')
throw new InvalidDataException("Cannot understand arg batch: " + line);
var argsv = int.Parse(line.Substring(1));
for (int i = 0; i < argsv; i++)
{
line = await reader.ReadLineAsync();
if (line == null || line[0] != '$')
throw new InvalidDataException("Cannot understand arg length: " + line);
var argLen = int.Parse(line.Substring(1));
line = await reader.ReadLineAsync();
if (line == null || line.Length != argLen)
throw new InvalidDataException("Wrong arg length expected " + argLen + " got: " + line);
args.Add(line);
}
var reply = ExecuteCommand(args);
if(reply == null)
{
await writer.WriteLineAsync("$-1");
}
else
{
await writer.WriteLineAsync($"${reply.Length}\r\n{reply}");
}
await writer.FlushAsync();
}
}
catch (Exception e)
{
try
{
string? line;
var errReader = new StringReader(e.ToString());
while ((line = errReader.ReadLine()) != null)
{
await writer.WriteAsync("-");
await writer.WriteLineAsync(line);
}
await writer.FlushAsync();
}
catch (Exception)
{
// nothing we can do
}
}
}
string? ExecuteCommand(List<string> args)
{
switch (args[0])
{
case "GET":
return _state.GetValueOrDefault(args[1]);
case "SET":
_state[args[1]] = args[2];
return null;
default:
throw new ArgumentOutOfRangeException("Unknown command: " + args[0]);
}
}
}
只是關於實現的幾個注意事項。我實際上並沒有做太多事情。大部分程式碼用於解析 Redis 協定。程式碼充滿了記憶體分配。每個命令解析都是使用多個字串拆分和連線來完成的。對使用者端的回覆需要更多的連線。系統的「儲存」實際上只是一個簡單的 ConcurrentDictionary,沒有任何避免鎖競爭或高成本的東西。
我們處理I/O的方式非常糟糕,而且......我想你明白我的想法,對吧?我的目標是看看如何使用這個(非常簡單的)範例來獲得更高的效能,而不必處理很多額外的細節。
鑑於我最初的嘗試已經接近100萬QPS,這是一個非常好的開始,即使我自己這麼說。
我想採取的下一步是處理這裡多餘的記憶體分配。我們也許可以在記憶體分配這方面做得更好,雖然我的目標只是嘗試。但我將在下一篇文章中這樣做。