🎫 设计一个优惠券系统:羊毛党的战场!

51 阅读11分钟

📖 开场:超市的优惠券

想象你在超市 🛒:

没有优惠券系统(混乱)

超市:发10000张50元优惠券
    ↓
放在门口,自己拿
    ↓
羊毛党:一人拿100张 💀
    ↓
1小时后,券被拿光
    ↓
正常用户:没拿到 ❌
    ↓
超市:亏大了(有人拿了券不买东西)💀

结果:
- 正常用户生气 ❌
- 羊毛党钻空子 ❌
- 超市亏钱 ❌

有优惠券系统(严格)

超市:发10000张50元优惠券
    ↓
规则:
- 每人限领1张 ✅
- 实名领取 ✅
- 限时使用(7天内)⏰
- 满100元可用 ✅
    ↓
羊毛党:只能领1张,没法薅羊毛 ❌
    ↓
正常用户:公平领取 ✅
    ↓
超市:达到营销目的 ✅

结果:
- 公平 ✅
- 防羊毛 ✅
- 营销效果好 ✅

这就是优惠券系统:营销的利器!


🤔 核心挑战

挑战1:高并发秒杀 🔥

双11:
10:00:00 发放优惠券
    ↓
100万人同时抢
    ↓
QPS:100万 💀
    ↓
服务器能扛住吗?

问题:
- 超发(发了15000张,库存只有10000)💀
- 并发冲突 💀
- 性能瓶颈 💀

挑战2:防羊毛党 🛡️

羊毛党手段:
1. 一人注册100个账号 💀
2. 机器人脚本抢券 💀
3. 抢了不用(浪费)💀

必须:
- 限制领取次数 ✅
- 实名认证 ✅
- 风控系统 ✅

挑战3:库存扣减 📦

问题:
优惠券库存:10000张
    ↓
100万人同时抢
    ↓
如何保证不超发?

方案:
- 数据库乐观锁 ❌(性能差)
- Redis + Lua ✅(推荐)
- 消息队列 ✅(削峰)

🎯 核心设计

设计1:优惠券模型 🎫

优惠券类型

优惠券类型:
1. 满减券:满100减50
2. 折扣券:8折
3. 立减券:立减20元
4. 包邮券:免运费

数据库设计

-- ⭐ 优惠券模板表
CREATE TABLE t_coupon_template (
    id BIGINT PRIMARY KEY COMMENT '模板ID',
    coupon_name VARCHAR(100) NOT NULL COMMENT '优惠券名称',
    coupon_type TINYINT NOT NULL COMMENT '类型:1-满减 2-折扣 3-立减',
    discount_amount DECIMAL(10,2) COMMENT '优惠金额',
    discount_rate DECIMAL(5,2) COMMENT '折扣率(0.8=8折)',
    min_amount DECIMAL(10,2) COMMENT '最低消费金额',
    total_count INT NOT NULL COMMENT '总发行量',
    remain_count INT NOT NULL COMMENT '剩余数量',
    per_user_limit INT NOT NULL DEFAULT 1 COMMENT '每人限领数量',
    valid_days INT NOT NULL COMMENT '有效天数',
    start_time DATETIME NOT NULL COMMENT '开始时间',
    end_time DATETIME NOT NULL COMMENT '结束时间',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-未开始 2-进行中 3-已结束',
    create_time DATETIME NOT NULL,
    
    INDEX idx_status (status, start_time)
) COMMENT '优惠券模板表';

-- ⭐ 用户优惠券表
CREATE TABLE t_user_coupon (
    id BIGINT PRIMARY KEY COMMENT '用户券ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    template_id BIGINT NOT NULL COMMENT '模板ID',
    coupon_code VARCHAR(32) NOT NULL COMMENT '优惠券码(唯一)',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-未使用 2-已使用 3-已过期',
    receive_time DATETIME NOT NULL COMMENT '领取时间',
    use_time DATETIME COMMENT '使用时间',
    expire_time DATETIME NOT NULL COMMENT '过期时间',
    order_id BIGINT COMMENT '使用的订单ID',
    
    UNIQUE KEY uk_coupon_code (coupon_code),
    INDEX idx_user_status (user_id, status),
    INDEX idx_template (template_id)
) COMMENT '用户优惠券表';

