Сериализация массивов в базу данных: сравнение JSON, BLOB и бинарных форматов хранения | AdminWiki

Сериализация массивов в базу данных: сравнение JSON, BLOB и бинарных форматов хранения

16 сентября 2026 15 мин. чтения
Содержание статьи

Что такое сериализация массивов в базу данных и когда она нужна

Сериализация превращает структуру в поток байт. Массив, словарь или объект записывается в одну колонку, а СУБД хранит результат как текст, JSON-документ или набор байт в bytea и BLOB. База о внутреннем устройстве значения не знает: она умеет сравнивать его целиком, сжимать, отдавать приложению и строить индекс по целому значению.

Короткий ответ на главный вопрос. Данные нужно фильтровать, сортировать или соединять по элементам массива - берите JSONB с GIN-индексом или нормализованную схему. Массив читается и пишется целиком, в WHERE и JOIN не участвует - выигрывают бинарные форматы: MessagePack и Protobuf в bytea или BLOB дают меньший размер и меньшую нагрузку на CPU. Форматы, привязанные к рантайму (pickle, PHP serialize), годятся только для данных, которые записало само приложение и никто извне не мог подменить.

Сценарии, где колонка с массивом решает задачу: конфигурация сервиса, снапшот состояния на момент времени, payload очереди задач, кэш ответов внешнего API, метаданные файла, вектор текста для поиска. Сценарии, где колонка мешает: теги статьи с фильтром по тегу, участники проекта с выборкой по пользователю, позиции заказа с агрегатами. Во втором случае появится запрос, который упрётся в разбор всего массива на стороне приложения.

Когда одна колонка лучше нормализации

Денормализация выигрывает при совпадении пяти условий: значение читается и пишется целиком одним запросом; структура разнородна и меняется чаще, чем схема таблиц; элементов десятки, а не миллионы; условия WHERE и JOIN по отдельным элементам не нужны; ссылочную целостность элементов проверять не требуется. Настройки интерфейса, черновик формы, конфиг агента мониторинга под эти условия подходят.

Обратный пример: список тегов статьи. Запрос «все статьи с тегом bigdata» по JSONB работает через GIN-индекс и на сотнях тысяч строк проигрывает таблице-связке, потому что индекс GIN по массиву отдаёт кандидатов, а затем требует перепроверки строк. Компромиссную схему и чек-лист выбора описывает статья о нормализации и денормализации при хранении массивов.

Нативные типы СУБД против сериализации вручную

PostgreSQL даёт три варианта, и каждый закрывает свой случай. ARRAY принимает элементы одного типа, поддерживает операторы @> и &&, индексируется GIN. JSONB хранит разобранное дерево, понимает операторы ->, #>, @> и индексируется GIN. bytea принимает произвольные байты и структуру не понимает вообще.

MySQL предлагает тип JSON с валидацией документа, функциями JSON_EXTRACT и JSON_TABLE и многофункциональными индексами по выражениям. В MariaDB тип JSON объявлен как алиас LONGTEXT с проверкой CHECK, и индексировать поля документа там нельзя. Примеры миграции и ограничения версий собраны в материале про хранение массивов в MySQL: JSON, сериализация и нормализация.

Собственный кодек нужен, когда запросы внутрь структуры не планируются, а размер и скорость стоят первыми. Тогда колонка объявляется как bytea или BLOB, а внутрь кладётся MessagePack, Protobuf или сжатый JSON.

Обзор форматов: JSON, MessagePack, Protobuf, PHP serialize, pickle и BLOB

Шесть вариантов различаются по четырём признакам: наличие схемы, читаемость человеком, привязка к одному рантайму и плотность упаковки.

ФорматТипСхемаЧитаемостьПереносимость
JSON, JSONBтекст и бинарный документнетполная, видно в psqlлюбой язык
MessagePackбинарныйнетнужен декодервсе популярные языки, с нюансами
Protobufбинарныйесть, файл .protoнужен декодер и схемавсе популярные языки
PHP serializeтекст с бинарными вставкаминеявная, имена классовчастичнотолько PHP
pickleбинарныйнеявная, имена классовнеттолько Python
BLOB, byteaконтейнер для байтзависит от содержимогоhex-дампнейтрален, но формат надо знать

