逻辑过期 代码
使用 逻辑过期 解决 缓存击穿
"逻辑过期时长,这里设20秒"privatestaticfinallongLOGIC_EXPIRE_SECONDS=20L;"创建独立线程池,专门用来异步更新缓存(只创建一次)"privatestaticfinalExecutorServiceCACHE_REBUILD_EXECUTOR=Executors.newFixedThreadPool(10);@OverridepublicResultqueryShopByLogicExpire(Longid){Stringkey=CACHE_SHOP_KEY+id;StringshopJson=stringRedisTemplate.opsForValue().get(key);// 逻辑过期方案:需要提前预热缓存,如果redis没有key,直接返回不存在if(StrUtil.isBlank(shopJson)){returnResult.fail("商铺不存在");}// 反序列化RedisData对象RedisDataredisData=JSONUtil.toBean(shopJson,RedisData.class);Shopshop=(Shop)redisData.getData();LocalDateTimeexpireTime=redisData.getExpireTime();"判断 逻辑过期 是否过期,过期时间在当前时间之后 = 未过期"if(expireTime.isAfter(LocalDateTime.now())){// 未过期,直接返回商铺信息returnResult.ok(shop);}"走到这里,缓存,逻辑过期了"StringlockKey=LOCK_SHOP_KEY+id;booleanisLock=tryLogicLock(lockKey);if(isLock){// 获取锁成功,开启异步线程,去更新缓存CACHE_REBUILD_EXECUTOR.submit(()->{try{saveShopRedis(id,LOGIC_EXPIRE_SECONDS);}finally{// 释放锁unlock(lockKey);}});}"重点:不管拿到锁还是没拿到锁,主线程直接返回旧数据,不等待!"returnResult.ok(shop);}// 逻辑过期时间 - 获取锁privatebooleantryLogicLock(StringlockKey){// NX,锁10秒Booleanflag=stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1",10,TimeUnit.SECONDS);returnBooleanUtil.isTrue(flag);}"缓存预热方法:项目启动时手动调用,提前把数据放入Redis。""逻辑过期方案,必须先预热,不能等查询的时候才存入redis。"publicvoidsaveShopRedis(Longid,LongexpireSeconds){// 1. 查询数据库Shopshop=getById(id);// 2. 封装RedisDataRedisDataredisData=newRedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));// 3. 写入Redis,不设置过期时间stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));}privatevoidunlock(StringlockKey){stringRedisTemplate.delete(lockKey);}完整执行流程
前提:提前调用 saveShopRedis() 预热,cache:shop:1已经存在Redis,key没有物理过期时间,value里面包含shop数据 + 逻辑过期时间。
- 用户请求接口,传入商铺 id,拼接 cache:shop:1,查询 Redis 拿到 json 字符串。
- 判断
StrUtil.isBlank:Redis有数据,继续执行。 - JSON字符串反序列成RedisData对象。
- 取出shop对象(强制转换Object→Shop)、取出逻辑过期时间expireTime。
- 判断 expireTime 是否在当前时间之后:
- ✅未过期:直接返回shop,请求结束。
- ❌已过期:进入缓存更新逻辑。
- 拼接锁 key
lock:shop:1,尝试获取分布式锁。- ✅抢到锁:向线程池提交异步任务,新开线程执行saveShopRedis。
- 异步线程:查DB,封装RedisData,写入Redis更新数据与新的逻辑过期时间,最后释放锁。
- ❌没抢到锁:不做任何操作。
- ✅抢到锁:向线程池提交异步任务,新开线程执行saveShopRedis。
- 主线程不等待异步任务,直接返回旧的shop数据给前端!
后续再来查询这个商铺:Redis已经是异步线程更新后的新数据,逻辑过期时间也刷新,直接返回新数据。
核心特点&缺陷
✅特点
- Redis 的 key 物理上永不过期,靠 value 内部的 expireTime 判断过期;
- 缓存过期后用户请求不会阻塞,没有sleep,高并发性能好;
- 分布式锁只用来控制:同一时刻最多只有1个异步线程更新缓存,不是阻塞用户;
- 弱一致性:缓存过期之后,异步更新完成前,所有用户拿到旧数据。
❌缺陷
- 必须提前预热缓存,不能访问时动态加载;忘记预热直接返回不存在;
- 弱一致性,存在旧数据窗口;
- 解锁直接delete,没有锁持有者校验,存在锁误删风险;
- Redis key永久存在,占用Redis内存;
- 不能解决缓存穿透,只解决缓存击穿。
和互斥锁方案一句话区分
- 互斥锁:Redis 真实删除 key,请求阻塞等待,读到最新数据,强一致性。
- 逻辑过期:Redis 保留 key,判断内部逻辑时间,请求不阻塞,短暂读到旧数据,高并发。
Executors.newFixedThreadPool(10)
privatestaticfinalExecutorServiceCACHE_REBUILD_EXECUTOR=Executors.newFixedThreadPool(10);ExecutorService是线程池接口,定义提交任务、关闭线程池等方法。Executors.newFixedThreadPool(10)返回的对象实现了这个接口,用接口接收,面向接口编程CACHE_REBUILD_EXECUTOR变量名
语义:缓存重建线程池。专门用来跑「异步更新缓存」的任务,不和Tomcat 的 HTTP工作线程 混用。Executors.newFixedThreadPool(10)Executors是 JDK 提供的 线程池工具类。newFixedThreadPool(10)→固定大小线程池,核心线程数量=10。特性:
- 线程数量固定为10个;最多同时跑10个缓存重建任务
- 任务多了,多余任务进入无界队列LinkedBlockingQueue排队等待
- 线程不会自动回收,常驻内存
- 适合:短时间、高频的异步任务,这里就是用来后台更新Redis缓存
CACHE_REBUILD_EXECUTOR.submit(()->{try{saveShopRedis(id,LOGIC_EXPIRE_SECONDS);}finally{unlock(lockKey);}});.submit():提交任务给线程池,从线程池拿出一条空闲线程去执行括号里面的代码- 而当前用户请求的主线程(Tomcat线程)不会卡住,直接往下执行return返回旧数据
核心目的:把缓存重建任务交给独立线程池异步执行,不占用Tomcat处理HTTP请求的工作线程。
如果不单独开线程池,用 Tomcat 本身的线程去做更新,那这个请求就会被阻塞,逻辑过期 “不阻塞” 的优势就没了。
无界队列
固定线程池newFixedThreadPool(10):最多同时10个线程干活。无界队列里排队的任务,只要有一个线程空闲出来,就从队列头部取出一个任务交给这个线程执行。
例子
线程池核心线程 = 10
- 第1~10个任务:直接分配给10条线程,全部开始执行
- 第11、12、13…任务:线程全部忙,进
LinkedBlockingQueue(无界队列)排队
假设:任务1执行完毕,线程1空闲了
- 线程池检测到有空闲线程
- 从队列队首取出排在最前面的任务(任务11)
- 线程1执行任务11
队列里剩下任务继续等待,哪个线程干完活,就从队列拿一个新任务
队列是先进先出 FIFO,先来的任务先被执行。
⚠️ 重点:LinkedBlockingQueue 是无界队列
队列没有上限,可以无限往里塞任务。
极端场景:大量缓存同时过期,疯狂submit任务,任务不断堆在队列里,队列越来越大,占用堆内存,最终OOM。
这就是阿里开发手册禁止直接用
Executors.newFixedThreadPool的原因。
补充:线程池 和 Tomcat线程 区别
- Tomcat线程池:专门接收、处理用户 http 请求,请求来了就用这个线程执行 controller/service 代码。
- CACHE_REBUILD_EXECUTOR:独立线程池,只做后台缓存更新,和http请求线程隔离。
用户主线程(Tomcat线程)只是提交任务,提交完就解放,直接返回结果;真正查DB、更新Redis的动作,交给缓存重建线程池里的线程。
面试小问题
Q:为什么要单独创建一个线程池,不用Tomcat线程直接做更新?
A:如果用 Tomcat 线程去同步更新缓存,主线程就会阻塞等待更新完成,逻辑过期方案“不阻塞请求”的特点就失效。单独线程池,把重建任务异步后台执行,Tomcat 请求线程提交任务后立刻返回,提升并发能力。
Q:static在这里的意义?
A:static 保证整个项目只有这一个线程池实例,不会多次创建多个线程池,避免创建大量多余线程消耗系统资源。
Q:final的作用?
A:防止代码其他地方不小心把CACHE_REBUILD_EXECUTOR重新赋值为null或者另一个线程池,保证引用不变。
Q:Executors.newFixedThreadPool()底层队列是无界队列。
如果短时间大量缓存同时过期,大量任务不断丢进队列,队列无限膨胀,会OOM内存溢出。
面试标准答案:
开发演示写这个没问题;生产环境不推荐 Executors 快捷创建线程池,推荐手动ThreadPoolExecutor构造方法,自己指定队列最大容量 + 拒绝策略,防止任务堆积OOM。