第 38 章

慢查询与大 Key 分析

第38章 慢查询与大 Key 分析

性能问题的根源往往藏在细节中:一个被遗忘的 KEYS *、一个膨胀到十万 field 的 Hash、一个被高频访问的热 key。本章系统讲解如何发现、定位、分析并修复这些问题。


1. slowlog 配置与使用

1.1 配置参数

# redis.conf
slowlog-log-slower-than 10000   # 单位:微秒(μs)。10000μs = 10ms
                                 # 设为 0 记录所有命令(调试用)
                                 # 设为 -1 禁用 slowlog
slowlog-max-len 128             # 最多保留128条(环形缓冲,超出丢弃旧的)

动态修改(无需重启):

CONFIG SET slowlog-log-slower-than 5000   # 改为 5ms
CONFIG SET slowlog-max-len 256
CONFIG REWRITE                             # 持久化到 redis.conf

1.2 查询慢日志

SLOWLOG GET         # 获取所有(最多 slowlog-max-len 条)
SLOWLOG GET 10      # 最近 10 条
SLOWLOG LEN         # 当前慢日志数量
SLOWLOG RESET       # 清空慢日志

# 输出格式(每条包含4个字段):
# 1) 1) (integer) 14         ← 日志 ID(自增)
#    2) (integer) 1699000000 ← Unix 时间戳(命令执行时刻)
#    3) (integer) 28000      ← 执行耗时(微秒),28000μs = 28ms
#    4) 1) "KEYS"            ← 命令名
#       2) "*"               ← 参数(默认截断到128字节)
#    5) "127.0.0.1:54321"    ← 客户端地址(Redis 4.0+)
#    6) "myapp"              ← 客户端名称(CLIENT SETNAME 设置)

1.3 Python 解析慢日志

import redis
from datetime import datetime

r = redis.Redis()

def analyze_slowlog(count: int = 50):
    entries = r.slowlog_get(count)
    for entry in entries:
        ts = datetime.fromtimestamp(entry['start_time'])
        duration_ms = entry['duration'] / 1000
        cmd = ' '.join(
            arg.decode() if isinstance(arg, bytes) else str(arg)
            for arg in entry['command']
        )
        print(f"[{ts}] {duration_ms:.1f}ms | {cmd[:120]}")

analyze_slowlog()

2. 常见慢命令一览

命令 复杂度 阻塞风险 替代方案
KEYS * O(N) 极高 SCAN
HGETALL O(N) 高(field多时) HSCAN 分批
SMEMBERS O(N) 高(成员多时) SSCAN 分批
SORT O(N+M·log M) 预排序存 ZSET
LRANGE 0 -1 O(N) 高(列表长时) LRANGE 分页
SUNIONSTORE O(N) 拆分 + 归并
SINTERSTORE O(N·M) 极高 分步操作
ZRANGEBYLEX O(log N+M) 可用
DEL(大key) O(N) UNLINK(异步删除)
FLUSHDB O(N) 极高(同步) FLUSHDB ASYNC
# DEL 是同步删除,大 key 会阻塞主线程
DEL big_hash          # 危险:10万field的Hash,DEL耗时数百ms

# UNLINK 主线程只做脱离引用(O(1)),后台线程异步回收内存
UNLINK big_hash       # 安全
# 代码层面:对可能的大 key 统一使用 UNLINK
def safe_delete(*keys):
    return r.unlink(*keys)

3. bigkeys 扫描

3.1 使用 redis-cli --bigkeys

redis-cli --bigkeys
# 使用 SCAN 遍历,不阻塞主线程
# 对每种类型分别统计最大的 key

# 加 -i 参数降低扫描频率(减少对生产的影响)
redis-cli --bigkeys -i 0.1   # 每执行 100 条 SCAN 休眠 0.1 秒

输出示例:

# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

[00.00%] Biggest string found so far 'config:blob' with 512000 bytes
[23.45%] Biggest hash   found so far 'user:profile:9999' with 18432 fields
[67.89%] Biggest zset   found so far 'leaderboard:all' with 2048321 members

-------- summary -------
Sampled 1000000 keys in the keyspace!
Total key length in bytes is 24000000 (avg len 24.00)

Biggest   string found 'config:blob'       has 512000 bytes
Biggest     hash found 'user:profile:9999' has 18432 fields
Biggest     zset found 'leaderboard:all'   has 2048321 members
Biggest      set found 'tags:popular'      has 10240 members
Biggest     list found 'queue:jobs'        has 50000 items

1000000 strings with 8000000 bytes (08.00 bytes mean)
50000   hashs with 920000 fields (18.40 fields mean)
...

3.2 单 key 内存分析

# 递归计算 key 占用的所有内存(包括编码开销)
MEMORY USAGE user:profile:9999
# 返回:字节数

MEMORY USAGE user:profile:9999 SAMPLES 0   # 不采样,精确计算(慢)

4. hotkeys 扫描

4.1 开启 LFU 策略

hotkeys 扫描需要 Redis 使用 LFU 淘汰策略:

# redis.conf
maxmemory-policy allkeys-lfu   # 或 volatile-lfu

# 动态设置
CONFIG SET maxmemory-policy allkeys-lfu
redis-cli --hotkeys
# 扫描输出:
# [67.89%] Hot key 'product:detail:10086' found so far with counter 9876
# [89.12%] Hot key 'user:session:abc123' found so far with counter 7654
#
# -------- summary -------
# Sampled 1000000 keys in the keyspace!
# hot key found with counter: 9876    keyname: product:detail:10086
# hot key found with counter: 7654    keyname: user:session:abc123

4.2 实时监控热 key(MONITOR 方式)

# 危险:MONITOR 会输出所有命令,大流量下严重影响性能
# 仅用于短时间紧急排查
redis-cli monitor | head -n 10000 | grep -oP "(?<=\")[^\"]+(?=\")" | sort | uniq -c | sort -rn | head -20

5. 内存分析工具

5.1 在线工具

# 单 key 内存
redis-cli memory usage mykey

# 内存使用统计
redis-cli memory stats
# 输出:peak.allocated / total.allocated / fragmentation.ratio 等

# 内存医生(建议)
redis-cli memory doctor
# 示例输出:
# Hi Sam, I can't find any memory issues in your instance. I can however spot
# a few issues in your configuration:
# * Your vm.overcommit_memory is set to 0, ...

5.2 离线 RDB 分析

# 方式1:redis-rdb-tools(Python)
pip install rdbtools python-lzf

rdb --command memory dump.rdb > memory.csv
# 输出 CSV:database,type,key,size_in_bytes,encoding,num_elements,len_largest_element

# 排序找最大 key
sort -t',' -k4 -rn memory.csv | head -20

# 方式2:rdb-cli(Go,更快)
go install github.com/HDT3213/rdb@latest
rdb -c memory -port 7379 dump.rdb
# 启动 HTTP 服务,浏览器可视化分析

5.3 生成 RDB 快照

# 手动触发 RDB(会 fork,对性能有短暂影响)
BGSAVE
LASTSAVE   # 查看最近一次 RDB 时间戳

# 等待完成
redis-cli info persistence | grep rdb_bgsave_in_progress

6. 大 Key 拆分实战

6.1 场景:大 Hash(10 万 field)

# 问题:HGETALL user:1000 返回 100000 个 field,耗时 500ms+
HGETALL user:1000

拆分方案:按 field hash 分桶

import hashlib

SHARD_COUNT = 16

def hash_shard(field: str) -> int:
    return int(hashlib.md5(field.encode()).hexdigest(), 16) % SHARD_COUNT

def hset_sharded(base_key: str, field: str, value: str):
    shard = hash_shard(field)
    r.hset(f"{base_key}:s{shard}", field, value)

def hget_sharded(base_key: str, field: str) -> str:
    shard = hash_shard(field)
    return r.hget(f"{base_key}:s{shard}", field)

def hgetall_sharded(base_key: str) -> dict:
    result = {}
    pipe = r.pipeline()
    for i in range(SHARD_COUNT):
        pipe.hgetall(f"{base_key}:s{i}")
    shards = pipe.execute()
    for shard_data in shards:
        result.update({k.decode(): v.decode() for k, v in shard_data.items()})
    return result

6.2 场景:大 Set(百万成员)

SET_SHARD_COUNT = 100

def sadd_sharded(base_key: str, member: str):
    shard = hash(member) % SET_SHARD_COUNT
    r.sadd(f"{base_key}:s{shard}", member)

def sismember_sharded(base_key: str, member: str) -> bool:
    shard = hash(member) % SET_SHARD_COUNT
    return bool(r.sismember(f"{base_key}:s{shard}", member))

def scard_sharded(base_key: str) -> int:
    pipe = r.pipeline()
    for i in range(SET_SHARD_COUNT):
        pipe.scard(f"{base_key}:s{i}")
    return sum(pipe.execute())

6.3 场景:大 ZSet(亿级成员排行榜)

见第36章"超大排行榜"章节,此处不赘述。

6.4 大 String(值超过 1MB)

# 问题:存储了 Blob 或序列化后的大对象
SET product:all "[{...10万条商品JSON...}]"   # 10MB

# 解决方案1:压缩后存储(客户端压缩)
# 解决方案2:拆分为多个 key(结合分页)
# 解决方案3:大对象存 OSS/S3,Redis 只存 URL + 元数据
import gzip
import json

def set_compressed(key: str, data: dict, ttl: int = 3600):
    """压缩后存储大 JSON"""
    raw = json.dumps(data, ensure_ascii=False).encode()
    compressed = gzip.compress(raw)
    r.setex(key, ttl, compressed)

def get_compressed(key: str) -> dict:
    compressed = r.get(key)
    if not compressed:
        return None
    raw = gzip.decompress(compressed)
    return json.loads(raw.decode())

7. 内存碎片率处理

INFO memory | grep mem_fragmentation_ratio
# mem_fragmentation_ratio: 1.89   ← 碎片率 = RSS / used_memory
# 正常范围:1.0–1.5
# > 1.5:碎片严重,考虑整理
# < 1.0:内存被 swap 或使用 jemalloc 统计偏差

7.1 主动碎片整理(Redis 4.0+)

# 开启自动碎片整理(对性能有影响,建议在低峰期开启)
CONFIG SET activedefrag yes
CONFIG SET active-defrag-ignore-bytes 100mb    # 碎片超过100MB才整理
CONFIG SET active-defrag-enabled yes
CONFIG SET active-defrag-threshold-lower 10    # 碎片率>10%时开始
CONFIG SET active-defrag-threshold-upper 100   # 碎片率>100%时全速整理
CONFIG SET active-defrag-cycle-min 1           # 最低CPU占用%
CONFIG SET active-defrag-cycle-max 25          # 最高CPU占用%

# 手动触发一次整理(Redis 4.0+)
MEMORY PURGE

7.2 重启整理(终极手段)

大碎片率的最彻底解决:主从切换 + 重启旧主。RDB 加载后内存紧凑,碎片消除。


8. 生产排查 Checklist

发现 Redis 响应变慢时的标准排查流程:

□ 1. 查看慢日志:SLOWLOG GET 20
□ 2. 确认 QPS:INFO stats | grep instantaneous_ops_per_sec
□ 3. 确认内存:INFO memory(used_memory / maxmemory / fragmentation_ratio)
□ 4. 确认连接数:INFO clients(connected_clients / blocked_clients)
□ 5. 有无大 key:redis-cli --bigkeys -i 0.1(低流量时执行)
□ 6. 有无热 key:redis-cli --hotkeys(需 LFU 策略)
□ 7. 查看持久化状态:INFO persistence(aof_rewrite / rdb_bgsave 是否在进行)
□ 8. 查看复制状态:INFO replication(repl_backlog 是否溢出)
□ 9. 检查 CPU:top -p $(pgrep redis-server)
□ 10. 检查网络:netstat -an | grep 6379 | wc -l

本章总结

本章评分
4.6  / 5  (3 评分)

💬 留言讨论