Параллелизм может быть только 1
Вводные
В ходе предварительного нагрузочного тестирования было выявлено, что обработка событий жизненного цикла часто упиралась в блокировки, что приводило к переотправке событий очередью. Несмотря на то, что это является вполне нормальной практикой — отправлять сообщения заново, если сейчас их обработать нельзя — с точки зрения производительности это приводит к нестабильности, и что хуже — чтению ненужных сообщений. Так как любая очередь сообщений работает с окном, а не отдельными записями — то уже обработанные сообщения вновь попадали в пайплайн событий, пусть и, благодаря кешу идентификаторов сообщений, не обрабатывались.
Очевидным решением было бы создать отдельный топик для переотправки, но тут так же возникают проблемы с производительностью, потому что возрастает ненужная нагрузка на очередь сообщений, и проблема блокировок никуда не уходит.
Единственным решением для оптимизации процесса является полный и безоговорочный отказ от блокировок и транзакций для работы с данными. Они только замедляют работу системы и не дают никаких преимуществ. К тому же, зачем платёжной системе вообще нужны транзакции и синхронизация доступа? Мы же работаем со слишком важными данными! Пусть все пишут одновременно! Параллелизм может быть только 1!
Решение
Все изменения в сервис можно увидеть в коммите.
Первым делом, нужно убрать все упоминания о нашем транзакционном прошлом. Поэтому ищем все упоминания @Transactional и удаляем их. Следующим шагом является удаление блокировок записей. Источник этого зла лежал в интерфейсе.
Теперь, все что осталось — это проверить, что наш процессор контрактов продолжает работать корректно. Для этого были специально написаны тесты для симуляции эффектов гонки (пример).
Следует разобрать один из тестов на состояние гонки, с целью понимания остальных, т.к. они по сути своей одинаковы.
Основная конструкция для тестирования обработки сообщений приведена ниже:
assertAckAll(
defaultContractLifecycleEventMessageQueue,
times(
CONCURRENCY,
() -> expectAutoApproved(
contract,
localWalletId,
ApprovalType.WITHDRAW)
)
);
Её особенностью является тот факт, что в начале мы ожидаем все необходимые сообщения с помощью функции times, и только когда все сообщения получены — передаём их функции, которая удостоверится, что результат обработки — ACK. Обработка сообщений будет запущена полностью параллельно без ограничений на количество потоков. Таким образом можно достичь эффектов гонки, и если такие проблемы в обработке сообщений есть, то они обязательно проявятся. Задавая значение констант TestCaseUtil.REPETITIONS и TestCaseUtil.CONCURRENCY можно запускать процесс обработки одного и того же события хоть тысячу раз в полностью параллельном режиме, ожидая, что обработка корректно завершится. Такой тест хорош тем, что его можно запускать в пайплайне сборки проекта и при этом не тратить слишком много ресурсов. На сборке проекта данный тест не сильно сказывается.
Нагрузочное тестирование
В прошлый раз, во время предварительного тестирования, у каждого сервиса было только 3 подключения к БД, в целом, с блокировками это хорошо работало, но после оптимизации, оказалось, что нагрузка на каждое подключение значительно превышает возможности обработки, и было принято решение увеличить количество подключений к БД до 8, события жизненного цикла контракта обрабатываются в 8 потоках максимум, так же есть другие события, у которых ограничение в 4 потока (у каждого события свои лимиты и они никак не влияют друг на друга). При такой конфигурации всем обработчикам должно хватать времени работы с подключением на выполнение своих запросов. Так как транзакции не используются и все запросы выполняются в режиме авто-коммита, то это вполне приемлемое решение.
Сам процесс нагрузочного тестирования разделен на 3 этапа:
Малая нагрузка;
Почти предельная нагрузка;
Аварийная работа в условиях превышения возможностей системы по своевременной обработки контрактов.
Малая нагрузка
На рисунках в спойлере отображены метрики работы системы в штатном режиме с небольшой нагрузкой. Следует уточнить, что метрика ack включает в себя processing, эта метрика нужна для того, что бы понимать сколько времени теряется на этапе работы пайплайна. Контракты создавались 50/50 на каждый узел, при этом половина контрактов была локальными (внутри узла), и другая половина — на сторонний узел. Т.е. половина всех контрактов — локальные, половина — нелокальные. Отличным показателем является отсутствие NACK при обработке событий. Так же сам процесс обработки контрактов стал стабильнее и не вызывает никаких нареканий. Можно приступать к более тяжёлым нагрузкам.
Телеметрия для малой нагрузки
Беклог и время обработки сообщений
Время обработки отказа обработки сообщения и характеристики обработки контрактов
Утилизация ЦПУ
Почти предельная нагрузка
На рисунках в спойлере приведена телеметрия для более серьезной нагрузки на систему. Как видно, теперь одновременно в очереди обработки находятся уже десятки контрактов и поэтому время обработки каждого из них выросло до 8–15 секунд, вместо 3–6 ранее. Обработка самих событий при этом явно никак не изменилась, задержки обработки сообщений пульсара держатся на том же самом уровне и не превышают 1 секунды.
Телеметрия для предельной нагрузки
Беклог и время обработки сообщений
Время обработки отказа обработки сообщения и характеристики обработки контрактов
Утилизация ЦПУ
Аварийная нагрузка
На рисунках в спойлере телеметрия работы сервиса в аварийном режиме, когда система уже не справляется с нагрузкой. При создании 32 контрактов в секунду активно копится беклог и очередь необработанных контрактов. Система должна все равно справляться с такой проблемой, даже если не увеличивать ресурсы системы. В ходе этого теста хорошо заметно, что даже если продолжать создавать контракты, пусть и с низкой интенсивностью — сервис успешно разбирает накопившуюся очередь.
Телеметрия для аварийной нагрузки
Беклог и время обработки сообщений
Время обработки отказа обработки сообщения и характеристики обработки контрактов
Утилизация ЦПУ
Следует обратить внимание, что узел 0001 не смог сразу обработать свою очередь, узел 0000 его обогнал — это проблема с подключениями БД, они иногда падали. Возможно, что из-за нехватки на самом сервере БД, автор не изучал эту проблему.
Отдельно по всем тестам следует обратить внимание на утилизацию ЦПУ, между тестами она не сильно отличается, что говорит о том, что потоки были полностью насыщены и дальнейшее увеличение производительности возможно только за счёт увеличения ресурсов. Но целью этой работы является не сжечь как можно больше электроэнергии, тем более что с алгоритмами, обладающими параллелизмом равным единице (на самом деле, если брать во внимание БД, то 1 за вычетом бесконечно малой величины, т.к. параллелизм равный 1 в реальных приложениях — это как скорость света) — следующий ограничивающий порог будет уже в железе — возможности обрабатывать пакеты и передавать их по сети, объем ОЗУ и дисковое пространство.
В ходе всех тестов на всех трёх физических машинах кластера гипервизор отчитывался о 16–18% утилизации ЦПУ, при этом в простое этот показатель составляет 6–7%. Все машины 32 ядерные с 64 потоками. Данная метрика важна, что бы учитывать полный эффект от нагрузки на систему. Даже если вместо виртуальных машин использовать реальные, то для маршрутизации все равно придётся использовать вычислительную мощь, пусть и маршрутизаторов.
К несчастью, ошибки все равно произошли, и 18 контрактов зависли в состоянии завершения. Скорее всего отсутствует какой-то кейс при обработке событий пополнения/получения статуса завершения от другого узла. Удивительно, что все 18 ошибок произошли на втором тесте. В первом и третьем — они отсутствуют. Баг воспроизводится только на продовой конфигурации под высокой нагрузкой, без логов (а их будет слишком много) проанализировать дефект невозможно, а тратить 30–50 минут на отработку всех сценариев ради минорного бага — автор не может позволить, баг не будет исправлен. Наличие логов позволило бы осуществить эту операцию за 2–10 минут, но для этого требуются серверные nvme (читатель может поинтересоваться стоимостью Kioxia CD8 на 8 ТБ, а таких необходимо минимум три штуки).
Домашнее задание
Несмотря на то, что автор использовал определённую теорию, которую можно было бы назвать «теория проектирования автоматических систем произвольной сложности с заданными характеристиками, ограниченными законами физики», которая сильно помогает в упрощении процесса создания подобных систем. В проекте mireapay оказалось достаточно всего лишь одного метода, по этой статье данный метод можно дедуктивно воспроизвести. Хотя сервис контрактов — это нулевой уровень, пожалуй, нужно перейти на следующий и дать более сложный пример, как в данном домашнем задании. Автор не даст вам описание этого метода — используйте подсказки.
Для проверки ваших навыков, дорогой читатель, вам нужно перепроектировать сервис Баланс, что бы он больше не использовал транзакции или блокировки. Списывать средства со счета (или резервировать товар ;)) можно без использования транзакций, блокировок данных. Достаточно только гарантии целостности от БД и поддержки уникальных ключей. Автор дал достаточно подсказок читателю, что бы решить эту задачу самостоятельно.
Поэтому переходим к постановке задачи:
Перепроектировать и переписать сервис Баланс для работы без транзакций и блокировок записей. Процесс обработки платежей при этом все равно должен подразумевать двух-этапность, а именно: создание платежа и получение команды от клиента на подтверждение, либо отмену. Важно заметить, что отмена платежа может прийти даже до начала обработки создания. Поэтому важно предусмотреть этот вариант и не позволять создавать то, что уже отменено. Так же следует учитывать, что в процессе работы в произвольный момент может произойти отказ оборудования, падение приложения или другие варианты прекращения выполнения работы. Все сохранённые промежуточные данные не должны приводить к невозможности дальнейшей работы как текущего платежа, так и других. Более того, отмена может произойти в процессе создания в произвольный момент времени, в том числе параллельно. Возможна ситуация, когда создание не завершилось, операция прервалась на середине процесса, часть данных создана уже. Отмена должна происходить без ожидания завершения создания платежа.
При любой ситуации, неполные (некорректные с точки зрения конкретной операции) данные должны быть всегда корректными для всех остальных операций и не блокировать их работу.
Так как решений — несколько, то автором были выбраны пункты, которые позволят разграничить решения по их «качеству», а именно:
Получение текущего баланса производится за О (1);
Создание, подтверждение и отмена так же производится за О (1);
Дополнительный бонус, если отмена платежа не требует переотправки сообщений очередью и не используется кеширование.
Работа с базой данных ограничена командами INSERT, UPDATE, SELECT, у таблиц могут быть только индексы и ключи, например триггеры использовать нельзя;
Экспертный уровень: команда UPDATE не используется;
Мастерский уровень: Удельная производительность на ядро-секунду (количество подтверждённых (99%) или отменённых (1%) платежей на количество затраченных ядро-секунд за период времени системой целиком, включая БД и маршрутизацию трафика).
Примечания:
В расчёт сложности алгоритма не берётся сложность выборки данных конкретного пользователя, в силу бинарных деревьев оно всегда будет равно O (log (n)), постарайтесь абстрагироваться от того факта, что система многопользовательская, для первоначального решения достаточно одного пользователя;
Если берётся первый или последний элемент в индексе, то это считается за О (1), т.к. большинство современных БД хранят указатели на эти элементы;
Учитывать сложность алгоритмов самой БД для получения или сохранения записей не нужно, БД разные и работают по разному;
Пункты экспертного уровня и выше не обязательны к исполнению, так большинству архитекторов и разработчиков будет невозможно их достичь, но если вы себя считаете хотя бы середнячком, то стоит учитывать пункт экспертного уровня.
И автор напоминает ещё раз, что использовать блокировки записей или транзакции — нельзя!
Несмотря на то, что даже с этими пунктами все равно будет несколько вариантов уникальных решений (согласно его теории), автор не видит причин добавлять другие фильтры решений для их ранжирования, т.к. уже перечисленные выше удовлетворяют тем требованиям, что стоят перед информационными системами сейчас. Поэтому все решения удовлетворяющие первым трём пунктам будут считаться равнозначными.
Домашняя работа состоит из двух частей — проектирование и реализация. Дабы не отсекать тех, кто не умеет проектировать, но хотел бы научиться, автор предоставляет последнюю подсказку по домашней работе. Дальше все зависит только от тебя, читатель.
Подсказка для слабых духом
Заключение
Так вышло, что эта статья ещё и совмещает в себе результаты работы автора за год. Поставленной цели в 100 млн контрактов в день при ограниченных вычислительных ресурсах — автору удалось достичь. Цифра в 8 потоков была выбрана не случайно. В будущем, когда автор перепишет код на ЯП без рантайма, это позволит работать системе на ЦПУ отечественного производства — Эльбрусах и Байкалах. Возможно, что производительность будет ниже, но не в разы.
Использовать для платёжных систем JVM для обработки столь важных данных для государства — не самая лучшая затея, разве что в рамках пилота или натурных испытаний, а так же отсутствия адекватной альтернативы. Java — великолепный инструмент для прототипирования и отладки алгоритмов, решений, но итоговый продукт все равно должен быть воплощён в железе и максимально эффективно его утилизировать. Не нужно считать эти слова пустыми, автор вкладывает в них смысл — не надо транжирить ресурсы впустую. Работу JVM никак нельзя назвать эффективной, тем более что о скорости работы на Эльбрусах не пинал JVM только ленивый.
Автором были предложены механизмы реализации платёжной системы без необходимости использовать блокировки или транзакции, что снижает требования к оборудованию. Позволяет обрабатывать сообщения параллельно на нескольких машинах без потери производительности. Такой подход позволяет, даже в случае серьёзной аварии части системы, продолжить обрабатывать платежи без потери во времени обслуживания — платёж одновременно может обрабатываться на нескольких машинах. Разумеется, текущие показатели в ~20 секунд для обработки платежа при 100% нагрузке нельзя назвать хорошим, но его можно оптимизировать в 2–4 раза, если бы у автора были необходимые 2 недели — несколько месяцев, требуемые на конфигурирование системы и детальную настройку отправки сообщений, самих очередей сообщений. В условиях нехватки ресурсов — тратиться на оптимизацию в 2–4 раза — не имеет смысла, слишком малый выигрыш, который не стоит свеч. В данный момент прототип выполняет поставленную перед ним базовую функцию, а именно перевод средств со счета на счёт, при этом гарантируя отказоустойчивость, гораздо большую, чем описал автор. Статья уже превысила лимит по тексту.
Разумеется множество функций пока не реализовано, только в планах. Поэтому читайте статьи автора и в новом 2025 году. Автор желает всем успехов!
Для работодателей
Работодатель, поспеши завести себе Java-кота в команду! Мышей не ловит, но зато пишет Java-код, а еще проектирует немножко.
https://hh.ru/resume/b33504daff020c31070039ed1f77794a774336
И не забываем, дорогие мои HRы:
С 1 января при подписании трудового договора работодатель обязуется выплатить соискателю одну зарплату (+НДФЛ) за каждый месяц начиная с 1 января в качестве невозвращаемого приветственного бонуса. Так с 1 января — 1 зарплата, с 1 февраля 2 зарплаты и т.д. Сумма определяется в момент получения соискателем оффера и в силе в течении 10 рабочих дней, иначе рассчитывается исходя из даты подписания трудового договора за исключением случаев, когда перенос за границы 10 рабочих дней произошел по инициативе соискателя.