Re[30]: Помогите правильно спроектировать микросервисное при
От: Sinclair Россия https://github.com/evilguest/
Дата: 16.02.26 12:47
Оценка:
Здравствуйте, gandjustas, Вы писали:

G>У нас все соединения ненадежные. Мы на практике не рассматриваем случай когда фейл происходит между коммитом и ответом о коммите от базы.

Не совсем так. Если речь идёт о традиционном клиенте, неважно — толстом там или тонком, то стейт такого клиента в любом случае эфемерный.
И если у нас при нажатии кнопки "сабмит" в таком клиенте вылетает какая-нибудь "неожиданная ошибка", то рекомендованное поведение в общем случае сводится к "перезапустите клиента".
То есть мы выбрасываем наше эфемерное состояние и загружаем его обратно из нашей БД.
А уже в ней у нас есть все гарантии ACID-ity. То есть заказ, который мы отправляли на резервирование, в любом случае либо целиком зарезервирован, либо целиком незарезервирован.
И работает это благодаря тому, что вся транзакция исполняется в контексте монолитной СУБД, у которой нет возможности получить partition.
И только когда мы начинаем исполнять эту транзакцию на нескольких узлах, которые связаны ненадёжными каналами и имеют шансы нарваться на partition, в дело вступает Брюер со своей CAP теоремой.

S>>МСА предлагает использование Representational State Transfer. Работает он так:

S>>1. Заказ в базе Б находится в статусе "черновик".
S>>2. По команде "зарезервировать заказ" заказ атомарно переходит в статус "резервируется". Из этого статуса его вручную вывести нельзя, и изменить состав заказа в этом статусе тоже нельзя.
S>>3. Сервисом заказов делается попытка выполнить идемпотентную операцию "создать резерв" в сервисе А.
S>>3.1. Если сервис А отвечает отказом, то сервис Б атомарно возвращает заказ в статус "черновик" и пишет в историю заказа "резервирование не удалось".
S>>3.2. Если сервис А отвечает подтверждением, то сервис Б атомарно перемещает заказ в статус "зарезервировано", и для него становятся доступны следующие операции стейт-машины.
S>>3.3. Если сервису Б не удается обработать ответ от сервиса А по любой из причин, перечисленных в моём предыдущем посте, то заказ остаётся в статусе "резервируется".
G>А когда получает ответ пользователь?
после 3.1/3.2
G>Ему же надо дать ответ в моменте и показать окно оплаты.
Окно оплаты мы показываем только в п. 3.2.
G>И что будет если приложение упадет в пункте 3.2 между ответом А и изменением статуса заказа в Б?
Тут нам придётся немножко погадать о том, что такое "приложение" в этом сценарии. Ну, допустим, это тонкий стейтлесс-клиент, всё "падение" которого — это браузер, который внезапно устал ждать разрешения пользователя на установку апдейта и самопроизвольно перезапустился. Пользователь видит ту же страницу того же заказа, статус которого приложение перезапрашивает у Б через API и видит соответствующий статус "товары в резерве, произведите оплату до ЧЧ:ММ ДД.ММ.ГГГГ".

G>Я в принципе знаю правильный ответ: он называется WAL, а на более высоком уровне абстракции — transactional outbox. Данные для выполнения транзакции сначала атомарно пишутся в (одно, иначе атомарности не будет) долговременное хранилище, а потом пытаются примениться к конкретным таблицам.

Да, в некоторых случаях мы можем попытаться нарулить что-то вроде WAL. Но не всегда, т.к. под капотом у WAL — элементарные таблицы, для которых чётко и однозначно определены операции чтения и записи.
Это и даёт нам железобетонную уверенность в том, что после рестарта мы можем откатить неоконченные транзакции и накатить оконченные.
Когда мы проектируем в терминах сервисов, такой гарантии у нас в общем случае нет.

G>>>Если код падает по между шагами 2 и 3, то в базе остается несогласованное состояние.

S>>Несогласованное состояние в REST возникает только в тот момент, когда мы не получили ответ на запрос "распространить изменение". Причём мы знаем о том, что оно несогласованное — и можем принять меры, чтобы эта несогласованность не вышла нам боком.
G>Несогласованное состояние в примере выше возникнет если заказ на складе будет забронирован в сервисе А, а на сервисе Б не изменен статус.
Вы говорите то же самое, что и я, немного другими словами. Потому, что "в сервисе Б не изменён статус" == "сервис Б не смог получить ответ на запрос о распространении изменения".
Как только он сможет получить этот ответ, статус заказа сразу же и изменится. Без каких-либо рукопашных действий со стороны пользователя или администратора.

G>А что насчет фейла, что я описал выше?

Не уверен, что я правильно понял сценарий фейла, который вы описали.
Я описал ВСЕ сценарии, которые возможны в рассматриваемой ситуации.

G>В твоем примере выше проблема та же самая. Процесс на шаге 3 может прерваться между обращениями к базам А и Б.

G>Это будет ошибка — неконсистентное состояние, которое нужно будет или откатить до исходного или каким-то образом докатить до финального.
Я же написал, как именно докатить. У нас в сервисе Б есть заказ(ы) в состоянии "резервируется". Всё, что нам нужно знать про эти заказы для завершения резервирования, лежит прямо в базе Б и никак не зависит от прихода или ухода клиента. "Фоновый поток" стейт машины берёт каждый такой заказ (на самом деле — remote request task, т.к. этому коду всё равно, идёт ли речь о заказах, или доставках, или рассылках, или ещё о чём-то) из очереди и пытается выполнить заказанную идемпотентную операцию. В нашем случае — "создать резерв для заказа Х" в сервисе А. Если у сервиса А уже есть резерв для этого заказа — он отвечает 200 Ok, давая возможность сервису Б записать в его базу результат операции, то есть переключить заказ Х в состояние "зарезервирован, ожидает оплаты". Если у сервиса А нет резерва для этого заказа — он попробует его выполнить, и ответит либо 200 Ok, либо 4хх если резерв выполнить невозможно. В обоих случаях это даёт сервису Б шанс записать в свой стейт новый статус заказа.
Если у нас каждый 10й запрос от Б к А заканчивается "потерей результата" (неважно по чьей вине — или А в это время перезапускают, или Б, или сеть между ними ложится из-за игр админов), с вероятностью 90% мы получим результат за 1 обращение, с вероятностью 99% — за 2, с вероятностью 99.9% — за три и так далее. В реальности надёжность системы из сети и двух сервисов настолько высока, что в 95-й процентиль укладываются запросы с 1 попытки.

G>Причем делать это уже фоновым процессом, потому что прерывание, вероятнее всего, произойдет по причине того, что пользователь ушел\связь прервалась.

Пользователь здесь играет только роль инициатора операции. Ему не надо "следить" за системой. Вот в вашей системе что будет, если связь с пользователем прервалась между тем, как транзакция в БД закоммитилась, и ему было показано окно оплаты?

G>Фоновому процессу надо где-то брать данные о той операции, что надо откатить\докатить, это и будет wal или аналог.

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

G>Необходимости на уровне кода определять "были ли ошибка" конечно нет. Но код который откатывает\докатывает транзакцию все равно нужен.

Да, и он пишется и отлаживается примерно 1 раз на систему.

G>Так давайте на одном простом примере разберемся как оно должно быть? Я вот до сих пор не понимаю как сделать надежное резервирование заказов в рамках МСА с разделением сервисов на "заказ" и "склад", чтобы хотя бы по надежности не уступало одной базе. По трудозатратам однозначно в разы проиграет и по быстродействию\масштабируемости тоже.

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

S>>И пока там кто-то начисляет бонусы сотрудникам, остатки на складах начинают лагать

G>Ну нет конечно. readable реплики никто не отменял.
Readable реплики быстро превращаются в OLAP, а за ним паровозом едет ETL, и это то, что нынешние эксплуатанты делать не хотят.

G>А нагрузка на запись масштабируется дисками и партицированием.

Если бы всё было так просто, то никто бы не заморачивался никакой архитектурой, кроме монолита. Я согласен с тем, что монолит является предпочтительным решением для старта. И МСА зачастую выступает именно как оверкилл решение, в котором самая рациональная часть — это "если наш сервис не выстрелит, то response time в 100 миллисекунд вместо 5 никого не расстроит. А если выстрелит, то у нас не будет времени на вдумчивое партиционирование и настройку репликации. Народ повалит с реддита так, что нам надо будет за двое суток его отмасштабировать от 200 пользователей до двух миллионов; и если в МСА мы просто навалим туда ресурсов из амазона, то в монолите мы просто ляжем, и второй раз к нам никто не пойдёт".

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

G>Причём если при патицировании можно делить только нагруженные таблицы, то при шардировании придется делить всё. Да еще и придумывать механизм реплицирования изменений в общих таблицах.
Именно. А в микросервисах ничего реплицировать не надо: шардируем нагруженные таблицы, а общие слабонагруженные таблицы так и остаются там, где они остаются.
S>>Вот это утверждение я не понял. Вы собираетесь все микросервисы гонять на одном узле?
G>Чаще всего так и происходит, что система изначально построенная по МСА гоняется на одном сервере БД в разных базах, а по мере роста нагрузки поднимаются новые серваки. При этом потребность в масштабировании при МСА возникает гораздо раньше, чем в монолитной базе.
Да, в этом смысле вы совершенно правы. В целом, конечно, "высокоабстрактные" архитектуры (к коим относится и МСА) как раз позволяют лёгким движением руки поднять количество серверов со 100 до 1000, чтобы обработать нагрузку, с которой монолит бы справился и на 1 сервере

G>Если пропала связь в момент между "обновлены остатки на складе" и "обновлен статус заказа", то в МСА получаем неконсистентное состояние, которое еще и непонятно как откатывать\докатывать.

Нет там ничего непонятного. Мы данные для любой внешней операции берём не из эфемерного контекста, а из своей локальной БД.
Всё, это означает, что у нас цикл do res = try_reserve(...) while (is_transient_error(res)) прекрасно переживает перезапуск нашего приложения.

G>В случае монобазы мы получаем отмену транзакции и возврат к исходному состоянию будто ничего не было. Причем бесплатно с точки зрения трудозатрат.

Да, совершенно верно. А если нам совсем повезло, то вообще вся транзакция, включая пользовательскую переписку и обработку платежей, лежит в пределах этой одной монобазы, и мы можем делать кросс-сервисный ACID.
Жаль, что такое встречается исчезающе редко.

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

А то. Но вы же помните, что эта буква работает на полную катушку только при уровне изоляции serializable?
Положа руку на сердце: какая часть транзакций в типовой разрабатываемой вами системе работает с таким уровнем?

G>Но когда мы границы транзакции в одной базе пересекаем никаких гарантий больше нет и все надо делать руками.

Да, что делает ценными специалистов, которые знают, как быть в таком случае

G>Это на один сценарий, где требуется изменение более чем в одной базе. На каждый сценарий будет свой код отката\доката, который вряд ли получится обобщить.

Специфичный для сценария код отката и доката легко встраивается в универсальный механизм state machine, который пишется и отлаживается один раз.

G>С точки зрения РЕСТ — да, а с точки зрения архитектуры — нет. На фронте нет никакой транзакционности и отката. Выполнение сценария на фронте может прекратиться в любой момент и не продолжиться вообще никогда, и это не является ошибкой.