-- ⭐ 领券记录表(防重)
CREATE TABLE t_coupon_receive_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL COMMENT '用户ID',
    template_id BIGINT NOT NULL COMMENT '模板ID',
    receive_time DATETIME NOT NULL COMMENT '领取时间',
    
    UNIQUE KEY uk_user_template (user_id, template_id),
    INDEX idx_template (template_id)
) COMMENT '领券记录表';

设计2:优惠券发放 🎁

方案1:数据库扣减(简单,性能差)

@Service
public class CouponService {
    
    @Autowired
    private CouponTemplateMapper couponTemplateMapper;
    
    @Autowired
    private UserCouponMapper userCouponMapper;
    
    /**
     * ⭐ 领取优惠券(数据库扣减)
     */
    @Transactional(rollbackFor = Exception.class)
    public UserCoupon receiveCoupon(Long userId, Long templateId) {
        // 1. 查询优惠券模板
        CouponTemplate template = couponTemplateMapper.selectById(templateId);
        
        if (template == null) {
            throw new CouponNotFoundException("优惠券不存在");
        }
        
        if (template.getRemainCount() <= 0) {
            throw new CouponSoldOutException("优惠券已抢光");
        }
        
        // 2. 检查用户是否已领取
        int count = userCouponMapper.countByUserAndTemplate(userId, templateId);
        if (count >= template.getPerUserLimit()) {
            throw new CouponLimitException("已达到领取上限");
        }
        
        // ⭐ 3. 扣减库存(乐观锁)
        int rows = couponTemplateMapper.decreaseRemainCount(templateId, template.getVersion());
        
        if (rows == 0) {
            throw new ConcurrentUpdateException("领取失败,请重试");
        }
        
        // 4. 生成用户优惠券
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setId(idGenerator.nextId());
        userCoupon.setUserId(userId);
        userCoupon.setTemplateId(templateId);
        userCoupon.setCouponCode(generateCouponCode());
        userCoupon.setStatus(CouponStatus.UNUSED);
        userCoupon.setReceiveTime(new Date());
        userCoupon.setExpireTime(calculateExpireTime(template.getValidDays()));
        userCouponMapper.insert(userCoupon);
        
        return userCoupon;
    }
}

Mapper实现

@Mapper
public interface CouponTemplateMapper {
    
    /**
     * ⭐ 扣减库存(乐观锁)
     */
    @Update("UPDATE t_coupon_template " +
            "SET remain_count = remain_count - 1, " +
            "    version = version + 1 " +
            "WHERE id = #{templateId} " +
            "  AND version = #{version} " +
            "  AND remain_count > 0")
    int decreaseRemainCount(@Param("templateId") Long templateId, 
                            @Param("version") Integer version);
}

缺点

  • 性能差(数据库压力大)❌
  • 高并发时大量失败 ❌

方案2:Redis + Lua(推荐)⭐⭐⭐

