☰
从零手撸黑马点评:Redis实战项目中那些缓存、并发与分布式锁的坑
2026/9/30 4:39:13 网站建设 项目流程

黑马点评这个项目,我前前后后跟了两遍,第一遍开倍速看教程,第二遍关掉视频自己从零撸。两遍下来最直接的感受是:它不是一个教你写 CRUD 的教学项目,而是一套把 Redis 塞进真实业务场景里的实战样板间。商户查询、点赞关注、优惠券秒杀、附近店铺,看似都是日常功能,但每个模块背后都藏着一两个特别值得展开的并发或缓存问题。对于想进阶 Spring Boot + Redis 的 Java 开发者来说,黑马点评几乎可以作为练手首选。

这篇总结我不想按目录再复述一遍知识点,而是把我在实战中真正卡过壳、回过味来的地方拆开聊。你会发现很多设计不是拍脑袋想出来的,而是被并发场景逼出来的。能看懂,和能在面试里讲清楚,是两码事;能讲清楚,和能在自己的项目里用对,又是另一码事。

1. 项目整体链路与难点分布

1.1 业务地图:它到底在做什么

黑马点评的业务形态很像大众点评的轻量版,核心用户是本地生活消费者。整体可以拆成几块:用户登录与 Session 管理、商户查询与缓存、优惠券秒杀、探店笔记的点赞关注、Feed 流推送、附近商铺 GEO 检索,以及 UV 统计这类辅助功能。

这些模块单拎出来都不复杂,但组合在一起就有意思了。比如登录不只是登录,它关系到后续所有接口如何识别用户;商户查询不只是查一张表,它要把热点数据的访问压力从数据库剥离;秒杀也不只是写个下单接口,它同时涉及库存、用户、订单三张表的事务一致性。

我在做第一遍的时候犯过一个错:一上来就去读代码,结果被各种 Redis 的 key 前缀和缓存策略绕晕。后面我换了个方式,先把每个模块的数据流转画出来,从请求进来到 Redis 怎么用、MySQL 什么时候查、缓存什么时候更新,全部用箭头标清楚,再回头看代码就顺畅得多。这个习惯也是我想推荐给所有正在刷实战项目的人的。

1.2 为什么这个项目的实战含量高

黑马点评最值得夸的一点,是它把“理论上的高并发问题”做成了“代码里的具体选择”。

举个例子,缓存穿透、缓存击穿、缓存雪崩,这三个词很多人在八股文里背得滚瓜烂熟,但真到了写代码的时候,往往不知道在哪个方法里加判断。黑马点评会让你在查询店铺接口里做缓存穿透处理,在热点店铺缓存过期时做互斥锁或逻辑过期处理,在批量缓存时做 TTL 随机化。这些问题不是一次性抛给你的,而是跟着业务自然长出来的。

另外,这个项目还覆盖了分布式锁的一个完整演进:从最原始的setnx,到加随机值校验,再到引入 Redisson 看门狗自动续期。每一步都是因为前一步有问题才出现的,这种演进逻辑比背十个分布式锁对比表格有用得多。跟着走一遍,你会记住为什么 Redis 锁需要重入、为什么释放锁要比较线程标识、看门狗到底在防什么。

1.3 环境准备与基础要求

我是用 Spring Boot 2.7 + MySQL 8 + Redis 6 跑的,JDK 用的 1.8,这套组合对新手最友好。如果你已经习惯 JDK 17 或 Spring Boot 3,问题也不大,核心逻辑基本不变,主要注意javax和jakarta命名空间差异。

动手之前,建议准备好这几个工具:一个 Redis 可视化客户端,比如 Another Redis Desktop Manager 或者 RedisInsight;一个接口调试工具,Postman 够用;再就是 MySQL 客户端。实际上并不需要 Docker,Windows/Mac 本地装 Redis 也很快,顶多就是调一下 Redis 配置文件的requirepass和 Windows 服务注册。

如果你打算把项目当作面试项目写进简历,建议准备一台能复现 Redis 集群的服务器,哪怕只是两台虚拟机,也能帮你理解哨兵和分片的区别。如果只是学思路,单机 Redis 完全够用。

2. 登录与用户状态的 Redis 化

2.1 为什么要把 Session 换掉

黑马点评的第一课就把 Session 方案否掉了,理由是真实分布式环境下,用户请求会被负载均衡打到不同 Tomcat 实例,Session 默认存在各自的 JVM 内存里,互相看不到,用户可能刚登录成功刷新一下又变成未登录。

用 Redis 存登录状态,本质是把会话数据从“进程内”搬到“独立存储”。这个思路对熟悉传统 Java Web 开发的人尤其需要适应。你在黑马点评里不会看到HttpSession,取而代之的是自己生成 token,然后以login:token:{uuid}为 key 把用户信息存进 Redis。

这里有一个容易忽略的细节:Redis 里存的不是整个用户对象,而是脱敏后的UserDTO,只保留 id、昵称、头像这些前端需要展示的字段。原因很直接:User表里有密码,存 Redis 属于内存和安全的双重浪费。这个取舍面试常问,答案就这么简单。

2.2 验证码、用户信息与拦截器三件套

完整登录流程大概是三步。第一步,发送验证码接口接收手机号,生成六位随机码,以login:code:{phone}为 key 存入 Redis,TTL 设 5 分钟,然后把验证码返回给前端方便调试。第二步,登录接口校验验证码,比对通过后随机生成 token,把UserDTO转成 JSON 写入login:token:{token},TTL 设 30 分钟。第三步,前端把 token 放在请求头里,后续所有接口通过拦截器识别当前用户。

拦截器这块有意思,项目里用了两个拦截器。第一个拦截所有请求,只要请求头带 token,就去 Redis 查用户,查到就把用户保存到 ThreadLocal,同时刷新 token 的过期时间。第二个拦截器才真正做“必须登录”的校验,如果 ThreadLocal 里没有用户,直接返回 401。为什么要拆两个?因为有些接口游客也能访问,比如查看店铺、浏览笔记,不能因为没登录就把整条请求拦死,但又要让已登录用户保持在线状态,所以把“可选的刷新”和“强制的校验”分开做。

2.3 我在登录模块踩过的坑

第一个坑是序列化。项目如果用默认的JdkSerializationRedisSerializer,Redis 里会出现一串乱码前缀,肉眼完全没法排查。后来换成StringRedisTemplate,key 和 value 都自己手动转 JSON,虽然代码啰嗦一点,但至少能在 Redis 里看清存的是什么。

第二个坑是Long和LocalDateTime的 JSON 序列化。用户 id 在数据库里是BIGINT,如果直接用默认 Jackson 序列化成 JSON,前端拿到的大整数可能丢精度。实战里稳妥的办法是在 DTO 里把 id 序列化为字符串,或者全局配置ToStringSerializer。LocalDateTime也需要注册JavaTimeModule并指定格式化,否则 Redis 里存的是数组结构,反序列化直接报错。

第三个坑是 token 过期时间。一开始我把 TTL 放在登录时写死 30 分钟,结果用户连续操作超过 30 分钟就被踢下线。后面按双拦截器的思路改成“每次请求都刷新”,才符合移动端用户的直觉。如果你做的是公众号 H5 或者 App,可以适当延长到 7 天,但要注意安全问题,最好配合 token 不透明化、服务端可吊销等手段。

3. 商户查询缓存与三大经典问题

3.1 缓存更新策略:先更新 DB 还是先删缓存

商户查询是黑马点评最典型的读多写少场景,所以项目为shop表加了一层 Redis 缓存,key 是cache:shop:{id},value 是店铺 JSON。

首次查询时缓存未命中,查数据库后回填 Redis,并设置一个合理 TTL。后续查询直接走 Redis,QPS 立刻提上来。这里的关键问题在于:后台修改店铺信息后,缓存怎么更新?

项目采取的是“先更新数据库,再删除缓存”的 Cache Aside 策略,同时保留 TTL 兜底。为什么不先删缓存再更新数据库?因为这样会有一段空窗期,旧请求查到旧数据后可能重新把旧值写进缓存,而数据库已经改了,缓存和库就对不上了。反过来操作,删除缓存失败的概率是存在的,但由于有 TTL,最终缓存还是会过期,对一致性要求不高的场景完全够用。

注意:如果你在真实项目里对缓存一致性要求极高,做法是把删除缓存的操作放进可靠消息或者本地消息表里,保证失败可以重试,而不是盲目相信“删了就删了”。

3.2 缓存穿透的两种解法

缓存穿透说的是查询一个根本不存在的数据,比如 id 为负数或者一个很大的不存在的店铺 id。这个请求既打不到缓存,也查不到数据库,但如果恶意刷接口,每个请求都会穿透到 MySQL,可能直接把库打崩。

项目里的第一道防线是参数校验,id 小于等于 0 直接返回错误,连 Redis 都不查。第二道防线是缓存空值:如果查库发现店铺不存在,也往 Redis 写一个空值,TTL 设置短一些,比如 2 到 5 分钟。这样同一个不存在的 id 在短时间内不会再打到数据库。

我在自己写的时候额外加了一层布隆过滤器预热,把所有有效的店铺 id 加入布隆,请求先判断 id 是否可能存在。布隆的好处是空间占用极小、判断速度飞快,坏处是有误判率,而且更新不及时的话会把新店铺误伤,需要定期重建。对于黑马点评这个体量,空值缓存已经完全够了,面试时把布隆作为扩展方案讲出来反而更显思考深度。

3.3 缓存击穿:互斥锁与逻辑过期怎么选

缓存击穿针对的是单个热点 key,在缓存过期的一瞬间,大量请求同时发现缓存未命中,全部涌向数据库。

黑马点评给了两种方案。一种是互斥锁,查缓存未命中后先尝试获取 Redis 锁,拿到锁的线程查库并重建缓存,其他线程 sleep 一小段时间后重试。另一种是逻辑过期,缓存里存的不再是裸数据,而是一个带过期时间的包装对象,查询时发现逻辑过期,先返回旧数据,再开一个线程去后台重建缓存,重建期间其他请求都拿到过期但不至于打崩库的数据。

我个人的建议是:数据一致性要求高、热点 key 的并发量不算极端时,用互斥锁;首页大屏、秒杀详情这种“宁可看旧数据也不允许数据库被打崩”的场景,用逻辑过期。两种方案都要配合一个细节:重建缓存时要用分布式锁,防止多个后台线程同时重建。

3.4 缓存雪崩的预防

雪崩和击穿不一样,它说的是大量 key 在同一时间段集中过期,请求全部落到数据库。最简单的解法就是让过期时间错开,比如给 TTL 加一个随机值,300 + random.nextInt(300)秒,让 key 过期时间呈现均匀分布。

我在项目里还会做两个事情:一是上线前提前把热点数据手动加载到 Redis,而不是等着黑马点评触发查询再回填;二是把热点店铺的 TTL 设成长期有效,后台更新店铺时才主动刷新缓存。前者是缓存预热,后者是主动失效。只要不是全宕机,基本不会出现雪崩级问题。

4. 秒杀下单的并发控制

4.1 超卖问题的根子

优惠券秒杀是黑马点评里最有含金量的模块。它的核心矛盾是:一个库存有限的券,在极短时间内被大量用户抢购,怎么保证库存不超卖、用户不重复下单。

最直观的做法是“先查库存,够就减一,再创建订单”,但这个流程在并发下必然出问题。两个请求同时查到库存为 1,都认为可以买,都去减库存,最终库存可能变成 -1。

项目给出的解法是乐观锁,把扣减库存的操作放到更新语句里,用条件stock > 0保证只有库存还有余量时才更新成功:

UPDATE seckill_voucher SET stock = stock - 1 WHERE voucher_id = ? AND stock > 0

这条 SQL 的影响行数如果等于 1,说明扣减成功,否则说明库存已经不足。这个方案比悲观锁性能好,因为它没有让线程排队等待锁,而是依赖数据库的行锁在更新时互斥。秒杀场景通常并发集中在同一个商品上,一行更新既解决了原子性,也不存在死锁风险。

4.2 一人一单与事务失效

库存扣完还不够,还要保证同一个用户只能下一单。项目里用了两个手段:业务上先查订单表有没有该用户对该优惠券的订单;数据库层面给user_id和voucher_id建唯一索引兜底。

