惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
博客园 - 三生石上(FineUI控件)
量子位
腾讯CDC
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
云风的 BLOG
云风的 BLOG
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LangChain Blog
aimingoo的专栏
aimingoo的专栏
The Hacker News
The Hacker News
T
The Exploit Database - CXSecurity.com
B
Blog
S
SegmentFault 最新的问题
P
Privacy & Cybersecurity Law Blog
T
Threatpost
博客园 - 聂微东
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
C
Check Point Blog
N
Netflix TechBlog - Medium
D
DataBreaches.Net
爱范儿
爱范儿
IT之家
IT之家
S
Secure Thoughts
M
MIT News - Artificial intelligence
NISL@THU
NISL@THU
C
Cisco Blogs
TaoSecurity Blog
TaoSecurity Blog
有赞技术团队
有赞技术团队
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Proofpoint News Feed
Spread Privacy
Spread Privacy
Schneier on Security
Schneier on Security
Simon Willison's Weblog
Simon Willison's Weblog
G
GRAHAM CLULEY
雷峰网
雷峰网
Project Zero
Project Zero
博客园 - Franky
H
Heimdal Security Blog
A
About on SuperTechFans
Security Latest
Security Latest
Webroot Blog
Webroot Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Buffer Pool и Clock-sweep: как мы боремся с cache pollution и p99 latency
anishukserg · 2026-05-04 · via Все публикации подряд на Хабре

Уровень сложностиСложный

Время на прочтение16 мин

Охват и читатели487

Один аналитик с одним SELECT count(*) FROM orders способен серьёзно ухудшить p99 latency всего OLTP-трафика. Пока скан читает таблицу страница за страницей, Buffer Pool заполняется «холодными» данными, горячие OLTP-страницы вытесняются, и после окончания скана приложение тянет данные с диска вместо кэша — ровно до тех пор, пока hot working set не прогреется заново. Это классический cache pollution, и с ним рано или поздно сталкивается любая СУБД с честным LRU.

В предыдущей статье мы разобрали API-контракты между слоями OLTP-ядра, USDT/eBPF-наблюдаемость и Adaptive Tuning. Сейчас — разбор Buffer Pool: почему Clock-sweep лучше LRU для конкурентной среды, как BufferRing изолирует сканы от горячего рабочего набора, и почему no-steal это не выбор стиля, а вопрос корректности recovery.

Здесь описана текущая реализация: что работает в коде, какие компромиссы зафиксированы как MVP, где что-то не готово — скажем прямо. За рамками статьи намеренно остаются sharded buffer pool, алгоритмы GCLOCK / CLOCK-Pro / ARC, полный Resource Broker с динамическим перераспределением памяти и bulk write маршрут — это отдельные задачи, у каждой свой дизайн-документ.

Что реализовано на момент публикации: Clock-sweep в основном BufferPool; BufferRing (BulkRead path) в HeapStore::seq_scan_with_strategy; BackpressureCoordinator с тремя источниками давления; все метрики из §7. Что ещё не готово: SQL-планировщик пока не переключает seq_scan на BulkRead автоматически; счётчик clock_sweep_evictions_total в основном пуле ещё не добавлен.


1. Cache pollution: почему наивная LRU умирает на смешанной нагрузке

Классический сценарий, который ломает любую базу с честным LRU. У вас есть OLTP-приложение: 99% запросов это короткие точечные чтения по индексу, hot working set спокойно влезает в Buffer Pool, hit ratio под 99%, p99 latency предсказуемая. И тут приходит аналитик и запускает безобидный SELECT count(*) FROM orders или генератор отчётов из BI-системы.

Что происходит дальше с LRU-кэшем:

  1. Полный скан читает таблицу страница за страницей.

  2. Каждая прочитанная страница помещается в Buffer Pool.

  3. Поскольку страница «свежая», LRU держит её сверху списка.

  4. Hot OLTP-страницы (например, корни B-tree-индексов) выталкиваются вниз и эвиктятся.

  5. После окончания скана hit ratio резко падает, запросы начинают идти в I/O, p99 уезжает на порядок-полтора вверх.

Это и есть cache pollution. Самое неприятное в нём это асимметрия: один аналитик с одним «непринципиальным» запросом способен существенно ухудшить tail latency для всего OLTP-трафика — на время, которое займёт прогрев hot working set заново через random reads с диска.

