资讯动态

黑马点评源码实战:Redis缓存穿透、分布式锁与Feed流落地

发布时间:2026/10/26 7:53:38 来源:尧图企业网站定制
简介资源定位这份资源是基于 Redis 的“黑马点评”项目实战源码包面向正在学习 Redis 缓存、高并发处理与 Spring Boot 整合开发的 Java 开发者。项目模拟在线点评平台的典型业务场景重点展示 Redis 在热点数据缓存、点赞与排行榜、会话维持、发布订阅等功能中的落地方式适合有 Java 基础、希望提升服务端性能优化能力的读者参考。包体信息压缩包共 79 个文件大小约 90KB包含 72 个 Java 源码文件、2 个 XML 配置、2 个 Lua 脚本、1 个 YAML 配置文件与 1 个 SQL 脚本Lua 脚本可用于实现原子性操作SQL 脚本用于初始化点评平台相关数据表。学习热度该资源已有 313 人学习下载。价值感资源目录按 Maven 工程规范组织覆盖项目主代码、测试代码与配置文件便于直接导入 IDE 后结合业务理解 Redis 整合思路也可作为课设、毕业设计或企业级点评类项目初版参考。1. 黑马点评这个项目到底值不值得花两天跑一遍如果你正处在“Redis 命令背得熟、但一进业务就不知道怎么用”的阶段黑马点评是绕不开的那份源码。它表面上是一个仿大众点评的单机项目实际上是把 Redis 最常见的六类真实场景全部串了一遍短信验证码用 String 存店铺查询用缓存扛穿透秒杀用分布式锁防击穿关注 Feed 流用 List 和 ZSet 做推拉点赞排行榜用 ZSet 排序。花两天跑通这份实战源码你能回答的不再是“Redis 快在哪”而是“缓存什么时候失效、锁什么时候会被误删、数据对不上时谁背锅”。本文按“设计动机 → 本地跑通 → 核心代码 → 踩坑 → 体检”的顺序把我实际调这个项目的经验完整写出来。2. Redis 在点评项目里的真实分工5 种数据类型和它们的业务落脚点2.1 从 Redis 命令反推设计动机基础类型不是背出来的很多人在学 Redis 时是把五种类型当“数据结构”背的String 是字符串、List 是列表、Set 是无序集合。但在黑马点评里每种类型的出现都对应一个业务约束。String 承担验证码和登录令牌是因为这两个数据“一个 key 只存一个值”而且都需要 TTL 自动过期List 承担 Feed 流是因为关注信息流的读取顺序是“新在前、旧在后”LPUSH LRANGE天然满足ZSet 承担点赞排行榜是因为业务要求“按点赞时间排序”而 Set 只能保证去重无法排序。这里最反直觉的是 Hash 居然不是主角。很多新手拿到“店铺缓存”第一反应是用 Hash 存字段但源码里用的是 String 存 JSON。原因很好理解商户查询是按 id 整体读取返回给前端时也要整个对象String 存JSON.toJSONString(shop)一次写入一次读出几乎没有字段级修改的需求。Hash 适合“热点字段单独更新”的场景比如评分要频繁 0.1 而店铺名不变黑马点评里没有这种操作所以用 String 更省事。理解这一点很重要因为同样的业务换到别的项目里选型就可能翻转。黑马点评的价值不在于它用了 Redis而在于每个命令都能对应到“为什么是它”的答案。我建议你拉一份数据类型速查表放在手边边看源码边对照效果比先背命令再找项目好得多。2.2 业务场景到命令的映射表与序列化选型把黑马点评的 Redis 使用场景拆开核心映射如下表。这张表建议贴在你 IDE 旁边排查问题时先定位到类型再想命令行为。业务模块数据类型核心命令为什么这样选短信验证码StringSET、GET、DELETE单值存储TTL 自动过期登录令牌StringSET、GET、DELETE随机 token 对应登录用户 JSON店铺缓存StringSET、GET整体读写序列化为 JSON点赞去重SetSADD、SISMEMBER、SCARD天然去重判断是否点过赞点赞排行榜ZSetZADD、ZREVRANGE、ZSCORE按时间/分数排序翻页方便关注 Feed 流ListLPUSH、LRANGE新内容在头部滚动分页取区间序列化选型是这个项目里最容易让新手翻车的地方。源码里如果用默认的RedisTemplateObject, Objectkey 和 value 都会走 JDK 序列化存进 Redis 的 key 会变成\xAC\xED\x00\x05t...一串乱码value 也是二进制肉眼没法排查。我一般直接改用StringRedisTemplate或给 RedisTemplate 显式指定StringRedisSerializer做 key、GenericJackson2JsonRedisSerializer做 value这样redis-cli能看到可读的 key出问题时能用命令直接定位。这个配置在第 5 章会给出完整代码。另一个序列化细节是 LocalDateTime。Jackson 默认序列化 Java 8 时间类型会抛异常需要在 ObjectMapper 里注册JavaTimeModule或者把时间字段转成字符串再塞进 JSON。黑马点评里缓存店铺、缓存登录用户都涉及时间字段漏掉这步会出现“缓存写入失败”或“反序列化出来时间是 null”的隐蔽问题。后面第 5 章的乱码排查会再次提到这个坑。3. 从 zip 到能登录本地跑通黑马点评的完整流程3.1 解压结构与环境清单JDK、MySQL、Redis 版本怎么定拿到“基于 Redis 的黑马点评项目实战源码.zip”第一步不是急着改代码而是先确认三个核心环境JDK、MySQL、Redis。这个项目是 Spring Boot 2.x 技术栈JDK 1.8 或 11 都能跑别直接用 JDK 17 硬扛除非你愿意处理 Spring Boot 2.x 和最新版兼容性的额外麻烦。MySQL 5.7 和 8.0 都行但要注意 8.0 的驱动配置必须带cj前缀时区参数也不能省。Redis 版本建议 6.x 以上因为 6.0 之后 ACL 和更好的内存策略让排查更方便5.0 也能跑但没必要为难自己。解压后的目录一般包含后端 Maven 工程、前端静态资源和一份 SQL 脚本或 db 目录。后端是标准的 Spring Boot 工程依赖里能看到spring-boot-starter-data-redis、mybatis-plus、mysql-connector-java这些关键件。前端通常是 Vue 打包后的 dist 目录配一个 nginx也有部分教程版会给前端源码。你不需要把前端源码重新npm run build一遍直接用 dist 静态文件最快。启动之前先做一个体检mvn -v、java -version、mysql --version、redis-cli ping四条命令各跑一遍任何一条报错都先解决再继续。这个项目的报错 80% 出在环境不一致不是代码问题。提前确认版本能少走一小时弯路。3.2 MySQL 建库与 Redis 服务最容易翻车的前置步骤初始化数据库时我见过最多的失败是把 SQL 文件用 Navicat 手动执行结果因为字符集或外键顺序跑到一半中断。我一般直接在终端用命令行导入干净利落mysql -uroot -p --default-character-setutf8mb4 init.sql这条命令做的事情是把项目自带的初始化脚本导入本地 MySQL。--default-character-setutf8mb4必须显式指定否则中文注释和 emoji 昵称在部分 MySQL 版本下会变成乱码或直接报错。导入完成后用show tables;检查核心业务表至少能看到tb_user、tb_shop、tb_blog、tb_voucher、tb_seckill_voucher这几张缺表说明导入中断需要 drop 库重新导不要增量补。Redis 服务我建议用 Docker 起干净又可控docker run -d --name redis \ -p 6379:6379 \ -v /data/redis:/data \ redis:6.2 \ --appendonly yes-v /data/redis:/data是把容器内的持久化目录挂载到宿主机--appendonly yes开启 AOF防止容器重启后缓存数据全部蒸发。本地学习可以不设密码但 application.yml 里如果把密码写死成 123456Redis 服务端没密码时启动会报ERR Client sent AUTH, but no password is set这一点在后面的避坑章会展开。启动完成后一定先执行一次redis-cli ping看到PONG再进下一步。3.3 启动后端与前端Vue 项目连不上后端的第一步排查后端的 Redis 连接配置在 application.yml 里我建议把 host 显式写成127.0.0.1不要写localhost。原因在第 5 章会细说这里先记住结论。timeout 也不要留默认值写短一点方便快速失败spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 2000mstimeout 设成 2 秒意味着连不上 Redis 时后端快速报错而不是等 60 秒让你以为项目卡死了。这个配置在调试阶段能省下大量等待时间。MySQL 的连接配置同理注意serverTimezoneAsia/Shanghai必须加否则驱动会拿 UTC 时区去解析时间字段导致查询结果差 8 小时。前端启动方式有两种。教程版一般配好 nginx直接把 nginx 指到 dist 目录proxy_pass指到 8080 后端端口。用 nginx 方案时最常见的坑是刷新页面 404因为 Vue 路由是 history 模式需要在location /里加try_files $uri $uri/ /index.html;。如果手头只有前端源码也可以npm run dev起本地开发服务器并在 vite.config 里配 proxy 把/api代理到 8080。前端连不上后端时第一步永远不是改前端代码而是先在浏览器直接访问http://127.0.0.1:8080/api/...看后端通不通。4. 逐个模块看核心代码登录、缓存穿透、分布式锁和 Feed 流的落地写法4.1 短信验证码登录String 和 TTL 的边界验证码登录是黑马点评第一个 Redis 场景代码量不大但把 String 的过期、覆盖、删除三个行为全用到了。核心逻辑分成“发送验证码”和“校验并登录”两步Service public class UserService { private final StringRedisTemplate redis; public void sendCode(String phone) { // 6位随机验证码\u8fd9\u91cc\u7528 Math.random() \u53ea\u4e3a\u793a\u610f\uff0c\u751f\u4ea7\u73af\u5883\u5efa\u8bae\u7528 SecureRandom String code String.valueOf((int) ((Math.random() * 9 1) * 100000)); String key login:code: phone; // 有效期5分钟重复发送会覆盖旧验证码 redis.opsForValue().set(key, code, 5, TimeUnit.MINUTES); } public String login(String phone, String inputCode) { String key login:code: phone; String right redis.opsForValue().get(key); if (right null || !right.equals(inputCode)) { throw new BizException(验证码错误或已过期); } // 一次性凭证校验完立刻删除 redis.delete(key); String token UUID.randomUUID().toString().replace(-, ); String userJson JSON.toJSONString(getUserByPhone(phone)); // 登录状态保持30分钟无操作自动失效 redis.opsForValue().set(login:token: token, userJson, 30, TimeUnit.MINUTES); return token; } }这段代码有两个关键设计。第一key 都用业务前缀加冒号login:code:和login:token:区分两类数据也方便redis-cli keys login:*一条命令看全部登录相关缓存。第二验证码校验成功后先delete再创建 token避免同一个验证码被重放攻击。顺序上有讲究——如果先发 token 再删验证码极端情况下二次请求还能用旧验证码再换一个 token等于多了一个未注销的会话。TTL 是这套逻辑的兜底验证码 5 分钟过期token 30 分钟过期都不需要额外的定时任务清理Redis 自己处理。从参数角度看验证码有效期 5 分钟对大多数短信通道的到达时间都够太短会导致用户还没收到就过期太长则增加爆破面。token 有效期 30 分钟适合“短期免登录”如果这个项目要改成长会话可以把 TTL 拉到 7 天并配合redisTemplate.expire()做滑动续期——每次请求接口时重置一次过期时间。但在黑马点评的教学场景里固定 TTL 更直观。4.2 店铺缓存与逻辑过期缓存穿透之后谁来兜底店铺查询是黑马点评里 Redis 缓存的核心场景课程里给的是“逻辑过期”方案不是简单的先查缓存再查库。逻辑过期的思路是缓存里不只存业务 JSON还带一个过期时间字段查询时先比较这个时间。没过期直接返回过期了也不立刻去查库而是先返回旧数据同时让一个线程去后台重建缓存。// 写入缓存的包装结构 class RedisData { LocalDateTime expireTime; Object data; } public Shop queryShop(Long id) { String key cache:shop: id; String json redis.opsForValue().get(key); if (StrUtil.isBlank(json)) { // 双检可能另一个线程刚重建完这里回源DB一次 return queryFromDbAndCache(id); } RedisData redisData JSON.parseObject(json, RedisData.class); if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 未过期直接返回 return (Shop) redisData.getData(); } // 逻辑过期尝试拿锁拿不到就返回旧数据保证可用 boolean locked tryLock(lock:shop: id); if (locked) { try { Shop shop queryFromDbAndCache(id); // 重建缓存 return shop; } finally { unlock(lock:shop: id); } } return (Shop) redisData.getData(); // 拿不到锁先返回旧值 }逻辑过期方案和“缓存空值”方案的区别是本质性的。缓存空值解决的是缓存穿透——DB 里没有这个 idRedis 里也查不到请求打到 DB 上所以把“查不到”的结果也缓存成空对象给一个 5 分钟 TTL。逻辑过期解决的是缓存击穿——热点 key 过期的一瞬间并发请求全部打到 DB所以让缓存永远有值值里带时间戳过期后由一个线程去重建。参数上逻辑过期时间我建议设 30 分钟重建线程执行时间要远小于这个值。如果重建一次要 2 秒锁的 TTL 必须大于 2 秒否则锁提前释放会放进来第二个重建线程逻辑过期方案就退化成“多次击穿”。重建失败时的兜底是保留旧缓存继续返回所以这个方案牺牲了一点数据新鲜度换来了极端情况下的可用性对店铺信息这种“晚几分钟更新无感知”的业务是划算的。4.3 分布式锁Setnx 加锁和 Lua 解锁的完整写法黑马点评里分布式锁出现在两个地方逻辑过期重建缓存以及秒杀扣减库存。这里的锁不用 Redisson 的RLock而是基于 Redis 的SETNX手写教程意图是让你理解锁的本质。但手写锁最容易翻车的是不是加锁而是解锁——直接DEL key会把别的线程的锁删掉。正确写法是加锁时带一个唯一 value解锁时先用 Lua 脚本比对再删public boolean tryLock(String key, String value, long timeout, TimeUnit unit) { // 关键setIfAbsent 过期时间原子操作 return redis.opsForValue().setIfAbsent(key, value, timeout, unit); } public void unlock(String key, String value) { // Lua脚本保证“比对value 删除”是原子操作 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redis.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(key), value); }setIfAbsent(key, value, timeout, unit)是原子的等价于SET key value NX EX不存在“先 setnx 再 expire 两步走导致中途宕机锁永不过期”的风险。value 必须传一个每次加锁都不同的值我一般用UUID.randomUUID()这样 unlock 时才能确认“这把锁是我加的”。如果不比对 value 直接 DEL线程 A 执行超时锁过期线程 B 加锁成功A 执行完一个 DEL 把 B 的锁删了B 的临界区就失去保护库存就会超卖。锁的 TTL 参数是这门手写锁的艺术。设 10 秒听起来合理但如果业务执行要 15 秒锁 10 秒后自动过期第二个线程进来了等于锁白加。我一般按“预估执行耗时的 3 倍”做最小可用值配合监控调优。真正生产环境还是建议上 Redisson 这种带看门狗续期的实现但黑马点评存在的意义就是让你先理解超时时间为什么是个问题。参数配置上锁 key 也要带业务维度lock:shop:{id}和lock:stock:{id}要分开否则“抢店铺锁”和“抢库存锁”会互相阻塞。4.4 Feed 流与点赞排行榜List 当消息队列、ZSet 当计数器的参数细节Feed 流是黑马点评里最具“工程味”的部分。核心业务是用户发布博客后把动态推送给粉丝。实现上有两种流派推模式和拉模式。推模式是发布时直接往每个粉丝的收件箱 List 里写一条内容读的时候LRANGE取一页拉模式是读的时候去查所有关注对象的动态再聚合Redis 主要用来加速。黑马点评用的是推模式因为关注关系是有限集合写扩散可以接受读的时候延迟最低// 发布博客后推送 public void pushFeed(Long blogId, Long authorId) { ListLong fans getFans(authorId); // 查询粉丝id列表 for (Long fanId : fans) { String key feed: fanId; // LEFT_PUSH: 新内容永远在列表头部 redis.opsForList().leftPush(key, String.valueOf(blogId)); } } // 读者拉取一页Feed public ListLong queryFeed(Long userId, int offset) { String key feed: userId; // 取从offset开始的5条滚动分页不用游标 ListString ids redis.opsForList().range(key, offset, offset 4); return ids.stream().map(Long::valueOf).collect(Collectors.toList()); }这里有几个容易被忽略的参数决策。leftPush的方向决定了新内容在列表左端range 0 4拿的是最新的 5 条如果误用rightPush列表变成先进先出新内容在右端分页逻辑全部要反过来。成员统一用String.valueOf(blogId)而不是直接放 Long是为了避免 RedisTemplate 的序列化器把 Long 和 String 当成两种 member导致同一个 id 出现两条数据。滚动分页的 offset 由前端传每页 5 条是我调试时比较舒服的默认值压测后可以按业务Hero调成 10 或 20。点赞排行榜用 ZSet 是另一个亮点。Set 能去重但无法排序ZSet 在去重基础上给每个成员一个 score业务上把“点赞时间”当 score就能实现“按点赞先后排序”的榜单// 点赞/取消点赞 public void likeBlog(Long blogId, Long userId) { String key blog:liked: blogId; Double score redis.opsForZSet().score(key, userId.toString()); if (score null) { // 未点赞score存当前时间戳单位毫秒 redis.opsForZSet().add(key, userId.toString(), System.currentTimeMillis()); } else { // 已点赞取消 redis.opsForZSet().remove(key, userId.toString()); } }score 用时间戳是为了两点一是能排序二是能知道“什么时候点的赞”。如果你把 score 直接存 1 表示“点过赞”那就退化成了 Set无法回答“谁点赞最早”的问题。这里还有一个精度细节——System.currentTimeMillis()是 long 类型转成 double 后在毫秒量级完全不会有精度损失放心用。只有当时间戳大到超过 2^53 才可能误差那得等几万年。5. 避坑指南黑马点评跑不动或数据不对先查这 5 个地方5.1 现象启动后接口卡死报 RedisCommandTimeoutException日志里出现Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException时第一反应不要改代码先看连接地址。这个报错在本地最常见的诱因是 application.yml 里写了localhost而本机 Redis 监听的是 IPv4 的127.0.0.1。Java 解析 localhost 时可能优先解析到 IPv6 的::1Lettuce 拿着 IPv6 地址去连 IPv4 的 RedisSocket 层不会立刻拒绝而是等到 timeout 才报错。解决方式是把 host 显式改成127.0.0.1或在 Redis 配置里把bind从127.0.0.1改成0.0.0.0。还有一个加速排查的技巧把 timeout 从默认值调成2000ms让问题“快速失败”别等 60 秒。5.2 现象登录后 Redis 里全是\xAC\xED开头的乱码 key用 Redis Desktop Manager 或redis-cli keys *看到 key 是\xAC\xED\x00\x05t...login:token:xxx这种不可读字符串原因是 RedisTemplate 默认用的 JdkSerializationRedisSerializer。这个序列化器会把 key 也做 Java 对象序列化于是每个 key 前面都带了一长串二进制头。表象是“看着乱”实际危害是keys login:*这种通配符匹配失效用习惯了很难 Debug。解决方式是在配置类里显式替换序列化器key 和 hashKey 用 StringRedisSerializervalue 用 GenericJackson2JsonRedisSerializer。替换后切记重启服务并清空旧缓存否则新代码读旧二进制数据会反序列化失败。5.3 现象凌晨零点一过DB 压力飙升接口突然变慢一个典型的缓存雪崩场景店铺缓存统一设置 12 小时过期23:59 写入11:59 到期第二天中午 12 点整所有热点 key 同时失效并发请求全部穿透到 MySQL数据库 CPU 瞬间打满。这不是玄学是固定过期时间必然导致的结果。解决方式有三个层次最常做的是“过期时间加随机值”基础 TTL 设 30 分钟再叠加一个 60 到 120 秒的随机数让 key 的失效时间均匀分散第二步是第 4 章讲的逻辑过期方案缓存永远有值第三步才是考虑 Redis 集群预热本地学习做到前两层就够了。排查时先看监控里 MySQL QPS 是否有规律的尖峰再抽查 Redis 中 key 的 TTL 是否大量相同。5.4 现象加了分布式锁库存还是出现超卖锁没起作用的常见原因不是加锁代码写错而是解锁逻辑把别人的锁删了。线程 A 加锁后因为 GC 或网络抖动执行超过锁 TTL锁自动过期线程 B 加锁成功A 执行完后直接delete key把 B 的锁释放B 和 C 同时进入临界区超卖就发生了。解决方案就是 4.3 节里那段 Lua 脚本——解锁前先比对 value 是否等于自己加的 UUID相等才删除。注意这里必须用 Lua 脚本保证“比对 删除”两步的原子性不能用先get再del的 Java 代码因为两条命令之间可能有另一个线程介入。排查时在日志里打印每次加锁和解锁的 value定位到删错锁的线程去看它的执行耗时是否超 TTL。5.5 现象Feed 流刷不出新内容或点赞列表里同一个人出现两次ZSet 和 List 的数据对不上十有八九是 member 类型不一致导致的。比如点赞时存的是user.getId()返回的 Long而查询榜单时转成了 StringRedis 会认为这是两个不同的 member同一个用户就出现了两次。解决方式是统一成员类型进 Redis 之前全部String.valueOf转一道。还有 Feed 流刷不出新内容常发生在分页 offset 计算错误。如果用range(key, offset, offset 4)做滚动分页前端需要每次把上次返回的最后一条记录的下标传回来而不是传“第几页”因为 List 左端不断有新数据插入按页码取会出现数据重复或跳过。排查思路是先LRANGE key 0 -1看整体数据对不对再验证前端传参逻辑。6. 从跑通到能扛住用排查命令和压测给项目做体检6.1 上线前必跑慢查询日志和 bigkey 扫描两条命令跑通黑马点评只是起点项目要“能用”还得做一轮 Redis 体检。我一般先开慢查询日志统计 Redis 执行超过 10 毫秒的命令redis-cli CONFIG SET slowlog-log-slower-than 10000 redis-cli SLOWLOG GET 1010000是微秒等于 10 毫秒。对于本地缓存来说超过 10 毫秒的命令就值得警惕一般是KEYS、大LRANGE或复杂ZREVRANGE。SLOWLOG 只看最近 N 条适合在压测后抓典型。另一条是 bigkey 扫描redis-cli --bigkeys输出会列出每个类型里最大的 key。黑马点评里常见的 bigkey 是 Feed 流 List粉丝多的大 V 发一条动态feed:{userId}列表会被推到每个粉丝的收件箱单个 list 可能膨胀到几十万条。如果发现某类 key 过大要么限制 feed 长度要么改成 ZSet 分页而不是靠 Redis 硬扛。6.2 用 JMeter 做一轮压测怎么判断缓存真的生效了压测对象选“查询店铺详情”最合适因为它是纯读缓存场景最容易看出效果。JMeter 里建一个线程组50 个线程、Ramp-Up 5 秒、循环 10 次请求GET /shop/{id}id 固定用同一个热点值这样能压出缓存击穿时的真实表现。跑完后看聚合报告的 Avg 和 TP99再对比压测前后 Redis 的命中率redis-cli INFO stats | grep keyspace_hits判断标准不是“平均响应时间从 50ms 降到 5ms”而是看keyspace_hits和keyspace_misses的比例。如果 miss 率超过 20%说明缓存设计有大量穿透先查热点 key 的过期时间分布。我在调这个项目时养成的习惯是每次改完缓存策略先用SLOWLOG RESET清空慢查询再压一遍最后对比三组数据——TP99、miss 率、DB 连接数。三组都良性才敢说这个方案能扛住。这里的每一步都可以直接用命令复现不需要额外平台这也是 Redis 项目最好的一点——所有问题都能在redis-cli里找到答案。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