DEV Community

Maksim
Maksim

Posted on

Баланс транзакцій: як уникнути подвійних ставок та подвійних виплат

Баланс транзакцій: як уникнути подвійних ставок та подвійних виплат

У світі онлайн-казино кожна помилка в обробці транзакцій може коштувати тисячі доларів. Подвійні ставки, подвійні виплати, race conditions — це реальні проблеми, з якими стикається кожна платформа під навантаженням. Розглянемо перевірені рішення.

Проблема: чому виникають подвійні транзакції?

Сценарій 1: Подвійний callback від провайдера

Часова лінія:
T0: Гравець робить ставку $10
T1: Провайдер відправляє callback #1 (bet $10)
T2: Callback #1 обробляється, баланс: $100 → $90
T3: Провайдер не отримав відповідь (network timeout)
T4: Провайдер відправляє callback #2 (той самий bet $10)
T5: Callback #2 обробляється, баланс: $90 → $80 ❌

Результат: гравець втратив $20 замість $10
Enter fullscreen mode Exit fullscreen mode

Сценарій 2: Race condition при одночасних ставках

Гравець відкрив гру в двох вкладках браузера:

Thread A: Ставка $5, читає баланс $100
Thread B: Ставка $5, читає баланс $100
Thread A: Пише баланс $95
Thread B: Пише баланс $95 ❌

Результат: одна ставка "з'їла" іншу
Enter fullscreen mode Exit fullscreen mode

Сценарій 3: Retry механізм клієнта

T0: Frontend відправляє запит на ставку
T1: Backend обробляє ставку
T2: Network timeout на клієнті
T3: Frontend робить retry
T4: Backend обробляє ще раз ❌
Enter fullscreen mode Exit fullscreen mode

Рішення 1: Idempotency Keys

Idempotency key — це унікальний ідентифікатор запиту, який гарантує, що одна операція не виконається двічі.

Генерація idempotency key:

type IdempotencyKey struct {
    UserID        string
    ProviderID    string
    TransactionID string
    Type          string
}

func GenerateIdempotencyKey(params IdempotencyKey) string {
    // Формат: {provider}:{user}:{type}:{transaction_id}
    return fmt.Sprintf("%s:%s:%s:%s",
        params.ProviderID,
        params.UserID,
        params.Type,
        params.TransactionID,
    )
}

// Приклад: "pragmatic:user123:bet:txn_abc123"
Enter fullscreen mode Exit fullscreen mode

Перевірка дубліката:

CREATE TABLE transaction_journal (
    id UUID PRIMARY KEY,
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,
    user_id UUID NOT NULL,
    transaction_type VARCHAR(50) NOT NULL,
    amount DECIMAL(20,8) NOT NULL,
    currency VARCHAR(3) NOT NULL,
    status VARCHAR(20) NOT NULL,
    result JSONB,
    created_at TIMESTAMP DEFAULT NOW(),
    processed_at TIMESTAMP
);

-- Унікальний індекс для швидкої перевірки
CREATE UNIQUE INDEX idx_idempotency_key ON transaction_journal(idempotency_key);
Enter fullscreen mode Exit fullscreen mode

Реалізація в коді:

type TransactionProcessor struct {
    db *sql.DB
}

