Idempotency vs Duplicate Orders: 5 Reliable Solutions After a Double‑Charge Mishap

The article explains that idempotency prevents the same operation from being executed twice, illustrates a real double‑charge bug, evaluates five concrete approaches—including Redis check‑then‑set, DB unique index, optimistic lock, Redis distributed lock, and a message deduplication table—details their failure conditions and suitable scenarios, and recommends combining Redis lock with a database unique index for the most robust protection.

Coder Life Journal
Coder Life Journal
Coder Life Journal
Idempotency vs Duplicate Orders: 5 Reliable Solutions After a Double‑Charge Mishap

Problem

Check‑then‑set is not an atomic operation, so two concurrent requests can both see the key as absent, execute the business logic, and cause duplicate execution.

Solution 1: Check‑then‑Set in Redis (anti‑example)

// From an online tutorial
public boolean pay(String orderId, BigDecimal amount) {
    String key = "idempotent:" + orderId;
    if (redisTemplate.hasKey(key)) {
        return false;
    }
    accountService.deduct(orderId, amount);
    orderService.create(orderId, amount);
    redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
    return true;
}

Failure condition: Two requests can both see the key as absent between the check and the set, leading to duplicate deductions. The higher the concurrency, the larger the window for collision.

Conclusion: Do not use in production; it provides little protection compared to a proper idempotent design.

Solution 2: Database Unique Index

CREATE TABLE idempotent_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_id VARCHAR(64) NOT NULL,
    status TINYINT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_biz_id (biz_id)
);
@Transactional
public void pay(String orderId, BigDecimal amount) {
    try {
        idempotentMapper.insert(orderId); // INSERT idempotent record
    } catch (DuplicateKeyException e) {
        return; // duplicate request, skip
    }
    accountService.deduct(orderId, amount);
    orderService.create(orderId, amount);
    idempotentMapper.updateStatus(orderId, 1); // mark as processed
}

Why reliable: The INSERT operation is atomic and guaranteed by the database; concurrent INSERTs on the same unique key cause one to fail.

Limitations: Works only when the entire business operation can be wrapped in a database transaction. If external calls (e.g., third‑party payment) are involved, a successful INSERT may need compensating logic.

Applicable scenarios: Order payment, balance deduction, coupon redemption—operations whose data reside in your own database.

Solution 3: State Machine + Optimistic Lock

@Transactional
public void confirmOrder(String orderId) {
    // WHERE status = 0 ensures only the first request updates
    int rows = orderMapper.updateStatus(orderId, 0, 1);
    if (rows == 0) {
        return; // already processed
    }
    couponService.grant(orderId);
    pointsService.add(orderId);
}
UPDATE t_order SET status = 1 WHERE id = ? AND status = 0;

Difference from Solution 2: Solution 2 uses a dedicated idempotent table; Solution 3 reuses an existing status column, avoiding an extra table when the business naturally has state transitions.

Failure condition: Not suitable for operations that do not change a status (e.g., pure logging). If the status can revert (e.g., refund → pending), the optimistic‑lock WHERE clause must be redesigned.

Applicable scenarios: Order status flow, approval processes—any workflow that moves from state A to state B.

Solution 4: Redis Distributed Lock

public void pay(String orderId, BigDecimal amount) {
    String lockKey = "lock:pay:" + orderId;
    RLock lock = redissonClient.getLock(lockKey);
    try {
        // wait up to 3 seconds, lock expires after 30 seconds
        if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) {
            throw new RuntimeException("System busy");
        }
        // business logic
        accountService.deduct(orderId, amount);
        orderService.create(orderId, amount);
    } finally {
        lock.unlock();
    }
}

Why use Redisson instead of a manual SET NX: Redisson’s tryLock provides a watchdog that automatically renews the lock before expiration, avoiding the window where a 31‑second operation would lose a 30‑second lock.

Limitations: Redis is an AP system; during master‑slave failover the lock may be lost. For scenarios demanding strong consistency, consider RedLock or a database‑based lock.

Applicable scenarios: Cross‑service idempotency, operations that do not involve the database (e.g., third‑party API calls), or cases where a database unique index is inconvenient.

Solution 5: Message Deduplication Table

@Transactional
public void onMessage(OrderMessage msg) {
    try {
        msgDedupMapper.insert(msg.getMessageId()); // message ID as unique key
    } catch (DuplicateKeyException e) {
        return; // already consumed
    }
    orderService.create(msg.getOrderId(), msg.getAmount());
    msgDedupMapper.updateStatus(msg.getMessageId(), 1);
}

Difference from Solution 2: Solution 2 protects against duplicate business requests; Solution 5 protects against duplicate message deliveries. If the message already carries the business ID, the two tables can be merged, but in practice they are kept separate because their cleanup strategies differ.

Applicable scenarios: RocketMQ/Kafka consumers, scheduled‑task deduplication.

Choosing the Right Solution

If the whole operation fits inside a database transaction → use Solution 2 (unique index).

If the business already has a status field → use Solution 3 (optimistic lock).

If external services are involved → combine Solution 4 (Redis lock) with Solution 2 as a fallback.

For MQ or timed‑task deduplication → use Solution 5.

Avoid Solution 1 in any production scenario; it behaves like having no idempotency at all.

Most robust combination: Redis lock (Solution 4) to block most concurrent attempts, plus database unique index (Solution 2) as a safety net when the lock fails. The probability of both mechanisms failing is negligible.

Why “Check‑then‑Set” Is Popular

The pattern looks simple and works under low‑concurrency or insufficient load testing, so many tutorials adopt it. In a stress test of 2000 QPS for five minutes, duplicate deductions were observed because two requests simultaneously passed the “check” step. The author experienced the same mistake, discovered the flaw after the stress test, and then investigated transaction isolation levels and Redis lock documentation.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

databaseRedisdistributed lockoptimistic lockidempotencymessage deduplication
Coder Life Journal
Written by

Coder Life Journal

An ordinary programmer sharing tech and life.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.