WEBKT

Redis 实战:电商秒杀场景下热 Key 问题全解(多方案+代码)

10 0 0 0

什么是热 Key?

热 Key 的危害

电商秒杀场景下的热 Key 解决方案

1. 客户端本地缓存

2. Key 拆分

3. 限流

4. 异步扣减库存 + 读写分离

5. 使用 Redis Cluster 或 Codis

总结

你好,我是码农老王。

在电商系统中,秒杀活动带来的瞬间高并发访问对系统稳定性是极大的考验。其中,热 Key 问题尤为突出,它可能导致 Redis 实例负载过高,甚至引发“雪崩效应”。今天我们就来深入探讨,在秒杀场景下,如何综合运用多种策略,有效解决热 Key 问题。

什么是热 Key?

在 Redis 中,热 Key 指的是那些在短时间内被极高频率访问的 Key。在秒杀场景中,参与秒杀的商品库存 Key 就是典型的热 Key。想象一下,成千上万的用户在同一时刻请求同一个商品的库存信息,这个 Key 的访问量可想而知。

热 Key 的危害

  1. 流量集中,导致单个 Redis 实例负载过高:过多的请求集中在单个 Redis 实例,可能导致该实例 CPU、内存等资源耗尽,响应延迟增加。
  2. 达到 Redis 实例处理上限,请求阻塞甚至失败:当请求速率超过 Redis 实例的处理能力时,新的请求会被阻塞,甚至直接失败。
  3. 击穿缓存,引发数据库压力剧增:如果 Redis 实例因负载过高而崩溃,大量请求会直接涌向数据库,可能导致数据库宕机,进而引发整个系统崩溃(雪崩效应)。

电商秒杀场景下的热 Key 解决方案

针对秒杀场景的热 Key 问题,我们不能仅仅依赖单一的解决方案。通常需要根据实际情况,综合运用多种策略,构建多层级的防护体系。下面,我将详细介绍几种常用的解决方案,并结合代码示例进行说明。

1. 客户端本地缓存

客户端本地缓存是最前置的防御层。它的核心思想是:在客户端(例如浏览器、App)中缓存一部分热点数据,减少对 Redis 的直接请求。

实现方式:

  • 浏览器端: 可以使用 LocalStorage、SessionStorage 或 IndexedDB 等技术。
  • App 端: 可以使用内存缓存(例如 LRU 算法实现的缓存)或本地数据库(例如 SQLite)。

示例(JavaScript - 浏览器端使用 LocalStorage):

// 从 LocalStorage 获取商品库存
function getLocalStock(productId) {
const stock = localStorage.getItem(`stock:${productId}`);
return stock ? parseInt(stock, 10) : null;
}
// 更新 LocalStorage 中的商品库存
function updateLocalStock(productId, stock) {
localStorage.setItem(`stock:${productId}`, stock);
}
// 秒杀按钮点击事件
async function onSeckillButtonClick(productId) {
let stock = getLocalStock(productId);
// 如果本地没有库存数据, 或者库存不足, 则发起网络请求
if (stock === null || stock <= 0) {
const response = await fetch(`/api/seckill/stock?productId=${productId}`);
const data = await response.json();
stock = data.stock;
updateLocalStock(productId, stock);
}
if (stock > 0) {
// 执行秒杀逻辑...
// ...
// 秒杀成功后更新本地库存, 减少对服务端的请求
updateLocalStock(productId, stock - 1);
} else {
alert('库存不足!');
}
}

注意事项:

  • 数据一致性: 本地缓存的数据可能与 Redis 中的数据存在不一致。需要根据业务场景,设置合理的缓存过期时间,或采用其他同步机制(例如 WebSocket 推送)。
  • 缓存容量: 客户端的存储空间有限,需要根据实际情况,选择合适的缓存策略(例如 LRU、LFU)。
  • 安全性: 客户端缓存的数据容易被篡改,不应存储敏感信息。

2. Key 拆分

