Beyond Distributed Locks: Three-Layer Idempotency for Spring Boot Order APIs

The article demonstrates why distributed locks alone fail to guarantee idempotency in Spring Boot order APIs, and presents a three-layer solution combining Idempotency-Key with request fingerprinting, Redis SET NX for fast duplicate interception, and a database unique constraint as the ultimate safeguard against duplicate orders even after crashes or Redis failures.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Beyond Distributed Locks: Three-Layer Idempotency for Spring Boot Order APIs

Problem: Duplicate Orders from Retries

While troubleshooting an order issue, the author found two orders created for the same user and product within 300 milliseconds. The root cause: the first request reached the server but responded slowly, so the client retried. Similar duplicates can arise from network jitter, client timeouts, gateway retries, or duplicate message delivery.

Why a Simple Redis Lock Is Not Enough

The typical pattern uses

redisTemplate.opsForValue().setIfAbsent("order:" + userId, "1", Duration.ofSeconds(5))

to acquire a lock, then calls createOrder() and deletes the lock in a finally block. This only prevents simultaneous requests. It fails when:

The first request successfully inserts the order ( INSERT INTO t_order ... succeeds).

The process crashes before returning the response.

The lock expires and is released.

The client retries with identical parameters, acquires the lock again, and calls createOrder() a second time.

Result: a duplicate order is created.

Redesign: Three-Layer Idempotency

The author separates concerns into three layers:

Idempotency-Key
↓
Redis fast duplicate interception
↓
Database unique constraint as final safeguard
↓
Save first execution result
↓
Duplicate requests return first result

After the change, even ten simultaneous calls create only one order.

Step 1: Add Idempotency-Key Header

Original endpoint:

@PostMapping("/api/orders")
public CreateOrderResponse create(@RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

The server cannot distinguish a genuine second order from a retry. The fix: require clients to send an Idempotency-Key header (e.g., Idempotency-Key: 73a3dcad-dbae-457d-b8ab-cfb7e59384fd) that uniquely identifies a business operation. Retries must reuse the same key; a new order gets a new key. This mirrors Stripe's API design.

Step 2: Request Fingerprint to Detect Parameter Changes

If a client reuses the same Idempotency-Key but changes the request body (e.g., different skuId or quantity), the server must reject it. The author adds a SHA-256 fingerprint of the serialized request body:

@Component
public class RequestFingerprint {
    private final ObjectMapper objectMapper;
    public RequestFingerprint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    public String generate(Object request) {
        try {
            byte[] json = objectMapper.writeValueAsBytes(request);
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(json);
            return HexFormat.of().formatHex(hash);
        } catch (Exception e) {
            throw new IllegalStateException("Generate request fingerprint failed", e);
        }
    }
}

Now a request is identified by Idempotency-Key + Request Fingerprint. Same key + same fingerprint = retry; same key + different fingerprint = error.

Step 3: Redis for Fast Interception (Not Business Locking)

Two Redis keys are used: idem:lock:create-order:{idempotencyKey} – indicates a request is in progress. idem:result:create-order:{idempotencyKey} – stores the completed result.

Dependencies: spring-boot-starter-data-redis. Configuration example:

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      timeout: 2s

The core IdempotencyExecutor implements the flow:

Validate key (non-null, non-blank, max 128 chars).

Generate request fingerprint.

Check resultKey in Redis; if present and fingerprint matches, return cached result immediately.

Attempt to acquire lock with SET NX EX 30s (value = fingerprint:ownerToken).

If lock acquisition fails, inspect existing lock value:

If lock missing → throw RequestProcessingException (retry).

If fingerprint differs → throw IdempotencyConflictException.

Otherwise → throw RequestProcessingException (request in progress).

After acquiring lock, double-check resultKey again (another request may have finished between check and lock).

Execute the business action ( Supplier<T> action).

Save result to resultKey with 24-hour TTL.

Release lock via Lua script that only deletes if the value still matches fingerprint:ownerToken, preventing accidental deletion of a newer lock.

Key implementation details: LOCK_TTL = 30s, RESULT_TTL = 24h.

Lock value combines fingerprint and a unique owner token (UUID).

Unlock Lua script:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

.

This follows Redis's recommended SET ... NX PX pattern for single-instance locks.

Step 4: Database Unique Constraint as Ultimate Safeguard

Redis can fail or lose data. If the database commit succeeds but Redis fails to save the result, a retry would see no lock and no result, and would re-execute the business logic. To prevent this, the order table adds a unique index on request_id (which stores the Idempotency-Key):

CREATE TABLE t_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(40) NOT NULL,
    request_id VARCHAR(128) NOT NULL,
    user_id BIGINT NOT NULL,
    sku_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    amount DECIMAL(12,2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_order_no (order_no),
    UNIQUE KEY uk_request_id (request_id)
);

The OrderRepository uses JdbcTemplate to insert and catch DuplicateKeyException. On duplicate, it queries the existing order by request_id and returns it.

@Repository
public class OrderRepository {
    private final JdbcTemplate jdbcTemplate;
    public OrderRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    public Long insert(String requestId, String orderNo, CreateOrderRequest request, BigDecimal amount) {
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(
                """INSERT INTO t_order
(order_no, request_id, user_id, sku_id, quantity, amount, status)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
                Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, orderNo);
            ps.setString(2, requestId);
            ps.setLong(3, request.userId());
            ps.setLong(4, request.skuId());
            ps.setInt(5, request.quantity());
            ps.setBigDecimal(6, amount);
            ps.setString(7, "CREATED");
            return ps;
        }, keyHolder);
        Number key = keyHolder.getKey();
        if (key == null) throw new IllegalStateException("Create order failed");
        return key.longValue();
    }
    public Order findByRequestId(String requestId) {
        return jdbcTemplate.queryForObject(
            """SELECT id, order_no, request_id, user_id, sku_id, quantity, amount, status, created_at
FROM t_order WHERE request_id = ?""",
            (rs, rowNum) -> new Order(
                rs.getLong("id"),
                rs.getString("order_no"),
                rs.getString("request_id"),
                rs.getLong("user_id"),
                rs.getLong("sku_id"),
                rs.getInt("quantity"),
                rs.getBigDecimal("amount"),
                rs.getString("status"),
                rs.getTimestamp("created_at").toLocalDateTime()
            ),
            requestId
        );
    }
}

Service Layer: Handling DuplicateKeyException

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
    @Transactional
    public CreateOrderResponse create(String requestId, CreateOrderRequest request) {
        try {
            String orderNo = generateOrderNo();
            BigDecimal amount = calculateAmount(request);
            Long orderId = orderRepository.insert(requestId, orderNo, request, amount);
            return new CreateOrderResponse(orderId, orderNo, "CREATED");
        } catch (DuplicateKeyException e) {
            Order existing = orderRepository.findByRequestId(requestId);
            return new CreateOrderResponse(existing.id(), existing.orderNo(), existing.status());
        }
    }
    private String generateOrderNo() {
        return "O"
            + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"))
            + ThreadLocalRandom.current().nextInt(1000, 9999);
    }
    private BigDecimal calculateAmount(CreateOrderRequest request) {
        return new BigDecimal("99.00").multiply(BigDecimal.valueOf(request.quantity()));
    }
}

Controller Wiring

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;
    private final IdempotencyExecutor idempotencyExecutor;
    public OrderController(OrderService orderService, IdempotencyExecutor idempotencyExecutor) {
        this.orderService = orderService;
        this.idempotencyExecutor = idempotencyExecutor;
    }
    @PostMapping
    public CreateOrderResponse create(
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody CreateOrderRequest request) {
        return idempotencyExecutor.execute(
                "create-order",
                idempotencyKey,
                request,
                () -> orderService.create(idempotencyKey, request),
                CreateOrderResponse.class
        );
    }
}

Request/Response DTOs

public record CreateOrderRequest(Long userId, Long skuId, Integer quantity) {}
public record CreateOrderResponse(Long orderId, String orderNo, String status) {}

Execution Flows

First Request

Idempotency-Key = A001
↓
Redis result not found
↓
Redis SET NX succeeds
↓
INSERT t_order (request_id = A001)
↓
Database commits
↓
Redis saves first response
↓
Return orderId = 10001

Late Retry (Result Cached)

Idempotency-Key = A001
↓
Redis finds result
↓
Fingerprint matches
↓
Skip Service execution
↓
Return orderId = 10001

Near-Simultaneous Requests

Request A          Request B
SET NX succeeds    SET NX fails
↓                  ↓
Create order       Return "processing"
↓
Save result

Worst Case: Crash After DB Commit, Before Redis Save

INSERT succeeds
↓
Database committed
↓
Application crashes (kill -9)
↓
Redis never saved result
Client retries:
Redis no result
↓
Re-execute Service
↓
INSERT request_id = A001
↓
Database unique index conflict
↓
SELECT WHERE request_id = A001
↓
Find first order
↓
Return original orderId
↓
Re-write Redis result

This final layer is the true guarantee of idempotency.

Handling "In-Progress" Requests: Fail Fast, Don't Block

When a duplicate request arrives while the first is still executing, the author chooses to return a 409 Conflict immediately rather than making the caller wait. A global exception handler maps RequestProcessingException to HTTP 409 with code 40901 and message "请求正在处理中,请稍后重试" (Request being processed, please retry later), and IdempotencyConflictException to 409 with code 40902 and message "Idempotency-Key 已被其他请求使用" (Idempotency-Key used by another request). The client retries with the same Idempotency-Key after a short delay. This avoids tying up server threads (Tomcat or virtual threads) while the first request runs for seconds.

Validation Tests

Concurrent 20 requests with same key → database count for that request_id = 1.

Repeat same key, same params → returns original orderId and orderNo.

Same key, different params → returns 40902 error.

Simulate crash after DB commit, before Redis save (kill -9) → restart, retry → database unique index blocks second insert, returns first order, repopulates Redis.

Why the Old "Anti-Duplicate" Code Was Flawed

Previous pattern: setIfAbsent("submit:" + userId + ":" + skuId, "1", 3s). This blocks any similar request within 3 seconds, but:

It conflates "same business operation" with "similar parameters within a time window".

A user may legitimately buy the same SKU twice within 3 seconds. Idempotency-Key cleanly separates distinct business operations (A001, A002) even if all parameters match.

Lock vs. Idempotency

Locks solve concurrency mutual exclusion .

Idempotency solves same business operation executed multiple times → same effect as once .

They address different problems; sometimes both are needed, but they are not interchangeable.

Final Three-Layer Stack

Layer 1: Idempotency-Key + fingerprint → identify "same request"
Layer 2: Redis SET NX → intercept simultaneous duplicates
Layer 3: MySQL UNIQUE(request_id) → guarantee no duplicate data
Post-execution: Cache result in Redis (idem:result:create-order:A001)
Subsequent retries return cached result without re-executing logic.

Applicability Beyond Orders

The same pattern applies to coupon issuance, payment creation, refund submission, task creation, report generation, webhook callbacks, message consumption, and third-party callbacks — any operation where duplicate execution causes side effects.

For external payment systems (Alipay, WeChat Pay), pass the business request ID downstream so the provider can also enforce idempotency, or combine with local transactional outbox patterns.

Key Questions to Ask Before Claiming Idempotency

What if service crashes after DB commit?
What if Redis loses data?
What if first response is lost?
What if retry comes a minute later?
What if same Key arrives with different parameters?

If any of these can still create a second record, you only solved concurrent duplicate submission, not true business idempotency.

Recommended Spring Boot Combo

Idempotency-Key + request fingerprint
+ Redis SET NX
+ Result caching
+ Database unique constraint

No complex middleware required, but far more robust than a single setIfAbsent(). The real challenge in production duplicate-order, duplicate-coupon, duplicate-task issues is not "two threads at once" — it's "the first attempt succeeded, but the caller didn't know it." That is what true interface idempotency must solve.

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.

RedisSpring BootMySQLAPI DesignIdempotencyOrder ServiceDistributed LocksIdempotency-Key
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

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.