Перейти к содержимому

28 моделей Pydantic v1, шесть вечеров и три пожара на стенде

Константин Потапов
20 min

Июнь 2023. Учебный проект, 28 моделей, нагрузка около 2000 req/s. Latency валидации с 12 мс до 3. Feature flags сломались в два часа ночи. Юнит-тесты этого не видели.

Июнь 2023. Pydantic v2 обещает ускорение в пять-семнадцать раз. У меня учебный проект: 28 моделей, три сервиса, нагрузка около двух тысяч запросов в секунду. Вечера и выходные. Никто не орёт, если стенд ляжет в два часа ночи.

API Gateway на каждый запрос разбирал JWT из пяти полей. P95 валидации 12 мс, 18% CPU, потолок около 2000 rps. Order Processing читал CSV на 50 тысяч строк и вспухал до 1.2 ГБ. Список из тысячи пользователей сериализовался 450 мс.

Я пробовал выжать v1: model_construct() на доверенных данных, кэш схем, батчи. Выжал 20-30%. На пике CPU всё равно упирался. Решил переписать. Худшее, что могло случиться: пара выходных и записка, зачем не стоило.

Вышло иначе. libCST утащил 78% моделей сам. Latency 12 мс → 3, CPU 18% → 7%, throughput 2100 → 4800 rps. На стенде три пожара: feature flags, утечка памяти, всплеск ValidationError. В «прод» этого учебного стенда не ушло ничего. Юнит-тесты не видели типы из Redis и кривые форматы от клиента.

Серия из пяти частей. Здесь миграция. Дальше ConfigDict, скорость, контракты между сервисами, async и брокеры.

Если валидируете меньше сотни объектов в секунду, шесть недель на это я бы не тратил.

Зачем я полез

Три узких места, все в v1.

Проблема №1: Валидация съедает CPU

API Gateway валидирует JWT токен на каждый запрос. Простая модель из пяти полей:

# Pydantic v1
class JWTPayload(BaseModel):
    user_id: int
    email: str
    roles: List[str]
    exp: int
    iat: int
 
# Метрики до миграции
Latency (P95): 12ms на валидацию токена
CPU: 18% utilization на валидацию
Throughput: ~2000 req/sec до CPU throttling

Итог: При 2500+ req/sec валидация становится bottleneck. 18% CPU уходит только на проверку пяти полей в токене.

Проблема №2: Память течёт при массовых операциях

Order Processing Service загружает CSV с 50,000 заказов:

orders = [Order(**row) for row in csv_reader]  # Пиковое потребление: 1.2GB

При параллельной обработке нескольких файлов памяти не хватало.

3. Медленная сериализация

User Management возвращал списки пользователей с вложенными данными:

# 1000 пользователей с профилями и правами
users = [UserResponse.from_orm(u) for u in db_users]
serialized = [u.dict() for u in users]  # ~450ms

А может просто оптимизировать v1?

Естественно, я попробовал выжать максимум из v1 перед тем, как лезть в миграцию:

  • model_construct() для trusted data, помогло, но применимо не везде (теряем валидацию)
  • Кэширование схем, жалкие ~2-3%, узкое место оказалось не там
  • Батчинг запросов, сложность интеграции перевесила выгоду

Итог: выжал 20-30%, но CPU всё равно упирается в потолок на пиках.

А тут Pydantic v2 машет флагом с цифрами "5-17x faster" благодаря Rust-core. Решил: хрен с ним, попробую. Худшее, что может случиться, потрачу пару выходных и напишу пост "почему миграция была плохой идеей".

Что такое model_construct() и почему он помог

model_construct(), метод Pydantic, который создаёт экземпляр модели без валидации. Это bypass механизма проверки данных.

Как работает обычное создание модели:

class Order(BaseModel):
    product_id: int
    quantity: int
    price: Decimal
 
# Обычное создание (с валидацией)
order = Order(product_id="123", quantity="5", price="99.99")
# 1. Валидация типов: "123" -> int, "5" -> int
# 2. Валидация constraints (если есть)
# 3. Создание объекта
# Время: ~10-50 μs на объект (зависит от сложности модели)

