После SELECT count(*) по таблице в 92 МБ в shared_buffers осталось 32 её страницы, а таблица в 9,5 МБ осталась целиком. Так работает выбор жертвы: у каждого буфера (в нём одна страница 8 КБ) есть счётчик использования 0–5, «стрелка часов» его уменьшает, а большое чтение идёт через маленькое кольцо и кэш не вытесняет.
Часы и кольцо: числа из опыта
Опыт 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до и после.
Чек-лист
CREATE EXTENSION pg_buffercacheи запросSELECT usagecount, count(*) … GROUP BY 1— снимок состояния пула перед тем, как менять размер.- Кто занимает пул:
pg_buffercacheJOINpg_classпоrelfilenodeиreldatabase, сavg(usagecount)по отношениям. - Для медленного запроса:
EXPLAIN (ANALYZE, BUFFERS)сtrack_io_timing = on, смотриshared hit,shared readиI/O Timingsвместе. - Если запрос читает таблицу больше
shared_buffers / 4, жди кольцо иshared readна каждом проходе. - Контейнер опыта убирается командой
docker rm -f dba-shared-buffers-clock-sweep.