Một nhà hàng có đúng một người bếp cực nhanh, và bạn thấy bàn ăn phục vụ chậm. Phản xạ đầu tiên là "thuê thêm bếp" — nhưng nếu nhìn kỹ, người bếp ấy phần lớn thời gian đứng chờ: bồi bàn chạy ra ghi một món, mang vào, đợi nấu xong, mang ra, rồi mới quay lại ghi món tiếp. Nút thắt không phải cái bếp, mà là quãng chạy đi chạy lại của bồi bàn. Gộp mười món vào một lần ghi, hoặc cho thêm vài bồi bàn chạy song song, và cái bếp một người ấy bỗng phục vụ gấp mấy chục lần — chẳng cần thêm bếp. Redis là cái bếp một người đó, và bài này đo xem nút thắt thật nằm ở đâu.
Redis xử lý lệnh bằng một luồng duy nhất. Trên máy 16 nhân, điều đó nghe như lãng phí. Ta đo.
Thông lượng theo số kết nối
| Kết nối | ops/s |
|---|---|
| 1 | 25.381 |
| 2 | 47.695 |
| 4 | 91.743 |
| 8 | 194.805 |
| 16 | 271.493 |
| 32 | 306.122 |
| 64 | 270.270 |
| 128 | 260.870 |
Một máy chủ một luồng mà tăng gần tuyến tính từ 1 lên 8 kết nối: 25.381 → 194.805, tức 7,7 lần cho 8 lần số kết nối. Nếu nút thắt là CPU của Redis thì con số phải phẳng ngay từ đầu — một luồng thì không xử lý được nhiều hơn dù có bao nhiêu khách. Nó không phẳng, nên nút thắt nằm ở chỗ khác. Đỉnh ở 32 kết nối, rồi giảm dần. Thêm khách sau điểm đó chỉ thêm việc chuyển ngữ cảnh và thêm socket phải theo dõi.
Nút thắt là số lượt đi về
Với một kết nối và không đường ống, mỗi lệnh là một chu kỳ đầy đủ: gửi, chờ, nhận. Trong lúc chờ, kết nối đó không có gì đang bay trên dây. Bằng chứng: giữ nguyên một kết nối, chỉ gộp nhiều lệnh vào mỗi lượt gửi.
| Đường ống | ops/s |
|---|---|
-P 1 |
25.850 |
-P 8 |
202.429 |
-P 64 |
1.136.364 |
Gấp 44 lần, vẫn chỉ một kết nối. Không đổi cấu hình Redis, không thêm nhân, không thêm luồng. Và kết hợp cả hai — 50 kết nối với đường ống 16 — cho 3.125.000 ops/s. Con số đó lớn hơn kết quả tốt nhất không đường ống hơn mười lần, trên cùng một tiến trình một luồng.
CPU thật của Redis
Chạy khách ở một container riêng để đo được CPU của riêng máy chủ:
50 kết nối, không đường ống ~89% chưa hết một nhân
50 kết nối, đường ống 16 ~100% đúng một nhân, kín
Không đường ống, Redis không dùng hết nổi một nhân — nó dành thời gian trong lời gọi hệ thống đọc và ghi socket, mỗi lời gọi mang đúng một lệnh. Có đường ống, một lời gọi read mang về 16 lệnh, và luồng xử lý chạy hết công suất. 100% là trần thật: đó là toàn bộ những gì một luồng có. Và 15 trong 16 nhân nằm không, ở cả hai trường hợp.
Một lần đo hỏng
Lần đầu tôi chạy khách trong cùng container với Redis và đo được 181% cho một máy chủ một luồng. Con số đó bất khả thi, và đó là cách tôi biết phép đo đã hỏng: docker stats đo cả cgroup, mà redis-benchmark chạy bằng docker exec cũng nằm trong cgroup đó. Tôi đang đo tổng của máy chủ và khách.
Bài học chung: khi đo một tiến trình, hãy chắc rằng khách không nằm cùng chỗ được tính. Nếu con số vượt qua giới hạn vật lý của thứ bạn đang đo, đừng tìm cách giải thích nó — hãy nghi phép đo trước.
Qua mạng thì mất bao nhiêu
Toàn bộ số ở trên đo qua loopback. Chuyển khách sang container khác:
| Loopback | Qua mạng | |
|---|---|---|
| 1 kết nối, không đường ống | 25.381 | 22.949 |
| 50 kết nối, không đường ống | ~306.000 | 233.100 |
| 1 kết nối, đường ống 64 | 1.136.364 | 852.878 |
| 50 kết nối, đường ống 16 | 3.125.000 | 2.202.643 |
Mất khoảng 25–30% ở mọi cấu hình. Đây vẫn là mạng ảo của Docker trên cùng một máy; qua mạng thật giữa hai máy chủ, khoảng cách lớn hơn nhiều — và đường ống càng quan trọng hơn, vì mỗi lượt đi về tốn nhiều hơn.
Điều này nghĩa là gì cho ứng dụng của bạn
Dùng đường ống khi có nhiều lệnh không phụ thuộc nhau. Đọc 100 khoá thì gửi một lượt, đừng gọi GET một trăm lần:
p = r.pipeline(transaction=False)
for k in keys:
p.get(k)
values = p.execute()
Với những lệnh có sẵn dạng nhiều khoá thì càng đơn giản: MGET, MSET, HMGET. Chúng là một lệnh, một lượt đi về.
Dùng lại kết nối. Mở kết nối mới cho mỗi thao tác là thêm một lượt bắt tay TCP vào mỗi lệnh. Mọi thư viện đều có bể kết nối; hãy dùng.
Đừng đặt Redis quá xa. Vì thời gian gần như hoàn toàn là đi về, độ trễ mạng cộng thẳng vào mỗi lệnh. Redis ở vùng khác là hỏng thiết kế, không phải chậm một chút.
Đừng mua máy nhiều nhân cho Redis. Một tiến trình dùng đúng một nhân. Muốn dùng hết máy thì chạy nhiều tiến trình Redis trên các cổng khác nhau, hoặc dùng Redis Cluster — sê-ri sẽ đo cả hai.
io-threads không phải đa luồng
Redis 6 thêm io-threads, và tên gọi gây hiểu nhầm. Chúng chỉ đọc và ghi socket song song; việc thực thi lệnh vẫn một luồng duy nhất, và đó là chủ ý thiết kế — không khoá, không tranh chấp, mọi lệnh là nguyên tử miễn phí. Mặc định io-threads 1, tức tắt. Nó chỉ giúp khi bạn ở đúng tình huống mà phép đo trên cho thấy: nhiều kết nối, không đường ống, tốn thời gian trong lời gọi hệ thống. Nếu ứng dụng đã dùng đường ống thì bật nó gần như không đổi gì.
Nhân tiện, tiến trình Redis có 6 luồng trong bảng tiến trình, không phải một. Những luồng kia làm việc nền: giải phóng bộ nhớ lớn, fsync cho AOF, đóng tệp. Chúng không chạm vào không gian khoá.
Mẫu số chung: nút thắt thông lượng thường là vòng đi-về, không phải máy chủ
Cái làm một luồng Redis đạt ba triệu lệnh/giây là một quy luật đúng với gần như mọi hệ khách-chủ: khi mỗi thao tác là một vòng đi-về đồng bộ (gửi → chờ → nhận), thông lượng bị chặn bởi độ trễ mỗi vòng chứ không bởi sức xử lý của máy chủ — máy chủ phần lớn thời gian đứng chờ trên dây. Hai đòn bẩy phá nút thắt này không đụng gì tới máy chủ: gộp lô (nhiều việc mỗi vòng) và đồng thời (nhiều vòng chạy song song). Và một tiến trình đơn luồng không hề chậm — nó đổi song song lấy sự đơn giản: không khoá, không tranh chấp, mọi thao tác nguyên tử miễn phí.
Trong thế giới Java và web, đây đúng là lý do một máy chủ event-loop đơn luồng (Node.js, Netty, Nginx) phục vụ được hàng chục nghìn kết nối: nút thắt là I/O chờ, không phải CPU, nên một luồng bận rộn đủ dùng. Cũng là lý do HTTP keep-alive và HTTP/2 multiplexing tồn tại — gộp nhiều request lên một kết nối để khỏi trả phí bắt tay và vòng đi-về mỗi lần. Còn ở cơ sở dữ liệu, đây là JDBC batch và gRPC streaming: N+1 query chậm không vì CSDL yếu mà vì N vòng đi-về; gộp thành một lệnh là cùng đòn bẩy đường ống ở đây.
Mẫu số chung: khi thao tác là vòng đi-về đồng bộ, thông lượng bị chặn bởi độ trễ mỗi vòng chứ không bởi CPU máy chủ — nên trước khi thêm phần cứng, hãy gộp lô và chạy đồng thời để lấp cái dây đang trống. Câu hỏi đúng khi thấy máy chủ "chậm mà CPU thấp" không phải "máy có yếu không" mà là "tôi có đang để mỗi kết nối chờ một vòng trước khi gửi vòng tiếp không" — vì đó, không phải CPU, mới là trần thật. (Và một bài học phụ đắt giá: khi đo một tiến trình, đừng để khách đo chung cgroup với nó — 181% CPU trên một luồng là dấu hiệu phép đo hỏng, không phải phép màu.)
Muốn tự đo xem ứng dụng của bạn đang tốn bao nhiêu vào lượt đi về:
redis-cli info stats | grep -E 'total_commands_processed|instantaneous_ops'
redis-cli info clients | grep -E 'connected_clients|blocked_clients'
redis-cli --latency -i 5
Rồi so instantaneous_ops_per_sec với CPU của tiến trình Redis:
top -p $(pgrep -x redis-server) -b -n 1 | tail -2
Ops cao mà CPU dưới 50% nghĩa là bạn đang bị chặn ở lượt đi về, không phải ở Redis — và đường ống sẽ cho bạn thêm vài lần thông lượng mà không cần đổi phần cứng.
Phần sau: các kiểu dữ liệu — đo xem mỗi kiểu tốn bao nhiêu byte và nhanh chậm ra sao.