Vert.x và Spring WebFlux là hai câu trả lời cho cùng một câu hỏi trên JVM: làm sao phục vụ nhiều kết nối mà không cần một luồng cho mỗi kết nối. Cả hai đều dùng Netty bên dưới. Câu hỏi thực tế là: chọn cái nào thì khác gì?

Tôi viết cùng năm route bằng cả hai, dùng chung một dịch vụ hạ nguồn, rồi đo theo thứ tự A-B-B-A để trừ bớt ảnh hưởng của thứ tự chạy.

Phép đo đầu tiên của tôi không công bằng

Lần chạy đầu cho Vert.x thắng đậm ở route đơn giản nhất: 106 983 so với 72 743 req/s, tức hơn 35%. Con số đó hợp với định kiến phổ biến ("Spring nặng hơn"), nên rất dễ viết thẳng vào bài.

May là tôi đếm luồng trước khi viết:

BenWebFlux:
  http-nio-23501-exec    200
  reactor-http-nio        16
  GC Thread               13

Hai trăm luồng tên http-nio-…-execpool worker của Tomcat. Tiến trình WebFlux của tôi đang chạy cả Tomcat lẫn Reactor Netty, vì trong classpath của dự án thí nghiệm còn sót spring-boot-starter-web từ một bài trước. Ngay cả khi đã ép spring.main.web-application-type=reactive, đống lớp và luồng của tầng servlet vẫn được nạp.

Tôi dựng lại WebFlux trong một module riêng chỉ có đúng spring-boot-starter-webflux. Kết quả:

BenWebFlux (classpath sach):
  reactor-http-nio        16
  GC Thread               13

Và toàn bộ bảng số đổi. Đây là lý do tôi luôn kết thúc mỗi bài bằng một phép đo chạy thật: định kiến của người đo là thứ dễ lọt vào số liệu nhất, và nó lọt vào qua những chỗ chẳng ai nghĩ tới, như một dependency thừa.

Bảng số sau khi dọn sạch

200 kết nối, 10 giây, thứ tự A-B-B-A:

Route Vert.x WebFlux
/tran — trả chuỗi thuần 108 376 / 108 494 97 674 / 107 795
/json — tuần tự hoá JSON 108 736 / 112 324 105 037 / 109 802
/goi-mot — gọi 1 hạ nguồn 89 826 / 89 484 68 509 / 79 021
/goi-ba — gọi 3 hạ nguồn song song 41 514 / 38 608 40 195 / 39 959
/cham — hạ nguồn trả sau 30 ms 6 144 / 6 131 6 105 / 6 122

Bốn trên năm route ngang nhau trong phạm vi nhiễu. Route duy nhất Vert.x thắng ổn định là /goi-mot, chừng 13–15%, và tôi không có phép đo nào giải thích được dứt khoát vì sao chỉ riêng route đó — /goi-ba cũng gọi hạ nguồn mà lại hoà.

Đáng chú ý nhất là hàng cuối: khi hạ nguồn mất 30 ms, hai bên bằng nhau tới từng phần nghìn. Điều đó đúng theo lẽ thường mà người ta hay quên khi so sánh framework: nếu ứng dụng của bạn dành phần lớn thời gian chờ dịch vụ khác — tức là mọi microservice điển hình — thì framework không phải là thứ quyết định thông lượng. Cái quyết định là hạ nguồn.

Chỗ khác biệt thật: khởi động

Lần 1 Lần 2 Lần 3
Vert.x 222 ms 209 ms 231 ms
WebFlux 761 ms 660 ms 698 ms

Gấp 3,2 lần, và ổn định qua ba lần đo. Đó là cái giá của quét component, tự cấu hình, và dựng context của Spring — những thứ bạn trả một lần mỗi lần khởi động, đổi lấy việc không phải nối dây thủ công.

Với một máy chủ chạy nhiều tháng thì 470 ms chênh lệch là không đáng gì. Với một hàm serverless khởi động lạnh liên tục thì đó lại là con số quan trọng nhất trong cả bài — và ở đó ảnh native ở phần 47 mới là câu trả lời thật.

Bộ nhớ thì gần như không phân biệt được: heap sống sau tải, đo sau khi ép GC, là 18 MB cho Vert.x19 MB cho WebFlux. Số luồng 36 so với 66.

Những chỗ so sánh này vẫn không công bằng

Tôi nêu rõ để bạn biết cân nhắc bảng trên tới đâu:

  • Bộ sinh tải chạy cùng máy với cả hai máy chủ, nên cả hai đều thiếu CPU. Đây là điều kiện giống nhau cho hai bên, nhưng nó nén khoảng cách lại.
  • Số event loop khác nhau: tôi đặt Vert.x 4 event loop với 4 bản sao Verticle, còn Reactor Netty tự chọn 16 luồng. Theo phần 48, nhiều event loop hơn không tốt hơn trên máy này, nên WebFlux có thể đang ở cấu hình bất lợi — nhưng đó mặc định của nó, và mặc định là thứ đa số người dùng chạy.
  • Ứng dụng của tôi không dùng gì của Spring. Không tiêm phụ thuộc, không cấu hình theo hồ sơ, không kiểm tra hợp lệ, không bảo mật, không quan sát. Toàn bộ những thứ đó là lý do người ta chọn Spring, và bài này không đo chúng — nó chỉ đo phần chồng lấn nhỏ giữa hai bên.
  • Không đo dưới sự cố. Những thứ sê-ri này đã đo được ở Vert.x — vách ngăn, áp lực ngược, event loop bị chặn — đều có bản tương ứng bên Reactor, và chúng mới là nơi hai mô hình khác nhau thật sự.

Vậy chọn thế nào

Số đo đã loại hiệu năng ra khỏi bàn cân với phần lớn ứng dụng. Cái còn lại là những thứ không đo bằng req/s:

Chọn Spring WebFlux khi đội của bạn đã ở trong hệ sinh thái Spring. Spring Data, Spring Security, Spring Cloud, và một lượng người biết dùng chúng — đó là giá trị thật, và nó lớn hơn 13% thông lượng ở một route.

Chọn Vert.x khi bạn muốn kiểm soát trực tiếp mô hình luồng, hoặc khi khởi động nhanh và ít phụ thuộc là yêu cầu thật. Vert.x là một thư viện, không phải một khung; bạn tự nối dây, tự chọn thành phần, và đổi lại được biết chính xác chuyện gì đang xảy ra — điều mà cả sê-ri này dựa vào để đo được từng lớp một.

Đừng chọn theo bảng thông lượng của người khác, kể cả bảng trong bài này. Bốn trên năm route của tôi hoà nhau, và route thứ năm chênh 13% — con số đó nhỏ hơn sai số giữa hai lần đo của chính tôi ở nhiều bài trước.

Thử ba mươi giây

Trước khi tin bất kỳ so sánh framework nào, kể cả của tôi, hãy đếm luồng của cả hai bên:

jcmd <pid> Thread.print | grep -oE '^"[^"]+' | sed 's/[-#]\?[0-9]*$//' | sort | uniq -c | sort -rn

Nếu thấy nhóm luồng nào không thuộc về framework bạn nghĩ mình đang đo — như 200 luồng Tomcat trong một tiến trình WebFlux — thì con số của bạn đang nói về một thứ khác.

Phần cuối của sê-ri gom toàn bộ phép đo lại thành một danh sách kiểm trước khi lên sản xuất.