☰
Java后端面试复盘:日志、并发、事务、算法四大核心考点精讲
2026/10/1 23:06:19 网站建设 项目流程

前几天给一个学弟做了一场蚂蚁金服后端校招一面的模拟面试,从日志聊到并发,从事务聊到算法,整整两个半小时,把他整得背后冒汗。这场面试的考察范围非常有代表性,基本覆盖了后端实习生一面的核心:日志、并发、事务、算法,每一块都不是单纯问概念,而是层层追问,一直追到你答不上来为止。

把这场模拟面试完整复盘一遍,把每道题背后的考点、面试官想听到什么、以及我当时是怎么引导他往正确方向答的,都整理出来。不管你是准备校招、实习,还是工作了两三年想查漏补缺,这篇内容都值得花十分钟过一遍。

1. 日志:从“日志框架选型”一路追到链路追踪

1.1 第一问:项目里日志框架怎么选的,日志级别怎么定

面试官习惯从简历上的项目切入,第一个问题往往是你最熟悉的东西。我让学弟先讲自己项目里的日志实现,他张口就是“用的logback,SpringBoot默认的”。这个回答能拿基础分,但经不起追问。

日志框架这个话题,面试官真正想听的深度是这样的:

  • 为什么SpringBoot默认用logback,而不是log4j2?因为logback是log4j作者的下一代作品,性能更好、原生支持SLF4J,配置更简洁。而log4j2在异步日志场景下性能更强,但需要额外引入适配层。
  • 异步日志是怎么回事?普通日志是同步写磁盘,IO操作会阻塞业务线程。异步日志是先把日志事件放进一个无锁队列,后台线程批量刷盘,业务线程只做入队操作,延迟极低。代价是极端宕机场景下可能丢日志,而且队列满的时候要有丢弃策略。
  • 日志级别为什么线上一般开info而不是debug?debug日志量是info的几十倍,磁盘IO、GC压力都会上来。但排查线上问题时debug信息又是最值钱的,所以关键业务模块可以用动态调整级别的方式,比如通过日志平台的接口临时把某个类的日志级别调到debug,而不是全局开启。

我给他提了一个非常实用的建议:所有日志输出必须用占位符,比如log.info("user:{}, order:{}", userId, orderId),而不是用字符串拼接log.info("user:" + userId)。字符串拼接会在日志级别不满足时也执行拼接逻辑,白白浪费CPU;占位符方式在级别不匹配时直接跳过,完全零开销。

日志级别这里还有个容易忽略的细节:error日志一定要带堆栈和上下文信息。很多人只打一句log.error("调用订单服务失败"),回头线上查问题的时候,连是哪个订单、什么参数、什么异常都不知道。正确写法是log.error("调用订单服务失败, orderId={}", orderId, e),把异常对象作为最后一个参数传进去,堆栈才能完整打出来。

1.2 第二问:一个请求跨多个服务,怎么把日志串起来

这个问题学弟答得磕磕绊绊,面试官追问了几次才说到点子上。核心是MDC(Mapped Diagnostic Context)。

现在的后端系统基本都是微服务架构,一个请求从网关进来,要经过认证服务、订单服务、库存服务、支付服务。如果每个服务各打各的日志,排查问题时要把四五台机器上的日志拼起来,靠时间对,效率极低。所以业界标准做法是链路追踪。

原理不复杂:网关在请求入口生成一个全局唯一的traceId,放在HTTP Header里往下游传,所有服务在打印日志时通过MDC把traceId塞进日志上下文。logback的pattern里配置[%X{traceId}],这条日志从诞生起就带着traceId,搜集起来一grep,整条调用链就很清晰了。

实操层面有三个细节:

  • 网关要用Filter或Interceptor在请求开始时MDC.put("traceId", UUID.randomUUID().toString()),结束时MDC.remove()。不remove会导致线程复用时traceId串号,线上查日志会查到脏数据。
  • 下游服务要从Header里取traceId,取不到就自己生成一个新的,但不能丢掉上游的标识,一般会加个spanId表示调用层级。这就是OpenTelemetry那套标准的简化版。
  • 线程池场景下MDC会丢。因为MDC是ThreadLocal实现的,子线程拿不到父线程的上下文。解决办法是自定义线程池的Task包装类,提交任务时把父线程的MDC内容拷进去,执行完再清理。Spring的TaskDecorator就是干这个的。

后来我还让他顺带看了一眼日志采集链路。热词里正好有filebeat和日志搜索命令,这其实是很多中小团队的真实状态:应用服务器上用Filebeat采集日志文件,打进Kafka或者Elasticsearch,再用Kibana做检索。面试聊到这块时,能说出“采集端要关注日志文件轮转、采集延迟、流量控制”这些话,比单纯背ELK三个字母强得多。

1.3 第三问:线上日志排查实战,考的不是API而是思路

我模仿面试官出了个场景题:凌晨收到告警,订单接口超时率飙升,你怎么用日志快速定位?

学弟说先看错误日志、搜异常堆栈。这个方向对,但不够快。我教他一套我自己线上排查的习惯动作:

  • 先grep关键字,比如grep "订单服务" app.log | grep "ERROR",把错误级别的内容捞出来。但是光看ERROR往往不够,问题可能出现在慢调用上,正常的日志里也有线索。
  • 用grep "订单" app.log | awk '{print $NF}'这类命令统计耗时字段,找出耗时明显偏高的traceId,再顺着traceId把整个调用链的日志拉出来。这才是“用日志做题”的方式,不是看单条日志,而是看日志之间的关系。
  • 很多问题不是没有日志,而是日志被日志框架缓冲了。异步队列不满不刷盘,日志会延迟写入。遇到“日志凭空消失”或者“最后一段日志丢了”的情况,先检查是不是异步appender的buffer没有flush,再考虑磁盘写满、时区问题、编码乱码。

这些排查思路,面试官很吃这一套,因为它证明你真的处理过线上问题,而不是只会写CRUD。

2. 并发:从线程池问到锁,再问到“一台16c32g能扛多少并发”

2.1 线程池七大参数,以及“16c32g服务器能支持多少并发”

模拟面试到这,学弟已经有点紧绑了。我问他:“你项目里的线程池是怎么配的?如果给你一台16核32G的服务器,你的接口能扛多少并发?”

这个问题坑极深。线程池的参数组合,网上背的概念很容易,但你要是真刀真枪配过线程池就知道,上下文不同,答案天差地别。

先理清七大参数:corePoolSize、maximumPoolSize、workQueue、keepAliveTime、threadFactory、handler,还有一个很多人忽略的allowCoreThreadTimeOut。线程池的执行顺序要背清楚:先创建核心线程,核心线程满了进队列,队列满了才创建非核心线程,线程数到最大后触发拒绝策略。很多人误以为先到max再进队列,这是完全反的,面试官一眼就能试出你有没有真看过源码。

线程池参数估算,我一般这么算:

  • CPU密集型任务:corePoolSize设为CPU核数+1,因为CPU密集任务基本不等待,线程多了反而频繁上下文切换。16核的话可以设16~17。
  • IO密集型任务:corePoolSize设为CPU核数*2左右,或者按“CPU核数 / (1 - 阻塞系数)”来估,阻塞系数0.8~0.9时,16核能到80~160线程。因为IO密集型线程大部分时间在等网络或磁盘,多开线程能提高吞吐。
  • 混合型任务要看哪种操作占主流,或者干脆拆成两个线程池,一个处理CPU密集计算,一个处理IO等待。

至于“16c32g支持多少并发”,这个问题的正确答案不是“能支持几万”,而是先反问:是什么类型的接口?平均耗时多少?QPS目标和响应时间要求是什么?然后粗略估算——假设接口平均耗时100ms,单线程1秒能处理10个请求,那80个线程的线程池理论上能撑到800QPS左右。如果平均耗时50ms,则能到1600QPS。内存方面,一个请求的临时对象占几百KB,32G内存减去JVM堆、系统开销、连接池,并发请求的堆积量其实很快就会成为瓶颈,所以并发的天花板往往不是CPU和线程,而是数据库连接池、下游接口吞吐和GC能力。

学弟听完说,原来不是背个数字就行。没错,面试官考察的是你估算的思路,而不是精确的结果。

2.2 synchronized和ReentrantLock的实现,以及AQS的底层逻辑

聊完线程池,我又问了一个经典问题:synchronized和ReentrantLock有什么区别?AQS是怎么实现的?

synchronized经过JDK6的锁升级,从偏向锁、轻量级锁到重量级锁,已经没那么“重”了。偏向锁是同一个线程反复获取锁时直接放行;轻量级锁是CAS自旋抢锁;竞争激烈才升级成重量级锁,线程进入阻塞队列。ReentrantLock则基于AQS实现,支持可中断获取锁、超时获取锁、公平锁、多个Condition条件队列。

AQS的底层我给他完整拆了一遍:

  • 核心是一个volatile的int state和一条FIFO双向队列(CLH变种)。
  • 加锁就是通过CAS把state从0改成1,改成功了就拿到锁;失败了就把当前线程包装成Node节点,挂到队列尾部,然后阻塞自己(LockSupport.park)。
  • 释放锁就是把state减回去,然后唤醒队列头部的线程(LockSupport.unpark),让它重新尝试抢锁。
  • 可重入机制:同一个线程再次加锁时,state累加,所以ReentrantLock可以嵌套加锁。
  • 公平锁和非公平锁的区别在于,公平锁在加锁前会先检查队列里有没有比自己排得久的节点,有就入队等待;非公平锁则直接CAS抢一次,抢不到再入队。

这段分析其实也是volatile和CAS的考点延伸。volatile保证state的可见性,CAS保证修改的原子性。面试官接着问ABA问题,学弟答上了:CAS里加版本号就能解决,比如AtomicStampedReference。

我给他说了一个理解锁的思路:锁本质上是把“并发问题”变成“排队问题”,而排队就一定有时间开销和资源占用。所以高并发场景的第一原则是缩小临界区,能不用锁就不用锁,能用乐观锁就用乐观锁,能用原子类就别用synchronized,锁的粒度越小越好。

2.3 高并发场景的数据一致性:从乐观锁到分布式锁

问了锁的实现原理,面试官自然要问现实场景:高并发扣库存、秒杀、超卖怎么防?

这题的答案层级很清晰:

  • 单机数据库层面,用悲观锁select ... for update,或者乐观锁update stock set count=count-1 where id=? and count>=1,后者不锁行,靠受影响行数判断是否成功。
  • 多实例场景,用分布式锁。Redis的SET lockKey value NX PX 30000,拿到锁的节点执行业务逻辑,执行完用Lua脚本删除锁。删除锁时要校验value是不是自己设置的值,防止误删别人持有的锁。
  • 分布式锁必须有过期时间,防止持有锁的节点宕机导致死锁。如果业务执行时间可能超过过期时间,要加看门狗机制自动续期,Redisson就是这么做的。
  • 锁只能保护共享资源的写操作,读多写少的场景可以用乐观锁版本号,性能更高。

学弟问到“高并发IM和AI Agent扛并发”这类更具体的场景,我给他讲了大致思路:IM场景的核心是长连接管理、消息异步化、离线消息存储,用Netty做接入层,接入层无状态、通过Redis pub/sub或者消息队列做服务间通信,在线状态用Redis存储心跳。AI Agent后端处理往往涉及大模型调用这类慢接口,要特别注意线程池隔离、超时熔断、排队限流,不能无脑开线程,慢接口的线程堆积比快接口严重得多。