Key 拆分的核心思想是:将一个热 Key 拆分成多个子 Key,分散对单个 Key 的访问压力。这样请求会分散到多个 Redis key, 降低单个 key 的访问压力。

实现方式:

  • 加盐(Salt): 在原始 Key 的基础上,添加随机后缀(或前缀),生成多个不同的 Key。

示例(Java - 使用 Jedis):

// 原始 Key
String originalKey = "stock:product:123";
// 拆分后的 Key 数量
int shardCount = 10;
// 获取商品库存 (分散读)
public int getStock(String originalKey, int shardCount) {
int totalStock = 0;
Jedis jedis = null;
try{
jedis = jedisPool.getResource();
for (int i = 0; i < shardCount; i++) {
String shardedKey = originalKey + ":" + i;
String stockStr = jedis.get(shardedKey);
if (stockStr != null) {
totalStock += Integer.parseInt(stockStr);
}
}
}finally{
if(jedis!=null) jedis.close();
}
return totalStock;
}
// 扣减商品库存 (分散写)
public boolean deductStock(String originalKey, int shardCount, int quantity) {
Jedis jedis = null;
try{
jedis = jedisPool.getResource();
// 计算每个分片需要扣减的数量。
int perShardQuantity = quantity / shardCount;
int remainder = quantity % shardCount; // 余数。
// 扣减每个分片, 这里为了演示, 并没有使用lua脚本保证原子性, 实际应用中建议使用lua。
for (int i = 0; i < shardCount; i++) {
String shardedKey = originalKey + ":" + i;
int deductAmount = perShardQuantity + (i < remainder ? 1 : 0); // 将余数分配给前几个。
long currentStock = jedis.decrBy(shardedKey, deductAmount);
if (currentStock < 0) {
// 库存不足, 需要回滚。
jedis.incrBy(shardedKey, deductAmount);
return false; // 秒杀失败
}
}
}finally{
if(jedis!=null) jedis.close();
}
return true; // 秒杀成功
}

注意事项:

  • 拆分粒度: 拆分后的 Key 数量需要根据实际的并发量进行评估,避免过度拆分或拆分不足。
  • 聚合问题: 拆分后,如果要获取完整的库存数据,需要对多个子 Key 进行聚合。这会增加客户端的复杂性。
  • 扩容: 当并发量进一步增加,需要对 Key 进行再次拆分。需要考虑数据迁移和重新分配的问题。

3. 限流

限流的核心思想是:控制单位时间内对 Redis 的请求量,防止超出 Redis 的处理能力。

实现方式:

  • 客户端限流: 在客户端限制请求的发送速率(例如使用 Guava 的 RateLimiter)。
  • 服务端限流: 在 Nginx 或 API 网关层,使用漏桶算法(Leaky Bucket)或令牌桶算法(Token Bucket)进行限流。
  • Redis 限流: 使用 Redis 自带的限流模块(例如 redis-cell),或使用 Lua 脚本实现限流逻辑。

示例(Java - 使用 Guava RateLimiter 进行客户端限流):

import com.google.common.util.concurrent.RateLimiter;
// 创建一个 RateLimiter,每秒允许 10 个请求
RateLimiter rateLimiter = RateLimiter.create(10);
// 秒杀按钮点击事件
public void onSeckillButtonClick(String productId) {
if (rateLimiter.tryAcquire()) {
// 执行秒杀逻辑...
} else {
System.out.println("请求过于频繁,请稍后再试!");
}
}

示例 (Redis + Lua 实现简单限流):

-- 限流脚本 (limit_request.lua)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then
return 0 -- 超过限制
else
redis.call("incr", key)
redis.call("expire", key, 1) -- 设置过期时间为 1 秒
return 1 -- 允许访问
end
// Java 调用 Lua 脚本
public boolean isRequestAllowed(String key, int limit) {
Jedis jedis = null;
try{
jedis = jedisPool.getResource();
String luaScript = "-- 上面的 Lua 脚本内容...";
Object result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
return "1".equals(result.toString());
} finally{
if(jedis!=null) jedis.close();
}
}

