В прошлой работе автор представил описание архитектуры платформы приемной коммисии для обработки миллиарда абитуриентов за один день. В этой работе автор предлагает разработать сервисы, используя Spring + JDBCTemplate и Kafka микросервисы для предложенной архитектуры. В предыдущей часте читателю было предложено решить проблему шины данных, каким образом передавать данные между модулями записи и чтения. Существует простое решение, если учесть, что максимальный объем данных, передаваемых модулем записи, является константой, а так же тот факт, что при росте числа модулей в бесконечность количество обращений в один топик не будет больше чем константа определяемая числом записей принимаемых модулем записи помноженной на соотношение модулей записи к модулям чтения, то при правильной организации партиций и распределения топиков между ними можно добиться линейного масштабирования при сохранении доступности 24/7.  На рисунке 1 изображен вариант организации партиций и топиков для получения желаемой производительности. Очевидно, что при наличии N модулей, которые могут осуществлять отправку параллельно в L топиков, то количество подключений, которое необходимо обрабатывать шине данных будет равно произведению N на L. Чтобы упростить шину данных, запись осуществлять будем в одну партицию, так же это позволит максимизировать скорость отправки сообщений. Контроль целостности и гарантия доставки будут описаны позже. Итого, имея N*122 мб/с - объем общего потока данных, а так же необходимость обрабатывать N*L подключений в секунду, можно высчитывать нагрузку на шину данных формульно, так как подобные характеристики обычно являются известными величинами. Прочитав данную информацию у читателя возможно возникнет вопрос, тогда какое количество пользователей в день сможет обрабатывать такая архитектура, если масштабируется она линейно от количества пользователей, а на каждый узел такой системы максимальная нагрузка равна константе (чем больше число пользователей, тем меньше будет влияние случайной величины, и больше проявление усреднения). После длительных вычислений и бессонных ночей в поисках этого числа, автор пришел к выводу, что единственный правильный ответ будет перевод одного известного мема на русский язык: Если же ответить более развернуто, то количество пользователей и их запросов будет ограничено только техническими возможностями человечества осуществлять обработку соотвествующего числа запросов, а так же поддержания жизнедеятельности такого числа пользователей. Если допустить, что человечество способно производить бесконечное число серверов, сетей и передавать произвольное число данных, а так же содержать бесконечное число пользователей, то рано или поздно будет достигнут физический предел (согласно законам физики, а точнее радиусу Шварцшильда), после которого пространство-время схлопывается в сингулярность и информация теряется бесследно. Однако одну оценку автор дать может. Данной архитектуры должно хватить человечеству на ближайшие пару млн лет +- сотню тысячелетий. Разумеется, если для вас такой горизонт планирования слишком короткий и неприемлимый - читатель всегда может обратиться к настоящим архитекторам. Автор уверен, что они смогут предложить более долгосрочное решение. Чтобы не захламлять статью большими таблицами, список всех архитекторов, выполнивших домашнее задание, можете прочитать в спойлере ниже.  Все остальные получают банан! В статье была описана модель данных для одиночной БД, с которой будут работать все микросервисы. Однако такое решение невозможно масштабировать и не представляется возможным хранить нужные объемы данных. Сама БД потребует существенных мощностей для корректного функционирования.  Для распределенного решения, описываемого в данном цикле статей, требуется изменить модель данных. Начнем с модуля записи.  Для начала потребуется две таблицы, для хранения текущих приемных кампаний, а так же специальностей, на которые абитуриенты могут поступать: В первой таблице хранятся идентификаторы приемных кампаний, во второй идентификаторы специальностей. Обновляться эти таблицы должны по событию. Хотя для приемной кампании флаг активности не очень нужен, то для специальности - обязателен, так как иначе не будет возможности проверить на уровне БД, что пользователь может поступать на выбранную специальность, например посредством триггера. Этот флаг позволит хранить историю приемных компаний, не нарушая целостность БД. Таблица абитуриента хранит базовую информацию о нем, а так же является авторизующим доступ токеном (своего рода). Такая запись повляется после получения модулем записи события о том, что абитуриент принимает участие в приемной кампании и может подавать документы. Так как модули записи делят между собой пользователей, то данная таблица не будет расти бесконечно. При наличии записи в таблице enrollee, абитуриент может поступать на специальности, для этого заведем таблицу с дополнительными полями, которые и позволят нам гарантировать целостность. Можно было бы ударится в схемотоз и вычислять контрольные суммы или еще какой-то безумный способ жечь электроэнергию в стиле блокчейнов, однако автор выбрал самый простой вариант. После отправки сообщения об изменении (а пишется в шину данных и передается в модуль чтения именно записи из таблицы enrollee_select), поле state изменяется в значение 1, что означает, что запись отправлена, но подтверждение еще не получено. После прихода подтверждения, у которого поле ordinal равно такому же полю записи в таблице, то значение state изменяется на 2, что означает, что запись была отправлена и получено подтверждение о приеме модулем чтения последних изменений. При таком подходе можно всегда узнать текущее состояние целостности системы и статус. Если в течении определенного промежутка времени не пришло подтверждение, тогда поле state сбрасывается в значение 0, чтобы отправить его еще раз. Таким образом не требуется писать сообщение сразу во все партиции, достаточно одной. Чтобы сбросить статус отправки, достаточно выполнить следующий запрос: Для его выполнения можно нанять специально обученного человека, который раз в 15 минут будет выполнять этот запрос в БД. Главное, чтобы его звали Кронос или у него была крон-содержащая фамилия, иначе не заработает. Для корректной работы БД заведем несколько триггеров, которые будут проверять данные, прежде чем они попадут в таблицу. Задача триггера select_insert заключается в том, чтобы проверить, что абитуриент может выбрать специальность (в коде этого нет, потому что это домашняя работа читателю, присылайте пулл реквест на гитхаб). Второй задачей является изменение количества доступных подач документов на специальности. Разумеется это можно сделать через ограничение с подсчетом уже имеющегося числа поданных заявлений, но тогда никто не напишет в комментарии свое мнение по этому поводу, да и способ этот потребует большей нагрузки на СУБД. Теперь имея такой триггер, можно регистрировать пользователя в системе используя только один запрос, по результатам которого вся необходимая работа будет выполнена: Выполнение этого запроса вернет 1 или 0, что и требуется, так как пользователю желательно отправлять ответ в формате: зарегистрирован, уже зарегистирован или отклонено (по причине отсутствия авторизации например). Так же для корректной обработки подтверждения подачи документов и отказа от поступления нужен второй триггер, который обрабатывает обновление записей: Задачами данного триггера являются проверки, что статус записи только увеличивается, а так же если абитуриент отправит согласие на специальность, уже имея подтверждение на поступление на другую специальность, то последняя будет отменена автоматически. Разумеется можно сделать блокирование подтверждения, если уже имеется одно, но для отладки проще именно такой вариант, так как можно в таблице менять одно поле, а дальше триггер сам внесет все необходимые правки. Да и запрос на подтверждение или отмену выглядит совсем простым: Теперь, чтобы получить список записей, которые нужно отправить в шину данных, можно получить следующим запросом: Так как при изменении дата модификации только увеличивается, то первыми всегда будут отправляться самые старые записи. После отправки в шину данных, нужно установить состояние (state) в значение 1, а после получения подтверждения уже в 2. Для модуля чтения модель данных является куда как более простой, заточенной на множественное чтение, а не запись с необходимостью проверять все и вся. Так как данные приходят из доверенных источников, а правок со стороны любых пользователей не предполагается, то для модуля чтения нужна всего одна таблица, она очень похожа на ту же, что и в модуле записи, с небольшим различием в части обеспечения целостности: Обратите внимание, что порядок следования колонок в главном ключе изменился, так как теперь важна специальность, а не пользователь, а главный ключ является индексом - следовательно можно использовать его префикс для наших запросов. Поле состояния отправки теперь у нас имеет тип булев, потому что подтверждение отправляется только один раз для одного набора (spec_id,session_id,user_id,ordinal) и в дальнейшем уже не должно отправляться. При таком подходе запрос на добавление/обновление при получении из шины данных сообщения будет выглядеть следующим образом: Так как делать проверку, что запись в БД уже есть - затратно и крайне непродуктивно, то выгоднее использовать конструкцию UPSERT, которая сама добавит или обновит поле за один запрос - и нет никаких исключений из СУБД. Это работает намного быстрее, чем проверка, что запись существует в таблице, с последущим разделением на вставку или обновление. Следует обратить внимание, что алиас на таблицу необходим, так как иначе в WHERE использование полей дает ошибку на проблему с определением поля, автор другим способом это победить не смог. Как уже выше было сказано, разработка будет вестись с использованием Spring Boot. Никаких реактивных стразов или асинхронных труб в коде не будет, максимально простое и легкое для понимание решение. Использовать Hibernate для работы с БД нет смысла, так как модель данных очень простая, однако ударяться в jdbc и ручками контроллировать все вполоть до подключения к СУБД - тоже идея плохая. Именно поэтому JDBCTemplate+spring для данного проекта является наиболее оптимальным вариантом. Организация проекта будет разделена на несколько проектов: scp-lib - общий код, используемый всеми сервисами для упрощения взаимодействия; scp-write-service - сервис приема команд в систему пользователями (абитуриенты и представители вуз вносят правки в этот сервис); scp-write-sender-service - сервис отправки изменений в шину данных; scp-read-service - сервис чтения списка поступающих на специальность с необходимыми запросами, фильтрами и т.д.; scp-read-receiver-service - сервис приема из шины данных сообщений об изменении записи абитуриента для последующего учета в таблице поступающих на специальность, а так же отправку подтверждения об успешном сохранении данных в БД. Чтобы собирать проекты нужно выполнить mvn install в проекте scp-lib,а затем в проекте scp-write-sender-service. Проект разделен на 2 основные части - работа с rest и с бд. Последний нужен только ради конфигурации, которая будет создавать для сервиса схему в БД, чтобы все данные (включая liquibase), лежали в одном месте и их можно было снести одним скриптом, не трогая остальные сервисы. Основа взята со stackoverflow. Работа с рестом делится на несколько основных частей: Обертки для передаваемых данных и обобщения потока; Обработка ошибок; Простейшая авторизация на основе проверки роли в jwt; Стартовые конфигурации, такие как сериализация времени в json и т.д. Следует рассмотреть отдельно контроль ошибок. В Spring уже существует механизм конвертации ошибок в рест-объекты, однако указать как ControllerAdvice можно только один бин, наследовать или как-то расширять обработку довольно проблемно. Так же большой проблемой является тот факт, что почти невозможно сформировать библиотеку ошибок, чтобы стандартизовать их и все микросервисы (хотя бы платформы) работали в одном пространстве ошибок - так фронту не нужно будет гадать или что-то спрашивать, потому что общая таблица ошибок позволит ему лучше объяснять пользователю, что происходит. Конечно можно на любую ошибку выкидывать Unknown Error, а в браузере показывать Oops!, вот только вы правда желаете быть тем самым разработчиком на М или Ж о котором часто все говорят, слава о вас идет впереди вас и каждый жаждет поговорить с вами? Общая библиотека ошибок хороша и для тех поддержки, потому что большую часть проблем они смогут решить самостоятельно без привлечения кого-либо. Чтобы сделать расширяемую конвертацию ошибок в объекты с подробным описанием, предлагается следующий алгоритм действий: Берем класс исключения и получаем список обработчиков, зарегистрированных на данный класс; У каждого обработчика есть приоритет, по очереди вызывая каждый получаем результат или null, если есть результат, то ошибка сконвертирована и продолжать не требуется; Считаем классом исключения его суперкласс и повторяем пункт 1. Сделаем более простую библиотеку, в которой предполагается только 1 обработчик на каждый класс исключения. Тогда базовый интерфейс будет: А чтобы получить карту, где ключем является класс исключения, а значением - обработчик, окажется достаточно следующего кода (если считать, что обработчики напрямую имплементируют интерфейс ErrorConverter): Сохраняем карту в статическое поле (изменять все равно ничего не нужно), и простейшими функциями осуществляем конвертацию: Теперь даже сложные исключения можно легко преобразовывать в человекочитаемый вид: Оба проекта обеспечивают взаимодействие пользователя с базой данных. Каждый из них по сути своей является сервисом для одного контроллера, в первом для работы с записями абитуриента, второй - для работы со списком поступающих на специальность в режиме для чтения. В проекте scp-write-service достаточно всего одного контроллера, чтобы реализовать все необходимые ручки для фронта. Так как на собеседовании часто спрашивают про http методы, то вам все в одном месте - наслаждайтесь: Аннотация RequiresRoles - это кастомная аннотация для работы аспекта, который проверит в jwt токене наличие заданных ролей. Timed - позволяет регистрировать метрики выполнения ручки, чтобы потом можно было формировать статистику по работе сервиса. И не забываем про swagger - аннотация Schema позволяет добавить описание к генерируемому конфигу, который можно будет позднее получить по ендпоинту /documentation/v3/api-docs. Сервис EnrolleeService нужен только для того, чтобы начать транзакцию, получить текущую сессию, а после этого выполнить соответствующий запрос. Очевидно, что делать два запроса в БД не очень эффективно каждый раз. В качестве домашней работы попробуйте исправить эту часть так, чтобы можно выполнять только один запрос к БД в большинстве случаев. Самая сложная часть происходит в объекте доступа к данным (dao, repository). Чтобы не затачивать проект сразу же на PostgreSQL, репозиторий сделаем интерфейсом, а реализацию для Postgres имплементацией. На примере подачи документов на специальность рассмотрим работу репозитория: Данный метод должен возвращать ложь или истину, если абитуриент уже поступает или он зарегистрирован соответственно. Добиться этого можно добавив к запросу ON CONFLICT DO NOTHING, из-за чего выполнение запроса вернет 0, а проверка равенства единице выдаст ложь. Если не существует специальность или сессия, то произойдет ошибка. Отдельно следует остановиться на конфигурации сервиса: Для сбора метрик в дальнейшем, нам потребуется включить актуатор для сбора данных prometheus, это делается через секцию management. Все основные параметры желательно позволять изменять посредством переменных окружения. Названия для текущего сервиса должны начинаться на префикс SCP_WS_ (если только переменная не является глобальной для всех сервисов, тогда префикс SCP_), это позволит проще запускать на одной машине все сервисы для отладки, да и в дальнейшем упростит взаимодействие для людей, так как все повинуется одному шаблону. Такой подход намного лучше, чем использование напрямую переменных окружения с именем свойства спринга (напомню, что server.port можно задать переменной окружения SERVER_PORT). У префикса есть и другие плюсы, особенно если вы часто работаете в консоли. Так же имеет смысл в этом сервисе слушать события о создании новой специальности или сессии приемной коммисии, а так же регистрации абитуриента на участие в сессии. В рамках данной работы такой функционал реализовываться не будет, т.к. все исходные данные будут загружаться непосредственно в БД.  Для сервиса чтения так же достаточно только одного контроллера: Так как в таблице может быть довольно большое число записей (до 625 млн), это может потребовать существенных затрат, чтобы вычислить точное количество абитуриентов для заданной специальности. Поэтому получение количества сделано в виде отдельно ручки, результат которой желательно кешировать, чтобы не обращаться в БД ради одних и тех же данных постоянно. Фильтрация списка поступающих, например чтобы исключить всех, кто отказался от поступления, может быть сделана посредством объекта Pagination, в рамках цикла статей эта возможность не будет реализовываться. Оба проекта очень похожи и выполняют схожие функции, первый пишет в кафку измененные записи абитуриентов и читает сообщение о подтверждении. Второй читает сообщения - измененные записи абитуриентов и пишет, после сохранения в БД, подтверждения о сохраненнии. Для того, чтобы уменьшить количество кода, для обоих проектов выделены общие части - отправка и прием. Из предыдущей статьи известно, что у каждого модуля чтения есть свой топик, определяется по маске, примененной к идентификатору специальности, аналогино для каждого модуля записи есть свой топик подтверждений, идентифицируемый по числу получаемому путем применения маски к идентификатору пользователя. Логика записи и чтения топиков одинакова, так же как и ожидаемая нагрузка. Как ранее было сказано, попытка упаковывать записи в пакеты - приведет к замедлению и увеличению времени доставки сообщений по мере роста числа пользователей в системе (а если точнее, то модулей записи). Поэтому записи будут сами являтся сообщениями и писаться как есть. Это позволит убрать проблему с долгой доставкой сообщений.  Сам процесс записи происходить таким образом, что в несколько потоков сервис пытается записать столько записей, сколько сможет (но не более чем пороговое значение, чтобы другие топики могли получить свои записи, иначе может произойти такая ситуация, что одна или несколько задач будут выполняться почти бесконечно). Такой подход гарантирует, что максимальное время доставки будет расти до определенного уровня, но по мере роста числа пользователей остановится на определенной константе, при условии, что не будет расти время отклика сервера по сети. Очевидно, что максимальный период отправки сообщения будет равен M/L * T, где M - число модулей чтения, L - количество потоков, а T - период отправки в топик при максимальной нагрузке. Так как количество изменений в секунду равно константе, то очевидно, что T будет уменьшаться с увеличением M, потому что время записи должно быть пропорционально c/M, где c - константа задающая количество изменений поступающих в модуль записи в секунду. Таким образом, можно разработать следующий алгоритм работы отправки сообщений в шину данных: сервис делит работу на поток чтения из БД и задачи записи в топики использующими общий пул потоков для обработки по очереди. Поток чтения забирает из БД все записи подлежащие отправке и распределяет их между соответствующими очередями. Потоки записи пытаются записать столько сообщений, сколько смогут с ограничением сверху, чтобы дать другим задачам выполнится. После записи в топик изменения добавляются в очередь потока чтения на сохранение состояния записи в БД. Можно выделить в абстрактный класс такой процесс обработки распределения сообщений между топиками. doBackground - это метод потока чтения из БД, который и пытается добавить в очередь на отправку столько сообщений, сколько сможет. Если по каким-то причинам сообщений добавить не удалось, то необходимо остановить работу потока, но не через Thread.sleep, а LockSupport.parkNanos - это более быстрый способ, который позволяет легко в другом потоке затем разбудить текущий. Такой подход намного лучше, чем использовать wait/notify. Перед тем, как пытаться читать из БД данные, нужно закоммитить изменения, о которых сообщили задачи отправки изменений. Это достигается простейшим методом: Очевидно, что этот метод может долго выполняться, поэтому возможно стоит ограничить количество циклов, например пока из очереди будет взято меньше, чем запрашиваемое число объектов. Однако он все равно должен будет рано или поздно остановиться, так как не происходит чтение из БД новых объектов. Метод чтения из БД представляет собой простейший алгоритм постраничного чтения из БД. Следует отметить, что в случае, если какая-то уже прочитанная запись будет изменена в БД, то она окажется на последней странице, т.е. порядок сползет и есть шанс пропустить в данном цикле регистрации какие-то изменения. Как это можно исправить и нужно ли вообще это исправлять - домашнее задание для читателя. Следует отметить, что в следующем цикле чтения запись все равно будет прочитана и отправлена, хоть и с нарушением порядка отправки. Регистрация изменений только раскидывает в соответствующие очереди изменения согласно их целевому топику в соответствующую задачу записи. При таком подходе задача записи в топик становится совсем простой: Так как отправка не обязательно осуществляется при помощи шины данных (можно и рестами долбить целевой сервис), то желательно выделить интерфейс для этого: В этом случае отправка в kafka будет иметь следущую реализацию: Особенностью отправки сообщений в кафку является тот факт, что она не будет писать в топик сообщения до тех пор, пока не накопится достаточное количество байт в буфере отпаврки или не выйдет таймаут. По этой причине писать сообщения по одному невозможно с точки зрения производительности. Разумеется можно вообще игнорировать результат записи, но тогда будет большое число ложных срабатываний отправки и рано или поздно накопится существенное число неотправленных записей. Для этого они пишутся все подряд, а затем ждем, пока CountDownLatch дойдет до 0, что сигнализирует о завершении отправки всех сообщений. Если отправка подразумевала однопоточное чтение и многопоточную запись, то прием делает все наоборот. Многопоточно читает из партиций и однопоточно пишет в БД. Все что делает метод - пока выполняется условие для записи в БД - а именно есть достаточное количество записей или прошло достаточно времени, цикл бесконечно пишет в БД. Если из источника сообщений данные будут постоянно приходить, то цикл будет срабатывать постоянно. Сам процесс записи в БД подразумевает, что могут приходить дубликаты сообщений. Они фильтруются при помощи регистрации идентификатора сообщения в специальное множество. Данные из источника приходят в виде списка сообщений и некоего объекта, назовем его точка коммита, который характеризует параметры источника, которые позднее должны быть сохранены дабы не получать те же самые сообщения еще раз (для кафки это оффсеты партиций). Хотя прием сообщений желательно делать таким образом, чтобы каждую партицию читать в отдельном потоке, автор не раскопал как это сделать аккуратно. Без консьюмера работать с кафкой не удается, поэтому в данной реализации используется один поток, а консьюмер уже считывает из всех партиций сразу, что не есть хорошо, но для демонстраций и комментариев под статьей - сойдет. Логика приема проста - попытаться сохранить оффсеты в кафку, а затем несколько раз осуществить чтение. Каждое чтение из ресивера передает нам список сообщений и точку коммита объединенных оберткой, которую сразу регистрируем в очередь записи в БД, если эта обертка не пуста. Так же нужно обязательно разбудить поток записи в БД, если очередь достаточно заполнена для запуска записи посредством метода LockSupport.unpark. Ресивер для кафки имплементирует похожий на ChangeSender интерфейс, только заточенный на прием: В течении определенного промежутка времени стараемся читать из всех доступных нам партиций посредством консьюмера. Точка коммита должна задавать оффсет сообщения после прочитанного нами сообщения, поэтому обновляем соответствующим образом ее. Если по каким-то причинам сообщение нельзя прочитать из json, то просто игнорируем его, считая, что оно пустое. В данной работе представлен вариант реализации сервисов для предложенной архитектуры и описаны способы решения возникающих проблем. Оптимизация и тюнинг сервисов оставлены на следущую статью, в которой автор расскажет о нелегкой доле дев-о-пса. P.S.  Программист