Уменьшение записей DNS-кэша 1.1.1.1
- title
- Уменьшение записей DNS-кэша 1.1.1.1
- type
- summary
- summary
- Пять оптимизаций структуры памяти в Rust сократили размер записи DNS-кэша Cloudflare на 56%, освободив ~100 ТБ и ускорив работу кэша
- tags
- rust, dns, memory-management, performance, caching
- sources
- cloudflare-dns-cache-memory
- created
- 2026-09-14
- updated
- 2026-09-14
- lang
- ru
- translation_of
- big-pineapple-dns-cache-layout
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Big Pineapple - это сервис на Rust, лежащий в основе резолвера 1.1.1.1 от Cloudflare, Gateway DNS, DNS Firewall, AS112 и других DNS-продуктов. В любой момент он хранит более 250 миллиардов записей кэша, поэтому один лишний байт на запись оборачивается более чем 250 ГБ потерь по всему парку серверов. В статье Cloudflare за август 2026 года описаны пять последовательных изменений структуры хранения записи в памяти cloudflare-dns-cache-memory. В бенчмарке Cloudflare они сократили размер записи с 953 до 420 байт. В продакшене это высвободило около 100 ТБ рабочего набора памяти, что статья приравнивает к объёму RAM в 130 серверах Gen 13, причём кэш стал работать быстрее, а не медленнее.
Как устроена запись
Ключ состоит из запрашиваемого имени, типа записи, флага authenticated и тега. Значение хранит временные метки, 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>,
// ...
}
Два свойства DNS делают такое представление расточительным. Запись никогда не модифицируется после вставки, а большинство ответов - это небольшие записи A или AAAA, чьё имя владельца совпадает с запрошенным. Если кэш использует EDNS Client Subnet, один и тот же запрос кэшируется отдельно для каждой клиентской сети, что увеличивает и число записей, и размер на запись, поэтому дата-центры с высокой долей ECS выигрывают сильнее всего.
Бенчмарк заполняет кэш случайными записями по профилю продакшен-трафика: 56% A, 25% AAAA, 19% TXT, от одной до четырёх записей на элемент кэша, а данные TXT размером от 64 до 224 байт имитируют все типы переменной длины. Обёртка над системным аллокатором Rust System считает аллокации и байты на запись, а пропускная способность вставки и задержка поиска замеряются по всему пути кэша.
1. Отказ от поля capacity
Vec<T> состоит из указателя, длины и ёмкости (capacity), причём в куче обычно резервируется запас под дальнейший рост. Неизменяемой записи не нужно ни то, ни другое. Box<[T]> содержит только указатель и длину строго по размеру данных, а Box<str> делает то же самое для String. В структуре записи было восемь полей Vec и String, поэтому их замена экономит 64 байта встроенного размера на запись плюс все лишние хвосты аллокаций в куче. В масштабах всего парка статья оценивает эту экономию более чем в 15 ТБ.
2. Один список записей со смещениями
Вместо трёх отдельных списков для секций answer, authority и additional запись теперь хранит один список и два смещения u16, указывающих, где начинаются вторая и третья секции. Число записей в секции DNS укладывается в 16 бит. Две пары из указателя и длины (32 байта) превращаются в два 2-байтовых смещения, экономя 28 байт на запись.
На этом же шаге несколько полей bool упаковали в одно значение bitflags. В статье подчёркивается, что подобная экономия не сводится к простому вычитанию убранных байтов: Rust округляет размер структуры до её выравнивания и добавляет padding между полями, поэтому удаление небольшого поля может убрать и выравнивающие байты, уменьшив структуру сильнее, чем весили сами булевы флаги. false-sharing-alignment-128 рассматривает те же правила выравнивания с противоположной стороны, когда padding добавляется намеренно.
3. Исключение имени владельца, совпадающего с запросом
У каждой записи есть имя владельца (owner name). В сетевом протоколе сжатие имён по RFC 1035 заменяет повторы 2-байтовым указателем, но переход по таким указателям при каждом поиске слишком медленен для горячего пути, поэтому кэш хранил полное имя владельца вместе с каждой записью. У большинства записей владелец совпадает с самим запрошенным именем, а оно уже есть в ключе кэша. Теперь запись содержит owner: Option<Box<Name>>: None означает "совпадает с запросом" и восстанавливается из ключа при формировании ответа, а Some указывает на имя в куче для случаев вроде записей A, стоящих за CNAME.
Цена такого решения - запись больше не является полностью автономной; её можно разобрать только вместе с ключом. Так как ключ всегда доступен при поиске, в Cloudflare сочли этот компромисс приемлемым, и типичный случай теперь обходится вообще без аллокации имени владельца.
4. Вынос крупных вариантов enum в Box
Данные записи представляли собой enum в Rust с отдельным вариантом для каждого типа записи. Размер enum равен размеру его самого крупного варианта плюс тег дискриминанта. Самым большим вариантом был NAPTR размером 136 байт (три строки переменной длины, доменное имя, два целых числа), из-за чего каждый RecordData занимал 144 байта. Записи A требуется 4 байта, а AAAA - 16 байт, при этом они составляют более 80% трафика.
pub enum RecordData {
A(Ipv4Addr),
Aaaa(Ipv6Addr),
Txt(Box<Txt>),
Naptr(Box<Naptr>),
Svcb(Box<Svcb>),
// ...
}
Сохранение небольших частых вариантов внутри структуры и вынос остальных в Box уменьшили enum до 24 байт и сэкономили 120 байт на каждой записи A и AAAA. Типы в Box, такие как TXT и CNAME, тоже выиграли, поскольку их аллокация в куче теперь точно подогнана под размер данных. NAPTR стал слегка менее эффективным из-за расходов на указатель и аллокацию, что вполне допустимо из-за его редкости.
Вынос в Box принёс две проблемы. Аллокатор округляет каждый box до своего размерного класса: jemalloc кладёт 32-байтовый TXT в 32-байтовую корзину без потерь, но 40-байтовый MX попадает в 48-байтовую корзину. Кроме того, каждое значение в Box лежит в отдельной области кучи, поэтому чтение записей означает переход по указателям, которые могут попадать в холодные кэш-линии в произвольных местах памяти, что при миллионах записей неизбежно и происходит.
5. Хранение данных записей в виде сырых байтов протокола
Очевидный предельный вариант - кэшировать весь ответ целиком в сетевом формате и только подменять ID сообщения для каждого клиента - был отклонён. Записи DNSSEC возвращаются только клиентам, выставившим флаг DO, поэтому полное сообщение пришлось бы кэшировать дважды либо фильтровать постфактум, а парсинг всего сообщения при каждом поиске обходится дороже, чем чтение уже разобранных полей.
Выбранный компромисс сохраняет метаданные записи в виде структурированных полей, но заменяет список разобранных записей единым Box<[u8]>: сырые байты каждой записи, перед которыми идёт 2-байтовая длина. Это избавляет от enum и от всех индивидуальных box'ов из шага 4, объединяя данные всех записей в одну непрерывную аллокацию. К записям больше нельзя обращаться по произвольному индексу, их приходится обходить последовательно, что усложняет round-robin ротацию ответов A/AAAA, однако записей в элементе кэша мало, поэтому в статье эти накладные расходы названы незначительными.
Формирование ответа также стало дешевле. Типы A, AAAA, TXT и все типы DNSSEC копируются напрямую из буфера в исходящее сообщение без повторной сериализации поле за полем. Только типы, содержащие доменные имена (CNAME, NS, MX, SOA), по-прежнему парсятся, чтобы резолвер мог применить сжатие имён в ответе. Благодаря лучшей локальности данных этот шаг снизил задержку поиска в бенчмарке на 5%.
Вставка использует переиспользуемый scratch-буфер, сохраняющийся между операциями вставки и потому редко требующий перевыделения памяти. Записи сериализуются в него, и как только итоговый размер становится известен, выделяется один Box<[u8]> точного размера, куда копируются байты. Это заменяет множество аллокаций под каждую запись на одну аллокацию на элемент кэша и исключает усечение Vec<u8>, при котором аллокатор не всегда способен вернуть освобождённый хвост. Одно это изменение увеличило пропускную способность вставки на 13%. По сути, это компактная реализация идеи arena-allocation: собрать данные во временной области и затем зафиксировать единым блоком.
Результаты
Бенчмарк со всеми пятью изменениями вместе:
| Метрика | До | После | Изменение |
|---|---|---|---|
| Чистый размер на запись | 953 байта | 420 байт | -56% |
| Аллокаций на запись | 1.1 КБ | 461 байт | -58% |
| Пропускная способность вставки | 625 000 записей/с | 893 000 записей/с | +43% |
| Задержка поиска | 828 нс | 670 нс | -19% |
Развёртывание шло с 18 мая по 6 июля 2026 года, по одному или несколько изменений за релиз, поэтому объём резидентной памяти снижался ступенчато. Каждый перезапуск начинался с пустого кэша, который затем наполнялся, поэтому в статье анализируются стабильные плато, а не просадки при рестарте. Резидентная память на экземпляр на p99 упала с 9.3 ГБ до 5.3 ГБ (43%), а на p90 - с 6.5 ГБ до 3.8 ГБ (42%). Снижение в продакшене меньше, чем 56% на запись в бенчмарке, поскольку резидентная память включает в себя и все остальные компоненты процесса; кроме того, авторы прямо указывают, что синтетические входные данные лишь приближаются к реальному трафику, а не в точности повторяют его. golang-maps-swiss-tables показывает, насколько большим может быть разрыв между микробенчмарком и всем парком машин; здесь он остался умеренным.
Cloudflare планирует направить освободившуюся память на увеличение ёмкости кэша при сохранении прежнего потребления RAM, чтобы повысить hit rate и снизить нагрузку на upstream-серверы.
Общий шаблон
Каждый шаг меняет универсальное представление данных на специализированное, оптимизированное под сценарий "одна запись - много чтений". Расширяемые контейнеры заменяются срезами фиксированной длины, отдельные списки секций - смещениями внутри одного списка, дублирующие ключ поля удаляются, а enum с отдельным типом на каждый вариант превращается в байты, готовые к прямой отправке в сеть. Четвёртый шаг особенно показателен тем, что не стал финальной точкой: упаковка в Box исправила размер enum, но разбросала данные по куче, а пятый шаг избавился как от выравнивающих байтов, так и от лишних указателей.