☰
深入理解Redis String类型:编码原理、原子操作与实战场景
2026/10/13 16:10:18 网站建设 项目流程

别急着去搜什么叫 “五大数据类型” 的面试八股,如果你手上有个项目正在用 Redis,那你迟早会发现——真正陪你走到最后的,大概率是最不起眼的 String 类型。它能做缓存、做计数器、做分布式锁的底子,甚至还能把对象序列化后直接怼进去,很多业务场景根本不需要什么 Hash、ZSet 出场,一个 String 就能解决 80% 的需求。这篇我不打算给你罗列命令文档,而是从实际项目出发,聊聊 String 到底怎么用、为什么这么设计、以及那些你早晚会踩的坑。

1. 先搞明白:String 类型到底在存什么

1.1 别被名字骗了,它不只是“字符串”

很多人刚接触 Redis 时,以为 String 只能存一段文本,比如用户昵称、验证码这种。这是最大的误解。Redis 的 String 本质上是二进制安全的字节数组,你往里面存的是一串字节,Redis 并不关心这些字节到底是文本、是整数、是图片的一部分,还是序列化后的对象。

这个概念有多重要?举个例子,你可以把一个 Java 对象用 JSON 序列化后存进 String,也可以把一张小图片的 byte[] 直接塞进去,甚至可以把一个 200MB 的大文件分块存到多个 String key 里。只要不超过 Redis 单 key 的 512MB 上限(新版默认配置是多少后面说),它都能装。

这里有个关键点叫二进制安全。普通 C 语言的字符串以\0作为结束符,如果你存的内容里本身包含了\0,传统字符串就截断了。但 Redis 的 String 在底层记录了字节长度,所以哪怕你存了一串包含空字符的二进制数据,它也能原封不动地读出来。

记住这个特性,你后面做分布式锁、做消息体存储、做节流计数,所有跟 String 相关的骚操作都建立在这条性质之上。

1.2 一组 key-value 就是一张“可访问的单元格”

String 的存储模型非常朴素:一个 key 对应一个 value。

key 是字符串,value 是字节数组。这个模型乍一看太简单了,但恰恰因为简单,它才被玩出了花。比如你可以通过 key 的设计来模拟出“表格”的效果:

user:1001:name -> 张三 user:1001:age -> 28 user:1001:city -> 上海

这三个 key 在一起,可不就是用户表的一行数据么?虽然不优雅,但确实能跑。很多老项目早期就是这么用 Redis 的,后来拆成 Hash 类型,或者干脆直接存序列化后的整个用户对象。

这里我想强调一点:String 是最灵活的存储单元,因为它不强制你定义结构。你可以随时改 value 的“格式”,比如从 JSON 格式换成 MessagePack 格式,只要读写双方约定好就行。这种灵活性在项目快速迭代期特别有用——不用为每一个数据结构变更去改 Redis 里的类型。

2. 它凭什么快?多看看 Redis 的线程模型

经常有人问,为什么 Redis 单线程还能这么快,String 的读写为什么能到每秒十万级?要理解这个,得先知道 String 在底层是怎么存储的。

2.1 键值定位依赖哈希表

Redis 维护了一张全局哈希表,key 经过哈希函数计算得到索引位置,每次读写的时间复杂度是 O(1)。所以哪怕你有 100 万个 key,平均查找耗时和只有 100 个 key 时差别不大。

当然,哈希表存在冲突的可能。Redis 用链地址法解决冲突,并且在哈希表负载因子过高时会自动扩容。这个机制跟 Java 的 HashMap 类似,但你不需要去调什么阈值参数。实际项目中你唯一要留意的,是长 key 对哈希计算的拖累——key 越长,计算哈希越慢,内存占用也越高。

2.2 单线程模型下,String 的操作是原子的

Redis 6.x 之前是纯单线程处理命令,6.x 之后引入了多线程来加速网络 IO 的读写,但命令的执行仍然是单线程。这意味着同一时刻只有一个命令在执行,所以单个 String 的 INCR、DECR、SETNX 等操作天然是原子的。

这带来一个杀手级好处:不需要加锁,就能安全地做并发场景下的计数。

比如你要统计某个商品被点击了多少次,多个后端实例同时执行INCR product:123:click,因为命令是串行执行的,结果不会出现竞态条件。这在数据库里可能需要SELECT ... FOR UPDATE或者乐观锁才能搞定的事,Redis 这边就是一个命令的事。

2.3 单 key 上限:512MB 是怎么来的

String 的最大 value 在默认配置下是 512MB。这个值的来历,跟 Redis 内部的数据结构设计有关——它用一个 64 位有符号整数来表示长度,同时为了兼容和限制,默认把单个字符串的最大长度砍到了 512MB。

实际生产里你基本用不到这么长的 value。如果 v 真有几百 MB 的需求,更合理的做法是考虑拆分存储,或者用流式处理,而不是硬塞进一个 key。我见过有人把 300MB 的日志文件塞进 Redis 做检索的,结果内存爆了不说,读取反序列化也要几百毫秒,还不如老老实实放磁盘。

3. String 的编码方式到底有哪几种

这一步非常关键,因为面试容易考,实际排查线上内存问题时也经常用到。如果你用OBJECT ENCODING key去查一个 String 的编码,通常你会看到三种结果:int、embstr、raw。

3.1 int:小数字的极致优化

当你要存的 value 是一个可以用 64 位有符号整数表示的十进制数字时,Redis 不会把它当成普通字符串来存,而是存成整数类型。它直接用一个 8 字节的 long 来存储,省去了字符串本身需要的头部开销和字符数组内存。

举个例子,你执行SET counter 100,这个 key 的编码就是int。

这个优化的收益在于:当你对这个 key 执行 INCR、DECR、INCRBY 这些操作时,Redis 可以直接基于整数运算,省去了字符串到数字的转换开销。如果存进去的是字符串"100",每次 INCR 都要先把字符串转成数字,算完再转回字符串,开销明显更大。

3.2 embstr:短字符串的省内存方案

当字符串长度小于等于 44 字节时(Redis 3.2 之后的上限,之前是 39 字节),Redis 使用embstr编码。

embstr是一次性分配一块连续的内存,对象头部和字符数组紧挨在一起。好处是内存分配次数少、缓存友好度高,因为只需要一次内存分配,读取时数据大概率能在同一缓存行里命中。

这 44 字节的门槛怎么来的?Redis 的字符串对象头部占 16 字节,sds 头部占一定字节,加上结束符,总共凑到 64 字节以内,正好对齐缓存行。所以这 44 不是拍脑袋定的,是经过内存对齐计算出来的。你不需要记住这个计算过程,但要知道:短字符串的性能和内存表现确实比长字符串好,所以能用短字符串表达的信息,别故意去拉长它。

3.3 raw:长字符串的兜底方案

一旦字符串长度超过 44 字节,编码自动变成raw。raw编码下,Redis 对象和实际字符数组是分开分配的两块内存,需要两次内存分配。好处是灵活,坏处是内存碎片和缓存不友好程度上升。

日常开发中你不需要手动指定编码,Redis 会自动在int、embstr、raw之间切换。但有一点要注意:编码转换是不可逆的。比如你先把"100"存成embstr,然后对某个不是 int 编码的 key 追加了字符串内容,导致它变成raw,那即使之后你把尾巴又截断回短字符串,它也不会自动回到embstr。

3.4 实战:用 OBJECT ENCODING 排查内存分布

当你怀疑 Redis 内存占用异常时,OBJECT ENCODING是你第一个该用的命令。有一次我排查项目里的 Redis 内存问题,发现一堆存储短状态码的 key 居然都是raw编码。后来一查,原来是代码里对 key 的 value 做了字符串拼接再塞回去,导致编码升级且无法回退。解决方式也简单:把写入逻辑改成直接 SET,而不是 GET + 拼接 + SET。

你可以时不时抽查线上 Redis 的部分 key:redis-cli --bigkeys能找出大 key,但OBJECT ENCODING能帮你分析类型分布。两条命令配合,基本能把内存问题定位到具体 key。

4. 核心命令的底层逻辑

String 的命令是从使用频率最高的几个 API 衍生出来的,理解它们的底层逻辑能帮你避开很多无效调用。

4.1 SET 和 GET:最基础的两个操作

SET key value的完整语法比你想象的要丰富:

SET key value [EX seconds] [PX milliseconds] [NX|XX] [KEEPTTL]

注意几个关键参数:

  • EX和PX:设置过期时间,单位分别是秒和毫秒。
  • NX:只在 key 不存在时设置成功,这是实现分布式锁的基础。
  • XX:只在 key 已存在时设置成功,常用于条件更新场景。
  • KEEPTTL:重置 value 时保留原有的过期时间,这个很实用。默认情况下,对一个已设置过期时间的 key 执行 SET,过期时间会被清除,key 变成永久有效。但如果你希望更新 value 后过期时间不变,就得带上KEEPTTL。

GET就不用多说了,返回 key 对应的 value。如果 key 不存在返回 nil。

这里有个我见过的常见失误:有的人用SET存了数字,却忘了给 key 设置过期时间,导致内存里堆积了大量永不过期的数据。String 的 key 不设置过期时间就是永久存储,跟 Session 那种自动过期完全不同,你得自己管理生命周期。

4.2 MSET 和 MGET:批量操作的性能关键

MSET key1 value1 key2 value2 ...可以一次性设置多个 key,MGET key1 key2 ...一次性读取多个。这两个命令能把多次网络请求合并成一次,极大降低 RTT(往返延迟)的影响。

举个例子,如果从 MySQL 查出 100 条数据后,需要把它们写入 Redis。用 100 次SET,每次都需要等待一次网络往返;用一次MSET,只需要一次往返。在机器多、流量大的时候,这个差别是质的飞跃。

同理,MGET批量读取,也比你写循环GET快得多。

4.3 INCR 家族:从计数器到限流不要一提 INCR 就觉得它只能做简单自增

INCR到底是怎么实现的?它把 value 解释成十进制整数,加 1,再写回。整个过程在 Redis 内部一步完成,不会有并发问题。

我之前统计过,INCR的 QPS 单实例可以轻松跑到 8 万以上。但真正要注意的是,value 如果内容不是整数,执行 INCR 会直接报错ERR value is not an integer or out of range。所以在设计数据格式时,如果这个 key 注定要做加法,写入时就要保证是纯数字字符串,别混入空格,别用前导零。

INCRBY可以加指定的步长,DECRBY做减量。还有一个容易忽视的坑:当整数溢出时,Redis 会返回错误,因为内部用的是有符号 64 位整数,范围是 -9223372036854775808 到 9223372036854775807。你基本碰不到这个上限,但日活过亿的产品还是要留个心眼。

4.4 SETNX、SETEX 和 GETSET:分布式锁的三板斧

分布式锁是 String 最常见的应用场景之一。最原始的写法是用SETNX+EXPIRE两个命令,先尝试加锁,再设置过期时间。但这两个命令不是原子的——如果加锁成功之后、设置过期时间之前,进程崩了,这个锁就永远释放不了。

所以后来演进成直接用一条命令:

SET lock:order:123 ownerId NX EX 30

这行命令的含义是:只有当 key 不存在时,写入成功,并设置 30 秒过期。ownerId 是你自己的机器标识,用来在释放锁时确认锁属于自己。

释放锁也有讲究,不能傻乎乎直接DEL。正确姿势是用 Lua 脚本或GETDEL(Redis 6.2 之后)先比对 ownerId 是否是自己,再删除,防止误删了别人刚设置的新锁。

4.5 APPEND、SETRANGE 和 STRLEN:操作长字符串的进阶工具

APPEND在 value 末尾追加内容,适合做日志累积、消息体拼接。SETRANGE可以在指定位置覆盖写入,适合做位图、修改二进制内容。STRLEN返回 value 的字节数,注意它统计的是字节数,不是字符数——如果你存的是中文,一个中文在 UTF-8 下占 3 个字节,很容易算错。

这里要提醒一下:APPEND这种操作如果频繁执行,会导致raw编码不断扩容 realloc,引发内存碎片。如果确实需要频繁追加内容,考虑用 List 类型做队列,或者用专门的日志系统,而不是硬拿 Redis String 当消息缓冲。

4.6 GETSET、GETDEL 以及过期时间的操作细节

GETSET key newValue返回旧值,同时写入新值。乍一看像没用的命令,但它在“原子性地获取并重置”场景非常好用,比如定时任务把当前累计值取走并发给下游,然后归零:

old = GETSET counter 0

GETDEL是 Redis 6.2 新增的命令,取到 value 的同时删除 key。用它可以省掉先 GET 再 DEL 两轮网络往返。如果你的 Redis 版本低于 6.2,需要用 Lua 脚本代替,否则没办法原子地完成。

还有一点经常被忽略:EXPIRE和PEXPIRE只能设置过期时间,TTL和PTTL可以用来查询剩余存活时间。当你把一个 key 设置为永久后,TTL返回 -1;如果 key 不存在,返回 -2。这两个输出值代表了完全不同的状态,排查数据是否被误删时经常用到。

5. 实战场景拆解:从缓存到限流的完整方案

5.1 缓存 Session、用户信息、验证码

最经典的用法。将用户信息序列化后直接存 String,用 key 带上用户 ID 和会话标识:

SET session:abc123 '{"uid":1001,"name":"张三","role":"admin"}' EX 86400

验证码场景:

SET captcha:1001 482037 EX 300

这里有一个容易犯的错:验证码需要校验后立即失效,所以校验成功后要DEL。但如果你先GET出来对比,再DEL,这两个操作不是原子的,极端情况下会出现校验成功但删不掉、验证码还能二次使用的问题。解法是用 Lua 脚本比较并删除:

if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end

5.2 计数器:登录失败次数、点赞数、库存扣减

登录防暴力破解是典型的计数器场景。每失败一次,对login:fail:username执行 INCR,然后设置过期时间比如 10 分钟。当计数达到阈值时,锁定该用户的登录操作。

库存扣减也很有讲究。如果是秒杀场景,直接用DECR扣减库存 key,扣减前用GET判断剩余量,但 GET 和 DECR 之间不是原子的,会超卖。正确做法是用 Lua 脚本保证剩余量的检查和扣减在同一原子操作里完成。

5.3 分布式锁的正确打开方式

前面已经说过基础的加锁、释放锁逻辑。但完整的分布式锁还需要考虑更多:

  • 锁的过期时间不能太短,否则业务还没执行完锁就自动过期了,其他线程趁机加锁,导致并发进入。一般建议为业务最大耗时再留一倍缓冲。
  • 锁的 value 必须有唯一性标识,释放时必须校验。
  • 业务执行完毕后主动释放锁,不要依赖过期时间兜底。

这个方案不是完美的,存在主从切换或者集群脑裂情况下的极小概率问题。不是所有业务都需要 Redlock 那种级别的强一致,大部分微服务场景下,单机 Redis 加锁已经足够用。

5.4 简单的访问限流

用 String 实现固定窗口限流的核心是计数 + 过期时间:

FUNCTION acquire_limit(key, max, window_seconds): count = INCR(key) IF count == 1: EXPIRE(key, window_seconds) IF count > max: RETURN reject RETURN accept

思路是:第一次请求进来时 INCR 后等于 1,顺手设置窗口期的过期时间。之后每次请求 INCR,如果超过 max 就拒绝。窗口过期后 key 自动删除,新请求重新计数。

这个方案有一个小瑕疵:窗口边界处可能产生双倍流量爆发,但绝大多数业务能接受。如果要求更平滑的限流,可以考虑令牌桶,但实现复杂度会上去。

5.5 用 String 做对象存储时的序列化选择

把对象塞进 String,通常有三种序列化方案:

  • JSON:可读性好,但开销大,字段多时膨胀明显。
  • MessagePack / ProtoBuf:体积小,但 debug 时别人看不懂。
  • JDK 原生序列化:千万别用,体积大且有安全漏洞风险。

我个人的建议是:如果只是临时存储中间状态,直接 JSON 最简单;如果对内存敏感且不差人力和时间,用二进制序列化方案。但要注意,一旦选择了某种序列化方式,后续升级字段结构时要考虑兼容性。

6. 内存管理:String 是最能吃内存的类型吗

6.1 为什么你还没存多少数据,内存就快满了

String 的内存占用比很多人想象的大得多。每个 key 本身要存字符串内容、字典条目、过期时间指针、数据类型头部等元数据。虽然 Redis 做了共享对象和内存池优化,但一个 key 的实际开销远不止你看到的 value 大小。

比如你存一个 key 叫a,value 是1,表面看起来只有 2 个字节,实际上 Redis 的内存分配器会为它分配 64 字节甚至更多的空间。这在 Redis 里叫“小对象内存分配”,为了对齐缓存行和后续扩容,多花了不少内存。

所以线上 Redis 内存占比超过物理内存的 60%~70%,就该做数据治理了。

6.2 大 key 和热 key 的排查

大 key 的定义通常是 value 超过 10KB 或者包含元素个数超多。大 key 的问题在于:读写耗时高、删除时阻塞线程、迁移时网络开销大。

排查方法:

redis-cli --bigkeys

它会扫描所有 key,按类型统计最大的 key。另外用DEBUG OBJECT key可以看到 key 的序列化长度。平时写代码时,也要主动对大对象做拆分——比如把一个用户的所有信息拆成多个 String key,或者退一步用 Hash 类型。

热 key 的排查相对难一点。如果 QPS 过高,Redis 单线程处理不过来,后面请求全部排队,表现为延迟陡增。常见解法是加本地缓存,而不是全量打 Redis。

6.3 批量过期对性能的影响

如果你某种类型的 key 设置了相同的过期时间,比如所有 session key 都设 3600 秒,它们大概率在同一时刻集中过期。Redis 的过期清理是惰性 + 定时结合的,大批量同时过期时,CPU 会被集中占满,造成延迟毛刺。

解决方式很简单:设置过期时间时加一点随机偏移量。比如基础 3600 秒,再加 0~60 秒的随机尾巴。

6.4 为什么 String 存储效率不及 Hash

同样是存 100 个用户的 name、age 等字段,用 String 存 300 个 key,和用 Hash 存 100 个 key(每个 key 带 3 个字段),内存差异可能达到数倍。原因在于 Hash 的 field 是内嵌在同一个对象里的,省去了大量 key 本身的元数据开销。

所以当你看到user:1001:name、user:1001:age这样前缀完全相同的 key 成群出现时,就该考虑换成 Hash 了。String 的灵活性是优点,但在这种场景里反倒是浪费。

7. 实战排查:常见问题与定位技巧

7.1 问题一:Redis 变慢了,怎么快速定位

先用redis-cli --latency看整体延迟。如果延迟高,再用redis-cli --stat看内存和命令数量。进一步用SLOWLOG GET拉取慢命令,看是不是有大 key 操作或者复杂命令。

有一次我们线上 Redis 突然卡顿,查了半天,原来是有人往 String 里塞了一幅 2MB 的 base64 图片,然后频繁读取。一复查代码,发现只是为了省事写个临时存储,结果成了性能瓶颈。

7.2 问题二:缓存穿透和缓存击穿在 String 场景下的表现

缓存穿透是指查一个不存在的数据,每次请求都穿过缓存打到数据库。解决办法是缓存空值(Null 值也缓存,并设置较短过期时间),或者用布隆过滤器拦截。

缓存击穿是指某个热点 key 过期瞬间,大量请求涌到数据库。解决办法:加互斥锁重建缓存,或者延长热点 key 的过期时间并用后台任务刷新。

这两个问题跟 String 类型没有直接关系,但 String 作为缓存载体时,实施方案时需要知道坑在哪。

7.3 问题三:并发下计数不准

我见过有项目用GET+SET组合来实现自增,然后出现丢数据的情况。原因很简单:不是原子的,两个线程同时 GET 到旧值,各自加一后 SET 回来,结果少加了一次。正确做法是直接 INCR,或者在 Lua 里完成读改写。

7.4 问题四:TTL 负数到底是什么意思

TTL返回正数,说明还有多久过期;返回-1,表示 key 永久有效;返回-2,表示 key 不存在。

三者一定要分清。有的人排查时看到-1就以为 key 丢了,其实只是没设置过期时间。排查缓存故障前,先确认数据到底有没有过期策略,再谈别的。

8. 最后聊几个我自己踩过坑之后的经验

用 String 这么多年,踩过最大的坑是对“简单”的低估。正因为 String 看起来简单,很多人就不去深究它的编码、内存、原子性、批量接口这些底层细节,等到线上出问题才回头看文档。

三个实操建议送给正在使用 Redis String 的你:

第一,Key 的命名要有分层设计。用冒号分隔业务模块、实体 ID、字段。比如order:123:status,这样一方面可读性好,另一方面也方便用SCAN按前缀做治理。

第二,能用批量命令就用批量命令。单条 SET 在网络上的开销比你想象的大,压测场景下尤为明显。

第三,给所有写入 String 的 key 设计好生命周期。该设置过期时间就设置,别默认不设置。内存不是无限的,尤其是 Redis 这种纯内存数据库,回收内存最可靠的方式就是过期自动清理。

String 类型虽然基础,但它是理解 Redis 整个体系的钥匙。把它的编码机制、原子操作、底层内存模型搞清楚,再去看 List、Hash、ZSet,很多设计思路都是相通的。

这一块内容我可以负责任地说,你花一小时认认真真读一遍,省下的绝对不止以后排查问题的那几天时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询