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

FastAPI + SQLAlchemy: IO asíncrona y Back Pressure

Quería un SQLAlchemy API asíncrono pero acabé construyendo un SQLAlchemy API síncrono.

4 junio 2021
post main image
pexels.com

APIs son cada vez más importantes. Las empresas quieren compartir sus datos con los clientes. O quieren permitir a terceros crear aplicaciones basadas en sus APIs.

Hace unos meses creé un API con Flask, SQLAlchemy, Marshmallow y APISpec, no fue realmente difícil, y funciona bien. Luego leí más sobre FastAPI, un API framework que también soporta Python async out of the box. Está basado en Starlette, un ASGI ligero framework para construir servicios asyncio de alto rendimiento.

Este post trata de por qué construyo una aplicación FastAPI síncrona SQLAlchemy demo API y no una asíncrona SQLAlchemy API. El tema es complejo. A continuación cito algunos textos de la página web de FastAPI y de Github para que no te equivoques.

Por si te interesa, puedes ver la aplicación demo aquí:

https://fastapifriends.peterspython.com

API con acceso asíncrono a la base de datos

En general, las peticiones asíncronas no son más rápidas que las síncronas, sino que hacen lo mismo. Una petición sincrónica detiene la ejecución de la persona que llama y debe esperar a que se complete la tarea. Con las peticiones asíncronas, la petición se pone en cola y se procesa un tiempo después, no se espera el resultado, lo que permite hacer otras cosas mientras se espera la finalización de la tarea.

Operación asíncrona y Back Pressure

Siempre he tenido mucho cuidado con las operaciones asíncronas. Un ejemplo trivial es una página web con un botón que utiliza una llamada AJAX . He permitido una nueva petición sólo cuando la anterior ha terminado. Muy conservador, pero sólo quiero evitar problemas.

Armin Ronacher, creador de la web Flask framework, escribió un interesante artículo sobre Back Pressure, ver enlaces más abajo, por favor leer. En el caso síncrono, el acceso a nuestra base de datos se bloquea cuando nos quedamos sin sesiones de base de datos, las nuevas peticiones se bloquean hasta que las sesiones vuelven a estar disponibles.

En el caso asíncrono, todas las peticiones se añaden a una cola a la espera de ser procesadas. Cuando el ritmo de entrada es superior a la capacidad del sistema para procesarlas, el servidor se queda sin memoria y se bloquea. Se puede evitar esto mirando el número máximo de peticiones que se pueden poner en cola. Parece fácil, pero no lo es.

Cómo prevenir los problemas de Back Pressure

FastAPI Github issue #857 - @dmontagu escribió:

Aunque sería mejor si hubiera una forma directa de manejar esto en la capa de aplicación, incluso si tienes una carga muy pesada, en la práctica normalmente puedes prevenir los problemas de Back Pressure simplemente usando un balanceador de carga con límite de velocidad y una buena política de auto-escalado. Esto no se ocupará de todos los casos, y no te salvará de decisiones de diseño particularmente malas, pero para el 99% de las aplicaciones python desplegadas esto ya sería una exageración.

Starlette Github issue #802 - @tomchristie escribió:

Uvicorn actualmente permite establecer un --limit-concurrency <int> que limita el número máximo de tareas permitidas que pueden estar ejecutándose antes de que se devuelvan 503. En el caso más restringido se podría establecer esto basado en el número de conexiones de la base de datos
disponibles. En realidad, podríamos hacerlo mejor: sería mejor si Uvicorn pusiera en cola las peticiones que superen ese límite y sólo respondiera con un 503 después de un periodo de tiempo de espera.

La otra cosa que podríamos hacer y que sería inteligente es poner por defecto las excepciones de tiempo de espera en las respuestas 503. Así que... suponiendo que tienes un límite de recursos en: la adquisición de conexiones a la base de datos, la conexión a la caché, el envío de peticiones HTTP salientes, el envío de correos electrónicos.

