📖 开场:超市的优惠券
想象你在超市 🛒:
没有优惠券系统(混乱):
超市:发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张
- 实名认证:绑定身份证
- IP限制:同一IP最多10次
- 设备指纹:同一设备最多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. 数据库设计 │
│ - 模板表 │
│ - 用户券表 │
│ - 领券记录表 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了优惠券系统的设计!🎊
核心要点:
- Redis + Lua:库存扣减,原子操作
- MQ异步:创建用户券,削峰填谷
- 乐观锁:核销优惠券,防止并发
- 风控系统:防羊毛党,五层防控
- 数据库设计:模板表、用户券表、领券记录表
下次面试,这样回答:
"优惠券系统的核心是库存扣减和防超发。采用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⭐!