Корень проблемы в том, что LRU исходит из неявного предположения «недавно прочитанное скоро снова понадобится» (recency = utility). Для OLTP с типичным паттерном Парето (20% страниц получают 80% обращений) это в среднем верно. Для последовательного скана это предположение ровно наоборот: страница уже не понадобится никогда, мы прошли её один раз и идём дальше. LRU не различает эти два случая, потому что у него нет информации о намерении (intent) обращения, только сам факт.

Из этого следуют две архитектурные задачи, которые мы решали:

  1. Выбрать алгоритм эвикции, который дружелюбен к высокой конкуренции (масштабируется лучше LRU при многопоточном доступе, не требует перезаписи указателей в связном списке на каждом обращении).

  2. Дать сканам отдельный кэш-маршрут (cache bypass), чтобы они не мешали основному рабочему набору.

Обе задачи решаются разными механизмами. Разберём их по очереди.


2. Clock-sweep: как мы выбираем жертву

Для основного Buffer Pool мы реализовали Clock-sweep (он же CLOCK, second-chance). Это классика, обкатанная десятилетиями в PostgreSQL и BSD VM, и она нам подходит по нескольким причинам:

  • Дешёвое обновление при попадании в кэш. Достаточно выставить один бит (ref_bit = true). Никаких перевешиваний узлов в связном списке, как у honest LRU.

  • Эвикция амортизирована. Жертва ищется только в момент, когда нужно освободить место под новую страницу. До этого мы вообще не платим за поддержание порядка.

  • Аппроксимация LRU. Страницы, к которым обращались часто, переживают несколько проходов «стрелки» и фактически защищены, как горячие записи в LRU.

Структура данных в нашем BufferPool устроена просто: словарь pages: HashMap<PageId, Page>, стабильный массив frames: Vec<PageId> (физический порядок «циферблата») и позиция стрелки clock_hand: usize. У каждой страницы есть ref_bit, pin_count и флаг dirty. Псевдокод эвикции (см. реальную реализацию):

fn evict_for_insert(&mut self, max_cached_pages: usize) {
    // ...
    let mut scans_left = self.frames.len().saturating_mul(4).max(1);
    while evicted < to_evict && scans_left > 0 && !self.frames.is_empty() {
        scans_left -= 1;
        if self.clock_hand >= self.frames.len() { self.clock_hand = 0; }
        let pid = self.frames[self.clock_hand];
        let p = self.pages.get_mut(&pid).unwrap();

        // Second chance: страница "горячая", снимаем бит и идём дальше.
        if p.ref_bit {
            p.ref_bit = false;
            self.clock_hand = (self.clock_hand + 1) % self.frames.len();
            continue;
        }
        // Никогда не вытесняем pinned/dirty страницы (no-evict-dirty на eviction-пути).
        if p.pin_count != 0 || p.dirty {
            self.clock_hand = (self.clock_hand + 1) % self.frames.len();
            continue;
        }
        // Эвикция.
        self.pages.remove(&pid);
        self.frame_remove(pid);
        evicted += 1;
    }
}

Что здесь важно с точки зрения корректности:

  1. Bounded scan. Лимит frames.len() * 4 гарантирует, что метод не зациклится, если все страницы заняты (pinned или dirty). В худшем случае мы вернём управление, не освободив все места, и выше по стеку это превратится в backpressure (об этом ниже).

  2. No-evict-dirty. dirty страница никогда не выбирается жертвой: эвикция и flush это разные механизмы, и они ортогональны (см. §5). Важное разграничение: это не то же самое, что классический no-steal — тот запрещает записывать на диск страницы с незакоммиченными изменениями и относится к flush-пути, а не к eviction-пути (подробно в §4.2). В нашем MVP оба ограничения действуют одновременно: no-evict-dirty на eviction-пути и no-steal на flush-пути.

  3. Pin count как hard barrier. Если страница залочена для активной транзакции, её невозможно вытолкнуть никаким алгоритмом эвикции, точка.

2.1. Почему именно Clock, а не GCLOCK / CLOCK-Pro / ARC

