黑马点评项目深度拆解:Redis缓存、分布式锁与秒杀实战
2026/9/16 5:26:06 网站建设 项目流程

1. 项目整体认知:黑马点评到底在解决什么问题

1.1 项目背景与业务模块

黑马点评这个项目,本质是一个模拟大众点评/美团 hybrid 模式的商户点评类应用。它看起来是一个单体SpringBoot项目,但里面塞进了非常多的经典业务场景:短信验证码登录、商户查询缓存、优惠券秒杀、好友关注Feed流、附近商户搜索、UV统计、签到打卡,等等。

为什么这套项目在Java后端求职圈里这么火?核心原因就一个:它在单体应用的壳子里,把Redis的典型应用场景基本上全部串了一遍。你去看很多培训机构的项目,要么是电商CRUD,要么是管理后台,Redis顶多拿来存个登录态、做个缓存,问深一点就答不上来了。而黑马点评的设计者很明显是对着Redis官方文档的使用场景(缓存、分布式锁、计数器、发布订阅、Stream、Geo、HyperLogLog、BitMap)一个不落地做了映射。所以准备这个项目的过程,本质上就是把Redis知识体系过了一遍。

业务模块主要分为这么几块:

  • 用户模块:短信验证码登录、用户信息维护、签到统计。
  • 商户模块:商户查询、缓存更新、类型查询、附近商户搜索。
  • 优惠券模块:秒杀券下单、库存扣减、一人一单限制、订单超时关单。
  • 关注模块:用户关注、共同关注、关注推送(Feed流)。
  • 数据统计模块:UV统计、签到次数统计。

你把这些模块拆开看,每一个都是一个面试题。商户模块对应缓存三兄弟问题(穿透、击穿、雪崩);优惠券模块对应分布式锁、Lua脚本、消息队列;附近商户对应Geo;签到和UV统计对应BitMap和HyperLogLog。所以这个项目不是让你背几个接口怎么调,而是要你把整个Redis知识树通过业务串起来。

1.2 技术栈与选型逻辑

黑马点评的技术栈本身不算新,Spring Boot 2.x、MyBatis-Plus、MySQL、Redis、RabbitMQ(扩展部分),但选型很讲究。我的建议是,你在准备面试介绍项目时,不要只报菜名,要讲清楚每个组件在这个项目里承担什么角色。

举个例子,Redis在项目里的角色就不是单一的:

  • 存登录token对应的用户对象,利用TTL实现自动过期。
  • 存商户数据,利用空值或布隆过滤器解决穿透。
  • 存秒杀库存,利用Redis单线程特性配合Lua保证原子扣减。
  • 存关注推送的收件箱,利用ZSet按时间排序。
  • 存地理坐标,利用Geo结构实现附近商户。
  • 存签到记录,利用BitMap按位标记。
  • 存UV数据,利用HyperLogLog做近似统计。

MySQL负责的是业务主数据的持久化,订单表、店铺表、用户表这些。RabbitMQ在扩展环节用于异步重建缓存、处理秒杀订单的异步下单。

这套选型背后其实有一条主线:能用Redis内存特性解决的,就不打到MySQL;能O(1)或O(logN)解决的,就不全表扫。你在面试时要能主动把这条主线讲出来,这是项目体现“设计感”的关键。

2. Redis在项目里的五个核心战场

2.1 短信登录与Session改造:从单体Session到Redis追踪

黑马点评的登录模块设计得很有代表性。传统的单体项目里,登录状态一般放Session,靠Cookie里的JSessionId关联。但这个方案在分布式部署下天然有问题:请求被负载均衡到不同机器,Session不共享,就得引入Spring Session做Session同步,方案重且不优雅。

黑马点评的做法是:验证码和登录用户信息都直接存Redis,用自定义Token代替SessionId返回给前端。具体链路我在后面第3章细讲,这里先讲设计思路的核心逻辑——把登录态从“服务器内存”搬到“独立缓存层”

这么做有三个直接收益:

  1. 天然支持水平扩容,不需要额外的Session共享组件。
  2. Redis的TTL机制天然适合登录过期场景,可以做到“最后一次操作后30分钟过期”,比Session有效期管理灵活得多。
  3. 可以方便地存储更多用户画像数据,后续扩展权限、风控都方便。

面试中容易被追问的一个点是:为什么用Hash结构存用户信息,而不是直接存JSON字符串?

这个我当时也纠结过。用String存JSON,读写都简单,但要修改某个字段就得整个读出再写回。用Hash结构,每个字段独立存储,修改昵称、签名这类操作可以只更新单个字段。而且在内存占用上,Hash在字段少的时候用了ziplist编码,比JSON字符串更省内存。所以黑马点评里用户对象用Hash存储,这个细节面试官问了就是加分项。

2.2 商户缓存:穿透、击穿、雪崩的三重防线

缓存这块是整个项目里面试密度最高的区域,没有之一。黑马点评的商户查询做了一个典型的Cache Aside Pattern,也就是先读缓存,读不到再读DB,然后回填缓存。这个模式本身不难,难的是三种异常情况怎么防。

穿透:查询一个不存在的id,每次都打到DB。黑马点评的做法是缓存空值,就是即使DB查询结果为null,也往Redis里写一个空值,TTL设短一些,比如2到5分钟。这样后续相同请求直接命中空值,不会打到DB。要穿透必须换着id打,那就要上布隆过滤器做前置过滤,但布隆过滤器的缺点是存在误判率,而且删除不方便,所以项目中主要用缓存空值兜底。

击穿:某个热点key突然过期,大量请求同时打到DB。黑马点评的做法是互斥锁,就是在缓存未命中时先尝试获取分布式锁,拿到锁的线程查DB回填缓存,其他线程等锁释放后再查缓存。还有一种方式叫逻辑过期,就是不给key设TTL,而是在value里存一个过期时间字段,每次读取时主动判断,发现逻辑过期就尝试获取锁并另起线程重建缓存,读线程先返回旧数据。两种方案各有取舍,互斥锁实现简单、数据一致性好,但存在等待时间;逻辑过期性能好、无等待,但实现复杂且短暂返回脏数据。面试时建议说清楚自己选哪种、为什么,以及两种方案的对比。

雪崩:大量key同时失效,导致DB压力骤增。黑马点评的思路是给TTL加一个随机因子,比如基础上加1到5分钟的随机值,避免同一批key在同一时刻集体过期。另外还能做多级缓存、Redis集群高可用,但那属于架构层面的扩展话题了。

2.3 优惠券秒杀:从超卖到分布式锁再到Lua脚本

秒杀是整个项目里技术深度最深、面试官最爱深挖的一块。从最初的超卖问题开始,到最终用Lua脚本一把梭,整个演进链路本身就是一套完整的面试素材。

超卖问题:高并发下扣减库存,先查库存再更新库存,这一查一改之间库存就被别人扣掉了,最后出现超卖。第一个方案是乐观锁,用版本号或库存本身作为版本条件,update时带上stock > 0条件,影响行数为0就说明没抢到。这个方案能解决超卖,但如果抢购失败,用户那边体验很生硬,没有重试机制。

一人一单问题:秒杀券通常限制每人只能买一单。实现思路是下单前先判断用户是否已存在订单,但这个判断和插入订单之间如果没有原子性保障,并发下还是会存在一个人抢多单。黑马点评的演进思路是:先加synchronized锁,只锁单机;然后升级为Redis分布式锁,用set lock uuid ex nx保证集群环境下的互斥。如果是基于Redisson实现的分布式锁,还有看门狗自动续期,避免业务执行时间过长导致锁过期。

但分布式锁只解决了查询和下单判断这一段逻辑的互斥,库存扣减的原子性还得靠Lua脚本。最终方案是:用Lua脚本把“判断库存是否充足、扣减库存、判断是否已下单、写入订单”这几个步骤合并为一个原子操作,Redis单线程执行Lua天然保证了这段逻辑不会被并发穿插。这是秒杀链路里最核心的一个设计点,后面第3章我会把完整脚本结构贴出来。

2.4 附近商户与签到统计:Geo、BitMap与HyperLogLog的实战应用

这几个数据结构属于“知道的人少、用了就出彩”的加分项。

附近商户用的是Redis的Geo结构。实现思路是先把所有商户的经纬度通过GEOADD写入Redis,查询时用GEOSEARCH根据用户坐标和半径搜索附近商户。更关键的一个细节是分页问题:传统分页用LIMIT offset count,但Geo搜索没有直接的分页能力。黑马点评的解决思路是,先把搜出来的所有商户id取出来,再按商户类型做分组,每组各自排序和分页。这个点面试时可以主动提,展示你考虑过真实业务场景里的工程细节。

