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

Эта серия разделена на три части. Вот список статей серии.

Параллелизм и база данных: роль гарантий изоляции — часть I
Параллелизм и база данных: роль гарантий изоляции — часть II
Параллелизм и база данных: роль гарантий изоляции — часть III

Отказ от ответственности

Большая часть информации на этой странице взята из главы 7: Транзакции книги Designing Data-Intensive Applications, написанной Мартином Клеппманном в 2017 году. Если вы уже читали эту главу, возможно, вы уже встречались с большинством тем. рассматривается в этой статье.

Чтение перекоса

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

Скажем, у Алисы есть 1000 долларов сбережений в банке, разделенных на два счета по 500 долларов на каждом. Теперь транзакция переводит 100 долларов с одного из ее счетов на другой.

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select balance from accounts where id = 1; -- T1. Shows 1 => 500
select balance from accounts where id = 1; -- T2
select balance from accounts where id = 2; -- T2
update accounts set balance = 600 where id = 1; -- T2
update accounts set balance = 400 where id = 2; -- T2
commit; -- T2
select balance from accounts where id = 2; -- T1. Shows 2 => 400
commit; -- T1

Как видите, при таком подходе Алиса может видеть свой общий баланс равным 900, что не соответствует действительности. Если таким образом было выполнено резервное копирование БД, Алиса потеряет 100

Решение перекоса чтения

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

Чтобы реализовать изоляцию моментальных снимков, в базе данных потенциально должно храниться несколько различных зафиксированных версий объекта, поскольку различным выполняемым транзакциям может потребоваться видеть состояние базы данных в разные моменты времени. Поскольку он поддерживает несколько версий объекта рядом друг с другом, этот метод известен как управление многоверсионным параллелизмом (MVCC).

Когда транзакция запускается, ей присваивается уникальный, постоянно увеличивающийся идентификатор транзакции. Всякий раз, когда транзакция записывает что-либо в базу данных, записываемые ею данные помечаются идентификатором транзакции записывающего устройства. Для хранения этих данных к каждой версии добавляются два дополнительных метаполя created_by иDeleted_by вместе с фактическими данными строки. База данных использует эту метаинформацию, чтобы проверить, какая версия должна возвращаться для какой транзакции
Большинство баз данных называют это изоляцией моментальных снимков. В Oracle это называется сериализуемым, а в PostgreSQL и MySQL — повторяемым чтением

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

Потерянное обновление (фантомное чтение)

Теперь, когда мы прошли уровень 1, давайте посмотрим, какие испытания нас ждут

Предположим, что две транзакции, A и B, пытаются одновременно обновить одну и ту же строку в таблице базы данных. Транзакция A считывает текущее значение строки, выполняет вычисление и обновляет строку с результатом. Тем временем транзакция B читает ту же строку, выполняет другое вычисление и обновляет строку своим собственным результатом. Однако, поскольку обновления выполняются одновременно, одно из обновлений перезапишет другое, в результате чего одно из вычислений будет потеряно или перезаписано.

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1
select * from test where id = 1; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 12 where id = 1; -- T2, BLOCKS
commit; -- T1. This unblocks T2, so T1's update is overwritten
commit; -- T2

Разве это не похоже на сценарий 1?
Действительно, если вы посмотрите внимательно, разница только в том, что в сценарии 1 запись происходит в незафиксированных данных, а в этом сценарии запись происходит в зафиксированных данных

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

Решение проблемы с потерянным обновлением

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

Явная блокировка

BEGIN TRANSACTION;
select * from test where id = 1 FOR UPDATE;
-- Check whether update is valid, then update the value
update test set value = 11 where id = 1;
COMMIT;

Сравнить и установить

-- This may or may not be safe, depending on the database implementation 
update test set value = 12 where id = 1 AND value = '2';

Уровень изоляции MySQL Serializable и уровень изоляции повторного чтения Postgres гарантируют предотвращение потери обновлений.

-- postgres
begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1
select * from test where id = 1; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 11 where id = 1; -- T2, BLOCKS
commit; -- T1. T2 now prints out "ERROR: could not serialize access due to concurrent update"
abort;  -- T2. There's nothing else we can do, this transaction has failed

Теперь мы разобрались и с потерянными обновлениями, посмотрим, что там еще осталось, в следующей статье здесь

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

Рекомендации



https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html

https://stackoverflow.com/questions/72850415/isolation-level-difference-between-dirty-write-and-lost-update

Клеппманн, М. (2017). Проектирование приложений, интенсивно использующих данные: большие идеи, лежащие в основе надежных, масштабируемых и удобных в сопровождении систем. O’Reilly Media, Inc. Глава 7: Транзакции. https://dataintensive.net/

https://github.com/ept/hermitage

Михалча, Влад. Транзакции и контроль параллелизма. High-Performance Java Persistence, Влад Михалча, 2016, стр. 87–122, https://vladmihalcea.com/books/high-performance-java-persistence/.

https://martin.kleppmann.com/2014/11/25/hermitage-testing-the-i-in-acid.html