
事情是这样的上个月面了一家做内容平台的公司技术面到一半面试官突然问“你们项目里 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 # 用户模块登录信息存 RedisRedis 具体干啥了一句话总结场景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. 查缓存ObjectcacheredisTemplate.opsForValue().get(article:detail:id);if(cache!null)return(Article)cache;// 2. 缓存没有查库ArticlearticlegetById(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)publicinterfaceRedisCacheDataRequire{RedisKeyskey();// 用哪个 key 模板RedisLockKeyslockKey()defaultRedisLockKeys.DEFAULT;// 防击穿的锁booleanneedFormat();// key 是否需要拼参数booleancacheNull()defaulttrue;// 查不到时是否缓存空值ResultTyperesultType()defaultResultType.DEFAULT;// 结果类型}Service 里用起来就一行注解的事RedisCacheDataRequire(keyRedisKeys.ARTICLE_DETAIL,lockKeyRedisLockKeys.LOCK_ARTICLE_DETAIL,needFormattrue,resultTypeResultType.MAP)publicMapString,ObjectgetArticleBaseInfoById(RedisCacheParamLongarticleId){// 这里只管查数据库缓存的事 AOP 自动搞定ArticlearticlegetById(articleId);returnBeanUtil.beanToMap(article);}就这一下所有 Service 方法再也不用手动写缓存代码了。四、最难啃的骨头AOP 缓存核心代码逐行拆解这块才是整个项目 Redis 的精髓面试高频考点也是这里。直接上核心代码Around(annotation(redisCacheDataRequire))publicObjectaround(ProceedingJoinPointpoint,RedisCacheDataRequireredisCacheDataRequire)throwsThrowable{// 第1步拿到 key 模板StringkeyredisCacheDataRequire.key().getKey();// article:detail:%sStringlockKeyredisCacheDataRequire.lockKey().getKey();// lock key// 第2步needFormattrue → 拼参数到 key 里if(redisCacheDataRequire.needFormat()){ObjectparamgetCacheParamValue(point);// 找 RedisCacheParam 的参数keyString.format(key,param);// article:detail:456lockKeyString.format(lockKey,param);}// 第3步先查 RedisBooleanhasKeyredisCache.hasKey(key);if(!Boolean.TRUE.equals(hasKey)){// Redis 没有这个 key booleanneedLockredisCacheDataRequire.lockKey()!RedisLockKeys.DEFAULT;if(needLock){// 场景高并发下 100 人同时查某篇文章// 不加锁 → 100 人全部查库 → DB 瞬间被打爆RLocklockredissonClient.getLock(lockKey);try{booleanlockSuccesslock.tryLock(3,10,TimeUnit.SECONDS);if(lockSuccess){// ★ 拿到锁后二次检查 RedisDouble Check// 为什么你等锁的功夫第一个人已经查完库写进 Redis 了hasKeyredisCache.hasKey(key);if(Boolean.TRUE.equals(hasKey)){redisCache.expire(key,expire);// 续期returngetResultByType(key,redisCacheDataRequire.resultType());}// 真的没有 → 查数据库Objectresultpoint.proceed();// ★ 缓存穿透防护数据库都没有 → 缓存空值if(resultnull){redisCache.cacheNullValue(key);// 存 NULL 标记2 分钟过期returnnull;}// 按类型存 RediscacheResByType(key,result,expire,redisCacheDataRequire.resultType());returnresult;}else{thrownewRuntimeException(系统繁忙请稍后重试);}}finally{if(lock!nulllock.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(ListLong ids)传入 100 个文章 ID其中 60 个已经在 Redis 里了只需要查库补 40 个。原始的写法是遍历查 Redis一个个判断有没有——这就浪费了 Redis 的批量能力。批量缓存的核心思路Around(annotation(redisCacheDataListRequire))publicObjectaround(ProceedingJoinPointpoint,RedisCacheDataListRequirerequire){// 第1步所有 ID 拼成完整 keyListStringallKeysparamList.stream().map(id-String.format(keyPrefix,id)).toList();// 第2步一次性 multiGet 查 RedisListObjectredisResredisCache.getRedisTemplate().opsForValue().multiGet(allKeys);// redisRes [data1, null, data3, null, data5, ...]// null 缓存没命中// 第3步筛选出没命中的 IDListObjectmissIds没命中的那些;// 第4步全部命中 → 直接返回if(missIds.isEmpty())returnredisRes;// 第5步修改原方法参数只查没命中的数据// 原方法要查 100 篇Redis 命中了 60 篇// → 把参数改成只传 40 个没命中的 ID 去查库args[参数索引]missIds;Objectresultpoint.proceed(args);// 只查 40 篇// 第6步新查的写回 RedisredisCache.multiSetWithExpire(keyPrefix,(Collection)result,expire);// 第7步合并 Redis 数据 新查的数据按原始顺序返回returnmergeResult(redisRes,result,paramList);}这段代码最难的地方有三点参数替换AOP 把原方法的100 个 ID偷偷改成40 个 ID原方法自己不知道返回值合并Redis 返回的 60 条 新查的 40 条要按用户传入的 ID 顺序合并成一个 List并发控制100 个请求同时来都发现 Redis 少了几个不能 100 个都去查库。用了 Redisson 的getMultiLock对每个没命中的 key 分别加锁六、浏览量系统千万级 PV 的终极方案问题有多严重浏览量是写密集型操作。一篇文章火了每秒几百上千人看每次UPDATE article SET view_count view_count 1MySQL 根本扛不住。但浏览量有一个特点不需要强一致性。用户看到浏览量是 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 文章 IDredis.call(INCR,KEYS[1])-- 浏览量 1原子操作redis.call(SADD,KEYS[2],ARGV[1])-- 文章 ID 入桶return1为什么用 Lua因为INCR和SADD必须是原子的。如果不用 Lua线程AINCR view:1231 线程BINCR view:1231 线程ASADD bucket 123 线程BSADD bucket 123虽然结果可能没错但如果 INCR 执行了但 SADD 没执行比如 Redis 挂了桶里没有这篇文章 ID定时任务就不会回写它的浏览量——数据丢了。定时回写代码Scheduled(fixedRate300000)// 每 5 分钟publicvoidarticleViewCountWriteBack(){// 1. 从桶里拿所有被浏览过的文章 IDSetLongidsredisCache.getCacheSet(ARTICLE_VIEW_BUCKET);if(ids.isEmpty())return;// 2. 批量拿增量浏览量ListStringkeysids.stream().map(id-ARTICLE_VIEW.getKey(id)).toList();ListIntegerviewsredisTemplate.opsForValue().multiGet(keys);// 3. 组装 Map文章ID, 增量MapLong,IntegerviewMap...;// 4. 批量 UPDATE MySQLgetBaseMapper().writeBackViewCount(viewMap);// 5. 清空桶已回写的redisCacheArticle.articleViewBatchClear(keys);}七、点赞/收藏Redis MySQL 双写Lua 保证一致性问题点赞操作需要同时做两件事把文章 ID 加入用户的点赞列表SADD如果文章缓存存在把缓存里的 likeCount 1HINCRBY如果不用 Lua高并发下可能出现线程ASADD 用户点赞列表成功 线程B也 SADD也成功 线程AHINCRBY 文章缓存1 但只加了一次 结果点赞列表两条记录缓存只加了一次 → 数据不一致Lua 脚本解决-- articleLikeAdd.lua-- KEYS[1] article:userLike:789用户点赞列表-- KEYS[2] article:detail:456文章缓存-- ARGV[1] 456文章ID-- 先清理异常类型之前可能是 string 类型localkeyTyperedis.call(TYPE,KEYS[1])iftype(keyType)tablethenkeyTypekeyType[ok]endifkeyTypestringthenredis.call(DEL,KEYS[1])end-- 把文章 ID 加入用户点赞列表用 List 存redis.call(LPUSH,KEYS[1],ARGV[1])-- 如果文章缓存存在likeCount 1localdetailExistsredis.call(EXISTS,KEYS[2])ifdetailExists1thenredis.call(HINCRBY,KEYS[2],likeCount,1)endreturn1Lua 脚本在 Redis 里是原子执行的整个脚本执行过程中不会被其他命令打断所以赞列表和缓存计数永远一致。我们项目里一共用了11 个 Lua 脚本覆盖浏览量、点赞、收藏、分享、批量操作等场景articleViewCountAdd.lua → 浏览量 1ID 入桶 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 → 验证用户名密码 ↓ 生成 JWT1小时过期 生成 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){longexpireloginUser.getExpire();// Redis 里存的过期时间戳longnowSystem.currentTimeMillis();if(expire-now0){// Redis 已经过期了 → 删除 → 抛异常redisCache.deleteObject(key);thrownewBusinessException(token已过期);}if(expire-nowtokenRenewal*60*1000L){// 还剩不到 20 分钟 → 自动续期loginUser.setExpire(nowloginTokenExpire*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{BeanpublicRedisTemplateObject,ObjectredisTemplate(RedisConnectionFactoryfactory){RedisTemplateObject,ObjecttemplatenewRedisTemplate();template.setConnectionFactory(factory);// 使用 FastJson 序列化FastJsonRedisSerializerObjectserializernewFastJsonRedisSerializer(Object.class);// key 用 StringRedisSerializertemplate.setKeySerializer(newStringRedisSerializer());// value 用 FastJsontemplate.setValueSerializer(serializer);// Hash 的 value 也用 FastJsontemplate.setHashValueSerializer(serializer);template.afterPropertiesSet();returntemplate;}}改完以后 Redis 里存的就是人类能看懂的 JSON 了排查问题直接用 Redis Desktop 看一眼就知道了。十、总结 避坑经验这套 Redis 体系的核心优势开发效率AOP 注解一行代码搞定缓存不用每个 Service 写重复代码性能批量 multiGet Lua 脚本原子操作把 Redis 性能压榨到极致安全分布式锁防击穿、空值缓存防穿透、过期分散防雪崩一致性浏览量用最终一致性Redis MySQL点赞/收藏用双写 Lua实战避坑清单坑血泪教训Key 格式不统一一开始就要用枚举管理不然后面排查问题想死忘了设过期时间Redis 是内存数据库不设过期时间迟早 OOM缓存穿透查不到的数据也要缓存空值不然恶意攻击直接打穿 DB缓存击穿没加锁热点 key 过期那一瞬间100 个请求同时打到 DB直接 500序列化用 JDK换了 JSON 序列化之后排查问题效率提升 10 倍Lua 脚本不幂等注意LPUSH会重复添加每次操作前要考虑是否要先清理类型转换异常缓存里之前可能是 String后来改成了 ListTYPE判断要兼容最后说两句Redis 本身不难set/get谁都会写。但真正体现水平的是缓存策略的设计——怎么防击穿、怎么做批量缓存、怎么保持一致性、怎么处理写密集型场景。我这套方案都是线上项目验证过的面试的时候能把这几个a点讲清楚面试官基本不会再追 Redis 的问题了。有什么问题欢迎评论区交流看到会回。如果觉得文章有用麻烦点个赞让更多人看到