分布式抽奖系统并发控制:Redis锁与库存防超卖实战

发布时间:2026/7/16 2:17:49
分布式抽奖系统并发控制:Redis锁与库存防超卖实战 最近在开发一个抽奖活动系统时遇到了一个很有意思的问题用户参与抽奖后系统偶尔会显示误闯天家……这样的提示。这个看似玄幻的提示背后其实隐藏着分布式系统下的并发控制难题。今天我们就来深入剖析这个问题的技术本质并给出完整的解决方案。1. 抽奖系统的并发陷阱误闯天家这个提示在技术层面通常意味着用户触发了系统的异常处理逻辑。在抽奖系统中最常见的情况是库存超卖——当多个请求同时扣减同一个奖品库存时由于并发控制不当导致实际发放的奖品数量超过了库存上限。想象这样一个场景某热门商品限量100件活动开始瞬间涌入10万用户点击。如果没有完善的并发控制数据库中的库存字段可能在极短时间内被重复扣减最终出现负数库存而系统已经发放了远超100件的奖品。这种问题在传统单体应用中可能通过简单的同步锁解决但在分布式架构下多个应用实例同时访问同一个数据库传统的Java synchronized或ReentrantLock就失效了。这就是为什么我们需要更高级的并发控制方案。2. 并发控制的三种核心方案2.1 数据库悲观锁悲观锁的核心思想是先取锁后操作。在MySQL中我们可以使用SELECT ... FOR UPDATE语句在事务中锁定记录。-- 开始事务 START TRANSACTION; -- 锁定要操作的库存记录 SELECT stock FROM prize_inventory WHERE prize_id 1001 FOR UPDATE; -- 检查并扣减库存 UPDATE prize_inventory SET stock stock - 1 WHERE prize_id 1001 AND stock 0; -- 提交事务释放锁 COMMIT;这种方案的优点是实现简单但缺点也很明显在高并发场景下大量请求会阻塞在锁等待上可能导致数据库连接池耗尽。2.2 数据库乐观锁乐观锁基于版本号机制适合读多写少的场景。-- 先查询当前版本号 SELECT prize_id, stock, version FROM prize_inventory WHERE prize_id 1001; -- 基于版本号更新版本号变化则更新失败 UPDATE prize_inventory SET stock stock - 1, version version 1 WHERE prize_id 1001 AND stock 0 AND version #{oldVersion};乐观锁的性能较好但需要处理更新失败后的重试逻辑增加了业务复杂度。2.3 Redis分布式锁对于超高并发场景Redis分布式锁是最佳选择。我们使用Redisson客户端实现// 配置Redisson客户端 Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379); return Redisson.create(config); } } // 抽奖服务 Service public class LotteryService { Autowired private RedissonClient redissonClient; public LotteryResult participate(Long userId, Long activityId) { String lockKey lottery:lock: activityId : userId; RLock lock redissonClient.getLock(lockKey); try { // 尝试获取锁等待时间5秒锁有效期30秒 if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { return doLottery(userId, activityId); } else { throw new BusinessException(系统繁忙请稍后重试); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(抽奖中断); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }3. 完整抽奖系统架构设计一个健壮的抽奖系统需要多层次的防护。以下是核心架构设计3.1 系统架构图用户请求 → 网关层(限流) → 业务层(分布式锁) → 缓存层(库存预扣) → 数据库(最终一致性)3.2 核心领域模型设计// 抽奖活动实体 Entity Table(name lottery_activity) public class LotteryActivity { Id private Long id; private String name; private LocalDateTime startTime; private LocalDateTime endTime; // 活动状态0-未开始 1-进行中 2-已结束 3-已暂停 private Integer status; OneToMany(mappedBy activity) private ListPrizeInventory prizes; } // 奖品库存实体 Entity Table(name prize_inventory) public class PrizeInventory { Id private Long id; ManyToOne JoinColumn(name activity_id) private LotteryActivity activity; private String prizeName; private Integer totalStock; // 总库存 private Integer currentStock; // 当前库存 private Integer version; // 乐观锁版本号 } // 抽奖记录实体 Entity Table(name lottery_record) public class LotteryRecord { Id private Long id; private Long userId; private Long activityId; private Long prizeId; // 中奖状态0-未中奖 1-中奖 2-发放中 3-已发放 private Integer status; private LocalDateTime createTime; }4. 库存扣减的完整实现4.1 基于Redis的库存预扣减在高并发场景下我们使用Redis进行库存预扣减减轻数据库压力。Service public class PrizeInventoryService { Autowired private RedisTemplateString, String redisTemplate; Autowired private PrizeInventoryRepository inventoryRepository; /** * 预扣减库存 */ public boolean preDeductStock(Long prizeId) { String key prize:stock: prizeId; // 使用Lua脚本保证原子性 String luaScript local current redis.call(get, KEYS[1]) if current and tonumber(current) 0 then redis.call(decr, KEYS[1]) return 1 else return 0 end ; DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(luaScript); script.setResultType(Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key)); return result ! null result 0; } /** * 实际扣减数据库库存 */ Transactional public boolean actualDeductStock(Long prizeId) { PrizeInventory inventory inventoryRepository.findById(prizeId) .orElseThrow(() - new BusinessException(奖品不存在)); int updated inventoryRepository.deductStock(prizeId, inventory.getVersion()); return updated 0; } }4.2 数据库层乐观锁实现Repository public interface PrizeInventoryRepository extends JpaRepositoryPrizeInventory, Long { Modifying Query(UPDATE PrizeInventory p SET p.currentStock p.currentStock - 1, p.version p.version 1 WHERE p.id :prizeId AND p.currentStock 0 AND p.version :version) int deductStock(Param(prizeId) Long prizeId, Param(version) Integer version); }5. 抽奖核心业务流程5.1 抽奖服务主流程Service Slf4j public class LotteryCoreService { Autowired private PrizeInventoryService inventoryService; Autowired private LotteryRecordService recordService; Autowired private RedissonClient redissonClient; public LotteryResult executeLottery(LotteryRequest request) { // 1. 基础校验 validateRequest(request); // 2. 分布式锁控制 String lockKey buildLockKey(request.getUserId(), request.getActivityId()); RLock lock redissonClient.getLock(lockKey); try { if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) { throw new BusinessException(操作过于频繁请稍后重试); } // 3. 检查参与资格 if (!checkQualification(request)) { return LotteryResult.fail(不满足参与条件); } // 4. 执行抽奖逻辑 return doLottery(request); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(抽奖中断); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } private LotteryResult doLottery(LotteryRequest request) { // 预扣减Redis库存 if (!inventoryService.preDeductStock(request.getPrizeId())) { return LotteryResult.fail(奖品已抢光); } try { // 实际扣减数据库库存 if (!inventoryService.actualDeductStock(request.getPrizeId())) { // 回滚Redis库存 inventoryService.rollbackStock(request.getPrizeId()); return LotteryResult.fail(库存扣减失败); } // 生成中奖记录 LotteryRecord record createWinningRecord(request); return LotteryResult.success(record); } catch (Exception e) { // 异常时回滚库存 inventoryService.rollbackStock(request.getPrizeId()); log.error(抽奖过程异常, e); throw new BusinessException(抽奖失败请重试); } } }5.2 抽奖算法实现根据不同的业务需求我们可以实现多种抽奖算法Component public class LotteryAlgorithm { /** * 概率抽奖算法 */ public Prize probabilityLottery(ListPrize prizes) { double totalProbability prizes.stream() .mapToDouble(Prize::getProbability) .sum(); if (Math.abs(totalProbability - 1.0) 0.0001) { throw new BusinessException(奖品概率总和必须为1); } double random ThreadLocalRandom.current().nextDouble(); double current 0.0; for (Prize prize : prizes) { current prize.getProbability(); if (random current) { return prize; } } return null; // 未中奖 } /** * 权重抽奖算法用于库存有限的场景 */ public Prize weightLottery(ListPrize prizes) { int totalWeight prizes.stream() .mapToInt(Prize::getWeight) .sum(); int random ThreadLocalRandom.current().nextInt(totalWeight); int current 0; for (Prize prize : prizes) { current prize.getWeight(); if (random current) { return prize; } } return null; } }6. 防刷与安全控制6.1 用户参与频率控制Service public class AntiSpamService { Autowired private RedisTemplateString, String redisTemplate; /** * 检查用户参与频率 */ public boolean checkFrequency(Long userId, Long activityId, Duration interval) { String key String.format(lottery:frequency:%s:%s, activityId, userId); // 使用SETNX实现原子性检查 Boolean success redisTemplate.opsForValue() .setIfAbsent(key, 1, interval); return Boolean.TRUE.equals(success); } /** * IP频率控制 */ public boolean checkIpFrequency(String ip, Long activityId, int maxAttempts, Duration window) { String key String.format(lottery:ip:%s:%s, activityId, ip); Long count redisTemplate.opsForValue().increment(key, 1); if (count ! null count 1) { redisTemplate.expire(key, window); } return count ! null count maxAttempts; } }6.2 验证码与人机校验对于重要活动建议增加验证码环节Service public class CaptchaService { /** * 生成图形验证码 */ public CaptchaResult generateCaptcha(String sessionId) { // 使用第三方验证码服务或自研实现 String code generateRandomCode(4); String imageBase64 generateImageBase64(code); // 存储验证码到Redis5分钟过期 redisTemplate.opsForValue() .set(captcha: sessionId, code, Duration.ofMinutes(5)); return new CaptchaResult(imageBase64, sessionId); } /** * 验证验证码 */ public boolean verifyCaptcha(String sessionId, String userInput) { String storedCode redisTemplate.opsForValue() .get(captcha: sessionId); if (storedCode null) { return false; // 验证码已过期 } // 验证成功后删除验证码 redisTemplate.delete(captcha: sessionId); return storedCode.equalsIgnoreCase(userInput); } }7. 性能优化与缓存策略7.1 多级缓存设计Service Slf4j public class LotteryCacheService { Autowired private RedisTemplateString, Object redisTemplate; Autowired private LotteryActivityRepository activityRepository; // 本地缓存Caffeine private CacheString, Object localCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(1000) .build(); /** * 获取活动信息多级缓存 */ public LotteryActivity getActivity(Long activityId) { String localKey activity: activityId; String redisKey cache:activity: activityId; // 1. 尝试本地缓存 LotteryActivity activity (LotteryActivity) localCache.getIfPresent(localKey); if (activity ! null) { return activity; } // 2. 尝试Redis缓存 activity (LotteryActivity) redisTemplate.opsForValue().get(redisKey); if (activity ! null) { // 回填本地缓存 localCache.put(localKey, activity); return activity; } // 3. 查询数据库 activity activityRepository.findById(activityId) .orElseThrow(() - new BusinessException(活动不存在)); // 4. 写入缓存 redisTemplate.opsForValue().set(redisKey, activity, Duration.ofHours(1)); localCache.put(localKey, activity); return activity; } }7.2 数据库连接池优化# application.yml spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 redis: lettuce: pool: max-active: 20 max-idle: 10 min-idle: 58. 监控与告警体系8.1 关键指标监控Component public class LotteryMetrics { private final MeterRegistry meterRegistry; // 定义关键指标 private final Counter participationCounter; private final Counter successCounter; private final Counter failureCounter; private final Timer lotteryTimer; public LotteryMetrics(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; this.participationCounter Counter.builder(lottery.participation) .description(抽奖参与次数) .register(meterRegistry); this.successCounter Counter.builder(lottery.success) .description(抽奖成功次数) .register(meterRegistry); this.failureCounter Counter.builder(lottery.failure) .description(抽奖失败次数) .register(meterRegistry); this.lotteryTimer Timer.builder(lottery.duration) .description(抽奖耗时) .register(meterRegistry); } public void recordParticipation() { participationCounter.increment(); } public void recordSuccess() { successCounter.increment(); } public Timer.Sample startTimer() { return Timer.start(meterRegistry); } public void stopTimer(Timer.Sample sample) { sample.stop(lotteryTimer); } }8.2 日志记录与追踪Aspect Component Slf4j public class LotteryLogAspect { Around(execution(* com.example.lottery.service..*.*(..))) public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().getName(); String className joinPoint.getTarget().getClass().getSimpleName(); long startTime System.currentTimeMillis(); try { Object result joinPoint.proceed(); long duration System.currentTimeMillis() - startTime; log.info({}#{} executed in {} ms, className, methodName, duration); return result; } catch (Exception e) { log.error(Error in {}#{}: {}, className, methodName, e.getMessage()); throw e; } } }9. 常见问题排查手册9.1 并发问题排查问题现象可能原因排查方式解决方案库存超卖分布式锁失效或未使用锁检查日志中的锁获取记录确保所有库存操作都在分布式锁内重复中奖用户重复请求未去重检查用户参与记录去重逻辑增加请求幂等性校验性能瓶颈数据库锁竞争激烈监控数据库锁等待时间使用Redis预扣减异步落库9.2 数据一致性保障Service public class DataConsistencyService { /** * 定期对账任务 */ Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void reconciliation() { // 检查Redis库存与数据库库存的一致性 ListLong prizeIds prizeInventoryRepository.findAllIds(); for (Long prizeId : prizeIds) { Integer dbStock getDatabaseStock(prizeId); Integer redisStock getRedisStock(prizeId); if (!dbStock.equals(redisStock)) { log.warn(库存不一致: prizeId{}, dbStock{}, redisStock{}, prizeId, dbStock, redisStock); // 以数据库为准修复Redis库存 fixRedisStock(prizeId, dbStock); } } } }通过以上完整的技术方案我们可以彻底解决误闯天家这类并发问题构建出高可用、高并发的抽奖系统。在实际项目中建议根据具体业务场景选择合适的方案组合并在上线前进行充分的压力测试。