单机缓存的世界很美好,数据只有一份,更新即生效。可当服务扩到多实例、缓存也拆成集群之后,问题就来了:A节点刚刚更新了数据库并刷新了自己的本地缓存,B节点和C节点还揣着旧数据继续对外服务,用户刷新一次页面,两次请求可能命中不同节点,看到的数据一新一旧。这就是分布式缓存一致性问题的典型表现。本文以Node.js为技术栈,围绕失效策略和跨节点同步两条主线,把这个问题拆开揉碎讲清楚。

为什么分布式缓存会出现不一致
先看一个最简单的场景。假设我们用Node.js的lru-cache做进程内缓存,同时部署了三个实例,前面挂负载均衡。用户修改了昵称,请求落在实例A上,A更新数据库后刷新了自己的缓存,然后返回成功。紧接着用户刷新个人主页,请求被负载均衡到实例B,B的本地缓存里还是旧昵称,于是用户疑惑:我明明改了,怎么没生效?
这个问题的根源在于:进程内缓存的数据是各实例私有的,没有任何机制把“数据变了”这个消息广播出去。解决办法无非两个方向:一是让缓存本身集中化,所有实例共享同一份缓存(比如都用Redis),失效时只需要删一处;二是保留本地缓存的速度优势,但引入跨节点的同步机制。两条路线各有取舍,实际项目里经常混合使用。
常见失效策略对比与选择
失效策略决定了“什么时候删缓存”,这是一致性的第一道防线。常见的有三种做法。
被动过期:设置TTL,到期自动失效。实现最简单,Node.js里cache.set(key, value, { ttl: 1000 * 60 })就能搞定。缺点是过期前的窗口期内数据始终是旧的,对时效敏感的业务不可接受。它适合做兜底,而不是唯一手段。
主动删除:数据更新时立刻删除对应缓存,下次读取时再回源。经典顺序是“先更新数据库,再删缓存”。为什么不先删缓存再更新数据库?因为在并发下,删完缓存、库还没更新成功的间隙,另一个读请求发现缓存缺失,回源读到旧值又塞回缓存,脏数据就永久驻留了。先更新库再删缓存,即使删除失败,也可以靠TTL兜底。
延迟双删:针对主从延迟场景的加强版。流程是:先删缓存,更新数据库,然后延迟几百毫秒再删一次缓存。第二次删除的目的,是清掉在主从同步窗口期内被读请求写回的旧值。Node.js实现很简单:
async function updateWithDoubleDelete(key, newValue) {
await redis.del(key); // 第一次删除
await db.update(key, newValue); // 更新数据库
setTimeout(async () => {
await redis.del(key); // 延迟后再删一次
}, 500);
}延迟时间怎么定?一般略大于主从同步的平均延迟即可,500毫秒到1秒是常见取值。这个方案不完美,延迟期内仍可能读到旧值,但它把不一致窗口压到了很短,对绝大多数业务足够。
跨节点同步:Redis Pub/Sub 与消息队列
如果坚持用本地缓存换性能,就必须解决“一处更新、处处失效”。下面介绍两种主流方案,都给出Node.js实现。
方案一:Redis Pub/Sub 广播失效消息
思路是所有实例订阅同一个频道,任何实例更新数据后,往频道发布一条失效消息,其他实例收到后删掉本地对应缓存。代码量很小,落地快:
const Redis = require('ioredis');
const LRU = require('lru-cache');
const localCache = new LRU({ max: 10000, ttl: 1000 * 60 * 5 });
const sub = new Redis();
const pub = new Redis();
// 订阅失效频道
sub.subscribe('cache_invalidate');
sub.on('message', (channel, message) => {
const { key, source } = JSON.parse(message);
// 避免删除自己刚写入的新值,可带上实例ID判断
if (source !== INSTANCE_ID) {
localCache.delete(key);
}
});
// 更新数据时广播失效
async function updateUser(userId, data) {
await db.update(userId, data);
await pub.publish('cache_invalidate', JSON.stringify({
key: `user:${userId}`,
source: INSTANCE_ID
}));
}注意两点。第一,发布方自己也要删本地缓存,广播是给别人的,别遗漏了自己;第二,Pub/Sub是“发后即忘”,消息不持久化,如果某实例发布消息的瞬间恰好有实例在重连,那条失效通知就丢了。所以本地缓存的TTL必须保留,作为漏消息后的兜底。
方案二:消息队列保证可靠送达
对一致性要求更高的场景,用Kafka或RabbitMQ替代Pub/Sub。消息队列有持久化和重试机制,即使消费方短暂宕机,重启后仍能补上漏掉的失效消息。代价是链路变长、延迟略增。用BullMQ(基于Redis的队列库)的实现:
const { Queue, Worker } = require('bullmq');
const invalidateQueue = new Queue('cache-invalidate', {
connection: { host: '127.0.0.1', port: 6379 }
});
async function updateUser(userId, data) {
await db.update(userId, data);
await invalidateQueue.add('invalidate', { key: `user:${userId}` });
}
new Worker('cache-invalidate', async (job) => {
localCache.delete(job.data.key);
}, { connection: { host: '127.0.0.1', port: 6379 } });选型上有个简单判断:业务能容忍偶发的短暂数据不一致,用Pub/Sub;涉及金额、库存这类敏感数据,老老实实上消息队列,必要时再加对账逻辑。
高并发下的典型坑与应对
第一个坑是缓存击穿导致的回源风暴。缓存失效那一瞬间,成千上万个请求同时回源数据库。解法是加互斥锁:拿到锁的那个请求去查库回填,其他请求短暂等待后重读缓存。Node.js单线程的特性让进程内互斥很容易,跨进程则可以用Redis的SET key value NX EX实现分布式锁。
第二个坑是删除缓存失败。数据库更新成功、缓存删除抛异常,脏数据就一直在。除了TTL兜底,还可以引入重试机制,把删除操作投递到重试队列,失败后由队列自动重投,直到成功或达到上限。
第三个坑是误把最终一致当成强一致。上述所有方案本质上都是最终一致性,存在毫秒到秒级的不一致窗口。如果业务真的要求强一致(比如账户余额扣减),那就不该让缓存承担这个职责,直接读写数据库,或者用分布式锁串行化操作。架构上想清楚一致性级别,比纠结技术细节更重要。
总结
分布式缓存一致性没有银弹,核心思路是分层防御:TTL做兜底,先更新库再删缓存保证基本正确,延迟双删应对主从延迟,Pub/Sub或消息队列实现跨节点失效通知,互斥锁防止回源风暴。在Node.js生态里,ioredis、lru-cache、BullMQ这些工具都很成熟,组合起来足以覆盖绝大多数业务场景。记住一条原则:先明确业务能接受的一致性级别,再选方案,别为了强一致把系统复杂度堆到失控。