Chấm dứt Race Condition: Hướng dẫn về Redis Lua Scripting

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Cơn ác mộng kho hàng lúc 2:14 sáng

Điện thoại của tôi rung bần bật trên tủ đầu giường vào đúng 2:14 sáng. Một chương trình flash sale vừa mới bắt đầu, và 5.000 người dùng đang truy cập vào một mã SKU duy nhất cùng một lúc. Mặc dù chúng tôi đã sử dụng Redis để tăng tốc độ, nhưng nhật ký hệ thống vẫn xuất hiện tình trạng tồn kho âm. Hai tiến trình cùng kiểm tra kho, thấy còn ‘1’ sản phẩm, và cả hai đều thực hiện giảm số lượng. Chúng tôi đã bán lố 42 mặt hàng chỉ trong ba phút. Đó là một lỗi race condition kinh điển khiến chúng tôi mất hàng giờ để dọn dẹp thủ công.

Tôi đã dành nhiều năm làm việc với MySQL, PostgresMongoDB. Mỗi loại đều có chỗ đứng riêng. Nhưng khi bạn cần tốc độ tuyệt đối và đảm bảo tính nguyên tử (atomicity), Redis là tiêu chuẩn vàng. Tuy nhiên, các lệnh cơ bản không phải lúc nào cũng đủ cho các logic phức tạp. Đêm đó, tôi nhận ra rằng việc chuyển logic từ application server vào trong Redis bằng Lua script là cách duy nhất để có thể ngủ yên giấc trở lại.

Bắt đầu nhanh: Lua script đầu tiên của bạn

Redis chạy trực tiếp các Lua script trên server bằng lệnh EVAL. Quy tắc quan trọng nhất? Đừng bao giờ viết cứng (hardcode) các key của bạn. Hãy truyền chúng dưới dạng tham số để Redis có thể quản lý chúng chính xác, đặc biệt là trong chế độ cluster.

Đây là cú pháp cơ bản:

EVAL "script" numkeys key1 key2 arg1 arg2

Hãy thử lệnh này trong redis-cli của bạn:

EVAL "return 'Giá trị của ' .. KEYS[1] .. ' là ' .. ARGV[1]" 1 mykey myvalue

Trong Lua, chỉ mục của bảng (table indexing) bắt đầu từ 1, không phải 0. KEYS là một bảng toàn cục cho các key của bạn, và ARGV chứa các đối số bổ sung. Việc tách biệt chúng giúp Redis biết chính xác script sẽ tác động đến những key nào ngay cả trước khi nó chạy.

Giải quyết lỗi kho hàng

Chúng ta có thể khắc phục race condition theo kiểu “kiểm tra rồi mới đặt” (check-then-set) bằng cách đóng gói logic vào một script. Điều này đảm bảo không có lệnh nào khác có thể xen vào giữa quá trình kiểm tra và giảm số lượng.

-- inventory.lua
local current_stock = tonumber(redis.call('GET', KEYS[1]))
if current_stock and current_stock > 0 then
    return redis.call('DECR', KEYS[1])
else
    return -1
end

Chạy script này từ terminal của bạn như sau:

redis-cli --eval inventory.lua stock_count , 0

Tại sao phải bận tâm với Lua?

Đợi đã, tại sao không dùng transaction với MULTI/EXEC? Transaction nhóm các lệnh lại với nhau, nhưng chúng không thể lấy kết quả của bước một để quyết định phải làm gì ở bước hai. Bạn sẽ cần đến WATCH, vốn sử dụng cơ chế khóa lạc quan (optimistic locking). Khi có sự tranh chấp cao, WATCH thất bại liên tục, buộc ứng dụng của bạn phải thử lại (retry) nhiều lần.

1. Đảm bảo tính nguyên tử (Atomicity)

Redis là đơn luồng (single-threaded) trong quá trình thực thi lệnh. Khi Lua script của bạn bắt đầu, nó sẽ chạy cho đến khi hoàn tất mà không bị gián đoạn. Không có client nào khác có thể sửa đổi dữ liệu của bạn cho đến khi script kết thúc. Điều này biến một quy trình nhiều bước thành một lệnh nguyên tử duy nhất.

2. Giảm thiểu độ trễ mạng

Hãy tưởng tượng một chuỗi thao tác yêu cầu năm lệnh GET và ba lệnh SET. Nếu application server của bạn cách instance Redis 10ms, bạn sẽ mất 80ms chỉ cho thời gian truyền tải. Lua đưa logic đến nơi có dữ liệu. Bạn gửi một yêu cầu, và server thực hiện toàn bộ công việc nặng nhọc, cắt giảm độ trễ mạng đó xuống còn 10ms.

