Lua Scripts trong Redis: Atomic Operations nâng cao

Photo of author

Văn Ngọc Tân

Bạn có biết?

Khi bạn cần thực hiện một logic phức tạp trong Redis — ví dụ: “giảm stock chỉ khi còn hàng, đồng thời tăng sold và ghi log” — các lệnh thông thường không đảm bảo atomicity giữa nhiều bước. Đây là lúc Lua Scripts phát huy sức mạnh, cho phép chạy toàn bộ logic như một lệnh duy nhất.

Lua Scripts là gì?

Redis tích hợp sẵn một Lua interpreter, cho phép bạn viết script bằng ngôn ngữ Lua và thực thi trực tiếp trên server. Toàn bộ script chạy như một atomic operation — không có lệnh nào khác xen vào giữa chừng.

So với MULTI/EXEC transactions:

  • True atomicity — Không ai có thể đọc dữ liệu giữa chừng
  • Conditional logic — if/else, loops, biến tạm
  • Ít round-trips — Gửi 1 request thay vì nhiều
  • Reusable — Script được cache trên server

Cú pháp cơ bản

EVAL — Thực thi script trực tiếp

# EVAL script numkeys key [key ...] arg [arg ...]
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "myvalue"
# OK

# Giải thích:
# - "return redis.call('SET', KEYS[1], ARGV[1])": script Lua
# - 1: số lượng keys (KEYS[1])
# - mykey: giá trị của KEYS[1]
# - "myvalue": giá trị của ARGV[1]

KEYS và ARGV

# KEYS[]: Danh sách keys (truyền vào numkeys đầu tiên)
# ARGV[]: Danh sách arguments (sau keys)

# Ví dụ: 2 keys, 1 arg
EVAL "
  local val1 = redis.call('GET', KEYS[1])
  local val2 = redis.call('GET', KEYS[2])
  return val1 .. ':' .. val2 .. ':' .. ARGV[1]
" 2 key1 key2 "suffix"
# "value1:value2:suffix"

redis.call vs redis.pcall

# redis.call: Ném lỗi nếu có lỗi (script dừng lại)
EVAL "return redis.call('GET', 'nonexistent:key')" 0
# (nil)

# redis.pcall: Bắt lỗi và trả về error object
EVAL "return redis.pcall('INCR', 'mykey')" 0
# Nếu mykey không phải số: {err="..."}

EVALSHA — Tối ưu băng thông

Mỗi lần dùng EVAL, Redis phải nhận toàn bộ nội dung script qua mạng. Nếu script dài và được gọi hàng nghìn lần mỗi giây, lượng bandwidth tiêu tốn rất lớn. EVALSHA giải quyết vấn đề này bằng cách chỉ gửi SHA1 hash (40 ký tự) thay vì toàn bộ script.

Flow hoạt động:

  • 🔹 Bước 1: SCRIPT LOAD "script_content" → Redis trả về SHA1 hash và cache script trên server
  • 🔹 Bước 2: EVALSHA <sha1_hash> numkeys ... → Chỉ gửi 40 ký tự hash, tiết kiệm băng thông đáng kể
  • 🔹 Nếu script chưa cache: Redis trả lỗi NOSCRIPT → Ứng dụng fallback về EVAL để nạp lại script
# Bước 1: Nạp script lên server, nhận về SHA1 hash
SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])"
# Trả về: "6b1bf4865ea630eb61b4e6d61a65e8e8d9"

# Bước 2: Dùng EVALSHA thay vì EVAL (chỉ gửi hash 40 ký tự)
EVALSHA 6b1bf4865ea630eb61b4e6d61a65e8e8d9 1 mykey "myvalue"
# OK — tiết kiệm bandwidth vì không gửi lại toàn bộ script

# Nếu script chưa được cache, Redis trả lỗi:
EVALSHA "nonexistent_sha1" 0
# (error) NOSCRIPT No matching script. Please use EVAL.

