Nghĩ về service discovery như cuốn danh bạ giấy. Nó có hai nết dở mà ai từng dùng đều biết: muốn tìm một cái tên thì phải lật từng trang từ đầu (không có mục lục theo tên), và cái số bạn chép ra giấy hôm nay sẽ lẳng lặng sai vào cái ngày người ta đổi số — cuốn danh bạ được in lại, còn mẩu giấy trong túi bạn thì không. Sổ đăng ký dịch vụ của Vert.x dính đúng cả hai nết đó, và bài này đo chúng: tra tốn bao nhiêu, và bản ghi cũ sống được bao lâu.
Khi số dịch vụ vượt quá con số bạn nhớ được, chúng cần một chỗ để tìm thấy nhau. Vert.x có sẵn module cho việc đó, và bài này đo hai thứ mà tài liệu không nói: tìm tốn bao nhiêu, và bản ghi cũ sống bao lâu.
Ba kiểu bản ghi
ServiceDiscovery sd = ServiceDiscovery.create(vertx);
sd.publish(HttpEndpoint.createRecord("don-hang", "localhost", 8080, "/"));
sd.publish(EventBusService.createRecord("kho", "dv.kho", KhoService.class));
sd.publish(MessageSource.createRecord("su-kien-don", "don.su-kien"));
don-hang kiểu=http-endpoint | {"endpoint":"http://localhost:8080/","host":"localhost","port":8080,...}
kho kiểu=eventbus-service-proxy | {"endpoint":"dv.kho"}
su-kien-don kiểu=message-source | {"endpoint":"don.su-kien"}
Ba kiểu tương ứng ba cách gọi nhau mà sê-ri đã đo: HTTP (phần 11), proxy qua event bus, và luồng sự kiện. Điểm chung là bên gọi chỉ cần biết tên, không cần biết host, cổng hay địa chỉ event bus.
Chi phí tìm tăng tuyến tính
| Số bản ghi trong sổ | p50 | p99 |
|---|---|---|
| 3 | 18,6 µs | 65,1 µs |
| 103 | 128,5 µs | 236,2 µs |
| 1 003 | 911,6 µs | 2 371,1 µs |
getRecord nhận một hàm lọc và duyệt tuần tự toàn bộ sổ đăng ký — không có chỉ mục theo tên. Ở 1 000 dịch vụ, mỗi lần tra cứu tốn gần một mili giây.
Để so sánh: phần 21 đo một lượt hỏi đáp qua cụm ở 66 µs. Nghĩa là với sổ đăng ký lớn, việc tìm dịch vụ đắt gấp mười bốn lần việc gọi nó.
Đó không phải lỗi thiết kế — sổ đăng ký sinh ra để tra cứu thưa thớt, không phải trên mỗi request. Nhưng nó nói rất rõ cách dùng đúng: tra một lần rồi giữ lại, đừng gọi getRecord trong đường xử lý request.
Và bản ghi đã cầm trong tay không bao giờ tự hết hạn
Đó chính là chỗ lời khuyên trên đâm vào một vấn đề khác.
lấy được: localhost:8090
sau khi dịch vụ biến mất, bản ghi trong tay vẫn trỏ tới: localhost:8090
(không có gì báo rằng nó đã chết)
getRecord sau khi unpublish trả về null — sổ đăng ký cập nhật đúng. Nhưng bản sao bạn đã lấy về thì không: nó là một đối tượng bình thường, không có hạn dùng, không có sự kiện nào bắn tới.
Hai lời khuyên đúng đắn mâu thuẫn nhau: tra mỗi lần thì tốn tới một mili giây; tra một lần thì bạn giữ một địa chỉ có thể đã chết.
Cách thoát khỏi thế kẹt đó
Tra một lần, và lắng nghe thay đổi. Sổ đăng ký phát sự kiện lên event bus khi có bản ghi được thêm hoặc gỡ; đăng ký nghe địa chỉ đó rồi cập nhật bộ nhớ đệm của bạn. Đây là cách đúng, và nó chỉ tốn vài dòng.
Đừng tin sổ đăng ký thay cho phép chờ. Một dịch vụ có thể chết mà chưa kịp unpublish — máy sập, container bị giết. Bản ghi vẫn nằm đó và trông hoàn toàn khoẻ mạnh. Phép chờ của phần 11 và circuit breaker (bài sau) mới là thứ phát hiện chuyện đó.
Cân nhắc xem bạn có cần nó không. Nếu các dịch vụ của bạn đã nằm sau một hệ thống điều phối — Kubernetes, một bộ cân bằng tải, hay event bus có phân cụm với địa chỉ cố định — thì bạn đã có tra cứu tên rồi, và thêm một sổ đăng ký nữa là thêm một chỗ để lệch nhau.
Muốn biết mỗi lần tra cứu đang tốn bao nhiêu, đếm số bản ghi trong sổ:
sd.getRecords(r -> true).onSuccess(ds ->
System.out.println("so ban ghi trong so: " + ds.size()));
Nhân số đó với 0,9 µs để ước lượng chi phí mỗi lần tra. Nếu mã của bạn gọi getRecord trong handler xử lý request, đó là con số đang cộng vào mọi lời gọi.
Mẫu số chung
Mọi bản sao bạn cầm trong tay đều là một tấm ảnh chụp tại lúc lấy — nó không tự biết nguồn đã đổi. Bản ghi dịch vụ, một dòng cấu hình đã đọc vào biến, một giá trị đã memoize, một hàng đã nạp vào cache tầng một của ORM: tất cả đều đúng lúc lấy và có thể sai ngay sau đó, mà không cái nào tự bắn tín hiệu "tôi cũ rồi". Đây chính là nửa khó của bài toán cache — không phải lưu, mà biết khi nào bỏ đi cái đã lưu. Có đúng hai lối thoát, và cả hai đều quen: hoặc tra tươi mỗi lần (đúng nhưng đắt — DNS query mỗi request), hoặc giữ lại rồi nghe tín hiệu vô hiệu hoá (DNS có TTL, cache có sự kiện xoá, sổ đăng ký phát sự kiện thêm/gỡ). Nguyên tắc: đã cache thì phải trả lời được câu "cái này hết hạn bằng cách nào" — không có câu trả lời thì bạn không có cache, bạn có một quả bom hẹn giờ trông giống dữ liệu.
Điều thứ hai, sắc và riêng: có tên trong một bảng tra cứu không phải là bằng chứng còn sống. Sổ đăng ký nói "cái này đã đăng ký", không nói "cái này đang chạy" — một dịch vụ sập trước khi kịp unpublish vẫn nằm đó trông khỏe mạnh. Cùng khoảng cách giữa "được khai báo" và "thực sự đáp ứng" ở một bản ghi DNS trỏ tới máy đã tắt, một entry trong bảng routing tới pod đã chết, một node trong danh sách cụm đã mất liên lạc. Sự hiện diện trong một danh mục là lời khai, còn sự thật chỉ đến từ việc chạm thử — một phép chờ, một health check, một circuit breaker. Đừng để một cái tên tra được ru ngủ rằng đầu kia còn thở.
Bài sau: circuit breaker — thứ duy nhất trong sê-ri này thật sự ngừng gửi việc đi khi hạ nguồn ốm, điều mà phần trước đo được rằng timeout không làm.