
一篇文章想写清楚,先得说清楚它不写什么。这一篇只做一件事:站在「用 String 这个类型」的视角,把它讲透。—— 系列的上一站《Redis 入门》解决了「Redis 能用」,从这篇开始,我们解决「Redis 到底能存什么、每种结构最擅长什么、什么时候会翻车」。
今天我们站在业务开发视角,告诉你每个特性会带来什么影响、怎么选型、怎么算内存。底层 SDS 的 C 源码、内存分配器细节不在本篇展开,统一放到源码精读篇章。
String 是 Redis 最基础的类型,也是几乎所有项目里第一个被用上的类型。正因为它"看起来简单",反而成了被误用最严重的一个——编码被悄悄升级导致内存翻倍、一个大 Value 卡住整个实例、把本该用 Hash 的对象塞进 String 白白多花 3 倍内存。这些坑不是"不会用",而是"不知道有这回事"。
一、开篇导读1.1、String 在 Redis 体系中的定位
String 是 Redis 最基础、使用频率最高、被误解最深的类型,也是所有其他复杂类型的底层基石。它不是简单的字符串,底层封装 SDS 动态字符串,支持文本、数字、二进制流,是你接触 Redis 第一个类型,也是线上故障高发类型。Redis 的计数器、分布式 ID、Bitmap 位图、甚至分布式锁的基础原语,全部依托 String 实现。可以说,不懂 String 的底层编码和内存模型,就谈不上真正理解 Redis。——你把 String 吃透,等于吃透了 Redis 的半个江山。
更关键的是,String 是理解 Redis 设计哲学的入口——它藏着 Redis 最核心的两个底层构件:SDS(字符串底座)和 redisObject(对象外壳)。把 String 的编码讲清楚了,后面 Hash、List、Set、ZSet 的编码逻辑就都是同一套东西的变体。
1.2、开发者普遍存在的认知误区
但绝大多数人对 String 的认知停留在 SET k v / GET k。真正出问题的地方恰恰在这里:
- 一个本该走 int 编码的高频计数器,因为业务代码多拼了一个前缀,白白浪费了一整块编码优化的收益;
- 一个线上 Redis 内存翻倍,往往不是因为它复杂,而是因为一批 String 的 value 悄悄从 44 字节涨到了 45 字节,编码从 embstr 变成了 raw;
- 喜欢把整个大对象 JSON 直接塞 String,无上限控制,GET 时直接把 Redis 主线程卡住,不知不觉产出大 Key;
- 一个用 SETNX + EXPIRE 两步写的“分布式锁”,在两步之间宕机就留下一个永不释放的死锁 key;
- 有人把「读出来 + 加一 + 写回去」当成计数器,并发下必然丢数,然后反过来质疑「Redis 计数不准」;
- INCR/DECR 随便用,不清楚数值溢出边界,线上出现负数、异常数值;
今天我们将按照:命令全解 → 三种编码的边界 → 七类生产场景 → 序列化选型 → 内存账 → 与 Hash 的对比 → 坑点清单,这样的顺序先把“怎么用”讲清楚,再把“为什么这样设计”讲到够用,最后落到“生产里怎么不踩坑”。
二、核心命令全集 本节只讲命令用法、参数、边界;不涉及底层编码。String 的命令看着简单,但把每个命令的选项、边界、坑都讲清楚,篇幅就不小了。我按功能分成六组:基础读写、数值操作、字符串操作、批量操作、过期与删除,以及一个常被忽略的位操作和一个冷门操作。
2.1、基础读写
2.1.1、SET—— 一个命令顶过去五个
SET 是 String 最核心、最强大的命令之一,很多人只会 SET key value,忽略全套可选参数:EX / PX / EXAT / PXAT / NX / XX / GET。很多老命令(SETNX、SETEX、PSETEX)的能力都被它用选项吸收了:
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
- NX:只有 key 不存在时才设置,是分布式锁的基石——SET lock:order:1001 uuid NX EX 30;
- XX:只有 key 存在时才设置,常用于「更新已有缓存」;
- GET:返回 key 的旧值(Redis 6.2)。SET k v2 GET 相当于原子的 GETSET,但更灵活,还能一起带 EX/PX;
- EX / PX:设置过期时间,单位为秒 / 毫秒;
- EXAT / PXAT:设置过期的绝对时间戳(Redis 6.2),适合「活动在某个时刻统一失效」;
- KEEPTTL:保留 key 原有 TTL(Redis 6.0);

Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.1.2、SETNX / SETEX / PSETEX —— 历史命令
- SETNX key value # 等价于 SET key value NX
- SETEX key seconds value # 等价于 SET key value EX seconds
- PSETEX key ms value # 等价于 SET key value PX ms
这三个是老 API,现在更推荐用 SET 的选项组合,因为只有 SET 能把「设置值 + 设置过期 + 判存在」做成一个原子操作。分两步写 SETNX 再 EXPIRE,两步之间宕机就会留下永不释放的死锁 key——分布式锁最经典的反面教材。
2.1.3、GET / GETSET / GETDEL / GETEX —— GET 系列命令
Redis 提供了多个获取值的变体命令,以满足不同的并发和清理需求。
- GET key # 取值
- GETSET key value # 取旧值并设新值(原子,6.2 起标记废弃)
- GETDEL key # 取旧值并删除(Redis 6.2)
- GETEX key [EX s | PX ms | EXAT ts | PXAT ts | PERSIST] # 取值并可顺带改过期(6.2)
GETEX 有个很实用的场景:刷新滑动过期。想让 Session 每次访问后自动续期一小时,GETEX session:u1 EX 3600 一步搞定。GETDEL 则适合「一次性令牌」这类用完即焚的场景。

Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.2 数值操作
INCR、INCRBY、DECR、DECRBY、INCRBYFLOAT 用于对字符串表示的数字进行原子递增或递减。由于是原子操作,它们非常适合高并发下的计数场景,无需应用层加锁。
- INCR key # +1
- DECR key # -1
- INCRBY key increment # +N
- DECRBY key decrement # -N
- INCRBYFLOAT key increment # 浮点加法