func (tp *TransactionProcessor) ProcessTransaction(
    ctx context.Context,
    tx *Transaction,
) (*Result, error) {
    // 1. Генеруємо idempotency key
    idempotencyKey := GenerateIdempotencyKey(IdempotencyKey{
        UserID:        tx.UserID,
        ProviderID:    tx.ProviderID,
        TransactionID: tx.ProviderTxnID,
        Type:          tx.Type,
    })

    // 2. Перевіряємо чи вже оброблено
    existing, err := tp.checkIdempotency(ctx, idempotencyKey)
    if err != nil && err != ErrNotFound {
        return nil, err
    }

    if existing != nil {
        // Транзакція вже оброблена - повертаємо збережений результат
        log.Info("Duplicate transaction detected",
            "idempotency_key", idempotencyKey,
            "original_id", existing.ID,
        )
        return existing.Result, nil
    }

    // 3. Створюємо запис у журналі (pending)
    journal := &JournalEntry{
        ID:             generateUUID(),
        IdempotencyKey: idempotencyKey,
        UserID:         tx.UserID,
        Type:           tx.Type,
        Amount:         tx.Amount,
        Status:         StatusPending,
        CreatedAt:      time.Now(),
    }

    // 4. Намагаємось вставити (може бути race condition)
    err = tp.insertJournal(ctx, journal)
    if err != nil {
        if IsUniqueViolation(err) {
            // Хтось інший вже створив запис - читаємо його результат
            return tp.waitForResult(ctx, idempotencyKey)
        }
        return nil, err
    }

    // 5. Обробляємо транзакцію
    result, err := tp.processInternal(ctx, tx)

    // 6. Оновлюємо статус
    journal.Status = StatusCompleted
    journal.Result = result
    if err != nil {
        journal.Status = StatusFailed
        journal.Error = err.Error()
    }
    journal.ProcessedAt = time.Now()

    if err := tp.updateJournal(ctx, journal); err != nil {
        log.Error("Failed to update journal", "error", err)
    }

    return result, err
}
Enter fullscreen mode Exit fullscreen mode

Обробка race condition при вставці:

func (tp *TransactionProcessor) waitForResult(
    ctx context.Context,
    idempotencyKey string,
) (*Result, error) {
    // Чекаємо поки інша горутина завершить обробку
    maxAttempts := 10
    backoff := 50 * time.Millisecond

    for attempt := 0; attempt < maxAttempts; attempt++ {
        entry, err := tp.getJournal(ctx, idempotencyKey)
        if err != nil {
            return nil, err
        }

        if entry.Status == StatusCompleted {
            return entry.Result, nil
        }

        if entry.Status == StatusFailed {
            return nil, fmt.Errorf("transaction failed: %s", entry.Error)
        }

        // Статус pending - чекаємо
        time.Sleep(backoff)
        backoff *= 2 // Exponential backoff
    }

    return nil, ErrProcessingTimeout
}
Enter fullscreen mode Exit fullscreen mode

Рішення 2: Distributed Locking

Для критичних операцій використовуємо розподілені блокування.

Redis-based locking:

type RedisLocker struct {
    client *redis.Client
}

func (rl *RedisLocker) Lock(
    ctx context.Context,
    key string,
    ttl time.Duration,
) (*Lock, error) {
    lockID := generateUUID()

    // SET key value NX PX milliseconds
    success, err := rl.client.SetNX(
        ctx,
        fmt.Sprintf("lock:%s", key),
        lockID,
        ttl,
    ).Result()

    if err != nil {
        return nil, err
    }

    if !success {
        return nil, ErrLockAcquireFailed
    }

    return &Lock{
        Key:    key,
        ID:     lockID,
        locker: rl,
    }, nil
}

func (rl *RedisLocker) Unlock(ctx context.Context, lock *Lock) error {
    // Lua script для атомарного видалення
    script := `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `

    return rl.client.Eval(
        ctx,
        script,
        []string{fmt.Sprintf("lock:%s", lock.Key)},
        lock.ID,
    ).Err()
}
Enter fullscreen mode Exit fullscreen mode

Використання в транзакціях:

func (tp *TransactionProcessor) ProcessWithLock(
    ctx context.Context,
    tx *Transaction,
) (*Result, error) {
    // Блокування на рівні користувача та раунду
    lockKey := fmt.Sprintf("user:%s:round:%s", tx.UserID, tx.RoundID)

    lock, err := tp.locker.Lock(ctx, lockKey, 10*time.Second)
    if err != nil {
        return nil, fmt.Errorf("failed to acquire lock: %w", err)
    }
    defer tp.locker.Unlock(ctx, lock)

    // Тепер можна безпечно обробляти транзакцію
    return tp.ProcessTransaction(ctx, tx)
}
Enter fullscreen mode Exit fullscreen mode