Честный ответ: потому что это MVP-выбор для correctness-first фазы. Clock даёт хорошую базовую производительность и крайне маленькую поверхность ошибок. Алгоритмы вроде GCLOCK (с usage_count > 1) или CLOCK-Pro (отдельный список тестируемых страниц) дают лучший hit ratio на отдельных классах нагрузки, но добавляют состояние, которое надо корректно обновлять под конкурентным доступом и протестировать property-based на инвариантах.

Возможность апгрейда до GCLOCK или CLOCK-Pro зафиксирована в дизайн-документе, и мы вернёмся к ней, когда будут стабильные бенчмарки и trace-данные с реальных нагрузок. Архитектурно это ровно одна точка изменения, функция evict_for_insert. До тех пор любое усложнение алгоритма было бы premature optimization.

Стоит оговориться: basic CLOCK хорошо справляется с классическим OLTP-паттерном Парето, но на нагрузках с выраженным скользящим временным окном доступа (например, time-series с rolling window) CLOCK-Pro даёт заметно лучший hit ratio — именно потому что умеет различать «горячее прямо сейчас» и «горячее ещё недавно». Насколько этот разрыв существенен на реальной смешанной нагрузке — покажут trace-данные.

2.2. Замечание про конкурентность

Сейчас весь BufferPool живёт за единым Mutex на уровне StorageEngine. Это сознательно простая модель для MVP: легко доказать корректность, легко тестировать, понятная семантика. Мы знаем, что для масштабирования на десятки ядер потребуется шардирование (sharded buffer pool с независимой CLOCK-стрелкой на шард, как в InnoDB), и это явно зафиксировано в дизайн-документе как следующий шаг. Но шардирование — отдельная задача со своими подводными камнями (балансировка между шардами, корректное взаимодействие с дроп-таблицами, метрики per-shard), и её мы оставляем на отдельный релиз. Пока мы не упёрлись в Mutex на бенчмарках, мы не оптимизируем то, что не является узким местом.

На практике порог, при котором mutex на Buffer Pool становится видимым узким местом, сильно зависит от соотношения read/write, размера страниц и scheduler affinity — в разных системах эта точка оказывается где-то между 8 и 32+ параллельными писателями, и она нелинейно зависит от процента dirty-страниц в пуле.


3. BufferRing: cache bypass для последовательных сканов

Clock-sweep сам по себе не решает проблему cache pollution. Он лишь делает выбор жертвы дешевле, чем у LRU. Если последовательный скан натащит в пул тысячи страниц с ref_bit = true, через одну прокрутку стрелки они станут кандидатами на эвикцию, и в эту же эвикцию попадут хот-страницы, у которых ref_bit тоже успел сброситься между обращениями.

Чтобы изолировать последовательные сканы от основного пула, мы реализовали отдельную структуру, BufferRing. Это маленький фиксированный кольцевой буфер (по умолчанию 32 страницы, диапазон [4, 256]), куда складываются страницы при сканах с явной подсказкой BufferAccessStrategy::BulkRead. Развилка выглядит так:

BufferAccessStrategy
    │
    ├─ Normal   ──▶  BufferPool   CLOCK-sweep · pin / unpin · no-steal
    │                    │
    └─ BulkRead ──▶  BufferRing   32 слота FIFO · read-only · свой на каждый скан
                         │
                   Дисковый I/O

Hot OLTP-страницы остаются в основном пуле и не страдают от того, что параллельно идёт full table scan. Сканы крутятся в своём маленьком кольце и эвиктят только друг друга.

pub enum BufferAccessStrategy {
    /// Обычный OLTP-доступ: страницы идут через основной BufferPool.
    Normal,
    /// Bulk read: страницы идут через BufferRing,
    /// основной пул не трогаем.
    BulkRead,
    /// Bulk write (зарезервировано под будущие COPY/INSERT batch).
    BulkWrite,
}

Ring устроен максимально просто: HashMap<PageId, Vec<u8>> и VecDeque<PageId> для FIFO-порядка. При переполнении выбрасываем самую старую страницу (FIFO eviction), без записи на диск, потому что страницы скана read-only по контракту. Каждый scan создаёт свой ring, никакой shared mutability, что снимает класс багов с конкурентным доступом полностью.

