В прошлой статье я разбирал платёжную платформу с точки зрения сметы: три слоя, где уходят человеко‑годы, что можно не писать. Главный вопрос, который она честно оставляла открытым, звучал так: окей, а как это собрать?
Эта статья — ответ. Референсная архитектура: модель учёта, границы транзакций, разбиение на модули, топология кластера, маршруты интеграций и точки восстановления. С кодом и схемами.
Оговорка сразу: это референс‑модель, а не выгрузка из конкретного прода. Код показывает реальный API и рабочие приёмы, но ваша модель учёта будет отличаться — и должна отличаться, об этом ниже отдельно.
Стек: типизированное хранилище redb поверх PostgreSQL, интеграционный движок redb.Route, рантайм redb.Tsak и сервер идентичности redb.Identity. Всё Pro, всё бесплатно на линейке 3.x.
Цикл про redb и redb.Route. Свежие статьи — сверху:
Платёжная платформа на.NET: во что она обходится и что из этого можно не писать
redb 3.4.0: переигрываем упавшее, патчим фреймворк без пересборки и раздаём права
redb.Route — уходим от MassTransit, идём к Apache Camel: Kafka, Scatter‑Gather и транзакции
Apache Camel под.NET: HTTP‑коннектор без ASP.NET MVC + Content‑Based Router
Исходники: github.com/redbase‑app. Про хранилище: redb.ru.
Общая карта
Начнём сверху. Вот вся платформа одной картинкой — дальше разберём каждый блок.
ВНЕШНИЙ МИР браузер/моб. эквайер банк партнёр регулятор │ │ │ │ │ HTTPS HTTPS IBM MQ SFTP выгрузка │ │ │ │ │╔═══════▼═════════════▼══════════▼══════════▼═══════════▼═══════╗║ TSAK WORKER (кластер) ║║ ║║ ┌──────────────┐ ┌──────────────────────────────────────┐ ║║ │ identity │ │ payments │ ║║ │ .tpkg │ │ │ ║║ │ │ │ payments.Api ← HTTP-фасад │ ║║ │ OAuth 2.1 │◄─┼─ payments.Acq ← эквайеры │ ║║ │ OIDC │ │ payments.Bank ← IBM MQ │ ║║ │ SCIM │ │ payments.Files ← SFTP-реестры │ ║║ │ аудит │ │ payments.Recon ← сверка (Quartz) │ ║║ │ │ │ payments.Core ← УЧЁТ + СХЕМЫ │ ║║ └──────┬───────┘ └───────────────┬──────────────────────┘ ║║ │ direct-vm:// │ ║║ │ (без сети) │ ║╚═════════╪══════════════════════════╪══════════════════════════╝ │ │ ┌────▼────┐ ┌────▼────────┐ │ identity│ │ payments │ │ БД │ │ БД │ └─────────┘ └─────────────┘ отдельный PostgreSQL named-redb (Pro)
Четыре вещи, которые стоит заметить на этой схеме сразу, потому что они определяют всё остальное:
Identity живёт в том же воркере, но со своей базой. Обращение к нему из платёжных модулей идёт через direct-vm:// — внутрипроцессный транспорт, без сокета и TLS. При этом снаружи он остаётся нормальным OIDC‑сервером на HTTPS для браузеров и мобильных.
payments.Core не имеет ни одного внешнего транспорта. Это чистый слой учёта: схемы данных, правила проводок, расчёт балансов. Все входы в него — через direct-vm:// из соседних модулей.
Каждый внешний протокол — отдельный модуль. Эквайер отвалился и его модуль надо перевыкатить — банковский контур этого не заметит.
Модулей много, воркер один. Или несколько — но это решение эксплуатации, а не архитектуры, и меняется конфигурацией. К этому вернёмся в разделе про кластер.
Слой учёта: проводка, которая никогда не меняется
Начинаем с модели данных, потому что от неё зависит всё остальное — границы транзакций, идемпотентность, сверка и то, что вы сможете ответить регулятору через три года.
Решение № 1: проводка — append‑only
Главное архитектурное решение всей платформы формулируется одной фразой: объект проводки создаётся один раз и не изменяется никогда. Ни статуса, ни отмены, ни правки суммы. Ошиблись — пишем сторнирующую проводку, а не правим старую.
Почему это важно именно здесь: у redb нет встроенной версионности объектов. Если вы будете апдейтить проводки, историю изменений придётся строить самому. А если не будете — история и есть сам журнал проводок, и строить нечего.
Это не ограничение движка, это правильный учёт. В бухгалтерии так работают двести лет.
[RedbScheme(Name = "payments.posting", Alias = "Проводка")]public class PostingProps{ /// <summary>Ключ бизнес-операции. Одна операция → несколько проводок с одним ключом.</summary> [RedbAlias("Операция")] public string OperationId { get; set; } = ""; /// <summary>Счёт. Плоский id, а не ссылка — см. пояснение ниже.</summary> [RedbAlias("Счёт")] public long AccountId { get; set; } /// <summary>Дельта: положительная — приход, отрицательная — расход.</summary> [RedbAlias("Дельта")] public decimal Delta { get; set; } [RedbAlias("Валюта")] public RedbListItem? Currency { get; set; } [RedbAlias("Вид операции")] public RedbListItem? Kind { get; set; } [RedbAlias("Момент операции")] public DateTimeOffset OccurredAt { get; set; } /// <summary>Ссылка на сторнируемую проводку — только для сторно.</summary> [RedbAlias("Сторно к")] public long? ReversesPostingId { get; set; } [RedbAlias("Основание")] public string? Reference { get; set; }}
Три детали, каждая — осознанный выбор.
decimal Delta → в базе NUMERIC(38,18). Ничего настраивать не нужно: любой decimal в props ложится в колонку с 38 знаками точности, из них 18 после запятой. Комиссии, курсы и НДС считаются без накопления ошибки округления, а восемнадцать знаков — это ровно та точность, в которой номинируется эфир, если завтра появится крипто‑направление.
long AccountId вместо RedbObject<AccountProps>. Ссылки на объекты redb поддерживает нативно, и для доменных сущностей это удобно — весь граф грузится одним вызовом. Но проводка читается миллионами и всегда по счёту, поэтому здесь нужен не граф, а быстрый фильтр по скалярному полю. Плоский long попадает в частичный индекс по числовым значениям и отрабатывает как обычная колонка. Правило простое: граф — там, где объект читают целиком; плоский ключ — там, где по нему фильтруют.
RedbListItem для валюты и вида операции. Это встроенные справочники redb: значение хранится ссылкой на элемент списка, фильтровать можно и по идентичности элемента, и по его строковому значению. Обычные enum‑таблицы с джойнами не нужны.
Решение № 2: у счёта нет поля «баланс»
[RedbScheme(Name = "payments.account", Alias = "Счёт")]public class AccountProps{ [RedbAlias("Номер")] public string Number { get; set; } = ""; [RedbAlias("Владелец")] public long OwnerId { get; set; } [RedbAlias("Валюта")] public RedbListItem? Currency { get; set; } [RedbAlias("Тип счёта")] public RedbListItem? AccountType { get; set; } [RedbAlias("Открыт")] public DateTimeOffset OpenedAt { get; set; } [RedbAlias("Закрыт")] public DateTimeOffset? ClosedAt { get; set; } // Поля Balance здесь НЕТ. Сознательно.}
Поле «баланс» на счёте — самая частая и самая дорогая ошибка в платёжных системах. Оно немедленно порождает два источника правды: баланс в поле и баланс как сумма проводок. Они расходятся. Всегда. Вопрос только в том, когда вы это заметите.
Баланс — это функция от журнала проводок, и точка:
var balance = await redb.Query<PostingProps>() .Where(p => p.AccountId == accountId) .SumAsync(p => p.Delta);
Один запрос, агрегация на стороне БД, никакой выгрузки в память.
Решение № 3: снимок баланса — оптимизация, а не источник правды
На счёте с миллионом проводок суммировать всё каждый раз, конечно, нельзя. Поэтому — снимок:
[RedbScheme(Name = "payments.balance_snapshot", Alias = "Снимок баланса")]public class BalanceSnapshotProps{ [RedbAlias("Счёт")] public long AccountId { get; set; } [RedbAlias("Сумма")] public decimal Amount { get; set; } /// <summary>Снимок учитывает все проводки с id ≤ этого значения.</summary> [RedbAlias("До проводки")] public long UpToPostingId { get; set; } [RedbAlias("Снят")] public DateTimeOffset TakenAt { get; set; }}
Баланс считается как «последний снимок плюс дельты после него»:
var snap = await redb.Query<BalanceSnapshotProps>() .Where(s => s.AccountId == accountId) .OrderByDescending(s => s.UpToPostingId) .FirstOrDefaultAsync();var baseAmount = snap?.Props.Amount ?? 0m;var fromId = snap?.Props.UpToPostingId ?? 0L;var delta = await redb.Query<PostingProps>() .Where(p => p.AccountId == accountId) .WhereRedb(o => o.Id > fromId) .SumAsync(p => p.Delta);var balance = baseAmount + delta;
Ключевое свойство этой конструкции: снимок можно выбросить и пересчитать в любой момент, потому что источник правды — журнал. Снимок повреждён, снимок отстал, снимок посчитан по старой логике — удалили и сняли заново. С полем «баланс» на счёте такой роскоши нет: если оно разошлось, вы уже не знаете, какое значение верное.
Снимки снимаются фоновой задачей по расписанию — это обычный маршрут с планировщиком, о нём в разделе про интеграции.
Решение № 4: идемпотентность — часть модели, а не таблица сбоку
Ключ операции лежит в самой проводке, а не в отдельной таблице «обработанные сообщения». Тогда проверка «эта операция уже проведена?» — обычный запрос:
var alreadyPosted = await redb.Query<PostingProps>() .Where(p => p.OperationId == operationId) .AnyAsync();
Почему так лучше, чем отдельная таблица идемпотентности: невозможно рассинхронизировать. Проводка и отметка о её выполнении — один и тот же объект, записанный одной операцией. Классическая схема с отдельной таблицей допускает состояние «отметка есть, проводки нет» и наоборот — и разгребается оно вручную.
Идемпотентный потребитель на уровне маршрута при этом тоже остаётся, но решает другую задачу — отсекает повторную доставку до того, как мы дошли до базы. Это два разных рубежа, и они не заменяют друг друга.
Модель целиком
┌────────────────┐ ┌─────────────────────┐ │ Account │ │ BalanceSnapshot │ │ payments. │◄────────┤ payments. │ │ account │ 1:N │ balance_snapshot │ │ │ │ │ │ Number │ │ AccountId │ │ OwnerId │ │ Amount │ │ Currency │ │ UpToPostingId ─────┼──┐ │ AccountType │ │ TakenAt │ │ │ (без баланса!) │ └─────────────────────┘ │ └───────┬────────┘ │ │ 1:N │ │ │ ┌───────▼───────────────────────────────┐ │ │ Posting │ │ │ payments.posting │◄──────────┘ │ │ снимок «до» │ OperationId ← идемпотентность │ │ AccountId │ │ Delta ← NUMERIC(38,18) │ │ Currency ← RedbListItem │ │ Kind ← RedbListItem │ │ OccurredAt │ │ ReversesPostingId ──┐ │ │ Reference │ сторно │ └──────────────────────┴────────────────┘ APPEND-ONLY. Не апдейтится.
Обратите внимание, чего в модели нет: таблицы идемпотентности, таблицы истории изменений, поля статуса на проводке, поля баланса на счёте. Каждого из них нет по отдельной причине, и каждая причина — про то, чтобы не заводить второй источник правды.
Границы транзакций: где именно проходит черта
Если предыдущий раздел — про то, что хранить, то этот — про то, что обязано происходить атомарно. Здесь ошибаются чаще всего, и здесь же ошибки самые дорогие.
Правило одно и оно жёсткое:
ВНУТРИ ОДНОЙ ТРАНЗАКЦИИ СНАРУЖИ — НИКОГДА ──────────────────────── ────────────────── ✓ все проводки одной операции ✗ HTTP к эквайеру ✓ запись в аутбокс ✗ публикация в брокер ✓ обновление снимка баланса ✗ отправка письма ✓ отметка идемпотентности ✗ любой сетевой вызов (она же — сама проводка) ✗ ожидание чужого ответа
Причина по‑школьному простая: транзакция держит блокировки, а сетевой вызов может длиться тридцать секунд. Внешний вызов внутри транзакции — это гарантированная деградация под нагрузкой и весёлые дедлоки в три часа ночи.
Отсюда же следует, почему аутбокс не роскошь, а необходимость: опубликовать событие атомарно с проводкой в брокер нельзя, а в свою же базу — можно.
Как это выглядит в коде
public async Task<TransferResult> TransferAsync( long fromAccountId, long toAccountId, decimal amount, string operationId, CancellationToken ct){ // 1. Дешёвый отказ ДО транзакции: повтор отсекаем, не занимая блокировок if (await redb.Query<PostingProps>() .Where(p => p.OperationId == operationId).AnyAsync()) return TransferResult.AlreadyProcessed; await using var tx = await redb.Context.BeginTransactionAsync(); // 2. Блокируем счета СТРОГО В ПОРЯДКЕ ВОЗРАСТАНИЯ id. // Иначе два встречных перевода A→B и B→A встанут в дедлок. var locked = new[] { fromAccountId, toAccountId }; Array.Sort(locked); await redb.LockForUpdateAsync(locked); // 3. Повторная проверка — уже под блокировкой (защита от TOCTOU) if (await redb.Query<PostingProps>() .Where(p => p.OperationId == operationId).AnyAsync()) return TransferResult.AlreadyProcessed; // dispose откатит // 4. Достаточность средств — считаем под той же блокировкой if (await GetBalanceAsync(fromAccountId) < amount) return TransferResult.InsufficientFunds; var now = DateTimeOffset.UtcNow; // 5. Обе проводки — одним батчем, одним round-trip await redb.AddNewObjectsAsync(new[] { Posting(operationId, fromAccountId, -amount, now), Posting(operationId, toAccountId, +amount, now), }); // 6. Событие в аутбокс — В ТОЙ ЖЕ транзакции await redb.SaveAsync(OutboxEvent("transfer.completed", operationId, now)); await tx.CommitAsync(); return TransferResult.Posted;}
Разберём неочевидное.
Двойная проверка идемпотентности — не паранойя. Первая, до транзакции, отсекает массовые повторы дёшево: не берёт блокировок и не мешает другим. Вторая, под блокировкой, закрывает окно между проверкой и записью. Без первой система деградирует на всплеске ретраев, без второй — пропускает двойное проведение.
Сортировка идентификаторов перед блокировкой обязательна. Это классика, но её регулярно забывают. Два встречных перевода — A→B и B→A — блокируют счета в противоположном порядке и встают намертво. Единый порядок захвата снимает целый класс дедлоков; тем же приёмом пользуется и само хранилище внутри пакетных операций.
Проверка баланса — под той же блокировкой, что и запись. Проверили без блокировки — получили классическую гонку с двойным списанием.
Обе проводки пишутся батчем. AddNewObjectsAsync уходит одним обращением, а не двумя. На переводе разница невелика, на массовых начислениях — принципиальная.
Если управление откатом не нужно, есть форма короче:
await redb.Context.ExecuteAtomicAsync(async () =>{ await redb.AddNewObjectsAsync(postings); await redb.SaveAsync(outboxEvent);});
Что здесь важно для будущего шардинга
Обратите внимание: вся транзакция целиком укладывается в одну базу. Это не случайность, а требование, которое надо заложить сразу.
Распределённой транзакции между шардами в кроссплатформенном.NET нет — двухфазный коммит поверх двух PostgreSQL недоступен. Значит если платформа когда‑нибудь поедет вширь, ключ шардирования обязан выбираться так, чтобы обе стороны перевода лежали в одном шарде. Практически это означает шардирование по клиенту или по группе счетов, а не по идентификатору платежа.
Решение принимается один раз, в начале. Переигрывать его на живых данных — отдельный проект.
Схема границ
HTTP-запрос │ ▼ ┌──────────────────────────────────────────────┐ │ 1. Проверка идемпотентности (без блокировок)│ ← вне транзакции └───────────────────┬──────────────────────────┘ │ ╔═══════════════════▼═══════════════════════════╗ ║ ТРАНЗАКЦИЯ ║ ║ ║ ║ 2. LockForUpdate(счета, по возрастанию id) ║ ║ 3. Проверка идемпотентности повторно ║ ║ 4. Проверка достаточности средств ║ ║ 5. AddNewObjects(проводки) ║ ║ 6. Save(событие в аутбокс) ║ ║ ║ ║ COMMIT ────────────────────────────┐ ║ ╚═══════════════════════════════════════════╪═══╝ │ ┌───────────────────────┘ │ дальше — асинхронно, вне транзакции ▼ ┌───────────────────────────────────────────────┐ │ Sql.Poll(аутбокс) → Kafka → эквайер / банк │ │ Здесь можно ждать сеть сколько угодно: │ │ блокировки уже отпущены, деньги уже проведены│ └───────────────────────────────────────────────┘
Разбиение на модули: единица деплоя = единица отказа
Модуль в Tsak — это .tpkg: собранный пакет с точкой входа, который рантайм загружает в свой процесс, даёт ему отдельный контекст маршрутов, отдельный загрузчик сборок и отдельный контейнер зависимостей.
Критерий нарезки простой и он не про «слои» и не про «домены»:
Модуль — это то, что вы захотите выкатить или откатить отдельно от остального.
Отсюда естественным образом получается нарезка по внешним протоколам, а не по бизнес‑сущностям.
┌─────────────────────────────────────────────────────────┐ │ payments.Core │ │ ──────────── │ │ • схемы: Account, Posting, BalanceSnapshot, Outbox │ │ • учёт: TransferAsync, GetBalanceAsync, Reverse │ │ • справочники: валюты, виды операций │ │ • НИ ОДНОГО внешнего транспорта │ │ │ │ Вход: direct-vm://payments-post │ │ direct-vm://payments-balance │ └────▲──────▲──────────▲──────────▲──────────▲────────────┘ │ │ │ │ │ │ │ │ │ │ direct-vm:// │ │ │ │ │ ┌────┴───┐ ┌┴──────┐ ┌─┴──────┐ ┌─┴──────┐ ┌─┴────────┐ │ .Api │ │ .Acq │ │ .Bank │ │ .Files │ │ .Recon │ │ │ │ │ │ │ │ │ │ │ │ HTTP │ │ HTTP │ │ IBM MQ │ │ SFTP │ │ Quartz │ │ приём │ │эквайер│ │ банк │ │реестры │ │ сверка │ └────────┘ └───────┘ └────────┘ └────────┘ └──────────┘ фасад исход. исход. файлы расписание
Что даёт такая нарезка на практике:
Эквайер сменил формат — перевыкатывается payments.Acq. Банковский контур, приём платежей и сверка этого не замечают: их модули не перезагружались.
Учёт правится реже всего и выкатывается осторожнее всего. payments.Core — единственный модуль, который трогает деньги. Его релизный цикл отличается от остальных, и это нормально: у него и правок меньше.
Новый эквайер — это новый модуль, а не правка существующего. Он не может сломать работающий, потому что физически лежит отдельно.
Отвалившийся SFTP не уронит приём платежей. У каждого модуля свой контекст маршрутов и свои соединения.
Точка входа модуля
public static class InitRoute{ public static IRouteContext main(IRouteContext context) { // Транспорты, нужные ЭТОМУ модулю — и только они context.AddComponent(new HttpComponent()); // Именованный экземпляр хранилища: у платежей своя база, // у identity — своя, они не делят ни соединения, ни кэши var redb = context.GetRedbService("payments"); context.AddRouteBuilder(new PaymentsApiRouteBuilder()); return context; }}
Именованное хранилище здесь — не деталь, а важное свойство: экземпляр создаётся на каждый обмен и живёт ровно столько, сколько живёт обработка сообщения. Соединение не превращается в общий разделяемый ресурс, и конкурентные запросы не встают в очередь к одному соединению.
Как модули зовут друг друга
Внутри одного воркера — без сети:
public class PaymentsApiRouteBuilder : RouteBuilder{ protected override void Configure() { From(Http.Listen("/api/payments/transfer").Port(8080).InOut()) .RouteId("api-transfer") .Unmarshal(typeof(JsonMessageSerializer), typeof(TransferRequest)) .Process(RequireScope("payments:write")) // проверка прав .IdempotentConsumer(e => e.Message.GetHeader<string>("Idempotency-Key")) .To("direct-vm://payments-post") // ← в ядро учёта, без сети .Marshal(typeof(JsonMessageSerializer)) .Respond(); }}
direct-vm:// — внутрипроцессный транспорт между контекстами маршрутов. Ни сокета, ни сериализации, ни TLS: тот же поток, тот же объект обмена.
И тут ключевое архитектурное свойство: если завтра payments.Core понадобится вынести в отдельный воркер, меняется строка URI, а не код. direct-vm://payments-post превращается в rabbitmq://payments-post или grpc://.../Post — и всё. Решение о том, монолит это или распределённая система, откладывается до эксплуатации и меняется конфигурацией.
Это, пожалуй, главное, ради чего стоит разбивать на модули именно так.
Слой интеграций: маршруты
Аутбокс — и почему он не redb‑объект
Единственное место, где мы сознательно уходим от типизированного хранилища к плоской таблице:
CREATE TABLE payments_outbox ( id bigserial PRIMARY KEY, event_type text NOT NULL, operation_id text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), processed boolean NOT NULL DEFAULT false, processed_at timestamptz);CREATE INDEX ix_outbox_pending ON payments_outbox (id) WHERE processed = false;
Почему так, если весь домен лежит в redb: аутбокс — это инфраструктура, а не предметная область. Он плоский по своей природе, живёт секунды, читается пачками по одному предикату и удаляется. Типизация, граф объектов и эволюция схемы ему не нужны — а вот частичный индекс по необработанным и опрос пачками нужны очень.
И это ровно та ситуация, ради которой важно, что структура хранилища открыта: в одной транзакции можно писать и в redb, и обычным SQL, потому что контекст и соединение общие.
await redb.Context.ExecuteAtomicAsync(async () =>{ await redb.AddNewObjectsAsync(postings); // домен → redb await redb.Context.ExecuteAsync( // инфраструктура → SQL "INSERT INTO payments_outbox (event_type, operation_id, payload) VALUES (@t, @o, @p)", eventType, operationId, payloadJson);});
Правило, которое из этого выводится: redb — для того, что вы читаете, ищете и эволюционируете. Плоский SQL — для того, что вы прокачиваете насквозь. Смешивать в одной транзакции можно, и это нормально.
Дальше — публикатор:
From(Sql.Poll("SELECT * FROM payments_outbox WHERE processed = false ORDER BY id LIMIT 200") .DataSource("payments") .Delay(500) .OnSuccess("UPDATE payments_outbox SET processed = true, processed_at = now() " + "WHERE id = ANY(@ids)") .Transacted()) .RouteId("outbox-publisher") .Split(Body()) .To(Kafka.Topic("payments.events") .Acks("All") .EnableIdempotence(true) .EnableTransactionalProducer(true) .TransactionIdPrefix("payments-outbox"));
OnSuccess выполняется в той же области транзакции, что и публикация: либо событие ушло и помечено обработанным, либо не ушло и не помечено. Промежуточного состояния нет.
Исходящий платёж к эквайеру: где ставить точку сохранения
From(Kafka.Topic("payments.events").GroupId("acq")) .RouteId("acq-charge") .Filter(Header("event_type").isEqualTo("transfer.completed")) .Replayable("acq-charge") // ← ТОЧКА СОХРАНЕНИЯ .Process(BuildAcquirerRequest) .Retry(3).RedeliveryDelay(2000).UseExponentialBackOff() .To(Http.Post("https://acq.example.com/v1/charge").Timeout(15_000)) .Process(ParseAcquirerResponse) .To("direct-vm://payments-mark-charged") .EndReplayable();
Точка сохранения ставится после входа в маршрут и до первого внешнего вызова — то есть на границе «состояние уже собрано, но наружу мы ещё не ходили». Если эквайер лёг, ретраи не помогли и обмен упал, снимок уедет в очередь недоставленного, а оператор поддержки нажмёт «Переиграть» из дашборда, когда эквайер поднимется.
Важная деталь, которую движок проверяет за вас: точку сохранения нельзя бездумно ставить на маршрут, где повторной доставкой уже управляет брокер или транзакция. Иначе за ретрай отвечают двое, и это прямой путь к двойному списанию. Здесь маршрут не помечен .Transacted() именно поэтому: за повтор отвечает механизм точек сохранения, а не Kafka.
[СКРИН: страница Dead‑letter в дашборде Tsak — список застрявших операций с причиной и кнопкой Replay]
Банк на IBM MQ: тут наоборот, транзакция
From(Wmq.Queue("PAYMENTS.IN").QueueManager("QM.PROD").Transacted(true)) .RouteId("bank-inbound") .Transacted() // ← подтверждение и отправка коммитятся вместе .Unmarshal(typeof(Iso20022Codec)) .ValidateXsd(Schemas.Pacs008) // валидация по официальной схеме — штатный шаг .Process(MapToPostingRequest) .To("direct-vm://payments-post") .To(Wmq.Queue("PAYMENTS.ACK").Transacted(true));
Здесь модель ровно обратная предыдущей: повторной доставкой управляет очередь. При падении — откат, сообщение возвращается в очередь, счётчик неудачных обработок растёт, и после порога сообщение уезжает в отдельную очередь разбора вместо бесконечного круга.
Разбор ISO 20 022 пишете вы (готовых кодеков в поставке нет — профили у всех разные), а вот валидация по XSD встроенная, и это половина работы.
Реестры по SFTP
From(Sftp.Poll("/in/registry").Include("*.xml").Delay(60_000).Move("/in/done")) .RouteId("files-registry") .ValidateXsd(Schemas.Registry) .Split(XPath("//Payment")) .Threads(8) // разбор пачки параллелим .Process(MapToPostingRequest) .To("direct-vm://payments-post") .EndSplit() .To("log://registry-done");
.Threads(8) здесь принципиально: источник опрашивается последовательно, а вот разбор реестра на десять тысяч строк параллелится по пулу. Порядок внутри реестра при этом теряется — для начислений это допустимо, для последовательности операций по одному счёту нет, и тогда параллелить надо по счетам, а не по строкам.
Сверка по расписанию
From(Quartz.Cron("0 30 3 * * ?")) // каждый день в 03:30 .RouteId("recon-daily") .Process(async (e, ct) => { var redb = e.Context.GetRedbService("payments", e); var byMerchant = await redb.Query<PostingProps>() .WhereRedb(o => o.DateCreate >= DateTime.Today.AddDays(-1)) .GroupBy(p => p.MerchantId) .SelectAsync(g => new { g.Key, Total = Agg.Sum(g, p => p.Delta) }); e.Message.SetBody(byMerchant); }) .To("direct-vm://recon-compare") // сверить с выпиской эквайера .To(Sql.Insert("recon_report"));
Агрегация считается на стороне БД по боевым данным. Отдельный аналитический контур для этого не нужен — он понадобится позже и для другого, о чём ниже.
Карта маршрутов
ВХОД ЯДРО УЧЁТА ВЫХОД ──── ────────── ───── HTTP /transfer ──┐ │ IBM MQ PAY.IN ───┼──► direct-vm:// ┌──► payments_outbox (transacted) │ payments-post ────────┤ (та же транзакция) │ │ └──► проводки в redb SFTP реестры ────┘ │ (Threads 8) ▼ ┌─────────┐ Quartz 03:30 ────────►│ redb │ (сверка) │payments │ └─────────┘ ▲ │ ┌─────────────────────┘ │ Sql.Poll(outbox) ──► Kafka (EOS) ──┬──► эквайер (HTTP) │ .Transacted() │ └ .Replayable │ └──► уведомления └─ OnSuccess: processed = true
Фасад: где стоит авторизация
Сервер идентичности живёт в том же воркере, но со своей базой. Это важно: платёжные данные и OAuth‑записи не делят ни соединения, ни транзакции, ни кэши. Разнести их по разным серверам БД — вопрос строки подключения, а не переписывания.
ВНЕШНИЕ КЛИЕНТЫ ВНУТРЕННИЕ МОДУЛИ ─────────────── ───────────────── браузер, мобильное payments.Api приложение, партнёр payments.Acq │ │ │ HTTPS │ direct-vm:// │ (стандарт требует │ (без сети, │ браузер только для │ без TLS, │ /authorize) │ без JSON) ▼ ▼ ┌─────────────────────────────────────────────────┐ │ redb.Identity │ │ │ │ /connect/token direct-vm://identity-token │ │ /connect/introspect direct-vm://identity-... │ │ /connect/authorize ← только это требует HTTP │ │ /scim/v2/* │ │ │ │ 79 типов событий аудита ──► redb + SIEM │ └──────────────────┬───────────────────────────────┘ │ ┌───────▼────────┐ │ identity БД │ ← отдельная от payments └────────────────┘
Проверка прав на входе в платёжный API:
.Process(async (e, ct) =>{ var token = e.Message.GetHeader<string>("Authorization")?["Bearer ".Length..]; // Интроспекция БЕЗ сетевого вызова — тот же процесс var result = await _identity.RequestBody<IntrospectionResponse>( IdentityEndpoints.Introspect, new { token }); if (result?.Active != true || !result.Scopes.Contains("payments:write")) throw new UnauthorizedException(); e.SetProperty("subject", result.Subject);})
Разница с обычной схемой здесь не косметическая. В классике каждый внутренний вызов означает поход по сети к серверу идентичности — и на заметном трафике он становится и узким местом, и единой точкой отказа, и постоянной добавкой к времени ответа. Здесь это вызов метода.
При этом снаружи сервер остаётся нормальным OIDC‑провайдером: браузерная часть работает по HTTPS, потому что так требует стандарт, а всё остальное — выдача, обновление, интроспекция, отзыв, управление, SCIM — транспортно‑нейтрально.
Отсюда же вытекает возможность, которую стоит держать в голове при проектировании закрытых контуров: входов в сервер может быть столько, сколько у вас каналов. Межфилиальный сегмент со своей криптографией, площадка, где разрешена только корпоративная шина, партнёрский канал со своим форматом — это отдельные модули‑переходники перед общим ядром. Ядро одно, права одни, аудит один; разные только адаптеры.
Топология кластера: ноды не обязаны быть одинаковыми
Здесь начинается эксплуатация, и здесь же — свойство, которое в архитектуру закладывают редко, а зря.
Координатор в Tsak трёхуровневый: кластер → группа → нода. Группа — это географическое или логическое разделение. А размещение хранится по модулю, с привязкой к группе — то есть нода не реплика соседа, а носитель конкретного набора модулей.
cluster: production │ ├── group: acquiring ← горячий контур, много нод │ ├── node-acq-1 [payments.Api, payments.Acq, payments.Core] │ ├── node-acq-2 [payments.Api, payments.Acq, payments.Core] │ └── node-acq-3 [payments.Api, payments.Acq, payments.Core] │ ├── group: payouts ← банковский контур, отдельная сеть │ ├── node-pay-1 [payments.Bank, payments.Files, payments.Core] │ └── node-pay-2 [payments.Bank, payments.Files, payments.Core] │ └── group: reporting ← регламент, одна нода достаточно └── node-rep-1 [payments.Recon, identity] Лидер: выбирается на весь кластер, с эпохой и фенсингом. Устаревший лидер не может испортить состояние после потери выборов.
Что это даёт архитектурно:
Контуры разделены по нагрузке и по критичности, но не разведены в разные системы. Эквайринговый контур масштабируется горизонтально под пики, банковский живёт на двух нодах в отдельной сети, регламентные задачи — на одной. При этом это один кластер, один пульт, один аудит.
Модуль ядра учёта присутствует в двух группах. Он нужен и приёму, и выплатам. Это нормально: модуль stateless, состояние в базе.
Планировщик знает про кластер. Регламентная сверка помечается как задача лидера и не запускается одновременно на трёх нодах.
Конфигурация при этом одна на все ноды — различаются идентификатор ноды, её адрес и группа:
{ "ConnectionStrings": { "Postgres": "Host=db.cluster;Database=payments;..." }, "Tsak": { "Storage": { "Type": "Redb" }, "Cluster": { "Enabled": true, "ClusterName": "production", "GroupName": "acquiring", // ← отличается по группе "NodeId": "node-acq-1", // ← отличается по ноде "ApiEndpoint": "http://node-acq-1:9090", "HeartbeatIntervalSeconds": 15, "DeadNodeTimeoutSeconds": 60, "LeaderLockTtlSeconds": 30 }, "HotReload": { "RollingUpdate": true }, // катим по нодам последовательно "Auth": { "Enabled": true } }}
Вывод ноды на обслуживание — не выключение, а дренирование:
tsak cluster cordon node-acq-2 # новую работу не берёт, начатую дорабатывает# ... обслуживание ...tsak cluster uncordon node-acq-2
Карта отказов: что ломается и что это чинит
Архитектура проверяется не тем, как она работает, а тем, как она отказывает. Пройдёмся по сценариям.
|
Что случилось |
Что происходит |
Кто чинит |
|---|---|---|
|
Процесс упал между проводкой и публикацией |
Проводка закоммичена, событие в аутбоксе не помечено обработанным. Публикатор подберёт после старта |
Само |
|
Дубль сообщения из брокера |
Идемпотентный потребитель отсекает по ключу; если проскочило — проверка |
Само, два рубежа |
|
Эквайер отвечает 500 |
Три ретрая с экспоненциальной задержкой. Не помогло — снимок в очередь недоставленного |
Поддержка кнопкой |
|
Эквайер лежит час |
То же, но снимков накопится много. Поднялся — массовое переигрывание |
Поддержка |
|
Банк вернул ошибку по MQ |
Откат транзакции, сообщение обратно в очередь, счётчик неудач растёт; после порога — в очередь разбора |
Само + разбор |
|
Два встречных перевода одновременно |
Блокировка счетов в едином порядке по возрастанию id — дедлока нет |
Само |
|
Нода умерла |
Хартбиты пропали, лидер переназначил её модули на живые ноды |
Само |
|
Умер лидер |
Новые выборы по истечении TTL блокировки; эпоха инкрементируется, старый лидер не сможет навредить |
Само |
|
Снимок баланса разошёлся |
Удалить и пересчитать: источник правды — журнал проводок |
Регламентная задача |
|
Выкатили плохую версию модуля |
Загрузить предыдущий |
Одна команда |
|
Неподписанный модуль в каталоге |
Отклонён на границе загрузки, до выполнения хоть одной строки его кода |
Само |
Обратите внимание на распределение в колонке справа: подавляющее большинство сценариев закрывается механизмами, а не человеком. Человек нужен там, где отказ чужой — а его чинить не нам.
Где точки сохранения, а где нет
Правило, которое стоит зафиксировать в чек‑листе ревью:
.Transacted() ─── повтором управляет брокер/транзакция → точку сохранения НЕ ставить (иначе за ретрай отвечают двое) .Replayable("имя") ─── повтором управляем мы → ставить ПОСЛЕ входа, ДО первого внешнего вызова ни то, ни другое ─── повтора нет вообще → осознанное решение, а не забывчивость
Движок предупредит, если точка сохранения оказалась на транзакционном маршруте, — но лучше ловить это на ревью.
Чек‑лист архитектурных решений
Свод того, что придётся решить, — в порядке, в котором эти решения стоит принимать. Первые три меняются потом дороже всего.
1. Ключ шардирования — до того, как понадобится шардинг. Обе стороны перевода обязаны лежать в одном шарде: распределённой транзакции не будет. Шардируйте по клиенту или группе счетов, не по идентификатору платежа.
2. Проводка append‑only. Никаких статусов, никаких правок. Ошибка исправляется сторно. Это же снимает вопрос истории изменений — журнал и есть история.
3. Баланса как поля не существует. Только журнал плюс снимок как кэш. Снимок обязан быть выбрасываемым и пересчитываемым.
4. Идемпотентность в двух рубежах. На маршруте — против повторной доставки. В модели — ключ операции внутри проводки, проверяется под блокировкой.
5. Порядок захвата блокировок — единый. По возрастанию идентификатора, всегда.
6. Границы транзакции — без сетевых вызовов. Всё внешнее уходит за аутбокс.
7. Аутбокс плоский, домен типизированный. Смешивать в одной транзакции — нормально.
8. Нарезка модулей по внешним протоколам. Единица деплоя = единица отказа.
9. Ядро учёта без транспортов. Все входы через внутрипроцессный вызов — тогда вынос в отдельный воркер станет сменой строки URI.
10. У сервера идентичности своя база. Разнести потом — вопрос строки подключения.
11. Группы кластера по контурам, а не по «одинаковости». Ноды несут разные наборы модулей.
12. Точки сохранения там и только там, где повтором управляем мы.
Что осталось за кадром
Честно про границы этой референсной модели.
Модель учёта у вас будет другая. Показанная — минимальная: счета, проводки, снимки. Реальная обрастёт планом счетов, аналитическими разрезами, мультивалютностью с переоценкой, резервированием средств и правилами тарификации. Это ваша предметная область, и никакой вендор её за вас не спроектирует.
Кодеки финансовых форматов пишете сами. Транспорт, кадрирование и валидация по схеме есть; разбор конкретного профиля ISO 20 022 или ISO 8583 — ваш. И всё равно был бы ваш: профили у всех разные.
Регулируемое открытое банкинг‑взаимодействие требует доработки. Взаимная аутентификация по сертификатам, детализированные запросы авторизации, развязанное подтверждение и повышение уровня аутентификации под операцию — не реализованы. Криптооснова под них в сервере есть, это очерченный спринт.
Аналитический контур появится. Встроенная агрегация закрывает операционные запросы — остаток по лимиту, сегодняшнее расхождение, окно для правила антифрода. Годовой срез в десяти разрезах на боевой базе не считают ни на каком хранилище, и витрины вы построите. Просто не в первый год и не как условие запуска — а регламентная задача «собрать агрегаты и разложить плоско» это обычный маршрут с планировщиком.
Шардинг — конструктор. Механизмы есть: генерация ключей блоками с возможностью вынести источник в отдельную базу, изоляция кэшей по подключению, несколько независимых хранилищ в одном модуле. Собранного решения нет. Это тема отдельной статьи, и она в работе — там же будет про пустую базу с одной секвенцией в роли источника ключей и про то, почему авария на нём чинится одной строкой SQL.
Итог
Референсная модель в одном абзаце: проводки append‑only и баланс как функция от них; транзакция, внутри которой нет ни одного сетевого вызова; аутбокс как мост в асинхронный мир; модули, нарезанные по внешним протоколам; ядро учёта без транспортов, куда ходят внутрипроцессно; кластер, где ноды несут разные наборы модулей; и точки сохранения ровно там, где повтором управляем мы.
Ничего из этого не является изобретением — это дисциплина, известная тем, кто строил платёжные системы. Разница в том, сколько кода нужно, чтобы её реализовать: транзакции, блокировки, точная арифметика, идемпотентность, аутбокс, транспорты, точки сохранения, кластер и пульт эксплуатации здесь уже есть, и остаётся описать свой учёт.
Про то, во что этот слой обходится, если писать его самому, — в парной статье с разбором сметы.
Если строите похожее — расскажите в комментариях, какие решения у вас разошлись с этими и почему. Особенно интересны те, где вы выбрали иначе и не пожалели: такие развилки полезнее любого списка возможностей.
Исходники и релизы: github.com/redbase‑app. Про хранилище redb: redb.ru. Прошлые статьи цикла — в профиле.
If this was useful — a ⭐ on GitHub helps others find it.
ссылка на оригинал статьи https://habr.com/ru/articles/1064218/