Este tipo de componentes siempre deberían tener algún tipo de limitación de recursos disponible, y deberían levantar timeouts si el recurso no puede ser adquirido durante un periodo de tiempo. Si Starlette está configurado para mapear esas excepciones en respuestas 503, entonces obtendrás un comportamiento de fallo elegante cuando un servidor esté sobrecargado".

SQLAlchemy sincrónico vs asíncrono

Utilizo SQLAlchemy ORM cuando es posible. SQLAlchemy 1.4 se publicó recientemente y tiene soporte para Python asyncio. Pero hay limitaciones. Una de ellas es que no se puede usar lazy loading porque los objetos relacionados pueden caducar y podrían lanzar una excepción asyncio . Una forma de evitarlo es utilizar la función 'run_sync()' que permite ejecutar funciones síncronas bajo asyncio.

Pydantic's orm_mode

De la página FastAPI 'SQL (Relational) Databases':

Sin orm_mode, si devolviera un modelo SQLAlchemy de su operación de ruta, no incluiría los datos de las relaciones. Incluso si declaró esas relaciones en sus modelos Pydantic . Pero con el modo ORM , como el propio Pydantic intentará acceder a los datos que necesita desde los atributos (en lugar de asumir un dict), puedes declarar los datos específicos que quieres devolver y podrá ir a buscarlos, incluso desde ORMs.

Esto significaría que si utilizas un modelo Pydantic para la respuesta, obtendrás también los datos de la relación. No sé si esto siempre obtendrá los datos de la relación de forma fiable, debe probar esto.

No utilizo IO asíncrono (todavía)

En resumen, async IO introduce complejidades adicionales y, en mi caso, el rendimiento probablemente no mejorará. Hay muchas aplicaciones que pueden beneficiarse, pero un API estándar impulsado por la base de datos no lo hace. Entonces, ¿por qué seguir utilizando FastAPI? La razón principal para mí es que en un futuro próximo puedo querer añadir más funcionalidad que puede beneficiarse de asyncio.

SQLAlchemy sincrónico: un objeto de sesión de base de datos durante una solicitud

Estoy utilizando el método y el código descrito en la sección FastAPI 'Sesión de base de datos alternativa con middleware': 'El middleware que añadiremos (sólo una función) creará una nueva SQLAlchemy SessionLocal para cada petición, la añadirá a la petición y la cerrará una vez que la petición haya terminado.'

El código que se muestra en esta sección es:

@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

A todas las peticiones se les asigna un objeto de sesión de la base de datos. Si alguna vez queremos añadir alguna(s) función(es) asíncrona(s) podemos excluir estas rutas de db_session_middleware().

Dependencias que devuelven objetos de base de datos

Si está utilizando SQLAlchemy síncrono y utiliza Dependency Injection que devuelve un objeto de base de datos, DEBE utilizar un objeto de sesión de base de datos que sea válido toda la solicitud. De lo contrario, el objeto recuperado puede caducar. Esto parece obvio pero la documentación de FastAPI no es muy clara al respecto. Por ejemplo, supongamos que tenemos una función 'read_items()' con una dependencia 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

Supongamos que no utilizamos la función db_session_middleware() como se mencionó anteriormente, sino que utilizaríamos:

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

En este caso podemos tener varias sesiones de base de datos durante una petición y el objeto db_user puede expirar en la función read_items() , porque la sesión de base de datos se cierra después de procesar get_current_user().

Si utilizáramos la función asíncrona SQLAlchemy, el objeto db_user devuelto se desprende de la sesión y permanecerá disponible. Al menos eso es lo que yo esperaría.

Resumen

Quería crear un API basado en FastAPI y SQLAlchemy y PostgreSQL. Lo primero que se me planteó fue la cuestión de si debía utilizar SQLAlchemy síncrono o asíncrono. Ya sabía que las operaciones asíncronas pueden causar problemas si no se manejan adecuadamente. Fue informativo leer lo que otros dijeron sobre la IO asíncrona (y FastAPI). Para evitar problemas, me decidí por el SQLAlchemy síncrono primero.

Enlaces / créditos

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/

Deje un comentario

Comente de forma anónima o inicie sesión para comentar.

Comentarios

Deje una respuesta.

Responda de forma anónima o inicie sesión para responder.