在高併發場景下,Redis 的效能瓶頸往往來自於頻繁的網路請求和資料函式庫操作。本文將介紹如何使用 Lua 指令碼最佳化 Redis 效能,以自動完成和市場交易為例,並探討分片 LIST 的實作與最佳化技巧。藉由 Lua 指令碼,我們可以將多個 Redis 指令封裝成一個原子操作,減少網路往返次數,並避免競爭條件造成的效能損耗。

使用 Lua 指令碼提升 Redis 效能:以自動完成與市場範例為例

在前面的章節中,我們探討瞭如何使用 Redis 實作自動完成(autocomplete)功能以及建立一個簡單的市場(marketplace)。然而,這些實作都面臨了效能上的挑戰,尤其是在高並發的情況下,傳統的 WATCHMULTIEXEC 事務處理方式可能會導致效能瓶頸或因競爭條件(contention)而出現錯誤。本章將介紹如何利用 Redis 的 Lua 指令碼功能來提升這些範例的效能。

11.3.1 提升自動完成效能

首先,我們來看看如何使用 Lua 指令碼改進自動完成功能。傳統的自動完成實作依賴於 ZSET 來儲存候選詞,並使用 ZRANGEZREM 等命令來管理和更新這些候選詞。然而,在高並發環境下,這些操作可能會因為競爭條件而導致效能下降。

使用 Lua 指令碼最佳化自動完成

Lua 指令碼允許我們將多個 Redis 操作組合成一個原子操作,從而避免了因競爭條件而導致的錯誤。以下是最佳化後的 Lua 自動完成指令碼範例:

local function autocomplete(conn, prefix)
    local candidates = conn:call('ZRANGE', 'autocomplete:' .. prefix, 0, -1)
    return candidates
end

然而,實際上我們的 Lua 指令碼需要處理更複雜的邏輯,例如檢查候選詞是否存在、更新候選詞的權重等。下面是一個更完整的範例:

local function add_to_autocomplete(conn, prefix, item)
    local key = 'autocomplete:' .. prefix
    conn:call('ZINCRBY', key, 1, item)
end

local function remove_from_autocomplete(conn, prefix, item)
    local key = 'autocomplete:' .. prefix
    conn:call('ZREM', key, item)
end

效能比較

透過將自動完成邏輯轉移到 Lua 指令碼中,我們可以顯著減少客戶端與 Redis 之間的往返次數,從而提升效能。測試結果表明,使用 Lua 指令碼後的自動完成功能在 10 個客戶端的情況下,效能提升了超過 20 倍。

11.3.2 進一步最佳化市場範例

接下來,我們將目光轉向市場範例。市場範例涉及買家購買商品的過程,包括檢查商品是否可用、買家是否有足夠的資金、轉移商品所有權以及更新買賣雙方餘額等操作。這些操作在傳統實作中需要使用鎖或 WATCHMULTIEXEC 來確保原子性。

使用 Lua 指令碼最佳化市場操作

透過使用 Lua 指令碼,我們可以將這些操作組合成一個原子操作,從而避免使用鎖或事務處理。以下是最佳化後的 Lua 指令碼範例,用於處理商品購買:

local price = tonumber(redis.call('zscore', KEYS[1], ARGV[1]))
local funds = tonumber(redis.call('hget', KEYS[2], 'funds'))

if price and funds and funds >= price then
    redis.call('hincrby', KEYS[3], 'funds', price)
    redis.call('hincrby', KEYS[2], 'funds', -price)
    redis.call('sadd', KEYS[4], ARGV[2])
    redis.call('zrem', KEYS[1], ARGV[1])
    return true
end

這個指令碼首先檢查商品價格和買家資金是否足夠,如果滿足條件,則執行資金轉移和商品所有權轉移的操作。

