В прошлой статье я разбирал платёжную платформу с точки зрения сметы: три слоя, где уходят человеко‑годы, что можно не писать. Главный вопрос, который она честно оставляла открытым, звучал так: окей, а как это собрать?Эта статья — ответ. Референсная архитектура: модель учёта, границы транзакций, разбиение на модули, топология кластера, маршруты интеграций и точки восстановления. С кодом и схемами.Оговорка сразу: это референс‑модель, а не выгрузка из конкретного прода. Код показывает реальный API и рабочие приёмы, но ваша модель учёта будет отличаться — и должна отличаться, об этом ниже отдельно.Стек: типизированное хранилище redb поверх PostgreSQL, интеграционный движок redb.Route, рантайм redb.Tsak и сервер идентичности redb.Identity. Всё Pro, всё бесплатно на линейке 3.x. Читать далее

redb fintech
В прошлой статье я разбирал платёжную платформу с точки зрения сметы: три слоя, где уходят человеко‑годы, что можно не писать. Главный вопрос, который она честно оставляла открытым, звучал так: окей, а как это собрать?
Эта статья — ответ. Референсная архитектура: модель учёта, границы транзакций, разбиение на модули, топология кластера, маршруты интеграций и точки восстановления. С кодом и схемами.
Оговорка сразу: это референс‑модель, а не выгрузка из конкретного прода. Код показывает реальный 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‑таблицы с джойнами не нужны.
[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 — и всё. Решение о том, монолит это или распределённая система, откладывается до эксплуатации и меняется конфигурацией.
Это, пожалуй, главное, ради чего стоит разбивать на модули именно так.

модульная карта
Единственное место, где мы сознательно уходим от типизированного хранилища к плоской таблице:
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.
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 встроенная, и это половина работы.
Реестры по SFTPFrom(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.