
最近折腾Redis缓存,踩了几个坑,这篇把问题说清楚。 benchmark永远很好看:接入缓存前平均响应840ms,接入后命中时80ms,整体延迟降低60%,"系统好慢"的工单直接清零。但benchmark不会告诉你的是:为解决数据过期问题花了三天排查、流量高峰时缓存击穿把数据库打挂用了一下午才解开、以及那个在迭代中期被迫重构的缓存key命名方案——当时设计的时候没考虑前缀匹配批量失效的场景。
这篇文章把两件事都讲清楚:怎么用缓存拿到收益,以及怎么让收益不会变成运维噩梦。
Node.js后端的缓存不是"要不要用Redis"这一个决策,而是一个三层架构,每一层针对不同的访问模式优化:
第一层——进程内LRU:数据存在Node.js堆内存里,没有网络往返,亚毫秒级访问。但每个服务实例独立,不共享——每台机器都有一份副本。适用场景:变化频率低、允许短暂单实例不一致的热引用数据(开关配置、配置项、字典表)。
第二层——Redis:数据存在共享的外部内存存储,约1ms网络往返,所有服务实例共享。适用场景:会话数据、用户状态、共享计数器、需要在整个集群保持一致的计算结果。
第三层——CDN边缘缓存:数据存在地理分布式边缘节点,国内用户基本10ms以内。只缓存HTTP响应,不缓存应用数据。适用场景:公开API响应、静态资源、可以带Cache-Control头的一切。
大多数生产应用三层都需要,针对不同数据类别有选择地使用。常见的错误是把Redis当成所有缓存问题的答案——有些数据用进程内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
编译后的正则表达式或校验规则
不适合放进程内的数据:
会话数据(服务重启或负载均衡路由到不同实例时用户会话会丢失)
任何写入后需要在多个服务实例间立即保持一致的数据
最常见也最适用的模式。缓存按需填充:未命中时查询数据库,然后写入缓存。
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过期。写路径加缓存失效要同步做,不要事后补救。
每次写入同时更新缓存和数据库,读取始终从缓存返回。适用于读写频率相近、且读后写一致性有要求的场景。
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命名规范
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<