Всем привет. Как вы, возможно, знаете, раньше я все больше писал и рассказывал про хранилища, Vertica, хранилища больших данных и прочие аналитические вещи. Сейчас в область моей ответственности упали и все остальные базы, не только аналитические, но и OLTP (PostgreSQL), и NOSQL (MongoDB, Redis, Tarantool). Эта ситуация позволила мне взглянуть на организацию, имеющую несколько баз данных, как на организацию, имеющую одну распределенную гетерогенную (разнородную) базу. Единую распределенную гетерогенную базу, состоящую из кучи PostgreSQL, Redis-ов и Монг… И, возможно, из одной-двух баз Vertica. Работа этой единой распределенной базы порождает кучу интересных задач. Прежде всего, с точки зрения бизнеса важно, чтобы с данными, движущимися по такой базе, все было нормально. Я специально не использую здесь термин целостность, consistency, т.к. термин это сложный, и в разных нюансах рассмотрения СУБД (ACID и CAP теорема) он имеет разный смысл. Ситуация с распределенной базой обостряется, если компания пытается перейти на микросервисную архитектуру. Под катом я рассказываю, как обеспечить целостность данных в микросервисной архитектуре без распределенных транзакций и жесткой связности. (А в самом конце объясняю, почему выбрал для статьи такую иллюстрацию).   Согласно Крису Ричардсону (одному из известнейших евангелистов микросервисной архитектуры), в этой архитектуре есть два подхода к работе с базами данных: shared database и database-per-service.  Shared database — это неплохой первый шаг, отличное решение для небольшой компании без амбициозных планов роста. При этом сам по себе этот паттерн является анти-паттерном с точки зрения микросервисной архитектуры, т.к. два сервиса, делящих общую базу, нельзя независимо тестировать и масштабировать. Т.е. эти сервисы скорее — один сервис, тяготеющий к превращению в монолит. Паттерн database-per-service предполагает, что у каждого сервиса своя база. Сервис может обращаться к данным другого сервиса только через API (в широком смысле), без прямого подключения к его базе. Паттерн database-per-service позволяет командам соответствующих сервисов выбирать базы, как им нравится. Кто-то умеет в MongoDB, кто-то верит в PostgreSQL, кому-то достаточно Redis (риск потери данных при выключении для этого сервиса приемлем), а кто-то вообще хранит данные в CSV-файлах на диске (а почему бы, собственно, и нет?).  Работа с подобным «зоопарком» баз данных поднимает задачу наведения порядка в данных на абсолютно новый уровень сложности.  Давайте посмотрим на задачу наведения порядка через призму классического СУБД-шного набора требований ACID: развернем суть каждой буквы аббревиатуры, и проиллюстрируем сложности с этой буквой в микросервисной архитектуре.  (A) CID — Atomicity. Atomicity — все или ничего.  Согласно требованию Atomicity, нужно обязательно выполнить все шаги (с возможными повторами), при отказе важного шага — отменить выполнившиеся. В приведенной иллюстрации демонстрируется тестовый процесс покупки услуги VIP: резервируются деньги в биллинге (1), для пользователя подключается бонусная услуга (2), тип пользователя меняется на Pro (3), зарезервированные деньги в биллинге списываются (4). Все четыре шага должны либо выполниться, либо не выполниться.   При этом нельзя зависнуть посередине процесса, поэтому предпочтительнее асинхронность, в крайнем случае — синхронность со встроенным таймаутом. A(С)ID – Consistency. Consistency – каждый шаг не должен противоречить граничным условиям.  Классические примеры условий для, например, отправки денег от клиента А в сервисе 1 клиенту B в сервисе 2: в результате подобной отправки денег не должно стать меньше (деньги при пересылке не должны пропасть) или больше (недопустимо отправить одни и те же деньги двум пользователям одновременно). Для соблюдения этого требования нужно где-то кодировать условия и проверять данные для условий (в идеале — без дополнительных обращений).  ACI(D) — Durability. Требование Durability означает, что последствия операций не пропадают.  В условиях Polyglot persistence сервис может работать на базе данных, которая штатно может «потерять» записанные в нее данные. Подобный фокус можно получить даже от солидных баз наподобие PostgreSQL, если там включена асинхронная репликация. На иллюстрации демонстрируется, как изменения, записанные в Master, но не доехавшие в Slave по асинхронной репликации, могут быть уничтожены сгоранием сервера Master. Для обеспечения требования Durability требуется уметь штатно диагностировать и восстанавливать подобные потери.  А где же I, спросите вы?  А нигде. Изоляция в среде нескольких независимых асинхронных сервисов — это техническое требование. Современные исследования показали, что реальные бизнес-процессы можно реализовывать без изоляции. Изоляция упрощает мышление тем, что минимизирует параллелизм (разработка параллельных вычислений сложнее для программиста), но микросервисная архитектура по своей сути массивно-параллельная, изоляция в такой среде избыточна.  Существует много подходов, позволяющих добиться соблюдения перечисленных выше требований. Наиболее широко известен алгоритм распределенных транзакций, обеспечиваемых так называемым двухфазным коммитом (2PC). К сожалению, реализация двухфазных коммитов требует переписывания всех вовлеченных сервисов. И самое серьезное: этот алгоритм не очень производителен. Приведенные иллюстрации из недавних исследований показывают, что этот алгоритм показывает определенную производительность на распределенной базе из двух серверов, но при росте количества серверов производительность растет не линейно… А точнее, практически совсем не растет.  Одно из главных достоинств микросервисной архитектуры — возможность около линейно наращивать производительность, просто добавляя больше и больше серверов. Получается, что если для обеспечения распределенной целостности использовать двухфазный коммит, то этот процесс станет узким местом, ограничителем для роста производительности, несмотря на наращивание количества серверов.  Как же можно обеспечить распределенную целостность (требования ACiD) без двухфазных коммитов, с возможностью линейно масштабироваться по производительности? Современные исследования (например, An Evaluation of Distributed Concurrency Control. VLDB 2017) утверждают, что помочь может так называемый «оптимистический подход». Разницу между двухфазным коммитом и обобщенным «оптимистическим подходом» можно проиллюстрировать разницей между старым советским магазином (с прилавком), и современным супермаркетом, вроде Ашана. В магазине с прилавком каждый покупатель считается подозрительным, и обслуживается с максимальным контролем. Отсюда очереди и конфликты. А в супермаркете покупатель по умолчанию считается честным, ему дают возможность самому подходить к полкам и набивать тележки. Конечно, есть средства мониторинга для ловли жуликов (камеры, охрана), но большинству покупателей никогда не приходится с ними сталкиваться.  Поэтому супермаркет можно масштабировать, расширять, просто ставя больше касс. Аналогично и с микросервисной архитектурой: если распределенная целостность обеспечивается «оптимистическим подходом», когда дополнительно нагружаются проверками только процессы, где что-то пошло не так. А нормальные процессы идут без дополнительных проверок. Важно. К «оптимистическому подходу» относится несколько алгоритмов. Я хотел бы рассказать вам про сагу — алгоритм поддержания распределенной целостности, рекомендуемый Крисом Ричардсоном.  Алгоритм саг имеет два варианта. Поэтому сначала я хотел бы универсально описать требуемые элементы алгоритма, чтобы описание подходило для обоих вариантов. Элемент 1. Надежный персистентный канал доставки событий между сервисами, гарантирующий «at least once delivery». Т.е. если шаг 2 процесса успешно завершился, то извещение (событие) об этом должно достигнуть шага 3 как минимум однажды, повторные доставки допустимы, но потеряться ничего не должно. «Персистентный» означает, что канал должен хранить извещения какое-то время (2-3 дня, неделю), чтобы сервис, потерявший последние изменения из-за потери базы (см. пример про Durability, на иллюстрации это шаг 2), мог восстановить эти изменения, «перепроиграв» события из канала.   Элемент 2. Идемпотентность вызовов сервисов за счет использования уникального ключа идемпотентности. Представим, что я (пользователь) инициирую процесс покупки VIP-пакета (см. пример для Atomicity). В начале процесса мне выдается уникальный ключ, ключ идемпотентности, например, 42. Далее вызов каждого из шагов (1→2→3→4) должен выполняться с указанием ключа идемпотентности. В пункте выше упоминается возможность повторного прихода одинакового сообщения в сервис (в шаг). Сервис (шаг) должен автоматически уметь игнорировать повторный приход обработанного события, проверяя повторность по ключу идемпотентности. Т.е., если все сервисы (шаги процессов) идемпотентные, то для обеспечения требований Atomicity и Durability достаточно переотправить в шаги, соответствующие событиям из каналов. Шаги, пропустившие события, выполнят их, а шаги, уже выполнившие события, проигнорируют их из-за идемпотентности.   Элемент 3. Отменяемость вызовов сервисов (шагов) по ключу идемпотентности. Для обеспечения Atomicity (см. пример), если процесс с ключем идемпотентности 42, например, остановился/упал на шаге 3, то необходимо отменить успешные выполнения шагов 1 и 2 для ключа 42. Для этого каждый обязательный шаг процесса должен обладать «компенсирующим» шагом, API-методом, отменяющим выполнение обязательного шага для указанного ключа идемпотентности (42). Реализация компенсирующих вызовов — это тяжелый, но необходимый элемент доработки сервисов в рамках внедрения алгоритма саг.   Перечисленные выше три элемента актуальны для обоих вариантов реализации «cаг»: оркестрируемых и хореографических. Более простой и очевидный алгоритм оркестрируемых саг проще для понимания и реализации. В своей отличной статье kevteev описал алгоритм и процесс реализации механизма оркестрируемых саг в Авито. Их алгоритм предполагает существование контролирующего сервиса, «оркестрирующего» вызовы сервисов в рамках обслуживаемых бизнес-процессов. Этот же контролирующий сервис может обладать собственной базой данных (например, PostgreSQL), выступающей в роли надежного персистентного канала доставки событий (элемент 1). С хореографической сагой хитрее. Тут в качестве надежного персистентного канала должна выступать шина данных, реализующая следующие требования: fire-and-forget publishing, publish-subscribe event delivery, at least once delivery. Т.е. каждый шаг каждого процесса должен получать команду на срабатывание из шины, и кидать туда же сообщение об успешном выполнении, о старте следующего шага, чтобы тот тоже прочитал его из шины и продолжил выполнение процесса. При этом на каждое сообщение может быть несколько подписчиков.  В хореографической саге тоже должен быть контролирующий сервис, сервис саг, но существенно более «легковесный». Сервис должен знать о зарегистрированных в системе бизнес-процессах, о составе входящих в каждый процесс шагов. Также он должен слушать шину, мониторить выполнение каждого процесса (каждого ключа идемпотентности), и, только если что-то пошло не так, либо кидать «повторы» конкретных шагов, либо кидать «отмены», «компенсации» для выполнившихся шагов.  Один из самых важных нюансов саг, отличающих их от классических транзакций, является отход от линейности, последовательности, обязательности каждого шага. Сага — это не обязательно линейная цепочка событий, этом может быть направленный граф: событие регистрации нового пользователя может породить несколько шагов в параллель (отправка смс, регистрация логина, генерация пароля, отправка письма), часть из которых может являться необязательными. В первом приближении кажется, что в подобной «разветвленной» саге с необязательными шагами тяжело определить завершение саги (процесса), но, на самом деле, все просто: сага (процесс) завершена, когда завершены все обязательные шаги, в любом порядке.  Второй нюанс, характерный скорее для хореографических саг, но возможный и для оркестрируемых, заключается в выборе подхода к регистрации бизнес-процессов, типов саг в сервисе саг. В примере для Atomicity описан процесс из четырех последовательных обязательных шагов.  Кто зарегистрировал этот процесс, указал все шаги, расставил зависимости и обязательность шагов? Очевидный, но старомодный ответ заключается в том, что регистрация процесса должна выполняться централизованно, в сервисе саг. Но этот ответ не очень соответствует микросервисной архитектуре. В микросервисной архитектуре более перспективно, более производительно и более быстро регистрировать процессы «снизу вверх». Т.е. не прописывать все нюансы процесса в сервисе саг, а дать возможность отдельным сервисам самостоятельно «вписываться» в существующие процессы, указывая свою обязательность/необязательность и обязательных предшественников.  Т.е. процесс регистрации пользователя в сервисе саг может изначально состоять из трёх шагов, а потом, в ходе развития системы, туда впишутся еще семь шагов, а один шаг выпишется, и их станет девять. Подобная «анархическая» и «децентрализованная» схема сложна для тестирования, для реализации строгого согласованного процесса, но она намного удобнее для Agile команд, для непрерывной разнонаправленной эволюции продукта.  Собственно вот. С серьезным изложением, думаю, стоит закончить, а то статья получилась слишком большая.  Вот ссылка на презентацию этого материала, доклад на эту тему я делал на Highload Siberia 2018.
UPD — и видео с конференции:  Напоследок хотел бы попробовать объяснить все, перечисленное выше, более образным языком.
Ведь что такое сага изначально? Это сюжет, это приключение из средних веков… Или из Игры Престолов. Происходит событие (битва, свадьба, кто-то умирает), весть об этом летит по миру через гонцов, через почтовых голубей, через купцов. Когда весть доходит до заинтересованных (через неделю, через месяц, через год), они реагируют: отправляют армии, объявляют войну, кого-то казнят, и летят новые вести.  Нет контролирующего органа, следящего за последовательностью действий. Нет транзакций, нет rollback, в смысле отмены действия, как будто его никогда не было. Все по-взрослому, каждое действие происходит навсегда. Его можно компенсировать, но это именно действие (убийство) и компенсация (плата за голову, вира), а не отмена смерти.  События идут долго, доходят из разных источников, действия происходят параллельно, а не строго последовательно. И довольно часто в сюжете внезапно возникают новые участники, решившие поучаствовать (прилетают драконы ;))… а кое-кто из старых участников внезапно умирает.  Такие дела. Вроде бардак и хаос, но все работает, внутренняя согласованность мира не нарушается, сюжет развивается и непротиворечив… Хотя иногда и непредсказуем.