签到统计用的是BitMap。每个用户一年签到状态用一个365位的位图表示,签到当天把对应位设为1,通过BITFIELD命令批量操作,计算连续签到天数时按位从后往前数,数到0为止。这个方案的优越性在于:365天只需要46字节,即使每天签到一次,十年也就几百字节,内存开销几乎可以忽略。

UV统计用的是HyperLogLog。UV和PV不一样,UV需要去重,如果直接存用户id集合,再大的内存都不够。HyperLogLog用概率算法,标准误差0.81%,换来的是每个key固定占用12KB,不管多少用户来访问,内存都是常量级。做活动页面的独立访客统计时,这个结构比用Set存用户id划算得多。

3. 关键功能实现拆解:面试官最爱问的几个点

3.1 短信验证码登录的完整链路

这一块我认为值得完整梳理一遍,因为它的完整度能体现一个人对“登录”这件事的工程化理解深度。

整个登录流程分三步:

  1. 用户输入手机号,点击获取验证码,后端生成6位随机码存Redis,key用login:code:{phone},TTL设为5分钟,同时通过短信服务商发送给用户。
  2. 用户输入验证码,后端从Redis取出比对,一致则继续,不一致直接返回“验证码错误”。
  3. 比对通过后,从MySQL查用户,查不到就自动注册一个新用户。然后生成一个随机Token,比如UUID或更随机的字符串,把用户对象以Hash结构写入Redis,key为login:token:{token},TTL设30分钟。前端后续请求带着这个Token来访问。

这里有个很容易被忽略但非常加分的细节:Token怎么保证不可预测?

很多初学者会用UUID拼接一些固定字符串,安全性其实不够。我在实操中更推荐用SecureRandom生成随机字节,再Base64编码为URL安全的字符串,或者用UUID去掉横杠加上一个随机盐值。黑马点评原版用的是UUID,但面试时你如果能主动说“UUID虽然够用,但可预测性方面我做了改进”,面试官会很吃这一套。

另一个细节是刷新TTL。每次请求都带着Token,后端应该把这个Token对应的key重新设置过期时间,这样用户连续操作就不会被中途踢下线。这相当于一个滑动过期策略,业务上用户体验更好。注意这里要用EXPIRE命令重新设置,而不是重新写入整个key。

3.2 缓存一致性怎么保证的

缓存和数据库的一致性是面试重灾区。黑马点评里的场景主要是商户信息的更新,面试考察的是一个很经典的问题:先更新数据库还是先删缓存?

先更新DB再删缓存,可能的问题是:更新DB成功、删缓存失败,缓存里还是旧数据。先删缓存再更新DB,可能的问题是:删完缓存后、更新DB前,有请求把旧数据重新写入缓存,导致缓存长期是脏数据。

更细一点说,标题里的答案应该是:先更新DB,再删缓存,并配合延迟双删或消息队列补偿。黑马点评里采用了先更新DB后删缓存的方案,同时在扩展部分引入了RabbitMQ,更新DB成功后发送消息到队列,消费者删除对应缓存。如果删除失败,消息重试,最终保证一致性。

我自己在阅读这块源码时还发现一个细节:原版项目在更新商铺时用的接口是updateById,更新完直接del缓存。但实际把缓存删了之后,如果紧接着大量请求进来,会发生缓存击穿。所以更稳的写法是:更新DB前,先主动把缓存删除,而不是更新后删;或者在删除后短暂地加一个互斥重建的过渡逻辑。面到这一层,项目深度就比较明显了。

3.3 秒杀流程的原子性保证

秒杀下单的核心问题,用一句话说就是:多个操作之间只要存在非原子窗口,就有并发风险。黑马点评的最终方案,是把判断、扣减、下单三个动作通过Lua脚本合并为一次Redis调用。

一段典型的秒杀Lua脚本逻辑是这样的:

-- 判断库存是否充足 if tonumber(redis.call('get', stockKey)) <= 0 then return 1 end -- 判断用户是否已下单 if redis.call('sismember', orderKey, userId) == 1 then return 2 end -- 扣减库存 redis.call('incrby', stockKey, -1) -- 记录用户已下单 redis.call('sadd', orderKey, userId) return 0

这个脚本的核心思想是:把判断逻辑和写操作全部交给Redis单线程执行。Redis本身是单线程处理命令,所以一段Lua脚本在执行期间不会被其他命令穿插,原子性天然成立。调用方拿到返回值后,0表示成功,1表示库存不足,2表示重复下单。真正的订单落库可以异步处理,进一步降低数据库压力。

