对Redis做容量规划时,最容易被低估的不是业务数据本身,而是Redis为了支持快速读写所引入的结构化开销。一个存有100字节字符串的键,最终在内存中可能占用超过200字节;一个字段数很多的哈希,在内部编码切换前后内存曲线会突然抬升。要准确预估容量,不能只统计键值长度,必须从Redis内存模型出发,理解对象头、字典项、分配器碎片和持久化子进程等隐性成本。本文将这些开销拆开,并结合数据结构编码、业务增长模型和监控指标,给出可落地的估算与扩容方法。

一、Redis内存模型:为什么不能只看键值大小
Redis的used_memory并不是业务数据简单累加,而是由数据内存、进程运行结构、客户端连接缓冲区、复制积压缓冲区、AOF缓冲区以及内存分配器碎片共同组成。即使业务只写入1GB的键值对,操作系统层面看到的RSS也可能达到1.5GB甚至更高。使用jemalloc时,Redis按照不同大小类申请内存,例如请求90字节可能被分配到96字节或112字节的分配单元,这会造成一定浪费。
每个字符串类型键至少包含一个key的SDS、一个redisObject、一个dictEntry以及一个value的SDS或整数。dictEntry用于哈希表存储键值对,redisObject记录类型、编码、引用计数等元信息,SDS头部记录长度和容量。除此之外,Redis主哈希表本身会周期性触发rehash,rehash期间会临时产生两个哈希表的开销。因此估算时要给结构开销留出比例。
下面的简化函数可以帮助理解一个字符串键的最小内存构成:
def estimate_string_memory(key_len, val_len):
# Redis 对象头、字典项和 SDS 头的近似值
redis_object = 16
dict_entry = 24
key_sds = 8 + key_len + 1
val_sds = 8 + val_len + 1
return redis_object + dict_entry + key_sds + val_sds
print(estimate_string_memory(30, 200)) # 查看粗略结果
以上只是简化计算,实际还会受到jemalloc向上取整和内存碎片影响。通常可以在公式结果上乘以1.2到1.5作为真实占用区间。如果业务键值数量很大,这个结构开销占比会非常可观,因此容量规划必须把对象头和分配器行为考虑进去。
二、不同数据结构的内部编码与内存特征
Redis为了节省内存,对集合类型采用多种内部编码。哈希、列表和有序集合在元素数量少、元素长度小时,可以使用listpack或旧版ziplist紧凑存储,把多个字段和值顺序排列,减少指针和对象头。一旦元素数量或单个元素长度超过阈值,就会转为hashtable、skiplist等编码,CPU操作效率提高,但内存占用会明显跳升。
例如一个小型哈希用listpack编码存储,每个字段只保留长度前缀和真实内容;切换为hashtable后,每个字段和值都要额外创建redisObject、dictEntry和SDS,字段越多额外开销越大。类似地,集合在全部为整数时可使用intset,有序集合在小数据量时使用listpack,这些编码的切换阈值可以在redis.conf中调整。
hash-max-listpack-entries 512 hash-max-listpack-value 64 list-max-listpack-entries 512 list-max-listpack-value 64 zset-max-listpack-entries 128 zset-max-listpack-value 64 set-max-intset-entries 512
调大这些阈值可以降低内存占用,但会让元素更新和查询在紧凑编码上产生更多CPU消耗;调小阈值则会让数据结构更早切换到高效编码,但内存曲线会更快上升。实际规划时不能只盯着单个键的大小,还要考虑编码切换后大量键同时上升带来的整体影响。
三、基于业务模型进行容量规划
制定容量规划时,先划分数据类型和增长曲线。对字符串键,需统计key平均长度、value平均长度、总量和未来峰值;对哈希键,除了key数量,还要统计每个哈希的平均字段数、字段名长度和字段值长度。列表、集合、有序集合也应有类似维度。将这些数据代入估算模型,能得到接近实际的数据内存量。
估算出数据内存后,不能直接等于实例规格。必须乘以碎片系数、业务波动系数和预留水位。通常建议:数据内存乘以1.3到1.5的碎片与结构系数,再除以70%水位,得到建议的maxmemory。例如字符串键1000万,平均每键粗估300字节,数据内存约3GB;乘以1.4后为4.2GB;再除以0.7得到6GB,建议使用8GB实例而不是4GB。
def estimate_total_memory(
string_keys,
avg_key_len,
avg_val_len,
hash_keys,
avg_fields,
avg_field_len,
avg_field_val_len,
frag_ratio=1.4,
reserve_ratio=1.3,
):
# 先按简单模型计算字符串键
str_per_key = 16 + 24 + (8 + avg_key_len + 1) + (8 + avg_val_len + 1)
str_total = string_keys * str_per_key
# 哈希键在 listpack 编码下粗略按字段累加,再乘字典项开销
hash_per_key = (
16
+ 24
+ (8 + avg_key_len + 1)
+ avg_fields
* ((8 + avg_field_len + 1) + (8 + avg_field_val_len + 1))
)
hash_total = hash_keys * hash_per_key
data_memory = str_total + hash_total
return data_memory * frag_ratio * reserve_ratio
print(estimate_total_memory(10000000, 30, 200, 5000000, 6, 16, 64))
除了常驻数据,还要考虑RDB或AOF重写时的fork子进程。Redis fork后父子进程共享内存页,如果重写期间存在大量写入,会复制被修改的页,内存可能短时间内接近原来的两倍。因此在线扩容前应预留30%到50%的额外空间,或有计划地错峰持久化。
主从复制场景还要考虑复制积压缓冲区和从库输出缓冲区。repl-backlog-size默认值较小,高写入场景应调大;从库消费慢时,输出缓冲区会积累命令,造成内存增长。容量规划不是只计算业务键值,还要把连接数峰值、发布订阅消息、Lua脚本调用等一并纳入。
四、监控指标与容量水位实践
运行阶段不能只看used_memory,要结合used_memory_rss和mem_fragmentation_ratio。used_memory是Redis内部视角,used_memory_rss是操作系统分配的内存。两者比值大于1说明存在碎片;如果长期高于1.5,说明碎片严重,可考虑重启整理或使用activedefrag参数;如果接近1但RSS很高,说明分配器比较高效。
# Memory used_memory:734003200 used_memory_human:700.00M used_memory_rss:872415232 used_memory_rss_human:832.00M mem_fragmentation_ratio:1.19 maxmemory:1073741824 maxmemory_human:1.00G maxmemory_policy:noeviction used_memory_dataset:629145600 used_memory_scripts:0 used_memory_scripts_human:0B used_memory_lua:0 used_memory_lua_human:0B
used_memory_dataset用来观察纯数据部分,如果dataset与used_memory差距很大,说明缓冲区、复制或脚本占用异常。maxmemory用于限制内存上限,noeviction时超限会拒绝写入,其他淘汰策略会触发evicted_keys增长。监控时还要关注expired_keys和evicted_keys的变化趋势。
建议正常水位让used_memory低于maxmemory的70%,业务高峰允许到85%。若evicted_keys在短时间内持续增长,说明实例规格不够或TTL设计不合理,需要扩容或拆分热键。若expired_keys峰值过高,要注意集中过期可能阻塞请求。容量评估应定期复盘,按月度业务增长率提前扩容,避免事故性OOM。
准确预估Redis内存需要综合内部编码、对象头、jemalloc碎片、持久化子进程和复制缓冲区。通过模型估算、监控验证和容量水位管理,可以在成本与稳定性之间找到平衡,避免因为内存预估偏差导致线上故障。