第43天。今天的安排其实很明确把黑马点评从登录到下单的所有核心链路重新过一遍然后刷两道栈的题收尾。黑马点评这个项目我在第30天左右已经完整写过一版总结但今天复习时明显感觉到隔了十来天再看很多细节确实会模糊——比如Lua脚本扣减库存的原子性边界、Feed流滚动分页的lastId怎么取、GEO底层的zset结构为什么能支撑附近的人。这些点光靠背笔记没用必须自己对着源码重新走一遍链路才有安全感。所以这篇文章就按我今天复习的实际顺序来写前半部分是黑马点评的复盘干货后半部分是两道栈题的完整解法最后聊几句怎么把项目复习和算法刷题在面试里串起来。先说这个组合为什么合理。项目复习解决的是你有没有真实落地经验的问题栈题解决的是你数据结构底子扎不扎实的问题。很多候选人项目讲得头头是道一道每日温度写不出单调栈也有的人算法题刷得飞起一问缓存击穿就只会说加锁三个字。两个都抓才不至于在某一轮被问穿。如果你也在准备Java后端面试或者正处于刷题项目两手抓的阶段今天的复盘思路可以直接抄。1. 今天复习黑马点评我为什么把项目又从头过了一遍1.1 隔两周再看为什么必须重新走源码说实话第30天我写复习笔记的时候觉得自己已经把黑马点评吃透了。缓存穿透、击穿、雪崩三个概念能背分布式锁的SETNX命令能默写秒杀的一人一单逻辑也能画出来。但今天打开项目源码从登录接口一处一处往下跟的时候发现有几个地方只是背过答案并没有真正理解。最典型的是缓存击穿里的逻辑过期方案。当时笔记上写着不设过期时间而是在value里存一个expire字段后台开线程重建缓存。问题是为什么逻辑过期能解决击穿我当时其实没有想透只是记住了步骤。今天重新看代码才意识到逻辑过期的核心优势在于缓存永远不会真正失效请求永远能拿到旧数据只有检测到逻辑过期才会尝试获取互斥锁去重建。这样即使用户请求量再大也不会直接打到数据库——因为大家都在等那个重建缓存的线程把新数据写进去。而互斥锁方案是让大量请求阻塞等待本质上是用串行化换不穿透两者取舍完全不同。这种重新走一遍源码的价值就是能把背下来的结论变成自己能推导的结论。我自己用的方法是不看任何笔记打开一个接口试着给旁边的人讲清楚这段代码为什么要这么写讲不出来的地方就是理解薄弱点马上翻回去看注释和调用链。今天整个项目过完大概花了三个多小时其中大概四十分钟都花在卡壳的节点上。1.2 黑马点评整体架构一个Redis撑起的高并发教学现场先给没接触过这个项目的朋友说一下它是什么。黑马点评本质是一个仿大众点评的本地生活平台典型功能包括短信验证码登录、商户查询缓存、优惠券秒杀、好友关注Feed流、附近商户搜索、签到统计、UV统计。技术栈以Spring Boot MyBatis-Plus MySQL Redis为主部分版本还会引入RabbitMQ做异步解耦。项目里Redis不是单纯当缓存用而是贯穿了一整条高并发业务线。登录态用Redis存商户数据用Redis缓存分布式锁用Redis实现秒杀库存扣减和异步下单也用Redis完成Feed流的排序用Redis的zset附近商户用Redis的GEO签到用BitMapUV统计用HyperLogLog。一个数据库把这么多数据结构都用上了这是这个项目最大的价值——面试官问Redis的时候你几乎能从任何一个方向展开讲案例。从架构上看它的主流程可以概括为前端请求进来先过登录拦截器Redis校验token并刷新有效期再进入业务层。商户查询类请求优先走Redis缓存缓存未命中才回源MySQL秒杀类请求先经过Redis预扣减成功后再异步创建订单Feed流请求从Redis的收件箱里拉取关注博主的动态滚动分页返回。理解这条主链路比记住一个个孤立的知识点有用得多。1.3 复习路径拉代码、画流程、对着空屏给自己讲我今天的复习路径不是从头看视频而是直接拉出项目源码按照登录、缓存、秒杀、Feed流、附近商户、签到、UV这个顺序把每一个模块的核心代码找出来在草稿纸上画一遍请求时序图然后合上代码对着自己画出来的图重新讲一遍业务逻辑。为什么要画时序图因为黑马点评的很多方案是多个组件协作完成的光看代码看不出全局。比如秒杀功能涉及Redis判断库存Lua脚本扣减Stream异步下单数据库订单创建这几个步骤环环相扣。只盯着某一个类看会觉得好像每个方法都不复杂但面试官问整个秒杀流程是怎么串起来的就答不完整。画图能强迫你把每个参与者的职责理清楚Redis负责什么、MySQL负责什么、MQ负责什么、订单服务负责什么。这里也推荐一个复习小技巧把每个模块录一段语音或者用文字写一篇给自己讲的笔记。不用追求文采只需要能用三两句话说清楚这个模块解决什么问题、用了什么技术、为什么这么选、有什么缺陷。能写出来才是真的理解。2. 黑马点评核心模块复盘缓存、锁与秒杀的关键答案2.1 缓存三兄弟穿透、击穿、雪崩面试标准口径这三个概念是黑马点评的高频考点今天重新复盘一遍。第一个是缓存穿透。指请求查询了一个数据库里根本不存在的数据缓存永远不可能命中于是一堆请求直接打到数据库。解决方案有两种第一种是缓存空值把查询结果为null的数据也写入Redis设定一个较短的过期时间比如5分钟这样后续相同请求就能命中缓存。第二种是布隆过滤器在缓存前挡一道把所有可能存在的数据hash到一个bit数组里查询前先判断key大概率是否存在不存在直接拒绝。布隆过滤器的问题是存在误判率和删除困难所以大多数业务场景里缓存空值是更简单实用的方案。第二个是缓存击穿。指一个热点key过期的那一瞬间大量请求同时发现缓存没命中全部涌向数据库。解决方案刚才讲过有两种互斥锁和逻辑过期。互斥锁的思路是让第一个发现缓存失效的线程去查数据库其他线程阻塞等待等缓存重建完成再放行。逻辑过期则是缓存永不真正过期value里存一个逻辑过期时间发现过期后尝试获取锁由拿到锁的线程重建缓存其他请求直接返回旧数据。要注意逻辑过期方案的缺点是数据一致性有窗口适合允许短暂不一致的场景比如商品详情页这种。第三个是缓存雪崩。指大量key同时过期或者Redis实例宕机导致所有请求一起去压数据库。解决手段一是给过期时间加随机值让key的过期时间错开比如基础过期时间加0到300秒的随机偏移二是Redis集群高可用配合多级缓存兜底三是服务层加限流和降级。面试时要把三种场景分开讲清楚不要混在一起这是最容易扣分的地方。2.2 分布式锁从SETNX到Lua锁的重入与释放陷阱黑马点评里分布式锁主要用在两个地方缓存重建和秒杀下单。缓存重建的互斥锁、秒杀的一人一单并发控制本质上都是保证同一时刻只有一个线程操作共享资源的需求。实现细节上早年大家喜欢用SETNX key value加锁再用EXPIRE key seconds设过期时间但这两个命令不是原子的如果加锁后还没来得及设过期时间进程就挂了锁会永久不释放。正确做法是用一条命令SET key value NX EX seconds。释放锁的时候也不能直接DEL因为持有锁的线程可能因为执行时间太长锁已经过期被其他人抢走了这时候旧线程去DEL会把别人的锁删掉。标准做法是删除前先比对value是自己加锁时设置的唯一标识才删而且比较和删除必须用Lua脚本保证原子性。今天复习时我又重新看了一遍Lua脚本的写法核心逻辑就两行先get key判断value等于当前线程标识等于才执行del。这个脚本在Redis里是原子执行的所以不存在判断完还没删就被其他线程抢锁的窗口。顺着这个思路可以自然引出Redisson的看门狗机制——Redisson为什么能自动续期因为它给锁加了一个后台定时任务每10秒检查一次锁是否还持有如果持有就续期到30秒。这些扩展点面试时能讲出来说明你是真的踩过并发问题的。2.3 秒杀下单全流程Lua扣库存与异步订单的协作秒杀是黑马点评里最复杂的模块今天我把它的完整链路重新串了一遍。整个流程分为四步第一步判断秒杀是否在有效时间内。这个直接在Redis里查秒杀活动的开始结束时间。第二步用Lua脚本执行判断库存是否充足 扣减库存 记录用户是否已买过三个操作一次网络请求内完成避免并发超卖。第三步把下单请求发给Stream消息队列异步处理。第四步后台消费者从队列里取出消息创建数据库订单并扣减真实库存。为什么不用数据库行锁直接扣库存因为秒杀场景并发极高所有请求都去MySQL抢行锁会拖垮数据库。先用Redis把大部分请求过滤掉——要么库存不足要么已经买过——真正到数据库下单的请求量就小得多了这时候再配合数据库的乐观锁或唯一索引兜底。这里有个容易忽略的细节一人一单不能只靠Redis里记录userId因为Redis记录也可能被绕过最好的兜底是在订单表上建联合唯一索引从数据库层面保证同一个用户同一场活动只能有一条订单。面试官问到超卖怎么办一人一单怎么保证时把这套多层防线讲出来基本就稳了。2.4 Feed流与附近商户推模式、滚动分页和GEO这两个模块是黑马点评里比较有亮点的部分也是很多面试官的追问点。好友关注后查看动态Feed流项目里用的是推模式当一个博主发布新笔记时系统直接把笔记ID推送到所有粉丝的收件箱。收件箱用什么结构存用的是zsetscore存发布时间戳member存笔记ID。粉丝刷动态时直接从自己的收件箱里按时间倒序取数据。这里的关键难点是滚动分页。Feed流不能用传统的page分页因为动态是实时变化的用页码会出现重复或遗漏。项目里用lastId加offset结合ZREVRANGEBYSCORE实现滚动分页记录上一次返回的最后一条动态的时间戳和相同分数下已取数量下一次查小于这个时间戳的数据如果分数相同则跳过已读的offset。这个细节值得反复看因为滚动分页是短视频和社交产品面试里的高频题。附近商户模块用的是Redis的GEO。GEO底层是zset每个商户的经纬度会被编码成一个52位的geohash整数作为score查询附近商户时用GEOSEARCH key FROMLONLAT 经度 纬度 BYRADIUS 5 km ASC就能按距离排序返回。面试时能讲出GEO底层是zset通过geohash把二维坐标映射成一维分数这句话会比只说用GEO查显得专业得多。另外要注意GEO的坐标信息只能追加不能修改如果商户经纬度变了需要删除后重新添加这是一个容易踩的坑。2.5 签到与UV统计BitMap和HyperLogLog的底层逻辑签到这个功能核心是BitMap。Redis的BitMap本质是字符串每一位用0或1表示是否签到按天为单位偏移。比如用户ID是10086签到月份是7月那Redis的key可以设计成sign:10086:202507第10天签到就把第10位设为1。统计当月签到总天数直接用BITCOUNT统计连续签到天数则把最近一个月的数据取出来做位运算从当前位开始往前逐位判断。UV统计则用HyperLogLog。HyperLogLog的核心思想是用极小的内存统计一个集合的基数标准误差在0.81%以内。几十万甚至上百万的UV内存只占12KB左右。它是通过hash后分桶、计算前导零个数来估算唯一值数量。面试官如果问亿级UV怎么统计你给出HyperLogLog方案并说明误差范围就已经超过大半候选人了。但要注意HyperLogLog不能精确统计也不能查询某个具体用户是否访问过适合UV这种只需要看量级的场景。3. 栈题第一道有效的括号基础栈的边界全在这3.1 题目与思路不是简单匹配关键是抵消顺序今天刷的第一道栈题是LeetCode 20题有效的括号。题目要求判断一个只包含()[]{}的字符串是否有效有效条件是左括号必须用同类型右括号闭合并且按正确顺序闭合。这道题看似简单但它考察的是对栈这一数据结构最本质的理解栈天然适合处理最近的、需要依次抵消的匹配关系。思路也很直接遇到左括号就压栈遇到右括号就弹出栈顶元素检查类型是否匹配。如果遍历过程中发现栈为空说明右括号多余或者弹出的左括号类型不匹配直接返回false遍历结束后如果栈不为空说明有左括号剩余也返回false。3.2 代码实现与三个容易写错的地方直接上代码class Solution { public boolean isValid(String s) { DequeCharacter stack new ArrayDeque(); for (char c : s.toCharArray()) { if (c ( || c [ || c {) { stack.push(c); } else { if (stack.isEmpty()) { return false; } char top stack.pop(); if (c ) top ! () { return false; } if (c ] top ! [) { return false; } if (c } top ! {) { return false; } } } return stack.isEmpty(); } }这里有三个容易写错的地方。第一个是必须用Deque而不是老的Stack类Java官方已经不建议用Stack了因为它继承了Vector有很多锁开销面试用ArrayDeque是更规范的写法。第二个是判断顺序右括号触发时先判断栈是否为空为空就直接返回false这个判断漏掉的话)这种用例就会报错。第三个是遍历结束后必须检查栈是否为空否则(()这种用例会被误判为有效。再加一个优化技巧可以用HashMap把右括号映射到对应的左括号代码会更简洁class Solution { public boolean isValid(String s) { DequeCharacter stack new ArrayDeque(); MapCharacter, Character map Map.of(), (, ], [, }, {); for (char c : s.toCharArray()) { if (!map.containsKey(c)) { stack.push(c); } else if (stack.isEmpty() || stack.pop() ! map.get(c)) { return false; } } return stack.isEmpty(); } }3.3 这道题背后的面试意图栈的基本功考察有效的括号这种题面试官看着简单但能挖出不少东西。第一层是数据结构基本功你知不知道栈是LIFO的能不能用栈解决嵌套匹配问题。第二层是代码严谨性边界条件处理得干不干净空栈判断和不匹配判断有没有写全。第三层是扩展能力面试官可能会追一句如果是带通配符的括号匹配怎么做或者如果括号类型不止三种比如加了尖括号怎么改这时候能反应过来只要改Map映射就行说明是真的理解了。我刷这道题的一个心得是不要因为题简单就跳过简单题的边界条件往往是最能暴露代码习惯的地方。今天写的时候我就因为忘了最后检查stack.isEmpty()错了一次这种低级失误在面试白板编程时一旦出现非常减分。4. 栈题第二道每日温度把单调栈的思路彻底讲透4.1 暴力解法为什么不行从O(n^2)到O(n)第二道题是LeetCode 739题每日温度。题目给一个温度数组temperatures要求返回一个等长数组每个位置表示要等多少天才会等到更高的温度如果没有就填0。最直的思路是双层循环对每个温度往后遍历找第一个更高温度的下标。这个解法的时间复杂度是O(n^2)在数组长度10万级别时直接超时。优化的突破口在于很多元素的下一个更高温度其实可以提前从后往前推算出来而且可以用一个栈来完成这种推算。4.2 单调栈原理为什么叫单调栈里存的是什么单调栈的核心思想是维护一个栈内元素单调递减从栈底到栈顶的栈。具体到每日温度这道题栈里存的是下标而不是温度值本身。为什么存下标因为答案要求返回的是相隔几天——用下标相减就能得到。为什么温度要单调递减因为当新遍历到的温度比栈顶元素对应温度高时说明栈顶元素等到了自己的下一个更高温度此时栈顶元素就可以出栈结算了。整个过程可以理解成所有人按顺序排队但只排一个序列每个人都在等右边第一个比自己高的人。新来的人如果比前面的人高前面那些矮个子就可以离开队伍去结算等待天数如果新来的人矮那就入栈继续等。所以栈里一直是从栈底到栈顶温度递减的状态这就是单调栈这个名称的来源。一个经常被人问到的问题是单调栈到底单调递增还是单调递减答案不固定题目要求不同维护方向也不同关键是理解什么条件下出栈结算。4.3 代码实现与复杂度分析class Solution { public int[] dailyTemperatures(int[] temperatures) { int n temperatures.length; int[] ans new int[n]; DequeInteger stack new ArrayDeque(); for (int i 0; i n; i) { while (!stack.isEmpty() temperatures[i] temperatures[stack.peek()]) { int idx stack.pop(); ans[idx] i - idx; } stack.push(i); } return ans; } }关键点有两个一个是while循环新温度进来后把所有比它温度低的栈顶元素都结算一遍而不是只结算一次另一个是结算完成后把当前下标入栈等待它右边的更高温度。每个元素最多入栈一次、出栈一次所以时间复杂度O(n)空间复杂度O(n)。顺手把复杂度分析放在一起对比方案时间复杂度空间复杂度适用场景暴力双层循环O(n^2)O(1)数据量小单调栈O(n)O(n)数据量大必须线性解4.4 扩展接雨水、下一个更大元素、backtrace里的栈回溯每日温度只是单调栈的入门题它的解题模板可以迁移到很多经典题上。最典型的扩展是LeetCode 42题接雨水——本质是找每个位置左右两侧的最高柱子用维护单调递减栈、遇到更高的就结算的思路就能解。还有LeetCode 496题下一个更大元素、LeetCode 84题柱状图中最大的矩形都是同一个模板。这里顺便提一个容易混淆的点栈在真实世界的应用远不止算法题。程序运行时的函数调用栈就是典型的栈结构函数调用时压栈、返回时弹栈。崩溃排查时用到的backtrace栈回溯就是沿着调用栈逐层恢复出是谁调用了谁的现场arm架构下的调用栈回溯还要考虑寄存器和栈帧的布局。搞懂这些再看算法题里的栈会有一种原来数据结构真的是从底层长出来的的感觉。同样大家常说的堆和栈里的栈其实也是指运行时内存中的栈区它和算法里的栈结构在思想上一脉相承——后进先出。5. 面试现场栈题和项目怎么串起来讲5.1 面对你最近在学什么的开放式提问面试经常会有一个开放式问题你最近在做什么这时候把今天的复习内容组合起来说是很加分的。你可以说我最近在复习一个基于Redis的本地生活项目重点关注缓存一致性和高并发秒杀场景同时每天保持刷两到三道算法题近几天集中过一遍栈和单调栈的题目。这样既展示了项目深度又展示了持续学习的技术热情两个信息点互相佐证。5.2 黑马点评高频追问和答题口径我根据今天复习的内容整理了几个高频追问的标准答题口径。第一个缓存击穿和缓存穿透有什么区别答穿透是查一个不存在的数据缓存和数据库都没有击穿是缓存中热点key过期大量请求同时回源数据库。一个针对不存在数据一个针对热点数据失效。第二个Redis分布式锁怎么防止锁误删答加锁时设置唯一标识释放锁时先get比对是自己的锁才del且用Lua保证比较和删除原子执行。第三个秒杀超卖问题怎么解决答三层防线第一层Redis Lua脚本原子扣减库存第二层数据库乐观锁控制库存更新第三层订单表唯一索引兜底一人一单。第四个Feed流为什么不用MySQL分页答动态是实时写入的传统分页有偏移量不准的问题用zset按时间戳滚动分页更合适。这些口径最好写在本地笔记里面试前快速过一遍。5.3 栈题在面试中的常见追问方向栈题本身也可能被问出花来。比如做完每日温度面试官可能会问如果有平温怎么办答案严格大于才出栈等于不出栈所以答案是0或者问如果题目改成找右边第一个小于当前温度的单调栈怎么调答案把while条件改成小于维护递增栈。再比如有效的括号变种如果括号嵌套深度限制为K层怎么办每次入栈时记录深度超过K直接返回false如果字符串特别长怎么优化遇到不合法的右括号直接提前短路不需要全部遍历。这些变种问题在面经里出现频率很高建议刷题时顺手多想一想不要只满足于AC。6. 第43天复盘小记踩坑与下一步计划6.1 今天复习踩到的两个坑写出来提醒自己第一个坑是复习时差点陷入看视频回放的舒适区。我一开始打开之前存的课程视频想倍速过一遍看了十分钟发现完全没有记忆留存立刻关掉了改成拉源码对着画图。这里想提醒大家项目复习最忌讳用看视频的方式因为人很容易在视频里产生我会了的错觉合上电脑全忘。正确方式是主动回忆哪怕想不起来翻笔记也比被动看视频强。第二个坑是写每日温度时一开始想用暴力法提交后再看答案结果提交超时后养成了AC就行的心态差点跳过单调栈分析。后来逼着自己把单调栈的结算过程用具体例子推演了一遍才发现之前理解有偏差——我一直以为栈里存的是温度值实际上必须存下标。这种其实没懂的感觉只有自己动手模拟一组数据才能发现。刷题不能只看答案要能亲手在纸上跑通一遍才算数。6.2 接下来的安排从Redis细节到更多栈题变种今天复盘完黑马点评我发现自己对缓存一致性——先更新数据库还是先删缓存这个问题还讲不透彻明天的计划是专门研究Cache Aside模式和延迟双删策略把这一块吃透。栈题方面我准备明天把接雨水和柱状图最大矩形这两道单调栈进阶题刷完然后做一个小结对比下一个更大元素和最大矩形面积两种结算模式的区别。刷题加项目交替进行每天都有输出这种感觉是刷题马拉松里最宝贵的正反馈。按我个人经验项目复盘和算法刷题的最佳比例大概是二比一或者一比一。第43天是一个很微妙的时间节点既要防止刷题刷到麻木又要防止项目复习变成背八股。如果能每天都像今天这样用一个核心项目模块加一组同类算法题做组合坚持到100天的时候你会发现面试里那些既问项目又问算法的压力面其实也没有想象中可怕。