SpringBoot 搶券活動(dòng):Redis 熱點(diǎn) Key 三大防護(hù)
引言
在電商系統(tǒng)的搶券活動(dòng)中,經(jīng)常會(huì)出現(xiàn)某張熱門優(yōu)惠券被大量用戶同時(shí)訪問的情況,這就是典型的熱點(diǎn) Key 問題。這類問題會(huì)導(dǎo)致 Redis 負(fù)載過高,甚至可能引發(fā)緩存擊穿,大量請(qǐng)求直接打到數(shù)據(jù)庫,造成系統(tǒng)崩潰。
本文將從緩存擊穿、分片、異步化等角度,探討如何在項(xiàng)目中優(yōu)化 Redis 和數(shù)據(jù)庫的性能,以應(yīng)對(duì)搶券活動(dòng)中的熱點(diǎn) Key 問題。
熱點(diǎn) Key 問題分析
在搶券場(chǎng)景中,熱點(diǎn) Key 問題主要表現(xiàn)為:
- 當(dāng)該熱點(diǎn)
Key在Redis中過期時(shí),大量請(qǐng)求會(huì)同時(shí)穿透到數(shù)據(jù)庫,造成緩存擊穿 - 某張熱門優(yōu)惠券的訪問量遠(yuǎn)超其他優(yōu)惠券,導(dǎo)致
Redis單節(jié)點(diǎn)負(fù)載過高 - 數(shù)據(jù)庫瞬時(shí)承受巨大壓力,可能導(dǎo)致查詢超時(shí)甚至服務(wù)不可用
?
- 緩存擊穿:是指當(dāng)某一
key的緩存過期時(shí)大并發(fā)量的請(qǐng)求同時(shí)訪問此key,瞬間擊穿緩存服務(wù)器直接訪問數(shù)據(jù)庫,讓數(shù)據(jù)庫處于負(fù)載的情況。- 緩存穿透:是指緩存服務(wù)器中沒有緩存數(shù)據(jù),數(shù)據(jù)庫中也沒有符合條件的數(shù)據(jù),導(dǎo)致業(yè)務(wù)系統(tǒng)每次都繞過緩存服務(wù)器查詢下游的數(shù)據(jù)庫,緩存服務(wù)器完全失去了其應(yīng)有的作用。
- 緩存雪崩:是指當(dāng)大量緩存同時(shí)過期或緩存服務(wù)宕機(jī),所有請(qǐng)求的都直接訪問數(shù)據(jù)庫,造成數(shù)據(jù)庫高負(fù)載,影響性能,甚至數(shù)據(jù)庫宕機(jī)。
緩存擊穿的解決方案
分布式鎖
// 使用Redisson實(shí)現(xiàn)分布式鎖防止緩存擊穿
@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秒,鎖持有時(shí)間為10秒
boolean isLocked = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (isLocked) {
try {
// 再次檢查Redis中是否有值
coupon = (Coupon) redisTemplate.opsForValue().get(key);
if (coupon == null) {
// 從數(shù)據(jù)庫中查詢
coupon = couponDao.getCouponById(couponId);
if (coupon != null) {
// 設(shè)置帶過期時(shí)間的緩存
redisTemplate.opsForValue().set(key, coupon, 30, TimeUnit.MINUTES);
}
}
} finally {
// 釋放鎖
lock.unlock();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return coupon;
}
}熱點(diǎn) Key 分片處理
當(dāng)單個(gè)熱點(diǎn) Key 的訪問量極高時(shí),可以采用分片策略將請(qǐng)求分散到多個(gè) Redis 節(jié)點(diǎn)上:
// 熱點(diǎn)Key分片處理實(shí)現(xiàn)
@Service
public class CouponService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private CouponDao couponDao;
// 分片數(shù)量
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) {
// 計(jì)算每個(gè)分片的庫存
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);
}
}
// 扣減庫存(嘗試從隨機(jī)分片獲?。? public boolean deductStock(String couponId) {
// 隨機(jī)選擇一個(gè)分片
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;
}
}根據(jù)分片負(fù)載動(dòng)態(tài)選擇
// 動(dòng)態(tài)分片選擇(根據(jù)剩余庫存)
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) {
// 對(duì)選中的分片進(jìn)行扣減
String key = getShardedKey(couponId, maxStockIndex);
// 執(zhí)行Lua腳本扣減庫存...
}
returnfalse;
}異步化處理
// 異步化處理搶券請(qǐng)求
@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) {
// 庫存扣減成功,發(fā)送消息到MQ異步處理
CouponGrabMessage message = new CouponGrabMessage(userId, couponId);
rabbitTemplate.convertAndSend("coupon.exchange", "coupon.grab", message);
returntrue;
}
returnfalse;
}
// 異步處理搶券結(jié)果
@RabbitListener(queues = "coupon.grab.queue")
public void handleCouponGrab(CouponGrabMessage message) {
try {
// 在數(shù)據(jù)庫中記錄用戶領(lǐng)取優(yōu)惠券的信息
couponDao.recordUserCoupon(message.getUserId(), message.getCouponId());
// 可以在這里添加其他業(yè)務(wù)邏輯,如發(fā)送通知等
} catch (Exception e) {
// 處理失敗,可以記錄日志或進(jìn)行補(bǔ)償操作
log.error("Failed to handle coupon grab for user: {}, coupon: {}",
message.getUserId(), message.getCouponId(), e);
// 回滾Redis中的庫存(這里簡(jiǎn)化處理,實(shí)際中可能需要更復(fù)雜的補(bǔ)償機(jī)制)
String stockKey = "coupon:" + message.getCouponId() + ":stock";
redisTemplate.opsForValue().increment(stockKey);
}
}
}其他優(yōu)化策略
本地緩存
// 使用Caffeine實(shí)現(xiàn)本地緩存
@Service
public class CouponService {
// 本地緩存,最大容量100,過期時(shí)間5分鐘
private LoadingCache<String, Coupon> localCache = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(this::loadCouponFromRedis);
// 從Redis加載優(yōu)惠券信息
private Coupon loadCouponFromRedis(String couponId) {
String key = "coupon:" + couponId;
return (Coupon) redisTemplate.opsForValue().get(key);
}
// 獲取優(yōu)惠券信息
public Coupon getCoupon(String couponId) {
try {
return localCache.get(couponId);
} catch (ExecutionException e) {
// 處理異常,從其他地方獲取數(shù)據(jù)
return loadCouponFromRedis(couponId);
}
}
}限流
// 使用Sentinel實(shí)現(xiàn)熱點(diǎn)參數(shù)限流
@Service
public class CouponService {
// 定義熱點(diǎn)參數(shù)限流規(guī)則
static {
initFlowRules();
}
private static void initFlowRules() {
List<ParamFlowRule> rules = new ArrayList<>();
ParamFlowRule rule = new ParamFlowRule();
rule.setResource("getCoupon");
rule.setParamIdx(0); // 第一個(gè)參數(shù)作為限流參數(shù)
rule.setCount(1000); // 每秒允許的請(qǐng)求數(shù)
// 針對(duì)特定值的限流設(shè)置
ParamFlowItem item = new ParamFlowItem();
item.setObject("hotCouponId1");
item.setClassType(String.class.getName());
item.setCount(500); // 針對(duì)熱點(diǎn)優(yōu)惠券ID的特殊限流
rule.getParamFlowItemList().add(item);
rules.add(rule);
ParamFlowRuleManager.loadRules(rules);
}
// 帶限流的獲取優(yōu)惠券方法
public Coupon getCoupon(String couponId) {
Entry entry = null;
try {
// 資源名可使用方法名
entry = SphU.entry("getCoupon", EntryType.IN, 1, couponId);
// 業(yè)務(wù)邏輯
return getCouponFromRedis(couponId);
} catch (BlockException ex) {
// 資源訪問阻止,被限流或降級(jí)
// 進(jìn)行相應(yīng)的處理操作
return getDefaultCoupon();
} finally {
if (entry != null) {
entry.exit();
}
}
}
}實(shí)施建議
- 對(duì)優(yōu)惠券系統(tǒng)進(jìn)行分層設(shè)計(jì),將熱點(diǎn)數(shù)據(jù)與普通數(shù)據(jù)分離處理
- 監(jiān)控
Redis的性能指標(biāo),及時(shí)發(fā)現(xiàn)和處理熱點(diǎn)Key - 提前對(duì)可能的熱點(diǎn)
Key進(jìn)行預(yù)判和預(yù)熱 - 設(shè)計(jì)完善的降級(jí)和熔斷策略,保障系統(tǒng)在極端情況下的可用性
- 定期進(jìn)行全鏈路壓測(cè),發(fā)現(xiàn)系統(tǒng)瓶頸并持續(xù)優(yōu)化
總結(jié)
在搶券活動(dòng)等高并發(fā)場(chǎng)景下,熱點(diǎn) Key 問題是 Redis 和數(shù)據(jù)庫面臨的主要挑戰(zhàn)之一。通過采用緩存擊穿預(yù)防、熱點(diǎn) Key 分片、異步化處理、本地緩存和限流等多種優(yōu)化策略,可以有效提升系統(tǒng)的性能和穩(wěn)定性。
在實(shí)際應(yīng)用中,應(yīng)根據(jù)具體業(yè)務(wù)場(chǎng)景選擇合適的優(yōu)化方案,并進(jìn)行充分的性能測(cè)試和壓力測(cè)試,確保系統(tǒng)在高并發(fā)情況下依然能夠穩(wěn)定運(yùn)行。





























