Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save dmitry-osin/5fb33ffb5b351d75deb97b56f2bd2ecd to your computer and use it in GitHub Desktop.

Select an option

Save dmitry-osin/5fb33ffb5b351d75deb97b56f2bd2ecd to your computer and use it in GitHub Desktop.
Корпоративные шаблоны для финтеха с нуля до архитектора

Модуль 1: Resilience & Stability (Обеспечение SLA)

Цель: Система должна работать (или корректно деградировать) даже когда все вокруг падает.

  1. Circuit Breaker (Автоматический выключатель)
    • State Machine: Closed, Open, Half-Open.
    • Sliding Window Metrics (подсчет ошибок).
  2. Bulkhead (Отсеки)
    • Изоляция пулов потоков (Thread Pools) и семафоров.
    • Защита от "шумных соседей" (Noisy Neighbor).
  3. Rate Limiting (Ограничение нагрузки)
    • Алгоритмы: Token Bucket, Leaky Bucket, Sliding Window Log.
    • Distributed Rate Limiting (Redis + Lua scripts).
    • Load Shedding (сброс нагрузки) и Backpressure.
  4. Retry & Exponential Backoff (Стратегии повторов)
    • Проблема Retry Storm.
    • Jitter (внесение случайности для размытия пиков).
  5. Timeouts & Deadline Propagation
    • Global Deadline vs Local Timeout.
    • Отмена обработки по цепочке вызовов.

Модуль 2: Data Consistency & Transactions (Целостность данных)

Цель: Гарантия атомарности операций в распределенной среде. Никаких потерянных денег.

  1. Transactional Outbox (Транзакционный исходящий ящик)
    • Паттерн: Атомарная запись в БД + Событие.
    • Реализации: Polling Publisher vs Transaction Log Tailing (CDC/Debezium).
  2. Transactional Inbox (Транзакционный входящий ящик)
    • Гарантия Exactly-Once Processing на стороне потребителя.
    • Deduplication Table (таблица дедупликации).
  3. Idempotency (Идемпотентность)
    • Idempotency Keys (ключи идемпотентности) в API.
    • Стратегии хранения ключей и блокировок (Redis vs DB).
  4. Saga Pattern (Сага)
    • Choreography (событийная) vs Orchestration (централизованная).
    • Compensating Transactions (компенсирующие действия/откаты).
  5. TCC (Try-Confirm-Cancel) / Reservation
    • Двухфазные бизнес-транзакции.
    • Резервирование ресурсов перед финальным комитом.

Модуль 3: Event Sourcing & Storage (Хранение и Аудит)

Цель: Полная прослеживаемость и юридическая значимость истории.

  1. Event Sourcing (Событийно-ориентированное хранение)
    • Event Store как Source of Truth (Single Source of Truth).
    • Replaying (восстановление состояния).
    • Snapshotting (снимки состояния для оптимизации чтения).
  2. CQRS (Command Query Responsibility Segregation)
    • Разделение моделей записи (Write) и чтения (Read).
    • Projections (Проекции) и денормализация данных.
    • Eventual Consistency (Согласованность в конечном счете).
  3. Audit Log (Неизменяемый журнал)
    • Immutable Logs: отличие технического лога от аудиторского.
    • Временные метки и подписи.

Модуль 4: High Load & Scalability (Масштабирование)

Цель: Обработка высокой нагрузки (RPS) без деградации.

  1. Sharding & Partitioning (Шардирование)
    • Database Sharding: выбор Shard Key.
    • Rebalancing (перебалансировка данных).
    • Consistent Hashing (Согласованное хеширование).
  2. Caching Strategies (Стратегии кэширования)
    • Cache-Aside, Read-Through, Write-Behind.
    • Cache Penetration, Breakdown & Stampede (Probabilistic locking).
    • Многоуровневый кэш (L1/L2).
  3. Asynchronous Processing (Асинхронность)
    • Queue-Based Load Leveling (сглаживание пиков через очереди).
    • Competing Consumers Pattern.

Модуль 5: Low Latency (Низкая задержка)

Цель: Минимизация времени отклика (для трейдинга и ядер процессинга).

  1. LMAX Disruptor & Ring Buffer
    • Single Writer Principle.
    • Lock-free алгоритмы.
  2. Mechanical Sympathy (Симпатия к железу)
    • CPU Caches (L1/L2/L3) и False Sharing.
    • Memory Layout и Data Locality.
  3. Reactive Streams & Backpressure
    • Неблокирующий I/O (NIO).
    • Управление потоком данных при перегрузке.

Модуль 6: Observability (Наблюдаемость)

Цель: Быстрый поиск проблем и доказательство качества сервиса.

  1. Distributed Tracing (Распределенная трассировка)
    • Trace ID / Span ID propagation.
    • Sampling strategies (Tail-based vs Head-based).
  2. Metrics (Метрики)
    • USE Method (Utilization, Saturation, Errors).
    • RED Method (Rate, Errors, Duration).
    • Histograms & Percentiles (p95, p99, p99.9).
  3. Correlation IDs
    • Сквозное логирование (MDC).

Модуль 7: Reconciliation (Сверка)

Цель: "Страховочная сетка" для выявления расхождений.

  1. Internal Reconciliation (Внутренняя сверка)
    • Сверка "Сервис А" vs "Сервис Б".
  2. External Reconciliation (Внешняя сверка)
    • Сверка с платежными шлюзами и банками.
  3. Discrepancy Handling (Обработка расхождений)
    • Автоматические корректировки vs Ручной разбор (Manual adjustment).

Модуль 8: Security (Безопасность данных)

Цель: Защита чувствительных данных.

  1. Tokenization (Токенизация)
    • Защита PAN карт и PII данных.
  2. Envelope Encryption (Конвертное шифрование)
    • Управление ключами (DEK/KEK).
  3. Zero Trust & mTLS
    • Взаимная аутентификация микросервисов.

Модуль 1: Resilience & Stability (Надежность и SLA)

Глава 1: Circuit Breaker (Автоматический выключатель)

В распределенных системах, особенно в финтехе, "молчание" или медленная работа внешнего сервиса страшнее, чем явная ошибка. Если платежный шлюз банка-партнера начал отвечать за 30 секунд вместо 50 мс, наши потоки (Thread Pool) быстро забьются ожиданием, и вся наша система встанет, даже если остальные функции (например, просмотр баланса) могли бы работать.

Circuit Breaker решает эту проблему. Это прокси, который "разрывает цепь", если количество ошибок превышает пороговое значение, позволяя системе "отдохнуть" и не тратить ресурсы на заведомо провальные вызовы.

Теория: Машина состояний (State Machine)

Паттерн работает как конечный автомат с тремя основными состояниями.

  1. CLOSED (Закрыт):
    • Нормальное состояние. Ток (запросы) идет.
    • Счетчик ошибок молчит, пока уровень ошибок (failure rate) ниже порога.
  2. OPEN (Открыт):
    • Порог ошибок превышен (например, 50% запросов упали).
    • Цепь разрывается. Все новые запросы моментально отклоняются с ошибкой CallNotPermittedException без попытки вызова реального сервиса.
    • Это называется Fail Fast (быстрый отказ) — мы не блокируем потоки.
    • Система ждет заданное время (waitDurationInOpenState), давая внешнему сервису время на восстановление.
  3. HALF-OPEN (Полуоткрыт):
    • После периода ожидания система пропускает ограниченное количество "пробных" запросов.
    • Если они успешны -> переходим в CLOSED.
    • Если они падают -> возвращаемся в OPEN.

Реализация на Java (Spring Boot + Resilience4j)

Исторически стандартом был Netflix Hystrix, но он deprecated. Современный стандарт — Resilience4j. Он легче, функциональнее и построен на функциональных интерфейсах Java 8+.

1. Зависимости (Maven)

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. Сервис с применением Circuit Breaker

Представим сервис PaymentIntegrationService, который вызывает внешний банковский API.

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentIntegrationService {

    private final RestTemplate restTemplate;
    
    // Имя инстанса CB, которое мы будем настраивать в yaml
    private static final String SERVICE_NAME = "bankGateway";

    /**
     * Основной метод вызова внешней системы.
     * @CircuitBreaker оборачивает вызов в прокси.
     * * name: связка с конфигом.
     * fallbackMethod: метод, который будет вызван при ошибке ИЛИ открытом Circuit Breaker.
     */
    @CircuitBreaker(name = SERVICE_NAME, fallbackMethod = "processPaymentFallback")
    public String processPayment(String orderId, Double amount) {
        log.info("Попытка проведения платежа для Order: {}", orderId);
        
        // Представим, что тут может вылететь Timeout или 500 ошибка
        String response = restTemplate.postForObject("https://external-bank.com/api/pay", null, String.class);
        
        return response;
    }

    /**
     * Fallback метод. Должен иметь ТУ ЖЕ сигнатуру, что и основной, 
     * плюс аргумент исключения.
     * * В финтехе Fallback - это не просто "вернуть null". 
     * Это бизнес-логика: сохранить в очередь на повтор, вернуть статус "PENDING" и т.д.
     */
    public String processPaymentFallback(String orderId, Double amount, Throwable t) {
        // Важно: логируем, почему мы здесь (была ли это ошибка сети или открытый CB)
        log.error("Сработал Fallback для Order: {}. Причина: {}", orderId, t.getMessage());

        // CallNotPermittedException означает, что CB открыт (Open state)
        if (t instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException) {
             log.warn("Circuit Breaker открыт! Внешняя система недоступна.");
             // Логика: например, ставим задачу в очередь (Dead Letter Queue) для ручного разбора
             return "PAYMENT_QUEUED_FOR_RETRY"; 
        }

        // Если это просто таймаут, но CB еще закрыт
        return "PAYMENT_FAILED_TEMPORARILY";
    }
}

3. Конфигурация (application.yml)

Это самая важная часть. Настройки по умолчанию часто не подходят для High-Load.

resilience4j:
  circuitbreaker:
    instances:
      bankGateway:
        # Настройка Sliding Window (Окно метрик)
        # COUNT_BASED: считаем последние N запросов.
        # TIME_BASED: считаем запросы за последние N секунд.
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 20 # Смотрим на последние 20 запросов
        
        # Порог срабатывания в процентах. Если 50% из 20 упали -> OPEN.
        failureRateThreshold: 50 
        
        # Порог медленных запросов. Если 50% запросов медленнее 2с -> OPEN.
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 2000ms
        
        # Минимальное кол-во вызовов, чтобы начать считать статистику.
        # Иначе 1 ошибка из 1 запроса сразу откроет CB.
        minimumNumberOfCalls: 10
        
        # Сколько ждать в состоянии OPEN перед переходом в HALF-OPEN
        waitDurationInOpenState: 10s 
        
        # Сколько пробных запросов пропустить в состоянии HALF-OPEN
        permittedNumberOfCallsInHalfOpenState: 3
        
        # Переходить ли в OPEN автоматически при старте приложения? (обычно false)
        automaticTransitionFromOpenToHalfOpenEnabled: true
        
        # Критично для Java: какие исключения считать сбоем?
        # Мы НЕ хотим открывать CB, если пользователь ввел неверный CVV (400 Bad Request).
        # Мы хотим открывать CB, если сеть лежит (IOException, Timeout, 500).
        recordExceptions:
          - org.springframework.web.client.HttpServerErrorException
          - java.util.concurrent.TimeoutException
          - java.io.IOException
        ignoreExceptions:
          - org.springframework.web.client.HttpClientErrorException # 4xx ошибки не ломают систему

Глубокий анализ для Senior Developer

  1. Sliding Window (Скользящее окно):
    • В High-Load системах лучше использовать COUNT_BASED с разумным размером окна. TIME_BASED может быть ненадежным при резких всплесках трафика (bursts), когда за секунду прилетает 1000 запросов.
  2. Игнорирование бизнес-ошибок:
    • Обрати внимание на секцию ignoreExceptions. Если банк ответил "Недостаточно средств" — это успешный с технической точки зрения ответ. Система работает штатно. Circuit Breaker не должен реагировать на бизнес-отказы, иначе вы заблокируете платежи всем из-за одного клиента без денег.
  3. Thread Pool Saturation:
    • Сам по себе @CircuitBreaker работает в потоке вызывающего (Tomcat thread). Если внешний сервис "тупит", поток все равно блокируется до таймаута. Поэтому Circuit Breaker часто комбинируют с Bulkhead (следующая тема) или используют TimeLimiter.

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


Глава 2: Bulkhead (Отсеки / Переборки)

Если Circuit Breaker — это "пробки", которые выбивает при коротком замыкании, то Bulkhead — это разделение корабля на герметичные отсеки. Если пробоина в одном отсеке (например, сервис уведомлений завис), вода не должна затопить соседний (процессинг платежей).

В контексте Java и Spring Boot это решает проблему исчерпания пула потоков (Thread Pool Saturation).

Проблема: "Один за всех и все легли"

По умолчанию в Spring Boot (Tomcat) есть единый пул потоков на обработку HTTP-запросов (обычно 200 штук). Представь ситуацию:

  1. У тебя есть быстрый API (получение баланса) и медленный API (генерация PDF-выписки).
  2. Генерация выписки внезапно начинает тупить (30 секунд на запрос).
  3. Пользователи нажимают F5, пытаясь скачать выписку.
  4. Все 200 потоков Tomcat занимаются ожиданием генерации PDF.
  5. Итог: Пользователь не может даже проверить баланс, хотя база данных свободна. Сервис перестал отвечать на все запросы, включая health check. Kubernetes убивает под, и всё повторяется.

Решение: Паттерн Bulkhead

Мы выделяем под критические или опасные операции отдельные ресурсы. В Resilience4j есть два типа реализации:

1. Semaphore Bulkhead (Семафор)

  • Как работает: Ограничивает количество одновременных вызовов. Например, "не больше 10 параллельных запросов к сервису А".
  • Ресурсы: Работает в текущем потоке. Очень легкий (low overhead).
  • Когда применять: Для защиты от перегрузки CPU или когда время отклика (latency) внешнего сервиса небольшое, но мы боимся всплеска запросов.

2. ThreadPool Bulkhead (Пул потоков)

  • Как работает: Выделяет отдельный пул потоков Java для конкретного сервиса.
  • Ресурсы: Накладные расходы на переключение контекста (Context Switch).
  • Когда применять: Для "тяжелых" I/O операций (сетевые вызовы, работа с диском). Это классический выбор для High-Load, так как он освобождает потоки Tomcat для обработки других запросов.

Реализация на Java (Spring Boot + Resilience4j)

Рассмотрим вариант с ThreadPool, так как он наиболее надежен для внешних интеграций.

1. Сервис с изоляцией

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadFullException;
import org.springframework.scheduling.annotation.Async;
import java.util.concurrent.CompletableFuture;

@Service
public class StatementService {

    // Имя конфигурации из yaml
    private static final String BULKHEAD_NAME = "pdfGenerator";

    /**
     * type = Bulkhead.Type.THREADPOOL означает, что мы выходим из 
     * основного потока Tomcat и идем в отдельный изолированный пул.
     * Возвращаемый тип обязан быть CompletableFuture (или CompletionStage).
     */
    @Bulkhead(name = BULKHEAD_NAME, type = Bulkhead.Type.THREADPOOL, fallbackMethod = "fallbackPdf")
    public CompletableFuture<String> generateStatement(String userId) {
        return CompletableFuture.supplyAsync(() -> {
            // Имитация долгой работы
            slowExternalCall(); 
            return "Ссылка на PDF для " + userId;
        });
    }

    /**
     * Fallback срабатывает, если:
     * 1. Пул потоков забит.
     * 2. Очередь задач (Queue) перед пулом забита.
     */
    public CompletableFuture<String> fallbackPdf(String userId, BulkheadFullException ex) {
        // В Финтехе мы не просто говорим "Error".
        // Мы можем вернуть заглушку или поставить задачу в "медленную" очередь Kafka.
        return CompletableFuture.completedFuture("Система перегружена. Ваша выписка будет отправлена на Email позже.");
    }

    private void slowExternalCall() {
        try { Thread.sleep(5000); } catch (InterruptedException e) {}
    }
}

2. Конфигурация (application.yml)

Здесь мы настраиваем размер нашего "отсека".

resilience4j:
  thread-pool-bulkhead:
    instances:
      pdfGenerator:
        # Максимальное кол-во потоков в пуле (параллельных выполнений)
        maxThreadPoolSize: 10
        # Минимальное (ядро) кол-во потоков. Обычно равно max для предсказуемости.
        coreThreadPoolSize: 10
        # Емкость очереди. Если все 10 потоков заняты, запросы встают в очередь.
        # Если очередь (20) заполнится -> BulkheadFullException.
        queueCapacity: 20
        # Время жизни простаивающего потока (если core < max)
        keepAliveDuration: 20ms

Разбор кейса (Почему это круто)

Представь, что пришел трафик 1000 RPS на генерацию PDF.

  1. Без Bulkhead: Все 200 потоков Tomcat забиты. Сайт лежит.
  2. С Bulkhead:
    • Первые 10 запросов заняли потоки pdfGenerator.
    • Следующие 20 запросов встали в очередь (Queue).
    • 31-й запрос моментально получает BulkheadFullException и ответ "Система перегружена", не блокируя поток Tomcat.
    • Результат: Платежи проходят, авторизация работает. Медленный сервис PDF изолирован в своей "песочнице" размером в 10 потоков.

Вопрос с собеседования на Senior Java Dev

В: Чем Bulkhead отличается от Rate Limiter? Оба ведь ограничивают нагрузку. О:

  • Rate Limiter ограничивает количество запросов за единицу времени (например, 100 RPS). Его цель — защитить внешний сервис от нашей активности или соблюсти квоты.
  • Bulkhead ограничивает количество параллельных ресурсов (потоков). Его цель — защитить нас самих от того, чтобы внешний сервис не съел все наши ресурсы своим медленным ответом.

Пример: Если внешний сервис отвечает за 1мс, Rate Limiter пропустит 1000 запросов в секунду. Если он начал отвечать за 10 секунд, Rate Limiter все равно пропустит 1000 запросов, и у нас будет 10000 висящих потоков. Bulkhead же скажет "Стоп, у меня кончились потоки" уже на 10-м запросе, спасая JVM.


Итог по главе: Мы научились изолировать ресурсы. Теперь падение одной интеграции не утянет за собой весь микросервис.


Глава 3: Rate Limiting (Ограничение интенсивности запросов)

