У нас был большой продакшен-сервис с ~10M MAU, где Redis использовался как основное хранилище состояния пользователей. Все данные лежали в нём в виде JSON-сериализованных Pydantic-моделей. Это выглядело удобно, пока не стало больно.

На определённом этапе мы выросли до Redis Cluster из пяти нод — и он всё равно задыхался по памяти. JSON-объекты раздувались в разы относительно полезных данных, и мы платили за тонны пустоты — буквально деньгами и деградацией.
Я посчитал сколько весят реально полезные данные и получил цифру, от которой понял, что так жить больше нельзя:
14000 байт на пользователя в JSON → 2000 байт в бинарном формате
Семикратная разница. Только из-за формата.
Вот тогда я и написал решение, которое теперь превратилось в небольшую библиотеку: PyByntic — бинарный encoder/decoder для Pydantic-моделей. И ниже — история того, как я к этому пришел.
Почему JSON стал проблемой
JSON хорош как универсальная валюта обмена. Но в низкоуровневом кэше он — прожорливый монстр:
хранит ключи целиком
хранит типы косвенно (как строки)
дублирует структуру
не оптимизирован под бинарные данные
inflate на уровне RAM: данные 3–10× больше, чем должны быть
Когда у тебя десятки миллионов объектов в Redis — это не абстрактная неэффективность, это счёт на реальные деньги и лишние сервера в кластере.
Какие альтернативы я пробовал (и почему они не зашли)
Я честно проверил все очевидные варианты:
Формат |
Проблема в нашем кейсе |
|---|---|
Protobuf |
слишком замороченный, отдельные схемы, генерация, кодген, лишняя боль |
MessagePack |
компактнее JSON, но недостаточно, и интеграция с Pydantic всё равно пляски |
BSON |
размер лучше JSON, но интеграция с Pydantic всё равно не удобная |
Все эти форматы хорошие сами по себе. Но в точечном сценарии «Pydantic + Redis как state storage» они похожи на стрельбу из пушки по воробьям – сложно, шумно, а облегчения почти нет.
Мне нужно было решение, которое:
встраивается в текущий код за пару строк
даёт радикальный выигрыш по памяти
не требует отдельного DSL, схем или генерации кода
работает строго с Pydantic-моделями, не ломая экосистему
Что я сделал
Я написал минималистичный бинарный формат + encoder/decoder поверх аннотированных Pydantic-моделей. Так появилась библиотека PyByntic.
Её API специально сделан так, чтобы можно было просто заменить вызовы:
model.serialize() # вместо .json()
Model.deserialize(bytes) # вместо .parse_raw()
Пример:
from pybyntic import AnnotatedBaseModel
from pybyntic.types import UInt32, String, Bool
from typing import Annotated
class User(AnnotatedBaseModel):
user_id: Annotated[int, UInt32]
username: Annotated[str, String]
is_active: Annotated[bool, Bool]
data = User(
user_id=123,
username="alice",
is_active=True,
)
raw = data.serialize()
obj = User.deserialize(raw)
Опционально можно задать функцию для дополнительной компрессии:
import zlib
serialized = user.serialize(encoder=zlib.compress)
deserialized_user = User.deserialize(serialized, decoder=zlib.decompress)
Сравнение
Для сравнения я создал 2 миллиона записей пользователей на основе наших продакшн моделей. У пользователей есть различные поля: UInt16, UInt32, Int32, Int64, Bool, Float32, String, DateTime32. Так же в каждом пользователе есть вложенные объекты – роли и пермишены, в некоторых случаях пермишенов могут быть сотни.

