
Rust ลด RAM ให้ DNS Cache: บทเรียนจาก 250,000 ล้านรายการของ Cloudflare
ถ้าโปรแกรมใช้หน่วยความจำเกินมารายการละ 1 ไบต์ เราอาจไม่รู้สึกอะไร แต่ถ้ามีข้อมูล 250,000 ล้านรายการ ไบต์เดียวจะกลายเป็นหน่วยความจำมากกว่า 250 กิกะไบต์ทันที นี่คือเหตุผลที่งาน “จัดของในกล่องให้แน่นขึ้น” กลายเป็นงานระดับโครงสร้างพื้นฐานของ Cloudflare
Cloudflare เล่าเมื่อ 27 สิงหาคม 2026 ว่า Big Pineapple ซึ่งอยู่เบื้องหลัง 1.1.1.1 และบริการ DNS อื่น ๆ ลดขนาด cache entry จาก 953 เหลือ 420 ไบต์ หรือ 56% ปริมาณหน่วยความจำรวมที่คืนให้ระบบอยู่ราว 100 เทราไบต์ ขณะเดียวกัน insert throughput เพิ่ม 43% และ lookup latency ลด 19%
ประเด็นสำคัญไม่ใช่ให้เรารีบเปลี่ยนทุก Vec<T> เป็น Box<[T]> แต่คือ ชนิดข้อมูลควรสะท้อนวงจรชีวิตจริงของมัน ถ้าข้อมูลโตระหว่างประกอบ แต่ไม่โตอีกเลยหลังเข้า cache เราไม่จำเป็นต้องแบกความสามารถในการขยายติดตัวไปตลอดอายุของรายการนั้นครับ
WHY: โครงสร้างข้อมูลคือสัญญาค่าเช่าระยะยาว
Vec<T> เหมือนลังที่เผื่อพื้นที่ให้เติมของภายหลัง มันต้องจำ pointer, length และ capacity ตามสัญญาของ Rust ส่วน Box<[T]> เป็นถาดที่ปิดขนาดแล้ว จึงไม่ต้องจำ capacity สำหรับการเติบโตในอนาคต
ค่า overhead นี้เล็กมากในหนึ่งรายการ แต่ cache ต่างจากหน้าจอทั่วไปตรงที่โครงสร้างเดิมถูกทำซ้ำมหาศาล ถ้า cache entry มี Vec หรือ String หลายช่อง ต้นทุนของ header, พื้นที่ที่จองเกิน และ allocation หลายก้อนจะถูกคูณทุกครั้ง
เอกสาร Vec ระบุว่า Vec<T> เป็นชุด pointer, capacity และ length และจะไม่ย่อตัวเองอัตโนมัติ ส่วน size_of เตือนว่าขนาด type ทั่วไปไม่ใช่สัญญาคงที่ข้ามทุก compilation เพราะ alignment และ padding มีผล เราจึงควรวัดบน target จริง ไม่เดาขนาดจากจำนวน field ด้วยสายตา
HOW: แยกช่วง “ประกอบ” ออกจากช่วง “เสิร์ฟ”
ตัวอย่างด้านล่างจำลอง DNS response ที่มี answer, authority และ additional section ช่วงสร้างใช้ Vec ตามธรรมชาติ พอข้อมูลพร้อมเก็บ เรารวม record ไว้ใน Box<[Record]> ก้อนเดียว แล้วใช้ offset บอกจุดเริ่มของแต่ละ section
Test แรกตรวจว่าข้อมูลทั้งสามส่วนยังอ่านกลับได้ถูกต้อง ไม่ได้แลก correctness กับ RAM ส่วน test ที่สองบันทึกความจริงของ toolchain ที่รันบทความนี้ว่า header ของ frozen slice เล็กกว่า header ของ Vec บน target ปัจจุบัน
use std::mem::size_of;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Record(u8);
struct FrozenEntry {
records: Box<[Record]>,
authority_start: u16,
additional_start: u16,
}
impl FrozenEntry {
fn from_sections(
answers: Vec<Record>,
authority: Vec<Record>,
additional: Vec<Record>,
) -> Self {
let authority_start = answers.len();
let additional_start = authority_start + authority.len();
assert!(additional_start <= u16::MAX as usize);
let mut records = answers;
records.extend(authority);
records.extend(additional);
Self {
records: records.into_boxed_slice(),
authority_start: authority_start as u16,
additional_start: additional_start as u16,
}
}
fn sections(&self) -> (&[Record], &[Record], &[Record]) {
let (answers, tail) = self.records.split_at(self.authority_start as usize);
let authority_len = self.additional_start as usize - self.authority_start as usize;
let (authority, additional) = tail.split_at(authority_len);
(answers, authority, additional)
}
}
#[test]
fn frozen_layout_preserves_dns_sections() {
let entry = FrozenEntry::from_sections(
vec![Record(1), Record(2)],
vec![Record(3)],
vec![Record(4), Record(5)],
);
let (answers, authority, additional) = entry.sections();
assert_eq!(answers, [Record(1), Record(2)]);
assert_eq!(authority, [Record(3)]);
assert_eq!(additional, [Record(4), Record(5)]);
}
#[test]
fn frozen_slice_drops_the_vec_capacity_word_on_this_target() {
assert!(size_of::<Box<[Record]>>() < size_of::<Vec<Record>>());
}
โค้ดนี้ไม่ได้จำลอง DNS protocol ทั้งหมด และตัวเลขขนาดอาจต่างกันบนสถาปัตยกรรมอื่น แต่พิสูจน์หลักคิดที่นำไปใช้ได้จริง: ใช้โครงสร้าง growable ตอนประกอบ แล้ว freeze เป็น representation ที่ตรงกับงานอ่านซ้ำ
ห้าก้าวของ Cloudflare และเหตุผลเบื้องหลัง
ก้าวแรกคือเปลี่ยน Vec<T> เป็น Box<[T]> และ String เป็น Box<str> ในช่องที่ไม่แก้หลัง insert เพื่อตัด capacity ที่ไม่ใช้ Cloudflare รายงานว่ามี 8 ช่องลักษณะนี้ จึงลดได้ 64 ไบต์ต่อ entry นอกเหนือจาก heap ที่จองเผื่อ
ก้าวที่สองคือรวม list หลายชุดเป็น list เดียว แล้วใช้ u16 offsets แทน pointer กับ length ของแต่ละ list การออกแบบนี้เหมือนรวมเอกสารสามแฟ้มไว้ในกล่องเดียว แล้วคั่นด้วยที่คั่นหน้าสองอัน แทนการแบกแฟ้มสามใบ
ก้าวที่สามคือเก็บ owner ของ DNS record เฉพาะเมื่อไม่เหมือนชื่อที่ query ถ้าเหมือนกันก็อนุมานจาก cache key ตอนอ่าน วิธีนี้ยอมให้ record ไม่ self-contained เพื่อกำจัดข้อมูลซ้ำ แต่ทำได้เพราะ lookup มี key อยู่แล้วเสมอ
ก้าวที่สี่คือจัดการ enum ตามสัดส่วนข้อมูลจริง ในระบบนี้ record แบบ A และ AAAA มีมากกว่า 80% แต่ enum จะมีขนาดตาม variant ที่ใหญ่ที่สุด การ box variant ใหญ่ช่วยให้กรณีเล็กและพบบ่อยไม่ต้องแบกพื้นที่ว่างมหาศาล ทว่า pointer chasing กับ allocation ใหม่ก็มีต้นทุน จึงยังไม่ใช่จุดจบ
ก้าวสุดท้ายคือเก็บ record data เป็น wire-format bytes ต่อเนื่องใน Box<[u8]> พร้อม length prefix แนวทางนี้ลด enum padding, ลด allocation และทำให้ข้อมูลอยู่ใกล้กันใน CPU cache ข้อแลกเปลี่ยนคือสุ่ม index ไม่สะดวก แต่เมื่อแต่ละ entry มี record ไม่มาก การเดินตามลำดับถูกกว่าการแบกโครงสร้างใหญ่ทุกครั้ง
อย่าหลงตัวเลขจนลืมวิธีวัด
Cloudflare ไม่ได้ดูแค่ size_of แล้วประกาศชัยชนะ ทีมใช้ allocator wrapper นับ allocation ต่อ entry, สร้าง benchmark ด้วยสัดส่วน record ใกล้ production, วัด insert และ lookup และตาม resident memory ระหว่าง rollout จริง
นี่สำคัญมาก เพราะการลด field 8 ไบต์อาจลด struct มากกว่าหรือน้อยกว่านั้นจาก padding และการ box อาจทำให้ size_of ของตัวแม่เล็กลง แต่เพิ่ม allocator overhead กับ cache miss บน heap ถ้าวัดเพียงมิติเดียว เราอาจย้ายค่าใช้จ่ายจาก RAM ไป latency โดยไม่รู้ตัว
สำหรับระบบทั่วไป ลำดับที่ปลอดภัยคือเก็บ baseline ก่อน เปลี่ยน representation ทีละชั้น ทดสอบผลลัพธ์เดิม วัด memory กับ latency คู่กัน แล้ว rollout แบบย้อนกลับได้ อย่าคัดลอก wire format หรือ bit packing เพียงเพราะดูฉลาด ถ้าขนาดข้อมูลยังไม่มากพอให้ complexity คืนทุน
Recap
บทเรียนจาก DNS cache นี้ไม่ใช่ “Rust ใช้ RAM น้อยอัตโนมัติ” แต่คือ Rust ทำให้เราเลือก representation และวัดผลได้ละเอียดพอจะจ่ายเฉพาะความสามารถที่ต้องใช้
จำง่าย ๆ สามข้อ: ข้อมูลที่หยุดโตควร freeze, ข้อมูลที่อ่านด้วยกันควรอยู่ใกล้กัน และ optimization ต้องผ่านทั้ง correctness, memory และ latency ถ้าเริ่มจากวงจรชีวิตของข้อมูลก่อน syntax เราจะเห็นโอกาสลดต้นทุนโดยไม่ทำให้โค้ดกลายเป็นปริศนาครับ