真正写代码的时候,比较容易踩的是 Spring 事务自调用问题。秒杀下单这个操作需要事务包裹,但如果你在控制器里直接调用 Service 的秒杀方法,而方法内部又自己调用自己的另一个@Transactional方法,事务是不生效的。原因很简单:Spring 事务基于 AOP 代理,自调用绕过了代理对象。

解决方案是让 Service 注入自己的代理,或者使用AopContext.currentProxy(),确保通过代理调用事务方法。我在实战中把锁和事务的关系也捋了一下,锁的目的是防止并发进入,事务的目的是保证多个 SQL 要么全成功要么全失败,两者缺一不可。锁要在事务方法外层的入口执行,否则锁释放了事务可能还没提交,并发请求照样能读到旧数据。

4.3 分布式锁:从 setnx 到 Redisson

单机秒杀可以用 JVM 的synchronized或Lock,但到了多实例部署,JVM 锁就只能管住自己这台机器。黑马点评在这里引出了分布式锁。

最粗粒度的方案是 RedisSET key value NX EX timeout,拿到锁的线程执行业务,最后删除锁。但这里有两个细节:第一,删除锁之前要判断 value 是不是自己设置的,否则当前线程的锁过期后,另一个线程拿到了新锁,前一个线程再去删除就会误删别人的锁;第二,锁要有过期时间,防止持有锁的线程宕机造成死锁。

Redisson 就是把这些问题包装好的成熟方案。它的RLock支持可重入、自动续期,默认看门狗每 10 秒给有效期 30 秒的锁续期,只要线程没结束,锁就不会提前过期。在秒杀场景里,我直接用redisLock.lock()包裹“判断用户是否下单 + 扣减库存 + 创建订单”整段逻辑。要注意,用分布式锁时尽量缩小锁的粒度,这里的 key 可以设计成lock:order:{userId}:{voucherId},而不是lock:voucher:{voucherId},这样不同用户之间不会被同一把锁串行化,只有同一个用户对同一个券的请求才互斥。

4.4 Lua 脚本把秒杀做成原子操作

锁能解决并发互斥,但每次都走一次 Java 方法调用,在秒杀这种超高并发下还是有点浪费。黑马点评最后给了一套优化方案:用 Lua 脚本把“判断库存是否充足、判断用户是否已下单、扣减库存、保存秒杀订单”四个步骤放在 Redis 里一次性原子执行。

我照着写了一个简化版脚本,核心思路是先用exists检查优惠券是否存在,再用hget拿到库存和用户下单标记,最后用decr减库存。整个脚本由 Redis 单线程执行,天然不会并发穿插。Java 侧只用DefaultRedisScript执行脚本,然后根据返回值判断成功或失败。

执行完 Lua 后,秒杀订单如果直接同步写 MySQL,数据库压力还是很大。项目里进一步用了基于 Redis Stream 的异步订单处理:脚本执行成功后,把 userId 和 voucherId 写入 Stream,后台消费者拉取消息创建订单,前端先返回“正在抢购中”的提示,后续通过接口轮询订单结果。这是比较典型的削峰填谷思路,理解了这一条,再去看 RocketMQ 或 RabbitMQ 的秒杀方案就不会发怵。

5. 小功能里的大设计:点赞、关注与 Feed 流

5.1 点赞与点赞排行榜的数据结构选择

点赞这个功能很多项目都用 MySQL 的user_like表实现,但在高并发场景下,读多写多,每次都查库比较吃力。黑马点评给出的做法是把点赞状态放进 Redis 的 Set 或 SortedSet。

用 Set 时,key 是blog:liked:{blogId},value 是用户 id,点赞就是SADD,取消就是SREM,判断是否已点赞就是SISMEMBER,点赞数量就是SCARD。这个方案写起来简单,但它只能告诉你“谁赞过”,不能按点赞时间排序。

如果要看“点赞排行榜”,就要用 SortedSet,score 存点赞时间戳,取前五名用ZREVRANGE key 0 4。我在实际中更倾向于直接上 SortedSet,因为排行榜展示是社交产品的常见需求,后加需求再改数据结构成本高。顺带提一句,真实项目里点赞数据最终还是要落库的,Redis 只是缓解读压力,可以在点赞时异步更新数据库。

5.2 关注、共同关注与粉丝列表

