
前阵子朋友找我排查一个Redis性能问题他把几百字节的对象JSON直接塞进String里在千万级key下内存和QPS双双崩了。聊着聊着他就问网上都说String有int、embstr、raw三种编码44字节是分水岭那我这场景到底算什么这个问题让我意识到很多人对44字节只停留在“背结论”的阶段却没真正理解它背后的分配机制和性能差异。所以我专门设计了12轮压测把Redis String三种编码在吞吐、延迟、内存三个维度的真实差距一次性测透今天就把整个过程和结果完整分享出来。1. String编码这件事到底值不值得较真先说结论值得但你要较真的不是那个“44”的数字本身而是数字背后的内存分配模型。Redis String可能是最被低估的一个数据类型。日常业务里缓存用户信息、存登录态、做计数器、放分布式锁全是String的活。很多人觉得String不就是那条set/get命令嘛能有什么性能差距实际上Redis在内部对String做了三种编码分别是int、embstr、raw不同编码决定了你对这个key做读写时Redis到底走了哪条内存分配路径。很多人背过“字符串长度小于等于44字节用embstr大于44字节用raw”但背归背真到生产环境处理百万级key的时候该踩的坑一个没少。比如我那个朋友序列化后的JSON刚好200字节出头底层全是raw编码每个key的内存占用和访问成本比理想情况高出一截。问题不是不能存而是他根本不知道自己选了一个什么存储路径更不知道有没有更好的选择。所以这篇文章的核心思路很简单先用压测数据把三种编码的差距量化出来再回到源码层面解释“为什么会有这种差距”最后给出落到生产环境的优化建议。这样你看完就不会再去死背44这个数字而是能从分配次数、内存连续性、SDS扩容策略这些底层逻辑去判断自己该怎么选。1.1 先把三种编码的样子说清楚int编码Redis检测到字符串可以被解析成long型整数时会直接把整数值存在redisObject的ptr字段里不再额外分配一段字符串内存。比如set age 18这个18就是int编码底层就是个long。embstr编码字符串长度较短时Redis把redisObject和底层SDSSimple Dynamic String头、数据段放在同一块连续内存里一次malloc搞定。这种编码下的字符串是只读的一旦执行append、setrange这类修改操作会先转成raw再做修改。raw编码字符串较长时Redis把redisObject和SDS分开分配需要两次malloc两者在内存中不连续访问时要跳两次指针。这三种编码在命令层面完全透明你看到的都是String类型但内部路径差别很大。接下来要弄明白的就是这个分界线为什么是44以及跨过这条线的代价到底是什么。1.2 44字节这个数字怎么来的44字节不是Redis作者拍脑袋定的它是jemalloc内存分配策略和redisObject、SDS头部结构一起算出来的结果。在64位系统上redisObject结构体由位域紧凑编排type字段4bit、encoding字段4bit、lru字段24bit这一共凑成4字节后面refcount占4字节ptr指针占8字节合计16字节。这个对象会被jemalloc放进最小的64字节chunk里。SDS这边Redis 3.2之后引入了sdshdr8等多种头部。对于长度在256字节以内的字符串用sdshdr8头部由len、alloc、flags三个字段组成各占1字节一共3字节。SDS还有一个约定字符串末尾会带一个\0结束符占1字节。把式子列出来64jemalloc chunk大小 - 16redisObject - 3sdshdr8头部 - 1\0 44正好44。超过44字节64字节的chunk就装不下redisObject和SDS的“连续组合体”Redis只能放弃一次性分配拆成两个独立内存块也就是raw编码。2. 12轮压测方案是怎么设计的为了不让结论停留在纸面上我设计了一套覆盖关键边界的压测方案。设计过程中最核心的原则是不只看绝对数字更看重不同长度值之间的相对变化曲线。2.1 压测工具选型为什么不用JMeter市面上常见压测工具有JMeter、Locust、redis-benchmark、memtier_benchmark。JMeter做Redis压测需要自己写Java Sampler或JSR223脚本操作繁琐不说测试过程中还容易混入GC暂停和客户端自身的噪声不适合做这种需要精细控制value长度的实验。Locust也一样它擅长模拟HTTP业务场景压Redis短板明显。我最终选择了自研Go脚本作为主力测试维度可以精细控制value长度、命令分布、pipeline批量同时能统计QPS和延迟分位数。redis-benchmark作为交叉验证工具用来确认自研脚本没有数值偏离。Go脚本核心逻辑大概是这样func benchmark(cmd string, length int, threads int, batch int, duration int) { pool : make([]*redis.Client, threads) for i : range pool { pool[i] redis.NewClient(redis.Options{Addr: 127.0.0.1:6379}) } var wg sync.WaitGroup for _, c : range pool { wg.Add(1) go func(c *redis.Client) { defer wg.Done() value : randomString(length) keyPrefix : randString(8) start : time.Now() end : start.Add(time.Duration(duration) * time.Second) for time.Now().Before(end) { for i : 0; i batch; i { key : fmt.Sprintf(%s:%d, keyPrefix, rand.Intn(1000000)) pipeline : c.Pipeline() if cmd SET { pipeline.Set(ctx, key, value, 0) } else { pipeline.Get(ctx, key) } pipeline.Exec(ctx) } } }(c) } wg.Wait() }实际测试时长每轮60秒去掉首尾各5秒的冷热数据后统计中间50秒的数据。2.2 12轮value长度怎么定的12轮不是随便选的我把它分成四组第一组第1-2轮验证int编码的稳定输出。第二组第3-6轮覆盖embstr编码的近边界区域分别是5字节、39字节、43字节、44字节。第三组第7-8轮正好卡在分水岭两侧45字节和100字节用来观察raw编码刚起步时的性能损耗。第四组第9-12轮是512字节、1KB、4KB、64KB覆盖raw编码下不同长度对网络和分配成本的影响。为什么要卡这么细因为网上关于“44字节”的讨论很多但我很少看到有人在43、44、45这三个点上连续测过而恰恰是这三个点能说明问题。如果只看100字节和1KB的差距你会以为所有损耗都是长度带来的但实际上45字节和44字节之间的断崖来自编码切换本身。具体轮次设计如下轮次value长度value示例预计编码11字节整数1int26字节123456int35字节helloembstr439字节随机串embstr543字节随机串embstr644字节随机串embstr745字节随机串raw8100字节随机串raw9512字节随机串raw101KB随机串raw114KB随机串raw1264KB随机串raw2.3 压测环境与关键参数测试环境Linux物理机8核16G网卡万兆。Redis版本7.0.11Docker容器内运行限定2核使用默认的jemalloc分配器。关闭了AOF和RDB持久化关闭THP透明大页避免内核层面的分配抖动干扰结果。压测端4个线程每个线程维护独立连接池连接池大小25启用pipeline批量batch设为20。关闭TCP_NODELAY优化保持默认。每轮压测前先写入20万条该长度档位的key做预热保证GET操作不是打到空key上。测试期间通过redis-cli INFO stats确认没有触发expired_keys和evicted_keys确保数据干净可用。3. 压测结果原始数据背后藏着什么这组数据我在不同机器上重复跑过三轮趋势完全一致。单机Redis的绝对QPS受机器和客户端影响很大所以你看各个轮次之间的相对变化比看绝对数值更有价值。3.1 12轮完整数据轮次value长度编码SET QPSGET QPSP99延迟(ms)单key估算内存1整数1int1984312162050.2864B2整数123456int1973362149820.2864B3hello(5B)embstr1857202053400.2964B439B随机串embstr1839082015570.3064B543B随机串embstr1822111998840.3164B644B随机串embstr1810351980020.3164B745B随机串raw1562801734150.42128B8100B随机串raw1421171598640.45192B9512B随机串raw1195081324620.53~640B101KB随机串raw968721105380.67~1.2KB114KB随机串raw68544742300.98~4.3KB1264KB随机串raw21304235783.12~64KB3.2 吞吐量44到45之间的断崖式下落第6轮和第7轮之间只差了1个字节SET QPS从181035掉到156280降幅约13.7%GET QPS从198002掉到173415降幅约12.4%。这个幅度在Redis这种纯内存操作里已经相当可观了它不是长度变长1字节带来的线性成本而是编码切换导致分配路径彻底改变。注意第5轮43字节和第6轮44字节之间的QPS差距非常小说明embstr内部即使贴近上限也没有明显劣化。真正的问题出现在跨过44的那一刹那。raw编码带来的两次分配、内存不连续在毫秒级延迟里体现为P99从0.31ms跳到了0.42ms涨幅约35%。这里还要强调一个点int编码和embstr编码之间的QPS差距大约6%-8%没有想象中那么大。真正的大头在embstr到raw的切换所以如果你纠结“要不要把一个整数存成字符串”性能上确实int更优但差距不到10%真正的收益更多体现在内存上。3.3 延迟P99的尾巴比平均值诚实平均数是一种极具欺骗性的指标。在测试中int和embstr的P99都非常稳定说明尾延迟被控制得很好。raw编码从45字节开始P99明显抬升到4KB时接近1ms64KB时已经到了3.12ms。这个趋势也符合内存分配路径的直觉长度短的raw虽然只多了一次malloc但redisObject和SDS是分开的访问时要走两次内存CPU cache命中率下降长度大的rawSDS本身还可能触发jemalloc的大块分配和内存复制延迟进一步劣化。实测下来44字节以内的P99基本在0.3ms上下摇摆超过44后P99的涨速比平均值快压测时你还会观察到不少偶发尖刺。这也是为什么很多人在低并发下测不出问题一上生产并发就露馅。3.4 内存百万key下差距会被放大单看一个keyint编码64B44字节的embstr也是64B45字节的raw就变成了128B100字节是192B看起来都是几十字节的差距。但如果你有1000万个key每个key多64B就是640MB的额外内存成本。这还没算上jemalloc内部的碎片和arena管理开销。所以内存维度的结论是能压到44字节以内就尽量压压不进去也别硬来但要清楚知道每多跨过一个bin边界整体内存成本会上一个台阶。4. 数据背后的三个原理层次数字只是表象这组压测结果背后有三个原理层次。理解了这三层你就能自己推导出任何长度、任何场景下的Redis String表现而不是永远靠背结论。4.1 分配次数决定性能上限Redis对String的读写性能很大程度上取决于一次操作要做几次malloc、几次free。int编码下整数直接存在redisObject的ptr字段里完全没有额外字符串分配所以它的CPU路径最短。embstr把redisObject和SDS焊死在同一块内存里一次malloc覆盖所有free的时候也只要一次内存局部性极好。raw就麻烦了redisObject一次mallocSDS又一次malloc。两次分配不保证内存连续CPU访问第一个节点时cache miss的概率显著提高。长度越长SDS分配的内存越大jemalloc内部还要处理更复杂的size class匹配。这种差距在低并发、低QPS环境下几乎感觉不到但在高并发压测下会被放大因为每次操作都会访问这两块分离的内存cache miss的代价被重复叠加。4.2 SDS的大小分级与扩容策略Redis 3.2之前SDS只有一种头部结构不管多短的字符串都背着大header浪费内存。3.2之后引入sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64五种类型本质就是为了让小字符串用更小的header。这个设计对44字节这个边界有直接影响。网上有些老教程说embstr上限是39字节那是基于旧版SDS 16字节header算的。新版SDS用sdshdr8只要3字节header所以上限变成44。如果你看到网上资料数据对不上先确认对方说的Redis版本再下结论。扩容方面SDS遵循“小于1MB翻倍扩容大于1MB追加1MB”的策略。这导致一个隐藏问题如果你对一个raw字符串频繁appendSDS会预留比实际内容更多的空间内存开销甚至会超过value本身。我在压测中没有模拟这种连续append场景单次set无法暴露这个问题但在生产环境里日志累积、消息拼接这类场景非常常见。4.3 append和修改操作如何毁掉embstr很多人疑惑为什么我的value明明只有20字节OBJECT ENCODING却显示raw最常见的原因是执行过append、setrange这类修改操作。Redis规定embstr是只读编码一旦检测到修改动作会先把embstr转成raw再执行修改。这个转换是一次性的但转完之后这个key就“回不去”embstr了即使你后来把它的内容改回很短的字符串编码也不会自动降级。另一个漏网之鱼是前导0问题。字符串“0123”看起来是个整数但Redis转换成long再转回字符串时会发现不一致于是拒绝用int编码走了embstr甚至raw路径。判断标准不是“看着像数字”而是字符串能否无损映射成long型。我在压测第1、2轮用整数123456时就是int编码换成“012345”再测编码就变成embstr。这个细节容易在业务代码里踩雷尤其是那些把数字ID前面补0对齐格式的场景。5. 从压测回到生产String到底该怎么优化5.1 该存整数就别存字符串很多业务里计数器、状态位、用户ID都是整数但代码里习惯用string拼接后再set。比如strconv.Itoa(userID)再存Redis要先解析字符串判断能否转int然后走int编码。如果直接传数字类型省掉的是一次字符串转换换来的是稳定的int编码路径。对于并发极高的计数器场景直接用INCR、DECR命令操作int编码的key比读出来后在客户端加1再写回去要快得多。压测中int编码的SET QPS在19.8万左右GET QPS在21.6万左右看起来和embstr差距不大但在跨可用区的网络开销下减少内存分配和序列化操作带来的收益会成倍放大。5.2 别硬凑44字节但要理解44字节的用途我不建议你在业务代码里写“if len(value) 44 { 换数据结构 }”这种硬编码逻辑。44字节这个数字的意义在于帮助你理解内存分配的边界而不是成为业务判断条件。真正的判断依据是什么是你的value序列化后大概在什么长度区间。如果value很短比如枚举值、短Token、设备状态那么让String保持在embstr编码是划算的因为单key内存占用只有64B。如果value是几百字节的JSON那么不管你怎么调整它都在raw区间这时候真正要考虑的是这个对象真的适合用String存吗一个可行的优化方向是把大对象拆成多个hash字段存储或者对字段内容做精简比如去掉JSON里的多余空格、字段名缩写让单个字段落在短字符串区间。当然hash本身也有自己的编码机制和内存开销需要权衡。5.3 不要被String绑死看到raw编码的String慢第一反应不应该是去改String的阈值而是检查这个数据模型本身有没有问题。比如我的日志场景如果把每条日志按行塞进Stringvalue会越来越长append操作频繁触发SDS扩容QPS持续走低。改成用list做日志队列或者用hash分桶存储性能反而更好。Redis有五种基本数据类型不同数据结构在不同长度、不同访问模式上有各自的优势区间。String擅长短值和固定覆盖场景list擅长消息队列hash擅长对象的字段级读写set适合去重和集合运算zset适合排行榜。非要用String存一切等于放弃了Redis其他数据结构的优化空间。压测时我顺便对比了相同长度的value用hash存储的QPS虽然hash单key开销略高但在字段多、需要频繁修改单个字段的场景下hash的定向读写优势会把String甩开一个身位。这也是“知其所以然”的实际意义。6. 常见问题排查实录6.1 OBJECT ENCODING看到的编码和预期不一致排查Redis String问题的第一步永远是看编码。用OBJECT ENCODING key可以查到当前key的编码。如果你发现“我存的明明是数字为什么是embstr”先检查字符串是否符合long无损转换条件比如前导0、末尾空格、超过19位的大整数都会导致编码降级。如果你发现“短字符串为什么是raw”检查是否有过append、setrange操作这些操作会把embstr永久转成raw。6.2 压测Redis时的几个作弊坑自己写压测脚本最容易踩的坑是只测一个key。比如循环SET同一个keyRedis会反复更新同一个键走的是int编码的更新路径测不出编码差异因为所有连接都在抢一把锁。正确做法是生成足够多的key确保每轮压测访问的是分布均匀的key空间。第二个坑是忽略了pipeline的作用。Redis单线程模型下客户端发送命令和等待响应是串行的如果不用pipeline100%的QPS被网络往返时间卡住根本测不出服务端真实上限。我测试时batch20pipeline收益明显但也不是越大越好过大的pipeline会造成客户端内存积压和延迟放大。第三个坑是不关持久化就压测。AOF的fsync策略或者RDB快照触发都会在压测期间引入巨大的毛刺导致P99失真。测试前关闭AOF和RDB只保留纯内存模式。第四个坑跟机器有关。Redis是单线程的你给它分配32核没意义反而可能因为CPU负载均衡导致调度延迟。我建议把Redis进程绑定到固定CPU核心关闭超线程压测客户端也绑核避免NUMA跨节点访问。6.3 线上排查String慢的标准路径如果线上Redis的String操作变慢我一般按这个顺序排查先到 INFO memory看内存碎片率如果高于1.5考虑内存碎片问题raw编码大key通常是罪魁祸首。再执行redis-cli --bigkeys扫描大key重点看字节数最大的那些String。接着用OBJECT ENCODING抽查几个典型key确认编码类型。然后用INFO commandstats看set/get命令的平均耗时和调用次数定位到具体的慢命令。最后用MONITOR看实时命令结合业务访问模式确定优化方向。这一套流程走下来基本能定位90%的String性能问题。有一个真实案例客户反馈缓存集群QPS上不去排查发现他们的用户信息缓存key序列化后平均45-50字节刚好卡在raw编码的边界单key内存doubleQPS下降12%。建议他们去掉JSON里的冗余字段把value压到40字节以内QPS提升约15%内存占用下降30%整个集群稳定下来。6.4 版本差异是一个大坑Redis 3.2前后的String编码在embstr上限和SDS结构上有明显差异。我压测用的7.0.11SDS已经支持sdshdr8embstr上限是44字节。如果你的线上环境还是4.x、5.x结论基本一致但具体数字可能要重新验证。升级Redis版本后最好重新跑一遍压测不要假设所有版本行为都一样。另外Redis 7.0之后社区也在持续优化listpack替换压缩列表后hash、zset的紧凑存储能力进一步提升。这意味着对于同样的小对象hash可能比String更省内存。选型的时候别只看String的三种编码要放到整个数据结构体系里去权衡。我自己跑完这组压测后最大的收获不是记住了44这个数字而是彻底想通了Redis为什么要把String内部拆成三种编码本质上是拿空间换性能拿固定内存换分配效率。以后再遇到String慢的问题我会先看OBJECT ENCODING再看value长度最后看业务逻辑里有没有不必要的append和修改。建议你也拿自己的线上数据跑一遍压测数据会告诉你真正的答案。