一篇文章想写清楚,先得说清楚它不写什么 。这一篇只做一件事:站在「用 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);

参数能组合,别只记单个: 分布式锁的标准写法是 SET key val NX PX 30000------NX 保证只有一个人拿到锁,PX 30000 保证锁 30 秒后自动释放,两者缺一都不是可靠的锁。反过来,EX 与 PX 互斥、EXAT 与 PXAT 互斥,KEEPTTL 不能与任何过期参数同时出现------组合前先确认兼容性。
Redis 命令行示例:
redis
# TTL = 3600
SET session:u1 "data" EX 3600
# 坑!TTL 被清空,变成永不过期
SET session:u1 "newdata"
# 正确做法,TTL 保留
SET session:u1 "newdata" KEEPTTL
这里有个必须记住的坑 :对一个已有 TTL 的 key 执行默认
SET,会清除它的过期时间 ;这个坑极小,却极常踩。很多人排查「Session 为什么永不过期」排查半天,最后发现是某处
SET覆盖时没带KEEPTTL。
C# (StackExchange.Redis) 代码示例:
csharp
var db = redis.GetDatabase();
// 设置值,30秒过期,NX 模式(仅不存在时写入)
bool success = await db.StringSetAsync(
"session:user:1001",
"token_abc",
TimeSpan.FromSeconds(30),
When.NotExists);
// 更新值,保留原有 TTL
await db.StringSetAsync(
"session:user:1001",
"token_new",
keepTtl: true);
// 仅在键已存在时更新
await db.StringSetAsync(
"user:1001:age",
"26",
when: When.Exists);
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 则适合「一次性令牌」这类用完即焚的场景。

怎么选: 只取值用 GET;取值 + 换值 要原子完成用 GETSET(新版本推荐 SET key value GET);取值 + 删除 防重复消费用 GETDEL;取值 + 续期 做滑动 Session 用 GETEX。四个命令都是原子操作,在并发场景下不要用"GET 之后再 SET / DEL / EXPIRE"的写法替代------那会引入竞态窗口。
Redis 命令行示例:
# 获取值并将过期时间重置为 60 秒
GETEX session:user:1001 EX 60
# 获取一次性验证码并删除
GETDEL verify:code:1001
# 获取旧值并设置新值(原子操作)
GETSET counter:login:1001 "0"
C# (StackExchange.Redis) 代码示例:
csharp
// GETEX:获取并续期
var value = await db.StringGetAsync("session:user:1001");
if (value.HasValue)
{
await db.StringSetAsync("session:user:1001", value, TimeSpan.FromSeconds(60));
}
// GETDEL:获取并删除
var token = await db.StringGetDeleteAsync("verify:code:1001");
2.2 数值操作
INCR、INCRBY、DECR、DECRBY、INCRBYFLOAT 用于对字符串表示的数字进行原子递增或递减。由于是原子操作,它们非常适合高并发下的计数场景,无需应用层加锁。
- INCR key # +1
- DECR key # -1
- INCRBY key increment # +N
- DECRBY key decrement # -N
- INCRBYFLOAT key increment # 浮点加法

选命令看三件事: 方向(加还是减)、步长(1 还是指定值)、类型(整数还是浮点)。五个命令都是原子的 ,并发下直接算就行,不用先 GET 再 SET。
⚠ 金额场景慎用 INCRBYFLOAT:二进制浮点会引入精度误差,涉及真实金额时建议改用「整数分」计数(存 999 表示 9.99 元),或把金额计算放到业务层用 decimal / BigDecimal 处理。
INCR 家族是 String 最有价值的能力之一,因为它天生原子 。多线程并发 INCR 不会有竞态问题,这是它相对「读出来 + 加一 + 写回去」的决定性优势。但有三个边界必须知道:
边界一:整数值上限是 64 位有符号整数。
值必须是合法 64 位有符号整数(范围:-2^63 ~ 2^63-1,即 -9223372036854775808 ~ 9223372036854775807),否则直接报错
redis
SET counter 9223372036854775807 # long 最大值
INCR counter # (error) ERR increment or decrement would overflow
所以用 INCR 做分布式 ID 一定要评估增速和生命周期,避免溢出。这也是为什么分布式 ID 更推荐「Redis 号段模式」而非单键自增。
边界二: INCR 的对象必须是「能解析成整数的字符串」。
redis
SET k "10" → INCR k → 11 ✅
SET k "10.5" → INCR k → (error) ERR value is not an integer ❌
SET k "abc" → INCR k → (error) ❌
这条看似简单,却是后面「int 编码」的判定依据。
边界三:浮点计数有精度问题。 INCRBYFLOAT 底层用 long double 运算,存在舍入误差,金额类场景绝对不要用它记钱------要么用「分」为单位的整数计数,要么在应用层用 decimal。
核心特性:操作是原子性的,单线程模型保证并发安全
Redis 命令行示例:
# 文章浏览量 +1
INCR article:view:123
# 点赞数 +1
INCR article:like:123
# 限流计数(配合 EXPIRE)
INCR rate_limit:api:login
EXPIRE rate_limit:api:login 60
C# (StackExchange.Redis) 代码示例:
csharp
// 原子递增并获取新值
long count = await db.StringIncrementAsync("article:view:123");
// 限流场景:首次触发时设置过期时间
long rateCount = await db.StringIncrementAsync("rate_limit:api:login");
if (rateCount == 1)
{
await db.KeyExpireAsync("rate_limit:api:login", TimeSpan.FromSeconds(60));
}
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 命令行示例:
redis
# 追加日志
APPEND log:app "2024-10-29 Error occurred\n"
# 获取字符串长度(注意是字节数)
STRLEN user:bio
# 批量获取
MGET user:1001 user:1002 user:1003
C# (StackExchange.Redis) 代码示例:
csharp
// 追加字符串
await db.StringAppendAsync("log:app", "2024-10-29 Error occurred\n");
// 批量获取
var values = await db.StringGetAsync(new RedisKey[] { "user:1001", "user:1002", "user:1003" });
// 获取字符串字节长度
long byteLength = await db.StringLengthAsync("user:bio");
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 命令行示例:
redis
# 批量设置
MSET user:1001:name "Alice" user:1001:age "30"
# 批量获取
MGET user:1001:name user:1001:age user:1002:name
# 批量删除(不是 MDEL)
DEL temp:1 temp:2 temp:3
# Cluster 下用 Hash Tag 保证同 slot
MSET {user:1001}:name "Alice" {user:1001}:age "30"
MGET {user:1001}:name {user:1001}:age
C# (StackExchange.Redis) 代码示例:
csharp
using StackExchange.Redis;
using System.Collections.Generic;
// 批量设置
await db.StringSetAsync(new KeyValuePair<RedisKey, RedisValue>[]
{
new("user:1001:name", "Alice"),
new("user:1001:age", "30")
});
// 批量获取
RedisValue[] values = await db.StringGetAsync(new RedisKey[]
{
"user:1001:name",
"user:1001:age",
"user:1002:name"
});
// 批量删除
long deleted = await db.KeyDeleteAsync(new RedisKey[]
{
"temp:1",
"temp:2",
"temp:3"
});
// Cluster 下用 Hash Tag 保证同 slot
await db.StringSetAsync(new KeyValuePair<RedisKey, RedisValue>[]
{
new("{user:1001}:name", "Alice"),
new("{user:1001}:age", "30")
});
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 推荐)