@Service
public class CouponService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private UserCouponMapper userCouponMapper;
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    private static final String COUPON_STOCK_KEY = "coupon:stock:";
    private static final String USER_RECEIVE_KEY = "coupon:user:";
    
    /**
     * ⭐ 领取优惠券(Redis扣减)
     */
    public UserCoupon receiveCoupon(Long userId, Long templateId) {
        // ⭐ 1. Redis扣减库存(Lua脚本,原子操作)
        Long result = deductStock(userId, templateId);
        
        if (result == 0) {
            throw new CouponSoldOutException("优惠券已抢光");
        } else if (result == -1) {
            throw new CouponLimitException("已达到领取上限");
        }
        
        // ⭐ 2. 异步创建用户优惠券(发送MQ消息)
        CouponReceiveMessage message = new CouponReceiveMessage();
        message.setUserId(userId);
        message.setTemplateId(templateId);
        rocketMQTemplate.syncSend("coupon-receive-topic", message);
        
        // 3. 返回(异步生成券码)
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setUserId(userId);
        userCoupon.setTemplateId(templateId);
        return userCoupon;
    }
    
    /**
     * ⭐ Redis扣减库存(Lua脚本)
     */
    private Long deductStock(Long userId, Long templateId) {
        String stockKey = COUPON_STOCK_KEY + templateId;
        String userKey = USER_RECEIVE_KEY + templateId + ":" + userId;
        
        // Lua脚本(原子操作)
        String luaScript = 
            "-- 检查库存\n" +
            "local stock = redis.call('get', KEYS[1])\n" +
            "if stock == false or tonumber(stock) <= 0 then\n" +
            "    return 0  -- 库存不足\n" +
            "end\n" +
            "\n" +
            "-- 检查用户是否已领取\n" +
            "local userReceived = redis.call('get', KEYS[2])\n" +
            "if userReceived then\n" +
            "    return -1  -- 已领取\n" +
            "end\n" +
            "\n" +
            "-- 扣减库存\n" +
            "redis.call('decr', KEYS[1])\n" +
            "\n" +
            "-- 记录用户已领取\n" +
            "redis.call('setex', KEYS[2], 86400, '1')  -- 1天过期\n" +
            "\n" +
            "return 1  -- 成功\n";
        
        return redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Arrays.asList(stockKey, userKey)
        );
    }
    
    /**
     * ⭐ 初始化Redis库存
     */
    public void initStock(Long templateId, int stock) {
        String stockKey = COUPON_STOCK_KEY + templateId;
        redisTemplate.opsForValue().set(stockKey, String.valueOf(stock));
    }
}

MQ消费者

/**
 * ⭐ 优惠券领取消费者(异步创建用户券)
 */
@Component
@RocketMQMessageListener(
    topic = "coupon-receive-topic",
    consumerGroup = "coupon-receive-consumer"
)
public class CouponReceiveConsumer implements RocketMQListener<CouponReceiveMessage> {
    
    @Autowired
    private UserCouponMapper userCouponMapper;
    
    @Autowired
    private CouponTemplateMapper couponTemplateMapper;
    
    @Override
    public void onMessage(CouponReceiveMessage message) {
        Long userId = message.getUserId();
        Long templateId = message.getTemplateId();
        
        // 查询模板
        CouponTemplate template = couponTemplateMapper.selectById(templateId);
        
        // ⭐ 创建用户优惠券
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setId(idGenerator.nextId());
        userCoupon.setUserId(userId);
        userCoupon.setTemplateId(templateId);
        userCoupon.setCouponCode(generateCouponCode());
        userCoupon.setStatus(CouponStatus.UNUSED);
        userCoupon.setReceiveTime(new Date());
        userCoupon.setExpireTime(calculateExpireTime(template.getValidDays()));
        userCouponMapper.insert(userCoupon);
        
        System.out.println("⭐ 用户券创建成功:" + userCoupon.getCouponCode());
    }
    
    /**
     * 生成优惠券码(12位)
     */
    private String generateCouponCode() {
        return RandomStringUtils.randomAlphanumeric(12).toUpperCase();
    }
    
    /**
     * 计算过期时间
     */
    private Date calculateExpireTime(int validDays) {
        return DateUtils.addDays(new Date(), validDays);
    }
}

优点

  • 高性能(Redis内存操作)✅
  • 原子性(Lua脚本)✅
  • 削峰(MQ异步)✅

设计3:优惠券核销 ✅

核销流程

