Справочник DBA: базы данных изнутри читается по порядку

Статья 3.4 · раздел 3 «WAL, буферы и надёжность записи» · 19 из 109

shared_buffers и clock sweep: счётчик 0–5, кольцевые буферы и кэш ОС

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18 (shared_buffers = 128MB, io_method = worker); расширение pg_buffercache; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

После SELECT count(*) по таблице в 92 МБ в shared_buffers осталось 32 её страницы, а таблица в 9,5 МБ осталась целиком. Так работает выбор жертвы: у каждого буфера (в нём одна страница 8 КБ) есть счётчик использования 0–5, «стрелка часов» его уменьшает, а большое чтение идёт через маленькое кольцо и кэш не вытесняет.

DBA / 01

Часы и кольцо: числа из опыта

Часы и кольцо: числа из опыта Слева 16 буферов по кругу с числами usage_count от 0 до 5 и стрелка, которая ищет буфер со счётчиком 0. Справа таблица big на 11 776 страниц проходит через кольцо из 32 буферов со счётчиком 1, и в кэше остаётся 32 страницы. Таблица small на 1 216 страниц остаётся в кэше целиком. Пул из 16 384 буферов 2 0 1 3 1 2 5 0 1 2 4 1 2 3 0 4 clock sweep Число в клетке — usage_count. Стрелка идёт по кругу, вычитает 1 и берёт первую клетку с 0 (зелёная). Доли 3 / 4 / 4 / 2 / 2 / 1 из опыта 1. big, 92 МБ 11 776 страниц seq scan проходит все 11 776 кольцо: 32 буфера × 8 КБ = 256 КБ у всех usage_count = 1 кэш других таблиц не тронут в кэше big: 32 из 11 776 small, 9,5 МБ 1 216 страниц в кэше: 1 216 из 1 216
Слева обычные буферы: счётчик растёт при обращении и уменьшается стрелкой, жертва — клетка с нулём. Справа большая таблица идёт через кольцо в 32 буфера и оставляет в кэше только последние 32 страницы, а таблица меньше четверти пула остаётся целиком.

Опыт 1: что лежит в shared_buffers после нагрузки

Поднимаем PostgreSQL 18 с пулом 128 МБ (16 384 буфера по 8 КБ), включаем pg_buffercache, создаём данные pgbench на 259 МБ и 30 секунд крутим 4 клиента.

docker run -d --name dba-shared-buffers-clock-sweep -e POSTGRES_PASSWORD=pg postgres:18 -c shared_buffers=128MB
docker exec -it dba-shared-buffers-clock-sweep psql -U postgres -c "CREATE EXTENSION pg_buffercache"
docker exec dba-shared-buffers-clock-sweep pgbench -U postgres -i -s 20 postgres
docker exec dba-shared-buffers-clock-sweep pgbench -U postgres -c 4 -T 30 postgres
number of transactions actually processed: 47055
tps = 1568.847970 (without initial connection time)

Таблица pgbench_accounts занимает 33 203 страницы, то есть вдвое больше пула, и свободных буферов нет. Распределение счётчиков:

SELECT usagecount, count(*) AS buffers
FROM pg_buffercache GROUP BY usagecount ORDER BY usagecount;
 usagecount | buffers
------------+---------
          0 |    3477
          1 |    4070
          2 |    3545
          3 |    2038
          4 |    1990
          5 |    1264

Максимум — 5, больше не бывает. Кому принадлежат буферы и какой у них счётчик:

SELECT c.relname, count(*) AS buffers,
       round(avg(b.usagecount), 2) AS avg_usage, max(b.usagecount) AS max_usage
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
 AND b.reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))
GROUP BY c.relname ORDER BY buffers DESC LIMIT 4;
        relname        | buffers | avg_usage | max_usage
-----------------------+---------+-----------+-----------
 pgbench_accounts      |   10547 |      1.16 |         5
 pgbench_accounts_pkey |    5329 |      3.29 |         5
 pgbench_history       |     306 |      1.78 |         5
 pg_proc               |      17 |      3.00 |         5

