Solving Inventory Over-Selling with Locking Mechanisms and Distributed Locks

Understanding the Root Cause: Inventory Over-Selling

A classic inventory over-selling issue arises when multiple conucrrent requests decrement stock without proper synchronization. Consider a scenario where product inventory is stored in MySQL, and the stock reduction logic is implemented as follows:

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    public void checkAndLock() {
        // Step 1: Query current stock
        Stock stock = this.stockMapper.selectById(1L);

        // Step 2: Reduce stock if sufficient
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }
}

This pattern is inherently non-atomic—two operations separated by a gap make it vulnerable to race conditions under high concurrency.


1. JVM-Level Locking: Local Synchronizasion

Single Instance, Non-Distributed Environment

To ensure thread safety within a single JVM instance, you can apply Java-level locking mechanisms:

Using synchronized:

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    public synchronized void checkAndLock() {
        Stock stock = this.stockMapper.selectById(1L);
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }
}

The synchronized keyword ensures only one thread can execute this method at a time, preventing concurrent modifications. This works well for single-instance applications.

Using ReentrantLock:

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    private final ReentrantLock lock = new ReentrantLock();

    public void checkAndLock() {
        lock.lock();
        try {
            Stock stock = this.stockMapper.selectById(1L);
            if (stock != null && stock.getCount() > 0) {
                stock.setCount(stock.getCount() - 1);
                this.stockMapper.updateById(stock);
            }
        } finally {
            lock.unlock();
        }
    }
}

Both approaches work correctly in monolithic, single-node environments. However, they fail in distributed or multi-instance setups because each JVM instance maintains its own lock state—locks are not shared across instances.

With Transactional Context

Adding @Transactional does not resolve the problem. Consider this sequence:

User A User B
Begin transaction Begin transaction
Acquire lock
Read stock: 21
Update to 20
Release lock
Commit
Acquire lock
Read stock: 21
Update to 20
Release lock
Commit

Result: Two purchases, but only one stock deduction. The root cause is that the transaction isolation level allows dirty reads during the window between read and write.


2. Atomic Operation via Single SQL

A more robust solution is to perform the check-and-decrement operation atomically using a single UPDATE statement:

UPDATE db_stock 
SET count = count - 1 
WHERE product_code = '1001' AND count > 0;

This eliminates race conditions because the database handles both conditions in one atomic step. InnoDB locks the affected row(s), ensuring no other transaction can modify them concurrently.

Advantages:

  • Eliminates need for application-level locks
  • Works across multiple instances and clusters
  • High reliability under load

Limitations:

  • Only valid for products with a single global stock entry
  • Cannot track pre-update and post-update states
  • May escalate to table-level locks if product_code lacks an index

Even with indexing, full table scans can occur due to query plan issues. Thus, this approach is limited to simple scenarios.


3. MySQL Pessimistic Locking: SELECT ... FOR UPDATE

Use FOR UPDATE to acquire a row-level exclusive lock during the read phase:

SELECT * FROM tb_stock WHERE product_code = '1001' FOR UPDATE;

If product_code has an index, this applies a row lock; otherwise, it escalates to a table lock.

Implementation:

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    @Transactional
    public void checkAndLock() {
        Stock stock = this.stockMapper.selectStockForUpdate(1L);
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }
}

This ensures that subsequent requests block until the current transaction commits. It’s effective but introduces performance overhead.

Drawbacks:

  • Lower throughput compared to atomic UPDATE
  • Risk of deadlocks when acquiring locks in inconsistent order

Example deadlock scenario:

Transaction A Transaction B
BEGIN BEGIN
SELECT ... FOR UDPATE (ID=1)
SELECT ... FOR UPDATE (ID=2)
SELECT ... FOR UPDATE (ID=2) → Wait
SELECT ... FOR UPDATE (ID=1) → Deadlock

4. MySQL Optimistic Locking: Version-Based Control

Optimistic locking assumes conflicts are rare. It uses a version number field to detect stale updates.

Schema enhancement: Add a version column to the db_stock table.

Logic:

public void checkAndLock() {
    Stock stock = this.stockMapper.selectById(1L);
    if (stock == null || stock.getCount() <= 0) return;

    Long expectedVersion = stock.getVersion();
    stock.setCount(stock.getCount() - 1);
    stock.setVersion(expectedVersion + 1);

    int updatedRows = this.stockMapper.update(
        stock,
        new UpdateWrapper<Stock>()
            .eq("id", stock.getId())
            .eq("version", expectedVersion)
    );

    if (updatedRows == 0) {
        // Conflict detected — retry
        checkAndLock();
    }
}

Important Notes:

  • Do not use @Transactional on this method. Without explicit rollback control, failed retries keep holding database connections.
  • High contention leads to repeated retries, increasing CPU usage and potentially causing stack overflow.
  • In distributed databases (e.g., MySQL replication, MyCAT), version checks may fail due to replication lag.
  • Many middleware layers do not support pessimistic locks, making optimistic locking unreliable in read-write split architectures.

When to Use Each Approach:

Strategy Best For Performance Notes
Single SQL Simple global stock ⭐⭐⭐⭐⭐ Fastest, but inflexible
Pessimistic Locking (FOR UPDATE) High-write, low-concurrency ⭐⭐⭐⭐ Safe, but risks deadlocks
Optimistic Locking Read-heavy, low update frequency ⭐⭐⭐ High retry cost under contention
JVM Locking Not recommended ⭐⭐ Fails in distributed systems

In summary, prefer atomic SQL updates for simplicity and performance. Use pessimistic locking when writes are frequent and conflict risk is high. Reserve optimistic locking for low-contention, read-dominant workloads.

Tags: Inventory Management Locking Mechanisms database concurrency MySQL Distributed Systems

Posted on Sun, 27 Sep 2026 16:51:05 +0000 by Diceman