Обработка транзакций с помощью Psycopg2
Last updated
Was this helpful?
Last updated
Was this helpful?
Базы данных необходимы для большинства приложений, однако большая часть взаимодействия с базами данных часто игнорируется разработчиками Python, которые используют библиотеки более высокого уровня, такие как Django или SQLAlchemy. Мы используем и любим PostgreSQL с Psycopg2, но недавно я понял, что не очень хорошо понимаю, как именно psycopg2 реализует основные концепции баз данных: в частности, изоляция транзакций и безопасность потоков.
Вот что говорится в документации относительно транзакций:
Транзакции обрабатываются классом соединения connection. По умолчанию при первой отправке команды в базу данных (с использованием одного из курсоров cursor, созданных соединением) создается новая транзакция. Следующие команды базы данных будут выполнены в контексте той же транзакции — не только команды, выданные первым курсором, но и те, которые выданы всеми курсорами, созданными тем же соединением. Если какая-либо команда не будет выполнена, транзакция будет прервана, и никакая следующая команда не будет выполнена до вызова метода rollback().
Таким образом, транзакции зависят от соединения. Когда вы создаете соединение, вы можете создать несколько курсоров, транзакция начинается, когда первый курсор выдает команду execute – все команды, выполняемые всеми курсорами после этого, являются частью одной транзакции до фиксации commit или отката rollback. После вызова любого из этих методов следующая транзакция запускается при следующем вызове execute.
Это поднимает очень важный момент:
По умолчанию даже простой SELECT начнет транзакцию: в долго работающих программах, если не предпринимать никаких дальнейших действий, сеанс останется «простаивающим в транзакции» (idle in transaction, что нежелательно по нескольким причинам (сеанс удерживает блокировки, таблицы раздуваются…). Для долгоживущих скриптов либо убедитесь, что вы завершаете транзакцию как можно скорее, либо используйте соединение с автофиксацией autocommit.
Похоже, это указывает на то, что при работе напрямую с psycopg2 понимание транзакций необходимо для написания стабильных скриптов. Поэтому в этой статье подробно описаны мои заметки и методы для более эффективной работы с PostgreSQL из Python.
Чтобы продемонстрировать код в этой статье блога, нам нужна база данных. Классический пример базы данных, который преподают студентам, — это банковский счет, поэтому мы продолжим эту тему здесь! Извините, если эта часть утомительна, можете пропустить ее. В файле schema.sql
я определил следующую схему как DDL (язык определения данных):
Это создает простую базу данных с двумя таблицами. Таблица владельцев содержит PIN-код для проверки. Владельцы могут иметь один или несколько счетов, а счета имеют ограничение, что баланс никогда не может быть ниже $0.00. Мы также можем заполнить базу данных некоторыми начальными данными:
Переходя к коду Python, мы можем добавить некоторый шаблонный код, который позволит нам подключиться к базе данных и выполнить SQL в нашем файле выше:
Функция connect ищет строку подключения к базе данных в переменной среды $DATABASE_URL
. Поскольку код конфигурации базы данных может содержать пароли и сетевую информацию, всегда лучше хранить его в среде или в локальном, защищенном файле конфигурации, к которому может получить доступ только процесс и который не может быть зарегистрирован с помощью кода. Строка подключения должна выглядеть примерно так: postgresql://user@localhost:5432/dbname
.
Функция createdb считывает SQL из файла schema.sql
и выполняет его в базе данных. Обратите внимание, что именно поэтому у нас есть операторы DROP TABLE IF EXISTS
, чтобы мы могли гарантировать, что всегда начинаем с новой базы данных при запуске этого скрипта. Эта функция также дает нам первый взгляд на транзакции и взаимодействие базы данных с Python.
Транзакция состоит из одной или нескольких связанных операций, которые представляют собой одну единицу работы. Например, в примере с банковским счетом у вас может быть транзакция депозита, которая выполняет запросы для поиска счета и проверки пользователя, добавления записи в список ежедневных депозитов, проверки достижения ежедневного лимита депозита, а затем изменения баланса счета. Все эти операции представляют собой все шаги, необходимые для выполнения депозита.
Цель транзакции заключается в том, чтобы после завершения транзакции база данных оставалась в едином согласованном состоянии. Согласованность часто определяется инвариантами или ограничениями, которые на более высоком уровне описывают, как база данных должна хранить информацию. С точки зрения программирования, если эти ограничения нарушаются, возникает исключение. Например, база данных имеет ограничение positive_balance, если баланс счета становится ниже нуля, возникает исключение. При нарушении этого ограничения база данных должна оставаться неизменной, а все операции, выполненные транзакцией, должны быть отменены (rollback). Если транзакция прошла успешно, мы можем зафиксировать изменения (commit), что гарантирует, что база данных успешно применила нашу операцию.
Так зачем же нам нужно управлять транзакциями? Рассмотрим следующий код:
Первый curs.execute запускает исключение ограничения, которое перехватывается и выводится. Однако база данных теперь находится в несогласованном состоянии. При попытке выполнить второй запрос возникает psycopg2.InternalError: "current transaction is aborted, commands ignored until end of transaction block"
. Чтобы продолжить работу с приложением, необходимо вызвать conn.rollback()
, чтобы завершить транзакцию и начать новую.
По сути, это означает, что все транзакции можно обернуть в блок try, если они завершаются успешно, их можно зафиксировать, однако если они вызывают исключение, их необходимо откатить. Базовый декоратор, который делает это, выглядит следующим образом:
Этот декоратор оборачивает указанную функцию, возвращая внутреннюю функцию, которая внедряет новое соединение в качестве первого аргумента в декорированную функцию. Если декорированная функция вызывает исключение, транзакция откатывается, а ошибка регистрируется.
Это позволяет вам писать код с использованием with следующим образом:
Контекстный менеджер позволяет вам легко объединить две транзакции внутри одной функции — конечно, это может быть не по теме. Однако не составит труда объединить методы декоратора и контекстного менеджера в два шага (подробнее об этом в уровнях изоляции).
Итак, давайте поговорим о двух конкретных транзакциях для воображаемого приложения базы данных: депозит и снятие. Каждая из этих операций состоит из нескольких шагов:
Подтвердите пользователя с помощью связанного PIN-кода
Убедитесь, что пользователь владеет изменяемым счетом
Запишите запись в бухгалтерскую книгу с применением кредита или дебета
В кредите убедитесь, что ежедневный лимит депозита не достигнут
Измените баланс счета
Извлеките текущий баланс для отображения пользователю
Каждая транзакция будет выполнять 6-7 различных SQL-запросов: SELECT, INSERT и UPDATE. Если какой-либо из них не сработает, то база данных должна остаться полностью неизменной. В этом случае сбой заключается в том, что возникает исключение, что потенциально является самым простым способом, когда у вас есть стек функций, вызывающих другие функции. Давайте сначала рассмотрим deposit:
Эта функция просто вызывает другие функции, передавая контекст транзакции (в данном случае соединение, а также входные данные) другим функциям, которые могут или не могут вызывать исключения. Вот два метода аутентификации:
Функции authenticate и verify_account в основном ищут в базе данных запись, которая соответствует условиям — пользователь с соответствующим PIN-кодом в authenticate и пара (user, account_id) в verify_account. Обе эти функции полагаются на ограничение UNIQUE в базе данных для имен пользователей и идентификаторов учетных записей. Этот пример показывает, как стек вызовов функций может стать произвольно глубоким; verify_account вызывается authenticate, который вызывается deposit. При возникновении исключения в любой точке стека транзакция не будет продолжена, что защищает нас от вреда в дальнейшей транзакции.
Обратите внимание также, что ни одна из этих функций не имеет декоратора @transaction, это связано с тем, что ожидается, что они будут вызваны из другой транзакции. Это независимые операции, но их можно вызывать независимо в транзакции с помощью менеджера контекста.
Далее мы вставляем запись в реестр:
Это первое место, где мы изменяем состояние базы данных, вставляя запись в реестр. Если при проверке check_daily_deposit мы обнаруживаем, что наш лимит депозита был превышен за день, возникает исключение, которое откатывает транзакцию. Это гарантирует, что запись в реестре не будет случайно сохранена на диске. Наконец, мы обновляем баланс счета:
Я еще расскажу об update_balance, когда мы обсудим уровни изоляции, но достаточно сказать, что это еще одно место, где в случае сбоя транзакции мы хотим убедиться, что наш счет не будет изменен! Чтобы завершить пример, вот транзакция вывода withdraw:
Это похоже, но изменяет входные данные для различных операций, чтобы уменьшить сумму счета на запись в дебетовой книге. Мы можем запустить:
И мы должны увидеть следующие записи журнала:
Это должно задать базовый уровень для создания простых и удобных в использовании транзакций в Python. Однако, если вы помните свой курс по базам данных в бакалавриате, все становится интереснее, когда две транзакции происходят одновременно. Мы рассмотрим это на примере одного процесса, рассмотрев многопоточные соединения с базой данных.
Давайте рассмотрим, как запустить две транзакции одновременно из одного приложения. Самый простой способ сделать это — использовать библиотеку потоков для одновременного выполнения транзакций. Как добиться безопасности потоков при доступе к базе данных? Возвращаясь к документации:
Объекты соединения потокобезопасны: множество потоков могут обращаться к одной и той же базе данных, используя отдельные сеансы и создавая соединение на поток, или используя одно и то же соединение и создавая отдельные курсоры. На языке DB API 2.0 Psycopg является потокобезопасным на уровне 2.
Это означает, что каждый поток должен иметь свой собственный объект conn (который исследуется в разделе пула подключений). Любой курсор, созданный из одного и того же объекта подключения, будет находиться в одной транзакции независимо от потока. Мы также хотим рассмотреть, как каждая транзакция влияет друг на друга, и мы сначала рассмотрим это, исследуя уровни изоляции и состояние сеанса.
Допустим, у Алисы и Чарли есть общий счет на имя Алисы. Они оба появляются у банкоматов в одно и то же время, Алиса пытается положить 75 долларов, а затем снять 25 долларов, а Чарли пытается снять 300 долларов. Мы можем смоделировать это с помощью потоков следующим образом:
В зависимости от времени может произойти одно из двух. Чарли может быть отклонен из-за отсутствия достаточного количества денег на счете, и конечное состояние базы данных может быть 300 долларов, или все транзакции могут быть успешными с конечным состоянием базы данных, установленным на 0 долларов. Происходит три транзакции, две транзакции снятия withdraw и депозит deposit. Каждая из этих транзакций выполняется изолированно, то есть они видят базу данных, как они начинались, и любые изменения, которые они вносят; поэтому, если снятие withdraw Чарли и депозит deposit Алисы происходят одновременно, Чарли будет отклонен, так как он не знает о депозите, пока он не будет завершен. Независимо от того, что происходит, база данных останется в том же состоянии.
Однако из соображений производительности вы можете изменить уровень изоляции для конкретной транзакции. Возможны следующие уровни:
READ UNCOMMITTED: самый низкий уровень изоляции, транзакция может считывать значения, которые еще не зафиксированы (и могут никогда не быть зафиксированы).
READ COMMITTED: блокировки записи сохраняются, но блокировки чтения снимаются после выбора, что означает, что два разных значения могут быть прочитаны в разных частях транзакции.
REPEATABLE READ: сохранять блокировки чтения и записи, чтобы несколько чтений возвращали одинаковые значения, но могут происходить фантомные чтения.
SERIALIZABLE: самый высокий уровень изоляции: блокировки чтения, записи и диапазона сохраняются до конца транзакции.
DEFAULT: устанавливается конфигурацией сервера, а не Python, обычно READ COMMITTED.
Обратите внимание, что по мере увеличения уровня изоляции увеличивается и количество поддерживаемых блокировок, что серьезно влияет на производительность в случае возникновения конфликтов блокировок или взаимоблокировок. Можно установить уровень изоляции для каждой транзакции, чтобы повысить производительность всех транзакций, происходящих одновременно. Для этого мы должны изменить параметры сеанса в соединении, которые изменяют поведение транзакции или операторов, следующих в этом конкретном сеансе. Кроме того, мы можем установить сеанс только для чтения readonly, что не позволяет выполнять запись во временные таблицы (для производительности и безопасности) или отложить deferrable.
Отсрочка очень интересна в транзакции, поскольку она изменяет способ проверки ограничений базы данных. Неотсроченные транзакции немедленно проверяют ограничение после выполнения оператора. Это означает, что UPDATE accounts SET balance=-5.45
немедленно вызовет исключение. Однако отсроченные транзакции ждут завершения транзакции перед проверкой ограничений. Это позволяет вам писать несколько перекрывающихся операций, которые могут привести базу данных в правильное состояние к концу транзакции, но потенциально не во время транзакции (это также перекрывается с производительностью различных уровней изоляции).
Чтобы изменить сеанс, мы будем использовать менеджер контекста, как мы делали раньше, чтобы изменить сеанс для транзакции, а затем сбросим сеанс обратно к значениям по умолчанию:
Затем мы можем использовать with для проведения транзакций с различными уровнями изоляции:
Подключения не могут быть общими для потоков. В примере потоков выше, если мы удалим декоратор @transaction и передадим одно и то же подключение в обе операции следующим образом:
Если op1 withdraw срабатывает первым, исключение приведет к тому, что все операторы op2 также не сработают, поскольку они находятся в одной транзакции. По сути, это означает, что и op1, и op2 находятся в одной транзакции, хотя они находятся в разных потоках!
До сих пор мы избегали этого, создавая новое соединение каждый раз, когда выполняется транзакция. Однако подключение к базе данных может быть дорогим, и при высоких транзакционных нагрузках мы можем просто оставить соединение открытым, но гарантировать, что они используются только одной транзакцией за раз. Решение заключается в использовании пулов соединений. Мы можем изменить нашу функцию соединения следующим образом:
Это создает потокобезопасный пул соединений, который устанавливает не менее 2 соединений и будет увеличиваться до максимум 4 соединений по требованию. Чтобы использовать объект пула в нашем декораторе транзакций, нам придется подключиться, когда декоратор импортируется, создавая глобальный объект пула:
Использование pool.getconn извлекает соединение из пула (если оно доступно, блокируя до тех пор, пока оно не будет готово), затем, когда мы закончим, мы можем pool.putconn освободить объект соединения.
Это была куча заметок о более прямом использовании psycopg2. Извините, я не смог написать более убедительное заключение, но уже поздно, и этот пост теперь почти в 4 тыс. слов. Пора идти ужинать!
Я использовал журналирование в качестве основного вывода для этого приложения. Журналирование было настроено следующим образом:
Соответствуя , мы создаем подключение к базе данных, затем создаем курсор из подключения. Курсоры управляют выполнением SQL в отношении базы данных, а также извлечением данных. Мы выполняем SQL в нашем файле схемы, фиксируя транзакцию, если не возникает никаких исключений, и откатывая ее, если она не выполняется. Мы рассмотрим это подробнее в следующем разделе.
Метод декоратора хорош, но внедрение соединения может быть немного странным. Альтернативой является , который гарантирует, что соединение зафиксировано или откатится аналогичным образом:
Полный код смотрите .