Отдельный путь для векторов в PostgreSQL: расширение pgvector добавляет тип vector и индексы HNSW или IVFFlat для поиска по косинусной дистанции. Размерность столбца должна совпадать с длиной векторов, которые выдаёт модель эмбеддингов; векторы разных моделей сравнивать между собой нельзя, результат получится бессмысленным. HNSW и IVFFlat дают приближённый поиск, точный результат выходит только при чтении всей таблицы без векторного индекса. Эмбеддинги удобно получать через агрегатор API нейросетей AiTunnel, если в проекте несколько моделей и нужен единый интерфейс с оплатой в рублях.

Текстовые форматы: JSON и его производные

JSON читается человеком, парсится любым языком, отлаживается прямо в psql. Операторы ->, #> и @> достают поля и проверяют вложенность, GIN-индекс ускоряет поиск по ключам и значениям, запросы вида «найти документ, где в массиве есть элемент с полем type = email» решаются без выгрузки строк в приложение. Слабые места: размер, потеря типов (числа становятся double или int64, десятичные дроби теряют точность), даты хранятся строкой без проверки формата.

JSONB в PostgreSQL хранит разобранное дерево: чтение ускоряется, вставка замедляется из-за разбора, а размер обычно сопоставим с текстовым JSON или чуть больше, поскольку ключи выносятся в отдельную структуру. Сжатие меняет картину: gzip или zstd поверх JSON уменьшают объём в 3-6 раз на текстовых данных, но сжимать и распаковывать приходится на стороне приложения.

Бинарные форматы без схемы: MessagePack

MessagePack кодирует те же типы, что JSON, но в байтах: целые числа переменной длины, строки с префиксом длины, отсутствие повторяющихся имён полей. Экономия на типичных структурах составляет 1.5-3 раза, а на массивах однотипных чисел достигает 3-4 раз. Декодирование в тестах на Go и Python ускоряется в 2-5 раз по сравнению с текстовым JSON.

Схемы нет. Опечатка в имени ключа всплывёт в момент чтения, а не при записи, а несовпадение типов приведёт к ошибке в рантайме. Формат подходит для кэша, payload очередей, обмена между сервисами на одном стеке.

Бинарные форматы со схемой: Protobuf

Protobuf требует описания сообщения в файле .proto. Каждому полю присваивается номер, и именно номер, а не имя, уходит в байты. Размер меньше текстового JSON в 3-10 раз, у структур с повторяющимися именами полей выигрыш максимален. Номера задают эволюцию схемы: добавленное поле с новым номером старые читатели игнорируют, удалённые номера резервируют через reserved, чтобы их не заняли случайно.

Цена: кодогенерация для каждого языка и пересборка при изменении схемы. Хранению в БД это не мешает: файл .proto служит контрактом и для gRPC, и для колонки bytea.

Форматы, привязанные к рантайму: PHP serialize и pickle

PHP serialize хранит типы PHP, включая имена классов в строке вида O:8:«UserData»:1:{...}. pickle делает то же для Python и записывает полное имя модуля и класса. Оба формата кодируют структуру класса, а не набор данных, поэтому переименование класса, перенос в другой модуль или смена полей ломают чтение старых записей.

Скорость у обоих сопоставима с JSON, а на маленьких словарях pickle в Python быстрее за счёт отсутствия разбора текста. Размер близок к JSON, у pickle иногда меньше из-за бинарных чисел и отсутствия имён полей в повторяющихся структурах.

BLOB и bytea как контейнер, а не формат

BLOB в MySQL и bytea в PostgreSQL - это тип колонки для произвольных байт. Внутри может лежать gzip-сжатый JSON, Protobuf, MessagePack, изображение или архив. Формат определяет не тип колонки, а код, который кладёт и достаёт значение, и эту связь нужно фиксировать документацией или полем с именем формата.

PostgreSQL выносит значения крупнее примерно 2 КБ в TOAST-таблицу и сжимает их алгоритмом pglz или lz4. Индекс на bytea имеет смысл только хеш-или GIN-по-триграммам; обычный B-tree по большим блобам бесполезен и занимает место. В MySQL длинные значения уходят на overflow-страницы, а формат строки DYNAMIC хранит в основной странице 20-байтовый указатель.

Сравнение по размеру на диске и скорости кодирования

Порядок величин на наборе из 1000 объектов с пятью полями (строки, числа, вложенный список из пяти значений), где текстовый JSON весит около 90 КБ:

