Redis 数据类型全解 - String 不只是 get set:90% 开发者忽略的 8 个高阶用法与内存陷阱(一)

0 阅读40分钟

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

1.png

参数能组合,别只记单个: 分布式锁的标准写法是 SET key val NX PX 30000——NX 保证只有一个人拿到锁,PX 30000 保证锁 30 秒后自动释放,两者缺一都不是可靠的锁。反过来,EX 与 PX 互斥、EXAT 与 PXAT 互斥,KEEPTTL 不能与任何过期参数同时出现——组合前先确认兼容性。

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) 代码示例:

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 则适合「一次性令牌」这类用完即焚的场景。

2.png

怎么选: 只取值用 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) 代码示例:

// 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 # 浮点加法

3.png

选命令看三件事: 方向(加还是减)、步长(1 还是指定值)、类型(整数还是浮点)。五个命令都是原子的,并发下直接算就行,不用先 GET 再 SET。

⚠ 金额场景慎用 INCRBYFLOAT:二进制浮点会引入精度误差,涉及真实金额时建议改用「整数分」计数(存 999 表示 9.99 元),或把金额计算放到业务层用 decimal / BigDecimal 处理。

INCR 家族是 String 最有价值的能力之一,因为它天生原子。多线程并发 INCR 不会有竞态问题,这是它相对「读出来 + 加一 + 写回去」的决定性优势。但有三个边界必须知道:

边界一:整数值上限是 64 位有符号整数。

值必须是合法 64 位有符号整数(范围:-2^63 ~ 2^63-1,即 -9223372036854775808 ~ 9223372036854775807),否则直接报错

SET counter 9223372036854775807    # long 最大值
INCR counter # (error) ERR increment or decrement would overflow

所以用 INCR 做分布式 ID 一定要评估增速和生命周期,避免溢出。这也是为什么分布式 ID 更推荐「Redis 号段模式」而非单键自增。

边界二:INCR 的对象必须是「能解析成整数的字符串」。

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) 代码示例:

// 原子递增并获取新值
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 表示末尾)

4.png

高频踩坑点:

  • 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 命令行示例:

# 追加日志
APPEND log:app "2024-10-29 Error occurred\n"

# 获取字符串长度(注意是字节数)
STRLEN user:bio

# 批量获取
MGET user:1001 user:1002 user:1003

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

// 追加字符串
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 命令)

5.png

高频踩坑点:

  • 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 命令行示例:

# 批量设置
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) 代码示例:

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 推荐)

6.png

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 命令行示例:

# 设置 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) 代码示例:

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/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] # 位域读写

7.png

位图的内存优势是数量级的——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 命令行示例:

# 签到:第 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) 代码示例:

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] # 返回匹配块的位置信息
# 计算 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 中最基础的数据结构:读写、判存在、过期、数值、字符串、位、子序列——前八类是日常,第九类慎用。

8.png

怎么用这张表: 把它当“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 对上:

9.png

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:

type + encoding + lru + refcount + ptr

在 64 位系统上,这层对象头固定占 16 字节。

影响:哪怕你存的是 1,也要额外付出 16 字节对象头。所以「海量小 key」通常比「少量大 key」更吃内存。

三种编码对比

10.png

可概括为:

int 不额外分配,embstr 一次分配,raw 两次分配。

示例:

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 分桶,例如:

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 依赖以下前提:

  1. 使用 jemalloc;
  2. 使用 64 字节 size class;
  3. 64 位系统下对象头为 16 字节。

如果换了分配器,比如 libc malloc,或者换成 32 位系统(指针只有 4 字节,对象头更小),边界都会变化。

另外,44 是 Redis 3.2 之后源码中的常量,运行期不可通过配置调整;只有修改源码并重新编译才可能改变。业务上按 44 字节作为判断标准即可。

编码选择流程

同一个 SET 命令,Redis 会按“先看内容、再看长度”的顺序做两次判断,决定用 int / embstr / raw 哪种编码。

11.png

流程里的两个判断顺序不能换: Redis 先判断“是不是数字”,再判断“长度够不够短”——因为 int 编码根本不创建 SDS,也就没有长度概念。所以 SET k 123 无论多短都走 int,不会走 embstr。 ⚠ 编码不是永久的:对 int 值执行 APPEND 会立刻退化为 raw;对 embstr 执行任何写操作(APPEND / SETRANGE)也会升级为 raw。所以“高频修改的短字符串”,最终都会停在 raw。

修改操作会触发编码升级

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

12.png

为什么会“单向掉级”:三种编码的内存布局不同——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)

编码选择对你的实际影响:

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

13.png

三句话记住结论: 数字类 key 只用 INCR 系列,一旦 APPEND 就永久丢失 int 编码;短字符串控制住 ≤ 44 字节 就能稳定停在 embstr,省一半内存;超过 44 字节注定 raw,别硬扛——把大 JSON 拆成多个小 key 或改用 Hash,比纠结编码更划算。

四、生产业务场景实战

本节只讲本类型层面的场景落地;

4.1 场景一:通用业务缓存

需求:将用户信息缓存到 Redis,减少数据库查询压力。

方案:将对象序列化为 JSON 字符串,存入 String,设置过期时间。

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) 代码:

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) 代码:

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# 代码:

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) 代码:

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# 代码:

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 成本四者怎么取舍。

14.png

5.2 选型建议

  1. 开发/调试阶段:首选 JSON,可读性强,调试方便,.NET 原生支持(System.Text.Json);
  2. 生产环境海量对象缓存:首选 MessagePack 或 Protobuf,平衡开发效率与内存开销;
  3. 跨语言服务间通信:首选 Protobuf,压缩率最高、序列化速度最快;

5.3 实测对比参考

10个字段对象实测:Protobuf 体积是 JSON 的 1/3、耗时是 JSON 的 1/5——但 Schema 成本也是它带来的。

15.png

建议:缓存层用 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 字节——差了将近一倍。

16.png

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 的差距。

17.png

差出近一倍,往往只因为编码从 int 误转为 raw。实操优先用 MEMORY USAGE key 直接看真实占用,公式仅用于前期容量测算。

6.5 5 条 String 内存优化技巧

  1. 尽量使用 int 编码:ID、时间戳等纯数字不要加引号或前缀
  2. 控制 embstr 边界:尽量让常用短字符串保持在 44 字节以内
  3. 避免大 Key:单个 String 超过 10KB 就要警惕,考虑拆分或压缩
  4. 使用压缩:对于大文本,应用层压缩(如 GZip)后再存入,但需权衡 CPU
  5. 及时清理:合理设置 TTL,避免僵尸 Key 占用内存

直接执行 MEMORY USAGE key,一键查看真实占用,优先使用命令,公式仅做前期容量测算。

七、与 Hash 存储对象的选型对比

b本节只做局部对比;完整决策(后面章节会详细讲解)。

这是面试和实战中最常被问到的问题:什么时候用 String,什么时候用 Hash?

7.1 方案对比

String(JSON) vs String(分 key) vs Hash 对比

同一个对象怎么存?三种方式的核心差异在于:能否只改一个字段、能否单字段自增、每个对象吃多少内存。

18.png

7.2 内存占用对比(反直觉结论)

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

中等对象(5~10 字段)内存对比:

19.png

反直觉结论:小对象(≤3 字段)用 String JSON 更省内存;中等对象(5~10 字段)Hash ziplist 编码最省;大对象(>10 字段或值很大)Hash 升级为 hashtable 后反而最费内存。

7.3 选型决策(直接落地)

把上文的维度对比落到业务:看“怎么读写”和“要不要单字段操作”,两个问题就能定方案。

20.png

一句话总结:整体缓存用 String + JSON;字段级操作用 Hash;需要字段级 TTL 才退回到 String 分 key。Hash 在内存和并发安全上几乎全面优于"分 key 存 String",唯一短板是 TTL 粒度粗和可读性差。

7.4 混合方案代码示例

// 整体缓存用 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 类型展开了深度剖析,带你从入门走向精通:

  1. 命令进阶:掌握了 SET 的 EX/NX/XX/KEEPTTL 参数、GETEX/GETDEL 系列、INCR 原子计数器、MSET/MGET 批量操作等进阶用法;
  2. SDS 原理:深入理解了 SDS 数据结构的设计哲学——二进制安全、O(1) 长度获取、内存预分配与惰性释放;
  3. 编码转换:搞清楚了 int/embstr/raw 三种编码的触发条件和转换边界,牢记"embstr 不可变、转换不可逆"的关键结论;
  4. 实战场景:掌握了计数器、分布式 ID(INCR/号段/雪花算法)、缓存穿透防护(空值缓存)、固定窗口限流等经典场景的 C# 实现;
  5. 序列化选型:对比了 JSON / Protobuf / MessagePack 三种方案的优劣和适用场景;
  6. 内存估算:学会了使用 MEMORY USAGE 命令以及不同编码的内存占用分析;
  7. String vs Hash:从内存、性能、更新模式等维度,对比了两种存储方案的选型策略;
  8. 生产坑点:总结了 SET 覆盖、编码转换、大 Key、Bitmap 偏移等 10 个常见陷阱;

String 的正确打开方式:简单值用 String,对象看场景选 String 或 Hash,大 Value 要拆分,过期时间必须设。