В финтехе Rate Limiting выполняет две функции:

  1. Защитная: Не дать внешнему миру (или внутреннему багу в цикле) "положить" базу данных.
  2. Бизнесовая: Тарификация API (Tiered API). Например, "Free tier" — 10 RPS, "Gold tier" — 1000 RPS.

В отличие от Bulkhead, который ограничивает параллелизм (сколько потоков занято прямо сейчас), Rate Limiter ограничивает скорость (сколько запросов прошло за последнюю секунду/минуту).

Теория: Алгоритмы ограничения

Выбор алгоритма критичен для SLA.

  1. Token Bucket (Маркерная корзина):
    • Самый популярный.
    • Есть "ведро" емкостью $N$ токенов. Токены добавляются со скоростью $R$ (refill rate).
    • Запрос забирает 1 токен. Нет токена — отказ (HTTP 429 Too Many Requests).
    • Фича: Позволяет кратковременные всплески (bursts). Если ведро полное, можно мгновенно пропустить $N$ запросов, а потом ограничиться скоростью $R$ .
  2. Leaky Bucket (Дырявое ведро):
    • Запросы попадают в очередь и выходят из нее с фиксированной скоростью.
    • Фича: Идеально сглаживает трафик (Traffic Shaping), превращая рваную нагрузку в ровную линию. Но увеличивает latency из-за очереди.
  3. Sliding Window Log (Скользящее окно):
    • Точный подсчет запросов за "последние X секунд".
    • Требует хранения временных меток каждого запроса (дорого по памяти в Redis).

Реализация 1: Локальный Rate Limiter (Resilience4j)

Подходит, если у вас один инстанс приложения или вам не важно общее ограничение на кластер (защищаем конкретный узел CPU).

Конфигурация (application.yml)

Resilience4j использует алгоритм Token Bucket.

resilience4j:
  ratelimiter:
    instances:
      paymentApi:
        # Лимит за период (емкость ведра)
        limitForPeriod: 10
        # Период обновления токенов (скорость пополнения)
        limitRefreshPeriod: 1s
        # Тайм-аут ожидания токена.
        # 0s = Fail Fast (сразу 429).
        # 500ms = поток подождет полсекунды, вдруг токен появится.
        timeoutDuration: 0s

Код (Spring Boot)

import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PaymentController {

    private static final String RATE_LIMITER_NAME = "paymentApi";

    @GetMapping("/api/v1/payments/history")
    @RateLimiter(name = RATE_LIMITER_NAME, fallbackMethod = "rateLimitFallback")
    public ResponseEntity<String> getPaymentHistory() {
        return ResponseEntity.ok("История платежей: [...]");
    }

    /**
     * Fallback при превышении лимита.
     * RequestNotPermitted - исключение Resilience4j.
     */
    public ResponseEntity<String> rateLimitFallback(RequestNotPermitted ex) {
        // Важно: возвращаем 429 Too Many Requests, чтобы клиент знал, что нужно притормозить
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .header("Retry-After", "1") // Подсказка клиенту: попробуй через 1 секунду
                .body("Превышен лимит запросов. Попробуйте позже.");
    }
}

Реализация 2: Распределенный Rate Limiter (Redis + Bucket4j / Lua)

Проблема локального лимитера: В Kubernetes у нас 50 подов. Если мы поставили лимит 10 RPS локально, то в сумме на базу прилетит $10\times 50=500$ RPS. Это не то, что нам нужно для точного контроля SLA внешнего API.

Нам нужно глобальное состояние. Стандарт индустрии — Redis.

Архитектура

Мы не будем писать @RateLimiter на каждом методе сервиса. Обычно это выносится на уровень API Gateway (Spring Cloud Gateway) или реализуется через библиотеку Bucket4j с бэкендом в Redis.

Пример логики на Lua (концептуально)

Redis выполняет Lua-скрипты атомарно. Это критично, чтобы два пода не забрали один и тот же "последний" токен.

-- Псевдокод Lua-скрипта в Redis для Token Bucket
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or "0")

if current + 1 > limit then
    return 0 -- Отказ
else
    redis.call('incr', key)
    redis.call('expire', key, 1) -- Сброс счетчика через секунду (для Fixed Window)
    return 1 -- Успех
end

Примечание: Для Token Bucket скрипт сложнее (считает время и пополнение), но принцип атомарности тот же.

Advanced Topic: Load Shedding (Сброс нагрузки)

Senior-разработчик знает: когда лимит исчерпан, просто отбивать всех подряд — плохая стратегия.

Стратегия Prioritized Load Shedding:

  1. Система под нагрузкой (CPU > 80% или Rate Limit достигнут).
  2. Приходит запрос. Мы смотрим на приоритет (User Tier или тип запроса).
    • POST /create-payment (Ввод денег) -> Пропускаем (High Criticality).
    • GET /payment-history (Просмотр истории) -> Отклоняем (Low Criticality).

Это реализуется через кастомные фильтры или interceptors перед Rate Limiter'ом.


Итог по главе:

  • Resilience4j — отлично для защиты конкретного микросервиса от перегрузки (bulkhead + rate limiter).
  • Redis + Gateway — стандарт для глобального ограничения трафика клиентов.
  • HTTP 429 — обязательный статус ответа. Клиент должен уметь его обрабатывать (об этом в следующей главе).

Глава 4: Retry & Exponential Backoff (Повторные попытки)

В распределенной системе сетевые сбои (glitches) неизбежны. Пакет потерялся, GC пауза на 500мс, переключение лидера в базе данных. В 99% случаев повторный запрос через секунду пройдет успешно.

Но если внешний сервис действительно лежит (или перегружен), а 1000 наших микросервисов начнут долбить его повторными запросами одновременно — он не поднимется никогда. Это называется Retry Storm (шторм повторов).

Теория: Алгоритмы ожидания

  1. Immediate Retry (Мгновенный повтор):
    • Как работает: Ошибка -> Сразу повтор.
    • Результат: Убийство целевого сервиса. Запрещено в High-Load.
  2. Fixed Interval (Фиксированный интервал):
    • Как работает: Ждем 2с -> Повтор -> Ждем 2с -> Повтор.
    • Проблема: Если 100 клиентов упали одновременно (например, моргнула сеть), они все придут через 2 секунды синхронно. Это создает пики нагрузки ("Thundering Herd problem").
  3. Exponential Backoff (Экспоненциальная задержка):
    • Как работает: Интервал растет с каждой попыткой.
    • Формула: $WaitTime=Base\times Multiplier^{Attempt}$ .
    • Пример: 100ms -> 200ms -> 400ms -> 800ms.
    • Это дает системе "дышать".
  4. Jitter (Дрожание / Случайность):
    • Как работает: Добавляем случайный шум к интервалу.
    • Формула: $WaitTime=\left(Base\times 2^{Attempt}\right)+Random\left(-X,+X\right)$ .
    • Результат: Потоки рассинхронизируются. Вместо удара кувалдой (1000 запросов в 10:00:01), мы получаем "дождь" (размазанную нагрузку).

Реализация на Java (Resilience4j)

Resilience4j позволяет декларативно настроить Backoff и Jitter.

1. Конфигурация (application.yml)

resilience4j:
  retry:
    instances:
      bankTransfer:
        # Максимальное кол-во попыток (включая первую)
        maxAttempts: 3
        
        # Начальный интервал ожидания
        waitDuration: 500ms
        
        # Включаем экспоненциальный рост
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2 # 500ms -> 1000ms -> 2000ms
        
        # Включаем Jitter (рандомизацию)
        # Resilience4j использует randomized wait duration по умолчанию, если включен backoff,
        # но можно настроить randomizedWaitFactor (0.5 = +/- 50%)
        enableRandomizedWait: true 
        randomizedWaitFactor: 0.5 

        # Какие исключения ретраить?
        retryExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.ResourceAccessException # Network error
          
        # Какие НЕ ретраить? (Бизнес-ошибки)
        ignoreExceptions:
          - org.springframework.web.client.HttpClientErrorException # 4xx ошибки (клиент неправ)
          - com.mycompany.exceptions.BusinessException # "Недостаточно средств" ретраить бесполезно

2. Применение в коде

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class TransferService {

    private static final String RETRY_NAME = "bankTransfer";

    @Retry(name = RETRY_NAME, fallbackMethod = "transferFallback")
    public String transferMoney(String fromAccount, String toAccount, Double amount) {
        // Опасная операция! Требует идемпотентности.
        return restTemplate.postForObject("http://bank-core/transfer", request, String.class);
    }

    public String transferFallback(String from, String to, Double amount, Throwable t) {
        // Если все 3 попытки с Backoff провалились
        log.error("Все попытки перевода исчерпаны: {}", t.getMessage());
        throw new TransferFailedException("Сервис временно недоступен, попробуйте позже");
    }
}

Критически важный нюанс для Финтеха: Идемпотентность

Ты Senior Java Developer, поэтому я должен подсветить главный риск.

Retry + Non-Idempotent Operation = Duplicate Money.

Представь сценарий:

  1. Ты отправляешь POST запрос на списание средств.
  2. Сервис банка списал деньги и уже отправил ответ "OK".
  3. В этот момент сеть моргнула. Ответ до твоего сервиса не дошел (Read Timeout).
  4. Твой Retry думает: "Ага, IOException, надо повторить!".
  5. Ты отправляешь запрос второй раз.
  6. Сервис банка списывает деньги второй раз.

Правило: Никогда не используй Retry на мутирующих операциях (POST/PUT/PATCH), если у целевого сервиса нет поддержки Idempotency Key.

Если целевой сервис не поддерживает идемпотентность:

  1. Ретраить можно только GET запросы (идемпотентные по природе).
  2. Для POST запросов при ошибке сети нужно переходить в режим Reconciliation (ставить пометку "статус неизвестен" и разбираться отдельным джобом), а не делать слепой Retry.

Retry vs Circuit Breaker

Частый вопрос: "Зачем мне Circuit Breaker, если есть Retry?"

  • Retry: Для кратковременных (transient) сбоев. Моргнула сеть, GC пауза. "Попробуй еще раз, вдруг повезет".
  • Circuit Breaker: Для долгосрочных (persistent) сбоев. Сервис лежит, база перегружена. "Прекрати долбиться, дай ему встать".

В Resilience4j они работают вместе:

  1. Сначала срабатывает Retry.
  2. Если Retry исчерпал попытки и вернул ошибку, эта ошибка засчитывается в статистику Circuit Breaker.
  3. Если ошибок много, Circuit Breaker открывается и новые вызовы не доходят даже до первой попытки.

Итог по главе:

  1. Мы используем Exponential Backoff + Jitter (Рандом), чтобы не положить себя.
  2. Мы никогда не ретраим POST-запросы без идемпотентного ключа в заголовке (Idempotency-Key: UUID).
  3. Мы ретраим только технические сбои (Timeout, 503 Service Unavailable, IOException), но не бизнес-ошибки (404 Not Found, 400 Bad Request).

Глава 5: Timeouts & Deadline Propagation (Тайм-ауты и Распространение дедлайнов)

Это финальная глава модуля о надежности. Мы научились перезапускать запросы (Retry), ограничивать их (Rate Limit) и изолировать сбои (Circuit Breaker/Bulkhead).

Но есть фундаментальная проблема распределенных систем: Потерянное время и "Зомби-запросы".

Проблема: Локальный тайм-аут vs Глобальный дедлайн

Представь цепочку вызовов: Mobile App -> API Gateway -> Core Service -> Fraud Service -> Database.

  1. Mobile App ждет ответа максимум 3 секунды.
  2. API Gateway имеет тайм-аут 5 секунд на вызов Core.
  3. Core Service вызывает Fraud Service и ждет 10 секунд.

Сценарий катастрофы:

  • Мобильное приложение отвалилось по тайм-ауту через 3 секунды. Пользователь увидел "Ошибка" и ушел.
  • Но API Gateway продолжает ждать еще 2 секунды.
  • А Core Service и Fraud Service продолжают молотить CPU и базу данных еще 7 секунд!

Это называется Wasted Work (Выброшенная работа). Мы тратим ресурсы на обработку запроса, результат которого уже никому не нужен. В High-Load это приводит к тому, что система занята обработкой "мертвых" запросов, пока новые клиенты получают 503.

Решение: Deadline Propagation (Распространение дедлайна)

Вместо фиксированных тайм-аутов на каждом этапе ("жди 5 секунд"), мы передаем "Бюджет времени" или "Дедлайн" (абсолютное время, когда запрос должен умереть).

Алгоритм:

  1. Запрос входит в систему в 12:00:00. SLA — 3 секунды.
  2. Дедлайн = 12:00:03.
  3. Сервис А тратит 500мс. Текущее время 12:00:00.500.
  4. Сервис А вызывает Сервис Б и передает ему заголовок: X-Deadline: 12:00:03 (или X-Timeout-Remaining: 2500ms).
  5. Сервис Б видит, что у него осталось 2.5с. Если он будет делать запрос в БД, он выставит тайм-аут драйвера JDBC ровно в 2.5с, а не в дефолтные 30с.

Реализация 1: TimeLimiter в Resilience4j

Resilience4j позволяет жестко ограничить время выполнения метода, оборачивая его в CompletableFuture или Flux/Mono.

Конфигурация (application.yml)

resilience4j:
  timelimiter:
    instances:
      fraudCheck:
        # Жесткий лимит времени на выполнение метода.
        # Если метод не вернет результат за 1с -> TimeoutException.
        timeoutDuration: 1s
        # Если false, поток будет прерван (cancel running future).
        cancelRunningFuture: true

Код (Spring Boot)

import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

@Service
public class FraudService {

    @TimeLimiter(name = "fraudCheck", fallbackMethod = "fraudFallback")
    public CompletionStage<Boolean> checkTransaction(Transaction tx) {
        return CompletableFuture.supplyAsync(() -> {
            // Имитация долгой логики
            expensiveAlgorithm(tx); 
            return true;
        });
    }

    public CompletionStage<Boolean> fraudFallback(Transaction tx, java.util.concurrent.TimeoutException ex) {
        log.warn("Fraud check timed out for tx: {}", tx.getId());
        // Fail-open (разрешаем, если не успели проверить) или Fail-close (запрещаем)
        // Для финтеха часто безопаснее Fail-close (запретить).
        return CompletableFuture.completedFuture(false); 
    }
}

Реализация 2: Deadline Propagation (Руками через HTTP Headers)

В мире gRPC это работает "из коробки" (Context.current().getDeadline()). В REST (Spring Web) нам нужно передавать заголовок.

1. Interceptor для приема Дедлайна (Входящий запрос)

public class DeadlineInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String deadlineStr = request.getHeader("X-Deadline");
        if (deadlineStr != null) {
            long deadlineMillis = Long.parseLong(deadlineStr);
            long timeRemaining = deadlineMillis - System.currentTimeMillis();
            
            if (timeRemaining <= 0) {
                // Время уже вышло! Даже не начинаем обработку.
                response.setStatus(HttpStatus.GATEWAY_TIMEOUT.value());
                return false; 
            }
            
            // Сохраняем дедлайн в ThreadLocal (например, через MDC или RequestContext)
            RequestContext.setDeadline(deadlineMillis);
        }
        return true;
    }
}

2. Проброс Дедлайна дальше (Исходящий запрос через Feign/RestTemplate)

@Component
public class FeignDeadlineInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        Long deadline = RequestContext.getDeadline();
        if (deadline != null) {
            // Передаем абсолютное время дедлайна следующему сервису
            template.header("X-Deadline", String.valueOf(deadline));
            
            // Важно: динамически выставляем connect/read timeout для этого запроса
            long remaining = deadline - System.currentTimeMillis();
            template.options(new Request.Options(
                1000, TimeUnit.MILLISECONDS, // connect timeout
                (int) remaining, TimeUnit.MILLISECONDS, // read timeout
                true
            ));
        }
    }
}

Тонкости настройки (Senior Level)

  1. Connect Timeout vs Read Timeout:
    • Connect Timeout (TCP Handshake): Должен быть ультра-коротким (100-500ms). Если сеть работает, соединение устанавливается мгновенно. Если нет — нечего ждать.
    • Read Timeout (Ждем байты): Это и есть наше бизнес-время на обработку.
  2. Socket Timeout vs App Timeout:
    • Выставление socketTimeout в JDBC драйвере или HTTP клиенте просто разрывает соединение. Сервер на той стороне может не узнать об этом и продолжить работать.
    • Поэтому Distributed Cancellation (отмена по цепочке) — сложная задача. В HTTP/1.1 она почти не решаема (только закрытием TCP соединения). В HTTP/2 и gRPC есть фреймы отмены (RST_STREAM), которые реально останавливают обработку на сервере.

Итоги Модуля 1 (Resilience)

Мы построили защиту нашего финтех-приложения:

  1. Circuit Breaker: Не звоним туда, где пожар.
  2. Bulkhead: Не даем пожару в одной комнате сжечь весь дом.
  3. Rate Limiter: Не пускаем больше гостей, чем можем накормить.
  4. Retry: Пробуем еще раз, но с умом (Backoff + Jitter + Idempotency).
  5. Timeouts: Не тратим время на тех, кто уже ушел.

Модуль 2: Data Consistency & Transactions (Целостность данных)

Глава 1: Transactional Outbox (Транзакционный исходящий ящик)

Мы входим в зону "распределенных транзакций". В монолите всё просто: @Transactional откатывает всё или ничего. В микросервисах у нас есть База Данных (PostgreSQL) и Брокер Сообщений (Kafka/RabbitMQ). Это две разные системы.

Проблема: Dual Write (Двойная запись)

Представь код перевода денег:

@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
    // 1. Обновляем баланс в БД (ACID транзакция)
    accountRepository.debit(fromId, amount);
    accountRepository.credit(toId, amount);

    // 2. Отправляем событие в Kafka для аналитики/уведомлений
    kafkaTemplate.send("transfers", new TransferEvent(fromId, toId, amount));
}

Сценарий катастрофы:

  1. База закоммитилась, Kafka упала: Деньги переведены, но событие не ушло. Система уведомлений не сработала, аналитика не видит перевода. Данные разъехались.
  2. Kafka отправила, База упала (Rollback): Событие ушло ("Перевод успешен!"), но транзакция в БД откатилась. Клиент получил пуш "Деньги ушли", а баланс остался прежним. Это фантомные деньги.
  3. Порядок событий: Если мы отправим событие после коммита (через TransactionSynchronizationManager.afterCommit), то между коммитом и отправкой может выключиться свет в дата-центре. Результат тот же — потеря события.

Ни 2PC (Two-Phase Commit), ни XA-транзакции не спасут (они медленные и Kafka их плохо поддерживает).


Решение: Transactional Outbox Pattern

Мы используем свойство локальной ACID-транзакции БД, чтобы гарантировать атомарность.

Суть: Вместо прямой отправки в Kafka, мы сохраняем само сообщение (payload) в специальную таблицу outbox в той же самой транзакции, где меняем баланс.

Алгоритм:

  1. BEGIN TRANSACTION
  2. UPDATE accounts SET balance = ...
  3. INSERT INTO outbox (topic, payload) VALUES ('transfers', '{...}')
  4. COMMIT

Теперь у нас "все или ничего". Если транзакция упадет — события в outbox тоже не будет. Если пройдет — событие гарантированно лежит в базе.


Реализация на Java (Spring Boot)

1. Таблица Outbox (SQL)

CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(255) NOT NULL, -- e.g. "Account"
    aggregate_id VARCHAR(255) NOT NULL,   -- e.g. account_id (для Partition Key)
    type VARCHAR(255) NOT NULL,           -- e.g. "MoneyTransferred"
    payload JSONB NOT NULL,               -- само тело события
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    processed BOOLEAN DEFAULT FALSE       -- для Polling подхода
);

2. Код сервиса

Нам не нужны сложные фреймворки, просто еще один save.

@Service
@RequiredArgsConstructor
public class TransferService {

    private final AccountRepository accountRepository;
    private final OutboxRepository outboxRepository; // JPA Repository для таблицы outbox
    private final ObjectMapper objectMapper;

    @Transactional // Обычная Spring/JDBC транзакция
    public void transfer(String fromId, String toId, BigDecimal amount) {
        // 1. Бизнес-логика (меняем стейт)
        accountRepository.debit(fromId, amount);
        accountRepository.credit(toId, amount);

        // 2. Формируем событие
        TransferEvent event = new TransferEvent(fromId, toId, amount);
        String payload = objectMapper.writeValueAsString(event);

        // 3. Сохраняем в Outbox (в ТОЙ ЖЕ транзакции)
        OutboxEntity outboxMessage = OutboxEntity.builder()
                .id(UUID.randomUUID())
                .aggregateType("Account")
                .aggregateId(fromId) // Важно для Kafka Partitioning!
                .type("MoneyTransferred")
                .payload(payload)
                .build();

        outboxRepository.save(outboxMessage);
        
        // 4. Коммит происходит автоматически при выходе из метода
    }
}

Доставка сообщений (The Relay)

Теперь событие лежит в базе. Как переложить его в Kafka? Есть два пути.

Путь А: Polling Publisher (Простой, но нагружает БД)

Отдельный процесс (или тред с @Scheduled) периодически опрашивает таблицу.

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 1000) // Раз в секунду
    @Transactional
    public void relay() {
        // 1. Читаем неотправленные (SELECT ... FOR UPDATE SKIP LOCKED для конкурентности)
        List<OutboxEntity> events = outboxRepository.findTop50ByProcessedFalse();

        for (OutboxEntity event : events) {
            // 2. Отправляем в Kafka
            // Ключ партиционирования (aggregateId) важен для сохранения порядка!
            kafkaTemplate.send("transfers", event.getAggregateId(), event.getPayload())
                .addCallback(result -> {
                    // 3. Помечаем как отправленное (или удаляем запись)
                    event.setProcessed(true); 
                    outboxRepository.save(event);
                }, ex -> log.error("Kafka unavailable", ex));
        }
    }
}
  • Минусы: Задержка (polling interval), нагрузка на БД (постоянные SELECT/UPDATE).
  • Плюсы: Просто реализовать, работает на любой БД.

Путь Б: Transaction Log Tailing (CDC - Change Data Capture) — Enterprise Choice

Это уровень High-Load. Мы не поллим базу. Мы читаем журнал транзакций базы данных (WAL в Postgres, Binlog в MySQL).

Инструмент: Debezium.

  1. Сервис пишет в таблицу outbox и забывает.
  2. Debezium (работающий как Kafka Connect Source) "слушает" WAL-лог Postgres.
  3. Как только транзакция закоммичена, Debezium видит новую строку в outbox.
  4. Debezium моментально формирует событие Kafka и отправляет его.
  5. (Опционально) Kafka Connect удаляет запись из outbox после успешной отправки.
  • Плюсы: Нулевая нагрузка на приложение, минимальная задержка (near real-time), гарантия порядка.
  • Минусы: Сложнее в инфраструктуре (нужен Kafka Connect).

Важнейшие гарантии (Senior Knowledge)

  1. At-Least-Once Delivery (Доставка минимум один раз): Паттерн Outbox гарантирует, что сообщение точно уйдет. Но оно может уйти дважды.
    • Сценарий: Relay отправил сообщение в Kafka, получил ACK, но упал до того, как успел пометить запись в БД как processed=true. После перезапуска он снова прочитает эту запись и снова отправит.
    • Вывод: Консьюмер (получатель) обязан быть идемпотентным. (Это тема следующей главы).
  2. Message Ordering (Порядок сообщений): Если ты используешь Kafka, ты обязан отправлять связанные события (по одному account_id) в одну партицию.
    • В коде выше мы использовали event.getAggregateId() как ключ Kafka.
    • Если этого не сделать, событие "Создан" может прийти позже события "Обновлен", и консьюмер упадет.
  3. Outbox Table Bloating (Раздувание таблицы): Если не удалять обработанные записи, таблица вырастет до терабайтов.
    • Решение: Partitioning таблицы outbox по времени и DROP PARTITION старых данных, либо DELETE сразу после отправки (для CDC это норм, так как событие уже в логе БД).

Итог по главе: Мы решили проблему атомарности. Теперь наши микросервисы согласованы: если деньги списались, событие гарантированно будет в Kafka.


Глава 2: Transactional Inbox (Транзакционный входящий ящик)

Мы научились гарантированно отправлять события (Outbox). Теперь задача — гарантированно принимать их.

В мире Kafka и RabbitMQ стандартная гарантия доставки — At-Least-Once (Хотя бы один раз). Это значит, что если консьюмер упал после обработки сообщения, но до отправки ack (подтверждения) брокеру, сообщение будет доставлено снова.

Для чата это не страшно (подумаешь, два раза "Привет"). Для финтеха это катастрофа: Двойное начисление средств.

Проблема: Гонка и дубликаты

Представь код консьюмера:

@KafkaListener(topics = "transfers")
public void listen(TransferEvent event) {
    // 1. Проверяем, обрабатывали ли мы это (Idempotency Check)
    if (processedRepository.existsById(event.getId())) {
        return; 
    }
    // 2. Начисляем деньги
    accountService.credit(event.getToAccount(), event.getAmount());
    // 3. Сохраняем ID события, чтобы не обработать снова
    processedRepository.save(event.getId());
}

Где баг? Между шагом 1 (Check) и шагом 3 (Save) может произойти Race Condition, если два инстанса консьюмера (или два потока при ребалансировке Kafka) получат одно и то же сообщение одновременно. Или, что чаще: транзакция упадет на шаге 3, деньги уже начислены, а запись о дубликате не сохранилась. При повторе — деньги начислятся снова.

Решение: Transactional Inbox (Deduplication Table)

Мы должны сделать так, чтобы бизнес-действие (начисление денег) и запись факта обработки сообщения (дедупликация) происходили в одной ACID-транзакции.

Есть два подхода к Inbox:

  1. Полный Inbox (Store-Forward): Сначала сохраняем само сообщение (payload) в таблицу inbox, комитим офсет Kafka. Потом отдельный поток разбирает таблицу. Это дает максимальную надежность и сглаживание нагрузки.
  2. Deduplication Only (Дедупликация): Обрабатываем сообщение сразу, но в той же транзакции пишем message_id в таблицу processed_messages.

Рассмотрим второй вариант, так как он чаще используется в high-load для снижения задержки (latency).


Реализация на Java (Spring Boot)

1. Таблица дедупликации (SQL)

Критически важно наличие PRIMARY KEY или UNIQUE INDEX на ID сообщения. Именно база данных (а не Java код) гарантирует уникальность.

CREATE TABLE processed_messages (
    message_id UUID PRIMARY KEY, -- ID события из Kafka/Outbox
    consumer_group VARCHAR(50) NOT NULL, -- Если несколько групп читают один топик
    processed_at TIMESTAMP DEFAULT NOW()
);

2. Идемпотентный Консьюмер

@Service
@RequiredArgsConstructor
public class TransferConsumer {

    private final AccountRepository accountRepository;
    private final ProcessedMessageRepository processedMessageRepository;

    @KafkaListener(topics = "transfers", groupId = "accounting-service")
    @Transactional // Открываем транзакцию БД при получении сообщения
    public void listen(ConsumerRecord<String, String> record, @Header(KafkaHeaders.RECEIVED_MESSAGE_KEY) String messageId) {
        
        String eventId = messageId; // UUID из заголовка или тела
        
        // Попытка "застолбить" обработку.
        // Если запись уже есть -> вылетит DataIntegrityViolationException (Duplicate Key)
        // Важно делать это В НАЧАЛЕ транзакции.
        try {
            processedMessageRepository.insertIgnore(eventId, "accounting-service");
        } catch (DuplicateKeyException e) {
            log.warn("Дубликат сообщения {}. Пропускаем.", eventId);
            return; // Просто выходим, Kafka offset закоммитится автоматом
        }

        // --- БИЗНЕС ЛОГИКА ---
        TransferEvent event = parse(record.value());
        accountRepository.credit(event.getToAccount(), event.getAmount());
        
        // --- КОНЕЦ ТРАНЗАКЦИИ ---
        // Если упадет здесь -> откатятся И деньги, И запись в processed_messages.
        // Kafka перепошлет сообщение, и мы попробуем снова.
    }
}

Нюансы реализации (Senior Level)

  1. Insert First vs Insert Last:
    • Insert First (как в примере): Мы сразу блокируем ID. Это защищает от параллельной обработки дубликатов разными потоками.
    • Insert Last: Мы сначала делаем работу, потом сохраняем ID. Это работает только внутри одной транзакции. Если транзакции нет — это дыра в безопасности.
  2. Размер таблицы (Table Bloating): Таблица processed_messages будет расти бесконечно.
    • Решение: Партиционирование по времени или TTL. Но тут риск: если мы удалим ID через 30 дней, а Kafka решит переиграть офсеты месячной давности — мы получим дубликаты.
    • Компромисс: Хранить ID столько, сколько хранится лог в Kafka (retention policy) + запас.
  3. Idempotency Key в API: Этот же паттерн применяется не только для Kafka, но и для REST API. Клиент присылает заголовок X-Request-ID. Мы пишем его в processed_messages вместе с результатом ответа. Если клиент делает повтор (retry) с тем же ID — мы просто отдаем сохраненный ответ из базы, не выполняя логику.

Альтернатива: Дедупликация на уровне бизнес-сущности

Иногда таблицу processed_messages не создают, а добавляют поле last_processed_message_id прямо в таблицу счетов (accounts).

UPDATE accounts 
SET balance = balance + 100, 
    last_message_id = 'uuid-123'
WHERE id = 'acc-1' 
  AND last_message_id != 'uuid-123'; -- Оптимистичная блокировка
  • Плюс: Не нужна лишняя таблица и join'ы. Атомарность гарантирована строкой.
  • Минус: Работает, только если сообщение меняет ровно одну строку. Если одно событие меняет 5 счетов — сложнее.

Итог по главе

Мы замкнули круг транзакционности:

  1. Outbox: Гарантирует, что событие уйдет из источника.
  2. Inbox: Гарантирует, что событие применится в приемнике ровно один раз (логически).

Глава 3: Idempotency (Идемпотентность API)

Мы разобрали, как гарантировать уникальность событий внутри системы (Inbox/Outbox). Но все начинается с клиента — мобильного приложения или фронтенда.

В финтехе действует золотое правило: Любая мутирующая операция (создание платежа, перевода, заявки) должна быть идемпотентной.

Если мобильное приложение отправляет POST /transfers, и у пользователя "мигает" интернет (тайм-аут), приложение обязано повторить запрос. Без идемпотентности на сервере это приведет к дублированию транзакции.

Теория: Контракт Идемпотентности

Идемпотентность означает, что $f\left(x\right)=f\left(f\left(x\right)\right)$ . Один вызов или десять вызовов — состояние системы меняется ровно один раз.

Стандарт реализации (Stripe/Adyen style):

  1. Клиент генерирует уникальный ключ (UUID v4) на своей стороне до отправки запроса.
  2. Клиент передает этот ключ в заголовке Idempotency-Key (или X-Request-ID).
  3. Сервер проверяет ключ:
    • Ключ новый: Выполняет операцию, сохраняет результат и ключ.
    • Ключ существует и операция успешна: Возвращает сохраненный результат (200 OK) без повторного выполнения логики.
    • Ключ существует, но параметры запроса другие: Ошибка 422 (Unprocessable Entity). Защита от подмены данных.
    • Ключ в обработке (Race Condition): Ошибка 409 (Conflict).

Реализация на Java (Spring Boot)

Для High-Load систем лучше не смешивать логику идемпотентности с бизнес-логикой. Мы вынесем это в Filter или Aspect.

Хранилище ключей должно быть быстрым и надежным.

  • Redis: Быстро, TTL из коробки. Минус: при сбое Redis можно потерять ключи и допустить дубли (для мелких транзакций ОК).
  • PostgreSQL/DB: Надежно (ACID), но нагружает базу. Для критичных финансовых проводок — выбор №1.

1. Сущность IdempotentRequest (БД)

Мы храним не только ключ, но и хеш тела запроса (checksum), чтобы убедиться, что клиент не прислал тот же ключ с другими данными.

@Entity
@Table(name = "idempotency_keys")
public class IdempotencyKey {
    @Id
    private String key; // UUID из заголовка

    private String responseStatus; // 200, 400, etc.
    
    @Column(columnDefinition = "TEXT")
    private String responseBody; // JSON ответа

    private String requestChecksum; // SHA-256 от тела запроса

    private LocalDateTime createdAt;
    
    @Enumerated(EnumType.STRING)
    private ProcessingStatus status; // PROCESSING, COMPLETED, FAILED
}

2. Аспект или Интерсептор (Spring AOP)

Реализуем через аннотацию @Idempotent, которую можно вешать на контроллеры.

@Aspect
@Component
@RequiredArgsConstructor
public class IdempotencyAspect {

    private final IdempotencyRepository repository;

    @Around("@annotation(com.example.Idempotent)")
    public Object handleIdempotency(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. Извлекаем заголовок и тело
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String idempotencyKey = request.getHeader("Idempotency-Key");

        if (StringUtils.isBlank(idempotencyKey)) {
            throw new MissingHeaderException("Idempotency-Key is required");
        }

        String currentChecksum = calculateChecksum(joinPoint.getArgs()); // Хешируем аргументы

        // 2. Атомарная попытка вставки или чтения (Optimistic Locking)
        // В реальном High-Load это лучше делать через Redis SETNX или DB "INSERT ... ON CONFLICT DO NOTHING"
        return processKey(idempotencyKey, currentChecksum, joinPoint);
    }

    private Object processKey(String key, String checksum, ProceedingJoinPoint joinPoint) throws Throwable {
        Optional<IdempotencyKey> existing = repository.findById(key);

        if (existing.isPresent()) {
            IdempotencyKey record = existing.get();

            // Проверка на подмену данных (Critical Security Check)
            if (!record.getRequestChecksum().equals(checksum)) {
                throw new IdempotencyViolationException("Keys match but payloads differ!"); // 422 Unprocessable Entity
            }

            // Проверка на состояние гонки
            if (record.getStatus() == ProcessingStatus.PROCESSING) {
                throw new ConcurrentRequestException("Request is currently being processed"); // 409 Conflict
            }

            // Возврат сохраненного результата (Кэш)
            return ResponseEntity.status(Integer.parseInt(record.getResponseStatus()))
                    .body(record.getResponseBody());
        }

        // 3. Создаем запись со статусом PROCESSING
        createProcessingRecord(key, checksum);

        Object result;
        try {
            // 4. Выполняем реальный метод контроллера
            result = joinPoint.proceed();
            
            // 5. Сохраняем успех
            updateRecord(key, (ResponseEntity) result, ProcessingStatus.COMPLETED);
        } catch (Exception ex) {
            // 6. При ошибке удаляем ключ или ставим статус FAILED, чтобы дать клиенту переповторить
            deleteRecord(key);
            throw ex;
        }

        return result;
    }
}

Тонкие моменты (Senior Insights)

1. Проблема: "Ключ в обработке" (409 Conflict)

Представь, что клиент отправил запрос, сервер начал обработку (статус PROCESSING), но упал или завис. Ключ навсегда остался в статусе PROCESSING.

  • Решение: У ключей должен быть TTL (Time To Live) или Expiration.
  • Если клиент приходит с тем же ключом через 5 минут, а статус всё еще PROCESSING — мы считаем это "зомби-транзакцией", проверяем статус в реальной системе (Reconciliation) или сбрасываем ключ.

2. Где хранить ключ: Redis vs Postgres?

В финтехе часто используют гибридный подход:

  • Сначала идем в Redis (быстро отсекаем дубли).
  • Если в Redis ключа нет, идем в Postgres (надежно сохраняем факт начала транзакции).
  • Redis служит кэшем "горячих" ключей (последние 24 часа), Postgres — долгосрочным архивом.

3. Idempotency Key в теле vs в заголовке

  • Header (Idempotency-Key): Стандарт. Удобно для инфраструктуры (Gateway, Nginx могут логировать).
  • Body (json.idempotencyKey): Неудобно, так как нужно парсить тело запроса до проверки. Это повышает нагрузку (CPU) при DDoS-атаке. Всегда используй заголовки.

Практический пример: API Gateway

Если у вас микросервисная архитектура, проверку идемпотентности часто выносят на уровень API Gateway (например, Spring Cloud Gateway с Redis).

  1. Gateway получает запрос.
  2. Gateway делает SETNX key "PROCESSING" в Redis.
  3. Если вернулось 0 (ключ занят) -> Gateway отдает кешированный ответ (если есть) или 409.
  4. Gateway проксирует запрос в микросервис.
  5. Микросервис отвечает.
  6. Gateway обновляет Redis: SET key response_body EX 86400 (TTL 24 часа).

Это снимает нагрузку с бизнес-сервисов, но усложняет логику, так как Gateway теперь должен знать о структуре ответов.