Как работает model_construct():

# Создание без валидации
order = Order.model_construct(product_id=123, quantity=5, price=Decimal("99.99"))
# 1. Просто создаёт объект с переданными значениями
# 2. НЕТ проверки типов
# 3. НЕТ валидаторов
# Время: ~1-2 μs на объект (в 5-25 раз быстрее)

Когда использовать:

Безопасно (trusted data):

  • Данные из БД (уже валидированы при вставке)
  • Данные из внутренних сервисов
  • CSV файлы от проверенных источников (с предварительной валидацией структуры)

Опасно (untrusted data):

  • Данные от пользователей (API requests)
  • Внешние API
  • Файлы от неизвестных источников

Реальный пример из нашей миграции:

# CSV с 50,000 заказов от внутренней системы
# Формат гарантирован (генерируется нашим же кодом)
 
# ❌ Медленно: валидация каждой строки
orders = [Order(**row) for row in csv_rows]  # ~2.5 секунды + 1.2GB RAM
 
# ✅ Быстро: валидируем только структуру файла
def validate_csv_structure(headers):
    expected = set(Order.model_fields.keys())
    if not expected.issubset(headers):
        raise ValueError(f"Invalid CSV: missing {expected - set(headers)}")
 
validate_csv_structure(csv_headers)  # Один раз для файла
orders = [Order.model_construct(**row) for row in csv_rows]  # ~0.3 секунды + 380MB RAM

Trade-off:

  • Получаем 7-8x ускорение и 3x экономию памяти
  • Теряем защиту от некорректных данных
  • Решение: валидируем данные на границе системы (при загрузке файла), внутри используем model_construct()

Процесс миграции: что делал по неделям

Неделя 1-2: Подготовка и инвентаризация

Шаг 1: Инвентаризация моделей

# Скрипт для поиска всех BaseModel (rg = ripgrep, быстрая альтернатива grep)
rg "class \w+\(BaseModel\)" --type py -c
 
# Результат:
API Gateway: 8 моделей
Order Service: 12 моделей
User Service: 6 моделей
Shared libs: 2 модели
Итого: 28 моделей

Шаг 2: Анализ зависимостей, понять, что реально придётся переписывать

Перед миграцией нужно было оценить фронт работ: какие части Pydantic v1 API используются в проекте. Написал скрипт, который парсит Python-код через AST (Abstract Syntax Tree) и ищет паттерны v1, которые изменились в v2.

Как работает:

# check_dependencies.py
import ast
import sys
 
class ValidatorVisitor(ast.NodeVisitor):
    """Обходит AST Python-файла и собирает статистику использования Pydantic v1."""
 
    def __init__(self):
        self.validators = []        # Список моделей с @validator
        self.config_classes = []    # Список моделей с вложенным class Config
 
    def visit_FunctionDef(self, node):
        """Вызывается для каждой функции/метода в коде."""
        # Проверяем декораторы метода
        for decorator in node.decorator_list:
            # Если нашли @validator, это v1 API, в v2 нужен @field_validator
            if isinstance(decorator, ast.Name) and decorator.id == 'validator':
                self.validators.append(node.name)
 
    def visit_ClassDef(self, node):
        """Вызывается для каждого класса в коде."""
        # Проверяем тело класса на наличие вложенного class Config
        for item in node.body:
            if isinstance(item, ast.ClassDef) and item.name == 'Config':
                # class Config -> model_config = ConfigDict() в v2
                self.config_classes.append(node.name)
        self.generic_visit(node)  # Продолжаем обход вложенных узлов
 
# Запускаем на всех Python-файлах проекта, собираем статистику
# Результаты анализа:
# 18 моделей с @validator          ← нужно менять на @field_validator
# 14 моделей с классом Config       ← нужно менять на model_config = ConfigDict()
# 8 моделей с orm_mode = True       ← нужно менять на from_attributes = True
# 3 модели с JSON encoders          ← нужно переписывать на @field_serializer

Зачем это нужно: AST-анализ показал, что ~64% моделей (18 из 28) используют @validator, основное breaking change в v2. Без этой инвентаризации я бы не понял реальный объём работы.

Шаг 3: Создал бранч feat/pydantic-v2 и начал миграцию в изоляции.

Неделя 3-4: Автоматизация миграции

Написал кодмод (libCST) для автоматической миграции:

# migrate_pydantic.py
import libcst as cst
from libcst import matchers as m
 
class PydanticV2Transformer(cst.CSTTransformer):
    """Автоматическая миграция Pydantic v1 -> v2."""
 
    def leave_FunctionDef(self, original_node, updated_node):
        # @validator -> @field_validator
        if self._has_validator_decorator(updated_node):
            return self._convert_validator(updated_node)
        return updated_node
 
    def leave_ClassDef(self, original_node, updated_node):
        # Config -> model_config = ConfigDict(...)
        if self._has_config_class(updated_node):
            return self._convert_config(updated_node)
        return updated_node
 
    def _convert_validator(self, node):
        """@validator('field') -> @field_validator('field')."""
        new_decorators = []
        for decorator in node.decorators:
            if m.matches(decorator, m.Decorator(decorator=m.Name("validator"))):
                # Заменяем имя декоратора
                new_dec = decorator.with_changes(
                    decorator=cst.Name("field_validator")
                )
                new_decorators.append(new_dec)
            else:
                new_decorators.append(decorator)
 
        # Добавляем @classmethod если нет
        has_classmethod = any(
            m.matches(d, m.Decorator(decorator=m.Name("classmethod")))
            for d in new_decorators
        )
        if not has_classmethod:
            new_decorators.insert(0, cst.Decorator(decorator=cst.Name("classmethod")))
 
        return node.with_changes(decorators=new_decorators)
 
    def _convert_config(self, node):
        """class Config -> model_config = ConfigDict(...)."""
        # Находим Config внутри модели
        config_class = None
        new_body = []
 
        for item in node.body:
            if isinstance(item, cst.ClassDef) and item.name.value == "Config":
                config_class = item
            else:
                new_body.append(item)
 
        if not config_class:
            return node
 
        # Извлекаем параметры из Config
        config_params = self._extract_config_params(config_class)
 
        # Создаём model_config = ConfigDict(...)
        config_dict = cst.Assign(
            targets=[cst.AssignTarget(target=cst.Name("model_config"))],
            value=cst.Call(
                func=cst.Name("ConfigDict"),
                args=[
                    cst.Arg(
                        keyword=cst.Name(k),
                        value=cst.Name(str(v)) if isinstance(v, bool) else cst.SimpleString(f'"{v}"')
                    )
                    for k, v in config_params.items()
                ]
            )
        )
 
        # Вставляем в начало тела класса
        new_body.insert(0, config_dict)
 
        return node.with_changes(body=new_body)
 
# Применяем к файлам
for file_path in all_model_files:
    with open(file_path) as f:
        source = f.read()
 
    tree = cst.parse_module(source)
    transformer = PydanticV2Transformer()
    new_tree = tree.visit(transformer)
 
    with open(file_path, 'w') as f:
        f.write(new_tree.code)

Результат автоматизации:

  • 22 модели (78%) мигрировали автоматически
  • 6 моделей (22%) требовали ручной доработки (сложные валидаторы, кастомные JSON encoders)

Неделя 5: Ручная доработка и тесты

Проблемы, которые автоматизация не решила:

1. Сложные валидаторы с side effects

Антипаттерн из реальной практики: Примеры ниже показывают код из v1, который использовал I/O операции (запросы в БД) внутри валидаторов. Это плохая практика по нескольким причинам:

  • N+1 проблема: запрос в БД на каждый валидируемый объект
  • Блокирующий I/O: замедление валидации в десятки раз
  • Сложность тестирования: нужно мокировать БД для тестов
  • Нарушение Single Responsibility: валидатор должен проверять данные, а не обращаться к внешним системам

При миграции на v2 я исправил этот антипаттерн, вынеся проверки существования из валидаторов в бизнес-логику.

# v1 - работало (но это АНТИПАТТЕРН!)
class Order(BaseModel):
    product_id: int
    quantity: int
 
    @validator('product_id')
    def check_product_exists(cls, v):
        # Запрос в БД внутри валидатора
        product = db.query(Product).get(v)
        if not product:
            raise ValueError(f'Product {v} not found')
        return v
 
# v2 - нужно переписать
class Order(BaseModel):
    product_id: int
    quantity: int
 
    # Валидатор должен быть @classmethod в v2
    @field_validator('product_id')
    @classmethod
    def check_product_exists(cls, v):
        # ⚠️ Проблема: db недоступна в classmethod
        # Решение: убрать I/O из валидатора
        if v <= 0:
            raise ValueError('Product ID must be positive')
        return v
 
    # Проверку существования вынесли в endpoint
    @classmethod
    def create_with_validation(cls, data: dict, db: Session):
        order = cls(**data)
        # Проверяем product после валидации схемы
        product = db.query(Product).get(order.product_id)
        if not product:
            raise HTTPException(404, f'Product {order.product_id} not found')
        return order

2. Кастомные JSON encoders

# v1
class Event(BaseModel):
    timestamp: datetime
    data: dict
 
    class Config:
        json_encoders = {
            datetime: lambda v: v.isoformat()
        }
 
# v2 - используем field_serializer
class Event(BaseModel):
    timestamp: datetime
    data: dict
 
    @field_serializer('timestamp')
    def serialize_datetime(self, value: datetime) -> str:
        return value.isoformat()

3. Introspection кода сломался

# v1 - работало
for field_name, field in MyModel.__fields__.items():
    print(f"{field_name}: {field.type_}")
 
# v2 - нужно переписать
for field_name, field_info in MyModel.model_fields.items():
    print(f"{field_name}: {field_info.annotation}")

Переписал 6 моделей вручную за 1 день.

Тестирование:

# Прогнал всю тест-сьюту
pytest tests/ -v --cov
 
# Результаты:
Coverage: 87% (было 87% - не изменилось)
Tests passed: 142/145
Tests failed: 3 проблемы!

3 упавших теста:

  • 1 тест: изменились сообщения об ошибках валидации
  • 1 тест: mock'и для __fields__ не работают
  • 1 тест: проблемы с orm_mode (теперь from_attributes)

Исправил за 1 день.

Неделя 6: Deployment на тестовый стенд и первые проблемы

Задеплоил на тестовый стенд с имитацией нагрузки.

Проблема #1: Feature flags сломались

# Модель конфига
class FeatureFlags(BaseModel):
    new_checkout: bool
    beta_features: bool
    debug_mode: bool
 
# В Redis хранились строки "true"/"false"
# v1: приводил "false" -> False ✅
# v2 strict mode: "false" -> True (любая непустая строка) ❌
 
# Результат: все feature flags включились на тестовом стенде

Root cause: В v1 приведение строк к bool было более умным. v2 в non-strict mode использует Python's bool(str) → любая непустая строка = True.

Решение:

class FeatureFlags(BaseModel):
    model_config = ConfigDict(strict=True)  # Только точные типы
 
    new_checkout: bool
    beta_features: bool
    debug_mode: bool
 
# В коде загрузки из Redis добавили явное приведение
def load_feature_flags(redis_client):
    data = redis_client.hgetall("feature_flags")
    # Явно конвертируем строки в bool
    parsed = {
        k: v.lower() == "true" if isinstance(v, str) else v
        for k, v in data.items()
    }
    return FeatureFlags(**parsed)

Время на исправление: 2 часа (благо тестовый стенд).

Проблема #2: Утечка памяти в Order Service

После 6 часов работы на тестовом стенде Order Service начал падать с OOMKilled.

# Профилирование памяти
import tracemalloc
 
tracemalloc.start()
 
# До обработки файла
snapshot1 = tracemalloc.take_snapshot()
 
# Обработка 50k заказов
orders = [Order(**row) for row in csv_rows]
process_orders(orders)
 
# После обработки
snapshot2 = tracemalloc.take_snapshot()
 
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in top_stats[:3]:
    print(stat)
 
# Вывод:
# /app/models/order.py:45: size=842 MiB (+842 MiB), count=50234 (+50234)
# /venv/pydantic/main.py:156: size=234 MiB (+234 MiB), count=150702 (+150702)

