Four Practical Ways to Ensure Idempotency in High‑Traffic APIs
To prevent duplicate operations caused by network glitches, user errors, or retries, this article explains four common idempotency strategies—token, database unique index, distributed lock, and request payload hashing—providing clear concepts, key considerations, and ready‑to‑copy Java/Spring code examples.
Background
Duplicate requests caused by network jitter, user double‑clicks, or MQ retries can lead to multiple charges or over‑shipping. The following sections describe four practical idempotency patterns for Spring Boot services and provide copy‑ready code.
Token based idempotency
Idea: Generate a one‑time token, expose it to the client, and require the token in a request header. The token is stored in Redis with a short TTL and deleted atomically on first use.
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private StringRedisTemplate redis;
// ① Generate token for the client
@GetMapping("/token")
public String getToken() {
String token = UUID.randomUUID().toString();
// 10‑minute TTL is enough for a typical order flow
redis.opsForValue().set("tk:" + token, "1", Duration.ofMinutes(10));
return token;
}
// ② Order endpoint – token must be sent in the header
@PostMapping
public Result create(@RequestHeader("Idempotent-Token") String token,
@RequestBody OrderReq req) {
String key = "tk:" + token;
// Atomic delete: true means this is the first use
Boolean first = redis.delete(key);
if (Boolean.FALSE.equals(first)) {
return Result.fail("Please do not submit duplicate orders");
}
Order order = orderService.create(req);
return Result.ok(order);
}
}UUID guarantees global uniqueness; Redis TTL prevents stale tokens.
Redis delete is atomic, safe under high concurrency.
Passing the token via a header keeps the API signature clean.
Database unique index
Idea: Treat a business‑level unique field (e.g., a payment transaction ID) as a unique constraint in the relational database. An attempt to insert a duplicate row throws a DataIntegrityViolationException, which can be mapped to an idempotent response.
@Entity
@Table(name = "t_payment",
uniqueConstraints = @UniqueConstraint(columnNames = "transaction_id"))
public class Payment {
@Id
private Long id;
@Column(name = "transaction_id")
private String txId;
private BigDecimal amount;
private String status;
}
@Service
public class PayService {
@Autowired
private PaymentRepo repo;
public Result pay(PayReq req) {
try {
Payment p = new Payment();
p.setTxId(req.getTxId());
p.setAmount(req.getAmount());
p.setStatus("SUCCESS");
repo.save(p); // Duplicate triggers exception
return Result.ok("Payment successful");
} catch (DataIntegrityViolationException e) {
// Duplicate means the payment already exists
Payment exist = repo.findByTxId(req.getTxId());
return Result.ok("Already paid", exist.getId());
}
}
}The unique index guarantees 100 % duplicate protection at the DB level.
Catch the exception and translate it into a normal response for a smooth UX.
No external dependencies; works with legacy systems.
Distributed lock (Redisson)
Idea: Acquire a distributed lock on a business key such as product ID or user ID. Only the lock holder proceeds with the critical section, preventing concurrent modifications.
@Service
public class StockService {
@Autowired
private RedissonClient redisson;
public Result deduct(DeductCmd cmd) {
String lockKey = "lock:stock:" + cmd.getProductId();
RLock lock = redisson.getLock(lockKey);
try {
// Wait up to 3 s, auto‑release after 5 s
if (!lock.tryLock(3, 5, TimeUnit.SECONDS)) {
return Result.fail("Processing, please wait");
}
// Idempotent check – has this request ID been processed?
if (repo.existsByRequestId(cmd.getRequestId())) {
return Result.ok("Already deducted");
}
repo.deductStock(cmd);
return Result.ok("Deduction successful");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.fail("System busy");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}Redisson’s watchdog automatically renews the lock, avoiding deadlocks.
Combine a request‑ID table (unique index) with the lock for double safety.
Check lock ownership before unlocking to avoid releasing another thread’s lock.
Request payload digest (annotation + AOP)
Idea: Compute a hash (MD5 or SHA‑256) of the request body and use the hash as the idempotency key. A custom annotation together with an AOP interceptor makes the pattern reusable with virtually no code changes to existing endpoints.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
int expire() default 3600; // seconds
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redis;
@Around("@annotation(idem)")
public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// ① Compute request body digest
String body = IOUtils.toString(req.getReader());
String digest = DigestUtils.md5DigestAsHex(body.getBytes(StandardCharsets.UTF_8));
String key = "idem:digest:" + digest;
// ② First time? setIfAbsent returns true
Boolean absent = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(idem.expire()));
if (Boolean.FALSE.equals(absent)) {
return Result.fail("Duplicate request");
}
try {
return pjp.proceed();
} catch (Exception e) {
// Release the key on error so the client can retry
redis.delete(key);
throw e;
}
}
}
// Usage example
@RestController
public class TransferApi {
@PostMapping("/transfer")
@Idempotent(expire = 7200)
public Result transfer(@RequestBody TransferCmd cmd) {
return Result.ok(transferSvc.doTransfer(cmd));
}
}MD5 compresses any length payload to a 32‑character hex string with negligible collision risk. setIfAbsent guarantees atomicity; deleting on exception prevents false positives.
The annotation + AOP approach adds idempotency with a single line of code on any controller method.
Comparison of approaches
Token – medium latency, low complexity, requires Redis, best for pre‑generated steps such as order creation or payment.
Database unique index – low latency, low complexity, no external dependency, ideal for payment, registration, or any operation with a natural business key.
Distributed lock – medium latency, medium complexity, needs Redis or ZooKeeper, suited for high‑concurrency scenarios like flash sales.
Payload digest – low latency, medium complexity, needs Redis, works for operations without a pre‑generated key (e.g., transfers, callbacks).
Architect's Guide
Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