这里的核心逻辑是:并发方案永远跟业务特征绑定,没有万能的“高并发技术栈”。

3. 事务:从ACID问到分布式事务,一条链路问到底

3.1 事务的ACID和隔离级别,面试官专抠“幻读”

第三大模块从最简单的概念开始:说说事务的ACID是什么?

ACID四个特性:原子性、一致性、隔离性、持久性。学弟背得滚瓜烂熟,但面试官真正想考的是隔离性的实现机制。于是追问来了:MySQL默认隔离级别是什么?怎么解决幻读?

MySQL InnoDB默认是REPEATABLE READ(可重复读),通过MVCC加间隙锁解决幻读。这里必须把MVCC讲透:

  • 每行记录有两个隐藏列:事务ID和回滚指针(roll pointer)。
  • 更新操作不会直接覆盖旧数据,而是生成一个新版本,旧版本通过undo log串成一条版本链。
  • 事务读取数据时,根据Read View判断当前事务能看到哪个版本,这就是快照读,不会看到别的事务未提交的修改。
  • REPEATABLE READ下,快照在事务第一次读时就生成,之后一直复用这个快照。所以同一个事务内,同样的查询语句读到的是同一份快照,从而避免不可重复读。
  • 但快照读没法防幻读,因为新插入的行不在快照里。所以InnoDB又搞了当前读来解决写冲突:select ... for update不走快照,走当前读,配合间隙锁(Gap Lock)和临键锁(Next-Key Lock),锁住一个范围,别的事务在这个范围内插入就会阻塞。

听完他恍然大悟,原来幻读不是单纯靠MVCC解决的,而是MVCC快照读+锁当前读的合体方案。能把这个层次讲清楚的候选人,事务这块基本稳了。

3.2 @Transactional为什么失效:这题的坑比你想的多

我说再考一个实战题:说说你项目里@Transactional失效的场景有哪些?

这个问题基本上能劝退一大半候选人。学弟想了半天只说出“异常被catch了导致事务不滚”。实际上案例有这么几类:

  • 同类内部调用。Service里的方法A调用方法B,B上标了@Transactional,但B是通过this调用的,没走Spring代理,事务注解完全被忽略。这是最高频的坑。
  • private方法上标@Transactional。代理模式只能增强public方法,private方法直接绕过了代理。
  • 异常被try-catch吞掉。事务是基于异常回滚的,异常都没冒出来,事务自然不知道要回滚。
  • 抛出的是检查异常。Spring默认只对RuntimeException和Error回滚,Exception不会触发回滚,除非配置rollbackFor。
  • 类没有被Spring管理,比如忘了加@Service或@Component。
  • 多线程调用,子线程里的事务是独立的,不会和主线程共享一个事务。

最典型的就是同类调用问题。我建议的解法是:把需要事务的方法拆到另一个Bean里,或者注入自身的代理(@Autowired注入自己,Spring循环依赖兜底),或者直接用TransactionTemplate手动开启事务。TransactionTemplate能解决很多注解事务搞不定的场景,比如你需要在同一个方法里多次提交不同的事务,但它要求你手动写transactionTemplate.execute()的代码,比注解啰嗦,但灵活可控。

另外面试时最好主动提一句:大事务不要滥用。事务范围太大,锁持有时间就长,并发能力下降明显。我见过一个业务把文件上传、解析、入库全放进一个事务里,每个请求跑好几秒,数据库锁等待直接拉满,生产一压测就超时。事务里只放关键数据变更,别把RPC调用、文件处理、消息发送塞进去。

3.3 订单和库存的分布式事务:从2PC到事务消息

支付宝系公司的面试,分布式事务基本是必考。面试官给了一道场景题:下单扣库存,订单系统和服务系统是独立的两个服务,怎么做数据一致性?

