Властелин финансов. Битва за банкоматы

Немного теории

Прежде чем приступить к решению, нужно вернуться к азам информатики. А именно счётчикам. Разумеется описывать всю теорию в подробностях автор не видит смысла, однако описать один фундаментальный закон, который позволит получить решение, которое не будет ставить ограничения на количество рпс при работе со счетом — нужно в первую очередь.

Основополагающим законом, который ещё и выводится из любого раздела физики — является закон производительности идеального атомарного счётчика неотрицательных вещественных чисел в конкурентной среде.

Возьмём в качестве обозначений:

  • X — текущее значение счётчика;

  • m — величина изменения (вообще тут должно быть мат. ожидание, но внедрение в описание статистики и высшей математики не повлияет серьёзно на описание закона);

  • R (k) — производительность счётчика, измеряемая в количестве производимых операций в секунду, зависит от способа хранения информации;

  • k — коэффициент суммирования.

Из школьного курса информатики, читателю должно быть известно, что числа можно записывать в позиционном формате, например в десятичной форме число двести восемьдесят пять можно записать как 285. Но так же есть ещё вариант записи числа в виде рядов, или проще говоря сумм чисел. В примере выше наше число можно записать как сумму 100 + 43 + 65 + 70 + 7. В данном случае, можно сказать, что коэффициент суммирования равен 5. Пример второго изучают чуть ли не с детского сада, где учат считать на пальцах — как раз суммировать единички.

Исходя из законов физики — существует предельно-допустимое количество операций, которое мы можем совершать с пространством. Но если осуществить разделение нашего счётчика на фрагменты, то мы можем обойти это ограничение. Причём это позволяет получить счётчик с бесконечным масштабированием (разумеется существующее оборудование имеет верхнюю границу, которую перейти не удастся, т.к. оборудование делается из компромиссов с бессердечностью физики, а информатика — это почти математика с небольшими чертами физики). Но даже это ограничение можно обойти, если делать распределенный счётчик. Он вполне может быть атомарным и поддерживать все те же операции, что и обычный регистр процессора. Просто целые сервера становятся регистрами.

Так же нужно заметить, что при всех m > 0 максимальная и минимальная производительность счётчика равна R (k). Так происходит, потому что при любых m > 0 значение счётчика не может стать отрицательным (считая, что у нас есть возможность бесконечно записывать числа, иначе будет переполнение).

В то же время для m < 0 существует такое P (критическое значение счётчика, которое в нашем простом примере равна произведению |m| и k), при котором производительность будет в лучшем случае падать по линейному закону при дальнейшем уменьшении его значения. При значениях счётчика свыше P, максимальная производительность счётчика всегда равна R(k), а в худшем случае не может быть ниже R(1). Это очевидно, ведь даже для k=5 запись числа двести восемьдесят пять может быть в виде 285 + 0 + 0 + 0 + 0. Вычесть из 0 значение m - нельзя, т.к. счётчик не подразумевает отрицательных чисел. У читателя с математическим образованием уже давно должно возникнуть ощущение, зачем выше писалось про мат. ожидание и статистику ;)

Описывать счётчик вещественных чисел (т.е. разрешающего отрицательные числа) смысла нет, т.к. это вырожденный вариант закона, при котором максимальная и минимальная производительность счётчика всегда равна R (k) для любых m.

Необходимо отметить, что с ростом коэффициента k растёт неопределённость значения счётчика, т.к. для получения текущего точного значения придётся пожертвовать производительностью. Такой у вас выбор — производительность и неопределённость текущего значения, или точность и минимальная производительность, т.к. вам придётся на время вычисления суммы остановить все операции модификации счётчика.

В данной работе значения k не будет превышать 100, на таких масштабах этот эффект невозможно воспроизвести, но при k > 10^9 уже будет ощутим, особенно если система выполняет очень важную функцию. Но автор понятия не имеет, кому может потребоваться счётчик с производительностью измеряемой миллионами операций в секунду с контролем неотрицательности значения.

Простое демонстрационное решение

Прежде чем приступать к какому-то полноценному решению, нужно попробовать сделать самый вырожденный и простой вариант, который бы удовлетворял минимальным условиям. А именно — консистентность и отказоустойчивость. Очевидно, что для этого нужно использовать СУБД. Смоделируем наш счётчик в виде двух сущностей — баланс и значение баланса.

Структура сущностей

Структура сущностей

