Hình dung một siêu thị chỉ mở đúng một quầy thu ngân. Ngày thường ai cũng qua nhanh — vài món, quẹt thẻ, xong. Rồi một người đẩy tới cái xe chất ú hàng, và cả hàng dài phía sau đứng im: người chỉ cầm một chai nước cũng phải đợi hết cái xe đầy kia được tính xong. Redis xử lý lệnh bằng đúng một quầy như vậy — một luồng duy nhất — nên một lệnh chậm không chỉ chậm cho người gọi nó, nó chặn đầu hàng của tất cả những ai đến sau. Bài này đo cái hàng chờ đó dài ra bao nhiêu khi một lệnh nặng chen vào.
Phần 2 đo được Redis xử lý lệnh bằng một luồng duy nhất. Ta đo hậu quả.
Phép thử
Hai khách hàng: một gõ PING liên tục và ghi lại độ trễ, một gõ lệnh nặng. Cơ sở dữ liệu có 2 triệu khoá.
| Lệnh nặng chạy song song | Lệnh đó mất | PING p99 |
PING max |
|---|---|---|---|
| (không có gì) | — | 0,344 ms | 1,0 ms |
KEYS 'k:1*' |
0,42 s | 0,924 ms | 216,7 ms |
KEYS '*' |
0,60 s | 1,577 ms | 263,8 ms |
SCAN --count 1000 |
0,95 s | 0,561 ms | 4,2 ms |
Một lệnh KEYS đẩy độ trễ tệ nhất từ 1 ms lên 263,8 ms. Trong 263 mili giây đó, không khách hàng nào được phục vụ. Với một hệ thống chạy 50.000 lệnh mỗi giây, đó là khoảng 13.000 yêu cầu xếp hàng chờ — và mỗi cái trong số đó là một người dùng đang đợi. Đó là lý do KEYS không nên gọi là "lệnh chậm". Nó là lệnh làm sập dịch vụ.
SCAN chậm hơn mà tốt hơn
SCAN mất 0,95 giây để duyệt hết 2 triệu khoá — chậm hơn 58% so với KEYS '*'. Nhưng độ trễ tệ nhất chỉ 4,2 ms, ít hơn 63 lần.
Lý do: SCAN trả về một lô nhỏ rồi nhường lại luồng. Mỗi lô là một lệnh riêng, và giữa các lô Redis phục vụ mọi khách hàng khác — đúng người mua nhiều tự giác để người sau chen vào giữa. Tổng công việc nhiều hơn — phải quản lý con trỏ, phải quay lại nhiều lần — nhưng nó chia nhỏ ra. Đây là đánh đổi đúng gần như luôn luôn: thông lượng tổng ít quan trọng hơn độ trễ tệ nhất, vì độ trễ tệ nhất là thứ người dùng cảm nhận.
Tham số COUNT điều chỉnh mức chia: lớn hơn thì duyệt nhanh hơn nhưng mỗi lô chặn lâu hơn. Mặc định là 10 — thường quá nhỏ; 100 đến 1000 là khoảng hợp lý. Ba điều cần biết về SCAN:
- Nó không bảo đảm không lặp: một khoá có thể xuất hiện hai lần. Xử lý phải chịu được điều đó.
- Nó bảo đảm mọi khoá tồn tại suốt quá trình duyệt sẽ được trả về ít nhất một lần.
MATCHlọc sau khi lấy lô, nênSCAN MATCH 'hiem:*' COUNT 10vẫn phải duyệt hết bảng và trả về gần như toàn lô rỗng. Mẫu hiếm thì tăngCOUNT.
DEL còn tệ hơn KEYS
Một Hash 3.000.000 trường, 194 MB:
DEL 0,54 giây PING max 467,7 ms
UNLINK 0,08 giây PING max 3,6 ms
DEL chặn 467,7 ms — tệ hơn cả KEYS '*'. Lý do: giải phóng 3 triệu đối tượng là 3 triệu lần gọi free, và tất cả chạy trên luồng xử lý lệnh. Xoá là công việc, không phải phép màu.
UNLINK gỡ khoá khỏi không gian tên ngay lập tức rồi giao việc giải phóng cho luồng nền. Khoá biến mất tức thì với mọi khách hàng; bộ nhớ trả về vài mili giây sau. Đổi DEL thành UNLINK là một lần tìm-thay-thế trong mã nguồn, và nó bỏ đi 130 lần độ trễ chặn. Với khoá nhỏ, hai lệnh hoàn toàn như nhau — Redis tự chọn giải phóng ngay khi đối tượng đủ bé. Tương tự với FLUSHALL và FLUSHDB: cả hai nhận ASYNC.
Danh sách lệnh cần tránh trong production
| Lệnh | Thay bằng |
|---|---|
KEYS pattern |
SCAN |
SMEMBERS trên tập lớn |
SSCAN |
HGETALL trên hash lớn |
HSCAN, hoặc HMGET đúng trường cần |
LRANGE key 0 -1 |
LRANGE theo trang |
DEL khoá lớn |
UNLINK |
FLUSHALL |
FLUSHALL ASYNC |
SORT trên tập lớn |
Sắp xếp ở phía ứng dụng |
| Script Lua vòng lặp dài | Chia nhỏ thành nhiều lần gọi |
Điểm chung: mọi lệnh có độ phức tạp O(N) với N là số phần tử trong khoá hoặc số khoá trong cơ sở dữ liệu đều là ứng viên. Tài liệu Redis ghi độ phức tạp cho từng lệnh — đó là chỗ đáng đọc trước khi dùng lệnh lạ. Và Lua đáng nhắc riêng: script chạy nguyên tử, nghĩa là không lệnh nào khác chen vào được suốt thời gian nó chạy. Một vòng lặp 100.000 vòng trong Lua chặn đúng như một lệnh KEYS.
Một chỗ dễ nhầm
Độ trễ trung bình hầu như không đổi trong mọi phép đo ở trên: p50 dao động 0,074 đến 0,123 ms. Nếu bảng điều khiển của bạn chỉ vẽ độ trễ trung bình, một lệnh KEYS chạy mỗi phút sẽ hoàn toàn vô hình — nó chỉ ảnh hưởng vài chục yêu cầu trong hàng chục nghìn. Nhưng vài chục người dùng đó chờ một phần tư giây. Luôn đo p99 và max.
Mẫu số chung: một worker đơn, một tác vụ dài chặn cả hàng phía sau
Cái ta vừa đo có tên trong thiết kế hệ thống: head-of-line blocking (chặn đầu hàng). Khi một worker đơn xử lý một hàng đợi tuần tự, một tác vụ O(N) dài không chỉ tốn thời gian của chính nó — nó bắt mọi tác vụ ngắn xếp sau chờ đúng thời gian nó chạy, kể cả những tác vụ đáng lẽ xong trong micro-giây. Và lời giải gần như luôn giống nhau: không phải làm tác vụ dài nhanh hơn, mà là chia nó thành lô có nhường (cooperative chunking) để giữa các lô, việc ngắn được chen vào — đúng cái SCAN làm so với KEYS.
Trong thế giới Java, đây đúng là kỷ luật của Netty EventLoop: một luồng phục vụ nhiều kết nối, nên không bao giờ được chặn nó bằng việc dài — công việc nặng phải đẩy sang một thread pool riêng, y như Redis đẩy giải phóng bộ nhớ sang luồng nền với UNLINK. Node.js còn thẳng thớm hơn: vòng lặp sự kiện đơn luồng, một callback đồng bộ nặng làm đứng toàn bộ máy chủ, và cách chữa là chia nhỏ bằng setImmediate hay đẩy sang worker_threads.
Còn ở cơ sở dữ liệu, đây là một truy vấn quét toàn bảng giữ tài nguyên và làm chậm mọi truy vấn khác; lời giải là phân trang keyset (WHERE id > ? LIMIT n) thay vì lấy hết một lần — chính xác là cái SCAN con-trỏ làm, và là lý do LRANGE 0 -1 nên đổi thành LRANGE theo trang.
Mẫu số chung: trên một worker xử lý tuần tự, một tác vụ dài chặn đầu hàng làm mọi việc ngắn phía sau chờ đúng thời gian nó chạy — nên đừng tối ưu bằng cách làm nó nhanh hơn, hãy chia nó thành lô có nhường để việc ngắn xen vào được; và trung bình giấu kín chuyện này, phải đo đuôi (p99, max). Câu hỏi đúng khi thêm một thao tác nặng vào một hệ đơn-luồng không phải "nó chạy mất bao lâu" mà là "trong lúc nó chạy, mọi thứ khác đứng lại bao lâu, và tôi có chia nhỏ nó được không". Với Redis là SCAN/UNLINK; với event loop là chunking/offload; với CSDL là phân trang.
Muốn tự kiểm có ai đang gọi lệnh nguy hiểm trên hệ thống của bạn:
# theo doi truc tiep trong 10 giay, loc lenh dang ngo
timeout 10 redis-cli monitor | grep -iE '"(keys|flushall|flushdb|smembers|hgetall|lrange|sort|del)"' | head -20
# va xem nhat ky lenh cham
redis-cli config set slowlog-log-slower-than 1000
sleep 60
redis-cli slowlog get 20
MONITOR cũng tốn tài nguyên vì nó nhân bản mọi lệnh tới khách hàng của bạn — chạy vài giây rồi tắt, đừng để mở. Mỗi dòng in ra kèm tên khoá và địa chỉ khách hàng, đủ để tìm ra mã nguồn nào đang gọi. Và redis-cli --latency / --latency-history cho bức tranh tổng: con số max ở đó chính là thứ đo trong bài — nếu nó nhảy lên hàng trăm mili giây theo chu kỳ, hãy tìm một công việc định kỳ đang chạy KEYS.
Phần sau: đường ống — đo chính xác nó bỏ đi bao nhiêu lượt đi về, và giới hạn của nó ở đâu.