上一章把 Redis 放进了用户详情接口:GET /users/u1001 命中 user:profile:u1001 后,不再占用数据库连接。这个结果很容易让人形成一句过度简化的结论:Redis 快,因为数据在内存里。
这句话只说中了起点。一个后端请求从 Node.js 发出后,要经过客户端队列、TCP 往返、Redis 网络读写、协议解析、命令执行和响应回传。内存只缩短了其中一段。Redis 真正的设计选择,是让大多数命令只做很少的工作,再用事件循环接住大量连接,用串行执行换掉数据结构上的锁竞争,用紧凑编码降低内存与缓存失效率。
这套设计也把风险集中得很清楚:主线程一旦被一条慢命令占住,后面的快命令也会排队。 所以理解 Redis 的“快”,不能停在宣传数字上。我们要能沿着一次请求解释时间花在哪里,也要能在半夜接口突然变慢时,知道先查什么。
假设商品服务有三个同步接口:商品详情读取 product:sku1001,库存读取 inventory:sku1001,登录校验读取 session:sess_demo_001。平时三类命令都是毫秒以内完成,监控里的 Redis 连接数、内存和 CPU 也都不高。
某次运营排查缓存,临时脚本在生产实例上执行了全库扫描;与此同时,一份异常大的用户画像被完整序列化返回。Redis 进程没有退出,连接也没有断开,但商品详情的 P99 突然升高,库存接口跟着超时,应用开始重试。值班同学看到的第一印象是“Redis 怎么突然变慢了”,可真正拖慢全站的只是队头那一小段连续工作。

Redis 的命令执行路径以串行为主。前面的命令如果连续占用主线程 300 ms,那么后面一个本来只需 0.2 ms 的 GET,端到端延迟也可能超过 300 ms。这个 GET 没有变慢,它只是迟迟拿不到执行机会。
“命令复杂度是 O(1)”不等于“这次调用一定很快”。一个几十 MB 的字符串仍要读取、复制并写入网络;删除一个包含大量元素的大 key 也会产生释放成本。判断风险要同时看命令复杂度、数据规模、返回体积和它连续占用执行路径的时间。
这场事故把本章的母题说清了:Redis 快,是因为它把正常路径压得很短;Redis 的代价,是任何没有被控制住的长任务都会把排队时间传给其他客户端。
先把“访问内存很快”拆成完整链路。以文章浏览计数为例,应用调用 INCR counter:article:a100:view 时,大致经过下面几段:
只有第 4 步是“查内存”。如果应用与 Redis 跨地域部署,网络往返可能比命令执行慢几个数量级;如果客户端每次请求都重新建连,握手与调度又会继续加账;如果响应体很大,网卡带宽和缓冲区会成为限制。于是我们得到一个更接近工程现场的近似式:
它不是用来精确预测延迟的公式,而是提醒我们不要拿“Redis 内部执行耗时”冒充“接口端到端耗时”。优化前先确定时间主要落在哪一段。
内存仍然是重要起点。Redis 的工作数据集主要驻留在内存,普通读写不需要沿着磁盘页读取、查询计划和复杂事务路径前进。但这不意味着关系型数据库每次都从机械硬盘读取:数据库也有缓冲池,命中的数据页同样可能在内存。Redis 的优势来自整条路径更短、数据模型更直接,而不是“只有 Redis 会使用内存”。
如果一台 Redis 同时连接了几千个应用进程,最浪费的做法是给每条连接常驻一条线程,然后让绝大多数线程堵在“还没有数据可读”上。Redis 使用非阻塞 socket 和 I/O 多路复用,把等待工作交给操作系统:内核观察许多连接,只有连接真正可读或可写时,才把就绪事件交给 Redis。
你可以把它想成餐厅的叫号屏。顾客不必一人占住一个服务员干等;号码准备好后进入就绪队列,服务员按事件继续处理。这里有两个容易混淆的概念:

不同操作系统提供的就绪通知机制不同,Linux 常见的是 epoll,BSD/macOS 常见的是 kqueue。应用代码不需要轮询每个连接问“你有数据了吗”,事件库会把已经就绪的连接交出来。Redis 自己维护事件循环,既处理文件事件,也处理定时任务,例如推进过期 key 清理和周期性维护。
这套机制解决的是大量连接的等待成本,不是把一个慢命令拆成多份。一个连接进入就绪队列,只代表“现在可以读取”;读取完成并进入命令队列,也只代表“轮到时可以执行”。如果前面有长任务,它仍然要等。
下面的实验把五个业务连接放在同一条执行路径上。先把商品详情和库存加入就绪队列,逐步运行;再把其中一条请求调成 1.6 秒,观察其他请求的等待时间怎样被放大。
事件循环带来的另一个好处是执行模型更容易推理。某条命令开始修改数据后,其他普通命令不会在中途插进来改一半。这为单命令原子性、事务队列和 Lua 脚本提供了清楚的执行边界。但“串行”不等于所有业务天然安全:读取余额再由应用计算、随后写回,仍然跨了多个命令,其他客户端可以在它们之间插入操作。后续讲事务与 Lua 时,我们会专门处理这种边界。
把 Redis 简单描述成“单线程程序”会造成新的误解。现代 Redis 进程里可以有后台线程、I/O 线程,也会为 RDB 快照或 AOF 重写创建后台子进程。真正需要抓住的是:访问和修改核心内存数据结构的命令执行路径,仍然保持清楚的串行边界。
Redis 6 起可以启用 I/O 线程分担网络读写;配置允许时,也可让读取请求和协议解析在 I/O 线程侧分担。请求随后汇合到命令执行主线程,响应写回再由 I/O 线程协助。这能缓解网卡吞吐、协议处理和大批连接带来的网络 I/O 压力,却不会让两个 INCR 在同一个实例里随意并行改同一个计数器。