面试官如果追到线程模型这个层面,你要能说清楚:为什么Redis单线程还能这么快?答案核心在内存操作、IO多路复用、避免上下文切换和锁竞争。但也要补充一点,Lua脚本虽然原子,但执行时间长了会阻塞Redis服务,所以脚本里绝对不要写耗时的操作,比如循环上万次,更不要在里面写redis.call('keys', '*')这类O(N)命令。

3.4 Feed流推送的收件箱模型

关注模块里有一个比较容易被面试官追问的设计:关注推送的Feed流,到底是推还是拉?

黑马点评里用的是推模式的变种,叫“收件箱模型”。用户在发布内容时,把内容推送给自己的每个粉丝,粉丝查看Feed流时,直接从自己的收件箱里读。这里的收件箱就是Redis里的ZSet,score是发布时间戳,value是内容id。粉丝翻页时用ZREVRANGEBYSCORE按时间倒序取出需要的contentId列表,再去Redis或DB里查询内容详情并封装。

推模式解决的是“读扩散”问题,每个用户读自己收件箱,时间复杂度从需要查询所有关注对象的动态再聚合,变成一次ZSet范围查找,性能非常好。坏处是写放大,一个大V有几百万粉丝,发一条动态就要推送几百万次,粉丝少的内容创作者会浪费资源。

所以实际项目中更多用“推拉结合”:大V的内容走拉模式,普通用户走推模式。黑马点评原版没有做这个区分,但面试时主动提这个优化,会显得你有架构意识。我当时就是把“收件箱按粉丝数量分级”作为一个扩展点讲给面试官的,反馈很不错。

4. 面试怎么讲黑马点评项目:从自我介绍到深挖应对

4.1 一分钟项目介绍模板

面试官让你介绍项目时,切忌上来就背功能列表。我整理了一个可以直接套用的模板,你按照这个逻辑讲,面试官基本能快速抓到重点:

我做过一个商户点评类项目,整体是SpringBoot单体架构,核心是围绕Redis解决高并发场景的性能和数据一致性问题。项目包含商户查询、优惠券秒杀、关注Feed流、附近商户搜索等模块。我在其中主要负责两块:一是商户缓存体系的设计,解决了缓存穿透、击穿、雪崩问题;二是优惠券秒杀功能的实现,通过Redis+Lua脚本保证了库存扣减和一人一单的原子性,结合异步下单削峰。另外我还用Geo实现了附近商户,用BitMap和HyperLogLog做了签到和UV统计。

这个介绍的核心逻辑是:功能 + 技术难点 + 解决方案 + 成果。每一句话都在给面试官递问题素材,他有兴趣自然会往下追问。

4.2 高频面试问答实录

我在准备这个项目时,梳理了大概二十多个面试官高频追问,挑几个最典型的分享一下:

问:你们这个项目并发量有多大?

这是最尴尬的问题,因为我们自己练的项目并没有真实流量。我的建议是诚实说明:项目是单机演练,但设计上考虑了分布式扩展,并进行过压测。你可以在本地用JMeter或wrk压一下秒杀接口,记录下QPS和响应时间数据,面试时拿真实数据说话。我当时压测的结果大概是一个单机Redis+Lua脚本的方案QPS能到2000多,比纯DB方案高了一个数量级,这个数据是可以讲的。

问:为什么用Redis存验证码,不用Session?

从分布式部署、自动过期、数据可控性和可扩展性四个维度展开,同时补充Session方案在集群模式下需要引入额外组件做同步。答案看2.1的内容就够了。

问:缓存空值解决了穿透,那如果有人恶意用随机id刷呢?

这是追问。方案是:加布隆过滤器做前置拦截,把所有存在的商户id预加载到布隆过滤器里,请求来了先用布隆过滤器判断id是否存在,存在才继续查缓存。但布隆过滤器有误判率和删除问题,所以我会搭配空值缓存做兜底,以及加接口限流。

问:分布式锁超时了怎么办?

这是深水区问题。你如果用的是Redis的set nx ex,锁超时释放会导致业务还没执行完,锁被别人拿走了。更好的方案是Redisson的看门狗,默认每10秒续期一次,避免锁在业务执行期间提前过期。另外Redis分布式锁还有一个主从切换导致锁丢失的问题,如果要更强的可靠性,得用RedLock,但RedLock本身也有争议。面到这一层,说明面试官在压力测试,你只要表现出“知道这个问题的存在,且分析过不同方案的取舍”,就算过关了。