关注关系的 Redis 化也是黑马点评里的一个亮点。每个用户维护两个集合,一个是“我关注的用户”,另一个是“我的粉丝”。如果要用 Redis 存储,直接就是两个 Set:follow:{userId}存我关注的人,fans:{userId}存我的粉丝。

需要判断两个用户是否有共同关注时,用SINTER取交集即可。比如想看 A 和 B 共同关注了谁,SINTER follow:A follow:B,一条命令就能出结果。这个操作在 MySQL 里往往要拆成多个查询再内存比对,Redis 集合运算的优势非常明显。

这里要提醒一个业务陷阱:关注关系不能只看 Redis,最终还是要以数据库表为准。Redis 里的集合可以用于热门关注、共同关注这类读多接口,但“取关”和“关注”这种写操作最好在事务里先落库,再同步刷新 Redis,否则缓存丢了或过期了,数据就乱了。

5.3 Feed 流拉取、推模式与滚动分页

Feed 流是博主发布探店笔记后,粉丝在首页刷到的信息流。做这个功能最常遇到的坑是:不应该让每个粉丝实时去查博主的所有笔记,否则大 V 发一条动态,几百万粉丝同时查询,数据库直接被打爆。

黑马点评采用的是推模式加收件箱思路。每个用户有一个 Feed 收件箱,key 是feed:{userId},value 用 SortedSet 存笔记 id,score 是发布时间戳。博主发布笔记后,系统拿到该博主的所有粉丝 id,把这条笔记分别写入每个粉丝的收件箱。这么做读性能很好,代价是写放大,粉丝多的时候一次发布会触发大量写请求,但这在数据规模可控的场景下是划算的。

滚动分页是另一个容易错的地方。如果收件箱用 List,分页用LIMIT offset size,就会出现问题:用户下拉刷新的过程中,收件箱新增了内容,偏移量对不上,要么重复读到旧笔记,要么漏掉新笔记。黑马点评的解法是用ZREVRANGEBYSCORE。我维护了一个lastScore变量,每次只取比上次最小 score 更早的数据,配合LIMIT控制每页数量,这就实现了真正不重不漏的滚动加载。

6. 附近商铺与 UV 统计

6.1 GEO 实现附近店铺检索

附近店铺功能类似大众点评的“离我最近”。用 MySQL 做距离计算,比如根据经纬度算球面距离再排序,数据量大时性能很差。Redis 的 GEO 类型专门解决这个问题,项目为每个店铺类型维护了一个 GEO key,比如shop:geo:{typeId},用GEOADD把店铺 id 和经纬度写进去。

查询附近店铺时,调用GEOSEARCH指定起点经纬度、半径和距离单位,Redis 会返回半径内按距离排序的店铺 id,同时能带出距离。之后 Java 侧再用 id 列表去 MySQL 查店铺详情,手动保持 Redis 返回的顺序。

我在做这步时踩过一个坑:直接用WHERE id IN (...)查 MySQL,结果返回的顺序和传入的 id 列表不一致,离用户最近的店铺被排到后面。解决方式是查完之后在 Java 里用一个 id-index 映射重新排序。另一个细节是,GEO 的 member 是店铺 id 字符串,如果店铺 id 在业务里会重复或者 key 被误用,会导致覆盖,所以分类维度一定要在 key 设计上就定清楚。

6.2 HyperLogLog 统计 UV

UV 统计在业务上指的是独立访客数,同一个用户一天访问多次只算一次。如果用SETBIT或者 bitmap 精确统计,当用户量达到千万级别时内存开销会非常大。

黑马点评引入了 Redis 的 HyperLogLog。操作极简单,访问时PFADD uv:{date} userId,统计时PFCOUNT uv:{date}。HyperLogLog 的误差在 0.81% 以内,内存占用极低,适合对精度要求不高的统计场景。我实际验证过,写一万个随机用户进去,统计结果和精确值误差基本在几十以内,对业务完全可接受。

这个功能虽然小,但能体现你对 Redis 数据类型的理解广度。很多面试官会问“除了 String、Hash,你还用过哪些类型”,这时候能把 GEO、HyperLogLog、Stream 都落到具体业务场景上讲,和背诵命令是两种效果。

7. 高频问题与排错实录

7.1 缓存不一致的怪现象

