site logo

Marico's space

Node.js Redis 缓存:经生产验证的有效模式

算法解析 2026-06-12 14:48:52 6

最近折腾Redis缓存,踩了几个坑,这篇把问题说清楚。 benchmark永远很好看:接入缓存前平均响应840ms,接入后命中时80ms,整体延迟降低60%,"系统好慢"的工单直接清零。但benchmark不会告诉你的是:为解决数据过期问题花了三天排查、流量高峰时缓存击穿把数据库打挂用了一下午才解开、以及那个在迭代中期被迫重构的缓存key命名方案——当时设计的时候没考虑前缀匹配批量失效的场景。

这篇文章把两件事都讲清楚:怎么用缓存拿到收益,以及怎么让收益不会变成运维噩梦。

为什么是三层,不是一层

Node.js后端的缓存不是"要不要用Redis"这一个决策,而是一个三层架构,每一层针对不同的访问模式优化:

第一层——进程内LRU:数据存在Node.js堆内存里,没有网络往返,亚毫秒级访问。但每个服务实例独立,不共享——每台机器都有一份副本。适用场景:变化频率低、允许短暂单实例不一致的热引用数据(开关配置、配置项、字典表)。

第二层——Redis:数据存在共享的外部内存存储,约1ms网络往返,所有服务实例共享。适用场景:会话数据、用户状态、共享计数器、需要在整个集群保持一致的计算结果。

第三层——CDN边缘缓存:数据存在地理分布式边缘节点,国内用户基本10ms以内。只缓存HTTP响应,不缓存应用数据。适用场景:公开API响应、静态资源、可以带Cache-Control头的一切。

大多数生产应用三层都需要,针对不同数据类别有选择地使用。常见的错误是把Redis当成所有缓存问题的答案——有些数据用进程内LRU更快,有些数据压根就不该缓存。

第一层:进程内LRU缓存

最快的缓存是永不离开进程的缓存。对于每分钟被访问上千次但变化频率低的数据,进程内LRU不仅省掉了数据库往返,还省掉了Redis往返。

import { LRUCache } from 'lru-cache'; // 功能开关缓存——变化少,但每次请求都要读取
const featureFlagCache = new LRUCache({ max: 500, ttl: 1000 * 60, // 60秒TTL updateAgeOnGet: false, // 读取不重置TTL fetchMethod: async (flagKey) => { // 缓存未命中时自动调用——自动合并并发未命中请求 return await featureFlagService.getFlag(flagKey); },
}); // 数据库查询结果缓存——中高频读取
const queryCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 5, // 5分钟TTL updateAgeOnGet: false, allowStale: false,
}); // 使用方式
async function getFeatureFlag(key) { // fetchMethod自动处理未命中,无需手动处理 return await featureFlagCache.fetch(key);
} async function getCachedQueryResult(queryKey, queryFn) { const cached = queryCache.get(queryKey); if (cached !== undefined) return cached; const result = await queryFn(); queryCache.set(queryKey, result); return result;
}

lru-cache的fetchMethod选项值得重点说。当多个并发请求同时访问同一个key且都未命中时,fetchMethod会自动合并这些请求——底层数据源只会被调用一次,所有等待的Promise都得到相同的结果。这是内置在缓存里的请求合并机制。

适合放进程内的数据:

  • 功能开关和A/B测试分组

  • 应用配置

  • 静态字典表(国家码、分类列表)

  • JWT公钥/JWKS

  • 编译后的正则表达式或校验规则

不适合放进程内的数据:

  • 会话数据(服务重启或负载均衡路由到不同实例时用户会话会丢失)

  • 任何写入后需要在多个服务实例间立即保持一致的数据

第二层:Redis——经生产验证的模式

模式一:Cache-Aside(惰性加载)

最常见也最适用的模式。缓存按需填充:未命中时查询数据库,然后写入缓存。

import { createClient } from 'redis'; const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect(); async function getUser(userId) { const cacheKey = `user:${userId}`; // 1. 检查缓存 const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // 2. 缓存未命中——从数据库查询 const user = await db.query( 'SELECT id, name, email, role FROM users WHERE id = $1', [userId] ); if (!user) return null; // 3. 写入缓存,设置TTL await redis.setEx(cacheKey, 300, JSON.stringify(user));` `// 5分钟TTL return user;
} // 失效:所有修改用户信息的写路径都要调用这个
async function invalidateUser(userId) { await redis.del(`user:${userId}`);
}

