时间:26-04-21
免费影视、动漫、音乐、游戏、小说资源长期稳定更新! 👉 点此立即查看 👈
在分布式系统中,有一个场景堪称“流量刺客”:某个热点 key 在缓存中失效的瞬间,恰好遭遇海量并发请求。结果呢?所有请求瞬间穿透缓存,直接压向数据库,轻则导致响应延迟飙升,重则引发数据库雪崩。这就是典型的缓存击穿。那么,如何构建健壮的防线?下面这四种经过实战检验的代码方案,或许能给你答案。
这个思路很直接:当缓存失效时,别让所有线程一拥而上去查数据库。而是通过一把分布式锁,确保同一时刻只有一个线程有权去重建缓存,其他线程则乖乖等待,锁释放后直接读取新鲜出炉的数据即可。
具体怎么实现?第一步,尝试用 Redis 的 SET 命令,以 NX(仅当键不存在时设置)和 PX(设置毫秒级过期时间)的方式,为当前缓存 key 设置一个专属锁,比如 key 命名为 “lock:” + cacheKey,过期时间设个 30 秒防止死锁。
如果 SET 命令返回 OK,恭喜,当前线程拿到了锁。接下来,它就可以安心执行数据库查询,并将结果序列化成 JSON 字符串后写回 Redis 缓存,同时别忘了设置一个合理的业务过期时间,比如 600 秒。完事后,主动删除这个锁 key,释放资源。
如果 SET 命令返回 null,说明锁已经被别的线程持有了。这时候,常见的做法是让线程“稍等片刻”:休眠 50 毫秒后重试获取锁,循环个 20 次。如果重试多次依然失败,为了不影响服务可用性,可以考虑降级策略——比如直接查询数据库(但这次不写回缓存),或者抛出特定的 CacheLoadException 让上层处理。无论如何,最终都要返回查询结果,保证流程闭环。
互斥锁方案虽好,但获取锁失败的线程需要等待,多少有些阻塞。有没有更优雅的无锁方案?逻辑过期应运而生。它的核心在于:缓存物理上永不过期,但 value 里封装一个逻辑过期时间戳。访问时检查这个时间戳,过期了就在后台异步刷新,主线程则立刻返回旧值,实现零等待。
首先,需要定义一个内部数据结构,例如 CacheData 类,包含两个字段:一个是实际的数据对象(data),另一个是逻辑过期时间(expireTime,毫秒时间戳)。写入缓存时,将这个对象序列化存进去,并且不设置 Redis 的 TTL。
读取的时候,反序列化得到 CacheData 对象,然后比较 expireTime 和当前系统时间。如果还没过期,直接返回 data 字段,又快又稳。
如果发现过期了,也别慌。这时候可以尝试去 Redis 设置一个标记 key(比如 “loading:” + cacheKey),利用 SET NX 的原子性,确保只有一个线程能设置成功。拿到“刷新权”的线程,就另起一个异步任务去加载最新数据、更新缓存中的 CacheData(并设置新的逻辑过期时间,比如当前时间加 10 分钟),最后清理掉 loading 标记。而其他没抢到标记的线程,以及在此期间的所有请求,都继续返回旧的、但可用的数据,体验上毫无感知。
缓存击穿有时也源于一种特殊场景:查询一个根本不存在的数据(比如无效的用户 ID)。如果每次请求都穿透到数据库查个空,对资源也是极大的浪费。缓存空对象方案,就是专门用来拦截这类无效请求的。
具体做法是,当数据库查询明确返回 null 或空集合时,我们并不直接放弃,而是将一个特殊的空值标记(例如字符串 "NULL")写入缓存,并给它设置一个较短的 TTL,比如 2 分钟(120000 毫秒)。
这样一来,后续相同的无效请求过来,就会先命中这个带有 “NULL” 标记的缓存。业务代码中只需做一个简单判断:如果缓存 value 等于 “NULL”,就直接返回 null,彻底绕开数据库。为了保证上层业务逻辑透明,通常会在缓存访问层统一做一次转换,将 “NULL” 映射回 Ja va 的 null 引用。
不过,这个方案有个关键点需要注意:空对象的 TTL 不宜设置过长。一般建议不超过 5 分钟。否则,万一这个 key 后来在数据库里有真实数据了,会因为空对象缓存未过期而无法被及时感知,导致数据不一致。
对于真正的“顶流”热点 key,比如明星八卦、秒杀商品,常规的过期机制本身就是风险。更高级的玩法是主动出击:自动识别出它们,并升级为“永驻缓存”,配以后台心跳刷新,从根本上杜绝击穿。
如何识别热点 key?可以借助 Redis 自身的能力。例如,通过定期执行 INFO commandstats 命令分析命令调用频次,或者使用 SCAN 结合 OBJECT FREQ(如果 Redis 版本支持)来统计访问频率。在应用层,可以部署一个定时任务,比如每 30 秒跑一次,采集访问频率最高的前 100 个 key。
对于识别出的热点 key(比如频率超过 1000 次/秒),检查其剩余存活时间。如果 TTL 小于 3600 秒,就通过执行 EXPIRE key 0 命令,将其转为永不过期状态。
光永驻还不够,数据还得保持新鲜。接下来,为这个 key 启动一个独立的守护线程或定时任务,每隔 300 秒(5 分钟)就异步执行一次数据刷新:重新调用数据加载逻辑,并用新结果覆盖旧缓存。这个刷新过程必须做到不中断主线程响应,所有异常都要在后台捕获并记录日志,失败则沿用旧值。刷新前最好还能校验一下数据库连接的健康状态,如果连接不可用,就跳过本次刷新,避免将故障扩散引发雪崩。
说到底,防止缓存击穿没有银弹,关键在于根据业务场景,理解每种方案的 trade-off,并将其灵活地组合应用到你的架构之中。以上四种方案,从基础的互斥锁到高级的热点探测,希望能为你构建更稳健的系统提供一些切实可行的思路。