На графике видно, сколько памяти в Redis занимают 2 000 000 пользовательских объектов при разных форматах сериализации. JSON взят за базу (≈35.1 GB). PyByntic оказался самым компактным – всего ~4.6 GB (13.3% от JSON), что в 7.5 раз меньше. Protobuf и MessagePack тоже заметно выигрывают у JSON, но по абсолютным значениям всё равно сильно уступают PyByntic.
Откуда такая экономия?
Основная экономия места достигается за счёт двух вещей: отсутствия текстового формата и устранения повторяющейся структуры. В JSON обычно хранятся как строки "1970-01-01T00:00:01.000000" — это 26 символов, а каждый ASCII-символ = 1 байт = 8 бит, то есть одна дата занимает 208 бит. В бинарном же виде DateTime32 — это всего 32 бита, что в 6.5 раза компактнее, без всякого форматирования и суффиксов. То же самое происходит и с числами: например, 18446744073709551615 (2^64−1) в JSON занимает 20 символов = 160 бит, а в бинарном представлении — ровно 64 бита. И, наконец, JSON повторяет имена полей для каждого объекта, снова и снова, тысячи и тысячи раз — в бинарной схеме структура известна заранее, поэтому хранить её в каждом экземпляре просто не нужно. Именно на этих трёх механиках и происходит основной выигрыш в размере.
Вывод
Если вы живёте в Pydantic и храните состояние в Redis, то JSON — это роскошь, за которую вы платите RAM-налог.
Бинарный формат, совместимый с существующими моделями — гораздо логичнее.
PyByntic для нас стал именно такой «логичной оптимизацией», которая ничего не ломает, но снимает целый пласт проблем.
GitHub репозиторий проекта
Комментарии (28)

andreymal
28.10.2025 21:46"1970-01-01T00:00:01.000000"DateTime32— это всего 32 битаТо есть вы потеряли микросекунды и получили проблему 2038 (или 2106) года?

sijokun Автор
28.10.2025 21:46В библиотеке есть DateTime64 – это основная суть библиотеки, возможность самому выбрать максимально точно сколько байт тебе нужно для твоих данных.
В моем случае я не думаю, что структура которая сегодня сохраняется на пару часов в редисе будет актуальна через сколько-то там десятков лет.
Типы DateTime и Date полностью позаимстованы из ClickHouse, со всеми их минусами и плюсами.

user-book
28.10.2025 21:46Присоединюсь, что то вы очень странное за протобаф написали. Может вы просто не умеете их готовить? Все ж таки протобафы пришли из си и для правильной работы с ним нужно понимать за память и типы данных. Правильная инициализация, в нужном порядке и тд.
Что то мне кажется что вы там насоздавали велосипедов, напрямую переведя бесконечное json-дерево в итерируемые вложенные протобафы, не сильно заморачиваясь типами, вот и получили что получили.
Погуглил за то что вы использовали - это просто по сути найтивная либа что все делает за вас. То есть действительно, вы просто криво использовали протобаф, иначе бы разница меж вашим текущим решением была б минимальная по памяти, а не такая огромная.
У протобафов есть свои проблемы, тут я не буду спорить (те же bool которые занимают байт) но прото остается до сих пор самым широко используемым мировым grpc стандартом для связи как на уровне сообщений (запросов) так и на уровне методов (собственно grpc)

Deosis
28.10.2025 21:46Скорее всего отказались от версионирования, и при замене одного поля этот блоб превратится в тыкву.

debagger
28.10.2025 21:46А вы пробовали просто писать JSON в zip? Подозреваю, что выигрыш мог бы получиться сопоставимый с вашим решением.

sijokun Автор
28.10.2025 21:46Average size PyByntic: 2157.96 bytes
Average size PyByntic+gzip: 1178.82 bytes
Average size JSON+gzip: 2364.51 bytes
Сжатый JSON все еще хуже PyByntic, а если сжать PyByntic, то разница в два раза. Ну и CPU вы на gzip туда сюда будете заметно больше тратить, чем на запись и чтение байт.
VADemon
28.10.2025 21:46Есть другие (быстрые) алгоритмы сжатия, которые для swap используются, например. Но в целом, согласен, что они тут не к месту.

