Референсная архитектура платёжной платформы на.NET: схемы, границы транзакций, модули, кластер

от автора

redb fintech

redb fintech

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

Эта статья — ответ. Референсная архитектура: модель учёта, границы транзакций, разбиение на модули, топология кластера, маршруты интеграций и точки восстановления. С кодом и схемами.

Оговорка сразу: это референс‑модель, а не выгрузка из конкретного прода. Код показывает реальный API и рабочие приёмы, но ваша модель учёта будет отличаться — и должна отличаться, об этом ниже отдельно.

Стек: типизированное хранилище redb поверх PostgreSQL, интеграционный движок redb.Route, рантайм redb.Tsak и сервер идентичности redb.Identity. Всё Pro, всё бесплатно на линейке 3.x.

Цикл про redb и redb.Route. Свежие статьи — сверху:

Исходники: 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
топология кластера

топология кластера

Карта отказов: что ломается и что это чинит

Архитектура проверяется не тем, как она работает, а тем, как она отказывает. Пройдёмся по сценариям.

Что случилось

Что происходит

Кто чинит

Процесс упал между проводкой и публикацией

Проводка закоммичена, событие в аутбоксе не помечено обработанным. Публикатор подберёт после старта

Само

Дубль сообщения из брокера

Идемпотентный потребитель отсекает по ключу; если проскочило — проверка OperationId под блокировкой в транзакции

Само, два рубежа

Эквайер отвечает 500

Три ретрая с экспоненциальной задержкой. Не помогло — снимок в очередь недоставленного

Поддержка кнопкой

Эквайер лежит час

То же, но снимков накопится много. Поднялся — массовое переигрывание

Поддержка

Банк вернул ошибку по MQ

Откат транзакции, сообщение обратно в очередь, счётчик неудач растёт; после порога — в очередь разбора

Само + разбор

Два встречных перевода одновременно

Блокировка счетов в едином порядке по возрастанию id — дедлока нет

Само

Нода умерла

Хартбиты пропали, лидер переназначил её модули на живые ноды

Само

Умер лидер

Новые выборы по истечении TTL блокировки; эпоха инкрементируется, старый лидер не сможет навредить

Само

Снимок баланса разошёлся

Удалить и пересчитать: источник правды — журнал проводок

Регламентная задача

Выкатили плохую версию модуля

Загрузить предыдущий .tpkg; старый контекст дренируется, новый стартует

Одна команда

Неподписанный модуль в каталоге

Отклонён на границе загрузки, до выполнения хоть одной строки его кода

Само

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

Где точки сохранения, а где нет

Правило, которое стоит зафиксировать в чек‑листе ревью:

   .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/