Статьи

Руководство для начинающих по блокировке базы данных и потерянным явлениям обновления

Вступление

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

Стандарт SQL определяет три аномалии согласованности (явления) :

  • Грязное чтение , предотвращенное уровнями чтения Read Committed, Repeatable Read и Serializable
  • Неповторяемые операции чтения , предотвращаемые уровнями изоляции Repeatable Read и Serializable
  • Фантомное чтение , предотвращаемое сериализуемым уровнем изоляции

Менее известным явлением является аномалия потерянных обновлений, и это то, что мы собираемся обсудить в этой текущей статье.

Уровни изоляции

Большинство систем баз данных используют Read Committed в качестве уровня изоляции по умолчанию (MySQL вместо этого использует Repeatable Read). Выбор уровня изоляции заключается в поиске правильного баланса согласованности и масштабируемости для наших текущих требований приложений.

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

PostgreSQL использует как блокировки, так и MVCC (Multiversion Concurrency Control) . В MVCC блокировки чтения и записи не противоречат друг другу, поэтому чтение не блокирует запись, а запись также не блокирует чтение.

Поскольку большинство приложений используют уровень изоляции по умолчанию, очень важно понимать характеристики Read Committed:

  • Запросы видят только данные, принятые до начала запроса, а также незафиксированные изменения текущей транзакции.
  • Параллельные изменения, зафиксированные во время выполнения запроса, не будут видны текущему запросу
  • Операторы UPDATE / DELETE используют блокировки для предотвращения одновременных изменений

Если две транзакции пытаются обновить одну и ту же строку, вторая транзакция должна дождаться, чтобы первая из них либо зафиксировала, либо произвела откат, и если первая транзакция была зафиксирована, то условие DML WHERE для второй транзакции необходимо пересмотреть, чтобы увидеть, соответствует ли совпадение. все еще актуально.

uncontendedtransactions1

В этом примере UPDATE Боба должен ждать завершения транзакции Алисы (commit / rollback), чтобы продолжить.

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

Потерянные обновления

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

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

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

lostupdatesinglerequesttransactions

В этом примере Боб не знает, что Алиса только что изменила количество с 7 до 6, поэтому ее ОБНОВЛЕНИЕ заменяется изменением Боба.

Типичная стратегия поиска-изменения-сброса ORM

Hibernate (как и любой другой инструмент ORM) автоматически переводит переходы состояния сущности в запросы SQL . Сначала вы загружаете объект, изменяете его и позволяете механизму сброса Hibernate синхронизировать все изменения с базой данных.

01
02
03
04
05
06
07
08
09
10
11
public Product incrementLikes(Long id) {
    Product product = entityManager.find(Product.class, id);
    product.incrementLikes();
    return product;
}
 
public Product setProductQuantity(Long id, Long quantity) {
    Product product = entityManager.find(Product.class, id);
    product.setQuantity(quantity);
    return product;
}

Как я уже указывал, все операторы UPDATE получают блокировки записи даже в изоляции Read Committed. Политика обратной записи контекста постоянства направлена ​​на уменьшение интервала удержания блокировки, но чем больше период между операциями чтения и записи, тем больше шансов попасть в потерянную ситуацию обновления.

Hibernate включает все столбцы строк в инструкции UPDATE. Эту стратегию можно изменить, чтобы она включала только грязные свойства (с помощью аннотации @DynamicUpdate ), но справочная документация предупреждает нас о ее эффективности:

Хотя в некоторых случаях эти параметры могут повысить производительность, в других они могут фактически снизить производительность.

Итак, давайте посмотрим, как Алиса и Боб одновременно обновляют один и тот же Продукт с помощью платформы ORM:

Алиса боб
store = # BEGIN;
store = # ВЫБРАТЬ * ИЗ ПРОДУКТА
ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 7
(1 ряд)
store = # BEGIN;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 7
(1 ряд)
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7)
ГДЕ ID = 1;
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (5, 10)
ГДЕ ID = 1;
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 6 | 7
(1 ряд)
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 10
(1 ряд)
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;

ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 10
(1 ряд)

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

Повторяемое чтение

Использование Repeatable Read (а также Serializable, который предлагает еще более строгий уровень изоляции) может предотвратить потерю обновлений при одновременных транзакциях базы данных.

Алиса боб
store = # BEGIN;
store = # УСТАНОВИТЬ УРОВЕНЬ ИЗОЛЯЦИИ СДЕЛКИ ПОВТОРЯЮЩЕЙСЯ ЧИТАТЬ
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 7
(1 ряд)
store = # BEGIN;
store = # УСТАНОВИТЬ УРОВЕНЬ ИЗОЛЯЦИИ СДЕЛКИ ПОВТОРЯЮЩЕЙСЯ ЧИТАТЬ
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 7
(1 ряд)
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7) WHERE ID = 1;
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (5, 10) WHERE ID = 1;
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 6 | 7
(1 ряд)
ОШИБКА: не удалось сериализовать доступ из-за одновременного обновления
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ОШИБКА: текущая транзакция прервана, команды игнорируются до конца блока транзакции
(1 ряд)

На этот раз Боб не смог переписать изменения Алисы, и его транзакция была прервана. В режиме Repeatable Read запрос увидит моментальный снимок данных на момент начала текущей транзакции. Изменения, зафиксированные другими параллельными транзакциями, не видны текущей транзакции.

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

