Redis系列 - C#存取Redis (中)

C#存取Redis

延續上一篇Redis系列 - C#存取Redis (上),這篇文章會說明Redis的簡易Publish/Subscribe、Lua script的基礎知識。

PUBLISH/SUBSCRIBE

Redis的PUB/SUB是很簡易的訊息機制,並不像一些專門的Message Middleware(例如RabbitMQ、ActiveMQ)提供很多豐富的功能,比如它就不保證訊息發送後一定不會遺失,但因為附帶的這個功能實在太方便了,實務上還是經常使用到。

StackExchange.Redis對PUB/SUB的支援很完整,範例程式碼如下

RedisConnection的程式碼請參考Redis系列 - C#存取Redis (上)

StackExchange.Redis PUB/SUB 範例程式碼

RedisConnection.Init("localhost:6379");
var redis = RedisConnection.Instance.ConnectionMultiplexer;
var db = redis.GetDatabase(0);
var sub = redis.GetSubscriber();
Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss.fff")} - Subscribed channel topic.test ");
sub.Subscribe("topic.test", (channel, message) =>
{
    Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss.fff")} - Received message {message}");
});

redis.GetDatabase().Publish("topic.test", "Hello World!");
Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss.fff")} - Published message to channel topic.test");

執行結果: Pub/Sub result

PUB/SUB的實作結構特性

使用Redis的PUB/SUB要注意幾件事,同樣的Channel如果不先呼叫Unsubscribe()就呼叫Subscribe(),每Publish一次就會收到重覆的message。

這是因為Channel在Redis的實作上是一個Dictionary的結構,Key值是Channel,Value是一個Linked List,這個Linked List存的是有訂閱這個Channel的Client Id,因此每呼叫一次Subscribe(),就會往這個Linked List增加一個Client Id。

graph LR
    subgraph channel
        A(topic.test.1)-->B
        subgraph linked clients
            B-->B1
            B1-->B2
            B2-->B3
        end
        C(topic.test.2)-->D
        subgraph linked clients
            D-->D1
            D1-->D2
            D2-->D3
        end
    end

由於使用Linked List的關係,訊息傳遞的時間複雜度是O(N),如果同一個Channel有很多個Subscriber,排愈後面的Subscriber就會愈慢收到,但這個問題可以用多個Channel來解決。

非同步的訊息順序問題

另外StackExchange.Redis預設也提供了Publish()PublishAsync(),但實際呼叫時會發現結果似乎是一樣的

for (int i = 0; i < 10; i++)
{
    redis.GetDatabase().PublishAsync("topic.test", $"{i} Hello World!");
    Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss.fff")} - Published message to channel topic.test");
}

執行結果: Pub Async result

這是因為StackExchange.Redis預設是讓訊息的發送與接收在同一個Connection維持一致的順序,這像是queue的概念,這樣做的好處是有利於簡化訊息的處理順序問題,但缺點是必須一個一個的發送與接收,如果訊息量較大時,就會造成delay。

因此若不需要關心每個訊息的處理順序,可以調整一個設定值,讓PublishAsync()發揮非同步的能力

redis.PreserveAsyncOrder = false;
for (int i = 0; i < 10; i++)
{
    redis.GetDatabase().PublishAsync("topic.test", $"{i} Hello World!");
    Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss.fff")} - Published message to channel topic.test");
}

執行結果: Pub Async result 2

不限制非同步訊息的順序,會提昇訊息處理的Throughput,但就要自己控制訊息對系統狀態的影響。

Output buffer Hard limit/Soft limit對PUB/SUB的影響

最後還有一個Pub/Sub的坑要特別提一下,在使用Pub/Sub時,如果Publisher的發送量大於Subscriber的消化速度的話,會有機會被Redis關閉client端的連線。

Redis對每一個連上來的client端都有一個output buffer的設定,這是因為Redis的In-memory處理速度太快,而Network的I/O處理太慢,因此需要一個緩衝區讓Redis處理完請求後先將結果放進去,就可以繼續處理下一個請求。

Output buffer分成三種,預設值如下:

client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

可以看到pubsub有特別獨立出來一個設定值,其中的32mb是hard limit,8mb是soft limit,60是soft limit的時間限制。這個設定值的意思是如果pubsub的訊息大於32mb時,Redis會立刻關閉該client的連線,8mb與60是指如果達到8mb且超過60s,就會關閉該client的連線。

Lua script

Redis在2.6版就加入了對Lua script的支援,Lua script是一種非常簡單的腳本語言,被廣泛的用在很多軟體內做為內嵌語言,遊戲引擎內就很常看到它的出現。

使用Lua script的好壞處都很明顯,個人覺得是有些兩面刃的味道,用的好可以完全發揮Redis的優點,用不好也可以徹底搞死Redis…。

適用場景

還是要回到Redis的架構設計來看,因為單執行緒的設計,在運行Lua script的時候是沒辦法處理其他的請求的,所以Lua script並不能像Database的Stored Procedure一樣運行複雜的商務邏輯,它適用的場景是:

  • 組合出Redis沒有支援的command
  • Atomic的資料操作
  • Transaction
  • 避免多次請求來回浪費掉的round-trip network latency

蠻多工程師一開始接觸Redis並不清楚這個部份,發現Lua script的一些優點後,就會把Lua script當成DB的SQL在用,寫了一堆Lua script像是在寫stored procedure,放了很多複雜的商業邏輯在內,在開發環境可能還不會遇到問題,但在上到正式環境後就陸續發生一大堆效能問題,這時要再緊急上的Patch就很容易出現其他的bug。