Итог по главе: Идемпотентность — это не "фича", это обязательное требование контракта API в финтехе. Без заголовка Idempotency-Key (или аналогичного) API платежей не должно принимать запросы вообще.


Глава 4: Saga Pattern (Сага)

Мы добрались до самого сложного сценария. Представь типичный процесс "Оплата заказа":

  1. Order Service: Создать заказ (Pending).
  2. Payment Service: Списать деньги.
  3. Inventory Service: Зарезервировать товар.
  4. Delivery Service: Назначить курьера.
  5. Order Service: Подтвердить заказ (Confirmed).

В монолите это был бы один метод @Transactional. Если на шаге 4 нет курьеров — весь метод откатывается, деньги возвращаются, товар освобождается.

В микросервисах это 4 разных базы данных. Мы не можем сделать ROLLBACK в Payment Service, если Delivery Service упал спустя 5 секунд.

Теория: Сага — это цепочка локальных транзакций

Вместо одной большой ACID-транзакции мы разбиваем процесс на шаги.

  • Если шаг успешен -> вызываем следующий.
  • Если шаг упал -> запускаем Компенсирующие транзакции в обратном порядке, чтобы отменить изменения.

Два подхода: Хореография vs Оркестрация

Это классический выбор архитектора.

1. Choreography (Хореография / Event-Based)

Сервисы общаются событиями. Нет "главного".

  • Order: OrderCreated ->
  • Payment: слушаю OrderCreated -> списываю -> кидаю PaymentProcessed ->
  • Inventory: слушаю PaymentProcessed -> резервирую -> кидаю InventoryReserved.

Плюсы: Слабая связность, просто для 2-3 сервисов. Минусы: "Ад зависимостей" (Cyclic dependency). Сложно понять текущий статус заказа (кто виноват, если заказ завис?). В финтехе используется редко для критичных процессов.

2. Orchestration (Оркестрация / Command-Based) — Выбор Финтеха

Есть центральный Saga Orchestrator (обычно Order Service или отдельный процесс). Он говорит другим, что делать.

  • Orchestrator: "Payment, спиши деньги!" -> ждет ответ.
  • Orchestrator: "Inventory, резервируй!" -> ждет ответ.
  • Orchestrator: "Ошибка? Payment, верни деньги!" (Компенсация).

Плюсы: Централизованный контроль, таймауты, легкий мониторинг (мы точно знаем, на каком шаге застряли). Минусы: Единая точка отказа (нужна HA), лишняя сложность координатора.


Реализация Оркестрации на Java

В финтехе часто используют BPMN-движки (Camunda, Zeebe) для визуализации саг. Но для High-Load (тысячи RPS) лучше писать код (State Machine).

Рассмотрим пример на чистой Java + Spring.

1. Интерфейс Шага Саги

public interface SagaStep<T> {
    // Основное действие (Транзакция)
    void process(T context);
    
    // Компенсация (Откат)
    void rollback(T context);
    
    // Имя шага для логов
    String getName();
}

2. Реализация шагов

Шаг 1: Списание денег

@Service
@RequiredArgsConstructor
public class PaymentStep implements SagaStep<OrderContext> {
    
    private final PaymentClient paymentClient;

    @Override
    public void process(OrderContext ctx) {
        PaymentResult result = paymentClient.debit(ctx.getUserId(), ctx.getAmount());
        if (!result.isSuccess()) {
             throw new SagaException("Недостаточно средств");
        }
        ctx.setPaymentId(result.getId()); // Сохраняем ID для отката
    }

    @Override
    public void rollback(OrderContext ctx) {
        if (ctx.getPaymentId() != null) {
            // Компенсирующая транзакция (Refund)
            paymentClient.refund(ctx.getPaymentId());
        }
    }
}

Шаг 2: Резерв товара

@Service
public class InventoryStep implements SagaStep<OrderContext> {
    // ...
    @Override
    public void process(OrderContext ctx) {
        try {
            inventoryClient.reserve(ctx.getItemId());
        } catch (Exception e) {
            // Важно: выбрасываем исключение, чтобы триггернуть откат всей саги
            throw new SagaException("Товар закончился", e);
        }
    }

    @Override
    public void rollback(OrderContext ctx) {
        inventoryClient.release(ctx.getItemId());
    }
}

3. Оркестратор (Saga Manager)

@Service
public class OrderSagaOrchestrator {

    private final List<SagaStep<OrderContext>> steps;

    // Порядок шагов: Payment -> Inventory -> Delivery
    public OrderSagaOrchestrator(PaymentStep payment, InventoryStep inventory, DeliveryStep delivery) {
        this.steps = List.of(payment, inventory, delivery);
    }

    public void executeSaga(OrderContext ctx) {
        // Стек выполненных шагов (LIFO)
        Deque<SagaStep<OrderContext>> executedSteps = new ArrayDeque<>();

        try {
            for (SagaStep<OrderContext> step : steps) {
                step.process(ctx);
                executedSteps.push(step); // Запоминаем успешный шаг
            }
            // Все успешно -> Confirm Order
            completeOrder(ctx);
            
        } catch (Exception ex) {
            log.error("Saga failed on step: {}", ex.getMessage());
            // Начинаем откат в обратном порядке
            rollbackSaga(executedSteps, ctx);
            // Ставим статус заказа FAILED
            failOrder(ctx);
        }
    }

    private void rollbackSaga(Deque<SagaStep<OrderContext>> executedSteps, OrderContext ctx) {
        while (!executedSteps.isEmpty()) {
            SagaStep<OrderContext> step = executedSteps.pop();
            try {
                log.info("Compensating step: {}", step.getName());
                step.rollback(ctx);
            } catch (Exception e) {
                // САМОЕ СТРАШНОЕ: Откат не прошел!
                // Это "Saga Limbus" (Лимб). 
                // Требуется ручное вмешательство (Reconciliation).
                log.error("CRITICAL: Failed to compensate step {}", step.getName(), e);
                alertSystem.notifyAdmin("Inconsistent State for Order " + ctx.getOrderId());
            }
        }
    }
}

Тонкие моменты (Senior Insights)

  1. Компенсация должна быть Идемпотентной: Метод rollback() может быть вызван несколько раз (если оркестратор упал при откате). Он должен быть безопасным: "Если деньги уже возвращены — просто верни ОК".
  2. Semantic Locking (Семантическая блокировка): В Саге нет изоляции (буква I в ACID). Промежуточное состояние ВИДНО другим пользователям.
    • Пример: Деньги списаны, но заказ еще не подтвержден. Пользователь видит "Баланс: 0", но "Заказы: пусто".
    • Решение: Использовать статусы PENDING (Ожидание).
      • Баланс: 1000 (доступно 900, заблокировано 100).
      • Заказ: CREATING.
    • Если другой процесс попытается списать заблокированные 100 — он получит отказ. Это называется паттерном Reservation.
  3. Pivot Transaction (Поворотная транзакция): В любой Саге есть точка невозврата.
    • Шаги до нее — можно откатить (Compensatable).
    • Шаг в точке — решает исход (Pivot).
    • Шаги после — нельзя откатить (Retriable), их нужно дожать до конца.
    • Пример: Отправка e-mail подтверждения. Мы не можем "отменить" отправку письма. Поэтому этот шаг ставится в самый конец, когда все транзакции уже прошли.

Итог по главе

Сага — это единственный способ связать микросервисы транзакцией.

  • Мы меняем ACID на ACD (без изоляции).
  • Мы всегда готовы к тому, что rollback() тоже упадет (для этого нужен Модуль 7: Reconciliation).

Глава 5: TCC (Try-Confirm-Cancel) / Reservation Pattern

Мы обсудили Сагу, где стратегия "сначала стреляем, потом думаем" (выполнили действие -> если ошибка, то откатили). Это называется Optimistic Consistency.

В серьезном финтехе (биржи, букинг, крупные переводы) оптимизм — это риск. Представь, что ты покупаешь последний билет на самолет.

  • Saga: Ты купил билет. Через секунду система поняла, что мест нет (овербукинг), и вернула деньги. Ты расстроен.
  • TCC: Ты нажал "Купить". Система сказала "Место зарезервировано на 10 минут". Ты вводишь карту. Билет гарантированно твой, пока тикает таймер.

TCC (Try-Confirm-Cancel) — это программная реализация двухфазного коммита (2PC) на уровне бизнес-логики. Мы не блокируем базу данных, мы блокируем бизнес-ресурс.

Теория: Три фазы

  1. Try (Попытка / Резерв):
    • Проверяет бизнес-правила (хватает ли денег?).
    • Резервирует ресурс. Деньги не списываются, а замораживаются.
    • Инвариант: Если Try прошел успешно, система гарантирует, что Confirm выполнится успешно.
  2. Confirm (Подтверждение):
    • Использует зарезервированный ресурс.
    • Операция должна быть идемпотентной.
    • Не выполняет новых проверок (все проверено в Try).
  3. Cancel (Отмена):
    • Освобождает зарезервированный ресурс.
    • Вызывается при ошибке или таймауте Try на других участниках.
    • Тоже должна быть идемпотентной.

TCC vs Saga (Ключевое отличие)

  • Saga (Compensatable): Транзакция видна другим сразу.
    • Баланс: 1000 -> 900 (Списание) -> Ошибка -> 1000 (Возврат).
    • Проблема: "Грязное чтение". Пользователь видит скачки баланса.
  • TCC (Reservation): Транзакция изолирована семантически.
    • Баланс: 1000 (доступно 900, блок 100) -> Подтверждение -> 900 (доступно 900, блок 0).
    • Плюс: Строгая согласованность. Пользователь видит "Сумма в обработке".

Реализация на Java (Spring Boot)

Для TCC нам нужно изменить модель данных. Нам недостаточно поля balance. Нам нужно available_balance и blocked_balance.

1. Модель Счета

@Entity
public class Account {
    @Id
    private String id;
    
    private BigDecimal balance; // Общие средства
    private BigDecimal blocked; // Замороженные средства

    public BigDecimal getAvailable() {
        return balance.subtract(blocked);
    }
}

2. TCC Участник (Participant)

Каждый микросервис (Кошелек, Склад) должен реализовать API с тремя методами.

@Service
@RequiredArgsConstructor
public class WalletTccService {

    private final AccountRepository accountRepo;

    // --- PHASE 1: TRY ---
    @Transactional
    public String tryDeduct(String accountId, BigDecimal amount) {
        Account account = accountRepo.findById(accountId).orElseThrow();

        if (account.getAvailable().compareTo(amount) < 0) {
            throw new InsufficientFundsException();
        }

        // Блокируем средства (Семантическая блокировка)
        account.setBlocked(account.getBlocked().add(amount));
        accountRepo.save(account);

        // Возвращаем ID резервации (важно для Confirm/Cancel)
        return UUID.randomUUID().toString(); 
    }

    // --- PHASE 2: CONFIRM ---
    @Transactional
    public void confirmDeduct(String reservationId, String accountId, BigDecimal amount) {
        // Идемпотентность! Если резервация уже подтверждена - выходим.
        if (reservationRepo.isConfirmed(reservationId)) return;

        Account account = accountRepo.findById(accountId).orElseThrow();
        
        // Списываем реально
        account.setBalance(account.getBalance().subtract(amount));
        // Разблокируем
        account.setBlocked(account.getBlocked().subtract(amount));
        
        accountRepo.save(account);
        reservationRepo.markConfirmed(reservationId);
    }

    // --- PHASE 3: CANCEL ---
    @Transactional
    public void cancelDeduct(String reservationId, String accountId, BigDecimal amount) {
        // Идемпотентность! Если резервации нет или она уже отменена - выходим.
        if (!reservationRepo.exists(reservationId)) return;

        Account account = accountRepo.findById(accountId).orElseThrow();

        // Просто возвращаем из блока в доступные
        account.setBlocked(account.getBlocked().subtract(amount));
        
        accountRepo.save(account);
        reservationRepo.delete(reservationId);
    }
}

3. TCC Координатор (Orchestrator)

Координатор вызывает Try у всех сервисов.

  • Если все Try вернули OK -> вызывает Confirm у всех.
  • Если хоть один Try упал -> вызывает Cancel у тех, кто успел сделать Try.

Это похоже на Сагу, но логика отката проще (просто "разморозь", не надо вычислять "сколько вернуть").


Сложности и Подводные камни (Senior Level)

Главная проблема TCC — восстановление после сбоев (Crash Recovery).

Сценарий:

  1. Координатор вызвал Try у сервиса "Кошелек" (Успех, деньги заморожены).
  2. Координатор вызвал Try у сервиса "Билет" (Успех, место забронировано).
  3. Координатор начал вызывать Confirm...
  4. Координатор упал (Кернел паник, OutOfMemory).

Результат: Деньги заморожены, место забронировано. Координатор мертв и не вызывает ни Confirm, ни Cancel. Ресурсы "висят" вечно.

Решение: Timeouts & Recovery Job

В TCC участник не должен доверять координатору вечно.

  1. Passive Expiration (Ленивое истечение): При попытке доступа к резерву проверяем creation_time. Если прошло > 10 минут — считаем его недействительным.
  2. Active Recovery (Фоновая джоба): Сервис-участник должен иметь Scheduled Job:
    @Scheduled(fixedRate = 60000)
    public void releaseExpiredReservations() {
        // Найти все резервы старше 10 минут, которые НЕ подтверждены
        List<Reservation> Zombies = repo.findExpiredAndNotConfirmed();
        for (Reservation z : zombies) {
             walletService.cancelDeduct(z.getId(), ...);
             log.warn("Auto-cancelled zombie reservation: {}", z.getId());
        }
    }
    

Важно: Координатор тоже должен уметь восстанавливаться. Он должен писать состояние транзакции ("Started", "Try-Completed", "Confirmed") в журнал (Log). При рестарте он читает журнал и дожимает транзакцию.


Итог по Модулю 2

Мы разобрали полный спектр обеспечения целостности:

  1. Outbox/Inbox: Гарантированная доставка сообщений.
  2. Idempotency: Защита от дублей.
  3. Saga: Длинные транзакции с компенсацией (слабая связность).
  4. TCC: Строгие транзакции с резервированием (сильная связность, высокий контроль).

Теперь наши данные в безопасности. Но как нам эффективно хранить историю всех этих изменений, чтобы аудиторы были счастливы, а мы могли восстановить состояние системы на любой момент времени?


Модуль 3: Storage Patterns & Event Sourcing (Хранение и Аудит)

Глава 1: Event Sourcing (Событийно-ориентированное хранение)

Мы привыкли думать о базах данных как о хранилище текущего состояния.

  • Таблица: Account
  • Строка: ID=1, Balance=100

Когда происходит транзакция, мы делаем UPDATE Account SET Balance=150. Проблема: Мы только что уничтожили информацию. Мы знаем, что баланс 150, но мы забыли, что он был 100. Мы потеряли контекст (было ли это начисление зарплаты или возврат долга?).

В Финтехе потеря истории недопустима. Аудиторы, регуляторы и служба поддержки хотят знать всё.

Теория: State vs Events

Event Sourcing переворачивает подход.

  1. Source of Truth (Источник правды): Это не таблица с текущим состоянием, а журнал событий (Append-Only Log).
  2. State (Состояние): Это производная функция от истории событий. $$ State=f\left(Events_{0}...Events_{n}\right) $$

Пример: Вместо Balance = 150, мы храним:

  1. AccountCreated(0)
  2. MoneyDeposited(100)
  3. MoneyDeposited(50)

Чтобы узнать баланс, мы "проигрываем" (Replay) эти события: $0+100+50=150$ .


Реализация на Java (Domain Model)

В Event Sourcing наш Агрегат (Aggregate) — это не просто POJO с геттерами и сеттерами. Это машина состояний.

1. События (Immutable Facts)

События — это глаголы в прошедшем времени. Они неизменяемы.

// Базовый интерфейс
public interface DomainEvent {
    UUID getId();
    LocalDateTime getOccurredOn();
}

public record MoneyDeposited(UUID id, UUID accountId, BigDecimal amount, LocalDateTime occurredOn) implements DomainEvent {}
public record MoneyWithdrawn(UUID id, UUID accountId, BigDecimal amount, LocalDateTime occurredOn) implements DomainEvent {}

2. Агрегат (Aggregate Root)

Агрегат умеет делать две вещи:

  1. Принимать команды (Decide): Проверять бизнес-правила и генерировать события.
  2. Применять события (Apply): Менять свое внутреннее состояние на основе событий.
public class AccountAggregate {
    private UUID id;
    private BigDecimal balance = BigDecimal.ZERO;
    
    // Внутренний список изменений, которые еще не сохранены в БД
    private final List<DomainEvent> changes = new ArrayList<>();

    // --- 1. DECISION SIDE (Команды) ---
    
    public void deposit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Sum must be positive");
        
        // Мы не меняем state здесь! Мы генерируем факт.
        apply(new MoneyDeposited(UUID.randomUUID(), this.id, amount, LocalDateTime.now()));
    }

    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) throw new InsufficientFundsException();
        
        apply(new MoneyWithdrawn(UUID.randomUUID(), this.id, amount, LocalDateTime.now()));
    }

    // --- 2. APPLICATOR SIDE (Мутация состояния) ---
    
    private void apply(DomainEvent event) {
        // Маршрутизация события в метод-обработчик
        if (event instanceof MoneyDeposited e) {
            this.balance = this.balance.add(e.amount());
        } else if (event instanceof MoneyWithdrawn e) {
            this.balance = this.balance.subtract(e.amount());
        }
        
        // Добавляем в список "новых" событий для сохранения
        this.changes.add(event);
    }

    // --- 3. REHYDRATION (Восстановление) ---
    // Используется при загрузке из БД
    public static AccountAggregate loadFromHistory(UUID id, List<DomainEvent> history) {
        AccountAggregate aggregate = new AccountAggregate();
        aggregate.id = id;
        // Проигрываем историю без добавления в changes
        for (DomainEvent event : history) {
            aggregate.apply(event); 
        }
        aggregate.changes.clear(); // История уже в базе, сохранять не надо
        return aggregate;
    }
}

3. Репозиторий (Event Store)

Мы больше не сохраняем Account. Мы сохраняем List<DomainEvent>.

@Repository
public class EventStoreRepository {
    // В реальности это EventStoreDB, Postgres (JSONB) или Kafka
    private final JdbcTemplate jdbcTemplate; 

    public void save(AccountAggregate aggregate) {
        List<DomainEvent> newEvents = aggregate.getChanges();
        
        for (DomainEvent event : newEvents) {
            // Optimistic Locking: version expected vs actual
            jdbcTemplate.update("INSERT INTO events (aggregate_id, type, payload) VALUES (?, ?, ?)",
                aggregate.getId(), event.getClass().getSimpleName(), toJson(event));
        }
    }