Politura
28.10.2025 21:46О, сам решал похожую задачку по уменьшению данных для кэша в Редисе лет 10 назад (правда c#, а не Python), тогда сжатие данных съедало слишком много ЦПУ и плохо влияло на производительность, интересно как сейчас с этим обстоит дело?

Deosis
28.10.2025 21:46Если уж опустились на уровень битов, то почему бы не сравнить с традиционными БД, которые могут жать временные ряды до нескольких битов на отметку?

sijokun Автор
28.10.2025 21:46Раскрою секрет: Все типы моей библиотеке взяты и полностью совместимы с нативным форматом ClickHouse.
Ради интереса можно даже бинарные даты от SELECT ... FORMAT Native ей распарсить (нужно оставить только сами данные без хедера).

The_Answer
28.10.2025 21:46Добавлю к отписавшимся выше: protobuf-ом пользоваться не сильно сложнее чем json-ном, один раз разобраться с простым синтаксисом для написания .proto a для кодогенерации у популярных либ обычно несложный api. Единственно что бесит это когда кастомные типы завернуты в Option<T> (пишу на Rust), и без понимания всей архитектуры может начаться холивар - вводить слой валидации либо размазывать обработку ошибок по логике приложения, etc.

sijokun Автор
28.10.2025 21:46Проблема в том, что приходится поддерживать схему в двух местах – Pydyntic с которым работаем внутри питона и одельно .proto схему + кодген по нему. Плюс PyByntic в том, что все поля задаются один раз в одном месте.
Для сложных систем, где много микросервисов протобаф конечно удобнее, можно передать схему другому разработчику и он сам в своем языке с ней разберетс. Цель моего проекта – эффективно кэшировать объекты в рамках одного сервиса.

danilovmy
28.10.2025 21:46Люблю Habr. За инициативу в OSP, пусть даже наивную, могут легко в панамку напихать. Хотя, кмк, надо такие инициативы холить, люлеить и всячески поддерживать.
Я очень приветствую попытки в OSP, и рад, что очередной разработчик таки осмелился на кодовый эксгибиционизм :) , который может стать полезным, при определенных условиях. Потому - @sijokun все нормально ты делаешь. Просто учти замечания из комментариев, там и правда по делу есть:
Потеря TZ или микросекунд это прям очень не хорошо: вот я очаровался идеей проекта PyБантик, применяю его у себя... и мой клиент начал резко по пасифику логиниться. Но данные меняться не должны были, и таких подстав я не жду от сторонней библиотеки.
Со стороны внутрянки кода, например, непонятно, зачем используется "function composition" в
def dump()и почему методыdef is_buffer_readable; def _is_buffer_empty;a неproperty, и зачем название дублирует имя классаBuffer.is_buffer_readable(); Buffer._is_buffer_empty(),когдаBuffer.readable, Buffer.emptyУспехов в дальнейшем развитии проекта!

sijokun Автор
28.10.2025 21:46Спасибо!
Библиотека пока в ранней версии, поэтому 0.1.3, а не 1.0.0. Изначально она была сделана ASAP за пару ночей для срочного решения задачи, я попробовал привести до публикации ее в более приличный вид, но еще не все исправил. Комментарии читаю, записываю на листочек и буду делать – так же приветствую пулл реквесты.

TIEugene
28.10.2025 21:46Есть подозрение, что поддержка этого велосипеда (в ресурсах - деньгах и времени) обойдется в сопоставимое с 7 раз больше того же protobuf.

sijokun Автор
28.10.2025 21:46Первая версия кода была сделана в феврале, после этого этот же код был партирован в другой наш проект. Пока полет нормальный и все это время жили на том, что было написано тогда в режиме ASAP. Я не писал статью сразу – велосипед как вино, ему нужно настоятся и оправдать себя.
Многих фичей в изначальной версии не было, например Nullable, они нам были не нужны. Для публичной версии я добавил побольше типов, скоро планирую еще Variant добавить, чтобы уж совсем универсально стало.

