Mở đầu một sê-ri về RabbitMQ bằng câu "hàng đợi giúp hệ thống nhanh hơn" thì dễ, nhưng sai. Bài này dựng phép đo để xem nó thật sự đổi được cái gì — và đổi bằng giá nào.

Bàn thí nghiệm

Một tình huống quen: API nhận đơn hàng, và mỗi đơn phải gọi sang một dịch vụ hạ nguồn chậm — xuất hoá đơn, gửi thư, đẩy sang ERP. Tôi dựng đúng thế:

  dịch vụ hạ nguồn : 200 ms mỗi việc, 8 luồng phục vụ  →  trần 40 việc/giây
  khách            : 200 request, 20 luồng gọi cùng lúc
  broker           : RabbitMQ 4 trong Docker, hàng đợi durable, thông điệp persistent

Con số quan trọng nhất ở đây là trần 40 việc/giây. Nhớ nó, phần sau sẽ cần.

Hai cách nối API với dịch vụ đó:

Gọi thẳng so với đi qua hàng đợi

Đo lần một: gọi thẳng

=== A. GOI DONG BO ===
  do tre khach cam nhan : p50 464 ms | p95 613 ms | max 671 ms
  tong thoi gian        : 5170 ms | thong luong: 38,7 req/s | loi: 0

38,7 req/s — đúng bằng trần của hạ nguồn. Hệ thống chạy hết công suất, không có gì lãng phí.

Nhưng khách chờ 464 ms cho một việc đáng lẽ mất 200 ms. Phần dư là thời gian xếp hàng ở cửa: 20 khách gọi cùng lúc mà chỉ có 8 luồng phục vụ.

Đo lần hai: đẩy vào hàng đợi

Vẫn 200 request đó, nhưng API chỉ đẩy thông điệp vào RabbitMQ rồi trả lời ngay.

=== B. DAY VAO HANG DOI ===
  do tre khach cam nhan : p50 0 ms | p95 1 ms | max 1 ms
  tong thoi gian day    : 7 ms | thong luong: 25 337,7 req/s
  ton dong trong hang doi ngay sau khi day xong: 200 thong diep

7 ms cho cả 200 request, so với 5 170 ms. Nhanh hơn 738 lần. Nếu bài viết dừng ở đây thì RabbitMQ đúng là phép màu.

Chỗ mọi người dừng lại quá sớm

Việc chưa xong. Nó mới chỉ nằm trong hàng đợi. Cho consumer chạy — vẫn 8 luồng, vẫn gọi đúng dịch vụ hạ nguồn ấy — rồi đo từ lúc đẩy tới lúc việc thật sự hoàn tất:

  do tre tu luc day toi luc viec xong: p50 2701 ms | p95 4995 ms | max 5202 ms
  tong thoi gian rut : 5199 ms
  TONG tu request dau toi viec cuoi: 5207 ms
Gọi thẳng Qua hàng đợi
Khách chờ (p50) 464 ms dưới 1 ms
Việc xong sau (p50) 464 ms 2 701 ms
Làm xong cả 200 việc 5 170 ms 5 207 ms
Hàng đợi không thêm cho hạ nguồn một luồng nào. Trần vẫn là 40 việc/giây, nên tổng thời gian gần như không đổi — 5 207 ms so với 5 170 ms, chênh 0,7%, đúng bằng phần chi phí đi vòng qua broker. Cái đổi là ai phải ngồi chờ: trước là khách, giờ là thông điệp.

Và với từng việc một thì hàng đợi làm tệ hơn: p50 từ 464 ms lên 2 701 ms, muộn hơn 5,8 lần. Việc cuối cùng trong lô phải đợi 199 việc trước nó.

Vậy hàng đợi mua được gì

Ba thứ, và cả ba đều không phải "nhanh hơn".

Tách thời gian phản hồi khỏi thời gian xử lý. Khách không còn trả giá cho sự chậm của hạ nguồn. Hạ nguồn chậm đi gấp đôi thì cột "khách chờ" vẫn dưới 1 ms.

