"给缓存加了 8G,两周后数据库的慢查询反而多了。"这句话在容量复盘里反复出现。机制不玄:内存变大 → 装下更多 key → 命中率上升 → 数据库压力下降,这条链只在工作集装得下时成立。装不下时,多出来的内存只延长了长尾 key 的停留时间,而它们各自被读一次;同时淘汰高频触发,缓存自己的延迟先涨。
容量预算只回答三个数:内存给多少、淘汰怎么配、何时该扩。三个数各有失效边界,混在一起调就会得到"加了内存没变快、减了内存立刻崩"。
1. 容量的四个约束
| 约束 | 决定什么 | 越界后的表现 |
|---|---|---|
实例内存上限(maxmemory / 容器 limit) |
逻辑上能装多少 | 写入报 OOM command not allowed,或进程被 cgroup kill |
| 命中率拐点 | 装到多少才开始有用 | 曲线走平,加内存的边际收益接近零 |
| 淘汰成本 | 每次写入的额外开销 | 延迟毛刺、CPU 升高、淘汰与主线程争抢 |
| 副本与持久化开销 | 物理内存 = 逻辑内存 × 倍数 | fork 失败、AOF 重写反复重试、主从同时淘汰 |
四个约束里只有一个能被"加内存"消掉,第二个是数学,第三个是 CPU,第四个是物理账。
# redis.conf —— 上限先减掉持久化与副本的预留
maxmemory 6gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10
按 Redis 7.x 核对:maxmemory 只约束数据集与部分开销,不含 fork 的 COW 页、复制缓冲与 AOF 重写缓冲。
2. 命中率与容量不是线性
访问分布是重尾(近似 Zipf),前百分之一量级的 key 吃掉大部分请求,命中率随容量呈凹函数:先陡后平。所谓"再多也补不齐",指尾部 key 的访问间隔远长于它在内存里的存活时间——装进去,读一次,被淘汰,等于没装。
判据不是"命中率多少算好",而是命中率对容量的导数:连续两周加 5% 内存、涨幅都低于 1 个百分点,说明已过拐点,问题在失效策略或访问模式。
3. 工作集估算的三种方法
| 方法 | 输入 | 输出 | 主要误差来源 |
|---|---|---|---|
| 热数据统计 | --hotkeys、OBJECT FREQ |
高频 key 清单与占比 | 需 LFU 类策略,冷启动期统计不可信 |
| 访问分布曲线 | 代理层/慢日志按 key 聚合 | 覆盖 90% 请求所需 key 数 | 日志采样率与时间窗选择 |
| 按对象大小反推 | 日均活跃 key 数 × 平均 value 大小 | 字节级工作集 | 忽略 per-key 开销,通常低估 |
$ redis-cli -h HOST -p PORT --hotkeys # 需 LFU 策略,扫描本身有成本
$ redis-cli -h HOST -p PORT --bigkeys # 找大 key,不是找热 key
$ redis-cli -h HOST -p PORT --memkeys --memkeys-samples 5000
三法粗精度不同:分布曲线定位量级,热 key 清单校准,大小反推换算内存。
4. 对象内存的真实占用
一条看着 200 字节的 JSON,在 Redis 里可能占 400 字节以上,差额来自结构性开销。
| 开销项 | 量级 | 说明 |
|---|---|---|
| key 本身 | 与 key 长度相关 | 命名越长越贵,前缀复用可省一部分 |
SDS 头与 robj |
十几到几十字节 | 短/长字符串的编码分支不同 |
dictEntry + 哈希表 |
每 key 数十字节 | 装载因子影响桶数组大小 |
| 过期字典 | 带 TTL 的 key 多一份 entry | 全量 TTL 场景不可忽略 |
| 分配器取整 | 典型放大约 10%–30% | jemalloc size class 向上取整 |
| 编码转换 | 阈值跨越时突增 | 小 hash 用 listpack,超阈值转 hashtable |
$ redis-cli MEMORY USAGE user:profile:10086 # 单 key 实测字节
$ redis-cli OBJECT ENCODING user:profile:10086 # 是否已发生编码转换
$ redis-cli DEBUG OBJECT user:profile:10086 # 编码与长度细节,生产慎用
MEMORY USAGE 带采样参数时是估算值。预算时乘 key 总数再留 15%–25% 结构性余量,比拿 value 长度乘个数靠谱。
5. 淘汰策略的适用面与失效形态
| 策略 | 适用数据 | 失效形态 |
|---|---|---|
noeviction |
缓存不可丢、写失败可接受 | 写满后写入报错,业务须有降级分支 |
allkeys-lru |
通用读多写少 | 扫描型访问污染:全量遍历把冷 key 全标热 |
volatile-lru |
冷热混合、部分 key 无 TTL | 可淘汰集合太小时退化成 noeviction |
allkeys-lfu |
热点稳定、计数有意义 | 新热点冷启动饿死,需 lfu-decay-time 兜底 |
volatile-ttl |
短命数据为主 | 长 TTL 的大对象先被清,可能清掉贵的那个 |
W-TinyLFU(本地侧) |
高命中率要求 | 突发新热点有准入延迟,窗口内命不中 |
扫描污染的识别:keyspace_hits 骤降、evicted_keys 陡增、数据库 QPS 上升而请求总量没变。它不是容量不足。
# 用 LFU 必须同时配衰减,否则历史计数永久累积
maxmemory-policy allkeys-lfu
lfu-log-factor 10
lfu-decay-time 1
6. TTL 与容量的取舍
| TTL 设定 | 命中率 | 陈旧窗口 | 淘汰压力 | 适用 |
|---|---|---|---|---|
| 很短(分钟级) | 低 | 小 | 低 | 价格、库存、权限等强时效 |
| 中等(小时级) | 中 | 可控 | 中 | 列表页、聚合结果 |
| 很长(天级) | 高 | 大 | 低 | 静态字典、配置快照 |
| 无限 + 主动失效 | 最高 | 取决于失效通道 | 最低 | 有可靠广播失效时 |
长 TTL 把容量压力换成陈旧压力,短 TTL 把命中率换成数据库 QPS。加抖动是避免同批 key 集体过期,幅度按可接受的陈旧窗口给。
7. 缓存与持久化的内存叠加
| 场景 | 峰值来源 | 典型量级 | 处理 |
|---|---|---|---|
| RDB fork | 写入速率 × fork 时长 的 COW 页 | 与写入量同阶 | 预留内存或错峰触发 |
| AOF 重写 | 重写缓冲 + fork COW | 与重写期写入量同阶 | 调高 auto-aof-rewrite-percentage |
| 主从全量同步 | 复制缓冲区膨胀 | 与同步时长相关 | 限速、避开高峰 |
| 容器 limit | 上述峰值未计入 limit | 达 limit 即被 kill | limit = 数据 + 峰值 + 余量 |
$ redis-cli INFO memory | grep -E 'used_memory:|used_memory_rss|mem_fragmentation_ratio'
$ redis-cli INFO persistence | grep -E 'rdb_last_bgsave_status|aof_rewrite_in_progress'
$ redis-cli INFO stats | grep -E 'evicted_keys|expired_keys|keyspace_hits|keyspace_misses'
used_memory_rss / used_memory 长期高于 1.5 说明碎片吃掉了一部分预算。
8. 分片后的总容量与热点不均
总可用容量 ≈ 分片数 × 单分片可支配内存 × (1 − 碎片与副本预留)
真实约束 = min(各分片剩余内存),不是平均值
| 分片方式 | 不均来源 | 观测口径 |
|---|---|---|
| 客户端哈希 | 哈希函数与 key 分布 | 各实例 used_memory 极差 |
| Cluster slot | slot 分配不均、大 key 落单 slot | 各节点 used_memory 与 cluster slots |
| 代理层 | 路由倾斜、连接池不均 | 代理侧分片命中统计 |
平均值最容易骗人:10 个分片里 9 个用 40%、1 个用 95%,平均 45.5% 看着安全,实际已到顶。预算按极差做。
9. 击穿、穿透与容量无关
这三个词常被归因于"缓存不够大",实际是失效瞬间的并发问题:热点 key 过期那刻请求同时回源。手段是逻辑过期、单飞合并、预热与热点本地兜底。
判据:数据库 QPS 尖峰与淘汰/过期时间点对齐,问题在失效路径;与请求量对齐且命中率平稳,问题才在容量。
10. 扩容的时机信号
| 信号 | 阈值形态 | 含义 |
|---|---|---|
| 命中率平台 | 连续 14 天波动小于 1 个百分点 | 已过拐点,扩容无收益 |
| 淘汰速率 | 淘汰数 / 写入数 持续高于 10% | 容量长期不足 |
| 扩容后数据库 QPS 不降反升 | 与扩容量同向 | 淘汰把请求打回了数据库 |
| 单分片内存极差 | 最热分片超过 85% | 该分片决定整体上限 |
| 延迟毛刺周期 | 与 rdb_last_bgsave 对齐 |
是持久化,不是容量 |
11. 成本核算
年成本 ≈ 内存单价(GB·月) × 逻辑容量 × 副本倍数 × 12 + 运维与迁移工时
收益 ≈ 被挡下的数据库 QPS × 单 QPS 边际成本 × 12
结论 = 收益 > 成本 才扩;否则改策略、改 TTL、改数据模型
| 选择 | 有效收益 | 隐藏成本 |
|---|---|---|
| 加内存 | 命中率上升(过拐点后接近零) | 单价 × 副本倍数,主从双份 |
| 改淘汰策略 | 同样内存下命中率上升 | 需重跑热 key 统计,短期抖动 |
| 缩短 TTL | 陈旧窗口变小 | 数据库 QPS 上升 |
| 拆大 key / 改结构 | 单位内存装更多有效数据 | 改造与双写期复杂度 |
副本倍数最容易被漏:主从各一份等于单价翻倍。"省下的 QPS"只在数据库确为瓶颈时才算收益。
12. 排错
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 加内存后命中率不变 | 已过拐点,或分布本身平坦 | 停扩,改失效策略与数据模型 |
| 加内存后数据库更慢 | 扫描污染把热 key 挤出 | 换 LFU 或加本地层,隔离批量任务 |
内存没到 maxmemory 却开始淘汰 |
分片不均,单分片先到顶 | 按最热分片看,重分 slot |
| fork 时延飙升甚至失败 | COW 峰值超出物理余量 | 预留、错峰、调大重写阈值 |
| 命中率高但 P99 差 | 淘汰与大 key 操作争抢主线程 | 拆大 key、异步删除、加副本分担读 |
13. 边界
| 场景 | 为什么加内存不解决 | 该做什么 |
|---|---|---|
| 数据总量小于内存 | 本来就能全量装下,瓶颈在别处 | 查慢查询、序列化、网络 |
| 访问分布平坦 | 没有可缓存的热点,命中率天然低 | 换预计算或物化视图 |
| 命中率本就不高 | 回源比例由业务决定,容量不是变量 | 改接口聚合粒度 |
| 写多读少 | 缓存只增加一致性问题 | 直接优化写入路径 |
| 淘汰已占 CPU 显著比例 | 扩容压不住写入侧的淘汰频率 | 降写入、改批量、换分片 |
一句话判据:先确认瓶颈在"装不下"还是"留不住",两种账单不一样。
14. 自测
- 命中率连续两周不随容量变化,说明曲线处在哪一段?下一步看什么指标?
- 一次全量遍历为什么能污染 LRU?用哪两个计数器确认它发生了?
- 同一条 200 字节 JSON,为什么
MEMORY USAGE明显更大?差额主要来自哪几项? maxmemory为什么不包含 fork 的 COW 页?这个差额在容器里会造成什么后果?- 十个分片平均使用率 45%,为什么仍可能已到上限?
- 数据库 QPS 尖峰与缓存过期时间点对齐,问题该归到容量还是失效路径?