內容解密:

  1. local price = tonumber(redis.call('zscore', KEYS[1], ARGV[1])):取得商品的價格。

    • redis.call('zscore', KEYS[1], ARGV[1]):呼叫 Redis 的 ZSCORE 命令,取得指定商品在有序集合 KEYS[1] 中的分數,即價格。
    • tonumber(...):將結果轉換為數字型別,以便後續比較。
  2. local funds = tonumber(redis.call('hget', KEYS[2], 'funds')):取得買家的可用資金。

    • redis.call('hget', KEYS[2], 'funds'):呼叫 Redis 的 HGET 命令,取得買家 HASH 中 funds 欄位的值,即可用資金。
    • tonumber(...):將結果轉換為數字型別。
  3. if price and funds and funds >= price then:檢查商品價格是否存在、買家是否有足夠的資金。

    • 如果價格或資金任一不存在(即為 nil),則條件不滿足,指令碼結束。
    • 如果買家的資金少於商品價格,則條件不滿足,指令碼結束。
  4. redis.call('hincrby', KEYS[3], 'funds', price)redis.call('hincrby', KEYS[2], 'funds', -price):更新賣家和買家的資金。

    • HINCRBY 命令用於對 HASH 中指定欄位的值進行增減操作。這裡將賣家的資金增加商品價格,同時將買家的資金減少相同的金額。
  5. redis.call('sadd', KEYS[4], ARGV[2]):將商品新增到買家的庫存中。

    • SADD 命令用於將一個或多個元素新增到集合中。這裡將商品 ID 新增到買家的庫存集合中。
  6. redis.call('zrem', KEYS[1], ARGV[1]):從市場的有序集合中移除已售出的商品。

    • ZREM 命令用於從有序集合中移除指定的元素。這裡移除已售出的商品,以避免重複出售。
  7. return true:如果所有操作成功執行,則傳回 true 表示購買成功。

效能比較

測試結果顯示,使用 Lua 指令碼後的市場範例在 5 個列出商品的程式和 5 個購買程式的情況下,效能提升了超過 4.25 倍,平均購買延遲降低到 1 毫秒以內。

未來,我們可以進一步探索使用 Lua 指令碼最佳化其他 Redis 使用案例,例如複雜的資料處理流程、實時資料分析等。同時,也可以研究如何結合 Redis 的其他功能,如發布/訂閱(Pub/Sub)、流(Streams)等,以構建更高效、更靈活的資料處理系統。

圖表翻譯:

此圖表展示了不同最佳化方法對市場範例效能的影響。可以看到,使用 Lua 指令碼後,系統的吞吐量顯著提高,延遲大幅降低。

  graph LR;
    A["原始實作"] -->|低效能|> B["使用鎖"];
    B -->|效能提升|> C["使用細粒度鎖"];
    C -->|進一步提升|> D["使用 Lua 指令碼"];
    D -->|最高效能|> E["最終最佳化結果"];

圖表翻譯: 此圖示展示了從原始實作到最終使用 Lua 指令碼最佳化的過程,每一步都帶來了效能的提升。最終,使用 Lua 指令碼達到了最高的系統效能。

使用Lua實作分片LIST

在第9.2和9.3節中,我們已經對HASH、SET甚至STRING進行了分片,以減少記憶體的使用。在第10.3節中,我們對ZSET進行了分片,以允許搜尋索引超過單台機器的記憶體並提高效能。如同在9.2節中所承諾的,本文將建立一個分片LIST,以減少長LIST的記憶體使用。我們將支援在LIST的兩端進行推入和彈出操作,並且支援阻塞和非阻塞的彈出操作。

結構化分片LIST

為了以允許在兩端進行推入和彈出操作的方式儲存分片LIST,我們需要儲存第一個和最後一個分片的ID,以及LIST分片本身。為了儲存有關第一個和最後一個分片的資訊,我們將兩個數字儲存為標準的Redis字串。這些鍵的名稱將分別為<listname>:first<listname>:last。每當分片LIST為空時,這兩個數字將相同。圖11.1展示了第一個和最後一個分片ID的結構。

此外,每個分片將被命名為<listname>:<shardid>,並且分片將被順序分配。更具體地說,如果從左端彈出專案,那麼當專案被推入右端時,最後一個分片索引將增加,並且將使用具有更高分片ID的分片。同樣,如果從右端彈出專案,那麼當專案被推入左端時,第一個分片索引將減少,並且將使用具有更低分片ID的分片。圖11.2展示了作為相同分片LIST的一部分的一些示例分片。

向分片LIST推入專案

事實證明,我們將執行的最簡單的操作之一是向分片LIST的任一端推入專案。由於Redis 2.6中阻塞彈出操作的語義發生了一些小的變化,我們需要做一些工作,以確保不會意外地使分片溢位。在討論程式碼時,我將對此進行解釋。

為了向分片LIST的任一端推入專案,我們必須首先透過將資料分成塊來準備要傳送的資料。這是因為如果我們正在傳送到分片LIST,我們可能知道總容量,但不知道是否有客戶端正在等待對該LIST的阻塞彈出操作,因此對於大型LIST推入操作,我們可能需要多次傳遞。