    public AccountAggregate load(UUID id) {
        List<DomainEvent> history = jdbcTemplate.query("SELECT * FROM events WHERE aggregate_id = ? ORDER BY sequence_num", ...);
        return AccountAggregate.loadFromHistory(id, history);
    }
}

Проблема производительности и Snapshotting

Представь счет, который существует 10 лет. У него 1 миллион транзакций. Чтобы получить баланс, нам нужно вычитать 1 000 000 строк из БД и применить их в цикле. Это займет секунды (недопустимо).

Решение: Snapshotting (Снимки состояния)

Мы сохраняем "срез" состояния каждые N событий (например, каждые 100).

  1. Загружаем последний Снимок (Snapshot) -> Баланс на момент события #999,900.
  2. Догружаем только события после снимка (с #999,901 по #1,000,000).
  3. Применяем их.
// Сущность снимка
public record AccountSnapshot(UUID aggregateId, BigDecimal balance, long lastEventVersion) {}

// Логика загрузки
public AccountAggregate loadFast(UUID id) {
    // 1. Ищем последний снимок
    Optional<AccountSnapshot> snapshot = snapshotRepo.findLatest(id);
    
    AccountAggregate agg = new AccountAggregate();
    long version = 0;

    if (snapshot.isPresent()) {
        agg.setBalance(snapshot.get().balance());
        version = snapshot.get().lastEventVersion();
    }

    // 2. Догружаем "хвост" событий
    List<DomainEvent> tailEvents = eventRepo.findEventsAfter(id, version);
    
    // 3. Докатываем
    agg.replay(tailEvents);
    
    return agg;
}

В High-Load снапшоты создаются асинхронно отдельным воркером, чтобы не тормозить запись (Write Path).


Schema Evolution (Эволюция схемы)

В реляционной БД мы делаем ALTER TABLE. В Event Sourcing события неизменяемы. Мы не можем изменить событие, которое случилось год назад.

Но бизнес меняется.

  • Версия 1: MoneyDeposited(amount)
  • Версия 2: MoneyDeposited(amount, currency, exchangeRate)

Как читать старые события новым кодом?

  1. Multiple Versions: Агрегат умеет обрабатывать и MoneyDepositedV1, и MoneyDepositedV2.
  2. Upcasting (Апкастинг): Прослойка при чтении из БД, которая "на лету" конвертирует JSON старого события в новый формат (например, подставляя currency = "USD" по умолчанию). Это лучший паттерн, так как не засоряет бизнес-логику легаси-кодом.

Итог по главе

Плюсы:

  1. Аудит из коробки: Мы можем доказать каждое изменение баланса.
  2. Time Travel: Мы можем посмотреть, какое состояние было у системы ровно в прошлый вторник в 14:00 (для отладки багов).
  3. Аналитика: Мы можем строить любые отчеты задним числом ("Сколько раз пользователи пытались снять деньги при нулевом балансе?"), так как храним все события.

Минусы:

  1. Сложность: Читать текущее состояние сложно (нужен Replay).
  2. Eventual Consistency: Если мы используем асинхронные проекции (об этом далее).
  3. Сложные запросы: Нельзя сделать SELECT * FROM accounts WHERE balance > 1000. В Event Store нет колонки "balance".

Глава 2: CQRS (Command Query Responsibility Segregation)

Мы только что внедрили Event Sourcing. У нас есть идеальный лог транзакций. Но у нас огромная проблема: Как получить список всех пользователей, у которых баланс > 1000$?

В Event Store (хранилище событий) нет колонки balance. Там есть только миллионы записей MoneyDeposited и MoneyWithdrawn. Чтобы ответить на этот вопрос, нам пришлось бы поднять все агрегаты в памяти, проиграть их события и отфильтровать. Это O(N) и это смерть для базы.

CQRS решает эту проблему, разделяя приложение на две половинки:

  1. Command Side (Write Model): Валидация, бизнес-логика, генерация событий. Оптимизирована на запись. (Наш Event Store).
  2. Query Side (Read Model): Плоские, денормализованные таблицы, заточенные под конкретные выборки UI. Оптимизирована на чтение.

Теория: Асинхронная проекция

Вместо одной универсальной модели (как в CRUD), мы создаем Проекции (Projections). Проекция — это "тень" наших данных, отбрасываемая событиями.

  • Когда происходит MoneyDeposited, событие летит в Event Bus.
  • Специальный сервис (Projector) ловит его и делает UPDATE account_view SET balance = balance + 100 WHERE id = ....
  • Пользователь читает из таблицы account_view.

Это позволяет использовать разные базы данных:

  • Write: PostgreSQL (Event Store) — надежность.
  • Read 1: Redis — для мгновенного получения текущего баланса.
  • Read 2: ElasticSearch — для поиска транзакций по комментарию.
  • Read 3: Neo4j — для поиска связей между плательщиками (Fraud detection).

Реализация на Java (Spring Boot)

1. Read Model (Сущность для чтения)

Это обычная JPA сущность. Она тупая. В ней нет бизнес-логики. Она просто хранит данные в удобном для фронтенда виде.

@Entity
@Table(name = "account_view") // Обычная таблица
@Data
public class AccountView {
    @Id
    private UUID id;
    private String ownerName;
    private BigDecimal currentBalance;
    private LocalDateTime lastUpdated;
    private long version; // Для оптимистической блокировки проектора
}

2. Проектор (Event Listener)

Это компонент, который слушает события (из Kafka или локального EventBus) и обновляет Read Model.

@Component
@RequiredArgsConstructor
public class AccountProjector {

    private final AccountViewRepository viewRepo;

    // Слушаем событие "Деньги внесены"
    @EventListener // Или @KafkaListener для микросервисов
    @Transactional
    public void on(MoneyDeposited event) {
        viewRepo.findById(event.accountId())
            .ifPresentOrElse(
                view -> {
                    // Просто обновляем цифру. Никаких проверок "можно ли".
                    // Проверки были на Command Side. Здесь - факт.
                    view.setCurrentBalance(view.getCurrentBalance().add(event.amount()));
                    view.setLastUpdated(event.occurredOn());
                    view.setVersion(view.getVersion() + 1);
                },
                () -> {
                    // Если счета нет в View (например, сообщение о создании пришло позже),
                    // создаем или логируем ошибку (Eventual Consistency issue)
                    log.warn("Account view not found for id: {}", event.accountId());
                }
            );
    }

    // Слушаем событие "Деньги сняты"
    @EventListener
    public void on(MoneyWithdrawn event) {
        viewRepo.findById(event.accountId()).ifPresent(view -> {
            view.setCurrentBalance(view.getCurrentBalance().subtract(event.amount()));
            view.setLastUpdated(event.occurredOn());
        });
    }
}

3. Query Handler (Сервис чтения)

Здесь мы можем писать любые SQL-запросы, не боясь нагрузить основную систему записи.

@Service
@RequiredArgsConstructor
public class AccountQueryService {

    private final AccountViewRepository viewRepo;

    // Мгновенный ответ, SELECT * FROM account_view ...
    public List<AccountView> getRichUsers(BigDecimal minBalance) {
        return viewRepo.findByCurrentBalanceGreaterThan(minBalance);
    }
}

Главный компромисс: Eventual Consistency (Согласованность в конечном счете)

В монолите ты делаешь SAVE и тут же SELECT — данные на месте. В CQRS между записью события и обновлением AccountView есть задержка (Lag). В Kafka это могут быть миллисекунды, а при сбое — минуты.

Сценарий:

  1. Пользователь пополнил счет на 100$.
  2. Событие записано.
  3. Пользователь редиректится на страницу "Мой баланс".
  4. Проектор еще не успел прочитать сообщение из Kafka.
  5. Пользователь видит старый баланс. Паника! "Где мои деньги?!"

Решения для Финтеха:

  1. Frontend Optimistic UI: Фронтенд знает, что запрос прошел успешно. Он сам рисует +100$ в интерфейсе, не дожидаясь ответа от сервера чтения.
  2. Read-Your-Writes (Читай свои записи):
    • При команде возвращать не void, а текущее расчетное состояние агрегата.
    • Или использовать версионирование: Клиент знает, что он отправил версию 5. Он опрашивает API чтения: "Дай мне версию 5". Если API отдает версию 4 — клиент ждет и повторяет запрос.
  3. WebSockets / Push Notifications: Как только проектор обновит базу, сервер отправляет пуш клиенту: "Баланс обновился".

Киллер-фича: Replay (Перестройка проекций)

Представь, что бизнес приходит и говорит: "Нам нужно показывать пользователю среднюю сумму трат за последний месяц. Раньше мы это не считали."

В обычной БД тебе пришлось бы писать сложные миграции данных, парсить логи или признать, что "исторических данных нет".

В CQRS + Event Sourcing:

  1. Ты создаешь новую таблицу MonthlySpendingView.
  2. Ты пишешь новый Проектор MonthlySpendingProjector.
  3. Ты запускаешь специальный скрипт, который читает Event Store с самого начала времен (с первого события 5-летней давности).
  4. Проектор перемалывает историю и заполняет новую таблицу.
  5. Через час у тебя готова полная аналитика за 5 лет.

Это называется Projection Replay. Это суперсила архитектора.


Итог по главе

CQRS — это плата за производительность и гибкость.

  • Цена: Сложность кода (x2 классов), асинхронность, отставание данных.
  • Выигрыш: Мгновенное чтение, возможность строить любые отчеты задним числом, независимое масштабирование (можно поставить 1 сервер на запись и 50 серверов на чтение).

Глава 3: Audit Log (Журнал аудита)

Многие разработчики путают Application Logs (Log4j/SLF4J) и Audit Logs.

  • App Logs: "NullPointerException at line 42", "Connection timeout". Для разработчиков и админов. Хранятся 14-30 дней.
  • Audit Logs: "User 'admin' changed limit for User 'client1' from 1000 to 5000". Для службы безопасности, комплаенса и суда. Хранятся 3-5 лет.

В Финтехе отсутствие нормального аудита — это гарантированный штраф от регулятора (PCI DSS, GDPR, ЦБ) или невозможность доказать, что деньги украл не ваш сотрудник.

Теория: Модель 5W

Правильный аудит должен отвечать на 5 вопросов (5W):

  1. Who (Кто): User ID, IP-адрес, User-Agent, Session ID.
  2. What (Что): Бизнес-действие (Action), старое значение, новое значение (Diff).
  3. When (Когда): Точный Timestamp (UTC).
  4. Where (Где): В каком сервисе, на каком эндпоинте.
  5. Why (Почему): Основание (например, "Тикет в Jira #123").

Архитектура: Синхронная vs Асинхронная запись

Писать аудит прямо в транзакции бизнес-логики (INSERT INTO audit ...) — плохая практика.

  1. Это замедляет основной ответ.
  2. Если база аудита упала, бизнес-транзакция тоже откатится (клиент не может перевести деньги, потому что у нас кончилось место под логи).

Best Practice: Отправлять события аудита в Kafka (топик audit-events). Оттуда отдельный консьюмер (Audit Service) перекладывает их в надежное хранилище (Elasticsearch для поиска + Cold Storage на S3 для архива).


Реализация на Java (Spring AOP)

Лучший способ внедрить аудит, не засоряя бизнес-код — это Аспектно-Ориентированное Программирование (AOP).

1. Аннотация @Auditable

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Auditable {
    String action(); // Например, "CHANGE_LIMIT"
    boolean includeResult() default true; // Логировать ли возвращаемое значение?
}

2. Аспект (AuditAspect)

Этот компонент перехватывает вызовы методов, помеченных аннотацией.

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AuditAspect {

    private final KafkaTemplate<String, AuditEvent> kafkaTemplate;
    private final ObjectMapper objectMapper;

    @Around("@annotation(auditable)")
    public Object audit(ProceedingJoinPoint joinPoint, Auditable auditable) throws Throwable {
        long start = System.currentTimeMillis();
        boolean success = true;
        String errorMessage = null;
        Object result = null;

        try {
            // Выполняем реальный метод
            result = joinPoint.proceed();
            return result;
        } catch (Exception e) {
            success = false;
            errorMessage = e.getMessage();
            throw e; // Пробрасываем исключение дальше, аудит не должен его глотать
        } finally {
            // В блоке finally собираем данные ДАЖЕ если метод упал
            recordAudit(joinPoint, auditable, result, success, errorMessage);
        }
    }

    private void recordAudit(ProceedingJoinPoint jp, Auditable auditable, Object result, boolean success, String error) {
        // 1. Кто (SecurityContext)
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String user = (auth != null) ? auth.getName() : "ANONYMOUS";
        
        // 2. Аргументы (Что было на входе)
        Object[] args = jp.getArgs();
        String argsJson = safeSerialize(args); // Важно: маскировать пароли/PAN!

        // 3. Результат
        String resultJson = auditable.includeResult() ? safeSerialize(result) : "HIDDEN";

        // 4. Формируем событие
        AuditEvent event = AuditEvent.builder()
                .action(auditable.action())
                .user(user)
                .timestamp(Instant.now())
                .parameters(argsJson)
                .result(resultJson)
                .success(success)
                .errorMessage(error)
                .serviceName("payment-service")
                .build();

        // 5. Отправляем в Kafka (асинхронно)
        kafkaTemplate.send("audit-logs", event);
    }
    
    private String safeSerialize(Object obj) {
        // Тут должна быть логика маскирования (Masking) чувствительных данных
        // Например, замена PAN карты на 4242 **** **** 4242
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            return "SERIALIZATION_ERROR";
        }
    }
}

3. Применение в сервисе

@Service
public class AdminService {

    @Auditable(action = "CHANGE_USER_LIMIT")
    public void changeLimit(String userId, BigDecimal newLimit) {
        // Бизнес-логика
        userRepository.updateLimit(userId, newLimit);
    }
}

Критические моменты (Senior Insights)

1. Immutability (Неизменяемость)

Главный вектор атаки инсайдера (злобного админа): "Украсть деньги и удалить логи". Обычная база данных (Postgres DELETE FROM audit) не защищает от админа БД.

Решения:

  • Write-Once-Read-Many (WORM) Storage: Использование S3 Object Lock или специальных аппаратных хранилищ.
  • Blockchain-lite (Хеширование цепочки): Каждая запись аудита содержит хеш предыдущей записи. Record[N].prevHash = SHA256(Record[N-1]). Если злоумышленник удалит запись посередине, цепочка хешей сломается. Аудитор это сразу увидит. Пример реализации: Amazon QLDB (Quantum Ledger Database) или просто колонка prev_hash в Postgres.

2. Чувствительные данные (PII / PCI DSS)

Аудит — это главное место утечки данных.

  • Ошибка: Логировать объект User целиком.
  • Результат: В логах аудита лежат хеши паролей, email'ы и номера паспортов в открытом виде.
  • Решение: Использовать DTO для аудита или настроить Jackson MixIn для игнорирования полей @JsonIgnore / кастомных аннотаций @Sensitive.

3. Context Propagation

Если действие делает микросервис Б по приказу микросервиса А, который вызвал пользователь, в аудите микросервиса Б должно быть записано: "Initiator: User", а не "Initiator: Service A". Это решается пробросом заголовков (X-Initiator-User) через Feign/RestTemplate.


Итог по Модулю 3

Мы создали идеальный слой данных:

  1. Event Sourcing: Хранит полную историю изменений (техническую).
  2. CQRS: Позволяет быстро читать данные.
  3. Audit Log: Хранит юридически значимый след (кто и зачем).

Теперь, когда у нас есть надежные данные, пришло время ускоряться. Если пользователей станет 10 миллионов, одна база Postgres умрет.


Модуль 4: High Load & Scalability (Масштабирование)

Глава 1: Sharding & Partitioning (Шардирование и Партицирование)

Мы построили надежную систему (Circuit Breaker), гарантировали транзакции (Saga/Outbox) и настроили аудит. Но однажды к нам приходит успех.

  • 1 миллион пользователей: Postgres справляется.
  • 10 миллионов пользователей: Postgres начинает "потеть" (CPU 80%, I/O wait).
  • 100 миллионов пользователей: Никакой "Vertical Scaling" (покупка сервера помощнее) не спасет. Один сервер физически не может держать столько соединений и данных.

Нам нужно Horizontal Scaling. Нам нужно распилить базу данных.


Теория: Partitioning vs Sharding

Многие путают эти понятия. Разница — в физике.

Характеристика Partitioning (Партицирование) Sharding (Шардирование)
Где данные? На одном физическом сервере. На разных физических серверах (узлах).
Цель Ускорение запросов (Table Scan по маленькому куску). Удобство администрирования (удалить старые данные). Бесконечное масштабирование (Write/Read throughput).
Проблемы Не спасает от смерти сервера (SPOF). Сложность транзакций (Cross-shard transactions), JOIN-ов и агрегации.

В High-Load нас интересует именно Шардирование.

Стратегии Шардирования (Выбор ключа)

Выбор Shard Key (ключа шардирования) — это самое важное архитектурное решение. Его почти невозможно изменить потом без остановки бизнеса.

1. Range Based (По диапазонам)

  • Логика: Shard 1: ID 0-1,000,000. Shard 2: ID 1,000,001-2,000,000.
  • Плюс: Легко делать выборки "Дай мне пользователей за прошлый месяц".
  • Минус (Критический): Hotspot. Все новые пользователи пишутся в последний шард. Он горит, остальные простаивают.

2. Hash Based (По хешу / Modulo)

  • Логика: Shard_ID = (User_ID) % N, где N — кол-во шардов.
  • Плюс: Идеальное равномерное распределение нагрузки.
  • Минус: Resharding. Если N=4, а стало N=5, формула меняется, и 80% данных нужно перенести на другие сервера. (Решается через Consistent Hashing).

3. Directory Based (Справочник)

  • Логика: Есть отдельная база (Lookup DB), где написано: User A -> Shard 1, User B -> Shard 5.
  • Плюс: Полная гибкость. Можно переносить VIP-клиентов на выделенное железо.
  • Минус: Lookup DB становится узким местом и точкой отказа.

Реализация на Java (Spring Boot + AbstractRoutingDataSource)

Spring умеет "на лету" переключать Datasource (подключение к БД) в зависимости от контекста запроса. Нам не нужны тяжелые фреймворки типа ShardingSphere для начала, достаточно стандарта JDBC.

1. Контекст Шардирования (ThreadLocal)

