2979 字
约 9 分钟
15
S12-A05 · Redis 数据模型决策:结构选型与改写代价

用户资料以 SET user:1001 '{"id":1001,"nick":"a","avatar":"x"}' 存了两年。产品说"只改昵称",代码里就多出读全量、反序列化、改字段、序列化、写回五步;两个请求一并发,改头像的把改昵称的覆盖了。这不是并发技巧问题——模型按数据实体建,访问却按字段发生。账单从这天起计息,通常等量级上来才付。


1. 一次字段级修改的成本

GET user:1001 → 3 KB JSON → 反序列化 → 改 nick → 序列化 → SET 覆盖整键
并发窗口:A 读 v1,B 读 v1,A 写 v2,B 写 v3 → A 的修改消失
改法 改一个字段的代价 并发安全 何时成立
String + 整段 JSON 读全量 + 两次序列化 + 写全量 需 WATCH/Lua 兜底 整存整取、极少改
Hash 分字段 一次 HSET 单命令原子 字段独立更新
拆多个 String 一次 SET,键数暴涨 单命令原子,跨键无原子 每个字段都是独立热读

判据不是"哪个快",而是写粒度与读粒度是否一致。读整写零散 → 缺视图;读单字段写整体 → 该拆键。


2. 五种结构的语义与误用

结构 真实语义 常见误用 该用在
String 一段字节,可原子自增 存整个大对象;当锁却不设过期 计数、单值、位图
Hash 字段字典 存上万字段的"表",单键成大键 对象字段级读写
List 双端队列,按插入序 当可靠队列(无 ack、无消费组) 时间线、最近 N 条
Set 无序唯一集 只为去重却长期增长 交并差、标签
ZSet 按 score 排序的唯一集 全量 ZRANGE 无分页 排行、延时任务、范围查询

三个高频误判:Hash 存大对象后单键不可拆;List 弹出即丢,崩溃永久丢失;Set 去重的基数无上界时,本质是内存泄漏的体面写法。


3. 选型判据

判据 问法 指向
访问模式 整存整取,还是按字段读写 整体 → String;字段 → Hash
更新粒度 改一个字段,还是成批替换 单字段 → Hash;成批 → String
单键大小 元素数能否上界 不能 → 必须先切键
范围查询 按序/按分值区间取吗 需要 → ZSet;只需成员 → Set
1. 单键元素数能上界吗?不能 → 按业务维度切键(user:1001:tags / user:1001:orders)
2. 只读几个字段吗?是 → Hash,字段名当列名
3. 要"第 N 页"或"分值区间"吗?要 → ZSet,score 承载排序键
4. 全部指向整存整取 → String,别过度设计

单键大小决定的不是性能,而是未来能不能扩容:不可分的 500 MB Hash 在 Cluster 里永远是单槽单节点。


4. 按读取方式设计,而不是按数据实体

详情页 → 全字段一次读      → String order:{id}
列表页 → id/金额/状态/时间 → Hash order:list:{id},或 ZSet 索引 + MGET
改备注 → 只改一个字段      → Hash 字段级 HSET
// 反例:列表页读全量,90% 字段用不到
for (Long id : pageIds) {
    Order o = mapper.readValue(redis.get("order:" + id), Order.class);
}

// 正例:按读法建视图,只取所需字段
List<Object> rows = redis.executePipelined((RedisCallback<Object>) c -> {
    for (Long id : pageIds) c.hashCommands().hMGet(k(id).getBytes(), F_LIST);
    return null;
});

多一份视图 = 多一份写放大 + 一个不一致窗口。只有读远多于写、且读的形状稳定时才值得开视图。


5. 序列化格式的演化

格式 单字段改写 跨版本兼容 适用
JSON 全量 读全量 + 改 + 写全量 加字段宽松,删改字段要兼容代码 结构稳定、整存整取
Hash 字段 一次 HSET 字段增删天然兼容 字段独立更新
二进制(protobuf 等) 仍读全量改全量,但体积小 靠字段号与默认值 大 value、带宽敏感

字段级写 HSET user:1001 nick "newname",服务端不解析其他字段;整体写 SET user:1001 '{...}',中间存在并发窗口。

演化几乎单向:JSON → Hash。唯一回头理由是 value 太大导致字段数爆炸,那时该做的不是换格式,而是把大对象移出 Redis。二进制格式的隐性代价在升级:字段号一旦复用,旧数据会被按错误类型解析,而 Redis 没有 schema 迁移工具。


6. 原子性边界

手段 原子范围 竞态处理 代价
单命令 单键单次操作 天然无竞态 表达不了"读后判断再写"
Lua 脚本 脚本内全部命令,同节点 服务端串行 脚本须幂等、无随机与外部 IO
WATCH+MULTI 监视键未变才提交 冲突时 EXEC 返回 nil,要重试 高冲突下重试风暴
分布式锁 跨进程临界区 超时、续期、误删 引入第二套一致性问题
-- 版本一致才写:返回 1 成功 / 0 冲突
if redis.call('HGET', KEYS[1], 'version') ~= ARGV[1] then return 0 end
redis.call('HSET', KEYS[1], 'nick', ARGV[2], 'version', ARGV[3])
return 1
while (true) {                       // WATCH 冲突必须重试,否则等于静默丢弃
    redis.watch(key);
    String cur = redis.get(key);
    Transaction t = redis.multi();
    t.set(key, mutate(cur));
    if (t.exec() != null) return;     // null = 被改过
}

