黑马点评商户查询缓存五:逻辑过期为什么不删除热点缓存?
本文继续整理黑马点评 Redis 实战篇第 2 章「商户查询缓存」。
前一篇讲了缓存击穿的互斥锁方案:热点 key 过期后,只允许一个线程查数据库并重建缓存。
这一篇讲另一种方案:逻辑过期。它最反直觉的地方是:热点缓存不靠 Redis TTL 自动删除,而是把过期时间放到 value 里,由业务代码判断是否需要刷新。
本篇还会结合我当前项目中的改造版本:普通数据走 pass-through,热点数据走逻辑过期。
1. 为什么还要学逻辑过期
互斥锁可以解决缓存击穿。
但它有一个明显特点:
没抢到锁的线程需要等待或重试。
对于热点数据来说,高并发下大量线程等待,会影响接口响应速度。
逻辑过期的思路更偏向性能:
缓存逻辑过期后,当前请求不等待重建。
先返回旧数据。
后台线程异步重建缓存。
它牺牲的是短时间的数据一致性,换来更好的响应速度和数据库保护。
2. 我当时最困惑的两个问题
第一个问题:
逻辑过期方案里,如果 Redis 未命中为什么直接返回 null,不去查数据库?
第二个问题:
热点缓存不设置 Redis TTL,那是不是永远不会删除?既然不删除,设置逻辑过期时间还有什么意义?
这两个问题其实都指向同一个核心:
逻辑过期不是普通缓存查询方案,它是热点缓存保护方案。
3. 物理过期和逻辑过期
先分清两个概念。
物理过期:
Redis key 设置 TTL。
TTL 到期后,Redis 自动删除 key。
比如:
cache:shop:1 30分钟后过期
30 分钟后,这个 key 会从 Redis 中消失。
逻辑过期:
Redis key 不依赖 TTL 自动删除。
value 里额外保存一个 expireTime。
业务代码读出 value 后,自己判断 expireTime 是否过期。
热点缓存 value 大概长这样:
{
"expireTime": "2026-05-14T20:30:00",
"data": {
"id": 1,
"name": "某商铺"
}
}
最外层不是 Shop,而是一个包装对象 RedisData。
4. RedisData 是什么
项目里定义了一个类:
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
它的作用是:
data 保存真实业务数据。
expireTime 保存逻辑过期时间。
也就是说,普通缓存存的是:
Shop JSON
热点缓存存的是:
RedisData JSON
这也是为什么我当前项目里把普通缓存和热点缓存拆成了两个 key 前缀:
cache:shop:{id}
cache:shop:hot:{id}
因为它们虽然底层都是 Redis String,但 JSON 结构完全不同。
5. 为什么逻辑过期需要预热
逻辑过期方案有一个前提:
Redis 里必须先有热点数据。
也就是要先做缓存预热。
项目里的预热方法是:
public void saveHotShop2Redis(Long id, Long expireSeconds) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(
CACHE_HOT_SHOP_KEY + id,
JSONUtil.toJsonStr(redisData)
);
}
这段代码做了四件事:
查 MySQL 得到 Shop。
包装成 RedisData。
设置逻辑过期时间。
写入热点缓存 key。
如果没有预热,Redis 里根本没有旧数据。
而逻辑过期的核心是:
过期时先返回旧数据,再异步重建。
没有旧数据,就没法返回。
所以逻辑过期未命中直接返回 null,并不是 bug,而是它的适用场景决定的。
6. 热点缓存是不是永远不删除
不是。
更准确地说:
热点缓存不依赖 Redis TTL 自动删除,但它仍然可能被业务主动删除、被新值覆盖,或者被 Redis 淘汰。
在当前项目里,更新商铺时会删除两套缓存:
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
stringRedisTemplate.delete(CACHE_HOT_SHOP_KEY + id);
所以热点缓存不是永远不删。
只是它不像普通缓存那样:
到 TTL 时间后 Redis 自动删 key。
它更像:
长期驻留 Redis。
到逻辑过期时间后,由后台线程刷新。
业务更新时,主动删除。
7. 逻辑过期时间有什么意义
逻辑过期时间的意义不是删除 key。
它的意义是:
告诉业务代码:这份热点缓存什么时候应该刷新。
如果没有 expireTime,热点缓存可能一直存在,但永远不知道什么时候该更新。
结果就会变成:
Redis 一直有数据
每次都直接返回
数据可能永远是旧的
有了逻辑过期时间后:
未过期:直接返回
已过期:返回旧值,同时触发后台重建
所以逻辑过期时间不是删除闹钟,而是刷新闹钟。
8. 当前项目的思路 B:普通数据和热点数据分流
我当前项目没有把所有商铺都改成逻辑过期。
而是采用了更容易理解的思路:
普通商铺:走 pass-through。
热点商铺:走逻辑过期。
查询入口大致是:
public Result queryById(Long id) {
Shop shop;
Boolean hasHotKey = stringRedisTemplate.hasKey(CACHE_HOT_SHOP_KEY + id);
if (BooleanUtil.isTrue(hasHotKey)) {
shop = queryHotShopWithLogicalExpire(id);
} else {
shop = queryWithPassThrough(id);
}
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
这段代码的核心是:
stringRedisTemplate.hasKey(CACHE_HOT_SHOP_KEY + id)
它是在问 Redis:
cache:shop:hot:{id} 这个热点 key 是否存在?
如果存在,说明这个商铺已经被预热为热点商铺,走逻辑过期。
如果不存在,说明它不是当前热点缓存对象,走普通 pass-through。
9. 总查询流程图
#mermaid-svg-9Qn4ajRI3gfc6aYa{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9Qn4ajRI3gfc6aYa .error-icon{fill:#552222;}#mermaid-svg-9Qn4ajRI3gfc6aYa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9Qn4ajRI3gfc6aYa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .marker.cross{stroke:#333333;}#mermaid-svg-9Qn4ajRI3gfc6aYa svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9Qn4ajRI3gfc6aYa p{margin:0;}#mermaid-svg-9Qn4ajRI3gfc6aYa .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .cluster-label text{fill:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .cluster-label span{color:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .cluster-label span p{background-color:transparent;}#mermaid-svg-9Qn4ajRI3gfc6aYa .label text,#mermaid-svg-9Qn4ajRI3gfc6aYa span{fill:#333;color:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .node rect,#mermaid-svg-9Qn4ajRI3gfc6aYa .node circle,#mermaid-svg-9Qn4ajRI3gfc6aYa .node ellipse,#mermaid-svg-9Qn4ajRI3gfc6aYa .node polygon,#mermaid-svg-9Qn4ajRI3gfc6aYa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .rough-node .label text,#mermaid-svg-9Qn4ajRI3gfc6aYa .node .label text,#mermaid-svg-9Qn4ajRI3gfc6aYa .image-shape .label,#mermaid-svg-9Qn4ajRI3gfc6aYa .icon-shape .label{text-anchor:middle;}#mermaid-svg-9Qn4ajRI3gfc6aYa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .rough-node .label,#mermaid-svg-9Qn4ajRI3gfc6aYa .node .label,#mermaid-svg-9Qn4ajRI3gfc6aYa .image-shape .label,#mermaid-svg-9Qn4ajRI3gfc6aYa .icon-shape .label{text-align:center;}#mermaid-svg-9Qn4ajRI3gfc6aYa .node.clickable{cursor:pointer;}#mermaid-svg-9Qn4ajRI3gfc6aYa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .arrowheadPath{fill:#333333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9Qn4ajRI3gfc6aYa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9Qn4ajRI3gfc6aYa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9Qn4ajRI3gfc6aYa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9Qn4ajRI3gfc6aYa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .cluster text{fill:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa .cluster span{color:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9Qn4ajRI3gfc6aYa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9Qn4ajRI3gfc6aYa rect.text{fill:none;stroke-width:0;}#mermaid-svg-9Qn4ajRI3gfc6aYa .icon-shape,#mermaid-svg-9Qn4ajRI3gfc6aYa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9Qn4ajRI3gfc6aYa .icon-shape p,#mermaid-svg-9Qn4ajRI3gfc6aYa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9Qn4ajRI3gfc6aYa .icon-shape .label rect,#mermaid-svg-9Qn4ajRI3gfc6aYa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9Qn4ajRI3gfc6aYa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9Qn4ajRI3gfc6aYa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9Qn4ajRI3gfc6aYa :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
有
没有
命中真实数据
命中空值
未命中
未过期
已过期
请求 /shop/{id}
queryById(id)
判断 cache:shop:hot:{id} 是否存在
有热点 key 吗?
queryHotShopWithLogicalExpire(id)
queryWithPassThrough(id)
查普通缓存 cache:shop:{id}
普通缓存命中吗?
返回 Shop
返回店铺不存在
查 MySQL 并回写普通缓存
查热点缓存 RedisData
逻辑过期了吗?
尝试获取锁并异步重建
当前请求返回旧 Shop
这张图就是当前版本的完整查询流程。
10. 热点查询代码解释
热点查询方法大致是:
public Shop queryHotShopWithLogicalExpire(Long id) {
String key = CACHE_HOT_SHOP_KEY + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {
return null;
}
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveHotShop2Redis(id, CACHE_HOT_SHOP_TTL);
} finally {
unlock(lockKey);
}
});
}
return shop;
}
它分成几个步骤。
第一步,查热点缓存:
String json = stringRedisTemplate.opsForValue().get(key);
第二步,如果没查到,直接返回 null:
if (StrUtil.isBlank(json)) {
return null;
}
这一步再次说明:逻辑过期方法不负责冷数据回源。
第三步,解析 RedisData:
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
第四步,从 RedisData 中取出真实商铺:
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
第五步,判断逻辑过期时间:
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
如果 expireTime 在当前时间之后,说明还没过期,直接返回。
如果已经过期,就尝试加锁并异步重建。
11. 异步重建流程图
线程池MySQLRedis请求线程2请求线程1线程池MySQLRedis请求线程2请求线程1#mermaid-svg-oQ1HelTfQMWtHL88{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oQ1HelTfQMWtHL88 .error-icon{fill:#552222;}#mermaid-svg-oQ1HelTfQMWtHL88 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oQ1HelTfQMWtHL88 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oQ1HelTfQMWtHL88 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oQ1HelTfQMWtHL88 .marker.cross{stroke:#333333;}#mermaid-svg-oQ1HelTfQMWtHL88 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oQ1HelTfQMWtHL88 p{margin:0;}#mermaid-svg-oQ1HelTfQMWtHL88 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oQ1HelTfQMWtHL88 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oQ1HelTfQMWtHL88 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-oQ1HelTfQMWtHL88 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-oQ1HelTfQMWtHL88 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-oQ1HelTfQMWtHL88 .sequenceNumber{fill:white;}#mermaid-svg-oQ1HelTfQMWtHL88 #sequencenumber{fill:#333;}#mermaid-svg-oQ1HelTfQMWtHL88 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-oQ1HelTfQMWtHL88 .messageText{fill:#333;stroke:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oQ1HelTfQMWtHL88 .labelText,#mermaid-svg-oQ1HelTfQMWtHL88 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .loopText,#mermaid-svg-oQ1HelTfQMWtHL88 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oQ1HelTfQMWtHL88 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oQ1HelTfQMWtHL88 .noteText,#mermaid-svg-oQ1HelTfQMWtHL88 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-oQ1HelTfQMWtHL88 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oQ1HelTfQMWtHL88 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oQ1HelTfQMWtHL88 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oQ1HelTfQMWtHL88 .actorPopupMenu{position:absolute;}#mermaid-svg-oQ1HelTfQMWtHL88 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-oQ1HelTfQMWtHL88 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oQ1HelTfQMWtHL88 .actor-man circle,#mermaid-svg-oQ1HelTfQMWtHL88 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-oQ1HelTfQMWtHL88 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}读取热点缓存返回 RedisData,已逻辑过期尝试获取 lock:shop:{id}获取锁成功提交缓存重建任务返回旧 Shop读取热点缓存返回 RedisData,已逻辑过期尝试获取 lock:shop:{id}获取锁失败直接返回旧 Shop查询最新 Shop写入新的 RedisData删除锁
这里的关键是:
请求线程不等待数据库查询。
只有后台线程负责重建缓存。
12. 为什么普通缓存和热点缓存要拆 key
普通缓存:
key = cache:shop:{id}
value = Shop JSON
热点缓存:
key = cache:shop:hot:{id}
value = RedisData JSON
虽然它们在 Redis 里都是 String,但字符串内部结构不同。
普通缓存 JSON 大概是:
{
"id": 1,
"name": "某商铺"
}
热点缓存 JSON 大概是:
{
"expireTime": "2026-05-14T20:30:00",
"data": {
"id": 1,
"name": "某商铺"
}
}
如果共用同一个 key 前缀,普通查询方法可能把 RedisData JSON 当成 Shop JSON 解析,热点查询方法也可能把 Shop JSON 当成 RedisData JSON 解析。
这不是 Redis 类型冲突,而是应用层数据结构冲突。
拆 key 前缀的好处是:
看到 cache:shop:{id},就知道 value 是 Shop JSON。
看到 cache:shop:hot:{id},就知道 value 是 RedisData JSON。
13. 逻辑过期和互斥锁的取舍
互斥锁方案:
优点:数据更新后再返回,数据一致性更好。
缺点:没抢到锁的线程可能等待或重试,性能受影响。
逻辑过期方案:
优点:请求线程不用等待,响应快,数据库压力小。
缺点:缓存重建完成前,用户可能看到旧数据。
所以逻辑过期更适合:
热点数据
读请求非常多
可以接受短时间旧数据
更关注系统稳定性和响应速度
14. 易错点
第一个易错点:以为逻辑过期是 Redis 自动过期。
不是。逻辑过期时间存在 value 中,由 Java 代码自己判断。
第二个易错点:以为热点缓存永远不会删除。
不是。它只是不靠 Redis TTL 删除,业务更新时可以主动删除,也可以被新值覆盖。
第三个易错点:逻辑过期未命中还想直接查数据库。
纯逻辑过期方案默认热点数据已经预热。当前项目用思路 B,在外层未发现热点 key 时才回退到 pass-through。
第四个易错点:普通缓存和热点缓存共用 key。
它们 value 结构不同,共用 key 会导致 JSON 解析混乱。
第五个易错点:以为逻辑过期一定比互斥锁好。
不是。它只是取舍不同:性能更好,但允许短时间旧数据。
15. 面试怎么回答
如果面试官问:
什么是逻辑过期?
可以回答:
逻辑过期是指 Redis key 本身不依赖 TTL 自动删除,而是在 value 中保存一个 expireTime。
查询时先取出缓存数据,再由业务代码判断 expireTime 是否过期。
如果未过期,直接返回;
如果已过期,当前请求先返回旧数据,同时尝试获取锁,由后台线程异步查询数据库并重建缓存。
如果问:
逻辑过期为什么可以解决缓存击穿?
可以回答:
因为热点 key 不会在过期时从 Redis 中消失,即使逻辑上过期了,Redis 里仍然保留旧值。
大量请求过来时都能先拿到旧值返回,不会同时打到数据库。
只有抢到锁的线程会触发异步重建缓存,从而保护数据库。
如果问:
逻辑过期有什么缺点?
可以回答:
缺点是会短时间返回旧数据,数据一致性不如互斥锁方案。
另外实现更复杂,需要预热热点数据、保存逻辑过期时间、使用线程池异步重建缓存,并处理好锁释放。
如果问:
你项目里普通商铺和热点商铺怎么区分?
可以回答:
我采用的是普通数据走 pass-through,热点数据走逻辑过期。
查询入口先判断 Redis 中是否存在热点 key cache:shop:hot:{id}。
如果存在,说明该商铺已经预热为热点数据,走逻辑过期查询;
如果不存在,就走普通 pass-through,缓存未命中时回源数据库。
16. 总结
这一篇最重要的是记住:
逻辑过期不是让 Redis key 到期删除,而是让业务代码根据 value 中的 expireTime 决定是否异步刷新缓存。
它的核心动作是:
热点数据提前预热。
查询时即使逻辑过期,也先返回旧值。
只有抢到锁的线程异步重建缓存。
重建完成后,新的 RedisData 覆盖旧值。
当前项目采用“普通数据 pass-through,热点数据逻辑过期”的思路后,第二章商户缓存的几条线就比较清楚了:
普通查询:解决重复查数据库。
空值缓存:解决缓存穿透。
删除缓存:解决缓存和数据库一致性。
互斥锁:解决热点 key 物理过期导致的击穿。
逻辑过期:通过返回旧值和异步重建保护热点 key。
这也是第二章最完整的一条学习主线。

