1. 从一次缓存雪崩说起:为什么我要重新审视Redis字符串
去年冬天,我负责的一个服务在凌晨两点突然大面积超时。排查到天亮才发现,问题出在一个看似最简单的缓存写入逻辑上——我们用SET命令缓存了一批热点数据,但没设过期时间,结果内存打满后触发了淘汰策略,把一批关键配置项给挤掉了。那一刻我才意识到,Redis字符串这个被无数人当作“最没技术含量”的数据结构,其实藏着大量容易被忽视的细节。
Redis字符串(String)是Redis最基础、最常用的数据结构,没有之一。它不仅能存文本、数字、二进制数据,还能通过一系列原子操作实现计数器、分布式锁、限流器、会话缓存等复杂功能。但正因为太基础,很多人只记住了SET和GET,对SETEX、SETNX、INCR、GETSET、MSET这些命令的适用场景和坑点一知半解。这篇文章就是要把Redis字符串操作从“会用”讲到“用对”,从命令语法讲到内部编码,从单机场景讲到批量优化,适合所有正在用Redis做缓存、计数、锁的开发者,也适合想系统梳理Redis基础的同学。
我踩过的坑包括但不限于:用SETNX做锁却没设过期导致死锁、用INCR做计数器却没考虑溢出、用MSET批量写入却触发了大key、用GETSET做重置却丢了旧值。这些问题的根源,都是对字符串操作的边界条件理解不够。下面我会把这些经验拆开揉碎,配上可直接复现的命令和参数计算,让你少走弯路。
2. Redis字符串的底层设计与命令分类
2.1 为什么字符串是Redis的“万能砖”
Redis字符串的官方定义是“二进制安全的字节序列”,这意味着它不关心你存的是ASCII文本、UTF-8中文、图片字节流还是序列化后的对象,统统按字节处理。这个特性让它成为最通用的缓存载体。但通用不等于简单,Redis为了在不同场景下平衡内存和性能,为字符串设计了三种内部编码:int、embstr和raw。
int编码:当字符串内容是一个64位有符号整数时,Redis直接用整数存储,不分配额外内存。比如SET counter 100,底层就是一个long。embstr编码:当字符串长度小于等于44字节时,Redis把元数据和内容分配在一块连续内存里,减少内存碎片。这个44字节是Redis 3.2之后的阈值,之前是39字节。raw编码:超过44字节的字符串,元数据和内容分开分配,适合大文本。
你可以用OBJECT ENCODING key命令查看某个键的编码。这个知识点在实际调优中很有用:如果你发现大量raw编码的小字符串,说明可能因为某些操作(比如APPEND)导致编码退化,内存占用会比预期高。
2.2 字符串命令的四大类场景
我把Redis字符串命令按使用场景分成四类,这样你在遇到具体问题时能快速定位该用哪个:
| 场景分类 | 核心命令 | 典型用途 |
|---|---|---|
| 基础读写 | SET、GET、GETSET、MSET、MGET | 缓存、配置存储 |
| 过期控制 | SETEX、PSETEX、SET with EX/PX | 会话、验证码 |
| 原子计数 | INCR、DECR、INCRBY、DECRBY、INCRBYFLOAT | 限流、统计、库存 |
| 条件写入 | SETNX、SET with NX/XX | 分布式锁、幂等控制 |
这四类不是孤立的,实际业务中经常组合使用。比如用SET key value EX 60 NX一条命令同时实现“不存在才设置”和“60秒过期”,这就是分布式锁的经典写法。很多人只知道SETNX,却不知道SET命令通过NX和EX参数可以原子地完成同样的事,而且避免了SETNX+EXPIRE两步操作之间的竞态问题。
2.3 单命令原子性与多命令流水线的取舍
Redis的单条命令是原子性的,但多条命令之间不是。比如你先GET再SET,中间可能被其他客户端插入操作。要保证多条命令的原子性,有三种方案:
- 使用
MULTI/EXEC事务:把命令打包,但事务中的命令并不是全部成功才生效,而是逐条执行,遇到错误继续执行后面的。 - 使用Lua脚本:在服务端原子执行一段逻辑,适合复杂条件判断。
- 使用单命令的复合参数:比如
SET key value EX 60 NX,一条命令搞定条件+过期。
我的经验是:能用单命令复合参数解决的,绝不用事务;能用Lua解决的,绝不用多次网络往返。因为网络往返的延迟在分布式环境下会被放大,而且中间状态容易被其他客户端观察到。
3. 核心命令的实操细节与参数计算
3.1 SET命令的完整参数矩阵
SET命令的完整语法是:
SET key value [EX seconds] [PX milliseconds] [NX|XX] [KEEPTTL]每个参数都有明确的适用场景:
EX seconds:设置秒级过期时间。适合会话、验证码这类需要明确过期时间的场景。PX milliseconds:设置毫秒级过期时间。适合短时锁、高频限流。NX:只有key不存在时才设置。这是分布式锁的核心。XX:只有key存在时才设置。适合更新已有缓存。KEEPTTL:保留key原有的过期时间。这个参数在Redis 6.0引入,之前用SET会清除过期时间,导致需要重新设置。
我见过一个典型的坑:某同学用SET更新缓存时没加KEEPTTL,结果每次更新都把过期时间清掉了,缓存变成了永久数据,最后内存爆满。所以如果你是在更新一个已有过期时间的key,要么用KEEPTTL,要么重新指定EX。
参数计算示例:假设你要做一个限流器,允许每个用户每分钟最多100次请求。你可以用INCR+EXPIRE组合:
INCR user:123:requests EXPIRE user:123:requests 60但这两条命令不是原子的,如果INCR之后客户端崩溃,EXPIRE没执行,这个key就永不过期。更好的做法是用Lua脚本或者SET+INCR的组合。不过更简单的方案是直接用INCR后判断返回值,如果等于1就设置过期:
INCR user:123:requests # 如果返回1,说明是第一次请求 EXPIRE user:123:requests 60这个逻辑在大多数场景下够用,因为即使EXPIRE失败,下次请求还会尝试设置。但严格来说仍有竞态,生产环境建议用Lua。
3.2 INCR家族的溢出与精度问题
INCR、DECR、INCRBY、DECRBY操作的是64位有符号整数,范围是-9223372036854775808到9223372036854775807。超过这个范围会报错:
SET counter 9223372036854775807 INCR counter # (error) ERR increment or decrement would overflow这个错误不会改变key的值,但你的业务逻辑必须处理这个异常。我见过一个统计系统,用INCR统计每日PV,结果某天被刷了超过64位上限的量,直接报错导致统计中断。虽然这个量级很极端,但如果你做的是累加型计数器,最好加一个上限判断或者用INCRBYFLOAT。
INCRBYFLOAT支持浮点数,但要注意浮点精度问题。Redis内部用long double存储,精度有限。比如:
SET price 10.5 INCRBYFLOAT price 0.1 # "10.6"看起来没问题,但如果你做金融计算,建议用整数分存储,避免浮点误差。比如10.5元存成1050分,用INCRBY操作。
还有一个细节:INCR操作不存在的key时,会先把它当作0,然后加1。所以INCR newkey返回1,且key的值变成"1"。这个特性可以用来做“不存在则初始化”的计数器,但要注意它和SETNX的区别:SETNX是设置值,INCR是数值加一。
3.3 GETSET与SETNX的竞态陷阱
GETSET命令的语义是:设置新值并返回旧值。这个命令在Redis 6.2之后被标记为废弃,推荐用SET+GET参数替代:
SET key newvalue GET但很多老代码还在用GETSET。它的典型用途是重置计数器:比如你有一个每日任务,每天凌晨要把计数器归零,同时获取昨天的值做报表。用GETSET可以原子地完成:
GETSET daily:counter 0但这里有个坑:如果key不存在,GETSET返回nil,然后设置新值。如果你没处理nil,报表逻辑可能出错。而且GETSET会清除过期时间,和SET一样。所以如果你需要保留过期时间,得用SET+GET+KEEPTTL。
SETNX的竞态问题更经典:很多人用SETNX做锁,然后EXPIRE设过期,但这两步之间如果客户端崩溃,锁就永远不过期。正确的做法是用SET key value NX EX 30一条命令完成。这个改动虽然小,但能避免死锁。
3.4 批量操作的性能与内存权衡
MSET和MGET可以一次操作多个key,减少网络往返。但批量操作有两个坑:
- 大key问题:如果你一次
MSET几万个key,Redis会阻塞处理这个命令,因为它是单线程的。建议单次批量不超过1000个key。 - 内存突增:批量写入会一次性分配大量内存,可能触发内存淘汰或OOM。
我实测过,MSET1000个key(每个value 1KB)大约耗时2-3毫秒,而循环SET1000次需要1000次网络往返,在局域网内大约50-100毫秒。差距明显。但如果你批量的是大value,比如每个100KB,那MSET会阻塞更久,这时候应该拆分批次。
还有一个替代方案:用管道(pipeline)。管道不是Redis命令,而是客户端技术,把多个命令打包发送,服务端逐条执行。管道比MSET更灵活,因为可以混合不同命令,但管道不是原子的,中间可能插入其他客户端的命令。
4. 从缓存到锁:字符串的实战场景拆解
4.1 缓存场景:过期策略与内存淘汰的配合
用字符串做缓存是最常见的场景。核心问题是:过期时间设多长?内存满了怎么办?
过期时间的设计取决于数据更新频率和业务容忍度。比如用户会话通常设30分钟,验证码设5分钟,热点新闻设1小时。但如果你设了过期时间,还要考虑Redis的内存淘汰策略。当内存达到maxmemory时,Redis会根据maxmemory-policy淘汰key。常见的策略有:
noeviction:不淘汰,写入报错。适合数据不能丢的场景。allkeys-lru:淘汰最近最少使用的key。适合纯缓存。volatile-lru:只淘汰设置了过期时间的key。适合混合存储。
我踩过的坑是:用allkeys-lru策略,结果把没设过期时间的配置项也淘汰了。后来改成volatile-lru,只淘汰缓存数据,配置项用持久化存储。所以如果你的Redis既做缓存又存配置,一定要区分对待,给缓存设过期时间,配置不设或设很长。
另一个细节:SET命令的EX参数和EXPIRE命令的区别。SET key value EX 60是原子设置值和过期时间,而SET+EXPIRE是两步。在高并发下,两步操作中间可能被其他客户端读取到没有过期时间的值。所以能用SET EX就别用两步。
4.2 分布式锁:从SETNX到Redlock的简化实践
分布式锁是字符串操作的高阶应用。最简方案:
SET lock:resource unique_id NX EX 30NX保证只有第一个客户端能设置成功。EX 30保证锁30秒后自动释放,避免死锁。unique_id是客户端生成的唯一标识,用于释放锁时校验。
释放锁不能直接DEL,因为可能误删别人的锁。正确做法是用Lua脚本:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end这个脚本先比较值再删除,保证原子性。我见过有人用GET+DEL两步,结果在GET之后锁过期,另一个客户端拿到锁,然后DEL把别人的锁删了。这种bug在低并发下很难复现,但一旦出现就是生产事故。
锁的过期时间设多长?这取决于业务执行时间。如果业务可能执行超过30秒,你需要一个续期机制(看门狗)。但续期会增加复杂度,我的建议是:尽量让业务在锁过期时间内完成,如果实在不行,用更长的过期时间+续期线程。但续期线程本身也可能挂掉,所以要有兜底。
4.3 计数器与限流:滑动窗口的字符串实现
用INCR做计数器很简单,但做限流要考虑时间窗口。固定窗口限流:
INCR rate:user:123:202401011200 EXPIRE rate:user:123:202401011200 60这个key的命名包含了分钟级时间戳,每分钟一个key,自然实现固定窗口。但固定窗口有临界问题:比如限制每分钟100次,用户在12:00:59发了100次,12:01:00又发100次,实际两秒内发了200次。滑动窗口可以解决这个问题,但需要更复杂的数据结构,比如用ZSET存时间戳。不过如果只是简单限流,固定窗口够用,而且字符串操作性能最好。
另一个技巧:用INCRBY做批量计数。比如统计接口调用次数,每次调用INCRBY api:count 1,比INCR多一个参数但语义更清晰。如果要做加权计数,比如不同接口权重不同,INCRBY可以直接加权重值。
4.4 会话存储:序列化格式与过期刷新
用字符串存会话数据,通常是把对象序列化成JSON或MessagePack。这里的关键是过期刷新:用户每次请求都要刷新会话过期时间,否则30分钟后会话失效,用户被踢出。
刷新过期时间用EXPIRE命令:
EXPIRE session:abc123 1800但每次请求都调EXPIRE会增加Redis负载。优化方案是:只在剩余时间少于一半时才刷新。比如会话30分钟过期,你可以在剩余15分钟时刷新。这样每个会话最多刷新两次,减少命令调用。
序列化格式的选择:JSON可读性好但体积大,MessagePack体积小但需要额外库。我实测过,同样的会话数据,JSON是1.2KB,MessagePack是0.8KB。如果会话量大,MessagePack能省不少内存。但JSON的调试友好性更好,用GET命令直接能看到内容。所以我的建议是:开发环境用JSON,生产环境根据内存压力决定。
5. 常见问题与排查技巧实录
5.1 命令报错速查表
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
ERR value is not an integer or out of range | 对非整数执行INCR | 检查key的值是否为数字,用TYPE命令确认 |
ERR increment or decrement would overflow | 整数溢出 | 加业务上限判断,或用INCRBYFLOAT |
WRONGTYPE Operation against a key holding the wrong kind of value | key的类型不是字符串 | 用TYPE命令查看实际类型,可能是list或hash |
ERR syntax error | 命令参数拼写错误 | 检查SET的EX/PX/NX/XX参数顺序和拼写 |
OOM command not allowed when used memory > 'maxmemory' | 内存超限 | 调整maxmemory-policy或扩容 |
5.2 大key的发现与拆分
大key是Redis的性能杀手。一个字符串key如果value超过10KB,就值得关注;超过100KB,基本可以确定是大key。发现大key的方法:
redis-cli --bigkeys这个命令会扫描所有key,统计每种类型最大的key。但注意它会阻塞Redis,建议在从节点执行。
拆分大key的策略:如果是一个大JSON,可以拆成多个小key,用hash存储字段。比如用户信息,不要存成一个user:123的大字符串,而是用HSET user:123 name xxx age 20。这样每个字段可以单独更新,也避免了单key过大。
但hash也有自己的问题:字段太多时,HGETALL会阻塞。所以如果字段超过1000个,考虑分片,比如user:123:part1、user:123:part2。
5.3 过期时间不生效的排查思路
有时候你设了EXPIRE,但key就是不过期。排查步骤:
- 用
TTL key查看剩余时间。如果返回-1,说明没设过期;返回-2,说明key不存在。 - 检查是否被其他命令清除了过期时间。比如
SET会清除过期时间,GETSET也会。 - 检查是否被
PERSIST命令移除了过期时间。 - 检查Redis的
maxmemory-policy,如果是noeviction,内存满时不会淘汰,但过期key仍会被删除。 - 检查是否有客户端在频繁
SET这个key,每次SET都会重置过期时间。
我遇到过一个案例:某服务用SET更新缓存,但没加KEEPTTL,结果每次更新都把过期时间清掉了。后来改成SET key value EX 3600,问题解决。
5.4 批量操作超时的优化记录
有一次我用MGET一次取5000个key,结果客户端超时。排查发现是网络带宽打满,5000个key的响应包太大。优化方案:
- 拆分成每批500个key,循环
MGET。 - 用管道替代
MGET,但管道也是批量发送,效果类似。 - 如果key有规律,用
SCAN+MGET组合,但SCAN有重复风险。
最终我用了分批MGET,每批200个key,超时问题解决。实测下来,200个key的MGET在局域网内耗时约1毫秒,5000个key需要25毫秒,但网络传输时间占了大头。所以批量大小要根据网络带宽和value大小调整,没有固定值。
6. 我的实操心得与避坑清单
6.1 命令选择的三条原则
第一,能用单命令复合参数就别用多命令。SET key value EX 60 NX比SETNX+EXPIRE安全,因为原子。第二,能用整数就别用浮点。INCR比INCRBYFLOAT快且精确。第三,能用批量就别用循环。MSET比1000次SET快50倍以上。
6.2 过期时间的设置技巧
过期时间不要设太短,否则缓存频繁失效,数据库压力大。也不要设太长,否则内存回收慢。我的经验是:热点数据设1小时,普通数据设30分钟,冷数据设10分钟。如果业务允许,加一个随机偏移,比如EX 3600 + random(0, 300),避免同一时间大量key同时过期。
6.3 监控与告警的关键指标
用INFO memory查看内存使用,用INFO stats查看命令调用次数。重点关注expired_keys和evicted_keys,如果evicted_keys持续增长,说明内存不足,需要扩容或调整淘汰策略。用SLOWLOG GET查看慢查询,如果字符串操作出现在慢日志里,通常是大key或批量操作导致的。
6.4 一个真实的内存泄漏排查
某服务用INCR做计数器,但忘了设过期时间,结果计数器key越积越多,内存持续增长。排查时用SCAN 0 MATCH counter:* COUNT 1000扫描,发现几十万个计数器key。解决方案:给计数器设过期时间,比如EXPIRE counter:xxx 86400,每天自动清理。这个坑很常见,因为INCR不会自动设过期,必须手动加。
6.5 字符串操作的性能边界
单条SET命令的QPS在单机Redis上大约10万,GET类似。INCR因为涉及写操作,QPS略低,大约8万。MSET批量1000个key的QPS大约1000次批量操作,相当于100万次单key写入。但这些数字受网络、CPU、内存影响很大,实际压测才能确定你的环境上限。
最后分享一个小技巧:如果你不确定某个命令的行为,用redis-cli的MONITOR命令实时查看所有命令,或者用DEBUG OBJECT key查看key的内部信息。这些工具在排查问题时比看文档更直接。