Hình dung một cây cầu chỉ có một làn xe. Chiếc xe nào đang ở trên cầu thì độc chiếm nó — và đây là điều thú vị: chính cái độc chiếm ấy là thứ khiến chuyến qua cầu "nguyên tử" (không xe nào chen ngang được), đồng thời là thứ khiến mọi xe khác phải chờ đúng bằng thời gian nó qua. Một chiếc tải rề rà mất ba phút là ba phút cả hàng xe sau đứng im. Và nếu nó chết máy giữa cầu sau khi đã dỡ nửa thùng hàng xuống, bạn cũng không kéo lui gọn được. Lua script trong Redis là chiếc xe trên cây cầu một làn ấy, và bài này đo cái giá của việc độc chiếm đó.
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ó. Ta đo cái giá của khả năng đó.
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 — đúng chiếc xe trên cầu một làn.
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 KILL và SHUTDOWN 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 — đúng chiếc xe chết máy giữa cầu sau khi đã dỡ hàng. 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 GET và DEL, 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
- 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.
- Không vòng lặp không có trần.
whilekhô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ụ. - Mọi khoá qua
KEYS. Cho cache script, cho Cluster, và cho việc đọc mã dễ hiểu. - 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.
Mẫu số chung: trên một làn đơn, độ độc chiếm và độ chặn là cùng một con số
Cái ta vừa đo — nguyên tử bằng đúng thời gian chặn — là quy luật của mọi vùng tới hạn trên một tài nguyên nối tiếp: khi chỉ một luồng thực thi được tại một thời điểm (một luồng Redis, một khoá độc quyền, một giao dịch giữ lock), thì thứ làm cho thao tác nguyên tử — độc chiếm tài nguyên — cũng chính là thứ bắt mọi người khác chờ. Giữ càng lâu thì chặn càng lâu; hai đại lượng ấy không tách rời. Nên nguyên tắc chung ở khắp nơi giống hệt lời khuyên viết Lua: giữ vùng tới hạn ngắn nhất có thể, và đừng bao giờ làm việc dài (I/O, vòng lặp lớn) bên trong nó.
Trong thế giới Java, đây đúng là kỷ luật về synchronized/Lock: giữ monitor càng lâu thì mọi luồng khác tranh khoá đó chờ càng lâu, và cái phản mẫu kinh điển là gọi I/O (đọc mạng, truy vấn CSDL) trong khi đang giữ khoá — y hệt một script Lua vừa ghi vừa chạy lâu. Lời giải là thu vùng đồng bộ về đúng phần cần nguyên tử.
Trong Go, giữ một sync.Mutex xuyên qua một lời gọi chậm chặn mọi goroutine cần khoá đó; quy ước là không giữ mutex qua ranh giới I/O.
Còn ở cơ sở dữ liệu, đây là một giao dịch dài giữ lock hàng: nó chặn mọi writer đụng tới hàng đó đúng bằng thời gian nó mở — nên transaction phải ngắn. Và Node.js là ví dụ gần Redis nhất: vòng lặp sự kiện đơn luồng, một callback đồng bộ nặng làm toàn bộ tiến trình đứng, đúng cái script Lua làm với Redis.
Mẫu số chung: trên một tài nguyên nối tiếp, tính nguyên tử và thời gian chặn là cùng một đại lượng — bạn không thể có cái này mà không trả cái kia — nên hãy giữ vùng độc chiếm ngắn nhất đủ để đúng, và đẩy mọi việc dài ra ngoài nó. Câu hỏi đúng khi viết một khối nguyên tử không phải "nó có nguyên tử không" mà là "trong lúc nó chạy, mọi thứ khác đứng lại bao lâu, và tôi đã cắt hết phần không cần nguyên tử ra khỏi đây chưa". Với Redis, đó là lý do Lua để giành cho tính nguyên tử, còn thông lượng thì để cho đường ống.
Muốn tự 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_scriptslên tới hàng nghìn — bạn đang sinh script động, và đó là chỗ rò bộ nhớ.EVALxuấ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.