因此,下面几句话可以同时成立:
如果瓶颈已经确认在网络读写,I/O 线程才是值得压测的选项;如果 SLOWLOG 里是大范围集合运算,增加 I/O 线程不会把执行主线程救出来。若单实例的命令执行 CPU 已经接近上限,要从 key 分片、Redis Cluster 或多个独立实例考虑扩展,而不是把 io-threads 当成万能旋钮。
不要仅凭“CPU 不高”排除 Redis 阻塞。单个执行线程打满一个核心时,整台多核机器的总 CPU 百分比可能仍然不显眼。排查时要结合单核占用、命令延迟分位数、慢日志、事件循环延迟和业务端超时一起看。
Redis 快并不只因为省掉磁盘读取。数据在内存里如何摆放,会影响容量、CPU 缓存命中和遍历成本。一个业务对象如果被拆成大量小 key,每个 key 都要承担字典项、对象头、指针、字符串和内存分配器的额外开销。数据内容只有几个字节,不代表它在进程里也只占几个字节。
Redis 会根据类型、元素数量和元素大小选择内部编码。整数形式的字符串可以使用整数编码;短字符串可以使用紧凑的嵌入式表示;只包含整数且规模较小的 Set 可以使用 intset;较小的 Hash、Set 或 Sorted Set 在新版本中可以使用 listpack;List 通常以 quicklist 组织多个紧凑节点。对调用方来说仍然是 String、Hash、Set、ZSet,内部表示却可能随着数据增长自动转换。

紧凑编码的收益不只是一张“省内存”账单。元素连续摆放时,CPU 访问相邻数据更友好,也减少了大量独立分配。不过它是一笔 CPU 与内存之间的交易:结构变大、元素变长后,继续维持紧凑表示可能让插入、移动或转换成本升高,所以 Redis 设有转换阈值。
生产中不要为了追求极限节省,直接把紧凑编码阈值调得很大。更稳妥的顺序是:用真实 key 观察 MEMORY USAGE 和 OBJECT ENCODING,确认对象规模分布,再在隔离环境里压测编码转换和尾延迟。真正的大 key 即使编码很省,也仍然可能在一次读取、序列化、复制、删除或网络发送时形成长任务。
假设应用要给 240 篇文章读取计数,最直白的代码是循环 GET,每次等待响应后再发下一条。即使命令本身只占很短时间,客户端也要付 240 次网络往返。若单次 RTT 是 0.6 ms,光等待往返就接近 144 ms;跨地域时,这个数字会迅速失控。
Pipeline 允许客户端先把一批命令排进缓冲区,一次发给 Redis,再按原顺序收回一批响应。若每批 40 条,240 条命令只需要 6 次往返。服务端仍执行 240 次命令,没有获得“40 条并行执行”的能力。

用 ioredis 写法很直接:
import Redis from "ioredis";
const redis = new Redis({ host: "127.0.0.1", port: 6389 });
const pipeline = redis.pipeline();
for (const articleId of ["a100", "a101", "a102", "a103"]) {
pipeline.get(`counter:article:${articleId}:view`);
在只考虑 RTT 与服务端执行时间的教学模型里,若命令数为 、每批大小为 、单次 RTT 为 、单命令执行耗时为 ,可以近似写成:
两个式子里的 没有消失。Pipeline 主要削掉了重复 RTT。它也不是事务:其他客户端的命令仍可能在合适的执行边界进入;Pipeline 中某一条命令失败,也不会自动撤销已经执行的其他命令。
批次同样不能无限增大。客户端要暂存待发送命令,Redis 与客户端都要承受响应缓冲;批头命令的结果也可能等到同批响应一起传回。实践中应根据 RTT、响应大小和延迟目标分批压测。下面的计算器可以切换同机房与跨地域场景,并观察批次变大后往返减少、响应缓冲增加和批内等待之间的关系。
回到开头的事故。最危险的地方不是开发者故意写了一条“睡眠命令”,而是许多看似正常的操作会随着数据增长改变成本:HGETALL 要返回全部字段,SMEMBERS 要返回整个集合,集合交集要检查大量成员,KEYS 会在一次调用中遍历键空间,大 value 的 GET 还要复制并发送大量字节。
事故处理不要先猜。可以沿着这条顺序缩小范围:
先从应用端确认异常窗口:哪些接口的 P95/P99 上升,连接池是否积压,超时与重试是否同时增加。平均延迟正常不能排除少量严重长尾。
查看 SLOWLOG GET。慢日志记录的是命令在 Redis 内部的执行时间,不包含和客户端通信、发送响应等网络 I/O,因此它能指出执行路径上的长任务,但不能覆盖全部端到端延迟。
结合 INFO commandstats、延迟监控、key 大小与返回体积判断原因。慢日志为空而应用仍慢时,要继续查 RTT、客户端排队、连接复用、网络带宽和大响应发送。
优先缩短单次连续工作:精确取字段,用 系列分批迭代,把重聚合改为预计算或异步任务,对大 key 做拆分与渐进删除。改完后重新观察尾延迟,而不是只看吞吐。
常用诊断入口可以放进值班手册,但执行前要确认权限与实例规模:
redis-cli -h 127.0.0.1 -p 6379 SLOWLOG GET 20
redis-cli -h 127.0.0.1 -p 6379 INFO commandstats
redis-cli -h 127.0.0.1 -p 6379 INFO latencystats
redis-cli -h 127.0.0.1 -p 6379 MEMORY USAGE product:sku1001
redis-cli -h 127.0.0.1 -p 6379 OBJECT ENCODING product:sku1001SCAN 也不是“没有成本的 KEYS”。它把完整遍历拆成许多短调用,让业务命令有机会在批次之间执行;总遍历工作仍然存在。如果把 COUNT 设得极大,或在同步接口里不留间隔地循环到游标归零,还是可能把压力重新拼成一段长任务。
假设一秒内有 999 条命令各用 0.1 ms,另有一条全库操作占用 500 ms。只看“多数命令都很快”,系统似乎没有问题;可那 500 ms 期间到达的请求会在主线程后方排队。它们自己的执行时间仍然很短,用户看到的等待却已经跨过接口超时线。平均值把少量严重尖峰摊薄后,很容易让值班同学误判成应用网络抖动。
更有用的观察方式是把三个时间对齐:应用接口的 P95/P99、Redis 命令与事件循环的延迟记录、慢日志条目的发生时刻。如果尖峰时慢日志出现大范围命令,可以先处理命令和 key;如果慢日志没有对应条目,就要继续检查网络 RTT、客户端连接是否复用、响应是否过大,以及持久化 fork 或系统调度是否制造了停顿。
慢日志阈值也不是越低越好。阈值过高会漏掉对低延迟业务已经不可接受的阻塞,过低则可能堆出大量噪声。应该从接口允许的时间预算反推:一个请求总预算若只有 50 ms,就不能等到单条命令耗时 100 ms 才记录。日志长度也要能覆盖一次告警到人工介入之间的窗口,避免事故发生后旧条目已经被新记录挤掉。
处理方案同样要落到业务动作。用户画像只需要姓名和城市,就用 HMGET 精确取字段;后台找商品缓存,用带 MATCH 的 SCAN 小批推进并限制总预算;删除大集合时优先考虑异步释放;实时集合交集如果规模不可控,就预计算候选结果或移到异步链路。目标不是把危险命令换个名字,而是缩短一次连续占用主线程的时间。
在下面的事故实验里,先选择 KEYS 并把键空间调大,再切换到 SCAN。重点观察“完整扫描总工作”和“单次连续占用”不是同一个指标。
Redis 把工作数据放在内存,不等于它只能做一次性缓存。RDB 会生成某个时间点的快照;AOF 记录写操作,并在重启时重放。两者都把磁盘工作尽量移出普通命令的关键路径,但 fork、写盘、AOF 重写和系统内存压力仍可能制造延迟波动。
课程统一项目使用 Redis 8.2.2,启用了 AOF everysec,也配置了 RDB 保存条件。执行一次后台快照并检查状态后,控制台记录为:
redis_version:8.2.2
rdb_last_bgsave_status:ok
aof_enabled:1
aof_last_write_status:ok
role:master
connected_slaves:0
cluster_enabled:0这组状态只能说明该独立实例最近一次快照成功、AOF 写状态正常,并且它是没有副本的单主节点。它不能证明“一条数据都不会丢”,也不能证明实例已经高可用。appendfsync everysec 本身就是一项延迟、磁盘压力与故障窗口之间的选择。支付流水、余额账本和订单事实如果不能接受丢失,不能因为开启 AOF 就把 Redis 设成唯一真相源。
本章只建立边界:内存是普通请求的主要工作介质,持久化负责恢复能力并带来额外系统成本。RDB、AOF、重写、复制和故障切换会在后续章节分别展开。
到这里,我们可以把一句模糊的口号拆成五条可以验证的判断:
所以,评估一个 Redis 方案时,别只问“能跑多少 QPS”。先问应用和 Redis 是否同地域、客户端有没有复用连接、命令是否被逐条等待、key 与响应有多大、P99 是否出现尖峰、慢日志里谁占住主线程,以及故障窗口是否符合业务责任。
真正健康的目标不是让 Redis 在压测截图里显得很快,而是让正常命令足够短、批次大小可控、慢任务能被发现并移走。下一章学习 String、Hash、List、Set、Sorted Set 与 Stream 时,也要带着这个视角:选择数据结构,不只是选择功能,还在选择一次命令要做多少工作。
SCAN