返回文章列表

Lua 指令碼提升 Redis 效能案例分析

本文探討如何利用 Lua 指令碼提升 Redis 效能,以自動完成和市場交易兩個實際案例,展示 Lua 指令碼如何減少競爭條件並提升系統吞吐量。文章同時分析 Lua 指令碼的優缺點,並提供分片 LIST 的實作細節與效能最佳化策略。

資料函式庫 效能最佳化

在高併發場景下,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 指令碼後,系統的吞吐量顯著提高,延遲大幅降低。

圖表翻譯: 此圖示展示了從原始實作到最終使用 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的記憶體使用效率。

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Lua 指令碼提升 Redis 效能案例分析

package "資料庫架構" {
    package "應用層" {
        component [連線池] as pool
        component [ORM 框架] as orm
    }

    package "資料庫引擎" {
        component [查詢解析器] as parser
        component [優化器] as optimizer
        component [執行引擎] as executor
    }

    package "儲存層" {
        database [主資料庫] as master
        database [讀取副本] as replica
        database [快取層] as cache
    }
}

pool --> orm : 管理連線
orm --> parser : SQL 查詢
parser --> optimizer : 解析樹
optimizer --> executor : 執行計畫
executor --> master : 寫入操作
executor --> replica : 讀取操作
cache --> executor : 快取命中

master --> replica : 資料同步

note right of cache
  Redis/Memcached
  減少資料庫負載
end note

@enduml

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