Backend Development 12 min read

Preventing Coupon Over‑Issuance in High‑Concurrency Scenarios with Java, SQL, and Redis Distributed Locks

This article analyzes a coupon‑claiming race condition that caused stock to become negative under load and presents four solutions—including Java synchronized blocks, SQL conditional updates, Redis SETNX locks, and Redisson client locks—to reliably prevent over‑issuance in backend systems.

Architect's Guide
Architect's Guide
Architect's Guide
Preventing Coupon Over‑Issuance in High‑Concurrency Scenarios with Java, SQL, and Redis Distributed Locks

In a recent project a coupon‑claiming feature caused over‑issuance when multiple threads accessed the same coupon simultaneously.

Problem description: each coupon has a total stock and a per‑user limit; after a successful claim a record is written to a separate table (table B). Load testing with 500 JMeter requests showed the stock of coupon id 19 became –1, indicating an over‑deduction.

The root cause is a race condition: two threads can both pass the availability check before either decrements the stock, so both reduce the stock leading to a negative value.

Solution 1 – Java synchronized lock

Wrap the entire claim logic in a synchronized(this) block so only one thread can execute the method at a time.

synchronized (this){
    LoginUser loginUser = LoginInterceptor.threadLocal.get();
    CouponDO couponDO = couponMapper.selectOne(new QueryWrapper
()
            .eq("id", couponId)
            .eq("category", categoryEnum.name()));
    if(couponDO == null){
        throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
    }
    this.checkCoupon(couponDO, loginUser.getId());
    // build coupon record
    CouponRecordDO couponRecordDO = new CouponRecordDO();
    BeanUtils.copyProperties(couponDO, couponRecordDO);
    couponRecordDO.setCreateTime(new Date());
    couponRecordDO.setUseState(CouponStateEnum.NEW.name());
    couponRecordDO.setUserId(loginUser.getId());
    couponRecordDO.setUserName(loginUser.getName());
    couponRecordDO.setCouponId(couponDO.getId());
    couponRecordDO.setId(null);
    int row = couponMapper.reduceStock(couponId);
    if(row == 1){
        couponRecordMapper.insert(couponRecordDO);
    } else {
        log.info("发送优惠券失败:{},用户:{}", couponDO, loginUser);
    }
}

This eliminates over‑issuance in JMeter tests, but it only works within a single JVM and can become a bottleneck in clustered deployments.

Solution 2 – SQL‑level protection

Use an UPDATE that only decrements stock when the current stock is greater than zero, leveraging InnoDB row‑level locking.

<update id="reduceStock">
    update coupon set stock = stock - 1 where id = #{coupon_id} and stock > 0
</update>

For optimistic locking, include a version column and check it in the WHERE clause.

<update id="reduceStock">
    update product set stock = stock - 1, version = version + 1 
    where id = 1 and stock > 0 and version = #{lastVersion}
</update>

These statements prevent stock from dropping below zero, though they may suffer from ABA problems if versioning is not used.

Solution 3 – Redis distributed lock (SETNX)

Acquire a lock with SETNX ; if successful, set an expiration to avoid deadlocks.

String key = "lock:coupon:" + couponId;
try {
    if (setnx(key, "1")) {
        exp(key, 30, TimeUnit.MILLISECONDS);
        try {
            // business logic
        } finally {
            del(key);
        }
    } else {
        // retry or spin
    }
}

To avoid accidental deletion, store the thread ID as the lock value and verify it before deleting.

String key = "lock:coupon:" + couponId;
String threadId = Thread.currentThread().getId();
try {
    if (setnx(key, threadId)) {
        exp(key, 30, TimeUnit.MILLISECONDS);
        try {
            // business logic
        } finally {
            if (get(key).equals(threadId)) {
                del(key);
            }
        }
    } else {
        // retry
    }
}

For atomic check‑and‑delete, use a Lua script.

String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(key), threadId);

Solution 4 – Redisson client lock

Add the Redisson dependency and configure a RedissonClient .

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.17.4</version>
</dependency>
@Configuration
public class AppConfig {
    @Value("${spring.redis.host}") private String redisHost;
    @Value("${spring.redis.port}") private String redisPort;
    @Bean
    public RedissonClient redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }
}

Use RLock rLock = redisson.getLock(key); rLock.lock(); ... rLock.unlock(); in the service method. Redisson’s watchdog automatically extends the lock’s TTL, avoiding manual renewal.

These four approaches provide a comprehensive toolkit for preventing coupon over‑issuance in high‑concurrency backend systems.

JavaSQLConcurrencyRedisdistributed lockcoupon
Architect's Guide
Written by

Architect's Guide

Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.

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.