Bài này đáng lẽ chỉ là một bảng số về Redis. Nhưng lần chạy đầu tiên cho ra 72 373 092 lệnh mỗi giây, và con số đó đã cứu cả bài viết — vì nó sai một cách quá lộ liễu để bỏ qua.

Redis 7.4.11 trong Docker, vertx-redis-client.

Con số không thể đúng

Redis là một tiến trình đơn luồng. Nó làm được cỡ một triệu thao tác mỗi giây trong điều kiện tốt. Bảy mươi hai triệu thì không phải "nhanh bất ngờ" mà là "phép đo hỏng".

Tôi viết một chương trình kiểm chứng nhỏ: chạy 1 000 lô, mỗi lô 200 lệnh SET, rồi hỏi Redis xem thật sự có bao nhiêu khoá.

1000 lo x 200 lenh: 0.019 s | loi=959 | DBSIZE=200

959 trên 1 000 lô đã hỏng, và cơ sở dữ liệu chỉ có 200 khoá — đúng bằng một lô duy nhất thành công. Chương trình đo của tôi đếm cả những lời gọi thất bại, mà thất bại thì trả về gần như tức thì, nên nó chia 200 000 cho 0,019 giây và cho ra một con số vô nghĩa.

Nguyên nhân thật:

ConnectionPoolTooBusyException: Connection pool reached max wait queue size of 24

RedisOptions.maxPoolWaiting mặc định là 24. Không phải số kết nối — số lời gọi được phép xếp hàng chờ một kết nối rảnh. Bắn 200 lô đồng thời thì 176 cái bị từ chối ngay lập tức. Nâng maxPoolSize từ 16 lên 64 cũng không cứu được (vẫn 136 lỗi), vì hàng đợi chờ mới là chỗ nghẽn.

Đây là một mặc định đáng biết trước khi lên sản xuất: dưới một đợt tải dồn, Redis client của bạn sẽ từ chối thay vì xếp hàng, và ngoại lệ đó không giống bất cứ thứ gì liên quan tới Redis.

Bài học về đo đạc thì rộng hơn: một benchmark không kiểm tra lỗi sẽ đo tốc độ thất bại. Và thất bại bao giờ cũng nhanh. Từ đó tôi viết lại hàm đo để đếm lỗi và bỏ hẳn kết quả nếu có bất kỳ lỗi nào:

if (loi.get() > 0) { System.out.printf("!! %d/%d LOI — %s%n", loi.get(), n, viDu[0]); return -1; }

Bảng số thật

Sau khi đặt maxPoolWaiting(10_000) và giới hạn số lượt đang bay bằng một Semaphore, không còn lỗi nào.

Số lượt đang bay — cùng một lệnh SET, chỉ đổi mức đồng thời:

Lượt đang bay Thông lượng
1 7 691 lệnh/giây
8 35 494 lệnh/giây
64 53 949 lệnh/giây
512 48 045 lệnh/giây

Tuần tự cho 7 691 lệnh/giây, tức 130 µs mỗi lệnh — gần như toàn bộ là một vòng đi về mạng. Cho phép nhiều lượt cùng bay thì tăng lên bảy lần, rồi chững ở khoảng 64 và tụt nhẹ ở 512. Redis đơn luồng nên qua một ngưỡng nào đó, thêm lượt đồng thời chỉ thêm việc xếp hàng.

Gom thành lô thì lại là chuyện khác hẳn:

Cỡ lô Thông lượng Mỗi lệnh
1 50 779 lệnh/giây 19,7 µs
10 421 586 lệnh/giây 2,4 µs
50 1 443 622 lệnh/giây 0,7 µs
200 2 781 835 lệnh/giây 0,4 µs
1000 3 713 147 lệnh/giây 0,3 µs

Gấp 73 lần so với gửi từng lệnh. Lý do đơn giản: một lô là một vòng đi về mạng cho 1 000 lệnh, thay vì 1 000 vòng. Chi phí mỗi lệnh rơi từ 19,7 µs xuống 0,3 µs, và 0,3 µs đó gần đúng bằng thời gian Redis phân tích cú pháp rồi thực thi một lệnh — nghĩa là mạng gần như biến mất khỏi phương trình.