3. Hiệu quả băng thông

Sử dụng SCRIPT LOAD để lưu trữ script của bạn trên server. Redis sẽ trả về một mã băm SHA1. Từ đó về sau, bạn chỉ cần gửi mã băm 40 ký tự đó thay vì toàn bộ nội dung script. Điều này tiết kiệm đáng kể băng thông cho các lời gọi hàm tần suất cao.

# Tải script một lần
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# Trả về: "4e6d8fc8addea77f986083e81283fdec454c4e24"

# Chạy script bằng mã hash
EVALSHA 4e6d8fc8addea77f986083e81283fdec454c4e24 1 mykey

Logic nâng cao: Không chỉ là tăng số đơn giản

Môi trường production thường yêu cầu nhiều hơn là các phép toán cơ bản. Gần đây tôi đã thay thế một bộ giới hạn tốc độ (rate limiter) ở cấp ứng dụng bằng Lua. Phiên bản gốc yêu cầu ba lượt khứ hồi (round-trip) Redis cho mỗi request và đã gặp khó khăn ở mức 10.000 request mỗi giây.

Bộ giới hạn tốc độ (Rate Limiter) động

Script này theo dõi số lượng request và thiết lập thời gian hết hạn cho các cửa sổ thời gian (window) mới chỉ trong một lần chạy.

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)

if current == 1 then
    redis.call('EXPIRE', key, window)
end

if current > limit then
    return 0 -- Bị từ chối
end

return 1 -- Được phép

Lưu ý việc sử dụng redis.call(). Nếu một lệnh thất bại, script sẽ dừng và trả về lỗi. Nếu bạn cần xử lý lỗi một cách khéo léo, hãy sử dụng redis.pcall() thay thế. Nó trả về một bảng Lua chứa thông báo lỗi thay vì dừng thực thi.

Cập nhật JSON trực tiếp

Nếu bạn lưu trữ các chuỗi JSON, bạn có thể sử dụng thư viện cjson tích hợp sẵn. Đây là cứu cánh để cập nhật một trường duy nhất trong một đối tượng lớn mà không cần tải xuống và tải lên lại toàn bộ chuỗi.

local data = redis.call('GET', KEYS[1])
local obj = cjson.decode(data)
obj["last_login"] = ARGV[1]
redis.call('SET', KEYS[1], cjson.encode(obj))

Bài học thực chiến

Việc triển khai hàng tá Lua script đã dạy cho tôi một vài bài học xương máu. Hãy tuân thủ các quy tắc này để tránh bị gọi dậy lúc 2 giờ sáng.

  • Chú ý thời gian: Một script chạy lâu sẽ chặn toàn bộ server Redis. Nếu một script vượt quá 5 giây (mặc định của lua-time-limit), Redis sẽ bắt đầu từ chối các lệnh khác. Hãy giữ logic của bạn ở mức O(1) hoặc O(N) với N nhỏ.
  • Đảm bảo tính nhất quán (Deterministic): Các bản sao (replica) của Redis chạy script để giữ đồng bộ. Tránh sử dụng math.random() hoặc lấy thời gian hệ thống bên trong script. Nếu master và replica tạo ra các giá trị khác nhau, dữ liệu của bạn sẽ bị sai lệch. Thay vào đó, hãy truyền timestamp dưới dạng đối số.
  • Ghi log để kiểm soát: Bạn không thể sử dụng print(). Hãy dùng redis.log(redis.LOG_NOTICE, "Giá trị: " .. val) để ghi vào file log của Redis.
  • Luôn sử dụng ‘local’: Nếu bạn quên từ khóa local, các biến sẽ trở thành biến toàn cục. Điều này gây rò rỉ bộ nhớ và các lỗi kỳ quái khi lần thực thi script này làm rò rỉ dữ liệu sang lần thực thi sau.

Kiểm thử không sợ hãi

Đừng đoán xem script của bạn có hoạt động hay không. Hãy sử dụng trình gỡ lỗi Redis Lua (LDB). Nó cho phép bạn bước qua từng dòng code và kiểm tra các biến trong thời gian thực.

redis-cli --ldb --eval myscript.lua key1 , arg1

Lua scripting biến Redis từ một kho lưu trữ đơn giản thành một công cụ cơ sở dữ liệu có thể lập trình được. Nó lấp đầy khoảng trống giữa tốc độ thuần túy và sự an toàn phức tạp. Một khi bạn chuyển logic nguyên tử của mình sang Lua, bạn sẽ ngừng lo lắng về race condition và bắt đầu tập trung vào việc xây dựng các tính năng.

Share: