Эта статья рассчитана в большинстве своём на новичков. Тут мы поговорим о том, как не упереться в лимиты подключений к базе, и чтобы приложение в продакшн не упало.

просто смешная картинка
просто смешная картинка

В этой статье будет идти речь о SQLAlchemy и частности PostgreSQL. В Django таких проблем по умолчанию я не видел, а вот в алхимии, приходится вручную мониторить подключения.

Стандартное создание engine и сессии для алхимии вида:

engine = create_async_engine(
   SQLALCHEMY_DATABASE_URL,
   **(
       dict(pool_recycle=900, pool_size=100, max_overflow=3)
   )
)


SessionLocal = sessionmaker(
   autocommit=False,
   autoflush=False,
   bind=engine,

   expire_on_commit=False,
   class_=AsyncSession
)

UPD. Здесь мы будем работать с асинхронной сессией алхимии, но у синхронной такой же механизм действий

И потом мы применяем это примерно так:

async def foo(some: Any):
  # создаем транзакцию
  db = SessionLocal()

  some_do(some)

  # делаем коммит
  await db.commit()
  # закрываем соединение
  await db.close()

НО, что если на моменте some_do() случится ошибка? Тогда у нас не сделается коммит(ну в принципе логично), и что более важно, не закроется соединение, а это уже критично.

Разберемся почему это критично:

  • Превысив лимит на подключения к базе, другие запросы не смогут обрабатываться

  • Лишняя нагрузка на систему

  • Дополнительные открытые подключения в системе

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

Мы разобрались в чем проблема не закрытых соединений, но как тогда с ними работать?

Можно сделать просто try: … except: …

async def foo(some: Any):
  # создаем транзакцию
  db = SessionLocal()
  try:
    some_do(some)
  except Exception as e:
    print(e)
  await db.commit()
  await db.close()

Скажем так, оно будет работать, но всегда есть одно но.. вы серьезно захотите каждый раз делать try except? Всё таки надо найти какой-то более просто способ, чтобы сократить однотипные действия в функции, и убрать вложенность.

Первое что пришло в голову это middlewares, но оно подойдет только если ваше приложение позволяет его реализовать(веб-сервер, телеграм бот), а что если нам нужен универсальный обработчик транзакций? Тут из тени выходит контекстный менеджер

async def foo(some: Any):
  # создаем транзакцию
  async with SessionLocal() as s:
    some_do(some)
    await db.commit()

Мы определенно сократили количество кода в нашей функции, но всё так же остался лишний уровень вложенности, как мы можем избавиться от него? Передать сессию в функцию из декоратора..? Давайте попробуем

def transaction():
  def wrapper(cb):
    async def wrapped(*args, **kwargs):
      async with SessionLocal() as session:
        result = await cb(session, *args, **kwargs)
        await session.commit()
        return result

    return wrapped

  return wrapper


@transaction()
async def foo(session: AsyncSession, some: Any):
  some_do(session, some)

Хмм, кода стало больше, но зато в функции теперь без вложенности и минимальное количество строк. Но погодите-ка, мы каждый раз должны передавать в функцию объект сессии, что создает 1 дополнительную зависимость. Попробуем решить, а как? Просто получать из переменной объект сессии? Да, так в питоне можно, будем использовать contextvars. Можете посмотреть как там что работает, но если кратко, то, что мы обьявляем в родительской функции в переменной ContextVar, то значение передается в вызываемые функции.

Немного примеров:

import asyncio
from contextvars import ContextVar

var: ContextVar[int] = ContextVar('var') # Тайп хинт показывает, что мы планируем передавать числовой тип в переменную


async def bar():
  value = var.get()
  print(f"In context var value is {value}")


async def foo():
  token = var.set(777)
  await bar()
  var.reset(token)

asyncio.run(foo())  # In context var value is 777

На выходе мы получаем строку: In context var value is 777

Как видно, мы не передаем явно в функцию никаких значений, но это значение доступно по переменной, и в этом вся магия.

Значит мы можем применить это и к нашей сессии:

P = ParamSpec("P")
T = TypeVar("T")


