设计一个优惠券系统:防超发、核销并发、活动实时统计
设计一个优惠券系统:防超发、核销并发、活动实时统计
适读人群:Java中高级工程师、电商促销技术人员 | 阅读时长:约18分钟 | 难度:★★★★☆
开篇故事
做优惠券有个噩梦场景:双十一前夜,老板说发100万张9折券,结果实际发出去了127万张,多送出去27万张的折扣。事后复盘发现是并发控制出了问题——没有对发券做并发限制,高峰期100台机器同时发券,同一批库存被多次读取,各自以为还有余量,就各自发了。
这不是假设,我见过至少两家公司出过类似的事。优惠券系统麻烦在于:发券、领券、核销三个环节都有超发风险,必须在不同维度分别做并发控制。这篇文章把优惠券系统的防超发机制全链路讲清楚。
一、需求分析与规模估算
优惠券类型
- 全局券: 全平台通用,发N张,先到先得
- 品类券: 特定品类商品可用
- 商家券: 特定商家发放,商家承担折扣费用
- 满减券/折扣券/直减券
三个风险点
- 发券超发: 批量发券时,总发出量超过总配额
- 领券超发: 用户主动领取时(抢券活动),发出量超过设定上限
- 核销超发: 使用优惠券时,在并发下同一张券被使用多次
规模估算
发券量:
- 大促活动发券:1000万张/次
- 日常发券:100万张/天
用户领券QPS:
- 平时:100 QPS
- 抢券活动开始瞬间:10000 QPS
核销(使用优惠券):
- 每天核销量:50万次
- 峰值QPS:约3000 QPS(大促日)
二、系统架构设计
三、核心组件详解
3.1 防超发机制
三层防超发:
- Redis原子DECR(最快,第一道防线)
- 数据库乐观锁(可靠兜底)
- 核销幂等(防重复核销)
3.2 券的数据模型
-- 优惠券模板(活动级别)
CREATE TABLE coupon_template (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
type TINYINT, -- 1=满减 2=折扣 3=直减
discount_value DECIMAL(10,2), -- 折扣值(折扣券=0.9表示9折)
min_amount DECIMAL(10,2), -- 最低使用金额
total_count INT, -- 总发放量
issued_count INT DEFAULT 0, -- 已发放量
used_count INT DEFAULT 0, -- 已使用量
per_user_limit INT DEFAULT 1, -- 每人最多领取数量
start_time DATETIME,
end_time DATETIME, -- 使用截止时间
status TINYINT DEFAULT 1
);
-- 用户领券记录(按user_id分64张表)
CREATE TABLE user_coupon_0 (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
template_id BIGINT NOT NULL,
coupon_code VARCHAR(64) UNIQUE, -- 券码(唯一标识一张券)
status TINYINT DEFAULT 0, -- 0=未使用 1=已使用 2=已过期
order_id BIGINT, -- 使用时关联的订单ID
issue_time DATETIME,
use_time DATETIME,
expire_time DATETIME,
INDEX idx_user_template (user_id, template_id)
);四、关键代码实现
4.1 领券服务(防超发核心)
@Service
@Slf4j
public class CouponGrabService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private CouponTemplateMapper templateMapper;
@Autowired
private UserCouponMapper userCouponMapper;
private static final String STOCK_KEY_PREFIX = "coupon:stock:";
private static final String USER_GRAB_KEY_PREFIX = "coupon:user:grab:";
/**
* 用户领取优惠券(防超发、防重复领取)
*/
@Transactional
public GrabResult grabCoupon(Long userId, Long templateId) {
// 1. 防重复领取:检查用户是否已经领取过(Redis快速判断)
String userGrabKey = USER_GRAB_KEY_PREFIX + templateId + ":" + userId;
Boolean alreadyGrabbed = redisTemplate.hasKey(userGrabKey);
if (Boolean.TRUE.equals(alreadyGrabbed)) {
return GrabResult.fail("您已领取过此优惠券");
}
// 也从DB检查(Redis可能过期)
int userHoldCount = userCouponMapper.countByUserAndTemplate(userId, templateId);
CouponTemplate template = templateMapper.findById(templateId);
if (userHoldCount >= template.getPerUserLimit()) {
return GrabResult.fail("已达到领取上限");
}
// 2. Redis原子扣减库存(最快的防超发手段)
String stockKey = STOCK_KEY_PREFIX + templateId;
Long remaining = redisTemplate.opsForValue().decrement(stockKey);
if (remaining == null || remaining < 0) {
// 库存为负,回滚Redis扣减
redisTemplate.opsForValue().increment(stockKey);
return GrabResult.fail("优惠券已被抢完");
}
// 3. 数据库层的最终校验(乐观锁保证精确)
// UPDATE coupon_template SET issued_count=issued_count+1
// WHERE id=? AND issued_count < total_count
int affected = templateMapper.incrementIssuedCount(templateId);
if (affected == 0) {
// 数据库库存不足(Redis和DB之间有差距,极少发生)
redisTemplate.opsForValue().increment(stockKey); // 回滚Redis
return GrabResult.fail("优惠券已被抢完");
}
// 4. 生成券实例,写入用户券表
String couponCode = generateCouponCode(templateId, userId);
UserCoupon userCoupon = UserCoupon.builder()
.userId(userId)
.templateId(templateId)
.couponCode(couponCode)
.status(CouponStatus.UNUSED)
.issueTime(LocalDateTime.now())
.expireTime(template.getEndTime())
.build();
userCouponMapper.insert(userCoupon);
// 5. 记录用户已领取(防止再次领取)
redisTemplate.opsForValue().set(
userGrabKey, "1",
ChronoUnit.DAYS.between(LocalDateTime.now(), template.getEndTime()),
TimeUnit.DAYS
);
log.info("领券成功, userId={}, templateId={}, code={}",
userId, templateId, couponCode);
return GrabResult.success(couponCode);
}
private String generateCouponCode(Long templateId, Long userId) {
// 格式:模板ID + 时间戳 + 随机数
return String.format("%06d%013d%04d",
templateId, System.currentTimeMillis(),
ThreadLocalRandom.current().nextInt(1000, 9999));
}
}4.2 核销服务(并发核销防重复)
@Service
@Slf4j
public class CouponUseService {
@Autowired
private UserCouponMapper userCouponMapper;
@Autowired
private CouponTemplateMapper templateMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private KafkaTemplate<String, CouponUseEvent> kafkaTemplate;
/**
* 核销优惠券(下单时调用)
* 必须保证幂等:同一订单+同一优惠券只核销一次
*/
@Transactional
public UseResult useCoupon(Long userId, String couponCode, Long orderId,
BigDecimal orderAmount) {
// 1. 幂等检查:同一订单已经使用了这张券
String idempotentKey = "coupon:use:" + orderId + ":" + couponCode;
Boolean alreadyUsed = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
if (!Boolean.TRUE.equals(alreadyUsed)) {
// 已经处理过,查询并返回结果
UserCoupon coupon = userCouponMapper.findByCode(couponCode, userId);
if (coupon.getStatus() == CouponStatus.USED
&& orderId.equals(coupon.getOrderId())) {
return UseResult.success(coupon.getDiscountAmount());
}
return UseResult.fail("优惠券正在处理中");
}
try {
return doUseCoupon(userId, couponCode, orderId, orderAmount);
} catch (Exception e) {
// 发生异常,清除幂等Key(允许重试)
redisTemplate.delete(idempotentKey);
throw e;
}
}
private UseResult doUseCoupon(Long userId, String couponCode,
Long orderId, BigDecimal orderAmount) {
// 1. 查询优惠券(加行锁,防并发核销同一张券)
UserCoupon coupon = userCouponMapper.selectForUpdate(couponCode, userId);
if (coupon == null) {
throw new BusinessException("优惠券不存在");
}
// 2. 检查优惠券状态
if (coupon.getStatus() == CouponStatus.USED) {
throw new BusinessException("优惠券已使用");
}
if (coupon.getStatus() == CouponStatus.EXPIRED
|| LocalDateTime.now().isAfter(coupon.getExpireTime())) {
throw new BusinessException("优惠券已过期");
}
// 3. 检查使用条件(如满减门槛)
CouponTemplate template = templateMapper.findById(coupon.getTemplateId());
if (orderAmount.compareTo(template.getMinAmount()) < 0) {
throw new BusinessException(
String.format("订单金额不满足使用条件(最低%.2f元)",
template.getMinAmount())
);
}
// 4. 计算优惠金额
BigDecimal discountAmount = calculateDiscount(template, orderAmount);
// 5. 核销:CAS更新,确保并发安全
// UPDATE user_coupon SET status=1, order_id=?, use_time=NOW(), discount_amount=?
// WHERE coupon_code=? AND user_id=? AND status=0
int affected = userCouponMapper.useCoupon(
couponCode, userId, orderId, discountAmount);
if (affected == 0) {
throw new BusinessException("优惠券状态异常,请刷新重试");
}
// 6. 更新模板的已使用计数(异步,不阻塞核销主流程)
kafkaTemplate.send("coupon-use-events",
new CouponUseEvent(coupon.getTemplateId(), discountAmount));
log.info("优惠券核销成功, couponCode={}, orderId={}, discount={}",
couponCode, orderId, discountAmount);
return UseResult.success(discountAmount);
}
private BigDecimal calculateDiscount(CouponTemplate template, BigDecimal orderAmount) {
switch (template.getType()) {
case 1: // 满减
return template.getDiscountValue();
case 2: // 折扣
BigDecimal discount = orderAmount.multiply(
BigDecimal.ONE.subtract(template.getDiscountValue()));
return discount.setScale(2, RoundingMode.HALF_UP);
case 3: // 直减
return template.getDiscountValue();
default:
return BigDecimal.ZERO;
}
}
}4.3 活动实时统计
@Component
@Slf4j
public class CouponStatsService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ClickHouseTemplate clickHouseTemplate;
/**
* 消费核销事件,更新实时统计
*/
@KafkaListener(topics = "coupon-use-events", groupId = "coupon-stats-group")
public void onUseEvent(CouponUseEvent event) {
Long templateId = event.getTemplateId();
// 实时统计:Redis计数器
String dateStr = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
// 今日核销量
redisTemplate.opsForValue().increment(
"coupon:stats:use:" + templateId + ":" + dateStr);
// 今日节省金额
redisTemplate.opsForValue().increment(
"coupon:stats:discount:" + templateId + ":" + dateStr,
event.getDiscountAmount().longValue() // 分为单位
);
// 写入ClickHouse做详细分析
clickHouseTemplate.insertCouponUseRecord(
templateId, event.getDiscountAmount(), LocalDateTime.now());
}
/**
* 查询活动实时数据(给运营人员看)
*/
public CouponActivityStats getRealtimeStats(Long templateId) {
String dateStr = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String issued = redisTemplate.opsForValue().get(
"coupon:stock:" + templateId);
String useCount = redisTemplate.opsForValue().get(
"coupon:stats:use:" + templateId + ":" + dateStr);
String totalDiscount = redisTemplate.opsForValue().get(
"coupon:stats:discount:" + templateId + ":" + dateStr);
return CouponActivityStats.builder()
.remainingStock(issued != null ? Long.parseLong(issued) : 0L)
.todayUseCount(useCount != null ? Long.parseLong(useCount) : 0L)
.todayDiscountAmount(totalDiscount != null ?
new BigDecimal(totalDiscount).divide(new BigDecimal(100)) : BigDecimal.ZERO)
.build();
}
}五、扩展性设计
大规模发券(千万量级)
单次批量发1000万张券,如果同步写DB,会把数据库压垮。方案:异步批量写入。
// 批量发券:把发券任务拆成小批次,异步处理
public void batchIssueCoupons(Long templateId, List<Long> userIds) {
// 每次处理1000个用户
Lists.partition(userIds, 1000).forEach(batch -> {
kafkaTemplate.send("coupon-batch-issue",
new BatchIssueMessage(templateId, batch));
});
}六、踩坑实录
坑1:Redis库存和DB库存不一致
Redis发放成功但DB写入失败(网络抖动),Redis显示发出了100张,但DB只有90条记录。再次加载时从DB读库存,发现"还有10张没发",又发了10张,实际上总共发了110张。
解决方案:Redis的库存只作为防超发的第一道防线,DB的乐观锁是权威来源。不允许从DB重新初始化Redis库存(除非是全量重建),而是用两者中较小的值。
坑2:同一张券被两个订单同时使用
用户同时在两个Tab页结算,都选了同一张券。两个请求同时进来,读取到券状态都是"未使用",各自都认为可以用,结果同一张券用了两次。
解决方案:核销时对券记录加行锁(SELECT ... FOR UPDATE),同时用CAS更新(WHERE status=0),只有一个请求能更新成功。
坑3:过期优惠券没有清理,积累了大量垃圾数据
两年后,user_coupon表有10亿条数据,其中80%是已过期未使用的券。查询用户可用券的接口越来越慢。
解决方案:定期清理过期券(每月执行一次),将过期未使用的券归档到冷数据表,只保留最近3个月的活跃数据在主表。
七、总结
优惠券系统防超发的三道防线:
| 防线 | 位置 | 技术手段 | 性能 | 可靠性 |
|---|---|---|---|---|
| 第一道 | Redis | DECR原子操作 | 极高 | 中(可能丢失) |
| 第二道 | 数据库 | 乐观锁(CAS更新) | 中 | 高 |
| 第三道 | 核销层 | 行锁+幂等去重 | 低 | 极高 |
三道防线配合,既能扛住高并发领券的峰值压力,又能保证最终数据准确不超发。
