Как стать автором
Обновить

Мой путь к быстрой и понятной архитектуре, или зачем я выбросил агрегаты из DDD?

Уровень сложностиСредний
Время на прочтение19 мин
Количество просмотров12K
Всего голосов 12: ↑12 и ↓0+13
Комментарии33

Комментарии 33

Интересно. Но есть вопрос - в статье упомянут остаток на складе. Как можно было бы решить следующую задачу в рамках представленного подхода:

Есть остатки на складах, есть входящий поток заказов в несколько параллельных потоков на n физических серверах, БД одна. Для каждого резервируется товар на складе. Получается такой конкурентный доступ к остаткам, и очередному заказу может не хватить.

В рамках анемичной модели с подгрузкой состояния склада в память сервера и блокировками на уровне приложения - более менее стабильно это работало только на одном сервере. На двух и более серверах остатки и резервы в конце не совпадали.

Внедрение распределенной блокировки особо не помогло. Заметное увеличение времени обработки и хоть какая-то стабильность только в рамках периода блокировки. Малейшая случайная задержка в любом месте по любой причине на большой массе заказов - выдавало расхождение остатков и резервов.

И только перенос бизнес логики в хранимую процедуру с использованием специфики БД + короткая транзакция в рамках хранимки + без перегона массива данных между сервером БД и сервером приложений по сети - дало стабильный результат - быстро и без расхождений остатков.

Но бизнес логика в хранимке - ну никак не натягивается на глобус DDD. Может вы уже решали подобную задачу в рамках DDD и есть чем поделиться?

Я для себя решил, что если использование чего-то в БД - это важная часть доменной логики, то пусть будет в домене. Не вижу смысла абстрагирования от важной детали реализации. У нас код может буквально построен вокруг этой детали/гарантии/процедуры.

Да, просто отдельный метод. Может быть какая-то бд специфичная терминология. Если нам нужно управлять транзакцией, то в домене будет что-то для этого.

К сожалению я и правда не подскажу, у меня нет настоящего опыта работы с распределенными системами, но судя по всему какое то звено должно быть ответственно за консистентность

Единственный верный вариант решать этот вопрос в СУБД. Она для этого и была придумана. Там из коробки ACID реализован, а вы спрашиваете как его в DDD реализовать. А зачем?

Да я все пытаюсь понять как подружить вместе чистую архитектуру, DDD и перфоманс 😅 Пока что это взаимоисключающие параграфы.

есть входящий поток заказов в несколько параллельных потоков на n физических серверах, БД одна. Для каждого резервируется товар на складе.
Внедрение распределенной блокировки особо не помогло.

Это стандартный случай для распределенной блокировки. Если не работало, значит вы что-то сделали неправильно. Правильный порядок такой.

- Получаем мьютекс для id товара. Можно функцией GET_LOCK в MySQL или ее аналогом для другой БД, или сторонними средствами типа Redis. Мьютекс надо делать с таймаутом, чтобы не было бесконечных зависаний: GET_LOCK('Domain.Entity.Product:123', 10). Конечно все сервера должны пользоваться одной системой мьютексов.
- Получение мьютекса должно быть ДО загрузки и проверки данных в приложении. Загружать и проверять до получения мьютекса не имеет смысла. Можно добавить флаг needLock в метод репозитория, который загружает запись с остатками по id товара. Если запись приходит в сервис из вызывающего кода, и вы знаете, что она загружена без блокировки, в сервисе надо ее загрузить еще раз после блокировки.
- Потом загружаем запись с остатками товара, запускаем проверки и нужную логику, сохраняем новые остатки.
- Освобождаем мьютекс.

Такие блокировки лучше делать для любых действий на изменение данных. В идентификаторе мьютекса должен быть id изменяемого ресурса (обычно id сущности, который приходит в запросе), но не действие, тогда все действия с этим ресурсом будут выполняться последовательно.

Освобождение мьютекса надо делать только после сохранения. Внутри сущности, как рекомендует DDD, этот инвариант гарантировать нельзя, так как она сама себя не сохраняет. Можно только притвориться, что у нас все методы сущностей магически сами по себе выполняются последовательно, и написать в сущности только саму проверку.