def require_session():
  session = db_session_var.get()
  assert session is not None, "Session context is not provided"
  return session


def transaction():
  def wrapper(
    cb: Callable[P, Coroutine[Any, Any, T]]
  ) -> Callable[P, Coroutine[Any, Any, T]]:
    @wraps(cb)
    async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
      if db_session_var.get() is not None:
        return await cb(*args, **kwargs)

      async with cast(AsyncSession, SessionLocal()) as session:
        with use_context_value(db_session_var, session):
          result = await cb(*args, **kwargs)
          await session.commit()
          return result

    return wrapped

  return wrapper


@contextmanager
def use_context_value(context: ContextVar[T], value: T):
  reset = context.set(value)
  try:
    yield
  finally:
    context.reset(reset)


db_session_var: ContextVar[AsyncSession | None] = ContextVar("db_session_var", default=None)

UPD. Это уже окончательный вид для нашего менеджера, с type hinting и всякими плюшками, чтобы было приятнее работать.

Получается огромный блок для менеджера транзакций, но зато теперь у нас есть простой доступ к нашему объекту сессии:

@transaction()
async def foo():
  db = require_session()
  some_do()

И, в чем основная прелесть этого декоратора, то что для под вызываемых функций мы так же их оборачиваем их в transaction(), но у нас используется одна и та же сессия, что дает нам 1 подключение на 1 обработку запроса.

Теперь даже если что-то случится во время выполнения запроса, то у нас автоматически закроется сессия, и у нас не создается очередь из подключений ожидающих закрытие.

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

Спасибо за то что прочитали статью! Если у вас, опытных разработчиков,  есть какие-то замечания к статье, буду ждать в комментариях вас

