
0x3f 第43天黑马点评全量复习 栈两题今天是我跟着 0x3f 刷题打卡的第四十三天按计划该把“黑马点评”项目整个过一遍再配两道栈相关的题目练手。说实话到了第 40 天往后每天的任务已经不是“学新东西”了而是“跟遗忘作斗争”。尤其是黑马点评这种全栈项目知识点横跨前端小程序、后端 Spring Boot、Redis、MySQL甚至还有 Nginx 和 Linux 部署一两天不看细节就开始模糊。所以今天这篇不打算空谈什么“项目心得”我就把复习的完整思路、栈题目的两种典型解法以及实测过程中踩到的坑原原本本记录下来希望能给正在刷这个项目或者准备面试的朋友一点参考。先说这个打卡系列对我个人的价值。0x3f 的题单和项目复习节奏设计得比较合理每天的量不大但讲究“重复”和“收尾”。比如今天这种“全量复习栈两题”的组合本质上就是逼你跳出局部细节站在高处把整个项目的骨架重新捋一遍再通过算法题把计算机基础里最常用的一类数据结构——栈练到手熟。这两件事看起来不相关实际上都非常依赖“结构化思维”。项目复习讲究模块划分和依赖关系栈题目讲究函数调用和状态管理底层逻辑是相通的。如果你是正在学 Java 后端、准备找实习或者秋招的朋友这套复习方式可以直接照抄。不需要什么高深的基础哪怕你刚开始看黑马点评两周也可以试着用我下面这套“模块扫盲面试追问”的方法把项目串一遍。至于栈那两题我会尽量把单调栈和括号匹配这两类最常见考法的思考过程讲透——这两题做明白了栈相关的笔试题至少能覆盖七八成。1. 黑马点评全量复习先把项目骨架重新立起来复习跟第一次学项目完全不一样。第一次学的时候你是跟着视频一步步敲关注点全在“这行代码怎么写”“这个注解什么意思”上面。但复习阶段的重点不是代码而是“为什么这里要这么设计”。所以我给自己定的复习顺序是先不看代码靠记忆画出整个项目的模块图和请求流转路径画不出来的地方就是薄弱点再回头去看源码。黑马点评这个项目表面上看是一个类似大众点评的商户点评平台核心功能有用户登录、商户查询、优惠券秒杀、好友关注、签到和附近商户等。但如果把视角拉高你会发现整个项目其实是在用各种技术解决几个非常典型的业务问题。首先是登录状态管理。黑马点评用的是基于 Token 的登录方案用户登录成功后服务端生成 Token 并存入 RedisRedis 里同时维护一个登录用户的 Hash 结构key 是 Tokenvalue 是用户信息。请求进来的时候通过拦截器HandlerInterceptor统一校验 Token校验通过就把用户信息放入 ThreadLocal 中方便后续业务代码直接获取当前用户。为什么要用 Redis 而不是 JWT答案很简单要支持服务端主动失效以及方便存储额外的会话数据。这里经常被面试官追问的细节是Token 过期时间怎么设计的如果用户在活跃中怎么实现“续期”黑马点评的做法是在拦截器中判断剩余过期时间如果不足一定阈值就调用 expire 刷新过期时间。这个点很值得记下来很多项目里都会用到类似“滑动过期”的策略。其次是缓存设计。商户查询是典型的读多写少场景所以项目用 Redis 缓存商户信息缓存结构是 String 类型key 为cache:shop:{id}value 是 JSON 序列化后的商户数据。这里有一个经典的缓存问题链缓存穿透、缓存击穿、缓存雪崩。黑马点评里对穿透的解决方案是缓存空值加布隆过滤器对击穿的方案是互斥锁或逻辑过期。我当时复习的时候在这块卡了很久后来把三种问题的成因和解决方案整理成一个表格才彻底理顺。然后是秒杀系统。优惠券秒杀是黑马点评里技术含量最高的一个模块涉及库存扣减、一人一单、异步下单三个层次。库存扣减用 Redis 的 Lua 脚本来保证原子性一人一单用 Redis 的 Set 集合记录已购买用户下单流程通过 Stream 消息队列异步处理最终数据落库到 MySQL。这块如果面试被问到大概率会顺着“超卖怎么解决”一路追问到“Lua 脚本为什么能保证原子性”。我复习的时候专门把 Lua 脚本的语法和 Redis 执行机制重新看了一遍因为只背结论不追原理的话现场很容易被问穿。最后是 Feed 流与关注推送。项目用 Redis 的 ZSet有序集合实现了基于关注关系的 Feed 流推送score 用时间戳实现按时间倒序排列。收到关注博主发布新笔记的通知后将笔记 ID 推送到粉丝的收件箱中粉丝拉取时按 score 排序后分页查询笔记详情。这块涉及的难点是分页不能再用传统的 limit/offset因为数据量大了以后偏移量大性能差项目里用的是 ZSet 的 ZREVRANGE 配合 score 滚动分页来做的。把骨架重新立起来以后我才会打开源码做一些细节对照。比如看 ThreadLocal 的清理时机、看拦截器注册顺序、看 Redis 序列化器的配置等。这些细节是面试里“加分项”的来源——大家都知道项目用了 Redis但只有你清楚 Redis 的 key 前缀规范、过期时间策略和序列化方式才能显得项目是真正落地过的。1.1 核心业务模块的功能拆解与依赖梳理复习的第二层是把每个模块的请求链路画清楚。我习惯用“前端发起请求 - Controller - Service - Redis/MySQL/Lua”这种方式去画流转图。画完后重点标出哪些环节是直接读写 Redis 的哪些是走 MySQL 的哪些是先 Redis 后异步落库的。黑马点评里的请求基本可以分为三类。第一类是纯 Redis 操作型比如发送验证码、校验登录状态、查询缓存商户。这些请求响应快对 Redis 的依赖极高。第二类是 Redis MySQL 联动型典型的比如点赞笔记。点赞状态存在 Redis 的 Set 里但点赞总数需要同步到 MySQL 的笔记表中这就涉及双写一致性的问题。第三类是纯异步型比如秒杀的创建订单环节用户请求只是把订单消息丢到 Stream 里然后立刻返回“排队中”真正的订单创建是后台异步去消费消息完成的。这样拆解完之后你对项目的整体认知就从一个一个孤立的视频章节变成了一张有依赖关系的网状结构。面试官随便从哪个模块切入你都能顺着链路往下讲不会卡壳。1.2 技术选型背后的原因分析和取舍逻辑很多初学者复习项目时只看“用了什么”不看“为什么用这个而不用那个”。黑马点评的技术栈选型非常典型几乎每个组件都对应一个明确的问题场景。我复盘的时候把这些选型背后的逻辑过了一遍整理成下面这张表。技术组件解决的问题备选方案为什么选它RedisString/Hash/Set/ZSet缓存、登录态、秒杀库存、Feed 流ConcurrentHashMap/本地缓存分布式场景下多实例共享数据内存高性能支持丰富数据结构Lua 脚本秒杀扣库存的原子操作Redis 事务MULTI/EXECLua 脚本在执行期间不会被其他命令插入减少网络通信次数兼顾原子性与性能Stream 消息队列异步处理秒杀订单RabbitMQ/Kafka项目简化、不引入额外中间件Redis 5.0 后自带 Stream足以支撑演示级场景ThreadLocal请求链路内共享用户信息方法参数传递避免在每个方法中传一遍用户对象结合拦截器实现无侵入式存取布隆过滤器规避缓存穿透缓存空值布隆过滤器判定“不存在”时绝对准确能直接把非法请求挡在 Redis 前面这张表我建议你自己动手整理一遍因为整理的过程就是逼迫自己思考的过程。比如“为什么用 Stream 不用 RabbitMQ”标准答案是“减少依赖、起项目方便”但更深入的答案是“秒杀场景下单消息允许丢失吗允许延迟吗如果允许Stream 的简单可靠就比 RabbitMQ 的功能丰富更有价值”。这种取舍思路才是面试官真正想听的。2. 栈两题为什么“栈”是算法题里的基础题黑马点评复习完我按计划刷了两道栈相关题目。栈这个数据结构看着简单——后进先出LIFO但它在算法题里的出场率非常高因为它天然契合“嵌套结构”和“状态回溯”这两类问题的求解思路。为什么说栈是算法题里的基础因为很多“看起来完全不像栈”的题目最后都能归约到栈。比如函数调用时系统栈的管理就是一个天然的栈结构。再比如浏览器后退前进、操作系统的撤销操作、文本编辑器的括号匹配校验全是栈的经典应用场景。我在复习时习惯把栈题目归纳成三大类括号匹配类、单调栈类、栈模拟类。今天挑的这两题正好覆盖了其中最常见的方向。2.1 第一题有效括号的嵌套匹配问题第一题是很经典的“有效括号”变形题给定一个只包含()[]{}六种字符的字符串判断字符串是否有效。有效规则只有两条——左括号必须用相同类型的右括号闭合左括号必须以正确的顺序闭合。这题本身不复杂但它的“栈解法”是整个数据结构课程里最经典的教学案例之一。核心思路是遍历字符串时遇到左括号就压栈遇到右括号就弹栈并检查是否匹配最后检查栈是否为空。如果遍历过程中出现弹栈失败栈为空或栈顶元素不匹配则直接返回 false。为什么这题天然应该用栈因为括号匹配的本质就是“最近遇到的一个左括号必须被最近的右括号闭合”这个“最近优先”的逻辑正是后进先出的定义。我在刷这题的时候额外做了一步优化用 Map 把右括号和对应的左括号映射起来代码会比逐个 if-else 判断精炼不少。实测下来哪怕题目简单写出简洁可读的代码仍然能让面试官对你的代码风格留下好印象。还有一个细节值得注意遇到右括号时先检查栈是否为空——很多初学者会忽略这个条件导致在}这种非法输入上报空栈错误这种边界条件恰恰是笔试判题最容易挂的地方。2.2 第二题单调栈解决下一个更大元素问题第二题是典型的“下一个更大元素”Next Greater Element问题给定一个数组返回每个元素右侧第一个比它大的元素没有则返回 -1。这题如果暴力解两层循环 O(n²) 搞定但当数组长度达到 10^5 量级时就会超时。单调栈解法能把时间复杂度降到 O(n)是“用空间换时间”的典型代表。单调栈的思路值得好好讲讲因为它稍微绕一点。维护一个栈保证栈中元素从栈底到栈顶是单调递减的。遍历数组时如果当前元素比栈顶元素大说明当前元素就是栈顶元素的“下一个更大元素”——此时弹出栈顶并记录答案如果当前元素小于等于栈顶就把下标压栈继续往前走。每个元素最多被压入和弹出一次整体复杂度就是 O(n)。这里有个细节很容易踩坑栈里存的是数组下标而不是元素值。这题要是存值就得额外记录值对应的位置实现起来非常别扭。存下标的好处是既能通过下标拿到值又能直接定位答案数组的写入位置一举两得。我实测下来用下标入栈比用值入栈写起来顺手得多建议刷这题时直接养成存下标的好习惯。为了把单调栈这个思想练透我还会顺带看一下“每日温度”和“接雨水”这两道经典题。这两道题思路跟“下一个更大元素”一脉相承都是靠单调性维护“等待被匹配的候选元素”反复刷完以后你会发现单调栈的核心不是栈本身而是“利用单调性剪枝暴力搜索”。这是我在面试里答单调栈题的重要心法。2.3 栈的原理解读从数据结构到调用栈回溯栈题目刷到一定数量后我建议停下来想一想“栈到底在计算机里是以什么形式存在的”。这个话题在面试里也经常被变着法地问比如“函数调用的底层是怎么实现的”答案的核心就是调用栈Call Stack。每一次函数调用系统会把返回地址、参数和局部变量压入栈帧Stack Frame函数返回时再弹出。这个机制保证了嵌套调用可以层层展开、逆序回收其生命周期跟栈一脉相承。顺着这个方向延伸就涉及到一个很有用的实践技术——栈回溯Stack Backtrace。调试器里打印的调用堆栈就是通过遍历当前线程的栈帧地址来实现的。在 x86 架构上通常利用 EBP/RBP 寄存器保存栈帧基址沿着链表结构逐层回溯函数调用信息。在 ARM 架构上略有不同ARM 没有强制的帧指针寄存器所以更常用的做法是用链接寄存器LR保存返回地址配合栈上的保存区域由编译器按调用约定生成回溯信息。这也是为什么 ARM 上的栈回溯在某些深度优化场景下会更依赖调试信息或 unwind tables——没有标准的帧指针单纯扫描栈并不靠谱。我在项目调试里就遇到过undefined behaviour 或者栈被破坏的时候回溯出来的调用链是瞎的甚至直接崩溃这时就得靠 MemorySanitizer 或者 AddressSanitizer 这类工具来辅助定位。这样把算法里的抽象“栈”和系统里的具体“调用栈”联系起来之后你对栈这个数据结构的理解就不再停留在做题层面了。面试被问到“栈的应用场景”你既能答编译器的括号匹配和表达式求值也能答操作系统的调用栈与中断处理还能答栈回溯的原理这几个点一亮出来立刻跟只会背定义的候选人拉开差距。3. 黑马点评的复习实操模块扫盲与面试追问清单复习光看和背不行必须动手做输出测试。我自己的方法是“模块扫盲三步走”每个模块都走一遍效果远比重新二倍速看视频好得多。第一步是默写该模块涉及的 Redis 数据结构或核心类接口。比如秒杀模块我会先默写 Lua 脚本里的几个关键命令SISMEMBER判断是否已买过、INCR减库存、SADD记录用户然后对照自己记忆中的脚本去比对。如果发现某个命令写错了说明这段知识已经淡了需要马上回看。第二步是手画请求时序图不要求精确到每个方法名但要标清 Controller、Service、Redis、MySQL 之间的调用顺序。第三步是针对模块整理一道高频面试题用口述的方式独自回答一遍。比如缓存模块我就整理了一题“Redis 缓存与 MySQL 数据一致性问题如何解决”回答时我要先讲清楚是 Cache Aside Pattern再讲更新顺序为什么是先更新数据库再删除缓存再讲极端情况下的延迟双删策略。这套流程走下来一个模块大概需要四十分钟到一小时整个项目拆成八个模块分三天刚好能过一遍。今天的进度其实就推进到了登录和缓存两个模块秒杀和 Feed 流留给明天继续做。3.1 登录模块复习要点Token 会话与 ThreadLocal 管理登录模块我建议重点复习三个细节。第一验证码的存储与校验逻辑。SendCode接口将验证码保存到 Rediskey 是login:code:{phone}TTL 设为 5 分钟校验时先查 Redis 再比对。这里最容易忽略的是“验证码是否区分登录端Web/App”和“验证码重发时的 TTL 覆盖策略”我在面试中被问到过当时没答好因为真没想过。第二Token 的生成和续期策略。Token 用什么生成我用的 UUID有些资料会用 JWT但在这个项目里服务端 Session 需要主动失效UUID 更合适。续期策略是每次请求通过拦截器时判断剩余有效期是否小于 30 分钟小于则刷新为 30 分钟。第三ThreadLocal 的清理时机。这里有一个高频坑前置拦截器设置用户后置拦截器必须清理用户否则线程池复用会串号。项目里我用的是afterCompletion方法中调用UserHolder.remove()这一点在面试中属于典型的“加分细节”。3.2 缓存模块复习要点穿透、击穿、雪崩的攻防设计缓存模块是黑马点评里最容易展示深度的模块。穿透靠“缓存空值”和“布隆过滤器”击穿靠“互斥锁”和“逻辑过期”雪崩靠“TTL 随机化”和“多级缓存”。这些方案光背下来不够你得能说出各自的优缺点。比如互斥锁方案缓存命中率最高但会有线程阻塞逻辑过期方案性能最好但更新缓存时可能出现短时间数据不一致。我建议按下面的思路来记穿透是“查了一个根本不存在的数据”说明根本不是正常流量所以要在最前面挡住击穿是“热点 key 失效瞬间大量并发打向 DB”核心是让重建缓存的动作只允许一个线程执行雪崩是“大面积 key 同时失效”本质是错峰和降级。除此之外黑马点评还引入了缓存工具类的封装思路把“查缓存 - 命中直接返回 - 未命中查 DB - 回填缓存 - 返回”这套通用逻辑抽象成模板方法。复习时如果能自己手写这样一个带泛型和函数式接口的工具类对理解“缓存操作标准化”非常有帮助。3.3 秒杀模块复习要点从超卖问题到异步下单秒杀模块是整个项目里技术含量最高的部分也是面试重点中的重点。复习时一定要分清三个层次因为面试官大概率会层层递进地追问。第一层是“库存不足”和“超卖”问题。超卖的原因很简单多个线程同时读到库存为 1都执行了扣减最后库存变成负数。解决方案有两种思路——悲观锁数据库行锁select ... for update和乐观锁compare and set更新时检查库存大于 0。黑马点评里用的是 Lua 脚本在 Redis 端做原子扣减用INCR后判断是否超过预存库存量来决定是否下单这其实是一种更彻底的原子化方案。第二层是“一人一单”。单纯扣库存还不够还得防止同一个用户抢多次。实现上在 Lua 脚本里用SISMEMBER判断该用户是否已存在于已购买集合中存在则直接返回失败不存在则通过SADD加入并扣减库存。整个过程在 Redis 中原子完成天然避免了多线程下“先查询再插入”的竞态问题。第三层是“异步下单”。为了减轻数据库压力用户抢购成功后不直接写订单到 MySQL而是将创建订单的消息发布到 Redis Stream 中由独立线程或消费者组异步消费真正把订单数据插入数据库。消息里除了 userId 和 voucherId还会带上唯一标识消费端做幂等处理防止消息重复投递导致重复下单。这三层逐级递进的解决方案基本就是秒杀系统从简单到完善的完整演进路径。复习完这一模块我建议你合上资料尝试用自己的话把“一个用户点击秒杀按钮从请求进入 Controller 到最终订单入库全链路经历了哪些步骤”捋一遍。如果能捋清楚秒杀模块就算是吃透了。4. 常见问题与排查技巧实录无论是复习项目还是刷算法题总会踩到一些重复出现的坑。我把自己这段时间印象比较深的几个问题整理成一份速查表希望帮你避开这些弯路。问题现象可能原因排查技巧 / 解决建议登录后请求部分接口仍提示未登录拦截器未放行预检请求或静态资源检查拦截器的 excludePathPatterns尤其注意 CORS 预检请求 OPTIONS 的放行Redis 缓存出现 JSON 序列化异常默认 JDK 序列化器生成了乱码数据统一切换为 Jackson JSON 序列化器并指定 key 的 String 序列化方式秒杀时 Redis 报错“NOAUTH Authentication required”未配置密码或 lettuce 连接池未正确初始化检查spring.redis.password配置项并确认 Redis 服务端确实设置了密码秒杀成功但订单表里没有数据Stream 消费者线程未启动或消费时抛异常未捕获确认消费者代码中的while(true)循环有 try-catch并检查 Stream 的 pending entries 是否堆积用单调栈解题时结果数组下错位置栈里存值而非下标导致定位答案位置困难统一用下标入栈答案数组通过ans[stack.pop()] 当前元素写入递归或深层调用导致栈溢出递归深度过大或线程栈大小不足若非算法必要尝试改写成迭代如用显式栈模拟必要时通过增大栈大小来规避但治本方案仍是消除过深递归栈回溯时调用链打印出现乱码或崩溃栈帧被破坏或缺少 unwind 信息配合 AddressSanitizer / MemorySanitizer 定位内存越界对 ARM 设备开启相关调试节如.debug_frame4.1 缓存相关的疑难杂症与避坑心得我在复习缓存模块时最常遇到的问题不是“方案不会”而是“方案落地后的隐藏坑”。举一个例子缓存空值方案可以防穿透但如果空值本身被恶意写入大量不同 keyRedis 内存会被无效 key 占满。正确的做法是给空值缓存也设置一个较短的 TTL比如 30 到 60 秒避免长期残留。布隆过滤器也不能完全替代空值缓存因为布隆过滤器的误判率是存在的两种方案结合使用才是企业级做法。另一个坑是 Redis 序列化。Spring Data Redis 默认使用的 JdkSerializationRedisSerializer 会把可读字符串变成人眼崩溃的二进制数据排查起来非常痛苦。我复习时会把配置类中重新定义 RedisTemplate 的setKeySerializer、setValueSerializer、setHashKeySerializer、setHashValueSerializer四项全部改为 StringRedisSerializer 或 Jackson 序列化器实测下来后RedisInsight 里看到的 key 和 value 才变得可读。最后再提醒一个关于“双写一致性”的细节。更新数据库后删除缓存这个操作本身可能失败所以一般情况下会引入重试机制比如用消息队列或本地消息表来记录删除失败的重试任务。虽然黑马点评项目里没有实现这一步但你面试时主动提出这个扩展点会让面试官觉得你不是只会对着视频敲代码的。4.2 单调栈的边界条件与性能分析单调栈的代码很短但写错非常容易。最典型的错误发生在“数组全部相等”的场景比如[1,1,1,1]。如果代码里用的是“当前元素大于栈顶才弹栈”那这些相等元素相互之间不会被识别为“更大”正确结果应该是全为 -1。但如果误用大于等于就会把相等的元素也当成“下一个更大”答案就错了。我刷题时特意用这个用例自测了一下发现很多标准模板代码都会有这个隐患因此判断“严格大于还是大于等于”是单调栈题的一大考点。性能上单调栈的时间复杂度是 O(n)空间复杂度最坏 O(n)。这意味着它能轻松处理百万级长度的数组但代价是需要维护一个额外的栈容器。实际刷题时如果题目还要求“输出下一个更大元素的下标距离”通常就是考单调栈的变形。这类题要特别注意单调性的维持方向——求“右边更大”是栈底到栈顶单调递减求“左边更小”则是单调递增。方向搞反了结果全错。4.3 调用栈回溯的调试实战心得最后聊一下调用栈回溯Stack Backtrace的调试心得。很多人觉得这跟算法刷题没关系但实际上它跟“栈”的概念强相关也是项目调试里的硬功夫。遇到程序崩溃时第一件事就是从 core dump 或者调试器里拿到调用栈。x86 Linux 上常用backtrace()函数族或 gdb 的bt命令Windows 上则是CaptureStackBackTrace。拿到原始调用栈后如果动态符号表还在可以用addr2line把地址换算成源文件和行号这是我定位线上闪退最常用的手法。在 ARM 设备上做栈回溯时情况会复杂一些。很多嵌入式设备上跑的是裁剪过的 Linux动态符号表和 unwind table 可能都没有遇到栈回溯失败时不要慌——先确认编译选项里有没有加-fno-omit-frame-pointer如果加了就能用帧指针回溯如果关掉了就得依赖.eh_frame段。我在调一个原生崩溃时就是因为编译优化开了 O2 导致帧指针被优化没了栈回溯全是乱的后来重新编一版带调试信息的才定位到越界写入位置。这个经验值得记下来因为面试官问“你排查过内存问题吗”的时候能讲出这段过程说服力远高于背概念。5. 项目复习与算法刷题的协同效应最后分享一点今天复习完最大的感受项目复习和算法刷题同时推进其实是有协同效应的。黑马点评里用到的 ThreadLocal、调用拦截器、递归查询菜单树底层全是数据结构或操作系统的基础概念。比如 Feed 流的滚动分页用到了类似链表分页的思路而链表反转恰好又是栈题的经典变体。又比如秒杀模块的 Lua 脚本本质上是一个原子状态机它的执行路径跟用栈模拟函数调用时的状态流转非常相似。我现在的习惯是每天算法题刷完后会刻意想一想“这题的数据结构或算法思想在我正在复习的项目里有没有对应的落地场景”。栈对应的是函数调用链和浏览器的前进后退队列对应的是 Stream 消息队列和 Feed 流的异步解耦哈希对应的是 Redis 的 Hash 存储跳表对应的是 Redis 的 ZSet 底层实现。把这些点串起来之后项目和算法不再是两个割裂的世界而你复习的效率和深度都会上一个台阶。话说回来复习终究是为了对抗遗忘遗忘是常态不必苛求自己一遍就记住全部。今天把黑马点评的登录、缓存、秒杀三大模块重新过了一遍栈题也做了两道整体进度符合预期。如果你也在刷这个系列不妨试试我上面提到的“模块扫盲三步走”和“做题后映射项目场景”的方法。反正实测下来比单纯看视频或者单纯刷题都要扎实得多。明天继续推进 Feed 流和关注模块栈题打算换到队列方向练几道到时候再记录新的体会。