Backend Development 20 min read

High‑Concurrency Seckill Implementation in SpringBoot: Locking Strategies and Performance Testing

This article demonstrates how to simulate a high‑concurrency flash‑sale scenario using SpringBoot, MySQL and JMeter, analyzes why naive lock‑and‑transaction code causes overselling, and presents six refined solutions—including controller‑level locking, AOP locking, pessimistic and optimistic database locks, and queue‑based approaches—along with performance test results.

Code Ape Tech Column
Code Ape Tech Column
Code Ape Tech Column
High‑Concurrency Seckill Implementation in SpringBoot: Locking Strategies and Performance Testing

High concurrency is common in internet companies; this article uses a product flash‑sale (seckill) to simulate such a scenario and explores why simple lock‑and‑transaction implementations can lead to overselling.

Environment: SpringBoot 2.5.7, MySQL 8.0, MybatisPlus, Swagger2.9.2; Simulation tool: JMeter; Scenario: reduce inventory → create order → simulate payment.

1. Initial Implementation – Lock in Service

The service method is annotated with @Transactional and uses a ReentrantLock . Although the lock is released after the method finishes, the transaction may not have been committed yet, causing the lock to be released too early and resulting in overselling when 1,000 concurrent requests try to buy 100 items.

Test results (images) show the overselling problem.

2. Solving Overselling – Adjust Lock Timing

The lock must be acquired before the transaction starts. Two main ways are proposed:

Acquire the lock in the controller layer.

Use AOP to lock before the service method execution.

2.1 Method 1 – Controller‑Level Locking

@ApiOperation(value="秒杀实现方式——Lock加锁")
@PostMapping("/start/lock")
public Result startLock(long skgId) {
    // lock here
    lock.lock();
    try {
        log.info("开始秒杀方式一...");
        long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000;
        Result result = secondKillService.startSecondKillByLock(skgId, userId);
        if (result != null) {
            log.info("用户:{}--{}", userId, result.get("msg"));
        } else {
            log.info("用户:{}--{}", userId, "哎呦喂,人也太多了,请稍后!");
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        // release lock here
        lock.unlock();
    }
    return Result.ok();
}

This placement ensures the lock is held for the whole transaction, eliminating overselling.

2.2 Method 2 – AOP Locking

A custom annotation @ServiceLock and an aspect LockAspect are defined. The aspect acquires the lock before the annotated service method runs and releases it afterwards.

@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ServiceLock {
    String description() default "";
}
@Slf4j
@Component
@Scope
@Aspect
@Order(1)
public class LockAspect {
    private static Lock lock = new ReentrantLock(true);

    @Pointcut("@annotation(com.scorpios.secondkill.aop.ServiceLock)")
    public void lockAspect() {}

    @Around("lockAspect()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        lock.lock();
        try {
            return joinPoint.proceed();
        } finally {
            lock.unlock();
        }
    }
}

The service method is annotated with @ServiceLock and still uses @Transactional . This approach keeps the locking logic separate and more elegant.

2.3 Method 3 – Pessimistic Lock (FOR UPDATE)

Using a SELECT … FOR UPDATE inside a transaction acquires a row‑level lock until the transaction commits.

@Select("SELECT * FROM seckill WHERE seckill_id=#{skgId} FOR UPDATE")
SecondKill querySecondKillForUpdate(@Param("skgId") Long skgId);

The service method then updates the inventory safely within the same transaction.

2.4 Method 4 – Pessimistic Lock via UPDATE

An UPDATE statement that decrements the inventory only when the current quantity is greater than zero acts as a lock.

@Update("UPDATE seckill SET number=number-1 WHERE seckill_id=#{skgId} AND number>0")
int updateSecondKillById(@Param("skgId") long skgId);

2.5 Method 5 – Optimistic Lock

A version column is used; the UPDATE checks the version and increments it atomically. If the version has changed, the update fails, preventing overselling but causing many update‑exception failures.

@Update("UPDATE seckill SET number=number-#{number}, version=version+1 WHERE seckill_id=#{skgId} AND version=#{version}")
int updateSecondKillByVersion(@Param("number") int number, @Param("skgId") long skgId, @Param("version") int version);

2.6 Method 6 – Blocking Queue

A singleton SecondKillQueue (a LinkedBlockingQueue ) stores incoming requests. A consumer thread continuously polls the queue and processes each request via the service layer.

public class SecondKillQueue {
    static final int QUEUE_MAX_SIZE = 100;
    static BlockingQueue
blockingQueue = new LinkedBlockingQueue<>(QUEUE_MAX_SIZE);
    // singleton holder pattern omitted for brevity
    public Boolean produce(SuccessKilled kill) { return blockingQueue.offer(kill); }
    public SuccessKilled consume() throws InterruptedException { return blockingQueue.take(); }
    public int size() { return blockingQueue.size(); }
}

The controller simply creates a SuccessKilled object and enqueues it. The consumer thread calls secondKillService.startSecondKillByAop(...) for each dequeued item.

2.7 Method 7 – Disruptor Queue

Disruptor provides a high‑performance ring buffer. An EventFactory creates SecondKillEvent objects, an EventTranslator fills them, and an EventHandler processes them similarly to the blocking‑queue approach.

public class SecondKillEvent {
    private long seckillId;
    private long userId;
    // getters & setters omitted
}

public class SecondKillEventProducer {
    private final RingBuffer
ringBuffer;
    public void secondKill(long seckillId, long userId) {
        ringBuffer.publishEvent((event, seq, args) -> {
            event.setSeckillId((Long) args[0]);
            event.setUserId((Long) args[1]);
        }, seckillId, userId);
    }
}

Testing shows Disruptor improves throughput but still suffers from occasional overselling due to the gap between enqueue and dequeue.

3. Summary

Methods 1 and 2 solve overselling by moving the lock before the transaction starts.

Methods 3‑5 rely on database‑level locks; pessimistic locks (3,4) are reliable, while optimistic lock (5) performs poorly.

Methods 6‑7 use queues; they avoid lock contention but can cause under‑selling because of the time gap between enqueue and processing, and any uncaught exception will terminate the consumer thread.

Choosing the appropriate strategy depends on the specific performance and consistency requirements of the application.

AOPhigh concurrencyJMeterSpringBootLockQueueseckilldatabase lock
Code Ape Tech Column
Written by

Code Ape Tech Column

Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn

0 followers
Reader feedback

How this landed with the community

login 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.