Возможно у вас работало неправильно, потому что данные загружались в приложение до получения блокировки. Это легко не заметить.

Мечтаю посмотреть, как это будет работать в ситуации «акция, 10К пользователей одновременно пришли купить этот товар». С учетом того, что GET_LOCK не освобождает соединение, кажется (лень проверять, даже если освобождает — локи работают только в ненагруженной среде).

Правильно сделать так: запускать свой процесс/гринтред (в джаве так умеет Akka) на каждый товар, запрошенный пользователем. Получать данные из этого процесса синхронно, а потом асинхронно (можно батчами) обновлять базу. После пяти минут бездействия процесс можно потушить (если товаров меньше 300К — то и не нужно).

Горизонтально такое решение масштабируется простым добавлением сервера в кластер, нагрузка на базу по сути в режиме write-only, никаких локов, никаких «закончились коннекшены», никаких гонок и дедлоков.

Что, кстати, вы будете делать, когда GET_LOCK отвалится по таймауту? Покажете пользователю отлуп? Попробуете снова?

запускать свой процесс/гринтред на каждый товар, запрошенный пользователем

В базе у нас запись "Товар 123: остаток 4". На 2 разных сервера приходит 2 параллельных веб-запроса "зарезервировать 3 единицы для товара 123".
У вас будет 2 гринтреда? Тогда без локов работать не будет.
У вас будет 1 гринтред? Он создается при запросе? Тогда нужны локи при созданиии гринтреда.
Он создается заранее для каждого товара из базы? Тогда локи нужны для роутинга 2 веб-запросов с 2 разных серверов на 1 гринтред.
Если у вас 2 сервера работают одновременно с одним ресурсом, никак без локов обойтись нельзя. Возможно их делает ваш фреймворк или система очередей, но они все равно есть.

Получать данные из этого процесса синхронно, а потом асинхронно (можно батчами) обновлять базу.

Сервер с гринтредом отключился, батч не сохранился, и остатки разошлись. Сами поедете на склад пересчитывать? Потом приходят еще 10K пользователей, заказывают товар, а на складе его нету. Сами будете компенсации выплачивать?
Для правильного учета данные надо сохранять после каждой бизнес-операции. Может быть в вашей задаче правильный учет не нужен, но обычно он нужен.

как это будет работать в ситуации
10К пользователей одновременно пришли купить этот товар

Нормально будет работать. Сервер обрабатывает запросы параллельно сколько хватает ресурсов, остальные ждут.

Что, кстати, вы будете делать, когда GET_LOCK отвалится по таймауту? Покажете пользователю отлуп? Попробуете снова?

.Да, обычно достаточно показать пользователю ошибку "Не удалось зарезервировать товар, попробуйте еще раз". Можно попробовать снова в приложении, но если не получилось, все равно придется показать ошибку.
Это ничем не отличается от гринтредов. Что вы будете делать, если ресурсов сервера не хватает на всех пользователей?

Поскольку вы желаете мыслить только в парадигме джавы, придется добавить дополнительное звено: кафку (или любой другой брокер сообщений) между пользователем и процессором заказов. Могу сразу подсказать следующий аргумент: «ack» в брокер — это, по сути, лок (но нет, потому что он не может ничего отыквить).

батч не сохранился

Я не сказал: «обязательно батч». Если вы не умеете работать с персистентными батчами — фигарьте каждый апдейт в базу, я не возражаю.

обычно достаточно показать пользователю ошибку «Не удалось зарезервировать товар, попробуйте еще раз»

Может быть в сельском магазине это и так. Меня бы уволили за такое решение вчерашним днем без выходного пособия.

Поскольку вы желаете мыслить только в парадигме джавы

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

Возможно их делает ваш фреймворк или система очередей
Могу сразу подсказать следующий аргумент: «ack» в брокер — это, по сути, лок

Он не следующий, а предыдущий, я его уже написал. Но технически это не совсем так. Локи срабатывают до начала записи, а любые ack-и происходят после.

Если вы не умеете работать с персистентными батчами

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

