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, Postgres và MongoDB. 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ùngredis.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.

