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

Статья 2.3 · раздел 2 «Транзакции и MVCC» · 10 из 109

pg_xact и хинт-биты: почему первый SELECT после загрузки пишет на диск

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

Первый 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.

DBA / 01

Хинт-бит: один поход в pg_xact, 83 334 грязные страницы

Путь строки от чтения до грязной страницы при установке хинт-битаЧитатель берёт кортеж с t_xmin 796 и infomask 802 и проверяет бит 0x0100. Если бита нет, вызывается TransactionLogFetch, который читает pg_xact; на 10 000 000 строк одной транзакции это одно обращение. Затем в страницу в памяти пишется infomask 902, и MarkBufferDirtyHint помечает её грязной: 83 334 страницы, 83 334 полных образа в WAL, 654 мегабайта. Если бит уже есть, pg_xact не нужен, и второй проход ничего не помечает грязным.читаем кортежinfomask & 0x0100 ?TransactionLogFetchinfomask |= 0x0100MarkBufferDirtyHintбит есть: pg_xact не нуженt_xmin = 796, infomask 802: бита нетнетестьpg_stat_slru: blks_hit = 1 на 10 000 000 строкinfomask 802 → 902 в странице в памятиdirtied = 83 334 · wal_fpi = 83 334 · WAL 654 МБ
Первый читатель проходит все пять шагов и записывает ответ pg_xact в страницу; второй видит бит 0x0100 на втором шаге и до pg_xact не доходит. Дорог не журнал коммитов, а пятый шаг: каждая из 83 334 страниц ушла в WAL целиком.

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

Первый 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.

Чек-лист

  1. Запрос без INSERT/UPDATE показывает dirtied в EXPLAIN (ANALYZE, BUFFERS) — это хинт-биты; повтор того же запроса должен показать ноль.
  2. После разовой загрузки больших таблиц используй COPY ... WITH (FREEZE) в той же транзакции, что и CREATE TABLE, или запускай VACUUM до пользователей.
  3. Размер WAL от первого чтения оцени заранее: с data_checksums = on каждая грязная страница пишется в WAL целиком.
  4. Состояние транзакции проверяй через pg_xact_status(xid8); на уровне файла это два бита на транзакцию в pg_xact.

Контейнер удаляется командой docker rm -f dba-clog-and-hint-bits.

Источники

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