Роман Ремизов

Системный аналитик ГК Юзтех

Меня зовут Роман Ремизов. Я — системный аналитик ГК Юзтех. В рамках цикла статей «Хроники архитектурного дизайна» я делюсь своей экспертизой о разных автоматизированных банковских системах (АБС) и о том, что нужно знать перед тем, как приступить к архитектурному дизайну.

Данная статья, как и другие статьи этого цикла: Хроники архитектурного дизайна (Часть 1) Хроники архитектурного дизайна (Часть 2); Хроники архитектурного дизайна (Часть 3), написана с допустимым уровнем конкретики и соблюдением NDA. Напомню: все банки разные и на других проектах могут преобладать иные как архитектурные, так и программные решения.

В этой статье мы обсудим работу с транзакциями на примере использования PostgreSQL.

Работа с транзакциями. Немного про ACID.

PostgreSQL — мощная реляционная база данных, которая известна своей надежностью и целостностью данных, во многом благодаря строгому соблюдению ACID-свойств при работе с транзакциями.

ACID – это аббревиатура, обозначающая атомарность (Atomicity), согласованность (Consistency), изолированность (Isolation) и долговечность (Durability).

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

  • Согласованность обеспечивает, что база данных переходит из одного корректного состояния в другое.

  • Изолированность защищает одновременно выполняющиеся транзакции друг от друга.

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

Основные методы управления транзакциями.

Управление транзакциями в PostgreSQL предоставляет гибкие инструменты, позволяющие разработчикам контролировать целостность и согласованность данных в различных сценариях. Рассмотрим несколько основных методов управления транзакциями:

1. Явные транзакции с BEGIN, COMMIT и ROLLBACK.

Это наиболее распространенный и фундаментальный подход. Команда BEGIN (или ее синоним START TRANSACTION) инициирует транзакцию. Все последующие операции SQL (например, INSERT, UPDATE, DELETE, SELECT) выполняются в контексте этой транзакции. Если все операции завершаются успешно, команда COMMIT фиксирует изменения, делая их видимыми для других соединений. В случае ошибки или необходимости отмены изменений используется команда ROLLBACK, которая отменяет все выполненные в рамках транзакции операции, возвращая базу данных в состояние, предшествующее началу транзакции.

Пример использования:

BEGIN;

UPDATE users SET balance = balance + 100 WHERE user_id = 1;

UPDATE accounts SET balance = balance - 100 WHERE account_id = 5;

COMMIT; -- Транзакция успешно завершена

Если во время выполнения возникнет ошибка, например, нарушение уникальности ключа, можно обработать исключение и выполнить ROLLBACK:

BEGIN;

UPDATE users SET email = 'new_email@example.com' WHERE user_id = 1;

EXCEPTION WHEN unique_violation THEN 

    ROLLBACK; 

    RAISE NOTICE 'Ошибка: Email уже используется.';

END;

2. Автоматические транзакции.

Операции DDL (Data Definition Language) – команды, изменяющие структуру базы данных (например, CREATE TABLE, ALTER TABLE, DROP TABLE, CREATE INDEX) – автоматически выполняются в рамках неявных транзакций, которые завершаются командой COMMIT после успешного выполнения. Это важно понимать для обеспечения целостности схемы базы данных. Ошибка в DDL-операции приведет к автоматическому откату.

3. Обработка ошибок с PL/pgSQL.

Язык процедурного программирования PL/pgSQL предоставляет мощные средства обработки ошибок внутри транзакций. Блоки BEGIN ... EXCEPTION ... END позволяют перехватывать различные исключения (например, unique_violation, division_by_zero, foreign_key_violation) и выполнять соответствующие действия, включая ROLLBACK или частичный откат с использованием SAVEPOINT (что и описано ниже).

Более того, использование PL/pgSQL позволяет писать сложные логические блоки, которые обеспечивают атомарность операций даже при наличии множества взаимозависимых действий.