ФорматРазмер относительно JSONКодированиеДекодирование
JSON, текст100%1x1x
JSONB90-110%медленнее на 10-30% из-за разборабыстрее при выборках по ключам
MessagePack40-65%в 1.5-3 раза быстреев 2-5 раз быстрее
Protobuf20-40%в 2-4 раза быстреев 3-6 раз быстрее
gzip(JSON)15-30%в 5-20 раз медленнеезависит от I/O
PHP serialize70-110%сопоставимо с JSONсопоставимо с JSON
pickle60-100%сопоставимо с JSONв Python часто быстрее JSON

Разброс зависит от состава данных. На текстовых полях выигрыш бинарных форматов максимален, поскольку JSON повторяет имя каждого поля в каждом объекте. На массивах float и int64 выигрыш меньше: JSON пишет числа цифрами, что лишь в 2-3 раза дороже машинного представления. Protobuf дополнительно экономит на повторяющихся строках, если задействован словарь строк.

На средних payload'ах (10-100 КБ) разрыв в скорости декодирования составляет 2-5 раз в пользу Protobuf и MessagePack. На значениях до 200 байт разрыв сокращается: накладные расходы на вызовы библиотек сравниваются с самим разбором. gzip(JSON) на записи в 5-20 раз медленнее обычного JSON, а на чтении проигрыш компенсируется меньшим объёмом I/O, если данные идут с диска, а не из кэша страниц.

Как измерить размер и скорость на своём наборе данных

Синтетические данные дают нерепрезентативные результаты, поэтому берите реальный массив из продакшена. Порядок действий:

  1. Выгрузите 1000-5000 значений одного типа, которые записываются в колонку, без персональных данных.
  2. Закодируйте выборку в каждый формат и запишите длину результата в байтах.
  3. Загрузите значения в тестовую таблицу и сравните pg_column_size(payload) с octet_length(payload): первая цифра показывает размер после сжатия и TOAST, вторая - исходный объём.
  4. Замерьте время 10 000 циклов encode и decode для каждого формата, отдельно для записи в базу и для чтения из неё.
  5. Повторите тест на той версии библиотеки, которая стоит в проекте: msgpack и protobuf в разных языках отличаются по скорости в разы.

Учесть стоит и то, что узкое место часто не в формате, а в количестве обращений к базе: способы убрать лишние запросы разбирает материал про ускорение загрузки данных из базы: паттерны и решения.

Влияние TOAST и сжатия в PostgreSQL

Значения длиннее примерно 2 КБ PostgreSQL сжимает и выносит в TOAST-таблицу чанками по 2 КБ. Практические следствия: gzip на стороне приложения может не дать видимого выигрыша на диске, если СУБД уже сжала текстовый JSON; сжатый gzip-поток pglz почти не уменьшает, и в TOAST он ложится почти в исходном размере; извлечение таких значений требует дополнительного чтения, что заметно на выборках большими пачками.

Сравнивать форматы корректно по pg_column_size для колонки и pg_total_relation_size для таблицы с индексами, потому что GIN-индекс на JSONB занимает отдельное место и на больших объёмах может превышать размер самой таблицы. В MySQL аналогичную роль играет off-page storage для BLOB и формат строки: DYNAMIC держит указатель в основной странице, COMPRESSED дополнительно ужимает данные, но требует больше CPU. Общие принципы выбора носителя и оценок по IOPS и задержкам разобраны в статье про хранение данных в 2026: типы и способы организации.

Проверить цифры на управляемой базе без сборки стенда быстрее: Timeweb Cloud выдаёт PostgreSQL, объектное хранилище и VDS за несколько минут, и на таком инстансе удобно прогнать замеры на реальном объёме.

Безопасность: риски десериализации недоверенных данных

Разница между форматами в безопасности принципиальна. JSON, MessagePack и Protobuf при разборе только строят значения и код приложения не вызывают. pickle и PHP unserialize исполняют методы, описанные в самих данных, поэтому десериализация подменённого потока даёт выполнение произвольного кода.

Object injection в PHP и pickle в Python

В PHP строка сериализации может описывать объект произвольного класса. При вызове unserialize движок создаёт объект и вызывает __wakeup, а при уничтожении объекта - __destruct. Если в проекте или в подключённой библиотеке есть класс, чей __wakeup или __destruct выполняет файловые операции, удаление файлов, HTTP-запрос или запись в лог, злоумышленник собирает цепочку и получает чтение файлов или выполнение кода.