Мы должны знать, с каким шардом работаем в текущем потоке.

public class ShardContext {
    private static final ThreadLocal<String> currentShard = new ThreadLocal<>();

    public static void setShard(String shardKey) {
        currentShard.set(shardKey);
    }

    public static String getShard() {
        return currentShard.get();
    }

    public static void clear() {
        currentShard.remove();
    }
}

2. Routing Data Source

Магия Spring. Мы расширяем стандартный класс и говорим ему, откуда брать ключ.

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

public class ShardingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        // Spring вызовет это перед каждым запросом к БД
        return ShardContext.getShard();
    }
}

3. Конфигурация (Wiring)

@Configuration
public class DataSourceConfig {

    @Bean
    public DataSource dataSource() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        
        // Настраиваем реальные подключения (Physical Shards)
        targetDataSources.put("SHARD_1", createDataSource("jdbc:postgresql://host1:5432/db"));
        targetDataSources.put("SHARD_2", createDataSource("jdbc:postgresql://host2:5432/db"));
        targetDataSources.put("SHARD_3", createDataSource("jdbc:postgresql://host3:5432/db"));

        ShardingDataSource routingDataSource = new ShardingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        
        // Дефолтный шард (если ключ не задан)
        routingDataSource.setDefaultTargetDataSource(targetDataSources.get("SHARD_1"));
        
        return routingDataSource;
    }

    private DataSource createDataSource(String url) {
        // HikariCP config...
    }
}

4. Аспект для маршрутизации

Теперь нам нужно перехватывать запросы и вычислять шард. Допустим, мы шардируем по userId. Используем стратегию Modulo (остаток от деления).

@Aspect
@Component
public class ShardingAspect {

    private final int SHARD_COUNT = 3;

    @Before("execution(* com.example.service.*.*(..)) && args(userId,..)")
    public void routeShard(Long userId) {
        // Алгоритм выбора шарда: userId % 3
        // Для реального прода лучше использовать MurmurHash или Consistent Hashing
        long shardId = userId % SHARD_COUNT;
        
        String shardKey = "SHARD_" + (shardId + 1); // "SHARD_1", "SHARD_2"...
        
        ShardContext.setShard(shardKey);
    }

    @After("execution(* com.example.service.*.*(..))")
    public void clearShard() {
        ShardContext.clear(); // Очистка ThreadLocal обязательна! Иначе утечка памяти.
    }
}

Проблемы Шардирования (Pain Points)

Шардирование — это архитектурная "крайняя мера". Как только вы это сделали, вы потеряли:

  1. ACID Транзакции между шардами:
    • Нельзя сделать BEGIN; UPDATE Shard1...; UPDATE Shard2...; COMMIT;.
    • Решение: Использовать паттерн Saga (см. Модуль 2) или Two-Phase Commit (XA) (но это медленно).
  2. JOIN-ы между шардами:
    • Нельзя сделать SELECT * FROM Orders o JOIN Users u ON o.user_id = u.id, если User лежит на Шарде 1, а Order на Шарде 2.
    • Решение 1 (Data Locality): Хранить Orders на том же шарде, что и Users (вычислять шард для заказа по user_id, а не по order_id).
    • Решение 2 (Application Join): Сделать два запроса из кода (Java) и склеить данные в памяти (дорого).
  3. Unique Constraints (Глобальная уникальность):
    • База не может гарантировать уникальность email, если email'ы размазаны по 10 серверам.
    • Решение: Отдельная таблица lookup (индекс) или использование UUID.

Consistent Hashing (Согласованное хеширование)

Чтобы решить проблему "Modulo N" (когда добавление нового сервера меняет все хеши), используют кольцо хеширования.

  1. Представим круг (Ring) от 0 до $2^{32}$ .
  2. Размещаем на круге наши сервера (точки).
  3. Хешируем ключ пользователя -> попадаем в точку на круге.
  4. Двигаемся по часовой стрелке до ближайшего сервера.
  5. Фича: Если добавляется сервер, он забирает нагрузку только у своего "соседа", а не перетряхивает весь кластер.

Итог по главе

  1. Шардирование — это физическое разделение данных.
  2. Data Locality (Локальность данных) — критически важна. Все данные одного клиента (Счет, История, Профиль) должны лежать на одном шарде, чтобы работали локальные транзакции и JOIN-ы.
  3. В Spring это реализуется через AbstractRoutingDataSource.

Глава 2: Caching Strategies (Стратегии кэширования)

Кэширование — это самый дешевый способ ускорить чтение в 100 раз. Но в High-Load есть поговорка: "В информатике есть две сложные проблемы: инвалидация кэша и именование переменных".

Просто повесить @Cacheable — это уровень Junior. На уровне Senior/Architect мы боремся с тремя всадниками апокалипсиса кэширования:

  1. Cache Penetration (Пробитие кэша).
  2. Cache Breakdown / Stampede (Гонка за горячим ключом).
  3. Cache Avalanche (Лавина).

Стратегии записи и чтения

Для начала база. Как данные попадают в Redis?

  1. Cache-Aside (Lazy Loading):
    • Приложение читает из Кэша.
    • Если пусто (Miss) -> Читает из БД -> Кладет в Кэш -> Отдает результат.
    • Плюс: В кэше только то, что реально запрашивают.
    • Минус: Первый запрос всегда медленный (Cold Start).
  2. Write-Through (Сквозная запись):
    • Приложение пишет только в Кэш.
    • Кэш синхронно пишет в БД.
    • Плюс: Данные в кэше всегда свежие.
    • Минус: Запись медленнее (два хопа).
  3. Write-Behind (Асинхронная запись):
    • Приложение пишет в Кэш и сразу отдает OK.
    • Кэш асинхронно сбрасывает данные в БД (раз в секунду).
    • Риск: Если сервер упадет до сброса — данные потеряны. Используется для счетчиков (лайки, просмотры).

Проблема 1: Cache Stampede (Thundering Herd)

Представь, что у нас есть ключ exchange_rate_usd, который живет 60 секунд. На 61-й секунде он протухает. В эту же миллисекунду прилетает 10 000 запросов за курсом доллара.

  1. Все 10 000 идут в Redis.
  2. Все 10 000 получают null.
  3. Все 10 000 идут в БД c тяжелым SELECT-ом.
  4. БД падает.

Решение А: Mutex (Распределенная блокировка)

Мы разрешаем только одному потоку пойти в БД и обновить кэш. Остальные должны ждать.

Реализация на Java (Redis SetNX):

public String getExchangeRate(String currency) {
    String key = "rate:" + currency;
    
    // 1. Читаем из кэша
    String value = redisTemplate.opsForValue().get(key);
    if (value != null) return value;

    // 2. Кэш пуст. Пытаемся захватить лок (Mutex)
    String lockKey = "lock:" + key;
    Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            // 3. Мы - избранный поток! Идем в БД.
            // Double Check: вдруг кто-то успел обновить до нас?
            value = redisTemplate.opsForValue().get(key);
            if (value != null) return value;

            value = database.getRate(currency); // Тяжелый запрос
            
            // 4. Обновляем кэш
            redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(1));
        } finally {
            // 5. Освобождаем лок
            redisTemplate.delete(lockKey);
        }
    } else {
        // 6. Мы не успели. Ждем и пробуем снова (Spin Lock)
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        return getExchangeRate(currency); // Рекурсия
    }
    return value;
}

Решение Б: Logical Expiration (Логическое устаревание)

Мы вообще не ставим TTL (время жизни) в Redis. Ключ живет вечно. Но внутри значения мы храним поле expireAt.

  • При чтении проверяем: if (now > value.expireAt).
  • Если устарело -> отдаем старое значение (чтобы не тормозить клиента), но асинхронно запускаем задачу на обновление в фоне.

Проблема 2: Cache Penetration (Пробитие)

Хакер (или баг) начинает запрашивать несуществующий ID: user_id = -1.

  • Redis: нет такого.
  • БД: нет такого.
  • Результат не кэшируется (обычно null не пишут в кэш).
  • Следующий запрос user_id = -1 снова бьет в БД.

Решение: Null Object Pattern & Bloom Filter

  1. Cache Nulls: Если БД вернула null, мы записываем в Redis специальное значение "NULL_PLACEHOLDER" с коротким TTL (например, 5 минут).
  2. Bloom Filter: Перед походом в Redis проверяем ID через фильтр Блума (вероятностная структура данных, которая говорит "Точно нет" или "Возможно да"). Если фильтр говорит "нет" — даже не идем в базу.

Проблема 3: Cache Avalanche (Лавина)

Мы закешировали 1 000 000 товаров с TTL = 1 час. Ровно через час все ключи протухают одновременно. Нагрузка на БД подскакивает вертикально.

Решение: Jitter (Дрожание)

Никогда не ставь ровный TTL. Добавляй случайность.

$$ TTL=BaseTime+Random\left(0,300seconds\right) $$

В Spring Cache это делается кастомным CacheManager, но чаще — просто в коде сервиса.


Advanced: Multi-Level Caching (L1 + L2)

В сверхнагруженных системах (100k+ RPS) даже Redis становится узким местом (сетевые задержки 1-2 мс). Нам нужен кэш, который отвечает за наносекунды.

Архитектура:

  • L1 (Local Cache): Caffeine / Ehcache. В памяти JVM. Быстро, но мало места и рассинхрон между подами.
  • L2 (Distributed Cache): Redis. Общий для всех.

Реализация (Spring Boot + Caffeine + Redis): Библиотека Spring Cache поддерживает это через композицию, но лучше использовать готовое решение, например, Redisson (у него есть LocalCachedMap).

Пример ручной реализации (Decorator):

public User getUser(String id) {
    // 1. Check L1 (Caffeine)
    User user = caffeineCache.getIfPresent(id);
    if (user != null) return user;

    // 2. Check L2 (Redis)
    user = redisTemplate.opsForValue().get(id);
    if (user != null) {
        caffeineCache.put(id, user); // Populate L1
        return user;
    }

    // 3. DB
    user = db.findById(id);
    
    // 4. Populate L2 & L1
    redisTemplate.opsForValue().set(id, user);
    caffeineCache.put(id, user);
    
    return user;
}

Проблема L1: Как инвалидировать L1 на других серверах, если мы обновили данные на Сервере А? Решение: Redis Pub/Sub. При обновлении кидаем событие "Invalidate ID=123", и все поды слушают этот канал и удаляют запись из своего локального Caffeine.


Итог по главе

  1. Mutex спасает от Cache Stampede (горячие ключи).
  2. Bloom Filter спасает от Cache Penetration (несуществующие ключи).
  3. Jitter спасает от Cache Avalanche (одновременное протухание).
  4. L1+L2 кэширование нужно для экстремально низкой задержки.

Глава 3: Asynchronous Processing (Асинхронность и Очереди)

Синхронная обработка (HTTP REST) — это зло для High-Load. Если пользователь нажал "Сформировать отчет за год", и сервер начал считать это в том же потоке:

  1. Пользователь ждет 30 секунд (и закрывает вкладку).
  2. Поток сервера (Tomcat thread) заблокирован.
  3. Если придет 1000 таких пользователей — сервер ляжет (OutOfMemoryError или Thread Pool Exhaustion).

Асинхронность превращает "Сделай сейчас" в "Я принял твою задачу, сделаю когда смогу".

Паттерн: Queue-Based Load Leveling (Сглаживание нагрузки)

Представь Черную Пятницу. Трафик вырастает в 100 раз за 1 минуту. База данных может переварить 1000 записей в секунду (TPS). Прилетает 10 000 TPS. Результат: База падает, сервис падает, бизнес теряет деньги.

Решение: Ставим буфер (Очередь) перед базой.

  1. Producer (API): Принимает заказ, валидирует, кидает в Kafka и мгновенно отвечает клиенту "202 Accepted".
  2. Queue (Kafka): Накапливает сообщения. Она может держать миллионы сообщений без проблем.
  3. Consumer (Worker): Читает из Kafka со скоростью, которую может выдержать База (1000 TPS).
    • Если сообщений много — растет лаг (отставание).
    • База работает ровно, на пределе своих возможностей, но не умирает.

Выбор технологии: Kafka vs RabbitMQ

В финтехе используются оба, но для разных задач.

Характеристика Apache Kafka RabbitMQ
Модель Распределенный лог (Log). Умная очередь (Smart Broker, Dumb Consumer).
Хранение Длительное (дни/недели). Можно перечитать (Replay). Удаляется сразу после обработки (Ack).
Пропускная способность Экстремальная (миллионы msg/sec). Высокая (десятки тысяч msg/sec).
Гарантии порядка Строго внутри партиции. Нет гарантий при конкурентных консьюмерах.
Use Case Event Sourcing, стриминг логов, аналитика, CDC. Сложный роутинг, Task Queue, отложенные задачи.

Вердикт Архитектора:

  • Для событий (AccountCreated, MoneyTransferred) — Kafka.
  • Для задач (SendEmail, GenerateReport) — RabbitMQ (из-за гибкого роутинга и DLQ из коробки).

Реализация на Java (Spring Boot + Kafka)

Реализуем паттерн Competing Consumers (Конкурирующие потребители). У нас один топик orders и 10 инстансов сервиса OrderProcessor. Kafka сама распределит нагрузку (партиции) между ними.

1. Producer (API Gateway)

@Service
@RequiredArgsConstructor
public class OrderProducer {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public void sendOrder(OrderEvent event) {
        // Ключ (event.getOrderId()) важен! Все события по одному заказу
        // должны попадать в одну партицию, чтобы сохранить порядок.
        // Иначе статус "Paid" может прийти раньше "Created".
        kafkaTemplate.send("orders", event.getOrderId(), event);
    }
}

2. Consumer (Worker)

Самое важное — это Manual Acknowledgement (Ручное подтверждение). По умолчанию Spring делает Ack сразу после вызова метода. Если метод упал, но Ack ушел — сообщение потеряно.

Конфигурация (application.yml):

spring:
  kafka:
    consumer:
      enable-auto-commit: false # Отключаем авто-коммит!
      ack-mode: MANUAL_IMMEDIATE

Код:

@Service
@Slf4j
public class OrderConsumer {

    @KafkaListener(topics = "orders", groupId = "order-group")
    public void listen(ConsumerRecord<String, OrderEvent> record, Acknowledgment ack) {
        try {
            log.info("Processing order: {}", record.key());
            
            // Тяжелая бизнес-логика (обращение в БД, внешний API)
            processOrder(record.value());
            
            // Если все ОК - подтверждаем брокеру.
            // Сдвигаем офсет.
            ack.acknowledge(); 
            
        } catch (Exception e) {
            log.error("Error processing order: {}", record.key(), e);
            // ВАЖНО: Не делаем ack.acknowledge()!
            // Kafka перепошлет сообщение (или consumer остановится, зависит от настройки).
            // Для продакшена нужен механизм Dead Letter Queue.
        }
    }
}

Надежность: Dead Letter Queue (DLQ)

Что делать, если сообщение "битое" (Poison Message)? Например, в поле amount пришла строка "null".

  1. Консьюмер читает -> Ошибка парсинга -> Исключение.
  2. Ack не отправляется.
  3. Kafka отдает сообщение снова.
  4. Бесконечный цикл смерти (Loop of Death). Очередь встала.

Решение: После N неудачных попыток (Retry) перекладываем сообщение в специальный топик orders-dlq и коммитим оригинал.

В Spring Kafka это настраивается декларативно:

@Bean
public CommonErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
    // 1. Сначала пробуем 3 раза с задержкой (Backoff)
    DefaultErrorHandler handler = new DefaultErrorHandler(
        new DeadLetterPublishingRecoverer(template), // 2. Если не вышло -> в DLQ
        new FixedBackOff(1000L, 3)
    );
    return handler;
}

Теперь "битые" сообщения не блокируют очередь, а админы могут разобрать их потом в топике orders-dlq.


Итог по Модулю 4

Мы построили систему, которая выдерживает миллионы пользователей:

  1. Sharding: Распилили данные.
  2. Caching: Убрали чтение с базы.
  3. Async/Queues: Сгладили пики записи.

Но есть одна проблема. Если мы обрабатываем миллионы сообщений в секунду, обычный Thread Per Request (Блокирующий ввод-вывод) не справится. Потоки в Java дорогие (1-2 Мб памяти на стек). 10 000 потоков = 10-20 Гб RAM просто на стеки.

Нам нужно убрать блокировки совсем.


Модуль 5: Low Latency & High Performance (Низкая задержка)

Глава 1: LMAX Disruptor & Ring Buffer

Мы входим в элиту инженерии. Это зона High Frequency Trading (HFT) и матчинговых движков бирж (LSE, Nasdaq). Здесь время измеряется не в миллисекундах, а в микросекундах и даже наносекундах.

Стандартные очереди Java (ArrayBlockingQueue, LinkedBlockingQueue) здесь не работают. Почему?

  1. Блокировки (Locks): ReentrantLock и synchronized вызывают переключение контекста ядра (kernel context switch). Это дорого.
  2. Contention (Конкуренция): Когда 10 потоков пытаются писать в "голову" очереди и 10 читать из "хвоста", они постоянно дерутся за CAS-операции (Compare-And-Swap) или блокировки.
  3. Cache Misses (Промахи кэша): В LinkedBlockingQueue каждый узел — это новый объект в куче. Они разбросаны по памяти хаотично. Процессор не может предсказать (Prefetch), где данные, и постоянно ждет RAM.

LMAX Disruptor — это структура данных, созданная инженерами Лондонской биржи LMAX, которая обрабатывает 6 миллионов операций в секунду на одном потоке.

Теория: Ring Buffer (Кольцевой буфер)

В основе Disruptor лежит Ring Buffer. Это массив фиксированного размера, замкнутый в кольцо.

Ключевые принципы:

  1. Pre-allocation (Предварительное выделение): Мы создаем массив событий один раз при старте. Мы никогда не делаем new Event() в Runtime. Это убивает нагрузку на Garbage Collector (GC). Мы просто перезаписываем поля существующих объектов.
  2. Sequence Number (Номер последовательности): Нет указателей "head" и "tail". Есть просто long sequence, который постоянно растет (0, 1, 2... 100500). Позиция в массиве вычисляется как sequence % size.
  3. Single Writer Principle (Принцип одного писателя): Если в буфер пишет только один поток, нам вообще не нужны блокировки (Locks). Нам нужны только барьеры памяти (Memory Barriers), чтобы читатели видели изменения. Это на порядки быстрее.

Реализация на Java (LMAX Disruptor)

Нам понадобится зависимость:

