Как обрабатываются соединения в SQLAlchemy внутри компании

Context

Здравствуйте, я в основном использую Django для своего основного бэкенд-монолита. В Django вам не нужно самостоятельно обрабатывать соединения с базами данных, это абстрагировано (потому что Django использует паттерн Active Record вместо паттерна Data mapper, используемого в Sqlalchemy).

Я немного почитал, и мне кажется, что я знаю основы различных конструкций Sqlalchemy (таких как движок, соединение и сессия). Тем не менее, мы начали масштабировать наши приложения FastAPI + Sqlalchemy, но я обнаружил, что появляется эта ошибка:

sqlalchemy.exc.TimeoutError - anyio/streams/memory.py:92 - QueuePool limit of size 8 overflow 4 reached, connection timed out, timeout 10.00

Я хотел бы понять, почему это происходит.

Текущая установка

Прямо сейчас у нас запущены экземпляры веб-сервера, для этого выполните следующую команду:

python -m uvicorn somemodule.api.fast_api.main:app --host 0.0.0.0

Как вы можете видеть, я не устанавливаю флаг workers, поэтому uvicorn использует только 1 рабочий. На движке SQLAlchemy мы используем следующие опции:

SQLALCHEMY_ENGINE_OPTIONS = {
    "pool_pre_ping": True,
    "pool_size": 16,
    "max_overflow": 4,
    "pool_timeout": 10,
    "pool_recycle": 300,
}

engine = create_engine(get_db_url(), **SQLALCHEMY_ENGINE_OPTIONS)

Вопросы

Q1: относится ли это к каждому работнику uvicorn?

Я предполагаю, что если я кручу два рабочих в uvicorn, то пул соединений не делится между рабочими, поэтому если размер пула установлен на 16, а у меня два рабочих, то максимальное количество соединений (без учета максимального переполнения) будет 32. Я прав?

Q2: как соединения сочетаются с async?

Прямо сейчас я делаю обычную вещь - создаю сессию, например:

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


DBSessionDependency = Annotated[Session, Depends(get_db)]

Насколько я понимаю, сессия использует соединение из пула. Мы открываем новую сессию в начале запроса, а затем закрываем ее в конце запроса, так что в каждом запросе мы получаем чистое состояние сессии (но перерабатываем соединения из пула).

Теперь в async fast API мы можем обрабатывать гораздо больше запросов одновременно, поэтому я предполагаю, что размер пула является узким местом, и, возможно, из-за этого у нас заканчиваются доступные соединения в пуле. Правильно ли это? Я думаю так: uvicorn можно принять одновременно, скажем, 200 запросов, эти запросы начинают обрабатываться так, что каждому запросу назначается сессия с подключенным соединением из пула.

Итак, я полагаю, что для обработки большого количества запросов в Async Fast API мне также нужно иметь гораздо больший размер пула (или использовать pgbouncer и иметь меньше соединений между всеми рабочими?)

Q3: действительно ли это преимущество - устанавливать новую сессию для каждого запроса?

Мы используем паттерн репозитория, поэтому доступ к бд каждой модели централизован в классе. Не будет ли достаточно просто создать сессию внутри класса и все? Зачем мне нужно возиться с инъекцией зависимостей в Fast API, передавать сессию или использовать что-то вроде context vars для обмена объектом сессии с репозиториями?

Дайте мне знать, если вам нужны какие-либо разъяснения, и заранее спасибо!!! :D

Есть очень удобная настройка, которую можно настроить для выплескивания событий пула в журнал, с помощью которой можно действительно увидеть, что происходит: logging-reset-on-return-events

Также вы можете включить echo=True на самом движке, чтобы увидеть регулярные запросы и обработку транзакций в журнале. (ТАМ МНОГО ТЕКСТА, так что это обычно полезно, только если вы управляете проходящими запросами)

sqlalchemy.create_engine.params.echo

Я не эксперт в этой области, особенно в async, но думаю, что смогу ответить на некоторые из этих вопросов.

Q1:

  • По умолчанию пул не создает соединений, пока его не попросят об этом. Так что если вы создадите 10 рабочих, то будет 0 соединений, пока внутри рабочего кто-то не попросит пул об одном.
  • В противном случае, как я понимаю, 32 - это правильно для 2 рабочих с пулом размером 16.

Q2:

Кроме того, как я понимаю, при создании новой сессии мало что происходит (включая создание соединения), пока вы не используете ее, session.execute(), session.add() и т. д. Поэтому не должно быть больших затрат на создание сессии для каждого запроса, даже если вы не используете ее вообще.

Что касается async, то вы, вероятно, столкнетесь с ограничениями, как и в случае с потоками. Сама база данных обычно имеет установленный лимит соединений. Чтобы увеличить этот лимит, база данных выделяет больше ресурсов, точно так же, как для увеличения количества рабочих веб-узлов требуется больше оперативной памяти и процессора. Думаю, в этом случае после определенного предела вам понадобится pg_bouncer, чтобы начать передавать соединения между серверами и разбираться со всеми этими сложностями. Думаю, все зависит от того, что является "дешевым" в данной ситуации. Может быть, лучше добавить больше кэширования, чтобы не так сильно нагружать базу данных?

Кроме того, не каждый "поток" или "задача", скорее всего, будет использовать соединение в тот же момент, но, думаю, это зависит от вашего приложения. Некоторые будут возвращаться в пул, некоторые - выходить из него и т. д.

Q3:

Обычно сессия использует транзакцию, и вы хотите, чтобы она начиналась и заканчивалась как можно быстрее, но при этом была согласована с откатом. То есть вы не хотите, чтобы в одной и той же сессии был создан новый клиент, а его заказ был отменен (откачен). В зависимости от того, как настроены транзакции и какую базу данных вы используете, вы также не хотите иметь кучу откатов или таймаутов из-за того, что разные потоки/задачи пытаются манипулировать одними и теми же данными в одно и то же время или держат транзакцию открытой в течение длительного времени, поэтому вы хотите, чтобы транзакции были как можно короче.

Кроме того, сессия обеспечивает некоторое кэширование, но вы не хотите держать вещи в сессии слишком долго, потому что тогда они становятся несвежими. Так что снова возникает компромисс. Обычно достаточно запускать сессию и одну транзакцию через один "веб-запрос". Вы можете SELECT создать учетную запись пользователя и использовать ее для выполнения некоторой работы, а затем зафиксировать эту работу или просто отобразить некоторый результат, если вы делали только запросы на чтение. Затем все можно выбросить из "кэша" и начать сначала. При этом вы не хотите, чтобы другие "веб-запросы" испортили ваше состояние в сессии, пока вы пытаетесь с ней работать. Так что это довольно хорошо подходит, 1 сессия на 1 запрос.

Что касается того, куда девать сессионные вещи / т.е. почему бы не поместить их в класс db. Короткий ответ - держите управление сессиями отдельно от манипулирования данными. Эта рекомендация обсуждается здесь, в самом низу: when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it

Вернуться на верх