Root cause: В v2 при создании моделей кэшируются больше метаданных (для производительности). При массовом создании объектов память не освобождалась сразу.

Решение: Использовал model_construct() для trusted данных из CSV:

# До (с валидацией)
orders = [Order(**row) for row in csv_rows]  # 1.2GB памяти
 
# После (без валидации для trusted данных)
orders = [Order.model_construct(**row) for row in csv_rows]  # 380MB памяти
 
# Валидацию перенесли на этап загрузки CSV (fail fast)
def validate_csv_header(headers):
    expected = set(Order.model_fields.keys())
    if not expected.issubset(headers):
        raise ValueError(f"Missing columns: {expected - set(headers)}")

Время на исправление: 1 день (включая профилирование).

Timeline и затраты миграции

Сводная таблица по всем этапам миграции на тестовом проекте:

ЭтапДлительностьКлючевые задачиРезультат
Недели 1-2: Подготовка2 неделиИнвентаризация моделей, анализ зависимостей, создание бранча28 моделей инвентаризировано, граф зависимостей построен
Недели 3-4: Автоматизация2 неделиРазработка кодмода на libCST, автоматическая миграция22 модели (78%) мигрировано автоматически
Неделя 5: Ручная доработка1 неделяМиграция сложных валидаторов, JSON encoders, тесты6 моделей доработано вручную, 142/145 тестов проходят
Неделя 6: Deployment1 неделяDeploy на тестовый стенд, мониторинг, исправление проблем3 проблемы обнаружено и исправлено (feature flags, утечка памяти, validation errors)
Итого6 недельПолная миграция 28 моделей + 3 сервисовМиграция завершена, готовность к production

Ключевые метрики процесса:

  • Автоматизация: 78% моделей мигрировано автоматически (экономия ~80 часов ручной работы)
  • Качество: 98% покрытие тестами сохранено (было 87%, стало 87%)
  • Инциденты: 3 проблемы на тестовом стенде (0 в production благодаря тестированию)
  • Откаты: 0 (все проблемы обнаружены до production)

Тестирование: постепенное увеличение нагрузки

Стратегия тестирования

  1. Week 1: User Service (наименее критичный), 5% → 25% → 50% → 100% нагрузки
  2. Week 2: Order Service, 10% → 50% → 100%
  3. Week 3: API Gateway (самый нагруженный), 5% → 25% → 50% → 75% → 100%

Про нагрузочное тестирование: Сейчас готовлю подробный материал о том, как проводить нагрузочное тестирование с k6, писать сценарии, анализировать результаты и интегрировать в CI/CD. Следите за обновлениями.

Конфигурация для постепенного увеличения нагрузки:

# kubernetes/api-gateway-canary.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-gateway-canary
spec:
  selector:
    app: api-gateway
    version: v2-pydantic
  ports:
    - port: 8000
 
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: api-gateway-traffic-split
spec:
  hosts:
    - api-gateway
  http:
    - match:
        - headers:
            x-canary:
              exact: "true"
      route:
        - destination:
            host: api-gateway-canary
          weight: 100
    - route:
        - destination:
            host: api-gateway-stable
          weight: 95
        - destination:
            host: api-gateway-canary
          weight: 5 # Начал с 5%

Мониторинг тестов

# prometheus_metrics.py
from prometheus_client import Histogram, Counter
 
# Метрики для сравнения v1 vs v2
validation_duration = Histogram(
    'pydantic_validation_duration_seconds',
    'Time spent in Pydantic validation',
    ['version', 'model_name']
)
 
validation_errors = Counter(
    'pydantic_validation_errors_total',
    'Total validation errors',
    ['version', 'model_name', 'error_type']
)
 
# В коде
def validate_request(data: dict) -> RequestModel:
    version = "v2" if PYDANTIC_V2_ENABLED else "v1"
 
    with validation_duration.labels(version=version, model_name="RequestModel").time():
        try:
            return RequestModel(**data)
        except ValidationError as e:
            for error in e.errors():
                validation_errors.labels(
                    version=version,
                    model_name="RequestModel",
                    error_type=error['type']
                ).inc()
            raise

