首页 / 资讯中心 / 文章详情

Java后端面试实录:从秒杀系统到并发原理,一场八股文现形记

Java后端面试实录:从秒杀系统到并发原理,一场八股文现形记 ★ FEATURED ARTICLE
我把这场面试的每个细节都记录下来既当复盘也当反面教材。如果你正准备Java后端面试建议认真看到最后有些坑你大概率也会踩。那天的面试约在下午两点候选人化名谢飞机简历上写着两年Java开发经验项目栏里躺着一个“某商城秒杀系统”和一个“某支付网关”。面试官老周是组里的技术负责人出了名的爱往深处问。开场十分钟老周就发现了不对劲——这哥们儿不是一般的能扛属于那种“八股文背得滚瓜烂熟一问细节就原地起飞”的类型。整个面试过程精彩程度堪比综艺我坐在旁边做记录好几次差点笑出声。这篇文章把当时几个典型回合完整还原每个问题后面附上老周的复盘点评和对应的正确解法。一来给准备大厂面试的朋友提个醒二来也帮你们避开谢飞机踩过的那些坑。1. 项目经历拷问秒杀系统里最怕被追问的三个细节老周没有按套路问自我介绍上来直接点开简历里的秒杀项目。1.1 “你的秒杀接口怎么防超卖的”谢飞机眼睛一亮脱口而出“我用的是乐观锁update的时候加上 stock 0 的条件失败就返回秒杀失败这样就不会超卖。”老周点点头接着问“那你的乐观锁版本号是怎么设计的如果并发特别高大量请求同时进来你怎么控制库存扣减在数据库层面的锁竞争”谢飞机明显愣了一下支支吾吾半天憋出一句“我用的...我记得是Redis的Lua脚本把扣库存和判断库存放在一个脚本里执行的。Redis单线程嘛所以不会超卖。”老周没有放过他“那Redis和数据库的一致性怎么保证如果Redis扣减成功了数据库的更新失败了你怎么补偿Redis缓存里的库存数据和MySQL里的库存数据你以谁为准”谢飞机开始冒汗想了半天说“以数据库为准……然后Redis做异步同步失败了就重试”老周继续追问“那你Redis和数据库之间的同步方案是怎么设计的用的是消息队列还是定时任务如果同步失败了你怎么保证最终一致”谢飞机此刻的表情我至今难忘——眼神放空微微张嘴过了五六秒才说“这个我在项目里其实没有做得很深……就是Redis先扣然后MQ异步通知数据库那边再更新。如果失败的话可能就......会有一些数据不一致的情况。”老周笑了笑在纸上写了个“10分满分100”然后在旁边打字批注被动挨打没有主从视角。复盘一下老周想听什么。秒杀防超卖不是一个点而是一条链路。典型解法是这样的库存预热到Redis用Lua脚本原子扣减。Lua脚本里同时判断库存 0 和扣减动作保证原子性这一步没问题谢飞机答对了。数据库层同样需要兜底扣减SQL必须带上stock #{num}条件并且通过数据库行锁保证不会超卖这一步谢飞机没提。Redis和数据库的最终一致性常见方案是异步对账扣Redis成功后发送MQ消息数据库消费消息执行扣减MQ消费失败进入重试表由定时任务扫描补偿。真正讲究一点的团队还会设置对账任务每隔一段时间对比Redis的剩余库存和数据库的剩余库存发现不一致就告警并修正。老周要的是一个“你怎么保证我这一单不会超卖”的闭环答案而不是一句“Redis单线程所以不会超卖”。后者只是一段八股前者才是工程思维。1.2 “缓存穿透、击穿、雪崩你怎么解决”谢飞机这次吸取了教训抢答模式全开“穿透就是查一个不存在的数据缓存和数据库都没有所以每次请求都打到数据库上。解决方法是布隆过滤器把可能存在的数据先过滤一遍。击穿是缓存过期正好大量请求进来同时打到数据库。雪崩就是大量缓存同时失效数据库被压垮。解决方法是加锁、加随机过期时间、设置热点数据永不过期……”他像开机关枪一样把概念背了一圈。老周等他背完慢慢问了一句“那你说说布隆过滤器有没有误判率误判是误判成‘存在’还是‘不存在’能不能删除元素”谢飞机卡住了想了半天才说“好像可以把某个位改成0……应该是能删的吧”老周微微一笑“布隆过滤器删除元素这件事你自己实际写过吗”谢飞机摇了摇头。实际上布隆过滤器存在误判率误判方向是判断“可能存在”的元素实际上不存在它不会把存在的元素误判为不存在。至于删除元素标准的布隆过滤器是不支持删除的因为一个bit位可能被多个元素映射直接置0会导致其他元素被误判为不存在。要支持删除得用计数布隆过滤器Counting Bloom Filter或者Cuckoo Filter。但任何面试官听到这里都会对候选人高看一眼。雪崩场景下的正确展开也远不止“随机过期时间”。系统性的处理思路是热点数据永不过期但后台异步更新、多级缓存架构本地Caffeine 分布式Redis、针对数据库的限流和熔断兜底。尤其要注意“热点数据发现”这个前置问题——你不知道哪些key是热点谈何保护线上一般用流式计算统计热key或者直接配置规则比如单key访问QPS超过阈值就触发热点保护。1.3 老周的真实点评这场面试结束后老周在点评表里写了这么一段话“候选人具备基本的八股知识储备但深度不足。所有答案都是结论导向缺少推导过程且从未主动提出方案存在的前提条件、边界情况或失败场景。对于秒杀这种高频高并发的核心场景无法清晰描述数据一致性的保障链路说明真实项目参与深度有限。”这段话我建议大家抄在笔记本上——面试官区分水货和干货的核心标准就是看你有没有能力把一个结论变成一条链路。2. HashMap和ConcurrentHashMap最容易暴露基础的连环炮老周把简历翻到技能列表看到了“精通Java集合框架”于是出了第二组题。2.1 “HashMap的put流程讲一下”谢飞机嘴角微微上扬这个题他太熟了“先算hash值然后找到对应的桶如果桶是空的就直接放进去如果有冲突就变链表链表长度超过8就转红黑树。”老周问“那hash值是怎么算的直接key.hashCode()吗”谢飞机说“不是是做了一次扰动把高16位和低16位异或了一下。”老周继续“为什么要把高16位和低16位异或直接拿hashCode去用不行吗”谢飞机明显没有思考过这个问题他对老周说“这个……好像是为了让分布更均匀吧具体我也记不太清了。”老周没停“那HashMap的初始容量是多少负载因子是多少为什么要用2的幂次方作为容量”谢飞机答“初始是16负载因子是0.752的幂次方是因为……取模的时候可以用位运算速度更快。”老周追问“那如果初始化时传了一个不是2的幂次方的容量比如传17会怎么样”谢飞机愣了愣说“那就……应该是17吧”实际HashMap会通过tableSizeFor方法把17变成比它大的最近的2的幂次方也就是32。这个过程是把二进制位逐级右移再或运算最后统一加1。这个点没答上来说明对源码的生长逻辑不够熟。关于扰动函数设计成异或高16位和低16位的真正原因是为了让hashCode的高位特征参与进数组下标计算。大多数场景下key的数量远小于2的16次方如果直接用hashCode做散列高位信息就完全浪费了低位相同、高位不同的key会密集冲突。异或一下相当于把高位的随机性也混入低位分散度更好。2.2 “JDK 1.7 和 1.8 的HashMap有什么变化”这种经典题目谢飞机是没有背过的。他装作镇定答了一句“我记得1.7没有红黑树1.8有了红黑树其他的好像差不多。”老周没有表情继续问“那并发环境下1.8还有没有死循环的问题”谢飞机思索很久终于挤出一句“1.8应该……没有了吧它做了很多优化……”他答对了一半。JDK1.7的头插法在多线程扩容时产生环状链表导致下次get时死循环1.8改成了尾插法不再因为扩容顺序反转而出现环但数据丢失、覆盖更新的问题依然存在所以依然不能并发使用。谢飞机没有主动提这一点说明他并不真正理解“死循环”是怎么产生的。2.3 “ConcurrentHashMap为什么并发性能好”谢飞机这次开始背小学三年级课文“因为它用了分段锁每个段是一个独立的锁所以不同的线程可以同时操作不同的段锁竞争比较小。”老周笑了笑“你说的是1.7的设计。1.8呢”谢飞机呆住了。停顿了整整八秒才小声说“1.8好像是把synchronized用在桶上”老周点头“那你能说说为什么1.8抛弃分段锁改用synchronized加CAS吗这两者的区别在哪里”谢飞机彻底放弃直接笑着说“这个真的记不住了要不您给我讲讲”很多人听到“分段锁”就满足了但1.7到1.8的演进逻辑才是真正的考点1.7的分段锁锁粒度是Segment一个Segment下面挂很多桶并发操作粒度不够细。1.8直接对每个桶Node数组下标加锁粒度更细。put时先用CAS尝试插入空桶如果桶非空再用synchronized锁住链表的头节点锁的竞争范围大幅缩小。另外1.8引入了红黑树和扩容时的多线程协助机制让扩容过程也可以并发推进。老周说他其实并不指望候选人把整个源码默写出来但至少要知道“为什么会有这个演进”。一个只知道“分段锁”三个字的人和一个能说清“锁粒度变细、CAS减少锁开销”的人对并发的理解深度差了不止一个量级。3. 数据库索引一句“索引失效”引发的连环崩塌简历上写着“熟悉MySQL索引优化”。老周叹了口气问出第三组问题。3.1 “给一张表建索引你会怎么选字段”谢飞机对这个问题有着谜一般的自信“查询多的字段加索引经常where的字段加索引order by的字段可以考虑加索引区分度高的字段适合加索引。”这个答案还行但老周真正想听的是更细的工程判断逻辑区分度低的字段到底要不要建索引比如性别字段区分度约50%在百万级数据下用索引扫描一半数据代价比全表扫描更高优化器很可能直接放弃索引走全表所以通常不建。联合索引的字段顺序怎么排口诀是“等值查询在前、排序/范围字段在后”还要考虑哪个字段的过滤性更强。最左前缀原则的本质是什么联合索引里每个索引都是一个按字段顺序排列的B树跳过了前面的字段就无法利用索引的排序性定位。老周随后甩出了一个场景题“表里有user_id、create_time、status、amount四个字段常用的查询是WHERE user_id ? AND create_time BETWEEN ? AND ?你会建几个索引”谢飞机自信地答“建两个索引一个user_id的普通索引一个create_time的索引。”老周摇头顺手写下了正确答案“直接建一个联合索引(user_id, create_time)。这样等值条件负责定位、范围条件负责过滤B树的天然有序性让范围扫描效率最高。如果拆成两个独立索引MySQL通常只能利用其中一个另一个要靠回表再过滤性能严重打折。”3.2 “什么是索引失效说几个典型场景。”谢飞机这次逮住了机会数数一样蹦出来“在索引列上做函数计算、隐式类型转换、like前置百分号、or条件里有非索引列、对索引列排序时用了不同的排序规则……”他停了停老周示意他继续。他只好又补了一条“还有联合索引没用最左前缀的时候。”老周随后加了一个反常规的问题“那你说WHERE name LIKE %abc%一定不能用索引吗在什么情况下可以”谢飞机愣住了他从来没有想过这个问题的另外一面。实际上MySQL8.0之后引入的索引下推Index Condition PushdownICP和覆盖索引技术让某些前置模糊匹配也能用上索引前提是索引覆盖了查询需要的所有字段。如果查询的字段都在索引里MySQL可以在索引层面先做判断避免回表性能依然可观。这一层谢飞机的知识体系里完全没有。3.3 老周的总结索引要能“推导”不是“背诵”老周给出的建议很朴实“面试里索引相关的问题本质上考的是你对B树模型的理解。如果你脑子里有那棵树所有规则都是树上自然推导出来的。如果只背规则遇到稍微变形的问题就会崩。谢飞机就是典型的背规则选手。”老周的推导链是这样的B树的叶子节点按索引顺序排列所以等值查询可以快速定位、范围查询可以顺序扫描这是最左前缀原则的来源数据结构组织方式决定了查找算法走树的哪条路径因此一旦在索引列上做函数运算B树就无法用原本的顺序完成定位走不了树的查找算法索引就失效了。这就是“函数导致索引失效”的本质原因。4. volatile与JMM一个让谢飞机“原形毕露”的隐藏测试老周这时候换了个方向问出一个看似简单、实则致命的问题。4.1 “volatile关键字保证什么不保证什么”谢飞机眯着眼睛背诵模式再次上线“volatile保证可见性不保证原子性然后它禁止指令重排序可以防止出现半初始化状态。”老周抬了抬眉毛“那你说说为什么volatile能保证可见性底层原理是什么”谢飞机“因为……它加了内存屏障写完之后会立刻刷回主内存然后其他线程读的时候会失效自己的本地内存副本去主内存拿。”老周“嗯”了一声继续问“那你解释一下为什么写volatile变量会‘立刻刷回主内存’这个操作的成本是什么”谢飞机一脸茫然“这个是Java虚拟机底层做了保障具体细节我确实没有深研究过。”老周随后抛出了一个经典到不能再经典的题“那你来说说双重检查锁的单例模式为什么instance要加volatile”谢飞机笑了这个他会“因为不加volatile的话可能出现半初始化的情况就是线程A已经在堆里创建了对象但是还没来得及调用构造函数线程B看到的引用不为空于是拿去用了结果对象里面的字段还是默认值。加volatile是为了禁止指令重排序保证对象完全初始化之后才暴露出引用。”老周追问“具体是哪两条指令之间发生了重排序new一个对象的字节码层面是怎么做的”谢飞机笑容僵在了脸上然后结结巴巴说了句“new 一个对象好像是三行指令但是是哪三行……我不太记得了。”正确答案是new对象在字节码层面大致是三步第一步分配内存空间第二步调用构造函数初始化第三步把引用赋值给变量。真正可能被重排的是第二步和第三步如果构造函数的指令被挪到赋值之后另一个线程就会拿到一个分配了内存但尚未完成构造的对象。volatile修饰的变量为什么会禁止这种重排是因为写volatile变量的操作前后都插入了内存屏障前屏障阻止后续的普通写操作越过本次volatile写后屏障阻止前面的普通读操作越过这条屏障提前执行而这里的“普通写”就是对象的初始化赋值。加volatile之后初始化步骤被屏障限制在引用暴露之前完成。4.2 “volatile能保证复合操作的原子性吗”谢飞机摇头“不能比如count这种要加锁或者用AtomicInteger。”老周再问“那AtomicInteger底层是怎么实现的和volatile有什么关系”谢飞机又卡壳了。他只知道最后一句“用CAS”但他不知道AtomicInteger内部就是volatile int valueCAS通过比较并交换来更新而CAS在x86平台上最终落地的是cmpxchg指令配合内存屏障。到此为止AtomicInteger、volatile、CAS这条线已经彻底断了。老周最后点评“知识面停留在‘结论记忆层’没有下沉到‘原理推导层’。会背结论而不懂机制在真实系统里遇到诡异并发问题连排查方向都会找错。”5. 手写代码环节从单例模式到快速排序的崩溃现场面试进行到一半老周把椅子往后一推打开在线编辑器。5.1 “先写一个线程安全的单例”谢飞机撸起袖子十秒钟写完了public class Singleton { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }老周看了一眼说“这是饿汉式。用一用懒加载的写法。”谢飞机又写了一段public class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; } }“这是懒汉式但是锁的粒度太大了每次调用都要抢锁。”谢飞机补充道。老周点点头“那你优化一下用双重检查锁。”谢飞机的眼神开始飘忽他写下了最经典的问题版本public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }他忘记加volatile了。当老周指着instance字段问“这个字段要不要加volatile”时谢飞机沉默了三秒突然说“其实我觉得不加也能用反正平常跑着也没出过问题。”老周没有直接说错只回了一句“在面试里‘我没遇到过’不等于‘它不会发生’。并发BUG是最难复现的等你在线上遇到半初始化对象的时候已经晚了。”关于双重检查锁为什么一定要加volatile我上面已经说过字节码重排序的问题。这里我再补一句不加volatile的单例在低并发压力下几乎没有概率暴露问题因为重排序的发生需要特定的JIT编译优化时机和线程调度巧合所以很多人写了多年都没踩坑但这属于典型的“靠运气写并发代码”。并发编程里一切没有被语言规范禁止的行为都可能在某天真实发生。5.2 “手写一个快速排序”谢飞机三分钟写完交上来的版本是这样的public void quickSort(int[] arr, int left, int right) { if (left right) return; int pivot arr[(left right) / 2]; int i left, j right; while (i j) { while (arr[i] pivot) i; while (arr[j] pivot) j--; if (i j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; i; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); }逻辑没错。老周问了一句“说说时间复杂度以及什么情况下会退化。”谢飞机答“平均O(n log n)最坏O(n²)当数组已经有序的时候会退化。”他背熟了但并没有理解为什么有序会退化。在快速排序里如果每轮选的pivot恰好是当前区间的最小值或最大值划分出来的两个子区间就严重不平衡一个为空另一个包含n-1个元素递归树就退化成一条链表形递归链每层都要做O(n)次比较整体复杂度塌成O(n²)。他写的是“三数取中”的思路取中位下标作为基准实际上已经是优化过的写法但他说不出其中原理。老周评价“代码能写出来但讲到退化场景他给不出推导过程。这种‘代码会写、原理讲不透’的候选人在团队里带起来非常累因为他只能执行明确指令不能应对意外情况。”6. 系统设计速答题缓存一致性、消息堆积、限流最后一个环节老周抛了几个场景设计题。6.1 “缓存和数据库的一致性你怎么保证”谢飞机显然背过一点答道“先删缓存再更新数据库然后延迟双删隔几百毫秒再删一次缓存。或者先更新数据库再删缓存失败就重试。”老周追问“那这两种方案你在项目里用的哪个为什么”谢飞机愣了一下“我用的第二个就是先更新数据库再删缓存。因为如果先删缓存可能因为还没更新数据库别的线程把旧数据缓存回去了导致脏数据。”老周继续“更新数据库成功后来删缓存失败怎么办”谢飞机“那就重试……用消息队列异步重试吧。”到这里他总算答出了一个相对完整的链路。老周又补了一个问题“先更新数据库再删缓存如果删缓存发生在更新数据库之前会不会有问题”谢飞机迟疑片刻小声说“应该没问题吧……最终缓存都会被删掉。”这里老周讲了另一个深坑在读多写少的场景中线程A更新了数据库正要删缓存时线程B读取到旧缓存并回填正好覆盖了A要删的key导致一旦线程A的删除操作被覆盖失效旧数据就会长时间留在缓存里。业界主流的最终一致性打法之一是“异步双删”由专门任务在数据库更新成功后延迟几秒再删一次缓存配合消息队列的重试保证最终删除。真正的设计必须考虑失败补偿任何一步都可能失败方案里必须留有重试、对账、日志链路。6.2 “MQ消息堆积了怎么办”谢飞机说“可以加消费者增加消费能力。”老周追问“消费者增加就能解决堆积吗有没有什么情况加了消费者也没用”谢飞机沉默了。实际上如果消费者的消费速度本身受限于下游数据库的写性能比如下游单条写入要几十毫秒加多少消费者都会把数据库打垮堆积不减反增。更常见的情况是通过水平扩容消费者实例提升并行度同时检查消费逻辑里是否有串行调用外部接口、是否有批量处理优化空间、是否有单条消息死循环。老周最后又问“消息积压了你会怎么定位先看什么指标”谢飞机吃力地回答“看消费组的积压数量吧……然后看看consumer是不是挂了……没了。”正确答案应该是先看积压数量和积压时间趋势再看消费者活跃度、消费速率、处理异常率最后逐层排查是生产端发送量暴增、消费端逻辑变慢还是下游依赖故障。一个设计良好的系统积压时长超过阈值要自动告警消费者要具备优雅启停和幂等处理能力消息消费失败要有重试队列和死信队列。7. 面试官复盘什么样的候选人算“水货”面试结束后老周坐在工位上喝了一口凉掉的咖啡跟我说了一段话我觉得值得分享给所有人。他总结的“水货特征”是知识结构是孤立的“知识点”没有串成“链路”。问他缓存一致性他能背出“延迟双删”但你让他从一次读请求出发把缓存、数据库、异步删除、失败补偿、对账梳理成完整闭环他就断线了。所有回答都是“结论”不是“推导”。这就像一个学生只会背公式考试时题目稍微绕个弯就不会做。几乎不主动讲边界条件和失败场景。真实系统里正常路径只占很小一部分大部分精力都在处理异常路径。如果候选人从头到尾都在描述“理想状况”说明他没真正维护过线上系统。回答问题只盯着自己知道的不懂得“澄清需求”。比如老周问“单例怎么写”他应该先反问“是懒加载还是饿汉式有没有并发要求”而不是直接埋头写代码。8. 给我的读者一场水货面试的四个避坑建议看了这么多乐子最后说点正经的。以下是我作为旁观者从谢飞机这场面试里实打实提炼出来的教训。第一不要背八股要背“推导链”。HashMap的put流程、volatile的内存屏障、B树的索引结构都是可以画着图讲出来的。你如果能在白板上画出HashMap桶、链表、红黑树再从上往下讲树形结构的平衡过程、扩容过程中链表如何分裂成低位链和高位链、为什么高低位判断用上(e.hash oldCap)面试官自然会判定你看过源码。如果只背结论三连问一到就露馅。第二准备系统设计题要多准备“失败预案”。任何方案你说完主干之后主动补一句“如果这里失败了我用什么机制兜底”。这会让面试官觉得你有一线实战经验。比如“先更新数据库再删缓存”这个方案你主动说出“如果删缓存失败我会通过订阅Binlog的异步任务补偿删除”这个加分项立竿见影。第三代码题写完之后要主动讲复杂度和边界条件。老周说很多候选人写完代码就放下笔等下一题完全不提时间复杂度、空间复杂度、输入边界。实际上面试官期待的是你把代码看成一个完整的“作品”而不是一个“草稿”。还有一个小技巧写完代码后顺手用一两个边界case在脑子里走一遍比如空数组、单一元素、最大最小值说给面试官听。第四也是最重要的一点不要把“没遇到”当成“不存在”。并发BUG之所以难搞就是因为它概率低、影响大、难以复现。面试官反感的不是你不会而是你认为不会发生的事情不需要考虑。你要是能坦坦荡荡说一句“这块我线上没怎么遇到过但我理解的理论层面是这样”印象分会比硬撑好得多。谢飞机最后收到了拒信。老周在流程备注里写的是“可培养性中等现有深度不足以支撑核心系统开发。”事实上这哥们儿性格倒是挺好全程被问倒也没红脸离开时还乐呵呵地问前台要了杯水。但大厂面试不是脱口秀大会光靠心态好撑不过技术轮。我把这场实录整理出来是真心希望大家别走他的老路。背熟结论只是入场券能推导演绎才是真正的护城河。你的目标是当面试官把一个“结论”拆成一个“为什么”的时候你能顺着那条链路一路讲到虚拟机字节码、CPU缓存、操作系统的底层去。
阅读完成 · 觉得有帮助?
咨询建站