Redis字符串类型深度剖析:从SDS存储结构到核心命令实战
1. Redis 字符串类型先搞懂它到底在存什么Redis 的 String字符串类型是整个 Redis 体系里最基础、也最容易被低估的数据类型。很多人一听“字符串”就下意识觉得“不就是 key-value 存文本嘛”实际上它承担了缓存、计数器、分布式锁、限流、Session 共享、对象序列化存储等一整套核心业务场景。作为数据类型系列的第二个部分这篇重点拆解 Strings 的核心命令和底层存储结构尽量把“命令为什么要这么设计”“底层为什么选择这种结构”也讲明白而不只是贴一堆语法。1.1 字符串类型在 Redis 里的定位Redis 的数据模型是“一个键对应一个值”键永远是字符串值就有多种类型可选包括 String、Hash、List、Set、ZSet。而 String 是唯一一种“值本身也是字符串”的类型。它看起来最简单却是其他所有类型的地基不管是 Hash 的 field、List 的元素、Set 的成员底层本质上都是字符串。很多人不知道的是Redis 的字符串不是传统语言里的“字符数组”它既能保存普通的文本也能保存整数还能保存二进制数据比如图片的字节流、序列化后的对象。单个字符串的最大容量是 512MB所以在实际项目里把一个用户对象序列化成 JSON 再塞进 Redis或者把一个文件内容直接缓存起来在容量上完全没问题。1.2 为什么 Strings 能扛起大半业务场景我在实际项目里观察过线上 Redis 的 key 数量里String 类型常常能占到 60% 以上。原因无非这几点读写模型简单一个 key 一个 value业务要缓存什么直接 SET 进去、GET 出来心智负担极低。原子操作丰富INCR、DECR、SETNX 这些命令天然具备原子性不用额外加锁就能实现计数器、秒杀库存扣减、分布式锁。过期时间精细SET 命令可以同时指定过期时间缓存场景几乎是无缝适配。二进制安全存什么都不会被截断序列化后的 Protobuf、MessagePack 都能直接放进去。后面会发现很多看似简单的命令组合起来能覆盖相当复杂的业务需求。这也是我坚持先写 String 的原因基础类型没吃透后面学 Hash、ZSet 时会总觉得缺一块。2. 核心命令拆解从最基础的存取到花样操作2.1 最常用的三件套SET、GET、DEL先看最基础的一组SET user:name zhangsan GET user:name DEL user:nameSET 负责写入GET 负责读取DEL 负责删除。这套组合本身没什么好讲的但有几个细节很容易踩坑SET 一个已经存在的 key 时默认会直接覆盖旧值。GET 一个不存在的 key返回的是 nil不是空字符串。这个在客户端里要区分清楚很多新手在这里把 nil 当成空字符串处理结果判断逻辑出问题。DEL 可以同时删除多个 key比如DEL key1 key2 key3返回值是被删除的数量。实际业务里我最常用的是这套组合做热点数据的读取比如先查缓存缓存没有再查数据库查询成功后再回写缓存。看起来简单但配合后面的过期时间和 NX 参数就能做出防雪崩、防击穿的缓存方案。2.2 带过期时间的写入SET 的完整参数SET 命令不是只有两个参数它完整语法里有不少可选项SET key value [EX seconds] [PX milliseconds] [EXAT timestamp] [PXAT timestamp] [NX | XX] [KEEPTTL]逐个解释一下EX / PX指定过期时间EX 的单位是秒PX 的单位是毫秒。EXAT / PXAT指定一个绝对时间戳到点自动过期。EXAT 是秒级时间戳PXAT 是毫秒级时间戳。这个在定时任务场景里很有用比如设置某个优惠券在 2024 年某月某日失效。NX只在 key 不存在的时候才设置。这个参数是分布式锁的基石。XX只在 key 已经存在的时候才设置适合做“只更新已存在的缓存”。KEEPTTL重新设置 value 的时候保留原本的过期时间。这个参数特别容易被忽略很多人 SET 一个已有 TTL 的 key 时发现过期时间被清掉了问题就出在这里。这里有个我切身体会的大坑如果第一次用SET token abc EX 3600设置了过期时间后来又用SET token def去更新值没有加 EX 也没有 KEEPTTL那么这个 key 的过期时间会被清除变成一个永久 key。日志里就会出现“明明设置了过期时间为什么几百个 token 全不过期”的诡异现象。2.3 计数器三兄弟INCR、INCRBY、DECRBYINCR article:views:10001 INCRBY article:views:10001 10 DECR article:stock:10086 DECRBY article:stock:10086 5INCR 把 key 对应的数字加 1INCRBY 加指定步长DECR 和 DECRBY 则是减。这一组命令有几个非常重要但却常常被忽视的特性原子性INCR 在执行过程中不会被其他命令插队多个客户端同时调用也不会出现“加两次但只加了一次”的问题。这一点在做秒杀扣库存、点赞数统计时极其关键。自动初始化key 不存在时Redis 会先把它当成 0再执行加操作。比如INCR view在 view 不存在时结果直接是 1。必须是数字如果 key 的值不是整数或者超过了 64 位有符号整数范围Redis 会返回错误。曾经有人把用户手机号存进去再 INCR直接报错。我用计数器命令做过一个比较典型的场景文章阅读数。数据库里存一个基准数Redis 里存增量每隔一段时间把增量同步回数据库然后重置 Redis 里的值。这样数据库不用扛高频写入阅读数又不会丢。2.4 批量操作与单命令逻辑MSET、MGET、APPEND、STRLENMSET user:1 a user:2 b user:3 c MGET user:1 user:2 user:3 APPEND user:1 _suffix STRLEN user:1MSET 可以一次写入多个 keyMGET 一次读取多个 key。这样做的价值是减少网络往返本来需要 N 次请求的现在一次搞定。在 RTTRound-Trip Time很高的场景下比如跨机房调用这个优化尤其明显。APPEND 是在原有字符串末尾追加内容如果 key 不存在则等价于 SET。STRLEN 返回字符串的字节长度不是字符长度。这里要特别注意如果存的是中文一个汉字在 UTF-8 编码下占 3 个字节STRLEN 返回的长度跟肉眼看到的字符数是不一致的。比如STRLEN 你好返回的是 6不是 2。2.5 其他常用但容易忽略的命令还有一组命令值得提一下SETNX key value GETSET key new_value GETDEL key GETEX key EX 60 SETRANGE key offset value GETRANGE key start endSETNX 本质就是 SET key value NX只在 key 不存在时设置成功返回 1否则返回 0。它经常被用作分布式锁的“占坑”动作。GETSET 返回旧值并设置新值适合做“取出当前值并重置”的操作常用于流量统计里的“上一次统计周期值”。GETDEL 返回旧值并删除 key在“消费一次”的场景里很好用比如一次性 token 的校验。GETEX 可以读取 key 的同时重新设置过期时间适合做滑动过期比如用户活跃 session 续期。SETRANGE 和 GETRANGE 则是对字符串指定区间进行操作实际业务里用得不多但偶尔做字符串裁剪时非常方便。3. 存储结构SDS 与三种编码方式3.1 为什么 Redis 不用 C 字符串Redis 是 C 语言写的但它没有直接用 C 语言传统的char*字符串来存数据而是自己定义了一个结构叫 SDSSimple Dynamic String简单动态字符串。这个选择不是闲得慌而是为了解决几个实际问题O(1) 获取长度C 字符串要统计长度必须遍历到结尾的\0SDS 结构里直接记录了长度字段取长度没有遍历开销。二进制安全C 字符串以\0作为结束标志如果数据里本身包含\0就会被截断。SDS 用长度字段判断结尾所以存二进制数据、序列化数据都不会出问题。避免缓冲区溢出C 字符串拼接时如果没分配足够空间会把数据写到相邻内存里造成安全问题。SDS 在修改前会检查空间不够就先扩容。SDS 还做了空间预分配和惰性空间释放。简单说字符串变长时不会只分配刚好够用的内存而是多预留一些避免频繁扩容缩短时也不急着回收内存方便后续再次追加。这在频繁做 APPEND 操作的场景下能省下不少性能。3.2 int、embstr、raw 三种编码方式Redis 底层会根据字符串的内容和长度选择不同的编码方式。通过OBJECT ENCODING key可以查看当前 key 的编码SET num 12345 OBJECT ENCODING num # 返回 int SET short_str hello OBJECT ENCODING short_str # 返回 embstr SET long_str 长字符串超过 44 字节的内容 OBJECT ENCODING long_str # 返回 raw三种编码的含义int当字符串可以解析为 64 位有符号整数时Redis 直接以整数形式存储不再保存字符串字节序列。这样省内存INCR、DECR 命令操作起来也快。embstr短字符串通常小于等于 44 字节使用嵌入式字符串编码Redis 对象头和数据在连续内存里一次分配一次释放内存碎片少访问快。raw长字符串使用普通动态字符串编码redisObject 和 SDS 分开分配适合保存大文本和二进制数据。这里的 44 字节阈值经常被搞混。早期版本里阈值是 39 字节后来版本调整了结构常见 Redis 3.2 之后就是 44 字节。不同小版本的精确阈值可能略有差异最好的办法就是实际执行OBJECT ENCODING看看。还有一点编码不是固定的会随着操作动态变化。比如一个 embstr 字符串执行 APPEND 之后可能直接变成 raw。因为 APPEND 要修改数据Redis 选择转为 raw 再做追加。一旦变成 raw就算数据缩短也不会自动回到 embstr。int 类型如果执行 APPEND 变成字符串也会转为 embstr 或 raw。3.3 内存与性能的取舍从存储结构上能看出 Redis 的设计哲学能省则省能快则快。在内存型数据库里同样的 key 数量编码方式是 int 还是 raw最终内存占用差距可能非常大。我实际排查过一个内存增长问题某个服务把所有订单号都用 String 存订单号是 18 位纯数字但因为代码里用了格式化函数在 Redis 里存成了 18 字节字符串编码变成 raw。后来改为直接存整数内存直接降了约 30%。这就是编码方式对内存的真实影响。再看一个常见的问题是“大 key”。一个 value 达到几百 KB 甚至几 MB使用 raw 编码存储时没问题但 GET 和 SET 这种全量操作就会比较耗时甚至引起 Redis 阻塞。排查时可以用MEMORY USAGE key看一个 key 占用多少内存也可以用redis-cli --bigkeys扫描全库的大 key。MEMORY USAGE user:info:10001返回的是字节数能直观看到这个 key 吃了多少内存。我在排查线上问题时发现很多“Redis 内存涨得莫名其妙”的案例都是因为“当时只是为了方便把一个巨大的 JSON 直接塞了进去”这个 JSON 往往还是重复内容。4. 实战场景缓存、计数器与简单分布式锁4.1 缓存场景写法与过期策略缓存是 String 最典型的应用。一个标准的缓存读写流程是这样的# 尝试读缓存 GET product:info:10001 # 没命中则查数据库然后写缓存并设置过期时间 SET product:info:10001 {name:demo,price:99} EX 600这里有几个关键细节值得展开过期时间不能设置太长。太长会导致缓存里的数据长期和数据库不一致太短又容易击穿数据库。我通常的做法是基础过期时间 10 分钟再根据业务容忍度调整。为了防止缓存雪崩最好不要让所有 key 在同一时刻过期。可以在过期时间上加一个随机值比如EX 600 random(0, 300)。这样即使大量缓存同时失效也不会在同一瞬间全部打到数据库。防止缓存穿透可以在查询不存在的 key 时也写一个空值缓存比如SET product:info:99999 EX 60。这样恶意请求反复查不存在的商品也不会每次都穿透到数据库。防止缓存击穿可以用SET key value NX EX做一个互斥锁只允许一个请求去加载数据库其他请求等待。这些组合在实际项目中几乎人人都在用但很多人写的时候并不清楚背后的原理。比如互斥锁本质就是利用了 SETNX 的原子性第一个拿到锁的请求去加载数据库其他请求短暂等待或返回旧值。4.2 计数器场景高并发下的原子性在 MySQL 里做计数器通常要考虑行锁、事务但在 Redis 里用 INCR 就是一行命令的事。因为 INCR 是原子操作多个客户端并发执行时最终结果一定是准确的不会出现加少了或者覆盖了的问题。SET goods:stock:10001 100 INCR goods:stock:10001 # 99 INCR goods:stock:10001 # 98这里有个应用技巧如果要做“扣减到 0 就不能再减”的业务比如库存扣减只用 INCR 还不够需要先读再判断再扣这个流程不是原子的。简单方案是结合 Lua 脚本if redis.call(GET, KEYS[1]) 0 then return 0 end return redis.call(DECR, KEYS[1])Lua 脚本在 Redis 里是原子执行的业务里经常用这种方式实现安全扣减。当然这只是 String 层面的玩法真正精密的库存、秒杀系统还需要考虑更多但 Strings 的原子操作是地基。4.3 简单分布式锁SETNX 的正确打开方式String 实现分布式锁是很多面试题和高并发项目里绕不开的点。基础版本就是SET lock:order:10001 random_value NX EX 30NX 保证只有没人持有锁的时候才能设置成功。EX 30 保证即使持有锁的进程挂了锁也会在 30 秒后自动释放不会死锁。这个方案的问题也很明显如果业务执行时间超过 30 秒锁自动过期了另一个进程就能拿到锁造成并发问题。解决思路通常在两个方向一是把过期时间调大一点二是在释放锁时校验随机值只释放自己持有的锁。释放锁的校验和删除操作要保证原子性实际也是用 Lua 脚本做的这里不展开指令细节但思路要清楚不能直接 DEL一定要先判断 value 是否等于自己设的随机值。我用这个方案做过活动秒杀入口的防并发控制简单有效。但要注意它不是一个“完美”的分布式锁Redis 主从切换、长时间 GC 暂停等极端场景下仍然可能有竞态。所以在要求极高的金融场景里不建议直接用这个方案而在绝大多数互联网业务里它能覆盖 99% 的需求。4.4 命令选择速查表业务场景推荐命令关键注意点基础缓存读写SET / GET记得设置过期时间只允许新增的缓存SET NX防止覆盖已有数据缓存续期GETEX key EX实现滑动过期计数器 / 秒杀库存INCR / DECR天然原子但扣减到 0 需 Lua分布式锁SET NX EX释放前要校验随机值批量读取缓存MGET减少 RTT但要注意 key 数量一次性消费 tokenGETDEL取到即删防止重复使用统计上一次的值GETSET取出旧值并原子重置这张表是我平时写代码时经常对照的业务需求一变马上能映射到对应的命令不用重新翻文档。5. 常见问题与排查实录5.1 设置了过期时间key 却永久存在这个我前面提过最常见原因是后续 SET 没有带过期时间也没带 KEEPTTL把原来的 TTL 覆盖掉了。SET user:token abc123 EX 3600 SET user:token new_token # 过期时间被清掉排查时用TTL user:token查看剩余时间返回值 -1 表示永久有效。修复方法很简单更新 value 时加上KEEPTTLSET user:token new_token KEEPTTL还有一个场景也会造成误导Redis 集群模式下如果某台机器时钟不同步相对过期时间会看起来“不准时”因为 EX 的计时依赖服务器本地时间。生产环境要对好 NTP不然会出现缓存提前过期或延迟过期的怪问题。5.2 大 key 引发阻塞和内存暴涨大 key 是 Redis 生产事故里最经典的一类。String 类型出现大 key 通常是以下原因一次性缓存了很大的 JSON 数据比如把整个商品列表一次塞进一个 key。把文件、图片字节直接存进 Redis。频繁 APPEND 一个 key让它越长越大。大 key 带来的影响有两个一是内存占用高二是操作耗时高。GET 一个大 key 需要把几十 MB 的数据从内存里取出来传给客户端这个操作会阻塞 Redis 主线程影响所有其他请求。Redis 慢日志里经常能看到这类命令SLOWLOG GET 10如果发现某个 GET 命令耗时几十毫秒甚至上百毫秒基本可以怀疑是读了大 key。处理思路把大 key 拆成多个小 key用 Hash 结构分段存储或者业务上分页读取。不确定哪些 key 大时用redis-cli --bigkeys扫描。评估是否真的需要把这么大的数据放 Redis很多时候可以只缓存摘要完整内容走对象存储。5.3 连接数太多导致命令变慢String 本身不会导致这个问题但我在用 MGET 批量读取时就遇到过客户端一次性并发几千个 GET连接数瞬间打满命令排队变慢。根源是业务代码没有合理复用连接或者没有用 pipeline 批量提交。解决方案是用 MGET 替代多个 GET或者使用 pipeline 技术把多个命令打包发送。我用 MGET 优化过一个接口原本 50 次 GET 请求合并成 1 次 MGET 后整体耗时从 30ms 降到 3ms 左右效果立竿见影。5.4 中文长度和编码统计的坑STRLEN 统计的是字节数在中文场景下非常容易迷惑。比如一个昵称字段用户输入了 5 个汉字业务上预期长度是 5但 STRLEN 返回 15。其实这里的问题本质是字符集。UTF-8 下汉字通常占 3 字节如果客户端写入时用了 UTF-8 编码Redis 不会做任何编码转换存进去是什么就是什么。想要精确的字符数只能在业务层统计Redis 本身不关心字符和字节的区别。这个特性在存二进制数据时反而是优点。6. 一些实操后的心得Redis String 这部分内容讲完了最后分享几点我个人在实际项目中攒下的体会。第一不要忽视编码检查。线上环境如果内存紧张先用OBJECT ENCODING和MEMORY USAGE看看哪些 key 是 raw 编码哪些是 int 编码。很多内存优化并不需要换数据结构只需要改变写入方式。第二命令组合比单个命令更重要。SETNX 单独用只是一个占位操作配合 EX、随机值、Lua 脚本释放就成了一个实用的分布式锁INCR 单独用只是计数器配合 Lua 脚本就成了安全扣库存工具。学会组合才算真正会用 Strings。第三任何缓存方案都要想清楚过期和失效。String 的过期时间虽然好用但“缓存雪崩”“缓存穿透”“缓存击穿”这三座大山几乎全是围绕过期策略产生的。合理地设置 NX、随机过期时间、空值缓存比事后加机器更有效。第四遇到缓存相关问题先看慢日志和内存再改代码。有一次我以为某个接口变慢是数据库问题查了半天才发现是 Redis 里一个大 key 每次 GET 耗时 80ms。如果一开始就用SLOWLOG GET定位能省下好几个小时。字符串类型看起来简单但它承载的是整个 Redis 生态里最广泛的场景。把这套命令和存储结构吃透后面学 Hash、List、Set 时会轻松很多因为它们本质上都建立在 String 的基础机制之上。下一篇可以继续深入 Hash 类型的底层结构与命令陷阱。