失效逻辑和缓存逻辑同等重要。应用中所有修改用户信息的写路径都必须调用invalidateUser。如果新增了一个接口但忘记在修改用户邮箱时调用失效逻辑,用户会一直看到过期数据,直到TTL过期。写路径加缓存失效要同步做,不要事后补救。

模式二:Write-Through(写穿透)

每次写入同时更新缓存和数据库,读取始终从缓存返回。适用于读写频率相近、且读后写一致性有要求的场景。

async function updateUserProfile(userId, updates) { // 1. 先写数据库 const updated = await db.query( 'UPDATE users SET name = $1, bio = $2, updated_at = NOW() WHERE id = $3 RETURNING *', [updates.name, updates.bio, userId] ); // 2. 立即更新缓存 const cacheKey = `user:${userId}`; await redis.setEx(cacheKey, 300, JSON.stringify(updated));` ` return updated;
}

Write-Through单次写入成本更高,但消除了写入和缓存填充之间那个"窗口"——在这个窗口里读取会产生缓存未命中并打到数据库。对于用户个人资料更新这种场景,用户马上要看到自己修改的内容,Write-Through体验更好。

模式三:把缓存Key当成系统来设计

缓存Key设计是生产级缓存中最容易被忽视的环节。一个不可组合的Key方案会导致批量失效无法实现——要么过度失效(删太多),要么数据过期。

// 结构化的Key命名规范
const CacheKeys = { user: (id) => `user:${id}`, userOrders: (userId) => `user:${userId}:orders`, userOrderPage: (userId, page) => `user:${userId}:orders:page:${page}`, product: (id) => `product:${id}`, productsByCategory: (categoryId) => `products:category:${categoryId}`, // 基于标签的失效:某个用户的所有Key userPattern: (userId) => `user:${userId}:*`,
}; // 使用SCAN进行批量失效(生产环境绝不能用KEYS——它是O(N)操作,会阻塞Redis)
async function invalidateAllUserData(userId) { const pattern = CacheKeys.userPattern(userId); let cursor = 0; do { const result = await redis.scan(cursor, { MATCH: pattern, COUNT: 100, }); cursor = result.cursor; if (result.keys.length > 0) { await redis.del(result.keys); } while (cursor !== 0);
}

带COUNT参数的SCAN命令才是按模式搜索Key的正确方式。KEYS命令(redis.keys('user:*'))对整个Key空间是O(N)复杂度——它在执行期间会阻塞Redis事件循环,导致扫描期间连接到这个Redis实例的所有客户端都感受到额外延迟。生产环境绝不要用KEYS。

模式四:防止缓存击穿

经典的线上故障模式:一个高频缓存Key过期。1000个并发请求同时发现未命中,同时打到数据库。数据库挂了。

这就是惊群问题,流量越大越危险。

方案A:概率性提前过期(XFetch)

在缓存过期前就以一定概率重新计算值,概率与距离过期时间成正比。等Key真正过期时,它已经被刷新过了。

async function getCachedWithXFetch(key, fetchFn, ttl, beta = 1.0) { const raw = await redis.get(key); if (raw) { const { value, expiry, delta } = JSON.parse(raw); const now = Date.now() / 1000; // 概率性地提前重新计算 // beta值越高,提前刷新越激进 if (now - delta * beta * Math.log(Math.random()) < expiry) { return value; } } // 缓存未命中或触发了提前刷新 const start = Date.now(); const value = await fetchFn(); const delta = (Date.now() - start) / 1000; // 计算耗时,秒为单位 const expiry = Date.now() / 1000 + ttl; await redis.setEx(key, ttl, JSON.stringify({ value, expiry, delta }));` ` return value;
}

方案B:缓存未命中时加分布式锁

发生缓存未命中时,先获取分布式锁再查询数据。其他请求等待锁持有者填充缓存,而不是大家一起抢着查数据库。

async function getCachedWithLock(key, fetchFn, ttl) { const cached = await redis.get(key); if (cached) return JSON<