【Redis从入门到精通】第11篇:SDS实战——Redis字符串操作背后的黑科技

【Redis从入门到精通】第11篇:SDS实战——Redis字符串操作背后的黑科技 上一篇【第10篇】告别char*——SDS重新定义字符串下一篇【第12篇】链表——Redis列表的底层支撑摘要上一篇我们站在源码层面把SDS的设计哲学拆了个底朝天。但光说不练假把式这一篇咱们直接上手——看看在日常开发中SDS到底是怎么工作的。我们会从最常用的SET/GET命令出发一路挖到embstr和raw两种编码的切换逻辑再用APPEND亲手制造一次内存重分配。还会聊聊二进制安全为什么不光是能存\0这么简单以及大Key到底怎么坑你的。读完这篇你会发现自己写的那句SET hello world背后藏着的东西比你想象的精彩得多。一、SET命令背后的秘密createStringObject三兄弟当你在redis-cli里敲下SET name redis的时候Redis内部会调用一系列函数来创建一个字符串对象。这背后真正干活的是三个函数我管它们叫createStringObject三兄弟。1.1 老大createStringObject这是对外暴露的统一入口根据传入字符串的长度决定调用老二还是老三createStringObject(ptr, len) | v len OBJ_ENCODING_EMBSTR_SIZE_LIMIT (44)? / \ YES NO | | v v createEmbedded createRawString StringObject Object判断逻辑非常朴素——如果字符串长度不超过44字节就走embstr路径超过了就走raw路径。这个44字节的阈值在Redis 3.2版本正式确立3.0时代是39字节原因是Redis 3.2引入了新的内存分配器jemalloc的优化使得小对象的阈值发生了变化。1.2 老二createRawStringObject老二是给大字符串准备的流程如下调用sdsnewlen(ptr, len)创建一个SDS对象用createObject(OBJ_STRING, sds)创建RedisObject编码设为OBJ_ENCODING_RAW此时RedisObject和SDS是两个独立分配的内存块访问字符串需要两次指针跳转。内存布局如下栈/寄存器 堆内存1 堆内存2 -------- ------------------- ------------------- | robj* |-------| RedisObject | | SDS (sdshdr5/8) | -------- | type: OBJ_STRING | ---| len: 100 | | encoding: RAW | | | alloc: 128 | | ptr ---------------- | flags: ... | | refcount: 1 | | buf: hello... | ------------------- -------------------1.3 老三createEmbeddedStringObject老三是专门处理小字符串的高效方案。它的核心优化是——把RedisObject和SDS放在同一块连续内存中计算需要的总内存大小 sizeof(RedisObject) sizeof(sdshdr8) len 1一次性分配整块内存在这块内存上依次放置RedisObject和SDS编码设为OBJ_ENCODING_EMBSTR栈/寄存器 堆内存单次分配一整块 -------- ---------------------------------- | robj* |------| RedisObject | -------- | type: OBJ_STRING | | encoding: EMBSTR | | ptr -------- | ----------------|--- | | SDS (sdshdr8) | | | | len: 5 |-- | | alloc: 5 | | | flags: ... | | | buf: redis | | ----------------- | ----------------------------------这种设计的巧妙之处在于减少内存碎片一次分配代替两次分配提高缓存命中率RedisObject和SDS紧挨着都在同一Cache Line里释放简单一次free就完事不用追着两块内存分别释放⚠️ 注意embstr是只读的因为它是一整块连续内存没法单独扩展SDS的空间。如果你想对embstr编码的字符串做APPEND操作Redis会先把它转成raw编码再进行追加——这个过程隐含了一次完整的内存重分配和拷贝。二、embstr vs raw一个字节的差距决定了命运让我们用实验来验证embstr和raw的切换# 44字节以内 —— embstr编码127.0.0.1:6379SET key1aaaaaaaaaabbbbbbbbbbccccccccccddd# 42字节OK127.0.0.1:6379OBJECT ENCODING key1embstr# 超过44字节 —— raw编码127.0.0.1:6379SET key2aaaaaaaaaabbbbbbbbbbccccccccccddddd# 45字节OK127.0.0.1:6379OBJECT ENCODING key2raw为什么偏偏是44字节这得从RedisObject的大小说起。一个RedisObject结构体在64位系统上正好占用16字节jemalloc的最小分配单元再来看看内存分配器的特点jemalloc的最小bin是16字节然后是32、48、64……RedisObject(16B) sdshdr8(3B) 字符串(≤44B) ‘\0’(1B) 最多64B64B刚好是jemalloc的一个缓存行大小这就是精打细算的艺术——Redis作者们把一个字节掰成两半花确保embstr编码的小字符串恰好填满CPU Cache的一次加载单元。三、APPEND亲手制造一次内存重分配APPEND命令是观察SDS动态扩容的最好窗口。我们来做一次完整的实验。3.1 从embstr到raw的变身秀127.0.0.1:6379SET mottoStayOK127.0.0.1:6379OBJECT ENCODING mottoembstr# 初始状态embstr紧凑高效127.0.0.1:6379STRLEN motto(integer)4127.0.0.1:6379APPEND motto hungry(integer)11127.0.0.1:6379OBJECT ENCODING mottoraw# 追加后变成raw因为embstr不可原地扩容127.0.0.1:6379GET mottoStay hungry在这个过程中Redis内部做了这些事判断motto当前是embstr编码不支持原地扩容计算出新字符串长度4 7 11字节创建一个新的raw编码的RedisObject把老数据Stay和新数据 hungry都拷过去释放老对象此时refcount应该降为0将键motto指向新对象3.2 连续APPEND看SDS的预分配策略SDS在扩容时不是要多少给多少而是狮子大开口——多要一些省得下次再分配127.0.0.1:6379SETtestOK127.0.0.1:6379OBJECT ENCODINGtestembstr127.0.0.1:6379APPENDtesthello# 5B → 转为raw(integer)5# SDS内部alloc变成了 5 1MB所以按2倍预分配但5*210不够明显127.0.0.1:6379APPENDtest world# 6B 11B但alloc可能已经是10或更大(integer)11127.0.0.1:6379APPENDtest again# 6B 17B(integer)17# alloc至少是17*2 34后续在34以内APPEND不会再触发内存重分配SDS扩容的潜规则新长度1MB时分配新长度×2的空间新长度≥1MB时分配新长度1MB的空间这个策略的好处是摊还复杂度O(1)——大多数APPEND操作不需要重新分配内存。3.3 内存变化的ASCII图示用ASCII图来直观展示连续APPEND时内存的变化初始状态embstr: --------------------------- | robj | SDS(hdrbuf) | 整块内存163625Bjemalloc 32B bin | EMBSTR | len0 alloc0 | --------------------------- SET test hello → len5: --------------------------- | robj | SDS | 整块内存163625B | EMBSTR | len5 hello | --------------------------- APPEND test world → len11, 触发embstr→raw转换: ------------------- ---------------------------- | robj | ----| SDS | | encoding: RAW | | | len: 11 alloc: 22 | | ptr ----------------- | buf: hello world\0... | | refcount: 1 | ---------------------------- ------------------- APPEND test again → len17, alloc22够用, 不扩容: ------------------- ---------------------------- | robj | ----| SDS | | encoding: RAW | | | len: 17 alloc: 22 | | ptr ----------------- | buf: hello world again | | refcount: 1 | ----------------------------四、惰性空间释放宁可浪费也不频繁重分配SDS有一个有趣的特性——字符串缩短时它不会立即释放多余内存而是留作后用。这就是惰性空间释放Lazy Free。4.1 实际演示# 先创建一个较大的字符串127.0.0.1:6379SET bigaaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffOK# 用SETRANGE截断注意STRLEN不变只是把中间内容置空的行为不适用我们用SET覆盖来模拟127.0.0.1:6379SET bigshortOK# 此时SDS的alloc仍然保持原来的大容量只是len变成了5SDS的sdsRemoveFreeSpace函数不会自动被调用——除非你显式触发。它的设计逻辑是空间先留着万一你又要变长呢。这种策略非常适合写多读少、频繁修改的场景。4.2 什么时候会真正释放惰性空间只在以下情况被回收键被删除时DEL命令键过期时自动或被动过期内存淘汰时maxmemory-policy触发数据库清空时FLUSHDB/FLUSHALL正常业务中的SETRANGE缩小操作、GETRANGE读取子串都不会触发内存收缩。⚠️ 注意如果你的业务里经常先SET一个大字符串再SET一个小字符串惰性空间策略可能导致内存碎片。对于这种场景建议使用UNLINK异步删除老Key再SET新Key或者干脆开启activedefrag自动碎片整理。五、二进制安全不只是能存\0那么简单5.1 经典案例存储序列化数据SDS的二进制安全特性让它能存储任意字节序列。最实用的场景就是缓存序列化对象# Python中用pickle序列化一个对象# import pickle, redis# r redis.Redis()# data pickle.dumps({user_id: 123, name: 张三, score: 99.5})# r.set(user:123, data)# 在Redis中查看127.0.0.1:6379GET user:123\x80\x04\x95\x2a\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x07user_id\x94K{\x8c\x04name\x94\x8c\x06\xe5\xbc\xa0\xe4\xb8\x89\x94\x8c\x05score\x94GX\xe0\x00\x00\x00\x00\x00\x94u.如果是C语言的char*上面这个数据碰到第一个\x00就截断了。但SDS用len字段来界定长度根本不管buffer里有什么字节。5.2 一个容易踩的坑# 从文件读取图片字节存入Redis127.0.0.1:6379SET img:logo\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR...OK127.0.0.1:6379STRLEN img:logo(integer)256# 如果你用STRLEN它返回的是真实字节数基于SDS的len字段# 而不是strlen()那种到\0就停的诡异行为5.3 二进制安全在Redis中的应用场景场景说明命令示例缓存Protobuf/MessagePack二进制序列化协议的天然载体SET user:1 protobuf_bytesSession存储PHP/Go的session handler直接把序列化数据塞RedisSET session:abc123 serialized图片/文件缓存小文件直接存Redis减少磁盘IOSET avatar:1001 png_bytesBitmap数据BITFIELD/BITOP背后的二进制操作SETBIT online:20260526 10086 1加密数据存储AES加密后的密文随便存不用担心截断SET token:xyz encrypted_bytes六、字符串常用操作的时间复杂度分析面试官最爱问Redis为什么快答因为大部分操作是O(1)。但我们得知道哪些是O(1)哪些不是。命令时间复杂度底层SDS操作备注SETO(1)sdsnewlen createObject创建新字符串对象GETO(1)直接返回robj-ptr零拷贝返回APPEND摊还O(1)sdscatlen可能触发扩容单次最坏O(N)STRLENO(1)sdslen读len字段不遍历字符串GETRANGEO(N)N为返回长度sdsrange 拷贝实际拷贝的子串越长越慢SETRANGEO(N)N为写入长度sdsclear sdscatlen不改变alloc的话很快INCR/DECRO(1)直接操作int encoding注意是整数编码不是SDSINCRBY/DECRBYO(1)同上64位有符号整数范围INCRBYFLOATO(1)字符串转浮点运算再存回有一定的浮点精度问题MSET/MGETO(N)N为Key数量批量创建/读取打包命令减少RTT⚠️ 注意GETRANGE的时间复杂度是O(N)N是返回的子串长度。不要以为它是O(1)如果你GETRANGE bigkey 0 -1相当于把整个字符串拷一遍跟GET没区别还要多一次内存拷贝。七、INCR系列当字符串不再是字符串Redis的INCR/DECR命令看起来操作的是字符串但实际上它们在内部用的是整数编码——完全不走SDS。127.0.0.1:6379SET counter100OK127.0.0.1:6379OBJECT ENCODING counterint# 不是embstr是int编码127.0.0.1:6379INCR counter(integer)101127.0.0.1:6379INCRBY counter50(integer)151127.0.0.1:6379INCRBYFLOAT counter0.5151.5127.0.0.1:6379OBJECT ENCODING counterembstr# 变成浮点数后就转embstr了Redis对象有三种编码OBJ_ENCODING_INT纯数字取值范围在LONG_MIN~LONG_MAX之间的整数OBJ_ENCODING_EMBSTR≤44字节的短字符串或浮点数OBJ_ENCODING_RAW44字节的长字符串INCR命令全程操作OBJ_ENCODING_INT编码速度极快。但一旦调用INCRBYFLOAT或把值变成非纯数字编码就退化为embstr或raw了。八、大Key问题大String怎么坑你的8.1 什么是大KeyRedis社区一般认为单个String超过10KB就是大Key了超过1MB那就是灾难级别的。大String有三大罪状阻塞主线程DEL一个大StringRedis主线程要完整释放内存这期间其他命令都得排队等着网络带宽杀手一次GET 50MB带宽直接拉满内存碎片制造机巨大的内存块释放后容易留下难以利用的空洞8.2 拆分方案如果你的业务确实需要存储较大的值推荐以下几种拆分策略方案一分片存储# 原始方案一个大KeySET article:100150000字节的完整内容# 拆分方案分片存储SET article:1001:010000字节的第1段SET article:1001:110000字节的第2段SET article:1001:210000字节的第3段SET article:1001:310000字节的第4段SET article:1001:410000字节的第5段# 读取时用MGET一次全取出来拼装MGET article:1001:0 article:1001:1 article:1001:2 article:1001:3 article:1001:4方案二用Hash存储推荐≤100个fieldHSET article:1001 part110000字节的第1段HSET article:1001 part210000字节的第2段# ... 更多分段HGETALL article:1001方案三对象存储转引用# 大数据存OSS/CDNRedis只存URLSET article:1001:content_urlhttps://cdn.example.com/articles/1001/content.jsonSET article:1001:meta{title:xxx,author:yyy}# 小数据继续用Redis8.3 如何主动发现大Key# 方式一redis-cli自带的大Key扫描redis-cli--bigkeys# 方式二用MEMORY USAGE估算Redis 4.0127.0.0.1:6379MEMORY USAGE big_key_name(integer)524300# 方式三DEBUG OBJECT查看详细信息127.0.0.1:6379DEBUG OBJECT big_key_name Value at:0x7f8b3c0012a0 refcount:1 encoding:raw serializedlength:100000 lru:123456 lru_seconds_idle:30⚠️ 注意redis-cli --bigkeys的原理是执行SCAN命令遍历所有Key对每个Key执行STRLEN/LLEN/SCARD等命令。在生产环境跑的时候一定要在业务低峰期执行否则可能影响正常服务的响应时间。另外MEMORY USAGE命令也会对Key做一次采样预估大Key时会比较慢。九、总结本篇从实战角度把SDS在Redis字符串操作中的表现梳理了一遍SET命令根据长度自动选择embstr≤44B或raw44B编码embstr是RedisObjectSDS一块内存的紧凑方案效率高但不可原地修改APPEND在embstr上执行会触发编码转换在raw上执行可能触发扩容SDS扩容采用预分配策略1MB翻倍≥1MB加1MB惰性空间释放让SDS缩短时不立即回收内存减少分配次数二进制安全让Redis可以存储任意字节序列远不止能存\0大String是性能杀手超过10KB就该考虑拆分方案下一篇我们将进入Redis的列表世界看看双端链表是怎么撑起LPUSH/LPOP/RPUSH/RPOP这些命令的。上一篇【第10篇】告别char*——SDS重新定义字符串下一篇【第12篇】链表——Redis列表的底层支撑