一、Redis性能模型与瓶颈定位
Redis是单线程(6.0前)的事件循环模型,基于I/O多路复用(epoll/select/kqueue)。理解这个模型是分析瓶颈的前提:
// Redis 6.0 单线程事件处理模型
// 每次主循环(aeMain):
// 1. aeApiPoll(timeout) — I/O多路复用,等待就绪事件(epoll_wait)
// 2. beforeSleep() — 处理AOF刷盘、客户端超时、统计等
// 3. processEvents() — 处理已就绪的事件(socket可读/可写)
// 4. afterSleep() — 后处理
// 单线程瓶颈的三大来源:
// 1. CPU密集型命令:KEYS, SMEMBERS, SORT, UNION, LREM 等 — O(N)复杂度
// 2. 大Key操作:HGETALL/SMEMBERS/MGET 传输大量数据
// 3. 持久化阻塞:AOF fsync/BGSAVE fork() 时的COW(Copy-On-Write)
// redis-cli --latency 基准测试(延迟基准)
// $ redis-cli --latency-history -i 1
// avg: 0.057ms (min: 0.02, max: 0.15) — 正常延迟
// 基准测试工具
// redis-benchmark -n 100000 -c 100 -t SET,GET
// 关键指标:QPS、延迟分布(P50/P99/P999)
// slowlog:慢查询日志(超过slowlog-log-slower-than微秒的命令)
CONFIG SET slowlog-log-slower-than 1000 // 超过1ms记录
CONFIG SET slowlog-max-len 128
SLOWLOG GET 10 // 查看最近10条慢查询
1.1 六大经典瓶颈场景
- BigKey:单个Key数据量过大,阻塞主线程
- HotKey:某个Key被高频访问,单线程成为瓶颈
- 内存碎片:jemalloc分配器碎片化导致可用内存不足
- 持久化阻塞:BGSAVE/AOF重写 fork() 期间内存膨胀
- 网络带宽:大Value导致网络IO成为瓶颈
- CPU绑定:持久化子进程与主进程竞争CPU
二、BigKey问题诊断与解决
// BigKey诊断:使用SCAN替代KEYS(全量扫描不阻塞)
redis-cli --bigkeys // 扫描各类最大Key
redis-cli --scan | head -10000 | redis-cli --pipe-timeout 3 --pipe \
"$(redis-cli SCAN 0 COUNT 10000 | awk '{print "MEMORY USAGE "$1}')"
# Python脚本:找出所有BigKey
import redis
r = redis.Redis(host='localhost', port=6379)
for key in r.scan_iter(match='*', count=1000):
t = r.type(key).decode()
if t == 'string':
size = r.memory_usage(key)
if size and size > 1024*1024: # >1MB
print(f"BIG STRING: {key} = {size//1024//1024}MB")
elif t in ('list','set','zset','hash'):
count = r_llen(key) if t=='list' else r_scard(key)
if count > 10000:
print(f"BIG {t}: {key} = {count} items")
// BigKey删除:分批渐进式删除(避免阻塞)
# 删除大Hash:先获取field数量,再分批HDEL
let size = redis.call('HLEN', KEYS[1])
let batch = 1000
for i = 1, size, batch do
local fields = redis.call('HKEYS', KEYS[1], i, math.min(i+batch-1, size))
for _, field in ipairs(fields) do
redis.call('HDEL', KEYS[1], field)
end
redis.call('LCSLEEP', 50) -- 每次删除后休息50ms
end
# Lua脚本:渐进式删除Hash
EVAL "
local keys = redis.call('HKEYS', KEYS[1])
local batch = 1000
for i = 1, #keys, batch do
local fields = {}
for j = i, math.min(i+batch-1, #keys) do
table.insert(fields, keys[j])
end
redis.call('HDEL', KEYS[1], unpack(fields))
redis.call('LCSLEEP', 50)
end
redis.call('DEL', KEYS[1])
" 1 my_big_hash
// BigKey预防策略
# 1. 将大Hash按field数量拆分
user:1000:profile → user:1000:profile:base, user:1000:profile:extend
# 2. 用SortedSet替代List存储需要按序访问的数据
# 3. 设置TTL防止无限增长
2.1 HotKey问题:单机热点探测与分布式解决
// HotKey探测(客户端代理层)
# 阿里云Redis/腾讯云Redis提供热点Key分析接口
redis-cli HOTKEYS 100 // redis-cli 4.0+,采样100个Key
# 客户端本地计数
private ConcurrentHashMap hotKeyCounter = new ConcurrentHashMap<>();
public String get(String key) {
hotKeyCounter.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
return redisTemplate.opsForValue().get(key);
}
// 定时上报并清理
@Scheduled(fixedRate = 60000)
public void reportHotKeys() {
hotKeyCounter.entrySet().stream()
.filter(e -> e.getValue().get() > 10000)
.forEach(e -> {
log.warn("HOT KEY DETECTED: {} count={}", e.getKey(), e.getValue().get());
e.getValue().set(0);
});
}
// HotKey解决方案:本地缓存 + Redis + 多副本分流
# Redis Cluster:给热点Key创建多个副本
redis-cli CLUSTER SETSLOT 0 NODE // 迁移Slot
# 客户端:本地LRU缓存 + Redis + 一致性hash
LoadingCache localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(key -> redis.get(key)); // Cache Aside
三、Pipeline与Lua脚本优化
// 普通模式:N次命令 = N次RTT(Round Trip Time)
for (String key : keys) {
redisTemplate.opsForValue().get(key); // 每次命令一次网络往返
}
// 10个key × 1ms RTT = 10ms
// ✅ Pipeline模式:N次命令 = 1次RTT
RedisCallback> pipelineCallback = connection -> {
connection.openPipeline();
for (String key : keys) {
connection.get(key.getBytes());
}
return connection.closePipeline();
};
List
四、持久化阻塞与内存管理
// RDB持久化:BGSAVE的COW问题
# fork() 进程时,Linux使用Copy-On-Write
# 主进程修改数据时,Linux复制页表(Page Table)给子进程
# 如果父进程内存持续增长(大量写操作),COW会消耗2倍内存
# 解决方案:限制最大内存 + 允许部分数据过期
maxmemory 10gb
maxmemory-policy allkeys-lru # 内存达到上限时LRU淘汰
# 监控BGSAVE期间的内存
redis-cli INFO persistence
# rdb_last_save_time: 上次RDB保存时间
# rdb_last_save_time_elapsed: 距上次保存的秒数
# rdb_bgsave_in_progress: 是否正在BGSAVE
# AOF fsync策略选择
appendonly yes
appendfsync everysec # 每秒刷盘(推荐,最多丢1秒数据)
# no: 依赖OS刷盘,最快但不保证
# always: 每次写操作都fsync,最慢但零丢失
// 内存碎片治理
redis-cli INFO memory
# mem_fragmentation_ratio: 内存碎片率(应 < 1.5)
# used_memory: Redis实际使用内存
# used_memory_rss: OS分配的物理内存(包含碎片)
# 碎片率 > 1.5 时,触发内存碎片整理
redis-cli MEMORY PURGE # 触发碎片整理(会阻塞)
# jemalloc配置(Redis 4.0+)
activedefrag yes # 开启主动碎片整理
active-defrag-ignore-bytes 100mb # 碎片>100MB时开始整理
active-defrag-threshold-lower 10 # 碎片>10%时开始整理
active-defrag-threshold-upper 100 # 碎片>100%时全力整理
4.1 连接池调优:Jedis vs Lettuce
// Jedis连接池(传统模式)
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(200); // 最大连接数
config.setMaxIdle(50); // 最大空闲连接
config.setMinIdle(10); // 最小空闲连接
config.setMaxWait(Duration.ofMillis(100)); // 获取连接超时
config.setTestOnBorrow(true); // 借出时检查连通性
config.setTestWhileIdle(true); // 空闲时检测
JedisPool pool = new JedisPool(config, "127.0.0.1", 6379);
try (Jedis jedis = pool.getResource()) {
jedis.get("key");
}
// Lettuce(Redis 6+ 推荐,Netty异步驱动)
RedisClient client = RedisClient.create(RedisURI.create("redis://127.0.0.1:6379"));
GenericObjectPoolConfig> poolConfig =
new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(200);
poolConfig.setMaxIdle(50);
poolConfig.setMinIdle(10);
StatefulRedisConnection connection =
client.connect(new GenericRedisURI());
// 同步API(线程安全)
String result = connection.sync().get("key");
// 异步API
connection.async().get("key").thenAccept(System.out::println);
五、Redis Cluster集群调优
// Redis Cluster vs Codis vs Twemproxy 选择
// < 10万 QPS:单机Redis足够
// 10-50万 QPS:Redis Cluster(16K个Slot,自动分片)
// > 50万 QPS 或需要跨机房:Codis(代理模式,支持NSlot迁移)
// 纯代理分片:Twemproxy(Twitter开源,最成熟但不再维护)
// Redis Cluster 槽迁移(在线扩容)
# 1. 新节点以空集群方式启动
redis-cli --cluster add-node 10.0.0.5:6379 10.0.0.1:6379 --cluster-slave
# 2. 迁移槽:redis-cli --cluster reshard
redis-cli --cluster reshard 10.0.0.1:6379 \
--cluster-from <源节点ID> \
--cluster-to <新节点ID> \
--cluster-slots 5461 # 迁移5461个槽(总16384/3≈5461)
// MOVED重定向处理(Jedis客户端自动处理)
# 手动迁移key时,如果key所属slot正在迁移,会收到ASK重定向
# ASK重定向:客户端去新节点执行命令(不更新本地路由表)
# MOVED重定向:客户端更新本地路由表(永久)
// Multi-Key操作限制
# ❌ Redis Cluster不支持跨槽多键操作(MGET/MSET需要所有key在同一槽)
# ✅ 解决方案:同一业务使用相同前缀(相同槽)
# HashTag:user:{100}:profile 和 user:{100}:order 在同一槽
# user:{100}:profile 和 user:{200}:profile 不同槽
// Pipeline+HashTag正确用法
(1..100).each { |i|
pipe.set("user:100:profile:field#{i}", "value#{i}")
}
# HashTag提取:key中的 {100},所有含相同HashTag的key在同一Slot