Grafana dashboard для мониторинга:

# Latency сравнение v1 vs v2
histogram_quantile(0.95,
  rate(pydantic_validation_duration_seconds_bucket[5m])
) by (version)
 
# Error rate v1 vs v2
rate(pydantic_validation_errors_total[5m]) by (version, error_type)

Проблема #3: Spike в validation errors при 25% нагрузки

На тестовом API Gateway при увеличении нагрузки до 25% начали расти validation errors:

[ERROR] ValidationError in POST /api/orders
Input: {"items": [{"id": "123", "qty": 2}]}
Error: Input should be a valid integer [type=int_type, input_value='123', input_type=str]

Root cause: Мобильное приложение слало id как строку. v1 молча приводил к int, v2 с strict=False тоже должен был, но я включил strict=True после инцидента с feature flags.

Решение: Per-field strict mode:

# Было: глобальный strict
class OrderItem(BaseModel):
    model_config = ConfigDict(strict=True)
    id: int
    qty: int
 
# Стало: гибридный подход
class OrderItem(BaseModel):
    model_config = ConfigDict(strict=False)  # По умолчанию flexible
 
    id: int  # Разрешаем "123" -> 123
    qty: int
    price: Decimal = Field(strict=True)  # Но деньги только точные типы!

Действия: Временно вернул нагрузку на 5% пока фиксил. Через 3 часа задеплоил фикс, поднял до 50%.

Результаты миграции: метрики до/после

API Gateway (JWT валидация)

Метрикаv1 (до)v2 (после)Изменение
Latency (P50)8ms2ms-75%
Latency (P95)12ms3ms-75%
Latency (P99)18ms5ms-72%
CPU usage18%7%-61%
Memory (RSS)420MB380MB-9.5%
Max throughput2100 req/s4800 req/s+129%

Order Processing Service (CSV 50k строк)

Метрикаv1 (до)v2 (после)v2 (model_construct)
Processing time48s12s4.2s
Peak memory1.2GB1.1GB380MB
CPU usage85%45%28%

User Management Service (1000 users сериализация)

Метрикаv1 (до)v2 (после)Изменение
Serialization time450ms85ms-81%
Memory allocations15.2M objects4.8M objects-68%

Оценка ресурсов

До миграции (симуляция):

  • 12 инстансов API Gateway (t3.medium)
  • 8 инстансов Order Service (t3.large)
  • 6 инстансов User Service (t3.medium)

После миграции (симуляция):

  • 6 инстансов API Gateway (можем обработать тот же трафик)
  • 4 инстанса Order Service
  • 4 инстанса User Service

Вывод: Теоретическая экономия около 50% ресурсов при той же нагрузке благодаря снижению CPU usage на 61%.

Что я сделал бы иначе (грабли, на которые не стоит наступать)

1. Не тратить месяц на "идеальную подготовку"

Четыре недели ушло на написание кодмода, анализ зависимостей, построение графов. Круто, да? А потом я запустил на тестовом стенде и словил баг с feature flags, который не видели ни юнит-тесты, ни вся моя автоматизация.

Правильно было бы: Week 1-2, базовая автоматизация, Week 3, мигрировать ОДИН маленький сервис на тестовый стенд, Week 4-5, допиливать инструменты на основе РЕАЛЬНЫХ проблем, а не гипотетических.

Урок: Лучше получить feedback от реального стенда на неделю 3, чем строить идеальный кодмод, который всё равно не покроет все edge cases.

2. Откат через Kubernetes, это слишком долго

Канареечный deployment, круто. А вот когда что-то сломалось и нужно откатиться, начинаешь судорожно искать, где кнопка kubernetes rollback. 5-10 минут downtime, это вечность, когда у тебя горит на production (или в моем случае, на тестовом стенде, но паника та же).

Правильно: Feature flag в environment variables:

from my_app.config import settings
 
if settings.PYDANTIC_V2_ENABLED:
    from pydantic.v2 import BaseModel, ValidationError
else:
    from pydantic import BaseModel, ValidationError  # v1

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

