Баланс транзакцій: як уникнути подвійних ставок та подвійних виплат
У світі онлайн-казино кожна помилка в обробці транзакцій може коштувати тисячі доларів. Подвійні ставки, подвійні виплати, 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
Сценарій 2: Race condition при одночасних ставках
Гравець відкрив гру в двох вкладках браузера:
Thread A: Ставка $5, читає баланс $100
Thread B: Ставка $5, читає баланс $100
Thread A: Пише баланс $95
Thread B: Пише баланс $95 ❌
Результат: одна ставка "з'їла" іншу
Сценарій 3: Retry механізм клієнта
T0: Frontend відправляє запит на ставку
T1: Backend обробляє ставку
T2: Network timeout на клієнті
T3: Frontend робить retry
T4: Backend обробляє ще раз ❌
Рішення 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"
Перевірка дубліката:
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);
Реалізація в коді:
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
}
Обробка 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
}
Рішення 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()
}
Використання в транзакціях:
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)
}
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)
}
Рішення 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)
);
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
}
Рішення 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
}
Моніторинг дублікатів:
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,
))
}
}
Рішення 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()
);
Обробка 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
}
Рішення 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();
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 (останній захист)
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
}
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
}
Висновок
Запобігання подвійним транзакціям вимагає багатошарового підходу:
✅ Idempotency keys — обов'язково для всіх транзакцій
✅ Distributed locking — для критичних операцій
✅ Optimistic locking — на рівні БД
✅ Round-based deduplication — для ігрових раундів
✅ Database constraints — останній рубіж захисту
✅ Моніторинг — виявлення аномалій в реальному часі
Кожен шар захищає від різних сценаріїв збою. Комбінація всіх підходів дає надійну систему, яка витримує high-load та мережеві проблеми.
Питання? Поділіться своїм досвідом у коментарях! 💰
Top comments (0)