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;
}
}
熱點 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;
}
}
根據分片負載動態選擇
// 動態分片選擇(根據剩余庫存)
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;
}
異步化處理
// 異步化處理搶券請求
@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);
}
}
}
其他優化策略
本地緩存
// 使用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);
}
}
}
限流
// 使用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();
}
}
}
}
實施建議
- 對優惠券系統進行分層設計,將熱點數據與普通數據分離處理
- 監控
Redis
的性能指標,及時發現和處理熱點Key
- 提前對可能的熱點
Key
進行預判和預熱 - 設計完善的降級和熔斷策略,保障系統在極端情況下的可用性
- 定期進行全鏈路壓測,發現系統瓶頸并持續優化
總結
在搶券活動等高并發場景下,熱點 Key
問題是 Redis
和數據庫面臨的主要挑戰之一。通過采用緩存擊穿預防、熱點 Key
分片、異步化處理、本地緩存和限流等多種優化策略,可以有效提升系統的性能和穩定性。
在實際應用中,應根據具體業務場景選擇合適的優化方案,并進行充分的性能測試和壓力測試,確保系統在高并發情況下依然能夠穩定運行。