Spring Boot Distributed Locking with Redis and Lua: Prevent Overselling, Duplicate Orders, and Idempotency Failures

This article focuses on concurrency control in Spring Boot clusters. The core approach is to build a production-ready distributed lock with Redis and Lua to solve local lock failure, inventory overselling, duplicate orders, and API idempotency issues. Keywords: Spring Boot, Redis, Distributed Locking.

Technical Specifications at a Glance

Parameter Description
Language Java
Framework Spring Boot
Protocol / Mechanism Redis SET + EX, Lua atomic scripts
Applicable Scenarios Flash sales, inventory deduction, order creation, idempotency control
Star Count Not provided in the original article
Core Dependencies spring-data-redis, StringRedisTemplate, Lombok

Single-Node Locks Always Fail in Clustered Deployments

synchronized and ReentrantLock only work inside the current JVM process. In a monolithic deployment, all requests hit the same instance, so these locks are enough to guarantee thread mutual exclusion.

Once the service moves behind load balancing and multiple instances, different nodes cannot see each other’s local locks. As a result, node A may already be inside the critical section while node B still deducts inventory or creates an order at the same time. That is exactly how overselling and duplicate orders happen.

The Standard for a Distributed Lock Is Not “Can It Lock,” but “Is It Globally Verifiable”

A production-grade distributed lock must satisfy at least five requirements: global mutual exclusion, deadlock prevention, protection against accidental deletion, atomic operations, and sufficient scalability. If any one of these is missing, high concurrency can turn the issue into an inventory or financial incident.

// A single-node lock only constrains threads inside the current JVM
synchronized (this) {
    // This only prevents local concurrency, not cluster-wide concurrency
    createOrder();
}

This code only controls thread contention on a single instance. It cannot solve cross-node concurrency consistency.

Incorrect Redis Lock Implementations Turn “Overselling” into “Deadlocks”

The first mistake is calling only set key without setting an expiration time. If the service exits unexpectedly, the lock remains forever and all subsequent requests are blocked.

The second mistake is calling set and then expire. These two operations execute separately, so any network jitter or process crash can leave the key without a TTL and create a hidden deadlock.

The third mistake is calling del key unconditionally. If an old thread runs too long, the lock may expire and be acquired again by a new thread. If the old thread then deletes the key, it accidentally removes another thread’s valid lock.

A Correct Solution Must Handle Both Atomic Lock Acquisition and Ownership Verification During Unlock

Redis is a good fit for distributed locking not just because it is fast, but because it supports atomic commands and Lua scripts. The core idea is simple: write a unique identifier when acquiring the lock, verify that identifier when releasing it, and only allow the lock owner to unlock it.

if redis.call('exists', KEYS[1]) == 0 then
    redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
    return 1
else
    return 0
end

This Lua script atomically performs “check nonexistence + write lock value + set expiration time.”

Redis and Lua Together Provide Production-Ready Lock Safety Semantics

A safe lock design has four core steps: use a consistent prefix for the lock key, store a thread-unique ID in the lock value, use Lua to guarantee atomic lock acquisition, and compare the value before deleting the key during unlock.

The value of Lua is that Redis does not allow other commands to interleave while it is executing a script. That makes it possible to package multiple commands into one indivisible transactional fragment and prevent concurrent requests from breaking through the critical section.

You Can Encapsulate a Lightweight Lock Utility in Spring Boot

The following example shows a condensed implementation that keeps the most important production logic for lock acquisition and release.

@Component
public class RedisDistributedLockUtil {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final String LOCK_PREFIX = "business:distributed:lock:";
    private static final long LOCK_EXPIRE_SECOND = 30;

    public boolean tryLock(String lockKey, String lockId) {
        String realKey = LOCK_PREFIX + lockKey;
        String scriptText = "if redis.call('exists',KEYS[1])==0 then " +
                "redis.call('set',KEYS[1],ARGV[1],'EX',ARGV[2]) return 1 else return 0 end";

        DefaultRedisScript
<Long> script = new DefaultRedisScript<>(scriptText, Long.class);
        Long result = stringRedisTemplate.execute(
                script,
                Collections.singletonList(realKey),
                lockId, String.valueOf(LOCK_EXPIRE_SECOND)
        );
        return result != null && result == 1;
    }