У каждого пользователя могут быть деньги, мы можем их записать в виде одной записи в таблице баланс, но мы можем добавить несколько записей и изменять значение баланса одним запросом, не требуя транзакций или блокировки записей. Разумеется, возможны коллизии, которые приведут к ошибке одного из запросов, но эта проблема решаема. Для создания схемы используем запрос:

Создание таблиц

	create sequence _balance_tab_seq;
	CREATE TABLE _balance_tab (
		id BIGINT NOT NULL DEFAULT nextval('_balance_tab_seq'::regclass) PRIMARY KEY ,
		currency INT,
		user_id INT
	);
	
	CREATE INDEX _balance_tab_user_id_idx ON _balance_tab(user_id);
	
	CREATE TABLE _balance_amount_tab (
		balance_id BIGINT NOT NULL REFERENCES _balance_tab(id),
		id BIGINT NOT NULL,
		amount numeric(12, 4) NOT NULL,
		ref_id BIGINT ,
		PRIMARY KEY (balance_id, id) ,
		FOREIGN KEY (balance_id, ref_id) REFERENCES _balance_amount_tab(balance_id, id),
		CHECK (amount >= 0)
	);	

Так же добавим начальные данные:

Заполнение тестовыми данными

	INSERT INTO _balance_tab(currency, user_id)
	SELECT 1, user_id % 100
	FROM generate_series(1, 1000) as user_id
	;
	
	INSERT INTO _balance_amount_tab(balance_id, id, amount, ref_id)
	SELECT bt.id, 1, 1000000, NULL
	FROM _balance_tab bt
	;
	
	INSERT INTO _balance_amount_tab(balance_id, id, amount, ref_id)
	SELECT bt.id, ba_id, 1000 + ba_id, ba_id-1
	FROM _balance_tab bt,
	generate_series(2, 1000) ba_id
	;

Для получения текущего значение любого из баланса нам нужно найти первый элемент одно-связанного списка, который и описывается таблицей _balance_amount_tab. Ссылка на первый элемент в списке очевидно — отсутствует. Так же его идентификатор всегда максимален. Такой подход позволяет всегда получать начало списка простыми командами.

Рассмотрим запрос получения текущего значения баланса (используя оконные функции, но так же возможен вариант с anti-join):

	EXPLAIN (ANALYSE, buffers )
	SELECT ba.*
	FROM  (
		SELECT DISTINCT ON (balance_id) 
			balance_id, 
			max(id) over (partition by balance_id) as id 
			FROM _balance_amount_tab ba
		) AS actual
	JOIN _balance_amount_tab ba ON ba.balance_id = actual.balance_id AND ba.id = actual.id
	WHERE ba.balance_id = 5;

И такой план генерируется что для пустой БД, что для 1 000 000 записей, а так же если забить БД на 16 ГБ данных! Ведь мы по сути берем последний элемент в индексе!

План запроса

	Nested Loop  (cost=0.85..8.94 rows=1 width=29) (actual time=0.172..0.173 rows=1 loops=1)
		Buffers: shared hit=15
		->  Limit  (cost=0.42..0.48 rows=1 width=16) (actual time=0.168..0.168 rows=1 loops=1)
			Buffers: shared hit=11
			->  WindowAgg  (cost=0.42..56.80 rows=996 width=16) (actual time=0.167..0.168 rows=1 loops=1)
				Buffers: shared hit=11
				->  Index Only Scan using _balance_amount_tab_pkey on _balance_amount_tab ba_1  (cost=0.42..41.85 rows=996 width=16) (actual time=0.011..0.066 rows=1000 loops=1)
					Index Cond: (balance_id = 5)
					Heap Fetches: 0
					Buffers: shared hit=11
		->  Index Scan using _balance_amount_tab_pkey on _balance_amount_tab ba  (cost=0.42..8.45 rows=1 width=29) (actual time=0.003..0.003 rows=1 loops=1)
			Index Cond: ((balance_id = 5) AND (id = (max(ba_1.id) OVER (?))))
			Buffers: shared hit=4
	Planning Time: 0.080 ms
	Execution Time: 0.189 ms

Вставка в такую таблицу выглядит так:

	EXPLAIN (ANALYSE, buffers )
	INSERT INTO _balance_amount_tab(balance_id, id, amount, ref_id)
	SELECT ba.balance_id, ba.id + 1, ba.amount + :amount, ba.id
	FROM (SELECT DISTINCT ON (balance_id) balance_id, max(id) over (partition by balance_id) as id
		FROM _balance_amount_tab ba)
		AS actual
	JOIN _balance_amount_tab ba ON ba.balance_id = actual.balance_id AND ba.id = actual.id
	WHERE ba.balance_id = :balanceId;

Нужно только задать идентификатор баланса и число, которое мы хотим добавить к значению. Если нас кто-то обгонит, то наш запрос завершится с ошибкой (нельзя ссылаться на одну и ту же запись в balance_amount дважды). И всего лишь повторим запрос (если на этом балансе ещё есть нужное число для списания).

План выполнения запроса вставки

	Insert on _balance_amount_tab  (cost=0.85..8.95 rows=0 width=0) (actual time=0.277..0.278 rows=0 loops=1)
	Buffers: shared hit=31 dirtied=3 written=1
	->  Nested Loop  (cost=0.85..8.95 rows=1 width=40) (actual time=0.182..0.182 rows=1 loops=1)
			Buffers: shared hit=15
			->  Limit  (cost=0.42..0.48 rows=1 width=16) (actual time=0.177..0.177 rows=1 loops=1)
				Buffers: shared hit=11
				->  WindowAgg  (cost=0.42..56.80 rows=996 width=16) (actual time=0.176..0.176 rows=1 loops=1)
					Buffers: shared hit=11
					->  Index Only Scan using _balance_amount_tab_pkey on _balance_amount_tab ba_1  (cost=0.42..41.85 rows=996 width=16) (actual time=0.013..0.067 rows=1005 loops=1)
						Index Cond: (balance_id = 1)
						Heap Fetches: 5
						Buffers: shared hit=11
		->  Index Scan using _balance_amount_tab_pkey on _balance_amount_tab ba  (cost=0.42..8.45 rows=1 width=21) (actual time=0.002..0.002 rows=1 loops=1)
			Index Cond: ((balance_id = 1) AND (id = (max(ba_1.id) OVER (?))))
			Buffers: shared hit=4
	Planning Time: 0.093 ms
	Trigger for constraint _balance_amount_tab_balance_id_fkey: time=0.038 calls=1
	Trigger for constraint _balance_amount_tab_balance_id_ref_id_fkey: time=0.019 calls=1
	Execution Time: 0.356 ms

Можно сделать 1000 рпс изменения одного кошелька, используя только один баланс! А их может быть несколько!

Теперь, когда мы разобрали простой пример, попробуем изменить сервис Баланс в платёжной системе Mireapay.

В поисках решения

Очевидной проблемой решения выше — отсутствие партицирования. Складывать все данные в одну таблицу — можно, но нужно быть готовым, что много кошельков разместить не удастся, хотя архитектура mireapay позволят шардировать кошельки даже внутри одного узла, но автор этим заниматься не будет в ближайшее время.

Уже на 1 миллионе пользователей, если каждый будет совершать 10 000 покупок в год, то размер БД превысит 100 ГБ. Желательно разделить таблицу на партиции. Очевидно, что сделать партицирование по времени уже не удастся. Поэтому каждый кошелек будет жить в своей партиции, согласно регистрации. Устаревшие записи из таблиц будем удалять в часы простоя системы, что бы PostgreSQL Vaccuum не оказывал влияния на работу остальных систем. Для работы сервиса баланс закрытые платежи уже не нужны и могут спокойно удаляться. Пользователь историю списаний со счета посмотреть через сервис баланс не может, т.к. для этого служит сервис истории контрактов.

Базу данных необходимо полностью переделать, что бы можно было использовать новый алгоритм работы. В коммите приведён код изменений. Очевидно, что совместимости с прошлой версией нет, но система не используется пользователями, пока ещё, поэтому ломать можно.

Оказывается, что с партициями ни один из вариантов запроса не отрабатывает корректно. Планы как для Anti-join, так и для оконной функции оказываются настолько безумными, что использовать их в проде оказывается просто невозможно. Поэтому необходимо подходить к решению такой задачи другим способом.

Автор не видит смысла приводить запросы и планы их выполнения, учитывая стоимости 10к+ (а требуется в самом худшем случае 100) и время выполнения измеряемое в лучшем случае сотнями миллисекунд, поэтому сразу к решению.

В таблицу balance_tab необходимо добавить поле, которое будет указывать на примерно последнее изменение баланса. Так нам не нужно будет искать последний элемент среди всех и наш индекс сразу выдаст несколько строк для проверки.

Вторым изменением является добавление функции, которая будет предоставлять нам данные о последнем изменении баланса, т.к. планировщик не справляется со своей работой, и мы должны делать за него его работу, ограничивая область поиска:

