Tin ngành
Cloudflare tối ưu hóa bộ nhớ đệm DNS: Tiết kiệm 100 TB RAM nhờ Rust
(giờ Việt Nam)
Tóm tắt AI
Bằng cách tối ưu hóa cấu trúc bộ nhớ đệm DNS trên nền tảng Big Pineapple, Cloudflare đã giảm 56% dung lượng bộ nhớ trên mỗi mục, giúp giải phóng tổng cộng 100 TB RAM trên toàn hệ thống.
Bản dịch AI

Big Pineapple, nền tảng đứng sau 1.1.1.1, Gateway DNS, DNS Firewall, AS112 và một số dịch vụ Cloudflare DNS khác, lưu trữ hơn 250 tỷ mục bộ nhớ đệm (cache) DNS tại bất kỳ thời điểm nào. Ở quy mô đó, việc lãng phí dù chỉ một byte cho mỗi mục cũng tiêu tốn hơn 250 gigabyte bộ nhớ trên toàn bộ hệ thống của chúng tôi.
Năm thay đổi liên tiếp đối với cách lưu trữ các mục cache trong bộ nhớ đã cắt giảm hơn 50% dung lượng trên mỗi mục. Trên toàn bộ hệ thống, những thay đổi này đã giải phóng khoảng 100 terabyte bộ nhớ, tương đương với lượng RAM trong 130 máy chủ Gen 13 của chúng tôi. Bộ nhớ đệm cũng trở nên nhanh hơn. Thông lượng chèn tăng 43% và độ trễ tra cứu giảm 19%, nhờ vào việc giảm bớt các cấp phát bộ nhớ và cải thiện tính cục bộ của bộ nhớ, giúp chúng tôi không phải đánh đổi tốc độ lấy không gian lưu trữ.
Những gì chúng tôi lưu vào bộ nhớ đệm
Khi khởi động nguội (cold start), Big Pineapple bắt đầu với một bộ nhớ đệm trống. Khi các truy vấn DNS gửi đến, bộ nhớ đệm sẽ đầy dần cho đến khi đạt giới hạn số lượng mục tối đa, tại thời điểm đó, chúng tôi sẽ loại bỏ các mục cũ hơn hoặc ít phổ biến hơn để lấy chỗ trống.
Dung lượng bộ nhớ đệm chính xác thay đổi tùy theo trung tâm dữ liệu. Khi EDNS Client Subnet (ECS) được sử dụng, các máy chủ có thẩm quyền (authoritative servers) sẽ trả về các câu trả lời khác nhau tùy thuộc vào mạng của khách hàng, vì vậy chúng tôi lưu trữ nhiều phiên bản của cùng một truy vấn. Điều này làm tăng cả số lượng mục và bộ nhớ tiêu thụ cho mỗi mục, khiến các tối ưu hóa trong bài viết này đặc biệt hiệu quả đối với các vị trí có lưu lượng ECS lớn.
Mỗi mục trong bộ nhớ đệm là một cặp khóa-giá trị (key-value pair). Khóa xác định nội dung đã được truy vấn:
Giá trị lưu trữ chính phản hồi DNS: các phần câu trả lời (answer), thẩm quyền (authority) và các bản ghi bổ sung (additional record sections), cùng với siêu dữ liệu như thời gian tạo, bộ đếm lượt truy cập và Thời gian tồn tại (TTL).
Cả hai cấu trúc (struct) đều có thể cải thiện thêm. Một số trường sử dụng các kiểu dữ liệu mang theo chi phí dư thừa mà chúng tôi không cần đến sau khi mục đã được lưu trữ.
Đánh giá mức sử dụng bộ nhớ
Để đo lường tác động của từng thay đổi, chúng tôi thực hiện đánh giá bằng cách lấp đầy bộ nhớ đệm với các mục được tạo ngẫu nhiên, mô phỏng gần đúng phân phối lưu lượng mà chúng tôi thấy trong môi trường thực tế (production): 56% bản ghi A, 25% AAAA và 19% TXT. Mỗi mục chứa từ một đến bốn bản ghi.
Các bản ghi TXT đóng vai trò đại diện cho tất cả các loại bản ghi không phải A/AAAA trong bài kiểm tra. Kích thước của chúng được ngẫu nhiên hóa từ 64 đến 224 byte, gần với kích thước phản hồi trung bình mà chúng tôi thấy đối với các loại bản ghi có độ dài thay đổi.
Chúng tôi theo dõi mức sử dụng bộ nhớ bằng một bộ cấp phát tùy chỉnh bao bọc bộ cấp phát hệ thống của Rust và ghi lại số lượng cũng như kích thước các cấp phát cho mỗi mục cache. Bên cạnh bộ nhớ, chúng tôi đo lường thông lượng chèn và độ trễ tra cứu trên toàn bộ luồng cache để đảm bảo rằng việc tiết kiệm bộ nhớ không làm ảnh hưởng đến hiệu suất.
Các dữ liệu đầu vào này chỉ mang tính chất xấp xỉ môi trường thực tế chứ không tái tạo chính xác hoàn toàn. Bộ nhớ tiến trình cũng phụ thuộc vào hỗn hợp lưu lượng, mức độ chiếm dụng cache, trạng thái bộ cấp phát và bộ nhớ được sử dụng bên ngoài cache. Do đó, chúng tôi đã đo lường bộ nhớ thường trú (resident memory) trên các phiên bản thực tế trong quá trình triển khai.
Chi phí của dung lượng
Vec<T> lưu trữ ba trường: một con trỏ đến dữ liệu được cấp phát trên heap, độ dài hiện tại và tổng dung lượng. Khi bạn đẩy một mục vào, Vec sẽ kiểm tra xem độ dài có vượt quá dung lượng hay không và cấp phát lại nếu cần. Nếu còn chỗ, nó chỉ cần thêm mục đó vào và tăng độ dài.
Tuy nhiên, một khi chúng tôi lưu trữ phản hồi DNS trong cache, chúng tôi không bao giờ sửa đổi nó nữa. Trường dung lượng (capacity) không có mục đích sử dụng nhưng vẫn tiêu tốn 8 byte cho mỗi Vec. Không gian heap được cấp phát dư thừa cũng bị lãng phí, vì một Vec có dung lượng cho tám mục nhưng chỉ lưu trữ năm mục sẽ để lại ba vị trí không sử dụng trên heap.
Sử dụng Box<[T]> giải quyết được cả hai vấn đề. Nó không thể tăng kích thước sau khi tạo, vì vậy nó không cần trường dung lượng hoặc không gian dự trữ cho các phần tử trong tương lai. Điều tương tự cũng áp dụng cho String, vốn cũng mang theo một trường dung lượng. Box<str> loại bỏ nó.
Mỗi mục cache lưu trữ 8 trường Vec và String. Việc thay thế chúng bằng Box<[T]> và Box<str> giúp tiết kiệm 8 byte mỗi trường, tức 64 byte mỗi mục. Nó cũng loại bỏ bộ nhớ heap dư thừa mà Vec dự trữ cho việc tăng trưởng trong tương lai. Tổng mức tiết kiệm lên tới hơn 15 terabyte với hơn 250 tỷ mục cache.
Ít danh sách hơn, ít con trỏ hơn
Thay vì lưu trữ các phần câu trả lời, thẩm quyền và bổ sung trong các danh sách riêng biệt, chúng tôi có thể lưu trữ một danh sách duy nhất với các độ lệch (offsets) đến vị trí bắt đầu của mỗi phần. Vì số lượng bản ghi DNS trên mỗi phần nằm trong phạm vi u16, chúng tôi có thể sử dụng một u16 (2 byte) cho mỗi độ lệch, so với con trỏ 8 byte và độ dài 8 byte mà mỗi Box<[T]> riêng biệt yêu cầu.
Điều này loại bỏ hai danh sách, mỗi danh sách có một con trỏ 8 byte và độ dài 8 byte, và thay thế chúng bằng hai độ lệch 2 byte, tiết kiệm 28 byte cho mỗi mục.
Những khoản tiết kiệm này không phải lúc nào cũng tương ứng trực tiếp với số byte được loại bỏ từ các trường riêng lẻ. Rust chèn thêm phần đệm (padding) để đáp ứng các yêu cầu về căn chỉnh (alignment) và làm tròn kích thước của cấu trúc lên bội số của căn chỉnh đó. Do đó, việc loại bỏ một trường nhỏ có thể loại bỏ thêm phần đệm. Ví dụ, chúng tôi cũng đã đóng gói một vài trường boolean vào một bitflag duy nhất. Điều này làm giảm phần đệm xung quanh, khiến cấu trúc thu nhỏ lại nhiều hơn so với kích thước của các boolean riêng lẻ.
Loại bỏ chủ sở hữu (owner)
Mỗi bản ghi DNS đều có một chủ sở hữu, là tên miền mà bản ghi đó thuộc về. Trong nhiều trường hợp, chủ sở hữu này giống với tên miền đang được truy vấn. Ví dụ, một truy vấn cho example.com A trả về hai bản ghi với cùng một chủ sở hữu:
Nhưng khi có CNAME, ví dụ, chủ sở hữu bản ghi có thể khác với tên miền được truy vấn:
Định dạng truyền tin DNS xử lý các chủ sở hữu lặp lại bằng cách sử dụng nén tên, như được định nghĩa trong RFC 1035. Thay vì mã hóa cùng một tên miền hai lần, các lần xuất hiện tiếp theo sẽ lưu trữ một con trỏ 2 byte đến lần xuất hiện đầu tiên. Một tên miền như www.example.com có thể chỉ mã hóa www theo sau là một con trỏ đến nơi example.com đã xuất hiện trong thông điệp.
Điều này hoạt động tốt trên đường truyền, nhưng trong cache, chúng tôi lưu trữ tên chủ sở hữu đầy đủ cùng với mỗi bản ghi. Việc theo dõi các con trỏ nén trong quá trình tra cứu cache rất tốn kém trên đường dẫn nóng (hot path), vì vậy chúng tôi đánh đổi bộ nhớ lấy tốc độ.
Tuy nhiên, hầu hết các bản ghi đều có chủ sở hữu giống với tên miền được truy vấn. Đối với những bản ghi đó, chúng tôi có thể loại bỏ hoàn toàn chủ sở hữu và suy luận nó tại thời điểm đọc. Khi chủ sở hữu khác biệt, chẳng hạn như các bản ghi A đằng sau một CNAME, chúng tôi sẽ lưu trữ tên đầy đủ.
Khi chủ sở hữu là None, việc xây dựng phản hồi sẽ khôi phục tên miền được truy vấn từ khóa cache, tránh được việc cấp phát heap. Điều này có nghĩa là bản ghi không còn tự chứa (self-contained) nữa, nhưng khóa cache luôn có sẵn trong mỗi lần tra cứu. Khi chủ sở hữu khác biệt, Some sẽ lưu trữ một con trỏ đến tên đầy đủ trên heap.
Trên thực tế, hầu hết các bản ghi được lưu trong cache đều có chủ sở hữu giống với tên miền được truy vấn, vì vậy phần lớn không yêu cầu cấp phát heap cho trường chủ sở hữu.
Kích thước Enum
Các enum trong Rust là các kiểu tổng (sum types): mỗi biến thể có thể mang dữ liệu khác nhau, nhưng enum luôn có kích thước bằng với biến thể lớn nhất của nó.
Option là Some và chứa một giá trị, hoặc None và không chứa gì cả. Cả hai biến thể đều chiếm cùng một lượng bộ nhớ. Enum lưu trữ một thẻ (tag) chỉ ra biến thể đang hoạt động, theo sau là không gian đủ lớn cho dữ liệu của biến thể lớn nhất. Khi biến thể là None, không gian đó không được sử dụng.
Đối với dữ liệu bản ghi, có vẻ tự nhiên khi lưu trữ mỗi loại bản ghi DNS dưới dạng một biến thể enum:
Nhưng enum luôn lớn bằng biến thể lớn nhất của nó. Trong trường hợp của chúng tôi, đó là NAPTR với 136 byte. Nó lưu trữ ba trường văn bản có độ dài thay đổi, một tên miền và hai số nguyên. Kết quả là, toàn bộ enum, bao gồm thẻ biến thể và phần đệm, trở thành 144 byte.
Một bản ghi A chỉ cần 4 byte và bản ghi AAAA cần 16 byte. A và AAAA chiếm hơn 80% lưu lượng truy cập của chúng tôi, vì vậy hầu hết các bản ghi lãng phí hơn 120 byte cho phần đệm. Vì một mục cache duy nhất có thể lưu trữ nhiều bản ghi, con số này tăng lên rất nhanh.
Đóng gói các biến thể (Boxing the variants)
Để giải quyết vấn đề này, chúng ta có thể đóng gói (box) các biến thể lớn hơn của enum, di chuyển chúng sang một cấp phát heap riêng biệt. Khi đó, enum chỉ lưu trữ một con trỏ 8 byte đến heap, nơi dữ liệu chỉ chiếm kích thước thực tế cần thiết.
Đối với các bản ghi A và AAAA, điều này giúp tiết kiệm 120 byte mỗi bản ghi. Các loại biến thể nhỏ hơn như TXT và CNAME cũng được hưởng lợi. Chúng vẫn chiếm 24 byte của enum, nhưng cấp phát heap của chúng được định kích thước theo dữ liệu thực tế thay vì bị đệm lên 144 byte. NAPTR, biến thể lớn nhất, thực tế phải trả thêm một chút chi phí. Giờ đây, nó cộng thêm chi phí của một con trỏ heap và chi phí cấp phát. Nhưng các bản ghi NAPTR rất hiếm gặp trong thực tế, nên sự đánh đổi này là hoàn toàn xứng đáng.
Bài viết được AI dịch và tổng hợp tự động từ Cloudflare Blog. Liên kết bài gốc ở phía trên. AIHOT.vn luôn dẫn nguồn đầy đủ — nếu bạn thấy điểm cần chỉnh sửa, hãy gửi ý kiến tại trang phản hồi.