# Pattern xử lý NOSCRIPT trong ứng dụng:
try:
    result = r.evalsha(script_sha, 1, "mykey", "myvalue")
except redis.exceptions.NoScriptError:
    # Script chưa cache, fallback EVAL
    result = r.eval(script_content, 1, "mykey", "myvalue")

So sánh bandwidth: Giả sử script dài 500 bytes, gọi 10.000 lần/giây → EVAL tốn ~5MB/s bandwidth, còn EVALSHA chỉ tốn ~400KB/s (40 bytes × 10.000). Tiết kiệm hơn 12 lần!

Server room với nhiều máy chủ xử lý dữ liệu song song
Lua Scripts chạy trên Redis server — toàn bộ logic được thực thi atomic mà không bị can thiệp

EVALSHA — Tối ưu hiệu năng

Thay vì gửi toàn bộ script mỗi lần, bạn có thể cache script trên server và chỉ gửi SHA hash:

# Bước 1: Tính SHA1 hash của script
# (Redis tự động cache khi dùng EVAL)

# Bước 2: Dùng EVALSHA với hash
EVALSHA 6b1bf4865ea630eb61b4e6d61a65e8e8d9 1 mykey "myvalue"

# Nếu script chưa được cache, Redis trả lỗi NOSCRIPT
# Lúc đó fallback về EVAL

# SCRIPT LOAD: Nạp script trước
SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])"
# "6b1bf4865ea630eb61b4e6d61a65e8e8d9"

# Sau đó dùng EVALSHA
EVALSHA 6b1bf4865ea630eb61b4e6d61a65e8e8d9 1 mykey "myvalue"

Ví dụ thực tế

1. Atomic Stock Decrease

-- Giảm stock chỉ khi còn hàng
-- KEYS[1]: product:stock
-- KEYS[2]: product:sold
-- ARGV[1]: số lượng cần giảm

local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
local amount = tonumber(ARGV[1])

if stock >= amount then
    redis.call('DECRBY', KEYS[1], amount)
    redis.call('INCRBY', KEYS[2], amount)
    return 1  -- Thành công
else
    return 0  -- Không đủ hàng
end
# Sử dụng
EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
local amount = tonumber(ARGV[1])
if stock >= amount then
    redis.call('DECRBY', KEYS[1], amount)
    redis.call('INCRBY', KEYS[2], amount)
    return 1
else
    return 0
end" 2 product:555:stock product:555:sold 1
# 1 = thành công, 0 = hết hàng

2. Rate Limiter (Sliding Window)

-- Rate limiter: giới hạn N requests trong M giây
-- KEYS[1]: rate:user:1001
-- ARGV[1]: max requests (100)
-- ARGV[2]: window in seconds (60)
-- ARGV[3]: current timestamp

local key = KEYS[1]
local max_requests = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- Xóa requests cũ hơn window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- Đếm requests hiện tại
local current = redis.call('ZCARD', key)

if current < max_requests then
    -- Thêm request mới
    redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
    redis.call('EXPIRE', key, window)
    return 1  -- Cho phép
else
    return 0  -- Từ chối
end

3. Distributed Lock (Redlock đơn giản)

-- Acquire lock
-- KEYS[1]: lock key
-- ARGV[1]: unique value (process ID)
-- ARGV[2]: TTL in seconds

local lock_key = KEYS[1]
local unique_value = ARGV[1]
local ttl = tonumber(ARGV[2])

-- SET chỉ khi key chưa tồn tại (NX)
local result = redis.call('SET', lock_key, unique_value, 'NX', 'EX', ttl)

if result then
    return 1  -- Lock acquired
else
    return 0  -- Lock failed (đã bị chiếm)
end
-- Release lock (chỉ xóa nếu đúng owner)
-- KEYS[1]: lock key
-- ARGV[1]: unique value

local lock_key = KEYS[1]
local unique_value = ARGV[1]