Таблица занимает две трети пула, но средний счётчик у неё 1,16, а у индекса pgbench_accounts_pkey 3,29. Каждая транзакция идёт в случайную строку через индекс: корень и верхние страницы дерева трогает каждый запрос, и они держат 4–5, а страница таблицы попадается редко. Для индекса это 946 буферов со счётчиком 5 против 51 у таблицы. При нехватке места первой уйдёт страница таблицы со счётчиком 0, и именно этого мы хотим.

Как работает счётчик

Счётчик растёт в PinBuffer, когда процесс закрепляет буфер для работы, и растёт только до максимума:

if (strategy == NULL)
{
	if (BUF_STATE_GET_USAGECOUNT(buf_state) < BM_MAX_USAGE_COUNT)
		buf_state += BUF_USAGECOUNT_ONE;
}
else
{
	if (BUF_STATE_GET_USAGECOUNT(buf_state) == 0)
		buf_state += BUF_USAGECOUNT_ONE;
}

Источник: bufmgr.c, PinBuffer, строки 3119–3133, REL_18_6 (комментарии убраны). Ветка else относится к запросам с кольцом, о них ниже. Предел задан константой #define BM_MAX_USAGE_COUNT 5 в buf_internals.h, строка 87. Комментарий рядом объясняет выбор: чем выше предел, тем ближе алгоритм к LRU, но тем больше проходов нужно, чтобы найти свободный буфер.

Уменьшает счётчик стрелка в StrategyGetBuffer, когда нужен буфер под новую страницу:

for (;;)
{
	buf = GetBufferDescriptor(ClockSweepTick());
	local_buf_state = LockBufHdr(buf);
	if (BUF_STATE_GET_REFCOUNT(local_buf_state) == 0)
	{
		if (BUF_STATE_GET_USAGECOUNT(local_buf_state) != 0)
		{
			local_buf_state -= BUF_USAGECOUNT_ONE;
			trycounter = NBuffers;
		}
		else
		{
			if (strategy != NULL)
				AddBufferToRing(strategy, buf);

Источник: freelist.c, StrategyGetBuffer, строки 315–338, REL_18_6 (комментарии убраны, дальше код возвращает буфер). Закреплённый буфер (REFCOUNT не ноль) пропускается, у свободного вычитается единица, с нулём — жертва. Буфер со счётчиком 5 переживёт пять проходов стрелки, поэтому корень индекса из опыта 1 вытеснить трудно.

Опыт 2: кольцо для последовательного чтения

Если бы большой seq scan шёл через общий механизм, он поднял бы счётчики на миллионах прочитанных страниц и выбил бы всё полезное. Поэтому запрос, который читает таблицу больше четверти shared_buffers (здесь больше 32 МБ), получает кольцо. Создаём таблицы на 92 МБ и 9,5 МБ и перезапускаем сервер, чтобы пул был пуст:

CREATE TABLE big AS SELECT g AS id, repeat('x', 200) AS pad FROM generate_series(1, 400000) g;
CREATE TABLE small AS SELECT g AS id, repeat('x', 200) AS pad FROM generate_series(1, 40000) g;
VACUUM ANALYZE big, small;
CREATE VIEW cached AS
SELECT c.relname, count(*) AS buffers, count(*) * 8 AS kb
FROM pg_buffercache b JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
 AND b.reldatabase = (SELECT oid FROM pg_database WHERE datname = 'postgres')
WHERE c.relname IN ('big', 'small') GROUP BY c.relname;
 relname | relpages | pg_size_pretty
---------+----------+----------------
 big     |    11776 | 92 MB
 small   |     1216 | 9728 kB

После docker restart читаем обе таблицы подряд в одном сеансе. Параллельный план отключён, а effective_io_concurrency = 0 понадобится ниже, пока примем его на веру.

SELECT pg_stat_reset_shared('io');
SET max_parallel_workers_per_gather = 0;
SET effective_io_concurrency = 0;
SELECT count(*) FROM big;
SELECT count(*) FROM small;
SELECT * FROM cached ORDER BY relname;
 relname | buffers |  kb
---------+---------+------
 big     |      32 |  256
 small   |    1216 | 9728

Из 11 776 страниц big в кэше 32 (256 КБ), все со счётчиком 1: pg_buffercache показывает единственную строку usagecount = 1, count = 32. small лежит целиком. Повторный EXPLAIN подтверждает разницу:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM big;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM small;
 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=32 read=11744
   ->  Seq Scan on big (actual rows=400000.00 loops=1)
         Buffers: shared hit=32 read=11744

 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=1216
   ->  Seq Scan on small (actual rows=40000.00 loops=1)
         Buffers: shared hit=1216

Второй проход по big нашёл в кэше те же 32 страницы и прочитал остальные 11 744. Кэш не прогревается, сколько ни повторяй. Статистику ввода-вывода по контекстам даёт pg_stat_io (PostgreSQL 16+), контекст bulkread — это и есть кольцо:

SELECT context, reads, hits, evictions, reuses FROM pg_stat_io
WHERE backend_type = 'client backend' AND object = 'relation' AND context IN ('bulkread', 'normal');
 context  | reads | hits | evictions | reuses
----------+-------+------+-----------+--------
 bulkread |   740 |    0 |         0 |  11744
 normal   |   178 |  604 |         0 |

reuses — сколько раз буфер из кольца заняли под новую страницу: 11 744 (все страницы, кроме 32 первых заполнений кольца). reads в pg_stat_io считает операции, а не блоки: PostgreSQL 18 объединяет чтения до 128 КБ, 11 776 / 16 страниц ≈ 740.

Теперь размер кольца. Размеры колец в GetAccessStrategy:

ring_size_kb = 256;
ring_max_kb = GetPinLimit() * (BLCKSZ / 1024);
ring_max_kb = Max(ring_size_kb, ring_max_kb);
ring_size_kb += (BLCKSZ / 1024) *
	io_combine_limit * effective_io_concurrency;
if (ring_size_kb > ring_max_kb)
	ring_size_kb = ring_max_kb;

Источник: freelist.c, GetAccessStrategy, BAS_BULKREAD, строки 574–599, REL_18_6 (комментарии убраны, строки выбраны из блока). База 256 КБ, плюс место под асинхронные чтения. При effective_io_concurrency = 16 и io_combine_limit = 128kB формула даёт 2 304 КБ, но потолок — GetPinLimit(), то есть NBuffers / (MaxBackends + NUM_AUXILIARY_PROCS) (bufmgr.c, строка 4042). Проверим без SET effective_io_concurrency = 0, после ещё одного перезапуска:

 relname | buffers |  kb
---------+---------+-----
 big     |      94 | 752

В PostgreSQL 18 с настройками по умолчанию кольцо seq scan занимает 94 буфера (752 КБ): его упёрли в потолок для пула в 16 384 буфера; параллельный count(*) с двумя воркерами оставил 282 буфера, по 94 на процесс. Классические 256 КБ получаются, когда асинхронного ввода-вывода нет. Для VACUUM кольцо фиксированное, BAS_VACUUM: ring_size_kb = 2048 в той же функции, то есть 256 буферов. Для массовой записи (COPY, CREATE TABLE AS) — BAS_BULKWRITE, 16 МБ.

Опыт 3: двойное кэширование и что значит «read»

Строка shared read=1216 в EXPLAIN (ANALYZE, BUFFERS) — это чтение в shared_buffers из файла, а не обязательно с диска: файл может лежать в page cache ОС. Различает их время при track_io_timing. Перезапускаем сервер (пул пуст, кэш ОС остался) и читаем small с включённым таймером:

SET track_io_timing = on;
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF, TIMING OFF) SELECT count(*) FROM small;
 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared read=1216
   I/O Timings: shared read=0.374
   ->  Seq Scan on small (actual rows=40000.00 loops=1)
         Buffers: shared read=1216
         I/O Timings: shared read=0.374

read=1216 — первый проход, а 9,5 МБ прочитаны за 0,374 мс. Повторный запрос без перезапуска покажет shared hit=1216 и уберёт строки I/O Timings. Чтобы получить настоящее чтение с диска, выбросим файл таблицы из page cache ОС (dd с iflag=nocache вызывает posix_fadvise(DONTNEED) и не требует root; путь файла даёт pg_relation_filepath('small')) и снова перезапустим сервер:

docker restart dba-shared-buffers-clock-sweep
docker exec -u postgres dba-shared-buffers-clock-sweep \
  dd if=/var/lib/postgresql/18/docker/base/5/16436 iflag=nocache count=0

Три пары замеров I/O Timings: shared read для тех же 1216 страниц:

файл в page cache: 0.360, 0.814, 0.598 мс
файл выброшен из page cache: 2.850, 2.908, 3.271 мс

Для PostgreSQL обе строки выглядят одинаково: shared read=1216. Разница в 3,5–9 раз видна только во времени. Поэтому «read» без track_io_timing не отличает диск от кэша ОС, а пик read после рестарта сервера не обязательно значит холодный диск. Отсюда и правило про 25 %: каждая страница при чтении лежит дважды, в shared_buffers и в page cache. Документация называет 25 % ОЗУ разумным началом для выделенного сервера и пишет, что больше 40 % вряд ли лучше; это ориентир, который проверяют по pg_buffercache и доле hit, а не закон. Если рабочий набор влезает в пул, лишние гигабайты просто дублируют кэш ОС.

InnoDB: одна LRU вместо часов

InnoDB держит один список LRU с точкой вставки: прочитанная страница попадает в начало «старой» части, а в «молодую» переходит, только если к ней обратились ещё раз. Доля старой части задана innodb_old_blocks_pct, по умолчанию 100 * 3 / 8, то есть 37 % при допустимых 5–95 (ha_innodb.cc, строки 22949–22952, mysql-8.4.0); документация MySQL 8.4 называет это 3/8 пула. Размер старого списка поддерживает buf_LRU_old_adjust_len в buf0lru.cc. Результат для полного скана тот же, что у кольца, только достигается без отдельной структуры: страницы скана живут в старой части и первыми уходят. В PostgreSQL защита от скана — отдельное кольцо, которое выбирает исполнитель запроса, в InnoDB — порядок в списке и параметр. Для настройки это значит: у кольца запроса в PostgreSQL своей ручки нет, на размер влияют только shared_buffers и effective_io_concurrency, а в InnoDB долю старой части можно менять.

Что из этого следует

  • Размер пула проверяют по распределению. Если в pg_buffercache много буферов с 4–5 и мало с 0, горячий набор больше пула и страницы вытесняют друг друга; если половина буферов со счётчиком 0 и 1, пул хватает.
  • Большой seq scan не греет кэш. Таблица больше четверти пула читается через кольцо, и второй запрос читает её заново (опыт 2: hit=32 read=11744). Хочешь держать большую таблицу в кэше — читай её индексом или увеличивай пул.
  • Не читай «read» как диск. Без track_io_timing строка shared read не различает диск и page cache (0,36–0,81 мс против 2,85–3,27 мс на 9,5 МБ).
  • 25 % ОЗУ — точка старта. Меняй shared_buffers только вместе с замером доли hit и распределения usagecount до и после.

Чек-лист

  1. CREATE EXTENSION pg_buffercache и запрос SELECT usagecount, count(*) … GROUP BY 1 — снимок состояния пула перед тем, как менять размер.
  2. Кто занимает пул: pg_buffercache JOIN pg_class по relfilenode и reldatabase, с avg(usagecount) по отношениям.
  3. Для медленного запроса: EXPLAIN (ANALYZE, BUFFERS) с track_io_timing = on, смотри shared hit, shared read и I/O Timings вместе.
  4. Если запрос читает таблицу больше shared_buffers / 4, жди кольцо и shared read на каждом проходе.
  5. Контейнер опыта убирается командой docker rm -f dba-shared-buffers-clock-sweep.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru