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.
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 > 0MySQL 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.
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.
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.