Сам seq_scan_with_strategy(BulkRead) теперь работает в streaming-режиме: страницы подгружаются on-demand через StreamingRingPageProvider и пробрасываются итератору, не накапливаясь в памяти. В одной из ранних реализаций ring пытался предзагрузить весь скан и падал на больших таблицах с потерей хвостовых страниц; это исправлено и закрыто property-тестами на произвольный размер таблицы.

Контракт жёсткий и проверяется property-тестами:

  • ring_size < 4 или > 256: clamping с warning-записью в лог (fail-safe, не fail-closed: скан всё равно должен отработать).

  • Дублирующая вставка той же страницы: no-op (нельзя случайно вытолкнуть страницу при повторном обращении в том же скане).

  • Страницы из ring никогда не утекают в основной пул и наоборот.

Метрики: buffer_ring_evictions_total (FIFO-эвикции внутри одного ring), buffer_ring_active_slots (агрегированная ёмкость по всем активным ring-буферам в инстансе) и счётчики жизненного цикла buffer_ring_created_total / buffer_ring_dropped_total. По ним оператор видит и долгоживущие протечки ring-буферов (created сильно опережает dropped), и фактическую конкуренцию за слоты под нагрузкой.

3.1. Честная оговорка: где это работает сейчас

В текущем коде ядра ring уже подключён в HeapStore::seq_scan_with_strategy(BulkRead). Однако SQL-планировщик сейчас по умолчанию использует обычный путь через основной пул. Переход на BulkRead для полных сканов таблицы будет включаться отдельным изменением — с эвристикой «если ожидаемый объём скана > X% от размера пула, переключаться на BulkRead». Это типичный пример, когда нижний слой движка готов раньше, чем оптимизатор научится им пользоваться; мы писали сначала контракт и фундамент, а потом заводим вызывающую сторону.

Кроме того, для значительной части холодных чтений в нашем StorageEngine есть и другой механизм: read_page_bytes_verify читает страницу с диска во временный буфер и не вставляет её в основной BufferPool, если её там не было. Это даёт нам ещё одну точку cache bypass на самом нижнем уровне I/O, её используют, например, проверки целостности и фоновая телеметрия. Идея та же: «прочитал и забыл», без побочного эффекта на hot working set.

3.2. Три bypass-маршрута: сравнение свойств

Маршрут

Задевает hot working set?

Инкрементирует buffer_pool_miss_total?

Может вызвать pollution?

Активирован в планировщике автоматически?

seq_scan (Normal)

Да — страницы идут в основной пул

Да

Да

Да, путь по умолчанию

seq_scan_with_strategy(BulkRead)

Нет — изолированный BufferRing

Нет

Нет

Нет — явный вызов; автоматический выбор в планировщике ещё не реализован

read_page_bytes_verify

Нет — временный буфер, в пул не вставляется

Нет

Нет

Да — используется проверками целостности и фоновой телеметрией


4. Pinning, RAII и no-steal: почему мы никогда не эвиктим dirty

Клиентский код, который работает со страницей, делает это через PinnedPage<'a>, RAII-обёртку над пином. Семантика проста:

let page = engine.pin_page(page_id);
// ...работаем со страницей...
// Drop автоматически уменьшает pin_count.

Важная деталь: пиннинг в Buffer Pool это просто инкремент рефкаунта, у него нет режимов Shared или Exclusive. Логические блокировки на запись и чтение живут уровнем выше, в LockManager, и применяются к транзакционным сущностям, а не к физическим страницам в памяти. Пиннинг и блокировки решают разные задачи: пиннинг защищает страницу от вытеснения из пула пока с ней работают, а LockManager обеспечивает изоляцию транзакций.

Пока хотя бы один поток держит PinnedPage, страница архитектурно неприкосновенна для эвикции: evict_for_insert пропускает её по pin_count != 0. Это даёт единственно правильную семантику для конкурентного доступа: пока вы работаете со страницей, никто не может выдернуть её из-под вас и подменить байты.

4.1. Эвикция и flush: разные инварианты

