☰
黑马点评实战:Redis在缓存、秒杀与分布式锁中的核心应用
2026/10/8 14:48:08 网站建设 项目流程

如果你最近在准备Java后端面试,或者刚学完Spring Boot想找个项目练手,黑马点评这四个字大概率躲不掉。作为一个模仿大众点评的实战项目,它最大的价值不在于CRUD写得多花哨,而在于把Redis在真实业务里的那些用法串成了一条完整的线:缓存、分布式锁、秒杀、Feed流、附近的人,每一个都是面试官喜欢追问的场景。这篇文章我就把自己做这个项目时啃下来的技术点,按业务场景拆开讲清楚,顺便把那些最容易踩的坑和面试里真正值得讲的东西一并整理出来。

1. 先看懂黑马点评的整体盘子

1.1 项目在解决什么问题

黑马点评是一个商户点评类应用,核心功能包括商户查询、优惠券秒杀、用户点赞、好友关注、笔记Feed流、附近商户搜索。如果你只是把它当成一个普通的管理系统来写,那确实没什么好说的。但项目里大量业务都天然适合用Redis来承接,这才值得拆。

比如商户详情这种典型的热点数据,读多写少,几千上万个用户同时点进同一家店,数据库不一定撑得住,但Redis能扛。再比如优惠券秒杀,库存只有几百个,但瞬间可能进来几万请求,直接操作数据库行锁和事务会把你压垮。还有关注Feed流,每个人刷到的内容都不一样,怎么在毫秒级把内容推给用户,这也不是数据库order by能解决的事。

所以看这个项目,不能只盯着功能和界面,而是要看你写的每一段Redis代码,背后的设计动机是什么。这也是面试复盘时最有价值的部分:你能不能说清楚,这个场景为什么必须用Redis,不用会怎么样。

1.2 技术点与Redis数据结构的对应关系

这个项目最经典的地方在于,Redis的每一种常用数据结构都能在业务里找到合适的位置。我做完之后总结了一张映射表:

业务场景Redis数据结构核心技术点
商户查询缓存String + JSON缓存穿透、击穿、雪崩
登录校验String(Token)键有效期管理
优惠券秒杀String(库存)+ Set(一人一单)Lua脚本原子操作
分布式锁String(SetNX)锁的原子性与续期
点赞功能Set集合去重与计数
关注/粉丝列表ZSet排序与交集运算
Feed流推送ZSet推模式收件箱
附近商户搜索GEO坐标距离计算与排序
UV统计HyperLogLog基数统计省内存

这个表建议你自己也去整理一遍,因为面试官问Redis数据结构,往往不是问你String能存什么、List能存什么,而是给你一个业务场景让你选型。能脱口而出用哪种结构、为什么,比死记硬背八股文有用得多。

2. 缓存三大难题:穿透、击穿、雪崩

2.1 缓存穿透该用空值缓存还是布隆过滤器

缓存穿透是指请求的数据在缓存里没有,数据库里也没有,导致每次请求都直接打到数据库。比如一个商户ID被恶意遍历,查一个不存在的ID,Redis找不到,数据库也没有,返回值又不会写回缓存,下一次同样的请求继续打数据库。

黑马点评里最直接的方案是缓存空值:查询数据库返回空结果时,把一个空对象以短过期时间写入Redis,比如5分钟。这样同一个键的后续请求会命中这个空值,直接返回,不再穿透到数据库。

但空值缓存有一个前提条件,就是攻击的key数量要有限。如果对方每秒换一万个不存在的ID,缓存里存的空值数量也会爆炸,反而把Redis内存打满。所以在实际项目中,空值缓存经常配合参数校验,比如ID格式不合法直接拒绝,根本不去查Redis。

另一种思路是布隆过滤器,启动时把所有存在的ID加载到bit数组里,请求进来先判断ID是否存在,不存在直接返回,存在才去查缓存和数据库。布隆过滤器的问题是它存在误判率,说某个ID存在,它可能不存在,但反过来,说某个ID不存在,那它一定不存在,这个特性刚好够用。不过它需要维护一份全量ID数据,更新起来比较麻烦。你只要理解了这两种方案的适用边界,面试时就能从容回答。

2.2 缓存击穿:互斥锁和逻辑过期,两种做法都要会

缓存击穿指一个热点key过期瞬间,大量请求同时发现缓存没有,全部涌向数据库。区别是它不是一个不存在的key,而是刚好在某个瞬间过期了。

项目中给出了两种解法,都值得深入理解。

第一种是互斥锁。查询缓存没命中时,先尝试获取一个分布式锁,拿到锁的这个线程去查数据库,回填缓存,然后释放锁;没拿到锁的线程先短暂等待,再重新查询缓存。这样同一时刻只有一个请求真正访问数据库。它的问题也很明显:如果某个线程查数据库耗时较长,其他线程都在等待,整体请求延时会被拉高,而且如果代码里锁没释放好,直接死锁。

第二种是逻辑过期。在缓存对象里额外存一个过期时间字段,比如缓存值为"商户数据 + 过期时间戳"。查询时发现逻辑时间已过期,并不会直接删掉缓存,而是先返回旧数据,然后获取锁,另起一个线程去刷新缓存。旧数据还在,请求不会被阻塞,用户体验更好。它的代价是所有热点key实际上是"永久不过期"的,只能靠异步线程主动更新,存在一个短暂的数据不一致窗口。

面试时如果被问到这两种方案怎么选,我的回答思路是:互斥锁能保证强一致性,但会牺牲一点性能;逻辑过期性能好,但允许短暂不一致。黑马点评中应对热点数据缓存重建用的是互斥锁的思路,这一点要看清楚,因为很多面试官会让你对比,你得能说出项目里具体用的是哪一种。

2.3 缓存雪崩:过期时间为什么必须加随机值

缓存雪崩是指大量key在同一时段集体过期,或者Redis服务直接宕机,导致这些key的请求全部打到数据库,造成数据库压力瞬间飙升,甚至引发连环故障。

黑马点评里的典型做法是:在设置缓存过期时间时,不要用固定值,而是用一个基础值加上一个随机值。比如原定30分钟,实际设为30 * 60 + Random.nextInt(300)秒。这样同一批商户数据的过期时间被分散开,不会出现整点集体失效。

这里面值得深挖的是"为什么必须随机",而不是随便加一个Random就行。因为实际业务里的数据往往是分批写入的,如果你在写入时用同一个基础值去设过期时间,那它们一定会在同一时间过期。加随机值本质上是把集体失效变成均匀分布,数据库在每个时间点只需要处理一小部分回源请求,压力自然可控。

另外,Redis宕机导致的雪崩,解决思路就完全不同了,通常要靠集群高可用、主从切换、多级缓存、限流降级这些手段。项目里没有展开这一层,但面试时你可以提一句"仅靠随机过期时间应对的是key集体失效,不是Redis宕机",这样会让面试官觉得你边界想得很清。

2.4 缓存与数据库的一致性,怎么聊才能不露怯

黑马点评在更新商户数据时,采取的策略是先更新数据库,再删除缓存。为什么不是先更新缓存?因为如果先更新缓存再更新数据库,缓存写入成功、数据库更新失败,缓存里的就是脏数据。而先更新数据库再删缓存,就算删缓存失败,最坏情况是下次请求再查数据库回填,顶多多一次数据库查询,不会返回脏数据。

删除缓存也面临一个问题:如果你删完缓存,另一个线程又把旧数据写回缓存,那这个旧值就长期驻留了。解决这个问题的经典做法是延迟双删,也就是更新数据库后删除一次缓存,过几百毫秒再删一次,确保把中间线程写入的旧值清掉。黑马点评里对这个问题的处理不是重点,你仍然可以把它作为扩展点来讲。

面试官经常问的"强一致能不能保证",我的建议是坦诚地承认:纯Redis缓存方案无法做到强一致,它只能保证最终一致。Redis和数据库天然是两套存储,你不可能让两者在同一时刻绝对相同。想要更强的保障,就要引入Canal监听MySQL binlog异步更新缓存,或者让写操作直接走缓存并配合消息队列,这属于另一个量级的架构设计。你能把边界说清楚,比硬拗一个方案更显得有经验。

3. 分布式锁:从SetNX到Redisson

3.1 自己实现分布式锁,踩过的坑要能讲出来

黑马点评里实现分布式锁的经历,几乎就是一套完整的踩坑教材,也是最容易在面试时展开聊的部分。

第一版方案很简单:用Redis的SetNX命令尝试加锁,设置一个业务相关的key,加锁成功就执行业务,业务执行完再Del删除锁。听起来没问题,但你很快会发现两个致命缺陷。第一个是如果业务执行过程中抛了异常,Del代码根本走不到,锁永远不会释放,后续请求全部卡死,这就是死锁。第二个是如果服务在执行业务时宕机,锁也一样不会被删除。

所以第二版方案会加上过期时间,比如SetNX成功后执行Expire,给锁设置一个10秒的自动过期时间。这就解决了死锁问题,但引入了一个更隐蔽的Bug:如果你的业务执行时间超过10秒,锁已经自动过期,另一个线程成功加了新锁,此时第一个线程执行完,它执行Del删掉的其实是别人的锁。经典的误删问题。

