外观
内存治理:大 key、热 key 与碎片率
Redis 出故障,八成是内存问题:没设上限被 OOM 杀、大 key 阻塞主线程、碎片率飙升、key 过期了内存不降。
场景:先回答三个问题
maxmemory设了吗?没设 = 无上限 = 迟早被内核 OOM killer 带走。- 最大的 key 有多大?超过 10MB 就是定时炸弹。
- 内存里有用的和闲置的比例是多少?这决定要不要扩容或清理。
一、看清内存构成
bash
redis-cli -a '密码' info memorytext
# Memory
used_memory:2194302464
used_memory_human:2.04G
used_memory_rss:2684354560
used_memory_rss_human:2.50G
used_memory_peak:2415919104
used_memory_peak_human:2.25G
used_memory_peak_perc:90.79%
used_memory_overhead:351272960
used_memory_startup:1024000
used_memory_dataset:1843029504
used_memory_dataset_perc:84.01%
allocator_allocated:2194631264
allocator_active:2358022144
allocator_resident:2659184640
total_system_memory:16850210816
total_system_memory_human:15.69G
used_memory_lua:31744
used_memory_vm_eval:31744
used_memory_scripts_eval:0
number_of_cached_scripts:8
number_of_functions:0
maxmemory:4294967296
maxmemory_human:4.00G
maxmemory_policy:allkeys-lru
mem_fragmentation_ratio:1.22
mem_fragmentation_bytes:490051584
mem_not_counted_for_evict:0
mem_replication_backlog:0
mem_total_replication_buffers:0
mem_clients_slaves:0
mem_clients_normal:512000
mem_cluster_links:0
mem_aof_buffer:0
mem_allocator:libc
active_defrag_running:0
lazyfree_pending_objects:0
lazyfreed_objects:0四个必看字段:
| 字段 | 含义 | 判断标准 |
|---|---|---|
used_memory | Redis 自己统计的已用内存(含开销) | 接近 maxmemory 就开始淘汰 |
used_memory_rss | 操作系统看到的实际物理内存 | 与 used_memory 的比值就是碎片率 |
mem_fragmentation_ratio | rss / used_memory | 1.0~1.5 正常;>1.5 碎片严重;<1 在用 swap |
used_memory_dataset_perc | 有效数据占比 | 太低说明开销大(大量小 key) |
mem_fragmentation_ratio < 1 是在 swap
说明操作系统把一部分内存换到磁盘了。Redis 是内存数据库,一旦走 swap,延迟从微秒变成毫秒级。free -h 看 swap 使用量,vm.swappiness 调到 1 或 0。
二、设上限与淘汰策略
bash
redis-cli -a '密码' config set maxmemory 4gb
redis-cli -a '密码' config set maxmemory-policy allkeys-lru
redis-cli -a '密码' config rewrite八种策略:
| 策略 | 淘汰范围 | 适用 |
|---|---|---|
noeviction | 不淘汰,写满报错 | 当数据库用,不能丢数据 |
allkeys-lru | 所有 key,最近最少用 | 纯缓存首选 |
allkeys-lfu | 所有 key,访问频率最低 | 热点集中、有明显冷数据 |
allkeys-random | 所有 key 随机 | 几乎不用 |
volatile-lru | 仅设了过期时间的 key | 缓存 + 持久数据混存 |
volatile-lfu | 同上,频率维度 | 同上且访问倾斜明显 |
volatile-random | 同上,随机 | 不用 |
volatile-ttl | 优先淘汰快过期的 | 有明确的短期数据 |
选择路径:
text
所有 key 都是缓存 → allkeys-lru
混存(有不能丢的) → volatile-lru,并且给缓存 key 必须设 TTL
绝对不能丢 → noeviction,靠监控 + 扩容,写满就报错而不是静默丢volatile-lru + 部分 key 没设 TTL = 内存打满后写失败
volatile-* 只淘汰有 TTL 的 key。如果你的缓存 key 有一半忘了设过期时间,内存满了以后这些永久 key 不会被淘汰,Redis 直接返回 OOM command not allowed when used memory > 'maxmemory'。
LFU 的两个参数:
ini
# 计数器衰减:多少分钟不用,频率计数减 1
lfu-decay-time 1
# 初始计数(新 key 不要一下子太高)
lfu-log-factor 10三、大 key
怎么找
bash
redis-cli -a '密码' --bigkeys输出:
text
# 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 '"user:8891:profile"' with 18342 bytes
[12.31%] Biggest hash found so far '"cart:30012"' with 4211 fields
[45.72%] Biggest list found so far '"feed:timeline"' with 128374 items
...
-------- summary -------
Sampled 182344 keys in the keyspace!
Total key length in bytes is 4382112 (avg len 24.03)
Biggest string found '"user:8891:profile"' has 18342 bytes
Biggest hash found '"cart:30012"' has 4211 fields
Biggest list found '"feed:timeline"' has 128374 items
48912 strings with 1204512 bytes (26.82% of keys, avg size 24.63)
102334 hashes with 8123441 fields (56.12% of keys, avg size 79.38)
...--bigkeys 用的是 SCAN + TYPE + STRLEN/LLEN/HLEN,不会阻塞,但会扫全库。千万级的库要几分钟,期间 QPS 会有些抖,低峰跑。
精确查单个 key:
bash
redis-cli -a '密码' memory usage feed:timeline samples 0
redis-cli -a '密码' type feed:timeline
redis-cli -a '密码' llen feed:timelinetext
(integer) 6183842
list
(integer) 1283746.1MB / 12.8 万个元素,这就是典型的大 key。
为什么大 key 危险
| 操作 | 后果 |
|---|---|
DEL bigkey | 主线程同步释放内存,10 万元素的 list 能卡几百毫秒 |
HGETALL bigkey | 一次性构造巨大响应,网卡打满 + 客户端 OOM |
LRANGE bigkey 0 -1 | 同上 |
SMEMBERS | 同上 |
| 过期删除 | 惰性删除在主线程执行,同样阻塞 |
| 迁移槽位 | 单 key 无法拆分,迁移卡住 |
| 主从全量同步 | 网络传输时间长 |
怎么治
1. 删除用 UNLINK
bash
redis-cli -a '密码' unlink feed:timelineUNLINK 把 key 从 keyspace 摘掉,实际内存释放交给后台线程。
ini
# 让过期/淘汰也走异步
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
lazyfree-lazy-user-del yes
lazyfree-lazy-user-flush yes2. 拆结构
bash
# 不要一个大 hash 存所有用户购物车
HSET cart:30012 item:1 1 item:2 3 ...
# 拆成 hash field 分桶:每桶最多 100 个
HSET cart:30012:0 item:1 1 ... item:100 1
HSET cart:30012:1 item:101 1 ...
# 或者 string + JSON 分片,按时间/ID 分 key3. 分批读
bash
# 不要 HGETALL
redis-cli --scan --pattern 'cart:30012:*' | head -20
# 代码里用 HSCAN 游标遍历text
0> HSCAN cart:30012 0 COUNT 100
1) "128"
2) 1) "item:1" 2) "1" 3) "item:2" 4) "3"4. 控制写入
bash
# list 只保留最近 1000 条
redis-cli -a '密码' ltrim feed:timeline 0 999线上规范:string ≤ 10KB,hash/list/set/zset ≤ 5000 元素。超过就要审查。
四、热 key
大 key 是"单个太大",热 key 是"单个太频繁"。1KB 的 key 每秒被读 5 万次,单线程的 Redis 一样会被打满。
怎么找
bash
redis-cli -a '密码' --hotkeys输出:
text
# Scanning the entire keyspace to find hot 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%] Hot key '"rank:global:daily"' found so far with counter 1042
[32.15%] Hot key '"config:app:version"' found so far with counter 8913
-------- summary -------
Sampled 182344 keys in the keyspace!
hot key found with counter: 8913 keyname: "config:app:version"
hot key found with counter: 1042 keyname: "rank:global:daily"--hotkeys 要求 maxmemory-policy 是 allkeys-lfu 或 volatile-lfu,因为它读的是 LFU 计数器。LRU 模式下用不了。
怎么治
| 手段 | 做法 | 代价 |
|---|---|---|
| 本地缓存 | 进程内 Caffeine/Guava 缓存 1~5 秒 | 数据有秒级延迟 |
| 多级缓存 | 本地 + Redis,本地过期时间短 | 一致性变复杂 |
| key 打散 | hotkey:{1..10} 随机读一个 | 要合并结果 |
| 读写分离 | 热 key 复制到多个从库,客户端随机读 | 从库延迟 |
| 限流 | 业务侧对该 key 的读加限流 | 影响功能 |
打散示例:
bash
# 写:写 10 份
for i in $(seq 1 10); do redis-cli -a '密码' -c set "rank:global:daily:$i" "$DATA"; done
# 读:随机挑一份
# index = random(1..10)
redis-cli -a '密码' -c get "rank:global:daily:$((RANDOM % 10 + 1))"五、过期了内存为什么不降
Redis 的过期删除是惰性 + 定期抽样:
- 惰性:只有访问到这个 key 才检查是否过期。
- 定期:每 100ms 随机抽 20 个带 TTL 的 key,删掉过期的;如果过期比例 > 25% 就再来一轮。
结果:大量 key 同时过期时,内存不会立刻下降,要等抽样慢慢删;而抽样在主线程跑,删得太猛还会造成延迟毛刺。
bash
redis-cli -a '密码' info stats | grep -E 'expired_keys|evicted_keys|keyspace_hits|keyspace_misses'
redis-cli -a '密码' info keyspacetext
expired_keys:1842934
evicted_keys:0
keyspace_hits:98123445
keyspace_misses:1209384
# Keyspace
db0:keys=182344,expires=120334,avg_ttl=3612841avg_ttl 单位是毫秒,3612841 ms ≈ 1 小时。expires 比 keys 少,说明有 6 万 key 没有过期时间——这批就是将来会累积成"内存泄漏"的东西。
给 key 打分散的 TTL
别用 EXPIRE key 86400 的整点写法,否则 24 小时后同一秒集中过期,那一秒 CPU 打满。加个随机偏移:86400 + random(0, 3600)。
六、碎片率
bash
redis-cli -a '密码' info memory | grep -E 'mem_fragmentation_ratio|mem_fragmentation_bytes|allocator_frag_ratio'text
mem_fragmentation_ratio:1.22
mem_fragmentation_bytes:490051584
allocator_frag_ratio:1.07解释:
mem_fragmentation_ratio≈rss / used_memory。- 1.0~1.5:正常。
- >1.5:碎片严重,
rss比实际数据多出 50%,重启能立刻回收。 - <1.0:在用 swap,性能灾难。
主动整理
bash
redis-cli -a '密码' config set activedefrag yes
redis-cli -a '密码' config set active-defrag-ignore-bytes 100mb
redis-cli -a '密码' config set active-defrag-threshold-lower 10
redis-cli -a '密码' config set active-defrag-threshold-upper 100
redis-cli -a '密码' config set active-defrag-cycle-min 1
redis-cli -a '密码' config set active-defrag-cycle-max 25
redis-cli -a '密码' config rewrite参数含义:
| 配置 | 含义 |
|---|---|
ignore-bytes 100mb | 碎片小于 100MB 不管 |
threshold-lower 10 | 碎片率 >10% 开始整理 |
threshold-upper 100 | 碎片率 >100% 全力整理 |
cycle-min/max 1/25 | 每次整理占用的 CPU 百分比区间 |
bash
redis-cli -a '密码' info memory | grep active_defrag_runningtext
active_defrag_running:0activedefrag 是在线整理,不阻塞,但吃 CPU。碎片率 >2 时,最干净的办法仍是低峰重启(配合主从切换,一台一台来)。
注意:用 jemalloc 时碎片率通常比 libc 低。容器环境若用 MALLOC=libc 编译,碎片会更明显。
七、内存估算
| 类型 | 单个元素额外开销 |
|---|---|
| key 本身 | dictEntry 约 24 字节 + SDS 头 + key 字符串 |
| string value | SDS 头(3~5 字节)+ 数据 |
| hash(小) | ziplist/listpack,几乎无额外开销 |
| hash(大) | 每个 field 一个 dictEntry,约 50~70 字节 |
| list | quicklist 节点,每个节点约 40 字节 + 数据 |
| set(大) | 每个元素一个 dictEntry,约 50 字节 |
| zset | 每个元素 skiplist 节点 + dictEntry,约 100 字节 |
所以 100 万个 20 字节的小 string,数据本身只占 20MB,实际内存可能到 70~90MB。用 MEMORY USAGE key 看单个,用 used_memory_dataset_perc 看整体开销占比。
bash
redis-cli -a '密码' memory usage some:key
redis-cli -a '密码' memory statstext
(integer) 104
127.0.0.1:6379> memory stats
1) "peak.allocated"
2) (integer) 2415919104
...
17) "keys.count"
18) (integer) 182344
19) "keys.bytes-per-key"
20) (integer) 12031
21) "dataset.bytes"
22) (integer) 1843029504keys.bytes-per-key 是平均每个 key 占的内存。10KB 以上说明小 key 太多或者大 key 拉高了均值。
八、踩过的坑
| 现象 | 根因 | 处置 |
|---|---|---|
| 进程被 OOM killer 杀 | 没设 maxmemory | 设置上限,并让 limit > maxmemory × 1.3 |
写命令报 OOM command not allowed | noeviction 且写满 | 换淘汰策略,或扩容 |
| 某个 key 删了以后卡顿 | 大 key 同步 DEL | 用 UNLINK,开 lazyfree-* |
内存不降但 dbsize 变小 | 碎片/过期未清 | 查 expired_keys,activedefrag |
| 整点请求变慢 | 批量 key 同时过期 | TTL 加随机偏移 |
mem_fragmentation_ratio < 1 | 用了 swap | 关 swap 或 vm.swappiness=1 |
| 主从内存差很多 | 从库有 repl-backlog 和客户端缓冲 | 正常,注意 mem_clients_* |
--hotkeys 报错 | 淘汰策略不是 LFU | 先切 allkeys-lfu,跑完再切回 |