Также непонятно, как ваш гринтред будет его подхватывать его после падения (или деплоя новой версии) и перезапуска на другом физическом сервере, где этого файла нет, и считать актуальный остаток, чтобы положить его в переменную. Вручную код писать? В базе обработка сбоев уже предусмотрена.

Может быть в сельском магазине это и так.

Ну я вам и сказал, что есть разные задачи, и для каждой правильное решение будет свое.
У вас допустимо заставлять пользователя ждать 10 минут пока очередь разгребется, а в обычном интернет-магазине он за это время закроет веб-страницу, купит товар в другом магазине и напишет плохой отзыв в интернете.

какими-то непонятными файлами

Классика жанра: вы выдумали безумный аргумент и яростно с ним дискутируете. Я что-то говорил про файлы?

непонятно, как ваш гринтред будет его подхватывать его после падения (или деплоя новой версии) и перезапуска на другом физическом сервере

Ну так спросите, если непонятно, обогатите свою эрудицию, вдруг пригодится, когда вы упретесь в лимиты базы.

У вас допустимо заставлять пользователя ждать 10 минут пока очередь разгребется […]

Есессно, ведь кафка медленнее базы в сотни раз. Ладно, мне всё ясно, больше не надоедаю.

Классика жанра: вы выдумали

Вы не говорите подробностей (умалчиваете), ограничиваясь намеками, поэтому я предполагаю наиболее вероятный вариант по умолчанию.

Я что-то говорил про файлы?

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

Ну так спросите, если непонятно

У меня там уже есть вопросительное слово "как". Я предположил, что если вы захотите сообщить подробности, то этого будет достаточно.
Ок, спрошу явно: Как ваш гринтред будет подхватывать данные из персистентного хранилища (которое не база) после перезапуска на другом сервере в случае падения или деплоя новой версии кода, и получать по нему и данным из базы актуальное значения остатка для записи в переменую?

Есессно, ведь кафка медленнее базы в сотни раз.

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

Слишком длинная статья для хабра.

Много специфичной терминологии вызывает когнитивную нагрузку, особенно в выходной.

А можно в двух словах?

Проблема (ы), известные решения, ваше предложение. Как-то в такой структуре.

А как в дух словах описать сложную систему? Типа, был процедурный подход, спаггети-код... Сделали через ООП в парадигме DDD, но проблема с уровнем абстракции.

Но вот чего не понимаю, почему мы все стараемся так сильно абстрагироваться от БД?

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

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

В общем, просто из-за того, что это внешние системы, на которые мы либо не можем повлиять вообще (например, внешние API), либо можем повлиять, но обязательно с учетом определенных особенностей самой внешней системы (БД, например). Также у внешних систем есть свойство время от времени меняться (хоть и нечасто либо вообще никогда), а в таком случае скрытие деталей за абстракциями как раз очень поможет.

