Простой способ сделать распределенные транзакции, согласованные в конечном счете (eventually consistent)

21 апреля 2014

Есть два взгляда на распределенные отказоустойчивые системы — теоретический и практический. В то время, как теоретики (a.k.a distributed systems nerds) пиарят так называемые NewSQL базы данных, рассуждают о Paxos и векторных часах, большинство практикующих программистов относятся к подобным решениям с некоторым скепсисом. Ведь инженерия — это штука про компромиссы. У любого решения всегда есть плюсы и минусы, не исключая NewSQL. Так или иначе, в реальных системах, как правило, все еще используется PostgreSQL и другие традиционные реляционные СУБД.

Вопреки распространенному мнению, такие системы обеспечивают неплохую отказоустойчивость и неплохо масштабируются, в том числе на запись. Увы, от разработчиков требуются дополнительные умственные усилия для того, чтобы обеспечить правильную группировку данных, чтоб большинство транзакций и join’ов выполнялись в рамках одного сервера, написание дополнительного кода для миграции данных между серверами (другими словами, решардинга), а также распихивание повсюду метрик и индикаторов. От админов же тем временем требуется пристально смотреть в мониторинг (например, Zabbix или Nagios) и иметь под рукой скрипт для быстрого промоутинга слейвов до мастеров. Но в целом это работает. И думается, что NewSQL базы данных, которые в итоге выживут, будут предоставлять все в точности то же самое, только готовое, из коробки.

А пока светлое будущее не наступило, программистам нужно как-то решать стоящие перед ними уже сегодня проблемы. Например, у вас в системе есть некие счета, а на этих счетах лежат некие деньги. В результате неких событий (скажем, пользователи делают ставки на результаты футбольных матчей или играют на форексе) состояние счетов изменяется. Поняв, что упираетесь на запись, вы сделали шардинг по account_id. Теперь счета живут на разных репликасетах. Вы мониторите нагрузку и при необходимости переносите счета с одного репликасета на другой. Все бы хорошо, но иногда изменение состояния счета может влиять на состояние другого счета. Например, если у вас есть реферальная программа. И не факт, что эти два счета находятся в одном репликасете. Что делать?

Типичное решение заключается в том, чтобы использовать 2PC. Но 2PC плох тем, что это блокирующий протокол. И если подумать, он предоставляет слишком строгие гарантии. На практике такие гарантии часто не нужны. Скажем, в нашем примере пользователи не могут видеть состояние счета других пользователей. А значит, нет ничего страшного в том, что реферал получит свой процент от комиссии системы с небольшой задержкой. Вообще, этот процент можно начислять по расписанию, время от времени. Однако в более общем случае состояние счетов требуется обновить одновременно. Хотя атомарность при этом и не обязательна.

Так вот, все это долгое введение было к тому, что на практике условия часто формулируются именно таким образом, и тогда можно обойтись без 2PC. Примем за рабочую теорию, что время на всех серверах синхронизировано при помощи ntpd до определенной точности, 5, 10, 20 миллисекунд — не суть важно. Это вполне реальное условие, которое грамотные админы соблюдают и жестко мониторят в любом случае хотя бы для того, чтобы можно было сопоставить логи или таймстампы, записанные в БД, с нескольких серверов. Теперь рассмотрим следующий алгоритм.

Клиент C хочет выполнить некую транзакцию, затрагивающую счета A и B, хранящиеся на серверах SA и SB соответственно. Скажем, перевести 10 баксов со счета A на счет B. Берется таймаут T, равный 1-ой, 2-м, 5-и секундам — главное, чтобы он был много больше погрешности локального времени. Таймаут может быть захардкожен или прописываться в конфиге приложения.

Тогда, чтобы выполнить транзакцию:

  1. Клиент генерирует UUID транзакции Id и узнает текущее время StartTime;
  2. Клиент пишет в специальную табличку (например, на SA) запись «транзакция Id с таймаутом T, со счета A переводится 10 баксов на счет B, транзакция начала выполняться в StartTime», говорит commit;
  3. Клиент идет на SA, обновляет состояние счета A, а также делает запись в специальной табличке с финансовыми транзакциями «в результате выполнения транзакции Id со счета A снялось 10 баксов», говорит commit;
  4. Аналогично для SB и других серверов и счетов, если транзакция затрагивает больше двух;
  5. Клиент обновляет запись, созданную на шаге 2, помечая транзакцию, как выполненную, говорит commit;

Шаги 3 и 4 могут выполняться параллельно. Если все шаги выполнены успешно, транзакция считается успешной, возвращается OK. Если во время выполнения любого шага по не важно какой причине произошла ошибка, клиент прекращает выполнение транзакции и возвращает ERROR. Если клиент видит, что выполнение шагов 1-5 заняло больше T единиц времени, транзакция считается неуспешной и возвращается ERROR.

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

Так что же делать в случае ошибки — откат или накат? Зависит от приложения. Но в общем случае желательнее откатывать. Во-первых, если транзакция не прошла, один из серверов может быть временно недоступен (а админы промоутнут слейва до мастера только через 5 минут) и накатить за разумное время все равно не получится. Быстрее откатить, приведя тем самым систему в согласованное состояние. Во-вторых, возможна ситуация, когда транзакция в принципе не может быть выполнена. В итоге накат не всегда возможен, и система может навсегда остаться в противоречивом состоянии, если не сделать откат. Наконец, в-третьих, откат соответствует всем привычной и понятной традиционной семантике транзакций — если транзакция не прошла, делается rollback. В некоторых случаях траназакцию с ошибкой можно как-то пометить и затем зажечь лампочку в мониторинге. Можно попробовать N раз накатить транзакцию, а затем откатывать. Тут все зависит от ситуации.

Независимо от того, накатываем мы или откатываем, никакие данные не удаляются. Например, если мы делаем откат из-за того, что сервер SB не смог выполнить транзакцию, в финансовых транзакциях аккаунта А делается еще одна запись «на счет А возвращается 10 баксов в результате отката транзакции Id». Понятное дело, перед выполнением любых действий, процесс проверяет, что частичный откат не был выполнен ранее. По выполнении полного отката транзакции в записи, сделанной клиентом на шаге 2, делается пометка, что транзакция была откачена.

Само собой разумеется, мы предполагаем, что (1) распределенные транзакции редки по сравнению с транзакциями в рамках одного сервера (иначе мы плохо пошардили данные), что (2) 99.9% времени система работает в штатном режиме и что большинство межсерверных транзакций выполняются успешно, что (3) если на одном сервере прошел commit, то эти данные уже никогда не потеряются (Raft, синхронная репликация, …), а также, что (4) в конечном счете все, что падало, поднимается. Если вас беспокоит, что в 0.1% случаев или реже система будет находится в противоречивом состоянии целых T + D единиц времени, транзакции можно либо «смазать», например, делая в базе пометку «эти данные станут видны в момент времени StartTime + T + 2*D», либо, еще лучше, применять изменения только после того, как транзакция уже была помечена, как успешная.

Полагаю, в силу предельной очевидности алгоритма, какое-либо строгое доказательство того, что он работает, не требуется. Идея настолько проста и естественна, что я почти уверен, что она пришла мне в голову далеко не первому. Рабочее название всего этого хозяйства пусть будет ECDT, Eventually Consistent Distributed Transactions. Но если вы вдруг знаете другое, более общепринятое название, сообщите его, плиз, в комментариях.

Дополнение: Еще кое-какая инфа о ручном шардинге и распределенных транзакциях приведена в статье Шардинг, перебалансировка и распределенные транзакции в реляционных базах данных.

Дополнение: По состоянию на октябрь 2021 описанный подход обычно называют распределенные саги / distributed sagas [PDF] (также существуют обычные саги [PDF]). За прошедшие 7+ лет NewSQL не пришел и никого ни от чего не спас. Все по-прежнему делают application-level sharding и пишут распределенные саги на PostgreSQL.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.