Документ 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.
Один jsonb в 366 816 байт: 184 чанка, и правка ключа переписывает все
Что из этого следует
jsonb — не журнал изменений. Если документ растёт за килобайты, а меняется по одному полю, то каждая правка пишет в WAL, в таблицу и в репликацию весь документ. Выноси то, что меняется часто (флаг, счётчик, статус), в обычные столбцы: правка такого столбца обходится в 128 байт независимо от размера документа.
Выбор метода сжатия. На наших 10 000 документов lz4 дал тот же размер, что pglz, и вставку в 3,6 раза быстрее. Это довод попробовать default_toast_compression = lz4 на своей нагрузке, а не заменять метод вслепую: параметр действует на новые значения, старые остаются со своим методом.
Чек-лист
- Строка больше 2032 байт (
TOAST_TUPLE_THRESHOLDна странице 8 КБ) сначала сжимается, а потом выносится вpg_toast.pg_toast_<oid>чанками до 1996 байт. - Правка ключа внутри jsonb пишет в WAL весь документ заново: считай WAL через
pg_current_wal_lsn()до и после, а не по размеру изменения. - В
pg_stat_all_tablestoast-таблицы смотриn_tup_insиn_tup_del:n_tup_updтам всегда ноль. - Меняй
default_toast_compressionтолько после замера на своих данных; метод хранится в каждом значении и действует на новые записи.
Контейнер удаляется командой docker rm -f dba-toast-large-values.