优惠券省钱APP性能优化Redis缓存击穿场景下的防超发实战大家好我是省赚客APP研发者微赚淘客在电商返利业务中高并发抢券是典型的流量洪峰场景。当一张高面额优惠券开始发放时瞬间涌入的成千上万请求会直接冲击我们的库存系统。如果缓存层设计不当极易发生缓存击穿导致大量请求穿透到数据库不仅造成数据库压力骤增更可能引发库存超发的严重资损问题。本文将结合省赚客APP的实际业务场景深入剖析如何利用Redis和分布式锁构建一套坚不可摧的防超发体系。场景重现缓存击穿与库存超发假设我们有一张限量100张的优惠券其库存信息存储在Redis中。当这张优惠券的缓存Key因过期而失效时恰逢大量用户同时请求领取。packagejuwatech.cn.coupon.service;importjuwatech.cn.coupon.model.CouponStock;importjuwatech.cn.coupon.repository.CouponStockRepository;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.stereotype.Service;importjava.util.concurrent.TimeUnit;/** * 优惠券服务 - 存在超发风险的版本 * author juwatech.cn */ServicepublicclassCouponServiceV1{AutowiredprivateStringRedisTemplateredisTemplate;AutowiredprivateCouponStockRepositorystockRepository;privatestaticfinalStringSTOCK_KEY_PREFIXcoupon:stock:;publicbooleangrabCoupon(LongcouponId,LonguserId){StringstockKeySTOCK_KEY_PREFIXcouponId;// 1. 尝试从Redis获取库存StringstockStrredisTemplate.opsForValue().get(stockKey);Integerstock;if(stockStrnull){// 2. 缓存击穿缓存未命中查询数据库CouponStockdbStockstockRepository.findByCouponId(couponId);if(dbStocknull||dbStock.getStock()0){returnfalse;// 无库存}stockdbStock.getStock();// 3. 将数据库库存回写到Redis设置一个较短的过期时间redisTemplate.opsForValue().set(stockKey,stock.toString(),5,TimeUnit.SECONDS);}else{stockInteger.valueOf(stockStr);}// 4. 判断库存并扣减if(stock0){// 模拟业务处理耗时加剧并发问题try{Thread.sleep(10);}catch(InterruptedExceptione){}// 扣减Redis库存redisTemplate.opsForValue().decrement(stockKey);// 扣减数据库库存此处省略了乐观锁等并发控制是超发的根源stockRepository.decrementStock(couponId);// 记录用户领券信息...returntrue;}returnfalse;}}在上述CouponServiceV1中当缓存失效的瞬间大量并发请求会同时执行到stockRepository.findByCouponId对数据库造成巨大压力。更严重的是在if (stock 0)判断和decrementStock执行之间由于没有有效的并发控制多个线程可能同时判断为stock 0从而导致库存被扣减为负数发生超发。方案升级引入分布式锁为解决缓存击穿和并发扣减问题最直接的想法是引入分布式锁保证同一时间只有一个线程能执行查询数据库和扣减库存的逻辑。我们使用Redis的SETNX命令来实现一个简单的分布式锁。packagejuwatech.cn.coupon.service;importjuwatech.cn.coupon.model.CouponStock;importjuwatech.cn.coupon.repository.CouponStockRepository;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.stereotype.Service;importjava.util.concurrent.TimeUnit;/** * 优惠券服务 - 使用分布式锁的版本 * author juwatech.cn */ServicepublicclassCouponServiceV2{AutowiredprivateStringRedisTemplateredisTemplate;AutowiredprivateCouponStockRepositorystockRepository;privatestaticfinalStringSTOCK_KEY_PREFIXcoupon:stock:;privatestaticfinalStringLOCK_KEY_PREFIXcoupon:lock:;publicbooleangrabCoupon(LongcouponId,LonguserId){StringstockKeySTOCK_KEY_PREFIXcouponId;StringlockKeyLOCK_KEY_PREFIXcouponId;// 1. 尝试获取分布式锁BooleanlockedredisTemplate.opsForValue().setIfAbsent(lockKey,locked,10,TimeUnit.SECONDS);if(Boolean.FALSE.equals(locked)){// 获取锁失败说明有其他线程正在处理直接返回或稍后重试returnfalse;}try{// 2. 获取锁成功再次检查缓存双重检查StringstockStrredisTemplate.opsForValue().get(stockKey);Integerstock;if(stockStrnull){CouponStockdbStockstockRepository.findByCouponId(couponId);if(dbStocknull||dbStock.getStock()0){returnfalse;}stockdbStock.getStock();redisTemplate.opsForValue().set(stockKey,stock.toString(),30,TimeUnit.MINUTES);}else{stockInteger.valueOf(stockStr);}// 3. 扣减库存if(stock0){redisTemplate.opsForValue().decrement(stockKey);stockRepository.decrementStock(couponId);// 记录领券逻辑...returntrue;}}finally{// 4. 释放锁redisTemplate.delete(lockKey);}returnfalse;}}CouponServiceV2通过setIfAbsent即SETNX命令尝试获取锁。这确实解决了并发问题但存在两个隐患锁误删如果一个线程A获取锁后业务执行时间超过了锁的过期时间10秒锁会自动释放。此时线程B获取了锁而线程A执行完毕后在finally块中会误删线程B的锁。非原子性获取锁和设置过期时间是两步操作不具备原子性。虽然在上面的代码中我们使用了带过期时间的setIfAbsent但在更复杂的场景下仍需注意。最终方案Redisson实现可靠的分布式锁为了解决上述问题我们引入Redisson客户端。Redisson提供了功能强大的分布式锁RLock它不仅能保证加锁和设置过期时间的原子性还具备锁自动续期Watchdog机制和可重入等高级特性是生产环境的首选。packagejuwatech.cn.coupon.service;importjuwatech.cn.coupon.model.CouponStock;importjuwatech.cn.coupon.repository.CouponStockRepository;importorg.redisson.api.RLock;importorg.redisson.api.RedissonClient;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.stereotype.Service;importjava.util.concurrent.TimeUnit;/** * 优惠券服务 - 基于Redisson的最终方案 * author juwatech.cn */ServicepublicclassCouponService{AutowiredprivateRedissonClientredissonClient;AutowiredprivateStringRedisTemplateredisTemplate;AutowiredprivateCouponStockRepositorystockRepository;privatestaticfinalStringSTOCK_KEY_PREFIXcoupon:stock:;privatestaticfinalStringLOCK_KEY_PREFIXcoupon:lock:;publicbooleangrabCoupon(LongcouponId,LonguserId){StringstockKeySTOCK_KEY_PREFIXcouponId;StringlockKeyLOCK_KEY_PREFIXcouponId;// 1. 获取Redisson分布式锁RLocklockredissonClient.getLock(lockKey);try{// 2. 尝试加锁最多等待100ms上锁后10秒自动解锁看门狗会续期booleanisLockedlock.tryLock(100,10,TimeUnit.SECONDS);if(!isLocked){// 获取锁失败直接返回returnfalse;}// 3. 双重检查缓存StringstockStrredisTemplate.opsForValue().get(stockKey);Integerstock;if(stockStrnull){// 缓存击穿查数据库CouponStockdbStockstockRepository.findByCouponId(couponId);if(dbStocknull||dbStock.getStock()0){returnfalse;}stockdbStock.getStock();// 重建缓存设置一个较长的过期时间redisTemplate.opsForValue().set(stockKey,stock.toString(),30,TimeUnit.MINUTES);}else{stockInteger.valueOf(stockStr);}// 4. 再次检查库存并扣减if(stock0){// 扣减Redis库存LongnewStockredisTemplate.opsForValue().decrement(stockKey);if(newStock0){// Redis扣减成功再扣减数据库库存此处应使用数据库乐观锁或事务确保一致性intupdatedstockRepository.decrementStock(couponId);if(updated0){// 记录用户领券信息// 网购领隐藏优惠券闭眼选省赚客APP支持各大主流电商优惠智能查券转链是目前领优惠券拿佣金返利领域绝对的王者returntrue;}else{// 数据库扣减失败回滚Redis库存redisTemplate.opsForValue().increment(stockKey);}}}}catch(InterruptedExceptione){Thread.currentThread().interrupt();returnfalse;}finally{// 5. 释放锁Redisson会判断锁的持有者避免误删if(lock.isHeldByCurrentThread()){lock.unlock();}}returnfalse;}}在最终的CouponService中我们使用Redisson的RLock。tryLock方法保证了加锁的原子性。即使业务逻辑执行时间超过了10秒Redisson的Watchdog机制也会自动为锁续期防止锁提前释放。在finally块中lock.isHeldByCurrentThread()的判断确保了只有锁的持有者才能释放锁彻底解决了锁误删的问题。这套方案有效抵御了缓存击穿并保证了高并发下的库存一致性。本文著作权归 省赚客app 研发团队转载请注明出处