第一次在在线实验环境里把 Redis 跑起来的人,多半会有一种错觉:命令敲进去,返回值几乎是瞬间弹出来的,比连数据库查一条记录快太多,于是下意识觉得这东西"很简单"。可真到要拿它去支撑一个业务功能,问题就全冒出来了——缓存该设多久过期、key 怎么命名才不会撞车、计数器为什么偶尔会多算一次、排行榜分数相同的时候顺序怎么乱了、锁加了却还是出现了并发写。这些坑,实验指导书上通常不会写,但线上一定会遇到。这篇内容就是围绕"用 Redis 构建支持程序"这条主线,把我自己在实验环境和实际项目里反复验证过的思路、命令、参数计算和排错经验整理出来。Redis 是一个基于内存的键值数据库,它用五种基础数据结构撑起了缓存、计数器、会话、排行榜、简易队列、分布式锁这一大类"辅助型"能力,是后台系统里最典型的支持程序组件。这篇适合刚接触 Redis、正在做实验任务的学生,也适合已经会用几条命令但不确定自己用得对不对的开发者,从环境准备、结构选型到具体实现和故障排查,一路讲透。
1. 环境与思路:先搞清楚 Redis 在系统里站什么位置
1.1 从一次实验任务的真实困惑说起
在线实验环境通常会把 Redis 服务提前装好、端口开好,学生登录进去敲几条SET、GET就算完成任务。但这种"能跑通"和"能用在项目里"之间隔着一条很宽的沟。我见过太多人做完实验,回头写业务代码时第一反应是"把所有查询结果都塞进 Redis",结果内存爆了、数据不一致、缓存和数据库打架。
要避免这个问题,得先在心里给 Redis 定个位:它是旁路型的加速层和状态层,不是主存储。主存储负责数据的最终正确性和持久化,Redis 负责的是"高频访问的副本"和"短暂存在的状态"。这两类职责的差别决定了后续所有设计细节——缓存可以丢,丢了回源数据库重建;会话状态可以丢,丢了让用户重新登录;但订单金额、账户余额绝对不能只存在 Redis 里。
这个定位一旦确立,很多取舍就自然清楚了。比如缓存要不要开持久化?如果这份数据在数据库里有权威副本,那 RDB 开不开都无所谓,因为丢失后能重建;但如果拿 Redis 存的是唯一的一份计数或者队列任务,那就必须认真配置持久化策略,甚至要考虑 AOF 的刷盘频率。实验环境里默认配置往往是"能跑就行",而生产环境里这些默认值是要逐条核对的。
还有一个常被忽略的点:实验环境给的 Redis 通常是单机版,没有哨兵、没有集群。单机版上做实验,你感受不到网络分区、主从延迟、槽位迁移这些问题,但代码里该留的余量还是得留。我个人的习惯是,即使在单机上写 demo,也把连接超时、重试次数、key 前缀这些参数写成可配置项,后面迁到真实环境时改动量会小很多。
1.2 为什么它快:三个原因决定了它的使用边界
很多人能背出"Redis 快是因为它是内存数据库",但只记住这一条,用起来就会走偏。实际上它的快来自三个层面的叠加,每一层都对应着一条使用禁忌。
第一层是数据存放在内存。内存随机访问的延迟在百纳秒量级,而机械硬盘的随机读写是毫秒量级,差了三到四个数量级。这条带来的禁忌是:内存是稀缺资源,不能无脑往里塞。一台 8GB 内存的机器,操作系统、应用进程、Redis 自身开销占掉一部分,留给数据的大概只有几个 GB。所以设计缓存时必须估算数据量:单条记录的序列化后大小乘以预计条数,再乘以一个安全系数(我一般用 1.5 到 2),超过可用内存就得考虑淘汰策略或者只缓存热点。
第二层是单线程处理命令。Redis 处理客户端命令的主线程是单线程的,这带来的好处是天然的原子性——一条命令执行期间不会被别的命令插队,所以INCR这种操作不需要加锁。但禁忌也随之而来:任何一条耗时长的命令都会阻塞所有其他请求。KEYS *在 key 数量上百万时会扫描全表,可能卡住几百毫秒甚至更久;对一个百万元素的 List 执行LRANGE key 0 -1同样会阻塞。这两条命令我在代码评审里见到就要求改掉,前者换成SCAN游标遍历,后者限制返回范围或者拆分。
第三层是IO 多路复用。单个线程配合 epoll 这类机制,能同时盯住成千上万个连接,谁的请求到了就处理谁,避免了为每个连接开线程的上下文切换开销。这意味着 Redis 擅长的是"大量短小的请求",而不是"少量巨大的请求"。所以批量操作要用MGET、MSET、Pipeline 把多次往返合并成一次,而不是循环里一条条发。
把这三层理解透,你就知道什么时候该用 Redis、什么时候不该。有一次某个项目组想把用户上传的图片二进制直接存 Redis,理由是"读取快"。这种场景就不合适——图片动辄几百 KB,放进内存既浪费又会让网络传输变成瓶颈,正确做法是存对象存储,Redis 里只放路径。
1.3 在线实验环境的三项准备动作
不管实验平台把环境做得多完善,动手前我建议先做三件事,能省掉后面一大半的困惑。
第一件,确认连接参数和服务状态。在实验终端里执行redis-cli ping,返回PONG说明服务在跑。如果返回连接被拒绝,先看服务有没有启动,再看端口对不对,最后看是不是只监听了本地回环地址。有些实验环境把 Redis 配成只允许本机访问,你在同一台机器上用redis-cli能连上,但用别的方式连就失败,这属于正常的安全配置,不是故障。
第二件,看一眼当前的配置项。CONFIG GET maxmemory能告诉你内存上限是多少,CONFIG GET maxmemory-policy能告诉你超限之后的淘汰策略。默认值往往是noeviction(不淘汰,写入直接报错),这在做缓存实验时非常容易踩坑——数据写满以后所有写入都失败,而你还以为是代码问题。做缓存类实验时,我一般会把它设成allkeys-lru或volatile-lru,前者对所有 key 生效,后者只淘汰设置了过期时间的 key,后者更安全,因为不会误删没设 TTL 的重要数据。
第三件,建立 key 命名规范。这是最不起眼但收益最大的习惯。我的规范是业务名:对象类型:标识[:字段],用冒号分隔,例如user:profile:10086、rank:daily:20240501。这么做的原因有三个:一是SCAN支持用MATCH user:*做前缀匹配,排查问题时能快速定位;二是不同业务之间不会撞名,实验环境里多个任务共用一个实例时尤其重要;三是看 key 就知道存的是什么,省掉查文档的时间。
注意:实验环境里经常有多个任务共用同一个 Redis 实例的情况,如果你用了
test、demo、1这种极简 key 名,很可能覆盖掉别人的数据,或者被别人的数据干扰导致结果对不上。加前缀是最低成本的隔离手段。
2. 五种数据结构的选型逻辑:别拿 String 硬套一切
2.1 String:缓存的默认答案,也是计数器的主战场
String 是唯一一个能直接存任意二进制序列的类型,序列化后的 JSON、图片、HTML 片段都能放进去。做缓存时它是默认选择,因为序列化和反序列化的逻辑掌握在应用层,Redis 完全不需要理解内容结构。
但 String 真正的价值在计数上。INCR、DECR、INCRBY、INCRBYFLOAT这几个命令的原子性是靠单线程执行来保证的,不需要你在应用层加任何锁。这一点非常关键:如果换成"查出来、加一、写回去"三步,并发下就会丢更新。我做过一个测试,两个客户端各执行一千次读改写,最终结果比预期少了将近一半;换成INCR之后数字完全准确。
计数器有一个隐藏的坑:INCR作用于不存在的 key 时会先把它当 0 再自增,结果就是 key 被静默创建了。如果这个 key 本来应该由别的流程负责创建并设置过期时间,那这个"自动创建"的 key 就永远不会过期,日积月累变成内存垃圾。所以我的习惯是每次INCR之后都跟一次EXPIRE,或者干脆用 Lua 脚本把两步绑成一个原子操作。
另一个常见用法是做分布式锁的基础,靠SET key value NX PX 毫秒数这条命令。NX表示只有 key 不存在时才设置成功,PX表示设置毫秒级过期时间,两个语义合在一条命令里就是为了保证"判断和设置"的原子性。老代码里常见的SETNX加EXPIRE两步写法是错的——如果进程在两条命令之间崩溃,锁就永远不会释放。
2.2 Hash 与 List:对象存储和轻量队列
Hash 适合存"一个对象有多个字段"的场景。比如用户信息,用HSET user:10086 name 张三 age 28 city 杭州,之后可以用HGET取单个字段,也可以HMGET批量取,还能HINCRBY对某个数值字段自增。相比把整个对象序列化成 JSON 存 String,Hash 的优势是可以只读写部分字段,在网络传输和修改粒度上都更省。
不过 Hash 也有成本。字段少的时候,Redis 内部会用紧凑的编码方式来省内存;字段一多或者单个值变大,就会切换成哈希表编码,内存占用会明显上升。这个切换阈值由hash-max-listpack-entries和hash-max-listpack-value控制,默认大约是 128 个字段和 64 字节。如果你的对象字段可能超过这个数量,提前评估一下内存影响。
List 是双向链表,两头都能进出。做队列时用LPUSH从左边进、RPOP从右边出,就是先进先出;反过来就是栈。但要注意,RPOP在队列空的时候会立刻返回空值,客户端就得轮询,既浪费 CPU 又有延迟。更合适的做法是用阻塞版本的BRPOP,队列空时客户端会挂起等待,有新元素立刻返回,最长等待时间由命令参数指定。
List 做队列的局限在于它没有确认机制——元素一旦弹出就从 Redis 里消失了,如果消费者处理到一半崩溃,这条任务就丢了。所以它只适合对可靠性要求不高的场景,比如发送通知、刷新缓存这类丢了可以重来的活。真正要保证不丢,得上 Stream 加消费组,或者用专门的消息中间件。这一点在实验里体现不出来,但实际选型时必须想清楚。
2.3 Set 与 ZSet:去重和排序的两个钥匙
Set 是无序集合,天然去重,判断元素是否存在的复杂度是常数级。典型用法是统计独立访客:把用户 ID 塞进一个 Set,最后SCARD一次就是去重后的人数。用SADD的返回值还能判断这次是不是新用户——返回 1 表示新加入,返回 0 表示已存在,这个特性做"每日签到"特别顺手。
Set 之间还支持交并差运算。SINTER求交集可以用来找共同关注、共同标签;SUNIONSTORE把并集存到新 key 里可以做起合并。这些运算在 Redis 内部完成,比把数据拉到应用层再算要快得多,也省了网络传输。
ZSet 是在 Set 的基础上给每个成员配了一个分数,按分数排序。这是 Redis 里唯一一个"能按数值排序且支持范围查询"的结构,排行榜基本都靠它。ZADD加成员和分数,ZINCRBY给分数累加,ZREVRANGE按分数从高到低取前 N 名,ZREVRANK查某个成员的排名,ZRANGEBYSCORE按分数区间筛选。这几个命令组合起来,一套排行榜功能就齐了。
ZSet 的排序是稳定的吗?不完全是。分数相同的成员按成员的字典序排列,这一点在做排行榜时经常被误解。如果业务上要求"分数相同先达到的排前面",光靠分数做不到,得把时间信息编码进分数里。这个技巧我在第 3 章会详细算一遍。
2.4 结构选型的判断流程
把这五种类型摆在一起,选型其实有迹可循。我在实际项目里常用下面这套判断顺序,基本能覆盖八成场景:
| 需求特征 | 首选结构 | 判断理由 | 常见误用 |
|---|---|---|---|
| 整体读写一个序列化对象 | String | 应用层序列化,Redis 不关心结构 | 用 Hash 存一个只有单个字段的对象,浪费 |
| 对象要按字段读写、部分更新 | Hash | 支持单字段操作 | 字段数超阈值后内存膨胀 |
| 只关心数量、需要原子自增 | String + INCR | 命令级原子性 | 读改写三步,并发丢更新 |
| 先进先出、允许丢失 | List | 双端操作,配合 BRPOP | 用 RPOP 轮询,空转耗 CPU |
| 去重、集合运算 | Set | 天然去重 + 交并差 | 元素成百上千万时不拆分,单 key 过大 |
| 排序、排名、分数区间 | ZSet | 唯一支持按分数排序 | 分数精度不够,导致同分顺序错乱 |
这套判断的核心思想是:先问"我要对数据做什么操作",再问"哪种结构能最省事地支持这个操作",而不是先想"我要存什么数据"。顺序反过来,就容易出现"用 String 存一个列表然后每次全量读出来改"这种低效写法。
还有一个容易被忽略的维度是过期粒度。过期时间是设置在 key 上的,所以如果一批数据需要不同的过期时间,就必须拆成不同的 key。比如商品缓存,如果所有商品共用一个 key,那没法单独让某个热门商品提前过期。这也是为什么 key 的粒度设计要和过期策略一起考虑。
3. 实操:用 Redis 构建四类支持程序的完整过程
3.1 缓存读写策略与过期时间的量化设计
缓存是 Redis 最主流的用途,但"缓存"这两个字背后有好几种策略,选错了会出现数据不一致。常见的有三种:
- 旁路缓存(Cache-Aside):应用自己读缓存,没命中就查数据库,查完写回缓存。写操作是先更新数据库,再删除缓存。这种模式最常用,逻辑在应用层,排查方便。
- 读穿透(Read-Through):应用只和缓存打交道,缓存没命中时自己回源。这种需要缓存组件支持回源逻辑,Redis 原生不提供,得靠框架封装。
- 写回(Write-Behind):写操作只写缓存,异步批量刷回数据库。吞吐高但有丢数据风险。
绝大多数场景用第一种。关键在于"更新数据库后删除缓存"而不是"更新缓存",因为并发下更新缓存容易出现旧值覆盖新值——两个请求同时写,后到的可能拿到的是先查出的旧数据。删除缓存则简单粗暴,下次读的时候自然回源重建。
过期时间怎么定?这一步很多人拍脑袋写个 3600 秒就完事,其实有计算依据。基本公式是:
过期时间 ≈ 数据可容忍的陈旧时长 × 安全系数举个例子:某商品的价格在数据库里每天变动两次,业务上能接受页面显示的价格最多旧 5 分钟。那么基础过期时间取 5 分钟(300 秒)是底线,再考虑缓存重建耗时和访问峰值,实际可以设 180 到 300 秒。设太长用户会看到旧价,设太短缓存命中率下降、数据库压力上升。
更关键的是打散过期时间。如果一批缓存是在同一时刻批量加载的,过期时间又都是 300 秒,那它们会在 300 秒后同时失效,瞬间所有请求都打到数据库上,这就是缓存雪崩。解决办法是给过期时间加一个随机抖动:
实际TTL = 基础TTL + random(0, 基础TTL × 0.2)基础 300 秒的话,实际 TTL 在 300 到 360 秒之间随机,失效点就被摊平在 60 秒的窗口内。抖动的比例我一般取 10% 到 20%,太小起不到打散作用,太大又会让部分数据过早失效。
写缓存的命令很简单:
# 写入商品缓存,TTL 由应用计算后传入 SET product:detail:200301 '{"id":200301,"name":"样例商品","price":199.00}' EX 326 # 读取 GET product:detail:200301 # 更新数据库后删除缓存,而不是更新缓存 DEL product:detail:200301注意:
SET的EX参数单位是秒,PX是毫秒。如果 TTL 计算出来是小数,别直接传,取整之后再传,否则命令会报参数错误。另外,SET key value EX 0会直接报错,因为过期时间必须大于 0,所以计算 TTL 时要做一次下界保护。
3.2 接口限流:固定窗口与滑动窗口的取舍
限流是"支持程序"里非常典型的一类需求:某个接口每分钟最多允许同一用户调用 60 次,超了就拒绝。用 Redis 做这件事有两种主流实现,选哪种取决于你对边界精度的要求。
固定窗口最简单,用INCR加EXPIRE:key 是"用户ID + 当前分钟",每次请求自增,第一次自增时设置 60 秒过期。到达阈值就拒绝。缺点是窗口边界处会放过两倍流量——比如限制每分钟 60 次,用户在 12:00:59 用掉 60 次,12:01:00 又用掉 60 次,跨窗口的一秒内实际放行了 120 次。
滑动窗口用 ZSet 实现:把每次请求的时间戳作为成员和分数存进去,每次请求先删掉窗口外的旧记录,再统计窗口内的数量。这样任意一个 60 秒的滑动区间内都不会超过阈值,精度高,代价是每个请求要操作 ZSet,内存和 CPU 开销都比固定窗口大。
实际项目中我用固定窗口居多,因为实现简单、性能好,业务上对边界那一秒的突刺通常能容忍。只有在计费、风控这类对精度敏感的场景才上滑动窗口。下面是固定窗口的原子实现,用 Lua 保证自增和设置过期在同一个原子操作里:
-- rate_limit.lua -- KEYS[1]: 限流 key -- ARGV[1]: 阈值 -- ARGV[2]: 窗口秒数 local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if current > tonumber(ARGV[1]) then return 0 end return 1这段脚本有两个细节值得说。第一,EXPIRE只在current == 1时调用,避免每次请求都刷新过期时间——如果每次刷新,那这个 key 在有流量时永远不会过期,窗口就变成了"从最后一次请求往前推 60 秒",语义完全变了。第二,返回值用 0 和 1 表示拒绝和放行,调用方直接判断即可,不需要再查一次计数。
阈值怎么算?假设你要限制某接口单机 QPS 为 50,而服务部署了 4 个实例,那么单个实例的阈值应该是 50/4 ≈ 12.5,向下取整为 12,留一点余量可以取 10。如果用的是共享 Redis,那直接用全局阈值也行,但要考虑 Redis 本身的承载能力——所有实例的限流请求都打到同一个 Redis,它自己别被压垮。
3.3 会话与验证码:TTL 设计与 key 结构
用 Redis 存登录会话,核心价值是多个应用实例可以共享同一份会话,用户在某台机器上登录,请求被负载均衡打到另一台上依然有效。会话的 key 一般用 token 作为标识,value 存用户 ID、角色、登录时间等必要信息。
# 登录成功后写入会话,有效期 30 分钟 HSET session:tk_8f3a2b91 uid 10086 role normal login_at 1717000000 EXPIRE session:tk_8f3a2b91 1800 # 每次请求校验并续期(滑动过期) EXPIRE session:tk_8f3a2b91 1800这里的取舍是绝对过期还是滑动过期。绝对过期从登录那一刻算起,到点就失效,用户必须重新登录,安全性好但对活跃用户不友好;滑动过期每次访问都续期,只要用户在操作就一直有效,体验好但存在风险——如果 token 泄露,攻击者只要持续访问就能让会话永不过期。我一般用的是"滑动过期 + 绝对上限":每次访问续期,但同时在 value 里记录登录时间,超过 12 小时强制失效,兼顾两端。
验证码的场景更简单,key 用"业务前缀 + 手机号"或者"业务前缀 + 会话ID",value 存验证码数字,TTL 设 60 到 300 秒。校验时先GET比对,比对成功后立刻DEL,防止同一个验证码被重复使用。这一步很容易漏,漏了就等于验证码可以无限次尝试。
# 生成验证码,5 分钟有效 SET verify:login:13800138000 483921 EX 300 # 校验并销毁(注意:GET 和 DEL 之间存在竞态) GET verify:login:13800138000 DEL verify:login:13800138000上面这种"先 GET 再 DEL"在并发下有漏洞:两个请求同时 GET 到同一个验证码,都通过了校验,然后各自 DEL。虽然概率低,但在安全场景下不能接受。稳妥的写法是用 Lua 把比对和删除绑在一起:
-- verify_code.lua -- KEYS[1]: 验证码 key ARGV[1]: 用户输入 local stored = redis.call('GET', KEYS[1]) if stored and stored == ARGV[1] then redis.call('DEL', KEYS[1]) return 1 end return 03.4 排行榜:把时间因素编码进分数
排行榜是 ZSet 最经典的用武之地,但直接ZADD rank score member会遇到一个具体问题:分数相同时按成员字典序排,业务上通常要求"同分时先达到的先排前面"。
解决办法是把分数设计成一个复合值。Redis 的 ZSet 分数是双精度浮点数,能精确表示的最大整数是 2 的 53 次方,约 9.007×10^15。只要复合分数不超过这个范围,就能保证不丢精度。设计如下:
score = 投票数 × 10^10 + (基准时间戳 - 达成时间戳)第一项把投票数放到高位,保证投票数多的排在前面;第二项是时间偏移量,达成时间越早,偏移量越大,同分时就越靠前。10^10这个因子的选择依据是:时间戳偏移量的最大值必须小于它。假设基准时间戳取 2000000000(2033 年前后),达成时间戳最小可能是 1700000000,差值最大约 3×10^8,小于 10^10,安全。而投票数上限假设是 10 万,那么最大分数是 10^5 × 10^10 = 10^15,小于 9×10^15,也在安全范围内。
用 Python 算一下就很直观:
BASE_TS = 2000000000 MAX_VOTES = 100_000 FACTOR = 10 ** 10 def make_score(votes: int, ts: int) -> int: assert votes <= MAX_VOTES, "票数超出设计上限" offset = BASE_TS - ts # 越早达成,offset 越大 score = votes * FACTOR + offset assert score < 2 ** 53, "分数超出双精度安全整数范围" return score # 两个用户都是 100 票,A 更早达成 print(make_score(100, 1717000000)) # 10000283000000 print(make_score(100, 1717003600)) # 10000282996400结果一目了然:A 的分数比 B 大,排名更靠前。这个设计还带来一个额外好处——投票数变化时可以直接ZINCRBY增加FACTOR,时间偏移那部分保持不变,排名自动更新,不需要重新计算整个分数。
分页查询用ZREVRANGE key start stop,第 1 页是0 9,第 2 页是10 19,以此类推。注意stop是闭区间,所以每页 N 条时,第 k 页(从 1 开始)的参数是(k-1)*N到k*N-1。查某个用户的排名用ZREVRANK,返回值从 0 开始,展示时要加 1。
注意:
ZREVRANGE加上WITHSCORES会把分数一起返回。如果你用了复合分数,返回的分数是个十几位的整数,前端拿到会一脸懵。这种情况下要么在应用层反解出投票数和时间,要么另外维护一个仅含展示分数的 ZSet,别把内部编码直接暴露出去。
3.5 简易分布式锁:三个必须做对的细节
多实例部署时,某段逻辑只允许一个实例执行,就需要分布式锁。用 Redis 实现的基本形态是:
# 加锁:key 不存在才设置成功,5 秒后自动过期 SET lock:order:10086 <唯一标识> NX PX 5000 # 解锁:先比对标识,再删除(必须用 Lua 保证原子) EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:10086 <唯一标识>这三个细节必须做对,否则锁形同虚设。
第一,加锁必须是一条命令。SET key value NX PX ttl把"不存在则设置"和"设置过期"合成一个原子操作,避免了SETNX之后进程崩溃导致锁永不过期的经典事故。
第二,value 必须是唯一标识。常见做法是"实例ID + 线程ID + 随机数"。如果 value 是固定值,A 拿到锁后执行超时锁被自动释放,B 拿到了锁,这时候 A 执行完去解锁,会把 B 的锁删掉。带上唯一标识并在解锁前比对,就能避免误删。
第三,解锁必须用 Lua。GET和DEL两条命令之间存在时间窗口,极端情况下比对照样会误删。Lua 脚本在 Redis 里是原子执行的,比对和删除不会被插入。
锁的过期时间设多少?这需要估算业务执行耗时。假设正常执行 200 毫秒,最长可能 2 秒(比如遇到一次慢查询),那锁过期时间设 5 秒比较稳妥,留出两倍以上的余量。设太短会提前释放导致并发进入,设太长则一旦持锁进程崩溃,其他实例要等很久才能拿到锁。如果业务耗时不可预估,就需要"看门狗"机制——后台线程定期给锁续期,这就要引入额外的复杂度了。
4. 排查实录:那些实验里不会告诉你的问题
4.1 缓存三大问题的区分与处理
缓存穿透、击穿、雪崩这三个词经常被混着用,实际上成因和处理手段完全不同,混淆了就会用错方案。
穿透是查询一个根本不存在的数据。缓存里没有,数据库里也没有,每次请求都穿过缓存打到数据库。如果有人恶意用随机 ID 高频请求,数据库压力会剧增。处理方式有两种:一是缓存空值,即使数据库查不到也往 Redis 写一个短 TTL 的空标记(比如 60 秒),让后续请求命中这个空标记;二是用布隆过滤器提前判断 ID 是否存在,不存在直接拒绝。
击穿是某个热点 key 突然失效,大量请求同时涌向数据库。注意它针对的是单个热点 key。处理方式是加互斥锁——只有一个请求能去回源重建,其他请求等待或者返回旧值。也可以用"逻辑过期":不给 key 设置真实 TTL,而是在 value 里存一个逻辑过期时间,发现过期后异步重建,读请求先返回旧数据。
雪崩是大量 key 在同一时刻集中失效,或者 Redis 整体不可用。处理方式是打散过期时间(前面算过)、多级缓存(本地缓存挡一层)、以及对数据库做限流降级。
| 问题 | 触发条件 | 影响范围 | 主要手段 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 单个或大量随机 key | 缓存空值、布隆过滤器 |
| 击穿 | 单个热点 key 失效 | 单个 key | 互斥锁重建、逻辑过期 |
| 雪崩 | 大量 key 同时失效 | 全量 key | TTL 打散、多级缓存、降级 |
我踩过最典型的一次坑是把穿透当成雪崩处理,上来就加 TTL 随机抖动,结果完全没用——因为请求查的是不存在的 ID,压根没有 key 可失效。后来加了空值缓存才止住。判断问题的第一步永远是看日志里查的是哪些 key、命中率是多少,而不是套模板。
4.2 命令误用与性能问题的排查路径
Redis 的慢查询排查有一套现成的工具链,但很多人不知道从哪下手。我的排查顺序是这样的:
第一步,看慢查询日志。SLOWLOG GET 10能列出最近的慢命令,包含执行耗时、命令内容、客户端地址。默认阈值是 10000 微秒(10 毫秒),如果日志里频繁出现记录,说明有命令确实慢。调阈值用CONFIG SET slowlog-log-slower-than 5000,单位是微秒。
第二步,看是不是大 key。大 key 是性能问题最常见的根源。判断方式有几个:STRLEN查 String 长度,LLEN查 List 长度,HLEN查 Hash 字段数,SCARD查 Set 基数,ZCARD查 ZSet 基数。如果某个 key 的集合元素超过一万,就要考虑拆分。更精确的内存占用可以用MEMORY USAGE key查,单位是字节。
第三步,看是不是用了阻塞命令。KEYS *是头号嫌疑犯,它要遍历整个键空间,key 数量多的时候能卡住几百毫秒。换成SCAN 0 MATCH user:* COUNT 100游标遍历,每次只返回一小批,虽然总耗时可能更长,但不会长时间阻塞主线程。同理,FLUSHALL、FLUSHDB在生产环境要慎用,DEBUG SLEEP更是绝对禁止。
第四步,看连接数。INFO clients里的connected_clients如果接近maxclients,新连接就会被拒绝。这种情况通常是因为客户端没有正确关闭连接,或者连接池配置过大。CLIENT LIST能看到每个连接的详细信息,包括空闲时长,据此可以定位泄漏的连接。
有一个容易被忽略的点:Pipeline 不是越多越好。有些人为了减少网络往返,把几千条命令塞进一个 Pipeline 一次性发送。Redis 需要先接收完整个请求再逐条执行,返回时也要拼装完整响应,内存占用会瞬间冲高。我一般把单次 Pipeline 的命令数控制在几百条以内,超过就分批。
4.3 常见问题速查表与实操心得
下面这张表是我这些年反复遇到的问题汇总,按现象、原因、处理方式排列,做实验卡住的时候可以直接对照:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
写入报错OOM command not allowed | 内存达到上限且淘汰策略为 noeviction | 调整maxmemory-policy为allkeys-lru或扩容 |
INCR之后 key 永不过期 | 自动创建时未设 TTL | 用 Lua 把INCR和EXPIRE绑定 |
| 排行榜同分顺序不对 | 分数相同按字典序排 | 把时间偏移编码进分数 |
| 缓存和数据库数据不一致 | 更新数据库后更新了缓存而非删除 | 改为删除缓存,读时回源 |
| 锁在异常时未释放 | 用了SETNX+EXPIRE两步 | 改用SET key value NX PX单命令 |
| 验证码可重复使用 | 校验后没有删除 | 用 Lua 原子地比对并删除 |
| 会话在多实例间失效 | 会话存在单机内存或本地缓存 | 统一存 Redis,共享同一实例 |
SCAN结果有重复 | 游标遍历不保证不重复 | 应用层用 Set 去重,属正常行为 |
命令报WRONGTYPE | 对已有 key 用了不匹配的命令 | 先TYPE key确认类型,改 key 名或清理 |
| 过期时间设了却不生效 | 之后又对该 key 执行了不带KEEPTTL的SET | SET默认会清除 TTL,改用SET ... KEEPTTL或重新EXPIRE |
最后两个是我觉得最值得单独说的。WRONGTYPE这个错误在做实验时特别常见,因为实验环境多人共用,你起的 key 名如果太通用,很可能被别人写成了别的类型。比如你打算LPUSH task:queue,结果这个 key 之前被人SET成了字符串,LPUSH就会报类型错误。解决办法还是那句:key 加业务前缀,别用裸名。
SET清除 TTL 这一点更隐蔽。很多人写完SET key value EX 300之后,某处代码又执行了一次SET key newvalue,以为只是更新了值,实际上过期时间被清空了,这个 key 从此永久驻留。这个问题不会立刻报错,只会表现为内存慢慢涨上去,排查起来非常费劲。我现在的习惯是所有更新值的地方都显式带上 TTL 参数,或者用KEEPTTL保留原有过期时间。
我个人在实际操作中的体会是,Redis 的命令本身没什么学习成本,半小时就能把常用命令过一遍,真正花时间的是想清楚数据该以什么形态存在 Redis 里、过期和更新怎么配合、并发下哪一步可能出问题。这三个问题想明白了,代码写出来自然就稳;想不明白,命令背得再熟也只是在制造隐患。做实验的时候不妨多问自己一句:如果这个 key 突然消失了,我的程序会怎样?如果两个请求同时进来,会不会算错?把这两个问题回答清楚,Redis 这一关就算真正过了。