4.3 简历写法和项目复盘建议

简历写法这块,很多人的误区是把项目模块全部列上去,导致篇幅又乱又长。我的建议是精选两到三个有技术亮点的模块,每个模块的要点控制在三行以内。比如秒杀模块这么写:

负责优惠券秒杀功能的开发,基于Redis+Lua脚本将库存判断、扣减、一人一单校验合并为原子操作,解决并发超卖问题,压测下QPS约2000;引入Stream消息队列实现异步下单,峰值流量削峰。

这行突出了三件事:场景、技术方案、量化结果。比“优化了秒杀功能性能”这种空话有说服力得多。

另外强烈建议在准备项目时,自己搭一个最小复现环境,把核心流程完整走一遍。有没有亲手写过Lua脚本、有没有实际压测过、有没有排查过一个Redis连接池耗尽的问题,面试时问两三轮就能分辨出来。纸上谈兵的项目经验在深挖两轮之后一定会露馅。

5. 面经复盘:我踩过的坑和最后的实操建议

5.1 准备这个项目时最容易踩的坑

第一个坑是只背概念,不写代码。Redis的各类数据结构和命令,看视频时觉得都懂,面试官一问ZREVRANGEBYSCORE的语法和参数当场卡壳的人我见过太多了。我的建议是准备项目时把核心命令全部敲一遍,至少把登录、缓存重建、秒杀脚本、Feed流这四个场景的代码亲手写到能默写的程度。

第二个坑是简历写了但讲不深。很多人把Redis分布式锁写进简历,面试官问“这个锁在异常情况下怎么释放的”,答不上来。简历上出现的每一个技术名词,都必须准备好一个至少能讲三分钟的对应故事,否则宁可不写。

第三个坑是不知道主动引导话题。面试是一个有来有回的对话,你完全可以通过介绍项目的顺序来引导面试官问你准备好的内容。我当时的策略是:不管被问到什么,都会想办法把话题绕回“秒杀这个场景里我是怎么一步步优化设计”的,因为这块我最熟、最有信心。这道题聊得越深,被问其他薄弱环节的概率就越小。

5.2 项目还能往哪些方向扩展

黑马点评本身是一个很完整的练手项目,但如果你想让它从“培训机构项目”变成“有个人思考的项目”,我建议在这个基础上做三个方向的扩展:

第一个是接口幂等与防重。秒杀场景下,用户快速点击两次,或者前端重试,会导致同一用户重复创建订单。用Redis的SETNX配合业务唯一流水号,可以在入口就拦截重复请求,比Lua里的sismember又多做了一层保护。

第二个是热点key的探测与本地缓存。秒杀开始时,有几个商品一定是热点,所有请求都会打到Redis单key上。可以用hotkey探测工具或自己加一层Caffeine本地缓存,热点请求在应用内存就处理一部分,进一步降低Redis压力。

第三个是全链路压测与监控。把秒杀接口跑在JMeter下面,同时监控Redis的命中率、慢查询、连接池状态和GC情况。面试时如果你能说“我通过压测发现Redis连接池默认配置不够用,调大了maxTotal之后QPS提升了30%”,这就是比任何背模板都更真实、更打动面试官的素材。

5.3 针对不同方向的个性化建议

准备这个项目时,我认为最核心的心态是:不要为了“背住答案”而准备,而是要把自己代入到“这个系统真实运行在线上”的场景里思考每个决策。如果你面试的是高级岗位,还需要能说清Redis单线程模型下Lua脚本为什么不能写O(N)命令、Redis主从模式下分布式锁为什么可能失效、消息队列异步下单后订单状态怎么保证最终一致,这些都需要结合项目细节去推演。

根据我自己复盘面试的经验,最后再分享一个小技巧:面试前把你准备的所有项目知识画成一张纸的脑图,从模块到技术点,每个技术点标上“能讲几分钟、关联哪些面试题”,然后照着脑图自问自答一遍。到了面试现场,你会发现系统性地复述三到五遍之后,这些内容已经是你的肌肉记忆了。黑马点评这个项目的价值不在于它本身多高深,而在于它把后端面试里最常考的Redis、并发、缓存、消息队列问题全部串到了一个完整业务里,好好吃透它,你的项目面基本就稳了。

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

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

立即咨询