準備好資料後,我們將其傳遞給底層的Lua指令碼。在Lua中,我們只需要找到第一個/最後一個分片,然後將專案推入該LIST,直到它滿了,並傳回被推入的專案的數量。用於向分片LIST的任一端推入專案的Python和Lua程式碼如下所示。

def sharded_push_helper(conn, key, *items, **kwargs):
    items = list(items)
    total = 0
    while items:
        pushed = sharded_push_lua(conn, [key+':', key+':first', key+':last'], [kwargs['cmd']] + items[:64])
        total += pushed
        del items[:pushed]
    return total

def sharded_lpush(conn, key, *items):
    return sharded_push_helper(conn, key, *items, cmd='lpush')

def sharded_rpush(conn, key, *items):
    return sharded_push_helper(conn, key, *items, cmd='rpush')

sharded_push_lua = script_load('''
local max = tonumber(redis.call('config', 'get', 'list-max-ziplist-entries')[2])
if #ARGV < 2 or max < 2 then return 0 end
local skey = ARGV[1] == 'lpush' and KEYS[2] or KEYS[3]
local shard = redis.call('get', skey) or '0'
while 1 do
    local current = tonumber(redis.call('llen', KEYS[1]..shard))
    local topush = math.min(#ARGV - 1, max - current - 1)
    if topush > 0 then
        redis.call(ARGV[1], KEYS[1]..shard, unpack(ARGV, 2, topush+1))
        return topush
    end
    shard = redis.call(ARGV[1] == 'lpush' and 'decr' or 'incr', skey)
end
''')

程式碼解析:

  1. sharded_push_helper函式:該函式負責將專案推入分片LIST。它首先將專案列表轉換為Python列表,然後進入迴圈,不斷呼叫Lua指令碼,直到所有專案都被推入。

  2. sharded_lpush和sharded_rpush函式:這兩個函式分別用於向LIST的左端和右端推入專案。它們呼叫sharded_push_helper函式,並指定相應的命令(lpushrpush)。

  3. Lua指令碼(sharded_push_lua):該指令碼首先取得LIST的最大容量。如果沒有要推入的專案或最大容量太小,則傳回0。然後,它根據推入命令(lpushrpush)確定要推入的分片,並計算可以推入當前分片的專案數量。如果可以推入,則執行推入操作並傳回推入的專案數量。否則,它會更新分片ID並重試。

內容解密:

  • Lua指令碼使用redis.call函式與Redis進行互動。
  • local max = tonumber(redis.call('config', 'get', 'list-max-ziplist-entries')[2]):取得Redis組態中的list-max-ziplist-entries值,該值決定了LIST的最大容量。
  • local skey = ARGV[1] == 'lpush' and KEYS[2] or KEYS[3]:根據推入命令確定要使用的鍵(firstlast)。
  • local shard = redis.call('get', skey) or '0':取得當前分片ID,如果不存在則預設為0。
  • while 1 do ... end:無限迴圈,直到能夠推入專案或更新分片ID。

分片LIST的限制

在本章的前面,我提到為了正確檢查分片資料函式庫中的鍵(例如在未來的Redis叢集中),我們應該將所有將被修改的鍵作為KEYS引數傳遞給Redis指令碼。但是,由於我們事先不知道要寫入哪些分片,因此在這裡無法做到這一點。因此,這個分片LIST只能包含在單個實際的Redis伺服器上,而不能分散到多個伺服器上。

未來可以進一步最佳化分片LIST的實作,例如支援跨多個Redis伺服器的分散式分片,或者改進現有的推入和彈出操作的效能。此外,還可以探索其他資料結構的分片實作,以進一步提高Redis的記憶體使用效率。

  graph LR;
    A[開始] --> B{檢查是否有專案要推入};
    B -->|是| C[呼叫sharded_push_helper];
    B -->|否| D[結束];
    C --> E[準備資料];
    E --> F[呼叫Lua指令碼];
    F --> G{檢查是否可以推入專案};
    G -->|是| H[推入專案];
    G -->|否| I[更新分片ID];
    H --> J[傳回推入的專案數量];
    I --> F;

圖表翻譯: 此圖示展示了向分片LIST推入專案的流程。首先檢查是否有專案要推入,如果有,則呼叫sharded_push_helper函式。該函式準備資料並呼叫Lua指令碼,Lua指令碼檢查是否可以推入專案。如果可以,則執行推入操作並傳回推入的專案數量;否則,更新分片ID並重試。