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 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;
}
}
热点 Key 分片处理
当单个热点 Key
的访问量极高时,可以采用分片策略将请求分散到多个 Redis
节点上:
// 热点Key分片处理实现
@Service
public class CouponService {
@Autowired
private RedisTemplate 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 redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList(key));
return result != null && result == 1;
}
}
根据分片负载动态选择
// 动态分片选择(根据剩余库存)
public boolean deductStockByDynamicShard(String couponId) {
// 获取所有分片的库存
List keys = new ArrayList<>();
for (int i = 0; i < SHARD_COUNT; i++) {
keys.add(getShardedKey(couponId, i));
}
// 使用MGET批量获取所有分片库存
List
异步化处理
// 异步化处理抢券请求
@Service
public class CouponService {
@Autowired
private RedisTemplate 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 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);
}
}
}
其他优化策略
本地缓存
// 使用Caffeine实现本地缓存
@Service
public class CouponService {
// 本地缓存,最大容量100,过期时间5分钟
private LoadingCache 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);
}
}
}
限流
// 使用Sentinel实现热点参数限流
@Service
public class CouponService {
// 定义热点参数限流规则
static {
initFlowRules();
}
private static void initFlowRules() {
List 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();
}
}
}
}
实施建议
- 对优惠券系统进行分层设计,将热点数据与普通数据分离处理
- 监控
Redis
的性能指标,及时发现和处理热点Key
- 提前对可能的热点
Key
进行预判和预热 - 设计完善的降级和熔断策略,保障系统在极端情况下的可用性
- 定期进行全链路压测,发现系统瓶颈并持续优化
总结
在抢券活动等高并发场景下,热点 Key
问题是 Redis
和数据库面临的主要挑战之一。通过采用缓存击穿预防、热点 Key
分片、异步化处理、本地缓存和限流等多种优化策略,可以有效提升系统的性能和稳定性。
在实际应用中,应根据具体业务场景选择合适的优化方案,并进行充分的性能测试和压力测试,确保系统在高并发情况下依然能够稳定运行。