<dependency>
    <groupId>com.lmax</groupId>
    <artifactId>disruptor</artifactId>
    <version>3.4.4</version>
</dependency>

1. Событие (Event)

Это просто контейнер для данных (DTO). Он мутабельный и переиспользуемый!

public class TradeEvent {
    private long price;
    private long volume;
    private String symbol;

    // Геттеры и сеттеры
    public void set(long price, long volume, String symbol) {
        this.price = price;
        this.volume = volume;
        this.symbol = symbol;
    }
    
    // Очистка перед переиспользованием (для безопасности)
    public void clear() {
        this.symbol = null;
    }
}

2. Фабрика событий (EventFactory)

Disruptor должен знать, как создать пустые события при старте, чтобы заполнить ими Ring Buffer.

import com.lmax.disruptor.EventFactory;

public class TradeEventFactory implements EventFactory<TradeEvent> {
    @Override
    public TradeEvent newInstance() {
        return new TradeEvent(); // Вызывается только 1024 раза при старте
    }
}

3. Обработчик (EventHandler / Consumer)

Это наша бизнес-логика.

import com.lmax.disruptor.EventHandler;

public class TradeEventHandler implements EventHandler<TradeEvent> {
    @Override
    public void onEvent(TradeEvent event, long sequence, boolean endOfBatch) {
        // Никаких блокировок! Мы эксклюзивные владельцы этого события в данный момент.
        // Обработка матчинга...
        System.out.println("Processing trade: " + event.getSymbol() + " " + event.getPrice());
        
        // Очистка не нужна здесь, она делается при публикации (Producer)
    }
}

4. Сборка (Disruptor Setup)

import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.util.DaemonThreadFactory;
import java.nio.ByteBuffer;

public class MatchingEngine {
    public static void main(String[] args) {
        // Размер буфера должен быть степенью двойки (2^n) для оптимизации битовых операций
        int bufferSize = 1024; 

        // Создаем Disruptor
        Disruptor<TradeEvent> disruptor = new Disruptor<>(
                new TradeEventFactory(),
                bufferSize,
                DaemonThreadFactory.INSTANCE // Фабрика потоков
        );

        // Подключаем обработчик
        disruptor.handleEventsWith(new TradeEventHandler());

        // Запускаем
        disruptor.start();

        // Получаем доступ к кольцу для публикации
        RingBuffer<TradeEvent> ringBuffer = disruptor.getRingBuffer();
        
        // Публикация событий (Producer)
        ByteBuffer bb = ByteBuffer.allocate(8);
        for (long l = 0; l < 100; l++) {
            // 1. Резервируем следующий слот (Sequence)
            long sequence = ringBuffer.next();
            try {
                // 2. Получаем объект по индексу (без создания нового!)
                TradeEvent event = ringBuffer.get(sequence);
                
                // 3. Заполняем данными
                event.set(100L + l, 50L, "BTC/USD");
            } finally {
                // 4. Публикуем (делаем доступным для Consumer)
                ringBuffer.publish(sequence);
            }
        }
    }
}

Mechanical Sympathy (Симпатия к железу)

Почему Disruptor такой быстрый? Потому что он учитывает, как работает процессор.

False Sharing (Ложное разделение кэш-линий)

Процессор читает память не байтами, а Кэш-линиями (Cache Lines), обычно по 64 байта.

Проблема: Представь объект с двумя полями: volatile long head (пишет Consumer) и volatile long tail (пишет Producer). Оба поля long (8 байт). Они лежат в памяти рядом и попадают в одну кэш-линию (64 байта).

  1. Ядро 1 меняет head. Кэш-линия на Ядре 1 помечается "грязной".
  2. Ядро 2 хочет прочитать tail. Но tail лежит в той же кэш-линии, что и head.
  3. Протокол когерентности кэша (MESI) заставляет Ядро 2 инвалидировать (сбросить) свою кэш-линию и перечитать её из RAM (или L3), хотя tail даже не менялся!
  4. Это называется False Sharing. Потоки не трогают данные друг друга, но дерутся за кэш-линию.

Решение (Padding): Мы добавляем "мусорные" поля между head и tail, чтобы раздвинуть их в памяти на расстояние больше 64 байт.

В Java 8+: Аннотация @Contended (требует флага -XX:-RestrictContended).

import jdk.internal.vm.annotation.Contended;

public class RingBufferFields {
    @Contended
    private volatile long p1, p2, p3, p4, p5, p6, p7; // Padding (заполнитель)
    
    private volatile long cursor; // Наша полезная переменная
    
    @Contended
    private volatile long p8, p9, p10, p11, p12, p13, p14; // Еще Padding
}

LMAX Disruptor делает это автоматически внутри своих классов Sequence.


Итог по главе

  1. LMAX Disruptor — это не очередь, это циклический буфер событий.
  2. Zero Garbage: Мы не создаем объекты под нагрузкой.
  3. Lock-Free: Используется только CAS и Memory Barriers.
  4. CPU Cache Friendly: Данные лежат плотно, False Sharing устранен.

Глава 2: Mechanical Sympathy (Симпатия к железу)

Термин Mechanical Sympathy ввел Мартин Томпсон (создатель LMAX Disruptor). Суть проста: Ты не пишешь код для JVM. Ты пишешь код для железа, на котором работает JVM.

Абстракции Java (List, Map, Object) скрывают от нас правду о том, как работает память. В обычном Enterprise это благо. В Low Latency/High Frequency Trading (HFT) — это враг.

Главный враг производительности — это не сложность алгоритма (O(n)), а Memory Latency (Задержка памяти).

1. Иерархия памяти и Latency Numbers

Чтобы понять масштаб трагедии, посмотри на цифры (приблизительные для современного CPU):

  1. L1 Cache (на ядре): ~1 наносекунда. (Мгновенно).
  2. L2 Cache: ~3-5 наносекунд.
  3. L3 Cache (общий): ~10-20 наносекунд.
  4. Main Memory (RAM): ~100 наносекунд.

Вывод: Поход в оперативную память (RAM) стоит в 100 раз дороже, чем чтение из кэша L1. Если твой процессор работает на частоте 4 ГГц, то за время одного похода в RAM он мог бы выполнить 400 инструкций. Если твои данные не влезают в кэш CPU — процессор простаивает (Stall).


2. Data Locality & Prefetching (Локальность данных)

Процессор пытается угадать, какие данные тебе понадобятся, и заранее подтягивает их из медленной RAM в быстрый L1. Это делает Hardware Prefetcher.

Он работает просто:

  • "Ага, ты прочитал адрес X, потом X+1... Наверное, тебе нужен X+2".
  • Он подгружает данные Кэш-линиями (Cache Lines) по 64 байта.

Битва: LinkedList vs ArrayList (vs array of primitives)

В Java это классический пример.

  1. LinkedList:
    • Узлы (Node) — это отдельные объекты.
    • Они разбросаны по куче (Heap) хаотично. Node A может быть по адресу 0x100, а Node B по адресу 0x9000.
    • Это Pointer Chasing (Погоня за указателями). Префетчер не может угадать адрес следующего узла, пока ты не прочитаешь текущий.
    • Результат: Cache Miss (Промах кэша) на каждом шаге.
  2. ArrayList (List<Integer>):
    • Внутри лежит массив ссылок Integer[].
    • Массив ссылок лежит в памяти непрерывно. Префетчер работает отлично для массива ссылок.
    • НО: Сами объекты Integer могут быть разбросаны где угодно! Мы все равно прыгаем по памяти, разыменовывая ссылки.
  3. Primitive Array (int[]):
    • Идеал. Данные лежат плотно.
    • В одну кэш-линию (64 байта) влезает 16 чисел int (по 4 байта).
    • Читая arr[0], ты бесплатно получаешь arr[1]...arr[15] в L1 кэш.

Совет для Low Latency: Используй примитивные коллекции (Trove, Eclipse Collections, FastUtil) или массивы. Избегай Integer, Long и других оберток (Boxed types) в горячих циклах.


3. Allocation Rate & GC Pressure (Проблема аллокации)

В Java создание объекта (new) — это очень дешево (просто сдвиг указателя в TLAB - Thread Local Allocation Buffer). Но сборка мусора (Garbage Collection) — это дорого.

Если ты создаешь миллионы временных объектов (DTO, итераторы, стримы) в секунду:

  1. Eden Space (Эдем) забивается мгновенно.
  2. Запускается Minor GC.
  3. GC останавливает потоки (Stop-The-World), чтобы пометить живые объекты.
  4. Даже пауза в 1 мс — это катастрофа для HFT.

Паттерн: Object Pooling (Пул объектов)

В High-Load мы возвращаемся к корням C++. Мы не удаляем объекты. Мы используем их повторно.

Пример (Переиспользование DTO):

public class MarketDataHandler {

    // Пул объектов, чтобы не делать new Quote()
    private final Queue<Quote> pool = new ArrayDeque<>(1024);

    public void onMessage(String symbol, double price) {
        // 1. Берем объект из пула (или создаем, если пусто)
        Quote quote = pool.poll();
        if (quote == null) {
            quote = new Quote();
        }

        // 2. Инициализируем (Mutable State!)
        quote.setSymbol(symbol);
        quote.setPrice(price);

        // 3. Обрабатываем
        process(quote);

        // 4. Очищаем и возвращаем в пул
        quote.clear(); 
        pool.offer(quote);
    }
}
  • Плюс: Zero Allocation. GC спит.
  • Минус: Сложно. Нужно следить, чтобы никто не сохранил ссылку на возвращенный в пул объект (иначе данные изменятся под ногами). Это частый источник багов.

4. Java Object Layout (Структура объекта)

Сколько памяти занимает new Long(10)?

  • 4 байта? Нет.
  • 8 байт? Нет.

Ответ: 24 байта (на 64-битной JVM со сжатыми ссылками).

  1. Mark Word (Заголовок): 8 байт (хэш-код, флаги блокировки, возраст GC).
  2. Class Pointer (Ссылка на класс): 4 байта.
  3. Data (Значение): 8 байт.
  4. Padding (Выравнивание): 4 байта (объект должен быть кратен 8 байтам).

Вывод: Используя List<Long>, мы тратим 24 байта на хранение 8 байт полезной информации. Overhead = 200%. Плюс ссылка в самом массиве списка (еще 4 байта). Итого 28 байт на одно число. Кэш забивается "мусором" (заголовками объектов).

Именно поэтому массивы примитивов (long[]) выигрывают: там только данные.


Итог по главе

Чтобы писать Low Latency на Java:

  1. Love the Hardware: Понимай, как процессор читает память.
  2. Avoid Wrappers: int[] вместо List<Integer>.
  3. Zero Allocation: Используй пулы объектов (Object Pooling) или Ring Buffer (Disruptor) для горячих путей.
  4. Data Locality: Храни данные, которые обрабатываются вместе, рядом в памяти.

Модуль 6: Observability (Наблюдаемость)

Глава 1: Distributed Tracing (Распределенная трассировка)

В монолите всё просто: у нас есть один лог-файл, и мы можем отследить путь запроса по thread name. В микросервисах один бизнес-запрос (например, "Купить билет") порождает каскад из 50 HTTP/gRPC вызовов между 10 разными сервисами.

Если на 45-м шаге произошла ошибка, а у тебя нет трассировки — ты слеп. Ты видишь ошибку в сервисе Z, но не знаешь, почему сервис А вообще его вызвал и с какими параметрами.

Теория: Анатомия Трейса

Distributed Tracing решает эту проблему, помечая каждый запрос уникальными ID, которые передаются в заголовках (Headers) между сервисами.

Основные понятия (OpenTelemetry Standard):

  1. Trace (Трейс): Описывает весь путь запроса от входа (Mobile App) до последней базы данных. У него есть уникальный Trace ID (128-bit UUID).
  2. Span (Спан / Пролет): Описывает конкретную операцию внутри трейса (например, "SQL запрос", "HTTP вызов", "Метод контроллера"). У спана есть:
    • Span ID: Уникальный ID внутри трейса.
    • Parent Span ID: Ссылка на родителя (кто вызвал).
    • Duration: Сколько времени заняло.
    • Tags/Attributes: Метаданные (URL, статус код, user_id).

Визуализация: Инструменты вроде Jaeger или Zipkin строят диаграмму Ганта (Waterfall), где видно:

  • Сервис А (100ms)
    • Сервис Б (50ms) -> Здесь была задержка!
      • База данных (5ms)

Реализация на Java (Spring Boot 3 + Micrometer Tracing)

Важно: В Spring Boot 2 стандартом был Spring Cloud Sleuth. В Spring Boot 3 он удален. Теперь используется Micrometer Tracing поверх OpenTelemetry.

1. Зависимости (Maven)

Нам нужен "мост" к OpenTelemetry и экспортер (куда слать данные, например, в Zipkin).

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. Конфигурация (application.yml)

management:
  tracing:
    sampling:
      probability: 1.0 # 100% запросов (для Dev). В Prod ставят 0.01 (1%)
  zipkin:
    tracing:
      endpoint: "http://jaeger:9411/api/v2/spans"

3. Автоматическая магия

Spring автоматически прописывает Trace ID и Span ID в:

  • Логи (MDC).
  • Исходящие HTTP-запросы (RestTemplate, WebClient, Feign).
  • Исходящие сообщения Kafka (KafkaTemplate).

Тебе ничего не нужно делать. Просто добавь в logback-spring.xml паттерн для вывода ID:

<pattern>
    %d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId:-},%X{spanId:-}] %-5level %logger{36} - %msg%n
</pattern>

Теперь логи выглядят так: 2023-10-27 10:00:00 [main] [653b6c8..., 123a...] INFO PaymentService - Starting transaction

4. Ручное создание спанов (Manual Span)

Иногда нужно замерить конкретный кусок внутренней логики (например, сложный алгоритм матчинга), который не является HTTP-вызовом. Используем аннотацию @Observed (замена @NewSpan).

import io.micrometer.observation.annotation.Observed;
import org.springframework.stereotype.Service;

@Service
public class MatchingService {

    @Observed(name = "calculate.match", contextualName = "matching-algorithm")
    public void runComplexAlgorithm() {
        // Этот метод появится в Jaeger как отдельный блок
        try { Thread.sleep(200); } catch (InterruptedException e) {}
    }
}

Для работы аннотации нужен бин ObservedAspect.


Baggage (Багаж)

Trace ID — это хорошо. Но часто нам нужно протащить сквозь все сервисы бизнес-контекст: User ID, Region, Session ID. Мы не хотим добавлять их в аргументы каждого метода (method(String userId, ...)).

В OpenTelemetry это называется Baggage. Это пары ключ-значение, которые летят в заголовках рядом с Trace ID.

Пример (Входящий фильтр):

import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.BaggageInScope;

@Component
public class UserContextFilter implements Filter {
    
    private final Tracer tracer; // Micrometer Tracer

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        String userId = req.getParameter("user_id");
        
        // Создаем багаж. Он автоматически пропишется в MDC и заголовки исходящих запросов.
        try (BaggageInScope baggage = tracer.createBaggageInScope("userId", userId)) {
            chain.doFilter(req, res);
        }
    }
}

Теперь в любом логе любого микросервиса по цепочке ты можешь вывести %X{userId}.


Sampling (Сэмплирование)

В High-Load (10k RPS) сохранять трейс на каждый запрос невозможно.

  1. Это убьет сеть (трейсы весят много).
  2. Это убьет хранилище (Elasticsearch/Cassandra лопнут).

Стратегии:

  1. Head-Based Sampling (Вероятностное): Решение принимается в начале (на Gateway). "Сохраняем 1% случайных запросов".
    • Плюс: Дешево и быстро.
    • Минус: Можно пропустить редкую ошибку.
  2. Tail-Based Sampling (Умное): Мы собираем все спаны в буфер (в коллекторе), ждем окончания трейса.
    • Если была ошибка (HTTP 500) или высокая задержка (>2с) -> Сохраняем.
    • Если все ОК -> Выкидываем.
    • Плюс: Мы видим 100% ошибок.
    • Минус: Требует много памяти на коллекторе (нужно держать хвост запросов).

Итог по главе

  1. Distributed Tracing связывает логи из разных сервисов в одну историю.
  2. Trace Context (W3C Trace Context) — стандарт заголовков (traceparent), который понимают все современные фреймворки.
  3. Baggage позволяет неявно передавать параметры (User ID) через всю систему.

Глава 2: Metrics (Метрики)

Трассировка (Tracing) говорит нам, где проблема. Логи (Logging) говорят, почему она возникла. А Метрики (Metrics) отвечают на вопрос: "А есть ли вообще проблема?" и "Насколько все плохо?".

Метрики — это дешево. Хранить миллиарды чисел (Time Series) гораздо дешевле, чем хранить миллиарды текстовых логов. Поэтому метрики — это первая линия обороны. Алерт (Alert) должен срабатывать по метрике, а не по логу.

Теория: Типы метрик (Micrometer primitives)

В Java стандартом де-факто является Micrometer. Это как SLF4J, но для метрик. Он позволяет писать код один раз, а отправлять данные хоть в Prometheus, хоть в Datadog, хоть в Graphite.

Основные примитивы:

  1. Counter (Счетчик):
    • Монотонно возрастающее число.
    • Пример: http_requests_total, orders_created_total, errors_total.
    • Смысл: Нас интересует не само число (100500), а скорость его роста (Rate). Например, 50 ошибок в секунду.
  2. Gauge (Датчик / Измеритель):
    • Число, которое может расти и падать. Мгновенный снимок состояния.
    • Пример: jvm_memory_used, thread_pool_active_threads, queue_size.
    • Смысл: Показывает нагрузку прямо сейчас.
  3. Timer / Histogram (Таймер / Гистограмма):
    • Самый сложный тип. Измеряет распределение величин (обычно времени или размера байт).
    • Пример: http_server_requests_seconds.

Золотые сигналы (The Golden Signals)

Google SRE книга определяет 4 сигнала, которые ты обязан мониторить для каждого микросервиса:

  1. Latency (Задержка): Время обработки успешных запросов.
  2. Traffic (Трафик): Нагрузка (RPS — Requests Per Second).
  3. Errors (Ошибки): Процент сбоев (5xx коды, исключения).
  4. Saturation (Насыщение): Насколько "полон" ресурс (CPU usage, размер пула коннектов к БД, заполненность очереди Kafka).

Ложь среднего значения (The Lie of Averages)

Почему нельзя мониторить "Среднее время ответа" (Average Latency)?

