前言

每年的618、双11大促,对技术人来说都是一场"大考"。去年618当晚,突然收到告警:核心Redis集群CPU使用率100%! 紧接着,业务侧反馈"购物车加载缓慢"、“下单超时”。

事后复盘,罪魁祸首就是热Key和大Key。本文将结合这次实战经验,深入剖析:

  • 如何快速定位热Key和大Key?
  • 有哪些行之有效的处理方案?
  • 如何设计架构防患于未然?

一、场景复现:大促下的Redis之痛

1.1 现象描述

大促期间,流量峰值达到平时的50倍。Redis集群出现以下症状:

  • CPU使用率:从30%飙升至100%
  • 慢查询日志:出现大量超过1秒的命令
  • 客户端异常:连接超时、Read Timeout
  • 业务影响:涉及该Redis的业务全部响应缓慢

1.2 根本原因分析

热Key(Hot Key):某一时刻,某个key被海量并发访问。

  • 例如:一个爆款商品的详情页key,瞬间被千万用户请求。
  • 后果:单节点CPU被打满,该节点上的所有key都受影响。

大Key(Big Key):key对应的value过大。

  • 例如:一个Hash结构存储了某个用户的全部好友关系,包含10万条数据。
  • 后果:操作耗时增加,阻塞后续命令;网络传输压力大。

二、快速定位:如何揪出元凶?

大促期间分秒必争,定位问题必须快、准、狠。以下是我总结的"三阶定位法"。

2.1 第一阶:应急排查命令

方法一:redis-cli --hotkeys(推荐,但需开启淘汰策略)

Redis 4.0+提供了–hotkeys参数,可以分析热key。

$ redis-cli -h r-123.redis.rds.aliyuncs.com --hotkeys

# 输出示例
-------- summary -------
hot keys found (with 'object idletime' field代表key的空闲时间,越小越热)
1) "product:123456" - 热度: 1000次/秒
2) "activity:seckill" - 热度: 800次/秒

注意: 该命令依赖maxmemory-policy配置(如allkeys-lru),因为Redis需要维护LFU/LRU信息。如果没开启,可能无法使用。

方法二:redis-cli monitor(谨慎使用)

monitor命令可以实时打印Redis执行的所有命令。

$ redis-cli monitor
1698372617.382138 [0 10.0.0.1:54321] "GET" "product:123456"
1698372617.382238 [0 10.0.0.2:54322] "GET" "product:123456"
1698372617.382318 [0 10.0.0.3:54323] "GET" "product:123456"

⚠️ 警告: monitor在高并发下会成倍消耗CPU,生产环境慎用!通常只能在短时间、低峰期使用。

方法三:redis-cli --bigkeys(定位大Key)

Redis自带的大Key扫描工具。

$ redis-cli --bigkeys

# 输出示例
-------- summary -------
Sampled 1000000 keys in the keyspace!
Total key length in bytes is 25000000 (avg len 25.00)

Biggest string found "product:999" has 10485760 bytes
Biggest   hash found "user:123:friends" has 100000 fields
Biggest    list found "news:list" has 50000 items

2.2 第二阶:客户端侧统计

生产环境不建议直接在Redis上跑命令,更好的方式是在客户端(如Jedis、Lettuce)侧统计。

Lettuce热Key统计示例

@Configuration
public class RedisConfig {
    
    @Bean
    public RedisClient redisClient() {
        RedisClient redisClient = RedisClient.create("redis://localhost");
        
        // 注册命令延迟事件监听
        redisClient.addListener(new RedisCommandListener() {
            @Override
            public void onCommand(RedisCommand command, CommandTimedOutException e) {
                String key = command.getArg(0); // 获取key
                countHotKey(key); // 自定义统计逻辑
            }
        });
        
        return redisClient;
    }
    
    private void countHotKey(String key) {
        // 使用滑动窗口或时间轮统计key的访问频率
        // 超过阈值(如1000次/秒)则记录告警
    }
}

2.3 第三阶:监控平台告警

完善的监控体系应该主动告警,而不是等我们去查。

Redis Exporter

Prometheus

Grafana展示

AlertManager

CPU > 80%

慢查询 > 10个/秒

钉钉/企业微信告警


关键监控指标

  • CPU使用率:>80% 告警
  • QPS峰值:同比突增
  • 慢查询数:>10个/秒
  • 网络IO:>100MB/s

三、热Key处理方案流程图

定位到热Key后,如何应对?下面是三种主流方案的决策流程:

定位到热Key

是否可以接受
短暂不一致?

方案一:本地缓存
JVM级Caffeine/Guava

是否可以拆分?

方案二:Key拆分
product_1 product_2

方案三:读写分离
增加从节点

应用层直接返回
Redis压力骤降

客户端随机读取子Key
分散热点

读流量分散到从节点

四、深度实战:热Key解决方案

4.1 方案一:本地缓存(多级缓存)

这是应对热Key最有效的方案。在应用服务器JVM内缓存热点数据,彻底杜绝Redis压力。

架构图

命中

未命中

命中

未命中

用户请求

本地缓存
Caffeine

直接返回

Redis

返回数据并回填本地缓存

数据库

回填Redis和本地缓存


代码实现(Caffeine + Redis)

@Service
public class ProductService {
    
    // 本地缓存:最大1万条,过期10秒
    private final Cache<String, Product> localCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .recordStats()
            .build();
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    public Product getProduct(String productId) {
        String key = "product:" + productId;
        
        // 1. 查本地缓存
        Product product = localCache.getIfPresent(key);
        if (product != null) {
            return product;
        }
        
        // 2. 查Redis(可以用分布式锁防止缓存击穿)
        String json = redisTemplate.opsForValue().get(key);
        if (StringUtils.hasText(json)) {
            product = JSON.parseObject(json, Product.class);
            localCache.put(key, product); // 回填本地缓存
            return product;
        }
        
        // 3. 查数据库
        product = queryFromDB(productId);
        
        // 4. 回填Redis和本地缓存
        redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 1, TimeUnit.HOURS);
        localCache.put(key, product);
        
        return product;
    }
}

优点: 抗热点效果极佳,QPS可达百万级。
缺点: 存在数据一致性问题(缓存延迟),本地缓存占用JVM内存。

4.2 方案二:热Key拆分

将单个热Key拆分为多个子Key,分散到不同Redis节点。

拆分逻辑

public class HotKeySharding {
    
    // 原Key:product:123456
    // 拆分为:product:123456:0, product:123456:1, ... product:123456:9
    
    private static final int SHARD_COUNT = 10;
    
    public Product getProductSharded(String productId) {
        // 随机选择一个分片
        int shard = ThreadLocalRandom.current().nextInt(SHARD_COUNT);
        String key = "product:" + productId + ":" + shard;
        
        // 从Redis读取
        String json = redisTemplate.opsForValue().get(key);
        if (json == null) {
            // 缓存失效时,需要重建所有分片或使用分布式锁
            rebuildShards(productId);
        }
        
        return JSON.parseObject(json, Product.class);
    }
    
    private void rebuildShards(String productId) {
        // 注意:需要加锁防止并发重建
        Product product = queryFromDB(productId);
        String json = JSON.toJSONString(product);
        
        // 重建所有分片
        for (int i = 0; i < SHARD_COUNT; i++) {
            String key = "product:" + productId + ":" + i;
            redisTemplate.opsForValue().set(key, json, 1, TimeUnit.HOURS);
        }
    }
}

优点: 将热点打散到多个节点,充分利用集群资源。
缺点: 需要修改客户端代码,缓存重建逻辑复杂。

4.3 方案三:读写分离

对于读多写少的场景,可以通过增加从节点来分摊读流量。

@Configuration
public class RedisReadWriteConfig {
    
    @Bean
    public LettuceClientConfiguration clientConfiguration() {
        return LettuceClientConfiguration.builder()
                .readFrom(ReadFrom.REPLICA_PREFERRED) // 优先读从节点
                .build();
    }
    
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStaticMasterReplicaConfiguration config = 
            new RedisStaticMasterReplicaConfiguration("master-host", 6379);
        config.addNode("replica1-host", 6379);
        config.addNode("replica2-host", 6379);
        
        return new LettuceConnectionFactory(config, clientConfiguration());
    }
}

五、大Key处理方案

5.1 大Key的危害

  • 阻塞后续命令:Redis单线程模型,操作大Key耗时较长。
  • 网络拥塞:传输大数据包占用带宽。
  • 内存不均:集群模式下数据倾斜。

5.2 拆分数据结构

案例: 一个Hash存储了用户的所有粉丝(10万条)。

优化前

key: user:123:fans (Hash)
field: fan_1, value: {…}
field: fan_2, value: {…}

field: fan_100000, value: {…}

优化后: 按粉丝ID哈希取模拆分

key: user:123:fans:0 (Hash,存储粉丝ID尾号为0的)
key: user:123:fans:1 (Hash,存储粉丝ID尾号为1的)

key: user:123:fans:9 (Hash)

5.3 异步删除(unlink)

删除大Key时,使用unlink代替del。

// 错误示范:阻塞删除
redisTemplate.delete("user:123:fans");

// 正确示范:异步删除
redisTemplate.unlink("user:123:fans");

unlink 命令会在后台回收内存,主线程可以继续处理其他命令。

5.4 渐进式扫描删除

如果必须删除一个大集合,可以分批删除。

-- Lua脚本:分批删除Hash中的字段
local cursor = 0
local count = 0
repeat
    -- 每次扫描100个field
    local result = redis.call('HSCAN', KEYS[1], cursor, 'COUNT', 100)
    cursor = tonumber(result[1])
    local fields = result[2]
    
    -- 删除这些field
    for i=1, #fields, 2 do
        redis.call('HDEL', KEYS[1], fields[i])
        count = count + 1
    end
until cursor == 0

return count

六、预防体系建设

与其等故障发生再处理,不如建立完善的预防机制。

6.1 代码审查规范

  • 禁止:直接存储大对象(如用户的所有粉丝列表)。
  • 禁止:使用keys * 命令。
  • 提醒:合理设置过期时间。
  • 提醒:使用压缩算法存储大文本。

6.2 压测常态化

大促前必须进行全链路压测,模拟真实流量:

  • 单Key QPS压测
  • 大Key性能压测
  • 故障演练(如节点宕机)

6.3 自动化治理平台

超过阈值

严重

定时扫描

大Key检测

热Key检测

阈值判断

自动告警

自动降级/限流

本地缓存生效

写入限流

七、总结

问题类型 定位手段 处理方案 预防措施
热Key --hotkeys、客户端统计 本地缓存、Key拆分、读写分离 压测、监控告警
大Key --bigkeys 拆分结构、unlink删除 代码审查、阈值限制

核心思想

  • 热Key的本质是读压力集中,解决方案是分散压力(本地缓存)或扩容读取能力(读写分离)。
  • 大Key的本质是数据结构不合理,解决方案是拆分和异步化。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