    public void releaseLock(String lockKey, String lockId) {
        String realKey = LOCK_PREFIX + lockKey;
        String scriptText = "if redis.call('get',KEYS[1])==ARGV[1] then " +
                "return redis.call('del',KEYS[1]) else return 0 end";

        DefaultRedisScript
<Long> script = new DefaultRedisScript<>(scriptText, Long.class);
        stringRedisTemplate.execute(script, Collections.singletonList(realKey), lockId);
    }
}

This code implements atomic lock acquisition, automatic expiration, and safe unlock based on a unique identifier.

Distributed Lock Integration at the Service Layer Should Use Fine-Grained Keys and finally for Release

You should not design the lock as a global constant key. Otherwise, different products, users, or orders will be serialized behind the same lock, and throughput will drop sharply. The correct approach is to build fine-grained locks by product ID, user ID, or order number.

At the same time, always release the lock in a finally block. Whether the business logic succeeds, fails, or throws an exception, the unlock logic must still run. Otherwise, blocking can amplify into a cascading failure during peak traffic.

@PostMapping("/seckill/create")
public R createSeckillOrder(@RequestParam Long goodsId) {
    String lockKey = "seckill:goods:" + goodsId;
    String lockId = UUID.randomUUID().toString().replace("-", ""); // Generate a unique lock identifier

    boolean locked = distributedLockUtil.tryLock(lockKey, lockId);
    if (!locked) {
        return R.fail("Too many users are trying to purchase right now. Please try again later.");
    }

    try {
        // Critical section: check inventory, prevent duplicates, deduct stock, create order
        boolean result = stockService.reduceStockAndCreateOrder(goodsId);
        return result ? R.success("Order placed successfully") : R.fail("Insufficient inventory");
    } finally {
        distributedLockUtil.releaseLock(lockKey, lockId); // Only release the current lock
    }
}

This integration example shows the most common distributed locking pattern in a flash-sale scenario.

Most Production Incidents Cluster Around Timeout, Accidental Deletion, and Lock Granularity

The first type of incident happens when the lock expiration time is shorter than the business execution time. The business logic has not finished yet, but the lock is already released automatically. Once a new request enters, it executes the critical section again and inventory may be deducted multiple times.

The second type of incident happens when an exception path does not release the lock. On the surface, this looks like a coding habit issue, but the root cause is usually a lack of unified encapsulation and fallback safeguards.

Long-Running Transactions Need Lease Renewal Instead of Blindly Extending TTL

Blindly increasing the TTL does not really solve the problem. If the TTL is too large, abnormal deadlocks affect a wider scope. If the TTL is too small, long-running tasks still lose the lock too early. A more reasonable solution is to add a renewal mechanism for long transactions or use a mature framework with a watchdog.

If your business handles standard flash sales, inventory deduction, or API idempotency, a native Redis lock is usually sufficient. If you need reentrancy, automatic lease renewal, primary-replica failover, and more advanced governance, Redisson is a better fit for production.

FAQ

Why can’t a distributed lock be replaced directly with synchronized?

Because synchronized only constrains threads inside a single JVM and cannot see other service instances. In a clustered environment, it has no cross-node mutual exclusion capability.

Why must unlock verify the UUID?

Because the lock may expire during business execution and be acquired again by another thread. If the old thread deletes the key directly, it can remove a valid lock owned by the new thread.

Where are the boundaries of a Redis distributed lock?

It is suitable for high-concurrency, low-latency mutual exclusion scenarios such as flash sales, inventory control, and idempotency enforcement. If the business requires stronger consistency or more advanced renewal behavior, consider Redisson or ZooKeeper.

Core Summary

This article systematically reconstructs a Spring Boot distributed locking solution. It explains why single-node locks fail in clustered environments and provides a production-grade implementation based on Redis and Lua. The article covers atomic lock acquisition, safe unlock, expiration safeguards, business integration, and common incident analysis. It is well suited for flash sales, inventory deduction, duplicate-order prevention, and API idempotency scenarios.