用户下单:
1. 选择优惠券
2. 计算优惠金额
3. 创建订单
4. 核销优惠券(标记已使用)
    ↓
订单支付失败:
    ↓
释放优惠券(标记未使用)✅

代码实现

@Service
public class CouponUseService {
    
    @Autowired
    private UserCouponMapper userCouponMapper;
    
    /**
     * ⭐ 核销优惠券
     */
    @Transactional(rollbackFor = Exception.class)
    public void useCoupon(Long userId, String couponCode, Long orderId, BigDecimal orderAmount) {
        // 1. 查询用户优惠券
        UserCoupon userCoupon = userCouponMapper.selectByCouponCode(couponCode);
        
        if (userCoupon == null) {
            throw new CouponNotFoundException("优惠券不存在");
        }
        
        if (!userCoupon.getUserId().equals(userId)) {
            throw new CouponOwnerException("不是您的优惠券");
        }
        
        if (userCoupon.getStatus() != CouponStatus.UNUSED) {
            throw new CouponStatusException("优惠券已使用或已过期");
        }
        
        if (new Date().after(userCoupon.getExpireTime())) {
            throw new CouponExpiredException("优惠券已过期");
        }
        
        // 2. 检查使用条件(最低消费金额)
        CouponTemplate template = couponTemplateMapper.selectById(userCoupon.getTemplateId());
        if (template.getMinAmount() != null && 
            orderAmount.compareTo(template.getMinAmount()) < 0) {
            throw new CouponConditionException(
                "订单金额不满足优惠券使用条件,需满" + template.getMinAmount() + "元");
        }
        
        // ⭐ 3. 核销优惠券(乐观锁)
        userCoupon.setStatus(CouponStatus.USED);
        userCoupon.setUseTime(new Date());
        userCoupon.setOrderId(orderId);
        
        int rows = userCouponMapper.updateStatus(
            userCoupon.getId(), 
            CouponStatus.UNUSED, 
            CouponStatus.USED
        );
        
        if (rows == 0) {
            throw new ConcurrentUpdateException("优惠券核销失败,请重试");
        }
    }
    
    /**
     * ⭐ 释放优惠券(订单取消时)
     */
    @Transactional(rollbackFor = Exception.class)
    public void releaseCoupon(Long orderId) {
        // 查询订单使用的优惠券
        UserCoupon userCoupon = userCouponMapper.selectByOrderId(orderId);
        
        if (userCoupon != null && userCoupon.getStatus() == CouponStatus.USED) {
            // ⭐ 释放优惠券
            userCoupon.setStatus(CouponStatus.UNUSED);
            userCoupon.setUseTime(null);
            userCoupon.setOrderId(null);
            
            userCouponMapper.updateStatus(
                userCoupon.getId(), 
                CouponStatus.USED, 
                CouponStatus.UNUSED
            );
        }
    }
    
    /**
     * 计算优惠金额
     */
    public BigDecimal calculateDiscount(UserCoupon userCoupon, BigDecimal orderAmount) {
        CouponTemplate template = couponTemplateMapper.selectById(userCoupon.getTemplateId());
        
        switch (template.getCouponType()) {
            case FULL_REDUCTION:  // 满减
                return template.getDiscountAmount();
                
            case DISCOUNT:  // 折扣
                BigDecimal discount = orderAmount.multiply(BigDecimal.ONE.subtract(template.getDiscountRate()));
                return discount.setScale(2, RoundingMode.HALF_UP);
                
            case IMMEDIATE_REDUCTION:  // 立减
                return template.getDiscountAmount();
                
            default:
                return BigDecimal.ZERO;
        }
    }
}

Mapper实现

@Mapper
public interface UserCouponMapper {
    
