Redis大Key与热Key问题深度解析:生产事故的隐形炸弹!

难度:⭐⭐⭐⭐ | 适合人群:想避免Redis生产事故的开发者


💥 开场:一次"惨烈"的线上故障

时间: 周六凌晨3点
地点: 家里(被电话吵醒)
事件: Redis雪崩

运维(慌张): "Redis集群挂了!所有节点都不响应了!"

我: "什么???" 😱(瞬间清醒)

运维: "监控显示某个节点CPU突然100%,然后就挂了,接着其他节点也跟着挂..."

我: "快看日志!" 😰


查看Redis日志:

[02:58:23] Client request timeout: DEL user:session:list
[02:58:25] Slow query: DEL user:session:list (5234ms)
[02:58:27] OOM: Cannot allocate memory
[02:58:28] Server crashed

我: "删除一个key居然要5秒???" 😱

连上Redis查看:

# 找到问题key
redis> DEBUG OBJECT user:session:list

Value at:0x7f8b9c0a1234 refcount:1 encoding:linkedlist serializedlength:524288000 lru:1234567
                                                           ↑
                                                    500MB!!!

redis> LLEN user:session:list
(integer) 5000000  # 500万个元素

我: "卧槽!一个List存了500万个元素,500MB!这是谁写的代码???" 😭


第二天紧急复盘:

技术总监: "这次事故是典型的大Key问题!"

哈吉米: "一个500MB的大Key删除时,阻塞了主线程,导致整个Redis卡死。"

南北绿豆: "然后请求积压,内存暴增,最后OOM崩溃。"

阿西噶阿西: "我们必须系统地解决大Key和热Key问题..."


🎯 第一问:什么是大Key?

大Key定义

大Key: 占用内存过大或元素过多的Key

判断标准:

String类型:
    value大小 > 10KB

List类型:
    元素数量 > 10000

Hash类型:
    元素数量 > 10000
    或单个field的value > 10KB

Set类型:
    元素数量 > 10000

ZSet类型:
    元素数量 > 10000

注意: 这是经验值,实际要根据业务场景调整


大Key的危害

哈吉米列举了6大危害:

1. 阻塞主线程

删除500MB的大Key:
    ↓
需要5秒(释放内存)
    ↓
这5秒Redis无法处理其他请求
    ↓
所有客户端超时!

2. 网络阻塞

GET一个100MB的大Key:
    ↓
网络传输耗时长
    ↓
带宽被占满
    ↓
其他请求变慢

3. 内存不均

Redis Cluster(3个节点):

Node1:10GB(有个500MB的大Key)
Node2:5GB
Node3:5GB

数据分布不均!
Node1压力大

4. 过期删除慢

大Key设置了过期时间:
    ↓
到期后删除
    ↓
删除耗时长
    ↓
阻塞主线程

5. 持久化影响

BGSAVE时:
    fork子进程
    ↓
    大Key修改
    ↓
    写时复制(Copy-On-Write)
    ↓
    复制大Key的内存
    ↓
    内存暴增!

6. 主从同步慢

Master有大Key:
    ↓
同步到Slave
    ↓
网络传输慢
    ↓
主从延迟增大

🔍 第二问:如何发现大Key?

方法1:--bigkeys扫描

# 扫描大Key
redis-cli --bigkeys

# 输出:
-------- summary -------

Sampled 10000 keys in the keyspace!
Total key length in bytes is 180000 (avg len 18.00)

Biggest string found: 'user:profile:12345' has 10485760 bytes
Biggest list found: 'user:session:list' has 5000000 items
Biggest hash found: 'product:detail:99999' has 50000 fields
Biggest set found: 'user:tags:123' has 100000 members
Biggest zset found: 'rank:score' has 200000 members

# 每种类型最大的key

优点:

  • ✅ 简单快速
  • ✅ 官方工具

缺点:

  • ❌ 只找最大的,不是所有大Key
  • ❌ 扫描时会阻塞(线上慎用)

方法2:SCAN + DEBUG OBJECT

# 遍历所有key
redis> SCAN 0 COUNT 100

# 检查每个key的大小
redis> DEBUG OBJECT key1

Value at:0x... refcount:1 encoding:raw serializedlength:10485760 lru:...
                                           ↑
                                      序列化后大小

Java实现:

@Service
public class BigKeyScanner {
    
    /**
     * 扫描大Key
     */
    public List<String> scanBigKeys(long threshold) {
        
        List<String> bigKeys = new ArrayList<>();
        
        ScanOptions options = ScanOptions.scanOptions()
            .count(100)
            .build();
        
        Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection(
            connection -> connection.scan(options)
        );
        
        while (cursor.hasNext()) {
            String key = new String(cursor.next());
            
            // 检查key大小
            Long size = getKeySize(key);
            
            if (size > threshold) {
                bigKeys.add(key + " (" + size + " bytes)");
                System.out.println("发现大Key:" + key + ",大小:" + size);
            }
        }
        
        return bigKeys;
    }
    
    /**
     * 获取key大小
     */
    private Long getKeySize(String key) {
        
        DataType type = redisTemplate.type(key);
        
        switch (type) {
            case STRING:
                return redisTemplate.opsForValue().size(key);
                
            case LIST:
                return redisTemplate.opsForList().size(key);
                
            case HASH:
                return redisTemplate.opsForHash().size(key);
                
            case SET:
                return redisTemplate.opsForSet().size(key);
                
            case ZSET:
                return redisTemplate.opsForZSet().size(key);
                
            default:
                return 0L;
        }
    }
}

方法3:Redis慢查询

# 查看慢查询
redis> SLOWLOG GET 10

1) 1) (integer) 5  # 日志ID
   2) (integer) 1705900000  # 时间戳
   3) (integer) 5234567  # 执行耗时(微秒)= 5.2秒
   4) 1) "DEL"
      2) "user:session:list"  # 大Key

# 配置慢查询阈值
redis> CONFIG SET slowlog-log-slower-than 10000  # 超过10ms记录

💻 第三问:大Key解决方案

方案1:拆分大Key

阿西噶阿西: "最根本的方法:不要让Key变大!"

场景:用户Session列表

// ❌ 不推荐:一个List存所有Session
// user:session:list → 500万个元素,500MB

LPUSH user:session:list session1
LPUSH user:session:list session2
...
LPUSH user:session:list session5000000

// 问题:
// - 单个key太大
// - 删除慢
// - 查询慢

✅ 推荐:拆分成多个小Key

/**
 * 拆分策略:按时间分片
 */
public void addSession(Long userId, String session) {
    
    // 按天拆分
    String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
    String key = "user:session:" + userId + ":" + date;
    
    // 每天一个List
    redisTemplate.opsForList().leftPush(key, session);
    
    // 设置过期时间(7天后删除)
    redisTemplate.expire(key, 7, TimeUnit.DAYS);
}

/**
 * 获取最近的Session
 */
public List<String> getRecentSessions(Long userId, int days) {
    
    List<String> allSessions = new ArrayList<>();
    
    // 查询最近N天的
    for (int i = 0; i < days; i++) {
        String date = LocalDate.now().minusDays(i)
            .format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        
        String key = "user:session:" + userId + ":" + date;
        List<String> sessions = redisTemplate.opsForList().range(key, 0, -1);
        
        if (sessions != null) {
            allSessions.addAll(sessions);
        }
    }
    
    return allSessions;
}

效果:

优化前:
user:session:list → 500万元素,500MB

优化后:
user:session:123:202401207000元素,700KB
user:session:123:202401217000元素,700KB
...

每个key很小,操作快速 ✅

方案2:异步删除

南北绿豆: "删除大Key用UNLINK,不要用DEL!"

# DEL:同步删除(阻塞)
redis> DEL bigkey
(5.23s)  # 阻塞5秒

# UNLINK:异步删除(不阻塞)
redis> UNLINK bigkey
(integer) 1  # 立即返回
# 后台线程慢慢删除

Java使用:

// ❌ 不推荐:同步删除
redisTemplate.delete("bigkey");  // 可能阻塞

// ✅ 推荐:异步删除
redisTemplate.unlink("bigkey");  // 不阻塞

批量异步删除:

public void deleteBigKeys(List<String> bigKeys) {
    
    // 分批删除
    int batchSize = 1000;
    
    for (int i = 0; i < bigKeys.size(); i += batchSize) {
        
        int end = Math.min(i + batchSize, bigKeys.size());
        List<String> batch = bigKeys.subList(i, end);
        
        // 使用UNLINK批量异步删除
        redisTemplate.unlink(batch.toArray(new String[0]));
        
        // 避免瞬间压力过大
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

方案3:渐进式删除(List/Set/ZSet)

对于集合类型,逐步删除:

/**
 * 渐进式删除大List
 */
public void deleteBigList(String key) {
    
    long total = redisTemplate.opsForList().size(key);
    System.out.println("大List元素数量:" + total);
    
    int batchSize = 100;  // 每次删除100个
    
    while (total > 0) {
        
        // 删除最右边的100个元素
        for (int i = 0; i < batchSize && total > 0; i++) {
            redisTemplate.opsForList().rightPop(key);
            total--;
        }
        
        System.out.println("已删除:" + batchSize + ",剩余:" + total);
        
        // 暂停10ms,避免持续阻塞
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    System.out.println("大List删除完成");
}

或使用LTRIM:

/**
 * 使用LTRIM渐进式删除
 */
public void deleteBigListWithTrim(String key) {
    
    long total = redisTemplate.opsForList().size(key);
    int batchSize = 100;
    
    while (total > batchSize) {
        // 保留前(total-batchSize)个,删除后batchSize个
        redisTemplate.opsForList().trim(key, 0, total - batchSize - 1);
        total -= batchSize;
        
        Thread.sleep(10);
    }
    
    // 删除剩余的
    redisTemplate.delete(key);
}

🔥 第四问:什么是热Key?

热Key定义

热Key: 访问频率非常高的Key

判断标准:

QPS(每秒查询次数):
    普通Key:< 1000 QPS
    热Key:> 10000 QPS
    超级热Key:> 100000 QPS

典型场景:

1. 热点新闻
   - 突发事件的新闻
   - 短时间内百万人访问

2. 秒杀商品
   - iPhone新品秒杀
   - 瞬间10万人抢购

3. 明星热搜
   - 明星结婚、离婚等
   - 微博热搜第一

4. 直播间信息
   - 头部主播直播间
   - 几百万人同时在线

热Key的危害

南北绿豆: "热Key比大Key更危险!"

1. 单点压力

Redis Cluster(3个节点):

热Key "product:iphone15" 在Node1
    ↓
所有请求都打到Node1
    ↓
Node1: CPU 100%,QPS 10Node2: CPU 10%,QPS 100
Node3: CPU 10%,QPS 100

负载不均!
Node1可能崩溃!

2. 网络瓶颈

热Key访问:10万QPS
每次响应:10KB
    ↓
网络流量:10万 × 10KB = 1GB/s
    ↓
网卡带宽(1Gbps)被打满!
    ↓
其他请求超时

3. 缓存击穿

热Key过期:
    ↓
瞬间10万请求
    ↓
Redis没有,全部打到数据库
    ↓
数据库崩溃!

🛡️ 第五问:热Key解决方案

方案1:本地缓存

阿西噶阿西: "最有效的方法:在应用层加本地缓存!"

@Service
public class ProductService {
    
    // 本地缓存(Caffeine)
    private Cache<String, Product> localCache = Caffeine.newBuilder()
        .maximumSize(1000)  // 最多1000个
        .expireAfterWrite(1, TimeUnit.MINUTES)  // 1分钟过期
        .recordStats()  // 记录统计
        .build();
    
    /**
     * 获取商品(本地缓存 + Redis)
     */
    public Product getProduct(Long productId) {
        
        String key = "product:" + productId;
        
        // 1. 查询本地缓存
        Product product = localCache.getIfPresent(key);
        if (product != null) {
            System.out.println("本地缓存命中");
            return product;
        }
        
        // 2. 查询Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            product = JSON.parseObject(json, Product.class);
            
            // 3. 存入本地缓存
            localCache.put(key, product);
            
            return product;
        }
        
        // 4. 查询数据库
        product = productDao.findById(productId);
        
        if (product != null) {
            // 存入Redis
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                1, TimeUnit.HOURS);
            
            // 存入本地缓存
            localCache.put(key, product);
        }
        
        return product;
    }
}

效果:

热Key访问:10万QPS
    ↓
99%命中本地缓存
    ↓
Redis QPS:1000(降低100倍)
    ↓
Redis压力骤减 ✅

方案2:热Key打散

哈吉米: "把一个热Key变成多个Key!"

/**
 * 热Key打散(添加随机后缀)
 */
public void setHotKey(String key, String value) {
    
    int copies = 10;  // 复制10份
    
    // 写入10个副本
    for (int i = 0; i < copies; i++) {
        String copyKey = key + ":copy:" + i;
        redisTemplate.opsForValue().set(copyKey, value, 1, TimeUnit.HOURS);
    }
}

/**
 * 读取热Key(随机选择一个副本)
 */
public String getHotKey(String key) {
    
    int copies = 10;
    int random = ThreadLocalRandom.current().nextInt(copies);
    
    String copyKey = key + ":copy:" + random;
    return redisTemplate.opsForValue().get(copyKey);
}

效果:

原来:
    product:iphone15 → 10万QPS → 集中在一个节点

打散后:
    product:iphone15:copy:01万QPS
    product:iphone15:copy:11万QPS
    ...
    product:iphone15:copy:91万QPS
    
10个key分散到不同节点
每个节点压力降低10倍 ✅

方案3:限流

@Service
public class HotKeyService {
    
    @Autowired
    private RateLimiter rateLimiter;  // Guava RateLimiter
    
    /**
     * 访问热Key(限流)
     */
    public Product getHotProduct(Long productId) {
        
        // 限流:每秒最多1000次
        if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
            System.out.println("请求被限流");
            
            // 返回降级数据
            return getProductFromLocalCache(productId);
        }
        
        // 正常查询Redis
        return getProductFromRedis(productId);
    }
}

方案4:多级缓存架构

请求
    ↓
浏览器缓存(1分钟)
    ↓ 未命中
CDN缓存(5分钟)
    ↓ 未命中
Nginx本地缓存(1分钟)
    ↓ 未命中
应用本地缓存(Caffeine,1分钟)
    ↓ 未命中
Redis缓存(1小时)
    ↓ 未命中
数据库

Java实现:

@Service
public class MultiLevelCacheService {
    
    // L1:本地缓存
    private Cache<String, Product> l1Cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build();
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;  // L2:Redis
    
    @Autowired
    private ProductDao productDao;  // L3:数据库
    
    public Product getProduct(Long productId) {
        
        String key = "product:" + productId;
        
        // L1:本地缓存
        Product product = l1Cache.getIfPresent(key);
        if (product != null) {
            return product;
        }
        
        // L2:Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            product = JSON.parseObject(json, Product.class);
            l1Cache.put(key, product);  // 回写L1
            return product;
        }
        
        // L3:数据库
        product = productDao.findById(productId);
        if (product != null) {
            // 回写Redis
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                1, TimeUnit.HOURS);
            
            // 回写本地缓存
            l1Cache.put(key, product);
        }
        
        return product;
    }
}

📊 第六问:大Key vs 热Key对比

对比表格

维度大Key热Key
定义占用内存大/元素多访问频率高
判断大小>10KB或元素>1万QPS>1万
主要危害阻塞、内存不均单点压力、网络瓶颈
发现方式--bigkeys扫描监控QPS、热点分析
解决方案拆分、异步删除本地缓存、打散、限流
预防设计时控制大小多级缓存

可以同时存在

一个Key既是大Key又是热Keyproduct:iphone15:detail
    ├─ 大小:50MB(大Key)
    └─ QPS:10万(热Key)
    
双重危害:
    - 每次查询传输50MB
    - 10万QPS = 5TB/s流量
    - 瞬间打爆网络!💥
    
必须:
    1. 拆分大Key
    2. 加本地缓存
    3. 限流

💡 最佳实践

1. 设计规范

String:
    ✅ 单个value < 10KB
    ❌ 不要存大对象、大文件

List:
    ✅ 元素数量 < 10000
    ❌ 不要当作无限队列

Hash:
    ✅ field数量 < 10000
    ✅ 单个value < 1KB
    ❌ 不要把所有属性都放一个Hash

Set:
    ✅ 元素数量 < 10000

ZSet:
    ✅ 元素数量 < 10000

2. 监控告警

@Component
public class BigKeyMonitor {
    
    @Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点
    public void scanBigKeys() {
        
        List<String> bigKeys = bigKeyScanner.scanBigKeys(10 * 1024);  // >10KB
        
        if (!bigKeys.isEmpty()) {
            System.err.println("⚠️  发现" + bigKeys.size() + "个大Key:");
            
            for (String bigKey : bigKeys) {
                System.err.println("    " + bigKey);
            }
            
            // 发送告警通知
            alertService.sendAlert("发现大Key", bigKeys.toString());
        }
    }
}

3. 预防措施

/**
 * 写入前检查大小
 */
public void setWithSizeCheck(String key, String value) {
    
    // 检查大小
    int size = value.getBytes().length;
    
    if (size > 10 * 1024) {  // 超过10KB
        log.warn("⚠️  尝试写入大Key:{},大小:{}KB", key, size / 1024);
        
        // 可以拒绝或分片
        throw new IllegalArgumentException("Value太大,请拆分");
    }
    
    // 正常写入
    redisTemplate.opsForValue().set(key, value);
}

