Redis Flash Sale One-User-One-Order in Practice: Fixing synchronized Lock Scope, Transaction Failure, and Spring AOP Proxy Issues

For the “one user, one order” requirement in a Redis flash sale system, this article breaks down the root causes of duplicate orders under high concurrency and presents a practical solution built on user-scoped synchronized locking, AOP-based transaction repair, and Redis connection pool enhancements. It addresses three core issues: concurrency bypass, transaction failure, and overly coarse lock granularity. Keywords: Redis, flash sale system, Spring transactions.

Technical Specification Snapshot

Parameter Description
Language Java
Core Frameworks Spring Boot / Spring AOP / Spring Transaction
Data Storage MySQL, Redis
Concurrency Control synchronized, conditional database update
Protocol / API Style HTTP + REST-style service calls
Core Dependencies spring-boot-starter-data-redis, spring-boot-starter-aop, commons-pool2
GitHub Stars Not provided in the original content

The one-user-one-order problem is fundamentally a failure of concurrent validation

Preventing overselling in a flash sale system does not automatically enforce per-user purchase limits. If the flow only does “check existing order, deduct stock, create order,” multiple threads can simultaneously observe that no order exists, all pass validation, and ultimately create multiple orders for the same user.

This is not a lost-update problem. It is a concurrency breach in the “validate before insert” step. Because of that, optimistic locking alone is not enough. You must add mutual exclusion at the user level so that only one request per user can enter the core ordering flow at a time.

The initial order placement logic expresses business rules but does not guarantee concurrency safety

public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();

    // Check whether the current user has already placed an order
    int count = query()
            .eq("user_id", userId) // Filter by user
            .eq("voucher_id", voucherId) // Filter by voucher
            .count();

    if (count > 0) {
        return Result.fail("The user has already purchased this voucher");
    }

    // Deduct stock only when stock is greater than 0
    boolean success = iSeckillVoucherService.update()
            .setSql("stock = stock - 1") // Atomically decrement stock
            .eq("voucher_id", voucherId)
            .gt("stock", 0)
            .update();

    if (!success) {
        return Result.fail("Insufficient stock");
    }
    return Result.ok();
}

This code correctly describes the business rule, but it cannot stop multiple threads from passing the “has the user already ordered” check at the same time.

Applying synchronized directly to the method serializes the entire system

Many implementations declare createVoucherOrder() as synchronized directly. That limits concurrency, but the lock object defaults to this. In Spring, @Service beans are usually singletons, so all user requests end up competing for the same lock.

As a result, you do not get “one lock per user.” You get “one lock for the entire service.” Business correctness improves, but throughput drops sharply, and the flash sale system degrades into a global queue.

A method-level lock is effectively a lock on the singleton Service instance

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
    // The lock object here is essentially this
    // Under a singleton Spring Service, all requests compete for the same lock
    return Result.ok();
}

This style is acceptable for validating the idea, but it is not a sustainable design for a high-concurrency flash sale system.

A better approach is to reduce lock granularity to the user level

The subject of the “one user, one order” constraint is the user, not the entire order service. Therefore, the lock should be tied to userId. With this approach, requests from the same user execute serially, while requests from different users can still run concurrently. That balances correctness and throughput.

There is one critical detail here: synchronized locks object identity, not value equality. userId.toString() may create a different String object each time. Without interning, two strings with the same content may still not map to the same lock.

Use userId.toString().intern() to bind the same user to the same lock object

public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    String lockKey = userId.toString().intern(); // Ensure the same userId maps to the same lock object

    synchronized (lockKey) {
        return createVoucherOrder(voucherId);
    }
}

This code narrows the lock scope from “the entire service instance” to “a single user,” which is a practical optimization within a single JVM.

intern is necessary because synchronized only recognizes the same object

Long objects are not always reused consistently. Once values exceed the cache range, identical numeric values may still correspond to different instances. The same principle applies to strings. If the new object returned by toString() is not interned into the string pool, lock identity cannot be guaranteed.

So the purpose of intern() is not string optimization. Its real purpose is to make identical user IDs reference the same String object, ensuring that requests from the same user truly compete for the same lock.

Lock semantics are completely different before and after intern

Long userId = 100L;
String s1 = userId.toString();
String s2 = userId.toString();
System.out.println(s1 == s2); // false, two different objects

String s3 = userId.toString().intern();
String s4 = userId.toString().intern();
System.out.println(s3 == s4); // true, the same pooled object

This example shows that identical content does not imply an identical lock. Only intern() can reliably establish user-level mutual exclusion.

The ordering of transactions and locks affects consistency

If you acquire the lock inside the transactional method, you may create a window where the lock is released before the transaction commits. During that interval, another thread can enter, fail to see the uncommitted order record, pass validation again, and still create duplicate orders for the same user.

The correct approach is to acquire the user-level lock in the outer layer first, then call the transactional method. That ensures the transaction runs entirely within the lock’s lifetime. Other threads must wait until the transaction commits before they can proceed and observe the latest order state.

The correct sequence is lock outside, transaction inside

