跪拜 Guibai
← Back to the summary

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

image.png

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

image.png

3.2 Four-layer defense: faster in front, stronger in the back

Production doesn't bet on a single scheme; instead layers defenses:

image.png

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

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:

image.png

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:

  1. DB unique constraint on out_trade_no, as fallback;
  2. Redis distributed lock on out_trade_no at business entry, to prevent concurrent callbacks;
  3. 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 select then insert? 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:

image.png

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 + DEL combined into one atomic operation via Lua script, thoroughly eliminating the classic "two concurrent requests both pass the check" trap. Similarly, issuing the token must use SET with expiration in one command — if you SET then EXPIRE, 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:

image.png

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 SELECT first then UPDATE? Because there's a time window between the two steps: two callback threads both see WAIT_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:

image.png

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:

  1. SET key value NX EX one command completes the placeholder, eliminating deadlock from SETNX then EXPIRE crashes;
  2. 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;
  3. Result snapshot replay: duplicate requests return the first successful result — this is the correct idempotency semantics;
  4. 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:

  1. Audit existing write interfaces: for all tables that create data, check if there's a unique constraint; add immediately if missing;
  2. Add idempotency table to core paths: funds, payments, rewards — result snapshot + three states, all must be present;
  3. Package a team standard @Idempotent component: unify standards, don't let everyone write their own;
  4. 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.