В Python pickle.loads доверяет инструкции GLOBAL, которая импортирует модуль по имени из потока, и методу __reduce__, определяющему, как объект собирается при загрузке. Достаточно, чтобы поток ссылался на функцию вида os.system, и она выполнится в момент загрузки. Это подтверждённый класс уязвимостей: CVE по небезопасной десериализации в Python и PHP выходят годами, потому что исправлять приходится не библиотеку, а архитектуру приложения.

Правила безопасного хранения сериализованных данных

  • Не вызывайте pickle.loads и unserialize для значений, которые могли прийти от пользователя, из очереди с внешними продюсерами или из импортируемого файла.
  • Для внешних данных используйте JSON и проверяйте структуру по схеме (JSON Schema, pydantic, валидаторы Go) до записи в базу.
  • Ограничьте права пользователя БД: приложению нужны только SELECT, INSERT, UPDATE по конкретным таблицам, без прав на другие схемы.
  • Проверяйте размер и тип значения перед разбором: pickle-поток или строка unserialize на пользовательские данные видны по первым байтам и по аномальной длине.
  • При миграции с pickle на JSON или Protobuf перекодируйте данные в контролируемом окружении, читая старый формат только в изолированном процессе со старыми версиями библиотек.

Отдельный риск создаёт JSON: разбор сам по себе безопасен, но приложение может доверять типам из документа. Прототипное загрязнение в JavaScript и подстановка путей в ключах встречаются там, где JSON принимают без проверки схемы.

Совместимость форматов при обновлении приложения

Деплой новой версии не должен ломать чтение старых записей. У форматов разная устойчивость к изменениям структуры.

Эволюция схемы в Protobuf

Правила проверены временем: номера существующих полей не меняют; удалённые номера резервируют через reserved, чтобы их не заняли случайно; новые поля добавляют с новыми номерами; отсутствующее поле получает значение по умолчанию, и код обязан это учитывать. Добавление поля email в сообщение User не мешает старому коду читать запись: неизвестный тег он пропустит. Повторяющиеся поля остаются совместимыми при добавлении элементов.

Версионирование JSON и MessagePack

Схемы нет, поэтому версию хранят явно: колонка schema_version рядом с payload или поле version внутри документа. Код читает версию, выбирает парсер и переводит старую структуру в новую. Переход с одного поля name на пару first_name и last_name делают в три шага: пишут оба представления, читают новое при его наличии, затем удаляют устаревшее поле. Для MessagePack логика та же, разница в том, что валидации типов нет и несовпадение числа полей в позиционных структурах всплывёт в рантайме.

Почему pickle и PHP serialize ломаются при рефакторинге

pickle хранит полный путь модуля и имя класса. Перенос класса в другой модуль, переименование, добавление обязательного поля в __init__ или смена базового класса делают старые записи нечитаемыми. PHP serialize ведёт себя так же: имена классов и свойств уходят в строку, приватные свойства кодируются с префиксом класса. Единственный безопасный сценарий для этих форматов - короткоживущие данные: кэш с TTL в минуты, очередь, которую можно пересоздать, сессия, живущая до перезапуска сервиса.

Миграцию между форматами проводят без простоя: сначала включают двойную запись, затем переносят чтение на новый формат с откатом на старый при ошибке разбора, потом перекодировывают записи пакетами, и только после сверки отключают старый путь.

Переносимость между языками и сервисами

JSON читается любым языком и почти любой библиотекой без настроек. MessagePack поддерживается в Go, Python, PHP, Node.js, Java, C#, Rust, но поведение реализаций различается. Protobuf требует файла .proto и кодогенерации, зато один и тот же файл даёт одинаковую структуру во всех языках. PHP serialize читается только из PHP, pickle - только из Python и только совместимой версией интерпретатора. BLOB нейтрален, но требует знания формата внутри.

Нюансы кросс-языковой совместимости MessagePack

Расхождения, которые ловят на интеграционных тестах: беззнаковые целые больше 2^63 в одних библиотеках кодируются как uint, в других падают с ошибкой; тип timestamp передают как extension, и не все библиотеки его поддерживают; ключи нестроковых типов ведут себя по-разному в Python и Go; строки в UTF-8 при некорректной кодировке часть реализаций отвергает, часть заменяет символ. Зафиксируйте версию библиотеки во всех сервисах и покройте кросс-языковое чтение парой тестов на фикстуре в бинарном виде.

Protobuf как контракт между сервисами

Один файл .proto служит и описанием методов gRPC, и схемой сообщений, которые ложатся в базу. Изменения проходят через ревью схемы, а не через правки кода, поэтому конфликтные изменения видны до деплоя. Плата - инфраструктура генерации, версионирование самих протоколов и аккуратность с импортами, из-за которых сборка ломается чаще, чем чтение данных.

Рекомендация по переносимости проста: данные будут читать сервисы на разных языках - берите Protobuf или JSON; читает один сервис - допустим MessagePack; данные должны оставаться читаемыми через пять лет - исключите pickle и PHP serialize.

Читаемость и отладка: когда важно видеть данные глазами

JSON и JSONB видны в psql напрямую: SELECT payload -> 'items' -> 0 извлекает элемент, и на инциденте это экономит минуты. MessagePack и Protobuf требуют декодера: подключаться к продакшену ради диагностики обычно нельзя, поэтому держат CLI-утилиту, которая читает дамп из байтовой колонки и печатает JSON. BLOB без знания формата даёт только hex-дамп длиной в килобайты.

Практический компромисс: критичные для разбора инцидентов данные держать в JSONB, объёмные и редко диагностируемые payload'ы - в MessagePack или Protobuf. Полезно писать рядом с бинарным значением короткую текстовую сводку: версию формата, число элементов, контрольную сумму, размер до сжатия. Такая сводка не заменяет данные, но показывает, чего ждать от записи, без её декодирования.

Практические критерии выбора формата

Сводка по сценариям, которые встречаются чаще всего в работе с PostgreSQL, MySQL и очередями:

ЗадачаФорматПочему
Конфиги и настройки, которые нужно читать глазамиJSONBВидно в psql, индексируется GIN, схема меняется без миграций
Данные, по которым идут фильтры и JOINНормализация или JSONB с GINРазбор массива в приложении не масштабируется
Кэш с TTLMessagePack или pickleДанные внутренние, живут минуты, пересоздаются при промахе
Payload очереди задачMessagePack или ProtobufКомпактно и быстро, схема задана контрактом
Межсервисный обмен и gRPCProtobufОдин файл .proto на все языки, номера полей дают эволюцию
ML-эмбеддинги в PostgreSQLpgvector, тип vectorПоиск по косинусной дистанции с HNSW или IVFFlat
Данные одного сервиса, важна скоростьMessagePack в byteaДекодирование в 2-5 раз быстрее JSON без кодогенерации
Данные, которые читают разные языки и версииProtobuf или JSONСтрогий контракт либо полная переносимость
Данные из внешних источниковJSON с валидацией схемыРазбор не исполняет код, схема ловит лишние поля
Данные с неясным сроком жизниИзбегайте pickle и PHP serializeРефакторинг классов ломает чтение старых записей

Если выбор идёт на уровне платформы, а не только формата, ориентируйтесь на поддерживаемые типы и расширения: сравнение PostgreSQL, MySQL, MongoDB, Redis и Cassandra с чек-листом собрано в статье про выбор СУБД и организацию хранения данных.

Чек-лист типичных ошибок

  • pickle или PHP serialize в колонке для данных, которые проходили через пользовательский ввод, импорт файла или внешний продюсер очереди.
  • JSON для бинарных данных без необходимости: base64 добавляет 33% к размеру ещё до сжатия.
  • Отсутствие версии схемы: любое изменение структуры требует ручного разбора старых записей в проде.
  • Индекс на большой бинарной колонке: B-tree по bytea почти бесполезен, а место занимает.
  • Хранение эмбеддингов в bytea или BLOB там, где рядом доступен pgvector с типом vector и индексом HNSW.
  • Оценка размера без учёта TOAST и сжатия PostgreSQL: реальный объём на диске может отличаться в разы.
  • Миграция формата без двойной записи и сверки: откат возможен только из бэкапа.
  • Экономия на размере там, где работает кэш страниц: если payload помещается в shared_buffers, выигрыш от бинарного формата на чтении почти незаметен.

Начните с одного замера: возьмите реальный массив из прод-таблицы, запишите его в четыре формата, сравните pg_column_size и время 10 000 декодирований. Этот результат важнее любой сводной таблицы, включая приведённую выше, потому что зависит от состава ваших данных и версий библиотек.

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