G>Поэтому мы должны на стороне сервера делать откат\докат фоновым процессом.
Он не фоновый, этот процесс — основной. Взаимодействие с клиентом становится асинхронным: то есть я не "резервирую заказ", я ставлю задачу "зарезервируй заказ" в очередь. Очередь разгребается не тем же потоком, который был инициирован в ответ на мой запрос. И делается это в первую очередь для того, чтобы не надо было перезапускать всего клиента каждый раз, как связь с сервером моргнёт.


G>>>Приложения можно нарезать на десятки отдельных модулей и запускать отдельно: в отдельных процессах, в модулях одного процесса — как удобно. Все что написано выше для них верно.

S>>Чтобы приложения можно было нарезать, между ними должны быть какие-то границы.
G>Границы могут быть разными, в том числе чисто логическими.
G>Допустим есть прям монолитное приложение, которое весь код запускает в одном процессе. Никакого состояния между запросами в памяти процесса не хранится, все сохраняется в монобазу.
G>Можем ли мы горизонтально масштабировать приложение, запуская несколько экземпляров на разных серверах? Если нет, то почему?
Это сильно зависит от того, как это приложение было спроектировано. Большинство известных мне монолитных приложений крайне плохо относятся к такой идее. Ну, вот 1С: попробуйте запустить несколько экземпляров на разных серверах, подключив их к одной монобазе. Внезапно оказывается, что нужно прямо как-то заранее бить разработчиков палкой, чтобы они не делали каких-нибудь фоновых процессов, полагающихся на свою эксклюзивность, или client-side locks.
А когда мы таки научили приложение не рассчитывать на монопольный доступ к базе, то оказывается, что узкое место — вовсе не код в App Server (особенно если мы не стали писать его в стиле рич-DDD, а свели к нормальной анемик-модели). Поэтому дополнительные экземпляры начинают дольше простаивать в ожидании БД, и всё.
Дальнейшее распиливание, например на read-реплики и master-реплику потребует либо обучать пользователей "не запускайте этот отчёт в мастер-приложении, идите на специальный адрес, где можно делать отчёты. Но там нельзя резервировать заказы", либо допиливать приложение, чтобы оно умело ходить в разные базы за разными сценариями. То есть опять не получается решить вопрос без трудозатрат.


G>Зачем нам разные процессы?

Вы только что написали:

Приложения можно нарезать на десятки отдельных модулей и запускать отдельно: в отдельных процессах, в модулях одного процесса — как удобно.

G>В монолите я могу сделать rolling update в кластере кубера, обновляя приложение вообще без остановки обслуживания.
Это интересно. Я так не умею. Есть какой-то авторитетный источник, который описывает механизм этого чуда для невежд вроде меня?
Потому что мне непонятно, как это у нас делаются роллинг апдейты, задействующие схему базы, с учётом того, что она у нас в единственном экземпляре.

G>С фича-флагами я могу вообще разные версии "модуля ценообразования" запускать даже для разных пользователей.

G>С хорошей модульностью я могу в части экземпляров запускать часть модулей, а в других этого не делать.
G>Имхо не надо подстраивать архитектуру под инфраструктурные задачи.
Ну, может быть.

S>>И количество серверов СУБД в HA-кластере вас не спасёт от того, что апдейт схемы плохо совместим с data-level блокировками.

G>А как тут разделение поможет? Если вам нужен access exclusive на таблицу для обновления, то какая разница сколько всего у вас баз?
Большая. Эксклюзивность-то мне нужна не на все базы, а только на вот эту конкретную, в которой я меняю схему. А остальные как ехали, так и едут — их вообще это обновление не касается.

S>>То есть "на ходу" его провести возможно далеко не всегда; безопасный способ — переключать базу в монопольный режим.

G>не так уж много операций требуют монопольного режима для всей базы, а для отдельных таблиц разделение не поможет.
Смена схемы — и есть та операция, которая требует этого монопольного режима. К ней же относятся все эти партиционирования и прочие волшебные меры по оптимизации.
Уйдемте отсюда, Румата! У вас слишком богатые погреба.
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.