/**
 * List添加前检查数量
 */
public void lpushWithSizeCheck(String key, String value) {
    
    Long size = redisTemplate.opsForList().size(key);
    
    if (size != null && size > 10000) {
        log.warn("⚠️  List元素过多:{},数量:{}", key, size);
        
        // 可以拒绝、删除旧元素、或分片
        // 删除最老的元素
        redisTemplate.opsForList().trim(key, 0, 9999);
    }
    
    redisTemplate.opsForList().leftPush(key, value);
}

💡 知识点总结

大Key与热Key核心要点

大Key问题

  • 定义:内存大或元素多
  • 危害:阻塞、网络慢、内存不均
  • 发现:--bigkeys、SCAN、慢查询
  • 解决:拆分、异步删除、渐进式删除

热Key问题

  • 定义:访问频率高
  • 危害:单点压力、网络瓶颈、击穿
  • 发现:监控QPS、热点分析
  • 解决:本地缓存、打散、限流、多级缓存

拆分策略

  • 按时间拆分(日期、小时)
  • 按范围拆分(ID段)
  • 按类型拆分(分类)

删除策略

  • String/Hash小数据:DELETE
  • 集合大数据:UNLINK(异步)
  • 超大集合:渐进式删除

预防措施

  • 设计时控制大小
  • 写入前检查
  • 定期扫描
  • 监控告警

记忆口诀

Key占用内存多,
阻塞删除是祸根。
拆分存储是正道,
异步删除UNLINK用。
渐进删除分批搞,
避免阻塞主线程。

热Key访问频率高,
单点压力要分担。
本地缓存第一层,
Redis压力骤减少。
热Key打散多副本,
请求分散到多节点。
多级缓存架构好,
CDN加Nginx保。

🤔 常见面试题

Q1: Redis的大Key问题是什么?大Key问题的缺点?

A:

大Key定义:
- String > 10KB
- List/Hash/Set/ZSet元素 > 1万

缺点(危害):
1. 阻塞主线程
   - 删除大Key耗时长
   - 阻塞其他请求

2. 网络阻塞
   - 传输大Key占用带宽

3. 内存不均
   - Cluster中某节点内存占用高

4. 主从同步慢
   - 大Key传输慢

5. 持久化影响
   - 写时复制内存暴增

6. 过期删除慢
   - 阻塞定期删除任务

Q2: Redis大key如何解决?

A:

解决方案:

1. 拆分大Key
   - 按时间拆分
   - 按范围拆分
   - 不让key变大

2. 异步删除
   - 使用UNLINK代替DEL
   - 后台线程删除

3. 渐进式删除
   - List:逐步RPOP
   - Set:逐步SPOP
   - 分批删除

4. 监控预防
   - 定期扫描大Key
   - 写入前检查大小
   - 告警通知

5. 配置优化
   - lazyfree-lazy-eviction yes
   - lazyfree-lazy-expire yes

Q3: 什么是热key?如何解决热key问题?

A:

热Key定义:
- 访问频率极高的key
- QPS > 1万

危害:
- 单点压力(集中在一个节点)
- 网络瓶颈(带宽打满)
- 缓存击穿(过期时压垮数据库)

解决方案:

1. 本地缓存(最有效)
   - Caffeine/Guava Cache
   - 应用层缓存
   - 降低Redis压力

2. 热Key打散
   - 复制多份(添加随机后缀)
   - 分散到多个节点

3. 限流
   - 控制访问频率
   - 防止打垮Redis

4. 多级缓存
   - CDN + Nginx + 本地 + Redis
   - 层层拦截

5. 永不过期
   - 热点数据设置永不过期
   - 异步更新

💬 写在最后

从大Key到热Key,我们深入学习了Redis的两大隐形炸弹:

  • 💣 理解了大Key和热Key的危害
  • 🔍 掌握了发现大Key和热Key的方法
  • 🛡️ 学会了多种解决方案
  • 💻 完成了实战优化案例

这篇文章,希望能帮你避免生产环境的Redis事故!

如果这篇文章对你有帮助,请:

  • 👍 点赞支持
  • ⭐ 收藏备用
  • 🔄 转发分享
  • 💬 评论交流

感谢阅读,期待下次再见! 👋