Redis 缓存击穿用互斥锁解决
FreeGuideOnline
最新
2026-07-04
使用互斥锁解决 Redis 缓存击穿
在构建高并发系统时,缓存是扛住流量洪峰的核心手段之一。然而,部分“热点”数据在缓存失效的瞬间,大量请求会直接穿透缓存,同时涌入数据库,这种现象被称为缓存击穿。如果不加控制,数据库可能瞬间过载,甚至拖垮整个服务。本教程将带你一步步理解缓存击穿的成因,并重点掌握使用互斥锁进行防御的实现方案。
什么是缓存击穿?
缓存击穿是指某一个热点 key 在缓存过期的瞬间,恰好有大量并发请求同时访问该 key。由于缓存中已不存在该数据,所有请求都会回源到数据库,导致数据库压力激增。
与缓存雪崩(大量 key 同时失效)和缓存穿透(查询不存在的数据)不同,缓存击穿聚焦于单个热点 key。
击穿的典型场景
- 秒杀活动中的商品详情
- 热搜话题对应的内容
- 首页高曝光的配置数据
为什么要用互斥锁?
解决缓存击穿的核心思路是:当缓存失效时,只允许一个请求去加载数据库并回写缓存,其余请求必须等待,直到缓存被重新构建完成。
互斥锁恰好提供了这种“单线程加载”的能力——使用 Redis 的 SETNX(SET if Not eXists)命令或分布式锁机制,确保同一时刻只有一个客户端能重建缓存,其他客户端则短暂自旋等待或快速失败降级。
互斥锁方案的工作原理
- 客户端查询缓存,若命中则直接返回。
- 若缓存未命中,尝试获取一个“重建锁”(key 通常命名为
lock:业务key)。 - 成功获得锁的客户端:去数据库查询数据,写入缓存,最后释放锁。
- 未获得锁的客户端:可休眠几十毫秒后重新查询缓存,或进入降级逻辑。
- 如此循环,直到缓存被成功更新。
流程图示意:
请求 → 查缓存 → 命中? → 返回
│
未命中
│
尝试获取互斥锁
/ \
获取成功 获取失败
│ │
查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 在并发控制中的强大威力吧!