Вообще я хотел сегодня писать про первый день “архитектурной недели” – треннинга с Udi Dahan, но ещё до этого я хотел написать свои мысли на тему CQRS, в продолжение дискуссии (см. далее).
Написать я хотел именно до того, как на треннинге начнутся какие-то мысли по этому поводу, просто для того, чтобы выразить свои мысли на текущий момент. Правда, после первого дня они уже малость задеты треннингом, хотя никаких CQ(R)S мы пока и близко не касались…
В выходные, хм, были выходные, поэтому я хотел написать этот постинг сегодня в поезде, но в поезде утром не было свободных мест, а вечером я ехал назад не один, поэтому теперь придётся писать два постинга: этот и про впечатления от “первого дня” :)
Итак, пишу.
Недавно в Баззе произошла дискуссия о CQRS, начавшаяся тем, что не все понимают, что это такое, а заглохнувшая на том, что CQS, вроде бы, штука бесспорная и сегрегировать таки нужно, а вот CQRS с его бесполезным messaging-подходом – это нечто бесполезное, связанное с бесполезным “доменным дизайном”.
Разночтения остались как раз в этом "message-подходе”, предписываемом, в частности, CQRS, так что я решил остановиться на нём подробнее.
Итак, мы имеем подходы “мы сегрегируем команду с запросом, но не будем делать классы команд (messages), а будем просто дёргать методы сервисов” и “подход, с классом-командой (message) и классами-обработчиками (handlers).
Что мы имеем в случае дёргания сервисных методов:
Интерфейсы и классы сервисов (IMyService / MyService), содержащие набор методов для дёргания.
Нарушение правила “Single Responsibility”, в том случае, когда методы MyService содержат реализацию бизнес-логики (так как класс становится ответственным за много-много-много неопределённого чего.
Либо, если логики там нет и если зона ответственности этого класса – делегировать исполнение конкретному исполнителю – то этакий монстрообразный роутер-делегатор, который попадает в категорию “зачем писать то, что можно НЕ писать”.
Проблемы с командной работой – те самые check-out/edit/merge в случае, когда несколько человек работают над разной функциональностью, но вынуждены править один файл (а то и два – сервис и его интерфейс)
Проблемы с непониманием того, что происходит и ложью себе и заказчику
Очень часто разработчики считают, что если они делают обработку так или иначе “асинхронно” – то это “чревато” работой с потенциально устаревшими данными (запросил здесь, а ответ получил там), боятся ужасных слов “eventually consistent” и так далее. Поэтому они начинают врать себе и заказчику, что если они делают запрос к данным синхронно, то они имеют самые свежие данные “realtime”.
Они синхронно вызвают какой-нибудь Web/WCF/Rest сервис, тот лезет в базу, достаёт данные, производит (с помощью ORM) маппинг на классы, из этого формирует какой-то ответ, сериализует его в SOAP/XML/Json, отправляет по сети обратно, там какой-нибудь прокси принимает этот ответ, десериализует в какой-то объект, передаёт его клиенту и… Кто уже дал гарантию, что с момента “тот лезет в базу” информация не успела устареть?
За исключением критических случаев, когда используется какая-нибудь жуткая распределённая транзакция или locks на предмет “пока я не разрешу информацию менять нельзя”, которых всё равно никто не делает – никто таких гарантий не давал.
Никто и не даст таких гарантий. Их нет.
А тогда зачем врать? А если незачем, смотрим дальше.
Проблемы с ошибками.
Что происходит, если удалённый сервис, метод которого мы дёргаем, недоступен?
Что происходит, если сервис доступен, сделали вызов, но тут произошёл таймаут? Или пропала связь? Произошло это на этапе запроса или на этапе ответа? Иными словами, успел удалённый сервер выполнить обработку или нет?!
Что происходит, когда сервер доступен и всё в порядке, но вот база данных ответила ошибкой (например, произошёл deadlock, но об этом позже)?
Итого, практически во всех случаях мы имеем потерю данных. Плюс наша система начинает прямо-таки требовать того, чтобы обе части распределённого приложения всё время были доступны online. И хорошо, если их ещё всего две.
Что мы делаем с ошибками?
Ага, пишем в лог и показываем клиенту. А что если клиента уже нет? Пропала сеть или что-то ещё? А кто вообще читает этот лог?
Проблемы с производительностью.
Здесь интересно :)
Каждый такой “синхронный” (а асинхронность, кстати, проблем не уменьшает, а может и усугубить, если идёт постоянный polling) вызов сервиса обходится серверу в один обрабатывающий поток. Мы ведь говорим о больших и серьёзных приложениях, поэтому разумно предположить, что клиентов, “бомбардирующих” сервис своими запросами, много. Что происходит, когда сервер не может больше создать потоков? Клиент получает исключение The request was refused by server. Что мы делаем с исключениями мы уже обсудили.
Поток и его создание – штука дорогая. Например, тоже в Баззе обсуждали, на поток отводится стек в размере 1 мегабайта (по умолчанию). 100 потоков – 100 мегабайт только стека. Плюс всё остальное. Кончится память => исключение => тот же “The request was refused by server” на стороне клиента.
Что происходит, когда, скажем, IIS обнаруживает, что application pool вдруг жрёт немерено памяти? Он делает автоматический recycle этому application pool. Все потоки прерываются и умирают, пул поднимается, и вроде всё хорошо должно быть… Но recycle – операция не мгновенная. Запросы множества клиентов, которые IIS заботливо накопил за время сброса пула, начинают обрабатываться пулом после сброса… Много, много запросов… Создаётся много, много потоков… Пул “вдруг” начинает жрать много-много памяти… IIS “просекает фишку” и сбрасывает пул снова…
Когда в базу данных приходит команда обновить какие-то данные, она накладывает т.н. row lock на запись (или записи) с которыми работает. Но что происходит при большом количестве запросов? Я не уверен, как другие, а MS SQL Server выполняет процедуру, называемую “эскалацией локов”. В первом приближении её можно описать так: операция lock достаточно трудоёмка, большое количество локов нежелательно, поэтому вместо блокирования конкретной записи сервер базы данных начинает блокировать страницы.
А здесь вы уже поняли: другие запросы, обновляющие другие данные, которые по “счастливой” случайности находятся на тех же страницах, вынуждены становится в очередь и ждать.
Что не только приводит к тому, что потоки “умирают” не так быстро, как хотелось бы, но и, как уже стало понятно, к риску возникновения dead lock’ов. Просто потому, что один запрос может заблокировать одну страницу, другой – другую, а потом начать ждать друг друга.
И ведь сервер базы данных честно нам скажет: виноват я, сервер базы данных, я убью одну транзакцию-то, а ты “please try again later”. Кто-нибудь “try again later” в таком подходе? Скорее всего – см. предыдущий пункт :)
Проблемы с памятью.
А уж если мы должны сделать что-то вроде “открыть транзакцию, сохранить в базу данных, получить Id, вызвать сервис (передать ему этот Id), если всё прошло успешно – подтвердить транзакцию”… Обычное дело для тех, кто “имел опыт работы с большими системами” на практике (никого конкретно не имею в виду).
Но на самом деле тут целый мешок проблем.
1) Я уже упоминал: вызвали сервис, получили ошибку по таймауту. Отменять транзакцию? А вдруг сервер таки обработал данные? Подтверждать? Это уже смешно! :)
2) Транзакция – штука такая. Чем длиннее она – тем нам не легче. И тем больше шанс возникновения дедлока, особенно в связи с page locks. А с вызовом удалённого сервиса и ожиданием некого магического “статуса" или “войда” она короче точно не станет.
3) Наконец, сами проблемы с памятью! Мы набрали кучку какой-то информации, ORM поработал, что-то сохранили, что-то ещё нет, вызвали сервис, ждём. А вот пока ждём, есть ну очень большая вероятность того, что время, когда наши объекты “переживут” и нулевое, и первое поколение сборщика мусора много раз пройдёт. Много – в мире сборщика мусора. И вся наша промежуточная байда, с которой мы имели дело в контексте метода (включая и прокси сервисов, и объекты ORM) попадут во второе поколение.
Вот мы и получили “утечки памяти второго рода” :) Второе поколение сборщик будет чистить только тогда, когда памяти действительно останется мало, операция это достаточно напряжённая, долгая, поток (а в случае клиентского GC, то и все потоки) нужно остановить, чтобы дефрагментировать кучу…
А тут ещё множество клиентов бомбардируют своими запросами, на которые надо выделять стеки, потоки, всё это опять жрёт память.. Круг замкнулся :)
Чем нам поможет message-oriented подход:
Тем, что мы, помимо классов команд вводим несколько новых сущностей (и выводим кое-какие имеющиеся).
Мы вводим понятие ServiceBus, или MessageBus.
В простом варианте – это очередь куда сыплются наши сообщения.
Этим мы явно даём понять, что команда обрабатывается не “сейчас-сейчас-прямосейчас”, а когда-то.
Этим мы явно даём понять, что никакого ответа или статуса мы в ответ не ожидаем.
Этим мы явно даём понять, что нас не интересует, доступна ли в данный момент та часть системы, которой предназначается сообщение и, более того, можем себе это позволить!
Отправка сообщения теперь сводится в передаче его в MessageBus, по сути – пиханию в локальную очередь. Операция сущкственно более быстрая, нежели “прямой” вызов сервиса, поэтому шанс попасть во второе поколение очень серьёзно меньше.
Время транзакции, описанной мною выше, существенно меньше, причём это “настоящая” транзакция: если что-то пошло не так, то мы отменяем как запись в базу данных, так и запихивание сообщения в очередь, то есть, операция изменения данных и операция отсылки сообщения становятся на самом деле атомарны.
Оправка сообщений из очереди клиента в очередь сервера не будет бомбардировать сервер немереным количеством запросов, количество можно контроллировать и обойти эти проблемы.
В случаях, скажем, MSMQ или SQL Server ServiceBroker эта задача уже решена за нас.
На стороне сервера мы тоже можем использовать столько потоков для обработки команд из очереди, сколько нам нужно. Мы можем создать потоки один раз и переиспользовать их, если заходим.
Так, как у нас нет теперь огромного количества одновременных запросов к базе данных, количество page locks по отношению к row locks можно свести к минимуму.
На сладкое мы имеем возможность в случае, если что-то произошло не так, попробовать автоматически повторить исполнение команды: раз, другой, третий, с разными промежутками времени, как нам диктует бизнес-логика.
Мы становимся более толерантны к некоторым вещам вроде ненадёжности сети и т.д.
Если посмотреть на практики Windows Azure, то они прямо рекомендуют в случае подобных ошибок просто повторять запрос ещё раз, так как сервис, к которому идёт обращение, мог как раз в эту секунду находиться в режиме переключения (одна нода заменялась другой) и т.д.
В качестве компота: команды, которые даже после повтора не были обработаны, можно сохранить в отдельной очереди, или где-то ещё, а потом, после устранения ошибки (пофиксили баг, восстановили отрубленный сетевой кабель, подняли сервер), “проиграть” заново.
Мы выкидываем монстрообразные IMyService/MyService.
Вместо этого у нас появляется два интерфейса, которые крайне вряд ли придётся менять, скажем IMessage и IMessageHandler<T> where T: IMessage.
Разработчику теперь, чтобы добавить новую функциональность, обработать новое сообщение, нужно только создать класс сообщения и новый обработчик, в котором реализовать логику. Или исправить. Один выстрел – один труп, никаких merge, принцип single responsibility соблюдён. Наконец, зто просто гораздо легче сделать, нежели править интерфейс, править большой класс сервиса и т.д.
Мы получаем отличную возможность версионности – практически бесплатно!
Скажем, если мы решили сделать новую версию, что-то добавить в MyMessage. Так мы просто наследуем MyMessage2 : MyMessage и делаем обработчик MyMessage2. Теперь “старые” и “новые” клиенты будут работать одинаково.
Пойдите-ка, добавьте параметров в напрямую дёргаемые методы, поподдерживайте разные версии клиентов настолько легко и непринуждённо :)
Ну и всякие остальные плюшки в виде централизованного логгинга, авторизации и даже бизнес-приоритетов отдельных задач: например, задача “сделать заказ” может быть гораздо более приоритетна, нежели задача “посмотреть мой собственный профиль” и ей можно отвести больше ресурсов.