Etcd-based distributed locking:

type EtcdLocker struct {
    client *clientv3.Client
}

func (el *EtcdLocker) Lock(
    ctx context.Context,
    key string,
    ttl time.Duration,
) (*Lock, error) {
    session, err := concurrency.NewSession(
        el.client,
        concurrency.WithTTL(int(ttl.Seconds())),
    )
    if err != nil {
        return nil, err
    }

    mutex := concurrency.NewMutex(session, fmt.Sprintf("/locks/%s", key))

    if err := mutex.Lock(ctx); err != nil {
        session.Close()
        return nil, err
    }

    return &Lock{
        Key:     key,
        mutex:   mutex,
        session: session,
    }, nil
}

func (el *EtcdLocker) Unlock(ctx context.Context, lock *Lock) error {
    defer lock.session.Close()
    return lock.mutex.Unlock(ctx)
}
Enter fullscreen mode Exit fullscreen mode

Рішення 3: Оптимістичне блокування на рівні БД

Використання версіонування записів для запобігання race conditions.

Database schema:

CREATE TABLE wallets (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    currency VARCHAR(3) NOT NULL,
    balance DECIMAL(20,8) NOT NULL DEFAULT 0,
    version BIGINT NOT NULL DEFAULT 0,
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(user_id, currency)
);
Enter fullscreen mode Exit fullscreen mode

Optimistic locking реалізація:

func (ws *WalletService) UpdateBalance(
    ctx context.Context,
    userID string,
    currency string,
    delta decimal.Decimal,
) error {
    maxRetries := 5

    for attempt := 0; attempt < maxRetries; attempt++ {
        // 1. Читаємо поточний стан
        wallet, err := ws.getWallet(ctx, userID, currency)
        if err != nil {
            return err
        }

        // 2. Обчислюємо новий баланс
        newBalance := wallet.Balance.Add(delta)
        if newBalance.IsNegative() {
            return ErrInsufficientBalance
        }

        // 3. Оновлюємо з перевіркою версії
        result, err := ws.db.ExecContext(ctx, `
            UPDATE wallets
            SET balance = $1,
                version = $2,
                updated_at = NOW()
            WHERE user_id = $3
              AND currency = $4
              AND version = $5
        `, newBalance, wallet.Version+1, userID, currency, wallet.Version)

        if err != nil {
            return err
        }

        affected, _ := result.RowsAffected()
        if affected == 1 {
            return nil // Успіх!
        }

        // Version mismatch - хтось інший змінив баланс
        log.Debug("Optimistic lock failed, retrying",
            "attempt", attempt+1,
            "user_id", userID,
        )

        // Exponential backoff
        time.Sleep(time.Millisecond * time.Duration(10*(attempt+1)))
    }

    return ErrOptimisticLockFailed
}
Enter fullscreen mode Exit fullscreen mode

Рішення 4: Обробка подвійних callback'ів

Реальний кейс: провайдер може відправити один і той же callback кілька разів.

Захист через idempotency + timeout:

type CallbackHandler struct {
    processor *TransactionProcessor
    cache     *redis.Client
}