4. Сохраненные точки (SAVEPOINTS).

SAVEPOINT — это механизм, позволяющий создавать промежуточные точки сохранения внутри транзакции. Это особенно полезно в сложных транзакциях, включающих множество операций. Если возникнет ошибка во время выполнения, можно отменить изменения только с момента последнего SAVEPOINT, не откатывая всю транзакцию.

Пример использования SAVEPOINT:

BEGIN;

SAVEPOINT sp1;

UPDATE products SET price = price * 0.9 WHERE category = 'A'; -- Скидка 10%

SAVEPOINT sp2;

UPDATE products SET price = price * 1.1 WHERE category = 'B'; -- Повышение цены 10%

-- Если возникает ошибка

EXCEPTION WHEN others THEN 

    ROLLBACK TO SAVEPOINT sp2; -- Частичный откат до sp2

END;

COMMIT;

5. Параметры транзакций и уровни изоляции.

Настройки транзакций, управляемые командой SET LOCAL, влияют на поведение транзакций, особенно на их изолированность. Уровни изоляции определяют, как транзакции видят данные, измененные другими транзакциями. PostgreSQL предлагает несколько уровней изоляции, каждый из которых представляет компромисс между производительностью и согласованностью:

READ UNCOMMITTED. Самый низкий уровень изоляции. Транзакция может видеть неподтвержденные изменения, совершенные другими транзакциями (грязное чтение). Высокая производительность, но риск появления некорректных данных.

Пример:

BEGIN; -- Транзакция 1

UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;

BEGIN; -- Транзакция 2

SELECT * FROM accounts WHERE user_id = 1; -- Может прочитать изменения из транзакции 1

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

Пример:

BEGIN; -- Транзакция 1

UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;

-- Транзакция 1 еще не завершена

BEGIN; -- Транзакция 2

SELECT * FROM accounts WHERE user_id = 1; -- Увидит старое значение

COMMIT; -- Завершение транзакции 2

COMMIT; -- Завершение транзакции 1

REPEATABLE READ. Гарантирует повторяемость чтения, исключая как грязное, так и неповторяемое чтение. Может возникнуть фантомное чтение (появление новых строк, удовлетворяющих условию запроса, из-за параллельно выполняющихся транзакций).

Пример:

BEGIN; -- Транзакция 1

SELECT * FROM accounts WHERE user_id = 1; -- Получаем, в том числе, значение баланса, скажем, 1000

BEGIN; -- Транзакция 2

UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- Изменение зафиксировано

COMMIT; -- Завершение транзакции 2

-- Транзакция 1 все еще работает

SELECT * FROM accounts WHERE user_id = 1; -- Увидит 1000, а не 900

COMMIT; -- Завершение транзакции 1

SERIALIZABLE. Самый высокий уровень изоляции. Гарантирует полную сериализацию транзакций, исключая все виды конфликтов, включая фантомное чтение. Обеспечивает максимальную согласованность, но снижает производительность.

Пример:

BEGIN; -- Транзакция 1

SELECT * FROM accounts WHERE user_id = 1; -- Получаем, в том числе, значение баланса, например, 1000

BEGIN; -- Транзакция 2

SELECT * FROM accounts WHERE user_id = 1; -- Попытка прочитать те же данные

-- В этом случае транзакция 2 может быть заблокирована до завершения транзакции 1

COMMIT; -- Завершение транзакции 1

COMMIT; -- Завершение транзакции 2

Выбор уровня изоляции осуществляется с помощью команды SET TRANSACTION ISOLATION LEVEL.

Расширяем базу опытом или «чем улучшить отличное».

Разберём два практических примера из моего опыта.

Во-первых, при работе с БД используются различные библиотеки, которые позволяют строить SQL-запросы с помощью типизированного подхода, например, JOOQ. Такие библиотеки обеспечивают функциональность по всем заявленным выше пунктам с описанием основным методов управления транзакциями. Сам JOOQ, пожалуй, заслуживает отдельной статьи с полноценным описанием его возможностей.

Во-вторых, в ранее опубликованной статье я уже рассказывал про использование JDBC коннектора Kafka. Спойлер: суть использования JDBC коннектора заключается в агрегации данных из нескольких сообщений перед их записью в БД в рамках одной транзакции.

Можно сказать, что два приведённых выше инструмента являются решением некоторой проблемы. Если JOOQ был заявлен изначально, как решение, которое отлично зарекомендовало себя исходя из практического опыта наших девелоперов, то использование JDBC коннектора Kafka – это вынужденная необходимость. При потоке в 5000 mps, учитывая то, что внутри каждого сообщения в среднем расположено по три обрабатываемых объекта с однородной структурой, уровень изоляции SERIALIZABLE давал на этапе нагрузочного тестирования весьма скромные результаты. С другой стороны, READ COMMITTED также не был оптимальным уровнем изоляции, так как около восьмидесяти инстансов микро-сервиса, запущенные одновременно, конкурировали бы при выполнении транзакций. Неповторяемое чтение стало нашей проблемой, которую позволил решить READ COMMITTED в паре с JDBC коннектором.

Отдельно отмечу, что два других уровня изоляции мы также исключили на этапе проработки решения. READ UNCOMMITTED из-за неприменимости грязного чтения, а REPEATABLE READ из-за всё той же низкой продуктивности, которая при большой количестве микро-сервисов снижалась и почти была равна SERIALIZABLE.

Предварительное заключение 4.

Эффективное управление транзакциями в PostgreSQL является критически важным для обеспечения надежности и целостности данных.

Выбор уровня изоляции зависит от конкретных требований приложения. По умолчанию в PostgreSQL используется уровень READ COMMITTED, который обеспечивает разумный баланс между производительностью и согласованностью.

Добавление инструментов, расширяющих базовую функциональность БД, является целесообразным решением. Например, когда необходимо подстроить функциональность для работы с целевым объёмом данных и соответствующее быстродействие.

Рекомендованная литература.

Статья Ремизова Романа «Как использовать JDBC коннектор Kafka для повышения производительности обработки и записи данных».

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


  1. kmatveev
    02.06.2025 16:01

    Неудачная структура статьи: главная часть - это очень базовое объяснение транзакций, которое знакомо мало-мальски работающему с базами данных человеку. Потом перепрыгнули на JOOQ непонятно зачем, про его связь с транзакциями не сказано ничего. И дальше про сохранялку из Kafka. Меня вышибает термин "JDBC коннектор Kafka", я не могу воспринимать это иначе чем нечто, что реализует JDBC-интерфейс для данных, хранящихся в логах Kafka. Оказалось, имелся в виду Kafka Connect, который сливает в базу данные из топиков. Мне кажется, вы в объяснении где-то напутали, и хотели сказать, что read repeatable вам не подошёл, а подошёл read commited. Ну логично, там же только сохранение данных. В конце вы сами пишете, что read commited - уровень по-умолчанию, значит даже у человека, не понимающего в транзакциях, всё бы сработало.


    1. kmatveev
      02.06.2025 16:01

      А ну хотя чего я удивляюсь, часть про транзакции и заключение писала LLM, а от автора тут только часть про JOOQ и сохранялку из Kafka. Фу так писать.


      1. romeocheliotrue
        02.06.2025 16:01

        Спасибо! Мы обязательно будем писать по другому и лучше.


    1. romeocheliotrue
      02.06.2025 16:01

      Здравствуйте! Благодарю за комментарий!

      • Человека не понимающего в транзакциях к настройке уровня изоляции транзакции вряд ли допустили бы.)

      • Статья не является мануалом для DBA.

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

      • Мне искренне жаль, что материал доставил сложности с восприятием.