kmosolov
28.10.2025 21:46Когда-то похожую проблему решил использованием DER (Distinguished Encoding Rules, ITU-T X.690), судя по содержимому проекта на GitHub автор изобрёл довольно похожий формат.
whocoulditbe
За счёт чего вы выиграли почти 3 гигабайта в размере против протобафа? Хотелось бы посмотреть на код бенчмарка, уж слишком хорошо ваш график выглядит для того, чтобы быть правдой.
sijokun Автор
https://github.com/sijokun/PyByntic/tree/test_protobuf_vs_pybyntic/protobuf_vs_pybyntic
Модель юзеров взята из реального хайлоуд проекта, я лишь убрал и переименовал часть полей, чтобы уж не совсем было явно откуда.
PyByntic – 2300 байт на юзера
Protobuf – 3500 байт на юзера
Json – порядка 10+ тысяч байт на юзера
В тесте данные рандомизируются, от запуска к запуску могут быть немного разные цифры, если не лень можете прогнать с большим семплом. В статье был тест на базе этой же модели юзера, я честно забыл какой там был параметр о количестве итемов/тасков на юзера, помню, что в районе 100, вроде с такими параметрами данные +- как в статье.
За счет чего выигрыши:
1. Вообще не сохраняется структура данных, использован абсолютный возможный минимумом байтов. Это совершенно не подходит для долгого хранения данных, например на диске, так как в байтах не сохраняется даже версия модели (это в планах добавить), но для хранения кэша в Редисе это не является большой проблемок.
2. В теории PyByntic лучше подходит для сжатия, потому что одинаковые поля хранятся рядом.
Например:
После сериализации структура становится колоннообразной:
Затем сохраняются только сами компактные бинарные данные. Поскольку все повторяющиеся длинные описания расположены в памяти подряд, а не разбросаны среди объектов, алгоритмы сжатия могут работать гораздо эффективнее.
whocoulditbe
Не лень.
https://github.com/sijokun/PyByntic/blob/b33ea3e242f87ed246a33754f20de3dc62efa8f8/protobuf_vs_pybyntic/models.py#L20 - размер неправильный (
UInt16), в user.protouint32 item_id = 1;.DateTime32 потерял зону - было
2106-02-07 06:28:15, стало2106-02-07 03:28:15+00:00.Честно говоря, не очень понял вот эту строчку. В протобафе не видел каких-то там разбросов, объекты ровно в том порядке, в котором должны быть: https://protogen.marcgravell.com/decode. Похоже, что у вас выигрыш за счёт того, что протобаф всегда пишет тип данных и их длину перед ними, а вы только длину.
Данные для примера:
Pybyntic:
Protobuf:
На этих данных ваша сериализация выиграла 7 байт (184 против 191 после правки размера item_id на UInt32). Почему protobuf не делает так же?
remzalp
Потому что он добавляет метаданные и можно исторические данные с предыдущей версией схемы успешно обработать
sijokun Автор
Я планирую в начале модели записывать один байт версии и дальше в нотации добавить возможность у каждого поля в питоне задавать минимальную и максимальную версию. Это решит часть проблем с разными версиями, но глобально для кэша это не особо критично.
sijokun Автор
В Protobuf нет ничего ниже uint32: документация.
В протобаф весь нестед объект лежит подряд, вот каждый итем, сначала его ид, потом тип, потом остальные поля и так по кругу. Я же раскрываю в "колонки", будут сначала все айди нестед итемов подряд, потом все типы и т.д. Если, например, одно текстовое поле с одинаковым текстом "text" повторяется в каждом итеме, то в моем формате будет texttexttexttexttext, для алгоритмов компрессии это выгоднее. В бенчмарке мы справниваем без компрессии, поэтому к нему это не относилось, а просто комментарий в общем о формате.
Зоны мы не сохраняем, но планируется добавить опцию timezone-aware. Это можно через кастомный тип сделать. Для нас этой необходимости небыло, мы на бэкенде храним все даты в UTC.