Redis String命令看着是最没技术含量的一块——SET、GET新手十分钟就能跑通。但我带过几个后端新人之后发现真正在线上栽跟头的往往就是这些最简单的命令SETNX加锁忘了原子过期导致死锁INCR打到2^63-1直接报错把下单流程卡死SETRANGE顺手把 value 撑到几百兆把内存打爆还有拿STRLEN当字符数用去做中文长度校验的。Redis String命令的坑不在会不会用而在知不知道它背后做了什么、边界在哪。这篇我按实战顺序重新捋一遍先从 SDS 底层和编码切换讲起再逐组拆开基础读写、数值计数、位操作、过期与锁这四类命令的真实用法和边界最后给一份我平时排查时真会翻的问题速查表。不管你是刚背完命令表想找手感的新手还是已经写过几万行业务代码但被线上问题追着跑的老手下面这些内容至少有一半是你命令手册里看不到的。1. Redis String 到底是个什么东西1.1 从 SDS 底层结构说起很多人以为 Redis 的 String 就是 C 语言的char *这是个很危险的误解。Redis 自己实现了一套叫SDSSimple Dynamic String简单动态字符串的结构用在所有需要字符串的地方包括 key、value 和 AOF 缓冲区。标准 SDS 头里有四个字段len当前字符串实际占用字节数、alloc已分配缓冲区总大小、flags标识 SDS 头类型、以及柔性数组buf[]。这个设计带来的第一个直观收益是O(1) 取长度。C 字符串求长度要遍历到\0SDS 直接读len字段所以STRLEN是常数复杂度哪怕 value 有 512MB 也一样快。第二个收益是二进制安全SDS 不靠\0判断结尾靠len所以你往 value 里塞\0、塞 JPEG 的原始字节、塞 protobuf 序列化结果都没问题。这一点非常关键——很多业务里把图片缩略图直接存 String靠的就是这个特性。第三个收益是杜绝缓冲区溢出。SDS 修改前会检查alloc - len是否够用不够就先扩容再写不像strcat那样闷头往后怼。第四个是空间预分配与惰性释放当len小于 1MB 时扩容后alloc会预分配成len的两倍len达到或超过 1MB 时每次多预留 1MB。而缩短字符串时不立刻free掉多余空间多余的记在alloc - len里留给下次增长用。注意预分配和惰性释放是用内存换效率的经典取舍。频繁APPEND大 value 时你会看到used_memory比实际数据量大不少这不是内存泄漏是 SDS 在帮你省 realloc 的次数。真在意内存就别拿 String 当 append 日志用改用 List 或 Stream。1.2 三种编码int、embstr、raw 的切换逻辑SDS 是底层但 Redis 为了进一步省内存给 String 准备了三档redisObject编码是通过OBJECT ENCODING key能直接看到的编码触发条件内存布局特点int值能完整表示为 64 位有符号整数且长度不太大redisObject的ptr指针位直接存整数不额外分配最省内存INCR极快embstr字符串长度 ≤ 44 字节redisObject和 SDS 一次性连续分配一次内存分配、一次释放缓存友好只读raw字符串长度 44 字节redisObject与 SDS 分开两次分配支持修改APPEND、SETRANGE走这条44 这个数字不是拍脑袋来的64 字节的jemalloc内存块减去redisObject的 16 字节再减去 SDS 头最少 3 字节和结尾\0的 1 字节剩下正好 44。这么设计是为了让embstr完整塞进一个内存块减少一次寻址。这解释了两个实操中很常见的现象。第一SET k 123之后OBJECT ENCODING k是int你APPEND k 4编码会立刻变成raw因为int和embstr都是只读的任何修改都会先转成raw再改而且转过去就回不来了——哪怕你把值改回123编码依然是raw。第二很多人做计数器时图省事存了0001这种带前导零的字符串结果INCR报错因为它不是合法整数表示编码是embstr而不是intINCR尝试解析时按严格整数规则会失败。实操心得做缓存 key 时尽量把 value 控制在 44 字节以内能白拿一次连续内存分配的性能。但别为了这个去截断数据业务正确性永远优先于编码优化。1.3 为什么 String 是 Redis 最通用的数据类型我见过不少团队一上来就用 Hash 存对象、用 ZSet 做排行榜把 String 当成临时放点东西的地方。这其实是把 Redis 最强的一张牌打废了。String 是唯一一个同时具备二进制安全、原子数值运算、位级操作、批量读写、可携带过期语义的类型这意味着同一块存储能覆盖缓存、计数、位图、分布式锁、简单消息标记这些完全不同的场景。从系统设计角度看String 还是所有其他类型的构造基础。List 的每个元素、Hash 的每个 field 和 value、Set 的每个成员、ZSet 的每个成员底层都是 SDS。你把 String 的 SDS 和编码机制搞明白了后面学其他类型几乎是顺水推舟。从协议开销看String 也是最轻的。SET一条命令客户端发出大概几十字节服务端回OK五个字节。相比之下 Hash 存单个字段要带 field 名ZSet 要带 score在 QPS 高、value 小的场景里通信量差距会被放大。真正需要提醒的是别拿 String 硬塞关系型数据。有人把一整行数据库记录序列化成 JSON 塞进一个 String读的时候整块拉出来在应用层反序列化改一个字段要重写整块。数据量小的时候看不出问题一旦对象涨到几十 KB网络和序列化开销会变得很难看还会引发后面要讲的大 key 问题。这种场景应该换 Hash或者拆成多个单字段 String。2. 基础读写命令从 SET 的一堆参数讲起2.1 SET、GET、DEL 的返回值陷阱SET用起来最没门槛但它返回的是OK而不是受影响行数这一点从 SQL 转过来的人常常懵一下。GET的行为更值得说key 不存在返回nilkey 存在但 value 是空字符串返回的是空串。这两个在协议层面完全不同很多客户端把nil映射成null、把映射成空字符串但如果你用某些语言的低层封装nil可能变成false或者空字节数组这时候不存在和存在但为空就被搅在一起了。不只是GETEXISTS在 Redis 3.0 之后已经支持多 key返回的是存在的 key 的个数而不是布尔值。老代码里if (EXISTS a) ...这种写法在新版本里如果传多个 key 会返回大于 1 的值如果语言层面把它强转成布尔倒是没问题但如果拿去做数值判断就会出错。这类命令语义升级导致的兼容性问题我建议在升级 Redis 大版本时统一过一遍命令清单。DEL也是容易踩的它返回被成功删除的 key 数量删不存在的 key 返回 0这不是失败。真正的失败只有类型不匹配比如对某个 List 的 key 执行DEL不会失败DEL是类型无关的但GET一个 List 会报WRONGTYPE。另外 Redis 4.0 引入的UNLINK是DEL的异步版本把内存回收丢到后台线程删大 key 时用它不会卡住主线程生产环境处理大 value 我基本都用UNLINK。2.2 SET 的 NX、XX、EX、PX、GET 组合参数拆解SET真正的威力在参数组合上。完整形式是SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]几个参数的语义必须记牢NX只有 key 不存在时才设置等价于老命令SETNX。这是实现抢锁抢占标记的核心。XX只有 key 已存在时才设置用来做只更新不新增能防止缓存击穿时把不存在的 key 意外写进去。EX/PX设置过期时间单位分别是秒和毫秒。Redis 6.2 之后还有EXAT/PXAT直接给绝对时间戳做定时失效非常顺手。KEEPTTL保留原有 TTL配合XX使用可以实现只改值不改过期时间这在用 String 做会话缓存时特别好用。GET设置新值的同时返回旧值一条命令完成读旧写新。Redis 6.2 引入。为什么强调用SET ... NX EX而不是SETNXEXPIRE因为那是两条命令。第一条执行完、第二条还没发的瞬间如果客户端崩了或者网络断了就留下一个永不过期的锁 key这就是经典的死锁源头。SET key val NX EX 30是一条命令过期和设置在服务端原子完成没有中间态。GET参数有个坑要注意如果 key 原来的值不是字符串类型比如是个 ListSET ... GET会报WRONGTYPE而且这个设置操作不会执行。有人以为它会强转实际不会。另外GET参数在 key 不存在时返回nil所以老代码里先GET判断再SET的两步逻辑可以合并成一步但也要注意返回nil和返回旧值是两种业务分支。2.3 MSET、MGET 与批量操作的网络开销权衡单个GET的耗时几乎全在网络上。同一个机房内 RTT 大概 0.1~0.5 毫秒你循环GET100 个 key光往返就是几十毫秒而 Redis 服务端处理这 100 个命令可能只需要几十微秒。MGET把 100 次往返压成 1 次性能差距是数量级的。MSET同理而且它本身是原子操作——要么全部成功要么全部失败失败的情况通常是参数个数不对或内存不足。MSETNX更严格只要有任意一个 key 已存在整批都不设置返回 0。这个特性可以拿来做一批标记同时抢占的场景。但批量绝不是越大越好。Redis 单线程处理命令一个MGET带 1 万个 key服务端要在这一个命令里完成 1 万次哈希查找和序列化期间所有其他客户端的请求全部排队。我就遇到过某服务一次MGET拉 5 万个 key导致 Redis 出现 200ms 的响应毛刺进而引发上游接口超时雪崩。注意批量命令的单批 key 数量我一般控制在 100~500 之间超过就分片。判断依据不是经验值而是你自己压测一下单次MGET的 P99 耗时控制在 1ms 以内比较安全。MSET还有个容易忽略的点它不支持过期时间。想批量写且带 TTL只能走 pipeline 发多条SET ... EX或者用MULTI/EXEC包起来。我个人更倾向 pipeline因为MULTI里命令多了会占用服务端内存暂存队列而 pipeline 只是客户端攒批服务端压力更平滑。3. 数值与计数INCR 系列的原子性为什么重要3.1 INCR、DECR、INCRBY 的溢出边界INCR、DECR、INCRBY、DECRBY这一组的核心价值是单命令原子。Redis 是单线程执行命令的INCR在服务端就是读出来-加一-写回去一气呵成中间不可能被别的客户端插队。这就把应用层最头疼的读改写竞态彻底消除了不用加锁、不用乐观重试。代价是它们只能处理 64 位有符号整数范围是-9223372036854775808到9223372036854775807。超了这个范围INCR会直接报ERR increment or decrement would overflow并且不会修改原值。这个不修改很关键意味着你的计数器会卡死在边界值上后续所有INCR全部失败。如果你的业务代码没有兜底那就是一串报错。什么时候会撞到边界正常业务量不太可能但有两种情况会一是有人把过期时间设得极长或者干脆没设计数器从上线跑到几年后二是有人把时间戳、金额以分为单位当计数器用几十亿的量级离9.22e18还很远但如果发生错误的重复递增比如消息重试导致同一笔加了 10 万次就有机会冲上去。处理方案我一般用两个第一给计数器设过期时间让它天然归零第二在应用层捕获溢出异常走重置为初始值或者告警分支。import redis r redis.Redis(decode_responsesTrue) def safe_incr(key: str, step: int 1, expire_seconds: int 86400): # 用 pipeline 保证 INCR 与 EXPIRE 一起发出减少往返 pipe r.pipeline() pipe.incr(key, step) pipe.expire(key, expire_seconds, nxTrue) # 只在无 TTL 时设置避免刷新已有 TTL count, _ pipe.execute() return count注意EXPIRE的nx选项是 Redis 7.0 才有的老版本要改成TTL判断。nx这个细节很重要如果每次INCR都无条件EXPIRE那计数器的 TTL 会被不断重置永远不会过期反而更容易撞边界。3.2 用 INCR 做计数器、限流与简单发号器最常见的三种用法我分别说一下边界。第一是业务计数器比如某篇文章的阅读数、某个商品的曝光量。写法就是INCR article:read:10086。这类场景通常允许短暂不一致对精度要求不高但要注意一个人反复刷新算不算多次这个业务问题Redis 只负责加。第二是固定窗口限流。典型写法是key rate:{userId}:{yyyyMMddHHmm}INCR之后如果结果等于 1 就EXPIRE 60超过阈值就拒绝。这里有个隐藏 bug如果INCR成功了但EXPIRE因为客户端崩溃没执行这个 key 就永久存在该用户会被永久限流。必现的修复方式是用pipeline或 Lua 把两件事绑在一起或者用SET key 0 EX 60 NX预初始化再INCR。-- 固定窗口限流原子执行返回当前计数 local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end return current第三是简易发号器。INCR order:seq拿到的数字天然唯一且单调递增用作订单号后缀、任务 ID 完全够用。但别拿它当严格连续的流水号用——如果 Redis 主从切换发生从库升主时可能因为异步复制丢掉最后几条命令导致号码回退出现重复。对连续性有强要求的场景必须上加锁或落库。3.3 INCRBYFLOAT 的精度坑INCRBYFLOAT看起来是浮点版INCRBY实际上坑最多。它内部用long double做运算然后把结果用最多 17 位有效数字的格式写回去。这意味着INCRBYFLOAT k 0.1三次之后你拿到的不是0.30000000000000004而是0.3——Redis 帮你做了截断。但这不代表没问题。long double在不同平台上的实际精度不一样x86 上是 80 位扩展精度ARM 上通常就是 128 位或 64 位同一份代码在不同架构的机器上跑出来的结果尾数可能不同。更实际的问题是你不能把它当作可靠的金额累加器。金额运算一律用整数存分用INCRBY加在应用层做格式化。我见过有人用INCRBYFLOAT累加订单金额对账时差了 0.01 元排查了大半天。另外INCRBYFLOAT的入参本身也不能是nan或inf否则直接报错。而且如果 key 原来存的是1.0e3这种科学计数法字符串它能正确解析成 1000但写回去之后就变成普通的1000这个隐式格式变化有时会影响下游的解析逻辑。实操心得浮点数在 Redis 里唯一相对安全的用法是存温度、存评分这类允许微小误差、且不做严格等值比较的指标。凡是涉及钱、涉及库存、涉及必须精确对账的一律走整数。4. 位操作String 最被低估的能力4.1 SETBIT、GETBIT 与位图签到SETBIT key offset value把 value 的第 offset 位从 0 开始最高位在左设成 0 或 1返回该位原来的值。这个命令把 String 当成一个巨大的比特数组offset 最大能到2^32 - 1也就是理论上单个 key 能表示 42 亿个位。真正好用的是它的内存效率。做一年签到365 天只需要 365 位也就是 46 字节。100 万用户的签到数据全部塞在一个 key 里也不过 46MB 左右。换成用 Hash 存用户 日期 状态内存要翻几十倍。签到场景的写法是SETBIT sign:{userId}:{year} {dayOfYear} 1查某天签没签就是GETBIT sign:{userId}:{year} {dayOfYear}查连续签到就是从今天往前循环GETBIT到第一个 0 为止。补签就是把对应位设成 1。这里必须提醒一个非常容易踩的坑SETBIT会自动扩展字符串。如果你 offset 写错了写成SETBIT k 4000000000 1Redis 会立刻给这个 key 分配约 500MB 内存。而且这个内存分配是一次性、不可逆的即使你把那一位改回 0字符串长度也不会缩回去。我就见过有人把用户 ID 直接当 offset 用结果某个大 ID 用户一登录直接给 Redis 塞了个 GB 级 key。注意位图的 offset 必须是由你控制的、从 0 开始连续增长的索引绝不能直接用外部系统的 ID。要么在应用层做 ID 到序号的映射要么老老实实换别的结构。4.2 BITCOUNT、BITPOS 的统计细节BITCOUNT key [start end [BYTE | BIT]]统计指定范围内值为 1 的位数。不带范围就是统计整个字符串。这里最容易搞混的是range 的单位默认按字节BYTE计数BITCOUNT k 0 0统计的是第一个字节里的 1。Redis 7.0 之后可以用BIT参数显式指定按位统计比如BITCOUNT k 0 7 BIT。为什么要强调这个因为按字节统计时边界不对齐会导致结果偏差。你要统计第 10 位到第 20 位用字节范围根本表达不了只能用BIT模式。老版本没有BIT模式就得自己在应用层做位移和掩码很别扭。BITPOS key bit [start [end [BYTE | BIT]]]找第一个指定值的位返回它的位置。它有两个反直觉的地方第一查找 0 时如果没找到也就是范围内全是 1不会返回 -1而是返回end * 8末尾位置之后那个位置前提是你没指定 end。如果你指定了 end 且范围内全是 1才返回 -1。这个差异经常让代码逻辑出错。第二BITPOS的 start/end 里start 指的是字节偏移但返回的值是位偏移。你传 start1返回 8 表示第 8 位别以为返回 1 就是第 1 位。4.3 BITOP 与 BITFIELD 的组合用法BITOP AND|OR|XOR|NOT destkey key [key ...]对多个 key 做按位运算结果存到 destkey返回结果字符串的字节长度。AND最典型的使用场景是求交集比如你有两个位图分别记录这周登录过的用户和这周下过单的用户BITOP AND一出结果位图里为 1 的就是既登录又下单的用户再用BITCOUNT一数就是精准人数。这里要注意BITOP的对齐规则参与运算的所有 key 中最短的会按 0 补齐到最长那个的长度。所以如果某个用户位图因为 ID 偏移设置得特别长运算结果也会变长白白多占内存。做位图运算前建议先统一控制各 key 的长度。NOT只接受一个源 key语义是逐位取反这个属性在算未签到天数时很有用生成一个全 1 的掩码位图BITOP XOR一下就拿反了。不过注意NOT会把补位也取反末尾的 0 位会变成 1需要额外处理有效位范围。BITFIELD是这组里最强也最复杂的命令它支持任意位宽u1到u63i1到i64的整数读写一条命令里可以组合多个子操作# 在 offset 0 处读一个无符号 8 位在 offset 8 处写一个无符号 16 位再把 offset 24 处自增 5 BITFIELD metrics GET u8 0 SET u16 8 1000 INCRBY i8 24 5 # 溢出策略WRAP 回绕、SAT 饱和、FAIL 报错 BITFIELD counter OVERFLOW SAT INCRBY u7 0 1OVERFLOW这个子命令是BITFIELD的灵魂。默认是WRAP像 C 语言一样回绕而SAT会在达到最大/最小值时停住FAIL会返回 nil 表示本次操作失败。用一个 7 位的无符号字段做每日计数最大 127配合SAT就是天生的封顶计数器超了也不会溢出成负数。BITFIELD的 offset 还能用#前缀表示第几个字段比如BITFIELD k GET u8 #2表示从第 2 个 8 位字段开始读等于offset 16。位宽不固定时用这个能少算很多乘法。5. 过期语义、锁与原子性边界5.1 TTL、EXPIRE、PERSIST 的真实行为EXPIRE key seconds返回 1 表示设置成功0 表示 key 不存在。除了秒级的EXPIRE还有PEXPIRE毫秒、EXPIREAT绝对秒级时间戳、PEXPIREAT绝对毫秒时间戳。做零点过期活动结束时间到期这类需求用EXPIREAT比算差值再用EXPIRE稳得多因为它不受客户端和服务端时钟偏差影响——只要时间戳是同一个绝对时间。TTL返回值的三种情况必须记死正整数表示剩余秒数-1表示 key 存在但没有过期时间-2表示 key 不存在。很多代码写if ttl 0 then refresh把-1当成不存在处理结果给永久 key 加上了过期时间把缓存变成了一次性的。过期到底是怎么删的Redis 用的是惰性删除 定期删除的组合。惰性删除是指访问某个 key 时才检查它是否过期过期就删掉并返回不存在。定期删除是后台每 100ms 触发一次频率由hz控制默认 10随机抽 20 个带 TTL 的 key删掉其中过期的如果过期比例超过 25%就再抽一轮并受active-expire-effort默认 1范围 1~10限制总时长。这个机制意味着过期时间到了内存不一定立刻释放。如果一个 key 过期后一直没人访问它可能要多待几秒甚至更久。所以在INFO memory里看到used_memory没降别急着怀疑内存泄漏先看expired_keys有没有在涨。如果缓存里混入大量不同 TTL 的 key把hz调到 10~100 之间能明显提高清理速度代价是 CPU 占用上升。PERSIST key是去掉过期时间返回 1 成功、0 表示 key 不存在或本来就没有 TTL。这个命令要慎用它会把一个本该自动清理的 key 变成永久的。5.2 SETNX 加锁的非原子性坑与正确姿势先说清楚错误示范这是我在代码评审里见得最多的# 错误示范两条命令中间可能挂 if r.setnx(lock:order:1001, 1): r.expire(lock:order:1001, 30) do_something()两个致命问题。第一SETNX成功但EXPIRE没执行客户端崩溃、网络闪断、进程被 kill锁就永久存在后续所有请求全部拿不到锁业务彻底卡死。第二即使EXPIRE执行了中间也有个时间窗口。正确写法只有一种SET lock:order:1001 random-token NX EX 30一条命令搞定服务端原子执行。返回OK就是拿到锁返回nil就是没拿到。但拿到锁只是第一步释放锁的坑更大。如果写成DEL lock:order:1001会发生这样的灾难客户端 A 拿了锁但业务执行超时超过 30 秒锁自动过期客户端 B 拿到了新锁开始干活这时 A 终于跑完了执行DEL把 B 的锁删了。于是 C 又能拿到锁B 和 C 同时持锁互斥彻底失效。修复方式是用一个随机 token 标识持有者释放时先比对再删而且比对 删除必须原子-- KEYS[1] 锁 keyARGV[1] 持有者 token if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这个脚本能一次完成校验和删除因为 Lua 脚本在 Redis 里是原子执行的中间不会有其他命令插入。import uuid import redis r redis.Redis(decode_responsesTrue) UNLOCK_SCRIPT if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) end return 0 unlock r.register_script(UNLOCK_SCRIPT) def try_lock(key: str, ttl: int 30): token uuid.uuid4().hex ok r.set(key, token, nxTrue, exttl) return token if ok else None def release_lock(key: str, token: str): return unlock(keys[key], args[token])5.3 锁续期与几个绕不开的现实约束即便写法全对SET NX EX也只是够用级别的锁它有三个绕不开的约束做方案选型时必须想清楚。第一是业务执行时间不可控。你设了 30 秒 TTL但某次数据库慢查询让业务跑了 40 秒锁在第 30 秒就自动释放了互斥窗口消失。要解决只能做看门狗续期后台起一个定时任务每 10 秒检查锁是否还是自己的是就EXPIRE续到 30 秒。续期的判断也必须用 Lua 脚本比对 token不能裸EXPIRE否则会把别人的锁续上。第二是主从切换丢锁。Redis 主从复制是异步的客户端在主节点拿到锁主节点还没把这条命令同步给从节点就宕机了从节点升主之后完全不知道这把锁存在另一个客户端就能立刻拿到同一把锁。对互斥要求极高的场景比如扣款这个风险不能接受需要引入额外的仲裁机制。第三是锁的粒度。见过有人用一把全局锁包住整个下单流程QPS 高的时候 Redis 上全是锁竞争性能还不如直接在数据库层做行锁。锁的 key 一定要细到业务实体级别lock:order:{orderId}、lock:user:{userId}这种。实操心得分布式锁不是越强越好是要和业务风险匹配。缓存重建这类重复执行代价小的场景用SET NX EX加 token 校验足够涉及资金变动的老老实实交给数据库事务或者带版本号的乐观锁别指望 Redis 兜底。6. 常见问题与排查实录6.1 命令级问题速查表下面这些是我在值班和代码评审中反复遇到的按现象归类可以直接对着查现象 / 报错根因处理方式ERR value is not an integer or out of rangevalue 带前导零、含空格、超出 64 位范围或压根不是数字用OBJECT ENCODING确认编码改存规范整数应用层先校验ERR increment or decrement would overflowINCR结果超出2^63-1或低于-2^63设 TTL 让计数自然归零捕获异常后重置或告警WRONGTYPE Operation against a key holding the wrong kind of valuekey 被别的类型占用Hash/List/ZSet或曾被APPEND改成了 rawTYPE key确认统一命名空间前缀区分类型STRLEN结果和业务预期对不上STRLEN返回字节数一个中文汉字占 3 字节字符数统计放应用层或用GETRANGE配合字符集处理SETBIT之后内存暴涨offset 用了外部大 ID触发字符串自动扩展建立 ID 到连续序号的映射上线前估算最大 offsetSETRANGE写入后读出来前面全是空字符目标 offset 超出原长度中间用\x00补齐明确SETRANGE只用于覆写已知长度的值扩展场景改用APPENDTTL返回-1但业务以为会过期只执行了SET没设过期或SET时漏了EX统一封装写入方法强制要求传 TTL分布式锁偶发失效、两个请求同时进入锁无 token 校验或 TTL 到期业务未完成加 token Lua 释放关键路径加续期BITPOS查 0 返回了一个很大的数未指定 end 且范围内全为 1返回end*8而非 -1显式传 end 参数对返回值做边界判断MGET引发响应毛刺单次批量过大阻塞单线程分片到每批 100~500 个过期 key 迟迟不释放内存惰性删除 定期删除的机制导致查看expired_keys指标必要时调高hz与active-expire-effort6.2 我踩过的几个真实坑与规避策略第一个坑拿STRLEN做长度校验。有个表单限制备注不超过 50 字服务端直接STRLEN判断是否大于 50。中文用户填 20 个汉字就被拒了因为STRLEN返回 60。这个错误的根源是把字节当字符。规避方式很简单长度校验一律在应用层用字符串长度 API 做Redis 只存不算。第二个坑大 key 拖垮整个实例。有个业务把用户的历史操作日志序列化成 JSON 字符串用APPEND往后追加跑了半年单个 key 涨到 80MB。某次GET这个 key 时 Redis 出现 300ms 阻塞因为要一次性把 80MB 序列化到输出缓冲区期间所有请求排队。事后我们的做法是把APPEND换成LPUSHLTRIM保留最近 N 条并且加了 key 大小监控单个 String value 超过 100KB 就报警。第三个坑SETRANGE把 value 撑爆。有人用SETRANGE往一个固定位置写数据以为像数组一样赋值结果第一次写入时 offset 设在 1MB 位置Redis 直接分配了 1MB 空间并用\x00填满。现在的规范是SETRANGE只在已知 value 长度且 offset 在范围内时使用扩展性写入一律禁止。第四个坑批量EXPIRE用了 pipeline 但没设错。有次做缓存预热用 pipeline 发了 5 万条SET其中夹杂着EXPIRE。因为客户端库的 pipeline 有缓冲区上限超出的部分被丢弃且没报错导致几千个 key 变成了永久的。后来统一改成SET key value EX ttl一条命令完成既省往返又不会漏。第五个坑以为DEL是异步的。删一个 200MB 的 keyDEL会同步释放内存主线程卡了将近 1 秒。后来所有可能的大 key 删除全部换成UNLINK把释放交给后台线程。6.3 面试与实战里最容易被追问的几个点顺带整理一下我给别人做技术评审、或者被问到时的高频问题答案其实都藏在前面几节里。String 为什么是二进制安全的因为底层是 SDS用len字段记录长度而不是靠\0判断结尾所以值里可以包含任意字节包括\0。SETNX和SET NX有什么区别SETNX是老命令只能设置值不能带过期SET key val NX EX是新写法把设置和过期合成一条原子命令分布式锁必须用后者。embstr和raw的区别为什么是 44 字节embstr把redisObject和 SDS 一次分配在连续内存里只读适合短字符串超过 44 字节就退化成raw分两次分配。44 是 64 字节内存块减去对象头和 SDS 头的最小开销得出的。任何对embstr的修改都会先转成raw且不可逆。怎么用 Redis 统计一亿用户的连续签到每个用户一个位图 key一年 365 位只占 46 字节SETBIT写入、GETBIT查询、BITCOUNT统计总天数。注意 offset 必须是连续序号不能直接用外部 ID。INCR是原子的那它加锁了吗没有锁。原子性来自 Redis 单线程执行命令模型一条命令执行期间不会被其他命令打断。这也是为什么它快但也是为什么长耗时命令比如大 key 的DEL、大批量MGET会拖慢所有人。最后补一个小技巧我个人排查 String 相关问题时的固定动作先TYPE确认类型再OBJECT ENCODING看编码然后STRLEN看字节长度最后MEMORY USAGE key看实际内存占用。这四步下来八成的问题现场就清楚了。剩下两成交给SLOWLOG GET和INFO commandstats看看是不是某个命令被调得太频繁。这套流程我用了几年比一上来就翻日志快得多。