Big Pineapple — платформа, на которой работают 1.1.1.1, Gateway DNS, DNS Firewall, AS112 и ряд других DNS-сервисов Cloudflare — хранит в любой момент времени более 250 миллиардов записей DNS-кэша. При таком масштабе трата даже одного лишнего байта на запись оборачивается потерей свыше 250 гигабайт памяти на всём флоте серверов.

Пять последовательных изменений в способе хранения записей кэша в памяти сократили объём, приходящийся на одну запись, более чем на 50%. В сумме по флоту это освободило около 100 терабайт памяти — столько же ОЗУ содержится в 130 серверах Gen 13 Cloudflare. При этом кэш стал работать быстрее: пропускная способность вставки выросла на 43%, а задержка поиска снизилась на 19% — меньше аллокаций и лучшая локальность памяти позволили не платить скоростью за экономию места.

Что кэшируется

При холодном старте Big Pineapple начинает работу с пустым кэшем. По мере поступления DNS-запросов кэш заполняется, пока не достигнет максимального числа записей — после этого старые или менее популярные элементы вытесняются, чтобы освободить место.

Точный размер кэша варьируется от одного дата-центра к другому. Когда используется EDNS Client Subnet (ECS), авторитативные серверы возвращают разные ответы в зависимости от сети клиента, поэтому приходится кэшировать несколько версий одного и того же запроса. Это увеличивает и количество записей, и объём памяти, который потребляет каждая из них, — поэтому описанные далее оптимизации особенно заметны в локациях с активным использованием ECS.

Каждый элемент кэша — это пара «ключ-значение». Ключ определяет, что именно было запрошено:

pub struct CacheKey {
    qname: Name,
    qtype: Rtype,
    authenticated: bool,
    tag: Vec<u8>,
}

Значение хранит сам ответ DNS: разделы answer, authority и additional, а также метаданные — время создания, счётчик обращений и Time-to-Live (TTL).

pub struct CacheEntry {
    timestamp: UnixTimeStamp,
    pub inception: Instant,
    pub ttl: Ttl,
    pub hits: u32,
    pub answers: Vec<Record>,
    pub authority: Vec<Record>,
    pub additional: Vec<Record>,
    pub errors: Vec<ExtendedError>,
    ...
}

Обе структуры можно было улучшить: несколько полей используют типы с накладными расходами, которые после сохранения записи уже не нужны.

Замер использования памяти

Чтобы измерить эффект каждого изменения, инженеры заполняли кэш случайно сгенерированными записями, примерно соответствующими распределению трафика в продакшене: 56% записей A, 25% AAAA и 19% TXT. Каждая запись содержит от одной до четырёх записей ресурса.

Записи TXT в бенчмарке выступают заменителем всех типов записей, кроме A/AAAA. Их размер случайно варьируется от 64 до 224 байт — это близко к среднему размеру ответов для типов записей переменной длины, наблюдаемому в продакшене.

Использование памяти отслеживалось с помощью кастомного аллокатора, который оборачивает System аллокатор Rust и фиксирует количество и размер аллокаций на каждую запись кэша. Помимо памяти, замерялись пропускная способность вставки и задержка поиска по всему пути обработки кэша, чтобы убедиться, что экономия памяти не достигается за счёт скорости.

Эти вводные данные приближают продакшен, но не воспроизводят его точно. Потребление памяти процессом также зависит от структуры трафика, заполненности кэша, состояния аллокатора и памяти, используемой за пределами кэша. Поэтому резидентную память на продакшен-инстансах измеряли и во время самого раскатывания изменений.

Цена вместимости

Vec<T> хранит три поля: указатель на данные в куче, текущую длину и общую вместимость (capacity). При добавлении элемента Vec проверяет, не превысит ли длина вместимость, и при необходимости перевыделяет память. Если место есть, элемент просто добавляется, а длина увеличивается.

Однако после того как DNS-ответ сохранён в кэше, он больше никогда не изменяется. Поле capacity в этом случае бесполезно, но всё равно занимает 8 байт на каждый Vec. Излишне выделенная память в куче также пропадает впустую: Vec с вместимостью на восемь элементов, но с пятью сохранёнными, оставляет три неиспользованных слота в куче.