func (ch *CallbackHandler) HandleProviderCallback(
    ctx context.Context,
    callback *ProviderCallback,
) (*CallbackResponse, error) {
    // 1. Швидка перевірка в Redis (кеш)
    cacheKey := fmt.Sprintf("callback:%s:%s",
        callback.ProviderID,
        callback.TransactionID,
    )

    cached, err := ch.cache.Get(ctx, cacheKey).Result()
    if err == nil {
        // Вже обробляли цей callback - повертаємо збережений результат
        var response CallbackResponse
        json.Unmarshal([]byte(cached), &response)
        return &response, nil
    }

    // 2. Обробка через основний pipeline з idempotency
    result, err := ch.processor.ProcessTransaction(ctx, &Transaction{
        UserID:        callback.UserID,
        ProviderID:    callback.ProviderID,
        ProviderTxnID: callback.TransactionID,
        Type:          callback.Type,
        Amount:        callback.Amount,
        Currency:      callback.Currency,
        RoundID:       callback.RoundID,
    })

    if err != nil {
        return nil, err
    }

    // 3. Кешуємо результат на 1 годину
    response := &CallbackResponse{
        Success: true,
        Balance: result.Balance,
        TransactionID: result.TransactionID,
    }

    responseJSON, _ := json.Marshal(response)
    ch.cache.Set(ctx, cacheKey, responseJSON, time.Hour)

    return response, nil
}
Enter fullscreen mode Exit fullscreen mode

Моніторинг дублікатів:

var duplicateCallbacks = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "casino_duplicate_callbacks_total",
        Help: "Total number of duplicate callbacks detected",
    },
    []string{"provider", "type"},
)

func (ch *CallbackHandler) recordDuplicate(provider, txnType string) {
    duplicateCallbacks.WithLabelValues(provider, txnType).Inc()

    // Алерт якщо дублікатів більше ніж 100/хв
    if rate := ch.getDuplicateRate(provider); rate > 100 {
        ch.sendAlert(fmt.Sprintf(
            "High duplicate callback rate for %s: %.2f/min",
            provider, rate,
        ))
    }
}
Enter fullscreen mode Exit fullscreen mode

Рішення 5: Round-based deduplication

Провайдери ігор використовують концепцію "раундів" — одна гра може мати кілька транзакцій.

Схема для раундів:

CREATE TABLE game_rounds (
    id UUID PRIMARY KEY,
    round_id VARCHAR(255) UNIQUE NOT NULL,
    user_id UUID NOT NULL,
    game_id UUID NOT NULL,
    provider_id VARCHAR(50) NOT NULL,
    total_bet DECIMAL(20,8) NOT NULL DEFAULT 0,
    total_win DECIMAL(20,8) NOT NULL DEFAULT 0,
    status VARCHAR(20) NOT NULL,
    started_at TIMESTAMP DEFAULT NOW(),
    finished_at TIMESTAMP,
    UNIQUE(provider_id, round_id)
);

CREATE TABLE round_transactions (
    id UUID PRIMARY KEY,
    round_id UUID REFERENCES game_rounds(id),
    transaction_id VARCHAR(255) UNIQUE NOT NULL,
    type VARCHAR(20) NOT NULL,
    amount DECIMAL(20,8) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Обробка round-based транзакцій:

type RoundProcessor struct {
    db *sql.DB
}

func (rp *RoundProcessor) ProcessRoundTransaction(
    ctx context.Context,
    tx *Transaction,
) error {
    // 1. Знайти або створити раунд
    round, err := rp.getOrCreateRound(ctx, tx)
    if err != nil {
        return err
    }

    // 2. Перевірка дубліката на рівні раунду
    exists, err := rp.transactionExists(ctx, round.ID, tx.ProviderTxnID)
    if err != nil {
        return err
    }

    if exists {
        return ErrDuplicateTransaction
    }

    // 3. Додати транзакцію до раунду
    return rp.addTransactionToRound(ctx, round, tx)
}

func (rp *RoundProcessor) getOrCreateRound(
    ctx context.Context,
    tx *Transaction,
) (*GameRound, error) {
    // Спроба знайти існуючий раунд
    round, err := rp.findRound(ctx, tx.ProviderID, tx.RoundID)
    if err == nil {
        return round, nil
    }

    if err != ErrNotFound {
        return nil, err
    }

    // Створення нового раунду
    round = &GameRound{
        ID:         generateUUID(),
        RoundID:    tx.RoundID,
        UserID:     tx.UserID,
        GameID:     tx.GameID,
        ProviderID: tx.ProviderID,
        Status:     RoundStatusActive,
        StartedAt:  time.Now(),
    }

    err = rp.db.QueryRowContext(ctx, `
        INSERT INTO game_rounds (id, round_id, user_id, game_id, provider_id, status, started_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        ON CONFLICT (provider_id, round_id) DO UPDATE
        SET id = EXCLUDED.id
        RETURNING id
    `, round.ID, round.RoundID, round.UserID, round.GameID,
        round.ProviderID, round.Status, round.StartedAt).Scan(&round.ID)

    return round, err
}
Enter fullscreen mode Exit fullscreen mode

Рішення 6: Database constraints як останній рубіж

-- Унікальний constraint на комбінацію критичних полів
CREATE UNIQUE INDEX idx_transactions_unique 
ON transactions (provider_id, provider_transaction_id, user_id, type)
WHERE status = 'completed';

-- Функція для валідації перед вставкою
CREATE OR REPLACE FUNCTION validate_transaction()
RETURNS TRIGGER AS $$
BEGIN
    -- Перевірка чи баланс не стане негативним
    IF (SELECT balance FROM wallets WHERE user_id = NEW.user_id AND currency = NEW.currency) + NEW.amount < 0 THEN
        RAISE EXCEPTION 'Insufficient balance';
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER check_balance_before_transaction
    BEFORE INSERT ON transactions
    FOR EACH ROW
    EXECUTE FUNCTION validate_transaction();
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Layers of protection:

Layer 1: Idempotency Key (швидка перевірка)
Layer 2: Redis Cache (дублікати за останню годину)
Layer 3: Distributed Lock (критичні операції)
Layer 4: Optimistic Locking (версіонування)
Layer 5: Database Constraints (останній захист)
Enter fullscreen mode Exit fullscreen mode

2. Таймаути та повторні спроби:

type RetryConfig struct {
    MaxAttempts int
    InitialDelay time.Duration
    MaxDelay time.Duration
    Multiplier float64
}

func RetryWithBackoff(
    ctx context.Context,
    config RetryConfig,
    fn func() error,
) error {
    delay := config.InitialDelay

    for attempt := 0; attempt < config.MaxAttempts; attempt++ {
        err := fn()
        if err == nil {
            return nil
        }

        // Не повторюємо якщо помилка не временна
        if !IsRetryable(err) {
            return err
        }

        if attempt < config.MaxAttempts-1 {
            time.Sleep(delay)
            delay = time.Duration(float64(delay) * config.Multiplier)
            if delay > config.MaxDelay {
                delay = config.MaxDelay
            }
        }
    }

    return ErrMaxRetriesExceeded
}
Enter fullscreen mode Exit fullscreen mode

3. Моніторинг аномалій:

type AnomalyDetector struct {
    thresholds map[string]float64
}

func (ad *AnomalyDetector) CheckTransactionPattern(
    ctx context.Context,
    userID string,
) error {
    // Перевірка незвичайної активності
    stats, err := ad.getUserStats(ctx, userID, time.Minute*5)
    if err != nil {
        return err
    }

    if stats.TransactionCount > 100 {
        return ErrSuspiciousActivity
    }

    if stats.TotalAmount > 10000 {
        return ErrHighValueAlert
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Висновок

Запобігання подвійним транзакціям вимагає багатошарового підходу:

Idempotency keys — обов'язково для всіх транзакцій

Distributed locking — для критичних операцій

Optimistic locking — на рівні БД

Round-based deduplication — для ігрових раундів

Database constraints — останній рубіж захисту

Моніторинг — виявлення аномалій в реальному часі

Кожен шар захищає від різних сценаріїв збою. Комбінація всіх підходів дає надійну систему, яка витримує high-load та мережеві проблеми.


Питання? Поділіться своїм досвідом у коментарях! 💰

Top comments (0)