用户资料以 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. 自测
- "读整存整取、偶尔改一个字段"的对象,你选 String 还是 Hash,判据是什么?
- 为什么 Cluster 下用 Lua 常常等价于"键设计里必须有 hash tag"?
- 每次写都调
EXPIRE,会产生什么语义偏差? - 同一份数据拆成 10 个小键后,扩容预算为什么会被低估?
- 迁移到新结构时,哪一步一旦执行,回滚成本陡增?
- 命中率长期偏低,先怀疑键设计还是先怀疑容量?