Смысл статьи в том, что чем дальше ваша система находится от однопоточного Pet-проекта, тем больше вам придется мириться с тем, что невозможно выполнить все теоретические выкладки из DDD. Добавляя многопоточность, большой RPS, ограничения на консистентность, вы будете все дальше и дальше от DDD и все ближе к процедурному программированию и хранимкам в БД. А вот как подружить все плюсы разных подходов, то это скорее всего разная история для отдельно взятых проектов и волшебной таблетки тут нет =(

Смысл статьи в том, чтобы показать один из возможных подходов к проектированию системы, который может обеспечить достаточно высокую производительность, сохранить инварианты, обеспечив при этом чистоту и полноту модели предметной области. В реальном проекте мы наоборот уходим от хранимок и логики на стороне БД. Если хотите, можем попробовать разобрать какой то пример.

Анемичной модели предметной области не существует по определению. Предметная область описывается объектами. Объект, по определению из ООД, определяется своим поведением и скрывает состояние. То, что не содержит поведения, а только структуры (чем и оперирует «анемичная модель») - это структура данных. Таким образом «анемичная модель» - это парадигма процедурного программирования, а DDD базируется на объектно-ориентированном дизайне, соответственно, «анемичная модель» в контексте к DDD не может быть определена.

В последующих изданиях он поменял местами тактические и стратегические шаблоны, так как считал, что роль последних значительно выше. 

Эванс только одну книгу написал. Про то что Эванс признал важность стратегического проектирования уже писал Вернон в своей «красной книге». Там как раз со стратегического проектирования и начиналось все.

Как по мне - анемичность - это какой-то миф. Сделали модели Клиентов, Заказов, Товаров... Добавили луковую архитектуру (сделали сервисный слой). Мы слишком заигрались в абстракции.

И зачем мы постоянно пытаемся как можно сильнее абстрагироваться от БД? Она же наш друг, хранилище данных, консистентность, атомарость, транзакции и общая благодать. Так то на заре интернета именно база данных была одним из валидаторов данных (ну, когда на перле\пхп писали без всяких типов).

Основная проблема с БД - её сложно использовать как сервис приложений. Мы так и не придумали её как горизонтально масштабировать и как нормально вести массовую разработку на хранимках.

Но это может работать! Я работал с системой, у которой апи фактически находилось в БД, а веб-сервер в основном ходил за данными и почти ничего не делал.

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

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

Как именно ООП решает эту проблему, можно узнать? Как вообще в голове у людей укладывается концепция «foo.bar = 42 — плохо, а foo.setBar(42) — хорошо»? Вы видите какую-то разницу? — А её нет.

Эта проблема решена в Аланокаевском ООП, но даже в Смолтолке — нет. Из существующих языков — она решена только в эрланге и эликсире.

В ООП не будет примитивных set/get, будет конструктор и действия. Как только появляется явное изменение параметров - это уже не объект, но структура, и да, иногда их смешивают, что усложняет развитие «объекта+структуры данных».

Самый простой пример при котором разница видна, это внутреннее поведение объекта при изменении его данных.

class Foo{
  private is_changed: boolean = false
  private bar: number = 0

  public setBar(value: number): this {
    this.bar = value
    this.is_changed = true
    return this
  }
}

P.S. код имеет очень банальный пример для демонстрации разницы по отношению к foo.bar = 42

Связка сущность-событие это полное повторение связки агрегат-"событие домена" из методологии ддд. Отказ от агрегатов в пользу сущностей может быть ситуативным - если это выгодно для конкретного проекта. В общем случае агрегат всё-таки более предпочтителен, чем сущность.

Агрегат - это и есть сущность, но с инвариантом над некоторым кластером сущностей, входящих в него и не имеющим контекстуального смысла за его пределами.

Если пойти от обратного, то сущность - это агрегат, в котором определен инвариант только над ним самим и который не включает в себя других сущностей.

К сожалению, мало что из статьи понял. Но вот, что имею сказать. Если достать из БД два поля в память. Назвать это агрегатом, сущность или ещё чем. А потом пытаться работать с этим из двух потоков, то нужна система блокировок, чтобы оба поля всегда были согласованы между собой. И при этом надо помнить, что оба поля уже не действительны, т.к. в БД все могло поменяться давно.

Да конечно, необходимо использовать блокировки, можно довольно удобно добиться требуемого поведения через уровни изоляции транзакций. Но это справедливо что для богатой, что для анемичной модели.

Мне кажется, изначальный посыл ложен. Вернее, неполон.

Большое количество проверок, охватывающих несколько агрегатов, в сложной системе быть, конечно, может.

Но тут есть важный нюанс - такие проверки концептуально не могут предполагать сильную мгновенную согласованность (strong immediate consistency) данных между агрегатами.

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

Но когда приходится переступить через порог под названием "распределенная система хранения", и strong immediate consistency становится невозможна в принципе, и появляется необходимость применять сложные механизмы поддержания согласованности, вот тут то и возникают аргументы в пользу разделения системы на агрегаты, поверх которых уже и строятся двухфазные коммиты, саги и прочее счастье распределенной разработки.

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

Скрытый текст

Пример реального сценария в нашем проекте: мы должны сохранить (создать/отредактировать) карту раскроя (раскладка деталей на листе металла). Карты раскроя входят в программу раскроя, программа раскроя просто объединяет несколько карт раскроя c одинаковым материалом и организацией. Так вот, при создании/редактировании карты раскроя необходимо проверить:

  1. Проект (а значит и его детали) относятся к той же организации что и программа

  2. Что проект находится в работе

  3. Детали либо не находятся в других картах, либо объем необходимый для их добавления доступен.

  4. Что добавляемые детали имеют тот же материал

  5. Что детали не являются давальческими (т.е. не передаются в субподряд)

  6. Что если деталь является стыковой деталью (условно половинкой целой детали), то ее второй стык уже добавлен в любую из карт этой программы, либо пока что еще вообще не добавлен ни в какую другую карту.

  7. Что детали проходят определенный производственный цикл

  8. Что карта раскроя не была передана в производство.

Если что то не так, то создать/изменить мы ничего не можем. И таких сценариев сотни.

Я не очень силен в распределенных системах, но предложил бы разобрать на простом примере. Есть 2 сущности проект и заказ, заказ может создаваться в рамках проекта (может и без него) но если в рамках проекта, то проект должен быть в работе. Пусть это будут 2 микросервиса проекты и заказы. Сценарий создать заказ по проекту. Какие тут возможны решения:

  1. (плохой) Обратиться напрямую в БД проектов (только на чтение) из микросервиса заказов. Схема взаимодействия довольно простая, плохая масштабируемость и производительность из за пессимистичных блокировок и удерживания транзакции, нарушение инкапсуляции. Но архитектура может быть такой же как и предложенная в статье.

  2. Один из стандартных вариантов, обратиться к сервису проектов через REST/gRPC API. Честно говоря, кроме разве что самих контрактов, которые предоставляет API на данные которыми обмениваются сервисы, для того чтобы изменения БД/сущностей не касались другого сервиса, ничем от первого варианта не отличается. Те же блокировки, только теперь репозиторий обращается не к БД а API другого сервиса.

  3. Event-Driven, тут если проект изменил свой статус, то он публикует событие, и сервис заказов сохраняет у себя в БД свою локальную копию проекта (упрощенную в рамках контекста заказов). Тут уже у меня не совсем хватает опыта, для того чтобы ответить как обеспечить транзакционность на публикацию этих событий, т.к. условно в данный миг времени сервис заказов может принимать какое то решение на основании текущего состояния. Но опять же архитектура такая же, т.к. это обычные локальные данные, с которыми работает микросервис. Т.е. со своей БД через свои репозитории.

  4. Сервис заказов создает заказ, условно наплевав на все проверки и публикует событие что заказ создан, тогда проект подписан на это событие и проверяет уже свое состояние и пишет в шину, например что произошла ошибка. И тут могут быть два подхода ожидать подтверждений от всех сервисов и потом сохранять в базу или сохранять в базу и потом использовать компенсирующие действия. Но разве это влияет на архитектуру домена? Все те же сущности, все те же (ну почти) события, нет делегатов да, но это и так понятно, да разница в инфраструктуре, в приложении, в каких то дополнительных слоях. Опять же в рамках сервиса чаще всего не одна сущность, взаимодействие между ними такое же.

На самом деле я буду рад конструктивной критике и объяснению в каких аспектах я ошибся в этом ответе

В моём мире так:

  • если у вас может гонка между операциями, то используем блокировку ресурсов в каком-то виде. и м.б. какой-то оркестратор, чтобы не мучиться с обработкой падений.

  • Если гонки нет, то читаем состояние и говорим, что на момент операции всё было как надо, а потом оно могло измениться.

  • Ещё можно взять оркестратор и написать сагу. Т.е. с оптимизмом всё делаем, а если проблема - откатываем.

Сам я пишу бэкенды и не верю в тактический ДДД. Особенно когда есть микросервисы.


Ну типа, у нас есть модуль в виде класса, чтобы потребители не переломали его инварианты. А у меня есть модуль в виде микросервиса, чтобы потребители не переломали его инварианты. Внутри он может быть написан как угодно, если мы можем разобраться с его внутренностями. Ровно как и с внутренностями агрегатов.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации