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

Для начала немного отдалённой теории.

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

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

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

Говоря профессиональным языком, ваш и мамин запросы в БД можно рассмотреть, как 2 процесса, которые совершили запрос в БД. Процесс – это сущность компьютерной программы, которая может выполняться в одном или нескольких потоках. Обычно процесс обладает образом машинного кода, памятью, контекстом и другими ресурсами. Иными словами характеризовать процесс можно как выполнение инструкций программы на процессоре. Когда ваше приложение делает запрос в БД, то мы говорим о том, что ваша БД обрабатывает полученный по сети запрос от одного процесса. Если пользователей, одновременно сидящих в приложении, двое, то и процессов в какой-то конкретный момент времени может быть двое.

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

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

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

Переход («transition») от одного состояния к другому – скажем, от in progress к failed – называется операцией. Наверное, всем известны операции CRUDcreate, read, update, delete, либо аналогичные им методы HTTPPOST, GET, PUT, DELETE. Но программисты в своём коде часто дают операциям другие имена, потому что операция может быть более сложной, чем просто прочитать некое значение из базы данных – она может заодно проверить данные, и тогда наша операция, приобретшая вид функции, будет называться, например, validate() А кто выполняет эти операции-функции? Уже описанные нами процессы.

Ещё немного, и вы поймёте, почему я так подробно описываю термины!

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

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

Так мы и подошли к важнейшему термину в области БД и распределённых приложений – concurrency. Что именно может означать одновременность двух операций? Если даны некая операция T1 и некая операция T2, то:

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

Понятно, что в рамках данной лекции мы говорим в первую очередь про запросы, поступающие в БД, и то, как система управления БД эти запросы воспринимает, но термин конкурентности важен, например, и в контексте операционных систем. Я не буду слишком сильно отходить в сторону от темы данной статьи, но считаю важным упомянуть, что конкурентность, о которой мы здесь говорим, не связана с дилеммой о конкурентности и параллелизме и их разнице, которую обсуждают в контексте работы операционных систем и high-performance computing. Параллелизм – это один из способов достижения конкурентности в среде с несколькими ядрами, процессорами или компьютерами. Мы же говорим о конкурентности в значении одновременного доступа разных процессов к общим данным.

А что, собственно, может пойти не так, чисто теоретически?

При работе над общими данными могут произойти многочисленные проблемы, связанные с конкурентностью, также названные "race conditions". Первая проблема возникает тогда, когда процесс получает данные, которые он не должен был получить: неполные, временные, отменённые или по какой-то иной причине «неправильные» данные. Вторая проблема – когда процесс получает неактуальные данные, то есть данные, которые не соответствуют последнему сохранённому состоянию БД. Скажем, какое-то приложение сняло деньги со счёта пользователя с нулевым балансом, потому что БД вернуло приложению состояние счёта, не учитывающее последнее снятие денег с него, произошедшее буквально пару миллисекунд назад. Ситуация так себе, не правда ли?

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

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

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

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

«Стоп», —скажете вы. Попридержи коней, сударь.

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

Чтобы было понятно, про какого рода истории мы говорим, приведу примеры. Например, есть такой вид истории – intermediate read. Он происходит, когда транзакции А разрешено читать данные из строки, которая была изменена другой запущенной транзакцией Б и еще не зафиксирована ("not committed") - то есть, фактически, измнения ещё не были окончательно совершены транзакцией Б, и она может в любой момент их отменить. А, например, aborted read – это как раз наш пример с отменённой транзакцией снятия денег

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

Описание этих уровней некоторым исследователям кажется расплывчатым, и они предлагают свои, более детальные, классификации. Советую обратить внимание на уже упомянутый 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 в целом. И именно по этой причине я начал разбор этой аббревиатуры с изоляции, а не пошёл по порядку, как обычно делают те, кто пытаются объяснить эту концепцию. А теперь давайте рассмотрим и оставшиеся три буквы.

Вспомним опять наш пример с банковским переводом. Транзакция по переводу средств с одного счета на другой включает в себя операцию вывода с первого счета и операцию пополнения на втором. Если операция пополнения второго счета не удалась, вы наверняка не хотите, чтобы операция вывода средств с первого произошла. Иными словами, либо транзакция удаётся полностью, или не происходит вообще, но она не может быть произведена лишь на какую-то часть. Это свойство называется атомарностью („atomicity“), и это “A” в ACID.

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

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

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

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

Вот буквально на днях я работал с DynamoDB от AWS (Amazon Web Services), и послал некие данные на сохранение, а получив ответ HTTP 200 (OK), или что-то вроде того, решил проверить – и не видел эти данные в базе в течение последующих 10 секунд. То есть, DynamoDB зафиксировала мои данные, но не все узлы моментально синхронизировались, чтобы получить последнюю копию данных (хотя возможно, дело было и в кэше). Тут мы опять залезли на территорию согласованности в контексте распределённых систем, но момент поговорить о ней по-прежнему не настал.

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