Введение
Применительно к платежным системам шардирование является важной возможностью, позволяющей делать гетерогенные сервисы. Например, очевидно, что пользователи у платежной системы будут отличаться по их оказываемой нагрузке на систему. Обычный пользователь, совершающий десяток покупок в неделю, не сможет оказать значимой нагрузки на систему. Однако держать его данные на дорогостоящих NVMe-накопителях - это перерасход средств с большой буквы. Данные таких пользователей очевидно должны хранится на обычных жестких дисках. Это позволит держать миллиарды записей с минимальной стоимостью хранения.
С другой стороны, у платежной системы несомненно будут пользователи, юридические лица, совершающие тысячи, а то миллионы транзакций в неделю. Хранение их данных на жестких дисках может привести к ненужному снижению производительности, проблемам поиска и анализа данных. При этом таких пользователей всегда небольшое количество, вряд ли превышает несколько тысяч.
При применении шардирования мы сможем разделить пользователей на ''холодных'' и ''горячих'', что позволит системе значительно повысить общую производительность, при значимом снижении стоимости эксплуатации из-за снижения требований к оборудованию, а так же снизить вероятность полного отказа системы из-за отказов оборудования, упростить процесс обновления системы, так как не нужно перезапускать огромные монолиты (даже если у вас микросервис, но он работает с миллиардами пользователей - это все равно монолит).
Дополнительно следует обратить внимание на снижение эксплуатационной стоимости системы, т.к. для инфраструктурных проектов именно данная характеристика является основной. Никто не будет внедрять дорогостоящую систему, находясь в здравом уме и памяти, за исключением случаев криминального характера.
Немного теории
Прежде чем приступать к описанию изменений необходимо погрузится в теорию шардирования. Без этого некоторые аспекты могут быть не очень понятны или вовсе не описаны.
Шардирование - это метод разделения данных между сервисами-клонами, которые могут обладать разной конфигурацией.
Почему автор дал именно такое определение? Разберем определение по частям.
Разделение данных - допустим, что у вас имеется некоторое множество сущностей X, у каждой сущности имеются атрибуты Ai, а так же (опционально) связь с некоторой родительской сущностью. Осуществить разбиение данных на подмножества можно как по атрибуту(ам) Ai, так и по связи с родительской сущностью. Каждый вариант имеет свои плюсы и недостатки, основным недостатком первого является полная невозможность осуществлять детерминированное пролистывание сущностей по родительской связи, что иногда может быть критично. Главным недостатком второго является невозможность бесконечно наращивать сущности связанные с одной родительской - должны быть ограничения;
Сервисы-клоны - шардирование подразумевает, что логика обработки сущностей принципиальным образом не меняется, в частности, это означает, что мы можем мигрировать сущность с одного шарда на другой и ожидать выполнение одних и тех же операций с одним и тем же результатом;
Различие в конфигурации - одной из важных особенностей шардирование является возможность создание гетерогенных систем, при которых именно конфигурация, например хранение данных в разных СУБД, позволяет достичь такого результата.
При работе с шардами всегда возникает проблема определения идентификатора шардирования (ShardId). Существует несколько способов вычисления:
Атрибут является идентификатором шардирования как есть (использовать такой подход нежелательно ввиду сложности с обеспечением безопасности, но иногда такой подход является допустимым);
Некоторая детерминированная функция вычисления идентификатора шардирования исходя из значения атрибута или композиции атрибутов, например хеш-сумма с взятием остатка от деления;
Сторонний сервис, предоставляющий идентификатор шардирования, как результат выполнения к нему запроса, классическим примером, хоть и притянутым за уши, является сервис сокращения ссылок, т.к. основной принцип работы у них одинаков, пусть и отличается тем, что сервис сокращения ссылок выдает уникальный результат на каждый уникальный запрос, а сервис идентификаторов шардирования детерминировано ассоциирует запрос на конкретный идентификатор шардирования.
Из чего можно можно сделать вывод: шардирование всегда бывает двух типов - статическое и динамическое.
У каждого способа есть свои плюсы и минусы, так первый и второй вариант (статическое шардирование) позволяют создавать кластера с бесконечным количеством шардов (буквально, сколько оборудования сможете обеспечить, столько можно будет использовать, лимита не существует), но цена этого в том, что после создания уже нельзя изменить размеры. Придется оставаться с тем, что имеем, либо создавать сложнейшие процессы балансировки, которые на бесконечном кластере, очевидно, займут бесконечное количество времени. Система не сможет обрабатывать запросы в процессе такого изменения из-за нахождения в некорректном состоянии.
Последний способ (динамическое шардирование) позволяет работать с кластерами с изменяемым размером в ходе эксплуатации от минимальных значений до нескольких миллиардов шардов, если сервис вычисления идентификатора шардирования сам будет шардирован статически (один из первых двух способов). Причем, при обеспечении функции миграции данных, можно обеспечить уменьшение кластера и все это без простоя системы! Все что потребуется, это на время миграции отдельной сущности не допускать её изменения плюс некоторая пауза, пока обновляются кеши.
Описание изменений
Для поддержания шардирования в системе нужно решить два основных вопроса:
Шардирование в очередях сообщений;
Шардирование для REST API.
Следует отметить, что в целях гарантии безопасности все идентификаторы шардирования ожидаются в формате ^[A-Za-z0-9]+$.
Изменение очередей сообщений
Для поддержки шардирования очередей сообщений не нужно каких-то особых сложностей, так как потребители сообщений могут просто читать топик с нужным суффиксом, указывающий на принадлежность к шарду. Для производителей сообщений ситуация сложнее, так как нужно знать какому именно шарду должно уйти сообщение.
Для этого библиотека message-queue была доработана таким образом, что бы при описании топика можно быть указать shardId для корректной генерации имени топика. Либо как и прежде, можно указать полное название топика, игнорируя генератор имен. Таким образом потребители сообщений будут подписываться на нужные топики.
Для отправки сообщений добавлен новый интерфейс ShardedEventProducer, позволяющий указать в какой именно шард нужно отправлять сообщение. В зависимости от флага sharded конфигурации производителя сообщения будет создаваться либо стандартная реализация, либо шардирования. Это позволит избежать проблем с конфигурацией бинов.
Пример конфигурации шардированного производителя сообщений:
producer:
- event-class: "com.lastrix.mps.node.model.payment.event.request.PaymentEvent"
sharded: true
message-queue-id: "balance"
В свою очередь отправка сообщений теперь требует способа определения идентификатора шардирования, для этого желательно использовать кеш, например таким образом:
this.cache = new ReactiveCache<>(
Caffeine.newBuilder()
.maximumSize(1_000_000)
.buildAsync((key, executor) -> walletRestAPIClient.findWalletBalanceShardId(key)
.toFuture())
);
Так как идентификаторы шардирования являются небольшими строками, не более 4 символов, то можно спокойно делать кеш на миллион записей, хотя такие настройки желательно убрать в конфигурацию.
При таком кешировании отправка сообщения в топик будет представлять из себя:
public Mono<Void> produce(PaymentEvent event) {
return cache.get(event.getPaymentInfo().getWalletId())
.flatMap(shardId -> producer.produce(shardId, event))
.then();
}
При этом если не устанавливать флаг sharded=true, то при создании обычного производителя сообщений можно все равно указать генератору имени топика идентификатор шарда, что позволит отправлять сообщения конкретному шарду.
Изменение REST API
Для поддержки шардирования в REST API, из-за использования плагина сборки для генерации клиентов и маршрутизаторов, задача решена таким образом, что теперь, если нужна поддержка шардирования REST API, то разработчик должен маркировать интерфейс, с которого и генерируются все необходимые классы, специальной аннотацией ShardedApi. При обнаружении её на интерфейсе, плагин будет ожидать, что каждый метод интерфейса будет обладать строго одним параметром, который позволит определить идентификатор шардирования. Для этого заведены две аннотации:
ShardId - значение параметра используется в качестве идентификатора шардирования как есть, значение должно быть в формате ^[A-Za-z0-9]+$;
ShardedBy - значение параметра используется для вычисления идентификатора шардирования одним из двух способов:
Если параметр реализует интерфейс Sharded, то будет вызван его метод для вычисления идентификатора шардирования;
В противном случае ожидается наличие бина ShardResolver с нужным параметром типа, если таких бинов несколько, то ожидается, что имя бина будет конструироваться исходя из формулы имяКлиента + Default + ТипаПараметра + ShardResolver, например balanceDefaultLongShardResolver будет использован для определения идентификатора шардирования по идентификатору кошелька (Long) для клиента сервиса баланс.
Разработчик может реализовать любой способ получения идентификатора шардирования, при выборе варианта с ShardResolver, так как получает параметр как есть.
Для гарантии обновления конфигураций, теперь нужно в конфигурации указывать не имяКлиента.web-client, а имяКлиента.sharded-web-client, что гарантирует отказ в запуске микросервиса, если тот неправильно настроен. При этом сама конфигурация точно такая же, за исключением того факта, что в base-url нужно обязательно указывать переменную в виде {shardId}, которая и будет заменяться в зависимости от полученного идентификатора шардирования. Например так:
internal-balance:
sharded-web-client:
base-url: "http://balance-api-{shardId}:8080/"
logger-name: "internal-api"
Переписываем сервис Баланс
Очевидно, что теперь для сервиса баланс не нужно партицирование, поэтому эту фичу нужно убрать, что значительно упростит процесс работы, и, как ясно из предыдущей статьи автора про задолженности, ещё приведет к увеличению производительности.
Изменения в сервисе Rest API таковы, что потребители этого API , даже не заметят разницы, так как оно никак для них не меняется, маршрутизатор запросов перенаправит запрос на нужный шард самостоятельно. Сами шарды сервиса Баланс, теперь делятся на два типа: обычные и горячие. На втором типе кошельки будут кешироваться в память сервиса, что бы обеспечить максимальную производительность при работе с этими кошельками и не вычитывать их каждый раз из БД. Именование идентификаторов шардов для обычных будет в виде [0-9]+, а для горячих h[0-9]+. Так можно будет их отличить друг от друга, а так же увидеть разницу в производительности.
Для обработки сообщений в сервисе Баланс ничего менять, кроме конфигурации, не нужно, так как достаточно указать в каждом сервисе свой shardId для генератора имени топика, например так:
kafka:
defaults:
namespace: "mireapay-node-0000.balance"
event:
provider: "kafka"
consumer:
- event-class: "com.lastrix.mps.node.model.payment.event.request.PaymentEvent"
topic:
shard-id: "0"
- event-class: "com.lastrix.mps.node.model.wallet.event.lifecycle.WalletLifecycleEvent"
topic:
namespace: "mireapay-node-0001.wallet"
- event-class: "com.lastrix.mps.node.model.balance.event.BalanceChangedEvent"
topic:
shard-id: "0"
producer:
- event-class: "com.lastrix.mps.node.model.payment.event.response.PaymentResultEvent"
- event-class: "com.lastrix.mps.node.model.balance.event.BalanceChangedEvent"
topic:
shard-id: "0"
Важно отметить, что конфигурации различаются у каждого шарда, и следовательно нужно каким-то образом параметризовать это при запуске сервиса. Для этого дополним bootstrap.yaml в проектах сервиса Баланс следующими настройками:
spring:
main:
cloud-platform: KUBERNETES
application:
name: "node-balance-processor-${SHARD_ID}"
cloud:
config:
allowOverride: ${LOCAL_CONF_IS_THE_BOSS:true}
overrideNone: ${LOCAL_CONF_IS_THE_BOSS:false}
overrideSystemProperties: ${LOCAL_CONF_IS_THE_BOSS:true}
kubernetes:
enabled: true
config:
enabled: true
name: ${spring.application.name}
namespace: ${POD_NAMESPACE}
sources:
- name: ${spring.application.name}
- name: boot-server
- name: spring-r2dbc
- name: srv-auth
- name: kafka
secrets:
enable-api: true
namespace: ${POD_NAMESPACE}
sources:
- name: ${spring.application.name}
- name: db-balance-${SHARD_ID}
discovery:
enabled: false
loadbalancer:
enabled: false
Через переменную окружения SHARD_ID будем передавать сервису его идентификатор шарда. Благодаря этому сервис получит свои конфиги и секреты, а так же подключение к БД, принадлежащие нужному шарду.
Несомненным плюсом будет возможность создания тестового шарда, у которого будет свое собственное окружение и настройки.
Так как с сервисом баланс работает исключительно сервис контрактов, то в нем нужно использовать ShardedEventProducer для отправки сообщений сервису Баланс, а так же не забыть про конфигурацию:
- event-class: "com.lastrix.mps.node.model.payment.event.request.PaymentEvent"
sharded: true
message-queue-id: "balance"
topic:
namespace: "mireapay-node-0000.balance"
Производитель сообщений для сервиса Баланс на основе идентификатора кошелька определит в какой шард нужно отправлять сообщение.
Заключение
В данной работе автором был предложен вариант для шардирования сервисов платежной системы MireaPay, начиная с сервиса Баланс. Данный подход является универсальным для всех сервисов, что позволит, при необходимости, шардировать не только сервис Баланс, но и все остальные.
Очевидно, что предложенное решение можно применить и в других проектах, ввиду его простоты и удобства использования. Автоматическая генерация клиентов позволяет снять с разработчика необходимость писать однообразный код, что значительно повышает производительность труда, при этом лаконичность и краткость конфигурации - исключить ошибки настройки.
Сам процесс шардирования позволит платежной системе MireaPay оптимизировать затраты на работу системы в ходе эксплуатации, так как часть кошельков, операции по которым совершаются крайне редко, можно вынести на специально выделенные шарды с более дешевым СХД, пусть и с меньшим быстродействием, например жесткие диски. И напротив, кошельки с большим количеством транзакций обрабатывать на высокоскоростных СХД, что несомненно проявит себя куда большей производительностью.
Такой подход позволит, в случае необходимости, платежной системе MireaPay наращивать количество кошельков на каждом узле, почти до бесконечности, расходуя минимум вычислительных ресурсов.