Why Disabled Buttons Still Produce Duplicate Orders – Rebuilding Idempotency in Spring Boot

The article examines a common duplicate‑order bug caused by client retries after network timeouts, explains why @Transactional alone cannot guarantee idempotency, critiques simple Redis SETNX solutions, and presents a robust Spring Boot design that stores idempotency records together with order data in a single MySQL transaction, uses request fingerprints, unique keys, and owner tokens to safely replay or reject retries while handling concurrency and cleanup.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Why Disabled Buttons Still Produce Duplicate Orders – Rebuilding Idempotency in Spring Boot

When a user submits an order, a network timeout can cause the client to retry the request, leading to two identical orders even though the front‑end button is disabled. The author first suspects the front‑end, disables the button with a submitting flag, and verifies locally that the button cannot be clicked twice, yet duplicate orders still appear.

Log analysis reveals many duplicate requests that are unrelated to button clicks. The typical flow is:

client request → order created → response sent → network timeout → client receives no response → client retries → server treats retry as a new request → second order created

From the server’s perspective, the second request is a perfectly valid new HTTP request, so @Transactional cannot prevent the duplicate because it only guarantees atomicity within a single request, not across retries.

Why Simple Redis SETNX Is Insufficient

Many projects use SETNX with an Idempotency-Key header:

Idempotency-Key: 8fa7c291-7f3e-46cf-a425-9e7cb75d7370

The first request succeeds, stores the key, and the second request is blocked. However, this approach has several problems:

If the first request has not finished when the second arrives, the client receives a generic “duplicate submission” error even though the order is still processing.

If the JVM crashes after the SETNX succeeds but before the Redis key is cleared, subsequent retries will be rejected or will create a new order.

Redis and MySQL are separate systems; there is always a window where the order is committed but the idempotency flag is not, leading to inconsistency.

Changing request parameters while reusing the same key is not detected.

A More Reliable Idempotency Design

The author proposes storing the idempotency record in the same MySQL transaction as the business data. The api_idempotency table contains:

id BIGINT AUTO_INCREMENT,
scope VARCHAR(160) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
owner_token VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL,
http_status INT NULL,
content_type VARCHAR(100) NULL,
response_body MEDIUMTEXT NULL,
created_at DATETIME(6) NOT NULL,
expires_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_scope_key (scope, idempotency_key),
KEY idx_expires_at (expires_at)

Key fields:

scope – usually HTTP_METHOD + ':' + PATH + ':' + USER_ID (e.g., POST:/orders:user:1001) to avoid key collisions across endpoints.

request_hash – SHA‑256 of the request payload and scope, ensuring that the same key cannot be reused with different data.

owner_token – a UUID generated for each request; it identifies which request created the record.

status – PROCESSING or COMPLETED.

The core service method is annotated with @Transactional and performs the following steps:

Compute scope and request_hash.

Generate owner_token.

Insert the idempotency record with INSERT … ON DUPLICATE KEY UPDATE id=id (a no‑op) to claim the key.

Read back the record. If the stored request_hash differs, throw IdempotencyKeyReusedException (HTTP 422). If the owner_token differs but status is COMPLETED, return the previously stored response (replay). If the status is PROCESSING, throw RequestInProgressException (HTTP 409).

If the claim belongs to the current request, insert the order, build the response, and call complete() to update the idempotency row with status='COMPLETED', HTTP status, content type, and the JSON body.

Because the idempotency insert, order insert, and response storage are all part of the same transaction, a failure at any point rolls back both the order and the idempotency record, allowing a later retry to start fresh.

Handling Concurrency

When many identical requests arrive simultaneously (e.g., 20 parallel curl calls with the same key), the unique constraint on (scope, idempotency_key) ensures that only one transaction can insert the record. The others block on the lock, then either see COMPLETED and replay the stored response, or see PROCESSING and wait for the first transaction to finish. If the first transaction rolls back, the waiting requests can claim the key and retry.

When Not to Use This Approach

For long‑running operations (30 seconds to several minutes), holding the database lock is undesirable. In such cases, an asynchronous job pattern (POST returns a job ID, later GET the status) or a message‑queue‑driven workflow is preferable.

Also, if the business flow spans multiple services (MySQL + Kafka + external payment), the idempotency table only protects the entry service; downstream consumers still need their own deduplication (unique business keys, outbox pattern, etc.).

Cleanup

Idempotency keys must expire; the table uses an expires_at column (default 24 hours) and a scheduled job deletes old rows in small batches to avoid large table scans:

DELETE FROM api_idempotency WHERE expires_at < NOW(6) LIMIT 5000;

The job runs every minute via @Scheduled(fixedDelay = 60000).

Final Thoughts

The author argues that the term “防重复提交” (prevent duplicate submission) is misleading because the real challenge is handling retries, not just disabling the button. A robust idempotency implementation must answer questions about client‑side retries, changed payloads, concurrent requests, and partial failures. By persisting idempotency state together with business data in a single transaction and using request fingerprints, the solution satisfies these requirements without relying on external locks or Redis.

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.

javaconcurrencyspring-bootmysqlapi-designidempotencytransactional
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.