Eight Idempotency Patterns and a Four-Layer Defense That Killed Duplicate Payment Tickets
The Interview Answer They Hate: "I Used Redis + Token"! 8 Idempotency Patterns + 4-Layer Defense to Drop Duplicate-Charge Tickets to Zero
User double-clicks the pay button and money is deducted twice; network timeout triggers automatic client retry and two orders are created — I used to handle a dozen of these "double-charge" tickets every month. After implementing a full idempotency defense system, that number went to zero.
This article explains API idempotency thoroughly: from concept and scheme selection to complete code and pitfall guidance. Whether preparing for interviews or production rollout, this one post is enough.
1. Don't memorize solutions first; explain what idempotency actually is
In interviews, 90% of people jump in with "I used Redis + Token," but the first thing interviewers really want to hear is your definition of the idempotency metric.
The standard definition is one sentence:
The same request executed once and executed N times has exactly the same side effects on the system. Math notation:
f(f(x)) = f(x).
Two common misunderstandings here.
Idempotency guarantees "same side effects," not "only called once." The interface can be called 100 times, but the result must take effect only once. So don't try to "block all duplicate requests" at the network layer — that's neither achievable nor necessary.
Idempotency ≠ deduplication, and ≠ concurrency control. If you conflate these three, one follow-up question will expose you:
| Concept | Meaning | Focus | Typical means |
|---|---|---|---|
| Deduplication | Filter out duplicate arriving requests | "not execute" | filters, interceptors |
| Idempotency | Multiple executions yield the same result | prevent duplicate side effects (no double charge, no double order) | unique index, state machine, idempotency table |
| Concurrency control | Only one execution at a time | prevent race conditions (oversell, cache breakdown) | locks, CAS |
One more emphasis: Distributed lock ≠ idempotency. Locks only stop "simultaneous" requests; they cannot stop a retry that comes 3 seconds later. Lock is one tool; idempotency is the goal. They often appear together but are not the same thing.
Add a RESTful perspective: per spec, GET, PUT, DELETE are naturally idempotent; POST is not. In real business, most write operations (create order, pay) are POST — so idempotency design is essentially patching "non-idempotent POST business."
Different interface types have different priorities:
| Interface type | Idempotency situation | Handling approach |
|---|---|---|
| Query | Naturally idempotent | No need; repeated queries return consistent results |
| Delete | Easy to make idempotent | Deleting a non-existent resource still returns success (idempotent delete) |
| Create | High-risk | unique business number + unique index |
| Update | High-risk | State machine / optimistic lock |
Now that the concept is clear, let's see where duplicate requests actually come from — more than you think.
2. Where do duplicate requests come from? Understand the sources before discussing solutions
In real development, duplicate requests come from at least six categories:
| Source | Typical scenario | Characteristic |
|---|---|---|
| User repeated clicks | Double-click on order, pay, coupon buttons | Frontend controllable, most basic |
| Network timeout retry | Client, gateway, Nginx auto retry | The request may have already reached the server and executed successfully |
| MQ duplicate consumption | Failed consumption redelivery, partition rebalance | "At-least-once" delivery semantics |
| RPC framework retry | Dubbo, Feign retry policies | Middleware-level resend |
| Scheduled task duplicate trigger | Multiple nodes running batch concurrently | Distributed scheduling competition |
| Page back | Browser back then re-submit form | Browser behavior |
As long as the system has "network retry" or "at-least-once delivery," you must implement idempotency at the business layer. Don't expect the caller to send only once. That's an iron law of distributed systems.
I fell into this trap before: thinking "the frontend button is greyed out, no problem," but the user had poor network, the browser retried on timeout, and duplicate orders appeared. Frontend prevention is only an experience optimization; idempotency must be done on the server.
3. The golden formula and the four-layer defense system
3.1 Golden formula for idempotency
All idempotency solutions, stripped to their shells, are three things:
Idempotency = unique identifier + dedup storage + atomic operation
- Unique identifier: How to determine "this is the same request"? Business order number, or a client-generated
requestId; - Dedup storage: Record "already processed" — Redis, MySQL, or a status field all work;
- Atomic operation: The check + write must happen in one atomic step, otherwise under concurrency both requests will see "not processed" and both will write.
3.2 Four-layer defense: faster in front, stronger in the back
Production doesn't bet on a single scheme; instead layers defenses:
- Frontend layer blocks 80% of low-level duplicates, but only fixes experience;
- Gateway/framework layer generic interception, zero business intrusion;
- Business layer uses state machines, CAS for business semantic fallback;
- The data layer's unique index is the last line of defense, solving 100% of correctness issues.
My rule: For any write interface, ask yourself — does this table have a unique constraint that can block duplicates? If not, the interface is unsafe.
3.3 Idempotency key design: if this step is wrong, everything loses
The first step is to define the key. The idempotency key must be a business unique identifier — for the same business intent, no matter how many retries, the key must be identical.
Common sources are three:
Idempotency-Key: 7f1f6c2f-5b8a-4a7b-9d9f-9c5f2a5c0b1e
- Frontend-generated: pass uniformly via the
Idempotency-Keyheader; - Business-generated: order number, payment serial number, business order number (most reliable);
- Gateway-generated: global
requestId, e.g., extracted from theX-Request-Idheader and propagated through the whole chain.
Which keys work, which absolutely should not be used:
| Recommended | Not recommended |
|---|---|
bizType + requestId |
userId + timestamp (drifts; won't match on retry) |
orderNo + operation type |
Fresh UUID generated each time (changes on every retry, defeats dedup) |
External payment serial externalTradeNo |
MQ's messageId (may change on redelivery) |
String idemKey = "pay:callback:" + externalTradeNo;
Key design principle:
businessDomain:operation:businessUniqueId(+parameterFingerprint), e.g.,idem:pay:ORDER_20261008_001. A key too coarse will wrongly block different requests; too fine won't block duplicates.
One point many ignore: you must force upstream callers to send the same idempotency key on retry, preferably generated by the caller and propagated through the entire chain down to your unique index. If the caller can't guarantee key consistency, your work elsewhere is pointless.
4. Scheme selection: a comparison table + a decision flowchart
There are 8 mainstream schemes. One table explains them all:
| Scheme | Core principle | Applicable scenario | Pros | Cons / Pain points |
|---|---|---|---|---|
| DB unique index | Unique constraint blocks duplicate inserts | Insert-type: orders, payment records, consumption fallback | Simple, strong consistency, ultimate fallback | Only prevents inserts, not updates; conflict semantics need distinguishing |
| Token mechanism | Pre-issued token, atomically checked and deleted on submit | Frontend forms, order pages | Blocks before business executes, high interception rate | Needs frontend cooperation, extra interaction |
| State machine | Only legal state transitions, CAS update | Orders, approvals, payment callbacks | Business-aligned, naturally idempotent, zero extra components | Only fits stateful business |
| Optimistic lock version | WHERE version = ? conditional update |
Inventory, balance updates | No lock blocking, good performance | Update-only; needs retry strategy on failure |
| Pessimistic lock | SELECT ... FOR UPDATE row lock |
Low-concurrency scenarios requiring strong consistency | Strong consistency | Poor performance, deadlock risk |
| Distributed lock | Redis/Redisson preemption serialization | Multi-step complex business orchestration | Highly generic | Lock expiry, accidental deletion risk; only prevents concurrency, not idempotency |
| Idempotency table | Dedicated dedup table + unique index + status + result snapshot | Generic scenarios, core fund/payment links | No intrusion on business tables, can replay results | One more table, needs periodic cleanup |
| MQ dedup table | Consumer-side dedup by message business unique key | MQ consumption | Simple and effective | Must be in the same transaction as business |
Selection mnemonic you can memorize directly:
Insert → unique index; update → optimistic lock; state flow → state machine; generic → idempotency table + Redis annotation; frontend → Token / grey-out fallback.
For a write interface, follow this decision flowchart:
Recommended combinations for different scenarios:
| Scenario | Recommended combination |
|---|---|
| Simple insert (create order) | Unique index |
| Frontend form submit | Token + grey-out button |
| Payment callback | State machine CAS + unique index; add distributed lock under high concurrency |
| Inventory / balance update | Optimistic lock + conditional update |
| MQ consumption | Business unique key dedup table (same transaction as business) |
| Complex distributed business | Global unique ID + idempotency table + full-chain propagation of idempotency key |
A real project combination example — payment callback triple insurance:
- DB unique constraint on
out_trade_no, as fallback; - Redis distributed lock on
out_trade_noat business entry, to prevent concurrent callbacks; - State machine constraint when updating order status, ensuring only "pending payment" → "paid".
Three layers stacked together, foolproof.
If this selection table is useful, save it. Follow for more real interview scenario questions and solutions — save yourself detours.
5. Core code implementations (copy-ready)
Below is code per scheme. If you have limited time and only read one section, 5.7's generic idempotency component is the one — it best demonstrates architectural ability.
5.1 DB unique index: the hardest fallback
When creating the order table, add a unique index on the order number:
CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(64) NOT NULL COMMENT 'business order no',
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 unpaid, 1 paid, 2 cancelled',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`) -- last line of defense for idempotency
) ENGINE=InnoDB;
Business code directly inserts; on conflict treat as duplicate request:
public Order createOrder(CreateOrderCmd cmd) {
try {
Order order = buildOrder(cmd);
orderMapper.insert(order); // unique index fallback
return order;
} catch (DuplicateKeyException e) {
// Not an error, but "this is a duplicate request, return the existing order"
return orderMapper.selectByOrderNo(cmd.getOrderNo());
}
}
Interviewers love asking: why not
selecttheninsert? Because check-then-act is unsafe under concurrency — two threads can both see "not exists" and both write. The correct approach is called optimistic insert: insert directly, query on conflict. The unique index pushes the "check" down to the DB's atomic operation — that's the key.
One detail: Unique key conflict ≠ necessarily an idempotency scenario. DuplicateKeyException must distinguish between "duplicate request" and "real business conflict" (e.g., two people grabbing the same order number). Must judge by business semantics; don't blindly swallow the exception and return old data, which would mask real data problems.
5.2 Token prevention of duplicate submission: classic frontend+backend collaboration
Here's the flow:
First, see why the naive approach is wrong:
// Wrong: two steps not atomic; under concurrency both requests pass the check
if (redisTemplate.hasKey(tokenKey)) {
redisTemplate.delete(tokenKey);
// execute business...
}
Two requests both hasKey return true, then each deletes and executes business — dedup completely fails. The correct way is to combine "check + delete" into one via Lua script:
@Service
public class TokenService {
// Lua: only delete if value matches; Redis single-threaded execution, naturally atomic
private static final DefaultRedisScript<Long> CHECK_AND_DEL = new DefaultRedisScript<>(
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end", Long.class);
@Resource
private StringRedisTemplate redisTemplate;
/** Create Token: SET with expiration in one command, avoid SET then EXPIRE non-atomic */
public String createToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("idem:token:" + token, token, 5, TimeUnit.MINUTES);
return token;
}
/** Verify and delete Token; return true only to allow business */
public boolean checkToken(String token) {
Long r = redisTemplate.execute(CHECK_AND_DEL, List.of("idem:token:" + token), token);
return r != null && r > 0;
}
}
Technical highlight:
GET + DELcombined into one atomic operation via Lua script, thoroughly eliminating the classic "two concurrent requests both pass the check" trap. Similarly, issuing the token must useSETwith expiration in one command — if youSETthenEXPIRE, a crash in between leaves a never-expiring zombie token.
Why is Token stored in Redis rather than a database? High-frequency verification, automatic TTL, good performance; true consistency is guaranteed by the DB unique index.
5.3 State machine CAS: the soul for orders and payment callbacks
For stateful business, a state machine is the most elegant idempotency solution. Order status flows only one way:
Simple rule: only allow an "untaken" edge to be traversed once; repeat of the same edge is ignored.
Core SQL combines "check status" and "update status" into one atomic statement:
UPDATE t_order
SET status = 'PAID', update_time = NOW()
WHERE order_no = #{orderNo}
AND status = 'WAIT_PAY'; -- CAS with expected status
Payment callback is the heaviest idempotency area — third parties may push N times. Full implementation:
@Transactional(rollbackFor = Exception.class)
public void payCallback(PayNotify notify) {
// 1. Unique index fallback: pay_no unique, duplicate callback just key conflict
payRecordMapper.insertIgnore(PayRecord.of(notify));
// 2. CAS update; rows = 0 means already handled or illegal status
int rows = orderMapper.casToPaid(notify.getOrderNo(), notify.getPayNo());
if (rows == 0) {
log.warn("Order already paid or status abnormal, ignore callback: {}", notify.getOrderNo());
return;
}
// 3. Subsequent derived actions (coupon / points / notification) go through reliable MQ delivery
eventPublisher.publish(new OrderPaidEvent(notify.getOrderNo()));
}
Why not
SELECTfirst thenUPDATE? Because there's a time window between the two steps: two callback threads both seeWAIT_PAY, each executes an update, causing points and inventory to be deducted twice. CAS pushes the check down to the DB row lock, naturally atomic. Even if the payment platform calls back 10 times, only one processing occurs.
5.4 Optimistic lock: update posture for inventory and balance
For update operations, use a version number or a remaining-quantity condition to do atomic updates:
// With version to prevent ABA, and num >= delta to prevent oversell
@Update("UPDATE t_stock SET num = num - #{delta}, version = version + 1 " +
"WHERE sku_id = #{skuId} AND num >= #{delta} AND version = #{version}")
int deductStock(@Param("skuId") Long skuId, @Param("delta") int delta,
@Param("version") int version);
Returning affected rows 0 means either insufficient stock or a concurrency race was lost; hand over to upper layer to retry or return "insufficient stock" — never oversell.
An advanced detail: rows == 0 has two meanings; you need to distinguish them:
if (rows == 0) {
Order latest = orderMapper.selectByOrderId(orderId);
if ("PAID".equals(latest.getStatus())) {
return; // already paid: idempotent, return success directly
}
throw new BizException("Concurrency conflict, please retry"); // version conflict: retryable
}
In the WHERE clause, status handles business idempotency, version handles concurrency control — one database interaction solves two problems.
5.5 Distributed lock: protect complex business blocks
For operations involving multi-step queries, calculations, and multi-table writes, when the unique index can't cover, use a distributed lock to serialize the whole block:
RLock lock = redissonClient.getLock("lock:pay:" + orderNo);
try {
if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
return Result.error("Request processing, please retry later");
}
// execute business logic
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Two benefits of Redisson: watchdog auto-renewal (after acquiring the lock, renews every 1/3 of expiry, so the lock won't expire while business is still running); it checks lock holder identity on release to avoid deleting someone else's lock.
But always remember: lock only handles "simultaneous", idempotency handles "duplicate". After the lock is released, a retry request arriving a few seconds later will still execute again. So the lock must be combined with unique index and state machine; it alone can't carry idempotency.
5.6 Idempotency table: result fallback, letting duplicate requests get the first result
For core links like funds, payments, marketing rewards, add an idempotency record table storing both status and result:
CREATE TABLE t_idempotent (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_type VARCHAR(64) NOT NULL COMMENT 'business type',
idempotent_key VARCHAR(128) NOT NULL COMMENT 'idempotency key',
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING / SUCCESS / FAIL',
response TEXT COMMENT 'business result snapshot',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_key (biz_type, idempotent_key),
KEY idx_create_time (create_time) -- for scheduled cleanup
) COMMENT 'idempotency record table';
Processing flow:
public Object executeWithIdempotent(String bizType, String key, Supplier<Object> biz) {
IdempotentDO record = new IdempotentDO(bizType, key, "PROCESSING");
try {
idempotentMapper.insert(record); // atomic placeholder, unique index guarantees
} catch (DuplicateKeyException e) {
IdempotentDO old = idempotentMapper.selectByKey(bizType, key);
if (old.isSuccess()) {
return JSON.parse(old.getResponse()); // replay historical result
}
if (old.isProcessing()) {
throw new BizException("Request being processed, do not resubmit");
}
throw new BizException("Last request failed, please retry later");
}
try {
Object result = biz.get();
idempotentMapper.markSuccess(bizType, key, JSON.toJSONString(result));
return result;
} catch (Throwable e) {
idempotentMapper.markFail(bizType, key);
throw e;
}
}
Technical highlight: Redis handles fast interception, the idempotency table handles result retention. True idempotency isn't just "not executing again" — it's duplicate requests also return the first result. If the first order creation succeeded, a second duplicate request should not error, but return that exact order. That's the full semantics of
f(f(x)) = f(x): even the return value must match.
5.7 Biggest highlight: @Idempotent annotation + AOP generic component
Wrap "unique identifier + Redis dedup + Lua atomicity + result replay" into an annotation; business side adds one line of code — this is the most resume-worthy piece.
Annotation definition:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/** Idempotency key, supports SpEL, e.g., "#req.orderNo" */
String key() default "";
/** Business prefix */
String prefix() default "idem";
/** Dedup window (seconds) */
int expire() default 30;
/** Duplicate request prompt */
String message() default "Please do not resubmit";
/** Whether to replay the first result on duplicate requests */
boolean replay() default true;
}
Aspect implementation, noting the Lua script's tri-state return value:
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final StringRedisTemplate redis;
/**
* KEYS[1]=idempotency key ARGV[1]=parameter fingerprint ARGV[2]=expiry (seconds)
* Returns: 1=first request passes | 2=duplicate request | 0=same key with different parameters
*/
private static final DefaultRedisScript<Long> SCRIPT = new DefaultRedisScript<>(
"if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 1 " +
"elseif redis.call('GET', KEYS[1]) == ARGV[1] then return 2 " +
"else return 0 end", Long.class);
@Around("@annotation(idem)")
public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
// 1. Parse business idempotency key via SpEL, append user and method dimension to prevent cross-user impact
String bizKey = SpELUtil.parse(idem.key(), pjp);
String redisKey = idem.prefix() + ":" + userId() + ":"
+ pjp.getSignature().toShortString() + ":" + bizKey;
// 2. Parameter fingerprint: same order number with changed amount is a different request
String fingerprint = DigestUtils.md5DigestAsHex(JSON.toJSONBytes(pjp.getArgs()));
Long r = redis.execute(SCRIPT, List.of(redisKey),
fingerprint, String.valueOf(idem.expire()));
if (r == null || r == 0) {
throw new BizException("Same idempotency key but inconsistent parameters, check request");
}
if (r == 2) {
// Duplicate request: replay first result rather than simply error
return idem.replay() ? replayResult(redisKey) : Result.fail(idem.message());
}
try {
Object result = pjp.proceed();
cacheResult(redisKey, result, idem.expire()); // result snapshot, for replay
return result;
} catch (Throwable e) {
// 3. Only release the placeholder for errors that are clearly "failed and retryable"
if (isRetryableException(e)) {
redis.delete(redisKey);
}
throw e;
}
}
private boolean isRetryableException(Throwable e) {
// Parameter validation, normal business exceptions are retryable; network timeout, unknown-result exceptions must not be released lightly
return e instanceof BizException;
}
}
Business-side usage, one line of annotation:
@Idempotent(prefix = "order:create", key = "#req.orderNo", expire = 600,
message = "Order is processing, please wait")
public Order createOrder(CreateOrderRequest req) {
return orderService.create(req);
}
Four points worth highlighting in this code:
SET key value NX EXone command completes the placeholder, eliminating deadlock fromSETNXthenEXPIREcrashes;- Parameter fingerprint prevents "fake idempotency": same order number with a different amount submitted again is judged as an exception, not silently treated as a duplicate;
- Result snapshot replay: duplicate requests return the first successful result — this is the correct idempotency semantics;
- Deleting the placeholder key on business failure: lets genuinely failed requests be retried by the user, instead of being permanently blocked — a blind spot for many.
5.8 MQ consumption idempotency: lifebuoy for at-least-once delivery
MQ's "at-least-once" delivery semantics mean the consumer must implement idempotency itself. Dedup table + same transaction is the most robust approach:
CREATE TABLE mq_consume_log (
msg_id VARCHAR(64) NOT NULL COMMENT 'message business unique key',
biz_key VARCHAR(64) NOT NULL COMMENT 'business primary key',
gmt_create DATETIME NOT NULL,
PRIMARY KEY (msg_id)
) ENGINE=InnoDB;
@Transactional(rollbackFor = Exception.class)
public void onMessage(Message msg) {
try {
consumeLogMapper.insert(msg.getBizKey()); // same DB and transaction as business
} catch (DuplicateKeyException e) {
log.warn("Duplicate message, already consumed: {}", msg.getBizKey());
return; // ACK directly, achieving idempotency
}
doBiz(msg);
}
Key point: the dedup record must be in the same local transaction as the business operation. Otherwise, if insert succeeds but business rolls back, the message will be redelivered and misjudged as "already consumed", losing data.
If using RabbitMQ manual ACK, the template is:
@RabbitListener(queues = "order.pay.queue")
public void onMessage(PayMessage msg, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
// Idempotency key = business unique ID, not MQ's messageId (may change on redelivery)
String bizNo = msg.getOrderNo() + ":" + msg.getPayType();
if (!tryInsertDedup(bizNo)) {
channel.basicAck(tag, false); // duplicate message, ACK directly
return;
}
try {
payService.handlePaySuccess(msg);
channel.basicAck(tag, false);
} catch (Exception e) {
channel.basicNack(tag, false, !isFatal(e)); // only retryable messages go back to queue
}
}
You can copy the code, but the pitfalls you stepped in are worth more — the next chapter is the most hardcore part of the whole post.
6. Technical difficulties and pitfall guide (interviewers' favorite follow-up zone)
The pitfalls of idempotency are mostly in the details. I've grouped them into four tables, then break down a few high-frequency difficulties separately.
6.1 Concurrency and atomicity class
| Difficulty | Phenomenon | Solution |
|---|---|---|
| check-then-act race | Both requests see "not exists", both write | Push check down to atomic operation: unique index / SET NX / conditional update |
| Token check-then-delete non-atomic | Both requests GET success, both pass |
Lua script merges GET + DEL |
SETNX then EXPIRE non-atomic |
Crash in between, lock never expires | SET key val NX EX one command, or Lua |
| Accidental lock deletion | A deletes B's lock | Store UUID as value, delete via Lua checking value |
| Multi-instance local dedup fails | JVM memory, local cache not shared across instances | Use global storage like Redis / DB |
6.2 Expiry and release class
| Difficulty | Phenomenon | Solution |
|---|---|---|
| Lock expires but business hasn't finished | Placeholder invalid, duplicate requests enter | Reasonable TTL assessment; Redisson watchdog auto-renewal |
| Placeholder not released after business failure | User retry permanently blocked | Delete key on exception, or mark FAIL in idempotency table |
| Release not unconditional | Timeout with unknown result; release may cause duplicate execution | Only release on "clearly failed and retryable"; for payment/deduction with unknown result, don't release; rely on CAS + unique index for retry safety |
| Delete placeholder before or after? | Before: failure can't retry; after: concurrent duplicates enter | Placeholder before execution, compensate delete on failure, final consistency backed by unique index |
| Idempotency key expiry | Duplicates outside window penetrate to DB | Expiry ≥ maximum retry window (gateway retry + MQ redelivery total time); DB unique index fallback |
6.3 Storage and performance class
| Difficulty | Phenomenon | Solution |
|---|---|---|
| Redis down | All interception fails | DB unique index fallback; Sentinel / cluster for high availability |
| Idempotency table bloat | History grows unbounded | Set TTL + periodic archive (e.g., keep 7-30 days) + cold/hot separation, sharding |
| Unique index hotspot | High concurrency causes frequent conflicts, DB CPU spikes | Pre-check with Redis; INSERT ... ON DUPLICATE KEY UPDATE |
| Sharding breaks unique constraint | Uniqueness only within single DB table | Idempotency key must include shard key; global ID generator; route by shard key to fixed table |
| Extreme high-concurrency performance | Unique index becomes write bottleneck | Hash of unique key tail to spread hotspot; Bloom filter pre-filter; non-financial business may downgrade to Redis dedup (eventual consistency) |
6.4 Link and semantics class
| Difficulty | Phenomenon | Solution |
|---|---|---|
| Duplicate request returns inconsistent value | First success but response timeout, retry gets "duplicate submission" error | Result snapshot: cache first result, replay on duplicate |
| MQ at-least-once delivery | Messages consumed repeatedly | Business unique key dedup table + same transaction as business; state machine fallback |
| Inconsistent idempotency keys across upstream/downstream | Caller retries with a different key, dedup useless | API protocol forces Idempotency-Key; gateway rejects missing key; propagate one transaction ID through the chain |
| Cross-system / cross-DB idempotency | Local transaction can't control external systems | Local message table / transactional message + downstream idempotency, eventual consistency |
| Batch operation partial success | Batch request can't be idempotent as a whole | Break into single-record granularity dedup, track per-item status, support partial failure retry |
| Unique key conflict ≠ necessarily idempotency | Two people grabbing the same order number is business conflict, not duplicate request | Judge by business semantics; don't blindly swallow exceptions |
| Non-idempotent SQL | num = num - 1 repeated execution yields different results |
Rewrite to absolute value num = #{newValue}; or ledger table + unique index (account_id + biz_no) |
| User retries while async processing | Request returned but slow processing; user clicks again | First write record and return "processing", frontend polls / callback for final status |
6.5 Four high-frequency difficulties broken down
Difficulty one: Duplicate requests must return the same result (true idempotency)
This is a high-frequency follow-up. Scenario: Frontend submits order → backend processes successfully and writes to DB → network timeout, frontend gets no response → user clicks retry → backend returns "duplicate request" → frontend shows "order failed", but the order was already created.
Solution is to cache the first result and return it on retry:
public Result createOrder(String requestId, OrderDTO dto) {
// 1. Check if result already exists
String cacheKey = "result:" + requestId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Result.class); // return identical result directly
}
// 2. Place idempotency placeholder, execute business
Order order = orderService.create(dto);
Result result = Result.success(order);
// 3. Cache result, expiry covers maximum retry window
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 1, TimeUnit.HOURS);
return result;
}
Difficulty two: Releasing placeholder on failure, careful with transaction boundaries
"Delete the key on business exception" sounds fine, but if the aspect is outside the transaction, this can happen: transaction committed, partial data persisted, then exception deletes the key, and retry executes again on existing data. Two countermeasures: use TransactionSynchronization to decide release after transaction commit; or accept that "release may bring retry" and let unique index and state machine ensure retry safety — that's why I keep emphasizing the DB fallback can't be omitted.
Difficulty three: Global unique ID clock rollback
Idempotency keys often rely on Snowflake, which depends on machine clock. Three handling ideas for clock rollback: for small rollbacks, throw exception and wait for clock to catch up; use historical max timestamp to continue issuing IDs; or switch to Meituan Leaf's segment mode, getting number ranges from DB, completely independent of time.
Difficulty four: Non-idempotent SQL refactoring
UPDATE t_account SET balance = balance + 10 is naturally non-idempotent; run twice, add twice. Two refactoring paths: write as absolute value SET balance = #{newValue}; or introduce a ledger table, insert one record per change and build a unique index on account_id + biz_no, turning accumulation into "insert ledger + summarize", naturally deduplicatable.
7. Interview follow-up prep
Eight most-asked follow-up questions, with answer points:
| Follow-up | Answer points |
|---|---|
| What if the idempotency key expires? | Expiry ≥ max retry window; core business doesn't rely on Redis, unique index as final fallback |
| Redis down, idempotency fails? | Redis is a "performance layer", unique index is the "correctness layer", responsibilities separate |
| How to do MQ consumption idempotency? | Business unique ID as idempotency key + dedup table in same transaction; or state machine to check if already processed |
| Under high concurrency does Redis become bottleneck? | SET NX single command + sharding; core path still backed by DB unique constraint |
| Why Redis for Token rather than DB? | High-frequency verification, auto TTL, performance; consistency is backed by DB unique index |
| Can check-then-insert work for idempotency? | No, check-then-act has race; correct is optimistic insert, query on conflict |
| What about Snowflake clock rollback? | Throw exception and wait for clock / use historical max timestamp / switch to Leaf segment mode |
| How to measure idempotency effectiveness? | Add monitoring: duplicate request interception rate, duplicate charge incident count (must be 0), dedup table hit rate |
8. Summary: one lock, two uniques, three state, four results
The whole post condenses to one sentence:
Idempotency isn't the job of a single component; it's a four-layer defense: frontend prevention → gateway interception → business CAS → database unique constraint. The first three layers solve 99% of performance and experience issues; the last unique index solves 100% of correctness.
The four-piece landing set: entry prevention with Redis Token / SETNX, storage fallback with unique index, business fallback with state machine CAS, result fallback with idempotency table storing snapshots. No silver bullet; combine according to business nature.
Finally, a 30-second mnemonic:
Form double-click, token apply first
Generic interface, fingerprint + SETNX
Fallback via unique, conflict means success
Status use CAS, don't check then update
Messages dedup, same transaction landing
Lock only prevents concurrency; idempotency is another account
Landing advice, in priority order:
- Audit existing write interfaces: for all tables that create data, check if there's a unique constraint; add immediately if missing;
- Add idempotency table to core paths: funds, payments, rewards — result snapshot + three states, all must be present;
- Package a team standard
@Idempotentcomponent: unify standards, don't let everyone write their own; - Add monitoring: interception rate, incident ticket count, dedup table hit rate — idempotency isn't done once deployed; it must be measurable.
Finally a question for interaction: What idempotency scheme does your company mainly use now? Or did you step on any memorable trap with duplicate submissions? Leave a comment; I read and reply to all. If this post is useful, like, watch, share — your support is my biggest motivation to keep updating.