8.1 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.

8.2 Повторные попытки ("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).
  • Повторные попытки можно предварять или завершать какими-то специальными записями в лог.

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

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

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

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

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

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

Плюсы Saga в том, что никакие объекты не блокируются. Но есть, конечно, и минусы.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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