Эта статья является конспектом книги «Designing Data-Intensive Applications».
В суровой реальности информационных систем очень многое может пойти не так - программное или аппаратное обеспечение базы данных может отказать в любой момент; в любой момент может произойти фатальный сбой приложения; разрывы сети могут неожиданно отрезать приложение от базы данных или один узел базы от другого; состояния гонки между клиентами могут привести к неожиданным ошибкам.
Транзакции в течение десятилетий считались предпочтительным механизмом решения этих проблем. Транзакция — способ группировки приложением нескольких операций записи и чтения в одну логическую единицу. По сути, все операции записи и чтения в ней выполняются как одна: вся транзакция или целиком выполняется успешно (с фиксацией изменений), или целиком завершается неудачно (с прерыванием и откатом). Транзакции значительно упрощают для приложения обработку ошибок, поскольку нет нужды заботиться о частичных отказах.
В этом конспекте рассмотрим примеры возможных проблем и изучим алгоритмы, которые используют БД для их предотвращения. Рассмотрим вопрос управления конкурентным доступом, обсудим различные виды возникающих состояний гонки, а также реализацию в базах различных уровней изоляции.
Скользкая концепция транзакции
В конце 2000-х годов приобрели популярность нереляционные (NoSQL) базы данных. Их целью было улучшить существующее положение дел с реляционными БД с помощью новых моделей данных, репликации и секционирования. Транзакции оказались главной жертвой этого новшества: многие базы нового поколения полностью от них отказались или поменяли значение термина: теперь он стал у них означать намного более слабый набор функциональных гарантий, чем ранее.
Вместе с шумихой вокруг этого нового обильного урожая распределенных баз данных стало широко распространяться мнение, что в любой крупномасштабной системе необходимо отказаться от транзакций ради сохранения хорошей производительности и высокой доступности. С другой стороны, производители БД иногда представляют транзакционные функциональные гарантии как обязательное требование для «серьезных приложений», оперирующих «ценными данными». Обе точки зрения — преувеличение.
Истина не столь проста: как и любое другое техническое проектное решение, транзакции имеют свои достоинства и ограничения. Чтобы лучше разобраться в их плюсах и минусах, глубже заглянем в подробности предоставляемых транзакциями функциональных гарантий.
Обеспечиваемые транзакциями гарантии функциональной безопасности часто описываются известной аббревиатурой ACID (atomicity, consistency, isolation, durability — атомарность, согласованность, изоляция и сохраняемость).
Однако на практике реализации ACID в разных базах отличаются друг от друга. На сегодняшний день заявление о «совместимости системы с ACID» не дает четкого представления о предоставляемых гарантиях. К сожалению, ACID стал скорее термином из области маркетинга.
Системы, не соответствующие критериям ACID, иногда называются BASE: «как правило, доступна» (Basically Available), «гибкое состояние» (Soft state) и «конечная согласованность» (Eventual consistency). Это понятие еще более расплывчатое, чем ACID.
Давайте посмотрим на определения атомарности, согласованности, изоляции и сохраняемости. Это позволит уточнить наши представления о транзакции.
Атомарность
В общем атомарность определяется как «невозможность разбиения на меньшие части». Данный термин означает немного различные вещи в разных отраслях информатики. Например, если в многопоточном программировании один из потоков выполняет атомарную операцию, это значит, что ни при каких обстоятельствах другие потоки не могут увидеть ее промежуточные результаты.
Напротив, в контексте ACID атомарность не связана с конкурентным доступом. Этот термин не описывает, что происходит, когда несколько процессов пытаются обратиться к одним и тем же данным одновременно, поскольку относится к понятию изоляции, то есть букве I в аббревиатуре ACID.
Атомарность в ACID описывает происходящее при сбое в процессе выполнения клиентом нескольких операций записи, в момент, когда выполнена лишь их часть. Если операции записи сгруппированы в атомарную транзакцию и ее не удается завершить (зафиксировать изменения) из-за сбоя, то она прерывается и базе данных приходится откатить все уже выполненные в рамках этой транзакции операции записи.
При возникновении ошибки во время выполнения нескольких изменений без атомарности было бы сложно понять, какие из них вступили в действие. Приложение способно попытаться выполнить их снова, но здесь возникает риск выполнения одних и тех же изменений дважды; это может привести к дублированию или к ошибкам в них. Атомарность упрощает задачу: если транзакция была прервана, то приложение может быть уверено, что ничего не было изменено и можно безопасно повторить изменения.
Согласованность
Слово «согласованность» ужасно перегружено.
Согласованность реплик и вопрос конечной согласованности, возникающий в асинхронно реплицируемых системах.
Согласованное хеширование — метод секционирования, используемый в некоторых системах для перебалансировки.
В теореме CAP слово «согласованность» используется для обозначения линеаризуемости (linearizability).
В контексте ACID под согласованностью понимается то, что база данных находится, с точки зрения приложения, в «хорошем состоянии».
О первых трех пунктах можно подробнее прочесть в книге. Для репликации и секционирования автор книги выделил по целой главе.
К сожалению, одно и то же слово применяется как минимум в четырех различных смыслах.
Идея согласованности в смысле ACID состоит в том, что определенные утверждения относительно данных (инварианты) должны всегда оставаться справедливыми — например, в системе бухгалтерского учета кредит всегда должен сходиться с дебетом по всем счетам. Если транзакция начинается при допустимом (в соответствии с этими инвариантами) состоянии базы данных и любые производимые во время транзакции операции записи сохраняют это свойство, то можно быть уверенными, что система всегда будет удовлетворять инвариантам.
Атомарность, изоляция и сохраняемость — свойства базы данных, в то время как согласованность (в смысле ACID) — свойство приложения. Оно может полагаться на свойства атомарности и изоляции базы данных, чтобы обеспечить согласованность, но не на одну только базу. По мнению автора книги, букве C на самом деле не место в аббревиатуре ACID.
Изоляция
К большинству баз данных обращается одновременно несколько клиентов. Это не вызывает проблем, когда они читают и записывают в различные части базы данных. Но если они обращаются к одним и тем же записям базы, то могут возникнуть проблемы конкурентного доступа (состояния гонки).
Изоляция в смысле ACID означает, что конкурентно выполняемые транзакции изолированы друг от друга — они не могут помешать друг другу. Классические учебники по базам данных понимают под изоляцией сериализуемость (serializability). То есть каждая транзакция выполняется так, будто она единственная во всей базе. БД гарантирует, что результат фиксации транзакций такой же, как если бы они выполнялись последовательно, хотя в реальности они могут выполняться конкурентно.
Сохраняемость
Сохраняемость (durability) — обязательство базы не терять записанных (успешно зафиксированных) транзакций данных, даже в случае сбоя аппаратного обеспечения или фатального сбоя самой БД.
В одноузловой базе сохраняемость обычно означает запись данных на энергонезависимый носитель информации, например, жесткий диск или SSD. Она обычно подразумевает также наличие журнала упреждающей записи или чего-то в этом роде, обеспечивающего возможность восстановления в случае повреждения структуры данных на диске. В реплицируемой БД сохраняемость может означать, что данные были успешно скопированы на некоторое количество узлов. Для обеспечения гарантии сохраняемости база должна дожидаться завершения этих операций записи или репликаций, прежде чем сообщать об успешной фиксации транзакции.
Однако абсолютная надежность недостижима: если все жесткие диски и резервные копии будут уничтожены одновременно, то база данных, безусловно, не сможет никак вас спасти.
Выводы по ACID
В ACID понятия атомарности и изоляции характеризуют действия, которые должна предпринимать база данных в случае выполнения клиентом нескольких операций записи в одной транзакции.
Атомарность. Если посередине последовательности операций записи происходит ошибка, то транзакцию необходимо прервать, а выполненные до того момента операции аннулировать.
Изоляция. Конкурентно выполняемые транзакции не должны мешать друг другу. Например, если одна транзакция выполняет несколько операций записи, то другая должна видеть или все их результаты, или никакие, но не какое-то подмножество.
Эти определения предполагают, что необходимо модифицировать несколько объектов (строк, документов или записей) одновременно. Подобные многообъектные транзакции часто оказываются нужны для обеспечения синхронизации нескольких элементов данных.
Обработка ошибок и прерывание транзакций
Отличительная особенность транзакций — возможность их прерывания и безопасного повторного выполнения в случае возникновения ошибки. На этом принципе построены базы данных ACID: при возникновении риска нарушения гарантий атомарности, изоляции или сохраняемости БД скорее полностью отменит транзакцию, чем оставит ее незавершенной.
Но не все системы следуют этой стратегии. В частности, хранилища данных, использующие репликацию без ведущего узла, работают более или менее на основе принципа «лучшее из возможного». Он формулируется следующим образом: «База данных делает все, что может, и при столкновении с ошибкой не станет откатывать уже выполненные действия», поэтому восстановление после ошибок является обязанностью приложения.
Хотя повторение прерванных транзакций — простой и эффективный механизм обработки ошибок, он имеет недостатки:
Если причина ошибки — в перегруженности, то повтор транзакции только усугубит проблему.
Имеет смысл повторять выполнение транзакций только для временных ошибок (происходящих, например, из-за взаимной блокировки, нарушения изоляции, временных проблем с сетью или восстановления после сбоя). Попытка повтора выполнения при постоянной ошибке (допустим, при нарушении ограничения) бессмысленна.
Если у транзакции есть побочные действия вне базы данных, то они могут выполняться даже в случае ее прерывания. Например, вряд ли вы захотите повторять отправку сообщения электронной почты при каждой попытке повтора транзакции.
В случае, когда транзакция была выполнена успешно, но произошел сбой сети при подтверждении клиенту ее успешной фиксации (вследствие чего клиент думает, что она завершилась неудачей), повтор приведет к выполнению этой транзакции дважды.
Слабые уровни изоляции
Транзакции, не затрагивающие одних и тех же данных, могут спокойно выполняться конкурентно, поскольку друг от друга не зависят. Проблемы конкурентного доступа (состояния гонки) возникают, только если одна транзакция читает данные, модифицируемые в этот момент другой, или две транзакции пытаются одновременно модифицировать одни и те же данные.
Базы данных долгое время пытались инкапсулировать вопросы конкурентного доступа от разработчиков приложений путем изоляции транзакций (transaction isolation). Теоретически изоляция должна была облегчить жизнь разработчиков, которые смогли бы сделать вид, что никакого конкурентного выполнения не происходит: сериализуемая изоляция означает гарантию базой данных такого режима выполнения транзакций, как будто они выполняются последовательно.
На практике, к сожалению, с изоляцией не все так просто. Затраты на сериализуемую изоляцию довольно высоки, и многие базы данных не согласны платить столь высокую цену. Так что многие системы часто задействуют более слабые уровни изоляции, защищающие от части проблем конкурентного доступа, а не от всех.
Автор книги не стал отдельно рассматривать уровень изоляции «чтение незафиксированных данных» (read uncommitted). Он предотвращает «грязные» операции записи, но не «грязные» операции чтения.
Чтение зафиксированных данных
Этот уровень изоляции обеспечивает две гарантии:
При чтении из БД клиент видит только зафиксированные данные (никаких «грязных» операций чтения).
При записи в БД можно перезаписывать только зафиксированные данные (никаких «грязных» операций записи).
Если транзакция записала данные в базу, но еще не была зафиксирована или была прервана и другая транзакция увидела эти незафиксированные данные, то такая операция чтения называется «грязной» (dirty read).
Выполняемые при уровне изоляции транзакций read committed (чтение зафиксированных данных) транзакции должны предотвращать «грязные» операции чтения. Это значит, что любые операции записи, выполняемые транзакцией, становятся видны другим транзакциям только после фиксации данной.
Если более ранняя операция записи представляет собой часть еще не зафиксированной транзакции и более поздняя транзакция перезаписывает незафиксированное значение, то такая операция называется «грязной» операцией записи.
Чтение зафиксированных данных предотвращает казусы, например, как на рис. 3, связанные с грязной операцией записи.
Чтение зафиксированных данных — очень популярный уровень изоляции. Он используется по умолчанию в Oracle 11g, PostgreSQL, SQL Server 2012, MemSQL и многих других базах данных.
Чаще всего базы используют блокировки строк для предотвращения «грязных» операций записи: прежде чем модифицировать конкретный объект (строку или документ), транзакция должна сначала установить блокировку на этот объект. Данная блокировка должна удерживаться вплоть до фиксации или прерывания транзакции. Удерживать блокировку на конкретный объект может только одна транзакция одновременно, другой транзакции, желающей выполнить операцию записи в этот объект, придется дождаться фиксации или прерывания первой транзакции и лишь затем получить на него блокировку и продолжить свою работу. Подобные блокировки выполняются базами автоматически в режиме чтения зафиксированных данных (и на более сильных уровнях изоляции).
Большинство БД предотвращают «грязные» операции чтения с помощью подхода, показанного на рис. 2: база запоминает для каждого записываемого объекта как старое зафиксированное значение, так и новое, устанавливаемое транзакцией, удерживающей в данный момент блокировку записи. Во время выполнения транзакции всем другим транзакциям, читающим объект, просто возвращается старое значение. Только после фиксации нового значения транзакции начинают получать его при чтении.
Изоляция снимков состояния и воспроизводимое чтение
На первый взгляд уровня изоляции чтения зафиксированных данных вполне достаточно для транзакций.
Однако на этом уровне изоляции все еще существует множество возможных ошибок конкурентного доступа. Например, на рис. 4 показана одна из вероятных проблем при чтении зафиксированных данных.
Подобная аномалия носит название невоспроизводимого чтения (nonrepeatable read) или асимметрии чтения (read skew): если Алиса прочитала бы баланс счета 1 опять в конце транзакции, то увидела бы значение ($600), отличное от прочитанного предыдущим запросом. Асимметрия считается допустимой при изоляции уровня чтения зафиксированных данных: видимые Алисе балансы счетов были, безусловно, зафиксированы на момент их чтения.
В случае Алисы — это лишь временная проблема. Однако в некоторых ситуациях подобные временные несоответствия недопустимы.
Резервное копирование. Резервная копия представляет собой копию всей базы данных, и ее создание на большой БД может занять несколько часов. Операции записи в базу продолжают выполняться во время создания резервной копии. Следовательно, может оказаться, что одни части копии содержат старые версии данных, а другие — новые. В случае восстановления БД из подобной резервной копии упомянутые расхождения (например, пропавшие деньги) станут из временных постоянными.
Аналитические запросы и проверки целостности. Иногда приходится выполнять запросы, просматривающие значительные части базы данных. Они также могут быть частью периодической проверки целостности (мониторинга на предмет порчи данных). Если подобные запросы будут видеть разные части БД по состоянию на различные моменты времени, то их результаты будут совершенно бессмысленными.
Изоляция снимков состояния — чаще всего используемое решение этой проблемы. Ее идея состоит в том, что каждая из транзакций читает данные из согласованного снимка состояния БД, то есть видит данные, которые были зафиксированы в базе на момент ее (транзакции) начала.
В других источниках данный уровень изоляции может называться повторяющимся чтением (repeatable read)
Операции чтения не требуют никаких блокировок. С точки зрения производительности основной принцип изоляции снимков состояния звучит как «чтение никогда не блокирует запись, а запись — чтение».
Базы данных применяют для реализации изоляции снимков состояния похожий механизм, действие которого по части предотвращения «грязных» операций чтения мы наблюдали на рис. 2. БД должна хранить для этого несколько различных зафиксированных версий объекта, поскольку разным выполняемым транзакциям может понадобиться состояние базы на различные моменты времени. Вследствие хранения одновременно нескольких версий объектов этот метод получил название многоверсионного управления конкурентным доступом (multiversion concurrency control, MVCC).
Если базе необходима только изоляция уровня чтения зафиксированных данных, но не уровня изоляции снимков состояния, достаточно было бы хранить только две версии объекта: зафиксированную версию и перезаписанную, но еще не зафиксированную версию. Однако поддерживающие изоляцию снимков состояния подсистемы хранения обычно используют MVCC и для изоляции уровня чтения зафиксированных данных. При этом обычно при чтении таких данных применяется отдельный снимок состояния для каждого запроса, а при изоляции снимков состояния — один и тот же снимок состояния для всей транзакции.
В каждой строке таблицы есть поле created_by, содержащее идентификатор транзакции, вставившей эту строку в таблицу. Более того, в каждой строке таблицы есть поле deleted_by, изначально пустое. Если транзакция удаляет строку, то строка на самом деле не удаляется из базы данных, а помечается для удаления путем установки значения этого поля в соответствии с идентификатором запросившей удаление транзакции. В дальнейшем, когда уже никакая транзакция точно не обратится к удаленным данным, процесс сборки мусора БД удалит все помеченные для удаления строки и освободит занимаемое ими место.
Более подробно о правилах видимости для согласованных снимков состояния можно прочесть в книге.
На этом первая часть конспекта, посвященного транзакциям, закончена. В следующей части рассмотрим асимметричные записи и фантомы, изоляцию уровня сериализуемости, в том числе различные методы, которые обеспечивают сериализуемость.
Ссылки на все части
Подсистемы хранения и извлечение данных. Конспект книги «Designing Data-Intensive Applications».
Транзакции. Часть 1. Конспект книги «Designing Data-Intensive Applications».
Транзакции. Часть 2. Конспект книги «Designing Data-Intensive Applications».
DmitryKoterov
Там интереснее есть моменты. Когда repeatable read не помогает тоже - про каунтеры. И на уровне блокировок строк вообще нельзя добиться честного serializable: надо либо всю базу лочить на каждое чтение и каждую запись (что невозможно с точки зрения производительности), либо держать в памяти все предикаты всех активных запросов и речекать все изменяемые/новые строки по ним (что тоже невозможно на практике, привет джойнам, агрегатам и т.д.). В общем, протекающая абстракция все это та еще, сферический конь в вакууме.