Hình dung hai cách niêm phong một lá thư. Cách một: một con dấu cao su mà cả người gửi lẫn người nhận đều có bản y hệt — đóng cái cạch là xong, kiểm cũng chỉ cần đóng lại rồi so, nhanh như chớp; nhược điểm là ai cầm được con dấu đó cũng làm giả được thư. Cách hai: một khuôn sáp chạm trổ tinh vi mà chỉ chiếc nhẫn của người gửi mới in ra được, còn ai cũng kiểm được bằng cách soi kính lúp đối chiếu — an toàn hơn hẳn, nhưng mỗi lần soi tốn thời gian thật. JWT ký bằng HS256 là con dấu cao su; ký bằng RS256 là khuôn sáp chạm trổ. Mọi API đều kiểm danh tính trên từng request, nên cái "tốn thời gian thật" đó nhân lên hàng chục nghìn lần mỗi giây. Bài này đo xem nó tốn bao nhiêu — và câu trả lời nằm gọn trong một chữ của tên thuật toán.
Bảo vệ route
JWTAuth auth = JWTAuth.create(vertx, new JWTAuthOptions()
.addPubSecKey(new PubSecKeyOptions()
.setAlgorithm("HS256")
.setBuffer("mot-chuoi-bi-mat-du-dai")));
router.get("/rieng")
.handler(JWTAuthHandler.create(auth)) // kiem chu ky + han dung
.handler(c -> c.response().end("ok"));
gọi không có token -> 401
gọi có token -> 200
JWTAuthHandler kiểm chữ ký, hạn dùng, rồi đặt c.user() cho các handler sau. Không hợp lệ thì nó tự trả 401 và không gọi next() — đúng luật của phần 9.
Chi phí mỗi request
500 kết nối đồng thời, endpoint không làm gì ngoài trả "ok":
| Endpoint | Thông lượng | p50 | So với không xác thực |
|---|---|---|---|
| Không xác thực | 99 113 req/s | 4,4 ms | — |
| JWT HS256 | 99 657 req/s | 4,6 ms | 100% |
| JWT RS256 | 27 486 req/s | 16,1 ms | 28% |
| JWT HS256 + kiểm vai trò | 99 034 req/s | 4,5 ms | 100% |
Lý do đúng như hai con dấu: HS256 là HMAC — một phép băm đối xứng, vài trăm nano giây, y như đóng lại con dấu cao su rồi so. RS256 là chữ ký RSA 2048 bit, và kiểm chứng nó là một phép luỹ thừa mô-đun — cái kính lúp soi khuôn sáp. Ở tốc độ gần trăm nghìn request mỗi giây, khác biệt đó chiếm trọn event loop.
Và nhớ phần 3: phép kiểm này chạy trên event loop. Nó không phải I/O nên executeBlocking không phải câu trả lời hiển nhiên — nhưng ở tải cao thì RSA chính là một dạng "chặn" bằng CPU.
Kiểm vai trò gần như miễn phí
router.get("/quan-tri")
.handler(JWTAuthHandler.create(auth))
.handler(c -> {
if (!"quan-tri".equals(c.user().principal().getString("vaiTro"))) { c.fail(403); return; }
c.response().end("ok");
});
99 034 req/s — bằng đúng mức chỉ xác thực. Hợp lý: thông tin vai trò đã nằm trong token và đã được giải mã lúc kiểm chữ ký; đọc thêm một trường JSON không tốn gì.
Đây cũng là lý lẽ chính của JWT: không phải "nhanh hơn session" nói chung, mà là không cần hỏi ai để biết người dùng là ai và được làm gì.
Chọn thuật toán
| Tình huống | Thuật toán |
|---|---|
| Bạn vừa phát hành vừa kiểm token (một hệ thống) | HS256 |
| Nhiều dịch vụ kiểm token do một nơi phát hành | RS256 — nhưng đọc tiếp |
| Thông lượng rất cao và bắt buộc RS256 | Cân nhắc ES256, hoặc cache kết quả kiểm |
Dòng giữa là tình huống thật của phần lớn kiến trúc microservice, và bảng đo ở trên là cái giá. RS256 tồn tại vì lý do chính đáng: dịch vụ kiểm token chỉ cần khoá công khai, nên lộ nó ra cũng không ai giả được token. Với HS256, mọi dịch vụ kiểm token đều giữ khoá bí mật — tức là mọi dịch vụ đều phát hành được token.
Đó là đánh đổi bảo mật, không phải đánh đổi hiệu năng, và bảo mật thường thắng. Nhưng hãy biết mình đang trả 72% thông lượng, và cân nhắc:
Kiểm một lần ở gateway. Gateway dùng RS256, các dịch vụ bên trong tin header đã được gateway xác minh. Bài 28 của sê-ri sẽ dựng gateway đó.
Token sống lâu hơn. Kiểm chữ ký là chi phí mỗi request; không có cách nào giảm số lần kiểm ngoài việc giảm số request.
Ba điều đừng quên
Luôn đặt hạn dùng. setExpiresInMinutes — thiếu nó thì một token rò rỉ có giá trị vĩnh viễn.
Đừng nhét dữ liệu lớn vào token. Token HS256 trong phép đo dài 183 ký tự, RS256 dài 482. Mọi request đều mang cả chuỗi đó qua mạng.
Đừng tin alg trong token. Cấu hình rõ thuật toán phía máy chủ; đây là lỗ hổng kinh điển của JWT khi thư viện chấp nhận alg: none.
Và nếu tò mò token của chính mình đang chở gì, giải phần giữa ra mà xem — không cần khoá, vì phần thân JWT không hề được mã hoá:
# xem trong token cua ban co gi (phan giua, khong can khoa)
echo "<token>" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
Thấy email, số điện thoại hay một danh sách quyền dài dằng dặc thì biết rằng mọi request của bạn đang chở chúng đi, và bất kỳ ai chặn được request đều đọc được sạch.
Mẫu số chung
Cái bẫy lớn nhất của bài này: hai thứ trông giống hệt nhau ở tầng bạn nhìn có thể có chi phí lệch nhau một bậc ở tầng bạn không nhìn. Cùng JWTAuthHandler, cùng một dòng mã, cùng chuẩn JWT — chỉ đổi chuỗi "HS256" thành "RS256" là thông lượng rơi từ 99.657 xuống 27.486, mà mã nguồn không hề gợi ý điều đó. Sự khác biệt sống ở phép toán bên dưới: HMAC là một phép băm, RSA là luỹ thừa mô-đun. Cùng cái ảo giác "chi phí ẩn dưới một cái tên" ở khắp nơi: hai truy vấn SQL viết y hệt nhau mà một cái quét index còn một cái quét toàn bảng, hashCode của String rẻ còn của một object lồng sâu thì đắt, một lời gọi hàm "đơn giản" hoá ra gọi ra mạng. Nguyên tắc: đừng đoán chi phí từ hình dạng của lời gọi — đo cái phép toán thật mà nó kích hoạt.
Điều thứ hai, và là chỗ dễ ra quyết định sai nhất: cái đắt hơn ở đây được chọn vì bảo mật, không phải vì hiệu năng — nên đừng dùng thước hiệu năng để bác nó. RS256 tốn gấp bốn không phải vì viết dở, mà vì nó mua một tính chất HS256 không có: người kiểm chỉ cần khoá công khai, nên chia khoá đó ra trăm dịch vụ cũng không ai giả được token. Đó là cùng một loại đánh đổi với TLS đắt hơn HTTP thường, với bcrypt cố tình chậm để chống dò mật khẩu, với SERIALIZABLE đắt hơn READ COMMITTED: chi phí là tính năng, trả tiền cho một đảm bảo chứ không phải cho sự lãng phí. Việc đúng cần làm không phải né cái đắt, mà là biết chính xác mình trả bao nhiêu (72% thông lượng) rồi dời chỗ trả cho khéo — kiểm RS256 một lần ở gateway, rồi để các dịch vụ bên trong đi con đường rẻ.
Bài sau: đo Vert.x Web cạnh Spring MVC — và cạnh Spring MVC bật luồng ảo, thứ làm bảng so sánh khác hẳn.