news 2026/10/22 21:50:51

Redis命令实战:从数据结构到性能优化与分布式锁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis命令实战:从数据结构到性能优化与分布式锁

接触过不少写后端的朋友,也看过很多简历,上面写着“熟练使用Redis”。但真聊起来,绝大多数人的Redis知识也就停留在set、get、expire这三板斧。一旦线上出现缓存穿透、大Key阻塞、或者需要设计一个排行榜、延时队列,很多人就不知道该用哪些命令、为什么这么用了。

Redis的命令其实不难记,难的是搞清楚每条命令背后的数据结构、时间复杂度和使用边界。这篇文章我不会把官方文档抄一遍,而是从日常写代码、排查线上问题最常用的命令入手,把命令背后的原理、场景选型和那些容易踩的坑一起讲透。希望你读完能有一种感觉:不是“我知道Redis有这些命令”,而是“我知道这个问题该用哪条命令,以及为什么”。

1. 先搞清楚Redis命令为什么长这样:数据类型的内存编码决定一切

很多人背命令喜欢死记硬背,比如String有set/get,Hash有hset/hget,List有lpush/lpop。但如果不理解Redis内部是怎么存这些数据的,你背再多命令也只会“用”,不会“选”。

Redis对外暴露了五种基本数据类型,但对内,每种类型其实有不止一种底层编码方式。换句话说,同样一个String类型的key,存一个整数和存一大段JSON,底层结构完全不一样。

1.1 一个最简单的例子:同样set,为什么编码不一样

我经常在面试里问一个问题:你连续执行下面三条命令,Redis底层分别是怎么存的?

127.0.0.1:6379> SET user:age 18 OK 127.0.0.1:6379> SET user:name "tom" OK 127.0.0.1:6379> SET user:profile "a very long string..." OK

前两条命令看起来没什么不同,但你可以用OBJECT ENCODING命令查一下它们真实的编码方式:

127.0.0.1:6379> OBJECT ENCODING user:age "int" 127.0.0.1:6379> OBJECT ENCODING user:name "embstr" 127.0.0.1:6379> OBJECT ENCODING user:profile "raw"

同样的String类型,Redis用了三种编码:

  • int:value是一个整数,Redis直接用8字节的long类型存储,省内存且支持原子自增。
  • embstr:value是一个不超过44字节的短字符串,Redis把字符串数据和redisObject对象头分配在一块连续内存里,一次内存分配搞定,读写效率很高。
  • raw:value超过44字节,Redis把字符串数据拆出来单独分配内存,需要两次内存分配。

这个设定极其关键。为什么小字符串快?因为它连内存分配都省了。为什么你的大Value容易出问题?因为它在raw编码下,每一次操作都是完整的内存拷贝和网络传输。

再看Hash类型。Redis 7.0之后,当Hash的字段数小于128、每个字段的value小于64字节时,底层用listpack紧凑编码;超过阈值后自动转为hashtable。List的底层是quicklist,本质是多个紧凑块组成的双向链表。Set在元素全是整数且数量小于512时用intset,否则退化为hashtable。ZSet在元素少时用listpack,元素多后用跳表加哈希表。不同版本里这些阈值和紧凑结构名称可能有差异,但设计思路是一样的:小对象用紧凑编码省内存,大对象用经典数据结构保证操作效率。

1.2 编码转换是会付出代价的

编码转换不是自动且无感的。举个例子:某个Hash一开始只有几个字段,listpack编码跑得飞快。随着业务增长,field数量超过阈值,整个结构一次性转为hashtable,所有字段要重新哈希。这个过程虽然不会让Redis阻塞几秒,但确实会带来CPU和内存的瞬时开销。

更典型的是Set。假设你用SADD往里加了几十万个随机生成的UUID字符串,intset根本存不了,从一开始就是hashtable。但如果你加的是自增ID,最开始是intset,加着加着超过512个,咔嚓一下转成hashtable。如果你在代码里做了预热,几百个Set同时在临界点转换,主线程还是能感觉到抖动的。

所以记住一个原则:不要指望编码转换是常态优化手段,它是Redis帮你兜底的设计。真正稳定的做法是创建数据时就想清楚规模,避免让大量小对象反复跨越编码阈值。

