Фотограф: Elliott Erwitt

Я – выскочка. По крайней мере, так я себя иногда ощущаю. Закончив второй курс политологии и журналистики в университете, я увидел американский рейтинг профессий по уровню оплаты труда. Журналист в этом рейтинге был на последнем месте, а на первых местах были data scientists и data engineers (политолога в этом списке, почему-то, не было). Я не знал, кто составлял этот список, и понятия не имел, кто такие эти data-челы с первых строк, но он меня впечатлил. Я бросил пить и начал проходить курсы на Coursera, а потом каким-то чудом заполучил студенческую подработку в стартапе. Так я сделал своё «войти в IT».

Когда человек, не имеющий университетской подготовки, пытается начать программировать, то он чувствует себя несчастным, который, увидев из окна солнце, вышел на улицу и попал под неожиданный в столь прекрасный день град: шаблоны проектирования, функции, классы, ООП, инкапсуляция, протоколы, потоки, ACID… Хочется прокричать, как Виктор Фёдорович в своё время:

И вот опять, просто открыв с утра какую-то статью на «Хабре», ты по итогу увязаешь в томах «Википедии», пытаясь понять какую-то абстрактную мутотень. Проходит год, второй – ты поучаствовал в нескольких проектах, разного повидал, и постепенно всё начинает становиться на свои места, и ты становишься восприимчив не только к конкретным инструкциям на Stack Overflow, но уже читаешь документацию и начинаешь проникаться какими-то концепциями. Ещё пара лет, и ты осознаёшь ценность вышеперечисленных терминов и аббревиатур, которые, оказывается, были придуманы для того, чтобы сделать жизнь разработчика не мучительнее, а легче.

Говорят, что процесс познания важнее самого знания. И всё же, если бы кто-то мне правильно объяснил некоторые из концепций, которыми я сам сейчас охотно пользуюсь, чуть раньше, то, возможно, я был бы сейчас лучшим разработчиком, чем я есть. Время назад не перемотаешь, но формализовать свой добытый потом и кровью опыт в виде доступного текста я могу. Зачем? Я разложу свои мысли по полочкам, а вы в который раз почитаете про ненавистный ACID и, возможно, узнаете что-то новое.

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

Что вы узнаете из статьи

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

Мне не удастся полностью избежать этого заезженного примера, но я постараюсь привести и другие примеры, и вообще показать для разных понятий более широкий контекст, нежели исключительно транзакции и БД. Я покажу, как понимание транзакций может сделать ваш код лучше. Много кода в статье не будет, но кое-какие примеры вы всё-таки увидите (они будут на Python 3.X - его синтаксис будет понятен, думаю, каждому).

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

Главные тезисы для тех, кому лень читать всё

  • ACID – это стандарт того, какие гарантии должна давать база данных (далее: БД), чтобы поддерживать транзакции; он не указывает деталей реализации;

  • Если вкратце, то транзакция – это способ изменить состояние взаимосвязанных данных сразу в нескольких местах (например, в таблицах одной БД или в разных БД) так, чтобы эти изменения были действительными с точки зрения нескольких критериев;

  • Транзакции нужны далеко не каждому приложению;

  • Когда речь заходит о том, выполняет ли БД требования ACID, то, как правило, речь заходит о том, гарантирует ли она изолированность транзакций. Изолированные транзакции - те, что не видят промежуточные значения других транзакций;

  • Некоторые термины (например, согласованность) могут пониматься по-разному в зависимости от контекста, поэтому вместо того, чтобы искать где-то короткий ответ на вопрос, даёт ли БД гарантии ACID или какие-то другие, нужно углубиться в документацию и смотреть, как именно реализованы те или иные механизмы в этой базе данных. Ещё лучше – тестировать всё самому;

  • Если БД не предлагает гарантии ACID, то их можно частично реализовать самому в приложении. Принципы реализации этих гарантий важно понимать любому разработчику, потому что они в любом случае помогут сделать его код лучше;

  • Любая БД, как и любая технология и подход вообще – это компромисс, на который придётся пойти в угоду чему-то. Многие БД NoSQL жертвуют согласованностью данных или другими свойствами из ACID ради более высокой производительности, получая которую, вы перекладываете дополнительную ответственность на ваше приложение. Либо вы используете БД, которая предоставляет гарантии ACID, и лишаете себя головной боли, но получаете меньшую производительность;

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

Повесть об ACID

Фото с протестов, США, конец 1960-х

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

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

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

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

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

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

Итак, большинство объектов в мире машин имеет некое состояние, которое с течением времени может меняться: наша pipeline, обрабатывающая большой пакет данных, выдаёт ошибку и становится failed, либо свойство объекта «Кошелёк», хранящее сумму денег, оставшихся на счету пользователя, меняется после поступления на счёт зарплаты. Переход («transition») от одного состояния к другому – скажем, от “in progress” к “failed” – называется операцией. Наверное, всем известны операции CRUD – create , read , update , delete , либо аналогичные им методы HTTP – POST , 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. Параллелизм – это один из способов достижения конкурентности в среде с несколькими ядрами, процессорами или компьютерами. Мы же говорим о конкурентности в значении одновременного доступа разных процессов к общим данным.

Справка для тех, кто не хочет путать конкурентность и параллелизм, как сделал я при сдаче диплома.

Компьютерная программа после компиляции в бинарный код может быть исполнена либо более легковесным потоком выполнения, либо процессом. Если у вашего компьютера один одноядерный CPU (процессор), что в 2020 году довольно маловероятно, то ваша программа не сможет быть исполнена параллельно ни на уровне потоков, ни на уровне процессов. В этом случае CPU используется одновременно попеременно несколькими потоками или процессами, которые сменяются друг другом программным кодом, который называется планировщиком (или диспетчером) и использует алгоритм планирования выполнения задач. Он попеременно даёт каждому заданию некое окно времени (“time slice”). В этом случае мы говорим о конкурентности, но не о параллелизме, который мы получаем, когда наш CPU имеет несколько ядер, либо мы имеем несколько процессоров. Поток выполнения может выполняться параллельно на разных ядрах одного CPU, в то время, как параллельные процессы могут быть запущены на разных ядрах, процессорах и даже физических узлах (компьютерах). Если вас интересует разница между потоками и процессами, а также вы хотите узнать конкретный пример того, как использование процессов вместо потоков дало преимущество Google Chrome, можете ознакомиться вот с этим материалом).

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

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

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

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

Race condition - явление неприятное и опасное.

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

Решить эту проблему было призвано такое свойство транзакции, как «изолированность»: наша транзакция выполняется так, словно других транзакций, выполняемых в тот же момент, не существует. Наша БД выполняет одновременные операции так, словно она выполняет их друг за другом, „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
Согласитесь, неприятно оказаться в ситуации аномалии данных: вроде данные только что были здесь, а вот теперь их уже и нет? Фотограф: Rene Maltete.

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

"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. И мы даже знаем, почему они полезны. Но действительно ли они нам нужны в каждом приложении? И если нет, то когда именно? Все ли БД предлагают эти гарантии, а если нет, то что они предлагают взамен?

Битва аббревиатур: BASE vs. ACID

"В химии pH измеряет относительную кислотность водного раствора. Шкала pH простирается от 0 (сильнокислые вещества) до 14 (сильнощелочные вещества); чистая вода при температуре 25 ° C имеет pH 7 и является нейтральной.

Инженеры по данным взяли эту метафору, чтобы сравнивать базы данных относительно надёжности транзакций." Источник.

Наверное, замысел был такой: чем выше pH, т.е. чем ближе БД к "щёлочи" (“BASE”), тем менее надёжны транзакции.

Популярные реляционные БД, такие, как MySQL, появились как раз на почве ACID. Но за последние лет десять так называемые базы NoSQL, которые объединяют под этим названием несколько весьма различных типов БД, довольно неплохо справляются и без ACID. На самом деле, есть большое количество разработчиков, которые работают с БД NoSQL и нисколько не запариваются по поводу транзакций и их надёжности. Давайте разберёмся, правы ли они.

Нельзя общо говорить о БД NoSQL, ведь это просто удачная абстракция. БД NoSQL различаются между собой и по дизайну подсистем хранения данных, и даже по моделям данных: NoSQL – это и документо-ориентированная CouchDB, и графовая Neo4J. Но если говорить о них в контексте транзакций, то все они, как правило, похожи в одном: они предоставляют ограниченные версии атомарности и изоляции, а значит, не предоставляют гарантии ACID. Чтобы понять, что это значит, давайте ответим на вопрос: а что же они предлагают, если не ACID? Ничего?

Не совсем. Ведь им, как и реляционным БД, тоже нужно продавать себя в красивой упаковке. И они придумали свою «химическую» аббревиатуру – BASE.

BASE как антагонист

И тут я снова пойду не по порядку буковок, а начну с основополагающего термина – consistency. Мне придётся нивелировать ваш эффект узнавания, ибо эта согласованность имеет мало общего с согласованностью из ACID. Проблема с термином согласованности заключается в том, что он употребляется в слишком большом кол-ве контекстов. Зато эта согласованность имеет куда более широкий контекст употребления, да и вообще это именно та согласованность, о которой идёт речь при обсуждении распределённых систем.

Реляционные БД, о которых мы говорили выше, предоставляют разные уровни изоляции транзакций, и самые строгие из них гарантируют, что одна транзакция не сможет увидеть недействительные изменения, осуществлённые другой транзакцией. Если вы стоите на кассе в магазине, и в этот момент с вашего счёта снимутся деньги за квартплату, но транзакция с переводом денег за квартплату провалится и ваш счёт снова примет прежнее значение (деньги не спишутся), то ваша транзакция оплаты на кассе не заметит всех этих телодвижений – ведь та транзакция так и не прошла, а исходя из требования изоляции транзакций, её временные изменения не могут быть замечены другими транзакциями. Многие NoSQL БД отказываются от гарантии изоляции и предлагают «согласованность в конечном счёте» (eventual consistency), согласно которой вы в конце концов увидите действительные данные, но есть вероятность, что ваша транзакция прочитает недействительные значения – то есть, временные, или частично обновлённые, или устаревшие. Возможно, данные станут согласованными в «ленивом» режиме при чтении ("lazily at read time").

Strong consistency? Нет, показалось - eventual... Фотограф: Jacques-Henri Lartigue

NoSQL были задуманы как БД для аналитики в режиме реального времени, и чтобы достигнуть бОльшую скорость, они пожертвовали согласованностью. А Eric Brewer, тот же парень, что придумал термин BASE, сформулировал так называемую "CAP-теорему", согласно которой:

Для любой реализации распределённых вычислений возможно обеспечить не более двух из трёх следующих свойств:

  • согласованность данных (consistency) - данные на разных узлах (instances) не противоречат друг другу;

  • доступность (availability) — любой запрос к распределённой системе завершается корректным откликом, однако без гарантии, что ответы всех узлов системы совпадают;

  • устойчивость к разделению (распределению) (partition tolerance) — Даже если между узлами нет связи, они продолжают работать независимо друг от друга.

Если вам нужно совсем простое объяснение CAP, то держите.

Есть мнения о том, что теорема CAP не работает, и вообще сформулирована слишком абстрактно. Так или иначе, базы NoSQL зачастую отказываются от согласованности в контексте теоремы CAP, что описывает следующую ситуацию: данные были обновлены в кластере с несколькими instances, но изменения были синхронизированны ещё не на всех instances. Помните, я выше упоминал пример с DynamoDB, которая сказала мне: твои изменения стали durable – вот тебе HTTP 200 - но изменения я увидел лишь через 10 секунд? Ещё один пример из повседневной жизни разработчика – DNS, система доменных имён. Если кто не знает, то это именно тот «словарь», который переводит http(s)-адреса в IP-адреса. Обновлённая DNS-запись распространяется по серверам в соответствии с настройками интервалов кэширования – поэтому обновления становятся заметными не моментально. Так вот, подобная временная несогласованность (т.е. согласованность в конечном счёте) может приключиться и с кластером реляционной БД (скажем, MySQL) – ведь эта согласованность не имеет ничего общего с согласованностью из ACID. Поэтому важно понимать, что в этом смысле БД SQL и NoSQL вряд ли будут сильно отличаться, если речь идёт о нескольких instances в кластере.

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

Не предоставляющие гарантии ACID базы данных NoSQL имеют так называемое «мягкое состояние» (“soft state”) вследствие модели согласованности в конечном счёте, что означает следующее: состояние системы может меняться со временем, даже без вводных данных (“input”). Зато такие системы стремятся обеспечить бОльшую доступность. Обеспечить стопроцентную доступность – нетривиальная задача, поэтому речь идёт о «базовой доступности». А вместе эти три понятия: «базовая доступность» („basically available“), «мягкое состояние» („soft state“) и «согласованность в конечном счёте» („eventual consistency“) формируют аббревиатуру BASE.

Может, это strong consistency? Нет, снова не то... Фотограф: Robert Doisneau

Если честно, мне понятие BASE кажется более пустой маркетинговой обёрткой, чем ACID – потому что оно не даёт ничего нового и никак не характеризует БД. А навешивание ярлыков (ACID, BASE, CAP) на те или иные БД может лишь запутать разработчиков. Я решил вас всё-таки познакомить с этим термином, потому что миновать его при изучении БД трудно, но теперь, когда вы знаете, что это, я хочу, чтобы вы поскорее про него забыли. И давайте снова вернёмся к понятию изоляции.

Получается, базы данных BASE совсем не выполняют критерии ACID?

По сути, чем отличаются БД ACID от не-ACID, так это тем, что не-ACID фактически отказываются от обеспечения изоляции. Это важно понимать. Но ещё важнее читать документацию БД и тестировать их так, как это делают ребята из проекта Hermitage. Не столь важно, как именно называют своё детище создатели той или иной БД – ACID или BASE, CAP или не CAP. Важно то, что именно предоставляет та или иная БД.

Если создатели БД утверждают, что она предоставляет гарантии ACID, то, наверное, у этого есть основания, но желательно самому протестировать, чтобы понять, так ли это и в какой степени. Если же они заявляют, что их БД такие гарантии не предоставляет, то это может значить следующие вещи:

  • БД не предоставляет гарантии атомарности. Хотя некоторые NoSQL базы данных предлагают отдельную API для атомарных операций (например, DynamoDB);

  • БД не предоставляет гарантии изоляции. Это может означать, например, что БД запишет данные не в том порядке, в котором они поступили на запись.

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

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

Для особо интересующихся: как разные БД индексируют данные, и как это влияет на durability, и не только

Есть два основных подхода к хранению и поиску данных.

Самый простой способ сохранять данные – это добавление операций в конец файла по принципу журнала (то есть, всегда происходит операция append): неважно, хотим ли мы добавить, изменить или удалить данные – все операции CRUD просто записываются в журнал. Искать по журналу – занятие неэффективное, и вот где на помощь приходит индекс – особая структура данных, которая хранит метаданные о том, где именно хранятся данные. Простейшая стратегия индексация для журналов – хэш-таблица (hash map), которая отслеживает ключи и значения. Значениями будут ссылки на байтовое смещение для данных, записанных внутрь файла, которая и представляет из себя журнал (log) и хранится на диске. Эта структура данных целиком хранится в памяти, в то время, как сами данные – на диске, и называется LSM-деревом (log structured merge). Вы, наверное, задались вопросом: если мы всё время пишем наши операции в журнал, то он же будет непомерно расти? Да, и поэтому была придумана техника уплотнения (“compaction”), которая с некоей периодичностью «подчищает» данные, а именно – оставляет для каждого ключа лишь наиболее актуальное значение, либо удаляет его. А если иметь не один журнал на диске, а несколько, и они все будут отсортированы, то мы получим новую структуру данных под названием SSTable (“sorted string table”), и это, несомненно, улучшит нашу проивзодительность. Если же мы захотим сортировать в памяти, то получим похожую структуру – так называемую таблицу MemTable, но с ней проблема в том, что если происходит фатальный сбой БД, то записанные позже всего данные (находящиеся в MemTable, но еще не записанные на диск) теряются. Собственно, в этом заключается потенциальная проблема с durability у БД, базирующихся на LSM-деревьях.

Другой подход к индексации основывается на B-деревьях (“B-trees”). В B-дереве данные записываются на диск страницами фиксированного размера. Эти блоки данных часто имеют размер около 4 КБ и имеют пары ключ-значение, отсортированные по ключу. Один узел B-дерева похож на массив со ссылками на диапазон страниц. Макс. количество ссылок в массиве называется фактором ветвления. Каждый диапазон страниц - это еще один узел B-дерева со ссылками на другие диапазоны страниц. В конце концов, на уровне листа вы найдете отдельные страницы. Эта идея похожа на указатели в языках программирования низкого уровня, за исключением того, что эти ссылки на страницы хранятся на диске, а не в памяти. Когда в БД происходят INSERTs и DELETEs, то какой-нибудь узел может разбиться на два поддерева, чтобы соответствовать коэффициенту ветвления. Если база данных выйдет из строя по какой-либо причине в середине процесса, то целостность данных может нарушиться. Чтобы предотвратить такой случай, использующие B-деревья БД ведут журнал упреждающей записи („write-ahead log“, или WAL), в котором записывается каждая отдельная транзакция. Этот WAL используется для восстановления состояния B-дерева в случае его повреждения. И кажется, что именно это делает использующие B-деревья БД лучше в плане durability. Но основанных на LSM БД также могут вести файл, по сути выполняющий такую же функцию, как WAL. Поэтому я повторю то, что уже говорил, и, возможно, не раз: разбирайтесь в механизмах работы выбранной вами БД.

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

Вместе с тем, дизайн индекса напрямую отражается на производительности БД. При LSM-дереве запись на диск осуществляется последовательно, а B-деревья вызывают множественные случайные доступы к диску, поэтому операции записи происходят у LSM быстрее, чем у B-деревьев. Разница особенно существенна для магнитных жёстких дисков (HDD), на которых последовательные операции записи работают намного быстрее, чем произвольные. Чтение же выполняется медленнее на LSM-деревьях потому, что приходится просматривать несколько различных структур данных и SS-таблиц, находящихся на разных стадиях уплотнения. Более детально это выглядит следующим образом. Если мы сделаем простой запрос к базе данных с LSM, мы сначала поищем ключ в MemTable. Если его там нет, мы смотрим в самую последнюю SSTable; если нет и там, то мы смотрим в предпоследнюю SSTable и т.д. Если запрашиваемый ключ не существует, то при LSM мы это узнаем в последнюю очередь. LSM-деревья используются, например, в: LevelDB, RocksDB, Cassandra и HBase.

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

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

Между прочим, помимо БД, записывающих на диск, ещё есть так называемые "in-memory" БД, которые работают преимущественно с RAM. Вкратце: располагаемые в памяти БД обычно предлагают более низкую durability ради большей скорости записи и чтения, но это может подходить для некоторых приложений. Длинная версия:

Вам правда больше нечем заняться? Праздники же, оливье там, ну вы чё...

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

  • Можно использовать RAM, питающейся от аккумуляторов;

  • Можно записывать на диск журналы изменений (что-то вроде упомянутых выше WAL), но не сами данные;

  • Можно периодически записывать на диск копии состояния БД (что без использования других опций не даёт гарантии, а лишь улучшает durability);

  • Можно проводить репликацию состояния оперативной памяти на другие машины.

Например, in-memory БД Redis, которая в основном используется как очередь сообщений или кэш, недостаёт именно durability из ACID: она не гарантирует, что успешно выполненная команда сохранится на диске, поскольку Redis сбрасывает данные на диск (если у вас включена сохраняемость) только асинхронно, через определённые интервалы. Впрочем, не для всех приложений это критично: я нашёл пример кооперативного онлайн-редактора EtherPad, который делал flush раз в 1-2 секунды, и потенциально пользователь мог потерять пару букв или слово, что вряд ли было критичным. В остальном же, поскольку располагаемые в памяти БД хороши тем, что они предоставляют модели данных, которые было бы тяжело реализовать с помощью дисковых индексов, Redis можно использоваться для реализации транзакций – её очередь по приоритету позволяет это сделать.

Как реализовать ACID в приложении? И зачем это надо

Вы могли подумать, что эта глава - для любителей переизобретать велосипеды. Но всё не так однозначно... Фотограф: Josef Koudelka

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

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

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

Базовый инструментарий для любителей транзакций

Оптимистичная и пессимистичная блокировка. Это два типа блокировки неких данных, к которым может возникнуть одновременный доступ.

Оптимист полагает, что вероятность одновременного доступа не так велика, а потому он делает следующее: читает нужную строку, запоминает номер её версии (или timestamp, или checksum / hash – если вы не можете изменить схему данных и добавить столбец для версии или timestamp), и перед тем, как записать в БД изменения для этих данных, проверяет, не изменилась ли версия этих данных. Если версия изменилась, то нужно как-то решить создавшийся конфликт и обновить данные (“commit”), либо откатить транзакцию (“rollback”). Минус этого метода в том, что он создаёт благоприятные условия для бага с длинным названием “time-of-check to time-of-use”, сокращённо TOCTOU: состояние в период времени между проверкой и записью может измениться. Я не имею опыта использования оптимистичной блокировки, а «Википедия» в качестве решения предлагает использовать exception handling вместо проверки, что мне лично в контексте баз данных мало о чём говорит, если честно.

В качестве примера я нашёл одну технологию из повседневной жизни разработчика, которая использует нечто вроде оптимистичной блокировки – это протокол HTTP. Ответ на изначальный HTTP-запрос GET может включать в себя заголовок ETag для последующих запросов PUT со стороны клиента, который тот может использовать в заголовке If-Match. Для методов GET и HEAD сервер отправит обратно запрошенный ресурс, только если он соответствует одному из знакомых ему ETag. Для PUT и других небезопасных методов он будет загружать ресурс также только в этом случае. Если вы не знаете, как работает ETag, то вот хороший пример, с использованием библиотеки "feedparser" (которая помогает парсить RSS и прочие feeds). Источник.

>>> import feedparser
>>> d = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml')
>>> d.etag
'"6c132-941-ad7e3080"'
>>> d2 = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml', etag=d.etag)
>>> d2.feed
{}
>>> d2.debug_message
'The feed has not changed since you last checked, so the server sent no data.  This is a feature, not a bug!'

Пессимист же исходит из того, что транзакции часто будут «встречаться» на одних и тех же данных, и чтобы упростить себе жизнь и избежать лишних race conditions, он просто блокирует необходимые ему данные. Для того, чтобы воплотить механизм блокировки, вам нужно либо поддерживать соединение с БД для вашей сессии (а не брать соединения из пула – в этом случае вам, скорее всего, придётся работать с оптимистичной блокировкой), либо использовать ID для транзакции, которая может быть использована независимо от соединения. Минус пессимистичной блокировки в том, что её использование замедляет обработку транзакций в целом, но зато вы можете быть спокойны за данные и получаете настоящую изоляцию. Дополнительная опасность, правда, таится в возможной взаимной блокировке („deadlock“), при которой несколько процессов ожидают ресурсы, заблокированные друг другом. Например, для проведения транзакции нужные ресурсы А и Б. Процесс 1 занял ресурс А, а процесс 2 – ресурс Б. Ни один из двух процессов не может продолжить выполнение. Существуют различные способы решения этого вопроса – я не хочу сейчас вдаваться в детали, поэтому для начала почитайте «Википедию» , но если вкратце, то есть возможность создания иерархии блокировок. Если вы хотите познакомиться подробнее с этой концепцией, то предлагают вам поломать голову над «Задачей об обедающих философах» (“dining philosophers problem”).

Вот тут есть хороший пример того, как поведут себя обе блокировки в одном и том же сценарии.

Касательно реализаций locks. Не хочу вдаваться в подробности, но для распределённых систем существуют менеджеры блокировок, например: ZooKeeper, Redis, etcd, Consul.

Идемпотентность операций

Идемпотентность кода – это вообще хорошая практика, и это как раз тот случай, когда разработчику хорошо бы уметь это делать вне зависимости от того, использует ли он транзакции или нет. Идемпотентность – это свойство операции давать тот же результат при повторном применении этой операции к объекту. Функция была вызвана – дала результат. Вызвана ещё раз через секунду или пять – дала тот же результат. Конечно, если данные в БД поменялись, то результат будет другой. Данные в третьих системах могут не зависеть от функции, но всё, что зависит – должно быть предсказуемым.

Проявлений у идемпотентности может быть несколько. Одно из них – это просто рекомендация к тому, как надо писать свой код. Вы же помните, что лучшая функция – это та, которая делает одну вещь? И что хорошо бы написать для этой функции unit-тесты? Если вы придерживаетесь этих двух правил, то вы уже повышаете шанс на то, что ваши функции будут идемпотентны. Чтобы не возникло путаницы, уточню, что идемпотентные функции – не обязательные «чистые» (в смысле „function purity“). Чистые функции – это те функции, которые оперируют только теми данными, которые получили на входе, никак их не меняя и возвращая обработанный результат. Это те функции, которые позволяют скалировать приложение, используя техники функционального программирования. Поскольку мы говорим про некие общие данные и БД, то наши функции вряд ли будут чистыми, ибо они будут менять состояние БД или программ (сервисов).

Вот это - чистая функция:

def square(num: int) -> int:
    return num * num

А вот эта функция - не чистая, но идемпотентная (прошу не делать выводов о том, как я пишу код, по этим кускам):

def insert_data(insert_query: str, db_connection: DbConnectionType) -> int:
  db_connection.execute(insert_query)
  return True

Вместо множества слов, я могу просто рассказать о том, как я вынужденно научился писать идемпотентные программы. Я много работаю с AWS, как вы уже могли понять, и там есть сервис под названием AWS Lambda. Lambda позволяет не заботиться о серверах, а просто загружать код, который будет запускаться в ответ на какие-то события или по расписанию. Событием могут быть сообщения, которые доставляются брокером (message broker). В AWS таким брокером является AWS SNS. Думаю, что это должно быть понятно даже для тех, кто не работает с AWS: у нас есть брокер, который отправляет сообщения по каналам (“topics”), и микросервисы, которые подписаны на эти каналы, получают сообщения и как-то на них реагируют.

Проблема заключаются в том, что SNS доставляет сообщения «как минимум один раз» („at-least-once delivery“). Что это значит? Что рано или поздно ваш код на Lambda будет вызван дважды. И это действительно случается. Существует целый ряд сценариев, когда ваша функция должна быть идемпотентной: например когда со счёта снимаются деньги, мы можем ожидать, что кто-то снимет одну и ту же сумму дважды, но мы должны убедиться, что это действительно 2 независимых друг от друга раза – иначе говоря, это 2 разные транзакции, а не повтор одной.

Я же для разнообразия приведу другой пример – ограничение частоты запросов к API (“rate limiting”). Наша Lambda принимает событие с неким user_id для которого должна быть сделана проверка, не исчерпал ли пользователь с таким ID своё кол-во возможных запросов к некой нашей API. Мы могли бы хранить в DynamoDB от AWS значение совершённых вызовов, и увеличивать его с каждым вызовов нашей функции на 1.

Но что делать, если эта Lambda-функция будет вызвана одним и тем же событием дважды? Кстати, вы обратили внимание на аргументы функции lambda_handler() Второй аргумент, context в AWS Lambda даётся по умолчанию, и он содержит разные метаданные, в том числе – request_id , который генерируется для каждого уникального вызова. Это значит, что теперь, вместо того, чтобы хранить в таблице число совершённых вызовов, мы можем хранить список request_id и при каждом вызове наша Lambda будет проверять, был ли данный запрос уже обработан:

import json
import os
from typing import Any, Dict

from aws_lambda_powertools.utilities.typing import LambdaContext  # нужно только для аннотации типа аргумента
import boto3

limit = os.getenv('LIMIT')

def handler_name(event: Dict[str: Any], context: LambdaContext):

    request_id = context.aws_request_id

    # Находим user_id во входящем событии
    user_id = event["user_id"]

    # Наша таблица на DynamoDB
    table = boto3.resource('dynamodb').Table('my_table')

    # Делаем update
    table.update_item(
        Key={'pkey': user_id},
        UpdateExpression='ADD requests :request_id',
        ConditionExpression='attribute_not_exists (requests) OR (size(requests) < :limit AND NOT contains(requests, :request_id))',
        ExpressionAttributeValues={
            ':request_id': {'S': request_id},
            ':requests': {'SS': [request_id]},
            ':limit': {'N': limit}
        }
    )

    # TODO: написать дальнейшую логику

    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json"
        },
        "body": json.dumps({
            "status ": "success"
        })
    }

Поскольку мой пример фактически взят из интернета, то я оставлю ссылку на первоисточник, тем более, что он даёт чуть больше информации-

Помните, выше я уже упоминал, что что-то наподобие уникального ID транзакции можно использовать для блокировки общих данных? Теперь мы узнали, что его можно использовать и для обеспечения идемпотентности операций. Давайте же узнаем, какими способами можно самим генерировать такие ID.

ID транзакций

Обозначается как XID или TxID (если есть разница – подскажите). В качестве TxID можно использовать timestamps, что может сыграть на руку, если мы захотим восстановить все действия к какому-то моменту времени. Проблема может возникнуть, если timestamp недостаточно гранулярный – тогда транзакции могут получить один и тот же ID.

Поэтому наиболее надёжный вариант – это генерировать уникальные ID проде UUID. В Python это делается очень просто:

>>> import uuid
>>> str(uuid.uuid4())
'f50ec0b7-f960-400d-91f0-c42a6d44e3d0'
>>> str(uuid.uuid4())
'd15bed89-c0a5-4a72-98d9-5507ea7bc0ba'

Также есть вариант хэшировать набор определяющих транзакцию данных и использовать этот хэш в качестве TxID

Повторные попытки ("retries")

Если мы знаем, что некая функция или программа идемпотентна, то это значит, что мы можем и должны пробовать повторить её вызов в случае ошибки. А мы просто обязаны быть готовы к тому, что какая-то операция выдаст ошибку – учитывая, что современные приложения распределены по сети и железу, ошибка должна рассматриваться не как исключение, а как норма. Ошибка может произойти из-за падения сервера, ошибки сети, перегруженности удалённого приложения. Как себя должно вести наше приложение? Правильно, попробовать повторить операцию.

Поскольку один кусочек кода может сказать больше, чем целая страница слов, то давайте на одном примере разберём, как в идеале должен работать механизм повторения операции в духе naive retrying. Я продемонстрирую это с использованием библиотеки Tenacity (у неё настолько продуманный дизайн, что даже если вы не планируете использовать её, пример должен наглядно показать, как можно спроектировать механизм повторения):

import logging
import random
import sys
from tenacity import retry, stop_after_attempt, stop_after_delay, wait_exponential, retry_if_exception_type, before_log

logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
logger = logging.getLogger(__name__)

@retry(
    stop=(stop_after_delay(10) | stop_after_attempt(5)),
    wait=wait_exponential(multiplier=1, min=4, max=10),
    retry=retry_if_exception_type(IOError),
    before=before_log(logger, logging.DEBUG)
)
def do_something_unreliable():
    if random.randint(0, 10) > 1:
        raise IOError("Broken sauce, everything is hosed!!!111one")
    else:
        return "Awesome sauce!"

print(do_something_unreliable.retry.statistics)

На всякий случай скажу: \@retry(...) - это такой специальный синтаксис Python, именуемый "декоратором". Это просто функция retry(...) , которая оборачивает другую функцию и выполняет некие действия до или после её исполнения.

Как мы видим, повторные попытки можно оформить креативно:

  • Можно ограничить попытки по времени (10 секунд) или количеству попыток (5);

  • Можно экспоненциально (то есть, 2 ** некоторое увеличивающееся число n ). или как-то ещё (например, фиксированно) увеличивать время между отдельными попытками. Экспоненциальный вариант носит название "congestion collapse";

  • Можно делать повторные попытки лишь для некоторых видов ошибок (IOError);

  • Повторные попытки можно предварять или завершать какими-то специальными записями в лог.

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

Продвинутый инструментарий для любителей транзакций

Я лишь дам довольно общие определения, поскольку эта тема достойна отдельной большой статьи. Кстати, я сразу и оставлю тут две ссылки: вот и вот.

Two-phase commit (2pc). 2pc имеет две фазы: фазу подготовки и фазу фиксации. На этапе подготовки всем микросервисам будет предложено подготовиться к некоторым изменениям данных, которые могут быть выполнены атомарно. Как только они все будут готовы, то на этапе фиксации будут внесены фактические изменения. Для координации процесса необходим глобальный координатор, который блокирует необходимые объекты – то есть, они становятся недоступны для изменений, пока координатор их не разблокирует. Если какой-то отдельный микросервис не готов к изменениям (например, не отвечает), координатор прервёт транзакцию и начнёт процесс отката.

Чем хорош этот протокол? Он обеспечивает атомарность. К тому же, он гарантирует изоляцию при записи и чтении. Это означает, что изменения одной транзакции не видны остальным, пока координатор не зафиксирует изменения. Но в этих свойствах кроется и минус: поскольку этот протокол синхронен (блокирующий), он замедляет работу систему (при том, что вызов RPC, сам по себе довольно медленный). И опять-таки, возникает опасность взаимной блокировки.

Saga. В этом шаблоне распределённая транзакция выполняется асинхронными локальными транзакциями во всех связанных микросервисах. Микросервисы связываются друг с другом через шину событий („event bus“). Если какой-либо микросервис не может завершить свою локальную транзакцию, другие микросервисы выполнят компенсационные транзакции для отката изменений.

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

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

Как понять, когда мне нужны гарантии ACID?

Я очень много разглагольствовал на протяжении всей статьи, и именно поэтому главную главу я хочу сделать максимально лаконичной. Если совсем кратко, то очень малое количество приложений нуждается в ACID везде. Как правило, они если и нуждаются в нём, то лишь в некоторых местах.

В каких случаях мне нужны ACID?

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

Простите за банальность, но типичный пример - финансовые транзакции.

Когда порядок выполнения транзакций имеет значение.

Представьте себе, что ваша компания собралась переходить с мессенджера FunnyYellowChat в мессенджер FunnyRedChat, потому что в FunnyRedChat можно отсылать гифки, а в FunnyYellowChat - нельзя. Но вы не просто меняете мессенджер - вы мигрируете переписку вашей компании из одного мессенджера в другой. Вы делаете это, потому что ваши программисты ленились документировать программы и процессы где-то централизованно, и вместо этого всё публиковали в разных каналах в мессенджере. Да и ваши продажники детали переговоров и соглашений публиковали там же. Короче, вся жизнь вашей компании - там, и поскольку ни у кого нет времени переносить всё это дело в сервис для документации, а поиск у мессенджеров работает неплохо, вы решили вместо разгребания завалов просто скопировать все сообщения в новое место. Очерёдность сообщений важна, потому что иначе всё может перепутаться, и вы, например, не будете понимать, где именно находится ответ на тот или иной вопрос.

Кстати, для переписки в мессенджере вообще важна очерёдность, но когда два человека одновременно пишут что-то в одном чате, то в целом не так важно, чьё сообщение покажется первым. Так что, именно для этого сценария ACID был бы не нужен.

Другой возможный пример - это биоинформатика. Я в этом совсем не разбираюсь, но предполагаю, что при расшифровке генома человека порядок важен. Впрочем, я слышал, что биоинформатики вообще какие-то свои инструменты для всего используют - возможно, у них и БД свои.

Когда нельзя выдать пользователю или процессу устаревшие данные.

И снова - финансовые транзакции. Честно говоря, не придумал иного примера.

Когда незавершенные транзакции связаны со значительными издержками.

Представьте себе проблемы, которые могут возникнуть, когда врач и медсестра одновременно обновляют карту пациента и стирают изменения друг друга, потому что БД не может изолировать транзакции. Система здравоохранения - это ещё одна сфера, помимо финансовой, для которой гарантии ACID, как правило, критически важны.

В каких случаях мне не нужны ACID?

Когда пользователи обновляют лишь некие свои приватные данные.

Например, пользователь оставляет комментарии или sticky notes к веб-странице. Или редактирует личные данные в личном кабинете у провайдера каких-либо услуг.

Когда пользователи вообще не обновляют данные, а только дополняют новыми (append).

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

Когда бизнес-логика не определяет необходимость некоего порядка выполнения транзакций.

Наверное, для блогера на Youtube, который во время очередного прямого эфира собирает пожертвования для производства нового материала, не так важно, кто когда именно и в какой очерёдности кинул ему денежку.

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

Теоретически, это любые новостные онлайн-медиа, или тот же Youtube. Или "Хабр".

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

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

Эпилог

Я надеюсь, что вам было интересно.

Если вы нашли какие-то фактические ошибки – обязательно сообщите об этом в комментариях.

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

Кроме того, если у вас есть предложения по тому, как расширить списки "Когда мне нужно ACID" и "Когда мне не нужно ACID", буду рад услышать их.