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

% снижения размера объекта в памяти по сравнению с JSON
% снижения размера объекта в памяти по сравнению с JSON

На определённом этапе мы выросли до 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» они похожи на стрельбу из пушки по воробьям – сложно, шумно, а облегчения почти нет.

Мне нужно было решение, которое:

  1. встраивается в текущий код за пару строк

  2. даёт радикальный выигрыш по памяти

  3. не требует отдельного DSL, схем или генерации кода

  4. работает строго с 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. Так же в каждом пользователе есть вложенные объекты – роли и пермишены, в некоторых случаях пермишенов могут быть сотни.

Сравнение JSON vs PyByntic vs MessagePack vs BSON vs Protobuf
Сравнение JSON vs PyByntic vs MessagePack vs BSON vs Protobuf

На графике видно, сколько памяти в 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)


  1. whocoulditbe
    28.10.2025 21:46

    За счёт чего вы выиграли почти 3 гигабайта в размере против протобафа? Хотелось бы посмотреть на код бенчмарка, уж слишком хорошо ваш график выглядит для того, чтобы быть правдой.


    1. sijokun Автор
      28.10.2025 21:46

      https://github.com/sijokun/PyByntic/tree/test_protobuf_vs_pybyntic/protobuf_vs_pybyntic

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

      PyByntic – 2300 байт на юзера
      Protobuf – 3500 байт на юзера
      Json – порядка 10+ тысяч байт на юзера

      В тесте данные рандомизируются, от запуска к запуску могут быть немного разные цифры, если не лень можете прогнать с большим семплом. В статье был тест на базе этой же модели юзера, я честно забыл какой там был параметр о количестве итемов/тасков на юзера, помню, что в районе 100, вроде с такими параметрами данные +- как в статье.


      За счет чего выигрыши:
      1. Вообще не сохраняется структура данных, использован абсолютный возможный минимумом байтов. Это совершенно не подходит для долгого хранения данных, например на диске, так как в байтах не сохраняется даже версия модели (это в планах добавить), но для хранения кэша в Редисе это не является большой проблемок.
      2. В теории PyByntic лучше подходит для сжатия, потому что одинаковые поля хранятся рядом.

      Например:

      class Tag:
          description = "some long text"
          id = 1
      
      class Post:
          text = "long text"
          tags: list[Tag]
      
      tags = []
      for i in range(1000):
          tags.append(Tag(description="very long description", id=i))
      
      post = Post(text="test", tags=tags)
      

      После сериализации структура становится колоннообразной:

      post.text = "long text"
      post.tags.description = ["some long text"] * 1000
      post.tags.id = [1, 2, .... 1000]

      Затем сохраняются только сами компактные бинарные данные. Поскольку все повторяющиеся длинные описания расположены в памяти подряд, а не разбросаны среди объектов, алгоритмы сжатия могут работать гораздо эффективнее.


      1. whocoulditbe
        28.10.2025 21:46

        Не лень.

        $ time python3 test_pybyntic.py 
        Average serialized size over 100000 samples: 2960.68 bytes
        real	1m55.465s
        user	1m55.448s
        sys	0m0.012s
        
        $ time python3 test_protobuf.py 
        Average serialized size over 100000 samples: 3495.20 bytes
        real	1m13.383s
        user	1m13.328s
        sys	0m0.044s
        

        https://github.com/sijokun/PyByntic/blob/b33ea3e242f87ed246a33754f20de3dc62efa8f8/protobuf_vs_pybyntic/models.py#L20 - размер неправильный (UInt16), в user.proto uint32 item_id = 1;.

        DateTime32 потерял зону - было 2106-02-07 06:28:15, стало 2106-02-07 03:28:15+00:00.

        Поскольку все повторяющиеся длинные описания расположены в памяти подряд, а не разбросаны среди объектов

        Честно говоря, не очень понял вот эту строчку. В протобафе не видел каких-то там разбросов, объекты ровно в том порядке, в котором должны быть: https://protogen.marcgravell.com/decode. Похоже, что у вас выигрыш за счёт того, что протобаф всегда пишет тип данных и их длину перед ними, а вы только длину.

        Данные для примера:

        CMDEBxIJZmlyc3ROYW1lGghMYXN0TmFtZSDAxAcolse9KDDj3rpKOQAAAAAAQb9AQiEIoLcBEgVl
        bGl0ZRjEICCa0gktz9X//zXP1f//Pc/V//9CIQigtwESBWVsaXRlGMQgIJrSCS3P1f//Nc/V//89
        z9X//0IhCKC3ARIFZWxpdGUYxCAgmtIJLc/V//81z9X//z3P1f//SgV0YXNrMUoFdGFzazJKBXRh
        c2szUggweDFhc2RnZ13P1f//YHs=
        

        Pybyntic:

        00000090: cfd5 ffff 0305 7461 736b 3105 7461 736b  ......task1.task
        000000a0: 3205 7461 736b 3308 3078 3161 7364 6767  2.task3.0x1asdgg
        

        Protobuf:

        00000090: cfd5 ffff 3dcf d5ff ff4a 0574 6173 6b31  ....=....J.task1
        000000a0: 4a05 7461 736b 324a 0574 6173 6b33 5208  J.task2J.task3R.
        

        На этих данных ваша сериализация выиграла 7 байт (184 против 191 после правки размера item_id на UInt32). Почему protobuf не делает так же?


        1. remzalp
          28.10.2025 21:46

          Почему protobuf не делает так же?

          Потому что он добавляет метаданные и можно исторические данные с предыдущей версией схемы успешно обработать


          1. sijokun Автор
            28.10.2025 21:46

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


        1. sijokun Автор
          28.10.2025 21:46

          размер неправильный (UInt16), в user.proto uint32 item_id = 1;.

          В Protobuf нет ничего ниже uint32: документация.

          не очень понял вот эту строчку.

          Protobuf юзера
          Protobuf юзера

          В протобаф весь нестед объект лежит подряд, вот каждый итем, сначала его ид, потом тип, потом остальные поля и так по кругу. Я же раскрываю в "колонки", будут сначала все айди нестед итемов подряд, потом все типы и т.д. Если, например, одно текстовое поле с одинаковым текстом "text" повторяется в каждом итеме, то в моем формате будет texttexttexttexttext, для алгоритмов компрессии это выгоднее. В бенчмарке мы справниваем без компрессии, поэтому к нему это не относилось, а просто комментарий в общем о формате.

          DateTime32 потерял зону - было 2106-02-07 06:28:15, стало 2106-02-07 03:28:15+00:00

          Зоны мы не сохраняем, но планируется добавить опцию timezone-aware. Это можно через кастомный тип сделать. Для нас этой необходимости небыло, мы на бэкенде храним все даты в UTC.


  1. andreymal
    28.10.2025 21:46

    "1970-01-01T00:00:01.000000"

    DateTime32 — это всего 32 бита

    То есть вы потеряли микросекунды и получили проблему 2038 (или 2106) года?


    1. sijokun Автор
      28.10.2025 21:46

      В библиотеке есть DateTime64 – это основная суть библиотеки, возможность самому выбрать максимально точно сколько байт тебе нужно для твоих данных.

      В моем случае я не думаю, что структура которая сегодня сохраняется на пару часов в редисе будет актуальна через сколько-то там десятков лет.


      Типы DateTime и Date полностью позаимстованы из ClickHouse, со всеми их минусами и плюсами.


  1. user-book
    28.10.2025 21:46

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

    Что то мне кажется что вы там насоздавали велосипедов, напрямую переведя бесконечное json-дерево в итерируемые вложенные протобафы, не сильно заморачиваясь типами, вот и получили что получили.

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

    У протобафов есть свои проблемы, тут я не буду спорить (те же bool которые занимают байт) но прото остается до сих пор самым широко используемым мировым grpc стандартом для связи как на уровне сообщений (запросов) так и на уровне методов (собственно grpc)


    1. Deosis
      28.10.2025 21:46

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


  1. debagger
    28.10.2025 21:46

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


    1. ohrenet
      28.10.2025 21:46

      А потом героическое решение проблем с производительностью?


    1. sijokun Автор
      28.10.2025 21:46

      Average size PyByntic: 2157.96 bytes
      Average size PyByntic+gzip: 1178.82 bytes
      Average size JSON+gzip: 2364.51 bytes

      Сжатый JSON все еще хуже PyByntic, а если сжать PyByntic, то разница в два раза. Ну и CPU вы на gzip туда сюда будете заметно больше тратить, чем на запись и чтение байт.


      1. VADemon
        28.10.2025 21:46

        Есть другие (быстрые) алгоритмы сжатия, которые для swap используются, например. Но в целом, согласен, что они тут не к месту.


  1. Politura
    28.10.2025 21:46

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


  1. Deosis
    28.10.2025 21:46

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


    1. sijokun Автор
      28.10.2025 21:46

      Раскрою секрет: Все типы моей библиотеке взяты и полностью совместимы с нативным форматом ClickHouse.

      Ради интереса можно даже бинарные даты от SELECT ... FORMAT Native ей распарсить (нужно оставить только сами данные без хедера).


      1. eigrad
        28.10.2025 21:46

        Только типы? А сам Native целиком вместе с реализацией что помешало взять?


  1. The_Answer
    28.10.2025 21:46

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


    1. sijokun Автор
      28.10.2025 21:46

      Проблема в том, что приходится поддерживать схему в двух местах – Pydyntic с которым работаем внутри питона и одельно .proto схему + кодген по нему. Плюс PyByntic в том, что все поля задаются один раз в одном месте.

      Для сложных систем, где много микросервисов протобаф конечно удобнее, можно передать схему другому разработчику и он сам в своем языке с ней разберетс. Цель моего проекта – эффективно кэшировать объекты в рамках одного сервиса.


  1. shasoftZ
    28.10.2025 21:46

    Фактически всё сводится к сжатию данных. Тогда уж эффективнее будет прикрутить сюда своё сжатие со статическим словарём. Тогда все ключи уйдут в этот словарь и итоговые данные будут ещё меньше.


    1. sijokun Автор
      28.10.2025 21:46

      Enum?


  1. 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

    Успехов в дальнейшем развитии проекта!


    1. sijokun Автор
      28.10.2025 21:46

      Спасибо!

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


  1. TIEugene
    28.10.2025 21:46

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


    1. sijokun Автор
      28.10.2025 21:46

      Первая версия кода была сделана в феврале, после этого этот же код был партирован в другой наш проект. Пока полет нормальный и все это время жили на том, что было написано тогда в режиме ASAP. Я не писал статью сразу – велосипед как вино, ему нужно настоятся и оправдать себя.

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


      1. TIEugene
        28.10.2025 21:46

        С другой стороны - да, кто ищет - тот обрящет.
        Дай бог, может и взлетит.


  1. kmosolov
    28.10.2025 21:46

    Когда-то похожую проблему решил использованием DER (Distinguished Encoding Rules, ITU-T X.690), судя по содержимому проекта на GitHub автор изобрёл довольно похожий формат.