ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis命令完全指南:核心类型、原子操作与线上避坑要点

Redis命令完全指南:核心类型、原子操作与线上避坑要点 1. 为什么Redis文档翻烂了线上还是频繁出事故很多人觉得Redis命令简单无非就是SET、GET、DEL这几个背一遍就完事了。我最初也是这个心态直到某天凌晨两点被运维电话叫醒——缓存里的数据大面积失效数据库连接被打满整个服务濒临宕机。排查到最后问题恰恰出在几条再熟悉不过的命令上有人用KEYS做模糊匹配有人把大对象一股脑塞进String还有人根本没搞懂EXPIRE的底层删除机制。Redis基本指令的真正价值不在于会用而在于在正确的场景用正确的指令并且知道每条指令背后的行为代价。这篇文章不打算做成命令手册的复读机而是围绕我实际用Redis这几年遇到的典型场景把常用指令按通用、五大核心类型、过期与持久化、批量与原子操作、踩坑实录五条线拆开来讲。适合刚入门想系统梳理命令的开发者也适合已经用了一段时间但总觉得差点火候的中间水平读者——你会发现很多知道的命令换个角度看其实理解得并不透彻。先说清楚一件事Redis的指令非常丰富官方文档给出的有二百多条但生产环境中高频使用的也就四五十条。把这几十条命令从语法、时间复杂度、适用场景、副作用四个维度彻底吃透价值远大于囫囵吞枣扫完整个命令表。2. 通用指令先搞清楚这组命令再谈业务2.1 查看帮助与命令信息HELP、COMMAND DOCS很多人在陌生环境里拿到Redis先敲PING然后就去问别人这个命令怎么用来着。其实Redis自带一套非常好用的在线帮助系统就是HELP命令。直接敲HELP能列出所有命令分类HELP SET能查看SET命令的完整语法、参数说明和返回值。新版Redis还提供了COMMAND DOCS返回的是结构化文档比HELP更适合程序化查询。我刚带团队的时候要求所有人必须养成两个习惯第一遇到没把握的命令先敲COMMAND INFO 命令名能看到复杂度标注第二看文档时重点看官方标注的O(1)、O(N)、O(logN)这直接决定了线上能不能用。比如LRANGE的复杂度是O(SN)S是start偏移量N是返回条数这意味着你取一个很长列表的末尾元素时代价比你想象的大得多。2.2 键空间的存量管理DBSIZE、SELECT、FLUSHDB、SWAPDBDBSIZE用来查看当前库的键数量SELECT用来切换逻辑库0到15号。这里有个常见的认知误区很多人以为Redis的多DB是资源隔离的好手段用0号库存业务数据、1号库存缓存、2号库存临时数据。实际在生产里多DB会带来很多麻烦——FLUSHDB误执行会清掉一整个库的数据又不支持按业务前缀精准清理而且Redis集群模式根本只支持0号库。我的建议是单机环境玩玩多DB可以生产环境统一用0号库靠key的前缀区分业务既方便SCAN扫描也方便统一过期时间管理。FLUSHDB和FLUSHALL是两条需要格外谨慎的命令。FLUSHDB清空当前库FLUSHALL清空所有库这两个操作都是同步阻塞的数据量大时会让Redis卡顿好几秒。更危险的是4.0之前没有恢复手段误操作只能靠备份恢复。现在可以通过FLUSHDB ASYNC走异步清空即便这样我仍然建议在运维侧做命令拦截把这两条命令在配置里改名或禁用。2.3 键是否存在与删除EXISTS、DEL、UNLINKEXISTS支持一次传入多个key返回存在的个数这个批量查询比循环单查高效很多。DEL是同步删除对于大key动辄几百MB的集合类型DEL会阻塞主线程于是UNLINK就派上了用场——它把释放内存的动作放到后台线程执行主线程能立刻返回。有一个容易被忽视的细节UNLINK并不是完全没有代价它只是让主线程不等待释放完成并没有改变大key本身对内存的占用和驱逐策略的影响。所以用UNLINK替代DEL解决的是阻塞问题不是大key问题该拆的key还是要拆。我曾经处理过一个事故有个List类型的key积累了上千万个元素同事用DEL删除后Redis直接阻塞了十来秒期间所有读写请求全部排队。换UNLINK后阻塞消失但内存的释放速度取决于后台线程的调度在内存压力极高的时刻依然可能出现短暂的延迟。这个案例我后面还会细讲这里先记住一个原则凡是删除集合类型的大key一律用UNLINK别用DEL。3. 五大核心类型的命令详解语法只是外壳场景才是灵魂3.1 String不只是SET和GETString是Redis最基础的类型也是被滥用得最厉害的类型。先说高频命令SET、GET、MSET、MGET、INCR、DECR、INCRBY、APPEND、SETNX、SETEX、GETSET、STRLEN。每个命令都对应一个典型场景但如果只记住语法很多优化机会就溜走了。SET的完整参数其实是SET key value [EX seconds] [PX milliseconds] [NX|XX] [GET]这个复合能力被很多人忽略。实现分布式锁时教科书里写着用SETNX加EXPIRE两条命令但这两步之间如果有网络抖动锁就会变成不死锁。正确的做法是SET lock_key unique_value NX EX 30一条命令完成原子加锁。这个细节我在项目里反复强调能用一条SET解决的事绝不要拆成两条命令去做因为TCP往返和原子性边界是实实在在的成本和风险。INCR系列命令则体现了Redis单线程模型的独特价值——自增操作天然具有原子性不需要加锁。用它做计数器、限流、发号器都非常可靠。注意INCRBYFLOAT做浮点累加时会引入浮点误差对精确度有要求的场景就别用它改用Lua脚本或落到数据库计算。关于String存储结构有一个面试必问但源码级的问题是为什么Redis要设计embstr和raw两种编码简单说短字符串44字节以内用embstr内存连续、分配一次就够长字符串用raw需要两次分配。这些细节平时用不到但能帮你理解为什么String不太适合存大对象——一个几MB的JSON塞进String内存碎片、网络传输、序列化开销都会放大。我在项目中处理用户画像缓存时用Hash代替了大JSON整体内存下降了约30%。3.2 Hash对象缓存的正确打开方式Hash类型对应的命令有HSET、HGET、HMGET、HGETALL、HDEL、HEXISTS、HINCRBY、HLEN、HKEYS、HVALS、HSCAN。它解决的核心问题是什么一个对象有多个字段如果每次只改其中一个字段把它序列化成JSON塞进String就得把整个对象取出来、反序列化、改字段、再序列化、写回代价惊人。用Hash可以直接操作单个字段HSET user:1001 name 张三 age 25一次写一个字段HGET取一个字段HINCRBY直接对某个计数字段做原子自增。HMGET的批量取字段能力尤其值得关注。拉取用户信息时HMGET user:1001 name age city一次拿到三个字段性能比循环HGET好得多。HGETALL则是要小心使用的命令它会把所有字段返回如果字段数量很大会产生O(N)的内存和网络开销。我在一个标签系统里见过有人把几万个属性全塞进一个Hash一调HGETALL直接把带宽打满。这种场景的替代方案是HSCAN做增量迭代或者干脆重新设计数据结构。Hash还有一个不太多人知道的细节字段非常多时它底层会从ziplist或listpack转换为hashtable转换本身有CPU开销且hashtable编码下HGETALL的代价线性上升。所以设计Hash时要有意识控制字段规模一般建议单key字段数控制在几百个以内这对大V的用户资料缓存这类场景特别重要。3.3 List消息缓冲与时间线场景的常青树List的常用命令是LPUSH、RPUSH、LPOP、RPOP、LRANGE、LLEN、LINDEX、LREM、LTRIM加上阻塞版本的BLPOP、BRPOP、BLMOVE。它的本质是一个双向链表所以头尾操作都是O(1)中间操作是O(N)。我在项目里用List做过两类事情一是轻量级任务队列生产者RPUSH消费者BLPOP。BLPOP是阻塞式弹出队列为空时连接会挂起等待配合超时参数可以做低延迟的异步处理这套方案在Redis 5.0引入Stream之前是很多小团队伪消息队列的标准做法。二是用户新鲜事Feed的时间线列表用LPUSH把新内容插到头部LRANGE 0 99取最近100条。List的几个坑必须说清楚。LREM按值删除时需要遍历链表大列表下极慢LINDEX取中间元素越靠中间越慢LRANGE取尾部大段数据也有O(N)风险。最典型的事故场景是列表越积越长在队列消费异常时无人清理某天有人调LRANGE 0 -1想看看里面还有啥结果O(N)操作直接把Redis拖垮。解决方案是定期用LLEN监控长度结合LTRIM裁剪过期数据。LTRIM这个命令容易被忽略LTRIM key 0 999把列表裁剪到前1000个元素是控制List无限增长最重要的防呆操作。3.4 Set去重、抽奖与集合关系的瑞士军刀Set的指令包括SADD、SREM、SMEMBERS、SISMEMBER、SCARD、SPOP、SRANDMEMBER、SMOVE以及集合运算SINTER、SUNION、SDIFF、SINTERSTORE、SUNIONSTORE、SDIFFSTORE。底层是哈希表所以SISMEMBER是O(1)的命中查询SCARD获得元素数量也是O(1)。应用场景大家最熟悉的是去重和抽奖。SPOP随机弹出N个元素天然适合抽奖场景SRANDMEMBER不弹出而只返回随机元素适合做推荐流的随机取样。这里有个容易混淆的点SPOP会真正移除元素SRANDMEMBER不会。做已读用户去重过滤时用SISMEMBER判断是否已存在比把集合全量SMEMBERS拉回来判断要快几个数量级。集合运算在大数据量下的表现要特别注意。SINTER、SUNION、SDIFF的复杂度都是O(N)N是参与运算集合的大小两个百万级集合做交集的耗时就已经非常可观。如果交集结果还需要存储复用用SINTERSTORE把结果落地成新key避免每次请求都重新计算。我做过一个共同好友功能最初直接SINTER两个大集合请求高峰期Redis CPU飙到90%改成定时用SINTERSTORE预计算结果后查询直接变成SISMEMBER级别的O(1)压力瞬间消失。3.5 ZSet排序业务的底层引擎ZSet是Redis里最有含金量的类型。命令有ZADD、ZREM、ZSCORE、ZINCRBY、ZRANK、ZREVRANK、ZRANGE、ZREVRANGE、ZRANGEBYSCORE、ZRANGEBYRANK、ZCOUNT、ZREMRANGEBYSCORE、ZREMRANGEBYRANK以及并集ZUNIONSTORE、交集ZINTERSTORE。它底层是跳表加哈希表插入、删除、按分数查询都是O(logN)排行榜类业务几乎是它的专场。做排行榜时最容易犯的错误是搞混ZRANGE和ZREVRANGE。ZRANGE从小到大排列ZREVRANGE从大到小排列。分数从高到低排应该用ZREVRANGE key 0 9 WITHSCORES取前十名分数从低到高才用ZRANGE key 0 9 WITHSCORES。ZRANK返回的是从小到大的排名ZREVRANK返回的是从大到小的排名这两个也是反着的。我把这两个反直觉的点写进团队规范因为踩坑的人实在太多了。ZINCRBY是一个被低估的命令它实现实时积分更新简直是量身定做用户每得一分ZINCRBY leaderboard 5 user_id原子更新。配合ZREVRANK可以实时拿到用户当前排名配合ZREVRANGE可以取榜单TopN。另外ZSET里元素唯一但分数可以相同并列排名需要自己额外处理Redis本身不会帮你算同分同排名的逻辑。4. 过期、持久化与淘汰机制三条命令遮蔽下的底层差异4.1 EXPIRE系列过期键是如何被真正清理的EXPIRE相关指令有EXPIRE、PEXPIRE、EXPIREAT、PEXPIREAT、TTL、PTTL、PERSIST。核心逻辑非常简单设置键的生存时间到期后键不可再被访问。但底层清理机制值得展开因为它直接解释了为什么我设了过期时间Redis内存却不降。Redis对过期键的处理有三条路径惰性删除、定期删除、内存淘汰。惰性删除是访问时发现已过期才删所以一个过期键只要一直不被访问就会一直占着内存。定期删除是后台每100ms抽样检查一批键有限删除一部分。正因为这两条机制都不是立刻物理删除所以过期键占用的内存可能不会马上被释放看上去内存只增不减。解决手段是用MEMORY PURGE触发内存碎片整理或者干脆设置maxmemory策略让淘汰机制兜底。还有一个常见的误操作对已经设置了EXPIRE的键再次SET会清除原来的过期时间。这是个很隐蔽的坑很多人的缓存写入逻辑是先SET再EXPIRE中间一旦有人改了代码顺序缓存就变成永不过期。Redis官方其实推荐在SET时直接带EX参数也就是SET key value EX 3600一条命令搞定就不存在这个坑了。4.2 持久化相关命令BGSAVE、LASTSAVE与AOF重写持久化命令平时用得不多但必须知道。BGSAVE触发RDB快照的后台保存LASTSAVE返回最后一次成功保存的时间戳通常用来判断备份是否按时完成。BGREWRITEAOF触发AOF文件重写把日志压缩成最小可回放集合。SHUTDOWN用于安全退出会先执行持久化再关进程。关于RDB和AOF的取舍我的建议是对数据不敏感、能接受丢失最近几十分钟数据的场景RDB足够恢复速度快对一致性要求高的场景必须开AOF且将appendfsync设置为everysec这是性能和安全的平衡点。不要在生产环境用always模式每秒强制刷盘会显著降低写入吞吐实测在批量写入时吞吐能下降一半以上。4.3 内存淘汰指令与参数MAXMEMORY、CONFIG SET内存淘汰不算指令但CONFIG命令是运行时调整参数的关键。CONFIG SET maxmemory 4gb可以动态修改内存上限CONFIG SET maxmemory-policy allkeys-lru能修改淘汰策略。注意所有maxmemory策略都是Redis内存达到上限后如何选择删除哪些keynoeviction直接拒绝写请求allkeys-lru从所有key中按近似LRU淘汰volatile-lru只从设置了过期时间的key中淘汰allkeys-lfu和volatile-lfu则基于访问频率淘汰。这里要展开讲一下LFU和LRU的区别因为很多人设置错了策略还浑然不觉。LRULeast Recently Used淘汰的是最久没被访问的键LFULeast Frequently Used淘汰的是访问频率最低的键。热点新闻场景某天突然爆火的key在LRU下会被保留但一个每天稳定访问N次的业务key若长期不触发访问也可能被LRU误杀LFU对低频但长期活跃的key更友好。我的经验是缓存场景里有明显的冷热周期波动时优先allkeys-lfu纯粹按时间做短缓存时用volatile-ttl或allkeys-lru都行。5. 批量操作与原子性边界PIPELINE、事务与Lua脚本5.1 Pipeline批量指令的正确打开方式Pipeline不是某一条命令而是一种客户端批量发送指令的方式把读多个key的请求合并成一次网络传输。代码里常见的误用是循环调用GET比如在for循环里逐个查询用户信息N个key就是N次RTT网络往返时延高得离谱。改用Pipeline或者直接用MGET一次RTT搞定。实测在本地网络下1000个GET用Pipeline从原本约30毫秒降到3毫秒远程环境下差距更悬殊。但Pipeline有两个局限需要讲清楚。第一它不是原子操作服务器只是逐个执行批量指令中途某个指令失败不会影响其他指令也没有回滚。第二一次Pipeline塞几千条指令会占满Socket缓冲区部分客户端会有默认限制需要分片发送。我在做缓存预热时就按200条一批分片Pipeline稳定很多。5.2 事务MULTI、EXEC、DISCARD、WATCHRedis事务通过MULTI开始事务EXEC提交事务DISCARD取消事务WATCH实现乐观锁。它的机制比较特殊MULTI之后的命令不会立即执行而是全部进入队列EXEC时才批量执行执行期间不会被其他命令插入。但这个原子性和关系型数据库的事务完全不是一个概念——Redis事务不保证失败回滚如果一个命令语法错误整个事务会失败如果运行时出错其他命令照常执行。WATCH用于实现CAS比较并交换WATCH key之后如果key在事务执行前被其他客户端修改EXEC会返回空结果需要重试。这个机制很适合做秒杀扣库存之类需要保证并发一致性的操作。我在业务里做过一个简单的库存扣减用WATCH stock检查后DECR冲突时重试三次虽然能用但在高并发争抢下重试成功率会下降后来还是换成了Lua脚本一次性原子完成判断库存是否足够再减扣。5.3 Lua脚本把多条指令焊成一块铁板Redis从2.6版本就支持Lua脚本EVAL命令是核心入口。脚本在服务器端执行整个脚本天然具备原子性在执行过程中不会插入其他命令。这个特性让比较并更新这类多步逻辑不再有竞态窗口。比如防止超卖的脚本local stock tonumber(redis.call(GET, KEYS[1])) if stock and stock 0 then redis.call(DECR, KEYS[1]) return 1 end return 0用EVAL或SCRIPT LOAD加EVALSHA执行在高并发下的表现比事务加WATCH稳定得多。需要注意脚本默认只支持单键的原子性跨多个key的复杂脚本虽然也能写但在Redis集群模式下会收到CROSSSLOT错误因为不同key的数据可能散落在不同节点上。设计时尽量把需要原子操作的数据放到同一个哈希槽里可以用Redis Cluster的hash tag功能key里包含{}的部分参与哈希计算来强制同槽。6. 实战踩坑实录那些指令没问题但线上出问题的瞬间6.1 KEYS的阻塞噩梦与SCAN的优雅替代KEYS pattern能匹配出所有符合条件的key语法太简单了于是很多新手在启动脚本里用它做缓存清理在管理工具里用它统计key数量。但KEYS的时间复杂度是O(N)N是库中所有key的数量它会全量遍历并阻塞主线程。生产环境下几百万个key一条KEYS能把Redis卡死几十秒期间所有请求全部堆积。替代方案是SCAN它基于游标的增量迭代每次返回少量key不阻塞主线程。使用示例SCAN 0 MATCH user:* COUNT 1000第一次返回游标0和一批key下次用返回的游标继续直到游标回到0完成遍历。注意COUNT只是请求每批条数的提示实际返回数可能不精确而且遍历过程中key的新增删除可能导致个别key重复或漏掉SCAN不保证精确快照。这类不完美但可用的设计恰恰是Redis为了高并发可用性做的取舍。6.2 大Key、热Key与命令复杂度叠加效应大Key指的是单个key的value特别大比如String超过10KB集合类型超过5000个元素这类key对任何命令的访问都可能触发连锁问题。具体来说GET一个大String要序列化传输几MB数据网络和内存都成倍浪费HGETALL一个超大Hash会占满带宽DEL一个大List刚才说过会阻塞主线程。高危命令还有SORT、ZUNIONSTORE、SINTERSTORE这类本身就带聚合计算的指令在超大集合上执行分分钟把CPU打满。排查手段是用MEMORY USAGE key估算某个key的内存占用配合redis-cli --bigkeys扫描全库找到最大的key。处理手法无非是拆分——大String改Hash分字段存大List按时间范围切割成多个key大ZSet可以考虑用Rollup把明细数据压缩成分钟级聚合数据。这里没有银弹只有结构性改造。热Key则是另一个问题。某个key被超高频率访问比如双十一爆款的库存会导致单个分片节点CPU饱和。指令层面虽然没什么命令能直接解决但可以在应用层做本地缓存兜底或者把热key复制成多份带后缀的key分散读压力。这个思路用到的是读写策略而非单纯指令但理解了指令的代价模型才能做出正确的架构调整。6.3 缓存穿透、击穿、雪崩在指令层面的解法这三个经典问题的表面原因都可以追溯到指令使用是否得当。缓存穿透是查询一个不存在的key请求直接落到数据库。指令层面的缓解方案是用SET key value NX EX写入空值缓存但空值缓存的过期时间不能太长否则会出现短暂的假数据。更稳的方案是在Redis前加布隆过滤器拦截但布隆过滤器的实现又在用SETBIT和GETBIT指令一套下来还是绕不开指令体系。缓存击穿是某个热点key突然过期大量请求同时打到数据库。解法是互斥锁用SET lock_key uuid NX EX 10保证只有一个请求能回源数据库其他请求短暂自旋等待。缓存雪崩则是一大批key在同一时间集体失效指令层面的短期解法是给过期时间加随机量让过期时间在基准值附近抖动避免整齐划一地失效。我通常会在工具类里封装一个randomExpire(base, range)方法就是为了避免这个坑。值得强调的是这三个问题的底层原因都不是Redis指令本身而是缓存策略设计缺陷。但理解指令的原子性、过期机制、阻塞代价能让你在设计和排查时更快定位问题边界。7. 命令学习的正确路径从背语法到建立心智模型最后聊一个很多人问的问题Redis命令这么多到底怎么学才高效我的建议是不要按字母序背命令表而是按类型→场景→代价三层来建立心智模型。第一步吃透五大类型的底层数据结构。String是动态字符串Hash和ZSet底层有压缩列表与哈希表/跳表的编码转换List是双向链表Set是哈希表。理解了数据结构命令的时间复杂度根本不用背——头尾O(1)、中间O(N)、全量O(N)都是自然而然的结果。第二步每条常用命令问自己三个问题它什么时候用最好它有哪些可变参数它的最坏时间复杂度是多少拿GET和MGET举例单查用GET批量查用MGET这是场景GET没有额外参数MGET也没有EX之类的扩展但SET有NX和EX这是参数GET是O(1)MGET是O(N)N是查询key数量这是代价。三个问题过一遍命令就不再是孤立的语法碎片。第三步也是我最近带新人时最强调的一点在生产环境动手前先在测试环境把命令的破坏性体验一遍。比如故意对一个百万级key执行KEYS观察阻塞故意DEL一个大List看延迟飙升。有了体感写代码时才会对每个命令保持敬畏。很多人出事故不是因为不知道命令怎么用而是因为完全低估了一条简单指令的放大效应。Redis这套命令体系之所以经典是因为它几十个核心指令就覆盖了绝大多数业务场景且每个指令都设计得足够克制——没有花哨的API只有明确的数据结构操作。把这份克制背后的成本模型吃透你写出来的代码自然会稳。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进