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参数可以原子地完成同样的事而且避免了SETNXEXPIRE两步操作之间的竞态问题。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次请求。你可以用INCREXPIRE组合INCR user:123:requests EXPIRE user:123:requests 60但这两条命令不是原子的如果INCR之后客户端崩溃EXPIRE没执行这个key就永不过期。更好的做法是用Lua脚本或者SETINCR的组合。不过更简单的方案是直接用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之后被标记为废弃推荐用SETGET参数替代SET key newvalue GET但很多老代码还在用GETSET。它的典型用途是重置计数器比如你有一个每日任务每天凌晨要把计数器归零同时获取昨天的值做报表。用GETSET可以原子地完成GETSET daily:counter 0但这里有个坑如果key不存在GETSET返回nil然后设置新值。如果你没处理nil报表逻辑可能出错。而且GETSET会清除过期时间和SET一样。所以如果你需要保留过期时间得用SETGETKEEPTTL。SETNX的竞态问题更经典很多人用SETNX做锁然后EXPIRE设过期但这两步之间如果客户端崩溃锁就永远不过期。正确的做法是用SET key value NX EX 30一条命令完成。这个改动虽然小但能避免死锁。3.4 批量操作的性能与内存权衡MSET和MGET可以一次操作多个key减少网络往返。但批量操作有两个坑大key问题如果你一次MSET几万个keyRedis会阻塞处理这个命令因为它是单线程的。建议单次批量不超过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是原子设置值和过期时间而SETEXPIRE是两步。在高并发下两步操作中间可能被其他客户端读取到没有过期时间的值。所以能用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这个脚本先比较值再删除保证原子性。我见过有人用GETDEL两步结果在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.2KBMessagePack是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整数溢出加业务上限判断或用INCRBYFLOATWRONGTYPE Operation against a key holding the wrong kind of valuekey的类型不是字符串用TYPE命令查看实际类型可能是list或hashERR 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有规律用SCANMGET组合但SCAN有重复风险。最终我用了分批MGET每批200个key超时问题解决。实测下来200个key的MGET在局域网内耗时约1毫秒5000个key需要25毫秒但网络传输时间占了大头。所以批量大小要根据网络带宽和value大小调整没有固定值。6. 我的实操心得与避坑清单6.1 命令选择的三条原则第一能用单命令复合参数就别用多命令。SET key value EX 60 NX比SETNXEXPIRE安全因为原子。第二能用整数就别用浮点。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的内部信息。这些工具在排查问题时比看文档更直接。