1.3 时间复杂度是选命令的核心依据

数据类型决定底层结构,底层结构决定命令的时间复杂度。这是全文最重要的一个概念。我整理了一份常用命令复杂度速查表,建议收藏:

数据类型常用命令时间复杂度
StringGET、SET、INCRO(1)
HashHGET、HSET、HDELO(1)
HashHGETALL、HKEYS、HVALSO(N),N是field数
ListLPUSH、RPUSH、LPOP、RPOP、LLENO(1)
ListLINDEX、LINSERT、LREMO(N),N是列表长度
SetSADD、SISMEMBER、SREMO(1)
SetSMEMBERSO(N),N是集合大小
ZSetZADD、ZSCORE、ZINCRBYO(log N)
ZSetZRANGE、ZREVRANGEO(log N + M),M是返回条数
ZSetZRANGEBYSCOREO(log N + M)
KeyKEYS patternO(N),且阻塞主线程
KeySCANO(N),但每次只返回少量,可分批执行
KeyDELO(N),N是key内元素数量
KeyUNLINKO(1)的回收语义,但实际是异步操作

Redis 6.0开始虽然引入了多线程来处理网络IO,但核心命令的执行依然是单线程。这意味着任何耗时操作都会堵住后面所有请求。KEYS、SMEMBERS、大Key的DEL,这些命令就是线上事故的高发源头。后面每一章我都会围绕“会不会阻塞”这个角度展开。

2. String与Hash的选择题:从set/get到字段级更新的取舍

String是Redis里最基础的类型,绝大多数人第一个学会的命令就是SET。但String用多了,会发现在某些场景下它并不是最优解。这一章把String和Hash放到一起对比,因为它们在很多场景下是可以互相替代的,而选错的人非常多。

2.1 String命令全家桶与几个容易忽略的参数

String的看家命令是SET和GET,但SET的完整参数很多人都没认真看过:

SET key value [NX | XX] [EX seconds | PX milliseconds | EXAT timestamp | PXAT timestamp]
  • NX:key不存在时才写入,这是分布式锁的基础。
  • XX:key存在时才写入,日常用的少,但更新场景有用。
  • EX:秒级过期。
  • PX:毫秒级过期。

还有个容易忽略的细节:SET命令覆盖key的value时,会把这个key原本的过期时间清掉。举个例子:

127.0.0.1:6379> SET verify:code 123456 EX 300 OK 127.0.0.1:6379> SET verify:code 654321 OK 127.0.0.1:6379> TTL verify:code (integer) -1

第二次SET原本只想改验证码内容,结果把300秒过期时间弄丢了,这个key变成了永久key。如果验证码逻辑里后续没有再设置过期时间,它就永远有效。正确做法是每次都用SETEX带上过期时间,或者用SET的EX参数。

常用的String命令再补充几个:

  • MGET / MSET:批量读写,一次网络往返搞定多个key,性能远高于循环GET。
  • INCR / INCRBY / DECR:原子自增自减,计数器、库存、点赞数的标准操作。
  • SETNX:不存在才设置,实现锁和幂等控制。
  • GETSET:设置新值并返回旧值,可以用来做“读取并重置”的原子操作。
  • STRLEN / APPEND / GETRANGE:字符串长度、追加、子串,某些特殊场景有用。

2.2 一个大Value引发的连锁事故

String最大的坑就是存大Value。我在线上见过最夸张的例子,有人把一份5MB的业务配置JSON整个塞进Redis,理由是“反正Redis支持”。结果是什么呢?每一次GET,这个5MB的字符串要完整地从内存拷贝出来、序列化到网络缓冲区、再传给客户端;每次写入,又要经历一次完整的内存分配和拷贝。请求量一上来,Redis主线程光忙着处理这一个key的读写,其他所有key的访问延迟全线飙高。

更危险的是持久化环节。Redis做AOF重写或者RDB快照时,需要fork一个子进程。fork之后父进程如果继续写入这个大Value,会触发操作系统的写时复制机制,内存开销可能瞬间翻倍。我一个真实遇到的情况是:服务器内存才8GB,一个大Key被频繁更新,fork之后内存涨到临界点,直接触发系统OOM,Redis进程被系统杀掉了。

实战里我自己的经验值:单个String的value不要超过几十KB。如果业务确实要缓存大的JSON,先压缩(gzip一下通常能压到十分之一),或者拆成多个小key。能用Hash存的结构就别硬塞进一个String。

2.3 Hash的字段级更新价值与内存特性

Hash类型用一条命令就能解决String的“整存整取”问题。

127.0.0.1:6379> HSET user:1001 name "tom" age 25 city "beijing" OK 127.0.0.1:6379> HGET user:1001 age "25" 127.0.0.1:6379> HINCRBY user:1001 age 1

假设你要缓存一个用户对象,用String的做法是把整个对象序列化成JSON,SET进去。用户改了个头像,你得把整个对象读出来、反序列化、改字段、再序列化、整个覆盖写回。用Hash就简单了,直接HSET user:1001 avatar "new_url",只更新一个字段,其他字段完全不受影响。

内存方面,小Hash用了listpack紧凑编码,省内存效果非常明显。同样是存一两百万个小对象,用Hash而不是String散key,能省下大量redisObject对象头和指针占用的空间。这在真实的缓存场景里是实打实的成本差异。

但Hash也有自己的坑:HGETALL会一次性返回所有字段和值,如果这个Hash有几千个field,返回的数据包可能很大,网络传输和序列化开销都不小。字段很多时,优先用HSCAN分批取,或者业务上拆成多个小Hash。

2.4 何时用String,何时用Hash:一张表说清楚

我用下面这张表总结一下选型逻辑,我自己写代码时基本就是按这个标准来:

对比维度StringHash
更新粒度整个value覆盖,无法只改一部分单个field更新,粒度细
典型形态单个key对应一个value一个key对应多个field-value
批量读取MGET跨多个key批量读HMGET同key内多个field批量读
内存效率小value很高效;大value是灾难小对象listpack非常省内存;field过多转hashtable后占用上升
典型场景验证码、token、计数器、短内容缓存用户信息、购物车、配置项、对象缓存
易踩的坑SET覆盖清掉过期时间、大Value阻塞HGETALL全量返回造成网络压力

购物车是个很典型的Hash例子:

# 用户1001的购物车,sku:888商品加1件 HSET cart:1001 sku:888 1 # 再加1件 HINCRBY cart:1001 sku:888 1 # 查看购物车 HGETALL cart:1001

如果用String,购物车每次变更都要读写整个JSON,Hash则天然支持商品维度的增减和查询。开发效率完全不同。

3. List与Set:队列、时间线、去重背后的命令组合

List和Set在实际业务里的出场频率可能没有String和Hash高,但用对了场景,它们能帮你解决非常棘手的问题。这一章重点讲几个“命令组合”的套路,而不是单个命令的孤立用法。

3.1 List:消息队列与时间线翻页的常用姿势

List本质是个双向链表,两头都能进能出。最经典的消息队列用法:

  • 生产者:LPUSH queue taskId
  • 消费者:BRPOP queue timeout

注意消费者我特意写成BRPOP而不是RPOP。BRPOP是阻塞式弹出,列表为空时它会在给定时间内挂起等待,而不是立刻返回空。这个机制比“循环RPOP+sleep”强太多:第一,没有空轮询,不浪费CPU;第二,消息来了能立刻被消费,延迟低。做个简单的异步任务队列,一个List加一个BRPOP能解决80%的需求。

List还有一类场景是“最新动态列表”,比如新闻站点的最新文章。套路是先LPUSH新文章ID进去,再用LTRIM控制列表长度,只保留最近100条:

LPUSH news:list article:1001 LTRIM news:list 0 99

LTRIM这个命令知道的人不多,它保留指定区间的元素,把区间外的全删掉。上面命令的含义是只保留前100个元素,最老的元素自动被清除。如果没有这条命令,列表会无限增长,内存迟早被撑爆。查看列表用什么?分页查看LRANGE:

# 从第0条开始取10条 LRANGE news:list 0 9

分页的时候要小心LINDEX和LRANGE的性能边界。列表很短时无所谓,列表有几万甚至几十万元素时,LINDEX是O(N),还得从头遍历,慢得很。News列表这种“只从两头操作”的场景没问题,但如果你的业务需要频繁按下标取中间元素,List就不是好选择了。

