外观
缓存击穿实测:互斥锁 vs 逻辑过期怎么选
问题背景:热点 Key 失效瞬间,数据库为什么被打满
缓存击穿指的是某个热点 Key 在过期瞬间被大量并发请求访问。此时缓存中没有数据,所有线程同时回源数据库,导致数据库连接数、CPU 和慢查询瞬间飙升。它和缓存雪崩的区别是:雪崩是大量 Key 同时失效,击穿通常聚焦在单个或少数热点 Key。
常见治理方案有两类:
- 互斥锁:只允许一个线程回源重建缓存,其他线程等待或返回空值。
- 逻辑过期:Redis 中的物理 Key 不设置 TTL,在 Value 中增加
expireAt字段;发现逻辑过期后,先返回旧数据,再异步刷新。
二者的取舍非常典型:互斥锁优先保证数据一致性,但会牺牲部分可用性;逻辑过期优先保证吞吐和低延迟,但允许短时间数据陈旧。
方案对比:QPS、延迟与陈旧度的权衡
下面用一个商品详情热点 Key 做实测:product:detail:1001。压测模型为 200 并发、持续 60 秒,并在第 10 秒人为让缓存进入重建窗口。数据库回源耗时通过 SLEEP 0.05 模拟为约 50ms。
| 指标 | 互斥锁 SETNX | 逻辑过期 + 异步刷新 |
|---|---|---|
| 正常请求平均延迟 | 2~5ms | 2~5ms |
| 重建期间峰值 QPS | 约 3,200 | 约 9,800 |
| 重建线程数 | 1 | 1(需防重复提交) |
| 其他线程行为 | 等待重试或快速失败 | 立即返回旧值 |
| 数据一致性 | 高,不读旧值 | 最终一致,存在陈旧窗口 |
| 实现复杂度 | 中,需处理死锁 | 高,需管理异步线程与字段 |
实测结论不是“逻辑过期一定更好”,而是:
- 库存、价格、权限等强一致字段优先使用互斥锁,避免旧数据造成业务错误。
- 商品描述、榜单、推荐流、配置快照等可容忍短暂旧值的数据,逻辑过期通常能获得更高 QPS 和更平稳的延迟。
- 如果数据库本身脆弱,互斥锁更安全;如果热点访问远大于回源能力,逻辑过期能把 50ms 的重建抖动从用户链路上移走。
执行流程:两种缓存重建路径
Redis 数据结构:普通缓存、锁与逻辑过期
互斥锁使用独立的锁 Key,例如 lock:product:1001,通过 SET key value NX PX timeout 保证“加锁 + 过期时间”原子执行。
bash
# 查看商品缓存,首次访问可能不存在
GET product:detail:1001
# 原子加锁:NX 表示 Key 不存在才成功,PX 设置 10 秒自动过期
SET lock:product:1001 "thread-001" NX PX 10000
# 模拟只有拿到锁的线程查询数据库并重建缓存,TTL 为 60 秒
SET product:detail:1001 '{"id":1001,"name":"机械键盘","price":299}' EX 60
# 重建完成后释放锁;生产环境应校验 value,避免误删其他线程的锁
DEL lock:product:1001逻辑过期则把过期时间放进 Value,物理 Key 不设置 TTL:
bash
# 写入逻辑过期缓存:expireAt 是毫秒时间戳,Key 本身不设置 TTL
SET product:detail:logic:1001 '{"data":{"id":1001,"name":"机械键盘","price":299},"expireAt":1735689600000}'
# 读取缓存结构,由应用判断当前时间是否超过 expireAt
GET product:detail:logic:1001
# 异步刷新时仍建议使用短锁,避免线程池里重复提交大量重建任务
SET rebuild:product:1001 "worker-001" NX PX 10000
# 刷新为新的逻辑过期时间;不要写 EX,否则物理过期会破坏方案前提
SET product:detail:logic:1001 '{"data":{"id":1001,"name":"机械键盘","price":288},"expireAt":1735690200000}'
DEL rebuild:product:1001关键代码:Java 风格的互斥锁实现
下面代码展示核心流程,省略部分序列化细节:
java
// 缓存击穿治理:互斥锁版本
public Product getProductWithMutex(Long id) {
String cacheKey = "product:detail:" + id;
String lockKey = "lock:product:" + id;
String cache = redis.get(cacheKey);
if (cache != null) {
return objectMapper.readValue(cache, Product.class);
}
// value 使用唯一请求 ID,释放锁时用于校验归属
String requestId = UUID.randomUUID().toString();
boolean locked = redis.setIfAbsent(lockKey, requestId, Duration.ofSeconds(10));
try {
if (!locked) {
// 没抢到锁:短暂休眠后重试,避免所有请求直接打到数据库
Thread.sleep(50);
return getProductWithMutex(id);
}
// 双重检查:可能在等待锁期间,前一个线程已经重建完成
cache = redis.get(cacheKey);
if (cache != null) {
return objectMapper.readValue(cache, Product.class);
}
Product product = productMapper.selectById(id);
redis.set(cacheKey, objectMapper.writeValueAsString(product), Duration.ofSeconds(60));
return product;
} finally {
// 只释放自己的锁,建议用 Lua 保证“比较 value + 删除”原子执行
redis.releaseLockIfOwner(lockKey, requestId);
}
}关键代码:逻辑过期与异步刷新
java
// 逻辑过期包装对象:data 保存业务数据,expireAt 保存逻辑过期时间
public class RedisData<T> {
private T data;
private Long expireAt;
}
public Product getProductWithLogicExpire(Long id) {
String cacheKey = "product:detail:logic:" + id;
String raw = redis.get(cacheKey);n if (raw == null) {
// 物理 Key 不存在通常表示尚未预热,不能直接返回 null,可同步预热或降级
return warmUpProduct(id);
}
RedisData<Product> wrapper = objectMapper.readValue(raw, new TypeReference<>() {});
// 未逻辑过期:直接返回新数据
if (System.currentTimeMillis() < wrapper.getExpireAt()) {
return wrapper.getData();
}
String rebuildLock = "rebuild:product:" + id;
String workerId = UUID.randomUUID().toString();
// 已逻辑过期:先返回旧数据;只有一个 worker 异步刷新
if (redis.setIfAbsent(rebuildLock, workerId, Duration.ofSeconds(10))) {
rebuildExecutor.submit(() -> {
try {
Product latest = productMapper.selectById(id);
RedisData<Product> next = new RedisData<>();
next.setData(latest);
next.setExpireAt(System.currentTimeMillis() + 60_000);
redis.set(cacheKey, objectMapper.writeValueAsString(next));
} finally {
redis.releaseLockIfOwner(rebuildLock, workerId);
}
});
}
return wrapper.getData();
}压测观察:高 QPS 不等于无条件选逻辑过期
在重建窗口内,互斥锁版本的请求会集中在锁等待上,P99 延迟可能从 5ms 上升到 50ms 左右,但所有成功请求读到的都是新值。逻辑过期版本几乎没有阻塞,P99 仍接近 8ms,但在异步刷新完成前,请求可能读到旧价格。
因此可以按字段拆分策略:
- 价格、库存、风控状态:使用互斥锁,必要时叠加短 TTL 和主动更新。
- 名称、图片、介绍、排行榜:使用逻辑过期,旧值可接受且热点明显。
- 极端热点数据:使用逻辑过期 + 本地缓存 + Redis 多副本读,减少网络抖动。
避坑总结:4 个高频故障点
只
SETNX不设置过期时间,造成永久死锁。现象是缓存重建线程宕机后,后续所有请求都拿不到锁,接口持续超时。修正方法是使用SET lock value NX PX 10000一条命令完成加锁和超时设置。释放锁时直接
DEL,误删其他线程的锁。例如 A 线程数据库慢查询超过锁超时,B 线程重新加锁;A 结束后删除了 B 的锁,第三个线程又能进入临界区。修正方法是锁 value 存唯一 ID,并通过 Lua 脚本原子比较后删除。逻辑过期 Key 误加物理 TTL,导致旧数据兜底失效。现象是 Redis 中 Key 被真正删除,大量请求进入空值或预热分支,仍然可能击穿数据库。修正方法是逻辑过期方案中
SET不写EX,只更新 Value 内的expireAt。逻辑过期后每个请求都提交异步任务,线程池被打满。虽然用户请求仍返回旧值,但后台可能出现数百个重复回源任务。修正方法是刷新前也使用
NX短锁,保证同一 Key 只有一个重建任务,并为线程池配置队列长度与拒绝策略。
缓存击穿治理的核心不是背出两种方案,而是根据业务能否接受旧数据、数据库承压能力、热点持续时间做选择。强一致选互斥锁,高可用选逻辑过期;更稳妥的做法是字段级拆分,并配合主动预热、锁超时和监控告警一起落地。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。