- PVSM.RU - https://www.pvsm.ru -

В 2026 году асинхронный Python уже никого не удивляет. Мы привыкли разворачивать FastAPI в Kubernetes, накидывать автоскейлинг в облаке и не особо задумываться о том, сколько тактов CPU съедает сериализация одного JSON. Но что делать, если ваш бюджет на инфраструктуру равен нулю, а в распоряжении есть только «печка» из 2012 года и шумный жесткий диск?
Цель эксперимента: доказать, что Resource-Constrained Engineering (проектирование в условиях ограничений) актуально и сегодня. Можно построить бэкенд, который не просто "работает", а выдает тысячи RPS и предсказуемый P99 на задачах, где обычно принято просто докупать ресурсы в облаке.
Процессор: AMD FX-8320 (Vishera).
Память: 12 ГБ DDR3.
Диск: HDD 7200 RPM.
Примечание: AMD FX-8320 - архитектура Piledriver подразумевает 4 модуля (всего 8 потоков) по 2 целочисленных ядра, разделяющих общие ресурсы, такие как FPU и L2-кэш.
Runtime: Python 3.12.6.
Servers: Granian (на базе Rust) и Uvicorn (uvloop).
Infrastructure:
Angie — форк Nginx с расширенной функциональностью (используется как фронтенд и SSL-терминатор).
Valkey — полностью open-source форк Redis (наш слой кэширования).
PgBouncer — для менеджмента соединений с БД, чтобы не плодить тяжелые процессы PostgreSQL.
PostgreSQL 18 — актуальная на момент эксперимента.
Frontend: Angie (Прием HTTPS, отдача статики, сглаживание всплесков).
Transport: Unix Domain Socket (Минимизация сетевого оверхеда между прокси и бэкендом).
Application: FastAPI на базе Granian (Rust-runtime) или Uvicorn.
DB Proxy: PgBouncer (Пул соединений для экономии ресурсов CPU/RAM).
Storage: PostgreSQL 18 + Valkey (L2 кэш).
Многие считают, что переход на FastAPI автоматически решает проблемы производительности. Но на старом железе асинхронность без тюнинга быстро превращается в тыкву. В ходе нагрузочных тестов я выделил 4 критических узких места:
|
Узел |
Проблема |
Последствие |
|---|---|---|
|
Crypto |
Тяжелый RSA-2048 |
CPU-bound лимит RPS |
|
Runtime |
Синхронный Argon2id |
Блокировка Event Loop, таймауты |
|
JSON |
jsonable_encoder |
Оверхед на аллокации и GC |
|
Storage |
random_page_cost |
Ошибки планировщика БД на HDD |
Высокая стоимость RSA-подписей: Вычислительная сложность RSA-2048 на архитектуре Piledriver приводила к быстрой утилизации ресурсов. На каждый запрос тратилось слишком много тактов процессора, что ограничивало общий предел RPS.
Блокировка Event Loop: Синхронное хэширование Argon2id буквально останавливало событийный цикл. Пока поток был занят вычислением хэша, приложение не могло ответить даже на «легкие» запросы вроде /ping, что вызывало каскадные таймауты.
Избыточность jsonable_encoder: Стандартный механизм FastAPI выполнял рекурсивный обход объектов для приведения типов (UUID, datetime) к примитивам. Это создавало лишние аллокации, нагружало Garbage Collector и неоправданно долго удерживало цикл.
Неверная оценка I/O в PostgreSQL: Из-за заниженного параметра random_page_cost планировщик считал случайный доступ к данным на диске дешевым. В результате он выбирал индексное сканирование там, где медленный HDD физически не успевал перемещать считывающую головку, вызывая деградацию при росте базы.
Главный враг асинхронности — блокировка событийного цикла. Если одна задача «повесила» поток, всё приложение замирает.
Ряд внесенных мной изменений:
Проблема: RSA-2048 (RS256) требует тяжелых вычислений, которые надолго занимали вычислительные блоки FX-8320.
Решение: Миграция со связки pyjwt (алгоритм RSA) на более современную библиотеку joserfc (алгоритм Ed25519).
Результат: Нагрузка на CPU снизилась, а Ed25519 обеспечивает сопоставимый уровень криптостойкости при значительно меньших затратах CPU. Бонусом упростился CI/CD: работа с ключами теперь не требует вороха .pem файлов, всё нативно поддерживается через стандарт JWK.
Проблема: Использование только Redis (L2) создавало лишние сетевые задержки (network round-trips) даже на «горячих» данных профиля пользователя.
Решение: Замена fastapi-cache2 на библиотеку cashews с включенным client_side=True.
Архитектура (Hybrid Cache (Client-side + L2)):
Client-side (L1): Использование локального кэша приложения позволило избежать лишних сетевых вызовов (round-trips) к Redis для самых «горячих» данных.
Redis/Valkey (L2): Общее хранилище состояния для синхронизации между воркерами.
Результат: Переход на библиотеку cashews ускорил чтение, из коробки есть защита от «Cache Stampede» (одновременное обновление кэша), а также гибкая инвалидация, которой не хватало в библиотеке fastapi-cache2.
Проблема: Хэширование паролей — самая тяжелая задача для CPU.
Решение: Чтобы не «вешать» основной поток, я вынес Argon2id в выделенный ThreadPoolExecutor.
Конфигурация:
Жесткое ограничение нагрузки — max_workers = 4 (ThreadPoolExecutor).
Запретили Argon2 плодить внутренние потоки - параметр parallelism=1.
Примечание: Ограничение max_workers = 4 продиктовано топологией FX-8320: процессор состоит из 4 модулей. Выделение одного потока под тяжелую криптографию на каждый модуль позволяет избежать борьбы за общий FPU и кэш L2, сохраняя ресурсы для планировщика и сетевого стека.
Результат: Ограничение позволило системе оставаться отзывчивой и обрабатывать новые запросы, пока часть ядер занята криптографией.
Проблема: Стандартный механизм FastAPI (jsonable_encoder) выполнял рекурсивный обход объектов для приведения типов (UUID, datetime) к примитивам. Это создавало лишние аллокации, нагружало Garbage Collector и неоправданно долго удерживало цикл.
Решение: Я разделил слои данных: валидация входящих запросов осталась на Pydantic (из-за удобства и интеграции с FastAPI), но сериализация ответов теперь идет через msgspec.Struct.
Результат: Полностью исключили стадию промежуточного кодирования. P99 latency на эндпоинте /health упала с 859 мс до 128 мс (в 6.5 раз). На слабом железе это один из наиболее эффективных способов избавиться от оверхеда при упаковке JSON.
Когда Python-код в порядке, бутылочное горлышко смещается в сторону дисковых операций на HDD 7200 RPM, для которого случайное чтение — это «физическая боль» из-за задержек позиционирования головки (seek time).
PostgreSQL: Дружим с «вращающимися блинами»
Проблема: Изначально заниженное мной значение (random_page_cost=1.1) заставляет планировщик верить, что чтение из произвольного места диска стоит столько же, сколько последовательное. Для HDD это ложь: головке диска нужно время на позиционирование.
Решение: После коррекции (изменил параметр random_page_cost с 1.1 до 3.0) планировщик начал строить адекватные планы запросов, и производительность перестала падать при росте базы.
Оптимизация:
Partial Attribute Loading: В ORM используем load_only, забирая только необходимые поля. Меньше данных с диска — меньше нагрузка на шину и кэш.
Экономия на INSERT: Отключил автоматическое обновление объектов (auto_refresh=False) средствами advanced-alchemy, избавившись от избыточных SELECT после инсертов.
Ядро Debian: Performance Mode
Чтобы система не «отфутболивала» соединения в моменты пиковой нагрузки, подкрутил параметры ядра:
net.core.somaxconn = 2048: Увеличили очередь на прослушивание портов. Это позволяет ОС удерживать входящие подключения в очереди, пока воркеры заняты вычислением Argon2id.
vm.overcommit_memory = 1: Критично для систем с 12 ГБ RAM при использовании Valkey/Redis. Разрешили ядру выделять память более гибко, предотвращая внезапные OOM-киллеры при всплесках нагрузки.
Синхронизация очередей (Backlog): Чтобы избежать потерь соединений на стыке компонентов, привел лимиты к единому знаменателю. Параметр ядра net.core.somaxconn = 2048 был синхронизирован с настройками запуска Valkey (--tcp-backlog 2048) и флагом Granian --backlog 2048. Это гарантирует, что ни одно звено цепи не начнет дропать пакеты раньше времени при резких всплесках нагрузки.
Прежде чем приступать к оптимизации, нужно было понять масштаб катастрофы.
Я развернул проект IronTrack на «голом железе» под управлением Debian 12.
Для чистоты эксперимента все замеры проводились с использованием утилиты wrk. Нагрузка подавалась с внешнего узла в той же локальной сети (1 Гбит), чтобы исключить влияние самого бенчмарка на ресурсы CPU.
Основные сценарии тестирования:
# 1. Проверка «пропускной способности» (Throughput)
# Цель: замерить чистый оверхед ASGI-адаптера и мидлварей без нагрузки на БД.
wrk -t8 -c100 -d30s --latency http://127.0.0.1:8000/ping
# 2. Интеграционный тест (App + DB + Cache)
# Цель: убедиться в отсутствии задержек при опросе всех зависимостей.
wrk -t8 -c100 -d30s --latency http://127.0.0.1:8000/health
# 3. Авторизованный доступ (JWT + Valkey)
# Цель: замерить производительность валидации JWT и скорость извлечения сессии из кэша.
wrk -t8 -c100 -d30s --latency -H 'Cookie: access_token="TOKEN"' http://127.0.0.1:8000/api/v1/access/me
# 4. Регистрация (Heavy CPU-bound + HDD)
# Цель: замер Argon2id и скорости записи на HDD.
# Используем 20 соединений, чтобы минимизировать context switching на 8 ядрах.
wrk -t8 -c20 -d30s --latency -s benchmarks/scripts/signup.lua http://127.0.0.1:8000/api/v1/access/signup
# 5. Тест через прокси (HTTPS + UDS)
# Цель: оценка оверхеда TLS и эффективности транспорта через Unix-сокеты (Angie).
wrk -t8 -c20 -d30s --latency -s benchmarks/scripts/signup.lua https://app.localhost/api/v1/access/signup
Каждый тест запускался после 30-секундного прогрева, результаты усреднялись по 5 прогонам.
Примечание: Ограничение до 20 соединений для регистрации позволяет ядрам реально доделывать работу, а не только переключаться между задачами.
Uvicorn: запускался с 8 воркерами и циклом событий --loop uvloop.
Granian: запускался с 8 воркерами, работал в многопоточном режиме --runtime-mode mt (--runtime-threads 2), циклом событий --loop uvloop и количеством соединений --backlog 2048.
Стандартные настройки Linux не рассчитаны на агрессивный бенчмаркинг. Чтобы стек TCP/IP не стал бутылочным горлышком раньше времени, я подкрутил лимиты:
ulimit -n 65535 # Расширяем лимит открытых файлов
sudo sysctl -w net.core.rmem_max=2500000 # Буферы приема
sudo sysctl -w net.core.wmem_max=2500000 # Буферы передачи
На этом этапе в коде еще жил тяжелый jsonable_encoder, RSA-2048 и PostgreSQL (random_page_cost=1.1).
|
Эндпоинт |
Сценарий |
Метрика |
Granian (mt) |
Uvicorn (uvloop) |
Вердикт |
|---|---|---|---|---|---|
|
/ping |
ASGI & Middlewares |
RPS |
5720 |
4703 |
Granian на 21% быстрее |
|
/health |
DB + Redis Check |
P99 Latency |
459 ms |
859 ms |
Uvicorn стабильнее (0 ошибок), но медленнее |
|
/access/me |
JWT + L2 Cache |
RPS |
1404 |
1613 |
Uvicorn быстрее на чтении |
|
/access/signup |
Argon2 + HDD |
Avg Latency |
863 ms |
970 ms |
Granian лучше под нагрузкой |
Здесь мы проверяем чистую работу с кэшем (Valkey) и валидацию JWT. На графике отчетливо видна разница в поведении серверов:
Слева: RPS (Uvicorn лидирует: 1613 vs 1404). Справа: P99 Latency (у Granian «хвост» задержек почти в 2 раза выше: 279 мс).
Инсайт: Uvicorn показывает себя стабильнее на простых операциях чтения. P99 в 279 мс у Granian — сигнал о том, что на архитектуре FX накладные расходы на синхронизацию потоков (mt) превышают пользу от Rust-движка.
Здесь в игру вступают Argon2id и запись на медленный HDD:
Слева: Успешные инсерты (Granian: 409, Uvicorn: 384). Справа: Средняя задержка (Granian быстрее: 863 мс против 970 мс).
Инсайт: Rust-планировщик Granian эффективнее распределяет задачи. Однако абсолютные цифры (задержка почти в 1 секунду) — это то, с чем мы будем бороться.
Сравнение прямой работы с бэкендом и через прокси Angie:
Снижение задержек на 12% при использовании связки Angie + UDS на эндпоинте /signup.
Инсайт: Это визуальное доказательство эффекта Request Smoothing. Несмотря на оверхед от TLS, Angie выступает демпфером: она буферизирует соединения и отдает их бэкенду ровным потоком через Unix-сокеты, минимизируя накладные расходы на сетевой стек внутри хоста. Это снижает вероятность нелинейного роста задержек при переполнении очереди задач планировщика Python.
После внедрения всех оптимизаций и глубокой настройки сетевого стека, мы получили полную картину производительности. Сравнение проводилось в трех состояниях: «голый» Baseline, оптимизированный код и финальная сборка под прокси-сервером.
На графике ниже представлено лобовое столкновение Uvicorn (uvloop) и Granian (mt) на разных типах задач.
Производительность серверов в зависимости от сложности запроса (RPS, логарифмическая шкала).
Инсайт:
Uvicorn остается королем на «пустых» запросах (/ping) и простых JSON-ответах. Его модель идеально ложится на архитектуру AMD FX, не создавая лишней нагрузки на общую шину данных и L3-кэш.
Granian вырывается вперед в тяжелом сценарии (/signup). Когда ядра CPU плотно заняты Argon2id, Rust-планировщик Granian эффективнее распределяет задачи, обеспечивая +10% RPS и более быстрый отклик.
Примечание: После тюнинга очередей в ядре Linux (somaxconn) преимущество Uvicorn на легких запросах стало еще заметнее. Это связано с тем, что на архитектуре Piledriver затраты на синхронизацию потоков в Granian (mt) начинают превышать время обработки самого запроса.
Путь от базовой настройки до финальной наглядно показывает эффективность принятых архитектурных решений.
Сравнение RPS и задержки (Latency) в процессе оптимизации системы.
Мы видим характерный «горб» производительности:
Baseline: Система работает на пределе возможностей RSA.
Optimized: Переход на Ed25519 и Hybrid L1-кэш поднял RPS с 1613 до 2384 (+48%).
Proxy Mode: Падение RPS в режиме Proxy обусловлено не только затратами на TLS-рукопожатия. На старом CPU суммируются задержки на копирование данных между сокетами (Userspace <-> Kernel), переключение контекста между процессами Angie и Granian, а также буферизацию пакетов. Однако Angie работает как демпфер (Request Smoothing), сглаживая распределение задержек (Latency distribution) и предотвращая переполнение очередей бэкенда.
Режим Proxy через Unix-сокеты доказал, что безопасность может приносить пользу стабильности бэкенда.
Сравнение прямой работы по HTTP и через связку Angie + HTTPS + UDS.
Анализ интеграции:
SSL Overhead: Падение RPS на легких запросах почти в два раза (3170 → 1556) было неизбежным. Старый FX-8320 тратит слишком много циклов на симметричное шифрование трафика.
Request Smoothing: На «тяжелой» регистрации (/signup) мы получили стабильные 10.2 RPS. Хотя это число ниже прямого HTTP, использование Angie позволило полностью избавиться от ошибок тайм-аута.
Надежность: Прокси выступает в роли демпфера: он принимает входящие соединения, буферизирует их и отдает бэкенду ровным потоком. Это позволило системе выдержать наполнение базы свыше 10,000 записей без деградации времени отклика.
Эксперимент показал, что «старое» железо часто списывают со счетов преждевременно. Проблема медленных ответов в 90% случаев крылась не в возрасте транзисторов, а в архитектурных решениях, которые мы принимаем по умолчанию, надеясь на мощь современных облаков.
Основные выводы для тех, кто решит повторить:
Криптография — это дорого. Если у вас старый CPU, замена RSA на Ed25519 — это самый дешевый способ поднять RPS.
Блокировка цикла — это фатально. ThreadPoolExecutor с жестким лимитом воркеров для Argon2id спасает отзывчивость системы.
Прокси — это стабилизатор. Angie (или Nginx) перед FastAPI на старом железе нужна не только для SSL, но и для того, чтобы бэкенд не захлебнулся от неравномерного потока TCP-пакетов.
В конечном итоге оптимизация под AMD FX и HDD — это отличный тренажер для инженера. Она заставляет вспомнить о том, как работают кэши, как перемещается головка диска и почему каждый лишний системный вызов имеет значение.
Все этапы эксперимента задокументированы и доступны для воспроизведения:
Репозиторий проекта: github.com/bizoxe/iron-track [2] — здесь находится код приложения, Docker-файлы и конфигурации Angie/PostgreSQL.
Результаты тестов: BENCHMARKS.md [3] — полная история замеров «До» и «После» оптимизации, команды запуска, логи.
Автор: bizoxe
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/fastapi/445414
Ссылки в тексте:
[1] Image: https://sourcecraft.dev/
[2] github.com/bizoxe/iron-track: https://github.com/bizoxe/iron-track
[3] BENCHMARKS.md: https://github.com/bizoxe/iron-track/blob/main/benchmarks/BENCHMARKS.md
[4] Источник: https://habr.com/ru/articles/1002312/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1002312
Нажмите здесь для печати.