5.1 Проблема одновременности

Любая информационная система (или просто приложение), созданное программистами, состоит из нескольких типичных блоков. Например, кэш используется для запоминания результата ресурсоёмкой операции для более быстрого чтения данных клиентом. Инструменты потоковой обработки позволяют отправлять сообщения другим компонентам для асинхронной обработки. А инструменты пакетной обработки используются для решения накопившихся объёмов данных с некоторой периодичностью.

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

Итак, давайте представим себе, что вы написали приложение с мобильным интерфейсом, которое позволяет загружать сохранённый ранее список дел по дому и дополнять его новыми заданиями, а также расставлять приоритеты для каждого задания от 1 (самый высокий) до 3 (самый низкий). Допустим, ваше мобильное приложение в данный момент использует только один человек. Но вот вы решили рассказать о своём творении маме, и теперь она стала вторым постоянным пользователем. Что произойдёт, если вы одновременно, в ту же миллисекунду, поставите заданию «помыть окна» разную степень приоритета?

Ваши и мамины запросы в базу данных можно представить как два процесса. Процесс - это программа, которая может выполняться в одном или нескольких потоках. Он имеет машинный код, память, контекст и другие ресурсы. Таким образом, процесс это выполнение программы на процессоре. Когда приложение делает запрос в БД, то БД обрабатывает запрос от одного процесса. Если в приложении двое пользователей, то и процессов двое.

Когда процесс делает запрос в БД, он находит ее в каком-то состоянии. Система состояния ("stateful") – это такая система, которая запоминает прошлые события и хранит информацию. Это и называется «состоянием». Переменная, объявленная как integer, может быть 0, 1, 2 или 42. Mutex (взаимное исключение) имеет два состояния: locked или unlocked, также как и двоичный семафор ("required" или "released") и переменные, которые могут иметь только два состояния – 1 или 0.

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

Итак, большинство объектов в мире машин имеет некое состояние, которое может меняться с течением времени. Например, наш pipeline, обрабатывающая большой пакет данных, может выдать ошибку и стать failed. Или свойство объекта «Кошелёк», хранящее сумму денег, оставшихся на счету пользователя, может измениться после поступления на счёт зарплаты.

Переход от одного состояния к другому называется операцией. Операции CRUD это create, read, update, delete, или аналогичные им методы HTTP: POST, GET, PUT, DELETE. Операция может быть более сложной и может называться, например, validate(). Эти операции-функции выполняют процессы.

Любая операция - будь то функция или, в распределённых системах, посылка запроса к другому серверу - имеет два свойства: время вызова (invocation time) и время завершения (completion time), которое будет строго больше времени вызова. Исследователи из Jepsen исходят из теоретического предположения, что оба этих timestamp будут даны воображаемыми, полностью синхронизированными и глобально доступными часами.

Давайте представим себе наше приложение со списком дел. Вы через мобильный интерфейс делаете запрос в БД во время 14:00:00.014, а ваша мама во время 13:59:59.678 (то есть, за 336 миллисекунд до этого) через тот же интерфейс обновила список дел, добавив в него мытьё посуды. Если мамы подруг используют то же приложение, то после обработки вашего запроса БД может выполнить мамин запрос. Это означает, что ваши запросы и запросы маминых подруг могут быть отправлены одновременно.

Так мы и подошли к ключевому термину в области БД и распределённых приложений – concurrency. Под этим понятием подразумевается одновременное выполнение двух операций. Это означает, что T1 и T2 могут выполняться параллельно, не влияя друг на друга и поэтому обеспечивая независимость и безопасность всех системных процессов. Например, для распределённой базы данных это дает возможность обрабатывать несколько запросов одновременно без потери целостности данных.

  • Задача 1 может быть начата до начала выполнения задачи 2, а завершена в промежутке между началом и окончанием выполнения задачи 2.
  • Задача 2 может быть начата до начала выполнения задачи 1, а завершена между началом и окончанием выполнения задачи 1.
  • Задача 1 может быть начата и завершена между датами начала и окончания выполнения задачи 1.
  • и любой другой сценарий, при котором задача 1 и задача 2 имеют некое общее время выполнения.

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

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

Что может идти не так?

При работе с общими данными могут возникнуть многочисленные проблемы, известные как «race conditions».

Первая проблема возникает, когда процесс получает данные, которые он не должен был получить: неполные, временные, отменённые или по какой-то другой причине «неправильные» данные.

Вторая проблема – когда процесс получает неактуальные данные, то есть данные, не соответствующие последнему сохранённому состоянию БД. Например, какое-то приложение сняло деньги со счёта пользователя с нулевым балансом, потому что БД вернула приложению состояние счёта, не учитывающее последнее снятие денег с него, произошедшее несколько миллисекунд назад. Не очень приятно, не так ли?

5.2 Транзакции пришли, чтобы спасти нас

Для решения таких проблем было введено понятие транзакции - группы последовательных операций (изменений состояния) с базой данных, которая представляет собой логическую единую операцию. Например, перевод денег с одного банковского счета на другой: сначала нужно снять сумму с исходного счета, а затем положить ее на целевой счет.

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

Чтобы решить эту проблему, было придумано свойство транзакции «изолированность»: наша транзакция выполняется так, словно других транзакций, выполняемых в тот же момент, не существует. Наша БД выполняет одновременные операции так, словно она выполняет их последовательно - это самый высокий уровень изоляции, называемый «Strict Serializable». Да, это самый высокий уровень, но бывают и другие.

Давайте вспомним, как я описывал, что каждая операция имеет время вызова и время выполнения. Можно рассматривать вызов и выполнение как два действия. Тогда отсортированный список всех таких действий называется историей БД. Уровень изоляции транзакций – это набор историй. Они определяют, какие истории являются «хорошими». Если история «нарушает сериализуемость» или «не сериализуема», значит, она не входит в набор сериализуемых историй.

Чтобы было понятно, приведу примеры истории. Например, intermediate read - это когда одна транзакция читает данные из строки, которую изменила другая транзакция, но еще не завершила свое изменение. А aborted read - это пример с отменённой транзакцией снятия денег.

Таких возможных аномалий несколько. Это некое нежелательное состояние данных, которое может возникнуть при конкурентном доступе к БД. Чтобы избежать нежелательных состояний, БД используют различные уровни изоляции. Стандарт ANSI SQL-92 перечисляет 4 таких уровня.

Некоторые исследователи считают описание этих уровней расплывчатым, и предлагают свои, более детальные, классификации. Обратите внимание на уже упомянутый Jepsen и проект Hermitage, который призван внести ясность в то, какие именно уровни изоляции предлагают конкретные СУБД, такие как MySQL или PostgreSQL. В репозитории можно посмотреть, какую череду SQL-команд используют для тестирования БД на аномалии. Например, вот один пример из репозитория:


-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

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

Чтобы понять, предлагает ли выбранная БД этот уровень, придётся изучить документацию. Для большинства приложений этого будет достаточно, но если у вас особые требования, то лучше провести тест, как это делают в проекте Hermitage.

5.3 "I" и другие буквы в ACID

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

Давайте вспомним наш пример с банковским переводом. Транзакция включает в себя вывод с одного счета и добавление на другой. Если добавление не удалось, то вывод не должен произойти. Это значит, что транзакция может быть выполнена либо полностью, либо не выполнена вообще. Это называется атомарностью и это «A» в ACID.

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

Эта гарантия получила название «согласованность» (consistency) и букву C в ACID (не путать с согласованностью из мира распределённых приложений, о которой мы поговорим позже). Например, приложение для онлайн-магазина хочет добавить в таблицу orders строку со значением product_id, которое будет указывать на соответствующий ID из таблицы products – типичный foreignkey.

Если продукт был удалён из ассортимента и, соответственно, из базы данных, то операция вставки строки не должна произойти, и мы получим ошибку. Эта гарантия, по сравнению с другими, немного притянута за уши, на мой взгляд — хотя бы потому, что активное использование ограничений базы данных означает перенос ответственности за данные (а также частичное перенос бизнес-логики, если мы говорим о таком ограничении, как CHECK) с приложения на базу данных, что, как нынче принято говорить, ну такое себе.

Ну и наконец, остаётся D - «стойкость» (durability). Системный сбой или другой сбой не должны приводить к потере результатов транзакции или содержимого БД. То есть, если БД ответила, что транзакция прошла успешно, то это означает, что данные были зафиксированы в энергонезависимой памяти - например, на жёстком диске. Это не означает, что вы немедленно увидите данные при следующем read-запросе.

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