if redis.call('GET', lock_key) == unique_value then
    redis.call('DEL', lock_key)
    return 1  -- Released
else
    return 0  -- Không phải owner
end

4. Atomic Counter với Reset

-- Tăng counter, reset nếu quá giới hạn
-- KEYS[1]: counter key
-- ARGV[1]: limit
-- ARGV[2]: reset value

local current = tonumber(redis.call('INCR', KEYS[1]) or 1)
local limit = tonumber(ARGV[1])

if current > limit then
    redis.call('SET', KEYS[1], ARGV[2])
    return tonumber(ARGV[2])
end

return current

5. Conditional Update

-- Cập nhật chỉ khi giá trị cũ khớp
-- KEYS[1]: key
-- ARGV[1]: expected old value
-- ARGV[2]: new value

local current = redis.call('GET', KEYS[1])

if current == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2])
    return 1  -- Updated
else
    return 0  -- Value changed by another process
end

Sử dụng với Programming Languages

Python

import redis

r = redis.Redis()

# Script Lua
stock_script = """
local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
local amount = tonumber(ARGV[1])
if stock >= amount then
    redis.call('DECRBY', KEYS[1], amount)
    redis.call('INCRBY', KEYS[2], amount)
    return 1
else
    return 0
end
"""

# Thực thi
result = r.eval(stock_script, 2, "product:555:stock", "product:555:sold", 1)

# Hoặc dùng EVALSHA (tối ưu)
script_sha = r.script_load(stock_script)
result = r.evalsha(script_sha, 2, "product:555:stock", "product:555:sold", 1)

Node.js

const redis = require('redis');
const client = redis.createClient();

// Script Lua
const rateLimitScript = `
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local current = redis.call('ZCARD', key)

if current < max then
    redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end
`;

// Thực thi
const allowed = await client.eval(
  rateLimitScript,
  { keys: ['rate:user:1001'], arguments: ['100', '60', Date.now().toString()] }
);

Lua Scripts vs Transactions

Tiêu chíTransactions (MULTI/EXEC)Lua Scripts
AtomicityPartial (không isolation)Full (true atomic)
Conditional logicCần WATCHTự nhiên (if/else)
Round-tripsNhiều (MULTI + commands + EXEC)1 (EVAL)
PerformanceNhanhNhanh hơn (ít round-trip)
DebuggingDễ hơnKhó hơn
Use caseBatch operations đơn giảnComplex logic, conditional

Best Practices

  1. Giữ script ngắn — Lua single-threaded, script dài block các client khác
  2. ⚠️ CẢNH BÁO: Script chạy quá lâu sẽ BLOCK toàn bộ server! — Redis chạy single-threaded, nghĩa là khi một Lua script đang thực thi, toàn bộ các client khác đều bị block, không thể xử lý bất kỳ lệnh nào. Tránh vòng lặp vô hạn, xử lý dữ liệu quá lớn, hoặc gọi các hàm tốn thời gian trong script. Nếu script chạy quá lâu, bạn có thể phải restart Redis server!
  3. Dùng EVALSHA — Tránh gửi lại script mỗi lần
  4. Không dùng biến global — Mỗi EVAL chạy trong môi trường riêng
  5. Kiểm tra nil — Luôn xử lý trường hợp key không tồn tại
  6. Script LOAD trước — Nạp script khi khởi động ứng dụng
  7. Test kỹ — Script lỗi có thể làm Redis crash

Bước tiếp theo

Bạn đã nắm vững Lua Scripts trong Redis! Tiếp theo, chúng ta sẽ tìm hiểu về Pipeline — cách tối ưu hiệu năng khi gửi nhiều lệnh cùng lúc.

👉 Bài tiếp theo: Pipeline trong Redis — Tối ưu hiệu năng batch operations

0 0 đánh giá
Đánh giá bài viết
Theo dõi
Thông báo của
guest
0 Góp ý
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất
Phản hồi nội tuyến
Xem tất cả bình luận