Продолжаем прорабатывать методы шардирования. В прошлой статье было предложено простое, но крайне трудозатратное решение по реализации динамического шардирования (когда ассоциация ключа и шарда лежат в отдельном сервисе, а не вычисляется при помощи зашитой в каждый сервис функции). В этой статье предлагается универсальное решение, которое позволяет гибко настраивать параметры шардирования и при этом не быть перегруженной возможностями, из-за которых производительность неуклонно снизится.
Проблемы прошлого решения
Одной из главных проблем прошлого решения было наличие отдельного сервиса на каждый домен системы. Для контрактов свой сервис шардирования, для балансов - свой и т.д. С отдельной схемой БД разумеется. При этом API этих сервисов не отличались либо были почти одинаковы.
Второй главной проблемой являлась сложность разработки - если нужно добавить новый шардируемый сервис, то для него нужно еще создать свой клиент, конфигурации окружения и подключить эти конфигурационные карты везде, где это необходимо. Сама информация о доступных шардах при этом хранилась в конфигурационных картах и монтировалась в каждый сервис. Нужно добавить новый шард? Добро пожаловать в перезапуск всех затронутых сервисов.
Третьей проблемой являлась избыточность ресурсов, необходимой для предоставления данной функции. Для работы нужно было создавать множество подов, которые тратили память стенда, но обрабатываемая ими нагрузка находилась на незначительном уровне при проведении НТ.
Автором было выбрано старое решение по следующим причинам:
Решение простое до примитивности;
Можно было добавить везде и всюду через копирование кода с автозаменой;
Не требовалось серьезной переработки плагинов gradle для автогенерации клиентов;
Для большинства сценариев такое решение в 99% более оправдано, чем универсализация;
Были не ясны до конца требования к универсальному решению.
Универсальный сервис шардирования
API претерпело серьезные изменения. Теперь логика работы сервиса такова: вся работа разделена на модели шардирования. В рамках нее выполняются проверки уникальности ключей и валидация значений. Сам ключ, присылаемый клиентом - это некоторый простой json, описывающий ключ шардирования. В 99% случаев это будет примитивный вариант {"key":"value"}. Но подобное усложнение позволит в дальнейшем дать клиенту возможность использовать составные ключи и поиск по части составного ключа.
Основные методы
@PostMapping Mono<ShardIds> create( @RequestBody @Valid ShardAllocationRequest request ); @PostMapping("{model}/find") Mono<ShardIds> get( @PathVariable("model") String model, @NotNull @RequestBody JsonNode shardKey );
При ассоциировании ключа с шардом клиент отправляет следующее тело запроса:
{ "model": "modelName", "requestId": "...", "shardKey": { "key": "value" }, "profile": "DEFAULT", "requestedShardIds": [], "shardCount": 1 }
Модель и ключ шарда уже были описаны выше, поэтому пропущены. Значения остальных полей следующее:
requestId - дедупликация запросов, если два клиента одновременно для одного и того же ключа будут создавать ассоциацию, то для одного из них сервис вернет 400 ошибку. Повтор с тем же самым идентификатором даст статус 200 и результат создания шарда, сохраненный в БД;
profile - это новое поле, позволяющее классифицировать шарды внутри модели на категории, данная возможность необходима для выбора горячих шардов или холодных, в зависимости от типа кошелька, разумеется читатель может придумать и другие использования данного решения;
requestedShardIds - клиент может запросить определенные ассоциации для ключа, если модель позволяет клиенту определять куда аллоцировать ключ, то сервис будет использовать эти идентификаторы, при условии, что в профиле выбранной модели есть такие идентификаторы шардов;
shardCount - взаимоисключающий requestShardIds аргумент, позволяющий указать количество шардов, на которые должен распространяться данный ключ, разумеется в настройках модели может быть указано меньшее значение предельно допустимого количества шардов на ключ, в этом случае сервис ответит 400 ошибкой.
В ответе всегда приходит одинаковая информация, а именно ключ шардирования из БД и список ассоциированных шардов. Название модели не присылается, так как вызывающая сторона всегда знает для какой модели получена информация.
Конфигурации шардирования
Для предоставления клиенту информации о моделях, существует отдельный метод:
@GetMapping("{model}/shard-ids") Mono<List<ShardingEntry>> getModelShardIds( @PathVariable("model") String model );
С помощью данного метода клиент сможет получить информацию о шардах модели и в каких профилях шард может быть использован. Благодаря этому методу клиент может корректно использовать аргумент requestedShardIds при создании новой аллокации, а так же не требовать перезапуска сервиса для получения обновления. Информация кешируется на определенный промежуток времени и запрашивается по необходимости. Таким образом решается проблема синхронизации конфигурации шардирования в системе.
Конфигурация модели - проста и удобна в чтении:
sharding: models: - name: WALLET allow-multiple: false allow-request-shard-ids: true max-shard-count: 1 shardIds: - id: "0" profiles: - 'DEFAULT' - 'HOT' - id: "1" profiles: - 'DEFAULT' - 'HOT'
Следует обратить внимание на:
allow-multiple - можно ли использовать множество шардов на один ключ;
allow-request-shard-ids - может ли клиент задавать шарды для ключа;
max-shard-count - максимальное количество шардов на ключ, активен только при наличии флага allow-multiple.
Реализация
Реализация в рамках MireaPay подразумевает:
Поддержка при генерации RestAPI клиентов и шлюзов (gradle-плагин для генерации кода согласно декларативно прописанным контроллерам);
Поддержка тестирования сервисов (предоставление моков, которые можно переиспользовать в сервисах);
Разработка специализированного сервиса, который можно добавить в конфигурацию любого неймспейса (будь то HQ, clearing или node).
Поддержка в gradle-плагине
В старой версии была реализована примитивная поддержка шардирования, в текущей пришлось значительно усложнить логику обработки. Ключевым различием является удаление необходимости самостоятельно реализовывать классы определения шарда по некоторому объекту, для этого были добавлены аннотации:
@ShardKeySource(name="shardKeyField",field="sourceObjectField")- составление ключа шардирования согласно некоторому полю объекта, можно использовать выражения, которые генератором будут развернуты в последовательность вызовов, примеры:name- полеnameиз аннотированного объекта, для записи будет вызываться методobject.name(), для обычного класса ожидается наличие геттера; если объект является картой, то будет сформировано получение значения по ключу из карты;config.type- взять из поляconfig, аннотированного класса, полеtype, аналогично предыдущему примеру будет формироваться цепочка;items[0].id- из первого элемента массива взять полеid;И т.д.
@ShardKeySources(value={})- позволяет группировать@ShardKeySource, если из объекта нужно получить более одного поля;@ShardKeyParameter(value="shardKeyField")- взять аннотированный объект как есть и использовать в качестве значения поля ключа шардирования.
Такие аннотации позволяют реализовать следующие RestAPI:
Получение состояния контракта по внешнему ключу контракта:
Mono<ContractState> findCurrentState( @ShardKeySources({ @ShardKeySource(name = "externalId", field = "externalId"), @ShardKeySource(name = "nodeId", field = "walletId.nodeId"), @ShardKeySource(name = "walletId", field = "walletId.id") }) @RequestBody ExternalContractId contractId );
Ранее приходилось реализовывать ShardResolver в качестве бина, который должен был бы выполнять операцию по определению шарда. Теперь ShardResolver не имеет параметров типа и работает с любым объектом, так как он осуществляет преобразование полученного аргумента в JsonNode. В данном случае плагин передаст в резолвер карту, составленную из полей externalId, nodeId и walletId.
Получить информацию о кошельке:
Mono<Wallet> findById( @ShardKeyParameter("walletId") @PathVariable @Pattern(regexp = Regexps.WALLET) String walletId, @RequestParam(value = "accountId", required = false) @Pattern(regexp = Regexps.ACCOUNT) String accountId );
Теперь нет необходимости создавать ShardResolver с определенным именем, которое будет ожидать внутренняя реализация. ShardResolver получит карту из одного элемента - walletId.
Больше примеров можно найти в репозиториях HQ Rest API и Node Rest API.
Моки для тестирования интеграции
Так как сервис - универсальный, и его API является стандартизированным, то очевидно - необходимо унифицировать и тестирование интеграций с ним.
Для этого в был добавлен класс com.lastrix.mps.sharding.test.InternalShardingWireMockStub, позволяющий в тестах эмулировать работу сервиса и не тратить время на дублирование кода.
Все методы разделены на две категории:
stub_*- создание заглушки с заданными параметрами;verify_*- проверка, что заглушка была вызвана с нужными параметрами.
Такой подход позволяет писать тесты в таком виде:
var pattern = SHARD_STUB.stub_create(MODEL_CONTRACT, List.of(shardId.value())); ... SHARD_STUB.verify_create(pattern);
Что позволяет внедрить такие заглушки в юнит-тесты без нарушения их логики, так как любой юнит-тест всегда строится по стандартной схеме.
Универсальный сервис шардирования
Исходный код сервиса по ссылке.
Так как данный сервис выполнен в виде общей компоненты, то его легко можно подключать к любой части MireaPay - будь то HQ, Node или Clearing. При этом каждый инстанс сервиса можно сконфигурировать своим особым образом, а то и вовсе сделать в рамках какой-то части два сервиса шардирования, каждый из которых будет работать со своими моделями.
В данный момент автор ограничился тем, что на каждую часть сервиса приходится один сервис шардирования (если нужен).
Заключение
В данной работе автор предложил универсализацию динамического шардирования для микросервисной архитектуры. Предложенное решение позволяет упростить процесс регистрации и получения информации о шардировании для системы, свести всю работу разработчика до написания конфигурации, которая бы описывала модели шардирования и правила, по которым оно осуществляется.
Такой сервис позволяет автору убрать лишнюю работу по поддержанию кодовой базы и упростить процесс добавления новых шардированных сервисов в будущем.
Исходный код платежной системы MireaPay доступен по ссылке.
Если вы желаете отблагодарить автора за проделанную работу, то сделайте пожертвование бойцам вооруженных сил Российской Федерации. Это будет лучшей наградой.