Использование Box<[T]> решает обе проблемы. Такая структура не может расти после создания, поэтому ей не нужны ни поле capacity, ни резерв для будущих элементов. То же касается String, у которого тоже есть поле capacity: Box<str> его не хранит.

Каждая запись кэша содержит 8 полей типа Vec и String. Замена их на Box<[T]> и Box<str> экономит 8 байт на поле, то есть 64 байта на запись. Кроме того, устраняется избыточная память в куче, которую Vec резервирует под будущий рост. Суммарная экономия при более чем 250 миллиардах записей кэша превышает 15 терабайт.

Меньше списков, меньше указателей

Вместо хранения разделов answer, authority и additional в отдельных списках можно хранить единый список со смещениями до начала каждого раздела. Поскольку количество DNS-записей в разделе укладывается в u16, для каждого смещения достаточно 2 байт — против 8-байтового указателя и 8-байтовой длины, которые требует каждый отдельный Box<[T]>.

Это устраняет два списка, каждый с 8-байтовым указателем и 8-байтовой длиной, заменяя их двумя 2-байтовыми смещениями — экономия 28 байт на запись.

Такая экономия не всегда напрямую совпадает с числом убранных из отдельных полей байтов. Rust добавляет отступы для выравнивания и округляет размер структуры до кратного значению выравнивания. Поэтому удаление маленького поля может дополнительно устранить лишние отступы. Например, несколько булевых полей были упакованы в один bitflag. Это уменьшило окружающие отступы, из-за чего структура сжалась сильнее, чем на размер самих булевых полей.

Отказ от владельца записи

У каждой DNS-записи есть владелец (owner) — домен, к которому она относится. Во многих случаях этот владелец совпадает с запрошенным доменом. Например, запрос example.com A возвращает две записи с одним и тем же владельцем:

$ dig example.com A

;; ANSWER SECTION:
example.com.        300    IN    A        198.51.100.1
example.com.        300    IN    A        198.51.100.2

Но когда задействован CNAME, например, владелец записи может отличаться от запрошенного домена:

$ dig example.com A

;; ANSWER SECTION:
example.com.        300    IN    CNAME    cdn.example.com.
cdn.example.com.    300    IN    A        198.51.100.1
cdn.example.com.    300    IN    A        198.51.100.2

DNS-формат передачи данных обрабатывает повторяющихся владельцев с помощью сжатия имён, определённого в RFC 1035. Вместо повторного кодирования того же домена последующие вхождения хранят 2-байтовый указатель на первое вхождение. Домен вида www.example.com может кодироваться просто как www с указателем на место, где example.com уже встречался в сообщении.

В формате передачи это работает хорошо, но в кэше полное имя владельца хранится рядом с каждой записью. Следование указателям сжатия при поиске в кэше дорого обходится на горячем пути, поэтому память меняется на скорость.

Однако у большинства записей владелец идентичен запрошенному домену. Для них можно вовсе отбросить владельца и восстановить его при чтении. Когда владелец отличается — например, у A-записей за CNAME — полное имя сохраняется.

pub struct Record {
    owner: Option<Box<Name>>,
    class: Class,
    ttl: Ttl,
    rtype: Rtype,
    data: RecordData,
}

Когда owner равен None, при формировании ответа запрошенный домен восстанавливается из ключа кэша — без выделения памяти в куче. Это означает, что запись больше не самодостаточна, но ключ кэша всегда доступен при каждом поиске. Когда владелец отличается, Some хранит указатель на полное имя в куче.

На практике у большинства кэшированных записей владелец идентичен запрошенному домену, поэтому для основной массы записей выделение памяти под поле владельца не требуется.

Размер перечислений (enum)

Перечисления (enum) в Rust — это суммовые типы: каждый вариант может содержать разные данные, но размер самого enum всегда равен размеру его крупнейшего варианта.

pub enum Option<T> {
    Some(T),
    None,
}

Option — это либо Some со значением, либо None без значения. Оба варианта занимают одинаковый объём памяти. Enum хранит тег, указывающий на активный вариант, а следом — место, достаточное для данных крупнейшего варианта. Когда активен вариант None, это место остаётся неиспользованным.

Для данных записи естественно выглядит хранение каждого типа DNS-записи как варианта enum:

pub enum RecordData {
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    Txt(Txt),
    Naptr(Naptr),
    Svcb(Svcb),
    // ...
}

Но enum всегда равен по размеру своему крупнейшему варианту. В данном случае это NAPTR размером 136 байт: он хранит три текстовых поля переменной длины, доменное имя и два целых числа. В результате весь enum, включая тег варианта и отступы, вырастает до 144 байт.

Записи A нужно всего 4 байта, а AAAA — 16 байт. A и AAAA составляют более 80% трафика, поэтому большинство записей теряют свыше 120 байт на отступы. Поскольку одна запись кэша может хранить множество записей ресурса, это быстро складывается в заметные объёмы.

Упаковка вариантов в Box

Для решения этой проблемы крупные варианты enum можно упаковать в отдельную аллокацию на куче. Enum тогда хранит 8-байтовый указатель на кучу, где данные занимают ровно тот объём, который им действительно нужен.

pub enum RecordData {
    // Small and common variants are stored inline
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    // Large variants are stored on the heap
    Txt(Box<Txt>),
    Naptr(Box<Naptr>),
    Svcb(Box<Svcb>),
    // ...
}

Для записей A и AAAA это экономит 120 байт на запись. Небольшие варианты, такие как TXT и CNAME, тоже выигрывают: они всё ещё занимают 24-байтовый enum, но их аллокация на куче теперь соответствует реальному размеру данных, а не дополняется до 144 байт. NAPTR, крупнейший вариант, наоборот, немного проигрывает — теперь к нему добавляется цена указателя на кучу и накладные расходы аллокации. Но записи NAPTR на практике редки, поэтому такой обмен оправдан.

Однако упаковка крупных вариантов записей в Box приносит собственные издержки.

Цена упаковки в Box

У упаковки в Box две цены. Первая — накладные расходы аллокатора. Каждый упакованный вариант становится отдельной аллокацией на куче, а аллокаторы округляют размер до ближайшего класса размера. Big Pineapple использует jemalloc — аллокатор, рассчитанный на многопоточные нагрузки с интенсивным выделением памяти. jemalloc группирует аллокации похожего размера в бины фиксированного размера. Запись TXT запрашивает 32 байта и точно попадает в 32-байтовый бин без потерь, а запись MX запрашивает 40 байт и округляется до 48, теряя 8 байт.

Вторая цена — плохая локальность памяти. Без упаковки значения enum записей для одной записи кэша лежат в единой смежной аллокации. С упаковкой данные каждого упакованного варианта живут в отдельной области кучи. Чтение требует перехода по указателю, и если указатель ведёт далеко от остальной части записи, процессору приходится подгружать новую кэш-линию. При миллионах записей кэша упакованные данные оказываются разбросаны по куче, а не упакованы вместе.

Каждая из этих цен по отдельности не критична, но устранение обеих сразу, как показано в следующем разделе, дало заметный прирост и в использовании памяти, и в задержке поиска.

Хранение записей в формате передачи (wire format)

Очевидным следующим шагом могло бы стать хранение полного DNS-ответа в формате передачи, с патчингом только клиент-специфичных полей, таких как идентификатор сообщения, при каждом поиске. Но у этого подхода есть недостатки. Записи DNSSEC включаются только тогда, когда клиент устанавливает флаг DO (DNSSEC OK). Хранение полного сообщения в формате передачи означало бы либо кэширование двух вариантов — с DNSSEC и без, — либо отфильтровывание их из уже собранного сообщения. Есть и цена разбора полного сообщения при каждом поиске, которую избегает подход с enum, описанный выше, за счёт хранения уже разобранных записей.

Компромиссным решением стало хранение данных самой записи в виде необработанных байтов, при этом остальная часть записи кэша остаётся структурированными полями. Вместо списка разобранных вариантов enum записи хранятся как единый Box<[u8]>, содержащий каждую запись, закодированную с 2-байтовым префиксом длины, за которым следуют её необработанные байты.

Это устраняет накладные расходы enum на каждый вариант и упакованные в Box аллокации из предыдущей оптимизации. Данные становятся упакованы смежно, что улучшает локальность в кэше процессора. Плата за это — записи больше нельзя индексировать в произвольном порядке, приходится проходить буфер последовательно. Это добавляет немного сложности для таких функций, как round-robin-ротация записей A/AAAA, но поскольку число записей на одну запись кэша невелико, эта цена незначительна.

При построении DNS-ответа из кэшированных записей большинство типов записей можно копировать прямо из буфера в исходящее сообщение. Раньше каждую разобранную запись приходилось сериализовать поле за полем обратно в формат передачи. Новая структура позволяет пропустить эту работу для A, AAAA, TXT и всех типов записей DNSSEC, копируя их закодированные байты напрямую. Только записи, содержащие доменные имена — такие как CNAME, NS, MX и SOA — всё ещё требуют разбора, чтобы можно было применить сжатие DNS-имён. Поскольку записи, поддерживающие прямое копирование, составляют подавляющую часть трафика, это изменение снижает объём работы на пути поиска. В сочетании с улучшенной локальностью памяти это снизило задержку поиска в кэше на 5% в бенчмарках.

Для сборки буфера данных записей используется переиспользуемый scratchspace-буфер, который сохраняется между вставками в кэш. Поскольку предыдущие записи уже расширили его, повторное выделение памяти требуется редко. Записи различаются по размеру, поэтому точный размер буфера неизвестен до момента их сериализации. После того как записи оказались в scratchspace-буфере, выделяется Box<[u8]>, и данные копируются в него с помощью memcpy. Это заменяет отдельную аллокацию для каждой упакованной записи одной аллокацией для всех данных записей сразу. Также это избегает потерь при уменьшении Vec<u8>, когда аллокатор может не суметь освободить неиспользуемый «хвост» исходной аллокации. В бенчмарке одно только это изменение увеличило пропускную способность вставки в кэш на 13%.

Результаты

Продакшен-замеры показали, как экономия на одну запись, выявленная в бенчмарках, отразилась на резидентной памяти процесса в целом. На графике ниже показано использование памяти на уровнях p90, p98 и p99 на инстансах Big Pineapple. Первая пунктирная линия отмечает начало раскатывания 18 мая 2026 года, вторая — завершение раскатывания на всех сервисах 6 июля 2026 года. Каждый релиз внедрял одну или несколько описанных выше оптимизаций, поэтому потребление памяти снижалось ступенчато, а не разом.

По мере раскатывания каждого релиза перезапущенные инстансы начинали с пустых кэшей и потребляли больше памяти по мере их заполнения. Поэтому стабильные плато на графике лучше отражают устойчивое потребление памяти, чем начальные провалы.

Потребление памяти на инстанс снизилось на всех процентилях. На уровне p99 память упала с 9,3 ГБ до 5,3 ГБ — снижение резидентной памяти на 43%. На уровне p90 — с 6,5 ГБ до 3,8 ГБ, снижение на 42%. Наибольшую абсолютную экономию показали инстансы с более заполненными кэшами.

В бенчмарках пять оптимизаций сократили объём памяти на одну запись с 953 до 420 байт — снижение на 56%. Количество аллокаций на запись упало с 1,1 КБ до 461 байта. В продакшене снижение оказалось меньше, поскольку резидентная память включает не только кэш, но и все остальные данные процесса. После стабилизации раскатывания суммарное рабочее потребление памяти по всему флоту оказалось примерно на 100 терабайт ниже.

Производительность также улучшилась: пропускная способность вставки в кэш выросла на 43%, а задержка поиска снизилась на 19%.

Метрика

До

После

Изменение

Чистый объём на запись

953 байта

420 байт

-56%

Аллокации на запись

1,1 КБ

461 байт

-58%

Пропускная способность вставки в кэш

625 000 записей/с

893 000 записей/с

+43%

Задержка поиска в кэше

828 нс

670 нс

-19%

Освободившуюся память планируется реинвестировать в увеличение вместимости кэша без роста общего потребления памяти, что должно повысить долю попаданий в кэш и снизить объём запросов к вышестоящим серверам. Также продолжается работа над дальнейшими оптимизациями самого кэша.

Подробнее о Big Pineapple можно узнать в материале How Rust and Wasm power Cloudflare's 1.1.1.1.