3. Юнит-тесты врут (иногда)

99% юнит-тестов прошло. Я был горд собой. А потом на тестовом стенде всё посыпалось:

  • Мобильное приложение шлёт id как строку "123" вместо числа, v1 молча приводил, v2 со strict=True падал
  • Feature flags в Redis хранились как строки "true"/"false", v2 любую непустую строку считал за True
  • CSV файлы со всякой дрянью типа пустых строк и null вместо пропущенных полей

Урок: Юнит-тесты проверяют, что код работает согласно спецификации. Интеграционные тесты с реальными данными проверяют, что код работает с тем бардаком, который шлют клиенты. Делайте больше вторых.

4. "Запомню в голове" не работает

Исправил баг с feature flags в 2 часа ночи. Пошёл спать довольный. Через неделю мигрирую другую часть проекта, натыкаюсь на ту же проблему. Сижу тупо и не могу вспомнить, как чинил в прошлый раз.

Решение: Завёл checklist "Pydantic v2 gotchas" и заношу туда каждый баг:

  • "false" в Redis → True (нужен explicit парсинг)
  • Деньги только с strict=True (иначе float проскочит)
  • CSV массово, через model_construct() (иначе память уйдёт)
  • JSON encoders → @field_serializer (API поменялось)

Звучит банально, но работает. Особенно когда возвращаешься к проекту через месяц.

Инструменты, которые помогли

1. Скрипт для сравнения производительности

# benchmark_versions.py
import timeit
from typing import List
import pydantic.v1 as pydantic_v1
from pydantic import BaseModel as BaseModelV2
 
# Одинаковые модели на v1 и v2
class UserV1(pydantic_v1.BaseModel):
    id: int
    username: str
    email: str
 
class UserV2(BaseModelV2):
    id: int
    username: str
    email: str
 
# Тестовые данные
data = [
    {"id": i, "username": f"user{i}", "email": f"user{i}@example.com"}
    for i in range(10000)
]
 
# Бенчмарк v1
time_v1 = timeit.timeit(
    lambda: [UserV1(**row) for row in data],
    number=10
) / 10
 
# Бенчмарк v2
time_v2 = timeit.timeit(
    lambda: [UserV2(**row) for row in data],
    number=10
) / 10
 
print(f"v1: {time_v1:.3f}s")
print(f"v2: {time_v2:.3f}s")
print(f"Speedup: {time_v1/time_v2:.1f}x")

Запускал перед и после каждого изменения.

2. Граф зависимостей моделей

# model_dependencies.py
import ast
import networkx as nx
import matplotlib.pyplot as plt
 
def extract_model_dependencies(file_path):
    """Строит граф зависимостей между моделями."""
 
    with open(file_path) as f:
        tree = ast.parse(f.read())
 
    graph = nx.DiGraph()
 
    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef):
            # Это Pydantic модель?
            if any(base.id == 'BaseModel' for base in node.bases if isinstance(base, ast.Name)):
                model_name = node.name
                graph.add_node(model_name)
 
                # Ищем зависимости в аннотациях полей
                for item in node.body:
                    if isinstance(item, ast.AnnAssign):
                        annotation = item.annotation
                        # Простой случай: other_field: OtherModel
                        if isinstance(annotation, ast.Name):
                            graph.add_edge(model_name, annotation.id)
                        # List[OtherModel]
                        elif isinstance(annotation, ast.Subscript):
                            if isinstance(annotation.slice, ast.Name):
                                graph.add_edge(model_name, annotation.slice.id)
 
    return graph
 
# Использование
graph = extract_model_dependencies("models/")
 
# Топологическая сортировка для порядка миграции
try:
    migration_order = list(nx.topological_sort(graph))
    print("Миграцировать в порядке:", migration_order)
except nx.NetworkXError:
    print("Обнаружены циклические зависимости!")
    cycles = list(nx.simple_cycles(graph))
    print("Циклы:", cycles)

Помогло найти порядок миграции моделей (от независимых к зависимым).

3. Regression test suite

# test_pydantic_regression.py
import pytest
from decimal import Decimal
 