我给学弟整理了一套从简单到复杂的递进回答:

  • 最推荐的做法是:不要分布式事务。很多场景其实通过合并服务、调整调用顺序就能规避。比如先扣库存、再创建订单,把扣库存和创建订单放到同一个服务里,用同一个数据库事务——问题是订单和库存往往已经拆在不同库了,那就要上分布式方案。
  • 2PC(两阶段提交):有一个协调者,先问所有参与者“能不能提交”,所有人都答“能”,协调者再通知所有人“提交”。问题是第二阶段协调者挂了,所有参与者都卡住;同步阻塞严重,性能差。XA协议是2PC的工业实现,但一般团队不会直接用。
  • TCC:Try阶段预留资源,Confirm阶段确认执行,Cancel阶段取消释放。比如库存服务Try阶段冻结库存,订单服务Try阶段创建待支付订单,最后Confirm/Cancel。TCC性能比2PC好,但业务侵入性强,每个操作要写三个方法。
  • 可靠消息最终一致性:核心是事务消息。业务方先发一条“半消息”给消息队列(对消费者不可见),然后执行本地事务,本地事务成功了就Commit让消息可见,失败了就Rollback消息。如果本地事务执行完断网了,消息队列会定时回查事务状态,根据业务方查询结果决定放行还是删除消息。RocketMQ的这整套流程,已经把分布式事务的复杂度封装得比较好了。
  • 消费端必须做幂等。因为消息可能重复投递,消费者要做唯一键约束、状态机校验,保证同一条消息业务只执行一次。

学弟问我哪个方案最好,我的答案是:能用最终一致性就不用强一致,能用事务消息就不用TCC。强一致方案好用但性能差、复杂度高;最终一致性虽然做不到实时一致,但绝大多数业务几秒钟内达到最终一致完全够用,关键是消息积压和回查超时要有监控和补偿机制。

4. 算法:一面考的不是拔高题,是“手稳心细会讲”

4.1 手撕归并排序:先讲思路,再写代码,最后测边界

最后到了算法环节。我挑选的题目要么经典,要么与业务关联,比如归并排序和KMP,因为热搜词里也反复出现。

归并排序题很经典:手写一个归并排序。排序算法那么多,为什么选它?因为归并排序涉及分治思想、递归思维、稳定性和空间复杂度权衡,一段代码能考察好几层功底。

学弟写出来的代码还算利索。归并排序的框架是这样的:

public void mergeSort(int[] nums, int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(nums, left, mid); mergeSort(nums, mid + 1, right); merge(nums, left, mid, right); } private void merge(int[] nums, int left, int mid, int right) { int[] temp = new int[right - left + 1]; int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (nums[i] <= nums[j]) { temp[k++] = nums[i++]; } else { temp[k++] = nums[j++]; } } while (i <= mid) temp[k++] = nums[i++]; while (j <= right) temp[k++] = nums[j++]; System.arraycopy(temp, 0, nums, left, temp.length); }

代码能跑通只是第一步。我让他说复杂度:时间O(nlogn),空间O(n)。再问:为什么是O(nlogn)?每一层合并整体要处理n个元素,递归树高度logn,乘起来就是nlogn。再问:归并排序是稳定排序,稳定有什么用?这里讲得好的话很加分。比如订单列表先按金额排序、再按时间排序,如果排序算法不稳定,第二次排序会把第一次的相对顺序打乱,两次排序后的结果就不是“小组内按金额升序”了。

边界测试也不能漏:空数组、单元素数组、已排序数组、大量重复元素的数组。这四个用例跑一遍,鲁棒性基本就有保障了。

4.2 KMP算法:next数组到底在干嘛

下面这道是字符串匹配。我给了一个经典的场景:在长字符串中查找子串出现的位置,要求平均复杂度做到O(n+m)。不了解KMP的人想破头也不知道如何能做到线性。KMP的三言两语确实不好讲透,所以这个题特别考理解。

