Три базы данных. Одна атомарная операция. Saga pattern + outbox table решают проблему распределённых транзакций между тенантами.
MySQL не поддерживает распределённые транзакции между разными инстансами
NetworkRequestEntity: pending → accepted → sent → completed. Нажмите на шаг для деталей.
Заявка создана
Контрагент принял
Деньги отправлены
Подтверждено ✓
Брокер-автор создаёт NetworkRequestEntity с типом (send/receive), суммой, валютой, методом.
NetworkService::createRequest( author_broker_id: 1, // Альфа target_broker_id: 2, // Бета (или null = broadcast) type: 'send', currency_code: 'USD', amount: 10000, method: 'cash', city_code: 'DXB' ) → NR-0A5BB4, status: pending
Целевой брокер видит заявку и подтверждает готовность. Фиксируются условия.
NetworkService::acceptRequest(request_id: 'NR-0A5BB4', broker_id: 2) → status: accepted, accepted_at: now() // Альтернативы: rejected, cancelled, expired (по deadline_at)
Автор отмечает отправку с proof (фото, tx_hash крипто, номер перевода).
NetworkService::markSent(request_id: 'NR-0A5BB4', proof: {
type: 'photo',
url: '/uploads/proof-xyz.jpg',
note: 'Передал курьеру Ахмеду в 14:30'
})
→ status: sent, sent_at: now()
Целевой брокер подтверждает получение. Именно здесь происходит атомарная запись в 3 базы.
NetworkService::confirmReceived(request_id: 'NR-0A5BB4', broker_id: 2) // Атомарная операция (Saga): Мастер-БД: ObligationEntity (debtor=1, creditor=2, $10K, active) Тенант Бр1: TransactionEntity (debit, $10K, hash=SHA256(...)) Тенант Бр2: TransactionEntity (credit, $10K, hash=SHA256(...)) → status: completed, completed_at: now()
rejected (отклонена), cancelled (автор отменил),
expired (по deadline_at), disputed (спор — арбитраж через хэш-цепочку).
Каждый шаг идемпотентен. При ошибке — компенсирующая транзакция.
status = pending. Это "резервация" обязательства. Ещё не активно.
Дебет на сумму операции. Обновить AccountEntity. Записать tx_hash.
Кредит на сумму операции. Обновить AccountEntity. Записать tx_hash.
status = active. Все три записи успешны — обязательство активно.
Компенсирующая транзакция: удалить/откатить записи из предыдущих шагов.
ObligationEntity → status = failed. Retry через outbox worker.
Каждый шаг Saga записывается в outbox. Async worker обрабатывает очередь.
| id | saga_id | step | target_db | payload | status | retries |
|---|---|---|---|---|---|---|
| 1 | SAGA-001 | 1 | master | ObligationEntity {debtor:1, creditor:2, $10K} | done | 0 |
| 2 | SAGA-001 | 2 | tenant_1 | TransactionEntity {type:debit, $10K} | done | 0 |
| 3 | SAGA-001 | 3 | tenant_2 | TransactionEntity {type:credit, $10K} | pending | 2 |
| 4 | SAGA-001 | 4 | master | Obligation.status = active | waiting | 0 |
OutboxEntity (мастер-БД, таблица: exchange_outbox) id int, PK, auto-increment saga_id string, indexed // группирует шаги одной операции step int // порядковый номер шага target_db string // master | tenant_{broker_id} entity_class string // ObligationEntity | TransactionEntity payload json // данные для записи status enum: pending|done|failed // статус выполнения retries int, default 0 // количество попыток max_retries int, default 5 error_message text, nullable created_at datetime processed_at datetime, nullable // Worker: каждые N секунд берёт pending записи, // выполняет в порядке step, обновляет status. // При max_retries → alert + manual resolution.
Нажмите на сценарий для деталей
Вася — клиент Альфы. Он в Стамбуле, а у Альфы нет кассира там. Бета есть.
1. Альфа создаёт OrderEntity (type=collect, executor_broker=Бета) counterpart_uid: FO-7a3f8b2c4d1e (Вася) instrument: RUB-CASH, amount: ₽47,500 2. Кассир Беты получает Васю (FO-7a3f...) → принимает ₽47,500 3. Атомарная запись (Saga): Тенант Альфы: AccountEntity (Вася, RUB) += 47,500 Тенант Беты: AccountEntity (кассир, RUB) += 47,500 Мастер-БД: ObligationEntity: debtor=Альфа, creditor=Бета, ₽47,500 // Теперь Альфа должна Бете ₽47,500. Вася получил баланс у Альфы.
Зеркальный сценарий: Вася хочет получить $500, а кассир Альфы далеко.
1. Альфа создаёт OrderEntity (type=payout, executor_broker=Бета) counterpart_uid: FO-7a3f8b2c4d1e (Вася) instrument: USD-CASH, amount: $500 2. Кассир Беты выдаёт Васе $500 3. Атомарная запись (Saga): Тенант Альфы: AccountEntity (Вася, USD) -= 500 Тенант Беты: AccountEntity (кассир, USD) -= 500 Мастер-БД: ObligationEntity: debtor=Альфа, creditor=Бета, $500 // Альфа снова должна Бете. Обязательства накапливаются.
Альфа переводит деньги клиенту Беты напрямую по UID.
1. Альфа создаёт NetworkRequestEntity: type: send, target_broker: Бета counterpart_uid: FO-2b9c1d4e8f3a (клиент Беты) currency: USDT-TRC20, amount: 5,000 2. Бета принимает → Альфа отправляет proof (tx_hash крипто) 3. Бета подтверждает получение → 4-шаговый протокол → completed 4. Saga: Мастер-БД: ObligationEntity: debtor=Бета, creditor=Альфа, 5000 USDT Тенант Альфы: TransactionEntity (debit) Тенант Беты: TransactionEntity (credit) // Обратное направление: Бета должна Альфе. // Если ранее Альфа должна Бете — обязательства частично гасятся (netting).
Три ключевые сущности в мастер-БД
Частичное погашение: send-request привязанный к obligation_id уменьшает remaining_amount
Логика проста, но бессмысленна при 2-5 брокерах. Включается при 10+ активных в сети.
NetworkService::proposeNetting() создан как заглушка — активируется при достижении масштаба.
// Погашение через send-request (Схема Е, 4 шага): Альфа должна Бете: $10K Бета должна Альфе: $7K → Платформа предлагает зачёт на $7K → Обе стороны подтверждают (NetworkRequest type=settlement) → ObligationSettlementEntity: obligation_id=X, amount=$7K → Obligation.remaining_amount: $10K → $3K // Остаётся: Альфа должна Бете $3K
10,000 соло-брокеров не могут иметь 10,000 отдельных баз. Решение: shared sharding.
Все соло-брокеры в общей БД fastotc_shared_01. Таблицы crm_partner, crm_swap, crm_account — все имеют broker_id. Repository добавляет WHERE broker_id = ? автоматически.
Отдельная база на брокера (текущая модель). Полная изоляция, кастомный домен, поддержка команды. AppTenancyInterface::getDb() возвращает выделенное соединение.
broker_id = X из shared-таблицBrokerEntity.broker_mode = FULL// Новые поля в BrokerEntity: broker_mode enum: SOLO | FULL shared_shard_id int // номер shared-базы (для SOLO) referred_by_broker_id int, nullable // кто пригласил (реферал) // Привязка к тарифам: Starter ($0) → SOLO → 3 offline к/а, unlimited online Growth ($29) → SOLO → 50 offline к/а, unlimited online Pro ($99) → FULL → unlimited + team + domain
MySQL не умеет кросс-инстансные транзакции. Saga с outbox table даёт eventual consistency + компенсирующие транзакции при ошибках. Каждый шаг идемпотентен.
pending → accepted → sent → completed. Обе стороны подтверждают. Proof (фото, tx_hash) хранится в json. Атомарная запись в 3 базы только на финальном шаге.
ObligationEntity накапливает долги между брокерами. Netting (взаимозачёт) отложен до масштаба 10+ брокеров. Пока — ручное погашение через send-request.
Бесплатные брокеры не создают 10K отдельных баз. Shared sharding + автоматическая миграция при апгрейде подписки. AppTenancyInterface абстрагирует разницу.