Здравствуйте, gandjustas, Вы писали:
G>У нас мобильное приложение или браузер, обращается через REST API к серверу, а сервер в свою очередь обращается к БД. Типичная трехзвенная архитектура.
G>Все проще, пользователь просто закрыл страницу когда не дождался ответа или интернет ему выключили. В любом случае сервер увидел потерю соединения и запустил отмену через CancellationToken, который из контроллера прокидывается во всю БЛ.
Нет конечно, сервер ничего не "увидел", и никакую "отмену" он не запустил.
Сервер может заметить потерю клиента только в тот момент, когда он начинает отправлять клиенту респонс —
после того, как все транзакции уже уехали в durable log.
Наоборот делать ни в коем случае нельзя — иначе клиент получит ответ, который может исчезнуть после перезагрузки сервиса.
Более того — сервер может так и не увидеть отвала клиента даже после отправки ответа.
Например, между клиентом и сервером стоит прокси, который в любом случае вычитает ответ до конца и вежливо дождётся закрытия соединения со стороны сервера.
G>И это событие по счастливой случайности происходит ровно в момент между тем как А ответил подтверждением, а на сервер Б еще не отправлена команда на изменение статуса заказа.
Вы по-прежнему пишете какие-то непонятные мне вещи. Никакой "команды на изменение статуса заказа" на сервер Б не отправляется.
В этой архитектуре
Пользователь на странице заказа нажимает кнопку "зарезервировать"
Клиент отправляет в "сервис Б" команду
PATCH /orders/42
{"status": "reserved"}
Сервис Б в локальной базе делает
update orders set status = "reserving" where id = 42
и немедленно возвращает клиенту 202 Accepted
Клиент, в зависимости от развитости чувства прекрасного у тех лида, либо делает регулярный polling этого ордера через GET /orders/42, либо идёт читать GET /orders/42/events.
Тем временем город засыпает просыпается workflow-процесс, который делает select * from orders where status = "reserving" (с поправкой на engine-specific приседания для того, чтобы сделать аккуратное разгребание этой очереди без лишних блокировок и рейсов)
Обнаружив в базе наш заказ с номером 42, он вычитывает его позиции, и делает PUT на адрес сервиса А:
PUT /reservations/42/
{
"items": [
{"product":17, "quantity":10},
{"product":5, "quantity":1}
]
}
обратите внимание, что этот процесс вообще ничего не знает о клиенте и пользователе. Его работа — двигать workflow по предписанной траектории, даже если эта траектория пересекает произвольное количество вынужденных и добровольных рестартов любого из участников.
Сервис А в ответ на этот запрос лезет в свою базу и ищет там reservation where id = 42.
Если находит, то проверяет, совпадают ли указанные в запросе продукты и количества с reservationItems where orderId = 42. Если совпали — то возвращает 200 Ok с ETag, вычисленным как хеш от всех продуктов и количеств. Если не совпали — возвращает 409 Conflict. (Тут на самом деле чуть сложнее, но в данном конкретном сценарии эту сложность можно пока проигнорировать)
Если не находит, то делает
begin transaction;
update stock
set reserved = reserved + 10,
available = available - 10
where productId = 17;
update stock
set reserved = reserved + 1,
available = available - 1
where productId = 5;
insert into reservations(orderId) values(42);
insert into reservationItems(orderId, productId, quantity) values(42, 17, 10);
insert into reservationItems(orderId, productId, quantity) values(42, 5, 1);
commit transaction
Эта радость может быть прервана constraint availability check(available >= 0) или ещё чем-нибудь — тогда сервис отдаёт обратно 4хх c понятным клиенту объяснением.
Конкретный SQL тут не так важен — важна его атомарность. Если мы всё же довели транзакцию до конца и получили в сервисе А положительный ответ от СУБД, то мы в ответ на это отдаём сервису Б
201 Object Created
ETag: 0x213123123412312
{
"items": [
{"product":17, "quantity":10},
{"product":5, "quantity":1}
]
}
Сервис Б, дождавшись этого ответа, делает в своей базе
update orders set status = "reserved", reservationETag = 0x213123123412312 where id = 42
Если вдруг так получилось, что сервис Б так и не смог сделать эту запись (питание моргнуло до коммита в базу; сеть моргнула до того, как респонс доехал до сервиса Б), то тот же самый процесс перейдёт к обработке следующего заказа в этом статусе; а потом очередь снова дойдёт до заказа 42 и сервис Б пойдёт по кругу, пока не получит от сервиса А внятного ответа и не сможет запомнить этот ответ.
Клиент в этом всём никакого участия не принимает: надёжность его связи с сервером на порядок хуже надёжности сети между сервисами А и Б.
В зависимости от вкусов разработчика, упомянутых выше, та же транзакция, которая пишет в orders, может заодно писать и в orderEvents. Тогда клиент, если он ещё не ушёл, узнает об этом по появлению новых данных в GET /orders/42/events.
Иначе, на очередном витке GET /orders/42, он увидит "status":"reserved" и покажет пользователю окошко для реквизитов оплаты.
G>Даже если мы эту отправку на серверы А и Б делаем на сервере, а не клиенте, то проблема никуда не девается. Процесс на сервере тоже также может упасть между.
Нет никаких "между".
G>В описанном выше сценарии пользователь перезагрузивший страницу\приложение увидит что заказ все еще в статусе "резервируется".
Можно и так написать, но зачем делать плохое решение, когда есть хорошее?
G>Что именно будет сохранено в WAL зависит в итоге от реализации хранилища, к которому мы будем этот самый WAL применять.
Я не знаю других вариантов устройства WAL, кроме как "данные до + данные после", т.к. опирается он на идемпотентность записи. Если у нас есть "хранилише", в которое можно делать идемпотентную запись И оно разрешает произвольное количество перезаписей туда/обратно, то можно сделать поверх него WAL. Иначе — нет.
G>От кого он этот ответ должен получить? Какой процесс должен сработать?
От сервиса А, к которому он обращается. Выше — пошаговый алгоритм.
G>Мне кажется дальнейшее пока рано обсуждать, потому что мы пока не достигли единого понимания сценария фейла для "транзакции" из двух таблиц. Когда поймем эти сценарии и поймем как их можно компенсировать за счет кода можно обсуждать все остальное.
Ок, давайте достигнем. Есть какие-то моменты, непонятные в схеме выше?