Помимо pin_count, у нас есть отдельный набор инвариантов в eligible_to_flush (для writeback) и в evict_for_insert (для эвикции). Они частично пересекаются, но решают разные задачи:

  • Эвикция не имеет права выбирать pinned ИЛИ dirty страницу. То, что мы не вытесняем dirty без записи, это просто корректность: иначе мы потеряли бы изменения. Из этого следует, что грязная страница должна сначала пройти через Checkpoint Worker (или явный flush_some_eligible), и только после успешного fsync WAL и pwrite самой страницы она снова становится кандидатом на эвикцию.

  • Flush не имеет права записывать страницу, если выполнено хотя бы одно из условий: pin_count != 0, страница содержит изменения незакоммиченной транзакции, page_lsn > txlog_durable_lsn (нарушение WAL-before-data) или страница попадает под backup fence.

4.2. No-steal через O(1) проверку HashSet

Второй пункт из списка выше это и есть классический no-steal в терминологии учебников по транзакционным БД. Точное определение no-steal: грязные страницы с изменениями незакоммиченных транзакций не должны попадать на диск. Зачем: если такая страница уехала на диск, а транзакция потом откатилась или упала, на восстановлении придётся применять UNDO к данным, которые уже физически переписаны, что требует совершенно другой схемы recovery (в стиле ARIES с UNDO-фазой, обязательной для штатного перезапуска).

В нашей реализации no-steal обеспечивается одной строкой в eligible_to_flush:

if uncommitted_pages.contains(&pid) {
    return Err(FlushSkipReason::Uncommitted);
}

uncommitted_pages это глобальный HashSet<PageId>, в который менеджер транзакций добавляет страницу при первой стейджинг-записи и удаляет при коммите или откате. Стоимость проверки на каждой итерации flush-цикла это O(1). Альтернативный подход (сканировать pending_by_txn по всем активным транзакциям) был бы O(N_txns) и заметно хуже под смешанной нагрузкой с большим числом параллельных писателей. Решение получилось элегантным именно потому, что задача чётко разделена: транзакционный слой ведёт ровно одну глобальную структуру, а flush-путь её только читает.

Зачем нам no-steal: наиболее прямолинейная альтернатива — steal-with-sync-flush — означала бы, что в нашей реализации обычное чтение страницы могло бы внезапно инициировать синхронный fsync в том же потоке, который просто хотел сделать SELECT. Это классический источник микроостановок, которые отлично прячутся под средней латентностью и убийственно влияют на p99/p99.9. Мы не хотим, чтобы пользовательский поток «случайно» стал backpressure-агентом для фонового checkpoint.

Оговорка, которую стоит держать в голове: no-steal создаёт своё давление при длинных batch-транзакциях с большим uncommitted write-set — пул быстро заполняется dirty-страницами, эвикция встаёт, и backpressure ударяет уже по новым писателям, а не по background flush. Это компромисс, а не бесплатная защита.


5. Checkpoint vs eviction: ортогональность и почему Unified Storage упрощает дело

В классической архитектуре с per-table файлами Checkpoint это координационная задача с N степенями свободы: какие таблицы флашить первыми, как балансировать I/O между разными файловыми дескрипторами, как избегать write amplification, когда несколько dirty страниц соседствуют логически, но физически лежат в разных файлах.

В нашей модели Unified Storage (общий формат, общий брокер ресурсов, единая адресация страниц через PageId = [table_id:16][local_page_id:48]) Checkpoint Worker имеет принципиально более простую работу:

  1. Он берёт dirty_queue из BufferPool (мы поддерживаем его как побочную структуру: VecDeque<PageId> плюс HashSet для дедупликации).

  2. Для каждой страницы проверяет eligible_to_flush (WAL durable LSN, отсутствие пина, отсутствие backup fence).

  3. Группирует записи по физическому файлу tablespace и пишет батчами с нужным приоритетом I/O.

  4. После успешной записи отдаёт страницу обратно в общий пул, теперь она снова clean и снова кандидат на эвикцию.

Никакой глобальной блокировки, никакого «остановись, мир, пока я снимаю чекпоинт». Чекпоинт это просто ещё один потребитель того же I/O-бюджета, которым рулит IoAdvisor (см. предыдущую статью): при приближении к лимиту IOPS он плавно троттлится, чтобы не отбирать пропускную способность у транзакционных I/O пользователя.

