先看一个可以在压测中复现的事故模型。周一上午十点,运营把一位作者的采访推上首页。五分钟后,GET /users/u1001 的 P95 从 38 ms 涨到 1.7 s,随后开始返回 503。值班同学先查 PostgreSQL:CPU 还没跑满,用户表也有主键索引,看上去不像一条“慢 SQL”。真正先亮红灯的是应用里的连接池:20 个连接全部借出,waitingCount 不断增加。
问题不神秘。首页上的头像、作者卡片、文章页侧栏和评论区都在读取同一个用户。每个请求都执行一次几乎相同的查询:
SELECT id, name, avatar_url, city, version
FROM users
WHERE id = 'u1001';假设一次查询连同排队、网络和结果解析占用连接 40 ms,20 个连接一轮最多处理 20 个请求。突然到来的 400 个并发读取至少要排成 20 轮。后面的请求即使只查一行,也得等前面的人归还连接。它们等待得越久,网关超时和客户端重试就越多;重试又生成新查询,连接池于是从保护数据库的闸门变成了排队点。

这类事故最容易被一句“数据库是磁盘,所以慢”带偏。现代数据库有缓冲池,命中的数据页可能早就在内存里。请求仍然会消耗数据库连接、解析与执行时间、CPU、锁、网络往返和结果序列化。后端引入 Redis,首先要解决的是重复工作挤占了稀缺资源,不是给数据库贴一个“慢”的标签。
连接池容量是有限的。没有空闲连接时,新查询进入队列;队列里的请求不占数据库连接,却继续占着应用的请求上下文、内存和上游等待时间。只要请求到达速度持续高于完成速度,积压就会越来越长。
可以先用一个粗略模型理解它。若回源请求数为 ,连接池大小为 ,单次查询耗时为 ,忽略其他开销时,完成这些查询至少需要:
这个式子不是性能承诺,它只揭示两个事实:第一,连接数不会凭空增加;第二,减少回源查询数,通常比无限增大连接池更直接。连接池从 20 调到 40,也可能只是让数据库同时承受更多工作;如果热点读取翻了十倍,问题很快会回来。
下面的模拟器把并发量、查询耗时、连接池大小和缓存命中率放在同一张工作台上。先把命中率设为 0,再逐步提高。观察的重点不是某个毫秒数,而是“要进入数据库的请求”如何减少。
看到连接池排队,不能立刻得出“加 Redis”的结论。缺失索引、一次请求中的 N+1 查询、忘记释放连接、长事务和下游网络抖动也会造成同样症状。先看查询日志、执行计划、池的总连接数、空闲连接数与等待数,再判断是否真有大量可复用的重复读取。
对 user_profile,我们先定一条边界:PostgreSQL 中的 users 表保存业务事实;Redis 的 user:profile:u1001 只是它的一份热副本。副本可以因为 TTL 到期、内存淘汰、节点故障或人工清理而消失。消失后,应用回到数据库读取,再建一份即可。

最常见的读法叫 Cache Aside,可以把它理解成应用自己管理的一条岔路:
请求 GET /users/u1001 先读取 user:profile:u1001。命中时反序列化后返回,不占用数据库连接。
未命中时查询 users 表。应用把查到的资料写进 Redis,并设置 TTL,然后把数据库结果返回给调用方。
用户修改资料时先更新数据库,再让旧缓存失效。下一次读取会回源并重建。这里存在一致性窗口,后续章节会专门处理;本章先记住缓存不是第二份真相。
下面是一段可运行的 Node.js + ioredis 最小实现。userTtl 给不同用户一个稳定的 120~150 秒过期时间,避免一批资料在同一秒整齐失效。为了看清数据来源,示例数据库用 Map 表示,真实项目只需把 readUserFromDatabase 换成参数化 SQL。
import Redis from "ioredis";
const redis = new Redis({
host: "127.0.0.1",
port: 6389,
lazyConnect: true,
maxRetriesPerRequest: 1,
});
await redis.connect();
const database = new Map([
["u1001", { id: "u1001", name: "小林"
在 Node.js v25.2.1、ioredis 5.7.0、Redis 8.2.2 的独立实例中执行后,输出为:
首次来源: database
再次来源: cache
用户: {"id":"u1001","name":"小林","city":"杭州","version":1}
数据库读取次数: 1第一次请求没有省掉数据库工作,它还多做了一次 Redis 查询和一次回填。收益来自后续重复读取:同一份资料在更新前被读一千次时,理想情况下数据库只负责少数未命中,而不是每次都重新执行查询。反过来,如果资料只读一次、每次查询条件都不同,缓存可能只有成本,没有命中。
Redis 不只做查询缓存,但也不能把所有“需要快”的数据都塞进去。本课程会反复使用下面五个对象。先把它们的责任说清,后面学习命令、数据结构和高可用时才不会失焦。

这五类数据里,只有用户资料明确是数据库记录的副本;会话更像有生命周期的运行时状态;浏览量和周榜是持续变化的派生结果;库存则碰到了跨系统一致性。它们共用一个 Redis,不代表拥有相同的可靠性要求。
下面的练习不是考命令,而是让你先判断“谁对这份数据负责”。如果这一层判断错了,后面的代码写得再漂亮,也可能在故障时丢掉不能丢的事实。
引入 Redis 能买到几类很具体的能力。热点读命中后,数据库少做一遍查询;多个无状态 Node.js 实例可以共享 Session;INCR 能把计数放在服务端原子完成;Sorted Set 可以直接维护实时顺序;TTL 能让临时数据自动退出生命周期。这些能力都通过网络服务提供,所以它比单进程 Map 更容易被多个实例共同使用。
账单也同样具体。
maxmemory 与淘汰策略不是上线后再补的参数。GET 也要经过连接、协议编解码和网络往返;多个串行命令会把往返时间反复累加。Redis 可以使用 RDB 快照、AOF 日志或两者组合来提高恢复能力,但“开启持久化”不等于零丢失。快照之间有窗口,AOF 的刷盘频率也代表明确的故障窗口。支付流水、订单事实、余额账本这类要求一条不丢且需要审计的数据,不应只因为 Redis 很快就把它设为唯一存储。
更准确的说法是:Redis 是一个通过网络访问、以内存为主要工作介质的数据结构服务。它能被用作缓存、会话存储、计数器和排行榜。它不是“更快的关系型数据库”,也没有自动替你解决两个数据源之间的一致性。
我更愿意从一张证据表开始,而不是从“并发量很大”开始。下面四个问题能得到清楚答案时,Redis 才从技术爱好变成可评审的工程方案。
GET /users/:id 的 P50、P95、P99、数据库查询次数、连接池等待数和超时数。若慢在第三方接口或 JSON 渲染,缓存数据库结果未必有效。适合优先考虑 Redis 的场景通常有明显的读热点、跨实例共享的短生命周期状态、需要原子计数,或者需要不断更新的实时排名。先别引入的情况也很常见:请求量很小;数据库有明确的索引或 N+1 问题还没修;数据几乎不重复读取;团队没有监控和运维能力;业务要求强一致,却没有设计缓存失效与降级路径。
正式上线也不该一次把所有用户资料都切到缓存。可以先选一个只读、可重建、流量可控的接口做小比例发布,对照发布前后的数据库查询数、连接池等待数、接口尾延迟与错误率。命中率看起来很高,却没有降低回源或尾延迟时,要继续检查对象是否太大、网络是否跨可用区、命令是否串行过多。缓存方案的价值要从完整请求链路验收,不能只盯着 Redis 自己的一次 GET 有多快。

你可以在下面的权衡台中改变数据可重建性、重复读取比例、延迟目标和故障回源能力。它不会替你给系统打一个武断分数,而是把下一项该测的证据指出来。
很多团队在接入时只验证“Redis 正常能不能命中”,直到故障发生才讨论要不要回源。此时 ioredis 的重连和离线队列如果沿用默认思路,命令可能在内存里等待;上游请求却有自己的超时,于是用户已经收到失败,后台命令还在继续排队。缓存层本来想保护数据库,最后却拖住了应用。
先按数据责任决定行为,再配置客户端:

user_profile 是可重建副本。Redis 读失败时可以在严格限流下绕过缓存查数据库,但不能让所有实例同时无上限回源。login_session 无法读取时,可能只能让用户重新认证。这里不能随意“查数据库猜一个登录态”,也不能为了可用性跳过鉴权。article_views 若业务允许少量误差,可以暂缓计数或写入备用通道;如果关系到结算,就要使用更可靠的事件链路。weekly_rank 通常可以暂时隐藏,稍后从事件或权威明细重建。非核心模块不该拖垮文章主页面。product_stock 不能在 Redis 不可用时凭本地旧值确认订单。宁可返回“稍后重试”,也不要卖出一份系统无法兑现的库存。一个面向缓存读取的 ioredis 客户端,可以从“尽快暴露失败”开始配置,而不是无限等待:
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST ?? "127.0.0.1",
port: Number(process.env.REDIS_PORT ?? 6379),
lazyConnect: true,
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
connectTimeout: 200,
retryStrategy(times) {
return
这不是可以原样复制到所有业务的“最佳配置”。缓存读可以失败开放,会话校验和库存确认却可能必须失败关闭;后台任务也许愿意等待更久。真正的配置来自上一步定义的故障语义,并且 Redis 超时要短于接口剩余的时间预算。
回到开头的 GET /users/u1001。一个能验收的方案不会写“接入 Redis 提升性能”,而会写成这些具体条件:
users 表仍是用户资料真相源,user:profile:<id> 可以随时删除并从数据库重建;做到这里,Redis 的价值才说得清:它用内存、可接受的一致性窗口和额外运维复杂度,换取更低的热点读取延迟与更少的数据库工作。值不值得,不由“后端都在用”决定,而由这个交换是否符合当前接口的目标决定。