    /**
     * ⭐ 更新优惠券状态(乐观锁)
     */
    @Update("UPDATE t_user_coupon " +
            "SET status = #{newStatus}, " +
            "    use_time = NOW() " +
            "WHERE id = #{couponId} " +
            "  AND status = #{oldStatus}")
    int updateStatus(@Param("couponId") Long couponId, 
                     @Param("oldStatus") CouponStatus oldStatus, 
                     @Param("newStatus") CouponStatus newStatus);
}

设计4:防羊毛党 🛡️

风控策略

风控规则:
1. 限制领取次数(每人1张)✅
2. 实名认证(绑定身份证)✅
3. IP限制(同一IP最多领10次)✅
4. 设备指纹(同一设备最多领5次)✅
5. 行为分析(领了不用的封号)✅

代码实现

@Service
public class CouponRiskControlService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String IP_LIMIT_KEY = "coupon:ip:";
    private static final String DEVICE_LIMIT_KEY = "coupon:device:";
    
    /**
     * ⭐ 风控检查
     */
    public void checkRisk(Long userId, Long templateId, String ip, String deviceId) {
        // 1. 检查用户是否实名认证
        if (!isRealNameVerified(userId)) {
            throw new RiskControlException("请先进行实名认证");
        }
        
        // 2. IP限制(同一IP最多领10次)
        String ipKey = IP_LIMIT_KEY + templateId + ":" + ip;
        Long ipCount = redisTemplate.opsForValue().increment(ipKey, 1);
        if (ipCount == 1) {
            redisTemplate.expire(ipKey, 1, TimeUnit.DAYS);
        }
        if (ipCount > 10) {
            throw new RiskControlException("该IP领取次数过多,请稍后再试");
        }
        
        // 3. 设备指纹限制(同一设备最多领5次)
        String deviceKey = DEVICE_LIMIT_KEY + templateId + ":" + deviceId;
        Long deviceCount = redisTemplate.opsForValue().increment(deviceKey, 1);
        if (deviceCount == 1) {
            redisTemplate.expire(deviceKey, 1, TimeUnit.DAYS);
        }
        if (deviceCount > 5) {
            throw new RiskControlException("该设备领取次数过多");
        }
        
        // 4. 行为分析(历史优惠券使用率)
        double useRate = calculateCouponUseRate(userId);
        if (useRate < 0.3) {  // 使用率低于30%
            throw new RiskControlException("您的优惠券使用率过低,暂时无法领取");
        }
    }
    
    /**
     * 检查是否实名认证
     */
    private boolean isRealNameVerified(Long userId) {
        // 查询用户信息
        // ...
        return true;
    }
    
    /**
     * 计算优惠券使用率
     */
    private double calculateCouponUseRate(Long userId) {
        // 查询用户历史优惠券
        int totalCount = userCouponMapper.countByUser(userId);
        int usedCount = userCouponMapper.countByUserAndStatus(userId, CouponStatus.USED);
        
        if (totalCount == 0) {
            return 1.0;  // 新用户,默认通过
        }
        
        return (double) usedCount / totalCount;
    }
}

🎓 面试题速答

Q1: 优惠券如何防止超发?

A: Redis + Lua脚本

-- Lua脚本(原子操作)
local stock = redis.call('get', KEYS[1])
if stock == false or tonumber(stock) <= 0 then
    return 0  -- 库存不足
end

redis.call('decr', KEYS[1])  -- 扣减库存
return 1  -- 成功

优点

  • 原子性(Redis单线程)
  • 高性能(内存操作)

Q2: 如何防止一人领多张?

A: Redis记录 + 数据库唯一索引

// Redis记录用户已领取
String userKey = "coupon:user:" + templateId + ":" + userId;
redis.setex(userKey, 86400, "1");

// 数据库唯一索引
UNIQUE KEY uk_user_template (user_id, template_id)

双重保障


Q3: 优惠券核销流程?

A: 核销 + 释放

// 核销(订单支付时)
UPDATE t_user_coupon 
SET status = 2,  -- 已使用
    use_time = NOW(),
    order_id = #{orderId}
