Linux 7.0 снижает пропускную способность PostgreSQL в два раза
- title
- Linux 7.0 снижает пропускную способность PostgreSQL в два раза
- type
- summary
- summary
- Удаление PREEMPT_NONE в Linux 7.0 позволяет вытеснять процесс посреди page fault с захваченным spinlock'ом PostgreSQL, что сжигает CPU под нагрузкой
- parent
- linux-preemption-models
- tags
- linux, postgresql, kernel, performance
- sources
- linux-broke-postgresql
- created
- 2026-04-30
- updated
- 2026-07-29
- lang
- ru
- translation_of
- linux-7-postgres-regression
- source_updated
- 2026-07-29
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
3 апреля 2026 года инженер AWS Salvatore Dipietro сообщил в LKML, что под одной и той же нагрузкой pgbench (сценарий simple-update) на 96-vCPU Graviton4 производительность упала с 98 565 TPS на Linux 6.x до 50 751 TPS на Linux 7.0 - пропускная способность просела вдвое исключительно из-за обновления ядра. perf показал, где оседало время: 56% CPU тратилось на активное ожидание внутри s_lock, вызываемого из StrategyGetBuffer в менеджере буферов postgresql.
Что изменилось
В Linux 7.0 модель PREEMPT_NONE убрали из доступных linux-preemption-models на современных процессорных архитектурах. Дистрибутивы, использовавшие PREEMPT_NONE по умолчанию для серверных нагрузок, переключились на PREEMPT_LAZY - модель вытеснения, добавленную в 6.12 с прицелом на высокую пропускную способность. Для подавляющего большинства задач она и правда служит прямой заменой. PostgreSQL же натолкнулся на редкий сценарий, где это не так.
Почему именно PostgreSQL
PostgreSQL кэширует данные в области shared_buffers - в бенчмарке она составляла 120 GB. Когда backend'у нужна страница, которой ещё нет в кэше, StrategyGetBuffer выбирает слот для вытеснения. Этот выбор защищён единственным глобальным spinlock. Критическая секция рассчитана на десятки наносекунд: весь смысл spinlock'а в том, что поток освобождает его быстрее, чем кто-либо успеет заметить ожидание.
Здесь сталкиваются два фактора:
- Пул разделяемых буферов выделяется лениво. При первом обращении к любому байту страницы Linux размером 4 KB в этой области возникает minor page fault - ядро выделяет физическую страницу и обновляет таблицу страниц. При 120 GB
shared_buffersсо страницами по 4 KB это даёт ~31 миллион потенциальных page fault'ов первого обращения, размазанных по всему времени работы, а не только на старте. StrategyGetBufferчитает и пишет в разделяемую память прямо под spinlock'ом. Часть этих обращений оказывается как раз первым касанием.
В итоге backend может словить page fault прямо с захваченным spinlock'ом. С PREEMPT_NONE ядро обрабатывало fault, а удерживающий блокировку процесс продолжал выполняться до её освобождения - остальные потоки крутились в цикле считанные микросекунды. С PREEMPT_LAZY планировщик вправе вытеснить держателя блокировки прямо посреди обработки fault'а. Теперь блокировка остаётся захваченной на время обработки fault'а плюс интервал до тех пор, пока планировщик снова не отдаст процессу квант времени. Все остальные backend'ы на машине всё это время вхолостую сжигают CPU в spin-циклах, умножая потери на число ожидающих потоков. На 96 vCPU с 1024 клиентами этого множителя хватило, чтобы просадить пропускную способность вдвое.
Уже существующее решение
У PostgreSQL есть настройка huge_pages. Если переключиться со страниц 4 KB на 2 MB или 1 GB huge-pages, арифметика кардинально меняется:
- 4 KB: ~31 000 000 потенциальных page fault'ов
- 2 MB: ~61 440 потенциальных page fault'ов
- 1 GB: ~120 потенциальных page fault'ов
Срабатывают сразу два эффекта. Fault'ы первого обращения становятся настолько редкими, что поток со spinlock'ом почти никогда на них не натыкается. Заодно резко снижается нагрузка на TLB: гораздо меньшее число записей покрывает тот же объём памяти, рабочий набор целиком помещается в TLB, и StrategyGetBuffer работает на полной скорости. Просадка исчезает.
В статье рекомендуется выставлять huge_pages = on вместо значения по умолчанию try. Тогда PostgreSQL откажется запускаться, если huge pages не настроены на уровне ОС, вместо того чтобы молча откатываться на обычные страницы.
Предложенное решение
Peter Zijlstra (инженер Intel и автор изменений в модели вытеснения) предложил перевести PostgreSQL на Restartable Sequences (rseq) - механизм Linux, позволяющий пространству пользователя пометить критическую секцию, чтобы при вытеснении ядро сообщало об этом, а код перезапускался заново. В PostgreSQL к предложению отнеслись прохладно: оно перекладывает работу на базу данных ради возврата поведения, которое раньше работало из коробки, и идёт вразрез с принципом ядра "не ломать userspace". Как сформулировано в статье: регрессия реальна, но предложенное решение перекладывает издержки не в ту сторону.
Почему это важно не только для Postgres
Паттерн "короткая критическая секция + spinlock + скрытый внутри системный вызов или fault" опасен сам по себе. Под PREEMPT_NONE проблема почти не проявлялась, потому что планировщик принципиально не снимал выполняющийся процесс с процессора. Отказ от этой страховки бьёт по любым программам, которые неявно на неё полагались. PostgreSQL стал самым заметным примером, так как под нагрузкой конкуренция упирается в один глобальный lock, но ровно та же схема встречается и в других долгоживущих многопоточных системах с разделяемой памятью.
Источники
- Патч в LKML: https://lkml.org/lkml/2026/4/3/1379
- Phoronix: https://www.phoronix.com/news/Linux-7.0-AWS-PostgreSQL-Drop
- thebuild.com: https://thebuild.com/blog/2026/04/23/preempt_none-is-dead-your-postgres-probably-doesnt-care/
- Оригинальная статья: linux-broke-postgresql на the-coder-cafe
- См. также compiler-codegen-luck - ещё один случай, когда идентичный исходный код работает с совершенно разной скоростью по причинам уровнем ниже самого кода (генерация машинного кода компилятором и предсказание переходов вместо вытеснения в ядре).
- Зеркальный пример - postgres-listen-notify-scaling: тоже поведение блокировок, но с противоположным симптомом - жёсткий потолок на пишущие операции с оповещениями без утилизации ресурсов вообще, так как backend'ы паркуются на глобальном lock'е вместо того, чтобы сжигать CPU в цикле активного ожидания.