黑马点评做完之后,我自己用高并发脚本测过店铺更新,发现偶尔会出现“数据库改了,Redis 里还是旧值”的情况。排查下来往往是删除缓存失败导致,比如 Redis 连接超时、删除操作偶发异常又没重试。

单靠程序里调用一次删除并不可靠,我后来借鉴了延迟双删的思路:更新数据库后先删除缓存,隔几百毫秒再删除一次,目的是把并发中可能回填旧缓存的请求兜底清掉。但延迟双删也有问题,第二次删除失败依然会脏。所以在真实项目里,更稳的方式是让删除缓存的操作进入消息队列,确保最终能执行,同时 TTL 兜底。

注意:黑马点评原始代码里的缓存更新策略适合教学,但不适合直接照搬到金融、订单这类强一致场景。我个人的取舍是:缓存永远只当作加速层,数据库才是事实来源,缓存丢了可以重算,数据库错了才是大事故。

7.2 Redis key 序列化乱码的排查

第一次跑通登录后,我打开 Redis 客户端,发现 key 长这样:\xac\xed\x00\x05t\x00\x0blogin:token:xxx。原因很简单,项目默认的RedisTemplate使用 JDK 序列化,把 key 也序列化成二进制了。

解决办法有两种:一是全局配置RedisTemplate,key 用StringRedisSerializer,value 用GenericJackson2JsonRedisSerializer;二是干脆用StringRedisTemplate,key 和 value 都用字符串,JSON 手动转换。我后来统一用了第二种,虽然多写几行序列化代码,但可读性和排查效率高很多。如果你坚持用GenericJackson2JsonRedisSerializer,记得要处理LocalDateTime的序列化模块,否则反序列化会直接抛异常。

7.3 事务提交与锁释放的时序问题

这是一个很隐蔽但面试极爱问的坑。如果事务方法和锁在同一个方法里,可能出现锁已经释放,但事务还没提交。另一个线程拿到锁后查数据库,查到的还是上一个事务未提交前的数据,导致重复下单或其他脏读问题。

黑马点评的解法是用动态代理开启事务,锁在整个调用链最外层。我实验时手动复现过这个问题,现象是并发十个请求,最后同一个用户生成了两笔秒杀订单。加上事务代理和唯一索引之后,重复数据才被彻底挡在数据库层。这也是为什么我一直强调:业务代码的逻辑判断只是第一层防护,数据库兜底才是最后底线。

7.4 简易压测与观察方法

如果你想验证自己的优化有没有效果,不需要上全套压测平台,用 JMeter 就能做一轮基本验证。我当时的做法是:为店铺查询接口写一个线程组,设置 100 并发、持续 60 秒,观察聚合报告里的 TPS 和错误率。没有缓存时,TPS 大概只有几十到几百;加了 Redis 缓存后,吞吐能明显上升,错误率下降。

观察 Redis 是否生效,可以用MONITOR命令快速看实时命令流,也可以看 Redis 的INFO stats里的keyspace_hits和keyspace_misses。命中率长期低于 80%,说明缓存设计可能有问题,比如 key 过期太频繁、缓存更新策略导致大量删除。这套方法适合所有中间件,学会之后对你的线上排查能力帮助很大。

8. 实战过后的几点心里话

把黑马点评完整做完之后,我最想对还没动手的朋友说一句:看视频和写代码完全是两个难度。看的时候觉得每个步骤都很自然,一旦关掉视频自己从头建工程,立刻会在 Redis key 设计、事务边界、JSON 序列化这些小地方反复卡住。

我个人很有收获的是把黑马点评里的设计思路迁移到了自己的小项目里。比如做一个简单的内容社区时,我会先思考这个功能适合用 ZSet 还是 Stream;做一个后台管理系统时,我会判断某个列表数据要不要加缓存、缓存失效后能否容忍短暂的不一致。这些判断力不是背八股文能获得的,而是靠一次次的调试和复盘堆出来的。

最后再分享一个小技巧:做完一个模块后,别急着写下一个,先花半小时画一张这个小模块的数据流转图,把“请求从哪里来、缓存怎么用、数据库什么时候碰、失败怎么办”画清楚。黑马点评那么多知识点,真正属于你的,其实就是你亲手画过、亲手改过的那部分。祝动手顺利。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询