CREATE OR REPLACE FUNCTION current_balance_amount_item(
			_part smallint, 
			_wallet_id BIGINT, 
			_balance_id BIGINT, 
			_offset BIGINT
	) RETURNS JSONB AS
$func$
DECLARE
	results JSONB;
BEGIN
	SELECT jsonb_build_object(
			'id', id,
			'amount', amount,
			'amountWithdraw', amount_withdraw,
			'amountDeposit', amount_deposit
		)
	INTO results
	FROM balance_amount_tab ba
	WHERE ba.part = _part
		AND ba.wallet_id = _wallet_id
		AND ba.balance_id = _balance_id
		AND ba.id >= _offset
		AND NOT EXISTS( SELECT 1
						FROM balance_amount_tab ba1
						WHERE ba1.balance_id = ba.balance_id
							AND ba1.wallet_id = ba.wallet_id
							AND ba1.part = ba.part
							AND ba1.ref_id = ba.id
							AND ba1.id >= _offset
						)
	LIMIT 1;
	
	RETURN results;
END
$func$
	language plpgsql
	immutable;	

Разумеется, теперь стоимость запроса уже не получить от постгреса, потому что он не умеет оценивать функции. Но время выполнения запроса теперь, даже если на балансе 1 миллион изменений не занимает больше 0.1 мс при условии, что мы передаём ей offset не устаревший более чем на 100 позиций.

Теперь для простоты работы можно создать view:

CREATE VIEW balance_view AS
SELECT bt.*, current_balance_amount_item(
				bt.part, 
				bt.wallet_id, 
				bt.id, 
				bt.cached_amount_id
			) as amount
FROM balance_tab bt
;

Поле cached_amount_id необходимо в фоне обновлять, периодически. Либо использовать запрос с передачей множества параметров (batch), так что бы на каждый баланс указывался свой offset.

Последним изменением является создание отдельной партиции для горячих кошельков. Все балансы будут хранится только в этой партиции и они не будут влиять на работу в других таблицах. Разумеется партиций для горячих кошельков может быть несколько. Из-за особенностей запросов, мы никогда не будем искать данные в партициях, которые не соответствуют целевому кошельку.

Тестируем в нагрузке

Для тестирования работы воспользуемся двумя способами — первый попробуем нагрузить БД постоянной генерацией запросов. Для этого было написано простейшее приложение. Его задача проста — нагрузить БД потоком случайных запросов.

Можно развернуть локально образ БД через команду:

docker run --rm --name balance-db -e POSTGRES_PASSWORD=xxXX12341234 -e POSTGRES_USER=user -p 5432:5432 docker.repo.local/mireapay/node/balance/database:latest

Разумеется вместо docker.repo.local/mireapay/node/balance/database: latest вам необходимо подставить название образа на вашей машине, т.к. репозитории автора вам недоступны.

Программа позволяет в несколько потоков осуществлять нагрузку так, как если бы она осуществлялась для горячих кошельков. А именно — все балансы держатся в памяти. Так как основное назначение этих кошельков — работа налоговой, то средства у неё закончится не могут, в принципе. Это невозможно, т.к. налоговая не тратит, а собирает. Поэтому такое кеширование является актуальным и никаким образом сломаться не может. Не забудь поставить лайк статьи, если ты налоговик и любишь «горячие кошельки».

Для специальных кошельков, которые могут потребоваться бизнесу, у которых средства, обычно не бесконечные, кеширование должно выполняться иначе. В проекте mireapay, по крайне мере пока, такие кошельки реализовываться не будут.

При каждом изменении балансов локально обновляем последний идентификатор изменения. Тогда при создании новых записей мы сможем передавать в запрос корректный фильтр, не сильно отличающийся от последнего значения. Если фильтр устарел более чем на 1000 изменений, то это приводит к ощутимому снижению производительности.

На машине автора такой подход позволил добиться 4,5к rps, каждый запрос не превышал 2 мс по времени выполнения. При этом играясь с параметрами автор не смог увеличить эту цифру выше, даже сняв все ограничения, что привело к выполнению запросов за 200 мс, т.к. СУБД и генератор запросов уже конкурировали за ресурсы одного и того же ЦПУ.

Хуже ситуация оказалась при запуске такого же теста на кластер постгреса из мастера и двух реплик. Каждый в своей виртуальной машине (8цпу16озу) под управлением patroni. Доступ к серверам проксируется через haproxy, сами haproxy объеденены через keepalived в виртуальный IP адрес, так что бы при потере доступности любого из балансировщиков — его работу перехватывал другой.

