Skip to content

内存治理:大 key、热 key 与碎片率 ​

Redis 出故障,八成是内存问题:没设上限被 OOM 杀、大 key 阻塞主线程、碎片率飙升、key 过期了内存不降。

场景:先回答三个问题 ​

  1. maxmemory 设了吗?没设 = 无上限 = 迟早被内核 OOM killer 带走。
  2. 最大的 key 有多大?超过 10MB 就是定时炸弹。
  3. 内存里有用的和闲置的比例是多少?这决定要不要扩容或清理。

一、看清内存构成 ​

bash
redis-cli -a '密码' info memory
text
# 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_memoryRedis 自己统计的已用内存(含开销)接近 maxmemory 就开始淘汰
used_memory_rss操作系统看到的实际物理内存与 used_memory 的比值就是碎片率
mem_fragmentation_ratiorss / used_memory1.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:timeline
text
(integer) 6183842
list
(integer) 128374

6.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:timeline

UNLINK 把 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 yes

2. 拆结构

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 分 key

3. 分批读

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 keyspace
text
expired_keys:1842934
evicted_keys:0
keyspace_hits:98123445
keyspace_misses:1209384

# Keyspace
db0:keys=182344,expires=120334,avg_ttl=3612841

avg_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_running
text
active_defrag_running:0

activedefrag 是在线整理,不阻塞,但吃 CPU。碎片率 >2 时,最干净的办法仍是低峰重启(配合主从切换,一台一台来)。

注意:用 jemalloc 时碎片率通常比 libc 低。容器环境若用 MALLOC=libc 编译,碎片会更明显。

七、内存估算 ​

类型单个元素额外开销
key 本身dictEntry 约 24 字节 + SDS 头 + key 字符串
string valueSDS 头(3~5 字节)+ 数据
hash(小)ziplist/listpack,几乎无额外开销
hash(大)每个 field 一个 dictEntry,约 50~70 字节
listquicklist 节点,每个节点约 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 stats
text
(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) 1843029504

keys.bytes-per-key 是平均每个 key 占的内存。10KB 以上说明小 key 太多或者大 key 拉高了均值。

八、踩过的坑 ​

现象根因处置
进程被 OOM killer 杀没设 maxmemory设置上限,并让 limit > maxmemory × 1.3
写命令报 OOM command not allowednoeviction 且写满换淘汰策略,或扩容
某个 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,跑完再切回