Проблемы изоляции транзакций
Конкурентный доступ к данным - одна из основных проблем при реализации изоляции транзакций. Способ решения этой проблемы определяет ключевые характеристики базы данных, на которые мы опираемся при выборе подходящего хранилища для своих проектов. И так повелось, что со времен изобретения SQL-стандарта так и не появилось однозначного определения "уровней изоляции транзакций", благодаря чему у каждой базы данных своё представление относительно этого вопроса. А разбираться с этим многообразием приходится нам - пользователям этих продуктов.
Если посмотреть на определение ACID, то оно не предполагает какой-либо вариативности относительно изоляции транзакций. Тем не менее, реализация таких жестких требований напрямую отражается на пропускной способности. Вследствие этого в SQL-стандарте имеется термин "уровень изоляции транзакции", предполагающий "компромисс", на который может пойти разработчик в случае, если ему потребуется большая пропускная способность. Иначе говоря, нам предлагают улучшить производительность приложения за счет корректности данных, с которыми оно работает. Ясно, что на подобный компромисс нужно идти осознанно, понимая, что именно может пойти не так при выборе определенного уровня изоляции и устраивает ли нас это.
SQL-стандарт определяет всего три феномена (phenomena), т.е. три типа нежелательных последствий, возможных при снижении уровня изоляции: грязное чтение (dirty read), неповторяющееся чтение (non-repeatable read) и фантом (phantom). Эта слабая категоризация послужила причиной вольных трактовок при реализации уровней изоляции. Вскоре этот момент был подвержен критике, и список возможных последствий был расширен и конкретизирован путем ввода нового понятия - аномалия (anomaly). Аномалии конкретизируют действия, которые могут привести к феномену. Существует замечательный исследовательский проект Hermitage, в котором определена современная классификация аномалий и в разрезе них приведено сравнение одних и тех же уровней изоляции в разных популярных базах данных (Oracle, PostgreSQL, MySQL и др.).
В качестве иллюстрации можно рассмотреть феномен неповторяющегося чтения. Транзакция T1 делает два последовательных чтения значения x: сначала считывает значение 10, затем 20. Так происходит, потому что значение x успели поменять в параллельно выполняющейся транзакции T2.
Краткая запись этого феномена выглядит так: "r1[x=10],w2[x=20],r1[x=20]". Данный феномен возможен, например, в режиме Read Committed. Аномалия для этого режима изоляции выглядит так: "r1[x=10],w2[x=20],c2,r1[x=20]" (т.е. после записи вторая транзакция фиксируется - c2). Примерно в таком стиле происходит формализация аномалий и их тестирование в разных базах данных.
Зная и понимая возможные аномалии, исключаем те из них, появление которых неприемлемо для нашего приложения, и таким образом выбираем подходящий уровень изоляции.
Рассмотрим классические уровни изоляции, которые есть в большинстве баз.
- Read Committed. Исключает грязное чтение и запись, т.е. транзакция может читать и изменять только зафиксированные изменения. Ключевой момент в том, что учитываются даже те изменения, которые были зафиксированы во время выполнения текущей транзакции. Обычно на уровне реализации грязную запись исключают за счет использования программных блокировок на уровне редактируемой строки (row-level lock); а грязное чтение - за счет хранения двух версий значения записи: последнее зафиксированное и еще не зафиксированное. Такой уровень изоляции установлен по умолчанию в PostgreSQL, MS SQL и Oracle. Допускает неповторяющееся чтение, потерю изменений (перезапись), искажение чтения/записи (read/write skew).
- Repeatable Read. Транзакция может читать только те изменения, которые были зафиксированы до ее начала. Обычно на уровне реализации грязную запись исключают за счет использования программных блокировок на уровне редактируемой строки (row-level lock); а грязное чтение - за счет хранения нескольких версий значения записи - алгоритм MVCC (Multi-Version Concurrency Control). Такой уровень изоляции установлен по умолчанию в MySQL и MariaDB. Допускает искажение чтения/записи (read/write skew), но в MySQL и MariaDB даже потерю изменений (перезапись). Идеально подходит для создания бэкапов или выполнения долгих read-only транзакций (например, сложные аналитические запросы).
- Serializable. Гарантирует, что даже при параллельном исполнении транзакций результат будет точно таким же, как если бы они исполнялись последовательно. Это определение полностью совпадает с определением изоляции в ACID. Позже этот уровень изоляции Daniel Abadi - один из соавторов протокола Calvin - назвал "идеальной изоляцией" (perfect isolation). Такой уровень изоляции установлен по умолчанию в CockroachDB и YDB. По определению он не должен допускать аномалий. (Примечательно, что в Oracle этот уровень изоляции на самом деле соответствует Repeatable Read.)
Можно подумать, что выбрав уровень Serializable, уйдут все проблемы. К сожалению, нет, если речь идет о распределенных базах данных. Там появляются дополнительные аномалии, большинство из которых связано с изменением порядка выполнения транзакций.
Например, последовательное выполнение трех транзакций "w1[x=1],w2[x=2],w3[x=3]" может закончиться результатом "x=2", поскольку реплика переупорядочила транзакции и в реальности выполнила "w1,w3,w2". Такой исход вполне возможен, например, из-за рассинхронизации часов на репликах в мультимастер-системах. Причем технически подобный исход будет считаться корректным, т.к. формальные требования упорядоченности транзакций (serializability) не нарушаются.
Решая эти проблемы, разработчики одних распределенных баз данных предпочитают скрыть от пользователя все эти сложности и особенности за привычными уровнями изоляции (CockroachDB, YugabyteDB, YDB); разработчики других, наоборот, изобретают свою классификацию уровней (CosmosDB), предоставляя максимальную гибкость в использовании.
Надеюсь, что у меня получилось сформировать более целостную картину относительно уровней изоляции транзакций.
Попытаюсь подвести итоги:
- Выбирая уровень изоляции, помним про аномалии, оцениваем возможные риски и способы их минимизации.
- Одни и те же уровни изоляции у всех реализованы по-разному, поэтому смотрим тесты Hermitage, обязательно пишем свои и читаем документацию.
- Указываем уровень изоляции транзакции явно, помня, что большинство баз данных в целях улучшения производительности по умолчанию использует более слабые уровни изоляции.
- Не допускаем конкурирующих транзакций с разным уровнем изоляции, иначе есть риск привести данные в несогласованное состояние.
- Помним, что базы данных гарантируют техническую целостность данных, но не их согласованность с точки зрения бизнеса. Однако первое может значительно упростить реализацию второго
Пока писал статью, открыл для себя проект Jepsen. Это фреймворк для тестирования распределенных систем на предмет согласованности и корректности работы в условиях сбоев. Инструмент является стандартом де-факто для проверки многих популярных распределённых систем и баз данных. Интересно, насколько реально/удобно использовать этот инструмент для тестирования прикладных проектов... Пока отложу этот вопрос на будущее, но ссылку оставляю в надежде, что она кому-то приходится. ;-)
P.s. Если вам интересна данная тематика, присоединяйтесь к моей новостной ленте в Telegram или здесь. Буду рад поделиться опытом. ;-)