当前位置: 首页 > news >正文

面试被问到“你们项目Redis怎么用的?“——我把这套AOP缓存框架甩给他,面试官直接沉默了

事情是这样的,上个月面了一家做内容平台的公司,技术面到一半,面试官突然问:“你们项目里 Redis 都用在哪些场景?缓存怎么做的?”

我心想,这不撞枪口上了吗。我们那个blog-parent项目,Redis 这块确实下了功夫,不是简单的set/get完事。我直接把项目里的AOP 注解缓存 + 分布式锁防击穿 + Lua 脚本原子操作 + 浏览量异步回写这套组合拳讲了一遍,面试官听完直接说"这块不用问了"。

今天就把我项目里 Redis 这部分的源码和设计思路掰开揉碎讲清楚,全是实战干货,看完你也能拿去面试。

一、先看看我们项目里 Redis 都干了啥

先上目录,我们项目blog-parent是个多模块 Spring Boot 项目,Redis 贯穿了登录认证、文章缓存、点赞收藏、浏览量统计四大核心场景。

├── blog-framework # 框架层:Redis 配置 + AOP 缓存 + 工具类 │ ├── RedisKeys.java # Redis Key 枚举(统一管理所有 key) │ ├── RedisConfig.java # Redis 序列化配置 │ ├── RedisCache.java # Redis 操作工具类(封装了 Lua 脚本) │ ├── RedisCacheSingleKeyAop # 单 key 缓存 AOP(缓存击穿防护) │ └── RedisCacheMultiKeyAop # 批量 key 缓存 AOP(批量缓存神器) ├── blog-article # 文章模块:浏览量/点赞/收藏的 Lua 脚本 │ └── RedisCacheArticle.java # 文章相关 Redis 操作(11 个 Lua 脚本) └── blog-user # 用户模块:登录信息存 Redis

Redis 具体干啥了?一句话总结:

场景Redis 存的啥为什么用 Redis
登录态login:userId:tokenId→ 用户信息JWT 无状态,Redis 做二级会话管理
文章详情article:detail:id→ 文章 Map缓存热点文章,扛住高并发读取
浏览量article:view:id→ 累计增量Redis INCR 原子自增,扛写密集型
点赞/收藏article:userLike:id→ 文章ID列表双重写 DB + Redis,保证一致性
用户信息user:baseInfo:id→ 用户基本信息减少 DB 查询,提升响应速度

二、踩过的第一个大坑:Redis Key 乱成一锅粥

问题

项目刚开始的时候,大家各写各的,有人用article_detail_123,有人用article:detail:123,还有人用ArticleDetail123。结果排查问题的时候,你想搜一下某某文章有没有缓存,得去代码里翻半天才知道 key 的格式。更坑的是,有个同事把过期时间写死了 24 小时,另一个同事在同一类数据上写的 30 分钟,数据一致性直接炸了。

解决方案:枚举统一管理

我们直接搞了一个RedisKeys枚举,所有 key 的格式和过期时间都写在一个地方,谁都不许自己拼字符串:

publicenumRedisKeys{LOGIN_KEY("login:%s:%s",2*60*60),// login:123:uuid → 登录信息ARTICLE_DETAIL("article:detail:%s",2*60*60),// article:detail:456 → 文章详情ARTICLE_VIEW("article:view:%s",-1),// article:view:456 → 浏览量增量ARTICLE_VIEW_BUCKET("article:view:bucket",-1),// 浏览桶ARTICLE_USER_LIKE("article:userLike:%s",2*60*60),// 用户点赞列表ARTICLE_USER_COLLECT("article:userCollect:%s",2*60*60);// 用户收藏列表privatefinalStringkey;// key 模板,带 %s 占位符privatefinallongexpire;// 过期时间,-1 表示手动控制publicStringgetKey(Object...params){returnString.format(key,params);// 替换 %s 为实际参数}}

核心好处:

  • 所有 key 的命名规范统一:模块:业务:标识
  • 过期时间集中配置,不会出现同一类数据过期时间不一致
  • 想看项目用了哪些 Redis key,一个枚举全看完了
  • 拼 key 的时候调用RedisKeys.ARTICLE_DETAIL.getKey(articleId),不会拼错

三、第二个大坑:每个 Service 都写重复的缓存代码

问题

一开始大家的 Service 是这样的:

// 每个方法都要写一遍 查缓存→查库→写缓存publicArticlegetArticle(Longid){// 1. 查缓存Objectcache=redisTemplate.opsForValue().get("article:detail:"+id);if(cache!=null)return(Article)cache;// 2. 缓存没有,查库Articlearticle=getById(id);// 3. 写缓存redisTemplate.opsForValue().set("article:detail:"+id,article,2,TimeUnit.HOURS);returnarticle;}

10 个 Service 方法,10 遍重复代码。而且每个人写的还不一样:

  • 有人忘了设过期时间
  • 有人没处理缓存穿透(数据库查不到就直接返回 null,下次请求又来查库)
  • 有人没加锁,高并发下缓存击穿直接让 DB 跪了

解决方案:自定义 AOP 缓存注解

我们搞了一个@RedisCacheDataRequire注解,所有缓存逻辑都交给 AOP 自动处理

@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public@interfaceRedisCacheDataRequire{RedisKeyskey();// 用哪个 key 模板RedisLockKeyslockKey()defaultRedisLockKeys.DEFAULT;// 防击穿的锁booleanneedFormat();// key 是否需要拼参数booleancacheNull()defaulttrue;// 查不到时是否缓存空值ResultTyperesultType()defaultResultType.DEFAULT;// 结果类型}

Service 里用起来就一行注解的事:

@RedisCacheDataRequire(key=RedisKeys.ARTICLE_DETAIL,lockKey=RedisLockKeys.LOCK_ARTICLE_DETAIL,needFormat=true,resultType=ResultType.MAP)publicMap<String,Object>getArticleBaseInfoById(@RedisCacheParamLongarticleId){// 这里只管查数据库,缓存的事 AOP 自动搞定Articlearticle=getById(articleId);returnBeanUtil.beanToMap(article);}

就这一下,所有 Service 方法再也不用手动写缓存代码了。


四、最难啃的骨头:AOP 缓存核心代码逐行拆解

这块才是整个项目 Redis 的精髓,面试高频考点也是这里。直接上核心代码:

@Around("@annotation(redisCacheDataRequire)")publicObjectaround(ProceedingJoinPointpoint,RedisCacheDataRequireredisCacheDataRequire)throwsThrowable{// 第1步:拿到 key 模板Stringkey=redisCacheDataRequire.key().getKey();// "article:detail:%s"StringlockKey=redisCacheDataRequire.lockKey().getKey();// lock key// 第2步:needFormat=true → 拼参数到 key 里if(redisCacheDataRequire.needFormat()){Objectparam=getCacheParamValue(point);// 找 @RedisCacheParam 的参数key=String.format(key,param);// "article:detail:456"lockKey=String.format(lockKey,param);}// 第3步:先查 RedisBooleanhasKey=redisCache.hasKey(key);if(!Boolean.TRUE.equals(hasKey)){// ===== Redis 没有这个 key =====booleanneedLock=redisCacheDataRequire.lockKey()!=RedisLockKeys.DEFAULT;if(needLock){// 场景:高并发下 100 人同时查某篇文章// 不加锁 → 100 人全部查库 → DB 瞬间被打爆RLocklock=redissonClient.getLock(lockKey);try{booleanlockSuccess=lock.tryLock(3,10,TimeUnit.SECONDS);if(lockSuccess){// ★ 拿到锁后二次检查 Redis(Double Check)// 为什么?你等锁的功夫,第一个人已经查完库写进 Redis 了hasKey=redisCache.hasKey(key);if(Boolean.TRUE.equals(hasKey)){redisCache.expire(key,expire);// 续期returngetResultByType(key,redisCacheDataRequire.resultType());}// 真的没有 → 查数据库Objectresult=point.proceed();// ★ 缓存穿透防护:数据库都没有 → 缓存空值if(result==null){redisCache.cacheNullValue(key);// 存 "NULL" 标记,2 分钟过期returnnull;}// 按类型存 RediscacheResByType(key,result,expire,redisCacheDataRequire.resultType());returnresult;}else{thrownewRuntimeException("系统繁忙,请稍后重试");}}finally{if(lock!=null&&lock.isHeldByCurrentThread()){lock.unlock();// 一定要释放锁!}}}// ...不需要锁的逻辑类似}else{// Redis 有缓存 → 直接返回,顺便续期redisCache.expire(key,expire);returngetResultByType(key,redisCacheDataRequire.resultType());}}

这道代码到底干了啥?看图就明白了:

请求1 → 查 Redis(没有)→ 拿到锁 → 查 DB → 写 Redis → 返回 请求2 → 查 Redis(没有)→ 等锁 → Redis已有 → 直接返回(续期) 请求3 → 查 Redis(没有)→ 等锁超时 → 返回"系统繁忙" 请求4 → 查 Redis(有!)→ 直接返回

面试常问:你们解决了哪些缓存问题?

问题现象我们的方案
缓存穿透查不存在的 ID,每次都穿透到 DBcacheNullValue()缓存空值 2 分钟
缓存击穿热点 key 过期,高并发全打到 DBRedisson 分布式锁 + Double Check
缓存雪崩大量 key 同时过期过期时间分散配置 + 续期策略
缓存穿透增强空集合也会穿透isEmptyCollection()判空后缓存空值

五、批量缓存 AOP:性能优化的王炸

单 key 缓存解决了单个文章的查询问题,但场景更复杂:用户点赞列表、文章列表这些需要一次查一批数据的怎么办?

比如getArticlesByIds(List<Long> ids),传入 100 个文章 ID,其中 60 个已经在 Redis 里了,只需要查库补 40 个。原始的写法是遍历查 Redis,一个个判断有没有——这就浪费了 Redis 的批量能力。

批量缓存的核心思路

@Around("@annotation(redisCacheDataListRequire)")publicObjectaround(ProceedingJoinPointpoint,RedisCacheDataListRequirerequire){// 第1步:所有 ID 拼成完整 keyList<String>allKeys=paramList.stream().map(id->String.format(keyPrefix,id)).toList();// 第2步:一次性 multiGet 查 RedisList<Object>redisRes=redisCache.getRedisTemplate().opsForValue().multiGet(allKeys);// redisRes = [data1, null, data3, null, data5, ...]// null = 缓存没命中// 第3步:筛选出没命中的 IDList<Object>missIds=没命中的那些;// 第4步:全部命中 → 直接返回if(missIds.isEmpty())returnredisRes;// 第5步:修改原方法参数,只查没命中的数据// 原方法要查 100 篇,Redis 命中了 60 篇// → 把参数改成只传 40 个没命中的 ID 去查库args[参数索引]=missIds;Objectresult=point.proceed(args);// 只查 40 篇// 第6步:新查的写回 RedisredisCache.multiSetWithExpire(keyPrefix,(Collection)result,expire);// 第7步:合并 Redis 数据 + 新查的数据,按原始顺序返回returnmergeResult(redisRes,result,paramList);}

这段代码最难的地方有三点:

  1. 参数替换:AOP 把原方法的100 个 ID偷偷改成40 个 ID,原方法自己不知道
  2. 返回值合并:Redis 返回的 60 条 + 新查的 40 条,要按用户传入的 ID 顺序合并成一个 List
  3. 并发控制:100 个请求同时来,都发现 Redis 少了几个,不能 100 个都去查库。用了 Redisson 的getMultiLock,对每个没命中的 key 分别加锁

六、浏览量系统:千万级 PV 的终极方案

问题有多严重

浏览量是写密集型操作。一篇文章火了,每秒几百上千人看,每次UPDATE article SET view_count = view_count + 1,MySQL 根本扛不住。

但浏览量有一个特点:不需要强一致性。用户看到浏览量是 100 还是 101 完全没影响。

最终方案:Redis INCR + MQ + 定时回写 MySQL

用户查看文章 ↓ Controller 直接返回文章内容(快!不等浏览量) ↓ 发 MQ 异步处理 查看队列 → Consumer → 执行 Lua 脚本 ↓ Redis: article:view:123 = 5(累计增量) Redis: article:view:bucket = {123, 456}(哪些文章被浏览过) ↓ 每 5 分钟定时任务 批量 multiGet 所有增量 → 批量 UPDATE MySQL ↓ 清空桶(不清浏览量 key,下次继续累加)

Lua 脚本保证原子性

-- articleViewCountAdd.lua-- KEYS[1] = article:view:123 (浏览量 key)-- KEYS[2] = article:view:bucket (桶 key)-- ARGV[1] = 123 (文章 ID)redis.call('INCR',KEYS[1])-- 浏览量 +1(原子操作)redis.call('SADD',KEYS[2],ARGV[1])-- 文章 ID 入桶return1

为什么用 Lua?因为INCRSADD必须是原子的。如果不用 Lua:

线程A:INCR view:123(+1) 线程B:INCR view:123(+1) 线程A:SADD bucket 123 线程B:SADD bucket 123

虽然结果可能没错,但如果 INCR 执行了但 SADD 没执行(比如 Redis 挂了),桶里没有这篇文章 ID,定时任务就不会回写它的浏览量——数据丢了

定时回写代码

@Scheduled(fixedRate=300000)// 每 5 分钟publicvoidarticleViewCountWriteBack(){// 1. 从桶里拿所有被浏览过的文章 IDSet<Long>ids=redisCache.getCacheSet(ARTICLE_VIEW_BUCKET);if(ids.isEmpty())return;// 2. 批量拿增量浏览量List<String>keys=ids.stream().map(id->ARTICLE_VIEW.getKey(id)).toList();List<Integer>views=redisTemplate.opsForValue().multiGet(keys);// 3. 组装 Map<文章ID, 增量>Map<Long,Integer>viewMap=...;// 4. 批量 UPDATE MySQLgetBaseMapper().writeBackViewCount(viewMap);// 5. 清空桶(已回写的)redisCacheArticle.articleViewBatchClear(keys);}

七、点赞/收藏:Redis + MySQL 双写,Lua 保证一致性

问题

点赞操作需要同时做两件事:

  1. 把文章 ID 加入用户的点赞列表(SADD)
  2. 如果文章缓存存在,把缓存里的 likeCount +1(HINCRBY)

如果不用 Lua,高并发下可能出现:

线程A:SADD 用户点赞列表(成功) 线程B:也 SADD(也成功?) 线程A:HINCRBY 文章缓存(+1 但只加了一次) 结果:点赞列表两条记录,缓存只加了一次 → 数据不一致

Lua 脚本解决

-- articleLikeAdd.lua-- KEYS[1] = article:userLike:789(用户点赞列表)-- KEYS[2] = article:detail:456(文章缓存)-- ARGV[1] = 456(文章ID)-- 先清理异常类型(之前可能是 string 类型)localkeyType=redis.call('TYPE',KEYS[1])iftype(keyType)=='table'thenkeyType=keyType['ok']endifkeyType=='string'thenredis.call('DEL',KEYS[1])end-- 把文章 ID 加入用户点赞列表(用 List 存)redis.call('LPUSH',KEYS[1],ARGV[1])-- 如果文章缓存存在,likeCount +1localdetailExists=redis.call('EXISTS',KEYS[2])ifdetailExists==1thenredis.call('HINCRBY',KEYS[2],'likeCount',1)endreturn1

Lua 脚本在 Redis 里是原子执行的,整个脚本执行过程中不会被其他命令打断,所以赞列表和缓存计数永远一致。

我们项目里一共用了11 个 Lua 脚本,覆盖浏览量、点赞、收藏、分享、批量操作等场景:

articleViewCountAdd.lua → 浏览量 +1,ID 入桶 articleViewBatchClear.lua → 清空桶 + 批量回写缓存中的浏览量 articleLikeAdd.lua → 点赞:列表添加 + 缓存 +1 articleLikeCancel.lua → 取消点赞:列表移除 + 缓存 -1 articleCollectAdd.lua → 收藏 articleCollectCancel.lua → 取消收藏 articleShareAdd.lua → 分享 articleShareCancel.lua → 取消分享 multiSetWithExpire.lua → 批量设值 + 过期时间 multiSetSameValueWithExpire.lua → 批量设相同值 + 过期时间 setKeysExpire.lua → 批量设过期时间

八、登录认证:JWT + Redis 双层过期策略

只有 JWT 的问题

JWT 一旦签发,在过期之前都是有效的。如果用户想"踢掉其他设备"或者管理员想禁用某个用户,JWT 做不到——因为它是无状态的,服务端不存任何 session。

我们的方案:Redis 做二级会话管理

登录流程: 用户 POST /login → 验证用户名密码 ↓ 生成 JWT(1小时过期)+ 生成 UUID ↓ Redis 存入 login:userId:uuid → LoginUser 对象(2小时过期) ↓ JWT 的 jti 字段 = UUID(把 JWT 和 Redis 关联起来) ↓ 返回前端 JWT

请求验证流程:

前端请求带 JWT → JwtAuthenticationTokenFilter ↓ JWT 过期了?→ 没关系,解析出 userId 和 tokenId ↓ 去 Redis 查 login:userId:tokenId ↓ Redis 有 → 用户还在活跃 → 自动续期 → 继续处理 Redis 没有 → 返回 401 → 前端跳登录页

核心代码(token 续期):

publicvoidverifyToken(LoginUserloginUser,Stringkey){longexpire=loginUser.getExpire();// Redis 里存的过期时间戳longnow=System.currentTimeMillis();if(expire-now<=0){// Redis 已经过期了 → 删除 → 抛异常redisCache.deleteObject(key);thrownewBusinessException("token已过期");}if(expire-now<tokenRenewal*60*1000L){// 还剩不到 20 分钟 → 自动续期loginUser.setExpire(now+loginTokenExpire*60*1000);redisCache.setCacheObject(key,loginUser,RedisKeys.LOGIN_KEY.getExpire());}}

这套方案的好处:

  • 想踢掉用户?删 Redis 就行,JWT 虽然没过期但 Redis 查不到 → 自动 401
  • 用户一直操作?只要在 20 分钟内有过请求,Redis 自动续期,不用重新登录
  • 多设备登录?每个设备一个login:userId:uuid,互不影响

九、序列化大坑:Redis 存进去读不出来

问题

之前用的 JDK 序列化,Redis 里存的是一串乱码:

\xAC\xED\x00\x05sr\x00\x11java.util.HashMap...

肉眼根本看不懂存了啥,而且 JDK 序列化性能差,占空间大。

解决方案:统一 JSON 序列化

@ConfigurationpublicclassRedisConfig{@BeanpublicRedisTemplate<Object,Object>redisTemplate(RedisConnectionFactoryfactory){RedisTemplate<Object,Object>template=newRedisTemplate<>();template.setConnectionFactory(factory);// 使用 FastJson 序列化FastJsonRedisSerializer<Object>serializer=newFastJsonRedisSerializer<>(Object.class);// key 用 StringRedisSerializertemplate.setKeySerializer(newStringRedisSerializer());// value 用 FastJsontemplate.setValueSerializer(serializer);// Hash 的 value 也用 FastJsontemplate.setHashValueSerializer(serializer);template.afterPropertiesSet();returntemplate;}}

改完以后 Redis 里存的就是人类能看懂的 JSON 了,排查问题直接用 Redis Desktop 看一眼就知道了。


十、总结 + 避坑经验

这套 Redis 体系的核心优势

  1. 开发效率:AOP 注解一行代码搞定缓存,不用每个 Service 写重复代码
  2. 性能:批量 multiGet + Lua 脚本原子操作,把 Redis 性能压榨到极致
  3. 安全:分布式锁防击穿、空值缓存防穿透、过期分散防雪崩
  4. 一致性:浏览量用最终一致性(Redis + MySQL),点赞/收藏用双写 + Lua

实战避坑清单

血泪教训
Key 格式不统一一开始就要用枚举管理,不然后面排查问题想死
忘了设过期时间Redis 是内存数据库,不设过期时间迟早 OOM
缓存穿透查不到的数据也要缓存空值,不然恶意攻击直接打穿 DB
缓存击穿没加锁热点 key 过期那一瞬间,100 个请求同时打到 DB,直接 500
序列化用 JDK换了 JSON 序列化之后,排查问题效率提升 10 倍
Lua 脚本不幂等注意LPUSH会重复添加,每次操作前要考虑是否要先清理
类型转换异常缓存里之前可能是 String,后来改成了 List,TYPE判断要兼容

最后说两句

Redis 本身不难,set/get谁都会写。但真正体现水平的是缓存策略的设计——怎么防击穿、怎么做批量缓存、怎么保持一致性、怎么处理写密集型场景。

我这套方案都是线上项目验证过的,面试的时候能把这几个a点讲清楚,面试官基本不会再追 Redis 的问题了。

有什么问题欢迎评论区交流,看到会回。


如果觉得文章有用,麻烦点个赞,让更多人看到~

http://www.zskr.cn/news/1373136.html

相关文章:

  • 安全合规:满足行业安全标准和法规要求
  • Go语言内存泄漏:pprof与监控
  • Qt6.5数控加工CAM框架实战:基于工厂模式与分层架构的CamCore完整实现
  • 2026宜宾装修公司推荐:宜宾装修公司哪家好/宜宾装修公司电话/宜宾装饰公司哪家好/宜宾装饰公司排行榜/宜宾装饰公司电话/选择指南 - 优质品牌商家
  • 用Python和Pandas搞定泰坦尼克号数据集:从数据清洗到特征工程的完整实战
  • 手机HTTPS抓包全链路解析:从代理配置到SSL Pinning绕过
  • Mininet安装后必做的3件事:从验证到排错,让你的Ubuntu模拟网络即刻可用
  • 你的算法真的强吗?用CEC2017的F21-F30组合函数来场硬核挑战(附Matlab对比测试模板)
  • Keil单用户许可证(LIC)更新与多设备管理指南
  • 2026年当下常德卫生间防水公司实力盘点:优家房屋修缮中心为何备受青睐? - 2026年企业推荐榜
  • 解决Linux内核调试中JTAG连接丢失问题
  • 单向晶闸管调压电路基础知识及Multisim电路仿真
  • 当Harness 热潮褪去:腾讯 AI 团队揭示 AI 工程的真正护城河是知识沉淀
  • Java异常处理机制详解 | 类层次、捕获处理、自定义异常与实战案例
  • 从零开始单细胞分析:手把手教你用Scanpy复现PBMC3K教程(附避坑指南)
  • 从集合运算到代码:一文搞懂Jaccard系数,附Python/NumPy/Pandas三种实现方法对比
  • MNIST识别项目复盘:除了准确率97%,我们更应该关注数据预处理与损失函数的选择
  • 【数据分析】具有随机效应的分数扩散的非参数估计附matlab代码
  • 无设备穿戴式无感定位 优化煤化工厂区人员动线管理
  • 别再死记硬背K-Means代码了!用Educoder实战,5分钟搞懂聚类中心怎么‘动’起来的
  • 【无人船】基于A星算法融合DWA限制内陆水域无人水型导航路径规划附Matlab代码
  • 2026年免费图片去水印保姆级教程:不用下载软件,微信小程序一步搞定
  • 零基础实战逻辑漏洞挖掘:从注册到注销的6大高频场景
  • Keil工具链LPT端口冲突解决方案与配置优化
  • ICLR 2026小米AI 技术深度解读
  • 【DeepSeek版本决策脑图】:基于17类真实场景(金融/教育/客服/代码生成)的精准匹配表
  • Django 从 0 到 1 打造完整电商平台:购物车实现方式分析与模型设计
  • ChatGPT生成图表总“丑”?3步精准调优Prompt+4类D3.js/Plotly适配模板,即刻提升专业度
  • Gemini KYC合规提效实战(2024最新FATF第24号指引适配版):3类高危漏审场景+4套动态阈值配置模板
  • 借助大模型实现多格式文档解析查看