Ограничения и лимиты хранения больших массивов в СУБД: практическое руководство 2026 | AdminWiki

Ограничения и лимиты хранения больших массивов в СУБД: практическое руководство 2026

16 сентября 2026 16 мин. чтения

Почему большие массивы в СУБД становятся проблемой

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 байт. Порядок работы такой:

  1. PostgreSQL пытается сжать крупные значения алгоритмом по умолчанию (pglz, с версии 14 доступен lz4).
  2. Если строка всё ещё больше порога, самые крупные значения выносятся out-of-line в TOAST-таблицу вида pg_toast_16385, где 16385 - OID основной таблицы.
  3. Значение режется на чанки по 2000 байт (TOAST_MAX_CHUNK_SIZE), каждый чанк - отдельная строка с собственным заголовком примерно на 24 байта.
  4. В основной таблице остаётся короткий указатель на вынесенное значение, поэтому строка помещается в страницу.

Чанки индексируются служебным индексом по 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

КритерийbyteaLarge 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/181 ГБ для varlena, 4 ТБ для large objectTOAST, автоматическиСжатие и вынос без участия администратора, но detoast на каждом чтении
MySQL 8 / InnoDB4 ГБ для LONGBLOBOff-page хранение, 20-байтовый указатель в строкеОграничение max_allowed_packet (по умолчанию 64 МБ) режет размер одной операции
SQL Server2 ГБ для varbinary(max)LOB-страницы, FILESTREAMДанные до 8000 байт хранятся в строке, FILESTREAM выносит файлы в файловую систему
Oracle(4 ГБ - 1) x размер блока, для 8 КБ это около 32 ТБLOB в отдельном сегменте и tablespaceSecureFiles даёт сжатие и дедупликацию, но требует отдельного планирования места

MySQL ограничивает и размер значения, и объём одного пакета: без правки max_allowed_packet вставка большой строки завершится ошибкой, хотя тип LONGBLOB допускает 4 ГБ. В SQL Server строка страницы ограничена 8060 байт, а крупные значения уходят на отдельные страницы. Oracle хранит LOB в отдельном tablespace, что упрощает сопровождение, но добавляет шаг при создании схемы.

PostgreSQL в этом ряду выделяется тем, что TOAST включён всегда и не требует решений от администратора. Это удобно на старте и опасно на дистанции: стоимость detoast и рост WAL проявляются незаметно, без ошибок в логах. В MySQL и SQL Server вынос крупных значений заметен уже на этапе настройки схемы.

Чек-лист и итоги

  1. Измерьте размеры полей: pg_column_size для значений, pg_total_relation_size минус pg_relation_size для доли TOAST.
  2. Настройте мониторинг WAL и репликации: pg_stat_wal, pg_stat_replication, pg_replication_slots, pg_stat_checkpointer.
  3. Выносите поля крупнее 1 МБ из основной таблицы в любом случае, поля крупнее 100 КБ при частых обновлениях.
  4. Файлы и крупные JSON храните в объектном хранилище, в базе держите ключ, размер и хеш.
  5. Проверьте, нужен ли поиск по JSONB. Если нет, jsonb не даёт преимуществ перед bytea или внешним хранилищем.
  6. Задавайте work_mem точечно для тяжёлых запросов и следите за temp_bytes в pg_stat_database.
  7. Тестируйте восстановление регулярно: pg_dump и pg_basebackup на базе с крупными значениями идут часами, и это надо знать заранее.

Цифры и механика приведены для PostgreSQL 17 и 18, они же применимы к 16 с поправкой на отсутствие pg_stat_checkpointer. Решения, принятые на этапе проектирования схемы, определяют сложность мониторинга, бэкапов и масштабирования позже, и разбор этой связи есть в материале о влиянии проектирования базы на администрирование.

Начните с одного запроса: выберите таблицу с самым большим pg_total_relation_size и сравните её размер с размером TOAST. Если TOAST занимает больше половины, схему пора менять.

Поделиться:
Сохранить гайд? В закладки браузера