DEL vs UNLINK :删除一个大 String(比如 10MB),
DEL会同步释放内存,阻塞主线程;UNLINK只是标记删除,后台线程异步回收。大 Value 删除一律用UNLINK。
高频踩坑点:
EXPIRE会覆盖原 TTL :默认直接改掉剩余时间;需要"只延长/只缩短"时用EXPIRE key seconds GT/LT(7.0+),避免误改;TTL的-1/-2要分清 :-1是"存在但无过期时间",-2是"key 不存在"。排查僵尸 key 时不要只看TTL == -1就以为 key 一定存在;- 过期不是"到点立刻删除":Redis 采用惰性删除 + 定期删除,TTL 到期后内存可能不会立刻释放;大量 key 同一时刻过期还可能造成缓存雪崩,TTL 应加随机抖动;
Redis 命令行示例:
bash
# 设置 30 分钟过期
EXPIRE session:123 1800
# 查看剩余 TTL(-2 不存在,-1 无过期时间)
TTL session:123
# 移除过期时间,变为永久 key
PERSIST session:123
# 批量删除小 key
DEL temp:1 temp:2 temp:3
# 删除大 Value,避免阻塞主线程
UNLINK big:key
# 只延长 TTL,不缩短(Redis 7.0+)
EXPIRE session:123 3600 GT
C# (StackExchange.Redis) 代码示例:
csharp
using StackExchange.Redis;
using System;
// 设置过期时间(30 分钟)
bool expireSet = await db.KeyExpireAsync("session:123", TimeSpan.FromMinutes(30));
// 查看剩余 TTL:null 表示 key 不存在或没有过期时间
TimeSpan? ttl = await db.KeyTimeToLiveAsync("session:123");
// 区分"不存在"和"无 TTL"
bool exists = await db.KeyExistsAsync("session:123");
if (exists && ttl == null)
{
// key 存在但没有过期时间
}
// 移除过期时间,变为持久化
bool persisted = await db.KeyPersistAsync("session:123");
// 删除小 key
bool deleted = await db.KeyDeleteAsync("temp:1");
// 批量删除(注意 Cluster 下需同 slot)
long deletedCount = await db.KeyDeleteAsync(new RedisKey[]
{
"temp:1",
"temp:2",
"temp:3"
});
// 删除大 Value:用 UNLINK 异步删除
await db.ExecuteAsync("UNLINK", "big:key");
2.6 位操作
位操作是 String 最被低估的部分,它让你把 1 个字节当 8 个布尔位来用,内存利用率提升 8 倍。适合签到、活跃用户、布尔状态标记、布隆过滤器、在线状态等"只有 0/1"的场景。
SETBIT key offset value# 设置某一位为 0/1GETBIT 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]# 位域读写

位图的内存优势是数量级的------1 个字节当 8 个布尔位用,比存
"1"/"0"字符省 8 倍以上。完整的签到系统设计(月签/年签/连续签到/补签)、在线状态与去重方案、以及 Bitmap vs HyperLogLog vs Set 的三方内存对比,见后续章节。
高频踩坑点:
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 命令行示例:
bash
# 签到:第 6 天签到
SETBIT sign:2026:u1001 5 1
# 查第 6 天是否签到
GETBIT sign:2026:u1001 5
# 统计已签到天数(默认按字节统计整个 key)
BITCOUNT sign:2026:u1001
# 只统计前 8 位(第 1 个字节)
BITCOUNT sign:2026:u1001 0 0
# 按位统计前 8 位(Redis 7.0+)
BITCOUNT sign:2026:u1001 0 7 BIT
# 找第一个签到的位
BITPOS sign:2026:u1001 1
# 求两天都签到的用户(AND)
BITOP AND sign:both sign:2026-10-30 sign:2026-10-31
# 求任意一天签到的用户(OR)
BITOP OR sign:any sign:2026-10-30 sign:2026-10-31
# 求未签到(NOT,只接受一个源 key)
BITOP NOT sign:not sign:2026-10-31
# BITFIELD:用 u8 存等级,第 0 个元素
BITFIELD user:1001:stats SET u8 #0 5 GET u8 #0
# BITFIELD:u8 饱和自增,255 再加不会回绕
BITFIELD user:1001:stats OVERFLOW SAT INCRBY u8 #0 1
C# (StackExchange.Redis) 代码示例:
csharp
using StackExchange.Redis;
using System;
// 签到:第 6 天签到
bool oldBit = await db.StringSetBitAsync("sign:2026:u1001", 5, true);
// 查第 6 天是否签到
bool signed = await db.StringGetBitAsync("sign:2026:u1001", 5);
// 统计已签到天数(整个 key)
long count = await db.StringBitCountAsync("sign:2026:u1001");
// 统计第 1 个字节(8 位)
long firstByteCount = await db.StringBitCountAsync("sign:2026:u1001", 0, 0);
// 按位统计(7.0+,StackExchange.Redis 未直接封装 BYTE/BIT,需走原生命令)
long bitCount = (long)await db.ExecuteAsync("BITCOUNT", "sign:2026:u1001", 0, 7, "BIT");
// 找第一个签到的位
long firstSigned = await db.StringBitPositionAsync("sign:2026:u1001", true);
// 求两天都签到(AND)
long andLen = await db.StringBitOperationAsync(
Bitwise.And,
"sign:both",
new RedisKey[] { "sign:2026-10-30", "sign:2026-10-31" });
// 求任意一天签到(OR)
await db.StringBitOperationAsync(
Bitwise.Or,
"sign:any",
new RedisKey[] { "sign:2026-10-30", "sign:2026-10-31" });
// 求未签到(NOT,只接受一个源 key)
await db.StringBitOperationAsync(
Bitwise.Not,
"sign:not",
new RedisKey[] { "sign:2026-10-31" });
// BITFIELD:u8 存等级
RedisResult fieldResult = await db.ExecuteAsync(
"BITFIELD", "user:1001:stats",
"SET", "u8", "#0", 5,
"GET", "u8", "#0");
// BITFIELD:u8 饱和自增,避免 255 回绕
RedisResult satResult = await db.ExecuteAsync(
"BITFIELD", "user:1001:stats",
"OVERFLOW", "SAT",
"INCRBY", "u8", "#0", 1);
2.7 冷门但有用的 LCS(7.0+)
LCS(最长公共子序列,Redis 7.0)。 一个很特别的字符串命令:
- LCS key1 key2 # 返回两个值的最长公共子序列
- LCS key1 key2 LEN# 只返回长度
- LCS key1 key2 IDX MINMATCHLEN 4 WITHMATCHLEN # 返回匹配块的位置信息
redis
# 计算 key1 与 key2 两个字符串值的最长公共子序列(LCS)
# 返回:最长公共子序列字符串
LCS key1 key2
# 只返回最长公共子序列的长度,不返回具体子序列内容
LCS key1 key2 LEN
# 返回最长公共子序列的匹配位置索引信息
# IDX:启用索引模式,返回匹配区间
# MINMATCHLEN 4:只返回长度 >= 4 的匹配段(过滤过短匹配)
# WITHMATCHLEN:在返回结果中同时包含每个匹配段的长度
LCS key1 key2 IDX [MINMATCHLEN 4] [WITHMATCHLEN]
它看着冷门,实际有两个实用场景:文章/代码相似度与 diff (公共子序列越长越相似,可做内容查重、版本差异高亮);字符串相似度打分 (LCS ... LEN 结果除以总长度得到粗略相似度,注意它衡量的是子序列 不是子串)。
唯一要小心:LCS 时间复杂度 O(N×M),两个大 value 相比会阻塞主线程。生产用它先把 value 控制在几十 KB 以内,或者移到离线任务。
2.8 命令速查表
String的九类命令覆盖了 Redis 中最基础的数据结构:读写、判存在、过期、数值、字符串、位、子序列------前八类是日常,第九类慎用。

怎么用这张表: 把它当"String 命令索引"用------需要什么能力就按分类定位。日常 90% 场景只用前五类 (读写 / 判存在 / 过期 / 数值 / 字符串);SETBIT 系列是进阶优化手段(签到、布隆过滤器),后面章节细讲;LCS 是 7.0 新增的算法命令,复杂度 O(N×M),只能在短字符串上用。
⚠ 三个高频坑:① 批量命令 MSET/MGET/MSETNX 在 Cluster 下要求所有 key 在同一 slot,否则报 CROSSSLOT;② INCR 系列只对 64 位整数安全,INCRBYFLOAT 有浮点精度误差,金额场景用整数分;③ STRLEN 返回的是字节数不是字符数,UTF-8 中文 3 字节、emoji 4 字节。
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
- 非数字直接抛异常;
- 调用前需确保值类型正确,不要假设写入的值一定是数字。
常见坑 :
StringSetAsync返回bool表示是否设置成功(NX模式下),但很多人忽略了返回值直接继续业务逻辑,导致分布式锁失效。提醒:StackExchange.Redis 默认不会自动序列化对象,需要自己做 JSON / MessagePack 序列化,不要直接传对象。
三、底层编码 / 内部核心机制
本节只回答两个问题------底层是什么 、它对你的代码有什么实际影响 。让你理解为什么 Redis String 性能高、为什么编码会「悄悄漂移」。不涉及
struct定义、预分配算法、内存布局图与编码转换的 C 代码路径(后面章节会详细讲解)。
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:
text
type + encoding + lru + refcount + ptr
在 64 位系统上,这层对象头固定占 16 字节。
影响 :哪怕你存的是
1,也要额外付出 16 字节对象头。所以「海量小 key」通常比「少量大 key」更吃内存。
三种编码对比

可概括为:
int 不额外分配,embstr 一次分配,raw 两次分配。
示例:
bash
SET num 12345
OBJECT ENCODING num # "int"
SET s "hello"
OBJECT ENCODING s # "embstr"
SET s "a string that is definitely longer than 44 bytes......"
OBJECT ENCODING s # "raw"
「44 字节」是怎么来的?
44 不是拍脑袋,而是从内存分配器倒推出来的。
Redis 默认使用 jemalloc,它按 size class 分桶,例如:
text
8、16、32、48、64、80、96、112......
申请内存时,实际拿到的是向上取整到最近桶的大小,而 64 字节是极常用的一个桶。
embstr 想把对象头和 SDS 塞进一次 64 字节分配里,于是:
text
对象头 16 + SDS 头部 3 + 数据长度 len + 结尾 \0 1 ≤ 64
→ len ≤ 64 - 16 - 3 - 1 = 44
超过 44 后:
text
16 + 3 + len + 1 > 64
会跳到 80 甚至更大的桶,一次分配的优势不再明显,Redis 干脆改用 raw 做两次分配。
这里的「SDS 头部 3 字节」对应 ≤ 255 字节档的
sdshdr8头部。头部结构和推导细节(后面章节会详细讲解)。
为什么老文章说 39,新文章说 44?
这是版本差异:
- Redis 3.2 之前:SDS 头固定 8 字节,代入公式:
text
64 - 16 - 8 - 1 = 39
所以老文章说阈值是 39。
- Redis 3.2 之后:引入分级 SDS 头,短字符串只用 3 字节头,阈值变为:
text
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 先判断"是不是数字",再判断"长度够不够短"------因为 int 编码根本不创建 SDS,也就没有长度概念。所以 SET k 123 无论多短都走 int,不会走 embstr。
⚠ 编码不是永久的:对 int 值执行 APPEND 会立刻退化为 raw;对 embstr 执行任何写操作(APPEND / SETRANGE)也会升级为 raw。所以"高频修改的短字符串",最终都会停在 raw。
修改操作会触发编码升级
编码升级是单向的:只会从省内存的编码掉到费内存的编码,永远不会自己变回来。

为什么会"单向掉级": 三种编码的内存布局不同------int 不分配 SDS、embstr 只做一次 malloc 且只读 、raw 是两块独立内存可自由修改。一旦发生写操作,原来的布局无法原地扩展,Redis 只能重新分配成 raw ;而 raw 已经是"最通用"的形态,没有"再变回省内存编码"的逆向路径。
⚠ 实战建议:① 计数类 key 不要混入 APPEND / SETRANGE,一混就彻底丢失 int 编码优势;② 高频修改的短字符串(如拼接日志)注定是 raw,不要指望 embstr 能长期生效;③ 用 OBJECT ENCODING key 可随时验证当前编码,排查"为什么内存涨了"。
关键结论与排查建议
- 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 脚本)
# 验证 int 编码
SET num "12345"
OBJECT ENCODING num
# 输出: "int"
# 验证 embstr 编码
SET str "hello"
OBJECT ENCODING str
# 输出: "embstr"
# 验证 embstr → raw 转换
APPEND str " world!!!"
OBJECT ENCODING str
# 输出: "raw"(修改后永久变成 raw)
# 验证 int → raw 转换
SET counter "42"
APPEND counter "abc"
OBJECT ENCODING counter
# 输出: "raw"(值变为非数字,int 升级为 raw)
编码选择对你的实际影响:
不是"记住三种编码",而是"知道什么场景该让它停在哪种编码"------选错了,内存就是白花的。

三句话记住结论: 数字类 key 只用 INCR 系列,一旦 APPEND 就永久丢失 int 编码 ;短字符串控制住 ≤ 44 字节 就能稳定停在 embstr,省一半内存;超过 44 字节注定 raw,别硬扛------把大 JSON 拆成多个小 key 或改用 Hash,比纠结编码更划算。
四、生产业务场景实战
本节只讲本类型层面的场景落地;
4.1 场景一:通用业务缓存
需求:将用户信息缓存到 Redis,减少数据库查询压力。
方案:将对象序列化为 JSON 字符串,存入 String,设置过期时间。
Redis 命令:
redis
# 写入缓存
SET user:1001 "{\"id\":1001,\"name\":\"张三\",\"age\":28}" EX 300
# 读取缓存
GET user:1001
# 更新缓存
SET user:1001 "{\"id\":1001,\"name\":\"张三\",\"age\":29}\" EX 300
C# (StackExchange.Redis) 代码:
csharp
public async Task<User> GetUserAsync(int userId)
{
var key = $"user:{userId}";
// 先查缓存
var cached = await _db.StringGetAsync(key);
if (cached.HasValue)
{
return JsonSerializer.Deserialize<User>(cached);
}
// 缓存未命中,查数据库
var user = await _userRepository.GetByIdAsync(userId);
if (user != null)
{
// 写入缓存,300秒过期
await _db.StringSetAsync(
key,
JsonSerializer.Serialize(user),
TimeSpan.FromSeconds(300));
}
return user;
}
边界:
- 更新单个字段必须全量覆盖,并发更新容易出现数据覆盖
- 适合一次性读取整个对象、极少单独修改字段的场景
- 完整缓存架构(Cache-Aside/Read-Through)(后面章节会详细讲解)
4.2 场景二:原子计数器
需求:实现文章浏览量、点赞数等高并发计数器。
方案:直接使用 INCR 命令,天然原子无锁,支撑超高并发。
Redis 命令:
# 文章浏览量 +1
INCR article:view:123
# 点赞数 +1
INCR article:like:123
# 获取当前计数
GET article:view:123
# 设置每日重置(配合过期时间)
EXPIRE article:view:123 86400 # 24小时过期
C# (StackExchange.Redis) 代码:
csharp
public async Task<long> IncrementPageViewAsync(string articleId)
{
var key = $"article:view:{articleId}";
var count = await _db.StringIncrementAsync(key);
// 仅在首次创建时设置过期时间(每日重置)
if (count == 1)
{
await _db.KeyExpireAsync(key, TimeSpan.FromDays(1));
}
return count;
}
边界约束:
- 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。
# 生成订单ID
INCR global:order:id
C# 代码:
csharp
public async Task<long> GenerateOrderIdAsync()
{
return await _db.StringIncrementAsync("global:order:id");
}
局限:集群环境下跨槽无法保证全局连续,仅能保证单调递增。
方案二:号段模式
从 DB 批量获取 ID 段缓存到 Redis,应用内存中自行分配,减少 Redis 调用次数。
# 一次批量获取 1000 个 ID
INCRBY global:order:id 1000
# 应用内存中从 (lastMax-999) 到 lastMax 自行分配
方案三:雪花算法 Snowflake
对于高可用和更高性能需求,通常使用雪花算法。Snowflake 的 C# 实现通常不依赖 Redis,而是依赖机器 ID 和时间戳。若需 Redis 协调机器 ID,可结合 INCR 分配。
4.4 场景四:缓存穿透防护
需求:防止恶意请求查询不存在的数据,打穿缓存直接打到数据库。
方案:空值缓存
当查询数据库返回空时,将空值(如空字符串)写入 Redis 并设置较短 TTL,防止恶意请求打穿到 DB。
C# (StackExchange.Redis) 代码:
csharp
public async Task<User> GetUserAsync(int userId)
{
var key = $"user:{userId}";
// 先查缓存
var cached = await _db.StringGetAsync(key);
if (cached.HasValue)
{
if (cached == "") return null; // 命中空值缓存
return JsonSerializer.Deserialize<User>(cached);
}
// 缓存未命中,查数据库
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
// 缓存空值,60秒过期(短过期避免永久脏数据)
await _db.StringSetAsync(key, "", TimeSpan.FromSeconds(60));
return null;
}
// 正常数据缓存,30分钟过期
await _db.StringSetAsync(
key,
JsonSerializer.Serialize(user),
TimeSpan.FromMinutes(30));
return user;
}
边界:
- 空值 TTL 不宜过长(建议 30~120 秒),避免数据恢复后缓存脏数据
- 更彻底的穿透防护方案(布隆过滤器)(后面章节会详细讲解)
- 完整缓存架构(击穿/雪崩防护)(后面章节会详细讲解)
4.5 场景五:固定窗口限流
需求:限制某个接口在单位时间内的调用次数。
方案:利用过期 + 自增实现简单限流。
Redis 命令:
# 设置限流key并初始化(仅首次)
SET limit:ip:192.168.1.1 0 EX 1
# 每次请求 +1
INCR limit:ip:192.168.1.1
# 检查是否超限
GET limit:ip:192.168.1.1
# 返回值 > 100 则拒绝
C# 代码:
csharp
public async Task<bool> TryAcquireRateLimit(string clientId)
{
var key = $"rate_limit:{clientId}";
var count = await _db.StringIncrementAsync(key);
// 首次设置过期时间
if (count == 1)
{
await _db.KeyExpireAsync(key, TimeSpan.FromSeconds(1));
}
return count <= 100; // 限制每秒100次
}
短板:存在临界窗口击穿问题(上一秒剩余配额 + 下一秒新配额可能瞬间翻倍)。高级滑动窗口限流(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 成本也是它带来的。

建议 :缓存层用 MessagePack 或 Protobuf;配置类、需要 redis-cli 直接查看的数据用 JSON。
补充:SDS 二进制安全特性,天然可以存储二进制报文,不会被
\0截断,完美适配二进制序列化格式(Protobuf/MessagePack)。
六、内存占用估算
本节只讲单 Key 容量预估;全局内存管理(后面章节会详细讲解)。
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 的差距。

差出近一倍 ,往往只因为编码从
int误转为raw。实操优先用MEMORY USAGE key直接看真实占用,公式仅用于前期容量测算。
6.5 5 条 String 内存优化技巧
- 尽量使用 int 编码:ID、时间戳等纯数字不要加引号或前缀
- 控制 embstr 边界:尽量让常用短字符串保持在 44 字节以内
- 避免大 Key:单个 String 超过 10KB 就要警惕,考虑拆分或压缩
- 使用压缩:对于大文本,应用层压缩(如 GZip)后再存入,但需权衡 CPU
- 及时清理:合理设置 TTL,避免僵尸 Key 占用内存
直接执行
MEMORY USAGE key,一键查看真实占用,优先使用命令,公式仅做前期容量测算。
七、与 Hash 存储对象的选型对比
b本节只做局部对比;完整决策(后面章节会详细讲解)。
这是面试和实战中最常被问到的问题:什么时候用 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 字段)内存对比:

反直觉结论:小对象(≤3 字段)用 String JSON 更省内存;中等对象(5~10 字段)Hash ziplist 编码最省;大对象(>10 字段或值很大)Hash 升级为 hashtable 后反而最费内存。
7.3 选型决策(直接落地)
把上文的维度对比落到业务:看"怎么读写"和"要不要单字段操作",两个问题就能定方案。

一句话总结:整体缓存用 String + JSON;字段级操作用 Hash;需要字段级 TTL 才退回到 String 分 key。Hash 在内存和并发安全上几乎全面优于"分 key 存 String",唯一短板是 TTL 粒度粗和可读性差。
7.4 混合方案代码示例
csharp
// 整体缓存用 String + JSON(订单快照,整体读取)
await _db.StringSetAsync("order:1001", JsonConvert.SerializeObject(order));
// 局部更新用 Hash(用户信息,频繁改单个字段)
await _db.HashSetAsync("user:1001", "last_login", DateTime.UtcNow.ToString());
一句话落地结论:整对象一次性读写用 String;字段频繁局部更新用 Hash。完整选型决策树(后面章节会详细讲解)。
八、高频线上坑点汇总
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 要拆分,过期时间必须设。