数据模型生产案例
学完数据模型五章,得到的是一套「现象 → 机制」的判断框架:内存为什么突然暴涨、删了数据为什么不降、UV 为什么和数据库对不上。本章收集 4 个公开事故或技术复盘,每个案例回答三件事——现象是什么、根因落在哪个知识点、怎么处理和预防。
1. 知识地图:数据模型能解释哪些生产问题
4 个案例的根因全部落在前五章的知识点上。先建立映射,再逐个展开:
| 知识点 | 生产问题 | 案例 |
|---|---|---|
| String 单值结构,整存整取 | 大 Key 阻塞、带宽打满 | 案例一 |
| BitMap 建立在 String 之上 | 稀疏偏移量撑爆内存 | 案例二 |
| 紧凑编码 ↔ 普通编码切换 | 编码不可逆升级、内存翻倍 | 案例三 |
| SDS 惰性释放 + 分配器碎片 | 内存只涨不降(RSS 偏高) | 案例四 |
2. 案例一:用 String 存整块对象,一个 Key 打满带宽
2.1 现象
2024 年双十一,京东科技上线一个促销活动。后端把整套活动配置(规则、奖励项)打包成一个对象,序列化后整体写入一个 String key,体积约 1.5MB。
活动生效的瞬间,所有服务实例的本地缓存同时失效(冷启动),几千 QPS 穿透到同一个 key。Redis 单个分片设了 200Mbps 网络限流(约 25MB/s),25MB/s ÷ 1.5MB ≈ 16 次/秒的有效吞吐,远低于实际并发。分片网络随即拥塞、请求排队超时,核心服务可用率从 100% 跌到 20%。
2.2 根因
根因不在流量,在数据结构选错:把复杂对象当成一个 String 整存整取。
String 是单值结构,一个 key 对应一整块 value(见基础类型 §1)。活动配置这种几十个字段的对象,序列化成一个 String 后,每次读取都要传输完整的 1.5MB——即使调用方只用到其中两三个字段。字段越多、对象越大,这个放大效应越明显。
这正是基础类型 §1 点名的反模式:「不要用 String 存整个 JSON 对象来模拟对象存储」。对象的字段应当拆开用 Hash 存,按需 HGET 单个字段,而不是每次 GET 整个对象。
1.5MB 的 String 早已超过 44 字节,编码是 raw,对象头和数据分离分配(见对象编码 §3.3)。但这只是次要因素,主因仍是「整块存取」这个选型。
2.3 处理与预防
应急手段(先止血,不阻塞):
- 序列化瘦身:JSON 换 Protostuff,1.5MB → 500KB。
- 压缩:对超过阈值(如 100KB)的对象做 gzip,500KB → 17KB。
- 本地缓存加锁:用
get(key, callable)只放一个线程回源,避免击穿。
根治手段(改结构):
- 大对象拆字段,改用 Hash 存储,按需
HGET。 - 禁止缓存整表、整个聚合对象。
3. 案例二:BitMap 稀疏偏移量撑爆内存
3.1 现象
三个独立事故,同一个根因:
- 腾讯云开发者社区案例:用钉钉 userId(字符串 + 数字)hash 后当 offset,hash 值 1762177145。一次
SETBIT后 BitMap 达 210.07MB,生产环境 Redis 内存被吃到 60G。 - 博客园案例(2013):用
HHmmssss(时分秒毫秒)当 offset,平均偏移量 11798000 位,约 1.4MB/key,2000 个 key 共占 2.8G。 - 「因为 BitMap 白白搭进去 8 台服务器」:用户 ID 稀疏(最小 1、最大 10 亿),一个 Bitmap 就占约 119MB。
3.2 根因
BitMap 不是独立类型,它建立在 String 之上(见BitMap 位操作)。Redis 按 offset 分配连续内存:把第 N 位设为 1,就分配 ceil(N/8) 字节,中间空位全部填 0。
内存占用 = 最大 offset ÷ 8,与「实际置 1 的位数」无关。稀疏 ID 下,只有少数位是 1,内存却按最大 offset 全量分配:
SETBIT mykey 1 1 # 分配 1 字节
SETBIT mykey 100000000 1 # 分配约 12.5MB,尽管只有 2 个位是 1
MEMORY USAGE mykey # 约 13421789 字节(12.8MB)上限由 String 的 512MB 决定:offset 最大 2^32-1,超过直接报 ERR bit offset is not an integer or out of range。
3.3 处理与预防
- 先重新编号,让 ID 密集,再映射到 BitMap。
- 分段存储:IP 场景按四段拆分,每段最大 255 位。
- 稀疏数据改用 Set 或 HyperLogLog。
4. 案例三:紧凑编码不可逆升级,内存翻倍
4.1 现象
51CTO 案例:运维为了让更多 Hash 走紧凑编码省内存,把 hash-max-ziplist-entries 从 512 调到 10240,把 hash-max-ziplist-value 从 64 调到 512。结果内存不降反升。
复现实验:插入 10000 个小型 Hash(每个 500 字段、每值 50 字节),紧凑编码下总内存约 300MB;调大阈值后随机给 10% 的 Hash 插入一个超 512 字节的大字段,这些 Hash 立即升级为 hashtable,总内存飙到近 1GB。
4.2 根因
Hash/Set/ZSet 在元素少、值小时用紧凑编码(listpack,连续内存、无指针),超过阈值切换 hashtable(有桶数组和指针开销,见对象编码 §4)。
两个机制叠加:
- 切换是单向的:一旦升级为 hashtable,即使删掉超长字段也降不回 listpack(见对象编码 §4.3)。
- 调大阈值反而增加升级概率:value 阈值调大后,原本会被「拒之门外」的超长字段现在会混进来,一旦写入就触发整表升级;而 entries 阈值越大,升级发生时 Hash 越「大」,成本越高。
用一个 key 验证:
HSET h a 1 b 2
OBJECT ENCODING h # "listpack"
HSET h c "$(printf 'x%.0s' {1..65})" # 写一个 65 字节的 value,超过默认 64 阈值
OBJECT ENCODING h # "hashtable" —— 永久升级
HDEL h c
OBJECT ENCODING h # 仍是 "hashtable",不会降回intset 有相同的单向机制(见只升级、不降级):Set 全存小整数时用 16 位 intset,存入一个 70000 升级为 32 位,删掉后仍是 32 位。
4.3 处理与预防
- 别盲目调大编码阈值,它可能适得其反。
- 用
OBJECT ENCODING抽查实际编码,发现意外升级。 - 上线前按业务预估 field 数量与 value 长度,控制 key 规模。
5. 案例四:内存只涨不降——惰性释放与分配器碎片
5.1 现象
「删了数据内存不降」在生产里其实是两类现象,常被混为一谈:
- 单个 key 缩短后不降:value 从 10MB 缩到 10 字节,
MEMORY USAGE变化很小。 - 进程整体碎片不降:大量 key 频繁增删后,
INFO memory里used_memory_rss(进程实际占用)远高于used_memory(逻辑数据量),mem_fragmentation_ratio持续 > 1.5,Kubernetes 下触发 OOM Killer 重启。
5.2 根因
两类现象来自两个不同层面,机制不同,不能用一个原因解释。
单 key 缩短不降,来自 SDS 惰性释放(见空间预分配)。SDS 缩短字符串(sdstrim、sdsclear 这类内部操作)只改 len、不减小 alloc,多出的空间留给同一对象后续写入复用。它只发生在对象内部,规模很小;SET 整体覆盖或 DEL 会调用 sdsfree 真正释放旧值,不属于此列。
进程整体碎片不降,来自分配器。jemalloc 释放内存后不立即归还操作系统,而是留在自己的内存池复用,导致 RSS 高于逻辑数据量。这是分配器行为,与 SDS 无关。
5.3 处理与预防
两类现象的处理方式不同:
| 现象 | 是否需处理 | 手段 |
|---|---|---|
| 单 key 缩短不降 | 不需要 | 设计使然,预留空间会被复用 |
| 整体碎片高(RSS >> used_memory) | 需要 | activedefrag / MEMORY PURGE / 重启 |
- SDS 惰性释放是设计使然,不是 bug,无需针对它做任何处理。
- 碎片率持续异常时,先确认
mem_allocator、版本、RSS 口径、写入模式和 CPU 余量,再决定是否启用activedefrag;该能力默认关闭且仅在受支持的分配器下生效。 MEMORY PURGE是手动辅助手段:官方定义是「尝试清理脏页让分配器回收」,仅 jemalloc 有效,属@slow类别,不建议频繁调用。它只作用于分配器,回收不了 SDS 预留的空间。- 重启最彻底,但需配合主从切换与持久化。
6. 小结与检查清单
4 个案例的共同点:问题不在 Redis 本身,而在「选错了结构」或「误读了机制」。
| 检查项 | 说明 |
|---|---|
| 对象用 Hash 不用 String | 避免大 Key 与全量覆盖 |
| BitMap 前先评估 ID 密集度 | 稀疏 ID 先重新编号 |
OBJECT ENCODING 抽查 | 发现意外升级为 hashtable / intset |
| 不盲目调大编码阈值 | 调大可能增加升级概率 |
关注 used_memory 与 RSS 差 | 识别内存碎片 |
7. 参考资料
下列链接包含官方社区文章、技术复盘和二手报道。案例中的精确数字与配置应以原始材料为准,不能把复盘推断写成 Redis 官方结论。
- 一个1.5MB的Redis Key,让整个系统在双十一瘫痪(京东科技,案例一)
- 慎用BitMap,小心玩爆你的内存(案例二)
- 线上redis服务内存异常分析(案例二)
- 因为 BitMap,白白搭进去 8 台服务器(案例二)
- Redis内存暴增后,我发现一个容易忽视的配置陷阱(案例三)
- Redis 生产环境大 Key 事故复盘:从内存碎片到集群雪崩(案例四)