Cluster 下 Lua 的键必须同槽,否则 CROSSSLOT——"要不要用 Lua"常等价于"键设计里有没有 hash tag"。


7. 过期与回收的语义

语义点 事实 后果
EXPIRE 是覆盖写 每次调用重置倒计时 频繁写的键永不过期
惰性删除 + 定期抽样 过期键被访问或抽中才释放 内存曲线滞后于逻辑过期
淘汰看内存不看 TTL allkeys-lru 可能淘汰未过期键 缓存与业务数据混库风险高
DEL 大键是同步的 大 Hash/List 阻塞事件循环 改用 UNLINK
maxmemory 4gb
maxmemory-policy allkeys-lru
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-user-del yes

判据:缓存键与业务键不同库不同策略,否则一次内存压力就会让长期数据先于缓存被淘汰。


8. 分片与 hash tag

场景 Cluster 约束 对策
MGET 多键 仅同槽可用 共同前缀 + 同一 tag
Lua 多键 全部同槽 关联键塞进一个 tag
$ redis-cli -c CLUSTER KEYSLOT user:1001            # 单键落槽
$ redis-cli -c CLUSTER KEYSLOT '{user:1001}:orders' # tag 强制同槽

hash tag 是双刃剑:让多键原子成为可能,同时把整组键钉在一个分片,热 key 的隔离能力一起丢掉。只有"这组键必须原子且热度可控"时才加。


9. 内存估算口径

单键 ≈ 数据字节 + 编码/结构开销 + 键名 + 过期字典条目 + 分配器碎片
小 Hash(字段少)结构开销可达数据本身的 2~3 倍;大 String 接近 1.05~1.3 倍
精确复核:MEMORY USAGE key SAMPLES 5(按目标版本核对输出含义)

口径要统一到"每个业务实体占多少字节",而不是每个键:同一份数据拆成 10 个小键,键名与结构开销翻几倍,按键数做预算必然低估。


10. 改模型的迁移路径

阶段 动作 回滚点
1 双写 新旧结构同时写,读仍走旧 关双写开关
2 回填 SCAN + 管道回填存量 任务可中断重跑
3 校验 抽样比对新旧一致率 不一致不切换
4 切读 按比例切读新结构 比例回 0
5 停旧 观察窗口后停写旧结构 旧数据留一个周期
try { writeNew(model); }                          // 双写要允许新写失败
catch (Exception e) { newWriteFailure.record(e); } // 打点,不阻断主流程
writeOld(model);

成本不在代码,在回滚点:第 4 步切读出问题能否分钟级回退,取决于旧结构是否还在被写。停旧写那一刻,回滚只剩"重新回填"。


11. 什么数据不该放 Redis

数据类型 原因 更合适的位置
需强一致的多键事务 跨槽无事务,Lua 也只保单节点 关系库
超大对象(几十 MB 起) 单键不可分、删除阻塞、网络放大 对象存储 + 元数据入 Redis
财务流水、审计账 淘汰策略可能直接丢 持久库 + 消息
唯一真相源的配置 被淘汰即行为突变 配置中心

判断句式:这份数据丢了能否靠重算恢复? 不能,就别把 Redis 当唯一住所。


12. 可观测

看四件事:按前缀分组的键空间分布(某前缀独占多数内存即设计偏差)、命中率 keyspace_hits/(hits+misses)、平均 value used_memory/keys、碎片率 mem_fragmentation_ratio(持续 > 1.5 需关注分配器)。大键扫描用 --bigkeys,键名形态用 --scan --pattern 采样。

命中率低不一定是容量不够,更常见是同一份数据用了不同键名读。


13. 排错

现象 原因 处理
CROSSSLOT 多键命令落在不同槽 加 hash tag,或客户端拆分后合并
字段改了仍读旧值 读写走了两个结构 查切读比例与回填进度
内存涨而键数不涨 单键在追加变大 --bigkeys + MEMORY USAGE
TTL 设了从不过期 每次写都 EXPIRE 重置 只在创建时设 TTL
并发改字段互相覆盖 读改写无版本校验 字段级 HSET 或 Lua 带版本

14. 边界

场景 建议 理由
数据量小、单机无扩容压力 保持简单结构,别提前拆 拆分成本是长期的,收益是假设的
关系库已覆盖查询 不加 Redis 视图层 多一份视图多一份一致性债
写多读少且要求强一致 不拿缓存当存储 淘汰与主从延迟破坏语义
单团队、键设计可全量评审 允许少量误用 治理成本高于收益时才治理

数据模型是用"键的粒度"换"访问的形状"。粒度切错,后面所有优化——管道、Lua、分片、TTL——都只是在给错误的模型加速。


15. 自测

  1. "读整存整取、偶尔改一个字段"的对象,你选 String 还是 Hash,判据是什么?
  2. 为什么 Cluster 下用 Lua 常常等价于"键设计里必须有 hash tag"?
  3. 每次写都调 EXPIRE,会产生什么语义偏差?
  4. 同一份数据拆成 10 个小键后,扩容预算为什么会被低估?
  5. 迁移到新结构时,哪一步一旦执行,回滚成本陡增?
  6. 命中率长期偏低,先怀疑键设计还是先怀疑容量?
S12-A05 · Redis 数据模型决策:结构选型与改写代价
https://www.clxhxhhr.top/posts/6120/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。