На прошлой неделе вышла бета-версия нового FastAPI 0.100-beta1, а это значит что? Правильно, пришло время performance-тестов!
Изменения
Главное изменение в новой версии FastAPI - это переход на новую версию библиотеки Pydantic v2.0b3 - вся логика валидации была переписана на языке Rust. Для Pydantic обещают увеличение производительности в 5-50x раз! Ну что же, посмотрим, как это скажется на скорости FastAPI в целом. Других изменений в версии 0.100-beta1 в release notes не указано.
Для Pydantic же
Подготовка тестового стенда
Мы, веб-девелоперы, нелюбим CRUD-ды, вот на нем давайте и протестируем. Чтобы хотя бы попытаться приблизиться к реальному приложению, на каждый запрос клиента будет работать с SQLAlchemy моделью, обращаясь к базе данных.
Весь основной код доступен на гитхабе, здесь приведу основные моменты:
У модели сделаем тройку полей даты-времени, два текстовых поля, одно поля bool, ну и конечно же id:
Код модели
# commons/models.py from datetime import datetime from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.types import Text from sqlalchemy.sql import func class Base(DeclarativeBase): pass class Post(Base): __tablename__ = "posts" id: Mapped[int] = mapped_column(primary_key=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(onupdate=func.now()) published_at: Mapped[datetime] = mapped_column(nullable=False) title: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text) is_deleted: Mapped[bool] = mapped_column(nullable=False, default=False)
Что? Ещё не видели новый-стильный-молодёжный стиль SQLAlchemy под названием mapping_styles ? Тогда скорее к документации. В целом, снова изменения были внесены для того, чтобы наши любимые IDEшки не ругались, когда в аттрибут объекта типа Column мы пытаемся записать какие-то данные не Column, а к примеру int, str и так далее.
Схема Pydantic v1 - стандартная модель Pydantic:
Код схемы
# commons/schemas.py from datetime import datetime from enum import StrEnum from pydantic import BaseModel, validator class PostOut(BaseModel): id: int published_at: datetime updated_at: datetime title: str content: str is_published: bool | None = None @validator("is_published", always=True) def compute_is_published(cls, v, values, **kwargs): return datetime.utcnow() >= values["published_at"] class Config: orm_mode = True class PostsOut(BaseModel): posts: list[PostOut] class PostIn(BaseModel): title: str content: str published_at: datetime class Order(StrEnum): ASC = "asc" DESC = "desc"
Из интересного тут - только вычисления поля is_published "на лету", то есть - при отдаче клиенту.
Для тестов сделаем три конечные точки, одна из них - на запись постов в БД, другая - на чтение постов из БД, третья - чисто синтетический тест скорости:
Код роутинга
# api/posts/router.py from fastapi import APIRouter, Depends, Response, status, HTTPException from sqlalchemy.orm import Session from commons.database import get_db from commons import schemas from crud import posts router = APIRouter(tags=["posts"]) @router.get("/posts") def get_posts( per_page: int = 10, page: int = 0, order: schemas.Order = schemas.Order.DESC, session: Session = Depends(get_db), ) -> schemas.PostsOut: return schemas.PostsOut(posts=posts.get(per_page, per_page*page, order, session)) @router.get("/posts_synthetic") def posts_synthetic( per_page: int = 10, ) -> schemas.PostsOut: return schemas.PostsOut( posts=[ schemas.PostOut( id=i, published_at=datetime(2023, 6, 30, 12, 0, 0), updated_at=datetime(2023, 6, 30, 12, 0, 0), title="Статья", content="Съешь ещё этих мягких французских булок, да выпей же чаю.", ) for i in range(per_page) ] ) @router.post("/posts") def create_post( post_in: schemas.PostIn, session: Session = Depends(get_db) ) -> schemas.PostOut: post = posts.create(post_in, session) return post
В полном соответствии с документацией, я отдаю работу по преобразованию моделей из SQLAlchemy в конечный ответ клиенту на плечи FastAPI.
Операции по работе с БД я сократил до минимума, без обновления и удаления:
Код работы с БД
# crud/posts.py from datetime import datetime from typing import Sequence from sqlalchemy import insert, select, update, desc, asc from sqlalchemy.orm import Session from sqlalchemy import exc from commons.schemas import PostIn, Order from commons.models import Post def get(limit: int, offset: int, order: Order, session: Session) -> Sequence[Post]: q = ( select(Post) .where(Post.is_deleted == False) .order_by( desc(Post.published_at) if order is Order.DESC else asc(Post.published_at) ) .limit(limit) .offset(offset) ) return session.execute(q).scalars().all() def create(post_in: PostIn, session: Session) -> Post: q = ( insert(Post) .values( updated_at=datetime.utcnow(), published_at=post_in.published_at, title=post_in.title, content=post_in.content, is_deleted=False, ) .returning(Post) ) post = session.execute(q).scalar_one() session.commit() return post
Изменения при переходе на Pydantic 2
Изменения мажорной версии несут с собой изменения в интерфейсах, поэтому для нашей версии приложения, работающей на версии FastAPI 0.100.0-beta1 + Pydantic 2 тоже потребуются изменения. Быстро пролистав Migration Guide, для своего тестового приложения мне пришлось внести следующие изменения:
Обновление зависимостей. Вот тут меня ждал сюрприз - оказывается, в версии Pydantic 2 они решили вынести знакомые многим
BaseSettingsв отдельную библиотекуpydantic-settings! А она требует в зависимостяхtyping-extensions<4.0.0, когда новая версия алхимии 2.0.17 требуетtyping-extensions>=4.2.0... Хорошо, что в моем маленьком CRUDе всего одна переменная, так что поставилиos.getenvи забыли - но в больших приложениях это может украсть много нервов. UPDATE: после релиза pydantic 2 вышла так же pydantic-settings==2.0.0, в которой данный конфликт зависимостей исправлен.В конфигурации модели Pydantic
orm_modeработает, но предупреждает, что название изменилось наfrom_attributes. Меняем.always=True в модели Pydantic теперь не работает, но зато появился долгожданный декоратор
computed_field- теперь вычисляемое свойство выглядит намного приличней:
class PostOut(BaseModel): ... @computed_field @property def is_published(self) -> bool: return datetime.utcnow() >= self.published_at
В целом, переход на маленьком приложении выглядит безболезненно.
Тестирование производительности
А теперь перейдём к вишенке на торте - самим тестам. Для этого я написал скрипт test.sh, который:
запускает БД, запускает приложение, тестирует клиент с помощью утилиты
ab(Apache benchmark) для приложения на версии FastAPI 0.98.0сносит всё командой docker compose down -v
повторяет первый пункт для приложения на версии FastAPI 0.100-beta1
Сами запросы представляют собой 1000 записей POST /posts и 1000 чтений первых 100 постов GET /posts?per_page=100, количество одновременно выполняемых запросов (параметр c) = 10
Так как я не являюсь уважаемым магистром bash, то вывод данных со скрипта у меня несколько корявый, вам же приведу уже обработанные данные (везде брал средний показатель из трёх прогонов, выполняемых тестом):
fastapi 0.98.0 | fastapi 0.100.0-beta1 | |
|---|---|---|
READ r/s | 126.90 | 371.19 |
READ r/s syntetic | 172.57 | 1203.18 |
WRITE r/s | 342.11 | 352.65 |
MEM USAGE BEFORE | 72.44MiB | 85.95MiB |
MEM USAGE AFTER | 85.95MiB | 98.91MiB |
Выводы:
Главное - скорость отдачи первых 100 постов увеличилась в x2,92 раза! Тут как раз помогает то, что скорость обращения к БД не так сильно играет роль при большом количестве повторяющихся запросов. А вот скорость фреймворка оказывает сильное влияние.
При чисто синтетическом тесте без обращения к БД скорость возрасла в ~7 раз!
Скорость записи, которая в основном зависит от скорости БД - увеличилась, но несущественно.
Но за всё нужно платить - потребление оперативно памяти увеличилось примерно на 15%.
Заключение
Я остался доволен большим увеличением скорости FastAPI. В текущий момент у Pydantic V2 на гитхабе открыто 7 issue, 4 из которых - о статических анализаторах кода, в целом - не много.
При желании провести дополнительные тесты - очень легко форкнуть репозиторий и внести изменения, так что welcome!
Исходный код приложения:
github.com
UPDATE.
Во время написания статьи Pydantic2 вышел из бета стадии в стабильную, а так же вышла версия FastAPI 0.100.0-beta2, использующая стабильную версию Pydantic2.
