我做了六年Redis运维和调优,被线上事故教育过不少次,最深的体会是:很多人把Redis当成“能用的缓存”就满足了,却对自己每天都在敲的Redis命令理解得很浅。比如同样是INCR,在高并发下单用和配合事务用完全是两个概念;同样是SET,带了EX、NX、XX参数之后,它就能从简单的赋值变成分布式锁的原语。这篇内容我不打算给你罗列官方文档的每一个命令,而是把Redis命令按“业务里真正会用到的场景”拆开,讲清楚每个命令为什么这么设计、怎么用才不踩坑、哪些参数组合才是工程上的最佳实践。适合正在用Redis做缓存、队列、分布式锁的开发者,也适合准备面试时需要把命令背后的原理想明白的人。
1. 先建立Redis命令的整体认知框架
很多初学者学Redis命令的方式是“拿着一张命令表挨个背”,今天记LPUSH明天记SADD,背完就忘,因为缺少一个能把命令挂靠上去的认知骨架。我建议你先建立一个简单的分类模型:Redis命令本质上只干三件事——存数据、取数据、管数据。存和取围绕的是五种核心数据类型,管数据则是Key本身的TTL、持久化、订阅发布、事务和脚本。
1.1 命令背后的“单线程”逻辑
理解Redis命令的第一步,是理解Redis的执行模型:Redis的核心处理逻辑是单线程的。这意味着所有命令在服务端是串行执行的,不存在多线程竞争的问题,也就没有加锁的负担。这个特性带来两个直接影响:第一,单个命令的耗时决定了整个实例的吞吐上限,所以那些会遍历大量Key的命令(比如KEYS)、聚合计算的命令(比如SMEMBERS拉取超大的Set),一定要谨慎使用;第二,当你用MULTI开启事务时,事务里的命令会被排队,然后一次性执行,中间不会插入其他客户端的命令。这条原理贯穿了后面所有命令的选型和优化思路——凡是可能阻塞单线程的命令,都值得你多留一个心眼。
1.2 命令的分类维度:类型、方向、生命周期
我个人习惯把Redis命令按三个维度去记忆。第一个维度是数据类型维度,String、Hash、List、Set、ZSet各自的增删改查命令,这是最基础的;第二个维度是访问方向维度,比如List命令要区分左端操作和右端操作,ZSet命令要区分按分数操作和按成员操作,方向不同、复杂度就不同;第三个维度是Key生命周期维度,包括过期时间设置、持久化策略、存在性判断,这三个维度组合起来,基本可以把Redis命令覆盖掉八成。剩下的两成是事务、Lua脚本、Pub/Sub、Stream这类扩展能力,它们不属于某个类型,而是跨类型的管理命令,建议在基础命令熟练之后再学,因为它们的应用场景更特殊,直接上手容易懵。
2. 五种核心数据类型的命令详解
2.1 String类型:不只是GET/SET,掌握这7个命令才算入门
String是Redis里最基础、也最容易出彩的类型。基础操作不用多说:SET key value存、GET key取、DEL key删,这些命令的要点在于参数。
SET是Redis命令里最值得反复琢磨的一个,它支持的参数非常强大。完整的格式是:
SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL]EX和PX是设置过期时间,前者单位秒,后者单位毫秒NX是“只有在Key不存在时才能设置成功”,XX是“只有在Key存在时才能设置成功”KEEPTTL是“保留原来Key的过期时间”
比如一个典型的场景:生成一个验证码存入Redis,5分钟过期,同时要求如果手机号已经存在验证码就不能重复生成:
SET sms:code:13800138000 482913 EX 300 NX这里EX 300控制了过期时间,NX保证了幂等性。如果不支持NX,你就得先EXISTS判断再SET,两步操作之间存在时间窗口,并发场景下必然出错。所以SET的多参数设计,本质上是为了把“检查+写值+设置过期”合并成一个原子操作。
INCR和DECR是String类型最常被低估的两个命令。它们的底层原理是:Redis会将字符串表示的整数解析出来,加1或减1之后再写回去,全程原子。正因为原子,INCR可以用于计数器场景——文章阅读量、点赞数、限流窗口计数,都是它的主场。如果你担心并发条件下GET之后再SET造成计数丢失,INCR直接帮你把这个担心抹掉了。
再往下还有APPEND、STRLEN、GETRANGE、SETRANGE,这几个命令的使用场景相对少,但我在两个场景里用到过:一是用APPEND连续拼接日志片段,二是用GETRANGE读取一个超大字符串的前100个字符用于快速预览。不过说实话,String类型不建议存特别大的value,单个value超过10KB就要开始警惕,因为大value会拖慢内存分配和网络传输,压缩、过期、持久化都会受影响。字符串的值太大时,优先考虑是否应该拆分成Hash结构。
2.2 Hash类型:面向字段的操作才是它的灵魂
Hash类型适合表达“一个对象的多组属性”。比如用户信息:
HSET user:1001 name "张三" age 28 city "上海" HGET user:1001 name HGETALL user:1001HSET可以一次设置多个字段,HGETALL一次取出全部字段,简单够用。但在生产环境里,HGETALL要慎用——如果这个Hash有几十个字段、每个字段都是大字符串,一次拉全量在数据量大时会有网络瓶颈。更好的做法是用HMGET精确指定需要的字段:
HMGET user:1001 name ageHINCRBY是Hash场景下的计数器命令。它跟INCR类似,但作用域是Hash里的某个字段。典型场景是商品维度的多字段统计:商品的浏览量、加购量、下单量各自独立计数,如果用String类型就得拆多个Key,用Hash一个Key全搞定。
HINCRBY product:2024 views 1 HINCRBY product:2024 carts 1还有一个实用小技巧:HSETNX,它只在字段不存在时才能设置成功,用于“初始化某个字段且不能被覆盖”的场景。比如记录用户首次注册时间,如果已经写入过就不能再改,这时候HSETNX比先HEXISTS判断再HSET安全,因为它是原子的。这个细节在面试里很加分,说明你关注到了“判断+写入”的原子性问题。
2.3 List类型:左右开工的消息队列与栈
List类型底层是链表结构,所以它头尾插入和弹出的性能都是O(1),但按索引定位元素是O(N),这个性能差异决定了它的使用边界——List适合做队列、栈、时间线数据,不适合做“随机访问的数组”。
LPUSH是从头部插入,RPUSH是从尾部插入,LPOP和RPOP分别是头部和尾部弹出。两个命令组合,就能实现不同的数据结构:
LPUSH+RPOP:左侧进右侧出,标准的先进先出队列LPUSH+LPOP:左侧进左侧出,这时的List变成了栈,后进先出
我常常看到有人把队列两头搞混,一个简单的记忆方法是:L开头操作左端,R开头操作右端,组合起来是什么结构,取决于你把哪一端当入口、哪一端当出口。
BRPOP和BLPOP是阻塞版的弹出命令,它们才是Redis做队列的真正主力。命令阻塞时,Redis会挂起客户端连接,直到队列里有数据可弹出,或者到达超时时间。阻塞版本解决了两个问题:一是减少了空轮询对CPU的浪费,二是让消费端可以立刻感知到新消息的到来。实际使用格式是:
BRPOP task:queue 10第二个参数10表示阻塞超时秒数,10秒内没数据就返回空。可能你会好奇:既然已经阻塞等待了,消费端怎么保证“拿到消息后进程挂掉不丢消息”?那么需要明确一点:LPOP/RPOP弹出即删除,消息一旦弹出就落在消费端内存里,如果消费端还没来得及处理进程就宕机,消息就真丢了。可靠消息一般会结合RPOPLPUSH命令,弹出后先备份到另一个List再处理,处理成功再删备份,这就把“至少要投递一次”变成可能。
LLEN可以获得List长度,这是个O(1)命令,在监控队列积压量时很有用。我常用的健康检查脚本就是每隔几秒LLEN一次,如果积压数持续上涨,说明消费速度跟不上生产速度,要及时扩容消费者。
2.4 Set与ZSet:去重、抽样与排行的实战用法
Set类型最大的特点是去重和无序。SADD添加成员、SREM移除成员、SISMEMBER判断成员是否存在、SCARD获取成员数、SINTER取交集、SUNION取并集、SDIFF取差集。最经典的场景是“共同关注”和“好友推荐”:比如A关注了{1,2,3},B关注了{3,4,5},用一条SINTER命令就能算出共同关注是{3}。
SINTER follow:1001 follow:1002有一个容易被忽略的坑:SMEMBERS会一次性拉取集合全部成员,当集合很大时可能造成阻塞和网络压力。正确的做法是使用SSCAN进行渐进式遍历:
SSCAN follow:1001 0 COUNT 100请记住一条经验法则:“生产环境禁止用KEYS和SMEMBERS这类全量命令”,这几乎是Redis运维的红线。原因在开头说过,单线程执行,全量遍历会把整条命令的执行时间拉长,期间所有其他客户端都在等着,Redis直接表现为卡顿。
ZSet是Set的有序版本,每个成员关联一个分数(score),按分数排序。核心命令是ZADD、ZRANGE、ZREVRANGE、ZSCORE、ZINCRBY、ZRANK和ZREVRANK。
最经典的场景是排行榜。比如实时更新用户积分排名:
ZINCRBY leaderboard 100 user:1001 ZREVRANGE leaderboard 0 9 WITHSCORESZREVRANGE从高到低取分数最高的前10名,这就是排行榜的雏形。还有一个很有用的命令是ZRANGEBYSCORE,按分数区间取成员,适合“取出某个时间范围内的记录”或者“给某个分数段的人发奖励”这类需求。如果榜单数据量很大,建议把ZREVRANGE配合分页使用,不要一次拉全量。另外注意:ZSet的取排名操作ZRANK是O(log N)的,所以在千万级成员上做排名查询也很快,这是面试时常常被问到的性能优势之一。
3. 命令之外:Key管理、过期与事务的配合
3.1 Key的命名规范与扫描技巧
命令用得再熟,Key管理混乱一样会出问题。我见过一个项目因为Key的命名不规范,线上排查慢查询时根本不知道某个Key属于哪个业务模块。建议从第一天就定好命名规则,我常用的格式是:
业务名:实体名:ID[:字段]比如order:info:10086表示订单信息,user:address:1001:list表示用户的地址列表。这样做的好处是用SCAN匹配时可以通过前缀精确过滤,日志排查时也能通过业务名快速定位。另外尽量避免“无意义的随机Key”长时间积累,一般会写个定时脚本,用SCAN找出超过7天没被访问的Key,再酌情清理。清理的时候同样不要直接用KEYS,理由上面说过了,要使用SCAN:
SCAN 0 MATCH user:* COUNT 1000SCAN命令的第一个参数是游标,返回结果中会带一个游标值,游标为0表示遍历结束。整个过程像“翻书页”一样,一次翻有限页,不阻塞主进程。
3.2 TTL的设定与过期删除策略
给Key设置过期时间是必须养成的好习惯。Redis的过期删除策略是惰性删除加定期删除的组合:惰性删除是当Key被访问时如果发现已过期就删除;定期删除是Redis每隔一段时间随机抽一批设置了过期时间的Key,把过期的删掉。正因为是“随机抽样”,你可能会遇到“明明设置了过期,但Key还活着”的情况——这通常是抽样还没抽到它,等几秒再看就没了。
设置过期时间有三种方式:
EXPIRE user:1001:token 600 # 给已有Key设置600秒过期 SETEX user:1001:token "abc" 600 # 写入时直接设置过期 PEXPIRE user:1001:token 600000 # 毫秒级过期删除或取消过期也有讲究:DEL彻底删除Key;PERSIST取消过期时间,让Key永久存活。有一个坑我踩过:对一个已经设置了过期时间的Key执行SET(不带KEEPTTL),新的值会覆盖旧的,同时过期时间会被清除。也就是说如果你更新了值,但忘了重新设置过期时间,这个Key就变成永久的了。所以更新一个有TTL的Key,要么用SET key value KEEPTTL保留原过期时间,要么更新后立刻EXPIRE。
3.3 MULTI、EXEC与Lua:如何让多条命令原子执行
Redis的事务机制用MULTI开始,用EXEC执行,事务中的命令会被依次执行,中间不会插入其他客户端的命令。但要注意:Redis事务不支持回滚。如果中间某条命令报错,之前的命令已经执行了,后面的命令也不会自动撤销,这和关系型数据库的事务是两码事。
举个例子:你想转账——从account:1001减100,往account:1002加100。写成事务:
MULTI DECRBY account:1001 100 INCRBY account:1002 100 EXEC两条命令之间保证了原子执行,不会出现“减了没加”的中间状态。但如果你在事务里写错了命令名,比如把INCRBY写成了INCRBYX,EXEC执行时会报错,但之前的DECRBY已经生效了,没有回滚。因此,事务适合用来保证“多条命令的顺序执行”,不适合把它当成“可以回滚的数据库事务”。
比事务更优雅的原子方案是Lua脚本。Redis从2.6开始支持内嵌Lua,使用EVAL命令执行脚本。比如上面的转账逻辑写成Lua:
EVAL "local from = KEYS[1]; local to = KEYS[2]; local amount = tonumber(ARGV[1]); if redis.call('get', from) - amount < 0 then return 0 end; redis.call('decrby', from, amount); redis.call('incrby', to, amount); return 1" 2 account:1001 account:1002 100Lua脚本在Redis服务端被整体原子执行,不会被打断,适合复杂的多条命令逻辑。不过Lua脚本也不是万能的,脚本里不能用耗时过长的循环,否则照样阻塞单线程。这里补充一句:如果你准备在面试中聊Redis事务,表述的重点最好放在“原子性”和“无法回滚”这两个特质的对比上——很多人只记得“事务”,却说不清Redis事务和数据库事务的根本差异。
4. 从命令到实战:分布式锁和缓存治理
4.1 用一条SET命令实现可靠的分布式锁
分布式锁可能是Redis命令场景里面试中出现频率最高的话题。从命令的具体用法来看,分布式锁的可靠实现就是靠一条带参数的SET:
SET lock:order:1001 owner_id EX 30 NX这条命令同时做了三件事:NX保证只有Key不存在时才能加锁成功;EX 30保证锁有30秒自动过期;value用owner_id标记持有者,用来在释放锁时做身份校验。如果没有NX和EX的组合,你就得先SETNX再加EXPIRE,两步之间一旦服务宕机,锁永远不释放,其他人全部阻塞。
释放锁的正确姿势也很有讲究。不能直接DEL,因为当前线程的锁可能已经过期被别的线程拿到了,你一个DEL就把别人的锁删了。正确做法是用Lua脚本比较value后再删:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:1001 owner_id这样只有持有锁的线程才能释放锁,避免误删。这套逻辑看起来简单,实际运行时最大的坑是“锁过期了但业务还没执行完”:线程A加锁后执行耗时任务,30秒锁过期了,线程B加锁成功开始执行,A处理完释放锁时把B的锁误删了——虽然我们设计了value校验,但如果A的value传入错误或者逻辑混乱,照样出问题。工程上的解法是使用Redisson这类成熟客户端,它会为锁启动自动续期(watchdog机制),默认每10秒续期一次,避免锁在业务执行期间过期。
4.2 缓存治理中的计数限流命令组合
Redis的INCR和EXPIRE组合是滑动窗口限流的一种简化实现。经典的做法是用当前时间窗口做Key,比如限制每个用户每分钟最多请求100次:
INCR rate:limit:user:1001:202405121000 EXPIRE rate:limit:user:1001:202405121000 60先计数,再给它60秒过期。每次请求前INCR,如果计数超过100就拒绝。但这里有个隐患:INCR和EXPIRE不是原子的,如果INCR之后进程崩溃,EXPIRE没执行,这个计数Key就永远留在Redis里。更稳妥的做法是使用Lua脚本把计数和过期绑定到一起:
EVAL "local c = redis.call('incr', KEYS[1]); if c == 1 then redis.call('expire', KEYS[1], ARGV[1]) end; return c" 1 rate:limit:user:1001 60第一次计数时顺手设置过期时间,之后的计数就不动了。这就是我在限流场景中最常用的命令组合。当然,更严谨的限流方案是基于ZADD和ZREMRANGEBYSCORE的滑动窗口实现,但计数加过期的方式胜在简单高效,应对大多数场景已经够用。
4.3 缓存穿透、击穿、雪崩对应的命令对策
缓存治理里绕不开三个概念:穿透、击穿、雪崩。它们对应的Redis命令操作各不相同。
缓存穿透是指查询一个不存在的Key,请求直接打到数据库。常见的对策是“缓存空结果”——把一个空值存到Redis里,设置一个较短的TTL。用命令表示就是:
SET cache:user:9999 null EX 120这样后续相同的查询命中缓存,不会穿透到数据库。另外一个对策是布隆过滤器,但布隆过滤器需要额外维护,不是Redis命令本身的能力。
缓存击穿是指某个热点Key过期瞬间,大量请求同时打到数据库。对应的命令层面解决方案是“互斥重建”:用SET lock:hot:key owner EX 10 NX加锁,只有一个线程能成功加锁并回源数据库重建缓存,其他线程要么自旋等待,要么先返回旧值。这里NX参数又一次承担了关键作用。
缓存雪崩是指大量Key同时过期,导致数据库瞬时压力激增。命令层面上能做的就是给过期时间加一个随机偏移量:
SET cache:user:1001 value EX 300 # 更好的方式:EX 300 + 随机0-60秒偏移,让过期时间散开实际操作时,我会在设置TTL时引入随机数,让Key的集中过期变成分布式过期,避免雪崩。
5. 命令使用中常踩的坑与排查实录
5.1 一个慢查询引发的“Redis假死”问题
有一次线上日志显示某个Redis实例的ops全线暴跌,单条命令耗时达到秒级。排查之后锁定了罪魁祸首:有人用KEYS user:*在几百万个Key里做模糊匹配,一次匹配经历了几十秒。因为Redis是单线程,这几十秒内所有其他读写请求全部排队等待,表现就是“假死”。
这个坑的修复方式很简单:把KEYS换成SCAN。我把内部规则定为三条:第一,任何代码中禁止出现KEYS命令;第二,禁止使用SMEMBERS拉取未知大小的集合;第三,所有聚合型命令(比如HGETALL、ZRANGE全量)必须先评估集合大小再决定是否分页。为了防患于未然,还可以通过SLOWLOG命令排查慢查询:
SLOWLOG GET 10它会返回最近10条慢命令的记录,包含耗时、命令参数。生产环境设置慢查询阈值是很有必要的,通常我会把阈值的下限设为10毫秒,超过就重点评审。
5.2 连接数暴涨,原来是忘了EXPIRE
排查过一起线上故障:Redis连接数从几十涨到了几千,导致客户端连接被拒绝。最后发现是登录Token用SET写入后忘了设置过期时间,用户每次访问都叠加一个永久Key,数量只增不减。修复时就一条条清理无效Token并重新上线代码,之后所有Token写入都强制加EX,再配合脚本定期清理。这个案例告诉我们:任何写入Redis的Key,都要问自己三个问题——这个Key可以随意覆盖吗?TTL是多少?如果永不删除会不会爆炸?没有一个明确答案,就别往Redis里写数据。
5.3 客户端工具与连接排障速查表
很多新人卡在“命令会敲但连不上Redis”这一步。常见问题出在redis.conf配置上,这里我给出一份排查速查表:
| 现象 | 可能原因 | 排查命令/操作 |
|---|---|---|
| 连接被拒绝 | 未绑定IP或protected-mode开启 | 检查bind 0.0.0.0和protected-mode no |
| 密码认证失败 | 未配置requirepass | 确认redis.conf里的requirepass和客户端参数一致 |
| 内存快满了 | noeviction淘汰策略 | 确认maxmemory-policy,用INFO memory查看 |
| 慢命令拖垮实例 | 大Key或KEYS | SLOWLOG GET定位耗时命令,换成SCAN分页 |
| Value值序列化后乱码 | 客户端和写入端序列化方式不一致 | 检查JDK序列化、JSON序列化与连接工具的兼容性 |
另外,我习惯用INFO命令做日常健康检查,重点关注这几个指标:used_memory_rss实际占用内存、connected_clients当前连接数、total_commands_processed累积命令量、keyspace_hits/misses的缓存命中率,以及rejected_connections被拒绝的连接数。INFO命令返回的信息非常全,定期拉一下,能提前发现很多隐患。
5.4 Redis命令学习的进阶路径
最后聊一下我建议的学习路线。第一步,把五种数据类型的常用命令都手敲一遍,不仅敲执行成功的路径,还要故意写错几次,看看Redis返回什么样的错误信息,很多时候错误信息本身就是最好的文档。第二步,找一个成熟的Redis客户端(比如Java的Redisson或者Spring Data Redis),对比你会发现客户端API很多都是对原生命令的封装,理解了原生命令,再去读客户端的源码就没那么吃力。第三步,针对具体业务场景做组合练习:写一个分布式锁、写一个限流器、写一个排行榜,用Redis命令去实现,你会发现命令的组合能力比单个命令的掌握更重要。第四步,如果对自己的理解有信心了,可以去看看Redis官方文档对每个命令复杂度的标注——那才是理解命令性能边界的最终答案,官方文档在https://redis.io/docs/latest/commands/,命令参数和复杂度都标得很清楚,值得反复翻。
我在实际项目里有一个习惯:每次遇到“不知道用什么命令”的瞬间,都会先把自己的需求拆成“存什么类型的结构、访问模式是读多还是写多、数据量级预期是多少”,再去命令表里选型。这样选出来的命令,通常就是那个场景下的最优解。老实说,Redis命令的学习从来不是“背下来”的事,而是“用明白”的事。多在生产场景里摔几次,多查几次慢日志,这些命令背后的设计逻辑自然就刻在脑子里了。