Автор оригинала: Vlad Mihalcea.
База данных-это очень параллельная система . Всегда существует вероятность конфликтов обновлений, например, когда две одновременные транзакции пытаются обновить одну и ту же запись. Если бы в любой момент времени была только одна транзакция базы данных, то все операции выполнялись бы последовательно. Проблема возникает, когда несколько транзакций пытаются обновить одни и те же строки базы данных, поскольку нам все еще необходимо обеспечить согласованный переход состояния данных.
Стандарт SQL определяет три аномалии согласованности (явления):
- Грязные чтения , предотвращаемые фиксированным чтением, повторяемым чтением и сериализуемыми уровнями изоляции
- Неповторяемые чтения , предотвращаемые повторяемыми уровнями изоляции чтения и сериализации
- Фантомные чтения , предотвращаемые уровнем сериализуемой изоляции
Менее известным явлением является аномалия потерянные обновления , и это то, что мы собираемся обсудить в этой текущей статье.
Большинство систем баз данных используют фиксированное чтение в качестве уровня изоляции по умолчанию (вместо этого MySQL использует повторяемое чтение). Выбор уровня изоляции связан с поиском правильного баланса согласованности и масштабируемости для наших текущих требований к приложениям.
Все следующие примеры будут выполняться на PostgreSQL . Другие системы баз данных могут вести себя по-разному в зависимости от их конкретной реализации ACID.
PostgreSQL использует как блокировки, так и MVCC (Управление многовариантным параллелизмом) . В MVCC блокировки чтения и записи не конфликтуют, поэтому читатели не блокируют авторов, а авторы не блокируют читателей.
Поскольку большинство приложений используют уровень изоляции по умолчанию, очень важно понимать характеристики фиксации чтения:
- Запросы отображают только данные, зафиксированные до начала запроса, а также незафиксированные изменения текущей транзакции
- Одновременные изменения, внесенные во время выполнения запроса, не будут видны текущему запросу
- Инструкции UPDATE/DELETE используют блокировки для предотвращения одновременных изменений
Если две транзакции пытаются обновить одну и ту же строку, вторая транзакция должна дождаться фиксации или отката первой транзакции, и если первая транзакция была зафиксирована, то предложение DML второй транзакции WHERE должно быть переоценено, чтобы убедиться, что совпадение все еще актуально.
В этом примере ОБНОВЛЕНИЕ Боба должно дождаться завершения транзакции Алисы (фиксация/откат), чтобы продолжить.
Зафиксированное чтение обеспечивает больше одновременных транзакций, чем другие более строгие уровни изоляции, но меньшая блокировка повышает вероятность потери обновлений.
Если две транзакции обновляют разные столбцы одной и той же строки, то конфликта нет. Второе обновление блокируется до тех пор, пока не будет зафиксирована первая транзакция, и конечный результат не отразит оба изменения обновления.
Если две транзакции захотят изменить одни и те же столбцы, вторая транзакция перезапишет первую, в результате чего первое обновление транзакции будет потеряно.
Таким образом, обновление теряется, когда пользователь переопределяет текущее состояние базы данных, не осознавая, что кто-то другой изменил его в период между моментом загрузки данных и моментом обновления.
В этом примере Боб не знает, что Алиса только что изменила количество с 7 на 6, поэтому ее ОБНОВЛЕНИЕ перезаписывается изменением Боба.
Hibernate (как и любой другой инструмент ORM) автоматически преобразует переходы состояний сущностей в запросы SQL . Сначала вы загружаете объект, изменяете его и позволяете механизму Hibernate flush синхронизировать все изменения с базой данных.
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; }
Как я уже указывал, все операторы ОБНОВЛЕНИЯ получают блокировки записи, даже в изоляции, зафиксированной для чтения. Политика скрытой записи в контексте сохранения направлена на сокращение интервала удержания блокировки, но чем дольше период между операциями чтения и записи, тем больше шансов попасть в ситуацию потери обновления.
Hibernate включает все столбцы строк в инструкцию UPDATE. Эта стратегия может быть изменена, чтобы включать только грязные свойства (через @DynamicUpdate аннотацию), но справочная документация предупреждает нас о ее эффективности:
Хотя в некоторых случаях эти параметры могут повысить производительность, в других они могут фактически снизить производительность.
Итак, давайте посмотрим, как Алиса и Боб одновременно обновляют один и тот же продукт с помощью платформы ORM:
магазин=# НАЧАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 7 (1 РЯД) | магазин=# НАЧАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 7 (1 РЯД) |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7), ГДЕ; | |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (5, 10), ГДЕ; | |
магазин=# ЗАФИКСИРОВАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 6 | 7 (1 РЯД) | |
магазин=# ЗАФИКСИРОВАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 10 (1 РЯД) | |
магазин=# ВЫБЕРИТЕ * ИЗ СПИСКА ТОВАРОВ, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 10 (1 РЯД) |
И снова обновление Алисы пропадает без того, чтобы Боб даже не узнал, что переписал ее изменения. Мы всегда должны предотвращать аномалии целостности данных, поэтому давайте посмотрим, как мы можем преодолеть это явление.
Повторяемое Чтение
Использование повторяемого чтения (а также сериализуемого, что обеспечивает еще более строгий уровень изоляции) может предотвратить потерю обновлений в параллельных транзакциях базы данных.
магазин=# НАЧАТЬ; магазин=# УСТАНОВИТЬ УРОВЕНЬ ИЗОЛЯЦИИ ТРАНЗАКЦИЙ, ПОВТОРЯЕМОЕ ЧТЕНИЕ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 7 (1 РЯД) | магазин=# НАЧАТЬ; магазин=# УСТАНОВИТЬ УРОВЕНЬ ИЗОЛЯЦИИ ТРАНЗАКЦИЙ, ПОВТОРЯЕМОЕ ЧТЕНИЕ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 7 (1 РЯД) |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7), ГДЕ; | |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (5, 10), ГДЕ; | |
магазин=# ЗАФИКСИРОВАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 6 | 7 (1 РЯД) | |
ОШИБКА: не удалось сериализовать доступ из-за параллельного хранилища обновлений=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ОШИБКА: текущая транзакция прервана, команды игнорируются до конца блока транзакций (1 СТРОКА) |
На этот раз Боб не смог перезаписать изменения Алисы, и его транзакция была прервана. При повторяемом чтении запрос будет отображать моментальный снимок данных на момент начала текущей транзакции. Изменения, внесенные другими параллельными транзакциями, не видны текущей транзакции.
Если две транзакции попытаются изменить одну и ту же запись, вторая транзакция будет ждать, пока первая либо зафиксирует, либо откатит. Если первая транзакция фиксируется, то вторая должна быть прервана, чтобы предотвратить потерю обновлений.
Другим решением было бы использовать ДЛЯ ОБНОВЛЕНИЯ с уровнем изоляции по умолчанию, зафиксированным для чтения. Это предложение блокировки получает те же блокировки записи, что и в операторах UPDATE и DELETE.
магазин=# НАЧАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ ДЛЯ ОБНОВЛЕНИЯ; | магазин=# НАЧАЛО; магазин=# ВЫБЕРИТЕ * ИЗ ПРОДУКТА, ГДЕ ТРЕБУЕТСЯ ОБНОВЛЕНИЕ; ИДЕНТИФИКАТОР | ЛАЙКИ | КОЛИЧЕСТВО –+—+—- 1 | 5 | 7 (1 РЯД) |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7) ГДЕ; магазин=# ЗАФИКСИРОВАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; ИДЕНТИФИКАТОР | НРАВИТСЯ | КОЛИЧЕСТВО –+—+—- 1 | 6 | 7 (1 РЯД) | |
идентификатор | лайки | количество –+—+—- 1 | 6 | 7 (1 строка) магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (ЛАЙКИ, КОЛИЧЕСТВО) = (6, 10) ГДЕ; ОБНОВИТЬ 1 магазин=# ЗАФИКСИРОВАТЬ; ЗАФИКСИРОВАТЬ магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; идентификатор | лайки | количество –+—+—- 1 | 6 | 10 (1 ряд) |
Боб не смог продолжить выполнение инструкции SELECT, потому что Алиса уже получила блокировки записи в той же строке. Бобу придется подождать, пока Алиса завершит транзакцию, и когда ВЫБОР Боба будет разблокирован, он автоматически увидит ее изменения, поэтому ОБНОВЛЕНИЕ Алисы не будет потеряно.
Обе транзакции должны использовать блокировку ДЛЯ ОБНОВЛЕНИЯ. Если первая транзакция не получит блокировок на запись, потерянное обновление все равно может произойти.
магазин=# НАЧАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; идентификатор | нравится | количество –+—+—- 1 | 5 | 7 (1 ряд) | |
магазин=# НАЧАЛО; магазин=# ВЫБЕРИТЕ * ИЗ ПРОДУКТА, ГДЕ ДЛЯ ОБНОВЛЕНИЯ идентификатора | лайков | количества –+—+—- 1 | 5 | 7 (1 ряд) | |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 7), ГДЕ; | |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО) = (6, 10) ГДЕ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; идентификатор | нравится | количество –+—+—- 1 | 6 | 10 (1 строка) хранить=# ЗАФИКСИРОВАТЬ; | |
магазин=# ВЫБЕРИТЕ * ИЗ СПИСКА ТОВАРОВ, ГДЕ; идентификатор | нравится | количество –+—+—- 1 | 6 | 7 (1 строка) хранить=# ЗАФИКСИРОВАТЬ; | |
магазин=# ВЫБЕРИТЕ * ИЗ СПИСКА ТОВАРОВ, ГДЕ; идентификатор | нравится | количество –+—+—- 1 | 6 | 7 (1 ряд) |
ОБНОВЛЕНИЕ Алисы блокируется до тех пор, пока Боб не снимет блокировку записи в конце своей текущей транзакции. Но контекст сохранения Алисы использует устаревший снимок сущности, поэтому она перезаписывает изменения Боба, что приводит к еще одной ситуации с потерянным обновлением.
Мой любимый подход-заменить пессимистическую блокировку оптимистичным механизмом блокировки. Как и MVCC , оптимистическая блокировка определяет модель управления параллелизмом управления версиями, которая работает без получения дополнительных блокировок записи в базу данных.
Таблица продуктов также будет содержать столбец версии, который предотвращает перезапись старых снимков данных последними данными.
магазин=# НАЧАЛО; НАЧАТЬ магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; идентификатор | нравится | количество | версия –+—+—-+— 1 | 5 | 7 | 2 (1 ряд) | магазин=# НАЧАЛО; НАЧАТЬ магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; идентификатор | нравится | количество | версия –+—+—-+— 1 | 5 | 7 | 2 (1 ряд) |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО, ВЕРСИЯ) = (6, 7, 3), ГДЕ (ИДЕНТИФИКАТОР, ВЕРСИЯ) = (1, 2); ОБНОВЛЕНИЕ 1 | |
магазин=# ОБНОВИТЬ НАБОР ПРОДУКТОВ (НРАВИТСЯ, КОЛИЧЕСТВО, ВЕРСИЯ) = (5, 10, 3), ГДЕ (ИДЕНТИФИКАТОР, ВЕРСИЯ) = (1, 2); | |
магазин=# ЗАФИКСИРОВАТЬ; магазин=# ВЫБРАТЬ * ИЗ ПРОДУКТА, ГДЕ; идентификатор | нравится | количество | версия –+—+—-+— 1 | 6 | 7 | 3 (1 ряд) | |
ОБНОВЛЕНИЕ 0 магазин=# ФИКСАЦИЯ; магазин=# ВЫБОР * ИЗ ПРОДУКТА, ГДЕ; идентификатор | нравится | количество | версия –+—+—-+— 1 | 6 | 7 | 3 (1 ряд) |
Каждое ОБНОВЛЕНИЕ помещает версию во время загрузки в предложение WHERE, предполагая, что никто не изменял эту строку с тех пор, как она была извлечена из базы данных. Если некоторые другие менеджеры транзакций зафиксируют более новую версию сущности, предложение UPDATE WHERE больше не будет соответствовать какой-либо строке, и поэтому потерянное обновление предотвращается.
Hibernate использует результат PreparedStatement#executeUpdate для проверки количества обновленных строк. Если ни одна строка не была сопоставлена, она создает исключение StaleObjectStateException (при использовании API гибернации) или исключение OptimisticLockException (при использовании JPA).
Как и в случае с повторяемым чтением, текущая транзакция и контекст сохранения прерываются в соответствии с гарантиями атомарности.
Потерянные обновления могут произойти, если вы не планируете предотвращать такие ситуации. За исключением оптимистической блокировки, все пессимистические подходы к блокировке эффективны только в рамках одной и той же транзакции базы данных, когда инструкции SELECT и UPDATE выполняются в одной и той же физической транзакции.