У нас был большой продакшен-сервис с ~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 репозиторий проекта