Конспект 7-ой части Кабанчика - Транзакции
Коротко о транзакциях
Введение #
Транзакции — механизм выполнения набора действий с данными как единой группы. Результатом выполнения транзакции будет либо успешное выполнение всех определённых в ней действий, либо невыполнение никаких действий из группы. Транзакции в каком-то смысле поэтому подрузамевают атомарность, то есть минимально допустимое для системы изменение, которое можно считать целостным.
Транзакции предоставляются в качестве инструмента для упрощения работы с системой (как правило с базами данных) на уровне кода бизнес-приложения.
Транзакции не бесплатны. Система, предоставляющая транзакции, гарантирует вышеописанные правила, а гарантии невозможно удовлетворить без дополнительных вычислительных усилий со стороны системы.
В этой части все понятия разбираются на примере наиболее популярных баз данных.
Не все приложения требуют механизм транзакций для своей работы, при необходимости и возможности от них можно отказаться в угоду производительности.
Определение ACID #
Акроним ACID был впервые описан в 1983 году, расшифровывается как Atomicity, Consistency, Isolation and Durability. При этом не существует строгого определения, что эти слова означают, поэтому в основном акроним используется в качестве маркетингового термина. Для двух разных систем, которые обещают ACID, гарантии систем могут сильно отличаться.
Кроме ACID для ещё более красного словца в период расцвета NoSQL баз данных был введён в употребление термин BASE (Basically Available, Soft state and Eventual Consistency), однако он ещё более расплывчатый.
Рассмотрим, что означает каждый из составляющий ACID термин.
Atomicity (атомарность?) #
В данном случае атомарность не та же, что в системном программировании. Здесь под атомарностью понимают как раз гарантии того, что можно определить группу действий с системой, которая будет либо полностью выполнена, либо при ошибке в одном из действий полностью не выполнена (см. определение транзакции).
В рамках этого термина можно ввести термин commit’а, то есть попытки фиксации изменений группой действий.
Consistency (консистентность? согласованность?) #
Термин консистентности сильно перегружен. В данном контексте предполагается, что для системы, гарантирующей ACID, всегда есть набор инвариантов, в которых она может находиться.
Тем не менее вне бизнес применения невозможно определить такую согласованность. Инварианты возникают при наполнении базы данных и работе с ней кода, описывающего бизнес-логику. Поэтому консистентность является в большей степени характеристикой приложения, а не базы данных (системы с ACID).
Isolation #
Согласно изначальному определению, изоляция гарантирует, что одновременно выполняющиеся разными пользователями транзакции изолированы одна от другой так, что любая транзакция выполняется как будто других транзакций не существует, и по порядку. Такое определение формализуется как serializability (сериализуемость, упорядоченность что ли).
Гарантия сериализуемости является очень жёсткой и её выполнение требует больших ограничений на производительность, поэтому на практике применяют разные уровни изоляции (см. далее Уровни изоляции).
Durability #
Система гарантирует, что если транзакция была успешно завершена, то данные надежно зафиксированы и не будут утеряны. Как правило под этим понимают, что данные записаны на жёсткий диск.
Важно помнить, что диски для хранения данных тоже вполне могут эти данные терять со временем.
Примеры, когда полезны гарантии транзакций #
Пример, который всегда приводят при работе с потоками - чтение частично изменённого большого объекта. Этот же пример отлично работает с базами данных. При отсутствии транзакций, один пользователь может обновить некоторое значение, а потом решить его откатить. При этом другой пользователь может прочитать значение между двумя вышеописанными событиями.
При записи большого объемного документа в базу данных может произойти сбой. Без гарантий транзакционности неясно, в каком состоянии будет находиться база данных, перезаписался ли документ частично, утеряно ли старое значение полностью или только частично?
Уровни изоляции #
Если транзакции не затрагивают одни и те же данные, то они могут быть безопасно выполнены в параллель. В обратном случае может возникнуть условие гонки. Ошибки, связанные с состоянием гонки, как правило сложно диагностировать, поскольку причины, приводящие к ошибке, обычно трудновоспроизводимы.
Для того чтобы упростить жизнь разработчику, базы данных традиционно предоставляют изоляцию транзакций.
Гарантия, что никто не может прочитать данные выполненные в некоторой транзакции, но ещё не закомиченные называется no dirty reads.
Гарантия, что при записи данных клиент может перезаписать только те данные, которые уже были закоммичены называется no dirty writes.
Read commited #
Если эти две гарантии выполняются вместе, то такой уровень называется read commited.
Гарантия read commited очень популярна и как правило является базовым уровнем изоляции. No dirty writes реализуется применением системы блокировок на уровне таблиц или строк. Если транзакция хочет модифицировать некоторый объект она сначала получает lock на объект. Если лок уже получен другой транзакцией, то первая сначала дожидается доступности лока.
No dirty reads тоже может быть выполнен с помощью блокировок, однако при доминировании операций чтения над записью этот подход ограничивает быстродействие системы. Если транзакция с записью выполняется долго, то чтения должны будут ожидать выполнения долгой транзакции. Обычно для решения проблемы применяют следующий подход: система хранит общее значение для предоставления операциям чтения и множество новых значений для текущих незавершённых транзакций. Каждая транзакция, которая требует записи, работает со своим значением, а все чтения получают при запросе общее значение. После коммита операции на запись общее значение перезаписывается новым значением.
Snapshot isolation and Repeatable Read #
Уровень изоляции read commited предоставляет изоляцию только в разрезе частных сущностей. Положим, что в рамках транзакции 1 мы проводим два последовательных чтения из разных сущностей. Если в промежутке между первым и вторым чтением другая транзакция изменила две описываемые сущности и закоммитила изменения, то второе чтение получит уже обновлённый вариант, а первое - старый. Это может привнести неконсистентность с точки зрения бизнес-логики.
Эту проблему решает уровень изоляции snapshot isolation. Для каждой транзакции фиксируется логическое время начала её работы. После начала работы транзакция может работать только с данными, которые были доступны на момент начала транзакции.
Такой подход – это спасение для долгих аналитических запросов и бэкапов.
Этот уровень изоляции может быть описан выражением чтение никогда не блокирует запись, запись никогда не блокирует чтение.
Реализация обычно использует обобщение предыдущего подхода. Хранятся несколько последних значений каждой сущности с метками логического времени. Такой подход носит название multi-version concurrency control (MVCC). Значение сущности доступно для транзакции в случае, если оба следующих условия верны:
- В момент, когда читающая транзакция началась, транзакция, которая создала сущность уже закоммичена.
- Сущность не помечена к удалению, или, если помечена, то транзакция, которая пометила сущность к удалению не закоммичена на момент, когда началась читающая транзакция.
Важно помнить, что реализация вышеописанных принципов требует дополнительной работы с индексами. Индексы должны в том числе учитывать наличие нескольких версий одной сущности базы данных и обновляться соответствующим образом в ходе работы клиентов с базой данных.
Решение проблем с конкурентной записью с помощью локов #
Уровень изоляции snapshot isolation (repeatable read) всё ещё не решает проблемы, связанные с конкурирующей записью. Типичный пример – lost update – две транзакции делают одновременную запись нового значения, которое зависит от старого значения. Как итог одно из значений теряется. Проблема типичная, разные БД решают её по-разному, например:
- Атомарные операции на запись
- Явное получение блокировки клиентами на сущности
- Иногда БД могут самостоятельно понять, что произошёл lost update и вернуть ошибку
- Если БД не предоставляет транзакций, то обычно есть хотя бы compare-and-set операции, аналогичные атомарным операциям в системных языках.
- Если БД распределённая (multi-leader, leaderless), то проблема обычно сводится к разрешению конфликтов, о которых говорили в части 5.
Write skew и фантомы #
В предыдущем примере рассматривалась запись одной сущности. При записи в две разные, но связанные на уровне бизнес-правил, сущности решить проблему ранее описанными блокировками уже не удаётся (но удаётся эксклюзивными блокировками, см. далее).
Пример ошибки: есть данные по дежурствам, где в таблице для каждого сотрудника существует флаг “дежурит”. Дежурить должен только один сотрудник. Например сейчас дежурства ещё не распределены. Сотрудник 1 читает базу данных в транзакции, видит, что дежурных нет, и записывает дежурство на себя. Это же делает второй сотрудник. Как итог нарушено бизнес правило, что дежурный должен быть только один. Такая проблема носит название write skew.
Эффект, когда запись в одной транзакции изменяет результат чтения в другой транзакции называется фантомом.
Для решения проблемы write skew можно перейти на следующий уровень изоляции, но иногда подобные ошибки можно решить преобразовав задачу к решаемой на уровне БД другими её средствами, тут нужно подходить креативно.
Serializability (последовательность, сериализуемость) #
Самый высокий уровень изоляции. В этом случае каждая транзакция выполняется последовательно без эффектов параллельности. Существует три варианта реализации, которые применяются в системах баз данных.
Actual Serial Execution (Буквально последовательное выполнение) #
Буквально последовательное выполнение на одном потоке. Для того чтобы система сохраняла быстродействие, транзакции должны быть заранее определены как процедуры, не содержащие долгих синхронных операций (например, вызовов по сети).
Возможно разнесение БД на несколько потоков, если каждый поток будет отвечать за отдельный массив данных. При этом возможно и взаимодействие между потоками, но надо быть готовым, что это сильно замедлит систему.
Также желательно, чтобы весь массив данных помещался в оперативную память для быстроты работы.
Two-phase locking (2PL) #
Достигается расширением блокировок до уровня, что операции на запись блокируют все другие операции, в том числе на чтение (сравните с Snapshot isolation).
Реализация достигается разделением локов на две группы: shared, exclusive. Определения не расписываю, потому что для программиста, который работал с многопоточкой тут всё и так очевидно.
2PL обладает довольно большим штрафом на быстродействие. Тем не менее я считаю, что если всё делать правильно, то этот штраф должен быть оправдан. Если по бизнес-смыслу нечто требует синхронизации, то это нечто объективно будет работать не быстрее, чем процесс, от которого зависит.
Serializable Snapshot Isolation (SSI) #
SSI - это расширение идеи snapshot isolation до выполнения условий сериализуемости.
Эта модель предлагает механизм оптимистичных блокировок. Работа по модификации данных сначала выполняется, и лишь при коммите проверяются условия изоляции. Здесь MVCC расширяется полями о номерах транзакций, которые выполняют действия над данными.
Быстродействие SSI в ситуации, когда условия оптимистичных блокировок выполняются часто, работает быстрее, чем 2PL. То есть как правило быстрее.