# Тесты на специфичные баги, которые нашёл
def test_feature_flag_string_bool():
    """Feature flags из Redis должны корректно парситься."""
 
    class Flags(BaseModel):
        enabled: bool
 
    # v1 парсил корректно, v2 нужен strict mode
    assert Flags(enabled="true").enabled is True
    assert Flags(enabled="false").enabled is False  # Важно!
    assert Flags(enabled=True).enabled is True
 
def test_decimal_precision():
    """Деньги должны сохранять точность."""
 
    class Payment(BaseModel):
        amount: Decimal
 
    # Проверяем, что 0.1 + 0.2 = 0.3 точно
    p1 = Payment(amount="0.1")
    p2 = Payment(amount="0.2")
    assert (p1.amount + p2.amount) == Decimal("0.3")
 
def test_model_construct_memory():
    """model_construct не должен создавать лишние объекты."""
 
    import tracemalloc
 
    class Item(BaseModel):
        id: int
        name: str
 
    data = [{"id": i, "name": f"Item {i}"} for i in range(10000)]
 
    tracemalloc.start()
    snapshot1 = tracemalloc.take_snapshot()
 
    items = [Item.model_construct(**row) for row in data]
 
    snapshot2 = tracemalloc.take_snapshot()
    top_stats = snapshot2.compare_to(snapshot1, 'lineno')
 
    # Должно быть меньше 10MB для 10k объектов
    total_size = sum(stat.size for stat in top_stats)
    assert total_size < 10 * 1024 * 1024, f"Memory usage too high: {total_size / 1024 / 1024:.1f}MB"

Стоило ли

Шесть недель вечеров. Три пожара на стенде. Feature flags в два часа ночи.

Взамен: 12 мс → 3 на P95, CPU 18% → 7%, потолок 2100 → 4800 rps. На учебной нагрузке это половина железа. На десяти объектах в секунду я бы остался на v1.

Юнит-тесты врали. Staging с живыми данными из Redis не врал. Если узкое место не валидация, переписывать модели ради галочки не надо.

Дальше в серии


Нагрузку я поднимал постепенно и смотрел latency, CPU, ошибки. Отдельный разбор k6 будет отдельно. Здесь важно другое: canary 5% → 25% → 50% → 100%, откат короче пяти минут, флаг между v1 и v2 без редеплоя.

Что проверить перед миграцией

Предпосылки для миграции

  • High-load проект: Валидация занимает >10% CPU или >100ms на запрос
  • Масштаб: >50 Pydantic моделей в проекте
  • Staging окружение: Есть возможность тестировать с реальной нагрузкой
  • Время команды: 2-6 недель (зависит от количества моделей)
  • Monitoring: Есть метрики для сравнения до/после (latency, CPU, memory)

Подготовка к миграции

  • Инвентаризация: подсчитали все BaseModel классы
  • AST-анализ: нашли все @validator, class Config, orm_mode
  • Построили граф зависимостей между моделями
  • Создали feature branch для миграции
  • Настроили CI/CD для автоматического тестирования

Автоматизация

  • Написали кодмод на libCST или использовали готовый инструмент
  • Протестировали кодмод на 2-3 моделях
  • Применили автоматическую миграцию ко всем моделям
  • Code review: проверили diff изменений

Ручная доработка

  • Мигрировали сложные валидаторы с I/O (вынесли в dependency injection)
  • Заменили json_encoders на @field_serializer
  • Обновили introspection код (__fields__model_fields)
  • Исправили все failing tests

Deployment и мониторинг

  • Canary deployment: начали с 5% нагрузки
  • Мониторинг метрик: latency, CPU, memory, errors
  • Постепенное увеличение: 5% → 25% → 50% → 100%
  • Rollback plan: готовы откатиться за <5 минут
  • Feature flag: можем переключиться между v1 и v2 без редеплоя

Результат

  • Да на 10+ пунктов? Вы готовы к миграции.
  • Да на 5-9 пунктов? Потратьте еще неделю на подготовку.
  • Да на <5 пунктов? Миграция преждевременна, сначала усильте инфраструктуру.

Цифры с учебного стенда. Нагрузка искусственная, грабли настоящие.