Phần 2 đo được đường ống cho hơn bốn mươi lần thông lượng trên một kết nối. Bài này tìm điểm dừng của nó, và cái giá phải trả.
Thông lượng theo cỡ đường ống
200.000 lệnh GET, khách chạy ở container khác:
| Cách gọi | ops/s | So với không đường ống |
|---|---|---|
| Từng lệnh một | 20.229 | 1× |
| Đường ống 10 | 151.718 | 7,5× |
| Đường ống 100 | 513.421 | 25,4× |
| Đường ống 1.000 | 773.911 | 38,3× |
| Đường ống 10.000 | 969.426 | 47,9× |
| Đường ống 100.000 | 473.477 | 23,4× |
MGET 100 khoá mỗi lệnh |
744.050 | 36,8× |
Lợi ích lớn nhất đến sớm: đi từ 1 lên 10 đã cho 7,5 lần. Từ 1.000 lên 10.000 chỉ thêm 25%.
Đường ống quá lớn thì chậm lại
Đường ống 100.000 cho 473.477 ops/s — chỉ bằng một nửa đường ống 10.000.
Tôi chạy lại vì tưởng đo nhầm. Không nhầm.
Lô quá lớn phải được gom trong bộ nhớ ở cả hai phía: khách dựng một khối yêu cầu hàng megabyte, máy chủ dựng một khối phản hồi tương ứng, và cả hai phải sao chép qua lại giữa các bộ đệm. Chi phí đó vượt qua phần tiết kiệm được từ việc bỏ lượt đi về — mà lượt đi về thì đã gần như bỏ hết từ mức 1.000 rồi.
Khoảng hợp lý là 100 đến 10.000. Dưới 100 thì chưa khai thác hết; trên 10.000 thì bắt đầu trả nhiều hơn nhận.
MGET nhanh hơn đường ống cùng cỡ
Đây là kết quả tôi không lường trước.
MGET 100 khoá 744.050 ops/s
Đường ống 100 lệnh 513.421 ops/s
Cùng số khoá, cùng một lượt đi về, mà MGET nhanh hơn 45%.
Lý do: đường ống là 100 lệnh riêng biệt. Redis phải phân tích 100 khối RESP, tra 100 lần trong bảng lệnh, và sinh 100 phản hồi độc lập. MGET là một lệnh với 100 tham số: một lần phân tích, một lần tra, một phản hồi mảng.
Quy tắc rút ra: nếu có lệnh nhiều khoá sẵn, dùng nó thay vì đường ống.
GET -> MGET
SET -> MSET
HGET -> HMGET
SADD nhiều phần tử, ZADD nhiều cặp, RPUSH nhiều giá trị
Đường ống dành cho những gì không có dạng nhiều khoá: một chuỗi INCR, EXPIRE, HSET trên các khoá khác nhau.
Và hai cách kết hợp được: đường ống chứa các lệnh MGET.
Đường ống lớn không làm khách khác đói
P=100 PING max 9,5 ms
P=10.000 PING max 10,6 ms
P=100.000 PING max 2,0 ms
Tôi trông đợi một đường ống 100.000 lệnh sẽ chặn máy chủ như KEYS ở phần trước. Nó không.
Redis đọc bộ đệm vào theo từng đoạn và xử lý những lệnh đã hoàn chỉnh, rồi quay lại vòng lặp sự kiện. Một đường ống lớn trở thành nhiều lô nhỏ, và giữa các lô mọi khách hàng khác vẫn được phục vụ.
Đây là khác biệt quan trọng so với script Lua: script chạy nguyên tử, không nhường luồng. Chuyển một vòng lặp từ đường ống sang Lua để "nhanh hơn" có thể đổi một thao tác thân thiện thành một thao tác chặn.
Cái giá nằm ở bộ nhớ máy chủ
mem_clients_normal đỉnh 2.662.283 byte
client-output-buffer-limit normal 0 0 0
Một đường ống 100.000 lệnh làm máy chủ giữ 2,66 MB bộ đệm cho riêng khách đó — và giá trị của tôi chỉ 100 byte.
Dòng thứ hai là chỗ đáng lo: giới hạn bộ đệm đầu ra cho khách thường là 0 0 0, tức không giới hạn. Redis đặt giới hạn cho bản sao và cho khách Pub/Sub, nhưng không cho khách thường.
Nghĩa là một khách gửi đường ống một triệu GET trên giá trị 1 KB sẽ buộc máy chủ dựng 1 GB bộ đệm — và nếu khách đọc chậm, bộ đệm đó nằm đó. Với maxmemory đã đặt, việc này có thể đẩy Redis vào trạng thái đuổi khoá hoặc từ chối ghi vì một khách hàng cư xử tệ.
Đặt giới hạn nếu bạn không kiểm soát được mọi khách hàng:
redis-cli config set client-output-buffer-limit "normal 268435456 134217728 60"
Ba số: cứng 256 MB, mềm 128 MB trong 60 giây. Vượt là Redis đóng kết nối đó — thà mất một khách còn hơn mất cả máy chủ.
Bốn điều nên nhớ khi dùng đường ống
Đường ống không phải giao dịch. Các lệnh chạy tuần tự nhưng lệnh của khách khác có thể chen vào giữa. Cần nguyên tử thì dùng MULTI/EXEC hoặc Lua — phần sau của sê-ri đo cả hai.
Không đọc được kết quả giữa chừng. Mọi lệnh phải quyết định trước khi gửi. Cần lệnh sau phụ thuộc kết quả lệnh trước thì đường ống không giúp được; đó là lúc Lua có ích.
Chia lô cố định. Đừng gom "tất cả" rồi gửi một lần. Chia theo lô 1.000 giữ cho bộ nhớ ở cả hai phía có trần.
Đo trên chính mạng của bạn. Con số 47,9 lần đo qua mạng ảo Docker. Qua mạng thật độ trễ mỗi lượt lớn hơn nhiều, nên lợi ích của đường ống còn lớn hơn — và điểm tối ưu dịch về phía lô lớn hơn.
Thử ba mươi giây
Xem ứng dụng của bạn có đang bỏ phí lượt đi về không:
redis-cli info stats | grep -E 'total_commands_processed|total_net_input_bytes|total_net_output_bytes'
redis-cli info clients | grep -E 'connected_clients|client_recent_max_input_buffer'
Chia total_net_input_bytes cho total_commands_processed: ra số byte trung bình mỗi lệnh. Con số đó gần bằng kích thước một lệnh đơn lẻ nghĩa là không ai đang dùng đường ống.
Và client_recent_max_input_buffer bằng vài chục byte cũng nói điều tương tự — chưa có khách nào từng gửi một lô lớn. Nếu ứng dụng của bạn có vòng lặp đọc hàng trăm khoá, đó là chỗ dễ lấy lại vài lần thông lượng nhất.
Phần sau: MULTI/EXEC — đo xem giao dịch của Redis thật sự bảo đảm gì.