INCR 家族是 String 最有价值的能力之一,因为它天生原子。多线程并发 INCR 不会有竞态问题,这是它相对「读出来 + 加一 + 写回去」的决定性优势。但有三个边界必须知道:
边界一:整数值上限是 64 位有符号整数。
值必须是合法 64 位有符号整数(范围:-263 ~ 263-1,即 -9223372036854775808 ~ 9223372036854775807),否则直接报错

所以用 INCR 做分布式 ID 一定要评估增速和生命周期,避免溢出。这也是为什么分布式 ID 更推荐「Redis 号段模式」而非单键自增。
边界二:INCR 的对象必须是「能解析成整数的字符串」。

这条看似简单,却是后面「int 编码」的判定依据。
边界三:浮点计数有精度问题。 INCRBYFLOAT 底层用 long double 运算,存在舍入误差,金额类场景绝对不要用它记钱——要么用「分」为单位的整数计数,要么在应用层用 decimal。
核心特性:操作是原子性的,单线程模型保证并发安全Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.3 字符串操作
用于截取字符串、尾部追加、局部覆盖写入,适合大文本分片、日志追加场景。
- APPEND key value # 追加,返回追加后的长度
- SETRANGE key offset value # 从 offset 开始覆写
- STRLEN key # 长度(字节数)
- GETRANGE key start end # 截取子串(支持负数,-1 表示末尾)

高频踩坑点:
- APPEND 对 int/embstr 编码的 key 执行会触发编码升级;
- STRLEN 返回字节长度,不是字符个数:UTF-8 中文单字占 3 字节,STRLEN "你好" 返回 6 而非 2,所以 STRLEN 一个纯中文串会得到「字数 × 3」。这是面试高频考点,也是业务里「限长校验」最容易算错的地方;
- SETRANGE 超大偏移会自动填充 \x00 空字节,瞬间撑大内存;
- GETRANGE 的负数索引:GETRANGE k 0 -1 取全串(等价 GET),GETRANGE k -3 -1 取最后 3 个字符。用它做「取前缀/取后缀」比在应用层切字符串省一次网络传输;
Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.4 批量操作
用于一次网络往返完成多个 key 的读写删,适合批量加载缓存、批量初始化配置、批量清理临时 key 等场景。核心收益是减少 RTT;
- MSET key value [key value ...] # 批量设置多个键值
- MGET key [key ...] # 批量获取多个键值
- DEL key [key ...] # 批量删除多个键(Redis 没有 MDEL 命令)

高频踩坑点:
- Cluster 下 MSET/MGET 跨 slot 报错:所有 key 必须落在同一 slot。用 Hash Tag 把相关 key 绑到同一 slot,例如 MSET {user:1001}:name Alice {user:1001}:age 30;注意 Hash Tag 只取第一对 {} 中的内容;
- MSET 会清除 TTL:MSET 不支持 KEEPTTL。如果更新缓存时必须保留过期时间,应改用 Pipeline 发送多个 SET key value KEEPTTL,或重新设置过期时间;
Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.5 过期与删除
用于给 key 设置、查看、移除过期时间,以及删除 key。核心目标是控制内存生命周期,并避免删除大 Value 时阻塞主线程。注意:EXPIRE 只对已存在的 key 生效;SET 覆盖写会清除原 TTL;大 Value 删除一律用 UNLINK。
- EXPIRE key seconds # 设置过期时间(秒)
- PEXPIRE key milliseconds # 设置过期时间(毫秒)
- TTL key # 查看剩余生存时间(秒)
- PERSIST key # 移除过期时间,变为持久化
- DEL key [key ...] # 同步删除
- UNLINK key [key ...] # 异步删除(4.0+,大 Value 推荐)

高频踩坑点:
- EXPIRE 会覆盖原 TTL:默认直接改掉剩余时间;需要“只延长/只缩短”时用 EXPIRE key seconds GT/LT(7.0+),避免误改;
- TTL 的 -1 / -2 要分清:-1 是“存在但无过期时间”,-2 是“key 不存在”。排查僵尸 key 时不要只看 TTL == -1 就以为 key 一定存在;
- 过期不是“到点立刻删除”:Redis 采用惰性删除 + 定期删除,TTL 到期后内存可能不会立刻释放;大量 key 同一时刻过期还可能造成缓存雪崩,TTL 应加随机抖动;
Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.6 位操作
位操作是 String 最被低估的部分,它让你把 1 个字节当 8 个布尔位来用,内存利用率提升 8 倍。适合签到、活跃用户、布尔状态标记、布隆过滤器、在线状态等“只有 0/1”的场景。
- SETBIT key offset value # 设置某一位为 0/1
- GETBIT key offset # 读取某一位
- BITCOUNT key [start end [BYTE|BIT]] # 统计 1 的个数
- BITPOS key bit [start [end [BYTE|BIT]]] # 第一个为 bit 的位的位置
- BITOP AND|OR|XOR|NOT destkey key [key ...] # 位运算
- BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset incr] [OVERFLOW WRAP|SAT|FAIL] # 位域读写

高频踩坑点:
- BITCOUNT / BITPOS 的 start/end 默认是字节索引,不是位索引:BITCOUNT k 0 0 统计的是第 1 个字节(8 位),不是第 1 位。7.0+ 要按位统计必须显式加 BIT:BITCOUNT k 0 7 BIT;
- 位序是 MSB first:Redis 位图按高位到低位编号,offset 0 是字节最高位。自己拼二进制协议或和外部系统对接时,位序不一致会全部错位;
- BITFIELD 溢出策略默认是 WRAP:INCRBY u8 从 255 再加 1 会回绕成 0,不报错。金额、计数类场景应显式用 OVERFLOW SAT(饱和)或 OVERFLOW FAIL(报错);
- 位图不是“无限长”:单个 String 最大 512MB,位图最多约 42.9 亿位;超大规模用户要分片,如按年、按月、按用户段拆 key;
Redis 命令行示例:

C# (StackExchange.Redis) 代码示例:

2.7 冷门但有用的 LCS(7.0+)
LCS(最长公共子序列,Redis 7.0)。 一个很特别的字符串命令:
- LCS key1 key2 # 返回两个值的最长公共子序列
- LCS key1 key2 LEN# 只返回长度
- LCS key1 key2 IDX [MINMATCHLEN 4] [WITHMATCHLEN] # 返回匹配块的位置信息

它看着冷门,实际有两个实用场景:文章/代码相似度与 diff(公共子序列越长越相似,可做内容查重、版本差异高亮);字符串相似度打分(LCS ... LEN 结果除以总长度得到粗略相似度,注意它衡量的是子序列不是子串)。
唯一要小心:LCS 时间复杂度 O(N×M),两个大 value 相比会阻塞主线程。生产用它先把 value 控制在几十 KB 以内,或者移到离线任务。
2.8 命令速查表
String的九类命令覆盖了 Redis 中最基础的数据结构:读写、判存在、过期、数值、字符串、位、子序列——前八类是日常,第九类慎用。