Представь 100 запросов:

  • 99 запросов: 1 мс.
  • 1 запрос: 10 секунд (GC пауза или лок базы).
  • Среднее: (99*1 + 10000) / 100 ≈ 100 мс.

На дашборде ты видишь 100 мс. "Всё отлично!". В реальности 1% пользователей видит зависший экран.

Решение: Перцентили (Percentiles / Quantiles)

  • p50 (Медиана): 50% запросов быстрее этого времени.
  • p95: 95% запросов быстрее этого (отсекаем редкие выбросы).
  • p99: 99% запросов быстрее этого (показывает, как страдает 1 из 100 пользователей).
  • p99.9: Самый важный показатель для High-Load (хвост задержки).

Реализация на Java (Spring Boot + Prometheus)

Проще всего использовать связку Spring Boot Actuator + Micrometer + Prometheus. Prometheus работает по модели Pull: он сам приходит к твоему сервису раз в 15 секунд и забирает (scrape) метрики.

1. Зависимости (Maven)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

2. Конфигурация (application.yml)

management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus # Открываем /actuator/prometheus
  metrics:
    tags:
      application: payment-service # Глобальный тег для всех метрик
    distribution:
      percentiles-histogram:
        http.server.requests: true # Включаем расчет гистограмм для HTTP

3. Кастомные метрики (Бизнес-метрики)

Системные метрики (CPU, Heap) Spring дает из коробки. Но в финтехе нам важны Бизнес-метрики: "Объем денег", "Количество отказов антифрода".

@Service
@RequiredArgsConstructor
public class PaymentMetricsService {

    private final MeterRegistry registry;

    // 1. Counter: Счетчик успешных платежей
    public void incrementSuccessPayment(String currency) {
        Counter.builder("business.payments.success")
                .tag("currency", currency) // Теги - это мощь!
                .description("Total successful payments")
                .register(registry)
                .increment();
    }

    // 2. Gauge: Текущий размер очереди на ручной разбор
    // Gauge должен быть привязан к состоянию, а не изменяться вручную
    public void monitorQueueSize(Queue queue) {
        Gauge.builder("business.manual.queue.size", queue, Queue::size)
                .register(registry);
    }
    
    // 3. Timer: Замер времени выполнения внешней интеграции
    public void recordBankLatency(long durationMillis, String bankName) {
        Timer.builder("external.bank.latency")
                .tag("bank", bankName)
                .publishPercentiles(0.5, 0.95, 0.99) // Считаем p50, p95, p99
                .register(registry)
                .record(Duration.ofMillis(durationMillis));
    }
}

Главная проблема: Cardinality Explosion (Взрыв кардинальности)

Это ошибка №1, которую допускают разработчики, убивая Prometheus.

Кардинальность — это количество уникальных комбинаций тегов. Prometheus создает новый временной ряд (Time Series) для каждого уникального набора тегов.

Плохой пример:

Counter.builder("http.requests")
    .tag("user_id", userId) // ОШИБКА! Пользователей миллионы.
    .tag("order_id", orderId) // ОШИБКА! Заказов миллиарды.
    .register(registry).increment();

Если у вас 1 миллион пользователей, Prometheus создаст 1 миллион метрик http.requests. Память Prometheus кончится за 5 минут, и он упадет с OOM.

Правило: В тегах метрик можно использовать только значения из ограниченного множества (Enum):

  • status (200, 404, 500) — OK.
  • region (US, EU, RU) — OK.
  • error_type (Exception class) — OK.
  • user_id, email, tokenЗАПРЕЩЕНО. Для этого есть Tracing и Logs.

USE vs RED Methods

Как понять, на что смотреть на дашборде Grafana?

  1. RED Method (для Микросервисов / API):
    • Rate (Частота запросов).
    • Errors (Ошибки).
    • Duration (Длительность / Latency).
    • Смысл: Описывает качество обслуживания клиентов.
  2. USE Method (для Железа / Ресурсов):
    • Utilization (Утилизация): % занятого времени (CPU usage).
    • Saturation (Насыщение): Длина очереди (Tasks waiting for CPU).
    • Errors (Ошибки): Ошибки оборудования (Disk I/O errors).
    • Смысл: Описывает здоровье инфраструктуры.

Итог по главе

  1. Micrometer — это стандарт для сбора метрик в Java.
  2. Prometheus + Grafana — стандарт для хранения и визуализации.
  3. Следим за p99, а не за средним.
  4. Никогда не суем userId в теги (Cardinality Explosion).
  5. Используем RED для сервисов и USE для железа.

Модуль 7: Reconciliation (Сверка данных)

Глава 1: Internal & External Reconciliation (Внутренняя и Внешняя сверка)

Это, пожалуй, самый важный модуль для карьеры в Финтехе. Ты можешь написать идеально отказоустойчивый код (Resilience). Ты можешь использовать Saga и TCC. Но в конце дня деньги все равно разойдутся.

  • Баг в коде (забыли commit).
  • Сбой в Kafka (потеряли сообщение).
  • Человеческий фактор (админ удалил строку).
  • Банк-партнер списал комиссию, о которой мы не знали.

Reconciliation (Recon / Рекон) — это процесс сравнения двух наборов данных (Dataset A и Dataset B) для выявления расхождений (Discrepancies). Это "страховочная сетка", которая спасает бизнес от убытков.


Теория: Типы сверок

  1. Internal Reconciliation (Внутренняя сверка):
    • Мы сверяем свои же микросервисы.
    • Пример: "Сумма всех транзакций в Payment Service должна быть равна сумме изменений балансов в Ledger Service".
    • Цель: Найти баги в собственной распределенной системе (Inconsistency).
  2. External Reconciliation (Внешняя сверка):
    • Мы сверяем свои данные с данными внешнего провайдера (Банк, Платежный шлюз).
    • Пример: Мы думаем, что на счете в банке 1 000 000$. Банк присылает выписку (Statement), где 999 500$. Где 500$?
    • Цель: Найти потерянные платежи, скрытые комиссии или фрод.

Архитектура: T+1 (Batch) vs Real-Time

1. Batch Reconciliation (Классика / T+1)

Это стандарт индустрии. Банки живут сутками.

  1. Импорт: Каждую ночь мы скачиваем реестр операций (Registry) от провайдера (обычно это CSV, XML или формат MT940).
  2. Нормализация: Приводим их файл и нашу базу к единому формату (Java POJO ReconItem).
  3. Матчинг (Matching): Сравниваем две огромные коллекции.
  4. Репорт: Генерируем список расхождений.

2. Real-Time Reconciliation (Стриминг)

Для систем, где важна скорость (Trading).

  1. Слушаем вебхуки от банка.
  2. Слушаем свои события Kafka.
  3. Джойним их "на лету" в Kafka Streams или Apache Flink с временным окном (Windowing).
  4. Если за 5 минут пара не нашлась -> Алерт.

Реализация на Java (Spring Batch)

Для сверки миллионов транзакций нельзя просто загрузить всё в ArrayList. Нужен потоковый процессинг. Spring Batch — идеальный инструмент.

Шаг 1: Загрузка данных (ETL)

Вместо того чтобы сравнивать CSV с SQL, мы обычно загружаем внешний файл в ту же БД во временную таблицу staging_bank_transactions.

-- Таблица для "сырых" данных банка
CREATE TABLE staging_bank_transactions (
    external_id VARCHAR(255),
    amount DECIMAL(19,4),
    currency VARCHAR(3),
    status VARCHAR(50),
    processed BOOLEAN DEFAULT FALSE
);

Шаг 2: SQL-Based Matching (Самый быстрый способ)

Зачем мучить Java, если база данных умеет делать FULL OUTER JOIN? Это самый производительный паттерн для объемов до 10-50 млн строк.

-- Находим расхождения одним запросом
SELECT 
    our.id as our_id, 
    bank.external_id as bank_id,
    our.amount as our_amount,
    bank.amount as bank_amount,
    CASE 
        WHEN our.id IS NULL THEN 'MISSING_IN_INTERNAL' -- Есть в банке, нет у нас
        WHEN bank.external_id IS NULL THEN 'MISSING_IN_BANK' -- Есть у нас, нет в банке
        WHEN our.amount != bank.amount THEN 'AMOUNT_MISMATCH' -- Суммы не сходятся
        WHEN our.status != bank.status THEN 'STATUS_MISMATCH' -- Статусы не сходятся
        ELSE 'MATCH'
    END as recon_status
FROM 
    our_transactions our
FULL OUTER JOIN 
    staging_bank_transactions bank ON our.gateway_id = bank.external_id
WHERE 
    our.created_at BETWEEN :start AND :end;

Шаг 3: Spring Batch Job (Обработка расхождений)

Теперь Java просто читает результаты этого SQL-запроса и решает, что делать.

@Configuration
@EnableBatchProcessing
public class ReconJobConfig {

    @Bean
    public Job reconJob(JobBuilderFactory jobs, Step step1) {
        return jobs.get("dailyReconJob")
                .start(step1)
                .build();
    }

    @Bean
    public Step step1(StepBuilderFactory steps, ItemReader<ReconResult> reader, 
                      ItemWriter<ReconResult> writer) {
        return steps.get("findDiscrepancies")
                .<ReconResult, ReconResult>chunk(1000) // Пачками по 1000
                .reader(reader) // Читает SQL выше
                .processor(new ReconProcessor()) // Логика авто-исправления
                .writer(writer) // Пишет в таблицу "Discrepancies" или создает тикеты
                .build();
    }
}
public class ReconProcessor implements ItemProcessor<ReconResult, ReconResult> {
    @Override
    public ReconResult process(ReconResult item) {
        if ("MATCH".equals(item.getStatus())) {
            return null; // Пропускаем, всё ок
        }
        
        if ("STATUS_MISMATCH".equals(item.getStatus())) {
            // Авто-фикс: Если у нас PENDING, а в банке SUCCESS -> обновляем у нас
            if ("SUCCESS".equals(item.getBankStatus())) {
                item.setAction("AUTO_HEAL");
            }
        }
        
        return item;
    }
}

Сложные сценарии (Senior Insights)

1. Many-to-One Matching (Агрегация)

Кейс: У нас 1000 мелких транзакций по 1$, а банк присылает одну строку "Settlement" на 1000$. Решение: Нам нужен group_id. Мы должны агрегировать наши транзакции (SUM(amount)) перед сверкой. Без уникального ключа сверки (batch_id) это невозможно. Требуйте от банка передавать ваш ID пакета в описании платежа.

2. Currency & Precision (Точность)

Кейс: У нас 100.00 RUB, в банке 100.0000 RUB. Или у нас 10.00 USD, а банк вернул 9.50 USD (комиссия). Решение:

  • Сверка с толерантностью (Tolerance): abs(A - B) < 0.0001.
  • Учет комиссий: Если A - B = Fee, то это OK. Справочник комиссий — это отдельная сложная тема.

3. Timezone Hell (Временные зоны)

Кейс: Мы записали операцию 31 декабря в 23:59 (UTC). Банк записал её 1 января в 02:59 (Local Time). При сверке за "Декабрь" эта операция выпадет в MISSING_IN_BANK. Решение: Сверять с "перехлестом" (Window Overlap). Брать данные банка за T и T+1, чтобы поймать пограничные случаи.


Итог по главе

Без сверки (Reconciliation) финтех не живет.

  1. T+1 Batch на базе SQL JOIN — самый надежный метод.
  2. Auto-Healing (Авто-лечение) статусов экономит тысячи часов саппорта.
  3. Discrepancy Dashboard — это то, на что смотрит финдиректор по утрам.

Модуль 8: Security (Безопасность данных)

Глава 1: Tokenization & Encryption (Токенизация и Шифрование)

В финтехе безопасность — это не "фича", это лицензия на работу. Если у вас утечет база данных с номерами карт (PAN) или паспортами (PII), бизнес закроют, а вас (как архитектора/CTO) могут привлечь к уголовной ответственности.

Стандарт PCI DSS (Payment Card Industry Data Security Standard) запрещает хранить CVV коды вообще, а полный номер карты (PAN) — в открытом виде.

1. Tokenization (Токенизация)

Токенизация — это замена чувствительных данных (PAN) на нечувствительный суррогат (Токен), который не имеет математической связи с исходными данными.

Сценарий:

  1. Клиент вводит карту на фронтенде.
  2. Фронтенд отправляет данные напрямую в специальный защищенный сервис (Token Service / Vault) или вообще в сторонний шлюз (Stripe/Adyen), минуя ваш бэкенд.
  3. Token Service сохраняет карту в супер-защищенную базу и возвращает Токен: tok_12345.
  4. Ваш основной бэкенд сохраняет только tok_12345 и последние 4 цифры (для отображения в UI: **** 4242).

Преимущество: Если хакеры украдут базу вашего основного бэкенда, они получат бесполезные токены tok_12345, которые нельзя превратить обратно в деньги без доступа к Token Service.


2. Encryption at Rest (Шифрование хранения)

Если данные всё-таки нужно хранить у себя (например, паспортные данные для KYC), их нужно шифровать.

Многие думают, что "Full Disk Encryption" (шифрование диска на уровне OS/AWS EBS) достаточно. Это ошибка. Шифрование диска защищает от кражи физического сервера из дата-центра. Но если хакер получил доступ к БД через SQL Injection или скомпрометировал учетку админа — он просто сделает SELECT * FROM users и получит всё в открытом виде, так как диск расшифровывается прозрачно для приложения.

Решение: Application-Level Encryption (Шифрование на уровне приложения). Данные шифруются в памяти Java-приложения перед отправкой в БД. В базе лежит мусор (byte array).


3. Envelope Encryption (Конвертное шифрование)

Главный вопрос криптографии: Где хранить ключ шифрования?

  • В коде? (Hardcoded) -> Утечет через Git.
  • В конфиге? (application.yml) -> Утечет через логи/Env vars.
  • В базе рядом с данными? -> Хакер украдет и замок, и ключ.

Паттерн Envelope Encryption решает проблему управления ключами (Key Management).

Иерархия ключей:

  1. DEK (Data Encryption Key): Ключ, которым шифруются конкретные данные (строка в БД). Он генерируется случайным образом для каждой записи (или группы записей).
  2. KEK (Key Encryption Key): Ключ, которым шифруется сам DEK.
  3. Root Key (Master Key): Главный ключ, который хранится в HSM (Hardware Security Module) или KMS (AWS KMS, Google KMS, HashiCorp Vault). Он никогда не покидает "железку".

Алгоритм записи:

  1. Генерируем новый случайный DEK (AES-256).
  2. Шифруем данные этим DEK: EncryptedData = AES(Data, DEK).
  3. Отправляем DEK в KMS, чтобы зашифровать его Мастер-ключом: EncryptedDEK = KMS_Encrypt(DEK).
  4. Сохраняем в БД рядом: EncryptedData + EncryptedDEK. Сам DEK уничтожаем из памяти.

Алгоритм чтения:

  1. Читаем из БД EncryptedData и EncryptedDEK.
  2. Отправляем EncryptedDEK в KMS.
  3. KMS (если у нас есть права) расшифровывает и возвращает нам чистый DEK.
  4. Расшифровываем данные: Data = AES(EncryptedData, DEK).

Реализация на Java (Spring Boot + Tink / Vault)

Google Tink — отличная библиотека для криптографии, но для демонстрации паттерна напишем простую обертку.

Сущность (Entity)

Мы храним зашифрованные байты, а не строки.

@Entity
@Table(name = "user_kyc")
public class UserKycEntity {
    @Id
    private UUID id;

    // Сами данные (паспорт)
    @Lob
    private byte[] encryptedPassportData;

    // Зашифрованный ключ, которым открывается passportData
    @Lob
    private byte[] encryptedDek; 

    // Вектор инициализации (IV) для AES-GCM (важно для уникальности шифротекста)
    private byte[] initializationVector;
}

Сервис шифрования (KmsService)

@Service
@RequiredArgsConstructor
public class EncryptionService {

    private final KmsClient kmsClient; // Клиент к AWS KMS или HashiCorp Vault

    public UserKycEntity encrypt(String passportNumber) {
        // 1. Генерируем одноразовый DEK (Data Encryption Key)
        SecretKey dek = generateAesKey(256);
        byte[] iv = generateIv(); // 12 bytes for GCM

        // 2. Шифруем данные локально (на сервере приложений)
        byte[] encryptedData = localAesEncrypt(passportNumber, dek, iv);

        // 3. Шифруем сам ключ через внешний KMS (Master Key живет там)
        // envelope = KMS(dek)
        byte[] encryptedDek = kmsClient.encrypt(dek.getEncoded());

        // 4. Сохраняем все в сущность
        UserKycEntity entity = new UserKycEntity();
        entity.setEncryptedPassportData(encryptedData);
        entity.setEncryptedDek(encryptedDek);
        entity.setInitializationVector(iv);
        
        return entity;
    }

    public String decrypt(UserKycEntity entity) {
        // 1. Идем в KMS, чтобы расшифровать DEK
        // Мы отдаем KMS зашифрованный мусор, он отдает нам ключ.
        // Если KMS недоступен или отозвал права -> данные прочитать невозможно.
        byte[] dekBytes = kmsClient.decrypt(entity.getEncryptedDek());
        SecretKey dek = new SecretKeySpec(dekBytes, "AES");

        // 2. Расшифровываем данные локально
        return localAesDecrypt(entity.getEncryptedPassportData(), dek, entity.getInitializationVector());
    }
}

Key Rotation (Ротация ключей)

PCI DSS требует регулярно менять ключи (например, раз в год).

Если бы мы зашифровали всю базу одним ключом, ротация была бы адом: пришлось бы расшифровать и зашифровать заново миллионы строк (Down-time на сутки).

С Envelope Encryption ротация тривиальна:

  1. Мы создаем новую версию Master Key в KMS.
  2. Старые данные в БД трогать не надо! Они зашифрованы своими уникальными DEK.
  3. Новые данные будут шифроваться DEK-ами, которые обернуты уже новым Master Key.
  4. Если нужно перешифровать старое (Rekeying), мы просто берем EncryptedDEK, расшифровываем его старым Master Key и шифруем новым Master Key. Сами гигабайты данных (EncryptedData) мы не трогаем. Это мгновенная операция.

Итог по главе

  1. PAN и PII — это токсичные отходы. Старайтесь не хранить их вообще (Токенизация).
  2. Если хранить надо — используйте Envelope Encryption.
  3. Ключи шифрования данных (DEK) хранятся рядом с данными, но в зашифрованном виде.
  4. Главный ключ (KEK) живет в KMS/Vault и никогда не покидает его (или покидает только в память приложения на секунду).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment