Реализация ограничений на основе набора в CQRS
Я все еще борюсь с тем, что должно быть основными (и решенными) проблемами, связанными с архитектурой стиля CQRS:
Как мы реализуем бизнес-правила, основанные на наборе совокупных корней?
Взять, к примеру, заявку на бронирование. Это может позволить вам забронировать билеты на концерт, места для фильма или столик в ресторане. В любом случае, будет только ограниченное количество "предметов" для продажи.
Давайте представим, что событие или место очень популярны. Когда продажи открываются для нового события или временного интервала, резервирования начинают поступать очень быстро - возможно, много в секунду.
На стороне запроса мы можем масштабировать, и резервирование ставится в очередь для асинхронной обработки автономным компонентом. Сначала, когда мы извлекаем команды резервирования из очереди, мы принимаем их, но в определенный момент нам придется начать отклонять остальные.
Как мы узнаем, когда достигнем предела?
Для каждой команды резервирования нам нужно запросить какое-то хранилище, чтобы выяснить, сможем ли мы удовлетворить запрос. Это означает, что нам нужно знать, сколько бронирований мы уже получили в то время.
Однако, если хранилище доменов - это нереляционное хранилище данных, такое как, например, Windows Azure Table Storage, мы не можем сделать SELECT COUNT(*) FROM ...
Одним из вариантов будет сохранение отдельного Агрегированного Корня, который просто отслеживает текущий счет, например так:
- AR: Бронирование (кто? Сколько?)
- AR: Событие / Временной интервал / Дата (общее количество)
Второй Aggregate Root будет денормализованным агрегированием первого, но когда базовое хранилище данных не поддерживает транзакции, очень вероятно, что они могут быть не синхронизированы в сценариях большого объема (что мы и пытаемся адрес в первую очередь).
Одним из возможных решений является сериализация обработки команд резервирования таким образом, чтобы обрабатываться только по одной за раз, но это идет вразрез с нашими целями масштабируемости (и избыточности).
Такие сценарии напоминают мне о стандартных сценариях "нет в наличии", но разница в том, что мы не можем поставить резервирование в обратном порядке. После того, как событие распродано, оно распродано, поэтому я не вижу, какое будет компенсационное действие.
Как мы справляемся с такими сценариями?
4 ответа
Подумав об этом в течение некоторого времени, я наконец осознал, что основная проблема связана не столько с CQRS, сколько с нетранзакционной природой разрозненных REST-сервисов.
На самом деле все сводится к этой проблеме: если вам нужно обновить несколько ресурсов, как вы обеспечиваете согласованность в случае сбоя второй операции записи?
Давайте представим, что мы хотим записывать обновления для ресурса A и ресурса B по порядку.
- Ресурс А успешно обновлен
- Попытка обновить Ресурс B не удалась
Первая операция записи не может быть легко отменена перед лицом исключения, так что мы можем сделать? Поймать и подавить исключительную ситуацию для выполнения компенсирующего действия в отношении ресурса А не является приемлемым вариантом. Во-первых, это сложно реализовать, но, во-вторых, это небезопасно: что произойдет, если первое исключение произошло из-за сбоя сетевого подключения? В этом сценарии мы также не можем написать компенсационное действие против ресурса А.
Ключ лежит в явной идемпотентности. Хотя очереди Windows Azure не гарантируют семантику ровно один раз, они гарантируют семантику хотя бы один раз. Это означает, что перед лицом периодически возникающих исключений сообщение будет позже воспроизведено.
В предыдущем сценарии это то, что происходит потом:
- Ресурс А попытался обновить. Тем не менее, воспроизведение обнаружено, поэтому на состояние A это не влияет. Однако операция "запись" завершается успешно.
- Ресурс Б успешно обновлен.
Когда все операции записи являются идемпотентными, возможная согласованность может быть достигнута с помощью повторов сообщений.
Интересный вопрос, и с этим вы прибиваете одну из болевых точек в CQRS.
Amazon справляется с этим, когда бизнес-сценарий справляется с состоянием ошибки, если запрошенные товары распроданы. Состояние ошибки состоит в том, чтобы просто уведомить клиента по электронной почте о том, что запрошенные товары в настоящее время отсутствуют на складе и предполагаемый день доставки.
Однако - это не полностью отвечает на ваш вопрос.
Размышляя о сценарии продажи билетов, я бы обязательно сказал клиенту, что запрос, который они дали, был запросом на бронирование. Чтобы запрос на резервирование был обработан как можно скорее, и что он получит окончательный ответ по почте позже. Принимая это во внимание, некоторые клиенты могут получить электронное письмо с отклонением их запроса.
Сейчас. Можем ли мы сделать это возвращение менее болезненным? Конечно. Вставив ключ в наш распределенный кеш с процентом или количеством товаров на складе и уменьшив этот счетчик, когда товар продается. Таким образом, мы могли бы предупредить пользователя до того, как будет отправлен запрос на резервирование, скажем, если останется только 10% от первоначального количества товаров, что клиент не сможет получить товар, о котором идет речь. Если счетчик равен нулю, мы просто отказались бы принимать больше запросов на бронирование.
Моя точка зрения:
1) сообщите пользователю, что это запрос, который он делает, и что он может быть отклонен 2) сообщите пользователю, что шансы на успех для получения рассматриваемого элемента низки
Не совсем точный ответ на ваш вопрос, но именно так я бы справился с подобным сценарием при работе с CQRS.
Давайте посмотрим на бизнес-перспективу (я занимаюсь похожими вещами - заказываю встречи на свободных слотах) ...
Первое, что поражает меня в вашем анализе, это отсутствие понятия о резервируемом билете / месте / столе. Эти ресурсы бронируются.
В случае транзакции вы можете использовать некоторую форму уникальности, чтобы гарантировать, что двойное бронирование не произойдет для одного и того же билета / места / стола (дополнительная информация на http://seabites.wordpress.com/2010/11/11/ согласованные индексы-ограничения) . Этот сценарий требует синхронной (но все же одновременной) обработки команд.
В случае отсутствия транзакций вы можете задним числом отслеживать поток событий и компенсировать команду. Вы даже можете дать конечному пользователю возможность дождаться подтверждения бронирования, пока система не будет точно знать - т.е. после анализа потока событий - что команда выполнена и была или не была компенсирована (что сводится к тому, "было ли выполнено бронирование?"). да или нет?"). Другими словами, компенсация может быть частью цикла подтверждения.
Давайте сделаем шаг назад...
Когда речь идет о выставлении счетов (например, продажа билетов через Интернет), я думаю, что весь этот сценарий в любом случае превращается в сагу (резервный билет + билет билета) . Даже без выставления счетов у вас будет сага (резервный стол + подтверждение бронирования), чтобы сделать этот опыт заслуживающим доверия. Таким образом, даже если вы только увеличиваете только один аспект бронирования билета / стола / места (то есть он все еще доступен), "длительная" транзакция не завершена, пока я не заплатил за нее или пока не подтвердил ее, Компенсация произойдет в любом случае, снова высвободив билет, когда я прерву транзакцию по любой причине. Теперь интересным становится то, как бизнес хочет с этим справиться: возможно, какой-то другой клиент завершил бы транзакцию, если бы мы дали ему / ей тот же билет. В этом случае возврат может стать более интересным при двойном бронировании билета / места / стола - даже предлагая скидку на следующее / подобное мероприятие, чтобы компенсировать неудобства. Ответ лежит в бизнес-модели, а не в технической модели.
ETag обеспечивает оптимистичный параллелизм, который вы можете использовать вместо транзакционной блокировки, чтобы обновить документ и безопасно обрабатывать потенциальные условия гонки. См. Замечания здесь http://msdn.microsoft.com/en-us/library/dd179427.aspx для получения дополнительной информации.
История может выглядеть примерно так: пользователь A создает событие E с максимальным количеством билетов 2, eTag - 123. Из-за высокого спроса 3 пользователя пытаются приобрести билеты почти одновременно. Пользователь B создает запрос на резервирование B. Пользователь C создает запрос на резервирование C. Пользователь D создает запрос на резервирование D.
Система S принимает запрос на резервирование B, считывает событие с eTag 123 и изменяет событие, чтобы иметь 1 оставшийся тикет, S отправляет обновление, включающее в себя eTag 123, который соответствует исходному eTag, чтобы обновление прошло успешно. ETag теперь 456. Запрос на резервирование одобрен, и пользователь уведомлен, что был успешным.
Другая система S2 получает запрос на резервирование C в то же время, когда система S обрабатывала запрос B, поэтому она также считывает событие, событие с eTag 123 изменяет его на 1 оставшийся билет и пытается обновить документ. Однако на этот раз eTag 123 не совпадает, поэтому обновление завершается с ошибкой. Система S2 пытается повторить операцию, перечитывая документ, который теперь имеет eTag 456 и счетчик 1, поэтому он уменьшает это значение до 0 и повторно отправляет eTag 456.
К сожалению для пользователя C, система S начала обработку запроса пользователя D сразу после пользователя B, а также прочитала документ с помощью eTag 456, но, поскольку система S работает быстрее, чем система S2, она смогла обновить событие с помощью eTag 456 до системы S2, поэтому пользователь D также успешно зарезервировал свой билет. eTag сейчас 789
Таким образом, система S2 снова дает сбой, дает еще одну попытку, но на этот раз, когда она читает событие с помощью eTag 789, она видит, что билетов нет, и, таким образом, отклоняет запрос резервирования пользователя C.
Как вы уведомите пользователей, что их запросы на бронирование были успешными или нет, зависит от вас. Вы можете просто опрашивать сервер каждые несколько секунд и ждать обновления статуса бронирования.