Redis 缓存击穿用互斥锁解决

FreeGuideOnline 最新 2026-07-04

使用互斥锁解决 Redis 缓存击穿

在构建高并发系统时,缓存是扛住流量洪峰的核心手段之一。然而,部分“热点”数据在缓存失效的瞬间,大量请求会直接穿透缓存,同时涌入数据库,这种现象被称为缓存击穿。如果不加控制,数据库可能瞬间过载,甚至拖垮整个服务。本教程将带你一步步理解缓存击穿的成因,并重点掌握使用互斥锁进行防御的实现方案。

什么是缓存击穿?

缓存击穿是指某一个热点 key 在缓存过期的瞬间,恰好有大量并发请求同时访问该 key。由于缓存中已不存在该数据,所有请求都会回源到数据库,导致数据库压力激增。
与缓存雪崩(大量 key 同时失效)和缓存穿透(查询不存在的数据)不同,缓存击穿聚焦于单个热点 key。

击穿的典型场景

  • 秒杀活动中的商品详情
  • 热搜话题对应的内容
  • 首页高曝光的配置数据

为什么要用互斥锁?

解决缓存击穿的核心思路是:当缓存失效时,只允许一个请求去加载数据库并回写缓存,其余请求必须等待,直到缓存被重新构建完成
互斥锁恰好提供了这种“单线程加载”的能力——使用 Redis 的 SETNX(SET if Not eXists)命令或分布式锁机制,确保同一时刻只有一个客户端能重建缓存,其他客户端则短暂自旋等待或快速失败降级。

互斥锁方案的工作原理

  1. 客户端查询缓存,若命中则直接返回。
  2. 若缓存未命中,尝试获取一个“重建锁”(key 通常命名为 lock:业务key)。
  3. 成功获得锁的客户端:去数据库查询数据,写入缓存,最后释放锁。
  4. 未获得锁的客户端:可休眠几十毫秒后重新查询缓存,或进入降级逻辑。
  5. 如此循环,直到缓存被成功更新。

流程图示意:

请求 → 查缓存 → 命中? → 返回
                 │
               未命中
                 │
           尝试获取互斥锁
               /       \
          获取成功      获取失败
            │              │
       查DB写缓存      sleep后重试查缓存
            │
        释放锁,返回

代码实现:基于 Redis SETNX 的互斥锁

以下示例使用 Python 与 redis-py 库,但思路适用于任何语言。

1. 获取锁函数

import redis
import time

r = redis.Redis(host='localhost', port=6379, db=0)

def acquire_lock(lock_key, lock_value, expire_seconds=10):
    """
    尝试获取锁,使用 SET NX EX 原子性设置锁和过期时间。
    返回 True 表示获取成功,False 失败。
    """
    return r.set(lock_key, lock_value, nx=True, ex=expire_seconds)

2. 释放锁函数(需校验 value 防止误删)

def release_lock(lock_key, lock_value):
    """使用 Lua 脚本保证原子性,仅当 value 匹配时才删除锁。"""
    lua_script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    return r.eval(lua_script, 1, lock_key, lock_value)

3. 业务缓存查询与锁控制

import uuid

def get_data_with_mutex(key):
    # 先从缓存读取
    data = r.get(key)
    if data:
        return data.decode('utf-8')
    
    lock_key = f"lock:{key}"
    lock_value = str(uuid.uuid4())   # 唯一标识,用于安全解锁
    
    # 尝试获取分布式锁
    if acquire_lock(lock_key, lock_value, expire_seconds=10):
        try:
            # 再次检查缓存(double check),防止在获取锁期间缓存已被回写
            data = r.get(key)
            if data:
                return data.decode('utf-8')
            
            # 模拟从数据库加载(实际替换为 DB 查询)
            db_value = query_database(key)  # 自定义函数
            if db_value is not None:
                # 写入缓存,设置合理的过期时间
                r.setex(key, 300, db_value)
            return db_value
        finally:
            release_lock(lock_key, lock_value)
    else:
        # 未获取到锁,短暂休眠后重试
        time.sleep(0.05)
        return get_data_with_mutex(key)   # 递归重试,可设最大重试次数

4. 增加重试次数防护

为避免极端情况下的无限递归,可传递重试计数器:

def get_data_with_mutex(key, max_retries=3):
    if max_retries <= 0:
        return None  # 或返回降级值
    data = r.get(key)
    if data:
        return data.decode()
    # ... 获取锁逻辑 ... 
    else:
        time.sleep(0.05)
        return get_data_with_mutex(key, max_retries-1)

锁方案的关键细节与优化

  • 锁过期时间:必须大于数据库查询 + 缓存写入的时间,否则锁提前释放会导致多个请求同时回源。可设置一个偏大的值(如 10 秒),并在数据库操作完成后主动释放。
  • 双重检查(Double Check):获取锁后再次查询缓存,避免在等待锁期间,缓存已被其他线程重建。
  • 安全释放:必须使用 Lua 脚本原子校验 value,防止释放了其他客户端的锁。
  • 羊群效应缓解:等待的客户端不要密集轮询,建议添加随机毫秒级 sleep(如 0.05 + random.uniform(0, 0.02))。
  • 终极优化:逻辑过期 + 互斥锁
    更优雅的解决方案是不设置缓存过期时间,而在 value 中存储逻辑过期时间。发现数据“逻辑过期”时,获取锁返回旧值(异步更新),写锁成功者进行缓存重建。这样几乎能保证所有请求都直接命中缓存,即使重建中也能读到一份虽旧但可用的数据。

与其他方案的对比

方案 优点 缺点
互斥锁 实现简单,保证数据一致性 存在线程阻塞等待,可能造成少量请求延迟
永不过期 彻底杜绝击穿 需要额外机制更新数据,占用内存
后台异步更新 用户请求无等待,高性能 实现复杂,短期内可能读到旧数据
限流 / 熔断 保护数据库 无法正常返回数据,影响用户体验

对于大多数业务场景,互斥锁方案已能很好地在性能与复杂度之间取得平衡,是解决缓存击穿的第一个推荐方案。

总结

  • 缓存击穿是高并发下典型的风险点,源自单个热点 key 失效时的并发回源。
  • 互斥锁通过 Redis 的 SETNX 和安全的锁释放机制,保证只有一个请求重建缓存,其余请求等待或快速失败。
  • 实现时务必注意锁超时、双重检查、安全解锁和重试次数,避免死锁或雪崩效应。
  • 可根据业务容忍度,进一步升级为“逻辑过期 + 互斥锁”方案,实现接近无等待的缓存更新。

通过掌握互斥锁技术,你已经具备防御缓存击穿的核心能力。动手实践一下,感受 Redis 在并发控制中的强大威力吧!