高并发优惠券系统设计与防超卖方案:从理论到实战
目录
前言
在Yato跨境电商平台的优惠券模块开发中,我们面临了一个经典的高并发问题:如何在保证数据一致性的前提下,支撑万级QPS的优惠券发放?
本文将详细记录我设计的优惠券系统架构,以及如何通过Redis预扣减、分布式锁、Lua脚本等技术手段解决超卖超领问题。
系统架构总览
优惠券领取时序图
一、业务场景分析
1.1 系统规模
- 日均活跃用户:50万+
- 大促峰值QPS:10000+
- 优惠券库存:单张优惠券10万+张
- 并发领取场景:整点秒杀、新人礼包
1.2 核心挑战
- 超卖问题:库存100张,不能发出去101张
- 超领问题:每人限领1张,不能领到2张
- 高并发性能:万级QPS下响应时间<100ms
- 数据一致性:Redis和MySQL数据最终一致
1.3 业务规则
public class CouponRule {
// 库存限制
private Integer totalStock; // 总库存
private Integer remainStock; // 剩余库存
// 领取限制
private Integer limitPerUser; // 每人限领数量
private Integer dailyLimit; // 每日限领数量
// 时间限制
private LocalDateTime startTime; // 开始时间
private LocalDateTime endTime; // 结束时间
// 使用限制
private BigDecimal minOrderAmount; // 最低使用金额
private List<Long> applicableCategories; // 适用类目
}二、架构设计演进
2.1 方案一:纯数据库方案(初级)
@Service
public class CouponServiceV1 {
@Transactional
public void grabCoupon(Long userId, Long couponId) {
// 1. 查询优惠券
Coupon coupon = couponMapper.selectById(couponId);
// 2. 检查库存
if (coupon.getRemainStock() <= 0) {
throw new BusinessException("优惠券已领完");
}
// 3. 检查是否已领取
Long count = userCouponMapper.selectCount(
new LambdaQueryWrapper<UserCoupon>()
.eq(UserCoupon::getUserId, userId)
.eq(UserCoupon::getCouponId, couponId)
);
if (count > 0) {
throw new BusinessException("您已领取过该优惠券");
}
// 4. 扣减库存
int affected = couponMapper.decreaseStock(couponId);
if (affected == 0) {
throw new BusinessException("优惠券已领完");
}
// 5. 记录用户优惠券
UserCoupon userCoupon = new UserCoupon();
userCoupon.setUserId(userId);
userCoupon.setCouponId(couponId);
userCoupon.setStatus(1);
userCouponMapper.insert(userCoupon);
}
}问题分析:
- ❌ 数据库压力大:每个请求都要查询、更新数据库
- ❌ 并发问题:
SELECT和UPDATE之间有时间窗口,可能导致超卖 - ❌ 性能差:数据库连接池成为瓶颈,QPS只有几百
2.2 方案二:乐观锁方案(中级)
// 数据库添加版本号字段
@Update("UPDATE coupon SET remain_stock = remain_stock - 1, version = version + 1 " +
"WHERE id = #{couponId} AND remain_stock > 0 AND version = #{version}")
int decreaseStock(@Param("couponId") Long couponId, @Param("version") Integer version);
@Service
public class CouponServiceV2 {
public void grabCoupon(Long userId, Long couponId) {
// 重试机制
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
doGrab(userId, couponId);
return;
} catch (OptimisticLockException e) {
if (i == maxRetries - 1) {
throw new BusinessException("系统繁忙,请稍后重试");
}
Thread.sleep(50);
}
}
}
@Transactional
protected void doGrab(Long userId, Long couponId) {
Coupon coupon = couponMapper.selectById(couponId);
// 使用版本号更新
int affected = couponMapper.decreaseStock(couponId, coupon.getVersion());
if (affected == 0) {
throw new OptimisticLockException("并发冲突");
}
// 记录用户优惠券...
}
}优缺点分析:
- ✅ 解决超卖问题
- ❌ 大量冲突导致重试,性能差
- ❌ 高并发下失败率高
2.3 方案三:Redis预扣减方案(生产级)
这是我们在生产环境使用的方案,核心思路:用Redis抗流量,异步写数据库
用户请求 -> Redis扣减 -> 发送MQ -> 异步写库三、Redis预扣减详细实现
3.1 Redis数据结构
# 优惠券库存
SET coupon:stock:{couponId} 100000
# 用户领取记录(Set,自动去重)
SADD coupon:users:{couponId} userId1 userId2 userId3
# 领取计数(限制每人领取数量)
HSET coupon:limit:{couponId} userId 1
# 分布式锁
SET coupon:lock:{couponId}:{userId} 1 EX 10 NX3.2 Lua脚本保证原子性
为什么用Lua脚本?
- Redis是单线程,Lua脚本原子执行
- 避免多次网络往返(RTT)
- 减少竞态条件
扣减库存脚本:
-- grab_coupon.lua
local stockKey = KEYS[1] -- coupon:stock:{couponId}
local userSetKey = KEYS[2] -- coupon:users:{couponId}
local limitKey = KEYS[3] -- coupon:limit:{couponId}
local userId = ARGV[1] -- 用户ID
local limitPerUser = tonumber(ARGV[2]) -- 每人限领数量
-- 1. 检查用户是否已领取
local hasGrabbed = redis.call('SISMEMBER', userSetKey, userId)
if hasGrabbed == 1 then
return -1 -- 已领取
end
-- 2. 检查领取限制
local currentCount = redis.call('HGET', limitKey, userId)
if currentCount and tonumber(currentCount) >= limitPerUser then
return -2 -- 超过领取限制
end
-- 3. 检查库存
local stock = redis.call('GET', stockKey)
if not stock or tonumber(stock) <= 0 then
return -3 -- 库存不足
end
-- 4. 扣减库存
redis.call('DECR', stockKey)
-- 5. 记录用户领取
redis.call('SADD', userSetKey, userId)
redis.call('HINCRBY', limitKey, userId, 1)
-- 6. 设置过期时间(与优惠券有效期一致)
redis.call('EXPIRE', stockKey, 86400)
redis.call('EXPIRE', userSetKey, 86400)
redis.call('EXPIRE', limitKey, 86400)
return 1 -- 领取成功Java调用代码:
@Service
public class CouponRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
// 预加载Lua脚本
private DefaultRedisScript<Long> grabScript;
@PostConstruct
public void init() {
grabScript = new DefaultRedisScript<>();
grabScript.setScriptText(loadLuaScript("grab_coupon.lua"));
grabScript.setResultType(Long.class);
}
/**
* 尝试领取优惠券(Redis预扣减)
* @return 1:成功, -1:已领取, -2:超限制, -3:库存不足
*/
public Long tryGrabCoupon(Long userId, Long couponId, Integer limitPerUser) {
String stockKey = "coupon:stock:" + couponId;
String userSetKey = "coupon:users:" + couponId;
String limitKey = "coupon:limit:" + couponId;
Long result = redisTemplate.execute(
grabScript,
Arrays.asList(stockKey, userSetKey, limitKey),
userId.toString(),
limitPerUser.toString()
);
return result;
}
}3.3 防止重复提交
使用自定义注解+AOP实现幂等:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key() default ""; // 幂等Key
long expire() default 10; // 过期时间(秒)
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint point, Idempotent idempotent) throws Throwable {
// 生成幂等Key
String key = generateKey(point, idempotent);
// 尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(key, "1", idempotent.expire(), TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("请勿重复提交");
}
try {
return point.proceed();
} finally {
// 延迟删除,防止并发问题
redisTemplate.delete(key);
}
}
private String generateKey(ProceedingJoinPoint point, Idempotent idempotent) {
if (!idempotent.key().isEmpty()) {
return "idempotent:" + idempotent.key();
}
// 默认使用类名+方法名+参数MD5
MethodSignature signature = (MethodSignature) point.getSignature();
String methodName = signature.getDeclaringTypeName() + "." + signature.getName();
String params = Arrays.toString(point.getArgs());
return "idempotent:" + methodName + ":" + DigestUtils.md5DigestAsHex(params.getBytes());
}
}使用示例:
@RestController
@RequestMapping("/api/coupon")
public class CouponController {
@Autowired
private CouponService couponService;
@PostMapping("/grab")
@Idempotent(key = "coupon:grab", expire = 5) // 5秒内防重
public Result<Void> grabCoupon(@RequestParam Long couponId) {
Long userId = UserContext.getUserId();
couponService.grabCoupon(userId, couponId);
return Result.success();
}
}四、异步写库与数据一致性
4.1 消息队列异步处理
@Service
public class CouponService {
@Autowired
private CouponRedisService redisService;
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Idempotent(key = "coupon:grab", expire = 5)
public void grabCoupon(Long userId, Long couponId) {
// 1. 查询优惠券规则
CouponRule rule = couponRuleService.getById(couponId);
// 2. Redis预扣减(原子操作)
Long result = redisService.tryGrabCoupon(userId, couponId, rule.getLimitPerUser());
if (result == 1) {
// 3. 发送MQ异步写库
CouponGrabMessage message = CouponGrabMessage.builder()
.userId(userId)
.couponId(couponId)
.grabTime(LocalDateTime.now())
.build();
rocketMQTemplate.asyncSend(
"coupon-grab-topic",
message,
new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("发送优惠券领取消息成功");
}
@Override
public void onException(Throwable e) {
log.error("发送优惠券领取消息失败", e);
// 发送失败,补偿回滚Redis
redisService.rollbackGrab(userId, couponId);
}
}
);
} else {
// 处理失败原因
String msg = switch (result.intValue()) {
case -1 -> "您已领取过该优惠券";
case -2 -> "超过领取限制";
case -3 -> "优惠券已领完";
default -> "领取失败";
};
throw new BusinessException(msg);
}
}
}4.2 消费者端实现
@Component
@RocketMQMessageListener(
topic = "coupon-grab-topic",
consumerGroup = "coupon-grab-consumer"
)
public class CouponGrabConsumer implements RocketMQListener<CouponGrabMessage> {
@Autowired
private UserCouponService userCouponService;
@Autowired
private CouponService couponService;
@Override
@Transactional
public void onMessage(CouponGrabMessage message) {
log.info("处理优惠券领取消息: {}", message);
try {
// 1. 幂等检查(防止消息重复消费)
String idempotentKey = "coupon:grab:mq:" + message.getUserId() + ":" + message.getCouponId();
if (!redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS)) {
log.warn("消息重复消费,跳过处理");
return;
}
// 2. 写入用户优惠券表
UserCoupon userCoupon = new UserCoupon();
userCoupon.setUserId(message.getUserId());
userCoupon.setCouponId(message.getCouponId());
userCoupon.setStatus(UserCouponStatus.UNUSED);
userCoupon.setGrabTime(message.getGrabTime());
userCoupon.setExpireTime(calculateExpireTime(message.getCouponId()));
userCouponService.save(userCoupon);
// 3. 更新优惠券已领取数量
couponService.increaseGrabCount(message.getCouponId());
log.info("优惠券领取处理成功");
} catch (Exception e) {
log.error("处理优惠券领取消息失败", e);
// 抛出异常,触发消息重试
throw e;
}
}
}4.3 数据一致性保障
最终一致性策略:
Redis预扣减(强一致性) -> MQ(至少一次投递) -> 数据库(最终一致)异常处理机制:
-
Redis扣减成功,MQ发送失败
- 同步发送改为异步发送+本地事务表
- 定时任务扫描本地表,补偿发送
-
MQ消费失败
- 消息重试(默认16次)
- 进入死信队列,人工处理
-
数据库写入失败
- 事务回滚
- Redis库存回滚(补偿机制)
@Component
public class DataConsistencyService {
/**
* Redis库存回滚
*/
public void rollbackStock(Long couponId) {
String stockKey = "coupon:stock:" + couponId;
redisTemplate.opsForValue().increment(stockKey);
}
/**
* 定时对账(每天凌晨执行)
*/
@Scheduled(cron = "0 0 2 * * ?")
public void reconcileData() {
// 1. 查询所有优惠券
List<Coupon> coupons = couponService.list();
for (Coupon coupon : coupons) {
String stockKey = "coupon:stock:" + coupon.getId();
String redisStock = redisTemplate.opsForValue().get(stockKey);
// 2. 计算数据库实际库存
int dbStock = coupon.getTotalStock() - coupon.getGrabbedCount();
// 3. 对比并修复
if (redisStock != null && Integer.parseInt(redisStock) != dbStock) {
log.warn("库存不一致,couponId={}, redis={}, db={}",
coupon.getId(), redisStock, dbStock);
// 以数据库为准,修复Redis
redisTemplate.opsForValue().set(stockKey, String.valueOf(dbStock));
}
}
}
}五、性能优化与压测数据
5.1 优化策略
- 连接池优化
spring:
redis:
lettuce:
pool:
max-active: 100
max-idle: 50
min-idle: 20- 批量操作
// 批量获取库存(Pipeline)
public List<Long> batchGetStock(List<Long> couponIds) {
return redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Long couponId : couponIds) {
connection.get(("coupon:stock:" + couponId).getBytes());
}
return null;
}).stream()
.map(obj -> obj != null ? Long.parseLong((String) obj) : 0L)
.collect(Collectors.toList());
}- 本地缓存(Caffeine)
@Component
public class CouponLocalCache {
private final LoadingCache<Long, CouponRule> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(couponRuleService::getById);
public CouponRule getRule(Long couponId) {
return cache.get(couponId);
}
}5.2 压测数据
压测场景: 10000并发用户,同时抢1000张优惠券
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均响应时间 | 1200ms | 45ms |
| P99延迟 | 3500ms | 120ms |
| 吞吐量(TPS) | 850 | 8500 |
| 超卖数量 | 23张 | 0张 |
| 数据库CPU | 95% | 25% |
六、架构决策总结
6.1 方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯数据库 | 数据一致性强 | 性能差 | 低并发 |
| 乐观锁 | 实现简单 | 冲突率高 | 中等并发 |
| Redis+Lua | 高性能 | 需要处理一致性 | 高并发 |
| 纯Redis | 性能最高 | 数据可能丢失 | 非关键业务 |
6.2 我们的选择理由
选择Redis预扣减+MQ异步写库方案:
- ✅ 支撑万级QPS
- ✅ 99.9%请求响应<100ms
- ✅ 零超卖超领
- ✅ 最终一致性满足业务需求
七、踩坑记录
坑1:Redis主从延迟导致超卖
现象: 库存显示还有,但领取时提示已领完
原因: 主从复制延迟,读取从库得到旧数据
解决: “写后查询再写入"策略
public void decreaseStock(Long couponId) {
String key = "coupon:stock:" + couponId;
// 1. 先扣减(写主库)
Long remain = redisTemplate.opsForValue().decrement(key);
if (remain < 0) {
// 2. 超卖,回滚
redisTemplate.opsForValue().increment(key);
throw new BusinessException("库存不足");
}
}坑2:大Key问题
现象: 某张优惠券领取用户过多,Set集合过大(10万+元素)
解决: 分片存储
// 按用户ID取模分片
public String getUserSetKey(Long couponId, Long userId) {
int shard = (int) (userId % 10);
return "coupon:users:" + couponId + ":" + shard;
}坑3:Redis内存溢出
现象: 优惠券过期后Redis数据未清理,内存持续增长
解决: 合理设置过期时间+定时清理
-- 在Lua脚本中设置过期时间
redis.call('EXPIRE', stockKey, 86400) -- 24小时
redis.call('EXPIRE', userSetKey, 86400)八、总结
优惠券系统是经典的高并发场景,通过本文的方案,我们实现了:
- 性能:支撑万级QPS,响应时间<100ms
- 一致性:零超卖超领,数据最终一致
- 可用性:服务可用性99.95%
关键设计要点:
- 用Redis抗流量,数据库做持久化
- Lua脚本保证原子性
- 消息队列异步解耦
- 完善监控和对账机制
希望这篇文章对你有所帮助!
相关文章: