别急着去搜什么叫 “五大数据类型” 的面试八股如果你手上有个项目正在用 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 的部分 keyredis-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 ...可以一次性设置多个 keyMGET 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 最常见的应用场景之一。最原始的写法是用SETNXEXPIRE两个命令先尝试加锁再设置过期时间。但这两个命令不是原子的——如果加锁成功之后、设置过期时间之前进程崩了这个锁就永远释放不了。所以后来演进成直接用一条命令SET lock:order:123 ownerId NX EX 30这行命令的含义是只有当 key 不存在时写入成功并设置 30 秒过期。ownerId 是你自己的机器标识用来在释放锁时确认锁属于自己。释放锁也有讲究不能傻乎乎直接DEL。正确姿势是用 Lua 脚本或GETDELRedis 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 0GETDEL是 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 end5.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 叫avalue 是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 问题三并发下计数不准我见过有项目用GETSET组合来实现自增然后出现丢数据的情况。原因很简单不是原子的两个线程同时 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很多设计思路都是相通的。这一块内容我可以负责任地说你花一小时认认真真读一遍省下的绝对不止以后排查问题的那几天时间。
阅读完成 · 觉得有帮助?