Fastapi 0.100.0-beta1: ещё быстрее
На прошлой неделе вышла бета-версия нового 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
и забыли -, но в больших приложениях это может украсть много нервов.В конфигурации модели 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