Preventing Coupon Over‑Issuance in High‑Concurrency Java Applications
This article examines why coupon stock can become negative under concurrent requests and presents four practical solutions—including Java synchronized blocks, SQL conditional updates, Redis distributed locks, and Redisson client usage—to reliably prevent coupon over‑issuance in high‑traffic systems.
Problem Statement
In a recent project we need a coupon‑claim feature. Each coupon has a total stock and a per‑user limit. When a user successfully claims a coupon we write a record to another table (table B).
Under load the stock can become negative, indicating over‑issuance.
Root Cause
When two threads check the stock simultaneously, both may pass the validation and then both decrement the stock, causing the stock to go below zero.
Solution 1 – Java synchronized
Wrap the whole claim logic in a synchronized(this) block so only one thread can execute it in a single JVM.
synchronized (this){
LoginUser loginUser = LoginInterceptor.threadLocal.get();
CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
.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 passes JMeter stress testing, but it is unsuitable for clustered deployments because the lock is JVM‑local and creates a serialization bottleneck.
synchronized only works on a single JVM instance.
It introduces thread contention, causing other requests to wait.
Solution 2 – SQL‑Level Protection
Update the stock only when it is greater than zero.
update coupon set stock = stock - 1 where id = #{coupon_id} and stock > 0MySQL InnoDB locks the row during the update, preventing concurrent modifications.
Alternatively, use an optimistic‑lock style update with a version column to avoid ABA problems.
update product set stock = stock - 1 where stock = #{previous_stock} and id = 1 and stock > 0 update product set stock = stock - 1, version = version + 1 where id = 1 and stock > 0 and version = #{previous_version}Solution 3 – Redis Distributed Lock
Use Redis SETNX to acquire a lock before touching the database, set an expiration, and release the lock in a finally block.
String key = "lock:coupon:" + couponId;
try{
if(setnx(key, "1")){
// lock acquired
exp(key, 30, TimeUnit.MILLISECONDS);
try{
// business logic
}finally{
del(key);
}
}else{
// lock not acquired, retry or spin
}
}To avoid deleting a lock owned by another thread, store the thread identifier and check it before deletion, or execute the check‑and‑delete atomically with a Lua script.
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
}
} 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
Redisson provides a high‑level API that handles lock acquisition, automatic watchdog renewal, and safe release, working across a cluster.
@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 rLock = redisson.getLock(key);
LoginUser loginUser = LoginInterceptor.threadLocal.get();
rLock.lock();
try{
// business logic
}finally{
rLock.unlock();
}
return JsonData.buildSuccess();
}Redisson’s watchdog automatically extends the lock’s TTL, eliminating manual expiration handling.
These approaches collectively ensure that coupon stock never drops below zero, even under heavy concurrent traffic.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java High-Performance Architecture
Sharing Java development articles and resources, including SSM architecture and the Spring ecosystem (Spring Boot, Spring Cloud, MyBatis, Dubbo, Docker), Zookeeper, Redis, architecture design, microservices, message queues, Git, etc.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
