事情是这样的,上个月面了一家做内容平台的公司,技术面到一半,面试官突然问:“你们项目里 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. 查缓存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,每次都穿透到 DB | cacheNullValue()缓存空值 2 分钟 |
| 缓存击穿 | 热点 key 过期,高并发全打到 DB | Redisson 分布式锁 + 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);}这段代码最难的地方有三点:
- 参数替换:AOP 把原方法的
100 个 ID偷偷改成40 个 ID,原方法自己不知道 - 返回值合并:Redis 返回的 60 条 + 新查的 40 条,要按用户传入的 ID 顺序合并成一个 List
- 并发控制: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?因为INCR和SADD必须是原子的。如果不用 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 保证一致性
问题
点赞操作需要同时做两件事:
- 把文章 ID 加入用户的点赞列表(SADD)
- 如果文章缓存存在,把缓存里的 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)endreturn1Lua 脚本在 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 体系的核心优势
- 开发效率:AOP 注解一行代码搞定缓存,不用每个 Service 写重复代码
- 性能:批量 multiGet + Lua 脚本原子操作,把 Redis 性能压榨到极致
- 安全:分布式锁防击穿、空值缓存防穿透、过期分散防雪崩
- 一致性:浏览量用最终一致性(Redis + MySQL),点赞/收藏用双写 + Lua
实战避坑清单
| 坑 | 血泪教训 |
|---|---|
| Key 格式不统一 | 一开始就要用枚举管理,不然后面排查问题想死 |
| 忘了设过期时间 | Redis 是内存数据库,不设过期时间迟早 OOM |
| 缓存穿透 | 查不到的数据也要缓存空值,不然恶意攻击直接打穿 DB |
| 缓存击穿没加锁 | 热点 key 过期那一瞬间,100 个请求同时打到 DB,直接 500 |
| 序列化用 JDK | 换了 JSON 序列化之后,排查问题效率提升 10 倍 |
| Lua 脚本不幂等 | 注意LPUSH会重复添加,每次操作前要考虑是否要先清理 |
| 类型转换异常 | 缓存里之前可能是 String,后来改成了 List,TYPE判断要兼容 |
最后说两句
Redis 本身不难,set/get谁都会写。但真正体现水平的是缓存策略的设计——怎么防击穿、怎么做批量缓存、怎么保持一致性、怎么处理写密集型场景。
我这套方案都是线上项目验证过的,面试的时候能把这几个a点讲清楚,面试官基本不会再追 Redis 的问题了。
有什么问题欢迎评论区交流,看到会回。
如果觉得文章有用,麻烦点个赞,让更多人看到~