怎么解决误删?可以给锁的value设置一个唯一标识,比如UUID,删除前先判断当前锁的value是不是自己的UUID,是才删。但这个"判断再加删除"是两个操作,中间依然有间隙,做不到原子性,在高并发下依然有可能误删。到这里你已经被迫走向Lua脚本了。

3.2 Lua脚本为什么能保证原子性

程序员对"原子性"这个概念的直觉通常是加锁、事务、CAS,但在Redis里,保证一系列操作不被其他请求插入的最直接办法,是把它写成一个Lua脚本。Redis执行Lua脚本时整个脚本是单线程执行的,中间不可能插入其他命令,所以脚本内的逻辑天然原子。

黑马点评里删除锁的Lua脚本我摘了一段:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这段脚本做的事情是先判断锁的value是否等于当前线程的UUID,是才删除。整个过程没有间隙,其他线程不可能在中间乘虚而入。这一小段代码基本是面试高频考点,建议不仅看懂,还要能默写出来。

有了Lua脚本,基本上一个可用自研分布式锁就成型了。我还应该多说一句:为什么用UUID而不是用线程ID?因为Java的线程ID在同一个JVM内唯一,但一旦部署多台机器,不同JVM之间的线程ID是可能重复的,UUID才能保证全局唯一,这是分布式环境的基本常识。

3.3 Redisson的看门狗机制,面试时怎么讲

如果你的业务场景还讲究可重入、自动续期、等待锁,那手写的SetNX版本就不够看了,项目上一般会直接引入Redisson。

Redisson最值得聊的是它的看门狗机制。当你用Redisson加锁并指定了leaseTime时,默认情况下它会启动一个后台调度任务,每隔锁有效期的三分之一时间就去给锁续期,比如锁默认30秒过期,那每隔10秒就续到30秒。只要业务线程还没执行完,锁就不会提前过期。这正好解决了我前面说的"业务执行太久,锁自动过期"的尴尬局面。

面试时我一般会这么回答:Redis分布式锁的本质是利用Redis单线程执行命令的特性,加上SetNX和Lua脚本的原子性,实现多进程之间的互斥控制。手写实现可以完成基本需求,但Redisson解决了重入、续期、等待、公平性这些工程细节。如果面试官继续追问"看门狗会不会导致锁永远不释放"——那就要回到Redisson的设计上,它只有在业务线程存活时才会续期,业务结束时加锁的线程会主动调用解锁命令删除锁,后台任务收到解锁通知后会停止续期,所以正常情况下不会出现锁永不释放。

4. 秒杀业务的Redis落地

4.1 为什么秒杀下单不能直接操作数据库

秒杀是黑马点评里并发度最高的场景,也是最容易暴露问题的部分。假设10000个人同时抢100张优惠券,如果代码直接把库存判断和扣减放在数据库里做,会发生什么?

数据库层面,库存扣减通常是一条UPDATE语句,修改同一行数据时会被行锁串行化,后面排队的请求都要等锁释放。与此同时,每个请求还要开启事务、插入订单、提交事务,连接池很快被占满。数据库不是不能扛,而是秒杀这种几十倍于日常流量的峰值,不值得让它硬扛。Redis的好处在于它是内存操作,单线程模型天然避免了大量并发修改同一份数据的锁竞争,对于"判断库存是否充足"和"扣减库存"这两个操作,只要用对命令,就能做到毫秒级响应。

项目的做法是把库存预减放到Redis里,数据库只负责异步落单。这也就意味着Redis里的库存数才是"前台可见"的实时库存,数据库里的库存只是备份和最终一致性的落点。理解了这个前提,再去看后面的流程就不会乱。

4.2 库存预减与异步下单的完整链路

秒杀的核心链路可以拆成四个环节:

  1. 校验优惠券信息并检查是否在秒杀时间内
  2. 检查该用户是否已经买过(一人一单)
  3. Redis扣减库存并保证原子性
  4. 创建订单并异步写入数据库

第三步是关键。项目中扣减库存用的是Lua脚本,脚本内部先判断库存是否大于0,是才执行Decr,否则直接返回失败标记。用Lua脚本而不是SELECT再UPDATE,就是避免两个操作之间有间隙,防止超卖。这个思路和之前删除锁的原子性问题是同一个道理。

第四步有两条路线,项目中用的是阻塞队列,秒杀请求在Redis扣减库存成功后,把订单信息封装成对象丢进阻塞队列,后台线程不断从队列中取出并写入数据库。用户并不知道后台在异步落库,前端只需要轮询查询订单状态即可。

我补充一个重要观察:阻塞队列方案适合单机部署和教学演示,如果你用的是秒杀场景,队列本身在内存里,服务重启数据会丢。生产环境更好的做法是把下单请求发给消息队列,比如RocketMQ或Kafka,由消费者异步建单。但项目里用阻塞队列的优点是简单、直白,能让你快速理解"削峰"的思想——先把瞬时流量接收下来存住,再慢慢消费。

4.3 一人一单用Set和Lua实现,面试必讲

秒杀还有一个隐藏问题:用户重复下单。如果不用任何机制,一个用户抢到一张券后疯狂重试,同一张优惠券会被一个人买走多份。项目里的解法是把用户ID存进Redis的一个Set集合,在扣减库存的Lua脚本里加一步判断,如果用户的ID已经存在于Set中,则拒绝下单,否则执行Sadd把用户ID加入Set,再继续扣减库存。

到了这一步,Lua脚本要完成的事情就更多了:判断秒杀是否开始、判断是否买过、扣减库存,全部在一个脚本里原子完成。面试时如果能把这个脚本的业务逻辑完整描述一遍,就已经说明你是真正做过这个功能,而不是背了几个Redis命令。

当然,Redis里做了Set判断,数据库里仍然应该给用户ID和优惠券ID建唯一索引,作为最后的兜底。因为在极端情况下,如果Redis缓存被清理,或者异步落库环节出现了并发插入,数据库的唯一索引能够抛出异常让你发现并补偿。我们讲的可靠性从来都不是靠单点保证的,而是多层防御。

5. 社交场景:点赞、关注与Feed流

5.1 点赞为什么用Set,数据规模大了怎么升级

黑马点评的点赞功能,核心需求是三个:判断用户是否点过赞、用户点赞/取消点赞、统计点赞数量。这个场景用Set集合非常契合。Set自带去重性质,用户ID作为元素,同一个用户不会重复点赞;SISMEMBER可以O(1)判断用户是否点赞;SADD和SREM分别处理点赞和取消;SCARD直接取总数。数据库里一张点赞记录表也可以做,但每次判断都要走一次SQL,高频访问时的压力明显,Set可以在Redis里零压力完成。

如果只是做"谁赞了我",Set就够了。但要展示"谁赞得最早"或者按时间排序,Set无序的特性就不满足需求了,这时要升级成ZSet,用点赞时间作为score。黑马点评里的需求来判断,普通Set已经够用,但你理解了从Set升级ZSet的动机,才能在面试里应对"数据量大了怎么办"这类追问。

5.2 关注、取关和共同关注怎么算

关注场景用的是ZSet列表,score存入关注时间。用户关注一个博主时,向自己的关注列表ZSet中Add该博主ID;取消关注时ZRem;查看自己的关注列表时按照score倒序取出。用ZSet而不是Set,作用是你能按关注时间排序,还能很方便地配合分页。

"共同关注"这个功能就更有意思了。两个人各自的关注集合是两个Set,取交集,Redis直接提供了SINTER命令,一条命令就能返回共同关注的人。这个能力背后是Redis对集合运算的原生支持,如果换成数据库,你需要先查出两个人的关注列表,再在代码里做双重循环求交集,效率和使用体验完全不在一个级别。面试时可以说出"我用了Redis的Set集合,配合SINTER一次搞定共同关注查询"这句话,这个点非常加分。

5.3 Feed流的推模式与拉模式

黑马点评的Feed流是个性化内容分发的一个简化版本。用户关注的人发布的笔记,会出现在首页时间流里。如果对每个用户都用数据库按关注列表查笔记,涉及多表关联或多次查询,性能很差,所以项目里引入了"推模式"。

推模式也叫写扩散。博主发布笔记时,系统把这个笔记ID写到他的所有粉丝的"收件箱"里,每个粉丝的收件箱是一个ZSet,score是发布时间。用户刷Feed流时,只需要从自己的ZSet中按score倒序分页取出笔记ID,再去查笔记详情。发布时多写几次,但读取很快,适合读多写少的场景。

另一种模式是拉模式,也叫读扩散。用户刷Feed时,系统实时去关注列表的每个博主那里取最新笔记,然后合并排序返回。它的好处是博主发布几乎没有额外开销,但刷Feed时可能涉及大量查询,延时不可控,所以适合博主少、粉丝少的场景。

黑马点评里选推模式的理由是关注量不大、笔记发布量也不大,写扩散的写放大成本可控。但你要知道这个大前提。如果做一个千万粉丝级的大V,他发一条笔记要给千万粉丝各写一条数据,写放大量是恐怖的,这时必须换混合模式,粉丝少的账号用推,粉丝多的账号用拉。能说出这种工程上的取舍,面试官会认为你不只是抄了代码,还在思考架构边界。

