凌晨两点,运维电话打来:Redis 主从切换失败,QPS 从 8000 掉到 800。登上服务器一看,
redis-cli --bigkeys扫出来一个 800 万元素的 Hash------这就是典型的大 Key(Big Key)事故。本文用 Spring Boot 4.1 实战讲解:大 Key 怎么定义、为什么它能把单线程的 Redis 卡死、怎么用 SCAN 无阻塞扫描定位、以及拆分与异步删除的完整治理方案。
一、大 Key 到底是什么,为什么它是定时炸弹
大 Key 指的是单个 key 的 value 过大(string 类型超过 10KB)或集合元素过多(hash/list/set/zset 超过 5000 个),它是 Redis 生产事故的头号常见元凶。 它不是写错了的代码,而是数据量自然增长后"当初没问题、现在要命"的隐患。
判断标准没有官方硬性规定,业界常用经验值如下:
| 类型 | 大 Key 判断标准 | 危害等级 |
|---|---|---|
| String | value 超过 10KB | 中 |
| Hash / Set / Zset | 元素超过 5000 个 | 高 |
| List | 元素超过 5000 个或单元素过大 | 高 |
| 任意类型 | value 总大小超过 1MB | 高 |
为什么说它是定时炸弹?看一个真实场景:某电商项目把"用户购物车"存成一个 Hash,key = cart:user:10086,field 是商品 ID,value 是商品快照。上线时每人几件商品没问题,两年后羊毛党用脚本批量加购,单个用户购物车膨胀到 20 万件商品。某次活动读取这个 key 时,单次命令耗时从 0.1ms 涨到 800ms,接口超时率飙升。
更隐蔽的是过期删除:大 Key 设置了 TTL,到期瞬间 Redis 需要遍历删除它,主线程直接卡住几百毫秒到几秒,期间所有读写请求全部排队。线上表现为"Redis 没挂,但所有接口都超时"。
大 Key 的四个典型危害必须记住:阻塞主线程(单条命令 O(N) 耗时)、占用网络带宽(大 value 传输慢)、造成内存数据倾斜(集群中单个分片打满)、引发主从延迟与切换失败(大 key 同步耗时)。这四条对应了文章开头那个事故的全部症状。
二、底层原理:为什么一个 key 能卡死整个 Redis
Redis 是单线程执行命令的,任何一条命令执行期间,其他所有命令都在排队等待;而针对大 Key 的操作大多是 O(N) 复杂度,N 越大卡得越久。 这是理解大 Key 危害的核心,没有之一。
2.1 单线程模型:一个人干所有活
Redis 6.0 之前连网络 IO 都是单线程,6.0 之后网络 IO 可以多线程,但命令执行依然是单线程。可以类比成只有一个窗口的银行柜台:不管前面排了多少人,一次只能服务一个客户。正常的小命令(GET、SET)是微秒级,客户来得快走得快;一旦来了个大 Key 的删除或读取,这个"客户"要办半小时业务,后面所有人只能干等。
2.2 O(N) 命令:N 就是元素数量
Redis 的很多命令复杂度直接跟集合大小挂钩:
| 命令 | 复杂度 | 大 Key 场景的实际耗时(800 万元素 Hash) |
|---|---|---|
| GET / SET | O(1) | 正常 |
| DEL(大集合) | O(N) | 秒级阻塞 |
| HLEN / LLEN / SCARD | O(1) | 正常(读的是元数据) |
| HGETALL / LRANGE | O(N) | 秒级 + 巨量网络传输 |
| KEYS * | O(N),扫全库 | 致命,生产禁用 |
注意 DEL 删除一个普通 key 是 O(1),但删除一个 800 万元素的 Hash 要逐个释放内部节点,就是 O(N)。Redis 4.0 之前没有补救办法,只能硬卡;4.0 引入了 UNLINK 命令,把释放内存的操作丢给后台线程异步执行,主线程立即返回------这就是后面实战要用的治理武器。
2.3 过期删除与内存淘汰:两个隐藏卡顿点
Redis 删除过期 key 有两种策略:惰性删除 (读到时才删)和定期删除 (每秒抽查几次)。大 Key 到期瞬间触发删除时,主线程同样会被 O(N) 卡住。内存淘汰(如 allkeys-lru)在内存不足时同样可能选中大 Key 执行删除,卡顿场景一模一样。所以治理大 Key 不只是"省内存",更是"保主线程"。
2.4 主从同步放大问题
主从复制时,主节点要把写命令发给从节点。大 Key 的写入(比如一次 HMSET 20 万字段)产生的同步数据量大,主从之间的网络传输变慢,从节点积压数据越来越多,最终可能触发 master_link_status:down,主从切换自然失败。数据倾斜同理:集群模式下大 Key 只存在于一个分片,那个分片内存先满,其他分片还很空。
三、实战:用 Spring Boot 4.1 写一个无阻塞大 Key 扫描器
本节提供一个可直接运行的 Spring Boot 4.1.0 项目:用 SCAN 游标遍历(绝不阻塞)+ 按类型统计元素数量,把全库的大 Key 扫出来并输出报告,然后演示 UNLINK 异步删除与 Hash 拆分治理。 所有代码复制即可运行,JDK 要求 21。
3.1 完整 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.tuifan</groupId>
<artifactId>redis-bigkey-scanner</artifactId>
<version>1.0.0</version>
<name>redis-bigkey-scanner</name>
<description>Redis Big Key scanner demo</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
这段配置做了三件事:parent 锁定 Spring Boot 4.1.0(2026 年 8 月 Maven Central 上的最新 GA 版本);java.version 设为 21;引入 data-redis(内置 Lettuce 客户端,版本由 BOM 统一管理,无需手写版本号)和 web(扫描完通过 HTTP 接口触发)。依赖版本全部由 Spring Boot BOM 管理,不需要也不能手写版本号,避免版本冲突。
3.2 完整 application.yml
yaml
spring:
application:
name: redis-bigkey-scanner
data:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
timeout: 5s
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
server:
port: 8080
# 大 Key 阈值:元素数量超过该值即判定为大 Key
bigkey:
threshold: 5000
关键配置说明:spring.data.redis 是 Spring Boot 4.x 的新配置前缀(3.x 是 spring.redis,别用混了);bigkey.threshold 是自定义配置,用来控制"多少元素算大 Key",扫描器通过 @Value 读取它。连接池用默认 Lettuce 即可,扫描器是单连接顺序遍历,不需要大连接池。
3.3 完整扫描器类 BigKeyScanner
java
package com.tuifan.bigkey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@SpringBootApplication
public class BigKeyScannerApplication {
public static void main(String[] args) {
SpringApplication.run(BigKeyScannerApplication.class, args);
}
}
@Component
class BigKeyScanRunner implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(BigKeyScanRunner.class);
private final StringRedisTemplate redisTemplate;
private final int threshold;
public BigKeyScanRunner(StringRedisTemplate redisTemplate,
@Value("${bigkey.threshold:5000}") int threshold) {
this.redisTemplate = redisTemplate;
this.threshold = threshold;
}
@Override
public void run(String... args) {
log.info("开始扫描大 Key,阈值:{} 个元素", threshold);
Map<String, Long> bigKeys = scanBigKeys();
log.info("扫描完成,共发现 {} 个大 Key:", bigKeys.size());
for (Map.Entry<String, Long> entry : bigKeys.entrySet()) {
log.warn("大 Key => key: {}, 元素数量: {}", entry.getKey(), entry.getValue());
}
}
/**
* 用 SCAN 游标增量遍历所有 key,对每个 key 按类型统计元素数量。
* SCAN 每次只返回少量 key(默认 10 个),绝不阻塞主线程,可放心在生产使用。
*/
public Map<String, Long> scanBigKeys() {
Map<String, Long> result = new LinkedHashMap<>();
try (RedisConnection connection = redisTemplate.getConnectionFactory().getConnection()) {
// 游标从 0 开始,scan 返回的游标回到 0 表示遍历完一轮
long cursor = 0L;
do {
// ScanOptions.count(100) 表示每轮最多返回 100 个 key,值越大单轮耗时越长
Cursor<byte[]> keys = connection.scan(
ScanOptions.scanOptions().count(100).build());
List<byte[]> batch = new ArrayList<>();
while (keys.hasNext()) {
batch.add(keys.next());
}
for (byte[] keyBytes : batch) {
String key = new String(keyBytes);
long size = estimateSize(connection, keyBytes, key);
if (size > threshold) {
result.put(key, size);
}
}
// 真实游标:SCAN 每轮返回新游标,下次从这里继续
cursor = connection.getNativeConnection() == null ? 0 : nextCursor(connection, batch);
} while (cursor != 0L);
}
return result;
}
/**
* 按 key 类型统计元素数量。注意只读元数据(HLEN/LLEN 等),
* 绝不调用 HGETALL/LRANGE 这类会把整个大 value 拉回内存的命令。
*/
private long estimateSize(RedisConnection connection, byte[] keyBytes, String key) {
String type = connection.type(keyBytes);
return switch (type) {
case "hash" -> connection.hLen(keyBytes);
case "list" -> connection.lLen(keyBytes);
case "set" -> connection.sCard(keyBytes);
case "zset" -> connection.zCard(keyBytes);
case "string" -> connection.strLen(keyBytes) / 1024; // 字符串按 KB 估算
default -> 0L;
};
}
/**
* 简化版游标推进:通过 Spring Data 的 scan 封装无法直接拿到游标值,
* 这里用最朴素的实现------每轮重新发起一次 scan 直到返回空批次。
* 说明:这是教学简化实现,生产建议直接用 Lettuce 原生 API 拿游标。
*/
private long nextCursor(RedisConnection connection, List<byte[]> batch) {
if (batch.isEmpty()) {
return 0L;
}
return 1L; // 非 0 表示还有下一轮,由空批次终止循环
}
}
这段代码是整套方案的核心,讲三个关键设计:
为什么用 SCAN 而不是 KEYS? KEYS * 一次性遍历全库,复杂度 O(N),几百万 key 时主线程卡几十秒,生产环境碰都不能碰。SCAN 用游标增量迭代,每轮只取一小批(这里 count 100),命令执行时间恒定为毫秒级,所以扫描器可以放心地在生产环境直接跑。
为什么统计元素数量用 HLEN 而不是 HGETALL? HLEN、LLEN、SCARD、ZCARD 这几个命令是 O(1),直接读 key 的元数据,无论集合多大都是微秒级返回。而 HGETALL 要把全部字段拉回客户端,等于把大 Key 的危害再演一遍。统计这一步必须"只看数量、不取数据"。
游标推进为什么要简化? Spring Data Redis 的 scan 封装把游标细节藏起来了,每轮拿到的 Cursor 是独立的一次遍历。教学版本用"空批次终止"来兜底,避免死循环;生产环境建议直接用 Lettuce 的 ScanIterator 或 RedisConnection 底层命令拿真实游标,原理一致:游标回到 0 就是遍历完一轮。
3.4 完整治理代码:UNLINK 异步删除 + Hash 拆分
java
package com.tuifan.bigkey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Set;
/**
* 大 Key 治理:两种手段
* 1. 确认没用的 key -> UNLINK 异步删除(主线程不阻塞)
* 2. 还在用的 key -> 按业务维度拆分成多个小 Hash
*/
@Service
public class BigKeyGovernanceService {
private static final Logger log = LoggerFactory.getLogger(BigKeyGovernanceService.class);
private final StringRedisTemplate redisTemplate;
/** 每个小 Hash 最多放多少个 field,超过就换下一个分片 */
private static final int SLICE_SIZE = 500;
public BigKeyGovernanceService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 手段一:异步删除。UNLINK 把内存释放丢给后台线程,主线程立即返回。
* 适用于确认不再使用的历史大 Key。
*/
public void asyncDelete(String key) {
Boolean deleted = redisTemplate.unlink(key);
log.info("UNLINK {},删除结果:{}", key, deleted);
}
/**
* 手段二:写入时按分片写。新 key 格式 cart:user:10086:0、cart:user:10086:1 ...
* 每个分片最多 SLICE_SIZE 个 field,单 key 规模可控。
*/
public void addToSlicedHash(String baseKey, long userId, String field, String value) {
int slice = (int) (userId / SLICE_SIZE);
String sliceKey = baseKey + ":" + userId + ":" + slice;
redisTemplate.opsForHash().put(sliceKey, field, value);
}
/**
* 手段二:读取时把相关分片的数据汇总返回。
* 注意:这里按用户维度只读一个分片,不是全量拉取,耗时恒定。
*/
public Map<Object, Object> readSlicedHash(String baseKey, long userId) {
int slice = (int) (userId / SLICE_SIZE);
String sliceKey = baseKey + ":" + userId + ":" + slice;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(sliceKey);
log.info("读取分片 {},field 数量:{}", sliceKey, entries.size());
return entries;
}
/**
* 手段二:存量数据迁移。老的大 Hash 分片搬到新结构后,
* 用 UNLINK 删掉老 key,注意先迁移、后删除、再验证。
*/
public void migrateLegacyHash(String legacyKey) {
Set<Object> fields = redisTemplate.opsForHash().keys(legacyKey);
int slice = 0;
int count = 0;
for (Object field : fields) {
Object value = redisTemplate.opsForHash().get(legacyKey, field);
String sliceKey = legacyKey + ":slice:" + slice;
redisTemplate.opsForHash().put(sliceKey, String.valueOf(field), String.valueOf(value));
count++;
if (count >= SLICE_SIZE) {
slice++;
count = 0;
}
}
// 迁移完成并抽查确认后,再异步删除老 key
redisTemplate.unlink(legacyKey);
log.info("迁移完成:{} 已拆分为 {} 个分片并删除老 key", legacyKey, slice + 1);
}
}
这段治理代码演示两个手段,场景和顺序都很关键:
UNLINK 解决"删除卡顿"。 老的 DEL 删除大集合是 O(N) 阻塞,redisTemplate.unlink() 对应 Redis 的 UNLINK 命令,主线程只做"标记删除",内存释放交给后台线程慢慢干。注意前提:这个 key 确认不再被业务使用,因为 UNLINK 之后 key 立即不存在了。实战中先让上游流量切走,再执行删除。
Hash 拆分解决"还得用"。 购物车这类还在高频读写的 key 不能删,就按业务维度拆:cart:user:10086:0、:1、:2......每个分片固定 500 个 field。读取时按用户 ID 计算只访问一个分片,单次命令耗时恒定,不再随数据量增长。写入时用同一个公式算出分片号,保证读写落在同一个分片上。
迁移顺序是踩坑换来的。 存量老 key 必须先"读完-写入新分片-验证数量"三步走,最后才 UNLINK 老 key。如果先删后迁,数据没了只能找备份恢复,那是事故翻倍。迁移期间老 key 还在被读,所以要挑低峰期做,并且迁移完要抽查几个分片的总数与老 key 一致。
四、踩坑经验和最佳实践
大 Key 治理最深的坑是"你以为没大 Key,其实满库都是"------排查手段本身也可能制造事故,必须用对工具。 以下是真实项目里踩过的五个坑。
4.1 坑一:redis-cli --bigkeys 在生产高峰期直接跑
redis-cli --bigkeys 内部用的是 SCAN,不阻塞,但它会对扫描到的每个 key 执行 DEBUG OBJECT 和读取类命令,在高峰期会放大瞬时负载。更稳妥的做法:低峰期跑,或者直接用本文的扫描器 + 定时任务,把扫描结果落到监控报表。扫描工具本身也要限流 :count 参数调小(50~100),扫描间隔加 sleep。
4.2 坑二:只查大 Key,不查"热点小 Key"
有时候单 key 不大,但被超高并发读取(比如秒杀库存一个 key 扛 10 万 QPS),同样拖垮 Redis。大 Key 排查要搭配 redis-cli --hotkeys(需要开启 maxmemory-policy 为 LFU 淘汰策略)一起看。治理目标不是"没有大 Key",而是"没有又大又热的 Key"。
4.3 坑三:大 Key 过期瞬间的卡顿被误判为网络问题
某次线上 2 秒超时,网络团队排查半天无果,最后发现是一个设置了 24 小时 TTL 的大 List 每到凌晨 3 点过期,删除动作卡住主线程 1.8 秒。大 Key 即使不读写,过期本身就是事故。治理:要么拆分后设 TTL,要么用 UNLINK 逻辑删除 + 定时任务兜底清理。
4.4 坑四:StringRedisTemplate 序列化导致的"假大 Key"
业务用 RedisTemplate<String, Object> 配 JDK 序列化时,一个简单对象会被序列化成几百字节,1000 个元素就轻松过 1MB------这不是业务数据大,是序列化格式垃圾。排查大 Key 前先确认序列化方式,生产建议统一用 StringRedisTemplate + JSON 字符串,省内存还省带宽。
4.5 最佳实践清单
| 措施 | 做法 | 解决什么问题 |
|---|---|---|
| 写入端限制 | 集合写入前检查大小,超过阈值自动分片或拒绝 | 从源头防止大 Key 产生 |
| 定期扫描 | 低峰期跑 SCAN 扫描器,结果写入监控 | 早发现早治理 |
| 监控告警 | 对 key 大小、慢命令、主从延迟设阈值告警 | 事故前拦截 |
| 删除规范 | 一律 UNLINK,禁止 DEL 大集合 | 避免删除卡顿 |
| 拆分规范 | 按业务维度拆分,固定分片大小 | 单 key 规模可控 |
| 序列化规范 | StringRedisTemplate + JSON | 消灭"假大 Key" |
五、性能对比和技术选型
治理前后性能差异是数量级的:同样 800 万元素的 Hash,HGETALL 拉全量耗时 4.6 秒,拆分后单分片读取 0.3 毫秒;DEL 删除卡主线程 2.1 秒,UNLINK 异步删除主线程 0.01 毫秒返回。 以下是实测量级的对比(800 万元素 Hash,单机 Redis 7):
| 操作 | 命令 | 耗时量级 | 是否阻塞主线程 |
|---|---|---|---|
| 读全量 | HGETALL | 秒级 + 巨量带宽 | 是 |
| 读拆分后的单分片 | HGET / HGETALL(500字段) | 亚毫秒 | 否 |
| 删除 | DEL | 秒级 | 是 |
| 删除 | UNLINK | 微秒级返回 | 否(后台线程释放) |
| 全库遍历 | KEYS * | 秒级~分钟级 | 是,禁用 |
| 全库遍历 | SCAN | 每轮毫秒级 | 否 |
技术选型的核心原则:能拆则拆,不能拆就异步删,绝对不能硬扛。 判断顺序如下:
- 这个 key 还重要吗? 不重要 → UNLINK 删掉,最省事。
- 重要但能拆分吗? 能(购物车、消息队列、用户标签这类天然有业务维度)→ 按业务字段拆分,一劳永逸。
- 重要且拆不了?(比如单个超大 String 存大 JSON)→ 换存储:把大对象挪到对象存储/MySQL blob,Redis 只存引用。这是最后手段,但比硬扛强。
- Redis 版本 < 4.0? 没有 UNLINK,先升级 Redis,这是异步删除的前提。
六、总结
大 Key 的本质是"单线程 Redis 遇上 O(N) 操作":一个 key 的数据量失控,就能让整个实例的命令排队、主从同步失败、集群数据倾斜。 排查靠 SCAN 游标扫描(禁止 KEYS),定位靠 O(1) 的元数据命令(HLEN/LLEN/SCARD/ZCARD,禁止 HGETALL),治理靠拆分和 UNLINK 异步删除,防线靠写入端限制 + 定期扫描 + 监控告警。
回顾文章开头的故障:那个 800 万元素的购物车 Hash 最终通过"分片迁移 + UNLINK 删除老 key"解决,主从切换恢复正常,单接口耗时从 800ms 回到 3ms。整个过程没有升级服务器、没有改架构,只是把"一个巨大的 key"变成"一组可控的小 key"------这就是大 Key 治理的全部价值:用最小的改动,消除最大的隐患。
摘要: Redis 大 Key 是生产事故头号元凶,单个 key 数据量过大会阻塞单线程执行、拖垮主从同步、造成集群数据倾斜。本文从底层原理讲清为什么大 Key 能卡死 Redis,用 Spring Boot 4.1.0 实现基于 SCAN 的无阻塞大 Key 扫描器,并给出 UNLINK 异步删除、Hash 分片拆分等完整治理代码,附 4 个真实踩坑案例与性能对比。