public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();

    synchronized (userId.toString().intern()) {
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId); // Call through the proxy so the transaction can take effect
    }
}

@Transactional
public Result createVoucherOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();

    int count = query().eq("user_id", userId)
            .eq("voucher_id", voucherId)
            .count();
    if (count > 0) {
        return Result.fail("The user has already purchased this voucher");
    }

    boolean success = iSeckillVoucherService.update()
            .setSql("stock = stock - 1") // Deduct stock
            .eq("voucher_id", voucherId)
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("Insufficient stock");
    }

    VoucherOrder order = new VoucherOrder();
    order.setId(redisIdWorker.nextId("order")); // Generate a globally unique order ID
    order.setUserId(userId);
    order.setVoucherId(voucherId);
    save(order);
    return Result.ok(order.getId());
}

This implementation solves user-level mutual exclusion, stock deduction, and transaction visibility after commit at the same time.

Spring self-invocation causes transactions to fail

@Transactional only works when the target method is invoked through a Spring proxy. If you call this.createVoucherOrder() inside the same class, the call bypasses the proxy, and the transaction advice is never applied.

This is also the root cause behind many cases where “the transaction annotation is present but does not work.” There are two common fixes: inject the bean’s own proxy, or retrieve the current proxy with AopContext.currentProxy(). The original solution uses the second approach.

You must enable exposeProxy to retrieve the current proxy

@EnableAspectJAutoProxy(exposeProxy = true) // Expose the proxy object so AopContext can retrieve it
@SpringBootApplication
public class AppApplication {
    public static void main(String[] args) {
        SpringApplication.run(AppApplication.class, args);
    }
}

This configuration enables AopContext.currentProxy() and prevents transactions from failing because of self-invocation.

Redis connection pool dependencies improve performance but do not guarantee concurrency correctness

The article also mentions adding commons-pool2. Its value lies in providing object pooling for the Redis client, which reduces the cost of creating and destroying connections frequently. That can improve overall throughput, but it cannot replace business-level locking and transaction boundary design.

If you use Lettuce with connection pooling enabled, it usually depends on this component. It solves connection reuse, while the “one user, one order” design solves concurrency consistency. They operate at different layers, but both matter for a stable flash sale system.

A typical dependency and connection pool configuration looks like this


<dependency>

<groupId>org.apache.commons</groupId>

<artifactId>commons-pool2</artifactId>
</dependency>
spring:
  redis:
    host: localhost
    port: 6379
    lettuce:
      pool:
        max-active: 8   # Maximum number of active connections
        max-idle: 8     # Maximum number of idle connections
        min-idle: 0     # Minimum number of idle connections

This configuration improves Redis access efficiency, but it does not participate in order idempotency checks.

The implementation path in the diagrams shows the evolution from bug fixes to timing fixes

Flash sale system implementation flowchart AI Visual Insight: This diagram shows the core control plane of the flash sale ordering pipeline: first obtain user identity and apply the one-user-one-order check, then deduct stock and generate the order. The real technical focus is not the page flow itself, but the fact that the three steps—validation, deduction, and order creation—must be wrapped by the same concurrency control strategy. If any step is exposed to a race window, duplicate orders or visibility issues can still occur.

Spring AOP configuration diagram AI Visual Insight: This diagram corresponds to the Spring AOP proxy exposure configuration entry point and shows that transactional advice depends on the proxy invocation chain. Technically, it highlights why exposeProxy = true is necessary: only when the current proxy can be retrieved at runtime can an internal method call re-enter the AOP interceptor chain and trigger transaction begin, commit, and rollback behavior.

Production-grade hardening should include a database unique constraint

A single-node synchronized solution only works within one JVM. If the service scales horizontally to multiple instances, a JVM lock alone will no longer work because memory locks are not shared across instances. In that case, you should at least add a database unique index, and when necessary upgrade to a Redis distributed lock or a Lua-plus-message-queue architecture.

A recommended practice is to add a composite unique index on (user_id, voucher_id) in the order table. Even if the upper-layer concurrency control fails occasionally, the database can still serve as the final idempotency barrier and prevent dirty data from being written.

FAQ

Q1: Why can’t checking the order count alone guarantee one user, one order?

Because concurrent threads may all read count=0 at the same time and then proceed to create the order. There is a race window between the query and the insert, so you must add mutual exclusion or a unique constraint.

Q2: Why does this.createVoucherOrder() cause transactions to fail?

Because Spring transactions are woven in through the proxy. Self-invocation bypasses the proxy and directly calls the real method, so the transaction interceptor never runs.

Q3: Can synchronized(userId.toString().intern()) work in a distributed deployment?

No. It is only effective inside a single JVM. In a multi-instance deployment, you need a Redis distributed lock, a database unique index, or a more complete Lua + MQ asynchronous ordering solution.

Core summary: This article reconstructs the one-user-one-order implementation path for a flash sale system, focusing on the misuse of synchronized under high concurrency, lock granularity optimization with userId.intern(), transaction commit timing, Spring AOP self-invocation failure, and practical additions such as Redis connection pool dependencies and production-ready code patterns.