1. 从一次缓存穿透事故说起:为什么字符串远不止 set 和 get
很多人对 Redis 字符串的理解停留在SET存、GET取,觉得这是 Redis 里最简单的一种结构,没什么可深挖的。我刚开始用的时候也这么想,直到有一次线上出现缓存穿透,大量请求直接打到数据库,排查了半天才发现问题出在一个看似无害的SET上——当时用SET key value存了一个热点数据,没设过期时间,结果数据源那边更新了,缓存里还是旧值,业务逻辑判断"缓存有值就直接返回",导致用户一直看到过期数据,同时因为缓存永远不过期,内存也在缓慢膨胀。
那次之后我才认真把 Redis 字符串的命令体系过了一遍。字符串确实是 Redis 最基础的数据类型,但它的能力边界远比"存取文本"要宽:它可以存二进制、可以当计数器、可以做分布式锁、可以实现位图统计、可以做批量原子操作。关键在于你是否知道每个命令背后的语义差异,以及这些差异在什么场景下会变成坑。
这篇内容我打算把 Redis 字符串的操作体系完整拆一遍,不是罗列命令文档,而是从实际使用角度出发,讲清楚每个命令解决什么问题、参数怎么选、什么情况下会踩坑。适合已经用过 Redis 但想系统梳理字符串操作的开发者,也适合刚开始接触 Redis、想少走弯路的朋友。文中涉及的命令和参数都基于 Redis 6.x 及以上版本的通用行为,不同版本可能有细微差异,实际使用时建议对照你所用版本的官方文档确认。
2. SET 命令的参数矩阵:一个命令背后的六种决策
2.1 从最简单的 SET 到带选项的完整形态
SET是字符串操作里最核心的命令,但它的完整语法远不止SET key value。完整形式是:
SET key value [EX seconds] [PX milliseconds] [EXAT timestamp] [PXAT timestamp] [NX|XX] [KEEPTTL] [GET]这一串选项不是随便堆上去的,每一个都对应一个真实场景下的决策点。我逐个拆解。
过期时间相关选项:EX是秒级过期,PX是毫秒级过期,EXAT和PXAT是指定绝对时间戳过期。为什么要有绝对时间戳?考虑一个场景:你需要在某个具体时刻让缓存失效,比如活动结束时间。如果用EX,你得先算出当前时间到活动结束还有多少秒,这个计算在分布式环境下可能因为各节点时钟不一致而出问题。用EXAT直接指定 Unix 时间戳,语义更明确,也不依赖调用方的时钟精度。
NX 和 XX:NX表示 key 不存在时才设置,XX表示 key 存在时才设置。这两个选项是实现分布式锁的基础。SET lock_key unique_value NX EX 10这条命令的语义是:如果锁不存在,就设置锁并给 10 秒过期时间;如果锁已存在,设置失败。整个过程是原子的,不需要先GET再SET,避免了竞态条件。
KEEPTTL:这个选项容易被忽略。默认情况下,对一个已有过期时间的 key 执行SET,会清除原有的 TTL。如果你希望保留原来的过期时间,就要加KEEPTTL。我踩过一次坑:用SET更新缓存值时没注意 TTL 被清掉了,结果那个 key 变成了永久有效,内存里慢慢堆积了一批"僵尸 key"。
GET:SET key value GET会在设置新值的同时返回旧值。这个选项在需要"更新并获取旧值"的场景下很有用,比如实现一个带旧值返回的计数器重置逻辑。但要注意,如果旧值不是字符串类型(比如是列表),GET会报错,因为SET只能操作字符串。
2.2 参数组合的实际决策表
| 场景 | 推荐命令形式 | 理由 |
|---|---|---|
| 普通缓存写入 | SET key value EX 300 | 明确过期时间,避免内存泄漏 |
| 分布式锁获取 | SET key uuid NX EX 10 | 原子性保证,防止死锁 |
| 更新缓存但保留 TTL | SET key value KEEPTTL | 避免 TTL 被意外清除 |
| 仅当 key 存在时更新 | SET key value XX | 防止误创建不存在的 key |
| 指定绝对过期时刻 | SET key value EXAT timestamp | 不依赖调用方时钟计算 |
| 更新并获取旧值 | SET key value GET | 减少一次网络往返 |
这张表不是让你背下来,而是让你在写代码时有一个决策依据。我见过太多项目里所有SET都是裸的SET key value,没有过期时间、没有条件判断,这种写法在开发环境没问题,上了生产就是隐患。
2.3 为什么 SET 的原子性如此重要
有人可能会问:我能不能先GET判断 key 是否存在,再决定要不要SET?答案是能,但不对。因为GET和SET之间有时间窗口,在这个窗口里可能有其他客户端也做了同样的判断,导致两个客户端都认为自己应该写入。这就是典型的竞态条件。
SET的NX选项把"判断+写入"合并成一个原子操作,Redis 单线程执行命令的特性保证了这中间不会插入其他命令。这是 Redis 做分布式锁的根基。理解这一点,你就能明白为什么很多分布式锁的实现都强调"必须用 SET NX 而不是 SETNX 命令"——SETNX是旧命令,不支持过期时间参数,你得单独再调EXPIRE,这两步之间如果客户端崩溃,锁就永远不会释放。
3. 批量操作的效率账:MSET 与管道到底怎么选
3.1 MSET 的原子性与性能边界
MSET key1 value1 key2 value2 ...可以一次设置多个 key。它的优势是减少网络往返——一次请求设置多个 key,而不是发多次请求。但要注意,MSET是原子操作,所有 key 要么都设置成功,要么都失败。这个特性在某些场景下是优点,在某些场景下是限制。
比如你要批量写入 1000 个缓存 key,如果其中某个 key 因为某种原因写入失败(虽然字符串 SET 很少失败),整个MSET都会失败。而且MSET不支持过期时间参数,这意味着你用MSET写入的 key 默认是永久的,需要额外对每个 key 调EXPIRE,这又破坏了原子性。
所以我的经验是:MSET适合写入一批不需要单独过期时间、且对原子性有要求的 key。如果每个 key 需要不同的过期时间,或者你希望部分失败不影响其他 key,那MSET就不合适。
3.2 管道与 MSET 的本质区别
管道(Pipeline)是另一个常被拿来和MSET比较的方案。它们的区别在于:
MSET是 Redis 服务端的原子命令,一次请求包含多个 key 的设置。- 管道是客户端的行为,把多个命令打包一次性发送,服务端逐条执行,逐条返回结果。
管道不保证原子性——如果中间某条命令失败,其他命令仍然会执行。但管道更灵活,可以混合不同类型的命令,也可以给每个SET单独指定过期时间。
| 对比维度 | MSET | 管道 |
|---|---|---|
| 原子性 | 是 | 否 |
| 支持过期时间 | 否 | 是(每条命令独立指定) |
| 支持混合命令 | 否 | 是 |
| 网络往返 | 1 次 | 1 次 |
| 适用场景 | 批量写入无过期时间的 key | 批量执行异构命令 |
实测下来,在写入 1000 个 key 的场景下,MSET和管道的耗时差异不大,主要瓶颈在网络往返次数上,两者都能把 1000 次往返压缩成 1 次。真正的差异在语义层面:你需要原子性就用MSET,需要灵活性就用管道。
3.3 批量操作的一个隐藏坑:大 key 问题
不管是MSET还是管道,如果你一次操作的数据量太大,比如一次写入 10 万个 key,会导致 Redis 单线程阻塞较长时间,影响其他请求。我一般建议单次批量操作控制在 500 到 1000 个 key 以内,超过这个量就分批处理。
另外,MSET写入的多个 key 如果恰好落在同一个哈希槽(在集群模式下),会由同一个节点处理;如果跨槽,集群模式下MSET会报错。这是集群环境和单机环境的一个重要差异,很多在单机上跑得好好的代码,上了集群就出问题,往往就是忽略了 key 的槽位分布。
4. 计数器与位图:字符串被低估的两个高阶用法
4.1 INCR 系列命令的原子计数能力
INCR、INCRBY、DECR、DECRBY、INCRBYFLOAT这一组命令让字符串变成了原子计数器。为什么强调原子?因为如果你用GET读出来、在应用层加一、再SET回去,这三个步骤之间可能有其他客户端也在做同样的操作,导致计数丢失。INCR把"读+加+写"合并成一个原子操作,天然适合计数场景。
实际使用中要注意几点:
INCR只能对整数值操作,如果 key 的值不是整数格式,会报错。所以不要用INCR去操作一个可能被SET成任意字符串的 key。INCR对不存在的 key 会先初始化为 0 再加一,所以不需要提前SET一个初始值。INCRBYFLOAT支持浮点数,但浮点数运算有精度问题,涉及金额计算时建议用整数(比如以分为单位)配合INCRBY。
我做过一个限流器,用INCR加EXPIRE实现固定窗口计数:每次请求INCR一个 key,如果是第一次(返回值为 1),就设置过期时间为窗口大小。这个方案简单有效,但有一个边界问题:如果INCR成功但EXPIRE失败(比如客户端在两步之间崩溃),key 就永远不会过期。更稳妥的做法是用 Lua 脚本把两步合并,或者直接用SET key 0 EX window NX初始化后再INCR。
4.2 SETBIT 与 BITCOUNT:用位图做统计
字符串在 Redis 里是二进制安全的,这意味着你可以把每一位当作一个独立的布尔值来用。SETBIT key offset 1可以把第 offset 位设为 1,GETBIT读取某一位,BITCOUNT统计为 1 的位数。
这个能力在统计场景下非常高效。比如统计每天有多少用户活跃,用户 ID 是整数,你可以用用户 ID 作为 offset,SETBIT active:20240101 user_id 1。一天 100 万用户活跃,只需要 100 万位,约 125KB 内存。BITCOUNT active:20240101就能得到活跃用户数。
但要注意 offset 的范围。Redis 字符串最大 512MB,对应 2^32 位。如果你的用户 ID 很大(比如雪花算法生成的 64 位 ID),直接用 ID 作为 offset 会浪费大量空间甚至超出限制。这时候需要做一个映射,把用户 ID 压缩到合理的范围,或者改用 HyperLogLog 做基数统计。
BITCOUNT还支持按字节范围统计:BITCOUNT key start end。这个在统计某个时间段的数据时有用,但要注意 start 和 end 是字节索引,不是位索引,换算时容易搞错。我一般会写一个辅助函数来处理位到字节的转换,避免手动计算出错。
4.3 位图操作的性能考量
SETBIT和GETBIT都是 O(1) 操作,BITCOUNT是 O(N),N 是字节数。对于 125KB 的位图,BITCOUNT的耗时在微秒级别,完全够用。但如果位图很大,比如几 MB,BITCOUNT可能会阻塞 Redis 一段时间。这种情况下可以考虑用BITCOUNT的BIT模式(Redis 7.0 引入)按位范围统计,或者把大位图拆成多个小位图。
还有一个容易忽略的点:SETBIT会自动扩展字符串长度。如果你SETBIT key 1000000 1,Redis 会把字符串扩展到 1000001 位,中间未设置的位都是 0。这意味着一个看似简单的SETBIT可能触发内存分配,如果 offset 很大,分配的内存也会很大。所以用位图时一定要控制 offset 的范围。
5. GETRANGE、SETRANGE 与 APPEND:字符串的局部操作
5.1 GETRANGE 的索引规则与负数索引
GETRANGE key start end返回字符串的子串,支持负数索引,-1 表示最后一个字符,-2 表示倒数第二个,以此类推。这个规则和很多编程语言的切片语法类似,但有一个关键差异:Redis 的GETRANGE是闭区间,end位置的字符会被包含在内。
比如GETRANGE key 0 -1返回整个字符串,GETRANGE key 0 0返回第一个字符。如果 start 大于 end,或者 start 超出字符串长度,返回空字符串。这些边界行为在写代码时要注意,特别是当 start 和 end 是动态计算出来的时候。
我用GETRANGE做过一个简单的日志截取功能:把最近的日志追加到一个字符串里,需要查看时用GETRANGE取最后 N 个字符。但后来发现这个方案有问题——字符串会无限增长,而且APPEND本身不提供截断能力。最终改用了 Redis 的 Stream 结构,更适合日志场景。这个经历告诉我,字符串虽然灵活,但不是所有场景都适合。
5.2 SETRANGE 的覆盖写入与零填充
SETRANGE key offset value从 offset 位置开始覆盖写入 value。如果 offset 超出当前字符串长度,中间会用零字节填充。这个特性可以用来做固定长度记录的更新,比如每个用户占 100 字节,用户 ID 乘以 100 作为 offset,更新时直接SETRANGE覆盖对应位置。
但零填充这个行为要特别小心。如果你SETRANGE key 1000 "hello",而 key 原本只有 10 个字节,Redis 会把字符串扩展到 1005 字节,中间 990 个字节都是零。这些零字节在读取时会返回,如果你的应用层没有处理,可能会得到意料之外的结果。
5.3 APPEND 的适用场景与限制
APPEND key value在字符串末尾追加内容。如果 key 不存在,等同于SET。这个命令的原子性保证了并发追加不会丢失数据,适合做简单的消息队列或日志收集。但它的限制也很明显:字符串会不断增长,没有内置的截断机制,而且当字符串很大时,每次追加都可能触发内存重新分配,性能会下降。
我一般只在数据量可控、且明确需要追加语义的场景下用APPEND,比如记录某个任务的执行步骤。如果数据量可能无限增长,我会选择 List 或 Stream。
6. 那些容易踩坑的细节:TTL、类型与集群
6.1 TTL 被意外清除的三种情况
除了前面提到的SET会清除 TTL,还有几种操作也会影响过期时间:
GETSET key value:设置新值并返回旧值,同时清除 TTL。这个命令在新版本中建议用SET key value GET替代,后者可以通过KEEPTTL保留过期时间。APPEND:如果 key 已存在且有 TTL,APPEND不会清除 TTL。但如果 key 不存在,APPEND创建的新 key 没有 TTL。SETRANGE:同样,对已存在的 key 操作不会清除 TTL,但创建新 key 时没有 TTL。
这些行为差异说明一件事:不要假设所有写操作对 TTL 的处理方式一致。在需要保留 TTL 的场景下,要么用支持KEEPTTL的命令,要么在操作后重新设置 TTL。
6.2 类型错误:当字符串命令遇到非字符串 key
Redis 的 key 是类型敏感的。如果你对一个 List 类型的 key 执行GET,会报WRONGTYPE错误。这个错误在开发环境容易发现,但在生产环境可能因为 key 命名冲突而意外触发。比如两个不同的业务模块用了相同的 key 前缀,一个存字符串,一个存列表,就会互相干扰。
我的做法是给不同业务模块的 key 加明确的前缀,比如user:profile:、order:cache:,并且在代码层面做 key 的集中管理,避免散落在各处。另外,在捕获到WRONGTYPE错误时,不要简单地忽略,这往往意味着 key 设计有问题,需要排查。
6.3 集群模式下的字符串操作限制
在 Redis 集群模式下,多 key 操作有额外限制。MSET、MGET这些命令要求所有 key 在同一个哈希槽,否则会报CROSSSLOT错误。解决方案是用哈希标签(hash tag),比如user:{1001}:name和user:{1001}:age,大括号内的内容相同,Redis 会确保它们落在同一个槽。
但哈希标签不能滥用,如果所有 key 都用同一个标签,会导致数据倾斜,所有数据集中到一个节点。合理的做法是按业务维度分组,比如同一个用户的数据用同一个标签,不同用户用不同标签。
另外,BITCOUNT、GETRANGE这些单 key 命令在集群模式下没有限制,可以正常使用。INCR也是单 key 操作,同样不受影响。所以在集群环境下,字符串的单 key 操作基本可以放心用,多 key 操作需要额外注意。
7. 我在实际项目中的字符串使用心得
7.1 缓存写入的默认模板
经过多次踩坑,我现在写缓存写入的默认模板是这样的:
import redis import uuid r = redis.Redis(host='localhost', port=6379, decode_responses=True) def set_cache(key, value, ttl=300): # 默认 5 分钟过期,避免内存泄漏 r.set(key, value, ex=ttl) def acquire_lock(lock_key, ttl=10): # 分布式锁:唯一值 + NX + 过期时间 token = str(uuid.uuid4()) acquired = r.set(lock_key, token, nx=True, ex=ttl) return token if acquired else None def release_lock(lock_key, token): # 释放锁:用 Lua 脚本保证原子性 lua = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ r.eval(lua, 1, lock_key, token)这个模板的核心原则是:任何写入都带过期时间,任何锁都带唯一值和原子释放。看起来简单,但能避免大部分常见问题。
7.2 计数器使用的注意事项
用INCR做计数器时,我会注意三点:第一,确保 key 不会被其他命令写成非整数值;第二,如果计数器需要定期重置,用SET key 0 EX ttl而不是DEL再INCR,避免重置和计数之间的竞态;第三,对于需要精确统计的场景,考虑用INCRBY而不是多次INCR,减少网络往返。
7.3 位图统计的边界处理
用位图做统计时,我会先确认 offset 的最大值,估算内存占用。如果 offset 可能很大,我会先做一个映射层,把业务 ID 压缩到合理范围。另外,BITCOUNT的结果是精确值,但如果只是需要近似基数,HyperLogLog 更省内存。选择哪种方案取决于你对精度的要求和数据规模。
7.4 监控与告警
最后分享一个容易被忽略的点:字符串 key 的内存占用需要监控。特别是那些没有设置 TTL 的 key,以及可能被APPEND或SETRANGE不断扩大的 key。我一般会用STRLEN定期检查大 key 的长度,超过阈值就告警。Redis 自带的--bigkeys扫描也能发现大 key,但会阻塞服务,建议在从节点或低峰期执行。
字符串是 Redis 最基础的结构,但基础不代表简单。把每个命令的语义、参数、边界行为搞清楚,能避免很多线上问题。我在实际使用中的体会是:不要假设,要验证。每个命令在边界条件下的行为,最好都在测试环境跑一遍,确认符合预期后再上生产。