ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

黑马点评实战:Redis在缓存、秒杀与分布式锁中的核心应用

黑马点评实战:Redis在缓存、秒杀与分布式锁中的核心应用 如果你最近在准备Java后端面试或者刚学完Spring Boot想找个项目练手黑马点评这四个字大概率躲不掉。作为一个模仿大众点评的实战项目它最大的价值不在于CRUD写得多花哨而在于把Redis在真实业务里的那些用法串成了一条完整的线缓存、分布式锁、秒杀、Feed流、附近的人每一个都是面试官喜欢追问的场景。这篇文章我就把自己做这个项目时啃下来的技术点按业务场景拆开讲清楚顺便把那些最容易踩的坑和面试里真正值得讲的东西一并整理出来。1. 先看懂黑马点评的整体盘子1.1 项目在解决什么问题黑马点评是一个商户点评类应用核心功能包括商户查询、优惠券秒杀、用户点赞、好友关注、笔记Feed流、附近商户搜索。如果你只是把它当成一个普通的管理系统来写那确实没什么好说的。但项目里大量业务都天然适合用Redis来承接这才值得拆。比如商户详情这种典型的热点数据读多写少几千上万个用户同时点进同一家店数据库不一定撑得住但Redis能扛。再比如优惠券秒杀库存只有几百个但瞬间可能进来几万请求直接操作数据库行锁和事务会把你压垮。还有关注Feed流每个人刷到的内容都不一样怎么在毫秒级把内容推给用户这也不是数据库order by能解决的事。所以看这个项目不能只盯着功能和界面而是要看你写的每一段Redis代码背后的设计动机是什么。这也是面试复盘时最有价值的部分你能不能说清楚这个场景为什么必须用Redis不用会怎么样。1.2 技术点与Redis数据结构的对应关系这个项目最经典的地方在于Redis的每一种常用数据结构都能在业务里找到合适的位置。我做完之后总结了一张映射表业务场景Redis数据结构核心技术点商户查询缓存String JSON缓存穿透、击穿、雪崩登录校验StringToken键有效期管理优惠券秒杀String库存 Set一人一单Lua脚本原子操作分布式锁StringSetNX锁的原子性与续期点赞功能Set集合去重与计数关注/粉丝列表ZSet排序与交集运算Feed流推送ZSet推模式收件箱附近商户搜索GEO坐标距离计算与排序UV统计HyperLogLog基数统计省内存这个表建议你自己也去整理一遍因为面试官问Redis数据结构往往不是问你String能存什么、List能存什么而是给你一个业务场景让你选型。能脱口而出用哪种结构、为什么比死记硬背八股文有用得多。2. 缓存三大难题穿透、击穿、雪崩2.1 缓存穿透该用空值缓存还是布隆过滤器缓存穿透是指请求的数据在缓存里没有数据库里也没有导致每次请求都直接打到数据库。比如一个商户ID被恶意遍历查一个不存在的IDRedis找不到数据库也没有返回值又不会写回缓存下一次同样的请求继续打数据库。黑马点评里最直接的方案是缓存空值查询数据库返回空结果时把一个空对象以短过期时间写入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到Redisson3.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 库存预减与异步下单的完整链路秒杀的核心链路可以拆成四个环节校验优惠券信息并检查是否在秒杀时间内检查该用户是否已经买过一人一单Redis扣减库存并保证原子性创建订单并异步写入数据库第三步是关键。项目中扣减库存用的是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写到他的所有粉丝的收件箱里每个粉丝的收件箱是一个ZSetscore是发布时间。用户刷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 黑马点评项目里面试官最爱追着问的几个问题结合我自己面试的经历和被问到的内容下面这几个问题基本绕不开String和SDS有什么区别为什么Redis用SDS而不用C字符串如果缓存穿透的量极大你除了空值缓存和布隆过滤器还有没有别的兜底手段分布式锁的过期时间怎么设置设置短了和长了分别有什么问题秒杀接口怎么防止脚本机器人刷单Redis层面能不能做缓存和数据库的一致性到底能不能做到强一致Lua脚本在执行过程中如果出错Redis会怎么处理你做的Feed流如果用户量再扩大十倍会怎么优化为什么Redis单线程还能这么快IO多路复用怎么理解这些问题的答案其实都藏在你做项目的细节里。你只要把每个技术点背后的业务场景讲清楚再把我在前面展开的那些权衡和取舍串起来就很难被问倒。最忌讳的是把八股文背得滚瓜烂熟却连自己项目里的Lua脚本长什么样都说不出来。6.3 基于这个项目还能往哪些方向扩展黑马点评本身不是终点做完之后它完全可以作为一个技术实验场继续扩展。我建议你有余力的话尝试下面几个方向第一个方向是把缓存更新方式改成订阅binlog的异步更新。引入Canal监听数据库的binlog数据库发生变更时Canal把变更事件发布出去应用服务收下来后主动更新Redis缓存。这个方案能取代手动删缓存的逻辑让缓存一致性维护变得更可靠。第二个方向是热点key的动态感知与多级缓存。你可以把每个key的访问次数做一个统计当某个key的QPS超过阈值时自动把它加载到进程内本地缓存里比如Caffeine。这样请求优先走本地缓存只有本地没有才去查RedisRedis再没有才查数据库等于多了一级缓冲。第三个方向是分库分表与读写分离。项目里的订单表、笔记表随着数据量增长一定会遇到瓶颈你先用Redis扛住了并发接下来就要考虑MySQL的拆分方案。但扩展之前你得想清楚一个前提绝大多数请求已经被Redis和本地缓存拦截真正落到MySQL的流量可能已经不足以把它打垮这时候再做分库分表是不是最优选择。能把这句话想明白你对架构的理解就又上了一个台阶。我自己做完这个项目的体会是黑马点评真正有价值的部分不是代码量而是每个Redis技术点背后都对应着真实的业务困境。你在复盘时不要按技术点一条条背要按业务链路从头到尾顺一遍用户从打开客户端、看商户详情、点秒杀、下单、刷关注Feed流每一步请求走到哪个存储Redis在哪个环节起作用这个环节不加Redis会怎样加Redis之后又引入了什么新问题。顺着这条线讲下来面试官会觉得你是真正理解了这个项目而不是培训班的复读机。如果以后有时间我还打算把附近商户的GEO搜索单独写一篇实战笔记那个部分的地图距离计算和坐标转换也是有不少细节可以挖的。
返回列表