Что критично с точки зрения консистентности, так это инвариант WAL-before-data: ни одна dirty страница не может быть записана на диск раньше, чем соответствующая WAL-запись стала durable. Этот инвариант проверяется именно в eligible_to_flush, а не в самом writeback. Такое разнесение защиты означает, что даже если кто-то по ошибке вызовет pwrite напрямую, не пройдя через стандартный путь, мы это поймаем на самом раннем шаге.


6. Backpressure: что происходит, когда писатели быстрее, чем диск

Допустим, нагрузка на запись внезапно выросла: dirty pages накапливаются, Checkpoint Worker не успевает, эвикция не может найти clean+unpinned страницу, потому что почти всё либо dirty, либо pinned. Что должно произойти?

Без координации — ничего хорошего. Исторически в storage-слое было три независимых места, где система «давила» на писателей: лимит uncommitted-страниц в Buffer Pool, глубина I/O-очереди и hard cap на max_cached_pages. Каждое работало само по себе, каждое сигнализировало своей метрикой. Когда оператор видел торможение, у него не было единственного ответа на вопрос «почему»: надо было проверять три разных дашборда. Это честный провал наблюдаемости.

Сейчас все три механизма сведены в один BackpressureCoordinator. Каждый источник остаётся изолированной реализацией трейта BackpressureSource, но все они вычисляются через одну точку и репортят в одну метрику — у оператора есть однозначный ответ на вопрос «почему база сейчас тормозит мою запись».

  1. UncommittedPagesSource. Сравнивает фактическую долю фреймов с uncommitted page-image deltas с порогом buffer_pool.uncommitted_pages_ratio_hard. При превышении источник возвращает Throttle (в режиме block) или Reject (в режиме fail_fast). Лучше предсказуемо замедлиться, чем выйти за пределы памяти и схватить OOM.

  2. WalQueueSource. Проверяет глубину high-priority I/O-очереди. Когда demand-чтения OLTP начинают её насыщать, координатор объявляет Throttle, и низкоприоритетные потребители (prefetch, фоновая warm-up-загрузка) автоматически зажимаются. Логика проста: если пользовательский запрос ждёт I/O, ни один спекулятивный prefetch не имеет права съесть его слот на диске.

  3. BufferPoolSource. Введён вместе с hard cap на max_cached_pages. Возвращает Throttle, когда хотя бы один писатель уже припаркован на capacity-cv (condition variable — переменная ожидания, на которой спит поток, ожидающий свободного слота в пуле), и Reject, когда пул ушёл в degraded-режим (любая попытка эвикции встретила полностью pinned набор фреймов — явный сигнал утечки пинов).

Решения координатора объединяются по жёсткому правилу Reject > Throttle > Pass: любой источник, говорящий «отказать прямо сейчас», немедленно выигрывает. Все решения экспортируются в Prometheus как backpressure_throttle_decisions_total{source, decision} (счётчик) плюс backpressure_active_sources (gauge). Само ожидание под backpressure размечается через WaitEvent::BackpressureThrottle в общей таксономии событий ожидания (см. §7), а гистограмма времени ожидания на capacity-cv — buffer_pool_waiter_wait_seconds.

Эта схема является прямым следствием нашего глобального принципа Restrictive by Default (мы разбирали его в первых статьях): когда система упирается в ресурс, она не должна тихо деградировать в надежде «как-нибудь разгрести». Она должна предсказуемо притормозить и громко сигнализировать.


7. Observability: как смотреть в Buffer Pool снаружи

Метрики разбиты на два слоя: здоровье пула (кэш, dirty, эвикция) и backpressure с ожиданиями (кто давит и сколько ждут).

Здоровье пула

Вопрос оператора

Метрика

Что показывает

Как часто попадаем в кэш?

buffer_pool_hit_total, _miss_total, _hit_ratio_milli

Hit ratio с разрешением до промилле

Сколько страниц в кэше?

storage_cached_pages_total

Текущая занятость пула

Сколько dirty pages ждут чекпоинта?

storage_dirty_pages_total, buffer_pool_uncommitted_dirty_pages

Очередь под flush

Пул ушёл в degraded-режим?

buffer_pool_over_capacity_pages, _evict_failed_total

Страниц «над» hard cap; сколько раз эвикция уткнулась в pinned-only набор

Используется ли BulkRead для сканов?