ВЫБРАТЬ ДЛЯ ОБНОВЛЕНИЯ

Другим решением было бы использование FOR UPDATE со стандартным уровнем изоляции Read Committed. Это предложение блокировки получает те же блокировки записи, что и для операторов UPDATE и DELETE.

Алиса боб
store = # BEGIN;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1 ДЛЯ ОБНОВЛЕНИЯ;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 5 | 7
(1 ряд)
store = # BEGIN;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1 ДЛЯ ОБНОВЛЕНИЯ;
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7) WHERE ID = 1;
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
ID | НРАВИТСЯ | КОЛИЧЕСТВО
— + — + —-
1 | 6 | 7
(1 ряд)
id | любит | количество
— + — + —-
1 | 6 | 7
(1 строка) store = # ОБНОВЛЕНИЕ ПРОДУКТА (LIKES, QUANTITY) = (6, 10) WHERE ID = 1;
ОБНОВЛЕНИЕ 1
store = # COMMIT;
COMMIT
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество
— + — + —-
1 | 6 | 10
(1 ряд)

Боб не смог продолжить работу с оператором SELECT, потому что Алиса уже получила блокировки записи в той же строке. Бобу придется подождать, пока Алиса завершит свою транзакцию, и, когда SELECT Боба разблокируется, он автоматически увидит ее изменения, поэтому ОБНОВЛЕНИЕ Алисы не будет потеряно.

Обе транзакции должны использовать блокировку FOR UPDATE. Если первая транзакция не получает блокировки записи, потерянное обновление все равно может произойти.

Алиса боб
store = # BEGIN;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество
— + — + —-
1 | 5 | 7
(1 ряд)
store = # BEGIN;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1 ДЛЯ ОБНОВЛЕНИЯ
id | любит | количество
— + — + —-
1 | 5 | 7
(1 ряд)
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7) WHERE ID = 1;
store = # ОБНОВЛЕНИЕ ПРОДУКТА (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 10) WHERE ID = 1;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество
— + — + —-
1 | 6 | 10
(1 ряд)
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;

id | любит | количество
— + — + —-
1 | 6 | 7
(1 ряд)

store = # COMMIT;

store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;

id | любит | количество
— + — + —-
1 | 6 | 7
(1 ряд)

ОБНОВЛЕНИЕ Алисы блокируется до тех пор, пока Боб не снимет блокировки записи в конце своей текущей транзакции. Но контекст постоянства Алисы использует устаревший моментальный снимок сущности, поэтому она перезаписывает изменения Боба, что приводит к другой ситуации с потерянным обновлением.

Оптимистическая блокировка

Мой любимый подход — заменить пессимистическую блокировку на оптимистическую блокировку. Как и MVCC, оптимистическая блокировка определяет модель управления параллелизмом версий, которая работает без получения дополнительных блокировок записи в базу данных.

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

Алиса боб
store = # BEGIN;
НАЧАТЬ
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество | версия
— + — + —- + —
1 | 5 | 7 | 2
(1 ряд)
store = # BEGIN;
НАЧАТЬ
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество | версия
— + — + —- + —
1 | 5 | 7 | 2
(1 ряд)
store = # ОБНОВЛЕНИЕ ПРОДУКТА (LIKES, QUANTITY, VERSION) = (6, 7, 3) WHERE (ID, VERSION) = (1, 2);
ОБНОВЛЕНИЕ 1
store = # ОБНОВЛЕНИЕ ПРОДУКТА (LIKES, QUANTITY, VERSION) = (5, 10, 3) WHERE (ID, VERSION) = (1, 2);
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество | версия
— + — + —- + —
1 | 6 | 7 | 3
(1 ряд)
ОБНОВЛЕНИЕ 0
store = # COMMIT;
store = # SELECT * ИЗ ПРОДУКТА, ГДЕ ID = 1;
id | любит | количество | версия
— + — + —- + —
1 | 6 | 7 | 3
(1 ряд)

Каждое ОБНОВЛЕНИЕ переносит версию времени загрузки в предложение WHERE, предполагая, что никто не изменил эту строку с момента ее получения из базы данных. Если какой-либо другой транзакции удается зафиксировать более новую версию объекта, предложение UPDATE WHERE больше не будет соответствовать ни одной строке, поэтому потерянное обновление будет предотвращено.

Hibernate использует результат PreparedStatement # executeUpdate для проверки количества обновленных строк. Если ни одна строка не была найдена , она генерирует исключение StaleObjectStateException (при использовании Hibernate API) или OptimisticLockException (при использовании JPA).

Как и в случае с Repeatable Read, текущая транзакция и контекст постоянства отменяются в отношении гарантий атомарности.

Вывод

Потерянные обновления могут произойти, если вы не планируете предотвращать такие ситуации. За исключением оптимистической блокировки, все подходы с пессимистической блокировкой эффективны только в области одной и той же транзакции базы данных, когда операторы SELECT и UPDATE выполняются в одной и той же физической транзакции.

В моем следующем посте я объясню, почему оптимистическая блокировка — единственное жизнеспособное решение при использовании транзакций уровня приложения, как это имеет место для большинства веб-приложений.