Июнь 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 и брокеры.
- Часть 1. Миграция (здесь)
- Часть 2. ConfigDict и валидация
- Часть 3. Скорость
- Часть 4. Сервисы
- Часть 5. Async, GraphQL, брокеры
Если валидируете меньше сотни объектов в секунду, шесть недель на это я бы не тратил.
Зачем я полез
Три узких места, все в 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 RAMTrade-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 order2. Кастомные 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: Deployment | 1 неделя | Deploy на тестовый стенд, мониторинг, исправление проблем | 3 проблемы обнаружено и исправлено (feature flags, утечка памяти, validation errors) |
| Итого | 6 недель | Полная миграция 28 моделей + 3 сервисов | Миграция завершена, готовность к production |
Ключевые метрики процесса:
- Автоматизация: 78% моделей мигрировано автоматически (экономия ~80 часов ручной работы)
- Качество: 98% покрытие тестами сохранено (было 87%, стало 87%)
- Инциденты: 3 проблемы на тестовом стенде (0 в production благодаря тестированию)
- Откаты: 0 (все проблемы обнаружены до production)
Тестирование: постепенное увеличение нагрузки
Стратегия тестирования
- Week 1: User Service (наименее критичный), 5% → 25% → 50% → 100% нагрузки
- Week 2: Order Service, 10% → 50% → 100%
- 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()
raiseGrafana 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) | 8ms | 2ms | -75% |
| Latency (P95) | 12ms | 3ms | -75% |
| Latency (P99) | 18ms | 5ms | -72% |
| CPU usage | 18% | 7% | -61% |
| Memory (RSS) | 420MB | 380MB | -9.5% |
| Max throughput | 2100 req/s | 4800 req/s | +129% |
Order Processing Service (CSV 50k строк)
| Метрика | v1 (до) | v2 (после) | v2 (model_construct) |
|---|---|---|---|
| Processing time | 48s | 12s | 4.2s |
| Peak memory | 1.2GB | 1.1GB | 380MB |
| CPU usage | 85% | 45% | 28% |
User Management Service (1000 users сериализация)
| Метрика | v1 (до) | v2 (после) | Изменение |
|---|---|---|---|
| Serialization time | 450ms | 85ms | -81% |
| Memory allocations | 15.2M objects | 4.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 пунктов? Миграция преждевременна, сначала усильте инфраструктуру.
Цифры с учебного стенда. Нагрузка искусственная, грабли настоящие.
