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.

Банк на 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.