目录

高并发优惠券系统设计与防超卖方案:从理论到实战

前言

在Yato跨境电商平台的优惠券模块开发中,我们面临了一个经典的高并发问题:如何在保证数据一致性的前提下,支撑万级QPS的优惠券发放?

本文将详细记录我设计的优惠券系统架构,以及如何通过Redis预扣减、分布式锁、Lua脚本等技术手段解决超卖超领问题。

系统架构总览

优惠券领取时序图

一、业务场景分析

1.1 系统规模

  • 日均活跃用户:50万+
  • 大促峰值QPS:10000+
  • 优惠券库存:单张优惠券10万+张
  • 并发领取场景:整点秒杀、新人礼包

1.2 核心挑战

  1. 超卖问题:库存100张,不能发出去101张
  2. 超领问题:每人限领1张,不能领到2张
  3. 高并发性能:万级QPS下响应时间<100ms
  4. 数据一致性: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);
    }
}

问题分析:

  • ❌ 数据库压力大:每个请求都要查询、更新数据库
  • ❌ 并发问题:SELECTUPDATE之间有时间窗口,可能导致超卖
  • ❌ 性能差:数据库连接池成为瓶颈,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 NX

3.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(至少一次投递) -> 数据库(最终一致)

异常处理机制:

  1. Redis扣减成功,MQ发送失败

    • 同步发送改为异步发送+本地事务表
    • 定时任务扫描本地表,补偿发送
  2. MQ消费失败

    • 消息重试(默认16次)
    • 进入死信队列,人工处理
  3. 数据库写入失败

    • 事务回滚
    • 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 优化策略

  1. 连接池优化
spring:
  redis:
    lettuce:
      pool:
        max-active: 100
        max-idle: 50
        min-idle: 20
  1. 批量操作
// 批量获取库存(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());
}
  1. 本地缓存(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)

八、总结

优惠券系统是经典的高并发场景,通过本文的方案,我们实现了:

  1. 性能:支撑万级QPS,响应时间<100ms
  2. 一致性:零超卖超领,数据最终一致
  3. 可用性:服务可用性99.95%

关键设计要点:

  • 用Redis抗流量,数据库做持久化
  • Lua脚本保证原子性
  • 消息队列异步解耦
  • 完善监控和对账机制

希望这篇文章对你有所帮助!


相关文章: