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
- Idempotency: The same operation executed once or multiple times has the same final effect on system state, i.e.,
f(f(x)) = f(x). - Duplicate Submission Prevention: A subset of idempotency, aimed at blocking duplicate requests caused by users/network within a short time window.
- Distributed Lock: A mutual exclusion mechanism that solves critical-section concurrency, but replay after lock release can still cause duplicates, so a lock is not a sufficient condition for idempotency.
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);
NX: write only when it does not exist, guaranteeing that only one request for the same business number succeeds in placeholder acquisition;PX/EX: must carry an expiration time to avoid the key persisting forever after a service crash, which would make retries impossible.
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:
- TTL set significantly larger than the normal interface latency (e.g., several times the P99);
- True strong consistency relies on unique index/state conditional update as backstop;
- 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:
- Gitee: https://gitee.com/Jackie51/weekly-tech-blog
- Directory:
juejin/idempotent-in-depth
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
- The core of idempotency is making "check + write" an atomic operation; any check-then-act can be broken under concurrency (this article's load test shows 100 concurrency letting through 100).
- SETNX solves "is it first," Lua sliding window solves "N times within window"; do not mix them.
- Redis entry interception is fast but not truth; resource-layer backstop with unique index/state machine is indispensable.
- Locks are mutual-exclusion tools, not a substitute for idempotency; MQ consumers use business key + unique constraint for idempotency.
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.