KMP的核心是next数组,也叫部分匹配表(前缀表)。它的含义是:当匹配失败时,模式串应该回退到哪里。常规暴力匹配失败时,文本串指针回溯到起始位置+1,模式串指针归零,每次都从头开始比,时间复杂度O(n*m)。KMP的做法是:文本串指针永不回退,模式串指针根据next数组跳到合适的位置继续比。

我让他现场推了一遍next数组。比如模式串“ABABCABAA”,每个位置的最长相等前后缀长度就是next数组的值。next[j]的计算过程要理解“回退时继续比较”的逻辑:

public static int[] buildNext(String pattern) { int m = pattern.length(); int[] next = new int[m]; int j = 0; for (int i = 1; i < m; i++) { while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) { j = next[j - 1]; } if (pattern.charAt(i) == pattern.charAt(j)) { j++; } next[i] = j; } return next; }

匹配过程就很简单了,文本串指针从头走到尾,模式串指针命中完整个模式串即找到匹配,然后通过next回退继续找下一个。

面试的时候如果觉得KMP太深,可以退一步说:我知道暴力匹配复杂度很差,所以需要避免重复比较,KMP用next数组记录最长相等前后缀,从而让文本串不回溯。能把这层“为什么”讲清楚,就算代码写得慢一点,面试官也不会太苛刻。但最好还是能写出来,因为这是字符串题里最高频的经典算法之一。

4.3 暴力枚举、剪枝与DFS:先跑通再优化的思路

快手一面算法不会太偏,但暴力枚举和剪枝这类“搜索类问题”出现频率也不低。我给了学弟一道典型题:数组中的子集枚举。要求输出数组的所有子集。

最直觉的做法是二进制枚举,2的n次方个子集。数据范围n<=20时没问题,n到了30以上就得考虑剪枝了。DFS回溯是更通用的框架:

  • 先写一个递归函数,每一层决定“选当前元素”或者“不选当前元素”。
  • 递归到终止条件时收集结果。
  • 剪枝发生在哪里?当剩余元素已经凑不够需要的数量时直接返回;当当前路径已经不满足约束条件时直接返回;当排序后相邻元素相同且不满足去重要求时跳过。
  • 回溯的关键是状态恢复,选了要记得撤销,否则兄弟分支会污染。

这类题面试官考察的不是你会不会背模板,而是你能不能根据数据范围选择算法。所以我建议学弟养成一个习惯:拿到题先不急着写,先问“数据规模多大”——这会决定暴力能不能跑过,要不要换二分、贪心、动规。能说出“n很小所以暴力枚举可行,但可以在这里剪枝”这种话,面试官会觉得你有工程判断力,而不是题海战术选手。

再补充一个复习路线的建议:数组、字符串、链表先过一遍,然后是二分、排序、栈、队列、二叉树,接着是回溯、DP、图论基础。实习一面的算法风格一般是“只要求中等题水平”,但手要稳、边界要全、思路要清晰,这三样比刷题数量重要得多。

写在复盘后

这场模拟面试做完,学弟长叹一口气说,“我以为自己准备的还行,被问到日志链路、事务失效、分布式事务的时候才知道好多细节只是记了个大概。”

我个人带人准备面试的经验是:日志、并发、事务、算法这四块确实是后端一面的核心四大件,但四块的复习方式完全不一样。日志靠的是平时写代码时养成的“可观测意识”,并发靠的是源码级别的理解,事务靠的是场景化的问题拆解,算法靠的是稳扎稳打的刷题加总结。临时背概念永远背不完,不如找几道典型的真实场景题,自己讲一遍、写一遍、复盘一遍,比看十篇面经都好使。

最后再说个自己的小习惯:面试前把简历里写到的每一个项目都按“日志查不查得到、并发扛不扛得住、事务一致不一致”这三个问题重新过一遍。这三个问题能全部答上,面试官不管怎么追问,你都有底气。希望这篇复盘能对你的面试准备有一点帮助,也祝正在准备Java后端面试的朋友早日上岸。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询