← Назад к вопросам

Какой интересный случай в разработке был за последнее время?

1.6 Junior🔥 81 комментариев
#Soft Skills и карьера

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Интересный случай: Race condition в payment processing

Ситуация

В микросервисном приложении платёжного шлюза произошла интересная проблема. Два запроса платежа пришли почти одновременно для одного и того же пользователя на одно и то же предложение. Система дважды списала деньги вместо одного платежа.

Контекст

Сервис обработки платежей:

@Service
public class PaymentService {
    @Autowired
    private PaymentRepository paymentRepository;
    
    @Autowired
    private SubscriptionService subscriptionService;
    
    public Payment processPayment(PaymentRequest request) {
        // Проверка баланса
        User user = userService.getUser(request.getUserId());
        if (user.getBalance() < request.getAmount()) {
            throw new InsufficientFundsException();
        }
        
        // Списание денег
        user.setBalance(user.getBalance() - request.getAmount());
        userRepository.save(user);
        
        // Создание платежа
        Payment payment = new Payment(request);
        paymentRepository.save(payment);
        
        // Активация подписки
        subscriptionService.activate(request.getSubscriptionId());
        
        return payment;
    }
}

Проблема: Между проверкой баланса и его списанием прошло некоторое время. Два параллельных запроса оба прошли проверку user.getBalance() < request.getAmount(), и оба достигли списания денег!

Диагностика

  1. Изучение логов: Нашли два платежа с временем создания 150ms друг от друга
  2. Проверка БД: Баланс был списан дважды, но подписка активирована один раз
  3. Race condition: Стандартная проблема параллелизма

Решение

Вариант 1: SELECT FOR UPDATE (Pessimistic Lock)

@Service
public class PaymentService {
    @Autowired
    private PaymentRepository paymentRepository;
    
    @Transactional
    public Payment processPayment(PaymentRequest request) {
        // Блокируем строку user до конца транзакции
        User user = userRepository.findByIdForUpdate(request.getUserId());
        
        // Теперь проверка и списание атомарны
        if (user.getBalance() < request.getAmount()) {
            throw new InsufficientFundsException();
        }
        
        user.setBalance(user.getBalance() - request.getAmount());
        userRepository.save(user);
        
        Payment payment = new Payment(request);
        paymentRepository.save(payment);
        
        return payment;
    }
}

// В репозитории
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    @Query("SELECT u FROM User u WHERE u.id = ?1 FOR UPDATE")
    User findByIdForUpdate(Long id);
}

Вариант 2: Optimistic Lock с версионированием

@Entity
public class User {
    @Id
    private Long id;
    
    private BigDecimal balance;
    
    @Version
    private Long version; // для optimistic lock
}

@Service
public class PaymentService {
    @Transactional
    public Payment processPayment(PaymentRequest request) {
        try {
            User user = userRepository.findById(request.getUserId()).orElseThrow();
            
            if (user.getBalance() < request.getAmount()) {
                throw new InsufficientFundsException();
            }
            
            user.setBalance(user.getBalance() - request.getAmount());
            userRepository.save(user); // вызовет OptimisticLockingFailureException при конфликте
            
            return paymentRepository.save(new Payment(request));
        } catch (OptimisticLockingFailureException e) {
            // Повторить попытку
            throw new PaymentRetryException("Another payment in progress");
        }
    }
}

Вариант 3: Idempotency key

@Entity
public class Payment {
    @Id
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String idempotencyKey; // уникальный ключ
    
    private BigDecimal amount;
    private Long userId;
}

@Service
public class PaymentService {
    @Transactional
    public Payment processPayment(String idempotencyKey, PaymentRequest request) {
        // Проверяем, не обработан ли уже этот платёж
        Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return existing.get(); // возвращаем существующий платёж
        }
        
        // Используем FOR UPDATE для безопасности
        User user = userRepository.findByIdForUpdate(request.getUserId());
        
        if (user.getBalance() < request.getAmount()) {
            throw new InsufficientFundsException();
        }
        
        user.setBalance(user.getBalance() - request.getAmount());
        userRepository.save(user);
        
        Payment payment = new Payment(request);
        payment.setIdempotencyKey(idempotencyKey);
        return paymentRepository.save(payment);
    }
}

Что я выбрал

Комбинированный подход:

  1. SELECT FOR UPDATE для критичных операций (платежи, баланс)
  2. Idempotency key для защиты от дублей (в случае переотправки запроса)
  3. Event sourcing для финансовых операций (audit trail)
@Service
public class PaymentService {
    
    @Transactional
    public Payment processPayment(
        String idempotencyKey,
        PaymentRequest request) {
        
        // 1. Проверка на дубль
        var existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return existing.get();
        }
        
        // 2. Заблокировать пользователя
        User user = userRepository.findByIdForUpdate(request.getUserId());
        
        // 3. Проверка и списание
        if (user.getBalance().compareTo(request.getAmount()) < 0) {
            throw new InsufficientFundsException();
        }
        
        user.setBalance(user.getBalance().subtract(request.getAmount()));
        userRepository.save(user);
        
        // 4. Создание платежа
        Payment payment = new Payment(request);
        payment.setIdempotencyKey(idempotencyKey);
        paymentRepository.save(payment);
        
        // 5. Публикация события для аудита
        eventPublisher.publish(new PaymentProcessedEvent(payment));
        
        return payment;
    }
}

Результат

  • Фикс: Все тесты на race conditions прошли
  • Bonus: Улучшилась производительность (SELECT FOR UPDATE SKIP LOCKED)
  • Learning: Важность тестирования параллелизма

Ключевой урок

ACID не гарантирует по умолчанию безопасность параллелизма — нужны явные механизмы блокировки или versioning. В финансовом коде это критично.