Backend Development 12 min read

Preventing Coupon Over‑Issuance: Concurrency Problems and Multi‑Layered Solutions

The article analyzes why a coupon‑distribution feature can over‑issue coupons under high concurrency, demonstrates the root cause with race conditions, and presents four practical solutions—including Java synchronized blocks, SQL row‑level checks, optimistic locking, and Redis‑based distributed locks using Redisson—to ensure safe coupon redemption.

Top Architect
Top Architect
Top Architect
Preventing Coupon Over‑Issuance: Concurrency Problems and Multi‑Layered Solutions

In a recent project a coupon‑claiming feature allowed users to receive more coupons than were issued, leading to negative stock values. Each coupon has a total issuance count and a per‑user limit (e.g., 120 total, each user can claim up to 140).

When a user successfully claims a coupon, a record is inserted into a separate table (Table B). Under low concurrency the problem does not appear, but load testing with JMeter (500 parallel requests) revealed that the stock of coupon ID 19 became –1, confirming an over‑issuance.

Problem Cause

Two threads can pass the availability check simultaneously before either decrements the stock, so both deduct the same remaining unit, resulting in an extra coupon being issued.

Solution 1 – Java Synchronized Lock

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 guarantees that only one thread can execute the critical section at a time, but it works only within a single JVM and can cause serialization bottlenecks.

Solution 2 – SQL Layer Protection

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

MySQL InnoDB locks the row during the update, preventing concurrent decrements. An optimistic‑lock variant can be used:

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

Both approaches avoid over‑issuance without requiring application‑level locks.

Solution 3 – Redis Distributed Lock (setnx)

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

To avoid accidental deletion, store the thread ID as the lock value and delete only if the stored value matches:

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

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 (Redis‑based Distributed Lock)

org.redisson
redisson
3.17.4
@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);
    }
}
public JsonData addCoupon(long couponId, CouponCategoryEnum categoryEnum) {
    String key = "lock:coupon:" + couponId;
    RLock lock = redisson.getLock(key);
    lock.lock();
    try {
        // business logic (check stock, create record, etc.)
    } finally {
        lock.unlock();
    }
    return JsonData.buildSuccess();
}

Redisson automatically starts a watchdog thread that renews the lock’s TTL, eliminating manual expiration handling. The watchdog interval can be customized with config.setLockWatchdogTimeout(...) .

All four methods solve the coupon over‑issuance problem; the choice depends on deployment architecture, performance requirements, and whether the system runs in a clustered environment.

javaSQLConcurrencyRedisdistributed lockcoupon
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

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.