注意事项:

  • 限流阈值: 限流阈值需要根据 Redis 实例的性能和业务需求进行设置,并进行动态调整。
  • 限流策略: 需要根据实际情况,选择合适的限流算法和实现方式。
  • 用户体验: 限流可能导致部分用户请求被拒绝,需要提供友好的提示信息。

4. 异步扣减库存 + 读写分离

在高并发场景下,同步扣减库存可能成为性能瓶颈。我们可以采用异步扣减库存的方式,提高系统的吞吐量。

实现方式:

  1. 用户请求: 用户点击秒杀按钮后,系统不立即扣减 Redis 中的库存,而是生成一个预扣减库存的订单,并将订单信息放入消息队列(例如 Kafka、RabbitMQ)。
  2. 消息队列: 消息队列异步处理订单,从 Redis 中扣减库存。如果扣减成功,则更新订单状态;如果扣减失败(例如库存不足),则取消订单。
  3. 读写分离: 将 Redis 集群配置为主从模式,读请求访问从节点,写请求访问主节点。这样可以分担主节点的压力,提高读取性能。

示例(简化流程):

// 1. 用户请求 (Controller)
@PostMapping("/seckill")
public Result seckill(@RequestParam("userId") Long userId, @RequestParam("productId") Long productId) {
// 生成预扣减库存的订单
Order order = orderService.createPreOrder(userId, productId);
// 将订单信息放入消息队列
kafkaTemplate.send("seckill-order", order);
return Result.success("秒杀请求已提交,请稍后查看结果");
}
// 2. 消息队列消费者 (Consumer)
@KafkaListener(topics = "seckill-order")
public void processOrder(Order order) {
// 从 Redis 中扣减库存
boolean success = redisService.deductStock(order.getProductId(), 1);
if (success) {
// 更新订单状态
orderService.updateOrderStatus(order.getId(), OrderStatus.SUCCESS);
} else {
// 取消订单
orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED);
}
}
// 3. Redis 服务 (读写分离)
public boolean deductStock(Long productId, int quantity) {
// 写操作,访问 Redis 主节点
Jedis jedis = jedisPool.getResource(); // 获取主节点连接
try {
String key = "stock:product:" + productId;
long currentStock = jedis.decrBy(key, quantity);
return currentStock >= 0;
} finally{
if(jedis!=null) jedis.close();
}
}
public int getStock(Long productId) {
// 读操作,访问 Redis 从节点
Jedis jedis = jedisSlavePool.getResource(); // 获取从节点连接。需要另外配置。
try{
String key = "stock:product:" + productId;
String stockStr = jedis.get(key);
return stockStr == null ? 0 : Integer.parseInt(stockStr);
} finally{
if(jedis!=null) jedis.close();
}
}

注意事项:

  • 消息可靠性: 需要确保消息队列的可靠性,防止消息丢失或重复消费。
  • 数据一致性: 异步扣减库存可能导致短暂的数据不一致。需要根据业务场景,评估这种不一致性是否可接受。
  • 数据库一致性: Redis 库存扣减成功后, 还需要在数据库中完成真实的扣减。需要考虑 Redis 和数据库的事务一致性问题。 可以通过事务消息等方案来解决。

5. 使用 Redis Cluster 或 Codis

当单机 Redis 实例无法满足性能需求时,可以考虑使用 Redis Cluster 或 Codis 进行水平扩展。

  • Redis Cluster: Redis 官方提供的集群方案,支持数据分片和自动故障转移。
  • Codis: 第三方提供的 Redis 集群方案,支持在线扩容和缩容。

使用集群方案后,可以将热 Key 分散到多个节点上,进一步提高系统的并发处理能力。

总结

解决电商秒杀场景下的热 Key 问题,需要综合运用多种策略,构建多层级的防护体系。没有一种方案是万能的,需要根据实际情况进行选择和调整。同时通过监控和压测及时的发现和优化系统瓶颈。

希望今天的分享对你有所帮助。如果你有任何问题或想法,欢迎在评论区留言。

码农老王 Redis秒杀热Key

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/7990