那场面试让我印象很深:面试官放下我的简历,问的第一个问题不是项目经历,而是一道听起来很“送分”的基础题——说说 Java 的八种基本数据类型。我差点脱口而出“int、long、float、double、char、boolean、byte、short”,但话到嘴边又咽了回去,因为我知道这种题从来不是真的只考背答案。Java全栈开发工程师的能力模型,往往就从这些最基础的东西开始往深处挖:数据类型背后的内存模型、包装类的缓存机制、JVM的运行时布局、并发场景下的可见性、数据层框架的映射原理、再到整个系统的分布式设计。一个小时的面试下来,既有手写冒泡排序的尴尬瞬间,也有关于 MyBatis Plus 如何根据实体类生成建表 SQL 的现场推演,甚至有围绕多商户跨境商城项目的交手讨论。
这篇文章我不打算写成标准的“面试题合集”,那种东西网上太多了。我更想以一次面试实录的形式,把从基础到实战遇到的问题完整复盘一遍,包括我当时怎么回答、在哪一步被追问到卡壳、事后又是如何补课的。无论是刚准备 Java 面试的初级开发,还是想系统性检查自己知识体系的老兵,应该都能从这里找到一些值得琢磨的点。
1. 从一道“Java数据类型”问题开始的面试破冰
1.1 面试官为什么总爱从基础题开场
很多人会觉得问基本数据类型太简单,低估了这道题的价值。实际上这是面试官测试候选人大脑里“知识地图”的第一道探针。
我那天很谨慎,不仅把八大类型全部报了一遍,还顺手补上了它们的位宽和默认值:byte 是 8 位,short 是 16 位,int 是 32 位,long 是 64 位,float 和 double 都是浮点数,长度分别是 32 位和 64 位,char 是 16 位无符号整数,boolean 比较特殊,JVM 规范里没有明确规定它单独占用多少字节,在 HotSpot 里通常按 int 处理,在数组中按 byte 处理。这个补充让面试官明显点了点头,紧跟着就抛出了下一个问题:“那 Integer 和 int 有什么区别?”
1.2 包装类背后的缓存池与拆箱陷阱
面试官的问题没有停在我列举完概念上,他直接转向了自动装箱的边界条件:Integer a = 127; Integer b = 127;这两个对象用==比较是什么结果?如果换成 128 呢?
这是 Java 基础里出现频次最高的隐藏考点,考察的是 Integer 缓存机制。Integer.valueOf(int)方法会在 -128 到 127 范围内返回缓存对象,而new Integer(127)永远创建新对象。所以a == b在 127 时是 true,换成 128 就是 false。但真正容易踩坑的是拆箱空指针:如果一个包装类型对象是 null,直接跟基本类型做比较,JVM 自动拆箱时会抛出 NullPointerException。这个坑在实际业务里太常见了,比如从 Redis 里取一个Long类型的值,没有判空就直接拿来和数字比较。
我当时的补充是:生产环境写代码时,能用基本类型就用基本类型,但数据库映射和泛型场景绕不开包装类,所以在网络传输、RPC 返回、缓存读取这些边界处,宁可多写一个判空也别偷懒。
1.3 BigDecimal 精度问题:最容易被低估的基础考点
面试回顾到这里,另一个高频陷阱也必须提:浮点数精度。float和double的二进制存储方式决定了它们无法精确表示所有十进制小数,比如0.1 + 0.2结果不是精确的0.3,而是一个让人头皮发麻的近似值。金融计算、订单金额、商品价格这类场景,一旦直接用浮点类型,线上迟早会出事。
正确做法是使用BigDecimal,但很多人在这一步又进了另一个坑:new BigDecimal(0.1)和new BigDecimal("0.1")的结果不一样。你如果拿着 double 直接塞进构造器,它依然不精确。应该优先用字符串构造器,或者用BigDecimal.valueOf()。还有个细节,equals方法和compareTo方法语义不同,1.0和1.00用equals比较是 false,用compareTo比较是 0。我在实际订单系统里封转过一个金额计算工具类,专门统一这两个行为,避免团队里不同人写出风格各异、隐患迭出的金额判断。
基础题问到这一步,基本就能看出来一个人是“背了八股”还是“真的在项目里被毒打过”。
2. JVM调优与内存问题:面试官最爱的深水区
2.1 为什么JVM问题是中级以上Java工程师的必考题
面试进行到十五分钟左右,画风开始变了。面试官突然一句“平时线上遇到过 OOM 或者频繁 Full GC 的告警吗?当时是怎么排查的。”这一下从“语法讲解”切换到了“实战复盘”。
这个问题之所以是深水区,是因为没有任何背题技巧能替代真实故障经历。一个合格的 Java 工程师,至少要能在脑子里快速构建出 Java 内存区域的完整图景:堆内存、栈内存、元空间、直接内存,以及它们的回收机制。
JDK 8 以后,永久代被元空间取代,字符串常量池从方法区挪到了堆里。这意味着一个简单的 String 截取、拼接操作,都可能因为常量池问题引发内存增长。理解这些布局,是排查内存问题的底线。
2.2 堆内存、栈内存与元空间的实际布局
我用一个订单服务的真实案例来回答这个问题。当时遇到的现象是服务每过一段时间响应变慢,监控面板上堆内存占用曲线呈锯齿状,Full GC 频率从每小时几次变成几分钟一次。
首先做的不是盲目调参,而是先确认问题来源。用jcmd工具拿到对应的 Java 进程号,然后通过 JVisualVM 或jcmd GC.heap_info查看堆内存的分布。排查结果发现,罪魁祸首是业务侧一个批量任务在每次执行时都向一个 static 的 ArrayList 里塞数据,处理完后没有清空,导致对象一直被引用无法回收。这就是典型的“对象生命周期管理不当”,它不是靠调大堆内存能解决的,反而堆越大,垃圾回收时 STW 的影响面越大。
JVM 参数方面,-Xms和-Xmx要设置一致,避免运行期动态扩缩容带来的性能开销。年轻代和老年代的比例-XX:NewRatio默认是 2,也就是老年代占三分之二、年轻代占三分之一。对于大多数订单类、请求类服务,适当调大年轻代比例,能减少对象在新生代就进入老年代的概率。但参数不是万金油,每次调优都要基于压测数据和监控曲线。
2.3 一次CPU飙升与频繁Full GC的完整排查链路
这里我分享一段真实的线上排查链路,也是面试官听完追问最多的部分:
- 收到告警,服务器 CPU 使用率飙到 90% 以上。
- 用
top -Hp查看进程内线程的 CPU 占用,找到最耗 CPU 的线程号。 - 把线程号转成十六进制,用
jstack导出线程栈,搜索对应的 nid,定位到具体代码行。 - 发现是某个方法里在 for 循环中反复调用正则表达式匹配,正则回溯导致 CPU 被耗尽。
- 优化方式是把预编译好的
Pattern对象抽成静态常量,并开启matches的快速失败机制。
这一整套链路看起来不难,但恰恰是很多人“听过没做过”的分水岭。面试官不期待你背出所有 JVM 命令行工具的参数,但他一定想听到你在真实故障中的操作顺序和决策依据:先看 CPU、再看线程、然后定位代码、最后优化逻辑。如果第一步就直接去看堆配置,方向就错了。
2.4 类加载机制与多版本JDK环境的小插曲
后面还聊到了类加载机制。双亲委派模型的意义在于防止核心类库被篡改,也避免同一个类被重复加载。面试官问了一个经典问题:“我们自己能写一个java.lang.String类并正常使用吗?”答案是不能,因为引导类加载器 Booting 会优先加载 JDK 自带的同名类,应用类加载器根本不会有加载机会。
这个环节我还顺便聊了早年在 2012 服务器上配置多个 JDK 的经历。那时候为了跑老项目和新技术栈,机器上同时装了 JDK 7 和 JDK 8,经常出现命令行里java -version显示版本和项目要求不一致的诡异问题。根本原因就是JAVA_HOME与PATH里的 java 可执行文件指向的不是同一个目录。排查方法是先在命令行执行where java(Windows 下)或which java(Linux 下),确认实际使用的解释器路径,再检查JAVA_HOME是否被多个程序改写过。这段经历虽然不起眼,但在面试里体现出动手排查问题的经验,比背参数更能让面试官觉得“这个人真干过活”。
3. 并发编程的实战陷阱:从八股到代码落地
3.1 volatile可见性的真实案例:用它解决超卖问题为什么不够
并发是 Java 全栈面试的必考板块,而且考察方式已经从“背 synchronized 和 lock 的区别”升级到了“给定一个场景,怎么保证数据一致性”。
面试官出的题很贴合业务:“秒杀库存还剩最后一件,多个线程同时扣减,怎么保证不会超卖?”我下意识想用 volatile 修饰库存变量,理由是它可以保证线程间可见性。这其实是很多人在并发初学阶段的经典误区:volatile 只保证读和写之间的可见性,不能保证复合操作的原子性,count--这种操作本质上包含了“读取-修改-写回”三步,volatile 管不住中间过程被其他线程插一脚。
正确方案要看业务场景。如果并发量不高,数据库层面的乐观锁就够用:UPDATE stock SET count = count - 1 WHERE id = ? AND count > 0,通过受影响行数判断是否扣减成功。如果并发量很大,需要选择 Redis 的 Lua 脚本做原子扣减,或者在应用层使用分布式锁。我当时在项目里采用的方案是 Lua 脚本加库存预热,把热点库存提前加载到 Redis,扣减成功后异步同步回数据库。面试官顺着这个思路继续追问了缓存与数据库一致性的问题,这正好是下一节的深水区。
3.2 synchronized与ReentrantLock的选择逻辑
光说分布式方案还不够,面试官又把视线拉回 JVM 层面,问“如果一个单体服务里多个线程操作同一个共享变量,synchronized 够用吗?”
答案是够用。JDK 6 之后 synchronized 引入了锁升级机制,从偏向锁到轻量级锁再到重量级锁,大多数场景里无竞争或低竞争状态下,性能已经相当可控。而 ReentrantLock 提供了更灵活的语义,比如可中断等待、公平锁、多个条件队列。我的选择习惯是:默认用 synchronized,写法简单且不容易出现忘记释放锁的问题;只有需要尝试获取锁并实现超时返回、或者要做多条件精确唤醒时,才换 ReentrantLock。
这里有个容易忽略的细节:锁的粒度。不要用 synchronized 锁住一整个方法,尤其当方法内部既有耗时的 I/O 操作又有真正需要保护的关键资源时,直接用方法锁会把无辜的读请求也挡在外面。正确的姿势是把共享资源隔离成独立方法,用单独的锁对象保护,锁范围控制在最小。
3.3 线程池参数设计与一个被拒绝策略坑惨的项目
线程池是并发问题里最容易翻车的点。面试官给了一个场景:“高峰期每秒有大量请求进来,怎么设计线程池参数不把服务打挂。”
这个问题背后考察的是四个核心参数之间的关系:核心线程数、最大线程数、阻塞队列容量、拒绝策略。先看队列,LinkedBlockingQueue是无界队列,任务再多也不会触发拒绝策略,但队列里的请求可能已经等超时了,响应速度全面恶化。ArrayBlockingQueue有界队列更符合生产场景。
我曾经负责过一个报表导出服务,最初用的就是无界队列加核心线程 4、最大线程 16 的配置。结果某次业务方一次性提交大量导出任务,队列积压了上万条任务,用户看到的反馈一直是“处理中”。后来改成有界队列ArrayBlockingQueue(200),并且把拒绝策略设为CallerRunsPolicy,让提交任务的线程自己执行被拒绝的任务。这个改动让系统不再无限积压,高负载时反而逼出了更合理的调度行为。
线程数的设计没有绝对值,我通常在压测之前先估算 I/O 密集型和 CPU 密集型任务的比例。I/O 密集型的线程数可以设为CPU 核心数 * 2或更高,因为线程大部分时间在等待 I/O;CPU 密集型的线程数则不宜超过核心数加一,否则线程切换本身就会稀释性能。
3.4 ThreadLocal的内存泄漏与连接池数据串味
关于并发,ThreadLocal 也是面试官格外喜欢深挖的点,尤其因为它和生产故障紧密相关。ThreadLocal 用来保存线程私有变量非常方便,比如在请求拦截器里存储当前登录用户信息,或者在链路追踪里存储 traceId。
但它的隐患在于线程复用场景。以 Tomcat 的工作线程为例,请求处理完线程并不会立即销毁,而是回到线程池等待下一次使用。如果代码里没有显式调用remove(),ThreadLocalMap 里的 value 会一直保留,即使 key 是弱引用,value 强引用链条却没有断开,长此以往内存泄漏。更隐蔽的是数据串味:A 用户请求设置了用户信息,线程复用后 B 用户请求进来,如果不重新 set,B 就可能拿到 A 的数据。
所以我的经验法则是:在 finally 代码块里必须remove(),并且尽量在入口拦截器统一设置、在出口统一清理。想要更保险,可以用阿里开源的 TransmittableThreadLocal 来处理线程池之间上下文传递的问题。
4. 排序算法与编码基本功:被小问题考住的大教训
4.1 冒泡排序为什么总出现在面试题里
面试进行到四十分钟左右,面试官突然拿出白板:“手写一个冒泡排序。”
我听到这个要求时有点意外,毕竟简历上写着多年全栈经验。但这类题目恰恰是全栈开发面试里的经典“清扫项”,它不看你会不会背 API,而是考察最基本的编码嗅觉:循环边界、交换逻辑、循环不变量。
我当时写的是最朴素的版本:
public static void bubbleSort(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }写完之后面试官补了一句:“能不能加一个优化?”这考察的是对算法本质的理解。如果数组在某一轮遍历中已经有序,后面的轮次其实完全没必要继续。加一个 swap 标记位即可:某一轮没有发生交换,直接 break 终止循环。看似只是多了一个变量,但在近乎有序的数据集上能大大缩短时间。冒泡排序本身时间复杂度是 O(n^2),空间复杂度 O(1),是稳定排序,这些结论要能流利说出来,同时手写代码时要注意j < n - 1 - i的边界,不然很容易数组越界或漏掉最后一次比较。
4.2 Comparable、Comparator与Arrays.sort的底层逻辑
写完冒泡排序,面试官把问题升级到了工程场景:“如果现在要对一个List<User>按年龄排序,你会怎么写?”
这里考察的是对排序 API 的理解。Comparable是类自身的自然排序实现,一般直接写在实体类里,比如让 User 实现 Comparable 接口并重写compareTo方法。而Comparator是外部排序器,适合在不改变源码的情况下临时定义多种排序规则,比如先按年龄、再按姓名。
在 Java 8 里,最常见的写法是用 Stream 流:
List<User> sortedUsers = userList.stream() .sorted(Comparator.comparing(User::getAge) .thenComparing(User::getName)) .collect(Collectors.toList());这个写法简洁是简洁,但要注意parallelStream()配合排序时,底层并行流对共享可变状态的改动是线程危险的,容易在特定数据量下产生非预期结果。普通业务场景用串行流就够了,数据量巨大、排序本身成为瓶颈时,再考虑并行流并对线程安全保持足够警惕。
Arrays.sort底层针对对象数组使用的是 TimSort,它结合了归并排序和插入排序的优点,对近乎有序的数据非常友好。服务端开发中要养成优先使用 JDK 自带排序方法的习惯,手写排序算法通常只在特殊场景(比如需要稳定且内存极小的环境)才有意义。
4.3 集合框架里容易被忽视的细节
基础章节还聊到了 HashMap,面试官问“HashMap 的初始容量为什么是 16?”,这背后是“容量必须为 2 的幂次方”的设计,为了让 key 的 hash 值在做取模运算时可以通过位运算高效实现。扩容阈值是加载因子乘以容量,默认加载因子 0.75。
更关键的是,如果实体类重写了equals方法而没有同步重写hashCode,就会导致同一个对象在放入 HashSet 或作为 HashMap 的 key 后,因为哈希值不同在检索时永远找不到。这在项目里遇到过真实事故,缓存 key 是一个自定义对象,线上偶发性的缓存失效,追查到最后发现就是 hashCode 没有随某个字段一起参与计算。这类问题在面试里讲过很容易显得有经验。
5. MyBatis Plus实体类生成建表SQL:一道面试实操题的完整推演
5.1 面试官现场提出的需求
这次面试的高潮出现在一个很“工程化”的题目上。面试官说:“假设你有一个 Java 实体类,希望根据这个实体类自动生成创建表的 SQL 语句,让你现场设计,你会怎么做?”
这是个典型的全栈开发生态问题,因为日常开发中 MyBatis Plus 可以根据数据库表逆向生成实体类,但反过来根据实体类生成 DDL 却并不是框架内置能力。如果真做,就要理解 Java 反射、注解解析、类型映射和 SQL 语法拼接。
我很自然把它拆解成几个步骤:
- 从 Class 对象获取字段列表。
- 解析字段上的注解(比如
@TableId、@TableField、@TableName)。 - 把 Java 类型映射到 MySQL 类型。
- 拼接
CREATE TABLE语句,补充主键、索引、字符集。
5.2 类型映射是核心难点
Java 类型和数据库类型之间的映射关系,是这道题里最有信息量的部分。比如Long映射成bigint,Integer映射成int,String的长度需要注解来决定,默认给varchar(255),BigDecimal映射成decimal,如果没有标注精度则给一个默认的decimal(10, 2),LocalDateTime映射成datetime,Boolean映射成tinyint(1)。
映射写得好不好,直接决定生成的 SQL 是能用还是半残废。我当场写了一个简化版 TypeMapper:
public class ColumnTypeMapper { public String mapType(Field field) { Class<?> type = field.getType(); TableField annotation = field.getAnnotation(TableField.class); if (annotation != null && StringUtils.hasText(annotation.column())) { // 自定义字段映射优先 } if (type == Long.class || type == long.class) return "bigint"; if (type == Integer.class || type == int.class) return "int"; if (type == String.class) return "varchar(" + getStringLength(field) + ")"; if (type == BigDecimal.class) return "decimal(10, 2)"; if (type == LocalDateTime.class) return "datetime"; if (type == Boolean.class || type == boolean.class) return "tinyint(1)"; return "text"; } }这里要注意,字段名转数据库列名还需要做驼峰转下划线的处理,userId转成user_id。生产级别的工具会直接复用 MyBatis Plus 里的命名策略,而不是自己发明一套轮子。
5.3 一条完整生成SQL的落地思路
把上面的模块组装起来,就可以生成类似这样的一条 SQL:
CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '姓名', `age` INT NOT NULL DEFAULT 0, `balance` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_name` (`name`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表';主键策略根据@TableId(type = IdType.AUTO)处理,默认值为 null 的字段也要在生成时统一用 NOT NULL + DEFAULT 来保证数据质量。字符集选择 utf8mb4 而不是 utf8,是因为 utf8 在 MySQL 里最多存三个字节,遇到 emoji 符号会直接插入失败,这类问题在社交类项目里屡见不鲜。
做完这个推演,面试官追着问了一句“MyBatis Plus 自己的代码生成器原理是什么”,我顺势把从数据库到实体类的逆向工程路径也捋了一遍:通过 JDBC 的DatabaseMetaData拿到表字段信息,再配合模板引擎生成 Java 文件,和正向生成是两条互逆的链路。
6. 多商户商城项目复盘:缓存、锁与行级权限的设计取舍
6.1 技术栈拆分与模块边界
面试后半段进入项目深挖环节。面试官指着简历上的“多商户跨境商城”项目问:“这个系统的技术栈是怎么组织的?你们底层的数据隔离怎么做?”
这类系统通常以 Spring Boot + MyBatis(Plus)+ Redis + MySQL 为核心,同时配套消息队列做订单异步化、对象存储做商品图片、搜索服务做商品检索。从领域建模上要拆出用户、商户、商品、订单、支付、库存、营销、售后等核心域。面试官重点关注的是你能否把模块边界说清楚,以及每个模块之间如何通过接口协作而不是直接互相访问数据库表。
我当时着重讲了商品域与库存域的解耦。商品下架时并不直接扣减库存,而是通过事件发布,库存服务去异步处理。这样做的好处是商品列表的读流量再大也不会拖垮库存写入,坏处是引入了一致性窗口,在极端情况下用户看到的库存可能略滞后。这个取舍在业务上可以接受,但必须能明确说出来,否则面试官会认为你根本没思考过分布式下的数据一致性代价。
6.2 Redis、分布式锁与扣库存的原子性
多商户商城的高并发场景离不开 Redis。面试官问我“商品详情页缓存怎么做”,我回答采用 Cache-Aside 模式:读请求先查缓存,缓存不命中再查数据库并回填缓存;写请求更新数据库后删除缓存,而不是更新缓存,因为删除比更新更容易避免并发写导致的数据错乱。
紧接着又是那个库存扣减问题在真实商城里的落地版本。我们的方案是先用 Lua 脚本在 Redis 里完成库存扣减,脚本内容大致如下:
local stock = tonumber(redis.call('GET', KEYS[1])) if stock > 0 then redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 end return 0它在 Redis 内执行时是原子的,不会出现超卖。扣减成功后通过可靠消息把扣减结果同步到 MySQL,以订单状态为幂等依据。如果同步失败,就用定时任务对账,把 Redis 与数据库的库存差异修正回来。这套链路讲下来,面试官的重点已经从“你知不知道 Redis”转移到了“你有没有处理过缓存与数据库之间的最终一致性问题”。
6.3 行级权限:多租户系统的数据隔离方案
商城项目另一个绕不开的问题是权限。多商户意味着同一个系统里,不同商户操作自己的商品库、订单库、价格库。我告诉面试官,我们在底层使用了行级权限模型:每一张业务表都带商户 ID 字段,数据权限通过 MyBatis 拦截器在 SQL 执行层自动追加条件,而不是在业务代码的每个查询里手动拼条件。
实现思路是借助 MyBatis 提供的Interceptor接口,拦截所有查询语句,根据当前上下文的商户 ID 动态拼接tenant_id = ?的过滤条件。这里最关键的是“上下文传递”,商户 ID 在登录认证阶段写入 ThreadLocal,在请求过滤器入口设置、出口清除。如果上下文没设置好,拦截器误拼接了空值,就会造成跨商户数据泄漏,这是生产事故级别的问题。
行级权限的粒度还可以继续细化:同一商户下不同角色看到的数据范围也不同,比如客服主管能看到整个店铺的订单,普通客服只能看到自己跟进的订单。这种维度的设计需要把数据条件抽成可组合的规则表达式,技术上用 AOP 结合策略模式去实现。面试官在这个项目上挖了很久,因为行级权限的设计能真实反映候选人有没有做过大型多人协作的业务系统。
6.4 支付回调与幂等处理
跨境商城项目里还涉及支付回调,这也是和数据一致性高度相关的实战点。第三方支付平台会异步通知你的服务,通知可能因为网络抖动重复触发。如果回调处理逻辑没有幂等保护,订单状态会被反复更新,严重时会出现一笔订单被重复发货、重复退款的情况。
我们的方案是建一张幂等记录表,以“支付流水号”作为唯一键,回调处理前先插入一条占位记录。如果插入冲突,说明这条流水已经处理过,直接忽略。在订单表上的更新则加上状态条件判断,比如只有当订单状态是“待支付”时才能更新为“已支付”,这样状态机本身也成了防止重复操作的第二道屏障。这套逻辑说起来并不复杂,但属于那种被真实业务坑过才知道要做好的设计。
7. 系统设计与跨团队协作:面试末尾的高分环节
7.1 设计一个秒杀系统的核心链路
面试进入尾声,面试官来了一道开放题:“如果让你设计一个秒杀系统,你会怎么做?”
这种系统设计题考察的不是标准答案,而是你在高并发场景下的取舍能力。我的回答按照流量漏斗来展开:
第一层是流量拦截。秒杀开始前通过活动页承接大量用户请求,但真正的下单接口要限制接入量,前端预约、答题、验证码等手段都是为了最先把无意义流量挡在外面。
第二层是应用层削峰。秒杀开始那一刻请求量最猛,直接把请求全部打到数据库没有任何服务能扛住。我们可以在应用层将用户请求先落到消息队列,由消费端以受控速度去处理真正的秒杀逻辑,行不行得通取决于业务是否允许异步完成。
第三层是 Redis 预扣库存。热点商品库存数量很小,完全可以提前加载到 Redis 内存里,借助 Lua 脚本做原子扣减,扣完即止,库存状态随即标记不可售。
第四层才是数据库持久化。订单真正写入 MySQL 时,通过唯一索引保证一个用户只能秒杀到一件,用事务保证状态更新的原子性。
这套链路讲完之后,面试官追问“如果秒杀系统被大量恶意请求刷怎么办”,我从接口防刷、用户维度的限流、Token 秒杀资格预发放几个角度补充了回答。这个话题的颗粒度很有弹性,能体现候选人到底是“做过架构”还是“只背过系统设计PPT”。
7.2 后端工程师怎么和产品经理对齐需求
很多技术面到系统设计就结束了,但这次面试官在最后特意问了一个非技术问题:“作为后端工程师,平时怎么和产品经理确认需求?”
这个问题我非常有感触。刚工作那几年,我总觉得自己是“被安排”的角色,产品提什么需求就做什么需求。后来吃了几个大亏才明白,后端在需求对齐阶段需要做的事非常多。
首先要确认的不是界面上长什么样,而是这个需求的核心指标是什么。比如产品说“要做商品搜索”,你要追问的是搜索的转化目标、数据规模、实时性要求。如果数据量几百条,直接数据库 LIKE 查询就够了;如果百万级,就需要搜索引擎。
其次要识别需求的边界和异常路径。产品经理通常只会描述正常流程,却不会主动说清楚“用户退款之后库存是否要恢复”“超时未支付订单怎么处理”“重复点击下单按钮会发生什么”。这些边界定义的缺失,最后都会变成线上 bug 和后端团队背的锅。
我现在的习惯是把需求拆成一套问题清单:谁在什么场景下用、核心流程是什么、异常情况怎么处理、性能指标是多少、有没有历史兼容问题。和产品经理沟通时,把这套清单过一遍,基本能避免一半以上返工。
7.3 全栈开发视角下的代码交付与持续演进
作为全栈方向的工程师,面试官还很在意你是否了解前端和部署链路。聊到部署时,我提到用 Docker 打包 Spring Boot 应用,Dockerfile 里用多阶段构建先编译再瘦身运行镜像。后续引入 CI/CD 流水线,提交代码后自动触发测试和部署。这些知识点不是考察你会不会敲命令,而是看你对现代软件开发流程是否有整体认知。
工程化层面的经验是,写代码时多留意包结构的设计。我曾经在商城项目里把 Controller、Service、Mapper 严格分层,但业务复杂度上去之后发现 Service 层逐渐膨胀成“上帝类”。后来按照业务垂直切片来组织包结构,每个条目的完整逻辑链放在同个模块内,阅读代码和理解业务上下文都轻松了很多。这些体会讲出来比单纯报一堆技术名词更能说明你的项目经历是真实的。
最后的复盘
面试结束后我坐在地铁上把这一个多小时的问题在脑子里过了一遍,意识到一个很朴素的道理:Java 全栈开发的基础与实战从来不是割裂的。数据类型、JVM、并发、排序这些看起来“很基础”的知识,在实际项目中以各种变体反复出现;而商城项目里的缓存一致性、行级权限、幂等设计,本质上也是基础原理的组合运用。备考时与其刷几千道题目,不如把一个真实项目从数据层到并发层完整复盘一遍,把这些原理串成一条线。
面试结果下来之后,我拿到了 offer。但比 offer 更值钱的是那次交流让我发现自己有几块短板:对 ThreadLocal 的底层存储讲得不够透彻,对 MySQL 类型映射的边界情况也没法一口气说全。我把这些问题列成了清单,后来又花了一个多星期挨个补课。技术面试这件事,本质上就是逼迫你把平时“能用就行”的代码重新从原理层面审视一遍,这个过程本身就是成长。