Trước khi chương trình của bạn kết nối tới api.example.com, phải có một bước thầm lặng xảy ra: đổi cái tên đó thành một địa chỉ IP. Bước này — phân giải DNS — nằm trước cả bắt tay TCP, và nếu nó chậm thì mọi thứ phía sau đều chậm theo. Bài này dựng một máy chủ DNS của riêng tôi trong container (không đụng tới DNS công cộng của ai) rồi đo từng chặng của một lần tra tên, thời gian mỗi chặng, và một cái bẫy khiến một lookup lẽ ra 50 mili giây kéo thành 6 giây.

Phân giải DNS

Các chặng của một lần tra tên

Khi bạn gọi getaddrinfo("web.test"), thư viện C không lập tức bắn ra mạng. Nó đi theo một thứ tự do /etc/nsswitch.conf quy định, và chặng đầu tiên thường là /etc/hosts. Tôi đo trực tiếp: thêm 10.9.9.9 fast.test vào /etc/hosts rồi tra fast.test, đồng thời bắt gói ở cổng 53:

getaddrinfo(fast.test) -> 10.9.9.9  trong ~4 ms
số gói ra mạng tới cổng 53: 0

Không một gói DNS nào rời máy. /etc/hosts được đọc trước, và nếu tên nằm trong đó thì việc phân giải dừng ngay tại chỗ. Đây là lý do bạn có thể ghi đè một tên miền về 127.0.0.1 bằng cách sửa hosts, và là lý do một dòng rác trong hosts có thể khiến một tên phân giải sai mà chẳng có truy vấn DNS nào để bạn lần theo.

Khi tên không có trong hosts, thư viện mới gửi truy vấn tới nameserver ghi trong /etc/resolv.conf. Và đây là chi tiết bất ngờ đầu tiên: một lần getaddrinfo gửi hai truy vấn, không phải một. Bắt gói cho một lần tra web.test cho thấy:

1  A?    web.test.
1  AAAA? web.test.

Một truy vấn A xin địa chỉ IPv4, một truy vấn AAAA xin địa chỉ IPv6, gửi gần như song song. getaddrinfo muốn biết cả hai họ địa chỉ để còn chọn, nên nó hỏi cả hai và chờ cả hai trả lời trước khi trả kết quả về cho bạn. Chỉ sau khi có địa chỉ, bắt tay TCP mới bắt đầu.

Thời gian: DNS là khoản phụ thu trước mọi kết nối

Với máy chủ DNS của tôi đặt độ trễ 20 mili giây mỗi câu trả lời, một lần tra tên thường tốn khoảng:

getaddrinfo(web.test) -> 10.1.2.3  trong ~50 ms

50 mili giây đó là khoản phụ thu trước khi byte dữ liệu đầu tiên kịp đi — cộng thẳng vào độ trễ của kết nối đầu tiên tới một tên miền, y như thuế bắt tay đã đo ở bài về bắt tay TCP. Trên đường thật tới một resolver ở xa, con số này dễ là hàng chục tới cả trăm mili giây, và nó lặp lại mỗi khi bạn tra một tên chưa được nhớ.

Bất ngờ tôi đo được: AAAA bị bỏ rơi làm stall 6 giây

Máy chủ DNS tôi viết ban đầu trả lời AAAA bằng NODATA — một câu trả lời hợp lệ nghĩa là "tên này không có bản ghi IPv6". Với cách đó, lookup nhanh gọn ~50 ms. Nhưng ngoài đời, nhiều resolver hỏng hoặc tường lửa cấu hình ẩu lại im lặng bỏ rơi truy vấn AAAA thay vì trả NODATA. Để xem điều đó ảnh hưởng ra sao, tôi cho máy chủ của mình bỏ qua hẳn AAAA, giữ nguyên A trả lời tức thì, rồi đo lại cùng cái tên:

AAAA bị bỏ rơi, A (IPv4) sẵn ngay:
getaddrinfo(web.test) -> 10.1.2.3  trong ~6000 ms   (ba lần đo đều ~6 giây)

Sáu giây, cho một cái tên mà địa chỉ IPv4 đã có sẵn ngay từ mili giây đầu tiên. Nguyên nhân: getaddrinfo chờ cả A lẫn AAAA. Khi AAAA không bao giờ được đáp, thư viện phải chờ hết thời gian chờ của trình phân giải (và các lần thử lại) rồi mới chịu bỏ cuộc với AAAA và trả về cái A vốn đã nằm sẵn. Một truy vấn im lặng ghìm trọn cả lookup.