3.2 LTRIM、BRPOP这些“不常见”命令的实战价值

很多Redis教程只讲LPUSH、RPUSH、LRANGE,把LTRIM、BLPOP、BRPOP当成生僻命令一笔带过。但在真实项目里,这些命令的实战价值极高。

我举一个真实的例子。某系统要把用户的操作日志在内存里保留最近500条,供排查问题时快速查询。最开始的做法是每条日志LPUSH进去,然后定期用一个定时任务去清旧数据。问题是定时任务很难控制“刚好保留500条”这个节奏,清多了数据不够,清少了内存涨。后来改成:

LPUSH op:log:user:1001 "2024-05-01 10:00:00 xxx operation" LTRIM op:log:user:1001 0 499

每次写入后紧跟一条LTRIM,天然保证列表长度不超过500。这个组合堪称内存管理的自动阀,不用再写任何清理逻辑。

BRPOP还有个衍生用法:多个消费者同时BRPOP同一个列表时,每一条消息只会被一个消费者拿到,天然实现了任务分发。注意,如果有人用LPOP加轮询来实现同样的效果,不仅浪费CPU,还容易在多消费者场景下出现消息重复处理的问题,因为轮询没法保证只有一个进程取到消息。

3.3 Set:去重、抽奖与关系计算的命令组合

Set的最大特点是元素唯一和O(1)的成员判断。最经常用的场景是去重和关系计算。

比如一个活动,要判断用户是否已经参与过:

SADD activity:2024:participants user:1001 # 判断是否参与过 SISMEMBER activity:2024:participants user:1001 # 总参与人数 SCARD activity:2024:participants

SISMEMBER在集合大小几百万时依然是O(1),这比查数据库高效多了。

关系计算是Set的杀手锏。比如社交场景里的共同好友、可能认识的人,用SINTER(交集)、SUNION(并集)、SDIFF(差集)几个命令组合就能算出来:

  • 共同好友:SINTER follow:a follow:b
  • A关注了但B没关注的人:SDIFF follow:a follow:b
  • 两边的总关系链:SUNION follow:a follow:b

如果计算出的结果集还要参与后续逻辑,可以使用SINTERSTORE把结果存到新key里,避免重复计算。

3.4 两个性能陷阱:SMEMBERS全量返回与随机成员的差异

Set有两个命令经常被混用,而且用错会造成性能问题。

第一个陷阱是SMEMBERS。它会把集合全部成员一次性返回。如果集合有几十万个元素,这个操作会生成一个巨大的响应包,网络和内存都可能扛不住。需要遍历集合时,正确做法是SSCAN:

# 每批取100个 SSCAN activity:2024:participants 0 COUNT 100

SSCAN和SCAN一样,返回一个游标和一批数据,客户端用返回的游标继续迭代,直到游标变0。这样能把一次大操作拆成多次小请求,不会阻塞主线程也不会撑爆网络。

第二个陷阱是SPOP和SRANDMEMBER的区别:

  • SPOP:从集合中随机移除并返回一个元素。抽一等奖只能抽一次的场景,用它。
  • SRANDMEMBER:随机返回一个元素,但不移除。允许同一参与者多次中奖的场景,用它。

看下面这个例子:

# 抽奖,中奖后从候选池移除 SPOP lottery:pool # 随机抽查一名活跃用户,但保留用户在池里 SRANDMEMBER activity:active_users

不知道这两个命令区别的人,经常在“允许重复中奖”的场景用了SPOP,结果一名用户被抽走后,后面再也抽不到他。功能直接做错。

4. ZSet不只是排行榜:分数设计决定你能走多远

ZSet(有序集合)是Redis所有数据类型里信息量最大的一种。它的全称是Sorted Set,每个成员带一个分值,按分值排序。很多人知道ZSet可以做排行榜,但它的应用边界远不止于此。这一章重点讲几种经典的ZSet套路,以及分数设计背后的逻辑。

4.1 排行榜场景的分数更新:ZINCRBY的正确打开方式

排行榜是ZSet最朴素的用法。假设要做一个积分排行榜:

# 用户user:1001初始积分100 ZADD rank:total user:1001 100 # 用户user:1001完成一个任务+50分 ZINCRBY rank:total 50 user:1001 # 查当前分数 ZSCORE rank:total user:1001 # 查第1到第10名(分数从高到低) ZREVRANGE rank:total 0 9 WITHSCORES # 查user:1001的排名 ZREVRANK rank:total user:1001

这里有个设计要点是:如果分数会频繁变化,ZINCRBY比先ZADD再ZSCORE再ZADD的方式好得多,因为它是一个原子操作,不需要读改写三步,也不会有并发覆盖的问题。

还有一个很容易忽略的点:ZSet是按分数排序,但分数相同时,按member的字典序排序。如果你在做一个比赛排行榜,两人同分,Redis默认按昵称的字母顺序排,这通常不符合业务预期。解决办法是在分数设计上留出“精度余量”,比如把分数乘1000或10000,把“微调因子”塞进分数的小数部分,避免完全同分。

4.2 延时队列:用时间戳做score的经典套路

延时队列也是ZSet的招牌用法:把任务的执行时间戳作为score,任务ID作为member。这样整个ZSet天然就是一个按执行时间排序的队列。

生产端:

ZADD task:delay 1714550400 task:order:1001

消费端只需要不断取出score小于等于当前时间戳的任务:

ZRANGEBYSCORE task:delay -inf 1714550400 WITHSCORES

取出来之后,还要ZREM把任务删除。在有多台机器并发消费的场景下,这里有个很关键的细节:不能先ZRANGEBYSCORE把所有到期任务都取出来,再一个个处理,因为并发时多个worker可能取到同一个任务。正确的做法是:取出来后,每个worker用ZREM去尝试删除,ZREM返回1的才代表抢到了这个任务,返回0说明任务已经被别的worker拿走了,直接跳过。ZREM本身是原子操作,天然可以作为互斥锁。

这个方案在生产环境是验证过的,替代了很多“定时扫数据库”的实现,效率完全不是一个量级。

4.3 滑动窗口限流:ZSet的隐藏用法

除了排行榜和队列,ZSet还能做滑动窗口限流,这个用法很多人没见过。核心思路:每个用户一个ZSet key,member存请求的唯一标识,score存当前请求的时间戳。

# 每次请求时 ZADD rate:user:1001 1714550400123 req:uuid # 删除窗口外的记录(窗口10秒) ZREMRANGEBYSCORE rate:user:1001 0 1714550400113 # 统计窗口内请求数 ZCARD rate:user:1001

ZCARD超过阈值就拒绝请求,落库或者提示“请求过于频繁”。这个方案的优点是滑动窗口是精确的,不像固定窗口会在窗口切换的瞬间出现双倍流量。代价是每个用户都要维护一个ZSet,内存占用比INCR+EXPIRE的固定窗口方案高。所以我的建议是:对限流精度要求高的接口(比如支付、短信验证码),用ZSet滑动窗口;对一般的接口限流,固定窗口足够了,别过度设计。

4.4 区间查询那些边界细节:闭区间、开区间与排名

ZSet的区间查询命令是ZRANGEBYSCORE,它的边界语义容易搞错。

