angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

FastAPI + SQLAlchemy : E/S asynchrones et Back Pressure

Je voulais un SQLAlchemy API asynchrone mais j'ai fini par construire un SQLAlchemy API synchrone.

4 juin 2021
Dans API, SQLAlchemy
post main image
pexels.com

APIs deviennent de plus en plus importants. Les entreprises veulent partager leurs données avec leurs clients. Ou veulent permettre à des tiers de créer des applications basées sur leur APIs.

Il y a quelques mois, j'ai créé un API avec Flask, SQLAlchemy, Marshmallow et APISpec, ce n'était pas très difficile et cela fonctionne bien. Puis j'ai lu davantage sur FastAPI, un API framework qui supporte également Python asynchrone. Il est basé sur Starlette, un ASGI léger pour la construction de services framework à haute performance asyncio .

Ce billet explique pourquoi je construis une application synchrone FastAPI SQLAlchemy de démonstration API et non une application asynchrone SQLAlchemy API. Le sujet est complexe. Je cite ci-dessous quelques textes du site web FastAPI et de Github pour éviter de vous raconter une histoire fausse.

Au cas où vous seriez intéressé, vous pouvez voir l'application de démonstration ici :

https://fastapifriends.peterspython.com

API avec accès asynchrone à la base de données

En général, les requêtes asynchrones ne sont pas plus rapides que les requêtes synchrones, elles font la même chose. Une requête synchrone arrête l'exécution de l'appelant et vous devez attendre la fin de la tâche. Avec les demandes asynchrones, la demande est mise en file d'attente et traitée un peu plus tard, vous n'attendez pas le résultat, ce qui vous permet de faire autre chose en attendant l'achèvement de la tâche.

Fonctionnement asynchrone et Back Pressure

J'ai toujours été très prudent avec les opérations asynchrones. Un exemple trivial est une page web avec un bouton qui utilise un appel AJAX . J'ai autorisé une nouvelle requête uniquement lorsque la requête précédente était terminée. C'est très conservateur mais je veux juste éviter les problèmes.

Armin Ronacher, créateur du site web Flask framework, a écrit un article intéressant sur Back Pressure, voir les liens ci-dessous, veuillez lire. Dans le cas synchrone, l'accès à notre base de données est bloqué lorsque nous sommes à court de sessions de bases de données, les nouvelles demandes sont bloquées jusqu'à ce que les sessions soient à nouveau disponibles.

Dans le cas asynchrone, toutes les demandes sont ajoutées à une file d'attente en attendant d'être traitées. Lorsque le nombre de demandes entrantes est supérieur à la capacité du système à les traiter, le serveur se retrouve à court de mémoire et tombe en panne. Vous pouvez éviter cela en examinant le nombre maximal de demandes pouvant être mises en file d'attente. Cela semble facile, mais ce n'est pas le cas.

Prévention des problèmes de Back Pressure

FastAPI Github issue #857 - @dmontagu a écrit :

Bien que ce serait mieux s'il y avait un moyen direct de gérer cela dans la couche application, même si vous avez une charge très lourde, en pratique, vous pouvez généralement éviter les problèmes de Back Pressure en utilisant simplement un équilibreur de charge limitant le taux et une bonne politique d'auto-scaling. Cela ne résoudra pas tous les cas et ne vous sauvera pas des choix de conception particulièrement mauvais, mais pour 99 % des applications python déployées, ce serait déjà trop.

Starlette Github issue #802 - @tomchristie a écrit :

Uvicorn vous permet actuellement de définir un --limit-concurrency <int> qui limite de manière stricte le nombre maximum de tâches autorisées qui peuvent être exécutées avant que des 503 ne soient retournés. Dans le cas le plus contraignant, vous pourriez simplement définir cette valeur en fonction du nombre de connexions disponibles à la base de données
. En fait, nous pourrions faire mieux - ce serait mieux si Uvicorn mettait en file d'attente les demandes au-delà de cette limite, et ne répondait par un 503 qu'après un délai d'attente.

L'autre chose que nous pourrions faire et qui serait intelligente, c'est d'attribuer par défaut des exceptions de délai aux réponses 503. Donc... supposons que vous ayez une limite de ressources pour : l'acquisition de connexions à la base de données, la connexion au cache, l'envoi de requêtes HTTP sortantes, l'envoi d'e-mails.

Ces types de composants devraient toujours avoir une sorte de limitation de pool disponible, et devraient lever des timeouts si la ressource ne peut être acquise pendant un certain temps. Si Starlette est configuré pour faire correspondre ces exceptions aux réponses 503, vous obtiendrez un comportement d'échec gracieux lorsqu'un serveur est surchargé.

SQLAlchemy synchrone et asynchrone

J'utilise SQLAlchemy ORM lorsque cela est possible. La version 1.4 de SQLAlchemy est sortie récemment et prend en charge Python asyncio. Mais il y a des limitations. L'une d'entre elles est que vous ne pouvez pas utiliser le chargement paresseux, car les objets liés peuvent expirer et lever une exception asyncio . Une solution consiste à utiliser la fonction 'run_sync()' qui permet d'exécuter des fonctions synchrones sous asyncio.

Pydantic's orm_mode

De la page FastAPI 'SQL (Relational) Databases' :

Sans orm_mode, si vous renvoyez un modèle SQLAlchemy à partir de votre opération de parcours, il n'inclura pas les données de relation. Même si vous avez déclaré ces relations dans vos modèles Pydantic . Mais avec le mode ORM , comme Pydantic essaiera d'accéder aux données dont il a besoin à partir des attributs (au lieu de supposer un dict), vous pouvez déclarer les données spécifiques que vous voulez renvoyer et il sera capable d'aller les chercher, même à partir de ORMs.

Cela signifie que si vous utilisez un modèle Pydantic pour la réponse, vous obtiendrez également les données de la relation. Je ne sais pas si cela permet toujours d'obtenir les données de relation de manière fiable, mais je dois essayer.

Je n'utilise pas (encore) les entrées-sorties asynchrones.

En résumé, les entrées-sorties asynchrones introduisent des complexités supplémentaires et, dans mon cas, les performances ne s'amélioreront probablement pas. De nombreuses applications peuvent en bénéficier, mais pas une base de données standard pilotée par API . Alors pourquoi continuer à utiliser FastAPI ? La raison principale est que dans un avenir proche, je pourrais vouloir ajouter d'autres fonctionnalités qui pourraient bénéficier de asyncio.

SQLAlchemy synchrone : un objet de session de base de données pendant une requête

J'utilise la méthode et le code décrits dans la section FastAPI 'Alternative DB session with middleware' : 'Le middleware que nous ajouterons (juste une fonction) créera une nouvelle SQLAlchemy SessionLocal pour chaque requête, l'ajoutera à la requête et la fermera une fois la requête terminée'.

Le code présenté dans cette section est :

@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
    response = Response("Internal server error", status_code=500)
    try:
        request.state.db = SessionLocal()
        response = await call_next(request)
    finally:
        request.state.db.close()
    return response


def get_db(request: Request):
    return request.state.db

Toutes les requêtes se voient attribuer un objet de session de base de données. Si jamais nous voulons ajouter une ou plusieurs fonctions asynchrones, nous pouvons exclure ces routes de db_session_middleware().

Dépendances qui renvoient des objets de base de données

Si vous utilisez une SQLAlchemy synchrone et que vous utilisez une Dependency Injection qui renvoie un objet de base de données, vous DEVEZ utiliser un objet de session de base de données qui est valide pendant toute la durée de la demande. Sinon, l'objet récupéré peut expirer. Cela semble évident mais la documentation de FastAPI n'est pas très claire à ce sujet. Par exemple, supposons que nous ayons une fonction 'read_items()' avec une dépendance get_current_user() :

def get_current_user(
    db: Session = Depends(get_db)):
):
    # get  User  object by  user_id in JWT
    ...
    return db_user


@app.get("/items")
def read_items(
        db_user: schemas.User  = Depends(get_current_user)
        db: Session = Depends(get_db),
):
    # do something
    ...
    items = crud.get_items(db, db_user)
    return  users

Supposons que nous n'utilisions pas la fonction db_session_middleware() comme indiqué ci-dessus, mais que nous utilisions plutôt la fonction get_current_user() :

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Dans ce cas, nous pouvons avoir plusieurs sessions de base de données pendant une requête et l'objet db_user peut expirer dans la fonction read_items() , car la session de base de données est fermée après le traitement de la get_current_user().

Si nous utilisons la fonction asynchrone SQLAlchemy, l'objet db_user renvoyé est détaché de la session et reste disponible. C'est du moins ce à quoi je m'attendais.

Résumé

Je voulais créer un API basé sur FastAPI et SQLAlchemy et PostgreSQL. La première chose à laquelle j'ai été confrontée était la question de savoir s'il fallait utiliser le SQLAlchemy synchrone ou asynchrone. Je savais déjà que les opérations asynchrones peuvent causer des problèmes si elles ne sont pas traitées correctement. Il a été instructif de lire ce que d'autres ont dit à propos des E/S asynchrones (et de la FastAPI). Pour éviter les problèmes, j'ai décidé de commencer par les SQLAlchemy synchrones.

Liens / crédits

Alternative DB session with middleware
https://fastapi.tiangolo.com/tutorial/sql-databases/

Asynchronous I/O (asyncio)
https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html

Asynchronous Python and Databases
https://techspot.zzzeek.org/2015/02/15/asynchronous-python-and-databases/

asyncio support for SQLAlchemy (and Flask, and any other blocking-IO library)
https://gist.github.com/zzzeek/2a8d94b03e46b8676a063a32f78140f1

Back pressure? #802
https://github.com/encode/starlette/issues/802#issuecomment-574606003

Backpressure explained — the resisted flow of data through software
https://medium.com/@jayphelps/backpressure-explained-the-flow-of-data-through-software-2350b3e77ce7

I'm not feeling the async pressure
https://lucumr.pocoo.org/2020/1/1/async-pressure/

Starlette
https://www.starlette.io/

Laissez un commentaire

Commentez anonymement ou connectez-vous pour commenter.

Commentaires

Laissez une réponse

Répondez de manière anonyme ou connectez-vous pour répondre.