Đây vừa là một cái bẫy production nổi tiếng, vừa là một bài học đo lường cho chính tôi: hành vi của máy chủ tôi tự dựng là một biến số. Máy chủ "ngoan" trả NODATA đã vô tình che mất điểm mong manh của đường IPv6; chỉ khi tôi cho nó cư xử như một resolver hỏng thật, sự khác biệt 100 lần (50 ms so với 6000 ms) mới lộ ra. Khác biệt giữa "trả lời NODATA" và "im lặng bỏ rơi" — hai thứ nghe gần như nhau — là khác biệt giữa một lookup tức thì và một lookup treo 6 giây.

Cách chữa hiện đại cho vấn đề này là Happy Eyeballs (RFC 8305): các client mới (trình duyệt, nhiều thư viện) không dùng getaddrinfo kiểu chờ-cả-hai, mà đua AAAAA song song, dùng ngay cái nào về trước và không để một họ địa chỉ chậm ghìm họ kia. Nhưng rất nhiều chương trình vẫn gọi thẳng getaddrinfo, và chúng vẫn dính đúng cái stall vừa đo.

Hệ điều hành không cache DNS như bạn tưởng

Một hiểu lầm phổ biến là "hệ điều hành nhớ kết quả DNS, nên tra lần hai sẽ nhanh". Tôi kiểm chứng bằng cách gọi getaddrinfo("web.test") hai lần liên tiếp và đếm truy vấn:

gọi 2 lần -> 4 truy vấn trên dây (2 x [A + AAAA])

Lần gọi thứ hai gửi truy vấn mới hoàn toàn, không hề dùng lại kết quả lần đầu. Trên một Linux trần — không có nscd, không systemd-resolved, không một trình phân giải đệm nào — hệ điều hành không cache DNS. Mỗi lần tra tên là một lượt ra mạng mới. Việc nhớ kết quả (theo TTL của bản ghi) là do một trình phân giải đệm riêng làm, hoặc do chính thư viện/ứng dụng của bạn làm — không phải thứ nhân tự động cho không. Nếu dịch vụ của bạn tra cùng một tên hàng nghìn lần mỗi giây mà không có tầng đệm, bạn đang bắn hàng nghìn truy vấn DNS mỗi giây một cách vô ích.

Vì sao điều này quan trọng khi lập trình

Ba điều rút ra rất cụ thể. Thứ nhất, DNS là một phụ thu độ trễ ẩn trước mọi kết nối tới một tên miền chưa được nhớ — nếu bạn đo hiệu năng một API bằng cách mở kết nối mới tới hostname mỗi lần, bạn đang đo lẫn cả thời gian phân giải vào con số của mình. Tái dùng kết nối, hoặc ít nhất đệm kết quả DNS, cắt được khoản này.

Thứ hai, cái stall AAAA là thủ phạm của vô số lỗi "chậm 5 giây rồi mới chạy" trong thực tế: một dịch vụ chạy chậm bất thường khi kết nối tới một tên nào đó, log không báo lỗi gì, hóa ra là đường IPv6 bị một tường lửa nuốt truy vấn AAAA. Khi gặp độ trễ đúng bằng vài giây tròn trịa trong lúc phân giải tên, hãy nghĩ ngay tới nó, và kiểm bằng cách so getaddrinfo với việc tra riêng A.

Thứ ba, hãy chủ động về caching: đặt một trình phân giải đệm (như systemd-resolved hay dnsmasq) hoặc dùng thư viện có cache theo TTL, thay vì cho rằng hệ điều hành lo giùm. Con số mang theo: một lần phân giải tên là hai truy vấn (A và AAAA) mà getaddrinfo chờ cả hai; nó không được HĐH cache; và một truy vấn im lặng có thể ghìm cả lookup nhiều giây — ba sự thật quyết định phần lớn độ trễ và độ ổn định của bước đầu tiên trong mọi kết nối tới một cái tên.

Thử ba mươi giây

Chạy getent hosts <một tên miền> để thấy tên được phân giải qua đúng chuỗi nsswitch mà chương trình thật dùng (kể cả /etc/hosts). Rồi so hai lệnh: time getent ahostsv4 <tên> (chỉ IPv4) và time getent ahosts <tên> (cả A lẫn AAAA). Nếu bản đầy đủ chậm hơn hẳn, đường AAAA của bạn đang có vấn đề — đúng cái stall vừa đo. Thêm một dòng vào /etc/hosts rồi tra lại cái tên đó để thấy nó phân giải tức thì mà không có gói nào ra mạng.