← Назад к вопросам
Как избежать дублирования операций и гарантировать, что операция выполнится только один раз?
3.0 Senior🔥 181 комментариев
#REST API и микросервисы#SOLID и паттерны проектирования#Многопоточность
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Ответ
Проблема
Если запрос повторяется из-за сетевой ошибки, операция может выполниться дважды:
Первый запрос → платёж создан ✓
Сеть падает → клиент не получил ответ
Повторный запрос → платёж создан ЕЩЁ РАЗ ✗
Решение 1: Idempotency Key
Клиент генерирует уникальный ключ для каждой операции и передаёт его сервере:
String idempotencyKey = UUID.randomUUID().toString();
HttpRequest request = HttpRequest.newBuilder()
.uri(java.net.URI.create("https://api.example.com/payments"))
.header("Idempotency-Key", idempotencyKey)
.POST(...)
.build();
Сервер проверяет по ключу был ли запрос раньше:
@PostMapping("/payments")
public PaymentResponse create(
@RequestBody PaymentRequest req,
@RequestHeader("Idempotency-Key") String key) {
// Проверяем кэш
PaymentResponse cached = idempotencyCache.get(key);
if (cached != null) return cached; // Уже выполнено
// Выполняем операцию
Payment payment = paymentRepository.save(new Payment(req));
PaymentResponse response = new PaymentResponse(payment);
// Сохраняем результат (TTL 24 часа)
idempotencyCache.put(key, response, Duration.ofHours(24));
return response;
}
Решение 2: UNIQUE констрейн в БД
Используй натуральный ключ для предотвращения дублей:
CREATE TABLE payments (
id UUID PRIMARY KEY,
external_reference_id VARCHAR(255) UNIQUE NOT NULL,
amount DECIMAL(10, 2)
);
@Transactional
public PaymentResponse create(PaymentRequest req, String refId) {
try {
Payment p = new Payment();
p.setExternalReferenceId(refId);
return new PaymentResponse(paymentRepository.save(p));
} catch (DataIntegrityViolationException e) {
// Платёж уже существует, возвращаем его
return new PaymentResponse(
paymentRepository.findByExternalReferenceId(refId).get()
);
}
}
Решение 3: Pessimistic Lock
Для критичных операций используй SELECT FOR UPDATE:
@Transactional
public TransferResponse transfer(TransferRequest req) {
// Блокируем запись на время транзакции
Transfer existing = transferRepo
.findByIdempotencyKeyWithLock(req.getIdempotencyKey())
.orElse(null);
if (existing != null) return new TransferResponse(existing);
Transfer t = new Transfer();
t.setIdempotencyKey(req.getIdempotencyKey());
Transfer saved = transferRepository.save(t);
accountService.decreaseBalance(req.getFrom(), req.getAmount());
accountService.increaseBalance(req.getTo(), req.getAmount());
return new TransferResponse(saved);
}
@Repository
public interface TransferRepository extends JpaRepository<Transfer, UUID> {
@Query("SELECT t FROM Transfer t WHERE t.idempotencyKey = ?1")
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT FOR UPDATE
Optional<Transfer> findByIdempotencyKeyWithLock(String key);
}
Сравнение
| Подход | Надёжность | Производительность | Использование |
|---|---|---|---|
| Idempotency Key | ✅✅ | ⚠️ Медленнее | Платежи, API |
| UNIQUE констрейн | ✅✅ | ✅ Быстро | Натуральные ключи |
| Pessimistic Lock | ✅✅✅ | ❌ Медленно | Критичные операции |
Best Practices
✅ Клиент: Генерируй UUID для каждого запроса
String idempotencyKey = UUID.randomUUID().toString();
✅ Сервер: Проверяй был ли запрос перед выполнением
if (idempotencyCache.contains(key)) return cachedResult;
✅ Сервер: Сохраняй результат с TTL
idempotencyCache.put(key, result, Duration.ofHours(24));
✅ БД: Используй транзакции
@Transactional
public void operation() { ... }
Итог
Для гарантирования выполнения операции только один раз:
- Простые случаи → UNIQUE констрейн
- REST API → Idempotency Key (стандарт Stripe, PayPal)
- Критичные операции → Pessimistic Lock (SELECT FOR UPDATE)
- Распределённые системы → Event Sourcing или Saga паттерн