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

Статья 1.6 · раздел 1 «Что делает DBA и как база лежит на диске» · 6 из 109

TOAST в PostgreSQL: большие значения, сжатие lz4 и цена правки одного ключа в jsonb

опубликовано
читать
≈ 11 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

Документ jsonb в 820 КБ лежит в строке указателем на 18 байт, а сам разрезан на 184 куска в служебной таблице. Поэтому UPDATE одного флага внутри документа пишет в WAL около 403 КБ, а обновление соседнего столбца — 128 байт. Это TOAST. Строку и страницу мы разобрали в статьях про заголовок строки и раскладку страницы.

Порог: когда значение уходит из строки

Строка кучи должна влезать в страницу с запасом: PostgreSQL хочет, чтобы на странице помещалось минимум четыре строки. Размер считает макрос в src/include/access/heaptoast.h, строки 46–50 дословно:

#define TOAST_TUPLES_PER_PAGE	4

#define TOAST_TUPLE_THRESHOLD	MaximumBytesPerTuple(TOAST_TUPLES_PER_PAGE)

#define TOAST_TUPLE_TARGET		TOAST_TUPLE_THRESHOLD

MaximumBytesPerTuple(4) для страницы 8192 байта даёт MAXALIGN_DOWN((8192 − MAXALIGN(24 + 4 × 4)) / 4) = 2032. Если строка целиком больше 2032 байт, срабатывает механизм: сначала сжатие, потом вынос в toast-таблицу, пока строка не уложится в TOAST_TUPLE_TARGET (сегодня это то же число). Порог относится к строке, а не к значению: чем шире остальные столбцы, тем меньше остаётся самому длинному.

Проверь на таблице с одним text. Подними контейнер:

docker run -d --name dba-toast-large-values -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-toast-large-values psql -U postgres
-- значения несжимаемые (склеенные md5), кроме пятого
SELECT version();
SHOW block_size;
CREATE TABLE doc (id int PRIMARY KEY, body text);
INSERT INTO doc VALUES
 (1, (SELECT string_agg(md5(i::text), '') FROM generate_series(1,32) i)),
 (2, (SELECT string_agg(md5(i::text), '') FROM generate_series(1,59) i)),
 (3, (SELECT string_agg(md5(i::text), '') FROM generate_series(1,78) i)),
 (4, (SELECT string_agg(md5(i::text), '') FROM generate_series(1,3200) i)),
 (5, repeat('log line; ', 2900));

SELECT id, octet_length(body) AS raw, pg_column_size(body) AS stored,
       pg_column_compression(body) AS compression,
       pg_column_toast_chunk_id(body) AS chunk_id
FROM doc ORDER BY id;

version() вернул PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, block_size — 8192. Читать результат так:

ЗначениеstoredГде лежит
1 КБ (1024 байта)1028в строке (+4 байта заголовка значения)
1,9 КБ (1888)1892в строке
2,5 КБ (2496)2496вынесено, compression пуст: сжатие не дало выигрыша
100 КБ (102 400)102 400вынесено, compression пуст
29 000, повторяющийся текст354в строке: pglz свернул его, вынос не понадобился

Вынос случается, только если сжатия не хватило, поэтому «значение больше 2 КБ» — неверная формулировка порога. pg_column_size для вынесенного значения показывает размер там, а не размер указателя в строке.

Точную границу видно, если подбирать длину байт за байтом:

CREATE TABLE edge (id int, body text);
INSERT INTO edge SELECT n, left((SELECT string_agg(md5(i::text), '') FROM generate_series(1,70) i), n)
FROM generate_series(1990, 2010) n;
SELECT id, octet_length(body) raw, pg_column_size(body) stored,
       pg_column_toast_chunk_id(body) IS NOT NULL AS external
FROM edge WHERE id BETWEEN 2000 AND 2001 ORDER BY id;

Для длины 2000 запрос вернул stored = 2004 и external = f, для 2001 — stored = 2001 и external = t. Значение в 2000 байт остаётся: 24 байта заголовка строки, 4 байта id и 4 байта заголовка значения плюс 2000 дают ровно 2032. На 2001-м строка перешагивает порог, и значение выходит наружу.

Что лежит в toast-таблице

У каждой таблицы с вынесенными столбцами есть служебная таблица pg_toast.pg_toast_<oid таблицы>, её номер хранит pg_class.reltoastrelid. Значение режется на чанки. Размер чанка задан в том же заголовке, строки 80–89:

#define EXTERN_TUPLES_PER_PAGE	4	/* tweak only this */

#define EXTERN_TUPLE_MAX_SIZE	MaximumBytesPerTuple(EXTERN_TUPLES_PER_PAGE)

#define TOAST_MAX_CHUNK_SIZE	\
	(EXTERN_TUPLE_MAX_SIZE -							\
	 MAXALIGN(SizeofHeapTupleHeader) -					\
	 sizeof(Oid) -										\
	 sizeof(int32) -									\
	 VARHDRSZ)

Чанк — это тоже строка кучи, поэтому на странице toast-таблицы помещается четыре чанка. Арифметика: 2032 − 24 (заголовок) − 4 (chunk_id) − 4 (chunk_seq) − 4 (заголовок bytea) = 1996. Опыт даёт то же:

SELECT reltoastrelid::regclass AS toast FROM pg_class WHERE relname = 'doc' \gset
SELECT chunk_id, count(*) AS chunks, max(octet_length(chunk_data)) AS max_chunk,
       sum(octet_length(chunk_data)) AS bytes
FROM :toast GROUP BY chunk_id ORDER BY chunk_id;

2496 байт — два чанка, 102 400 байт — 52 чанка: 51 по 1996 и последний неполный. Номера таблиц и чанков у тебя будут другими. Разрезает значение toast_save_datum в src/backend/access/common/toast_internals.c, строки 313–323:

		chunk_size = Min(TOAST_MAX_CHUNK_SIZE, data_todo);

		/*
		 * Build a tuple and store it
		 */
		t_values[1] = Int32GetDatum(chunk_seq++);
		SET_VARSIZE(&chunk_data, chunk_size + VARHDRSZ);
		memcpy(VARDATA(&chunk_data), data_p, chunk_size);
		toasttup = heap_form_tuple(toasttupDesc, t_values, t_isnull);

		heap_insert(toastrel, toasttup, mycid, options, NULL);

Каждый чанк — обычный heap_insert с номером chunk_seq, а для него создаётся запись в уникальном индексе toast-таблицы pg_toast_<oid>_index по (chunk_id, chunk_seq) (так его показывает pg_get_indexdef). Значит, вынесенное значение читается так: по chunk_id из указателя индекс находит чанки по порядку, и они склеиваются.

Вместо значения в строке лежит указатель varatt_external (src/include/varatt.h): 18 байт на любое вынесенное значение, какого бы размера оно ни было.

Четыре стратегии хранения

Что делать со значением, решает стратегия столбца, поле attstorage; по умолчанию его берёт из pg_type.typstorage (там x у text, bytea и jsonb, p у int4). Букв четыре: p plain — значение остаётся в строке, сжатия и выноса нет (int4 и все типы фиксированной длины); x extended — сначала сжать, потом вынести (text, bytea, jsonb); e external — вынести, не сжимая; m main — сжать и держать в строке, а выносить только в последнюю очередь. Как это выглядит в коде, src/backend/access/heap/heaptoast.c, функция heap_toast_insert_or_update, строки 184–197:

	while (heap_compute_data_size(tupleDesc,
								  toast_values, toast_isnull) > maxDataLen)
	{
		int			biggest_attno;

		biggest_attno = toast_tuple_find_biggest_attribute(&ttc, true, false);
		if (biggest_attno < 0)
			break;

		/*
		 * Attempt to compress it inline, if it has attstorage EXTENDED
		 */
		if (TupleDescAttr(tupleDesc, biggest_attno)->attstorage == TYPSTORAGE_EXTENDED)
			toast_tuple_try_compression(&ttc, biggest_attno);

Пока строка больше maxDataLen (порог минус заголовок), берётся самый большой подходящий столбец и сжимается; если после этого он всё ещё больше maxDataLen, его выносят (строки 215–217). Столбцы main откладываются на последнюю фазу (строки 258–270). Предел maxDataLen читает RelationGetToastTupleTarget(rel, TOAST_TUPLE_TARGET) в строке 177: у таблицы он переопределяется параметром хранения toast_tuple_target.

Стратегию столбца меняет ALTER TABLE ... ALTER COLUMN ... SET STORAGE; она действует на новые значения и не переписывает таблицу.

Опыт 2: pglz против lz4

Метод сжатия задаёт параметр default_toast_compression; в этом контейнере SHOW default_toast_compression вернул pglz. Метод записан в каждом значении отдельно (pg_column_compression), поэтому смена параметра не трогает старые. Создадим 10 000 разных документов jsonb по 8971–8995 байт (pg_column_size), в каждом 70 позиций с артикулом, хешем и тегами: часть повторяется, часть случайна. Положим в две одинаковые таблицы. Источник хранится без сжатия (EXTERNAL), чтобы оба INSERT читали одно и то же:

CREATE TABLE src (id int, doc jsonb);
ALTER TABLE src ALTER COLUMN doc SET STORAGE EXTERNAL;
SELECT setseed(0.42);
INSERT INTO src
SELECT g, jsonb_build_object('flag', false, 'items', a.items)
FROM generate_series(1,10000) g,
LATERAL (SELECT jsonb_agg(jsonb_build_object('sku', 'SKU-' || (random()*1e6)::int,
           'name', md5(random()::text), 'qty', (random()*100)::int, 'tags', jsonb_build_array('new','sale')))
         FROM generate_series(1,70) i WHERE g > 0) AS a(items);

Условие WHERE g > 0 нужно, чтобы подзапрос выполнялся для каждой строки: без привязки к g планировщик посчитал бы его один раз и все 10 000 документов оказались бы одинаковыми. Теперь вставка с каждым методом и замер времени:

CREATE TABLE docs_pglz (id int, doc jsonb);
CREATE TABLE docs_lz4  (id int, doc jsonb);
\timing on
SET default_toast_compression = pglz;
INSERT INTO docs_pglz SELECT * FROM src;
SET default_toast_compression = lz4;
INSERT INTO docs_lz4 SELECT * FROM src;
\timing off
SELECT 'pglz' AS method, pg_total_relation_size('docs_pglz') AS total_bytes,
       (SELECT avg(pg_column_size(doc))::int FROM docs_pglz) AS avg_stored,
       (SELECT count(*) FROM docs_pglz WHERE pg_column_compression(doc) = 'pglz') AS rows_with_method
UNION ALL
SELECT 'lz4', pg_total_relation_size('docs_lz4'),
       (SELECT avg(pg_column_size(doc))::int FROM docs_lz4),
       (SELECT count(*) FROM docs_lz4 WHERE pg_column_compression(doc) = 'lz4');
INSERT 0 10000
Time: 805.991 ms
INSERT 0 10000
Time: 254.056 ms

 method | total_bytes | avg_stored | rows_with_method 
--------+-------------+------------+------------------
 pglz   |    42008576 |       3391 |            10000
 lz4    |    42008576 |       3382 |            10000
(2 rows)

Размер одинаков до байта: 42 008 576, около 3,4 КБ на документ (сжатие в 2,65 раза у обоих): при чанке до 1996 байт это два чанка на документ. А вставка с lz4 быстрее: 254 мс против 806 мс. Повтор TRUNCATE и INSERT по три раза для каждого метода дал: pglz занял 803–826 мс, lz4 — 222–227 мс, то есть примерно в 3,6 раза быстрее. Это один тип данных на одной машине: твои документы дадут другое соотношение. Чтение с распаковкой не измерялось, вывода про него нет.

Опыт 3: цена частичного обновления

jsonb не умеет менять значение внутри toast: jsonb_set строит новое значение целиком, и оно записывается как новый вынесенный документ. Возьми 100 документов из src и поменяй флаг в первом:

CREATE TABLE upd (id int PRIMARY KEY, flag boolean DEFAULT false, doc jsonb);
INSERT INTO upd SELECT id, false, doc FROM src WHERE id <= 100;
SELECT reltoastrelid::regclass AS toast FROM pg_class WHERE relname = 'upd' \gset
VACUUM upd;
CHECKPOINT;
SELECT ctid, chunk_id, chunk_seq, octet_length(chunk_data) AS bytes
FROM :toast WHERE chunk_id = (SELECT pg_column_toast_chunk_id(doc) FROM upd WHERE id = 1) ORDER BY chunk_seq;

CREATE TEMP TABLE wal_log (n int, what text, wal_bytes bigint);
DO $$
DECLARE a pg_lsn; b pg_lsn;
BEGIN
  FOR i IN 1..4 LOOP
    a := pg_current_wal_insert_lsn();
    UPDATE upd SET doc = jsonb_set(doc, '{flag}', to_jsonb(i % 2 = 0)) WHERE id = 1;
    b := pg_current_wal_insert_lsn();
    INSERT INTO wal_log VALUES (i, 'jsonb_set(doc, ...)', b - a);
  END LOOP;
  FOR i IN 1..2 LOOP
    a := pg_current_wal_insert_lsn();
    UPDATE upd SET flag = NOT flag WHERE id = 1;
    b := pg_current_wal_insert_lsn();
    INSERT INTO wal_log VALUES (i, 'SET flag = NOT flag', b - a);
  END LOOP;
END $$;
SELECT * FROM wal_log ORDER BY what DESC, n;

SELECT ctid, chunk_id, chunk_seq, octet_length(chunk_data) AS bytes
FROM :toast WHERE chunk_id = (SELECT pg_column_toast_chunk_id(doc) FROM upd WHERE id = 1) ORDER BY chunk_seq;
SELECT pg_stat_force_next_flush() \g /dev/null
SELECT n_tup_ins, n_tup_upd, n_tup_del FROM pg_stat_all_tables WHERE relid = :'toast'::regclass;
  ctid  | chunk_id | chunk_seq | bytes 
-------+----------+-----------+-------
 (0,1) |   227087 |         0 |  1996
 (0,2) |   227087 |         1 |  1391
(2 rows)

 n |        what         | wal_bytes 
---+---------------------+-----------
 1 | SET flag = NOT flag |       128
 2 | SET flag = NOT flag |       128
 1 | jsonb_set(doc, ...) |     36712
 2 | jsonb_set(doc, ...) |      3928
 3 | jsonb_set(doc, ...) |     12176
 4 | jsonb_set(doc, ...) |      3928
(6 rows)

  ctid  | chunk_id | chunk_seq | bytes 
--------+----------+-----------+-------
 (51,3) |   227197 |         0 |  1996
 (51,4) |   227197 |         1 |  1393
(2 rows)

Документ переписан целиком. Новый chunk_id (227197 вместо 227087), другая страница (51 вместо нулевой), другой размер значения (3 389 байт вместо 3 387): значение собрано заново, а не исправлено на месте.

n_tup_upd у toast-таблицы равен нулю. Четыре обновления дали +8 вставок (по два чанка) и +8 удалений: до обновлений было 200 вставок и ни одного удаления. Toast-таблица чанки не обновляет, а вставляет новые и помечает старые удалёнными, поэтому смотри n_tup_ins и n_tup_del. Мёртвые чанки остаются до очистки (VACUUM и autovacuum).

WAL: 3 928 байт на документ в 3,4 КБ против 128 на соседний столбец. Это немногим больше 3 389 байт самого значения: в журнал уходят все новые чанки. Всплески 36 712 и 12 176 — первые записи на страницах после CHECKPOINT, когда в журнал попадает ещё и образ целой 8-килобайтной страницы. Цена растёт вместе с документом: на значении в 822 428 байт текста (366 816 после pglz, 184 чанка) тот же jsonb_set писал 402 712–410 984 байта WAL, а SET flag — всё те же 128. Те же команды на документе в 820 КБ:

CREATE TABLE big (id int PRIMARY KEY, flag boolean DEFAULT false, doc jsonb);
INSERT INTO big SELECT 1, false, jsonb_build_object('flag', false, 'items', a.items)
FROM (SELECT jsonb_agg(jsonb_build_object('sku', 'SKU-' || (random()*1e6)::int, 'name', md5(random()::text),
        'qty', (random()*100)::int, 'tags', jsonb_build_array('new','sale'))) FROM generate_series(1,8000)) AS a(items);
SELECT reltoastrelid::regclass AS toast FROM pg_class WHERE relname = 'big' \gset
TRUNCATE wal_log;
DO $$ DECLARE a pg_lsn; b pg_lsn; BEGIN
  FOR i IN 1..3 LOOP a := pg_current_wal_insert_lsn();
    UPDATE big SET doc = jsonb_set(doc, '{flag}', to_jsonb(i % 2 = 0)) WHERE id = 1;
    b := pg_current_wal_insert_lsn(); INSERT INTO wal_log VALUES (i, 'jsonb_set(doc, ...)', b - a); END LOOP;
  FOR i IN 1..3 LOOP a := pg_current_wal_insert_lsn(); UPDATE big SET flag = NOT flag WHERE id = 1;
    b := pg_current_wal_insert_lsn(); INSERT INTO wal_log VALUES (i, 'SET flag = NOT flag', b - a); END LOOP;
END $$;
SELECT * FROM wal_log ORDER BY what DESC, n;
SELECT octet_length(doc::text) AS json_text_bytes, pg_column_size(doc) AS stored, pg_column_compression(doc) FROM big;
SELECT count(*) AS chunks, min(octet_length(chunk_data)) AS min_chunk, max(octet_length(chunk_data)) AS max_chunk FROM :toast;
CREATE EXTENSION pageinspect; SELECT lp_len FROM heap_page_items(get_raw_page('big',0)) WHERE t_xmax = 0;
 n |        what         | wal_bytes 
---+---------------------+-----------
 1 | SET flag = NOT flag |       128
 2 | SET flag = NOT flag |       128
 3 | SET flag = NOT flag |       128
 1 | jsonb_set(doc, ...) |    410984
 2 | jsonb_set(doc, ...) |    403520
 3 | jsonb_set(doc, ...) |    402712

 json_text_bytes | stored | compression 
-----------------+--------+-------------
 822428 | 366816 | pglz

 chunks | min_chunk | max_chunk 
--------+-----------+-----------
    184 |      1548 |      1996

 lp_len 
--------
     47

Строка занимает 47 байт: 24 (заголовок) + 4 (id) + 1 (flag) + 18 (указатель на вынесенное значение, varatt_external).

Обновление flag дёшево, потому что toast_tuple_init в toast_helper.c сравнивает старый указатель с новым через memcmp. Указатель побайтово тот же: новая версия строки ссылается на те же чанки. Иначе старое значение помечается к удалению, а новое пишет toast_save_datum.

DBA / 01

Один jsonb в 366 816 байт: 184 чанка, и правка ключа переписывает все

Строка таблицы big, её указатель TOAST, индекс и 184 чанкаСтрока таблицы big занимает 47 байт: заголовок 24 байта, id 4 байта, flag 1 байт и указатель 18 байт на вынесенное значение. Значение jsonb после pglz занимает 366 816 байт. По индексу toast-таблицы на (chunk_id, chunk_seq) находятся 184 чанка по 1 996 байт, последний 1 548. UPDATE с jsonb_set пишет в WAL от 402 712 до 410 984 байт и вставляет 184 новых чанка, а UPDATE столбца flag пишет 128 байт и toast не трогает.heap: строка таблицы bigtoast-таблица pg_toast_<oid>lp_len 47 байтid · flag · указательуказатель: chunk_idтекстом 822 428 Бпосле pglz 366 816 Биндекс (chunk_id, chunk_seq)находит чанки по порядкуseq 01 996 Бseq 11 996 Б… 182 …seq 1831 548 Б184 чанка = 366 816 БUPDATE … jsonb_set(doc, '{flag}', …)402 712–410 984 Б WAL+184 новых чанка, 184 старых удалены, n_tup_upd = 0документ переписан целикомUPDATE … SET flag128 Б WALуказатель тот же,toast не тронут
Строка хранит 18 байт указателя, а документ лежит в 184 чанках. Правка ключа внутри jsonb (янтарный блок) создаёт новое значение: 184 вставки, 184 удаления и 402 712–410 984 байта WAL. Правка соседнего столбца (зелёный блок) оставляет указатель прежним и пишет 128 байт.

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

jsonb — не журнал изменений. Если документ растёт за килобайты, а меняется по одному полю, то каждая правка пишет в WAL, в таблицу и в репликацию весь документ. Выноси то, что меняется часто (флаг, счётчик, статус), в обычные столбцы: правка такого столбца обходится в 128 байт независимо от размера документа.

Выбор метода сжатия. На наших 10 000 документов lz4 дал тот же размер, что pglz, и вставку в 3,6 раза быстрее. Это довод попробовать default_toast_compression = lz4 на своей нагрузке, а не заменять метод вслепую: параметр действует на новые значения, старые остаются со своим методом.

Чек-лист

  1. Строка больше 2032 байт (TOAST_TUPLE_THRESHOLD на странице 8 КБ) сначала сжимается, а потом выносится в pg_toast.pg_toast_<oid> чанками до 1996 байт.
  2. Правка ключа внутри jsonb пишет в WAL весь документ заново: считай WAL через pg_current_wal_lsn() до и после, а не по размеру изменения.
  3. В pg_stat_all_tables toast-таблицы смотри n_tup_ins и n_tup_del: n_tup_upd там всегда ноль.
  4. Меняй default_toast_compression только после замера на своих данных; метод хранится в каждом значении и действует на новые записи.

Контейнер удаляется командой docker rm -f dba-toast-large-values.

Источники

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