Первый SELECT после COPY 10 млн строк помечает грязными 83 334 страницы и пишет в WAL 654 МБ, а второй не пишет ничего. Данные не менялись: читатель впервые спросил журнал коммитов pg_xact, закоммичена ли вставившая транзакция, и записал ответ в каждую строку хинт-битом. Где лежат биты infomask — в статье про заголовок строки, зачем они — в статье про MVCC.
Журнал коммитов: два бита на транзакцию
Состояние каждой транзакции хранится не в строках, а отдельно, в каталоге pg_xact (отсюда CLOG — журнал коммитов). Читает его функция TransactionIdGetStatus в src/backend/access/transam/clog.c. Вот её середина, строки 747–750 дословно:
slotno = SimpleLruReadPage_ReadOnly(XactCtl, pageno, xid);
byteptr = XactCtl->shared->page_buffer[slotno] + byteno;
status = (*byteptr >> bshift) & CLOG_XACT_BITMASK;
Страница журнала читается из кэша SLRU (простой LRU-кэш страниц pg_xact в разделяемой памяти), в ней берётся байт, из байта вырезаются два бита. Коды состояний лежат в clog.h: 0 — в работе, 1 — закоммичена, 2 — откатана, 3 — подтранзакция закоммичена, но её родитель ещё нет. Проверь на своих транзакциях: возьми номера трёх подряд идущих, закоммить первую, откати вторую, закоммить третью, и прочитай байты файла pg_xact/0000 сам.
docker run -d --name dba-clog-and-hint-bits -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-clog-and-hint-bits psql -U postgres
SELECT version();
SHOW data_checksums;
SHOW transaction_buffers;
SELECT pg_current_xact_id()::text::int AS c1 \gset
BEGIN; SELECT pg_current_xact_id()::text::int AS ab \gset
ROLLBACK;
SELECT pg_current_xact_id()::text::int AS c2 \gset
CHECKPOINT;
SELECT x AS xid, pg_xact_status(x::text::xid8) AS status,
x / 4 AS byteno, (x % 4) * 2 AS shift,
get_byte(pg_read_binary_file('pg_xact/0000', x / 4, 1), 0)::bit(8) AS byte,
(get_byte(pg_read_binary_file('pg_xact/0000', x / 4, 1), 0) >> ((x % 4) * 2)) & 3 AS bits
FROM unnest(ARRAY[:c1, :ab, :c2]) AS x;
SELECT name, (pg_stat_file('pg_xact/' || name)).size FROM pg_ls_dir('pg_xact') AS name;
xid | status | byteno | shift | byte | bits
-----+-----------+--------+-------+----------+------
791 | committed | 197 | 6 | 01100101 | 1
792 | aborted | 198 | 0 | 00000110 | 2
793 | committed | 198 | 2 | 00000110 | 1
(3 rows)
name | size
------+------
0000 | 8192
(1 row)
version() вернул PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, data_checksums — on, transaction_buffers — 256kB. CHECKPOINT стоит перед чтением файла, чтобы страница журнала из памяти попала на диск. Разбор байтов:
Транзакция 791: 791 / 4 = 197, остаток 3, значит байт 197, сдвиг 3 × 2 = 6, биты 7–6 байта 01100101 — это 01, закоммичена. Транзакции 792 и 793 лежат в байте 198 (00000110), в сдвигах 0 и 2: биты 1–0 это 10 (откатана), биты 3–2 это 01 (закоммичена). Четыре транзакции в байте, два бита на каждую, и pg_xact_status читает ровно эти биты. Весь журнал за 791 транзакцию уместился в одну страницу файла в 8192 байта: 8192 × 4 = 32 768 транзакций на страницу. Сегмент файла — SLRU_PAGES_PER_SEGMENT = 32 страниц (slru.h, строка 39), то есть 262 144 байта на 1 048 576 транзакций; кэш в памяти — transaction_buffers = 256 кБ, те же 32 страницы.
Хинт-бит: ответ журнала, записанный в строку
Спрашивать pg_xact про каждую строку при каждом чтении расточительно, поэтому проверка видимости записывает ответ в саму строку. Вот SetHintBits из heapam_visibility.c, строки 117–131 дословно:
if (TransactionIdIsValid(xid))
{
/* NB: xid must be known committed here! */
XLogRecPtr commitLSN = TransactionIdGetCommitLSN(xid);
if (BufferIsPermanent(buffer) && XLogNeedsFlush(commitLSN) &&
BufferGetLSNAtomic(buffer) < commitLSN)
{
/* not flushed and no LSN interlock, so don't set hint */
return;
}
}
tuple->t_infomask |= infomask;
MarkBufferDirtyHint(buffer, true);
Последние две строки — вся суть: бит ставится в страницу, лежащую в shared_buffers, и MarkBufferDirtyHint помечает её грязной. Так читающий запрос превращается в пишущий. Условие выше ставит предохранитель для асинхронных коммитов: пока запись коммита не сброшена в WAL, а страница не получила LSN не меньше LSN коммита, функция возвращается и бит не ставится: иначе после сбоя в странице остался бы бит о коммите, о котором журнал уже не помнит. Откатанные транзакции получают HEAP_XMIN_INVALID без такой проверки (вызов с InvalidTransactionId; обе ветки — в HeapTupleSatisfiesMVCC, строки 1063–1074). Проверь на откатанной вставке:
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE TABLE ab (id int);
BEGIN; INSERT INTO ab VALUES (1); ROLLBACK;
SELECT lp, t_xmin, to_hex(t_infomask) AS infomask FROM heap_page_items(get_raw_page('ab', 0));
SELECT count(*) FROM ab;
SELECT lp, t_xmin, to_hex(t_infomask) AS infomask FROM heap_page_items(get_raw_page('ab', 0));
lp | t_xmin | infomask
----+--------+----------
1 | 790 | 800
(1 row)
count
-------
0
(1 row)
lp | t_xmin | infomask
----+--------+----------
1 | 790 | a00
(1 row)
0x0800 (HEAP_XMAX_INVALID) превратился в 0x0A00: добавился 0x0200, HEAP_XMIN_INVALID. Мёртвая версия осталась в странице, но теперь любой читатель отбрасывает её по биту, не заглядывая в pg_xact.
Опыт: 10 млн строк и два SELECT подряд
Таблица создана с autovacuum_enabled = false: иначе автовакуум после вставки мог бы поставить биты раньше читателя. Данные идут через файл, чтобы загрузка была именно COPY:
CREATE TABLE big (id int, pad text) WITH (autovacuum_enabled = false);
COPY (SELECT g, md5(g::text) FROM generate_series(1,10000000) g) TO '/tmp/big.tsv';
COPY big FROM '/tmp/big.tsv';
CHECKPOINT;
SET max_parallel_workers_per_gather = 0;
SELECT pg_stat_reset_shared('io'), pg_stat_reset_shared('wal'), pg_stat_reset_slru('transaction');
SELECT lp, t_xmin, to_hex(t_infomask) AS infomask FROM heap_page_items(get_raw_page('big', 0)) WHERE lp <= 2;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM big;
SELECT lp, t_xmin, to_hex(t_infomask) AS infomask FROM heap_page_items(get_raw_page('big', 0)) WHERE lp <= 2;
SELECT writes, pg_size_pretty(write_bytes) AS written FROM pg_stat_io
WHERE backend_type = 'client backend' AND object = 'relation' AND context = 'bulkread';
SELECT wal_records, wal_fpi, pg_size_pretty(wal_bytes) AS wal FROM pg_stat_wal;
SELECT blks_hit, blks_read FROM pg_stat_slru WHERE name = 'transaction';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM big;
Таблица занимает 83 336 страниц (651 МБ). Параллельные воркеры выключены, чтобы весь проход делал один процесс. Вывод:
lp | t_xmin | infomask
----+--------+----------
1 | 796 | 802
2 | 796 | 802
(2 rows)
QUERY PLAN
----------------------------------------------------------------------
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=1 read=83335 dirtied=83334 written=65533
-> Seq Scan on big (actual rows=10000000.00 loops=1)
Buffers: shared hit=1 read=83335 dirtied=83334 written=65533
Planning:
Buffers: shared hit=9
(6 rows)
lp | t_xmin | infomask
----+--------+----------
1 | 796 | 902
2 | 796 | 902
(2 rows)
writes | written
--------+---------
65533 | 512 MB
(1 row)
wal_records | wal_fpi | wal
-------------+---------+--------
83334 | 83334 | 654 MB
(1 row)
blks_hit | blks_read
----------+-----------
1 | 0
(1 row)
QUERY PLAN
---------------------------------------------------------
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=16246 read=67090 written=94
-> Seq Scan on big (actual rows=10000000.00 loops=1)
Buffers: shared hit=16246 read=67090 written=94
(4 rows)
Что здесь видно:
dirtied=83334 на первом проходе. Все страницы, кроме двух, стали грязными, а infomask строк 1 и 2 изменился с 802 на 902: добавился 0x0100 (HEAP_XMIN_COMMITTED). pg_stat_io подтверждает запись самим читателем: 65 533 страницы (512 МБ) он записал на диск прямо во время скана (контекст bulkread — кольцевой буфер большого скана).
wal_fpi = 83334. Каждая грязная страница ушла в WAL целиком: 654 МБ от одного SELECT. Причина — контрольные суммы: data_checksums равен on (в PostgreSQL 18 так по умолчанию), и по документации при них изменение хинт-бита всегда пишется в WAL как полный образ страницы при первом изменении после контрольной точки. Поэтому перед опытом стоит CHECKPOINT.
blks_hit = 1 в pg_stat_slru. На 10 млн строк журнал коммитов прочитан один раз. Все строки вставила одна транзакция, а TransactionLogFetch в transam.c держит кэш на один номер, строки 27–34:
/*
* Single-item cache for results of TransactionLogFetch. It's worth having
* such a cache because we frequently find ourselves repeatedly checking the
* same XID, for example when scanning a table just after a bulk insert,
* update, or delete.
*/
static TransactionId cachedFetchXid = InvalidTransactionId;
static XidStatus cachedFetchXidStatus;
Дорог не поход в pg_xact, а запись страниц, которую он запускает.
Второй проход: dirtied нет. Строки Buffers не содержат ни dirtied, ни новых записей wal_fpi. Запись written=94 — грязные страницы первого прохода, которые второй проход вытеснил из буфера.
Что делать с первым SELECT после загрузки
Бит можно поставить заранее или избежать совсем. Сравни три таблицы по 10 млн строк после CHECKPOINT, с \timing on: обычная big, big_v с VACUUM перед чтением и big_f, загруженная через COPY ... WITH (FREEZE) в той же транзакции, что и CREATE TABLE.
BEGIN;
CREATE TABLE big_f (id int, pad text) WITH (autovacuum_enabled = false);
COPY big_f FROM '/tmp/big.tsv' WITH (FREEZE);
COMMIT;
Вывод (оставлены строки Buffers верхнего узла и Time):
-- big: первый SELECT без подготовки
Buffers: shared read=83336 dirtied=83334 written=65535
Time: 1897.660 ms (00:01.898)
-- big: второй SELECT
Buffers: shared hit=16290 read=67046 written=94
Time: 367.745 ms
-- big_v: VACUUM, затем SELECT
VACUUM
Time: 5775.228 ms (00:05.775)
Buffers: shared hit=256 read=83080
Time: 268.841 ms
-- big_f: COPY FREEZE, затем SELECT
Buffers: shared read=83336
Time: 269.838 ms
Первый SELECT занял 1,9 с, второй 0,37 с. VACUUM не убирает запись, а переносит её в команду, которая сама работает дольше SELECT: 5,8 с в этом прогоне, а в трёх прогонах на этой машине от 2,8 до 8,2 с. После него dirtied исчезает, и скан занимает 0,27 с. COPY FREEZE даёт тот же скан без подготовки: строки загружены уже замороженными, ни хинт-битов, ни VACUUM не нужно. Документация ограничивает приём: таблица должна быть создана или очищена в той же транзакции, а загруженные строки сразу видны всем сессиям, в обход обычных правил MVCC.
Хинт-бит: один поход в pg_xact, 83 334 грязные страницы
Что из этого следует
Первый SELECT после массовой загрузки пишет. dirtied и written в EXPLAIN (ANALYZE, BUFFERS) у запроса, который ничего не меняет, — признак хинт-битов. У него есть цена: 654 МБ WAL на таблицу в 651 МБ при включённых контрольных суммах.
Журнал коммитов тут ни при чём, если транзакций мало. Кэш на один номер превращает 10 млн проверок в одно обращение к pg_xact. Если же строки вставляли тысячи разных транзакций, каждая смена номера — новый поиск в странице pg_xact в памяти; этот случай здесь не измерялся.
Лекарство выбирай по способу загрузки. Разовую большую загрузку в новую таблицу делай через COPY ... WITH (FREEZE): скан сразу быстрый, и никто не платит за запись. Если таблица живая, запусти VACUUM сразу после загрузки: запись всё равно будет, но её делает служебная команда, а не первый пользовательский запрос.
Асинхронный коммит откладывает хинты. По коду SetHintBits, пока запись коммита не сброшена в WAL, бит не ставится, и читатели продолжают спрашивать pg_xact. Это не измерялось: учитывай, если читаешь данные, закоммиченные с synchronous_commit = off.
Чек-лист
- Запрос без
INSERT/UPDATEпоказываетdirtiedвEXPLAIN (ANALYZE, BUFFERS)— это хинт-биты; повтор того же запроса должен показать ноль. - После разовой загрузки больших таблиц используй
COPY ... WITH (FREEZE)в той же транзакции, что иCREATE TABLE, или запускайVACUUMдо пользователей. - Размер WAL от первого чтения оцени заранее: с
data_checksums = onкаждая грязная страница пишется в WAL целиком. - Состояние транзакции проверяй через
pg_xact_status(xid8); на уровне файла это два бита на транзакцию вpg_xact.
Контейнер удаляется командой docker rm -f dba-clog-and-hint-bits.