После запуска оказалось, что при 48 потоках и подключениях, а так же 100 балансах (на самом деле их можно было сделать меньше), удалось получить 2000 rps. Нагрузка на ЦПУ мастера при этом не превышала 20% согласно показаниям гипервизора о работе виртуальной машины. Количество неудачных операций (оно не учитывается в приводимом числе rps) не превышало 15%.

При этом на всем протяжении работы теста время обработки запроса не изменялось существенно, за исключением ситуаций, когда OSD Ceph начинали подлагивать из-за недостаточного количества доступных IOPS, что вызывало заморозку работы виртуальных машин, но это происходило очень редко, в отличие от следующего теста.

Нагружаем новый процессор балансов

Теперь, имея на руках такие тесты, можно протестировать наботу нового процессора балансов (MPS-93 и MPS-94).

Так как сервис контрактов не в состоянии нагрузить сервис Баланс предельными нагрузками с его штатной конфигурацией в 8 параллельно обрабатываемых событий, то для этого сделаем отдельное окружение, которое будет читать и писать в специальные топики, но работать с той же БД, что и основа. В этом случае мы сможем напрямую генерировать сообщения в топик для создания платежей.

В ходе теста оказалось, что в один поток невозможно загрузить сервис Баланс сообщениями полностью, поэтому был добавлен второй. В процессе генерации сообщений происходили периодические фризы, что заметно в виде пилы на графике количества созданных платежей.

Графики

ad4030ce695339d72576dfbbab2f82f0.png4d8abb48a9772d0cb2aa1593b82b5a65.png

Можно с уверенностью сказать, что сервис вполне в состоянии обеспечить обработку на скорости 80 платежей в секунду, что значительно превышает возможную нагрузку со стороны сервиса контрактов. Так же следует обратить внимание, что раньше сервис баланс обрабатывал сообщения 700–800 мс под предельной нагрузкой.

Примечания к тестам

  1. В ходе тестирования через спам запросами в БД по какой-то причине нагрузка на ЦПУ СУБД оказывалась ниже, чем такой же профиль нагрузки при тестировании сервиса баланс, хотя разница в количестве запросов была в 20 раз;

  2. При тестировании сервиса баланс в начале нагрузка на ЦПУ СУБД достигала 90%, когда сообщения генерировались в пульсар одним потоком, но когда нагрузка была увеличена и пошло накопление сообщений в беклоге, то нагрузка на ЦПУ СУБД упала до 10–20%;

  3. При тестировании Ceph периодически замораживал работу виртуальных машин, потому что не успевал обрабатывать запросы, вероятно это и послужило причиной нестабильности обработки, фризы были ощутимыми, по 500–1500 мс;

  4. Тесты нужно переделать на адекватной для соответствующей нагрузке дисковой системе, но у автора нет серверных nvme накопителей.

Заключение

В ходе оптимизации автору удалось убрать транзакции и блокировки записей при работе с балансами кошельков. Предложенное решение позволяет в теории убрать ограничение на количество запросов, которое можно выполнить с одним кошельком, на практике это, в следствие ограничения оборудования, разумеется скромнее, и есть жесткий лимит, который в состоянии обеспечить физический сервер.

Предлагаемое решение позволяет проводить платежи параллельно на одном и том же кошельке, позволяя, таким счетам как у налоговой, принимать тысячи платежей в секунду и не быть узким горлышком при реализации налоговой подсистемы для mireapay.

Для работодателей

Работодатель, поспеши завести себе Java-кота в команду! Мышей не ловит, но зато пишет Java-код, а еще проектирует немножко.

https://hh.ru/resume/b33504daff020c31070039ed1f77794a774336

И не забываем, дорогие мои HRы, что если бы Штирлиц был HRом:

91893360d5a1e6663242e9c2a0717045.png

С 1 января 2025 при подписании трудового договора работодатель обязуется выплатить соискателю одну зарплату (+НДФЛ) за каждый месяц начиная с 1 января в качестве невозвращаемого приветственного бонуса.Так с 1 января — 1 зарплата, с 1 февраля 2 зарплаты и т.д. Сумма определяется в момент получения соискателем оффера и в силе в течении 10 рабочих дней, иначе рассчитывается исходя из даты подписания трудового договора за исключением случаев, когда перенос за границы 10 рабочих дней произошел по инициативе соискателя.

© Habrahabr.ru