跪拜 Guibai
← Back to the summary

Idempotency Under Fire: Load-Test Proof That Check-Then-Act Fails, and the Redis+Lua Layered Fix

Spring Boot Interface Idempotency Design: Engineering Practice from Unique Indexes to Redis+Lua

Author: 踏浪的码者

Duplicate order submission, duplicate debits, duplicate MQ consumption—idempotency is a problem almost every backend system has to face. But "adding a distributed lock" does not equal "interface idempotency." Many production incidents happen precisely in implementations that look atomic but actually have race windows.

This article does not stop at listing options. Instead, it uses a runnable concurrency load-test project to first prove why "check-then-act" will definitely break under concurrency, then builds up correct implementations with unique indexes, SETNX atomic placeholder, and Lua sliding window, and finally discusses engineering boundaries such as lock timeout, unique constraints, and message replay. All conclusions come with real load-test data.

1. First, Align: Idempotency, Duplicate Prevention, and Distributed Locks Are Not the Same Thing

Truly reliable idempotency is usually layered: entry-level pre-validation (blocks the vast majority of duplicate traffic) + resource-layer strong constraint (catches all that slip through). Understanding this makes scheme selection less agonizing over "lock vs. unique index"—the answer is often both.

2. Use Concurrency Load Testing to See How Incorrect Implementations Are Broken

The most common pattern is "first check existence, then decide whether to act":

// Wrong example: check-then-act, a race window between two steps
if (!kv.exists(key)) {
    // ... arbitrary time-consuming work here: DB query, RPC, computation
    kv.set(key, ttl);
    return doCreateOrder();
}
return "duplicate request";

Under single-thread load testing it "looks fine," but under concurrency: two threads can simultaneously pass the exists == false check, and then both execute the creation. In Redis terms, this splits the "check + write" that should be one command into EXISTS and SET commands, and the network/computation gap between them is the concurrency window.

To avoid relying on "I think so," I wrote a zero-dependency concurrency load-test project that uses 100 threads to race for the same key via CountDownLatch. In the non-atomic implementation, I deliberately left a 5ms gap to reproduce the issue reliably (in real systems this gap is a DB query and network round trip, which is only longer):

@Override
public boolean tryAcquire(String key, int limit, long windowMillis) {
    if (kv.exists(key)) {
        return false;
    }
    Thread.sleep(5);          // amplify the race window; in real systems this is IO time
    kv.set(key, windowMillis);
    return true;
}

Load-test results (100 threads, same key, expected to allow only 1 pass):

non-atomic(check-then-set)  passed=100   ← completely broken
set-nx(atomic)              passed=1     ← exact
sliding-window(atomic)      passed=1     ← exact

Relaxing the allowed count to 5 and raising concurrency to 200:

non-atomic(check-then-set)  passed=200   ← broken
set-nx(atomic)              passed=1     ← placeholder semantics, naturally allows 1
sliding-window(atomic)      passed=5     ← exact count by limit

The non-atomic version let through 100 requests at 100 concurrency, meaning it provided zero protection. This data set is also the starting point for all correct implementations below: the idempotency check must be a single atomic operation.

3. First Principles: Turn "Check + Write" into One Command

3.1 Redis SETNX: Atomic Placeholder in One Command

Redis's SET key value NX PX ttl accomplishes "only set if not exists + with expiration" in a single command. Redis's single-threaded model guarantees that no other commands can interleave during this period:

Boolean first = redisTemplate.opsForValue()
        .setIfAbsent("order:" + orderNo, "1", Duration.ofSeconds(10));
if (Boolean.FALSE.equals(first)) {
    throw new RepeatSubmitException("Request processing, please do not resubmit");
}
return doCreateOrder(orderNo);

In the load-test project, ConcurrentHashMap.compute reproduces the same atomic semantics in single-machine memory (compute completes check and write under the bucket lock), and the conclusion is identical to Redis—at 100 concurrency only 1 passes:

public boolean setIfAbsent(String key, long ttlMillis) {
    long now = System.currentTimeMillis();
    boolean[] first = {false};
    map.compute(key, (k, old) -> {
        if (old == null || old.expireAt() <= now) {
            first[0] = true;
            return new Value(now + ttlMillis);
        }
        return old;
    });
    return first[0];
}

3.2 Placeholder ≠ Counting: Boundaries of SETNX Usage

Note the limit=5 data set in Section 2: SETNX allowed only 1 pass. That is not a bug, but a semantic difference—SETNX answers "is this business key appearing for the first time?" It is a placeholder/duplicate-prevention primitive, not a rate limiter. If you need "N times within a window," you need a counting algorithm (sliding window, token bucket), which is exactly the Lua scenario in the next section. Matching the tool to the scenario matters more than memorizing commands.

4. Sliding Window: When You Need "N Times Within a Window"

A fixed window can produce a 2x spike at boundaries (filling at the end of one window and again at the start of the next). A sliding window is more precise: it maintains timestamps of passes within the window, first evicting expired records, then checking the current count.

The difficulty lies in atomicity—"cleanup + count + enqueue" must happen as one. In Redis, these steps must be placed in a single Lua script (executed once within Redis's single thread). The in-memory version demonstrates the algorithm itself:

@Override
public boolean tryAcquire(String key, int limit, long windowMillis) {
    Deque<Long> deque = windows.computeIfAbsent(key, k -> new ArrayDeque<>());
    synchronized (deque) {
        long now = System.currentTimeMillis();
        long boundary = now - windowMillis;
        while (!deque.isEmpty() && deque.peekFirst() <= boundary) {
            deque.pollFirst();                 // evict expired timestamps
        }
        if (deque.size() >= limit) {
            return false;                      // window already full
        }
        deque.addLast(now);                    // record this pass
        return true;
    }
}

The corresponding Redis Lua (ZSet timestamp approach) roughly looks like:

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
  return 0
end
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 1

In load tests, the sliding window at 200 concurrency and limit=5 let through exactly 5, blocking duplicates without rejecting legitimate 2nd–5th requests the way SETNX would.

5. Resource-Layer Backstop: Why Unique Indexes Are Irreplaceable

The Redis placeholder at the entry is fast but not absolute truth: the key may be deleted due to expiration, Redis may fail over and lose data, or the code may crash after placeholder acquisition but before DB write. As long as a "placeholder succeeded but final write never happened" path exists, a final line of defense is required.

For write operations with a naturally unique number (order number, payment transaction number), a database unique index is the strongest backstop:

ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);
try {
    jdbcTemplate.update("INSERT INTO t_order(order_no, ...) VALUES (?, ...)", orderNo, ...);
} catch (DuplicateKeyException e) {
    // unique constraint backstop: this is a duplicate request
    throw new BusinessException("Order already exists: " + orderNo);
}

Why is it the "strongest": the constraint is enforced by the storage engine at commit time, independent of application-layer timing, with no race window. The cost is that duplicate requests actually hit the DB and throw exceptions, so it fits as a backstop, not the only means—Redis blocks 99% at the front, the unique index catches the extreme 1%.

A complete order-creation flow therefore usually looks like this (simplified version from the load-test project):

public boolean createOrder(String orderNo, long ttlMillis) {
    boolean first = kv.setIfAbsent("order:" + orderNo, ttlMillis);  // entry atomic interception
    if (first) {
        // write to DB; in real projects this INSERT carries a unique index as final backstop
        created.computeIfAbsent(orderNo, k -> new AtomicInteger()).incrementAndGet();
    }
    return first;
}

Production result: calling the same order serially 5 times, only the 1st created=true; 50 concurrent requests hitting the same order yield only 1 actual DB write:

created=true count at 50 concurrency: 1   (expected 1)

6. Engineering Boundaries: These Problems Are Worth More Thought Than the Commands

6.1 Placeholder key expires before business finishes—can duplicates occur?