San tải. Hàng đợi hấp thụ được cơn dồn: 200 request ập tới trong 7 ms, hạ nguồn vẫn rút đều 40 việc/giây mà không luồng nào bị từ chối. Gọi thẳng thì cơn dồn đó dội thẳng vào hạ nguồn.

Chịu lỗi. Đây là chỗ chênh lệch lớn nhất. Tôi tắt hẳn dịch vụ hạ nguồn rồi gửi tiếp 50 request:

=== C. HA NGUON CHET ===
  dong bo : 50 request -> 0 thanh cong, 50 LOI tra ve tan khach
  hang doi: 50 request -> 0 loi, 50 thong diep nam cho
  (bat lai dich vu ha nguon)
  hang doi tu rut het sau 1453 ms, ha nguon lam du 50 viec, mat 0 thong diep

Cùng một sự cố: một bên 50 khách nhận lỗi, một bên không ai biết có chuyện gì xảy ra. Hạ nguồn sống lại thì hàng đợi tự rút hết trong 1,5 giây, không mất việc nào.

Cái giá

Không có bữa trưa miễn phí, và cái giá ở đây không nhỏ:

  • Việc xong muộn hơn — 5,8 lần trong phép đo trên. Chấp nhận được với thư từ và báo cáo, không chấp nhận được với thứ khách đang ngồi chờ trên màn hình.
  • Khách không còn biết việc có thành công không. API trả 202 Accepted chứ không phải kết quả. Muốn báo lại thì phải dựng thêm đường về — một hệ thống nữa.
  • Thêm một hạ tầng phải vận hành: cài, giám sát, sao lưu, vá lỗi, và học cách gỡ khi nó hỏng.
  • Thông điệp có thể tới hai lần. RabbitMQ hứa at-least-once, không hứa exactly-once. Consumer phải lũy đẳng, nếu không thì một lần thử lại là hai hoá đơn.
  • Gỡ lỗi khó hơn. Ngăn xếp lời gọi đứt tại chỗ đẩy thông điệp; log hai phía không tự nối lại với nhau.

Ba mục cuối đủ lớn để mỗi mục có một bài riêng ở phần sau của sê-ri.

Khi nào đừng dùng

Khi khách cần kết quả. Tra cứu tồn kho, xác thực, thanh toán cần mã giao dịch — hàng đợi chỉ làm mọi thứ vòng vèo hơn.

Khi hạ nguồn vốn đã nhanh. Việc mất 5 ms thì đi qua broker chỉ tổ thêm chi phí; hãy đo trước.

Khi tất cả vẫn nằm trong một tiến trình. Nếu chỉ cần tách rời hai phần trong cùng ứng dụng thì sự kiện ứng dụng đủ rồi, và nó có thứ hàng đợi không cho: chạy trong cùng giao dịch. Chi tiết ở bài Một listener ném ngoại lệ, hai listener sau đó không chạy — nhưng nhớ rằng nó không giúp gì khi tiến trình chết giữa chừng.

Chốt lại

Hàng đợi không phải công cụ tăng tốc. Nó là công cụ dời thời điểmgiữ việc lại khi có sự cố. Nếu bài toán của bạn là "hạ nguồn quá chậm", thứ cần là thêm công suất cho hạ nguồn — hàng đợi chỉ giấu sự chậm đó khỏi mắt khách, và giấu rất giỏi.

Bốn mươi bài tới sẽ đi từ thông điệp đầu tiên tới định tuyến, độ tin cậy, Spring AMQP và vận hành trên sản xuất. Bài sau: dựng RabbitMQ bằng Docker và gửi thông điệp đầu tiên.

Thử ba mươi giây

docker run -d --name rmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management

Đợi vài giây rồi mở http://localhost:15672 (đăng nhập guest / guest). Vào tab Queues, tạo một hàng đợi tên thu, rồi dùng chính giao diện đó để đẩy vài thông điệp vào và xem chúng nằm chờ — chưa cần viết một dòng mã nào.