Комментарии (24)


  1. baldr
    30.12.2023 11:06
    +10

    То есть новичков вы сразу решили научить использованию глобальных переменных, причем неявно скрытых где-то в контексте? Не видите ли вы здесь каких-то недостатков?


    1. cicwak Автор
      30.12.2023 11:06

      Подскажи конкретнее о каких глобальных переменных речь? Довнесу в статью тогда. Из неявного только импорты хинтов и алхимии, все остальные переменные я попытался вынести в код, чтобы было видно людям.


      1. baldr
        30.12.2023 11:06
        +6

        contextvars - это, фактически, использование глобальной переменной. Примерно то же самое, что `def func(**kwargs)`. Из объявления функции непонятно с какими переменными она работает, что она изменяет. Это антипаттерн. Это может быть, иногда, удобно, но я бы советовал явно передавать аргументы.


        1. cicwak Автор
          30.12.2023 11:06

          хмм
          у меня чуть-чуть другое мнение. Ключевое преимущество, когда много вложенных функций, куда проще работать с базой. Обернул в декоратор, получил сессию. И не надо передавать в функцию никаких объектов, и ничего не надо редактировать(условно понадобился объект сессии в функции, а она у вас 100 раз вызывается, везде редактировать надо будет). Конечно пример это крайний случай, но всё же.

          И так же я бы не сказал что это глобальная переменная, в плане логики работы. У вас на каждый стек вызовов функций, свой контекст. То есть соседний вызов этой же функции не получил то значение, которое есть в этой функции

          Но это лишь моё мнение)


          1. Sap_ru
            30.12.2023 11:06
            +2

            А что изменится, если заменить ContextVar головной переменной или полем синглтона? Рефакторить проще, отлаживать проще. Минусов относительно ContextVar никаких. Зачем тогда ContexVar и чем он лучше обратной переменной или поля некоторого глобального класса?


            1. baldr
              30.12.2023 11:06

              В целом, никак не изменится, вы верно подчеркнули суть. Можно и с глобальной переменной провернуть, и с замыканием, наверное. Сам этот contextvars немного попахивает. Я бы понял если бы это была сторонняя библиотека, но оно в stdlib теперь.

              В последниее время Python нехорошо скатывается в очень странные изменения не только в синтаксисе (привет, switch), но и в идеологии. Возможно, Гвидо раньше как-то сдерживал самые идиотские пулл-реквесты, но теперь вместо него решения принимают другие люди.


            1. VasiliySoldatkin
              30.12.2023 11:06

              Не будет изоляции в асинхронном коде, как уже писали здесь


            1. Veritaris
              30.12.2023 11:06
              +1

              Мне кажется что в этом случае ContextVar выступает в роле ThreadLocal, только для питона AsyncContextLocal-переменной. Так как автор явно упомянул что пользуется этим кодом для Telegram-ботов и веба, а также учитывая что функции async, то "обернуть" сессию каждого запроса в ContextVar для меня кажется разумным решением чтобы сохранить их независимость друг от друга


              1. Sap_ru
                30.12.2023 11:06
                +1

                Как бы да, но оно почему-то реализовано самым безобразным образом. Так, чтобы уж точно не поддерживаться никакими синтаксическими анализаторами IDE и не рефакториться толком.
                Поверх этого можно было намазать синтаксического сахара, который бы позволил представить тоже самое в виде полей класса, или чего-то вроде NamedTupe или Dataclass. Если бы это было из коробки, пусть и ввиде ещё одного уровня абстракции, то нормально было бы. Но оно какое-то безобразное всё.

                Ну, и сама идея класть соединение в контекст потока как-то дурно пахнет. Очень легко всё это забыть и потерять. Аналог с ThreadLocal хорош - никто же не кладёт туда в здравом уме соединение к БД, так как чуйкой чуют, что потом проблем больше чем пользы. Побочных эффектов массу можно придумать. А точно есть гарантия что совершенно никогда не будет открытия нового конекта в том же контексте? А если потом когда понадобится, забудут же где на самом деле этот коннект хранится и что он не реентерабельный.
                Хранение потенциально изменяющихся или дополняющихся данных (а если потом несколько коннектов понадобиться или несколько баз? или в одном пуле несколько коннектов смешаются? а при тестировании точно не будет приключений?) в неявные и ни с чем несвязанных контексты это очень плохо. Какой-то нибудь явный объект контекста и то в 100 раз лучше было бы. Да, нужно было бы как-то это объект тащить с собой, но зато он явный, отслеживается и рефакторится.
                Не говоря уже о том, что конечная цель всё же не просто конект к базе иметь, а всю пользовательскую сессию. Ну, так и решение нужно делать сразу под возможность хранения каких-то данных сессии. А так оно всё раскидано по разным местам будет.


                1. cicwak Автор
                  30.12.2023 11:06

                  Ну, и сама идея класть соединение в контекст потока как-то дурно пахнет.

                  Согласен, могут возникнуть проблемы. В частности, когда задачу в фон класть через create_task, то у основного хендлера сессия закроется, а у задачи в create_task он ведь тоже закроется. И будет неявная ошибка. Для этого можно добавить 1 параметр, условно new_session, который отвечает за то чтобы добавить отдельную сессию на функцию, чтобы не зависеть от главной функции. Но да, всё оно имеет свои плюсы и минусы

                  А точно есть гарантия что совершенно никогда не будет открытия нового конекта в том же контексте? А если потом когда понадобится, забудут же где на самом деле этот коннект хранится и что он не реентерабельный.

                  Да, гарантия есть. При каждом вызове декоратора идет проверка, есть или нет сессия, если есть, отдается "старая" сессия, если нет, создается новая сессия.


  1. Yuribtr
    30.12.2023 11:06
    +3

    Хмм... использование сессии в глобальной переменной. А у вас нагрузочные тесты на этот код есть? Что произойдет при одновременном выполнении двух и более функций foo() ?

    Как я вижу в функции use_context_value вы в переданный инстанс ContextVar сохраняете предварительно открытую сессию. Затем через require_session в функции foo() вы ее считываете. Но что будет если между этими двумя событиями вызовется еще раз transaction()?

    Как я понимаю возможна такая ситуация когда в require_session() вы получите не свою сессию, а ту, которая была создана позже, во втором заходе transaction()

    Если все будет хорошо, и запросы пройдут успешно вы даже не заметите ничего, ну будет небольшая деградация производительности. Но если в какой либо функции foo() возникнет исключение - у вас откатятся обе "транзакции", так как по факту они будут в одной сессии. Если быть точнее там будет не две транзакции, а одна, так как первая созданная сессия будет пустая, а во второй сессии будут выполняться сразу две (или больше) функций foo()


    1. baldr
      30.12.2023 11:06
      +2

      Кстати, вопрос про реентерабельность хороший. Необязательно даже иметь рекурсию чтобы вызвать foo() два раза - достаточно иметь две функции с таким декоратором foo() и bar() и вызвать одну из другой.

      Что касается сессии - это contextvars, аналог threading.local, но для асинхронного кода. Если я правильно понял ваш вопрос - то нет, два разных запроса не должны смешаться.


      1. Yuribtr
        30.12.2023 11:06

        Да, действительно, вы правы. Инстансы ContextVar изолированы между корутинами.


    1. cicwak Автор
      30.12.2023 11:06

      Вижу что baidr вам уже ответил по поводу того что переменная изолирована в корутинах. Отвечу по нагрузочному тестированию

      Мы с коллегой используем это в работе, проект небольшой, но при 100-1000 рпс проблем нет, вообще никаких. Возможно, это создаст какие-то проблемы при 100000рпс, но до этого пока далеко. Так как по сути мы создаём 1 транзакцию, на 1 хендлер(что важно), и далее в вызываемых функциях мы используем транзакцию хендлера, то не вижу даже предполагаемых проблем, где что может замедлять работу приложения


  1. djamali
    30.12.2023 11:06
    -1

    Хм.. ничего непонятно


  1. ValeryIvanov
    30.12.2023 11:06
    +4

    Почему, а главное зачем?

    engine = create_async_engine(
       SQLALCHEMY_DATABASE_URL,
       **(dict(pool_recycle=900, pool_size=100, max_overflow=3))  # ??? 
    )
    

    В этом есть какой-то практический смысл?


    1. cicwak Автор
      30.12.2023 11:06

      В этом нет никакого смысла, просто мой коллега так сделал, решил и оставить

      Но статья совсем не об этом же)


      1. ef_end_y
        30.12.2023 11:06
        +3

        Она-то не о том, но я тоже запнулся на этом месте ища потаённый смысл.

        А если у вас будет 2 бд, как вы разрулите эту ситуацию? В декораторе имя передавать, наверное, норм


        1. cicwak Автор
          30.12.2023 11:06

          Хмм, очень хороший вопрос
          Если вы имеете в виду допустим шардированный постгре, то возможно сделать ещё один уровень абстракции, над транзакцией наверное.. хотя не особо уверен что поможет, тут надо подумать очень хорошо как это красиво реализовать
          Если просто допустим ещё редис, то лично в питоне он вообще как глобал переменная используется, и там создается подключение на каждый запрос(да можно объединить запросы). А так для другой СУБД ничего не мешает сделать такой же декоратор
          Вы задали очень правильный вопрос, пока не готов на него ответить!


  1. santjagocorkez
    30.12.2023 11:06
    +1

    Чем необязательный параметр session или connection не угодил? Ловишь его в декораторе, если он пустой или не передан — инициализируешь в декораторе и передаешь, если передан — pass through. Это позволит звать одну и ту же функцию как из другой функции над уже работающей транзакцией, так и звать ее как есть, представляя декоратору позаботиться о подготовке новой сессии.


    1. cicwak Автор
      30.12.2023 11:06

      Если я правильно понял ваш вопрос
      Так в декораторе и так реализовано, если уже есть сессия, то он просто обрабатывает функцию. Если нет, то создает сессию и обрабатывает функцию


      1. santjagocorkez
        30.12.2023 11:06

        А если сессия есть, но мне надо, чтобы функция явным порядком создала себе отдельную сессию и отработала в ней?


  1. Tishka17
    30.12.2023 11:06
    +3

    Рекомендую автору почитать зачем нужен dependency injection, dependency inversion и зачем люди делают зависимости более явными (спойлер: чтобы код работал более очевидно). Вы соорудили какого-то монстра чтобы сэкономить на передаче сессии, зато потом непонятно как это поддерживать.


  1. Eugene_Rymarev
    30.12.2023 11:06

    А два пробела вместо четырёх никого не смутило?