缓存击穿瞬间:用Redis Lua脚本解决热点Key问题

问题现场:一次突发的性能危机
那是一个平常的周二下午,我们电商平台的监控系统突然拉响了警报:首页商品接口的平均响应时间从50ms飙升至800ms!运维团队立即通知我们,用户反馈页面加载缓慢,订单转化率直线下降。
问题定位:热点Key的缓存击穿
经过紧急排查,我们发现问题出在首页推荐商品列表上。通过分析Redis监控和应用日志,我们锁定了根本原因:
- 一个热门商品的缓存Key(
hot_product:10086)恰好过期 - 大量并发请求同时发现缓存未命中
- 数百个请求同时涌向数据库查询同一商品信息
- 数据库连接池被瞬间耗尽,导致整体服务响应变慢
这是典型的缓存击穿场景 - 热点Key过期的瞬间,大量并发请求直接击穿缓存层,给后端数据库造成巨大压力。
解决思路:分布式锁 + 双重检查
最初我们考虑了几种方案:
- 设置永不过期的缓存(不可行,商品信息需要更新)
- 使用Java中的本地锁(不可行,多实例部署时无法保证互斥)
- 通过Redis的SETNX实现分布式锁(接近,但原子性难保证)
最终,我们决定使用Redis Lua脚本实现分布式锁,确保在缓存重建期间,只有一个请求会去查询数据库。
实现细节:Lua脚本的原子性保障
以下是我们的核心实现代码:
// 获取商品信息的方法
public ProductInfo getProductInfo(Long productId) {
String key = "product:" + productId;
// 1. 先尝试从缓存获取
ProductInfo product = redis.get(key);
if (product != null) {
return product;
}
// 2. 缓存未命中,尝试获取分布式锁
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = acquireLock(lockKey, requestId, 3000);
if (locked) {
try {
// 3. 双重检查,避免重复构建缓存
product = redis.get(key);
if (product == null) {
// 4. 从数据库获取
product = productDao.queryById(productId);
// 5. 更新缓存,设置过期时间加随机值,避免缓存雪崩
int expireTime = 3600 + new Random().nextInt(300);
redis.setex(key, expireTime, product);
}
} finally {
// 6. 释放锁
releaseLock(lockKey, requestId);
}
} else {
// 7. 没获取到锁,短暂休眠后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductInfo(productId); // 递归调用
}
return product;
}
获取锁和释放锁的Lua脚本实现:
// 获取锁的Lua脚本
private boolean acquireLock(String lockKey, String requestId, int expireTime) {
String script = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('pexpire', KEYS[1], ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
List<String> keys = Collections.singletonList(lockKey);
List<String> args = Arrays.asList(requestId, String.valueOf(expireTime));
Long result = (Long) redis.eval(script, keys, args);
return result == 1;
}
// 释放锁的Lua脚本
private boolean releaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else " +
"return 0 " +
"end";
List<String> keys = Collections.singletonList(lockKey);
List<String> args = Collections.singletonList(requestId);
Long result = (Long) redis.eval(script, keys, args);
return result == 1;
}
为什么使用Lua脚本?
Lua脚本在Redis中的执行具有原子性,使我们能够避免分布式环境中的竞态条件:
- 原子性操作:获取锁和设置过期时间在一个原子操作中完成
- 防止锁释放错误:确保只有获取锁的客户端才能释放锁
- 减少网络往返:一次网络请求完成多个Redis操作
成效与收获
部署这个解决方案后,即使在每秒数千请求的高峰期:
- 接口平均响应时间恢复到60ms以内
- 数据库连接数维持在健康水平
- 系统稳定性显著提升,再无类似警报
技术总结与思考
这次事件给我们的启示:
- 热点数据特殊处理:对于访问频率极高的数据,考虑更长的缓存时间或预加载机制
- 降级保护很重要:添加熔断机制,在极端情况下返回兜底数据
- 监控先行:完善的监控系统帮助我们在问题扩大前及时发现
Redis的Lua脚本为我们提供了一个强大的工具,在保证原子性的同时实现复杂的缓存控制逻辑。对于类似的高并发场景,这种"分布式锁+双重检查"的模式值得推广应用。
你有没有遇到过类似的缓存击穿问题?或者有其他解决热点Key的方案?欢迎在评论区分享你的经验!

被折叠的 条评论
为什么被折叠?



