一、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 results = redisTemplate.executePipelined(pipelineCallback);
// 10个key × 1次RTT = ~1ms

// RedisTemplate使用
List results = redisTemplate.executePipelined((SessionCallback) session -> {
    for (String key : keys) {
        session.opsForValue().get(key);
    }
    return null;
});

// Lua脚本:原子性 + 减少RTT
// 场景:库存扣减(先查后减,必须原子)
String luaScript =
    "local stock = redis.call('GET', KEYS[1]) " +
    "if stock and tonumber(stock) >= tonumber(ARGV[1]) then " +
    "  redis.call('DECRBY', KEYS[1], ARGV[1]) " +
    "  return 1 " +
    "else " +
    "  return 0 " +
    "end";
DefaultRedisScript script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, List.of("stock:product:1001"), "5");

// KEYS vs SCAN:生产环境禁止KEYS
// ❌ KEYS pattern — O(N),阻塞所有命令
// ✅ SCAN cursor — 分批渐进返回,每次最多10个key,不阻塞

// Lua脚本最佳实践
// 1. 避免长脚本(>10ms会阻塞其他命令)
// 2. 使用SCRIPT LOAD + EVALSHA 预加载脚本(避免每次发送脚本体)
String sha = redisTemplate.execute(new RedisScript() {
    public String getScript() { return luaScript; }
});
redisTemplate.execute(new RedisScript() {
    public String getScriptAsSha() { return sha; }
}, List.of("stock:1001"), "5");
    

    

四、持久化阻塞与内存管理

// 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