下面的公式记清楚:分数默认是闭区间,可以用(前缀改成开区间。

# 1到100,包含1和100 ZRANGEBYSCORE zset 1 100 # 1到100,不包含1,包含100 ZRANGEBYSCORE zset (1 100 # 大于1,小于等于5 ZRANGEBYSCORE zset (1 5

注意ZRANGEBYSCORE的start和stop也支持-inf和+inf,这在清理过期任务时特别有用。还有一点,ZREMRANGEBYRANK是按排名区间删除,例如清理积分最低的1000名:

ZREMRANGEBYRANK rank:total 0 999

顺便提一个真实踩过的坑:ZADD的语法是ZADD key score member。很多人写代码时会顺手写成ZADD key member score,然后发现分数全部错乱。这个顺序问题看起来低级,但越忙的时候越容易写反。刚接触ZSet的人务必注意。

5. Key级命令的生死线:过期、扫描与删除的线上教训

前几章讲的都是数据类型命令,这一章讲Key级别的操作。这些命令看起来简单,但它们是线上事故的重灾区。Redis单线程执行命令的特性,决定了任何“全量操作”都会带来灾难性的阻塞。这一章你值得多看几遍。

5.1 KEYS命令为什么是线上事故高发点

# 新手最喜欢的一条命令 KEYS user:*

这条命令会在整个key空间里做一次全量扫描,把所有匹配的key一次性返回。问题在于:如果你的Redis里有几百万甚至几千万个key,KEYS user:*会阻塞主线程几十秒甚至几分钟。这期间,所有读写请求全部排队,Redis对外表现就是“假死”。我在线上见过因为一条KEYS命令导致整个缓存集群几秒不可用的案例,事后定位就是某个同事在排查问题时手滑执行了KEYS。

有人可能会问:不是说KEYS支持通配符吗?不匹配的key很多,怎么还要全量扫?因为Redis底层对key的组织是一个哈希表,本身不支持按模式索引,任何带通配符的匹配都必须遍历整个哈希表。没有例外,没有优化空间。所以KEYS这个命令,线上环境请务必禁用,测试环境也少用。

5.2 SCAN的游标机制与COUNT的真实语义

替代KEYS的方案是SCAN,它通过游标增量遍历key空间,每次返回一小批数据。基本用法:

# cursor从0开始 SCAN 0 MATCH user:* COUNT 100

返回结果里包含两个部分:下一个游标和本批key列表。客户端把新游标传回去继续迭代,直到返回的游标为0,遍历结束。

这里有几个关键细节:

  • COUNT是“提示值”,不是“精确返回条数”。实际返回的条数可能大于也可能小于COUNT,取决于内存分布。不要拿COUNT当严格分页用。
  • SCAN遍历过程中如果key列表正在扩容或缩容,可能出现某条数据重复返回。客户端要做好去重。
  • SCAN比KEYS安全,并不意味着可以高频使用。大批量SCAN同样会占用主线程时间,只是把一次大阻塞切分成多次小阻塞,积少成多依然可观。建议在低峰期执行,并且限制频率。

同理,HSCAN遍历Hash的field,SSCAN遍历Set成员,ZSCAN遍历有序集合成员,用法一致。大对象遍历一律用SCAN系列,不要用HGETALL、SMEMBERS这种全量命令。

5.3 DEL大Key的阻塞与UNLINK异步删除

很多人以为DEL是瞬间完成的,直到有一天他删除一个几十万元素的List,然后整个Redis卡了。

DEL的时间复杂度和key内部元素数量成正比。删除一个几十万成员的Set,Redis要逐个释放每个元素的内存和指针,这个操作在主线程执行,耗时可能数百毫秒甚至更长。Redis 4.0之后提供了UNLINK命令:

# 异步删除,主线程立刻返回 UNLINK big:key

UNLINK只是把key从键空间摘掉,真正内存释放交给后台线程慢慢做。日常删除不确定大小的key,我建议一律用UNLINK代替DEL,成本更低,收益明确。

如果Redis版本低于4.0,没有UNLINK怎么办?只能分批删:

  • 大的List:循环LPOP或RPOP分批弹。
  • 大的Set:循环SPOP一小批一小批删。
  • 大的Hash:HSCAN一批field然后用HDEL删。
  • 大的ZSet:ZREMRANGEBYRANK按排名区间一批批删。

麻烦,但至少不会把线上搞挂。

5.4 EXPIRE的隐藏规则:SET会清掉过期时间

前面第2章提到过SET会清掉key的过期时间,这里再从Key管理的角度展开一下。

EXPIRE、TTL、PEXPIRE、PERSIST这几个命令管理key的生存时间:

  • EXPIRE key seconds:设置过期时间(秒)。
  • TTL key:查剩余时间,返回-1表示永不过期,-2表示key不存在或已过期。
  • PERSIST key:去掉过期时间,让key永久保留。

最常见的坑有两类。

第一类是本章已经说过的:业务代码里先EXPIRE给key设置了过期,之后某个分支又用SET重新赋值,过期时间被清掉,变成永久key。凡是“某个key应该自动失效,但线上数据一直没消失”的case,大概率就是这个原因。解决方式:所有需要覆盖写的地方使用SETEX或者SET带EX参数,不要用裸SET。

第二类是批量设置过期时间的误区。有人为了给一批缓存key统一续期,循环执行EXPIRE,几万个key要几万次网络往返,慢且没必要。更好的做法是:代码里生成key时,把过期时间作为key的后缀写进去,例如cache:user:1001:1714550400,版本切换时直接用新后缀的key,旧key自然过期淘汰。

最后提一下惰性删除和定期删除:Redis对过期key的清理分为惰性删除(访问时发现过期才删)和定期删除(后台抽样检查)。但要注意,一个大key到了过期时间被删除时,如果这个删除发生在主线程,一样有可能造成阻塞。这也是为什么设计缓存时要控制单个key体积,不要让过期瞬间变成性能事故。

6. 把常见命令串成方案:缓存穿透、雪崩与分布式锁实战

前面几章是“点”,这一章把它们串成“面”。很多业务遇到缓存问题时会发现,光靠某一条命令解决不了,需要一套组合拳。我挑三个高频问题和一个经典锁场景来演示命令组合的用法。

6.1 缓存穿透:空值缓存与布隆过滤器思路

缓存穿透是指请求的key在缓存和数据库里都不存在,导致每次请求都打到数据库。举个例子:用户查一个不存在的商品ID,缓存没有,数据库也没有。如果攻击者或异常请求一直访问这种key,数据库压力直接被打满。

最简单的方案是空值缓存:数据库查不到也把这个key写进Redis,value设为空标记,设置一个较短的过期时间。

SET cache:product:99999 "NULL" EX 60

后续请求在Redis里查到空值,直接返回“商品不存在”,不再穿透到数据库。注意空值缓存只适合“数据确实可能不存在”的场景,过期时间不能太长,否则业务上新增了这条数据,用户还要等缓存过期才能看到。

进阶方案是布隆过滤器:把所有存在的商品ID在启动时加载进布隆过滤器,请求来了先用布隆过滤器判断,过滤器说不存在就一定不存在,过滤器说存在再走缓存。布隆过滤器可以自己实现,也可以借助Redis的BitMap来模拟。这个方案适合key数量特别大、空值缓存会浪费大量缓存的场景。

6.2 缓存击穿:SETNX互斥锁的简单落地

缓存击穿和穿透不一样:热点key缓存过期的一瞬间,大量并发请求同时打到数据库。典型场景是首页某个热点商品,缓存过期后第一批请求全部发现缓存没有,于是全部去查数据库。

解决方案是互斥锁:只让一个请求去查数据库,其他请求稍等后重试。

流程如下:

  1. 请求到Redis查缓存,有就直接返回。
  2. 缓存没有,尝试SETNX加锁:
SET lock:hot:product:1001 1 NX EX 5
  1. 加锁成功的请求去数据库查询,把结果写回Redis,然后DEL锁。
  2. 加锁失败的请求sleep几十毫秒后重新查缓存。

这个方案用到的命令就两条:SETNX(加锁)和DEL(释放锁)。注意锁一定要有过期时间,否则加锁线程崩溃后锁永远不释放,所有请求都进不了数据库,系统直接雪崩。这个锁的过期时间也要比业务查询数据库的耗时更长,否则业务还没查完,锁到期了,又有一个请求进来重复查库。

6.3 缓存雪崩:过期时间抖动的工程细节

缓存雪崩是指大量key在同一时刻过期,请求全部落到数据库。很多时候不是偶然,而是代码里给所有缓存key设置了同一个“缓存时长”。比如所有商品详情都设置缓存24小时,凌晨零点一到,几十万个key同时过期,数据库瞬时被打爆。

解决方案很朴素:给过期时间加一个随机偏移量。

// 伪代码示意:基础过期时间28800秒(8小时),额外加0到300秒随机值 int ttl = 28800 + random.nextInt(300); redis.setex(key, ttl, value);

用SETEX一条命令就完成了写入和过期设置,关键在于ttl的随机性。这里再强调一下:不要用SET写入后再调用EXPIRE,两步操作有间隙,覆盖写场景还可能被SET清掉过期时间。SETEX一步到位,更稳。

6.4 分布式锁:SET NX EX与Lua脚本的原子释放

分布式锁是缓存场景里最经典的组合。前面的缓存击穿锁不严格要求锁的归属,但真正的分布式锁必须解决一个问题:只能由持锁人释放锁。否则A线程加的锁,过期或卡顿后被B线程看到锁没了,B加锁成功,这时A缓过来了,执行DEL直接把B的锁删了,锁就失效了。

标准写法分两步:

加锁:

SET lock:order:1001 ${requestId} NX EX 30

value不是固定值1,而是当前请求的唯一ID(比如UUID)。释放锁不能简单地DEL,得先判断value是不是自己的ID,一致才删除。判断和删除必须是原子操作,否则先GET后DEL中间插入别的命令又会出错。所以释放锁要用Lua脚本:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这段脚本的意思非常直白:key的value等于我的requestId就删除,否则不动。Redis执行Lua脚本是原子的,中间不会被其他命令打断,所以这个方案是安全的。

这套组合常年出现在各种项目里,核心就是SET的NX+EX参数解决了“加锁+过期”两个问题,Lua脚本解决了“原子释放”一个问题。能把这几个命令背后的语义讲清楚,才算真正理解Redis的分布式锁,而不是背个模板。

从最开始只会set/get,到现在看到一条命令先想它的复杂度、编码、会不会阻塞主线程,这个转变花了我不少时间,也踩了不少线上坑。Redis的命令远不止本文这些,Geo、HyperLogLog、发布订阅、Stream也都值得单独写,但不管学什么命令,我建议你先回答两个问题:这条命令背后的数据结构是什么?最坏时间复杂度是多少?把这两个问题想明白了,你写出来的Redis代码,基本不会出大乱子。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 21:38:20

移动边缘计算卸载算法实战:MECOptimalOffloading代码库解析与复现

简介:移动边缘计算的卸载算法实现包,面向研究边缘计算任务卸载的开发者与算法工程师,重点解决计算任务在本地与边缘服务器之间如何分配的问题,以降低时延和能耗、提升服务质量。压缩包共18个文件,以9个Python脚本为主体…

作者头像 李华
网站建设 2026/10/22 21:36:55

深度神经网络盲点:对抗机器学习中的鲁棒性体检

1. 项目概述:当深度神经网络“看错”世界时,我们在防什么“对抗机器学习系列——深度神经网络的盲点”这个标题乍看像一篇学术综述,但实际它直指当前AI落地中最隐蔽、也最危险的一类现实问题:模型不是“不会”,而是“会…

作者头像 李华
网站建设 2026/10/22 21:36:55

文档图像智能分析:从OCR到大模型的技术演进与落地实践

1. 从一场论坛聊起:文档图像智能分析到底在解决什么问题前阵子有个做企业数字化咨询的朋友找我吐槽,说他们给一家制造业客户做档案电子化项目,扫描了十几万页的历史工单和质检报告,结果OCR识别出来的文本错漏百出,表格…

作者头像 李华
网站建设 2026/10/22 21:33:18

NVIDIA加速代理式AI:推理优化与生产部署全解析

最近朋友圈里聊代理式 AI(Agentic AI)的人明显变多了,NVIDIA 在近期的开发者大会上反复强调一个判断:下一阶段的 AI 生产力,不是靠单次问答,而是靠能规划、能调用工具、能持续多步执行任务的智能体。这个方…

作者头像 李华
网站建设 2026/10/22 21:33:17

Linux服务端I/O进化史:从阻塞、Epoll到零拷贝与io_uring

1. 一切的起点:文件描述符与管道聊服务端I/O演进,绕不开一个最底层的东西:文件描述符(fd)。在Linux世界里,一切皆文件,网络连接是fd、磁盘文件是fd、管道是fd、甚至某些设备也是fd。I/O操作的演…

作者头像 李华
网站建设 2026/10/22 21:31:52

AI进课堂慢落地:技术牌与内容牌的先后顺序

1. 先搞清楚“慢落地”到底卡在哪 “慢落地”这个词最近在圈子里被反复提起,说的不是技术跑不动,而是AI进课堂这件事,从演示到日常,中间隔着一道看不见的墙。我过去两年参与过三个不同学段的智能化教学辅助项目,从小学…

作者头像 李华