Lợi ích giảm dần rõ rệt sau cỡ 200 (gấp 1,3 lần khi từ 200 lên 1 000, so với gấp 1,9 lần khi từ 50 lên 200). Lô càng to thì độ trễ của từng lệnh trong lô càng cao và bộ nhớ giữ lô càng lớn, nên vài trăm lệnh mỗi lô là chỗ đáng dừng với hầu hết ứng dụng.

List<Request> lo = new ArrayList<>();
for (int i = 0; i < 200; i++) lo.add(Request.cmd(Command.SET).arg("k"+i).arg("v"));
client.batch(lo).onSuccess(kq -> { /* kq.size() == 200 */ });

Các loại lệnh ở cùng mức đồng thời 64:

Thông lượng
GET 49 134 lệnh/giây
INCR 50 524 lệnh/giây
LRANGE trả 1 000 phần tử 20 141 lệnh/giây

GETINCR bằng nhau — với Redis, đọc hay ghi không khác nhau đáng kể, cả hai đều là thao tác trong bộ nhớ. Cái làm nên khác biệt là lượng dữ liệu trả về: LRANGE lấy 1 000 phần tử chậm hơn 2,4 lần, vì tiền không nằm ở lệnh mà ở việc tuần tự hoá và truyền chừng ấy byte.

Đây cũng là lý do trong lần đo hỏng đầu tiên, LRANGE báo nhanh hơn GET — một kết quả không thể đúng, và là dấu hiệu thứ hai lẽ ra tôi phải nhận ra sớm hơn.

Pub/sub

RedisConnection cn = nghe.connect().toCompletionStage().toCompletableFuture().get();
cn.handler(msg -> ...);
cn.send(Request.cmd(Command.SUBSCRIBE).arg("kenh"));
PUBLISH phía gửi 48 335 lệnh/giây
Phía nhận 48 333 bản tin/giây

Người nghe theo kịp người gửi gần như tuyệt đối. Nhưng có một điểm về kiến trúc quan trọng hơn con số: pub/sub của Redis không lưu gì cả. Người nghe ngắt kết nối một giây là mất sạch bản tin của giây đó, không có cách nào lấy lại.

Đó là khác biệt căn bản so với RabbitMQ ở phần trước hay Kafka ở phần 36, và nó quyết định lúc nào được dùng: pub/sub Redis hợp cho tín hiệu vô hại nếu mất — làm mới cache, thông báo hiện lên màn hình. Không hợp cho bất cứ thứ gì bạn cần đảm bảo. Redis Streams mới là thứ có lưu trữ, và đó là một công cụ khác.

Một chi tiết triển khai: kết nối đang SUBSCRIBE không dùng chung với lệnh thường được — sau khi subscribe, kết nối chuyển sang một chế độ chỉ nhận được vài lệnh. Vì thế tôi tạo hẳn một Redis client riêng cho người nghe. Dùng chung pool là bạn có những lỗi rất khó hiểu ở những chỗ chẳng liên quan.

Ba luật

  • Gom lô bất cứ khi nào có nhiều hơn một lệnh cần chạy. Đây là đòn bẩy lớn nhất, gấp 73 lần, lớn hơn mọi thứ khác trong bài cộng lại.
  • Đặt maxPoolWaiting trước khi lên sản xuất. Mặc định 24 nghĩa là một đợt dồn nhỏ cũng đủ sinh ra lỗi mà tên gọi chẳng nói lên điều gì.
  • Đừng dùng pub/sub Redis cho việc không được phép mất.

Thử ba mươi giây

Kiểm tra xem client Redis của bạn có đang âm thầm từ chối lệnh không:

api.set(List.of("k","v")).onComplete(ar -> {
    if (ar.failed()) System.out.println("LOI: " + ar.cause());
});

Đơn giản đến mức buồn cười, nhưng nếu mã của bạn chỉ có onSuccess mà không có onFailure — như đa số mã tôi từng đọc — thì ConnectionPoolTooBusyException sẽ không xuất hiện ở bất cứ đâu. Nó không ném ra, không ghi log, chỉ làm cái Future kia không bao giờ chạy nhánh thành công.

Và nếu bạn đang bấm giờ một thứ gì đó, hãy đếm lỗi trước khi tin vào con số. Cái tôi suýt công bố là 72 triệu lệnh mỗi giây trên một tiến trình đơn luồng.

Phần sau bàn về kiểm thử ứng dụng Vert.x — và vì sao test bất đồng bộ hay xanh giả.