Почему большие массивы в СУБД становятся проблемой
PostgreSQL хранит данные страницами по 8 КБ. Значение типа text, bytea или jsonb может занимать до 1 ГБ, но всё, что превышает примерно 2 КБ, уезжает в отдельную TOAST-таблицу и собирается обратно при каждом чтении. Три числа, 8 КБ, 2 КБ и 1 ГБ, задают потолок, в который упирается любая схема с крупными массивами.
Реляционные СУБД проектировались под короткие строки: десятки байт на запись, миллионы записей, быстрый доступ по индексу. Строка размером 500 КБ ломает эту модель сразу в нескольких местах: чтение требует дополнительного ввода-вывода, запись генерирует кратно больше WAL, реплика отстаёт, резервная копия растёт вместе с данными. Диск добавить можно, латентность запросов от этого не снизится.
Деградация проявляется раньше, чем заканчивается место. Сначала растёт среднее время ответа, затем падает пропускная способность, потом появляются временные файлы на диске, и только в конце заканчивается свободное пространство. Обновление значения на 1 МБ даёт около 1 МБ WAL плюс полные образы страниц при full_page_writes = on. Журнал растёт быстрее самих данных.
Арифметика для типовой задачи. Таблица на 10 млн строк со средней длиной строки 100 КБ занимает около 1 ТБ в основной таблице, ещё около 1 ТБ в TOAST при слабом сжатии, плюс индексы. Если каждую строку обновляют раз в месяц, за месяц через WAL пройдёт порядка 1 ТБ изменений, и этот объём придётся протащить через репликацию и резервное копирование.
Три уровня лимитов: страница, строка, поле
| Уровень | Лимит | Чем задан | Что происходит при превышении |
|---|---|---|---|
| Страница (BLCKSZ) | 8192 байта | Параметром сборки сервера | Строка не помещается целиком, включается TOAST |
| Строка | 1,6 ТБ теоретически, около 8 КБ в одной странице | Документированным максимумом PostgreSQL | Практический предел задаёт TOAST, а не сама строка |
| Поле varlena (text, bytea, jsonb) | 1 ГБ | Формат varlena, 30 бит на длину | Вставка завершается ошибкой |
Размер страницы меняют только вместе с initdb и пересборкой сервера. Максимум колонок в таблице - 1600, максимум ключевых колонок в индексе - 32, размер одного отношения ограничен 32 ТБ. На практике первым упирается поле: varlena-значение физически не может превысить 1 ГБ, тогда как строка собирается из множества полей и выживает за счёт TOAST.
Числа актуальны для PostgreSQL 17 и 18. Механика TOAST и лимит varlena не менялись с версии 9.x, а инструменты наблюдаемости за это время добавились: pg_stat_io в 16, отдельное представление pg_stat_checkpointer в 17, асинхронный ввод-вывод в 18.
Максимальный размер строки PostgreSQL и механизм TOAST
TOAST расшифровывается как The Oversized-Attribute Storage Technique. Механизм включается, когда длина строки превышает TOAST_TUPLE_THRESHOLD, по умолчанию 2000 байт. Порядок работы такой:
- PostgreSQL пытается сжать крупные значения алгоритмом по умолчанию (pglz, с версии 14 доступен lz4).
- Если строка всё ещё больше порога, самые крупные значения выносятся out-of-line в TOAST-таблицу вида pg_toast_16385, где 16385 - OID основной таблицы.
- Значение режется на чанки по 2000 байт (TOAST_MAX_CHUNK_SIZE), каждый чанк - отдельная строка с собственным заголовком примерно на 24 байта.
- В основной таблице остаётся короткий указатель на вынесенное значение, поэтому строка помещается в страницу.
Чанки индексируются служебным индексом по chunk_id и chunk_seq. Пользовательские индексы на TOAST-таблицу создать нельзя, это внутренняя структура. Значение в 500 КБ превращается примерно в 250 чанков, то есть 250 служебных строк на одну запись. Миллион таких записей даёт 250 млн чанков в TOAST-таблице.
TOAST применяется к varlena-типам: text, varchar, bytea, json, jsonb, numeric, а также к массивам, поскольку любой массив в PostgreSQL упакован в varlena. Типы фиксированной длины (int4, bigint, uuid, timestamp, boolean) не сжимаются и не выносятся: их размер предсказуем.
Сжатие и вынос работают вместе: сначала PostgreSQL пробует уменьшить значение, и только если это не помогает, пишет чанки на диск.
Проверка размеров:
SELECT pg_column_size(payload) AS value_bytes,
pg_column_size(t.*) AS row_bytes
FROM documents t
LIMIT 5;
SELECT relname,
pg_size_pretty(pg_relation_size(oid)) AS main_size,
pg_size_pretty(pg_total_relation_size(oid) - pg_relation_size(oid)) AS toast_and_indexes
FROM pg_class
WHERE relname = 'documents';
pg_column_size возвращает размер значения с учётом сжатия, pg_total_relation_size включает TOAST и индексы. Разница между двумя вызовами и есть цена TOAST.
Порог можно поднять для конкретной таблицы: параметр хранения toast_tuple_target принимает значения от 128 до 8160 байт и заставляет сервер выносить данные раньше или позже. Для колонки доступен выбор алгоритма сжатия:
CREATE TABLE documents ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, payload jsonb ) WITH (toast_tuple_target = 4096); ALTER TABLE documents ALTER COLUMN payload SET COMPRESSION lz4;
Как TOAST влияет на чтение и запись
При выборке большого поля PostgreSQL выполняет detoast: находит все чанки по служебному индексу, распаковывает их и собирает значение в памяти бэкенда. Для значения в 500 КБ это около 250 обращений к страницам TOAST-таблицы плюс распаковка. Отдельного узла detoast в плане запроса нет, расход виден в строках Buffers и в общем времени выборки из heap. Разница между чтением обычной колонки и detoast легко достигает 5-10 раз на одну строку.
Запись работает ещё дороже. UPDATE toasted-колонки создаёт полностью новый набор чанков, старые становятся мёртвыми и ждут vacuum. Одна правка поля на 1 МБ порождает около 500 новых чанков и столько же мёртвых. При частых обновлениях TOAST-таблица распухает быстрее основной.
SELECT * FROM pgstattuple('pg_toast.pg_toast_16385');
Функция покажет dead_tuple_percent и free_percent. Если мёртвые кортежи занимают больше 20% TOAST-таблицы, autovacuum не успевает за нагрузкой и его пороги стоит пересмотреть.
Когда TOAST не спасает
- Сжатие бесполезно для уже упакованных данных: JPEG, PNG, gzip, зашифрованные блобы. pglz может даже слегка увеличить значение.
- Крупное одиночное значение всё равно собирается в памяти целиком. Значение на 500 МБ при чтении займёт 500 МБ в бэкенде независимо от TOAST.
- Много средних полей. Строка из 60 колонок по 1,5 КБ весит около 90 КБ, TOAST сработает, но чтение потребует распаковки десятков значений.
- Large objects живут в отдельном механизме pg_largeobject, TOAST к ним не применяется.
- Индексный доступ не спасает от detoast: если колонка попала в список выборки, значение придётся собрать.
Лимиты JSON и JSONB в PostgreSQL
Типы json и jsonb тоже varlena, поэтому подчиняются лимиту 1 ГБ на значение и порогу TOAST в 2 КБ. Разница внутри: json хранит исходный текст со всеми пробелами и порядком ключей и разбирает его при каждом обращении, jsonb хранит разобранное бинарное дерево с отсортированными уникальными ключами. По размеру jsonb обычно на 5-15% больше исходного текста, зато читается без повторного парсинга и индексируется GIN.
Индексы GIN (jsonb_ops и jsonb_path_ops) ускоряют поиск по ключам и значениям, но не отменяют detoast при выборке документа целиком. Документ на 10 МБ, выбранный через SELECT payload, всё равно соберётся из чанков, а jsonb_path_ops обычно в 2-3 раза компактнее jsonb_ops. Обновление документа перезаписывает всё значение: частичного обновления в jsonb нет, поэтому даже правка одного поля на 2 КБ генерирует новые чанки для всего документа.
SELECT payload->>'status' AS status FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-02-01';
Оператор ->> вызывает detoast для каждой строки результата. На миллионе строк это миллион сборок, на выборке из 100 строк накладные расходы незаметны. Планируйте запросы так, чтобы toasted-колонка не попадала в выборку без необходимости.
Практический порог: когда JSONB становится проблемой
| Размер документа | Что происходит | Что делать |
|---|---|---|
| До 10 КБ | Обычная работа, GIN-индекс эффективен | Ничего |
| 10-100 КБ | TOAST включается, влияние на WAL заметно при частых обновлениях | Следить за wal_bytes и размером TOAST |
| 100 КБ - 1 МБ | Рост WAL и лага репликации, GIN-индекс дорожает | Разделить документ: горячие поля в колонки, остальное в отдельную таблицу |
| Больше 1 МБ | Деградация при каждом обновлении, риск временных файлов | Вынести в объектное хранилище, в базе оставить метаданные |
Расчёт для понимания масштаба. Миллион документов по 500 КБ - это 500 ГБ данных плюс TOAST плюс GIN-индекс, который занимает десятки гигабайт. Если 10% документов обновляются раз в неделю, еженедельно через WAL проходит около 50 ГБ только по этой таблице. Реплика должна применить тот же объём.
Разные форматы сериализации ведут себя по-разному: сравнение JSON, BLOB, MessagePack и Protobuf по размеру и скорости разобрано в отдельном материале о сериализации массивов в базу данных.
BLOB, bytea и large objects: лимиты и подводные камни
bytea - штатный бинарный тип PostgreSQL, varlena с лимитом 1 ГБ. Значения больше 2 КБ уходят в TOAST, но сжатие для бинарных данных почти всегда бесполезно: изображения, архивы и зашифрованные файлы уже упакованы. При выводе в текстовом режиме bytea кодируется в hex и занимает вдвое больше места, что важно при выгрузках.
Large objects работают по другому принципу. Данные лежат в системной таблице pg_largeobject страницами по 2 КБ, доступ идёт через дескрипторы lo_open, lo_read, lo_write, а также функции lo_import и lo_export. Лимит на один объект достигает 4 ТБ. Обратная сторона: such объекты не попадают в логическую репликацию, требуют отдельного внимания при бэкапе и накапливают осиротевшие записи, для очистки которых нужна утилита vacuumlo.
bytea и large objects: что выбрать в 2026
| Критерий | bytea | Large objects |
|---|---|---|
| Лимит на значение | 1 ГБ | 4 ТБ |
| TOAST и сжатие | Да, на практике бесполезно | Нет, постраничное хранение |
| pg_dump | Включается автоматически | Отдельный ключ -b, по умолчанию в современных версиях включён |
| Логическая репликация | Поддерживается | Не поддерживается |
| Чтение | Значение собирается целиком | Можно читать частями через lo_read |
| Сложность | Минимальная | Нужны дескрипторы, транзакции, vacuumlo |
Правило выбора простое. Файлы до нескольких мегабайт - bytea. Значения больше 1 ГБ, которые обязаны жить в базе, - large objects. Файлы больше 100 МБ - объектное хранилище, а в базе остаются метаданные: ключ, размер, хеш, тип содержимого. Подробнее про сравнение подходов и типов хранения рассказано в обзоре типов и способов организации хранения данных.
Как большие массивы влияют на WAL, репликацию и резервное копирование
WAL пишется на каждое изменение. При full_page_writes = on (значение по умолчанию) первое изменение страницы после checkpoint записывается в журнал целиком, все 8 КБ, даже если поменялся один байт. Обновление поля на 1 МБ даёт около 1 МБ записей плюс полные образы страниц. Метрики wal_records, wal_fpi и wal_bytes доступны в pg_stat_wal.
SELECT wal_records, wal_fpi, pg_size_pretty(wal_bytes) AS wal_size FROM pg_stat_wal;
Физическая репликация передаёт тот же поток WAL и применяет его одним процессом. Чем больше объём изменений, тем выше лаг и тем больше запаса WAL приходится держать на мастере. Репликационные слоты удерживают журнал до подтверждения от реплики: если реплика отстала или упала, каталог pg_wal растёт и может занять весь диск.
Логическая репликация передаёт строки целиком, включая toasted-значения. С PostgreSQL 14 неизменившиеся TOAST-значения не передаются повторно, подписчик берёт их локально, но при обновлении самого поля строка уходит полностью. Для документов по 1 МБ это мегабайты трафика на каждое изменение, а применяется логическая репликация медленнее физической.
Резервное копирование страдает тем же образом. pg_basebackup копирует каталог данных вместе с TOAST, и время растёт линейно от общего объёма. pg_dump читает каждую строку и выполняет detoast, поэтому выгрузка базы с крупными значениями идёт часами, даже с параллельным режимом -j: он распараллеливает таблицы, а не строки внутри одной таблицы. Восстановление добавляет сборку TOAST и перестройку индексов.
Пара практических следствий. Рост WAL больше 1 ГБ в час при небольшом числе транзакций почти всегда означает крупные поля. Репликационный слот, который держит больше 10 ГБ журнала, создаёт прямой риск заполнить диск. Настройки репликации и индексов под высокие нагрузки разобраны в руководстве по шардингу, репликации и индексам.
Мониторинг WAL и репликации при больших значениях
SELECT application_name, state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_lag
FROM pg_stat_replication;
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
SELECT num_timed, num_requested, write_time, sync_time
FROM pg_stat_checkpointer;
Ориентиры для дежурства: устойчивый лаг репликации выше 64 МБ, удержанный слотом WAL больше 10 ГБ, частота checkpoint выше одного в минуту при низкой транзакционной нагрузке. Представление pg_stat_checkpointer появилось в PostgreSQL 17, до этого счётчики лежали в pg_stat_bgwriter.
Потребление памяти и деградация производительности
work_mem выделяется на каждую операцию сортировки или хеширования, а не на запрос целиком. Сортировка 100 000 строк с полем по 200 КБ требует около 20 ГБ, поэтому при work_mem = 64 МБ PostgreSQL разобьёт работу на десятки проходов и напишет временные файлы. Каждый проход заново собирает toasted-значения из чанков, то есть добавляет ввод-вывод и работу процессора.
shared_buffers страдает иначе. TOAST-страницы конкурируют за кэш с горячими данными. Один запрос, читающий 5 ГБ TOAST, вытесняет из кэша рабочий набор приложения, и следующие OLTP-запросы идут на диск. Коэффициент попаданий в кэш в такой момент падает.
Detoast расходует память бэкенда: собранное значение живёт в памяти до конца обработки строки. Десять одновременных запросов с документом по 200 МБ дают 2 ГБ пикового потребления, что при лимитах контейнера легко приводит к OOM-killer.
Клиентская сторона тоже участвует. SELECT * по 1000 строк с полем 1 МБ передаёт 1 ГБ по сети, и столько же аллоцирует приложение. Часть инцидентов с большими массивами происходит не в базе, а в сервисе, который вычитывает всё в память.
Как заметить деградацию до её появления
Ранние сигналы видны в системных представлениях.
SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp_size
FROM pg_stat_database
ORDER BY temp_bytes DESC
LIMIT 5;
SELECT sum(blks_hit) * 100.0 / NULLIF(sum(blks_hit) + sum(blks_read), 0) AS cache_hit_ratio
FROM pg_stat_database;
SELECT queryid, calls, round(mean_exec_time::numeric, 2) AS mean_ms,
shared_blks_read, temp_blks_written
FROM pg_stat_statements
ORDER BY shared_blks_read DESC
LIMIT 10;
Пороги, после которых стоит разбираться: temp_bytes больше 10% от общего объёма ввода-вывода, коэффициент попаданий в кэш ниже 95% при OLTP-нагрузке (для аналитических сканов низкое значение нормально), shared_blks_read у конкретного запроса растёт вместе с числом строк результата. Диагностика одного запроса:
EXPLAIN (ANALYZE, BUFFERS) SELECT payload FROM documents WHERE id = 42;
Если Buffers shared read заметно превышает число страниц, которые запрос мог бы прочитать по индексу, виноват detoast. Ещё один признак - рост n_dead_tup в TOAST-таблице и запаздывание autovacuum.
Что помогает на уровне настроек: SET LOCAL work_mem = '256MB' для конкретного тяжёлого запроса вместо глобального повышения, отказ от SELECT * и сортировок по большим полям, вынос крупных колонок из основной таблицы. Асинхронный ввод-вывод в PostgreSQL 18 ускоряет чтение с диска, но распаковка и сборка значения остаются работой процессора на каждый запрос.
Практические рекомендации: когда выносить данные из СУБД
Алгоритм из трёх шагов. Измерьте фактический размер поля через pg_column_size и pg_total_relation_size. Если поле превышает 100 КБ и обновляется чаще раза в сутки, планируйте вынос. Если превышает 1 МБ, вынос нужен в любом случае.
Дальше выбор варианта. Структурированные данные, которые нужны частями, идут в отдельную таблицу со связью по внешнему ключу. Файлы и крупные тексты уходят в объектное хранилище, а в базе остаются метаданные. Временные ряды с большими значениями удобно партиционировать по дате, чтобы старые партиции уходили в архив вместе со своими TOAST-записями.
Целостность при выносе обеспечивают транзакционные паттерны. Классический вариант - outbox: в одной транзакции с основной записью создаётся задание на выгрузку, отдельный воркер его выполняет и помечает как готовое. Второй вариант - запись объекта в хранилище до коммита с компенсирующим удалением, если коммит не прошёл. Хеш содержимого (sha256) позволяет проверить, что в базе и в хранилище лежит одно и то же.
Вынос в отдельную таблицу: когда и как
Типовой сценарий: документы читают целиком, но редко, а запросы к атрибутам идут постоянно. Разделение на две таблицы убирает TOAST из горячего пути.
CREATE TABLE documents_meta ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, account_id bigint NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE documents_payload ( document_id bigint PRIMARY KEY REFERENCES documents_meta(id) ON DELETE CASCADE, payload jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() ); SELECT m.id, m.status, p.payload FROM documents_meta m JOIN documents_payload p ON p.document_id = m.id WHERE m.id = 42;
Основные запросы видят только метаданные и не читают ни одного TOAST-чанка. Плата за это - JOIN и вторая таблица в бэкапе. Для таблиц на сотни миллионов строк разумно партиционировать meta по created_at, а payload оставить единой таблицей с тем же ключом.
Что положить в meta: идентификаторы, статусы, счётчики, поля для фильтров и индексов. Что уходит в payload: длинные тексты, вложенные структуры, история версий. Горячие поля из JSONB, по которым идёт поиск, лучше поднять в отдельные колонки или в вычисляемые столбцы. Подробный разбор схем с массивами и критериев выбора есть в статье про нормализацию и денормализацию при хранении массивов.
Вынос в объектное хранилище: S3, MinIO, Ceph
Для файлов и крупных JSON схема с хранилищем проще, чем кажется. В базе остаётся таблица метаданных:
CREATE TABLE files ( id uuid PRIMARY KEY, owner_id bigint NOT NULL, s3_key text NOT NULL, size_bytes bigint NOT NULL, sha256 text NOT NULL, content_type text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() );
Порядок операций: загрузить объект в хранилище под ключом из UUID, затем вставить строку с ключом, размером и хешем. Удаление делают асинхронно: сначала помечают строку, потом фоновый процесс удаляет объект. Если вставка в базу не прошла, компенсирующее задание удаляет загруженный объект по ключу; если объект потерялся, проверка по sha256 это покажет.
Для on-premise подходят MinIO, Ceph RGW и S3-сервис на базе TrueNAS. Жизненный цикл настраивают сразу: перевод в холодный класс через 90 дней, удаление через год. Тогда рост хранилища перестаёт быть линейным. Хостинг базы и объектного хранилища без собственного железа закрывает Timeweb Cloud: там доступны managed PostgreSQL, серверы и объектное хранилище, что удобно, когда не хочется администрировать MinIO и реплику самостоятельно.
Сравнение лимитов PostgreSQL с другими СУБД
Проблема крупных значений общая для реляционных систем, различается только степень автоматизации.
| СУБД | Лимит на одно значение | Механизм | Особенности |
|---|---|---|---|
| PostgreSQL 17/18 | 1 ГБ для varlena, 4 ТБ для large object | TOAST, автоматически | Сжатие и вынос без участия администратора, но detoast на каждом чтении |
| MySQL 8 / InnoDB | 4 ГБ для LONGBLOB | Off-page хранение, 20-байтовый указатель в строке | Ограничение max_allowed_packet (по умолчанию 64 МБ) режет размер одной операции |
| SQL Server | 2 ГБ для varbinary(max) | LOB-страницы, FILESTREAM | Данные до 8000 байт хранятся в строке, FILESTREAM выносит файлы в файловую систему |
| Oracle | (4 ГБ - 1) x размер блока, для 8 КБ это около 32 ТБ | LOB в отдельном сегменте и tablespace | SecureFiles даёт сжатие и дедупликацию, но требует отдельного планирования места |
MySQL ограничивает и размер значения, и объём одного пакета: без правки max_allowed_packet вставка большой строки завершится ошибкой, хотя тип LONGBLOB допускает 4 ГБ. В SQL Server строка страницы ограничена 8060 байт, а крупные значения уходят на отдельные страницы. Oracle хранит LOB в отдельном tablespace, что упрощает сопровождение, но добавляет шаг при создании схемы.
PostgreSQL в этом ряду выделяется тем, что TOAST включён всегда и не требует решений от администратора. Это удобно на старте и опасно на дистанции: стоимость detoast и рост WAL проявляются незаметно, без ошибок в логах. В MySQL и SQL Server вынос крупных значений заметен уже на этапе настройки схемы.
Чек-лист и итоги
- Измерьте размеры полей: pg_column_size для значений, pg_total_relation_size минус pg_relation_size для доли TOAST.
- Настройте мониторинг WAL и репликации: pg_stat_wal, pg_stat_replication, pg_replication_slots, pg_stat_checkpointer.
- Выносите поля крупнее 1 МБ из основной таблицы в любом случае, поля крупнее 100 КБ при частых обновлениях.
- Файлы и крупные JSON храните в объектном хранилище, в базе держите ключ, размер и хеш.
- Проверьте, нужен ли поиск по JSONB. Если нет, jsonb не даёт преимуществ перед bytea или внешним хранилищем.
- Задавайте work_mem точечно для тяжёлых запросов и следите за temp_bytes в pg_stat_database.
- Тестируйте восстановление регулярно: pg_dump и pg_basebackup на базе с крупными значениями идут часами, и это надо знать заранее.
Цифры и механика приведены для PostgreSQL 17 и 18, они же применимы к 16 с поправкой на отсутствие pg_stat_checkpointer. Решения, принятые на этапе проектирования схемы, определяют сложность мониторинга, бэкапов и масштабирования позже, и разбор этой связи есть в материале о влиянии проектирования базы на администрирование.
Начните с одного запроса: выберите таблицу с самым большим pg_total_relation_size и сравните её размер с размером TOAST. Если TOAST занимает больше половины, схему пора менять.