Preventing Overselling in High‑Concurrency Flash Sales: 7 Locking & Queue Strategies with SpringBoot

This article analyzes the overselling problem in high‑concurrency flash‑sale scenarios, demonstrates seven concrete solutions—including improved locks, AOP locks, pessimistic and optimistic database locks, and queue‑based approaches using BlockingQueue and Disruptor—provides full SpringBoot code, JMeter test results, and practical recommendations for reliable stock reduction.

IT Architects Alliance
IT Architects Alliance
IT Architects Alliance
Preventing Overselling in High‑Concurrency Flash Sales: 7 Locking & Queue Strategies with SpringBoot

High‑concurrency flash‑sale (秒杀) often leads to overselling when many users try to purchase limited stock simultaneously.

Environment

SpringBoot 2.5.7

MySQL 8.0

Mybatis‑Plus, Swagger 2.9.2

JMeter for load testing

Problem

A naive implementation that places a lock inside a @Transactional service releases the lock before the transaction commits, causing overselling under 1000 concurrent requests for 100 items.

Solution 1 – Lock in Controller

@ApiOperation(value="秒杀实现方式——Lock加锁")
@PostMapping("/start/lock")
public Result startLock(long skgId) {
    lock.lock(); // lock before transaction
    try {
        log.info("开始秒杀方式一...");
        long userId = (int)(new Random().nextDouble()* (99999-10000+1)) + 10000;
        Result result = secondKillService.startSecondKillByLock(skgId, userId);
        // log result
    } finally {
        lock.unlock(); // release after transaction
    }
    return Result.ok();
}

The lock persists until the transaction finishes. Tested with three load patterns (1000/100, 1000/1000, 2000/1000).

Solution 2 – AOP Locking

@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ServiceLock { String description() default ""; }

@Aspect
@Order(1)
@Component
public class LockAspect {
    private static final Lock lock = new ReentrantLock(true);
    @Pointcut("@annotation(com.scorpios.secondkill.aop.ServiceLock)")
    public void lockAspect() {}
    @Around("lockAspect()")
    public Object around(ProceedingJoinPoint joinPoint) {
        lock.lock();
        try { return joinPoint.proceed(); }
        finally { lock.unlock(); }
    }
}

Apply @ServiceLock on the service method; the lock is acquired before the method (and thus before the transaction) executes.

Solution 3 – Pessimistic Lock (FOR UPDATE)

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

@Transactional
public Result startSecondKillByUpdate(long skgId, long userId) {
    SecondKill sk = secondKillMapper.querySecondKillForUpdate(skgId);
    if (sk.getNumber() > 0) {
        sk.setNumber(sk.getNumber() - 1);
        secondKillMapper.updateById(sk);
        // create order, payment, etc.
        return Result.ok(SecondKillStateEnum.SUCCESS);
    }
    return Result.error(SecondKillStateEnum.END);
}

Row‑level lock is held until the transaction commits.

Solution 4 – Update‑Based Table Lock

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

@Transactional
public Result startSecondKillByUpdateTwo(long skgId, long userId) {
    int affected = secondKillMapper.updateSecondKillById(skgId);
    if (affected > 0) {
        // create order, payment, etc.
        return Result.ok(SecondKillStateEnum.SUCCESS);
    }
    return Result.error(SecondKillStateEnum.END);
}

Directly decrements stock; if the update affects zero rows, stock is exhausted.

Solution 5 – Optimistic Lock (Version Field)

@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);

@Transactional
public Result startSecondKillByPesLock(long skgId, long userId, int number) {
    SecondKill sk = secondKillMapper.selectById(skgId);
    if (sk.getNumber() >= number) {
        int updated = secondKillMapper.updateSecondKillByVersion(number, skgId, sk.getVersion());
        if (updated > 0) {
            // create order, payment, etc.
            return Result.ok(SecondKillStateEnum.SUCCESS);
        }
    }
    return Result.error(SecondKillStateEnum.END);
}

High contention leads to many update failures; not recommended for large traffic.

Solution 6 – BlockingQueue

public class SecondKillQueue {
    static final int QUEUE_MAX_SIZE = 100;
    static BlockingQueue<SuccessKilled> blockingQueue = new LinkedBlockingQueue<>(QUEUE_MAX_SIZE);
    private static class SingletonHolder { static final SecondKillQueue INSTANCE = new SecondKillQueue(); }
    public static SecondKillQueue getInstance() { return SingletonHolder.INSTANCE; }
    public boolean produce(SuccessKilled sk) throws InterruptedException { return blockingQueue.offer(sk); }
    public SuccessKilled consume() throws InterruptedException { return blockingQueue.take(); }
}

A producer thread puts purchase requests into the queue; a consumer thread processes them sequentially, invoking the same business logic (with or without lock).

Solution 7 – Disruptor Queue

public class SecondKillEventFactory implements EventFactory<SecondKillEvent> {
    public SecondKillEvent newInstance() { return new SecondKillEvent(); }
}
public class SecondKillEventProducer {
    private final RingBuffer<SecondKillEvent> ringBuffer;
    public void secondKill(long seckillId, long userId) {
        ringBuffer.publishEvent((e, seq, args) -> { e.setSeckillId(args[0]); e.setUserId(args[1]); }, seckillId, userId);
    }
}
public class SecondKillEventConsumer implements EventHandler<SecondKillEvent> {
    public void onEvent(SecondKillEvent e, long seq, boolean end) {
        Result r = secondKillService.startSecondKillByAop(e.getSeckillId(), e.getUserId());
        if (r.equals(Result.ok(SecondKillStateEnum.SUCCESS))) {
            log.info("User:{} 秒杀成功", e.getUserId());
        }
    }
}

Provides higher throughput (up to millions of orders per second) but still suffers from a “queue‑gap” issue that can cause slight under‑selling.

Overall Findings

Methods 1 and 2 solve the lock‑timing issue by acquiring the lock before the transaction starts.

Methods 3‑5 rely on database‑level locking; pessimistic row locks (FOR UPDATE) are reliable, while optimistic locking performs poorly under high contention.

Methods 6 and 7 serialize requests via in‑process queues; they improve throughput but can introduce under‑selling when queue length does not match stock.

All seven implementations were validated with JMeter under three load scenarios: 1000 concurrent requests for 100 items, 1000 for 1000 items, and 2000 for 1000 items. The next step is to explore distributed solutions.

Source code repository: https://github.com/Hofanking/springboot-second-skill-example

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.

BackendconcurrencymysqlJMeterlockingSpringBootDisruptorQueue
IT Architects Alliance
Written by

IT Architects Alliance

Discussion and exchange on system, internet, large‑scale distributed, high‑availability, and high‑performance architectures, as well as big data, machine learning, AI, and architecture adjustments with internet technologies. Includes real‑world large‑scale architecture case studies. Open to architects who have ideas and enjoy sharing.

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.