用缓存最省事的做法是把整个对象 SET 成一段 JSON。等到要做排行榜、要做单字段更新、要统计去重人数时,才发现这条路走不通——不是性能问题,是结构问题:一段字符串没法在服务端做局部操作。Redis 的价值一半在内存,一半在"能按结构操作数据"。这篇按"选型 → 编码 → 成本 → 运维"的顺序走。
1. 五种类型与底层编码
| 类型 | 存什么 | 常用命令 | 底层编码(按版本核对) |
|---|---|---|---|
| string | 字节串、计数、序列化对象 | SET/GET/INCR/SETEX |
int、embstr、raw |
| hash | 对象的字段集合 | HSET/HGET/HGETALL/HDEL |
listpack(旧版 ziplist)、hashtable |
| list | 有序可重复队列 | LPUSH/RPOP/LRANGE |
quicklist(节点为 listpack) |
| set | 无序去重集合 | SADD/SMEMBERS/SINTER |
intset、listpack、hashtable |
| zset | 带分数的有序集合 | ZADD/ZRANGE/ZINCRBY |
listpack、skiplist + dict |
编码不是实现细节,它决定单个 key 的内存与操作复杂度:小对象用紧凑编码省内存,超阈值切到 hashtable / skiplist。
2. 编码切换的阈值
# redis.conf,数值按版本核对,Redis 7.x 的默认量级
hash-max-listpack-entries 128 # 字段数超过就转 hashtable
hash-max-listpack-value 64 # 单字段值超过就转 hashtable
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512 # 全整数集合超过就转 hashtable
list-max-listpack-size -2 # 负数表示按大小分级
| 现象 | 说明 |
|---|---|
| 元素数刚过阈值,内存突然涨一截 | 编码从 listpack 切到 hashtable / skiplist |
| 元素数降回阈值以下,内存不回落 | 编码不会自动转回紧凑格式,需重建 key |
| 单个大 value 放 hash 反而不省 | 超过 *-max-listpack-value 就整体转 hashtable |
OBJECT ENCODING key 可以直接看当前编码,排查"内存为什么比预期大"时先看它。
3. 典型场景与反例
| 场景 | 合适类型 | 反例 |
|---|---|---|
| 对象字段部分更新 | hash | 用 string 存 JSON,每次改一个字段都要读改写整串 |
| 计数、限流 | string + INCR/EXPIRE |
用 hash 存单值,多一层嵌套无收益 |
| 排行榜、延迟队列 | zset | 用 list 排序,每次都要全量取出再排 |
| 去重(UV、标签) | set | 用 string 存拼接串,判定存在要全串比较 |
| 简单队列 | list(或 stream) | 用 set 当队列,顺序不可控 |
最典型的错是用 string 存整个 JSON:整体读写很好,但改一个字段就要全量搬运,序列化成本压过 Redis 本身。
4. 单值大小与内存开销
| 单值大小 | 影响 |
|---|---|
| 几十字节到几 KB | 正常,紧凑编码友好 |
| 上百 KB | 单次网络往返变大,阻塞其他命令的时间变长 |
| 几 MB 以上(大 key) | 删除、过期、迁移都会造成明显停顿 |
# 找出大 key(抽样,非全量扫描)
redis-cli --bigkeys
# 看单个 key 的内存占用(Redis 4+)
redis-cli MEMORY USAGE user:1001
# 更细的分布:按前缀抽样统计
redis-cli --memkeys --memkeys-samples 1000
--bigkeys 是抽样扫描,会有一定开销,在从库或低峰执行。发现大 key 后优先考虑拆成 hash 字段或分片 key,而不是简单删掉了事。
5. 过期策略与"过期了内存没降"
Redis 的过期是惰性删除 + 定期删除的组合:访问时检查、后台周期性抽样清理。所以 key 逻辑上过期了,物理内存不一定立刻释放。
| 现象 | 原因 | 处理 |
|---|---|---|
DBSIZE 降了但 used_memory 没降 |
已删除对象的分配器碎片未归还 | 观察 mem_fragmentation_ratio,必要时重启或 MEMORY PURGE |
| 大量 key 同时过期 | 集中过期造成周期性延迟尖峰 | 给 TTL 加随机扰动,打散过期时间 |
| 过期 key 一直占内存 | 从未被访问,定期删除也没抽到 | 属正常;量大时考虑分批主动清理 |
TTL 管失效,maxmemory + 淘汰策略管"满了怎么办",两者不能互相替代。
6. 淘汰策略与命中率
maxmemory 4gb
maxmemory-policy allkeys-lru # 纯缓存场景的常见选择
| 策略 | 淘汰范围 | 适合 |
|---|---|---|
noeviction |
不淘汰,写入报错 | 当数据库用、数据不能丢 |
allkeys-lru |
所有 key 按最近最少使用 | 纯缓存,访问有局部性 |
allkeys-lfu |
按访问频率 | 热点稳定、长尾多 |
volatile-lru / volatile-ttl |
只在设了 TTL 的 key 里淘汰 | 混合用途:一部分必须保留、一部分可丢 |
allkeys-random |
随机 | 极少用,命中率不可控 |
取舍点很直接:淘汰越激进,命中率越低但写入越安全;越保守,写入越容易失败。把 noeviction 用在缓存上,流量高峰会直接变成写失败。
LRU 是近似实现(采样比较空闲时间),不是精确链表。
7. 单线程模型的实际含义
"单线程"指的是命令执行是串行的,不是整个进程只有一个线程(IO 多线程、后台线程另算)。它带来两个结论:
| 结论 | 含义 |
|---|---|
| 单条命令原子 | INCR、LPUSH 不需要额外加锁 |
| 慢命令会阻塞所有人 | 一条 O(N) 命令卡住,其他客户端全部等待 |
KEYS * 是经典事故:它遍历整个键空间,keys 越多阻塞越久。替代方案是 SCAN:
# 错误:全量遍历,阻塞主线程
redis-cli KEYS "user:*"
# 正确:游标分批,每次少量
SCAN 0 MATCH user:* COUNT 100
# 返回 (新游标, key 列表),游标为 0 表示结束
其他高风险 O(N) 命令:HGETALL 大 hash、SMEMBERS 大 set、DEL 大 key(改用 UNLINK)。
8. 持久化对延迟的影响
| 方式 | 机制 | 延迟影响 |
|---|---|---|
| RDB | fork 子进程写快照,父进程靠写时复制继续服务 | fork 本身在内存大时有停顿;写入量大时 COW 内存翻倍 |
AOF everysec |
每秒 fsync 一次 |
通常影响小,最坏丢 1 秒数据 |
AOF always |
每条命令都 fsync |
延迟明显上升,吞吐下降 |
AOF no |
交给操作系统 | 最快,但可能丢较多数据 |
| 混合持久化 | RDB + AOF 增量 | 恢复快,文件结构较复杂 |
建议:纯缓存只开 RDB 或关闭持久化;有业务数据用 AOF everysec;AOF 重写时同样关注 fork 停顿。
9. 分布式锁的最小实现与常见错误
# 加锁:必须带 NX(不存在才设)和过期时间,一条命令原子完成
SET lock:order:1001 <token> NX PX 30000
# 释放:先比对 token 再删,必须用 Lua 保证原子
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:order:1001 <token>
| 错误 | 后果 | 正确做法 |
|---|---|---|
SETNX 后再 EXPIRE |
两步之间崩溃 → 锁永不过期 | SET NX PX 一条命令 |
| 无过期时间 | 持有者崩溃后锁永久占用 | 必须设 TTL |
直接 DEL 不校验 |
误删他人的锁 | 用 token + Lua 比对 |
| 锁过期后继续操作 | 两个持有者同时写 | 长任务做续期(看门狗)或缩短临界区 |
| 把锁当强一致保证 | 主从切换时锁可能丢失 | 需要强一致时用带共识的方案 |
10. 连接、内存与写入拒绝
| 配置 | 作用 | 调错的症状 |
|---|---|---|
maxmemory |
内存上限 | 不设 → 打满物理内存被 OOM killer 杀掉 |
maxmemory-policy |
满后行为 | noeviction 下写入直接报错 |
maxclients |
最大连接数(默认万级,按版本核对) | 连接池过大 → 新连接被拒 |
timeout |
空闲连接回收 | 不设 → 连接堆积 |
连接池的经验方向:池大小按并发命令数估,不按线程数估;同时给每个操作设超时,避免一个慢命令拖住整个池。
11. 监控三个指标
| 指标 | 命令 | 异常判断 |
|---|---|---|
| 命中率 | INFO stats 的 keyspace_hits/misses |
长期低于预期 → 键设计或 TTL 有问题 |
| 内存 | INFO memory 的 used_memory、mem_fragmentation_ratio |
持续上涨或碎片比过高 |
| 淘汰量 | INFO stats 的 evicted_keys |
持续增长 → 容量不足或淘汰策略过激 |
12. 最小示例:四种结构一次用全
# string:计数器 + 过期
SET page:view:20261109 0 EX 86400
INCR page:view:20261109
GET page:view:20261109
# hash:对象字段局部更新
HSET user:1001 name "yg" level 3
HINCRBY user:1001 level 1
# zset:排行榜
ZADD rank:weekly 120 user:1001 90 user:1002
ZINCRBY rank:weekly 10 user:1001
ZREVRANGE rank:weekly 0 9 WITHSCORES
# set:去重统计
SADD uv:20261109 user:1001
SCARD uv:20261109
13. 排错:六个常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 偶发全站变慢 | KEYS 或大 key 的 HGETALL |
换 SCAN,拆大 key,看 SLOWLOG |
| 内存突然翻倍 | 编码切换(listpack → hashtable) | 看 OBJECT ENCODING,控制单 key 元素数 |
| 写入报 OOM 错误 | 淘汰策略为 noeviction 或已无可淘汰 key |
改策略或扩内存,不要只清 key |
| 缓存与数据库不一致 | 双写顺序错、或删除缓存失败 | 先更新库再删缓存,删除失败要重试 |
| 连接数打满 | 连接池过大或连接泄漏 | 收紧池上限,设空闲回收与超时 |
used_memory 高于数据量 |
分配器碎片 | 看碎片比,必要时 MEMORY PURGE 或重启 |
14. 边界:什么时候不该用 Redis 存这个
| 数据 | 更合适的选择 | 理由 |
|---|---|---|
| 需要事务、复杂查询的业务数据 | 关系库 | Redis 无跨 key 事务与查询能力 |
| 需要长期保存、不能丢的数据 | 数据库 + 缓存 | 持久化不等于可靠存储 |
| 超大对象(几十 MB 以上) | 对象存储 | 单值过大拖慢整个实例 |
| 需要强一致的分布式锁 | 共识系统 | 主从切换可能丢锁 |
| 复杂聚合统计 | 分析型存储 | 服务端聚合能力有限,且会阻塞 |
判断标准:Redis 是"快但小"的一层,不是主存储;当数据库用迟早撞上内存与恢复两个问题。
自测
- 为什么"用 string 存整个 JSON"在需要单字段更新时会失效?
- 元素数刚超过编码阈值时,内存为什么突然涨一截?降回来为什么不回落?
- 过期 key 为什么可能仍占内存?这和淘汰策略是什么关系?
noeviction用在纯缓存上会发生什么?KEYS *的危害来自单线程模型的哪一条特性?SETNX+EXPIRE两步写锁,风险具体在哪一步?