
1. 项目整体设计与核心业务拆解1.1 黑马点评到底是个什么项目很多学Java的同学简历上缺一个能拿得出手的实战项目。培训机构项目写得千篇一律电商秒杀又烂大街这时候“黑马点评”这个项目就非常合适——它是一个仿大众点评的商户点评类系统核心业务围绕“探店笔记、商户查询、优惠券秒杀、好友关注、签到统计”展开技术栈覆盖 Redis、MySQL、RabbitMQ、Lua、Nginx、JVM 调优、微服务等多块内容。我最早接触这个项目的时候第一反应是“这不就是一个CRUD项目吗”。真正刷完之后才发现它厉害的地方在于把面试中那些高频考点全部串起来了。比如缓存穿透、缓存击穿、缓存雪崩搞一个商户查询接口就全涉及分布式锁、Lua脚本、消息队列削峰做一个秒杀下单功能就全用上了。这种设计思路让项目看起来“麻雀虽小五脏俱全”非常适合用来当简历上的主打项目。从学习路径来看黑马点评适合两种人。一种是准备暑期实习或校招的Java后端同学需要用一个拿得出手的项目撑住面试另一种是已经工作但想系统梳理Redis、MQ等中间件知识点的开发人员。项目的前置要求不高掌握Java基础、Spring Boot基本用法、MySQL和Redis的基础操作就能跟得动中间遇到的坑我也会在后面的章节里逐个拆开讲。1.2 核心业务模块与数据流向整个项目的主链路其实就三条用户看笔记找店铺、用户买券到店消费、用户关注好友刷动态。每个链路背后都藏着对应的高并发技术方案这也是面试时最容易展开讲的地方。先看商户查询链路。用户在首页按类型刷店铺列表点进店铺详情页看评分、人均消费、营业时间这些信息。这些数据都存在MySQL里但是详情页的QPS远高于后台管理系统的写入QPS所以必须引入Redis做缓存。这就自然会引出缓存更新策略的问题——到底是先删缓存再更新数据库还是先更新数据库再删缓存缓存里没有数据了是同步回源数据库还是异步重建这些问题的答案组合起来就是你在面试时能讲出来的“项目难点”。再看秒杀链路。平台会上架一些限量的优惠券用户可以抢购抢到之后生成订单到店核销。秒杀场景有两大硬性问题一是超卖100张券被200个人抢到二是接口被脚本刷爆。解决方案我在后面的章节会详细说核心思路是“Lua脚本判断资格 RabbitMQ异步下单”把高并发的压力从数据库和Redis转移到MQ上削峰填谷。最后是关注与Feed流链路。用户关注某个博主之后可以在首页刷到该博主发布的探店笔记。这个功能如果直接查数据库一次刷Feed要关联十几张表延迟非常高。项目里用的是“推拉结合”的模式关注时直接把笔记写入关注者的收件箱Redis的ZSet刷Feed时直接从ZSet倒序拉取配合滚动分页解决深分页的性能瓶颈。1.3 项目的功能边界与扩展空间黑马点评这个项目网上能找到的版本比较多有基础版、进阶版、微服务版很多同学会纠结到底刷哪个版本。我的建议是先完整跑通基础版把Redis相关的核心逻辑吃透再根据自身时间和面试岗位方向选择性加装扩展点。基础版的核心模块包括用户登录基于Redis的Session共享、商户查询缓存、优惠券秒杀LuaMQ、好友关注、签到统计Bitmap、附近的商户GEO。这些模块覆盖了Redis最核心的六大数据结构只要能把它们全部搞明白面试中90%的缓存相关题目都能接得住。如果面试的是偏架构的岗位可以自己加一套RabbitMQ的延迟队列来做“未支付订单自动取消”或者用NginxLua做网关层的热点参数限流如果面试的是偏业务开发的岗位可以试试把笔记模块改成“关注Feed流 推荐排序”塞进协同过滤或者简单的热度排序算法。这些扩展点原项目没给现成代码但正是自己动手加功能的过程反而能在面试里成为有别于其他候选人的差异点。2. 核心技术难点攻坚缓存三大问题与Redis高级用法2.1 缓存穿透布隆过滤器还是缓存空值怎么选缓存穿透是指请求的数据在数据库和缓存里都不存在导致每次请求都直接打到数据库上。恶意攻击者如果构造一堆不存在的ID比如负数、超大数、随机字符串DB就会被无效请求压垮。黑马点评里面用到的一个处理方式就是缓存空值。当数据库查不到某个商铺时仍然把空值写入Redis并设置一个较短的过期时间比如2~5分钟这样后续相同ID的请求就会命中缓存的空值不会再穿透到DB。这种做法的优点是实现简单几乎零额外成本缺点是需要额外存储空对象而且如果攻击者不断换新的ID空值缓存就形同虚设。我在实际写代码时的建议是如果是低并发场景、非法ID数量有限优先用“缓存空值”如果是高并发场景、攻击者可以无限生成非法ID就需要在缓存层之前加一层布隆过滤器。布隆过滤器的原理比较简单——用多个哈希函数把数据映射到一个很长的位数组上判断某个ID“可能存在”或者“绝对不存在”。它占用内存极小1亿条数据只要125MB左右就能承载但存在一定的误判率所以通常和缓存空值组合使用。注意布隆过滤器在项目里不是银弹。误判会导致极少数合法ID被挡住而且布隆过滤器本身不支持删除操作。如果业务上有频繁删除ID的需求可以换用Counting Bloom Filter或者干脆用Redis的Bitmaps手动维护。2.2 缓存击穿 vs 缓存雪崩互斥锁与逻辑过期这两个问题经常被面试官连着问很多人容易搞混。缓存击穿是指一个热点key在过期的瞬间大量请求同时打到DB上缓存雪崩是指大批key在同一时间段集中过期或者Redis宕机导致所有请求都落到DB上。黑马点评给的标准解法是“互斥锁 逻辑过期”的组合拳。互斥锁方案很好理解某个key过期后第一个请求获得分布式锁并去DB加载数据其他线程自旋等待第一个线程把新缓存写入Redis后再释放锁。这样同一时刻只有一个请求能穿透到DB代价是接口的响应时间会变长因为大部分线程都在“等待锁释放”。我用Redisson的tryLock实现过一版关键代码大致是这样// 用Redis的setnx实现简单互斥锁 String lockKey lock:shop: id; boolean isLock stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (isLock) { // 查询数据库重建缓存最后释放锁 stringRedisTemplate.delete(lockKey); } else { // 休眠50ms后重试 Thread.sleep(50); return queryWithMutex(id); }逻辑过期方案则是“用空间换时间”。热点key不设置物理过期时间而是在value里额外存一个expireTime字段。每次查询时先判断逻辑时间是否过期如果没过期直接返回如果过了期则获取互斥锁后开一个新线程去异步重建缓存旧数据先返回给调用方。这种方案的好处是接口响应不受DB慢查询影响坏处是数据一致性会短暂不一致适合“容忍短暂脏读”的业务比如店铺详情页评分和营业时间差个几秒完全无感。我在项目里实际用的是逻辑过期方案因为店铺详情页的QPS比较高用互斥锁会让大量线程空转等待白白浪费Redis连接资源。但要注意逻辑过期方案需要自己维护一个线程池去异步重建缓存否则高并发下线程池被打满新缓存一直建不出来旧的逻辑过期数据就永远返回给用户了。2.3 缓存与数据库双写一致性先删缓存还是先更新库面试必问题。很多同学能背出“先更新数据库再删除缓存”这个结论但讲不清楚为什么。这里用最直白的逻辑拆一遍。方案A是先删缓存再更新数据库。线程1删除缓存还没更新DB时线程2来查缓存没命中回源DB拿到旧数据写回缓存然后线程1才更新DB。结果是缓存里永远躺着旧数据后面的请求全部读到旧值。这个方案在并发下几乎必出问题不推荐。方案B是先更新数据库再删除缓存。线程1更新DB线程2同时读到旧值写回缓存线程1删缓存把这个旧值删掉下次请求回源拿新值。看似中间有短暂不一致但最终能收敛所以行业标准答案是B。但方案B也有个隐藏坑如果删缓存失败怎么办。常规做法是引入重试机制或者通过订阅MySQL的binlog变更用 Canal 异步删除对应的Redis缓存。我在项目里用的是Alibaba的Canal中间件监听shop表的UPDATE事件在消费端删除指定缓存key实现“最终一致性”。面试时如果能把“先更新DB再删缓存 失败重试 最终一致性”这个链路讲完整面试官会觉得你是真在生产里踩过坑的。实操心得在本地玩项目时不要过度设计。如果你的场景是读多写少、并发没那么夸张直接“先更新DB再删缓存”加上一个定时任务做兜底补偿就够用了。Canal那套方案适合写在“项目优化”里面试时口述给面试官听让对方知道你懂有这条路线即可。2.4 Redis高级数据结构GEO、Bitmap、HyperLogLog黑马点评的进阶内容里有几个模块用到了Redis不太常见的数据结构如果能在面试时主动讲出来非常加分。附近商户功能用的是GEO。实现思路是先把所有商户的经纬度写入Redis的GEO集合查询时用GEOSEARCH key FROMLONLAT 经度 纬度 BYRADIUS 半径 km ASC COUNT就能拿到附近的商户ID列表。这里有一个小技巧GEO底层是ZSet所以member字段需要设计成“店铺ID”score字段存储的是52位GeoHash编码后的经纬度。在测试时我遇到过一个坑Redis 6.2以下版本没有GEOSEARCH命令只有GEORADIUS上线前要确认Redis版本不要想当然。签到功能用的是Bitmap。一张Bitmap用1个bit表示一天的签到状态一年365天只需要46个字节。统计连续签到天数时把当月所有bit取出来从右往左数“1”的连续个数逻辑上非常清爽。项目里用SETBIT和BITFIELD两个命令配合实测下来比用数据库表记录签到明细要快一个数量级。UV统计用的是HyperLogLog。如果业务上需要统计店铺页面的独立访客数精确去重用Set会占大量内存而HyperLogLog可以实现“1万条数据约12KB内存误差0.81%”的近似去重非常划算。面试时可以说一句“我在项目里用HyperLogLog做过UV统计有大约0.8%的误差但对业务指标无影响”面试官会觉得你不仅会封装工具类还知道背后的原理。3. 秒杀系统的设计与实现从超卖到削峰3.1 秒杀核心链路和数据库表设计秒杀是黑马点评的重头戏也是很多人简历上最亮眼的一段。整个给券流程是这样的用户点击抢购前端调用秒杀接口后端逻辑分三段——先校验库存和用户资格再扣减库存生成订单最后发送消息通知用户抢购成功。数据库表设计上有两张关键表一张是seckill_voucher秒杀券表一张是voucher_order订单表。seckill_voucher表除了券的基本信息还有stock库存和begin_time、end_time秒杀时间段。voucher_order表记录的是用户的抢购订单有一个唯一索引uk_user_id_voucher_id用来保证同一用户不能重复秒杀同一张券。这两张表的设计虽然简单但直接决定了后续并发控制的复杂度。如果不在订单表加唯一索引仅靠代码里的判重逻辑高并发下两个线程同时通过校验就会生成两条重复订单。所以我在实战中把“用户秒杀幂等性”分了两层来实现一层是数据库的唯一索引兜底一层是Redis的setnx前置拦截。3.2 超卖问题的本质与三种解决方案对比先给结论超卖的本质是“多线程并发下单时先查库存再扣库存”这个两步操作不是原子性的。两个线程同时读到库存为1都认为可以下单都执行了扣减库存就变成了-1。常见解法有三种。第一种是数据库乐观锁在seckill_voucher表加一个version字段扣减库存时加条件WHERE stock 0 AND version ?如果更新影响行数为0就说明库存不足或版本冲突直接返回失败。优点是实现简单缺点是DB压力大高并发下大量请求打到数据库上。第二种是Redis预扣减。把库存直接放在Redis里用DECR命令扣减库存这个命令本身是原子性的天然不会超卖。但Redis库存和DB库存的一致性问题需要处理Redis扣减成功后才去异步同步DB如果同步失败就出现两个库不一致。我在项目里采用的方案是“Redis扣减 消息队列异步落库”同时配合定时任务对账兜底。第三种是Lua脚本原子操作。把“判断库存足够 判断用户未买过 扣减库存 记录用户”这四步封装在一个Lua脚本里Redis本身就是单线程处理脚本的所以脚本执行过程中不会被其他命令插入。这就保证了整个操作是原子性的。我在黑马点评项目里用的就是这种方案叠加消息队列做异步下单。-- 秒杀资格判断 库存扣减Lua脚本核心逻辑 if (redis.call(exists, stockKey) 1) then local stock tonumber(redis.call(get, stockKey)); if (stock 0) then return 1; -- 库存不足 end if (redis.call(sismember, userKey, userId) 1) then return 2; -- 重复下单 end redis.call(decr, stockKey); redis.call(sadd, userKey, userId); return 0; -- 秒杀资格校验通过 end; return -1; -- 秒杀尚未开始3.3 RabbitMQ异步下单把什么放进队列消费者做什么很多网上教程讲到“Lua判断资格”就结束了但黑马点评完整的秒杀链路里必须要有MQ这一步否则就会出现一个问题1万人同时抢100张券Lua脚本里判了资格、扣了库存但是后面生成订单、关联用户券、记录日志这些操作如果同步执行接口的RT会非常长而且数据库瞬间被打爆。异步下单的设计思路是Lua脚本只负责“资格校验 扣减库存 记录用户到已购set”然后直接把用户的秒杀请求丢进RabbitMQ的一个队列。接口立刻返回“抢购成功正在处理中”前端轮询订单状态接口。消费者监听到队列消息后才真正去执行插入voucher_order表、更新seckill_voucher表、记录用户券等DB操作。这个设计带来的两个好处非常明显。第一接口响应时间从原来的几百毫秒降到了十几毫秒因为业务逻辑全部异步化了第二数据库的写并发从“瞬间涌入1万条”被MQ削峰成“消费者按自身能力匀速消费”比如每个消费者每秒钟只能处理50条订单那DB的写压力就是每秒50条不会被打爆。队列里放的应该是消息对象而不是整个订单对象。消息体我建议只放voucherId、userId、orderId这三个字段即可消费者收到消息后再去Redis查对应的用户信息和券信息避免消息体积过大导致MQ的吞吐量下降。注意这个方案里有个隐藏问题——如果消费者处理订单失败比如数据库挂了或者网络抖动消息会被RabbitMQ重新投递。如果处理逻辑不是幂等的就会产生重复订单。所以我在消费者里强制加了一步“先查订单是否已存在存在则直接ack”这步相当于给异步链路也上了一道保险。3.4 秒杀接口防刷与限流策略秒杀接口的另一个核心痛点在于脚本机器人。正常人抢券是一秒点一次脚本是毫秒级并发如果不做拦截Redis的库存会被机器人在前0.01秒全部刷光真实用户根本没机会。项目里用的第一层防护是前端秒杀按钮置灰加后端重复校验。用户点击抢购后按钮立刻置灰后端用Redis的String结构存储userId voucherId为key的值设置5秒过期5秒内重复请求直接返回“操作过于频繁”。第二层是接口限流。我在网关层用Nginx的limit_req模块做了IP级别的漏桶限流每个IP每秒最多放行5个请求到秒杀接口多余的请求直接返回503从源头挡住脚本流量。这里有个实操细节很多人的Nginx配置写的是limit_req_zone $binary_remote_addr zonemylimit:10m rate5r/s;但没注意burst参数也不够我实测下来建议burst10 nodelay既能容忍短时突发又不至于让脚本钻空子。第三层是黑名单机制。如果同一个IP在短时间内发起超过50次秒杀请求就把这个IP加入Redis的Set黑名单后续所有请求在网关层直接403。这个机制原项目没有我是在自己复现的时候加的面试时随口提一句效果意外地好。4. Feed流与关注模块推拉结合与滚动分页4.1 为什么不能直接查数据库关注模块需要实现的是“首页刷到关注博主发布的笔记”这个功能听起来简单实现起来却非常考验性能。如果用传统方案查询流程是这样的先查当前用户关注了哪些博主再查这些博主最近发布的笔记最后按时间排序返回。这个流程在关系型数据库里要关联3张以上的表关注人数多时还会产生“N1”查询问题响应时间经常超过500ms。更关键的是这个数据天然就带有“读多写少”的属性博主发笔记的频率远低于粉丝刷首页的频率。如果每次都实时查库数据库会被大量重复的只读查询压垮。所以这个场景非常适合“空间换时间”把每个用户的时间线提前算好放进Redis缓存。4.2 推模式、拉模式与推拉结合的取舍Feed流的三种实现模式业内已经总结得非常清楚了。推模式写扩散是指博主发布笔记后系统立即把笔记推送给所有粉丝的收件箱粉丝刷首页时直接读自己的收件箱即可优点是读延迟极低缺点是如果博主有千万粉丝一条笔记要写千万次Redis写入放大严重。拉模式读扩散是指粉丝刷首页时实时去拉取所有关注博主的笔记再合并排序优点是写操作很轻缺点是读延迟高而且合并排序时要维护多个博主的游标逻辑复杂且查询耗时。黑马点评项目里的策略是“推拉结合”核心思路是普通用户之间用推模式博主发布后推送给粉丝但当一个博主的粉丝量超过阈值比如10万时系统不再推送给所有粉丝而是把笔记放到博主的发件箱里粉丝刷Feed时直接从发件箱拉取。这个阈值就是推和拉之间的开关可以做成动态配置。我在实现时给follow表加了一个follow_user_id和followed_user_id的联合索引同时给每一条关注关系在Redis里维护一个ZSetkey设计为feed:box:{userId}score是笔记发布时间戳member是笔记ID。这样博主发笔记时只需要批量往关注者的ZSet里写入即可。4.3 滚动分页为什么比页数分页更适合Feed流很多同学在写Feed流时习惯用LIMIT offset, size做页数分页这时会碰到一个问题——刷着刷着就重复了。原因很简单Feed流的数据是动态增长的。用户在第1页刷到10条笔记往下翻的时候博主又发了一条新笔记最新笔记被排到了前面用户第2页拿到的可能就是之前已经看过的旧数据。黑马点评里用滚动分页完美解决了这个问题。滚动分页的核心是记录上一次查询的最小时间戳下一次查询时只取“小于这个时间戳”的数据并用ZREVRANGEBYSCORE命令倒序获取。// 滚动分页核心逻辑ZSet的score即笔记发布时间戳 Long lastId Long.parseLong(stringRedisTemplate.opsForZSet() .reverseRangeWithScores(feed:box: userId, (offset - 1) * size, offset * size - 1) .stream() .map(TypedTuple::getValue) .reduce(Long::min) .orElse(0L));实操心得滚动分页有个天然的边界问题是“分页阈值等于上一次的最小score时可能会丢数据”。解决办法是查询时加上score lastId而不是score lastId同时时间戳上加一个小的随机偏移量避免同一毫秒内有两条笔记时丢失其中一条。4.4 关注、取关与共同关注的数据结构设计关注功能本身就是典型的社交关系链用Redis的ZSet来存储是标准解。follow:user:{userId}这个key存的是当前用户关注了谁member是博主IDscore是关注时间fans:user:{userId}存的是当前用户的粉丝列表。共同关注可以用SINTER命令直接取两个Set的交集。由于我是用ZSet存储所以需要先ZRANGE取出两个集合的member再在内存里做交集。这个操作在关注人数几百几千时性能完全够用但如果用户关注了上万人就要考虑用Redis的Set而不是ZSet或者用SortedSet的ZINTERSTORE命令在服务端完成交集运算。观点说一句不要在关系型数据库里设计一张user_relation表来存“谁关注了谁”这张表在高并发下会迅速成为热点行锁竞争会让系统秒变“慢查询博物馆”。热点数据就应该放到Redis里DB只做冷数据备份和审计。5. 登录鉴权与Session共享从单体到分布式的演进5.1 单体应用的Session登录问题黑马点评最开始是用传统Session保存登录状态用户登录成功后用户的ID、手机号信息被写入HttpSession框架自动生成一个JSESSIONID的Cookie返回给浏览器后续请求带上Cookie服务端根据SessionID找到对应的Session数据。但这种方式只能工作在“单机部署”的场景。只要服务一上集群用户的请求被Nginx负载均衡转发到了第二台机器而Session却存在第一台机器上这个用户就“掉线”了。解决思路有两个方向一个是Session粘滞让同一用户的请求永远走同一台机器但这会导致负载不均衡另一个是Session共享把Session数据抽出来放到独立存储里。黑马点评给的标准解法是“Redis代替Session”把登录状态存到Redis中用Token代替SessionID。这个方案也是目前互联网公司最主流的登录方案。5.2 基于Redis的Token登录实现实现逻辑不复杂用户登录成功后生成一个随机UUID作为Token把这个Token作为Redis的key用户信息作为value设置有效期如30分钟并把Token返回给前端。前端在后续请求中把Token放入请求头里后端通过拦截器解析Token从Redis读取用户信息并放入ThreadLocal中供后续业务代码直接取用。这里有三个细节值得展开。第一Redis的key设计需要加业务前缀比如login:token:{uuid}避免和其他业务key冲突第二用户信息的序列化方式建议直接存储JSON字符串读取时用ObjectMapper转回对象会比JDK的序列化方式更省内存且兼容性更好第三缓存有效期不能设置太长否则用户长时间不操作但永远不用重新登录安全性有隐患。我实际写的时候做了一个优化——用拦截器实现“每次请求自动续期”。用户只要在30分钟内发起了任意一次请求Redis中该Token的有效期就会被重置为30分钟如果连续30分钟没有任何请求Token才会真正过期。这个逻辑需要在拦截器的preHandle方法里调用expire命令很简单但很见功夫。5.3 ThreadLocal存放用户信息的坑与规范用ThreadLocal存放当前登录用户信息是个常见操作但很多同学写完就忘了一个致命问题——没有在请求结束后清理ThreadLocal。Tomcat的工作线程是复用的如果当前请求结束时ThreadLocal里的用户信息没有被remove下一个请求复用了这个线程就能读到上一个用户的登录态。这在生产环境就是严重的数据越权事故。所以拦截器里用try-finally结构保证清理是底线操作try { // 业务逻辑 } finally { threadLocal.remove(); }注意在项目里不要为了方便把整个User对象塞进ThreadLocal建议只塞userId、nickName、icon这几个核心字段。一方面减少内存占用另一方面避免用户信息被业务代码随意篡改权限校验只认ThreadLocal里的userId这样更安全。6. 项目复盘与面试实战怎么把黑马点评讲出亮点6.1 面试时怎么介绍这个项目“项目介绍”是Java后端面试的第一关很多人讲项目时要么平铺直叙要么照本宣科听得面试官昏昏欲睡。我总结了一套“1分钟讲背景 3分钟讲难点 1分钟讲数据”的讲述公式。第一分钟先讲项目背景和整体架构。比如“我做的是一个面向C端的商户点评类应用对标大众点评核心功能包括商户查询、优惠券秒杀、笔记Feed流。技术上主要以Spring Boot MyBatis Plus作为后端框架Redis作为缓存中间件RabbitMQ做异步解耦整个项目部署在Linux服务器上用Nginx做反向代理和负载均衡。”第二到第四分钟重点讲难点和解决方案。这部分的节奏应该是“挑2到3个有深度的难点展开”比如缓存击穿怎么解决、秒杀超卖怎么解决、Feed流性能怎么优化。每一个难点都用“问题背景 — 方案选型 — 最终落地 — 踩坑改进”四段式来讲面试官很容易跟上你的思路。最后一分钟讲项目的数据指标和优化结果。比如秒杀接口的QPS优化前后对比、缓存命中率从多少提升到多少、Feed流接口响应时间的变化。没有真实数据可以自己压测模拟我是用JMeter在本地模拟了1000并发做完优化后秒杀接口的P99从280ms降低到了25ms这个数据在面试中足够有说服力。6.2 面试高频问题清单与回答思路把黑马点评项目刷完一遍后建议对照下面这份清单自测一遍。这些问题基本就是面试官针对这个项目最常问的高频问题缓存穿透、击穿、雪崩分别是什么你的项目里怎么解决的回答思路先讲清三个问题的触发场景区别再分别说出缓存空值/布隆过滤器、互斥锁/逻辑过期、缓存集群/多级缓存的具体方案。为什么要用Redis做分布式锁Redis分布式锁有什么缺点回答思路先说“单机JVM锁在分布式环境下失效”再说“用Redis的setnx 过期时间 红锁机制”解决误删锁和主从切换问题最后提一嘴Redisson封装好的看门狗机制。Lua脚本为什么能保证原子性回答思路Redis是单线程模型脚本会一次性加载执行中间不会插入其他命令所以天然原子。为什么用RabbitMQ不用Kafka或者RocketMQ回答思路RabbitMQ是轻量级消息中间件部署运维简单支持延迟队列等丰富特性秒杀场景的削峰需求完全够用如果用Kafka则适合海量日志和超高吞吐RocketMQ更适合事务消息和金融级场景。选型要看业务规模不要盲目跟风。你的缓存和数据库一致性怎么保证的回答思路先说结论“先更新数据库再删除缓存”再说“删缓存失败通过binlog异步补偿”最后提一句“最终一致性和强一致性的取舍”。怎么防止重复下单回答思路从“数据库唯一索引兜底 Redis预判重 消息队列消费幂等”三个层级来讲层层设防。6.3 项目中还能加什么差异化亮点原版黑马点评做得虽然完整但属于“人人都有”的项目面试官一听就知道是培训班标配。如果想要脱颖而出我建议额外做两件事。第一件加一个“分布式锁的Redisson改造”。把原生StringRedisTemplate的setnx锁换成Redisson的RLock利用它的自动续期机制解决锁过期误删问题面试时讲“我用原生setnx实现过一版后来发现有锁过期业务没执行完的问题就换成了Redisson”这句话足以证明你真的思考过分布式锁的细节。第二件抽一个“通用缓存工具类”。把缓存查询、缓存重建、缓存删除的逻辑封装成一个泛型方法支持传入单数和批量key加一个开关控制是否开启逻辑过期。这样代码结构会干净很多也方便在面试时说“我对缓存操作做了统一封装线上排查问题时能快速切换策略”。6.4 简历上怎么写项目和量化结果简历上关于项目经历的描述必须遵循“技术点 业务场景 量化结果”三步法不能只写“使用Redis实现缓存”。我提供一个参考模板黑马点评仿大众点评| 核心开发技术栈Spring Boot、MyBatis Plus、Redis、RabbitMQ、MySQL、Nginx负责商户查询模块的缓存设计基于Redis缓存热点数据缓存命中率稳定在95%以上针对缓存穿透/击穿/雪崩分别采用缓存空值、互斥锁与逻辑过期方案优化压测下接口P99延迟降低60%。设计并实现优惠券秒杀系统使用Lua脚本保证库存扣减与用户资格校验的原子性结合RabbitMQ异步下单完成削峰填谷1000并发压测下无超卖接口RT从280ms降至25ms。实现基于推拉结合模式的Feed流使用Redis ZSet存储关注收件箱支持滚动分页解决动态数据分页重复问题响应时间稳定在50ms内。这个写法的好处是每个技术点都有对应的量化数据面试官一眼就能看到你的项目含金量。不要写“熟练掌握Redis、RabbitMQ”这类空话把知识点落到具体业务里才真正有说服力。7. 实操记录与踩坑合集从零复现黑马点评7.1 环境准备与版本选择建议本地复现黑马点评需要的环境包括JDK 8、Maven 3.6、MySQL 5.7或8.0、Redis 6.x、RabbitMQ 3.8。网上有很多版本的源码很多同学会纠结拉哪一套我的建议是直接找一套“含RabbitMQ版”的完整源码作为起点因为基础版只有Lua脚本没有MQ面试时几乎没有任何竞争力。这里单独提醒一下版本匹配问题如果你本机装的是JDK 17而源码是基于JDK 8的Spring Boot 2.x启动时大概率会遇到IllegalArgumentException或者javax.annotation包找不到的问题。解决办法有两个要么装一个JDK 8要么在pom.xml里手动引入javax.annotation-api依赖。我更推荐第一种直接用JDK 8匹配Spring Boot 2.7.x踩坑最少。数据库的初始化脚本也要留意。很多博主分享的SQL文件不够完整比如缺少秒杀券的测试数据或者缺少tb_voucher_order表的唯一索引导致跑起来之后重复下单也能插入成功。建议导入SQL后自查一下这几张关键表尤其是索引字段。7.2 我复现过程中遇到的7个坑第一坑Redis连接失败导致启动报错。原因通常是本机Redis设置了密码但配置文件里的spring.redis.password为空。解决方案是统一使用spring.data.redis.*配置项Spring Boot 2.4以上同时确认Redis服务监听的是0.0.0.0而不是127.0.0.1。第二坑秒杀接口本地压测时出现ERR Error running script。原因是Lua脚本在Redis集群模式下key不在同一个slot中会报错。单机Redis不会触发此问题但部署到集群时必须保证Lua脚本里用到的key都带有相同的哈希标签比如{seckill:voucher}:stock:1和{seckill:voucher}:user:1。第三坑RabbitMQ消费端重复消费。原因是消费者处理完订单后在basicAck之前抛出了异常消息被重新投递。解决办法是消费逻辑幂等化——先根据订单号查库存在则直接确认消费不存在才执行新增。第四坑前端页面出现中文乱码。原因是IDEA里的文件编码默认是GBK而项目统一是UTF-8。安装一个强制UTF-8编码插件或者检查maven-compiler-plugin的编码配置即可解决。第五坑商铺查询接口偶发缓存空值堆积。原因是缓存空值的TTL设置太长攻击者构造大量不同ID时Redis内存被无意义的数据占满。建议空值TTL设成2分钟同时加一层简单的参数校验比如ID必须为正整数。第六坑启动Nginx后项目首页无法访问。检查Nginx配置里的proxy_pass是否有尾部斜杠proxy_pass http://localhost:8080/和proxy_pass http://localhost:8080转发的路径是完全不同的。这个问题排查了我整整一个下午。第七坑压测时发现QPS上不去最后发现是数据库连接池配置太小。Spring Boot默认的maximum-pool-size是101000并发时光连接池就卡死了。合理配置是initial-size: 20、maximum-pool-size: 100、min-idle: 20可以根据服务器资源再适当上调。7.3 压测方法与性能数据参考想拿到漂亮的性能数据用于简历或面试必须学会用JMeter做基本的压测。我的做法是这样本地用JMeter开1000个线程循环10次对秒杀接口发起请求观察聚合报告里的Average、P90、P99和Error%。优化前的数据大概是平均响应时间280msP99在800ms左右错误率15%超时和超卖丢弃。优化后的数据是平均响应时间25msP99在120ms左右错误率降至0%。这个优化幅度非常大主要归功于三个改动——用Lua脚本替代原来的多次Redis操作RT下降50%、用MQ异步下单替代同步DB写入RT下降60%、用逻辑过期替代互斥锁P99下降40%。压测时要注意一个细节JMeter的HTTP请求里必须带上Token否则所有请求都会在拦截器处被拦下压测结果等于无效。我自己就在这个细节上浪费过不少时间Token可以登录一次后从Redis里直接取出字符串配置到JMeter的HTTP Header Manager里。7.4 项目后续还能怎么扩展黑马点评本身是一个教学向的项目但它暴露出来的设计空间非常大。我在做完基础模块后又自己动手做了三件事在这里分享给同样在刷项目的同学参考。第一件是“优惠券秒杀改为一人一单 自研延迟队列”。原版只做到了优惠券的秒杀但“下单后超时未支付自动取消”这个经典场景并没有实现完整。我引入了RabbitMQ的延迟交换机订单创建后发送一条延迟消息TTL 15分钟消费者收到后检查订单状态如果仍未支付则取消订单并回补库存。这个功能做完后“MQ的延迟队列”就能写进简历了。第二件是“用户签到升级为按月补签 Redis Bitmap偏移计算”。原版签到模块只统计了连续签到天数我在它的基础上加了“月度补签卡”功能核心还是操作Bitmap但需要计算某个bit位在一个月中的偏移量。这个模块让我彻底弄懂了BITFIELD命令的GET u1 offset参数。第三件是“附近商户模块接入真实地图数据”。原版用的是写死的经纬度我接入了一个在线地图API把测试数据换成了真实城市里的商户坐标同时用GEO的GEOSEARCH做了“距离从近到远”的排序功能。虽然改动不大但演示时效果非常直观给面试官留下的印象远比跑一个欢迎页深刻。我个人在做完这些扩展后的体会是黑马点评这个项目真正的价值不在于代码本身而在于它给了你一张非常清晰的后端知识点地图。沿着这张地图往深处挖每个点都能延伸出一片值得写进简历的技术细节。这也是为什么我推荐所有Java后端同学认真刷一遍这个项目而不是随便看看别人的总结就觉得自己已经会了——有些坑真的只有自己踩过一遍面试时才能言之有物。