Какой интересный случай в разработке был за последнее время?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Интересный случай: 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(), и оба достигли списания денег!
Диагностика
- Изучение логов: Нашли два платежа с временем создания 150ms друг от друга
- Проверка БД: Баланс был списан дважды, но подписка активирована один раз
- 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);
}
}
Что я выбрал
Комбинированный подход:
- SELECT FOR UPDATE для критичных операций (платежи, баланс)
- Idempotency key для защиты от дублей (в случае переотправки запроса)
- 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. В финансовом коде это критично.