Вопросы параллелизма в компьютерных вычислениях очень сложны! Причинами большой сложности являются огромное количество деталей, которые нужно учитывать при разработке параллельных программ. В программирование и без того существует большое количество деталей, которые создают почву для ошибок, параллелизм же, добавляет ещё.
Вопросы конкурентного доступа к реляционным базам данных встают практически перед любыми разработчиками прикладного программного обеспечения и не только перед ними. Результатом такой востребованности этой области является наличие большого количества созданных архитектурных паттернов. Это позволяет успешно справляться с большой сложностью разработки таких программ. Ниже пойдёт речь о таких рецептах, а также механизмах на которых базируется их реализация. Повествование будет иллюстрироваться примерами кода на Java, но большинство материала не привязано к языку. Цель статьи — описать проблемы конкурентного доступа к реляционным базам данных, в качестве введения в предмет, а не полноценного охвата темы.
Рассмотрим ситуацию. В некой учётной системе необходимо отражать изменение остатков товара при проведении документов. Чтобы обсуждение было более предметным, я буду снабжать его примерами работающими на PostgreSQL. Вот структура базы данных для нашей учётной системы и тестовые данные.
Допустим наш документ отвечает за списание товара со склада. Код приложения загружает данные документа и остатка, производит вычисления и сохраняет данные в базу данных. Если это действие выполняет одно приложение, то всё в порядке.
Загружены данные документа с идентификатором 1 и остатка соответствующего ему. Вычислено новое значение остатка путём списания 56.40 — 15.60 = 40.80 и данные сохранены обратно вместе с пометкой об обработке документа.
Но однопользовательские системы это давнее прошлое. Сейчас невозможно представить бизнес-приложение для работы с которым пользователи должны выстраиваться в очередь. Поэтому давайте рассмотрим ситуацию когда обрабатываются два документа одновременно. Как и в первом случае приложения считывают данные из базы.
первое приложение
второе приложение
И тут возникает очевидная проблема: первое приложение вычисляет 56.40 — 15.60 = 40.80, а второе 56.40 — 26.10 = 30.30. И в базу запишется любой из этих результатов. Тот, для которого будет выполнен последний update запрос. Это явно не то поведение, которое ожидает увидеть пользователь. Проблемы такого вида хорошо известны в параллельном программирование и носят название состояния гонок (race conditions). Данный случай является составным действием под общим название прочитай-измени-запиши (read-modify-write).
В случае если бы мы проверяли документ на обработанность (поле processed), а в реальном приложении нам пришлось бы это делать, могла произойти ситуация, в которой, несмотря на проверку, документ мог быть обработан дважды. Всё тоже состояние гонок, но другой вид операции проверь-затем-действуй (check-then-act). К тому же в промежутках времени между update запросами база данных находится в несогласованном состоянии, то есть действие по документу уже выполнено, но документ не помечен как обработанный.
В результате мы имеем две наиболее общие проблемы, которые проявляются при некорректном конкурентном доступе к базам данных. Это потеря изменений, происходящих в случае ситуации гонки прочитай-измени-запиши. И несогласованное состояние данных в промежутке между update запросами.
Для того чтобы обсуждать конкретные рецепты конкурентного доступа к реляционным базам данных, необходимо быть знакомым с более низкоуровневыми механизмами, которые предоставляют СУБД и средства программирования. Это технические элементы на которых базируется реализация. Основными такими элементами являются транзакции и блокировки. О них и пойдёт речь далее.
Транзакция является единой последовательностью операций по взаимодействию с базой данных, которая воспринимается как одно целое, имеющей возможность на откат или подтверждения совершения операций. Транзакция может являться средством разрешения проблемы согласованности изменений, а также не допускать ситуации гонок. Пригодность транзакций для решения первой или обоих проблем зависит от уровня изоляции (isolation level). Изоляция — это свойство, которое определяет как/когда изменения сделанные одной операцией будут видны конкурентной операции.
Стандарт SQL определяет четыре уровня изоляции: Read uncommitted, Read committed, Repeatable read, Serializable. Все они отличаются минимально допустимым уровнем изоляции. Основные свойства этих уровней должны быть понятны из названия. Разные СУБД могут по разному реализовывать поддержку типов изоляции. Главное чтобы каждая реализация не допускала изоляцию, менее строгую, чем предписано для уровня. Например в PostgreSQL внутренне реализована поддержка только двух уровней изоляции Read committed и Serializable. Рассмотрим использование этих уровней изоляции для решение проблем описанных в примерах выше.
Использование уровня изоляции Read committed (используется по умолчанию в PostgreSQL) позволяет решить проблему несогласованного состояния данных. Это достигается за счёт того, что все изменения сделанные внутри транзакции становятся видны конкурентным транзакциям после совершения текущей.
Но такая транзакция не решает проблему с состоянием гонок при конкурентном выполнении операций. В такой ситуаций может помочь другой уровень изоляции — Serializable. Это возможное решение, но оно не единственное. Поведение реализации уровня изоляции Serializable в PostgreSQL не в полной мере соответствует названию. То есть все транзакции с уровнем изоляции Serializable не выполняются последовательно. Вместо этого при совершении (commit) транзакции проверяется конфликт при изменение данных и в случае, если данные уже были изменены конкурентной транзакцией, текущая транзакция завершается ошибкой.
Представленная выше транзакция решает проблему и с ситуацией гонок и с согласованностью данных. Отличительной чертой такого подхода является то, что код выполняющий эту транзакцию узнает об успешности только после завершения транзакции. И в результате неудачи необходимо будет повторять все действия и вычисления до тех пор, пока транзакция не совершится успешно, либо не будет принято решение отказаться от выполнения действия. Такое поведение неудовлетворительно при большой конкурентной нагрузке, потому как большое количество ресурсов будет потребляться на выполнение повторов. Подобное поведение называется оптимистическим конкурентным контролем (optimistic concurrency control).
Приведенные выше примеры основываются на допущении о том, что бизнес логика полностью реализована в коде приложения, а база данных выступает только в роли хранилища.
Важной деталью в использовании транзакций влияющей на производительность является их длинна. Транзакции не должны быть слишком длинными, чем быстрее они совершаются, тем большей производительностью будет обладать система. Это замечание может быть не критично для кода, выполняющего редактирование документа в приложении с небольшим количеством пользователей. Но критично, например в ситуации web сервиса, который взаимодействует с базой данных и используется большим количеством клиентов.
В параллельном программирования для контроля над потоками выполнения применяется механизм блокировок. Блокировки позволяют сериализовать доступ к определенным областям. СУБД тоже поддерживают механизмы блокировок для контроля доступа к данным. Рассмотрим возможности данного механизма на примере PostgreSQL.
В PostgreSQL реализована поддержка множества типов блокировок. Их основная отличительная черта — это множество типов блокировок, с которым конфликтует текущий тип блокировки. Конфликт означает, что текущая блокировка не может быть захвачена совместно с блокировками любого из конфликтующих типов. В добавок блокировки делятся на явные и неявные. Явные блокировки — это те, которые выполнены в запросе с помощью ключевого слова lock и модификаторов запросов for update или for share, другими словами указанные пользователем. Неявные блокировки — это те, которые захватываются при выполнение различных запросов (select, update, insert, alter и прочие). PostgerSQL поддерживает и отдельный вид блокировок называемых рекомендательными (advisory lock).
Блокировки захватываются с момента выполнение запроса и до конца текущей транзакции. Например в нашей гипотетической учётной системе можно было захватывать эксклюзивную блокировку на строку таблицы stocks соответствующей остатку с которым выполняется операция и на строку таблицы documents, тем самым гарантировать, что только текущая транзакция имеет доступ к этим данным.
В коде выше происходит захват блокировок на строки в таблицах documents и stocks. В данном случае к запросу на выборку данных select добавляется ключевые слова for update. Это и есть явное блокирование строки на обновление. Блокировать целую таблицу в данном случае не эффективно. Вообще блокировать таблицу необходимо только при масштабных операциях с данными в ней, a это достаточно редкие случаи. Иначе вы получаете проблему с производительностью при конкурентном доступе, потому-что все обращения к ней сериализуются.
При попытке захвата блокировки на области блокировка на которую уже захвачена, происходит блокирование запроса (по умолчанию) до тех пор, пока блокировка не освободиться, либо в случае с мертвой блокировкой (deadlock) возвращается сообщение об ошибке. Есть возможность устанавливать интервал возвращения управления из блокированного запроса, либо сразу получать сообщение о невозможности захвата блокировки (nowait).
Конкурентный контроль это правила по которым взаимодействуют параллельные потоки выполнения на конкурентных участках. Конкурентный контроль должен гарантирует корректность вычисляемого результата. В качестве дополнительной цели выступает получение результата так быстро, как это возможно в конкретном случае. Конкурентный контроль принято делить на виды согласно времени обработки конфликтов: оптимистический (optimistic), пессимистический (pessimistic) и частично-оптимистический (semi-optimistic).
Оптимистический конкурентный контроль предполагает проверку возможного конфликта при совершении действия. Например пользователь запрашивает некоторые данные из хранилища и изменяет их, после чего он пытается сохранить свои изменения. Если текущая версия данных находящаяся в хранилище соответствует версии данных на основе которых происходили изменения, то конфликт отсутствует и данные могут быть сохранены. В обратном случае возникает конфликт, который обрабатывается либо повторным выполнением действий, либо отказом от них. Оптимистический конкурентный контроль обеспечивает хорошую производительность при относительно малом соперничестве за конкурентный доступ.
Пессимистический конкурентный контроль предполагает проверку возможного конфликта перед выполнением действия. То есть конкурентные потоки выполнения сериализуются на защищаемой области. Это обеспечивает бОльшую производительность при высоком соперничестве за конкурентный доступ.
Частично-оптимистический — это смешанный тип в котором применяются оба подхода одновременно.
К данному моменту у читателя должно сформироваться общее представление о проблемах при конкурентном доступе и механизмах СУБД для их решения. В этой секции речь пойдёт о архитектурных паттернах, которые представляют решение проблем конкурентного доступа к базам данных. Ниже не будет полного описания, только общее представление и пример. За полным описанием обращайтесь по ссылкам внизу статьи.
При работе с СУБД данные загружаются в память приложения, с ними или над ними осуществляются действия и результат действий, как правило, должен быть сохранён обратно. Всё это время необходимо хранить информацию об изменениях над данными, чтобы знать, что изменилось. Помимо этого необходимо хранить информацию о созданных и удалённых объектах. Конечно, можно отражать все изменения в базе данных сразу, как только они происходят. В таком случае возникают следующие проблемы: системная транзакция выполняется очень долго, что приводит к конфликтам при конкурентном доступе, так как на протяжении всей транзакции удерживаются блокировки над изменениями в базе данных; взаимодействие с базой данных разбивается на много небольших частей, что тоже малоэффективно. Для решения проблем отслеживания изменений данных описан паттерн Unit of Work. Этот паттерн описывает объект, который отслеживает все изменения и может применить их к базе данных, чтобы согласовать её состояние с произведенными изменениями.
Выше представлена простейшая реализация паттерна Unit of Work. Объекты DomainObject могут регистрироваться в объекте UnitOfWork при выполнении над ними соответствующих действий. После завершения бизнес транзакции приложение вызывает метод commit у объекта UnitOfWork.
Бизнес транзакция обычно занимает продолжительное время. Как правило, она простирается на несколько системных транзакций. Причина этому уже упоминалась выше — проблемы с длительным захватом блокировок. Из этого следует, что необходимо реализовывать собственный механизм синхронизации, который будет работать поверх нескольких системных транзакций, так как механизмы синхронизации СУБД работают только в пределах одной системной транзакции. Для решения этой проблемы описаны два паттерна Optimistic Offline Lock и Pessimistic Offline Lock, которые реализуют оптимистический и пессимистический виды конкурентного контроля.
Допустим пользователь открывает форму редактирования, он работает над документом несколько минут и сохраняет результат. Это типичный пример бизнес транзакции.
Рассмотрим ситуацию с оптимистическим конкурентным контролем. В случае обнаружения конфликта при сохранении результата редактирования, пользователю будет выведено уведомление с измененными данными и диалог с выбором дальнейших действий. Паттерн Optimistic Offline Lock описывает механизм контроля изменений, который, в общем случае, опирается на механизм версионности данных. Каждый раз при сохранении изменения происходит сравнение версии данных в базе данных и версии на основе которой были выполнены изменения. Если эти версии равны то происходит изменение текущей версии и результат сохраняется, в противном случае возникает конфликт и он обрабатывается либо повтором действий, либо отменой.
В приведённом выше коде представлена реализация update метода с описанным выше паттерном Optimistic Offline Lock. В этой реализации происходит сохранение списка измененных объектов с проверкой версии. Если версия изменилась, то не будет обновлена ни одна запись в таблице и в данном примере происходит бросание исключения. Это исключение соответствует конфликту при котором, запись уже была обновлена конкурентным потоком выполнения.
В случае с пессимистическим конкурентным контролем, реализация паттерна Pessimistic Offline Lock представлена ниже.
В представленном коде при получении данных из базы данных устанавливается блокировка на поле blocked. Если она уже установлена вызывается исключение, в противном случае возвращается результат. Далее при обновлении объекта блокировка сбрасывается. Данная реализация будет работать при всех уровнях изоляции транзакций. При условии, что сохранение данных будет выполняться после захвата блокировок.
Представленные реализации лишь иллюстрируют описание и не предназначены для использования в реальных приложениях!
Политика блокировок — это правила, которыми регулируется конкурентный доступ к данным. В данном абзаце речь идет о политики блокировок на бизнес уровне, а не реализации на уровне запросов к СУБД и механизмов синхронизации платформы. Необходимо учитывать, что политика блокировок должна быть связанна с бизнес-логикой. И нельзя оставлять вопрос с конкурентным доступом на потом. Например в системе, где область применение говорит о том, что некий документ должен редактироваться единолично, необходимо чтобы бизнес-логика обладала знаниями о этом требовании. В случае реализации такого требования бизнес области, только с помощью механизмов синхронизации, реализация бизнес уровня в приложении будет разделена на несколько частей, что не очень хорошо. Во первых, потому что поддержка такой системы усложняется, хотя вариант оптимизирующий производительность путём использования встроенных процедур СУБД достаточно распространён. Во вторых, существует проблема с длинной системной транзакции, то есть нельзя позволять приложению держать системную транзакцию открытой то же время, что и бизнес транзакцию. Область ответственности за бизнес транзакцию лежит на бизнес логике приложения. Поэтому политика блокировок в бизнес транзакции не может рассматриваться отдельно от бизнес логики.
Patterns of Enterprise Application Architecture (Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford)
PostgreSQL Concurrency Control
Concurrency control
Isolation level
Вопросы конкурентного доступа к реляционным базам данных встают практически перед любыми разработчиками прикладного программного обеспечения и не только перед ними. Результатом такой востребованности этой области является наличие большого количества созданных архитектурных паттернов. Это позволяет успешно справляться с большой сложностью разработки таких программ. Ниже пойдёт речь о таких рецептах, а также механизмах на которых базируется их реализация. Повествование будет иллюстрироваться примерами кода на Java, но большинство материала не привязано к языку. Цель статьи — описать проблемы конкурентного доступа к реляционным базам данных, в качестве введения в предмет, а не полноценного охвата темы.
Проблемы при конкурентном доступе
Рассмотрим ситуацию. В некой учётной системе необходимо отражать изменение остатков товара при проведении документов. Чтобы обсуждение было более предметным, я буду снабжать его примерами работающими на PostgreSQL. Вот структура базы данных для нашей учётной системы и тестовые данные.
CREATE TABLE stocks (
id integer PRIMARY KEY,
name varchar(256) NOT NULL,
quantity decimal(10,2) NOT NULL DEFAULT 0.0
);
CREATE TABLE documents (
id integer PRIMARY KEY,
quantity decimal(10,2) NOT NULL DEFAULT 0.0,
processed boolean NOT NULL DEFAULT false,
stock integer REFERENCES stocks
);
INSERT INTO stocks (id, name, quantity) VALUES
(1, 'сыр', 56.4),
(2, 'молоко', 26.8);
INSERT INTO documents (id, quantity, stock) VALUES
(1, 15.6, 1),
(2, 26.1, 1);
Допустим наш документ отвечает за списание товара со склада. Код приложения загружает данные документа и остатка, производит вычисления и сохраняет данные в базу данных. Если это действие выполняет одно приложение, то всё в порядке.
SELECT quantity, processed, stock FROM documents WHERE id = 1;
quantity | processed | stock
----------+-----------+-------
15.60 | f | 1
SELECT name, quantity FROM stocks WHERE id = 1;
name | quantity
------+----------
сыр | 56.40
Загружены данные документа с идентификатором 1 и остатка соответствующего ему. Вычислено новое значение остатка путём списания 56.40 — 15.60 = 40.80 и данные сохранены обратно вместе с пометкой об обработке документа.
UPDATE stocks SET quantity = 40.80 WHERE id = 1;
UPDATE documents SET processed = true WHERE id = 1;
Но однопользовательские системы это давнее прошлое. Сейчас невозможно представить бизнес-приложение для работы с которым пользователи должны выстраиваться в очередь. Поэтому давайте рассмотрим ситуацию когда обрабатываются два документа одновременно. Как и в первом случае приложения считывают данные из базы.
первое приложение
SELECT quantity, processed, stock FROM documents WHERE id = 1;
quantity | processed | stock
----------+-----------+-------
15.60 | f | 1
SELECT name, quantity FROM stocks WHERE id = 1;
name | quantity
------+----------
сыр | 56.40
второе приложение
SELECT quantity, processed, stock FROM documents WHERE id = 2;
quantity | processed | stock
----------+-----------+-------
26.10 | f | 1
SELECT name, quantity FROM stocks WHERE id = 1;
name | quantity
------+----------
сыр | 56.40
И тут возникает очевидная проблема: первое приложение вычисляет 56.40 — 15.60 = 40.80, а второе 56.40 — 26.10 = 30.30. И в базу запишется любой из этих результатов. Тот, для которого будет выполнен последний update запрос. Это явно не то поведение, которое ожидает увидеть пользователь. Проблемы такого вида хорошо известны в параллельном программирование и носят название состояния гонок (race conditions). Данный случай является составным действием под общим название прочитай-измени-запиши (read-modify-write).
В случае если бы мы проверяли документ на обработанность (поле processed), а в реальном приложении нам пришлось бы это делать, могла произойти ситуация, в которой, несмотря на проверку, документ мог быть обработан дважды. Всё тоже состояние гонок, но другой вид операции проверь-затем-действуй (check-then-act). К тому же в промежутках времени между update запросами база данных находится в несогласованном состоянии, то есть действие по документу уже выполнено, но документ не помечен как обработанный.
В результате мы имеем две наиболее общие проблемы, которые проявляются при некорректном конкурентном доступе к базам данных. Это потеря изменений, происходящих в случае ситуации гонки прочитай-измени-запиши. И несогласованное состояние данных в промежутке между update запросами.
Механизмы СУБД
Для того чтобы обсуждать конкретные рецепты конкурентного доступа к реляционным базам данных, необходимо быть знакомым с более низкоуровневыми механизмами, которые предоставляют СУБД и средства программирования. Это технические элементы на которых базируется реализация. Основными такими элементами являются транзакции и блокировки. О них и пойдёт речь далее.
Транзакции и уровни изоляции
Транзакция является единой последовательностью операций по взаимодействию с базой данных, которая воспринимается как одно целое, имеющей возможность на откат или подтверждения совершения операций. Транзакция может являться средством разрешения проблемы согласованности изменений, а также не допускать ситуации гонок. Пригодность транзакций для решения первой или обоих проблем зависит от уровня изоляции (isolation level). Изоляция — это свойство, которое определяет как/когда изменения сделанные одной операцией будут видны конкурентной операции.
Стандарт SQL определяет четыре уровня изоляции: Read uncommitted, Read committed, Repeatable read, Serializable. Все они отличаются минимально допустимым уровнем изоляции. Основные свойства этих уровней должны быть понятны из названия. Разные СУБД могут по разному реализовывать поддержку типов изоляции. Главное чтобы каждая реализация не допускала изоляцию, менее строгую, чем предписано для уровня. Например в PostgreSQL внутренне реализована поддержка только двух уровней изоляции Read committed и Serializable. Рассмотрим использование этих уровней изоляции для решение проблем описанных в примерах выше.
Использование уровня изоляции Read committed (используется по умолчанию в PostgreSQL) позволяет решить проблему несогласованного состояния данных. Это достигается за счёт того, что все изменения сделанные внутри транзакции становятся видны конкурентным транзакциям после совершения текущей.
BEGIN;
SELECT quantity, processed, stock FROM documents WHERE id = 1;
SELECT name, quantity FROM stocks WHERE id = 1;
--вычисления в коде приложения
UPDATE stocks SET quantity = 40.80 WHERE id = 1;
UPDATE documents SET processed = true WHERE id = 1;
COMMIT;
Но такая транзакция не решает проблему с состоянием гонок при конкурентном выполнении операций. В такой ситуаций может помочь другой уровень изоляции — Serializable. Это возможное решение, но оно не единственное. Поведение реализации уровня изоляции Serializable в PostgreSQL не в полной мере соответствует названию. То есть все транзакции с уровнем изоляции Serializable не выполняются последовательно. Вместо этого при совершении (commit) транзакции проверяется конфликт при изменение данных и в случае, если данные уже были изменены конкурентной транзакцией, текущая транзакция завершается ошибкой.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT quantity, processed, stock FROM documents WHERE id = 1;
SELECT name, quantity FROM stocks WHERE id = 1;
--вычисления в коде приложения
UPDATE stocks SET quantity = 40.80 WHERE id = 1;
UPDATE documents SET processed = true WHERE id = 1;
COMMIT;
Представленная выше транзакция решает проблему и с ситуацией гонок и с согласованностью данных. Отличительной чертой такого подхода является то, что код выполняющий эту транзакцию узнает об успешности только после завершения транзакции. И в результате неудачи необходимо будет повторять все действия и вычисления до тех пор, пока транзакция не совершится успешно, либо не будет принято решение отказаться от выполнения действия. Такое поведение неудовлетворительно при большой конкурентной нагрузке, потому как большое количество ресурсов будет потребляться на выполнение повторов. Подобное поведение называется оптимистическим конкурентным контролем (optimistic concurrency control).
Приведенные выше примеры основываются на допущении о том, что бизнес логика полностью реализована в коде приложения, а база данных выступает только в роли хранилища.
Важной деталью в использовании транзакций влияющей на производительность является их длинна. Транзакции не должны быть слишком длинными, чем быстрее они совершаются, тем большей производительностью будет обладать система. Это замечание может быть не критично для кода, выполняющего редактирование документа в приложении с небольшим количеством пользователей. Но критично, например в ситуации web сервиса, который взаимодействует с базой данных и используется большим количеством клиентов.
Блокировки
В параллельном программирования для контроля над потоками выполнения применяется механизм блокировок. Блокировки позволяют сериализовать доступ к определенным областям. СУБД тоже поддерживают механизмы блокировок для контроля доступа к данным. Рассмотрим возможности данного механизма на примере PostgreSQL.
В PostgreSQL реализована поддержка множества типов блокировок. Их основная отличительная черта — это множество типов блокировок, с которым конфликтует текущий тип блокировки. Конфликт означает, что текущая блокировка не может быть захвачена совместно с блокировками любого из конфликтующих типов. В добавок блокировки делятся на явные и неявные. Явные блокировки — это те, которые выполнены в запросе с помощью ключевого слова lock и модификаторов запросов for update или for share, другими словами указанные пользователем. Неявные блокировки — это те, которые захватываются при выполнение различных запросов (select, update, insert, alter и прочие). PostgerSQL поддерживает и отдельный вид блокировок называемых рекомендательными (advisory lock).
Блокировки захватываются с момента выполнение запроса и до конца текущей транзакции. Например в нашей гипотетической учётной системе можно было захватывать эксклюзивную блокировку на строку таблицы stocks соответствующей остатку с которым выполняется операция и на строку таблицы documents, тем самым гарантировать, что только текущая транзакция имеет доступ к этим данным.
BEGIN;
SELECT quantity, processed, stock FROM documents WHERE id = 1 FOR UPDATE;
SELECT name, quantity FROM stocks WHERE id = 1 FOR UPDATE;
--вычисления в коде приложения
UPDATE stocks SET quantity = 40.80 WHERE id = 1;
UPDATE documents SET processed = true WHERE id = 1;
COMMIT;
В коде выше происходит захват блокировок на строки в таблицах documents и stocks. В данном случае к запросу на выборку данных select добавляется ключевые слова for update. Это и есть явное блокирование строки на обновление. Блокировать целую таблицу в данном случае не эффективно. Вообще блокировать таблицу необходимо только при масштабных операциях с данными в ней, a это достаточно редкие случаи. Иначе вы получаете проблему с производительностью при конкурентном доступе, потому-что все обращения к ней сериализуются.
При попытке захвата блокировки на области блокировка на которую уже захвачена, происходит блокирование запроса (по умолчанию) до тех пор, пока блокировка не освободиться, либо в случае с мертвой блокировкой (deadlock) возвращается сообщение об ошибке. Есть возможность устанавливать интервал возвращения управления из блокированного запроса, либо сразу получать сообщение о невозможности захвата блокировки (nowait).
Виды конкурентного контроля
Конкурентный контроль это правила по которым взаимодействуют параллельные потоки выполнения на конкурентных участках. Конкурентный контроль должен гарантирует корректность вычисляемого результата. В качестве дополнительной цели выступает получение результата так быстро, как это возможно в конкретном случае. Конкурентный контроль принято делить на виды согласно времени обработки конфликтов: оптимистический (optimistic), пессимистический (pessimistic) и частично-оптимистический (semi-optimistic).
Оптимистический конкурентный контроль предполагает проверку возможного конфликта при совершении действия. Например пользователь запрашивает некоторые данные из хранилища и изменяет их, после чего он пытается сохранить свои изменения. Если текущая версия данных находящаяся в хранилище соответствует версии данных на основе которых происходили изменения, то конфликт отсутствует и данные могут быть сохранены. В обратном случае возникает конфликт, который обрабатывается либо повторным выполнением действий, либо отказом от них. Оптимистический конкурентный контроль обеспечивает хорошую производительность при относительно малом соперничестве за конкурентный доступ.
Пессимистический конкурентный контроль предполагает проверку возможного конфликта перед выполнением действия. То есть конкурентные потоки выполнения сериализуются на защищаемой области. Это обеспечивает бОльшую производительность при высоком соперничестве за конкурентный доступ.
Частично-оптимистический — это смешанный тип в котором применяются оба подхода одновременно.
Архитектурные паттерны
К данному моменту у читателя должно сформироваться общее представление о проблемах при конкурентном доступе и механизмах СУБД для их решения. В этой секции речь пойдёт о архитектурных паттернах, которые представляют решение проблем конкурентного доступа к базам данных. Ниже не будет полного описания, только общее представление и пример. За полным описанием обращайтесь по ссылкам внизу статьи.
При работе с СУБД данные загружаются в память приложения, с ними или над ними осуществляются действия и результат действий, как правило, должен быть сохранён обратно. Всё это время необходимо хранить информацию об изменениях над данными, чтобы знать, что изменилось. Помимо этого необходимо хранить информацию о созданных и удалённых объектах. Конечно, можно отражать все изменения в базе данных сразу, как только они происходят. В таком случае возникают следующие проблемы: системная транзакция выполняется очень долго, что приводит к конфликтам при конкурентном доступе, так как на протяжении всей транзакции удерживаются блокировки над изменениями в базе данных; взаимодействие с базой данных разбивается на много небольших частей, что тоже малоэффективно. Для решения проблем отслеживания изменений данных описан паттерн Unit of Work. Этот паттерн описывает объект, который отслеживает все изменения и может применить их к базе данных, чтобы согласовать её состояние с произведенными изменениями.
public class UnitOfWork {
private List<DomainObject> newObjects;
private List<DomainObject> updatedObjects;
private List<DomainObject> deletedObjects;
/**
* Создать объект
* @return вернуть вновь созданный объект
*/
public DomainObject create() {
DomainObject domainObject = new DomainObject();
newObjects.add(domainObject);
return domainObject;
}
/**
* Пометить объект как измененный
* @param domainObject измененный объект
*/
public void update(DomainObject domainObject) {
updatedObjects.add(domainObject);
}
/**
* Пометить объект как удалённый
* @param domainObject удалённый объект
*/
public void remove(DomainObject domainObject) {
deletedObjects.add(domainObject);
}
/**
* Выполнить изменения в базе данных
*/
public void commit() {
//выполняет SQL запросы на вставку данных INSERT
insert(newObjects);
//выполняет SQL запросы на обновление данных UPDATE
udpate(updatedObjects);
//выполняет SQL запросы на удаление данных DELETE
delete(deletedObjects);
}
//реализация методов insert, update, delete и прочих
}
Выше представлена простейшая реализация паттерна Unit of Work. Объекты DomainObject могут регистрироваться в объекте UnitOfWork при выполнении над ними соответствующих действий. После завершения бизнес транзакции приложение вызывает метод commit у объекта UnitOfWork.
Бизнес транзакция обычно занимает продолжительное время. Как правило, она простирается на несколько системных транзакций. Причина этому уже упоминалась выше — проблемы с длительным захватом блокировок. Из этого следует, что необходимо реализовывать собственный механизм синхронизации, который будет работать поверх нескольких системных транзакций, так как механизмы синхронизации СУБД работают только в пределах одной системной транзакции. Для решения этой проблемы описаны два паттерна Optimistic Offline Lock и Pessimistic Offline Lock, которые реализуют оптимистический и пессимистический виды конкурентного контроля.
Допустим пользователь открывает форму редактирования, он работает над документом несколько минут и сохраняет результат. Это типичный пример бизнес транзакции.
Рассмотрим ситуацию с оптимистическим конкурентным контролем. В случае обнаружения конфликта при сохранении результата редактирования, пользователю будет выведено уведомление с измененными данными и диалог с выбором дальнейших действий. Паттерн Optimistic Offline Lock описывает механизм контроля изменений, который, в общем случае, опирается на механизм версионности данных. Каждый раз при сохранении изменения происходит сравнение версии данных в базе данных и версии на основе которой были выполнены изменения. Если эти версии равны то происходит изменение текущей версии и результат сохраняется, в противном случае возникает конфликт и он обрабатывается либо повтором действий, либо отменой.
public class DomainObject {
private Integer id;
private Integer version;
private Object data;
//геттеры и сеттеры
}
public class UnitOfWork {
//реализация методов, описаны выше
public void update(List<DomainObject> updatedObjects) throws SQLException {
PreparedStatement ps =
connection.prepareStatement(
"update domain_objects set data = ?, version = version + 1 " +
"where id = ? and version = ?"
);
for (DomainObject domainObject: updatedObjects) {
ps.setObject(1, domainObject.getData());
ps.setInt(2, domainObject.getId());
ps.setInt(3, domainObject.getVersion());
if (ps.executeUpdate() == 0) {
throw new RuntimeException("Конфликт версий");
}
}
}
}
В приведённом выше коде представлена реализация update метода с описанным выше паттерном Optimistic Offline Lock. В этой реализации происходит сохранение списка измененных объектов с проверкой версии. Если версия изменилась, то не будет обновлена ни одна запись в таблице и в данном примере происходит бросание исключения. Это исключение соответствует конфликту при котором, запись уже была обновлена конкурентным потоком выполнения.
В случае с пессимистическим конкурентным контролем, реализация паттерна Pessimistic Offline Lock представлена ниже.
class DomainObject {
private Integer id;
private Boolean blocked;
private Object data;
//геттеры и сеттеры
}
public class UnitOfWork {
//реализация методов, описаны выше
public void update(List<DomainObject> updatedObjects) throws SQLException {
PreparedStatement updateStatement =
connection.prepareStatement(
"update domain_objects set data = ?, blocked = false " +
"where id = ? and blocked = true"
);
for (DomainObject domainObject: updatedObjects) {
updateStatement.setObject(1, domainObject.getData());
updateStatement.setInt(2, domainObject.getId());
updateStatement.executeUpdate();
}
}
public DomainObject get(Integer id) throws SQLException {
PreparedStatement updateStatement =
connection.prepareStatement(
"update domain_objects set blocked = true " +
"where id = ? and blocked = false"
);
updateStatement.setInt(1, id);
if (updateStatement.executeUpdate() == 1) {
PreparedStatement selectStatement =
connection.prepareStatement(
"select * from domain_objects where id = ?"
);
selectStatement.setInt(1, id);
ResultSet result = selectStatement.executeQuery();
//result содержит необхродимые данные
//возврашение объекта DomainObject на основе данных result
return new DomainObject();
}
else {
throw new RuntimeException("Блокировка уже захвачена");
}
}
}
В представленном коде при получении данных из базы данных устанавливается блокировка на поле blocked. Если она уже установлена вызывается исключение, в противном случае возвращается результат. Далее при обновлении объекта блокировка сбрасывается. Данная реализация будет работать при всех уровнях изоляции транзакций. При условии, что сохранение данных будет выполняться после захвата блокировок.
Представленные реализации лишь иллюстрируют описание и не предназначены для использования в реальных приложениях!
Политика блокировок бизнес уровня
Политика блокировок — это правила, которыми регулируется конкурентный доступ к данным. В данном абзаце речь идет о политики блокировок на бизнес уровне, а не реализации на уровне запросов к СУБД и механизмов синхронизации платформы. Необходимо учитывать, что политика блокировок должна быть связанна с бизнес-логикой. И нельзя оставлять вопрос с конкурентным доступом на потом. Например в системе, где область применение говорит о том, что некий документ должен редактироваться единолично, необходимо чтобы бизнес-логика обладала знаниями о этом требовании. В случае реализации такого требования бизнес области, только с помощью механизмов синхронизации, реализация бизнес уровня в приложении будет разделена на несколько частей, что не очень хорошо. Во первых, потому что поддержка такой системы усложняется, хотя вариант оптимизирующий производительность путём использования встроенных процедур СУБД достаточно распространён. Во вторых, существует проблема с длинной системной транзакции, то есть нельзя позволять приложению держать системную транзакцию открытой то же время, что и бизнес транзакцию. Область ответственности за бизнес транзакцию лежит на бизнес логике приложения. Поэтому политика блокировок в бизнес транзакции не может рассматриваться отдельно от бизнес логики.
Дополнительные источники (en)
Patterns of Enterprise Application Architecture (Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford)
PostgreSQL Concurrency Control
Concurrency control
Isolation level