|
Персональные инструменты |
|||
|
Проблемы пакетной обработки запросов и их решения (часть 2)Материал из CustisWikiМаксим Зинченко, наш разработчик-эксперт, опубликовал на портале «Хабр» вторую часть статьи о пакетной обработке запросов. Как повысить скорость пакетной обработки межсервисных запросов путем увеличения пакетов? Чем могут помочь практики DDD и знание бизнес-особенностей? Всегда ли при внедрении пакетной обработки необходимо менять структуру приложения? Об этом — в материале «Проблемы пакетной обработки запросов и их решения (часть 2)». Это продолжение статьи «Проблемы пакетной обработки запросов и их решения». Рекомендуется сначала ознакомиться с первой частью, так как в ней подробно описана суть задачи и некоторые подходы к ее решению. Здесь же мы рассмотрим другие методы. Содержание[убрать]Краткое повторение задачиЕсть чат для согласования документа с предопределенным набором участников. Сообщения содержат текст и файлы. И, как в обычных чатах, сообщения могут быть ответами (reply) и пересылками (forward). Модель сообщения чата:
Через Dependency Injection нам доступны реализации следующих внешних сервисов:
Нам нужно реализовать REST-контроллер:
Где:
В предыдущей части мы рассмотрели наивную реализацию сервиса, использующего пакетную обработку, и несколько способов ее ускорить. Эти способы очень просты, но их применение не обеспечивает достаточно хорошую производительность. Увеличение пакетовГлавной проблемой наивных решений стал маленький размер пакетов. Для того чтобы вызовы группировать в пакеты большего размера, нужно как-то накапливать запросы. Вот эта строка не подразумевает накопления запросов:
Сейчас у нас в рантайме нет специального места для хранения перечня пользователей — он формируется постепенно. Это придется менять. Для начала нужно отделить логику получения данных от маппинга в методе ChatMessage.toFrontModel:
Получается, что эта функция зависит только от трех внешних функций (а не от целых классов, как было вначале). После такой переделки тело функции не стало менее понятным, а контракт стал жестче (в этом есть как плюсы, так и минусы). В действительности можно и не делать такое сужение контракта и оставить зависимости от интерфейсов. Главное, чтобы в них точно не было ничего лишнего, так как нам понадобится делать альтернативные реализации. Поскольку функция serializeMessage похожа на рекурсивную, на первом шаге рефакторинга это и можно сделать в виде явной рекурсии:
Я сделал заглушку для метода toFrontModel, которая пока работает ровно так же, как в нашей первой наивной реализации (реализация всех трех внешних функций осталась прежней).
Но нам нужно сделать так, чтобы функции getUser, getFile и serializeMessage работали эффективно, то есть слали запросы соответствующим сервисам пакетами нужных размеров (для каждого сервиса теоретически этот размер может быть разным) или вообще по одному запросу в каждый сервис, если разрешены безлимитные запросы. Проще всего добиться такой группировки, если до начала обработки у нас будут на руках все нужные запросы. Для этого нужно перед вызовом toFrontModel собрать все необходимые ссылки, сделать пакетную обработку и потом уже использовать результат. Еще можно попробовать схемы с накоплением запросов и постепенным их исполнением. Однако такие схемы потребуют асинхронного выполнения, а мы пока остановимся на синхронных. Итак, чтобы начать использовать пакетную обработку, так или иначе придется узнать заранее как можно больше запросов (желательно все), которые нам придется сделать. Если речь про REST-контроллер, было бы прекрасно объединить запросы к каждому сервису по всей сессии. Группировка всех вызовов В некоторых ситуациях все данные, которые необходимы в рамках сессии, могут быть получены сразу и не вызовут проблем с ресурсами ни у инициатора запроса, ни у исполнителя. В таком случае мы можем не ограничивать размер пакета для вызова сервиса и получать сразу вообще все данные. Еще одно допущение, которое сильно облегчает жизнь, — считать, что у инициатора хватит ресурсов на обработку всех данных. Запросы к внешним сервисам можно слать и ограниченными пакетами, если они этого требуют. Упрощение логики в этом случае касается того, как будут сопоставляться места, где данные нужны, с результатами вызовов. Если считать, что ресурсы инициатора сильно ограничены, и при этом пытаться минимизировать количество внешних вызовов, получится довольно сложная задача на оптимальное разрезание графа. Скорее всего, придется просто пожертвовать производительностью ради уменьшения потребления ресурсов. Будем считать, что конкретно в нашем демо-проекте инициатор не особо ограничен в ресурсах, может получать все необходимые данные и хранить их до окончания сессии. Если будут проблемы с ресурсами, мы просто сделаем пагинацию поменьше. Поскольку в моей практике именно такой подход наиболее востребован, дальнейшие примеры будут касаться этого варианта. Можно выделить такие способы получения больших наборов запросов:
Пройдемся по всем вариантам на примере нашего проекта. Реверс-инжинирингСбор всех запросовПоскольку у нас есть код реализации всех функций, участвующих в сборе информации и ее преобразовании для фронтенда, можно сделать реверс-инжиниринг и из этого кода понять, какие будут запросы:
Все запросы собраны, теперь нужно сделать собственно пакетную обработку. Для allUserReq и allFileReq делаем внешние запросы и группируем их по id. Если ограничений на размер пакета нет, то это будет выглядеть примерно так:
Если ограничение есть, то код примет такой вид:
К сожалению, в отличие от Stream, в Sequence нельзя легко перейти на параллельный запрос пакетов. Если вы считаете параллельный запрос допустимым и нужным, можно сделать, например, так:
Видно, что особо ничего не изменилось. В этом нам помогло использование некоторого количества Kotlin-магии:
Теперь осталось собрать все вместе:
Пояснения и упрощения Первое, что наверняка бросится в глаза, — это функция memoize. Дело в том, что функция serializeMessage почти наверняка будет вызываться для одних и тех же сообщений несколько раз (из-за reply и forward). Непонятно, зачем нам делать toFrontModel отдельно для каждого такого сообщения (в некоторых случаях это может быть нужно, но не в нашем). Поэтому можно сделать мемоизацию для функции serializeMessage. Реализуется это, например, так:
Далее нам нужно сконструировать мемоизированную функцию serializeMessage, но при этом у нее внутри будет использоваться она же. Здесь важно использовать внутри именно тот же экземпляр функции, иначе вся мемоизация пойдет псу под хвост. Для разрешения этой коллизии используем класс ValueHolder, который просто хранит ссылку на значение (можно взять вместо него что-то стандартное, например AtomicReference). Чтобы сократить запись для рекурсии, можно сделать так:
Если вы смогли понять этот стрелочный силлогизм с первого раза — поздравляю, вы функциональный программист :-) Теперь код будет выглядеть следующим образом:
Можно заметить еще orThrow, который определяется так:
Если во внешних сервисах отсутствуют данные по нашим id и это считается легальной ситуацией, нужно обрабатывать ее как-то по-другому. После этого исправления ожидается, что время выполнения getLast будет в районе 300 мс. Причем это время вырастет несильно, даже если запросы перестанут укладываться в ограничения на размер пакета (так как пакеты запрашиваются параллельно). Напомню, что наша цель-минимум — 500 мс, а нормальной работой можно будет считать 250 мс. ПараллельностьНо нужно двигаться дальше. Обращения к userRepository и fileRepository являются полностью независимыми, и их можно легко распараллелить, в теории приблизившись к 200 мс. Например, через нашу функцию join:
Как показывает практика, на исполнение затрачивается в районе 200 мс, и очень важно, что с увеличением количества сообщений время особо не растет. ПроблемыВ целом код стал, конечно, менее читабельным, чем наша наивная первая версия, но хорошо, что сама сериализация (реализация toFrontModel) почти не изменилась и осталась вполне читабельной. Вся логика хитрой работы с внешними сервисами живет в одном месте. Минус этого подхода в том, что наша абстракция протекает. Если нам понадобится внести изменения в toFrontModel, почти наверняка придется вносить изменения и в функцию getLast, что нарушает принцип подстановки Барбары Лисков (Liskov Substitution Principle). Например, мы договорились расшифровывать приложенные файлы только в основных сообщениях, но не в ответах и пересылках (reply/forward), или только в ответах и пересылках первого уровня. В таком случае после внесения изменений в код toFrontModel придется сделать соответствующие исправления и в коде сбора запросов для файлов. Причем исправление будет нетривиальным:
И здесь мы плавно подходим к еще одной проблеме, тесно связанной с предыдущей: правильность работы кода в целом зависит от грамотности проведенного реверс-инжиниринга. В некоторых сложных случаях код может сработать неправильно именно из-за некорректной работы сбора запросов. Нет никакой гарантии, что у вас получится быстро придумать юнит-тесты, которые покроют все такие хитрые ситуации. ВыводыПлюсы:
Минусы:
Важно отметить, что использовать этот метод можно и не делая реверс-инжиниринга как такового. Нам нужно получить контракт getLast, от которого зависит контракт предварительного расчета запросов (далее — prefetch). В данном случае мы сделали это, рассмотрев реализацию getLast (реверс-инжиниринг). Однако при таком подходе возникают сложности: правка этих двух кусков кода всегда должна быть синхронной, а обеспечить это никак невозможно (вспомните hashCode и equals, там ровно то же самое). Следующий подход, который я хотел бы показать, как раз призван решить эту проблему (или хотя бы смягчить). Бизнес-эвристикиРешение проблемы контрактаЧто если оперировать не точным контрактом и, следовательно, точным набором запросов, а примерным? Причем мы построим примерный набор так, что он будет строго включать точный и базироваться на особенностях предметной области. Таким образом, вместо зависимости контракта prefetch от getLast установим зависимость их обоих от какого-то общего контракта, который будет диктоваться пользователем. Основная сложность будет в том, чтобы как-то овеществить этот общий контракт в виде кода. Поиск полезных ограниченийДавайте попробуем сделать это на нашем примере. В нашем случае есть следующие бизнес-особенности:
Из первого ограничения следует, что не нужно бегать по сообщениям, смотреть, какие там есть пользователи, выбирать уникальных и по ним делать запрос. Можно просто сделать запрос по предопределенному списку. Если вы согласились с этим утверждением, значит, я вас поймал. На самом деле все не так просто. Список может быть предопределен, но в нем могут быть тысячи пользователей. Такие вещи необходимо уточнять. В нашем случае участников чата, как правило, будет два — три, редко больше. Так что вполне допустимо получать данные по ним всем. Далее, если список пользователей чата предопределен, но этой информации нет в сервисе пользователей (что очень вероятно), то толку от такой информации тоже не будет. Мы сделаем лишний запрос списка пользователей чата, а потом все равно придется делать запрос(-ы) к сервису пользователей. Допустим, что информация о связи пользователей и чата хранится в сервисе пользователей. В нашем случае это так, поскольку связь определяется правами пользователя. Тогда для пользователей получится такой prefetch-код: Здесь может показаться удивительным то, что мы не передаем никакого идентификатора чата. Я сделал это намеренно, чтобы не загромождать код примеров. Из второго ограничения, на первый взгляд, ничего не следует. Во всяком случае, у меня так и не получилось вывести из него что-то полезное. Третье ограничение мы уже использовали ранее. Оно может оказать существенное влияние на то, как мы будем хранить и получать цепочки сообщений. Не станем развивать эту тему, так как к REST-контроллеру и пакетной обработке это не имеет отношения. Что же делать с файлами? Очень хочется получить список всех файлов чата одним простым запросом. По условиям API нам нужны только заголовки файлов, без тел, так что это не выглядит ресурсоемкой и опасной задачей для вызывающей стороны. С другой стороны, нужно помнить, что мы получаем не все сообщения чата, а только последние N, и легко может оказаться, что они не содержат вообще никаких файлов. Здесь не может быть универсального ответа: все сильно зависит от бизнес-специфики и вариантов использования. При создании продуктового решения можно попасть впросак, если заложить эвристику под один вариант использования, а потом пользователи будут работать с функционалом другим способом. Для демонстраций и пресейлов это хороший вариант, но сейчас мы пытаемся написать честный продакшен-сервис. Так что, увы, делать для файлов бизнес-эвристику здесь можно будет только по итогам эксплуатации и сбора статистики (либо после экспертной оценки). Поскольку хочется все-таки как-то применить наш метод, допустим, что статистика показала следующее:
Отсюда следует, что для отображения почти всех сообщений понадобится получить заголовки каких-то файлов (потому что так устроен ChatMessageUI) и что общее количество файлов невелико. В таком случае разумным выглядит получение всех файлов чата одним запросом. Для этого в наш API для файлов придется добавить следующее:
Метод getHeadsByChat не выглядит надуманным и сделанным чисто из-за нашего желания оптимизировать производительность (хотя это тоже вполне себе обоснование). Довольно часто в чатах с файлами пользователи хотят увидеть все использованные файлы, причем в порядке их добавления (поэтому используем List). Реализация такой явной связи потребует хранения дополнительной информации в файловом сервисе или в нашем приложении. Все зависит от того, в чьей зоне ответственности, как мы считаем, должна храниться эта избыточная информация о связи файла с чатом. Избыточная она потому, что с файлом уже связано сообщение, а оно в свою очередь связано с чатом. Можно не использовать денормализацию, а извлекать эту информацию на лету из сообщений, то есть внутри SQL получать сразу все файлы по всему чату (это в нашем приложении) и запрашивать их все сразу у файлового сервиса. Такой вариант будет работать похуже, если сообщений в чате окажется много, но зато нам не понадобится денормализация. Я бы оба варианта скрыл за getHeadsByChat. Код получился такой:
Видно, что по сравнению с предыдущим вариантом изменилось очень мало и изменения коснулись только части с prefetch, что замечательно. Код prefetch стал намного короче и понятнее. Время исполнения не изменилось, что логично, так как количество запросов осталось прежним. Теоретически возможны случаи, когда масштабирование будет лучше, чем у честного реверс-инжиниринга (только за счет убирания звена сложного расчета). Однако в равной степени вероятны противоположные ситуации: эвристики гребут слишком много лишнего. Как показывает практика, если удается придумать адекватные эвристики, то особых перемен во времени исполнения быть не должно. Однако это еще не все. Мы не учли, что теперь получение детальных данных по пользователям и файлам не связано с получением сообщений и запросы можно запустить параллельно. Такой вариант дает стабильные 100 мс на запрос. Ошибки эвристикЧто если при использовании эвристик набор запросов окажется не больше, а чуть меньше, чем должен быть? Для большинства вариантов такие эвристики подойдут, но будут исключения, ради которых придется делать отдельный запрос. В моей практике такого рода решения оказывались неудачными, так как каждое исключение сильно сказывалось на производительности, и в конце концов какой-то пользователь делал запрос, полностью состоящий из исключений. Я бы сказал, что в таких ситуациях лучше использовать реверс-инжиниринг, даже если алгоритм сбора запросов получается жуткий и нечитаемый, но, конечно, все зависит от критичности сервиса. ВыводыПлюсы:
Минусы:
Из нашего примера можно сделать вывод, что применить данный подход очень сложно и овчинка не стоит выделки. На самом деле в реальных бизнес-проектах количество ограничений огромно и из этой кучи часто удается достать что-то полезное, что позволяет партиционировать данные или предсказывать статистику. Главный плюс этого подхода в том, что используемые ограничения трактуются бизнесом, поэтому они легко понимаются и валидируются. Обычно самой большой проблемой при попытке использовать этот подход оказывается разделение деятельности. Разработчик должен хорошо погрузиться в бизнес-логику и задавать уточняющие вопросы аналитику, что требует определенного уровня инициативности. Агрегаты в стиле DDDВ больших проектах часто можно увидеть использование практик DDD, поскольку они позволяют эффективно структурировать код. Не обязательно использовать в проекте все шаблоны DDD — иногда можно получить хорошую отдачу даже от внедрения одного. Рассмотрим такое понятие DDD, как агрегат. Агрегатом называют объединение логически связанных сущностей, работа с которыми осуществляется только через корень агрегата (обычно это сущность, которая является вершиной графа связности сущностей). С точки зрения получения данных главное в агрегате то, что вся логика работы со списками сущностей находится в одном месте — агрегате. Есть два подхода к тому, что следует передавать в агрегат при его конструировании:
Выбор подхода во многом зависит от того, насколько легко можно вынести prefetch за рамки агрегата. Если логика prefetch базируется на бизнес-эвристиках, то обычно ее несложно отделить от агрегата. Выносить за рамки агрегата логику, основанную на анализе его использования (реверс-инжиниринг), может оказаться опасным, так как мы разносим логически связанный код по разным классам. Логика укрупнения запросов внутри агрегатаПопробуем набросать агрегат, который бы соответствовал понятию «чат». Наши классы ChatMessage, UserReference, FileReference соответствуют модели хранения, поэтому их можно было бы переименовать с каким-то соответствующим префиксом, но у нас проект маленький, поэтому оставим как есть. Агрегат назовем Chat, а его составляющие — ChatPage и ChatPageMessage:
Пока что получается довольно много бессмысленного дублирования. Это связано с тем, что наша предметная модель похожа на модель хранения и они обе похожи на модель для фронтенда. Я использую классы FileHeadRemote и UserRemote напрямую, чтобы не писать лишнего кода, хотя обычно в домене стоит избегать прямого использования таких классов. Если использовать такой агрегат, наш REST-контроллер можно переписать так:
Этот вариант во многом напоминает нашу первую наивную реализацию, но при этом имеет важное преимущество: контроллер больше не занимается получением данных напрямую и не зависит от классов, связанных с хранением данных, а зависит только от агрегата, который задан через интерфейсы. Таким образом, и логики prefetch больше нет в контроллере. Контроллер занимается только преобразованием агрегата в модель фронтенда, что дает нам соблюдение принципа единственной ответственности (Single Responsibility Principle, SRP). К сожалению, для всех описанных в агрегате методов придется написать реализацию. Попробуем просто сохранить логику контроллера, реализованную при использовании бизнес-эвристик:
Здесь получилось, что в самой функции getLastPage живет стратегия получения данных, включая prefetch, а функция toDomainModel чисто техническая и отвечает за преобразование хранимых моделей в модель предметной области. Параллельные вызовы userRepository, fileRepository и messageRepository я переписал в более привычном для Kotlin виде. Надеюсь, что понятность кода из-за этого не пострадала. В целом такой метод уже вполне работоспособен, производительность при его применении будет такой же, как при простом использовании реверс-инжиниринга или бизнес-эвристик. Логика укрупнения запросов вне агрегатаВ процессе создания агрегата мы сразу же столкнемся с проблемой: для конструирования ChatPage размер страницы нужно будет задавать как константу при создании Chat, а не передавать его в getLast(), как обычно. Придется поменять сам интерфейс агрегата:
Поскольку у нас есть дочитка остальных сообщений и мы твердо хотим получать все данные за рамками агрегата, нам придется вообще отказаться от агрегата уровня Chat и сделать корнем ChatPage:
Далее необходимо создать код prefetch, отдельный от агрегата:
Теперь для того, чтобы создать агрегат, нужно его состыковать с prefetch. В DDD такого рода оркестрациями занимаются Application Services.
Ну а контроллер особо не изменится, нужно только вместо Chat::getLastPage использовать ChatService::getLastPage. То есть код изменится так:
Выводы
В следующей главе мы рассмотрим такой вариант реализации prefetch, который невозможно реализовать в отрыве от основной функции. Проксирование и двойной вызовРешение проблемы контрактаКак мы уже разобрались в предыдущих частях, основная проблема контракта prefetch в том, что он сильно связан с контрактом функции, для которой он должен подготовить данные. Если быть более точным, то он зависит от того, какие данные могут понадобиться основной функции. Что если мы не будем пытаться предсказывать, а попробуем сделать реверс-инжиниринг средствами самого кода? В простых ситуациях нам может помочь подход проксирования, широко используемый в тестировании. Такие библиотеки, как Mockito, генерируют классы с имплементаций интерфейсов, которые могут в том числе накапливать информацию о вызовах. Похожий подход используется в нашей библиотеке. Если вызвать основную функцию с проксированными репозиториями и собрать информацию о необходимых данных, то потом можно будет эти данные получить в виде пакета и повторно вызвать основную функцию для получения финального результата. Основное условие следующее: запрашиваемые данные не должны влиять на последующие запросы. Прокси будет возвращать не реальные данные, а только какие-то заглушки, поэтому все ветвления и получения связанных данных отпадают. В нашем случае это означает, что бесполезно проксировать messageRepository, поскольку по результатам запроса сообщений и делаются дальнейшие запросы. Это не проблема, так как к messageRepository у нас всего один запрос, так что никакой пакетной обработки здесь и не требуется. Поскольку проксировать мы будем простые функции UserReference->UserRemote и FileReference->FileHeadRemote, то накапливать нужно просто два списка аргументов. В итоге получаем следующее:
Если измерить производительность, получится, что при данном подходе она не хуже, чем при использовании методов реверс-инжиниринга, хотя мы вызываем функцию два раза. Это связано с тем, что по сравнению со временем выполнения внешних запросов, временем выполнения функции преобразования можно пренебречь (в нашем случае). Если сравнивать с производительностью при использовании бизнес-эвристик, то в нашем случае накопление запросов окажется менее эффективным. Но нужно учитывать, что не всегда удается найти такие хорошие эвристики. Например, если количество пользователей в чате будет большим, как и количество файлов, и при этом файлы будут прикрепляться к сообщениям редко, то наш алгоритм на бизнес-эвристиках сразу же начнет проигрывать честному получению списку запросов. ВыводыПлюсы:
Минусы:
Несмотря на кажущуюся экзотичность, накопление запросов через проксирование и повторный вызов вполне применимо в ситуациях, когда логика основной функции не завязана на получаемые данные. Основная сложность здесь такая же, как при реверс-инжиниринге: мы закладываемся на текущую реализацию функции, хотя и в гораздо меньшей степени (только на тот факт, что следующие запросы не зависят от результатов предыдущих запросов). Производительность упадет незначительно, зато в коде prefetch не нужно будет учитывать всех нюансов реализации основной функции. Можно использовать такой подход, когда не получается построить хороших бизнес-эвристик для предсказания запросов, а связность prefetch и функции хочется уменьшить. ЗаключениеИспользование пакетной обработки не так просто, как кажется на первый взгляд. Думаю, все шаблоны проектирования обладают этим свойством (вспомните кэширование). Для эффективной пакетной обработки запросов вызывающей стороне важно собрать вместе как можно больше запросов, что часто затрудняется структурой приложения. Выхода два: либо проектировать приложение в расчете на эффективную работу с данными (очень может быть, что это приведет к реактивному устройству приложения), либо, как это часто бывает, пытаться внедрить пакетную обработку в существующее приложение без значительной его перестройки. Самый очевидный способ собрать запросы в кучу — это реверс-инжиниринг существующего кода в поисках тяжелых запросов. Главным недостатком этого подхода будет увеличение неявной связности кода. Альтернатива — задействовать информацию о бизнес-особенностях для того, чтобы разделить данные на порции, которые часто используются совместно и целиком. Иногда для такого эффективного разделения придется денормализовать хранение, но зато, если такое получится осуществить, логика пакетной обработки будет определятся предметной областью, что хорошо. Менее очевидный способ получить все запросы — реализовать два прохода. На первом этапе собираем все необходимые запросы, на втором работаем с уже полученными данными. Применимость такого подхода ограничена требованием независимости запросов друг от друга. |
||