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、DECRBY
INCR article:views:10001 INCRBY article:views:10001 10 DECR article:stock:10086 DECRBY article:stock:10086 5INCR 把 key 对应的数字加 1,INCRBY 加指定步长,DECR 和 DECRBY 则是减。这一组命令有几个非常重要但却常常被忽视的特性:
- 原子性:INCR 在执行过程中不会被其他命令插队,多个客户端同时调用也不会出现“加两次但只加了一次”的问题。这一点在做秒杀扣库存、点赞数统计时极其关键。
- 自动初始化:key 不存在时,Redis 会先把它当成 0,再执行加操作。比如
INCR view在 view 不存在时,结果直接是 1。 - 必须是数字:如果 key 的值不是整数,或者超过了 64 位有符号整数范围,Redis 会返回错误。曾经有人把用户手机号存进去再 INCR,直接报错。
我用计数器命令做过一个比较典型的场景:文章阅读数。数据库里存一个基准数,Redis 里存增量,每隔一段时间把增量同步回数据库,然后重置 Redis 里的值。这样数据库不用扛高频写入,阅读数又不会丢。
2.4 批量操作与单命令逻辑:MSET、MGET、APPEND、STRLEN
MSET user:1 "a" user:2 "b" user:3 "c" MGET user:1 user:2 user:3 APPEND user:1 "_suffix" STRLEN user:1MSET 可以一次写入多个 key,MGET 一次读取多个 key。这样做的价值是减少网络往返,本来需要 N 次请求的,现在一次搞定。在 RTT(Round-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 end- SETNX 本质就是 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*字符串来存数据,而是自己定义了一个结构叫 SDS(Simple Dynamic String,简单动态字符串)。这个选择不是闲得慌,而是为了解决几个实际问题:
- O(1) 获取长度:C 字符串要统计长度必须遍历到结尾的
\0,SDS 结构里直接记录了长度字段,取长度没有遍历开销。 - 二进制安全: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 30- NX 保证只有没人持有锁的时候才能设置成功。
- 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 数量 |
| 一次性消费 token | GETDEL | 取到即删,防止重复使用 |
| 统计上一次的值 | GETSET | 取出旧值并原子重置 |
这张表是我平时写代码时经常对照的,业务需求一变,马上能映射到对应的命令,不用重新翻文档。
5. 常见问题与排查实录
5.1 设置了过期时间,key 却永久存在
这个我前面提过,最常见原因是后续 SET 没有带过期时间,也没带 KEEPTTL,把原来的 TTL 覆盖掉了。
SET user:token "abc123" EX 3600 SET user:token "new_token" # 过期时间被清掉排查时用TTL user:token查看剩余时间,返回值 -1 表示永久有效。修复方法很简单:更新 value 时加上KEEPTTL:
SET 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 类型的底层结构与命令陷阱。