WHERE id = #{couponId}
  AND status = 1  -- 未使用

// 释放(订单取消时)
UPDATE t_user_coupon 
SET status = 1,  -- 未使用
    use_time = NULL,
    order_id = NULL
WHERE order_id = #{orderId}
  AND status = 2  -- 已使用

Q4: 如何防止羊毛党?

A: 五层防控

  1. 限制领取次数:每人1张
  2. 实名认证:绑定身份证
  3. IP限制:同一IP最多10次
  4. 设备指纹:同一设备最多5次
  5. 行为分析:使用率低于30%不让领

Q5: 高并发如何优化?

A: Redis + MQ异步

流程:
1. Redis扣减库存(秒级)⚡
2. 发送MQ消息
3. 异步创建用户券
4. 立即返回 ✅

优点:
- 高性能(Redis)
- 削峰(MQ)
- 不阻塞

Q6: 优惠券过期如何处理?

A: 定时任务扫描

@Scheduled(cron = "0 0 1 * * ?")  // 每天凌晨1点
public void expireCoupons() {
    // 更新过期优惠券状态
    userCouponMapper.expireCoupons(new Date());
}

// SQL
UPDATE t_user_coupon 
SET status = 3  -- 已过期
WHERE status = 1  -- 未使用
  AND expire_time < NOW()

🎬 总结

       优惠券系统核心设计

┌────────────────────────────────────┐
│ 1. 库存扣减(Redis + Lua)⭐        │
│    - 原子操作                      │
│    - 高性能                        │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 异步创建券(MQ)                 │
│    - 削峰填谷                      │
│    - 不阻塞                        │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. 核销 + 释放                      │
│    - 乐观锁                        │
│    - 订单取消释放券                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. 防羊毛党 ⭐                      │
│    - 限领次数                      │
│    - IP/设备限制                   │
│    - 行为分析                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 数据库设计                      │
│    - 模板表                        │
│    - 用户券表                      │
│    - 领券记录表                    │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了优惠券系统的设计!🎊

核心要点

  1. Redis + Lua:库存扣减,原子操作
  2. MQ异步:创建用户券,削峰填谷
  3. 乐观锁:核销优惠券,防止并发
  4. 风控系统:防羊毛党,五层防控
  5. 数据库设计:模板表、用户券表、领券记录表

下次面试,这样回答

"优惠券系统的核心是库存扣减和防超发。采用Redis + Lua脚本实现原子性扣减。Lua脚本先检查库存和用户是否已领取,然后扣减库存并记录用户领取状态,整个过程是原子操作。Redis的单线程特性保证不会超发。

为提高性能,采用MQ异步创建用户优惠券。Redis扣减库存后立即返回,发送MQ消息,消费者异步创建用户券记录。这样可以削峰填谷,秒级响应100万QPS。

优惠券核销使用乐观锁。核销时将状态从未使用改为已使用,同时记录订单ID和使用时间。如果订单支付失败或取消,释放优惠券,将状态改回未使用。使用WHERE条件检查状态,防止并发重复核销。

防羊毛党采用五层防控:每人限领1张、实名认证、同一IP最多领10次、同一设备最多领5次、分析历史使用率低于30%不让领。IP和设备限制使用Redis计数器实现,key包含模板ID和IP/设备ID,1天过期。

数据库设计分为三张表:优惠券模板表存储券的基本信息和库存,用户优惠券表存储用户领取的券,领券记录表用于防重,user_id和template_id建立唯一索引。模板表的remain_count字段使用version乐观锁更新,但高并发场景下Redis性能更好。"

面试官:👍 "很好!你对优惠券系统的设计理解很深刻!"


本文完 🎬

上一篇: 213-设计一个支付系统.md
下一篇: 215-设计一个评论系统.md

作者注:写完这篇,我都想去当羊毛党了!😂
如果这篇文章对你有帮助,请给我一个Star⭐!