Lua cho bạn tính nguyên tử mà MULTI không cho: đọc một giá trị rồi quyết định làm gì dựa trên nó. Bài này đo cái giá của khả năng đó.

Thời gian chặn theo độ dài script, hành vi BUSY, và bộ nhớ cache script

Script chạy bao lâu thì mọi người chờ bấy lâu

Số lệnh SET trong script Script chạy PING max của khách khác
200.000 0,24 giây 169,3 ms
1.000.000 0,97 giây 881,7 ms
3.000.000 3,02 giây 2.951,6 ms

Độ chặn gần như bằng đúng thời gian script chạy. Không có ngoại lệ, không có nhường luồng.

Đây là khác biệt cốt lõi so với đường ống. Ở phần 12, một đường ống 100.000 lệnh chỉ làm độ trễ tệ nhất lên 2,0 ms — vì Redis xử lý bộ đệm vào theo từng đoạn và quay lại vòng lặp sự kiện. Script Lua thì không: nó chạy tới hết.

Tính nguyên tử mà bạn nhận được chính là thời gian chặn mà mọi người khác phải chịu. Hai thứ đó là một.

Vòng lặp vô hạn: máy chủ không chết

Tôi chạy một script while true do ... end rồi thử gõ lệnh:

PING         -> BUSY Redis is busy running a script.
GET gk       -> BUSY ...You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
SCRIPT KILL  -> OK
PING         -> PONG

Redis không treo im lặng. Sau busy-reply-threshold — mặc định 5.000 ms — nó bắt đầu trả BUSY cho mọi lệnh, và nhận đúng hai lệnh: SCRIPT KILLSHUTDOWN NOSAVE.

Trước mốc 5 giây, khách hàng chỉ đơn giản là treo. Sau mốc đó, họ nhận lỗi rõ ràng và biết chuyện gì đang xảy ra.

Một điều quan trọng: SCRIPT KILL chỉ giết được script chưa ghi gì. Nếu script đã thực hiện một lệnh ghi, Redis từ chối giết nó — vì giết nửa chừng sẽ để lại trạng thái không nhất quán mà không có cách quay lui. Lúc đó lựa chọn duy nhất là SHUTDOWN NOSAVE, tức là mất mọi thứ chưa lưu.

Nghĩa là: script vừa ghi vừa chạy lâu là tình huống không có lối thoát tốt. Viết script sao cho phần ghi ngắn và chắc chắn kết thúc.

EVALSHA nhanh hơn EVAL

Script 4.037 byte:

EVAL      15.215 ops/s
EVALSHA   19.320 ops/s     +27%

Khác biệt duy nhất là 4 KB mã nguồn gửi lại ở mỗi lần gọi. Với script lớn và tần suất cao, đó là băng thông thật.

Mẫu chuẩn của mọi thư viện tốt: thử EVALSHA trước; nếu nhận NOSCRIPT thì gọi EVAL một lần để nạp rồi dùng EVALSHA từ đó. Kiểm tra xem thư viện của bạn có làm vậy không — không phải thư viện nào cũng làm.

Cache script không bao giờ tự dọn

0 script            192 byte
20.000 script   6.793.408 byte     340 byte mỗi script

Redis giữ mọi script từng nạp, vĩnh viễn. Không có chính sách đuổi, không có TTL. Chỉ SCRIPT FLUSH mới xoá.

Với script cố định thì không sao — vài chục script là vài chục KB. Vấn đề nằm ở mã sinh script động:

-- SAI: giá trị nội suy thẳng vào mã nguồn
return redis.call('SET', 'user:12345', 'ten-nguoi-dung')

Mỗi id khác nhau là một mã nguồn khác nhau, một SHA khác nhau, một mục cache mới. Một triệu người dùng là một triệu script trong cache — 340 MB không ai dọn.

-- ĐÚNG: dùng KEYS và ARGV
return redis.call('SET', KEYS[1], ARGV[1])

Một script duy nhất, một SHA duy nhất, tham số truyền vào lúc gọi.

Đây cũng là yêu cầu để script chạy đúng trên Redis Cluster: mọi khoá phải nằm trong KEYS để Redis biết định tuyến tới nút nào.

Khi nào Lua đáng dùng

Đáng, khi thao tác cần đọc rồi quyết định, và bạn muốn tránh vòng lặp WATCH:

-- giải phóng khoá phân tán an toàn: chỉ xoá nếu đúng chủ
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

Hai lệnh này phải nguyên tử — giữa GETDEL, khoá có thể hết hạn và bị người khác chiếm. Không có cách nào diễn đạt điều này bằng lệnh Redis sẵn có.

Không đáng, khi bạn chỉ muốn gộp nhiều lệnh cho nhanh. Đường ống làm việc đó với chi phí chặn gần bằng không. Ở phần 13 tôi đo Lua nhanh hơn đường ống 23% — nhưng 23% thông lượng đổi lấy nguy cơ chặn hàng giây là một đánh đổi tệ.

Ranh giới thực dụng: Lua cho tính nguyên tử, đường ống cho thông lượng. Dùng Lua vì lý do hiệu năng là dùng sai công cụ.

Bốn quy tắc viết script

  1. Ngắn. Đo được: mỗi lệnh trong script tốn khoảng 1 µs, và toàn bộ thời gian đó là thời gian chặn. Dưới 10.000 lệnh là khoảng an toàn.
  2. Không vòng lặp không có trần. while không điều kiện dừng rõ ràng là cách chắc chắn nhất làm sập dịch vụ.
  3. Mọi khoá qua KEYS. Cho cache script, cho Cluster, và cho việc đọc mã dễ hiểu.
  4. Không dùng nguồn ngẫu nhiên hay thời gian một cách tuỳ tiện. Redis 7 cho phép, nhưng script không tất định sẽ cho kết quả khác nhau giữa bản chính và bản sao nếu chúng được nhân bản theo mã. Redis hiện nhân bản hiệu ứng chứ không nhân bản script, nên vấn đề nhẹ hơn trước — nhưng thói quen tốt vẫn là giữ script tất định.

Thử ba mươi giây

Xem script nào đang tốn thời gian trên hệ thống của bạn:

redis-cli info memory | grep -E 'number_of_cached_scripts|used_memory_scripts_human'
redis-cli config set slowlog-log-slower-than 5000
sleep 60
redis-cli slowlog get 20 | grep -A2 -i eval

Hai dấu hiệu:

  • number_of_cached_scripts lên tới hàng nghìn — bạn đang sinh script động, và đó là chỗ rò bộ nhớ.
  • EVAL xuất hiện trong nhật ký lệnh chậm — mỗi dòng là một lần toàn bộ máy chủ đứng lại đúng bằng thời gian ghi trong đó.

Phần sau: Pub/Sub — đo xem bản tin gửi khi không ai nghe đi đâu.