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

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

Redis Lua脚本解决缓存击穿 性能优化 分布式锁

问题现场:一次突发的性能危机

那是一个平常的周二下午,我们电商平台的监控系统突然拉响了警报:首页商品接口的平均响应时间从50ms飙升至800ms!运维团队立即通知我们,用户反馈页面加载缓慢,订单转化率直线下降。

问题定位:热点Key的缓存击穿

经过紧急排查,我们发现问题出在首页推荐商品列表上。通过分析Redis监控和应用日志,我们锁定了根本原因:

  1. 一个热门商品的缓存Key(hot_product:10086)恰好过期
  2. 大量并发请求同时发现缓存未命中
  3. 数百个请求同时涌向数据库查询同一商品信息
  4. 数据库连接池被瞬间耗尽,导致整体服务响应变慢

这是典型的缓存击穿场景 - 热点Key过期的瞬间,大量并发请求直接击穿缓存层,给后端数据库造成巨大压力。

解决思路:分布式锁 + 双重检查

最初我们考虑了几种方案:

  1. 设置永不过期的缓存(不可行,商品信息需要更新)
  2. 使用Java中的本地锁(不可行,多实例部署时无法保证互斥)
  3. 通过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中的执行具有原子性,使我们能够避免分布式环境中的竞态条件:

  1. 原子性操作:获取锁和设置过期时间在一个原子操作中完成
  2. 防止锁释放错误:确保只有获取锁的客户端才能释放锁
  3. 减少网络往返:一次网络请求完成多个Redis操作

成效与收获

部署这个解决方案后,即使在每秒数千请求的高峰期:

  1. 接口平均响应时间恢复到60ms以内
  2. 数据库连接数维持在健康水平
  3. 系统稳定性显著提升,再无类似警报

技术总结与思考

这次事件给我们的启示:

  1. 热点数据特殊处理:对于访问频率极高的数据,考虑更长的缓存时间或预加载机制
  2. 降级保护很重要:添加熔断机制,在极端情况下返回兜底数据
  3. 监控先行:完善的监控系统帮助我们在问题扩大前及时发现

Redis的Lua脚本为我们提供了一个强大的工具,在保证原子性的同时实现复杂的缓存控制逻辑。对于类似的高并发场景,这种"分布式锁+双重检查"的模式值得推广应用。


你有没有遇到过类似的缓存击穿问题?或者有其他解决热点Key的方案?欢迎在评论区分享你的经验!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值