6. 高频问题:Redis连不上、面试追问与扩展方向

6.1 Redis连接不上?把排查顺序背下来

"黑马点评连接不上redis"这个搜索词的热度一直很高,我刚开始做这个项目时也卡了好几个小时,一会儿报Unable to connect,一会儿报拒绝连接,Debug了半天发现是配置的问题。这里我把自己总结的排查顺序分享出来,全是实际验证过的。

现象排查点验证命令
本机可以连,远程连不上redis.conf里bind是否只绑定了127.0.0.1查看bind配置
远程连接被拒绝protected-mode是否为yes改为no
认证失败是否设置了requirepass启动Redis带配置文件
云服务器连不上安全组是否放行6379端口检查云控制台安全组
虚拟机里连不上防火墙是否拦截systemctl status firewalld
应用连接超时Spring配置host/port/password是否匹配检查application.yaml

大概的排查思路是:先确认Redis本身进程有没有启动,ps -ef | grep redis;然后看配置文件里的bind、protected-mode和requirepass,这三者是最常见的坑;再确认Spring配置文件里的连接信息和服务端是否一致;最后再看防火墙和安全组这些外部环节。你不要跳步骤,一步步排除,比乱试配置有用得多。

6.2 黑马点评项目里,面试官最爱追着问的几个问题

结合我自己面试的经历和被问到的内容,下面这几个问题基本绕不开:

  1. String和SDS有什么区别,为什么Redis用SDS而不用C字符串
  2. 如果缓存穿透的量极大,你除了空值缓存和布隆过滤器,还有没有别的兜底手段
  3. 分布式锁的过期时间怎么设置,设置短了和长了分别有什么问题
  4. 秒杀接口怎么防止脚本机器人刷单,Redis层面能不能做
  5. 缓存和数据库的一致性到底能不能做到强一致
  6. Lua脚本在执行过程中如果出错,Redis会怎么处理
  7. 你做的Feed流如果用户量再扩大十倍,会怎么优化
  8. 为什么Redis单线程还能这么快,IO多路复用怎么理解

这些问题的答案其实都藏在你做项目的细节里。你只要把每个技术点背后的业务场景讲清楚,再把我在前面展开的那些权衡和取舍串起来,就很难被问倒。最忌讳的是把八股文背得滚瓜烂熟,却连自己项目里的Lua脚本长什么样都说不出来。

6.3 基于这个项目,还能往哪些方向扩展

黑马点评本身不是终点,做完之后它完全可以作为一个技术实验场继续扩展。我建议你有余力的话尝试下面几个方向:

第一个方向是把缓存更新方式改成订阅binlog的异步更新。引入Canal监听数据库的binlog,数据库发生变更时,Canal把变更事件发布出去,应用服务收下来后主动更新Redis缓存。这个方案能取代手动删缓存的逻辑,让缓存一致性维护变得更可靠。

第二个方向是热点key的动态感知与多级缓存。你可以把每个key的访问次数做一个统计,当某个key的QPS超过阈值时,自动把它加载到进程内本地缓存里,比如Caffeine。这样请求优先走本地缓存,只有本地没有才去查Redis,Redis再没有才查数据库,等于多了一级缓冲。

第三个方向是分库分表与读写分离。项目里的订单表、笔记表随着数据量增长一定会遇到瓶颈,你先用Redis扛住了并发,接下来就要考虑MySQL的拆分方案。但扩展之前你得想清楚一个前提:绝大多数请求已经被Redis和本地缓存拦截,真正落到MySQL的流量可能已经不足以把它打垮,这时候再做分库分表是不是最优选择。能把这句话想明白,你对架构的理解就又上了一个台阶。

我自己做完这个项目的体会是,黑马点评真正有价值的部分,不是代码量,而是每个Redis技术点背后都对应着真实的业务困境。你在复盘时不要按技术点一条条背,要按业务链路从头到尾顺一遍:用户从打开客户端、看商户详情、点秒杀、下单、刷关注Feed流,每一步请求走到哪个存储,Redis在哪个环节起作用,这个环节不加Redis会怎样,加Redis之后又引入了什么新问题。顺着这条线讲下来,面试官会觉得你是真正理解了这个项目,而不是培训班的复读机。如果以后有时间,我还打算把附近商户的GEO搜索单独写一篇实战笔记,那个部分的地图距离计算和坐标转换也是有不少细节可以挖的。

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

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

立即咨询