buffer_ring_evictions_total, _active_slots, _created_total, _dropped_total

Активность и жизненный цикл изолированных BufferRing

Backpressure и ожидания

Вопрос оператора

Метрика

Что показывает

Кто сейчас давит на писателей?

backpressure_active_sources, _throttle_decisions_total{source,decision}

Какие из трёх источников BackpressureCoordinator активны и в каком режиме (throttle / reject)

Долго ли писатели ждут слота в пуле?

buffer_pool_waiter_wait_seconds (гистограмма)

Распределение времени ожидания на capacity-cv

На что ждут сессии прямо сейчас?

wait_events_total{event}, _active{event}, _duration_seconds_bucket{event,le}

Единая таксономия ожиданий: BufferPoolEviction, BackpressureThrottle и другие; total + active + гистограмма

Что делает IoAdvisor?

io_advisor_current_batch_size, _decisions_total{action}

AIMD-рекомендованный batch size чекпоинта; счётчик throttle/recover/hold-решений

Близко ли к лимиту RAM?

memory_pressure_ratio

RSS, делённый на сконфигурированный лимит (MemoryAdvisor v0)

Помимо метрик, у нас по-прежнему работает USDT-инфраструктура из прошлой статьи. Любая операция чтения или записи страницы проходит через WaitEvent::PageRead или WaitEvent::PageWrite, и оператор может в любой момент подключить bpftrace, чтобы построить распределение задержек I/O в микросекундах прямо на живой базе:

bpftrace -e 'usdt:/usr/bin/oltp_server:engine:io_end { @io_lat = hist(arg1); }'

7.1. Оговорка про метрики

Сейчас у нас нет отдельного счётчика clock_sweep_evictions_total для основного пула, мы инкрементируем только метрики ring eviction. Как только мы получим первые значимые цифры с production-style нагрузок, добавим как минимум счётчик эвикций и гистограмму «сколько шагов прошла стрелка до нахождения жертвы». Именно эта гистограмма является лучшим косвенным индикатором того, что пул работает «впритык»: если каждая эвикция требует много обходов, значит, у нас много dirty/pinned страниц и backpressure уже близко.


8. Известный технический долг

Три пункта заслуживают отдельного упоминания, потому что напрямую связаны с темой статьи:

  • Sharded buffer pool. Единый Mutex — осознанный MVP-выбор. Триггер для шардирования — измеримый lock contention в perf-lab на 32+ ядрах; пока его нет, оптимизировать нечего.

  • Sub-pools per page size. PageId = [table_id:16][local_page_id:48] архитектурно готов к маршрутизации в разные саб-кэши, но сейчас PAGE_SIZE = 16384 захардкожен глобально. Изменение требует миграции формата.

  • clock_sweep_evictions_total. Этого счётчика ещё нет (см. §7.1). Он нужен: гистограмма «шагов стрелки до жертвы» — лучший ранний индикатор приближающегося backpressure.


9. Заключение

Buffer Pool — одно из тех мест, где разница между «работает» и «работает предсказуемо под смешанной нагрузкой» составляет, наверное, 90% реальной сложности OLTP-движка. Алгоритм эвикции в учебнике помещается на полстраницы; обвязка вокруг него (изоляция сканов, no-steal-политика, корректное взаимодействие с чекпоинтом, backpressure, pin counts, RAII на стороне клиентского кода) — это ещё несколько тысяч строк кода и отдельные слои тестов.

Для MVP мы выбирали последовательно: Clock-sweep вместо CLOCK-Pro/ARC, BufferRing вместо умных политик внутри основного пула, явный no-steal вместо steal-with-sync-flush, единый Mutex вместо ранней оптимизации в шардированный пул. Каждый выбор — осознанный отказ от «красивого» в пользу «доказуемо корректного». Принцип прост: сначала убедиться, что система делает правильное, и только потом думать, как сделать это быстрее.

Дальше по плану: первые масштабные бенчмарки на смешанной нагрузке — в первую очередь сценарий «полный скан плюс OLTP-трафик», именно ради которого и писался BufferRing. Затем детальный разбор SQL-совместимости с PostgreSQL и материал о том, как Adaptive Tuning координирует память между Buffer Pool и кэшем выполнения без разрушения cache locality.