StackExchange.Redis 執行Lua script 範例程式碼

使用StackExchange.Redis呼叫Lua script的方式有好幾種不同的方式,使用上都是大同小異。

我寫了一個示範用的lua script,功能是先產生100筆key,然後依據查詢條件找出符合的key,這邊傳入的參數我用@key@value,稍後在C#的code就會用到了,請存成script.lua並放到你的專案中。

local cursor = 0
local values = {}
local match = @key
local count = @value
local result = {}
local done = false
-- 這個迴圈會寫入100個key
for i=1,100 do
    redis.call("set", "test:"..i, i+1)
end

-- 呼叫 SCAN, 並依據傳入的key pattern與每次查詢筆數, 逐次讀取符合條件的key存入table
repeat
    local searchResult = redis.call("SCAN", cursor, "MATCH", match, "COUNT", count)
    cursor = searchResult[1];
    values = searchResult[2];
    for i, val in ipairs(values) do
        table.insert(result, val)
    end
    if cursor == "0" then
        done = true;
    end
until done

--排序後回傳結果
table.sort(result)
return result

C#的部份我也寫了一個RunLuaScript()的私有靜態方法,請注意傳入的參數名稱key = (RedisKey)"test:*", value = 5,這必須跟Lua script接的@key@value同名,這是LuaScript class提供的功能,如果型別是RedisKey的話,會幫你轉成KEYS[],否則的話轉成ARGV[]

static void Main(string[] args)
{
    var redisHost = "localhost:6379";
    RedisConnection.Init(redisHost);
    var redis = RedisConnection.Instance.ConnectionMultiplexer;
    var db = redis.GetDatabase(0);
    var result = (string[])RunLuaScript(redis, db, redisHost).Result;
    foreach (var item in result)
    {
        Console.WriteLine(item);
    }

    Console.Read();
}

private static async Task<RedisResult> RunLuaScript(ConnectionMultiplexer redis, IDatabase db, string defaultServer)
{
    return await LuaScript
        .Prepare(File.ReadAllText("script.lua"))
        .Load(redis.GetServer(defaultServer))
        .EvaluateAsync(db, new { key = (RedisKey)"test:*", value = 5 });
}

這段程式碼還有呼叫了Load(),這會把Lua script先載入指定的Redis server,一般指定Master就可以了,會自動replicate到Slave。載入後會拿到一個SHA1 hash code,之後執行時只需傳入這個code,不需重傳整份Lua script,對需要頻繁執行的script有效能上的幫助。

Lua script的Debug方式

Lua script因為是運行在Redis上,所以在3.2版推出之前,Debug一直是比較麻煩的部份,但作者在3.2版提供了debug mode,方便開發人員在開發階段進行除錯。

testDebug.lua

local key = KEYS[1]
local data = ARGV[1]
redis.call("SET", key, data)
return redis.call("GET", key)

我寫了一個很簡單的Lua script並存成testDebug.lua,接下來要透過Redis-cli這支命令列程式來執行這個script,但參數加上--ldb,這是告訴Redis接下來以debug模式執行這支Lua script。

docker exec -it redis redis-cli --ldb --eval /tmp/lua/testDebug.lua "foo" , "bar"

執行後會進入debug模式,接下來可以輸入s單步執行,或是輸入p顯示所有的變數值。

Debug模式提供了蠻多的指令方便開發人員除錯,但要注意不要在正式環境執行--ldb,因為Debug模式是一種阻斷模式,所有的請求都會被這個session阻塞住,可想而之在正式環境加上這個參數絕對會搞死系統的(如果你的系統邊緣到根本沒人用就又另當別論了…)。

jed@Jed-MacBook-Pro:~/Workspace/code/lua% docker exec -it redis redis-cli --ldb --eval /tmp/lua/testDebug.lua "foo" , "bar"
Lua debugging session started, please use:
quit    -- End the session.
restart -- Restart the script in debug mode again.
help    -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1   local key = KEYS[1]
lua debugger> s
* Stopped at 2, stop reason = step over
-> 2   local data = ARGV[1]
lua debugger> s
* Stopped at 3, stop reason = step over
-> 3   redis.call("SET", key, data)
lua debugger> p
<value> key = "foo"
<value> data = "bar"
lua debugger>

除了運行--ldb以外,當然還有其他的方式可以幫助除錯,例如:

  • 在Lua script內呼叫redis.log(),把需要記錄的除錯訊息寫入Redis的log file
  • 用List當log,因為List是一直append的,符合log的特性
  • 使用專門的Lua script editor協助除錯,例如ZeroBrandStudio

ZeroBrandStudio是一個蠻好用的Lua script IDE,使用方式可以參考我同事小黑寫的這篇Write Redis Lua Script with ZeroBrane Studio

因為我個人比較習慣用VSCode,所以就沒有用ZeroBrandStudio,但它用來除錯的確蠻方便的。

小結

這一篇儘量把PUB/SUB與Lua script基礎的部份講完,包含了一些實務上可能會遇到的問題,因內容有些多,所以Trouble shooting的部份就留待下一篇再講了。如果有任何意見,歡迎留言討論。

上篇Redis系列 - C#存取Redis (下)
下篇Redis系列 - C#存取Redis (上)