SpringBoot 抢券活动:Redis 热点 Key 三大防护

引言

在电商系统的抢券活动中,经常会出现某张热门优惠券被大量用户同时访问的情况,这就是典型的热点 Key 问题。这类问题会导致 Redis 负载过高,甚至可能引发缓存击穿,大量请求直接打到数据库,造成系统崩溃。

本文将从缓存击穿、分片、异步化等角度,探讨如何在项目中优化 Redis 和数据库的性能,以应对抢券活动中的热点 Key 问题。

热点 Key 问题分析

在抢券场景中,热点 Key 问题主要表现为:

当该热点 Key 在 Redis 中过期时,大量请求会同时穿透到数据库,造成缓存击穿某张热门优惠券的访问量远超其他优惠券,导致 Redis 单节点负载过高数据库瞬时承受巨大压力,可能导致查询超时甚至服务不可用

缓存击穿:是指当某一key的缓存过期时大并发量的请求同时访问此key,瞬间击穿缓存服务器直接访问数据库,让数据库处于负载的情况。缓存穿透:是指缓存服务器中没有缓存数据,数据库中也没有符合条件的数据,导致业务系统每次都绕过缓存服务器查询下游的数据库,缓存服务器完全失去了其应有的作用。缓存雪崩:是指当大量缓存同时过期或缓存服务宕机,所有请求的都直接访问数据库,造成数据库高负载,影响性能,甚至数据库宕机。缓存击穿的解决方案分布式锁
复制
// 使用Redisson实现分布式锁防止缓存击穿 @Service public class CouponService { @Autowired private RedissonClient redissonClient; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private CouponDao couponDao; public Coupon getCoupon(String couponId) { String key = "coupon:" + couponId; Coupon coupon = (Coupon) redisTemplate.opsForValue().get(key); if (coupon == null) { // 获取分布式锁 RLock lock = redissonClient.getLock("lock:coupon:" + couponId); try { // 尝试加锁,最多等待100秒,锁持有时间为10秒 boolean isLocked = lock.tryLock(100, 10, TimeUnit.SECONDS); if (isLocked) { try { // 再次检查Redis中是否有值 coupon = (Coupon) redisTemplate.opsForValue().get(key); if (coupon == null) { // 从数据库中查询 coupon = couponDao.getCouponById(couponId); if (coupon != null) { // 设置带过期时间的缓存 redisTemplate.opsForValue().set(key, coupon, 30, TimeUnit.MINUTES); } } } finally { // 释放锁 lock.unlock(); } } } catch (Exception e) { e.printStackTrace(); } } return coupon; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.
热点 Key 分片处理

当单个热点 Key 的访问量极高时,可以采用分片策略将请求分散到多个 Redis 节点上:

复制
// 热点Key分片处理实现 @Service public class CouponService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private CouponDao couponDao; // 分片数量 private static final int SHARD_COUNT = 16; // 获取分片后的Key private String getShardedKey(String couponId, int shardIndex) { return"coupon:" + couponId + ":shard" + shardIndex; } // 初始化分片缓存 public void initCouponShards(String couponId, int stock) { // 计算每个分片的库存 int stockPerShard = stock / SHARD_COUNT; int remaining = stock % SHARD_COUNT; for (int i = 0; i < SHARD_COUNT; i++) { int currentStock = stockPerShard + (i < remaining ? 1 : 0); String key = getShardedKey(couponId, i); redisTemplate.opsForValue().set(key, currentStock); } } // 扣减库存(尝试从随机分片获取) public boolean deductStock(String couponId) { // 随机选择一个分片 int shardIndex = new Random().nextInt(SHARD_COUNT); String key = getShardedKey(couponId, shardIndex); // 使用Lua脚本原子性地扣减库存 String script = "local stock = tonumber(redis.call(get, KEYS[1])) " + "if stock and stock > 0 then " + " redis.call(decr, KEYS[1]) " + " return 1 " + "else " + " return 0 " + "end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(script); redisScript.setResultType(Long.class); Long result = redisTemplate.execute(redisScript, Collections.singletonList(key)); return result != null && result == 1; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.
根据分片负载动态选择
复制
// 动态分片选择(根据剩余库存) public boolean deductStockByDynamicShard(String couponId) { // 获取所有分片的库存 List<String> keys = new ArrayList<>(); for (int i = 0; i < SHARD_COUNT; i++) { keys.add(getShardedKey(couponId, i)); } // 使用MGET批量获取所有分片库存 List<Object> results = redisTemplate.opsForValue().multiGet(keys); // 选择库存最多的分片 int maxStockIndex = -1; int maxStock = 0; for (int i = 0; i < results.size(); i++) { if (results.get(i) != null) { int stock = Integer.parseInt(results.get(i).toString()); if (stock > maxStock) { maxStock = stock; maxStockIndex = i; } } } if (maxStockIndex >= 0) { // 对选中的分片进行扣减 String key = getShardedKey(couponId, maxStockIndex); // 执行Lua脚本扣减库存... } returnfalse; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.
异步化处理
复制
// 异步化处理抢券请求 @Service public class CouponService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private CouponDao couponDao; @Autowired private RabbitTemplate rabbitTemplate; // 抢券接口 - 快速返回,异步处理 public boolean grabCoupon(String userId, String couponId) { // 先快速检查Redis中是否有库存 String stockKey = "coupon:" + couponId + ":stock"; Long stock = (Long) redisTemplate.opsForValue().get(stockKey); if (stock == null || stock <= 0) { returnfalse; } // 使用Lua脚本原子性地扣减库存 String script = "local stock = tonumber(redis.call(get, KEYS[1])) " + "if stock and stock > 0 then " + " redis.call(decr, KEYS[1]) " + " return 1 " + "else " + " return 0 " + "end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(script); redisScript.setResultType(Long.class); Long result = redisTemplate.execute(redisScript, Collections.singletonList(stockKey)); if (result != null && result == 1) { // 库存扣减成功,发送消息到MQ异步处理 CouponGrabMessage message = new CouponGrabMessage(userId, couponId); rabbitTemplate.convertAndSend("coupon.exchange", "coupon.grab", message); returntrue; } returnfalse; } // 异步处理抢券结果 @RabbitListener(queues = "coupon.grab.queue") public void handleCouponGrab(CouponGrabMessage message) { try { // 在数据库中记录用户领取优惠券的信息 couponDao.recordUserCoupon(message.getUserId(), message.getCouponId()); // 可以在这里添加其他业务逻辑,如发送通知等 } catch (Exception e) { // 处理失败,可以记录日志或进行补偿操作 log.error("Failed to handle coupon grab for user: {}, coupon: {}", message.getUserId(), message.getCouponId(), e); // 回滚Redis中的库存(这里简化处理,实际中可能需要更复杂的补偿机制) String stockKey = "coupon:" + message.getCouponId() + ":stock"; redisTemplate.opsForValue().increment(stockKey); } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.
其他优化策略本地缓存
复制
// 使用Caffeine实现本地缓存 @Service public class CouponService { // 本地缓存,最大容量100,过期时间5分钟 private LoadingCache<String, Coupon> localCache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(5, TimeUnit.MINUTES) .build(this::loadCouponFromRedis); // 从Redis加载优惠券信息 private Coupon loadCouponFromRedis(String couponId) { String key = "coupon:" + couponId; return (Coupon) redisTemplate.opsForValue().get(key); } // 获取优惠券信息 public Coupon getCoupon(String couponId) { try { return localCache.get(couponId); } catch (ExecutionException e) { // 处理异常,从其他地方获取数据 return loadCouponFromRedis(couponId); } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.
限流
复制
// 使用Sentinel实现热点参数限流 @Service public class CouponService { // 定义热点参数限流规则 static { initFlowRules(); } private static void initFlowRules() { List<ParamFlowRule> rules = new ArrayList<>(); ParamFlowRule rule = new ParamFlowRule(); rule.setResource("getCoupon"); rule.setParamIdx(0); // 第一个参数作为限流参数 rule.setCount(1000); // 每秒允许的请求数 // 针对特定值的限流设置 ParamFlowItem item = new ParamFlowItem(); item.setObject("hotCouponId1"); item.setClassType(String.class.getName()); item.setCount(500); // 针对热点优惠券ID的特殊限流 rule.getParamFlowItemList().add(item); rules.add(rule); ParamFlowRuleManager.loadRules(rules); } // 带限流的获取优惠券方法 public Coupon getCoupon(String couponId) { Entry entry = null; try { // 资源名可使用方法名 entry = SphU.entry("getCoupon", EntryType.IN, 1, couponId); // 业务逻辑 return getCouponFromRedis(couponId); } catch (BlockException ex) { // 资源访问阻止,被限流或降级 // 进行相应的处理操作 return getDefaultCoupon(); } finally { if (entry != null) { entry.exit(); } } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.

实施建议

对优惠券系统进行分层设计,将热点数据与普通数据分离处理监控 Redis 的性能指标,及时发现和处理热点 Key提前对可能的热点 Key 进行预判和预热设计完善的降级和熔断策略,保障系统在极端情况下的可用性定期进行全链路压测,发现系统瓶颈并持续优化

总结

在抢券活动等高并发场景下,热点 Key 问题是 Redis 和数据库面临的主要挑战之一。通过采用缓存击穿预防、热点 Key 分片、异步化处理、本地缓存和限流等多种优化策略,可以有效提升系统的性能和稳定性。

在实际应用中,应根据具体业务场景选择合适的优化方案,并进行充分的性能测试和压力测试,确保系统在高并发情况下依然能够稳定运行。

阅读剩余
THE END