2.9 .NET API 对照与坑(StackExchange.Redis)
命令和客户端 API 对不上号,是新手最容易卡住的地方。这张表把常用命令和 StackExchange.Redis 对上:

StackExchange.Redis 操作 String 的坑点汇总
StackExchange.Redis 统一用 StringSet / StringGet 操作 String。
① RedisValue 的隐式转换会抛异常
- (long)val 在 val 是空值时直接抛异常。
- 判断值是否存在要用 val.HasValue,而不是拿它跟 null 比。
② StringSet 的重载顺序极易写错
- StringSet(key, value, expiry, When.NotExists) 四个参数位置固定。
- 把 expiry 和 When 写反不一定报错,但语义完全变了。
- 建议自己封装一层带命名参数的方法。
③ StringIncrement 只支持数字类型的 value
- 非数字直接抛异常;
- 调用前需确保值类型正确,不要假设写入的值一定是数字。
3.1 一句话说清 SDS
Redis 自研了一个叫 SDS(Simple Dynamic String,简单动态字符串) 的结构来存字符串。它的核心特征只有一个:把「长度」显式记录在字符串头部,让「长度」和「内容」分开保存。以此来解决三个问题:二进制安全(不依赖 \0 结尾)、O(1) 获取长度(记录 len 字段)、减少内存重分配(预分配 + 惰性释放)。
就这一句话,它带来了四个你每天都会碰到的后果。下面三节逐一讲。
3.2 影响一:二进制安全
C 字符串以 \0 结尾,无法存储包含 \0 的二进制数据(图片、序列化对象、压缩数据里极其常见)。SDS 靠头部的长度字段判断结束,所以可以安全存储任意二进制数据。
对你意味着什么:你可以放心地将图片字节流、Protobuf 序列化报文、加密密文存入 String 类型,不会被 \0 截断。这对二进制序列化方案(Protobuf/MessagePack)至关重要。
3.3 影响二:O(1) 长度获取
C 字符串获取长度需要遍历整个字符串(O(N)),SDS 直接读取 len 字段(O(1))。
C 字符串求长度要遍历到 \0,是 O(N)。Redis 是单线程模型,一个 STRLEN 若真去遍历几 MB 的 value,会阻塞整个实例。SDS 的长度是头里存的,取长度直接读字段,O(1)。
对你的影响:STRLEN、以及很多内部逻辑(如判断是否超过 proto-max-bulk-len)都是常数时间,你不会因为「查个长度」把 Redis 卡住。但反过来也提醒你:真正会阻塞的是「拿大 value」本身,比如 GET 一个几 MB 的 String——那是传输和拷贝的成本,不是 SDS 的锅。对你意味着什么:STRLEN 命令是 O(1) 的,可以放心在循环或高频接口中使用,不用担心性能瓶颈。
3.4 影响三:内存预分配与惰性释放
- 内存预分配:字符串增长时,SDS 分配比需求更多的空间(小于 1MB 时翻倍,大于 1MB 时多分配 1MB),减少重分配次数;
- 惰性释放:字符串缩短时,不立即释放多余内存,更新 len 即可,为未来增长预留空间;
对你意味着什么:频繁拼接字符串(如日志、大 JSON)时性能更好;但短期内存占用可能略高于实际需求(惰性释放的代价)。
3.5 编码转换边界(int / embstr / raw) 与「44 字节」的来历
一句话结论:Redis 对外只有 String 一种类型,但内部会根据 value 的形态自动选择 int / embstr / raw 三种编码,目标是尽量节约内存。
所有编码只能单向升级,不会自动降级。
查看编码:OBJECT ENCODING key。
先理解 redisObject:每个 value 都有对象头
Redis 中任何 value 都不是裸数据,外面都套了一层 redisObject:
type + encoding + lru + refcount + ptr
在 64 位系统上,这层对象头固定占 16 字节。
影响:哪怕你存的是 1,也要额外付出 16 字节对象头。所以「海量小 key」通常比「少量大 key」更吃内存。三种编码对比

可概括为:
int 不额外分配,embstr 一次分配,raw 两次分配。示例:

「44 字节」是怎么来的?
44 不是拍脑袋,而是从内存分配器倒推出来的。
Redis 默认使用 jemalloc,它按 size class 分桶,例如:
8、16、32、48、64、80、96、112……
申请内存时,实际拿到的是向上取整到最近桶的大小,而 64 字节是极常用的一个桶。
embstr 想把对象头和 SDS 塞进一次 64 字节分配里,于是:
对象头 16 + SDS 头部 3 + 数据长度 len + 结尾 \0 1 ≤ 64
→ len ≤ 64 - 16 - 3 - 1 = 44
超过 44 后:
16 + 3 + len + 1 > 64
会跳到 80 甚至更大的桶,一次分配的优势不再明显,Redis 干脆改用 raw 做两次分配。
这里的「SDS 头部 3 字节」对应 ≤ 255 字节档的 sdshdr8 头部。头部结构和推导细节(后面章节会详细讲解)。为什么老文章说 39,新文章说 44?
这是版本差异:
- Redis 3.2 之前:SDS 头固定 8 字节,代入公式:
64 - 16 - 8 - 1 = 39
所以老文章说阈值是 39。
- Redis 3.2 之后:引入分级 SDS 头,短字符串只用 3 字节头,阈值变为:
64 - 16 - 3 - 1 = 44
所以 3.2 之后以 44 为准。
44 不是普适真理
44 依赖以下前提:
- 使用 jemalloc;
- 使用 64 字节 size class;
- 64 位系统下对象头为 16 字节。
如果换了分配器,比如 libc malloc,或者换成 32 位系统(指针只有 4 字节,对象头更小),边界都会变化。
另外,44 是 Redis 3.2 之后源码中的常量,运行期不可通过配置调整;只有修改源码并重新编译才可能改变。业务上按 44 字节作为判断标准即可。
编码选择流程
同一个 SET 命令,Redis 会按“先看内容、再看长度”的顺序做两次判断,决定用 int / embstr / raw 哪种编码。

修改操作会触发编码升级
编码升级是单向的:只会从省内存的编码掉到费内存的编码,永远不会自己变回来。

关键结论与排查建议
- Redis String 内部有 int / embstr / raw 三种编码;
- int:合法 64 位整数,不创建 SDS,最省内存;
- embstr:非数字且 ≤ 44 字节,一次内存分配,缓存友好;
- raw:非数字且 > 44 字节,两次内存分配,支持任意修改;
- 44 来自:64 - 16 - 3 - 1 = 44;
- 3.2 之前是 39,3.2 之后是 44;
- 编码只升不降:int → raw、embstr → raw,raw 永不降级;
- 44 依赖 jemalloc + 64 字节 size class + 64 位对象头,不是普适真理;
- 排查内存异常时,用 OBJECT ENCODING key 抽查短字符串是否意外变成 raw;
3.6 亲手验证(redis-cli 脚本)

编码选择对你的实际影响:
不是“记住三种编码”,而是“知道什么场景该让它停在哪种编码”——选错了,内存就是白花的。

4.1 场景一:通用业务缓存
需求:将用户信息缓存到 Redis,减少数据库查询压力。
方案:将对象序列化为 JSON 字符串,存入 String,设置过期时间。
Redis 命令:

C# (StackExchange.Redis) 代码:

边界:
- 更新单个字段必须全量覆盖,并发更新容易出现数据覆盖
- 适合一次性读取整个对象、极少单独修改字段的场景
- 完整缓存架构(Cache-Aside/Read-Through)(后面章节会详细讲解)
4.2 场景二:原子计数器
需求:实现文章浏览量、点赞数等高并发计数器。
方案:直接使用 INCR 命令,天然原子无锁,支撑超高并发。
Redis 命令:

C# (StackExchange.Redis) 代码:

边界约束:
- INCR 仅支持合法 64 位有符号整数(最大值:9223372036854775807),超过上限直接报错
- 超大流量计数器需要做分片拆分(如 article:view:123:shard1 / article:view:123:shard2)
- INCR + EXPIRE 非原子问题:如果 INCR 成功但 EXPIRE 失败(如 Redis 宕机),key 永远不会过期。解决方案(后面章节会详细讲解)(Lua 脚本保证原子性)
4.3 场景三:分布式 ID 生成器
需求:在分布式系统中生成全局唯一、单调递增的业务 ID(如订单号)。
方案一:Redis INCR 模式
适用于单节点或简单集群,通过 INCR 生成全局唯一递增 ID。

C# 代码:

局限:集群环境下跨槽无法保证全局连续,仅能保证单调递增。
方案二:号段模式
从 DB 批量获取 ID 段缓存到 Redis,应用内存中自行分配,减少 Redis 调用次数。

方案三:雪花算法 Snowflake
对于高可用和更高性能需求,通常使用雪花算法。Snowflake 的 C# 实现通常不依赖 Redis,而是依赖机器 ID 和时间戳。若需 Redis 协调机器 ID,可结合 INCR 分配。
4.4 场景四:缓存穿透防护
需求:防止恶意请求查询不存在的数据,打穿缓存直接打到数据库。
方案:空值缓存
当查询数据库返回空时,将空值(如空字符串)写入 Redis 并设置较短 TTL,防止恶意请求打穿到 DB。
C# (StackExchange.Redis) 代码:

边界:
- 空值 TTL 不宜过长(建议 30~120 秒),避免数据恢复后缓存脏数据
- 更彻底的穿透防护方案(布隆过滤器)(后面章节会详细讲解)
- 完整缓存架构(击穿/雪崩防护)(后面章节会详细讲解)
4.5 场景五:固定窗口限流
需求:限制某个接口在单位时间内的调用次数。
方案:利用过期 + 自增实现简单限流。
Redis 命令:

C# 代码:

短板:存在临界窗口击穿问题(上一秒剩余配额 + 下一秒新配额可能瞬间翻倍)。高级滑动窗口限流(Lua 实现)(后面章节会详细讲解)。
五、序列化 / 存储形态选择把业务对象存入 String,序列化方案直接决定内存占用与吞吐性能。下面对比三种主流方案。
5.1 三种方案对比
三种序列化格式的核心区别不在“谁更先进”,而在可读性 / 体积 / 速度 / Schema 成本四者怎么取舍。

5.2 选型建议
- 开发/调试阶段:首选 JSON,可读性强,调试方便,.NET 原生支持(System.Text.Json);
- 生产环境海量对象缓存:首选 MessagePack 或 Protobuf,平衡开发效率与内存开销;
- 跨语言服务间通信:首选 Protobuf,压缩率最高、序列化速度最快;
5.3 实测对比参考
10个字段对象实测:Protobuf 体积是 JSON 的 1/3、耗时是 JSON 的 1/5——但 Schema 成本也是它带来的。

6.1 单个 Key 的完整构成
总内存 ≈ dictEntry(24B) + redisObject(16B) + 编码header(3~9B) + 实际值长度 + 对齐填充
6.2 刚性开销
刚性开销 = 与值内容无关的固定成本。同样存 1 个字节,int 编码只花 56 字节,raw 花 90 字节 + 1 字节——差了将近一倍。

6.3 估算公式
简化估算公式:
单 Key 总内存 = dictEntry(24B) + redisObject(16B) + SDS_header + 值长度 + 对齐填充
其中:
- dictEntry:字典条目,固定 24 字节
- redisObject:对象头,固定 16 字节
- SDS_header:根据编码类型不同(int 为 0,embstr 为 sdshdr8 = 3B,raw 为 sdshdr8/16/32/64 = 3~11B)
- 对齐填充:jemalloc 内存对齐,通常 8 字节对齐
6.4 完整演算(含百万 Key / 亿级用户测算)
把“单 Key 省几十字节”放大到百万、亿级:同样的数据,编码选错就是几百 MB 到几 GB 的差距。

6.5 5 条 String 内存优化技巧
- 尽量使用 int 编码:ID、时间戳等纯数字不要加引号或前缀
- 控制 embstr 边界:尽量让常用短字符串保持在 44 字节以内
- 避免大 Key:单个 String 超过 10KB 就要警惕,考虑拆分或压缩
- 使用压缩:对于大文本,应用层压缩(如 GZip)后再存入,但需权衡 CPU
- 及时清理:合理设置 TTL,避免僵尸 Key 占用内存
这是面试和实战中最常被问到的问题:什么时候用 String,什么时候用 Hash?
7.1 方案对比
String(JSON) vs String(分 key) vs Hash 对比
同一个对象怎么存?三种方式的核心差异在于:能否只改一个字段、能否单字段自增、每个对象吃多少内存。

7.2 内存占用对比(反直觉结论)
小对象用 Hash 反而更费内存。因为 Hash 的每个字段都需要独立的 redisObject 和 SDS 结构,还有 Hash 表本身的开销。只有当对象字段较多且 Hash 使用 ziplist/listpack 编码时,Hash 才可能比 JSON String 省内存。
中等对象(5~10 字段)内存对比:

7.3 选型决策(直接落地)
把上文的维度对比落到业务:看“怎么读写”和“要不要单字段操作”,两个问题就能定方案。

7.4 混合方案代码示例

8.1、大 Key 与阻塞类
1. 大 Key / 超大 JSON 存入 String 现象:GET 耗时突增、慢查询、集群迁移卡顿、RDB fork 放大。 根因:单个 value 几十 KB 甚至 MB,网络传输、拷贝、释放占用单线程。 规避:用 --bigkeys 排查;拆分对象、压缩或换 Hash/其他存储;控制 value <10KB;删除用 UNLINK。
2. DEL 大 Key 同步阻塞 现象:删除时实例卡顿。 根因:DEL 同步释放大对象。 规避:改用 UNLINK 异步删除。
3. LCS 大 value 阻塞 现象:执行 LCS 后 Redis 卡顿。 根因:LCS 复杂度 O(N×M)。 规避:value 控制在几十 KB 内,或移到离线任务。
4. 生产慎用 KEYS 现象:执行 KEYS user:* 后实例卡死。 根因:KEYS 是 O(N) 全量扫描。 规避:用 SCAN 渐进扫描;大 Key 用 --bigkeys。
5. MGET 一次携带上千 key 现象:RTT 飙升、影响其他命令。 根因:MGET 是 O(n),命令包过大占用单线程。 规避:单批建议 ≤50,最多不超过 200;分批读取或用 Pipeline。
6. Bitmap / SETRANGE 超大 offset 现象:单 key 内存瞬间暴涨、长度异常。 根因:offset 过大自动填充 \x00 空字节。 规避:用户 ID 做映射压缩;不要直接大偏移写入;写入前校验 offset。
8.2、编码与内存类
1. 数字带前缀导致 int 编码失效 现象:高频计数器内存偏高。 根因:value 存成 "incr:1" 这类非纯数字,无法走 int 编码。 规避:需要 INCR 的 key 只用纯数字做 value,不要拼前缀。
2. embstr 被修改后转 raw 现象:短字符串一次 APPEND 后 MEMORY USAGE 明显变大。 根因:embstr 只读,APPEND/SETRANGE 强制升级 raw,不可逆。 规避:静态配置、短值一次性写入;避免先 SET 再拼接或局部修改。
3. 编码只升不降 现象:内存持续增长。 根因:int/embstr 一旦变 raw 不会回落。 规避:用 OBJECT ENCODING 定期抽查;控制值长度,避免频繁修改短字符串。
4. STRLEN 返回字节数不是字符数 现象:中文限长校验算错。 根因:UTF-8 汉字通常占 3 字节。 规避:按字节设计限制,或应用层按字符数校验;注意 STRLEN 没有 COUNT 参数。
5. MEMORY USAGE 不是真实占用 现象:按它乘 key 数估容量,上线后内存翻倍。 根因:不含 jemalloc 对齐和 bucket 开销。 规避:看 INFO memory 的 used_memory 增量评估。
6. JSON 字段名重复导致序列化冗余 现象:内存占用过高。 根因:JSON 字段名重复存储。 规避:高频场景用 MessagePack/Protobuf 等紧凑协议。
7. 序列化方案不一致 / CLR 对象直接写入 现象:读出来乱码、反序列化失败,或存入类型名字符串。 根因:不同服务用不同序列化器;SDK 不自动序列化复杂 CLR 对象。 规避:统一序列化协议;加版本前缀;手动 JSON/MessagePack 序列化。
8. 空值缓存 TTL 太长 现象:数据已落库但用户一直看到“不存在”。 根因:用空值挡穿透时 TTL 给得太久。 规避:空值 TTL 控制在 1~5 分钟。
8.3、计数器与数值类
1. INCR 遇到非数字报错 现象:INCR 返回 ERR,且可能永久报错。 根因:key 被误写成非数字字符串。 规避:业务层保证计数器 key 不被 SET 覆盖;写入前校验类型。
2. INCR 数值越界 现象:计数器很高后 INCR 直接报错。 根因:Redis int 编码是有符号 64 位 Long,有上下限。 规避:提前评估量级;计数器分片;定期重置统计。
3. INCRBYFLOAT 浮点精度 现象:金额累计对不上。 根因:浮点运算存在精度误差。 规避:金额用整数“分”计数,或用 decimal 方案。
4. GETSET 误用 现象:key 不存在时返回 nil 却已写入新值,逻辑埋雷。 根因:GETSET 语义容易被误读。 规避:新代码优先用 SET ... GET。
8.4、TTL 与原子性类
1. SET 覆盖清除 TTL 现象:Session 永不过期、内存缓慢上涨。 根因:覆盖写没带 KEEPTTL。 规避:刷新缓存统一带 KEEPTTL,或用 GETEX 续期、重新设过期。
2. SETNX + EXPIRE 非原子 现象:中间崩溃产生永不过期死锁。 根因:加锁和设过期是两条命令。 规避:一律用 SET key value NX EX seconds。
3. INCR + EXPIRE 非原子 现象:计数 key 可能永不过期。 根因:自增和设过期不是原子操作。 规避:用 EXPIRE NX、Lua 脚本或事务保证原子。
4. 缓存 key 忘设过期时间 现象:“僵尸 key”慢慢吃满内存。 根因:缓存 key 忘记设 TTL。 规避:所有缓存 key 必须带 EX/PX;定期扫描 TTL == -1。
5. 普通 SET 无条件覆盖 现象:并发下旧值被意外覆盖。 根因:SET 默认无条件覆盖。 规避:写新 key 用 SET NX,更新已有 key 用 SET XX/KEEPTTL。
8.5、字符串操作类
1. SETRANGE 自动补零字节 现象:字符串中间出现不可见 \x00,长度异常、内容乱码。 根因:offset 超过当前长度,中间自动填充空字节。 规避:写入前校验 offset;远距离偏移不要用 SETRANGE,可改用 APPEND。
2. Bitmap 超大 offset 现象:单 key 内存瞬间暴涨。 根因:offset 过大自动填充空字节。 规避:用户 ID 做映射压缩,不做直接偏移。
说明:本条与“一、大 Key 与阻塞类”第 6 条有重叠,分别从“大 Key/阻塞”和“字符串操作”角度保留,便于排查时对照。8.6、批量、命令与运维类
1. MGET 命令包过大 现象:Redis 响应变慢、阻塞主线程。 根因:一次性携带上百个 key,O(n) 占用单线程。 规避:单批建议 ≤50,最多不超过 200;分批或用 Pipeline。
2. 删除大 Key 用 DEL 现象:删除时主线程卡顿。 根因:DEL 同步释放。 规避:用 UNLINK;.NET 可用 db.ExecuteAsync("UNLINK", key)。
3. 生产使用 KEYS 现象:实例卡死。 根因:O(N) 全量扫描。 规避:用 SCAN 替代。
4. LCS 大 value 现象:实例卡顿。 根因:O(N×M)。 规避:限制 value 大小,或离线执行。
5. MEMORY USAGE 估容量 现象:容量评估偏低。 根因:不含 jemalloc 对齐与 bucket 开销。 规避:用 INFO memory 的 used_memory 增量。
九、本篇小结作为「Redis 数据类型全解」系列的第一篇,本文围绕 String 类型展开了深度剖析,带你从入门走向精通:
- 命令进阶:掌握了 SET 的 EX/NX/XX/KEEPTTL 参数、GETEX/GETDEL 系列、INCR 原子计数器、MSET/MGET 批量操作等进阶用法;
- SDS 原理:深入理解了 SDS 数据结构的设计哲学——二进制安全、O(1) 长度获取、内存预分配与惰性释放;
- 编码转换:搞清楚了 int/embstr/raw 三种编码的触发条件和转换边界,牢记"embstr 不可变、转换不可逆"的关键结论;
- 实战场景:掌握了计数器、分布式 ID(INCR/号段/雪花算法)、缓存穿透防护(空值缓存)、固定窗口限流等经典场景的 C# 实现;
- 序列化选型:对比了 JSON / Protobuf / MessagePack 三种方案的优劣和适用场景;
- 内存估算:学会了使用 MEMORY USAGE 命令以及不同编码的内存占用分析;
- String vs Hash:从内存、性能、更新模式等维度,对比了两种存储方案的选型策略;
- 生产坑点:总结了 SET 覆盖、编码转换、大 Key、Bitmap 偏移等 10 个常见陷阱;
String 的正确打开方式:简单值用 String,对象看场景选 String 或 Hash,大 Value 要拆分,过期时间必须设。