This window exists. Suppose TTL=10s, but order creation takes 12s due to downstream slowness. When a second request arrives at the 11th second, the key has already expired and the request may acquire the placeholder again. Responses should combine:

  1. TTL set significantly larger than the normal interface latency (e.g., several times the P99);
  2. True strong consistency relies on unique index/state conditional update as backstop;
  3. When "the same business key must be unique across its entire lifecycle," the idempotency key should not rely solely on short TTL but should use an idempotency record table (unique key = business number), where "insert success" determines first occurrence.

6.2 Idempotency record table: option when no Redis or strong persistence is required

CREATE TABLE t_idempotent (
  biz_key VARCHAR(64) PRIMARY KEY,
  status TINYINT,
  created_at DATETIME
);

INSERT IGNORE / INSERT ... ON DUPLICATE "affected row count is 1" becomes the determining result. It trades some performance for no dependency on cache, auditability, and controllable lifecycle. Under high concurrency, Redis can front-load to reduce pressure.

6.3 How to handle MQ duplicate consumption

Use "business unique key + unique constraint/idempotency table" at the consumer side, not rely on the message broker's "exactly once" (most MQ systems are at-least-once). ACK only after successful consumption; duplicate messages are blocked by the unique constraint at DB write time, catch and treat as success to avoid infinite retries.

6.4 Can distributed locks replace the above?

No. A lock guarantees mutual exclusion of critical sections, but: ① locks have acquisition failure, timeout release, master-slave switch lost-lock issues; ② once the lock is released, late replay requests can still succeed. Locks fit protecting composite critical sections like "check inventory → deduct → create order," but final idempotency still requires unique constraint or state machine.

6.5 State machine: idempotency for lifecycle-bearing business

For stateful transitions like payments or approvals, use conditional updates so that "only a legal preceding state can transition":

UPDATE t_payment SET status = 'PAID' WHERE id = ? AND status = 'INIT';

Affected row count of 0 means the state has already been advanced; duplicate callbacks are ignored. Third-party payment async notifications do re-deliver, and this is what handles them.

7. Scheme Selection Overview

Scheme Consistency Strength Cost Suitable Scenario Main Boundaries
Database unique index Strongest (backstop) Very low Writes with unique business number Intercepts only at DB layer; exception cost
Redis SETNX placeholder Strong (subject to TTL/failover) Low High-frequency interface entry duplicate prevention Needs TTL; extreme cases need backstop
Lua sliding window Strong, countable Medium N times within window, rate limit + idempotency Need correct Lua, manage window keys
Idempotency record table Strong, persistent Medium No cache/audit required, full lifecycle Extra DB write
Distributed lock Mutual exclusion Medium-high Composite critical sections Not idempotency; still needs backstop
State conditional update Strong Low Explicit state transitions Only state-machine-type business

Practical combination: annotation/AOP + Redis atomic placeholder for entry interception, unique index or state machine as resource-layer backstop; use Lua sliding window when rate-limiter semantics are needed; layer an idempotency record table for critical fund flows.

8. Runnable Source Code and Reproduction

The project is based on Spring Boot 3.2.5 / Java 17, zero external middleware (reproducing Redis atomic semantics with in-memory primitives). It is in the repository's Juejin zone:

mvn spring-boot:run

# Concurrency load test
curl "http://localhost:8082/benchmark?threads=100&limit=1&window=2000"
curl "http://localhost:8082/benchmark?threads=200&limit=5&window=2000"

# Business interface
curl -X POST "http://localhost:8082/orders/ORD001?ttl=60000"

When migrating to production, replace KvStore.setIfAbsent with stringRedisTemplate.opsForValue().setIfAbsent(...), replace the sliding window with the Lua script in this article, and add a database unique index. The algorithm and conclusions remain unchanged.

9. Summary

If this helped you, feel free to like and bookmark. You can also discuss in the comments how you design idempotency for fund-related interfaces in production.