前言
最近和一位 10 年 Java 老兵进行了一场技术交流,通过 4 道覆盖 JVM、MySQL、分布式事务和线上排查的题目,发现了一个典型现象:经验丰富的工程师往往在底层原理上存在明显短板。
本文将完整还原这场"摸底考试"的过程,包括每道题的解析、暴露的知识盲区分析,以及针对性的学习计划。如果你也工作多年,不妨自测一下。
第 1 题:JVM 内存模型与线程可见性
题目
下面这段代码,在某些 JVM 配置下可能永远无法终止,请解释原因,以及如何修复:
public class VisibilityTest {
private static boolean running = true;
public static void main(String[] args) throws Exception {
new Thread(() -> {
int count = 0;
while (running) {
count++;
}
System.out.println("线程退出,count=" + count);
}).start();
Thread.sleep(1000);
running = false;
System.out.println("主线程已设置 running=false");
}
}
问题:
- 为什么可能不会终止?
- 底层机制是什么?
- 至少给出 3 种修复方式,并说明各自的原理差异
解析
核心问题:可见性
Java 内存模型(JMM)规定:每个线程有自己的工作内存(可以理解为 CPU 缓存的抽象),线程读变量时优先从工作内存读,而不是每次都从主内存读。主线程修改了 running = false 写入主内存,但工作线程可能一直读自己工作内存中的旧值 true,永远感知不到变化。
底层机制
这本质是 CPU 缓存一致性问题。现代 CPU 有多级缓存(L1/L2/L3),线程可能跑在不同核心上,各自缓存了 running 的副本。如果没有触发缓存失效和刷新,工作线程所在核心的缓存行永远是旧值。
JMM 定义了 happens-before 规则来保证可见性——如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。而上面的代码中,主线程的写和工作线程的读之间没有任何 happens-before 关系。
三种修复方式
| 方式 | 代码 | 原理 |
|---|---|---|
volatile | private static volatile boolean running = true; | volatile 写会强制刷新到主内存,volatile 读会强制从主内存读取,同时禁止指令重排序。底层通过内存屏障(Memory Barrier)实现 |
AtomicBoolean | private static AtomicBoolean running = new AtomicBoolean(true); | Atomic 包内部基于 volatile + CAS,同样保证可见性,额外提供原子操作能力 |
synchronized | 在循环体内加同步块 | synchronized 进入时清空工作内存从主内存读,退出时将工作内存刷回主内存。但性能开销大,不推荐此场景 |
第 2 题:数据库索引与查询优化
题目
假设有一张用户表,数据量 2000 万行:
CREATE TABLE user (
id BIGINT PRIMARY KEY,
city VARCHAR(32),
age INT,
gender CHAR(1),
name VARCHAR(64),
created_at DATETIME,
INDEX idx_city_age (city, age)
);
-- 查询语句
SELECT * FROM user WHERE city = '北京' AND age > 25 ORDER BY created_at LIMIT 100;
问题:
- 这条查询会走索引吗?走哪个索引?走的方式是什么(ref/range/index……)?
- 这个查询可能存在的性能问题是什么?
- 如果要优化,你会怎么改(索引层面或 SQL 层面)?
解析
1. 走哪个索引?走什么方式?
这条查询会走 idx_city_age 索引。走的方式是:
city = '北京'→ ref(等值匹配,精确定位到索引中 city 为"北京"的范围)age > 25→ range(在 ref 的基础上继续范围扫描)
所以 EXPLAIN 中你会看到 type: range,key: idx_city_age。
2. 实际的性能问题在哪里?
问题不只是 SELECT *,而是三个问题叠加:
问题一:回表。idx_city_age 只包含 city、age 和主键 id,但 SELECT * 需要 name、gender、created_at 等所有字段。MySQL 必须先用索引查出符合条件的 id,再逐行回聚簇索引取完整数据。如果 city='北京' 且 age>25 的行有 10 万条,就要回表 10 万次。
问题二:filesort。ORDER BY created_at 中的 created_at 不在索引中,索引本身按 (city, age) 排序,无法直接提供 created_at 的有序性。MySQL 必须把所有符合条件的行收集起来,在内存中做一次排序。如果结果集大,甚至需要磁盘临时文件辅助排序。
问题三:范围查询截断了索引。age > 25 是范围条件,根据最左前缀原则,范围条件之后的列无法继续利用索引。即使你把索引改成 (city, age, created_at),created_at 也无法用于排序,因为 age 已经是范围扫描了。
3. 怎么优化?
-- 方案一:覆盖索引(消除回表)
ALTER TABLE user ADD INDEX idx_city_age_created (city, age, created_at, id, name);
-- 但这个索引列太多,实际不太现实
-- 方案二(推荐):改写 SQL,先走索引查 id,再回表取少量行
SELECT u.* FROM user u
JOIN (
SELECT id FROM user WHERE city = '北京' AND age > 25
ORDER BY created_at LIMIT 100
) t ON u.id = t.id;
方案二的核心思路:子查询只查 id,可以走覆盖索引(idx_city_age 能覆盖 city、age、id),不需要回表。子查询先筛选 + 排序 + LIMIT 100,拿到 100 个 id 后,外层只需回表 100 次而不是 10 万次。这就是常说的延迟关联。
第 3 题:分布式一致性
题目
你的系统里有一个订单服务,用户下单后需要同时:
- 扣减库存(库存服务)
- 扣减账户余额(账户服务)
- 创建订单记录(订单服务自身的数据库)
三个服务各自有独立的数据库,不共享。现在的问题是:扣库存成功了、扣余额也成功了,但创建订单失败了。库存和余额已经扣了,订单却没建出来,数据不一致了。
问题:
- 你会用什么方案解决这个问题?说出你的思路就行
- 这个方案有什么代价或局限?
解析
补偿机制的核心思想
这道题对应的就是 Saga 模式的补偿方案。流程如下:
创建订单 → 扣库存 (成功) → 扣余额 (成功) → 创建订单记录 (失败)
↓ 触发补偿
退回余额 ← 退回库存
但这个方案有几个关键问题:
问题一:补偿本身也可能失败。 退回余额的接口调用超时了怎么办?库存服务此刻宕机了怎么办?如果补偿不成功,数据还是不一致的。所以补偿操作必须是幂等的——调用 1 次和调用 10 次效果一样,支持重试直到成功。
问题二:并发场景下的脏读。 扣完库存、还没退回的这段时间窗口内,其他请求看到的库存是扣减后的值,可能基于这个错误数据做了业务决策。
问题三:补偿顺序。 应该先退余额还是先退库存?如果先退余额成功、退库存失败,余额多了但库存少了,又是一致性问题。一般建议逆序补偿——最后执行的操作最先回退。
问题四:怎么知道要补偿? 你需要一个"协调者"来记录每一步的执行状态,失败后自动触发补偿链。这个协调者本身也要可靠(不能它自己也挂了就没人管了)。
业界主流方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 2PC / XA | 全局事务协调器,两阶段提交 | 强一致性 | 同步阻塞、性能差、单点故障 | 传统数据库跨库事务 |
| TCC | Try-Confirm-Cancel,业务层面实现三步 | 灵活、不锁资源 | 业务侵入大,每个操作要写三个方法 | 资金类高一致要求 |
| Saga | 正向操作链 + 补偿链 | 性能好、无锁 | 最终一致性、补偿复杂 | 长流程业务编排 |
| 本地消息表 | 本地事务写消息表,异步投递保证最终一致 | 实现简单、可靠 | 有延迟、不保证实时一致 | 大部分互联网场景 |
| 可靠消息最终一致 | 基于 RocketMQ 事务消息 | 解耦、高吞吐 | 需要消息中间件支持事务消息 | 订单→库存→支付链路 |
对这道题最实用的方案其实是本地消息表:
1. 订单服务本地事务中:创建订单 + 写一条消息到本地消息表(状态=待发送)
→ 这一步在同一个数据库事务里,要么都成功要么都失败,天然一致
2. 后台定时任务扫描消息表,把待发送的消息投递到 MQ
3. 库存服务、余额服务消费 MQ 消息,各自执行扣减
→ 执行成功则 ACK,失败则重试
4. 如果消费端始终失败,人工介入或走补偿
核心思路是:把分布式事务拆成多个本地事务 + 异步消息,用最终一致性代替强一致性。
第 4 题:网络与运维
题目
线上有一个 Spring Boot 服务,部署在 Linux 服务器上,某天突然收到大量用户反馈接口很慢。你 SSH 登上去后,只能用 Linux 原生命令(不能装新工具),你会按什么顺序排查?说出你的排查思路和每一步要看的指标。
解析
分层排查思路
核心原则是从外到内、逐层缩小范围:
接口慢
↓
是服务器本身的问题还是服务的问题?
↓
如果是系统资源异常 → CPU / 内存 / 磁盘 / 网络 哪个是瓶颈?
如果是应用层问题 → Java 进程状态 → GC 日志 → 线程 dump → 接口耗时
第一步:看系统整体负载(10 秒内完成)
# 负载均衡值,看 1/5/15 分钟的趋势
uptime
# 如果 load 远超 CPU 核数,系统过载
# CPU、内存、交换分区的全局视图
top -bn1 | head -20
# 看 IO 是否堆积
iostat -x 1 3
# 关注 %util(磁盘忙的比例)和 await(IO 等待时间)
第二步:锁定问题进程
# 找到 Java 进程的 PID 和资源占用
ps aux | grep java
# 实时看指定进程的 CPU、内存、线程数
top -p <pid> -H
# -H 显示线程级视图,能看到哪个线程最耗 CPU
第三步:根据症状深入
如果是 CPU 高:
# 找到最耗 CPU 的线程 ID
top -p <pid> -H → 记下 TID
# 线程 ID 转十六进制
printf "%x\n" <TID>
# 导出线程栈,搜索对应线程
jstack <pid> | grep <hex_tid> -A 30
# 这里能看到线程在干什么:是死循环、GC 线程、还是业务阻塞
如果是内存高或频繁 GC:
# 看 GC 统计(每秒刷新)
jstat -gcutil <pid> 1000
# 关注 FGC(Full GC 次数)和 FGCT(Full GC 总耗时)
# 如果 FGC 频繁增长,说明内存不足或内存泄漏
# 堆内存详情
jmap -heap <pid>
# 怀疑内存泄漏时导出堆 dump
jmap -dump:format=b,file=heap.hprof <pid>
# 后续用 MAT 或 VisualVM 分析
如果是网络或连接问题:
# 看 TCP 连接状态分布
netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
# 如果 CLOSE_WAIT 大量堆积 → 对端没关闭,代码没调 close
# 如果 TIME_WAIT 大量堆积 → 短连接过多
# 如果 ESTABLISHED 很大 → 并发高或连接泄漏
# 看某个端口的连接数
netstat -ant | grep :8080 | wc -l
# 看网卡流量
sar -n DEV 1 5
第四步:应用层定位
# 如果系统资源都正常,问题在应用逻辑层面
# 1. 看 JVM 线程有没有阻塞
jstack <pid> | grep -A 5 "BLOCKED\|WAITING"
# 2. 看是否有死锁
jstack <pid> | grep -A 10 "Found one Java-level deadlock"
# 3. 如果接入了 APM(SkyWalking/Prometheus),直接看慢接口链路
# 4. 如果没有 APM,看应用日志中的耗时
grep "cost\|elapsed\|耗时" app.log | tail -50
四道题的完整诊断报告
| 领域 | 题目 | 薄弱程度 |
|---|---|---|
| JVM 内存模型 | 可见性问题 | ★★★★ |
| MySQL 索引 | 联合索引与查询优化 | ★★★★ |
| 分布式事务 | 一致性方案 | ★★★ |
| 线上排查 | 系统化诊断 | ★★★ |
总体画像:10 年经验,框架运用和业务开发能力强,但计算机体系基础知识(JMM、索引结构、分布式理论、系统级排查)存在明显短板。这是典型的"经验型工程师"——靠积累解决问题,缺乏从底层原理推导的能力。
针对性学习计划
第一优先级:JVM 原理
学习路径:
- JMM 内存模型:主内存与工作内存、happens-before 八大规则、volatile/ynchronized/final 的内存语义
- GC 体系:分代模型、常见收集器(CMS → G1 → ZGC)的适用场景和调优参数、GC 日志阅读
- 类加载机制:双亲委派、打破双亲委派的场景(SPI/Tomcat)、运行时数据区结构
推荐资源:
- 《深入理解 Java 虚拟机》周志明——第 2、3、7 章精读
jstat -gcutil、jstack、jmap三个命令在自己的开发环境实际跑一遍,模拟 CPU 飙高和内存泄漏并排查
检验标准: 能解释 volatile 的内存屏障实现、能看懂 GC 日志判断是否需要调优、能从 jstack 输出定位到业务代码行
第二优先级:MySQL 索引与查询优化
学习路径:
- 索引结构:B+ 树为什么适合数据库、聚簇索引 vs 二级索引、索引的物理存储
- 索引使用规则:最左前缀原则、覆盖索引、索引下推(ICP)、索引跳跃扫描
- 查询优化实战:EXPLAIN 每个字段的含义、filesort 触发条件与优化、慢查询分析流程
推荐资源:
- 《高性能 MySQL》第 5、7 章
- 自己建表造数据,用 EXPLAIN 分析各种查询,对比加索引前后的执行计划变化
检验标准: 看到 EXPLAIN 输出能判断是否走了索引、是否回表、是否 filesort,并给出优化方案
第三优先级:分布式理论
学习路径:
- CAP 与 BASE:为什么分布式只能选 CP 或 AP、最终一致性的含义
- 分布式事务方案:2PC、TCC、Saga、本地消息表、可靠消息最终一致的原理和取舍
- 共识算法:Paxos/Raft 的核心思想(不需要手写实现,理解选举和日志复制即可)
推荐资源:
- 《分布式系统设计》Martin Kleppmann——数据复制和一致性章节
- Seata 官方文档的 TCC 和 AT 模式源码导读
检验标准: 给定一个业务场景,能选择合适的分布式事务方案并说出理由
第四优先级:线上排查体系化
学习路径:
- Linux 排查命令:top/htop、iostat、sar、netstat/tcpdump 的实战用法
- JDK 工具链:jps/jstat/jstack/jmap/jinfo 的组合使用场景
- Arthas:阿里开源的 Java 诊断工具,线上热更新、方法耗时追踪、GC 观察一站式解决
推荐资源:
- Arthas 官方文档 + 在线教程(直接在实验环境练)
- 自己制造故障场景(死循环、内存泄漏、线程死锁),用命令排查
检验标准: 给定一个线上故障现象,能在 5 分钟内定位到根因
每日学习节奏建议
| 时间段 | 内容 | 时长 |
|---|---|---|
| 早上 | 读一个底层原理章节,做笔记 | 30 分钟 |
| 中午 | 用题库自测 3-5 道题,错题标记 | 15 分钟 |
| 晚上碎片时间 | 看一篇技术博客或源码解析 | 20 分钟 |
| 周末 | 实操演练(模拟故障排查 / EXPLAIN 实战 / Arthas 练习) | 1-2 小时 |
按这个节奏,4 个方向大约 2-3 个月可以补到面试不虚的水平。最关键的第一优先级 JVM,建议先集中 3 周啃下来,后面的方向学起来会更快,因为很多底层原理是相通的。
结语
技术的深度决定了职业发展的上限。工作经验固然重要,但如果只停留在"会用"层面,很容易被替代。希望这篇文章能帮你发现自己的知识盲区,制定有针对性的学习计划。
记住:真正的资深工程师,不仅知道怎么做,更知道为什么这样做。