Проектирование Venmo и Zelle P2P платежи
"Money is only a tool. It will take you wherever you wish, but it will not replace you as the driver." — Ayn Rand
Peer-to-peer платежи радикально изменили повседневные финансовые взаимодействия. Разделить счёт в ресторане, заплатить за аренду, вернуть долг другу — всё это делается в два тапа по экрану. Venmo обрабатывает более 180 миллиардов. За кажущейся простотой «отправить $20 другу» скрывается сложнейшая инфраструктура: мгновенные переводы между банками, социальная лента платежей, защита от мошенничества, compliance с десятками финансовых регуляций и гарантия того, что ни один цент не потеряется и не будет продублирован.
В этой главе мы спроектируем P2P-платёжную систему, которая объединяет ключевые возможности Venmo и Zelle: социальные платежи с лентой активности, мгновенные переводы, привязку банковских счетов, запросы на оплату, разделение счетов и QR-платежи.
1 Введение
Почему P2P-платежи интересны с точки зрения System Design
P2P-платёжная система — это пересечение нескольких сложных доменов:
-
Финансовая корректность. Ни одна транзакция не может быть потеряна, продублирована или проведена частично. Это не лента постов, где потеря одного поста — мелкая неприятность. Здесь ошибка в $0.01 — инцидент.
-
Мгновенность. Пользователь ожидает, что деньги дойдут за секунды. Но за сценой — цепочка из нескольких банковских систем, каждая со своими таймаутами и протоколами.
-
Социальный компонент. Venmo добавил к платежам социальную ленту — революционная идея, превратившая скучный финансовый инструмент в социальную сеть. Это создаёт уникальные требования к приватности и масштабированию.
-
Регуляторная сложность. KYC (Know Your Customer), AML (Anti-Money Laundering), PCI DSS, лимиты на переводы, налоговая отчётность (1099-K в США) — регуляции определяют архитектуру не меньше, чем технические требования.
-
Защита от мошенничества. Деньги привлекают мошенников. Система должна в реальном времени оценивать риск каждой транзакции.
Какие компании используют подобные системы
- Venmo (PayPal) — социальные P2P-платежи, популярны среди молодёжи в США
- Zelle — банковский консорциум, платежи между банковскими счетами без посредника
- Cash App (Block/Square) — P2P-платежи + инвестиции + Bitcoin
- Revolut, Wise — международные P2P-переводы
- WeChat Pay, Alipay — P2P-платежи в Китае (суперапп-модель)
- СБП (Система быстрых платежей) — российский аналог Zelle
- PIX (Бразилия) — мгновенные P2P-переводы через центральный банк
2 Функциональные требования
Core features (must-have)
- Отправка денег. Пользователь отправляет деньги другому пользователю по номеру телефона, email или username.
- Запрос денег (Payment Request). Пользователь запрашивает оплату от другого пользователя. Получатель запроса может принять или отклонить.
- Баланс кошелька. Внутренний баланс, с которого отправляются и на который принимаются платежи.
- Привязка банковского счёта / карты. Пополнение кошелька и вывод средств на банковский счёт.
- Социальная лента платежей. Лента транзакций друзей с описаниями (Venmo-style). Настройки приватности: public, friends-only, private.
- История транзакций. Полная история всех отправленных и полученных платежей с фильтрацией и поиском.
- Уведомления. Push, email, SMS — о входящих платежах, запросах, успешных переводах, подозрительной активности.
Extended features (nice-to-have)
- Разделение счёта (Split Bill). Разделение суммы между несколькими пользователями.
- QR-код для оплаты. Генерация и сканирование QR-кода для быстрого перевода.
- Мгновенный вывод (Instant Transfer). Вывод на дебетовую карту за секунды (вместо 1–3 дней через ACH).
- Recurring payments. Автоматические периодические переводы (аренда, подписки между друзьями).
- Групповые чаты. Обсуждение совместных расходов.
- Кэшбэк и награды. Программа лояльности.
3 Нефункциональные требования
| Характеристика | Требование | Обоснование |
|---|---|---|
| Availability | 99.999% (5 девяток) | Финансовый сервис: недоступность = потеря доверия и нарушение регуляций |
| Consistency | Strong consistency для балансов | Деньги не допускают eventual consistency — баланс должен быть точным в любой момент |
| Latency | < 500 ms для P2P-перевода (внутри системы), < 2 сек для Instant Transfer | Пользователь ожидает мгновенности |
| Durability | Ни одна транзакция не может быть потеряна | Финансовые данные — zero data loss |
| Throughput | Десятки тысяч транзакций в секунду | Пики в вечерние часы, праздники, распродажи |
| Security | PCI DSS Level 1, encryption at rest и in transit | Регуляторное требование для обработки платёжных данных |
| Auditability | Полный audit trail каждой операции | Регуляторное требование — возможность восстановить историю любой транзакции |
| Idempotency | Каждая операция идемпотентна | Сетевые ошибки не должны приводить к дублированию платежей |
4 Back-of-the-envelope estimation
Возьмём масштаб, сопоставимый с Venmo + Zelle:
Пользователи
- Зарегистрированные пользователи: 100 миллионов
- DAU (Daily Active Users): 15 миллионов
- MAU: 50 миллионов
Транзакции
- Среднее количество транзакций на активного пользователя в день: 1.5
- Транзакций в день: 15M × 1.5 = 22.5 миллионов
- RPS (среднее): 22.5M / 86400 ≈ 260 RPS
- RPS (пиковое, x5): 1 300 RPS
- Средняя сумма транзакции: $50
- Объём транзакций в день: 22.5M × 1.125 миллиарда**
Хранение
Каждая транзакция:
- Основные данные: ~500 байт (IDs, суммы, статусы, метки времени, описание)
- Audit log: ~1 КБ (включая metadata, IP, device info, risk score)
- Всего на транзакцию: ~1.5 КБ
Хранение в день: 22.5M × 1.5 КБ ≈ 34 ГБ/день Хранение в год: 34 ГБ × 365 ≈ 12.4 ТБ/год
С учётом индексов, реплик и audit logs — ~50 ТБ/год.
Bandwidth
- API запросы (чтение + запись): ~500 RPS (пиковое: 2500 RPS)
- Средний размер запроса/ответа: ~2 КБ
- Bandwidth: 2500 × 2 КБ = 5 МБ/сек (скромно, это не медиа-платформа)
5 High-Level Design
Основные компоненты
┌──────────────────────────────────────────────────┐
│ Клиенты │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ iOS App │ │ Android │ │ Web App │ │
│ └────┬─────┘ └─────┬────┘ └─────┬────┘ │
└──────────┼──────────────┼─────────────┼──────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────┐
│ API Gateway │
│ (Rate Limiting, Auth, Routing, TLS) │
└─────────────────────┬─────────────────────┘
│
┌─────────────┬─────────────┬────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌───────────┐ ┌──────────┐ ┌──────────────┐
│ User │ │ Payment │ │ Social │ │ Notification │
│Service │ │ Service │ │ Feed │ │ Service │
│ │ │ │ │ Service │ │ │
└───┬────┘ └─────┬─────┘ └────┬─────┘ └───────┬──────┘
│ │ │ │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ Ledger │ │ │
│ │ Service │ │ │
│ │ (балансы) │ │ │
│ └──────┬──────┘ │ │
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐
│Users DB│ │Ledger DB │ │Feed DB │ │ Push/SMS/ │
│(PG) │ │(PG) │ │(Cassandra│ │ Email Gateway │
└────────┘ └──────────┘ └──────────┘ └───────────────┘
│
▼
┌───────────────────────┐
│ External Banking │
│ ┌─────┐ ┌─────────┐ │
│ │ ACH │ │ Card │ │
│ │ │ │ Network │ │
│ └─────┘ └─────────┘ │
└───────────────────────┘
Data Flow: отправка P2P-платежа
1. Пользователь A отправляет $50 пользователю B
Mobile App API Gateway Payment Service
│ │ │
│── POST /payments ──────▶│ │
│ {to: "B", amount: 50, │ │
│ note: "lunch", │ │
│ idempotency_key: ...}│ │
│ │── Forward ────────▶│
│ │ │
│ │ ┌─────┤
│ │ │ 1. Validate request
│ │ │ 2. Check fraud risk
│ │ │ 3. Check balance A≥$50
│ │ │ 4. Begin DB transaction
│ │ │ - Debit A: -$50
│ │ │ - Credit B: +$50
│ │ │ - Write ledger entries
│ │ │ 5. Commit transaction
│ │ └─────┤
│ │ │
│ │ │──▶Kafkapayment.completed
│ │ │ │
│ │◀── 200 OK ─────────│ ├──▶ Feed Service
│◀── Payment Confirmed ───│ │ ├──▶ Notification Service │ │ │ └──▶ Analytics
Ключевые компоненты
-
User Service — регистрация, аутентификация, KYC-верификация, управление привязанными банковскими счетами и картами.
-
Payment Service — ядро системы. Обработка P2P-переводов, запросов на оплату, split bills. Обеспечивает идемпотентность и согласованность.
-
Ledger Service — двойная бухгалтерия (double-entry bookkeeping). Каждая транзакция — пара записей: дебет одного счёта и кредит другого. Баланс — это агрегация всех записей по счёту.
-
Social Feed Service — социальная лента транзакций. Хранит описания платежей, управляет приватностью, генерирует feed для каждого пользователя.
-
Notification Service — отправка push-уведомлений, SMS и email при значимых событиях (входящий платёж, запрос на оплату, подозрительная активность).
-
Fraud Detection Service — оценка риска каждой транзакции в реальном времени. ML-модели + rule engine.
-
Banking Integration Service — интеграция с внешними системами: ACH для банковских переводов, карточные сети (Visa, Mastercard) для Instant Transfer.
6 API Design
Ключевые endpoints
# Платежи
POST /api/v1/payments # Отправить платёж
POST /api/v1/payment-requests # Запросить оплату
PUT /api/v1/payment-requests/{id}/accept # Принять запрос
PUT /api/v1/payment-requests/{id}/decline # Отклонить запрос
GET /api/v1/payments?cursor=...&limit=20 # История платежей
# Баланс и банковские счета
GET /api/v1/balance # Текущий баланс
POST /api/v1/bank-accounts # Привязать банковский счёт
POST /api/v1/transfers/deposit # Пополнить кошелёк
POST /api/v1/transfers/withdraw # Вывести на счёт
# Социальная лента
GET /api/v1/feed?cursor=...&limit=20 # Лента друзей
PUT /api/v1/payments/{id}/privacy # Изменить приватность
# Split Bill
POST /api/v1/splits # Создать split
PUT /api/v1/splits/{id}/pay # Оплатить свою долю
# QR
POST /api/v1/qr-codes # Создать QR для приёма
GET /api/v1/qr-codes/{code}/resolve # Расшифровать QR
Request/Response: отправка платежа
Request:
POST /api/v1/payments Headers: Authorization: Bearer <jwt_token> Idempotency-Key: "550e8400-e29b-41d4-a716-446655440000" { "recipient_id": "user_abc123", "amount": { "value": 5000, "currency": "USD" }, "note": "Lunch at Mario's 🍕", "privacy": "friends", "funding_source": "balance" }
Важно: amount.value — целое число в минимальных единицах валюты (центы для USD). Никогда не используйте float для денег. 50.00 → 5000.
Response (201 Created):
{ "payment_id": "pay_xyz789", "status": "completed", "sender": { "id": "user_me456", "display_name": "Alice" }, "recipient": { "id": "user_abc123", "display_name": "Bob" }, "amount": { "value": 5000, "currency": "USD" }, "note": "Lunch at Mario's 🍕", "privacy": "friends", "created_at": "2025-03-15T14:30:00Z", "idempotency_key": "550e8400-e29b-41d4-a716-446655440000" }
Request/Response: запрос на оплату
Request:
POST /api/v1/payment-requests { "payer_id": "user_abc123", "amount": { "value": 2500, "currency": "USD" }, "note": "Your share of dinner", "expires_at": "2025-03-22T00:00:00Z" }
Response (201 Created):
{ "request_id": "req_def456", "status": "pending", "requester": { "id": "user_me456", "display_name": "Alice" }, "payer": { "id": "user_abc123", "display_name": "Bob" }, "amount": { "value": 2500, "currency": "USD" }, "note": "Your share of dinner", "expires_at": "2025-03-22T00:00:00Z", "created_at": "2025-03-15T14:35:00Z" }
Authentication/Authorization
- OAuth 2.0 + JWT для аутентификации мобильных клиентов
- mTLS между внутренними сервисами
- Scopes:
payments:write,payments:read,balance:read,bank-accounts:manage - Step-up authentication: крупные переводы (> $500) требуют повторного ввода PIN / биометрии
- Device fingerprinting: каждый запрос содержит device_id и fingerprint для fraud detection
7 Data Model
Выбор базы данных
| Компонент | База данных | Обоснование |
|---|---|---|
| Users, Accounts | PostgreSQL | Структурированные данные, ACID, referential integrity |
| Ledger (балансы, записи) | PostgreSQL | Strong consistency обязательна для финансовых данных. SERIALIZABLE isolation |
| Социальная лента | Cassandra | Write-heavy, eventual consistency допустима, нужно масштабирование на сотни миллионов записей |
| Idempotency keys | Redis | TTL-based хранение, быстрый lookup, данные можно терять (worst case — повторная проверка в БД) |
| Fraud features | Redis + ClickHouse | Real-time features в Redis, исторические данные в ClickHouse |
| Audit logs | Apache Kafka → S3 (Parquet) | Append-only, огромные объёмы, доступ только для расследований |
| Поиск транзакций | Elasticsearch | Full-text search по описаниям, фильтрация по множеству параметров |
Database Schema
-- Пользователи CREATE TABLE users ( user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), phone VARCHAR(20) UNIQUE, email VARCHAR(255) UNIQUE, username VARCHAR(50) UNIQUE, display_name VARCHAR(100) NOT NULL, password_hash VARCHAR(255) NOT NULL, kyc_status VARCHAR(20) DEFAULT 'pending', -- pending, verified, rejected kyc_tier SMALLINT DEFAULT 1, -- 1: $299/week, 2:$4999/week, 3: unlimited status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Банковские счета пользователей CREATE TABLE bank_accounts ( bank_account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(user_id), bank_name VARCHAR(100), account_type VARCHAR(20), -- checking, savings routing_number VARCHAR(9), -- зашифрован account_number VARCHAR(17), -- зашифрован, хранится в Vault plaid_token VARCHAR(255), -- токен Plaid для доступа status VARCHAR(20) DEFAULT 'active', is_default BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Внутренние счета (ledger accounts) CREATE TABLE accounts ( account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(user_id), account_type VARCHAR(20) NOT NULL, -- user_wallet, fee_revenue, funding_hold currency CHAR(3) DEFAULT 'USD', balance BIGINT NOT NULL DEFAULT 0, -- в минимальных единицах (центы) version BIGINT NOT NULL DEFAULT 0, -- для optimistic locking created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT positive_balance CHECK (balance >= 0) ); CREATE INDEX idx_accounts_user ON accounts(user_id); -- Записи в леджере (double-entry) CREATE TABLE ledger_entries ( entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), transaction_id UUID NOT NULL, -- группирует дебет и кредит одной операции account_id UUID NOT NULL REFERENCES accounts(account_id), entry_type VARCHAR(10) NOT NULL, -- debit, credit amount BIGINT NOT NULL, -- всегда положительное число (центы) running_balance BIGINT NOT NULL, -- баланс после этой записи description VARCHAR(500), created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_ledger_transaction ON ledger_entries(transaction_id); CREATE INDEX idx_ledger_account_time ON ledger_entries(account_id, created_at DESC); -- Платежи CREATE TABLE payments ( payment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), idempotency_key VARCHAR(255) UNIQUE NOT NULL, sender_id UUID NOT NULL REFERENCES users(user_id), recipient_id UUID NOT NULL REFERENCES users(user_id), amount BIGINT NOT NULL, -- центы currency CHAR(3) DEFAULT 'USD', note VARCHAR(500), privacy VARCHAR(20) DEFAULT 'private', -- public, friends, private status VARCHAR(20) NOT NULL, -- pending, completed, failed, reversed funding_source VARCHAR(20) NOT NULL, -- balance, bank_account, card risk_score DECIMAL(5, 4), -- 0.0000 – 1.0000 created_at TIMESTAMPTZ DEFAULT NOW(), completed_at TIMESTAMPTZ ); CREATE INDEX idx_payments_sender ON payments(sender_id, created_at DESC); CREATE INDEX idx_payments_recipient ON payments(recipient_id, created_at DESC); -- Запросы на оплату CREATE TABLE payment_requests ( request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), requester_id UUID NOT NULL REFERENCES users(user_id), payer_id UUID NOT NULL REFERENCES users(user_id), amount BIGINT NOT NULL, currency CHAR(3) DEFAULT 'USD', note VARCHAR(500), status VARCHAR(20) DEFAULT 'pending', -- pending, accepted, declined, expired payment_id UUID REFERENCES payments(payment_id), -- ссылка на платёж при accept expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Split bills CREATE TABLE splits ( split_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), creator_id UUID NOT NULL REFERENCES users(user_id), total_amount BIGINT NOT NULL, currency CHAR(3) DEFAULT 'USD', description VARCHAR(500), status VARCHAR(20) DEFAULT 'active', -- active, completed, cancelled created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE split_participants ( split_id UUID NOT NULL REFERENCES splits(split_id), user_id UUID NOT NULL REFERENCES users(user_id), amount BIGINT NOT NULL, status VARCHAR(20) DEFAULT 'pending', -- pending, paid, declined payment_id UUID REFERENCES payments(payment_id), PRIMARY KEY (split_id, user_id) ); -- Idempotency keys (дублируется в Redis для быстрого доступа) CREATE TABLE idempotency_keys ( idempotency_key VARCHAR(255) PRIMARY KEY, user_id UUID NOT NULL, response_code INT, response_body JSONB, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours' );
Entity Relationship
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ users │──1:N──│bank_accounts │ │ payments │
└──────────┘ └──────────────┘ └──────┬───────┘
│ │
│ 1:1 │ 1:N
▼ ▼
┌──────────────┐ ┌──────────────┐
│ accounts │──────────1:N────────────▶│ledger_entries│
│ (wallet) │ └──────────────┘
└──────────────┘
│
│ N:1
▼
┌──────────────┐ ┌───────────────────┐
│ splits │──1:N──│split_participants │
└──────────────┘ └───────────────────┘
8 Deep Dive в ключевые компоненты
8.1 Ledger Service: двойная бухгалтерия
Двойная бухгалтерия (double-entry bookkeeping) — фундамент любой финансовой системы, изобретённый в XV веке и не изменившийся с тех пор. Принцип прост: каждая транзакция — это два равных и противоположных действия. Дебет одного счёта = кредит другого. Сумма всех дебетов в системе всегда равна сумме всех кредитов.
Почему не просто UPDATE balance?
Наивный подход:
-- ПЛОХО: потеря данных, нет audit trail, нет защиты от race conditions UPDATE accounts SET balance = balance - 5000 WHERE user_id = 'alice'; UPDATE accounts SET balance = balance + 5000 WHERE user_id = 'bob';
Проблемы:
- Нет истории — невозможно восстановить, как изменялся баланс
- При сбое между двумя UPDATE деньги исчезнут
- Нет возможности аудита: регулятор не примет «мы просто обновили число»
Правильный подход — append-only ledger с double-entry:
def transfer(sender_account_id: str, recipient_account_id: str, amount: int, idempotency_key: str, note: str) -> Payment: """ Перевод средств между двумя внутренними счетами. amount — в центах. Все операции в одной транзакции. """ with db.transaction(isolation_level='SERIALIZABLE') as tx: # 1. Проверяем idempotency existing = tx.query( "SELECT response_body FROM idempotency_keys WHERE idempotency_key = %s", idempotency_key ) if existing: return existing.response_body # Повторный запрос — возвращаем тот же ответ # 2. Блокируем счета (порядок по ID для предотвращения deadlock) accounts = sorted([sender_account_id, recipient_account_id]) sender = tx.query( "SELECT * FROM accounts WHERE account_id = %s FOR UPDATE", sender_account_id ) recipient = tx.query( "SELECT * FROM accounts WHERE account_id = %s FOR UPDATE", recipient_account_id ) # 3. Проверяем достаточность средств if sender.balance < amount: raise InsufficientFundsError( f"Balance {sender.balance}, required {amount}" ) # 4. Создаём transaction_id для группировки записей transaction_id = generate_uuid() # 5. Дебет отправителя new_sender_balance = sender.balance - amount tx.execute(""" INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount, running_balance, description) VALUES (%s, %s, 'debit', %s, %s, %s) """, transaction_id, sender_account_id, amount, new_sender_balance, note) # 6. Кредит получателя new_recipient_balance = recipient.balance + amount tx.execute(""" INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount, running_balance, description) VALUES (%s, %s, 'credit', %s, %s, %s) """, transaction_id, recipient_account_id, amount, new_recipient_balance, note) # 7. Обновляем балансы (с optimistic locking) tx.execute(""" UPDATE accounts SET balance = %s, version = version + 1, updated_at = NOW() WHERE account_id = %s AND version = %s """, new_sender_balance, sender_account_id, sender.version) tx.execute(""" UPDATE accounts SET balance = %s, version = version + 1, updated_at = NOW() WHERE account_id = %s AND version = %s """, new_recipient_balance, recipient_account_id, recipient.version) # 8. Сохраняем idempotency key payment = Payment(id=transaction_id, status='completed', ...) tx.execute(""" INSERT INTO idempotency_keys (idempotency_key, user_id, response_code, response_body) VALUES (%s, %s, %s, %s) """, idempotency_key, sender.user_id, 201, payment.to_json()) return payment
Reconciliation (сверка):
Периодический batch-процесс проверяет целостность данных:
-- Сумма всех дебетов должна равняться сумме всех кредитов SELECT SUM(CASE WHEN entry_type = 'debit' THEN amount ELSE 0 END) AS total_debits, SUM(CASE WHEN entry_type = 'credit' THEN amount ELSE 0 END) AS total_credits, SUM(CASE WHEN entry_type = 'debit' THEN amount ELSE 0 END) - SUM(CASE WHEN entry_type = 'credit' THEN amount ELSE 0 END) AS imbalance FROM ledger_entries; -- imbalance ДОЛЖЕН быть 0. Если нет — critical alert. -- Баланс каждого счёта = сумма кредитов - сумма дебетов SELECT a.account_id, a.balance AS stored_balance, COALESCE(SUM(CASE WHEN le.entry_type = 'credit' THEN le.amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN le.entry_type = 'debit' THEN le.amount ELSE 0 END), 0) AS computed_balance, a.balance - ( COALESCE(SUM(CASE WHEN le.entry_type = 'credit' THEN le.amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN le.entry_type = 'debit' THEN le.amount ELSE 0 END), 0) ) AS drift FROM accounts a LEFT JOIN ledger_entries le ON a.account_id = le.account_id GROUP BY a.account_id, a.balance HAVING a.balance != ( COALESCE(SUM(CASE WHEN le.entry_type = 'credit' THEN le.amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN le.entry_type = 'debit' THEN le.amount ELSE 0 END), 0) ); -- Должен возвращать 0 строк. Каждая строка — баг.
8.2 Идемпотентность: защита от дублирования платежей
В мобильной среде сеть ненадёжна. Пользователь нажимает «Отправить 50 спишутся дважды.
Механизм:
Клиент генерирует UUID (idempotency_key) до отправки запроса.
Каждый повторный запрос использует тот же ключ.
Первый запрос:
Client ──POST /payments {idempotency_key: "abc123"}──▶ Server
Server: ключ "abc123" не найден → обрабатываем платёж → сохраняем ответ
Server ──201 Created──▶ Client (ответ потерян в сети)
Повторный запрос:
Client ──POST /payments {idempotency_key: "abc123"}──▶ Server
Server: ключ "abc123" найден → возвращаем сохранённый ответ
Server ──201 Created──▶ Client (тот же ответ, что и в первый раз)
Реализация через Redis + PostgreSQL:
async def process_payment_idempotently(request: PaymentRequest) -> PaymentResponse: key = f"idempotency:{request.idempotency_key}" # 1. Быстрая проверка в Redis cached = await redis.get(key) if cached: return PaymentResponse.from_json(cached) # 2. Проверка в PostgreSQL (Redis мог потерять данные) existing = await db.fetch_one( "SELECT response_body FROM idempotency_keys WHERE idempotency_key = $1", request.idempotency_key ) if existing: # Восстанавливаем в Redis для будущих запросов await redis.setex(key, 86400, existing.response_body) return PaymentResponse.from_json(existing.response_body) # 3. Обрабатываем платёж (внутри транзакции с записью idempotency key) response = await execute_payment(request) # 4. Кэшируем в Redis await redis.setex(key, 86400, response.to_json()) return response
Важные нюансы:
- TTL idempotency key: 24 часа. Достаточно для защиты от дублей, не засоряет хранилище.
- Idempotency key привязан к user_id. Пользователь A не может «переиспользовать» ключ пользователя B.
- Если обработка failed — ключ всё равно сохраняется. Повторный запрос вернёт тот же error, а не попытается выполнить платёж заново. Клиент должен генерировать новый ключ для новой попытки.
8.3 Fraud Detection: оценка риска в реальном времени
Каждая транзакция проходит через fraud-scoring перед исполнением. Цель — заблокировать мошенническую транзакцию за < 100 мс, не создавая ложных срабатываний для легитимных пользователей.
Payment Request
│
▼
┌─────────────────────────────────────────────────┐
│ Rule Engine ← Детерминированные правила │
│ │
│ amount > $3000 и новый аккаунт → block │
│ 10+ транзакций за час → review │
│ новое устройство + крупная сумма → step-up auth │
│ получатель в blacklist → block │
└─────────┬───────────────────────────────────────┘
│
▼
┌───────────────────┐
│ ML Model │ ← Вероятностная оценка
│ │
│ Features: │
│ - user history │
│ - device info │
│ - amount pattern │
│ - geo location │
│ - time of day │
│ - social graph │
│ │
│ Output: risk │
│ score 0.0–1.0 │
└─────────┬─────────┘
│
▼
┌───────────────────────────────────────────┐
│ Decision │
│ │
│ score < 0.3 → allow │
│ 0.3–0.7 → step-up auth (PIN/biometric)│
│ 0.7–0.9 → manual review (hold funds) │
│ score > 0.9 → block + alert │
└───────────────────────────────────────────┘
Feature Store для fraud detection:
# Features вычисляются в реальном времени из Redis async def get_fraud_features(user_id: str, payment: PaymentRequest) -> dict: pipe = redis.pipeline() pipe.get(f"fraud:tx_count_1h:{user_id}") # транзакций за последний час pipe.get(f"fraud:tx_sum_24h:{user_id}") # сумма за 24 часа pipe.get(f"fraud:unique_recipients_7d:{user_id}")# уникальных получателей за неделю pipe.get(f"fraud:last_device:{user_id}") # последний device fingerprint pipe.get(f"fraud:last_location:{user_id}") # последняя геолокация results = await pipe.execute() return { "tx_count_1h": int(results[0] or 0), "tx_sum_24h": int(results[1] or 0), "unique_recipients_7d": int(results[2] or 0), "is_new_device": results[3] != payment.device_fingerprint, "geo_distance_km": calc_distance(results[4], payment.location), "account_age_days": (now() - user.created_at).days, "amount": payment.amount, "is_round_amount": payment.amount % 100 == 0, "hour_of_day": now().hour, }
9 Масштабирование
Шардирование
Ledger Database (PostgreSQL):
Шардирование по user_id. Каждый шард содержит полную информацию о счетах и ledger entries определённой группы пользователей.
Шард 0: user_id hash % 16 == 0 (accounts, ledger_entries, payments)
Шард 1: user_id hash % 16 == 1
...
Шард 15: user_id hash % 16 == 15
Проблема cross-shard транзакций: если Alice (шард 3) отправляет деньги Bob (шард 7), нужна распределённая транзакция. Решение — внутренний escrow-счёт:
Вместо:
Шард 3: debit Alice ←─ одна распределённая транзакция ─→ Шард 7: credit Bob
Используем:
1. Шард 3: debit Alice, credit escrow_outgoing (локальная транзакция)
2. Шард 7: debit escrow_incoming, credit Bob (локальная транзакция)
3. Reconciliation: escrow_outgoing == escrow_incoming (периодическая сверка)
Каждый шард имеет системные escrow-счета. Перевод разбивается на две локальные транзакции + асинхронную сверку. При сбое между шагами 1 и 2 — reconciliation обнаружит расхождение и либо завершит перевод, либо откатит.
Social Feed (Cassandra):
Партиционирование по user_id (чей feed). Каждый пользователь видит ленту из транзакций друзей — это fanout-on-write (предварительная генерация ленты, аналогично Twitter).
Кэширование
Уровень 1: In-process cache (Caffeine / Guava)
- User profiles (TTL 5 мин)
- KYC status (TTL 10 мин)
- Fraud rule sets (TTL 1 мин)
Уровень 2: Redis
- Балансы (TTL 30 сек, инвалидация при изменении)
- Fraud features (TTL по типу: 1 час для count, 24 часа для sum)
- Idempotency keys (TTL 24 часа)
- Session data
Уровень 3: CDN
- Статические ресурсы (аватары, иконки)
- QR-коды (статические ссылки)
Кэширование балансов — осторожно! Баланс кэшируется только для отображения в UI. Все операции списания/зачисления читают баланс из БД с блокировкой (SELECT ... FOR UPDATE). Stale balance в UI допустим, stale balance при переводе — недопустим.
Масштабирование Payment Service
┌────────────────┐
│ Load Balancer │
└─┬────────────┬─┘
│ │
┌─────────────┤ ├─────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Payment │ │ Payment │ │ Payment │ │ Payment │
│ Service │ │ Service │ │ Service │ │ Service │
│ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │ Pod N │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
Stateless: любой pod может обработать любой запрос.
Idempotency обеспечивается через Redis + PostgreSQL.
HPA: автоскейлинг от 4 pods (night) до 20 pods (peak).
10 Trade-offs и альтернативы
1. Синхронный перевод vs Event Sourcing
Наш выбор: синхронный перевод в одной транзакции.
| Подход | Плюсы | Минусы |
|---|---|---|
| Синхронный (выбран) | Простота, strong consistency, пользователь сразу видит результат | Ограниченная масштабируемость, cross-shard проблема |
| Event Sourcing | Полный audit trail бесплатно, отличная масштабируемость, replay | Eventual consistency для балансов, сложность дебага, необходимость CQRS |
Event Sourcing — мощный подход для зрелых финансовых систем (его использует Square/Block). Но для MVP и ранних стадий синхронный подход проще и надёжнее. Переход на Event Sourcing — естественная эволюция.
2. Internal balance vs Direct bank transfer
Venmo-модель (наш выбор): пользователь пополняет внутренний кошелёк, P2P-переводы мгновенные (внутри системы). Вывод на банковский счёт — отложенная операция.
Zelle-модель: деньги переводятся напрямую между банковскими счетами. Нет внутреннего баланса.
| Подход | Плюсы | Минусы |
|---|---|---|
| Internal balance (Venmo) | Мгновенные P2P-переводы, меньше зависимости от банков, проще монетизация | Регуляторная нагрузка (вы — «money transmitter»), пользователю надо пополнять кошелёк |
| Direct bank (Zelle) | Нет регуляторной нагрузки «кошелька», деньги сразу на банковском счёте | Зависимость от банковских API, медленнее (ACH = 1–3 дня), сложнее интеграция |
3. Push feed vs Pull feed (социальная лента)
Наш выбор: push (fanout-on-write).
При каждом платеже (с privacy=public или friends) запись пушится в feed каждого друга отправителя и получателя. При чтении ленты — простой SELECT по предгенерированной таблице.
Альтернатива: pull (fanout-on-read) — при чтении ленты агрегируем транзакции всех друзей. Проще при записи, но медленнее при чтении. Для P2P-платформы с умеренной частотой транзакций push — оптимальный выбор (в отличие от Twitter, где celebrities создают проблему fanout).
4. Money representation: integer vs decimal vs string
Наш выбор: integer (cents).
// ПЛОХО: floating point
0.1 + 0.2 = 0.30000000000000004 // потеря центов при миллионах транзакций
// ХОРОШО: integer cents
10 + 20 = 30 // всегда точно
// АЛЬТЕРНАТИВА: DECIMAL(19, 4) в PostgreSQL
// Хорошо для хранения, но в API и коде предпочтительнее integer
11 Отказоустойчивость
Single Points of Failure и их устранение
| Компонент | Риск | Митигация |
|---|---|---|
| PostgreSQL (Ledger) | Потеря данных, недоступность | Primary-Standby репликация (синхронная!), автоматический failover через Patroni/Stolon, cross-AZ deployment |
| Redis | Потеря idempotency keys, fraud features | Redis Sentinel с автофейловером, AOF persistence, fallback на PostgreSQL для idempotency |
| Payment Service | Недоступность переводов | Несколько реплик за LB, health checks, circuit breaker на зависимости |
| Kafka | Потеря событий (feed, notifications) | Replication factor 3, acks=all, min.insync.replicas=2 |
| Banking Integration | Банковское API недоступно | Retry с exponential backoff, Dead Letter Queue, manual reconciliation |
Disaster Recovery
Primary Region (us-east-1):
- Active: все сервисы работают
- PostgreSQL: primary + sync standby
- Kafka: 3 брокера
DR Region (us-west-2):
- Standby: PostgreSQL async replica (RPO < 1 сек)
- Kafka MirrorMaker: реплицирует topics
- Сервисы развёрнуты, но не принимают трафик
RTO (Recovery Time Objective): < 15 минут
RPO (Recovery Point Objective): < 5 секунд
Failover процедура:
1. DNS switch (Route 53 health check триггерит автоматически)
2. Promote PostgreSQL standby → primary
3. Kafka consumers переключаются на локальный кластер
4. Валидация: reconciliation check балансов
Data Backup
- PostgreSQL: continuous WAL archiving на S3, point-in-time recovery до любой секунды за последние 30 дней
- Daily logical backups: pg_dump для catastrophic recovery
- Ledger entries: immutable, append-only — никогда не удаляются, только архивируются в S3 (Parquet) после 2 лет
- Encryption: все бэкапы зашифрованы AES-256, ключи в AWS KMS
12 Мониторинг и Alerting
Ключевые метрики
Бизнес-метрики:
| Метрика | Описание | Alert threshold |
|---|---|---|
payment.success_rate | % успешных платежей | < 99.5% → P1 |
payment.volume_per_minute | Объём транзакций в минуту | Падение > 50% от baseline → P1 |
payment.avg_amount | Средняя сумма перевода | Аномальный рост > 3σ → investigate |
fraud.block_rate | % заблокированных транзакций | > 5% → investigate (возможно, ложные срабатывания) |
withdrawal.pending_count | Количество зависших выводов | > 100 pending > 1 час → P2 |
Технические метрики:
| Метрика | Описание | Alert threshold |
|---|---|---|
payment.latency.p99 | 99-й перцентиль латенси платежа | > 2 сек → P2 |
ledger.reconciliation.drift | Расхождение балансов | != 0 → P0 (CRITICAL) |
db.replication.lag_bytes | Отставание реплики | > 1 МБ → P2 |
redis.hit_rate | Cache hit rate | < 90% → investigate |
kafka.consumer.lag | Отставание consumer | > 10000 сообщений → P2 |
api.error_rate.5xx | % 5xx ответов | > 1% → P1 |
banking.ach.failure_rate | % неудачных ACH-переводов | > 2% → P2 |
SLI/SLO
| SLI | SLO | Measurement |
|---|---|---|
| Availability | 99.999% (5.26 мин downtime/год) | % успешных (non-5xx) ответов API |
| Latency | p50 < 200 ms, p99 < 1 сек | Время от запроса до ответа для POST /payments |
| Correctness | 100% | Reconciliation drift == 0 |
| Durability | 100% | 0 потерянных транзакций за любой период |
Alerting правила
# Prometheus alerting rules groups: - name: payment_critical rules: - alert: LedgerReconciliationDrift expr: ledger_reconciliation_drift != 0 for: 1m labels: severity: P0 annotations: summary: "CRITICAL: Ledger imbalance detected" description: "Drift of {{ $value }} cents detected. Immediate investigation required." - alert: PaymentSuccessRateLow expr: | rate(payment_total{status="completed"}[5m]) / rate(payment_total[5m]) < 0.995 for: 5m labels: severity: P1 annotations: summary: "Payment success rate below 99.5%" - alert: PaymentLatencyHigh expr: histogram_quantile(0.99, rate(payment_duration_seconds_bucket[5m])) > 2 for: 3m labels: severity: P2 annotations: summary: "Payment p99 latency exceeds 2 seconds" - alert: ReplicationLagCritical expr: pg_replication_lag_bytes > 10485760 # 10 MB for: 2m labels: severity: P1 annotations: summary: "PostgreSQL replication lag exceeds 10 MB"
13 Вопросы для самопроверки
-
Объясните, почему для финансовых балансов необходима strong consistency, а не eventual consistency. Приведите конкретный сценарий, при котором eventual consistency приведёт к потере денег.
-
Пользователь дважды нажал кнопку «Отправить $100». Как механизм идемпотентности предотвратит двойное списание? Что произойдёт, если Redis недоступен в момент второго запроса?
-
Alice (шард 3) отправляет $50 Bob (шард 7). Опишите пошагово, как escrow-механизм обеспечивает корректность перевода. Что произойдёт, если система упадёт после дебета Alice, но до кредита Bob?
-
Почему для хранения денежных сумм используется integer (центы), а не float? Приведите пример ошибки при использовании float.
-
Venmo показывает социальную ленту платежей. Как вы спроектируете хранение и генерацию этой ленты для 50 миллионов пользователей? Какой подход выберете: fanout-on-write или fanout-on-read? Почему?
-
Ваша система обрабатывает 1000 RPS платежей. PostgreSQL начинает деградировать. Какие шаги вы предпримете для масштабирования? В каком порядке?
-
Регулятор требует хранить все транзакции 7 лет. Объём данных — 50 ТБ/год. Спроектируйте стратегию хранения с учётом стоимости и требований к доступу.
-
Опишите архитектуру fraud detection для P2P-платежей. Какие features вы бы использовали? Как обеспечить латенси < 100 мс для scoring?
-
Сравните модели Venmo (internal balance) и Zelle (direct bank transfer). Какие архитектурные отличия между ними? Когда какой подход предпочтительнее?
-
Пользователь создаёт split bill на $200 между 4 людьми. Двое заплатили, один отклонил, один не ответил. Спроектируйте state machine для split bill. Какие edge cases нужно учесть?
14 Дополнительные ресурсы
Papers
- "The Auditability and Oversight of Digital Payment Systems" (Federal Reserve, 2022) — принципы проектирования аудитируемых платёжных систем, требования регуляторов к логированию и reconciliation.
- "Scaling the Square Cash App" (Square Engineering, 2019) — архитектура Cash App, переход от монолита к микросервисам, event sourcing для финансовых операций.
- "Real-time Fraud Detection at PayPal" (KDD, 2015) — ML-подходы к обнаружению мошенничества в реальном времени: features, модели, feedback loop.
- "Deterministic Simulation Testing for Payment Systems" (Stripe Engineering, 2022) — подход Stripe к тестированию платёжных систем через детерминированное моделирование сбоев.
Блоги
- PayPal/Venmo Engineering Blog (medium.com/paypal-tech) — статьи об архитектуре Venmo, масштабировании ledger, fraud detection.
- Square (Block) Engineering Blog (developer.squareup.com/blog) — архитектура Cash App, event sourcing, distributed systems challenges в финтехе.
- Stripe Engineering Blog (stripe.com/blog/engineering) — идемпотентность, distributed transactions, API design для платежей. Статья "Idempotency Keys" — must-read.
- Monzo Engineering Blog (monzo.com/blog/technology) — архитектура необанка на Go + Kubernetes + Cassandra, event sourcing для банковских операций.
Talks
- "Designing Payment Systems" (Martin Kleppmann, Strange Loop) — принципы проектирования корректных платёжных систем, double-entry bookkeeping, idempotency.
- "Building a Real-Time Fraud Detection System" (PayPal, QCon) — архитектура fraud detection в PayPal: streaming pipeline, ML scoring, feature store.
- "How Zelle Processes Billions in Payments" (AWS re:Invent) — архитектура Zelle на AWS, inter-bank messaging, real-time settlement.
- "Event Sourcing for Financial Systems" (Greg Young, DDD Europe) — паттерн Event Sourcing применительно к финансовым системам, CQRS, audit trail.