Реальные истории: ускорение API в 10 раз, поиск memory leak, оптимизация batch-задач
Теория без практики мертва. Практика без теории слепа.
Три реальных кейса оптимизации с использованием py-spy. От медленного API до утечек памяти.
REST API для получения статистики пользователя отвечал 2.5 секунды вместо целевых 200 мс.
Контекст:
/api/user/<id>/stats# Запустили нагрузочный тест
ab -n 100 -c 10 http://localhost:5000/api/user/123/stats
# В другом терминале профилирование
py-spy record -o api_slow.svg --rate 20 --duration 30 --pid 12345Открыли api_slow.svg:
┌─────────────────────────────────────────────────────┐
│ get_user_stats (95%) │
├─────────────────────────────────────────────────────┤
│ get_all_transactions (75%) │
│ calculate_metrics (15%) │
│ serialize_response (5%) │
└─────────────────────────────────────────────────────┘
Вывод: get_all_transactions занимает 75% времени!
Запрофилировали get_all_transactions:
┌─────────────────────────────────────────────────────┐
│ get_all_transactions (75%) │
├─────────────────────────────────────────────────────┤
│ Transaction.query.filter (5%) │
│ transaction.to_dict() (70%) ← Проблема! │
└─────────────────────────────────────────────────────┘
Код:
def get_all_transactions(user_id):
transactions = Transaction.query.filter_by(user_id=user_id).all()
return [t.to_dict() for t in transactions] # N+1!
# to_dict() делает запрос для каждой транзакции:
def to_dict(self):
return {
'id': self.id,
'amount': self.amount,
'category': self.category.name, # ← Запрос к БД!
'merchant': self.merchant.name # ← Ещё запрос!
}Диагноз: N+1 запросов! Для 500 транзакций = 1001 запрос к БД.
Было:
transactions = Transaction.query.filter_by(user_id=user_id).all()Стало:
from sqlalchemy.orm import joinedload
transactions = Transaction.query\
.filter_by(user_id=user_id)\
.options(
joinedload(Transaction.category),
joinedload(Transaction.merchant)
)\
.all()joinedload делает JOIN и загружает связанные объекты одним запросом.
# Снова профилируем
py-spy record -o api_fast.svg --rate 20 --duration 30 --pid 12345Новый профиль:
┌─────────────────────────────────────────────────────┐
│ get_user_stats (95%) │
├─────────────────────────────────────────────────────┤
│ get_all_transactions (25%) ← Стало лучше! │
│ calculate_metrics (60%) ← Теперь это узкое │
│ serialize_response (5%) │
└─────────────────────────────────────────────────────┘
| Метрика | До | После | Улучшение |
|---|---|---|---|
| Время ответа | 2500 мс | 250 мс | 10× |
| Запросов к БД | 1001 | 1 | 1000× |
| Запрофилированное время | 75% | 25% | 3× |
Всегда проверяйте N+1 запросы! py-spy показал, где время тратится, а анализ кода выявил причину.
Сервис обработки файлов начинал потреблять 4 ГБ памяти через 2 часа работы (стартовал с 200 МБ).
Контекст:
py-spy не профилирует память напрямую, но можно использовать dump для анализа состояния:
# Снимки каждые 5 минут
for i in {1..24}; do
py-spy dump --pid 12345 > dump_$(date +%H%M).txt
sleep 300
doneСнимок 1 (200 МБ):
Thread 0x7FF895C3A5C0 (active)
"MainThread"
Func: process_file, File: tasks.py, Line: 45
Func: parse_csv, File: parser.py, Line: 23
Снимок 12 (2 ГБ):
Thread 0x7FF895C3A5C0 (active)
"MainThread"
Func: process_file, File: tasks.py, Line: 45
Func: parse_csv, File: parser.py, Line: 23
# То же самое!
Вывод: Воркер застрял в обработке одного файла. Но почему память растёт?
Добавили логирование в код:
import tracemalloc
import gc
tracemalloc.start()
def process_file(filepath):
# До обработки
current, peak = tracemalloc.get_traced_memory()
print(f"До: {current / 1024 / 1024:.2f} MB")
data = parse_csv(filepath)
result = transform(data)
# После обработки
current, peak = tracemalloc.get_traced_memory()
print(f"После: {current / 1024 / 1024:.2f} MB")
return resultЛог:
До: 50.23 MB
После: 150.45 MB
До: 150.45 MB ← Не освободилось!
После: 280.67 MB
Анализ кода parse_csv:
def parse_csv(filepath):
rows = []
with open(filepath) as f:
for line in f:
rows.append(line.strip().split(',')) # ← Сохраняем всё в памяти
return rowsПроблема: Файл 500 МБ загружается целиком в память!
Стало:
def parse_csv_streaming(filepath):
"""Генератор для потоковой обработки"""
with open(filepath) as f:
for line in f:
yield line.strip().split(',')
def process_file(filepath):
# Обрабатываем по одной строке
for row in parse_csv_streaming(filepath):
process_row(row) # Не накапливаем в памяти| Метрика | До | После | Улучшение |
|---|---|---|---|
| Потребление памяти | 4 ГБ за 2 часа | 250 МБ стабильно | 16× |
| Время обработки | 30 сек/файл | 25 сек/файл | 1.2× |
py-spy dump показал, что воркер застревает в одной функции. Это навело на мысль о проблеме с памятью в этой функции.
Ночной job обработки 100 000 записей выполнялся 3 часа вместо 30 минут.
Контекст:
# Запустили job вручную
python -m celery -A myapp worker --loglevel=info
# Профилирование с запуском
py-spy record -o batch_slow.svg --children -- python batch_job.py┌─────────────────────────────────────────────────────┐
│ process_batch (95%) │
├─────────────────────────────────────────────────────┤
│ validate_record (40%) │
│ transform_record (35%) │
│ save_record (20%) │
└─────────────────────────────────────────────────────┘
Вывод: validate_record и transform_record занимают 75% времени.
┌─────────────────────────────────────────────────────┐
│ validate_record (40%) │
├─────────────────────────────────────────────────────┤
│ re.match (email_pattern) (25%) ← Regex! │
│ re.match (phone_pattern) (10%) │
│ check_duplicates (5%) │
└─────────────────────────────────────────────────────┘
Код:
import re
EMAIL_PATTERN = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
PHONE_PATTERN = r'^\+?1?\d{9,15}$'
def validate_record(record):
# Компиляция regex КАЖДЫЙ РАЗ!
if not re.match(EMAIL_PATTERN, record['email']):
return False
if not re.match(PHONE_PATTERN, record['phone']):
return False
return TrueПроблема: re.match компилирует regex каждый вызов! 100 000 записей × 2 паттерна = 200 000 компиляций.
Стало:
import re
# Компиляция ОДИН РАЗ при загрузке модуля
EMAIL_PATTERN = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
PHONE_PATTERN = re.compile(r'^\+?1?\d{9,15}$')
def validate_record(record):
if not EMAIL_PATTERN.match(record['email']):
return False
if not PHONE_PATTERN.match(record['phone']):
return False
return True┌─────────────────────────────────────────────────────┐
│ transform_record (35%) │
├─────────────────────────────────────────────────────┤
│ json.loads (20%) ← Парсинг JSON │
│ dict manipulation (10%) │
│ date parsing (5%) │
└─────────────────────────────────────────────────────┘
Код:
import json
def transform_record(record):
data = json.loads(record['raw_json']) # Парсинг каждый раз
data['processed_at'] = datetime.now().isoformat()
data['amount'] = float(data['amount']) / 100 # Конвертация копеек
return dataПроблема: json.loads вызывается для каждой записи. Можно кэшировать?
Оптимизация:
from functools import lru_cache
import json
@lru_cache(maxsize=1000)
def parse_json_cached(json_string):
return json.loads(json_string)
def transform_record(record):
# Кэширование одинаковых JSON
data = parse_json_cached(record['raw_json'])
data['processed_at'] = datetime.now().isoformat()
data['amount'] = float(data['amount']) / 100
return data| Метрика | До | После | Улучшение |
|---|---|---|---|
| Время job | 3 часа | 25 минут | 7× |
| Записей/сек | 100 | 700 | 7× |
| validate_record | 40% | 5% | 8× |
| transform_record | 35% | 15% | 2.3× |
Компиляция regex в цикле — классическая ошибка. py-spy показал, что re.match занимает 25% времени, что привело к поиску проблемы.
Симптом: Одна функция занимает 50%+ времени, внутри много вызовов БД.
Решение: joinedload, selectinload, пакетная обработка.
Симптом: re.match, json.loads, конструкторы классов занимают много времени.
Решение: Компилировать/создавать заранее, кэшировать.
Симптом: time.sleep, socket.recv, Lock.acquire в профиле.
Решение: Асинхронность, таймауты, неблокирующие операции.
Симптом: Одна и та же функция вызывается много раз с одинаковыми аргументами.
Решение: lru_cache, мемоизация.
Ключевая идея: py-spy не оптимизирует код за вас. Но он точно показывает, что оптимизировать. А дальше — ваш опыт и здравый смысл.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.