2888 字
约 9 分钟
18
S12-B06 · Redis 入门:五种数据结构与它们的真实成本

用缓存最省事的做法是把整个对象 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 是"快但小"的一层,不是主存储;当数据库用迟早撞上内存与恢复两个问题。


自测

  1. 为什么"用 string 存整个 JSON"在需要单字段更新时会失效?
  2. 元素数刚超过编码阈值时,内存为什么突然涨一截?降回来为什么不回落?
  3. 过期 key 为什么可能仍占内存?这和淘汰策略是什么关系?
  4. noeviction 用在纯缓存上会发生什么?
  5. KEYS * 的危害来自单线程模型的哪一条特性?
  6. SETNX + EXPIRE 两步写锁,风险具体在哪一步?
S12-B06 · Redis 入门:五种数据结构与它们的真实成本
https://www.clxhxhhr.top/posts/6063/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。