阿里面试题摘录(一面 + 二面全解析)
阿里面试题摘录(一面 + 二面全解析)
面试指北 · 专题
题意
这份笔记整理的是阿里巴巴 Java 后端两轮技术面的题目清单:一面 70 分钟,13 题;二面 80 分钟,14 题,合计 27 题。
它的价值不在某一道题的“标准答案”,而在于能看出一家公司在 70~80 分钟里想摸清什么:
- 一面偏“地基”:JVM/并发、Spring 原理、MySQL 索引、缓存、ES 数据链路,外加一道系统设计和一道排查题,用来判断你的知识是不是“背出来的”。
- 二面偏“落地”:订单关单、分布式锁、链路追踪、凑单、扣款幂等,全是真实业务场景,考的是你有没有把技术用在业务上,能不能想到边界和异常。
所以这篇笔记按“题目 → 考点 → 怎么答 → 常见问题”的方式把 27 题逐条拆开,最后再把容易混淆的知识点集中对比一遍。
结论
先给一个整体判断,方便你带着框架去看下面的展开:
- 这套题几乎不问定义,只问“为什么”和“什么时候会坏”。比如不问“什么是 AOP”,而问“AOP 什么场景会失效”;不问“volatile 是什么”,而问“i++ 有没有并发问题”。答题时如果只给定义,基本等于没答。
- 框架题集中在 Spring 的两个基本机制:IOC(控制反转到底反转了什么)和 AOP(代理机制 + 失效场景 + 循环依赖),这三题其实是同一条线。
- 并发题反复出现:volatile、ThreadLocal、线程同步与协作、线程池传递上下文,二面还把它们和“分布式场景下的参数透传”绑在一起。
- 场景设计题一定会有“兜底”追问:关单要问“关单和支付并发怎么办”,扣钱要问“怎么保证只扣一次”,缓存刷新要问“刷新失败怎么办”。回答任何设计题,都要主动补一句“异常/失败/重复时怎么办”。
- 排查类问题考的是方法论而不是答案:接口慢、索引失效这类题,应该按“分层定位 → 缩小范围 → 验证假设”的顺序作答。
要点展开
一面(70 分钟)
1. 印象最深刻的项目,或者做过什么优化
考察点:表达能力和技术深度。面试官想听的是“你怎么发现问题、怎么验证、优化前后差多少”,而不是项目介绍。
回答骨架:
- 一句话背景:业务是什么、量级多大(QPS、数据量、机器数),只用能支撑后面结论的数字。
- 问题:某个接口 P99 800ms、GC 频繁、慢 SQL 拖垮库,说清“怎么发现的”(监控、链路追踪、火焰图、慢查询日志)。
- 定位过程:先排除什么、再确认什么。这一段最值钱,能体现你是“猜”还是“测”。
- 方案:做了什么改动,为什么选这个方案,放弃了哪些备选。
- 结果:RT 从多少降到多少、CPU 降了多少、成本变化。
- 反思:还有什么没做好,如果重来会怎么做。
优化常见方向(按投入产出排序):慢 SQL 与索引 → 减少循环里的 RPC/查库(N+1)→ 加缓存 → 异步化/批量合并 → 线程池与连接池参数 → 对象创建与序列化开销 → JVM 参数与 GC 器选择 → 架构层拆分。
2. 怎么设计高可用系统,从哪些方面考虑
考察点:有没有体系化的高可用认知,而不是只会说“加机器、做集群”。
可以从这几个维度铺开:
| 维度 | 具体手段 |
|---|---|
| 冗余 | 多实例、多可用区、主从/多副本,消除单点 |
| 流量入口 | 负载均衡、DNS 多活、健康检查与自动摘除 |
| 无状态化 | 会话外置到 Redis,便于水平扩容和故障转移 |
| 过载保护 | 限流、熔断、降级、排队,防止雪崩 |
| 隔离 | 线程池/信号量隔离、舱壁模式,避免一个依赖拖垮全站 |
| 失败处理 | 超时、重试(必须配合退避与幂等)、快速失败 |
| 数据高可用 | 主从切换、多副本一致性、备份与恢复演练 |
| 变更安全 | 灰度发布、开关、可回滚 |
| 可观测 | 指标、日志、链路追踪、告警 |
| 演练 | 混沌工程、故障注入,验证 MTTR 而不只是 MTBF |
最后收一句:高可用不是“不出故障”,而是“故障发生时影响面可控、恢复时间可预期”,所以要有明确的 SLA 和降级预案。
3. Spring AOP、IOC,控制反转反转的是什么
考察点:Spring 的两个基本机制,判断你是“会用”还是“懂原理”。
控制反转反转的是“对象创建权和依赖装配权”。传统写法是对象自己 new 依赖、自己管生命周期;用了 IOC 之后,这两件事交给容器,对象只声明“我需要什么”(构造器参数、setter、字段注解),由容器在启动时创建、组装、注入。
所以 IOC 是思想,DI(依赖注入)是实现手段,容器是载体。好处是解耦、可替换(面向接口)、便于统一做生命周期管理和代理增强。
AOP 则是把日志、事务、鉴权、监控这类横切关注点从业务代码里抽出来,在运行时织入。Spring AOP 基于动态代理:目标类实现了接口默认用 JDK Proxy,否则用 CGLIB 生成子类。这也直接决定了后面第 13 题“AOP 什么时候失效”。
4. JMM 内存模型、volatile 关键字,i++ 会有并发问题吗
考察点:并发三大特性(原子性、可见性、有序性)的理解。
JMM 是一套抽象规范,定义了线程和主内存之间的交互规则:每个线程有自己的工作内存,共享变量存在主内存,线程读写得经过工作内存,因此需要 volatile、synchronized、final 以及 happens-before 规则来约束“什么时候能看到别人写的值、什么指令不能重排”。
i++ 有并发问题,而且是典型问题。它编译后是三条指令:读值 → 加一 → 写回,三步之间都可能被打断,两个线程同时读到 10,各自加完写回 11,最后结果少加了一次。
volatile 只能保证可见性和有序性,不保证原子性,所以 volatile int i; i++ 依然是错的。正确做法:AtomicInteger(CAS,低竞争好)、LongAdder(高竞争分段更好)、或者直接 synchronized/锁。
5. ThreadLocal 如何实现多个线程访问同一个变量
考察点:ThreadLocal 的实现原理,以及“线程隔离”这个核心概念。
这题表述有歧义,稳妥的答法是先分清两种理解:
- 如果问的是“多个线程各自访问自己的那份变量、互不干扰”,那就是 ThreadLocal 的线程隔离;
- 如果问的是“多个线程共享同一个变量”,那不该用 ThreadLocal,应该用共享对象加
volatile/锁,或者用AtomicXxx。
ThreadLocal 的实现:每个 Thread 对象内部有一个 ThreadLocalMap 成员变量,ThreadLocal 本身只作为 key。set() 是把值存进“当前线程自己的 map”,get() 是从当前线程的 map 里取,所以天然隔离,也不存在并发写冲突。
几个必须提的细节:
- key 是
ThreadLocal的弱引用,value 是强引用。ThreadLocal 对象被回收后 key 变 null,value 却还挂在 map 上,线程池里线程长期存活 → 内存泄漏。所以规范是try/finally里remove()。 - 哈希冲突用开放寻址法(线性探测),不是链表。
- 父子线程传递用
InheritableThreadLocal,但线程池复用会失效(下面二面第 9 题会展开)。
6. 设计一个 RPC 框架需要考虑哪些东西
考察点:能不能把一个“调用”拆成一条完整链路。
按调用链顺序讲,最不容易乱:
- 代理层:给接口生成动态代理,让调用方像调本地方法一样调用。
- 协议与序列化:协议头(魔数、版本、序列化方式、消息类型、请求 ID)+ 序列化(Hessian、Protobuf、JSON)。要考虑跨语言、性能、兼容性(字段增删)。
- 网络传输:一般用 Netty,NIO 多路复用,长连接 + 连接池;要处理粘包半包(定长/分隔符/长度字段)。
- 服务注册与发现:注册中心(Nacos/ZK),上下线通知,本地缓存 + 推送。
- 负载均衡:随机、轮询、加权、最少活跃连接、一致性哈希。
- 集群容错:Failover(重试)、Failfast、Failsafe、Forking、广播。
- 超时与重试:必须有超时,重试要配合幂等和退避,否则放大故障。
- 线程模型:IO 线程与业务线程分离,业务线程池隔离,避免慢请求占满 IO 线程。
- 治理能力:限流熔断、灰度路由、版本管理、泛化调用、SPI 扩展点。
- 可观测:调用链埋点、耗时统计、异常上报。
7. HashMap 的链表为什么要转换成红黑树
考察点:数据结构选型背后的权衡,以及 JDK 8 的改动细节。
- 直接原因:链表查询是 O(n),哈希冲突严重时(比如 hash 函数被构造碰撞),查一个 key 要遍历整条链,性能退化为 O(n)。红黑树是 O(log n),8 个节点时链表的平均查找长度已经明显超过树。
- 深层原因:防御哈希碰撞攻击(构造大量同 hash 的 key 打满 CPU)。
- 阈值选择:树化阈值 8、退化阈值 6,中间留了缓冲区防止频繁转换;而且树化要求 table 长度 ≥ 64,否则优先扩容而不是树化,因为小表扩容就能打散冲突。
- 为什么是红黑树不是 AVL:红黑树插入删除的旋转次数少,写多读少的场景更均衡;HashMap 读多但也要考虑扩容搬迁成本。
8. MySQL 中使用 B+ 树的好处,B+ 树与 B- 树的区别
先说区别:
- B- 树:每个节点既存 key 也存数据,一个节点能放的 key 少,树更高;查找可能在非叶子节点就命中,路径长度不一致。
- B+ 树:非叶子节点只存 key(和指针),数据全部在叶子节点;叶子节点之间用双向链表串起来。
再说好处:
- 扇出更大、树更矮:非叶子节点不存数据行,一页能塞更多索引项。InnoDB 一页 16KB,主键 bigint 情况下一个非叶子节点能放上千个 key,三层就能撑住千万级数据,查一次最多 3 次磁盘 IO。
- 查询稳定:任何一次查找都必须走到叶子,路径长度相同,性能可预期。
- 范围查询和排序快:叶子链表可以直接顺序扫描,
between、order by、>、<不用回溯到根节点。 - 全表扫描友好:顺序遍历叶子节点即可。
9. 使用数据库时有哪些情况会导致索引失效
考察点:是不是真的理解 B+ 树索引的工作方式,而不是背口诀。
常见失效场景:
- 不满足最左前缀:联合索引
(a,b,c),条件只有b、c用不上索引。 - 在索引列上做函数/运算:
where date(create_time) = '2024-01-01'、where id + 1 = 10。 - 隐式类型转换:
varchar列传数字(where phone = 138xxxx),相当于对列做了类型转换。 - 前导模糊匹配:
like '%abc';like 'abc%'是可以走索引的。 - OR 连接的条件里有一侧没索引,整体退化为全表扫描。
- 范围查询之后的列失效:
(a,b)索引,a > 1 and b = 2里 b 用不上索引(注意 MySQL 8 有索引下推 ICP,可以在索引层过滤,但定位仍只靠 a)。 - 区分度太低:性别、状态这类列,优化器判断走索引不如全表扫。
- 索引列参与
!=、not in、is not null时,可能因回表成本高被放弃。 - join 两边字符集/排序规则不一致,导致索引无法用于关联。
- 统计信息过期,优化器选错执行计划(可以
analyze table后对比)。
答题时最好补一句:“失效”有两种,一种是结构上根本用不到(如最左前缀、函数),一种是优化器基于成本主动放弃(如区分度低),后者强行 force index 反而更慢。
10. 缓存是如何刷新的,怎么保证缓存刷新成功
先说刷新策略:
- Cache Aside(最常用):读时未命中查库回填;写时先更新 DB,再删除缓存(不是更新缓存)。删除失败或并发读回填旧值,可以加延迟双删兜底。
- 订阅 binlog 异步刷新:Canal 订阅 MySQL binlog,投递到 MQ,消费端删缓存。业务代码零侵入,是主流做法。
- 写时同步 + 超时兜底:所有缓存都设 TTL,保证最终一致。
- 本地缓存(Caffeine)多机场景要靠 MQ 广播失效。
怎么保证“刷新成功”:
- TTL 兜底:无论什么策略,缓存必须有过期时间,这是最后一道保险。
- 可靠投递:Canal/MQ 有重试和死信队列,消费失败可重放。
- 失败重试 + 告警:删缓存失败记录到重试表或本地队列,多次失败打告警。
- 幂等消费:消息可能重复,删除缓存天然幂等,但如果改成 set 就要带版本号。
- 对账:定时抽样比对 DB 与缓存,发现不一致主动修复。
11. volatile 有哪几个特性
三个词说清楚:
- 可见性:写 volatile 变量后立即刷回主内存,读的时候从主内存重新读,不走线程本地缓存。底层通过 lock 前缀指令(或内存屏障)实现。
- 有序性:通过插入内存屏障禁止指令重排序,典型用途是双重检查锁单例里的
instance需要 volatile,防止“分配内存 → 赋值引用 → 初始化”被重排导致拿到半成品对象。 - 不保证原子性:
i++、check-then-act依然有并发问题。
补充一句加分:volatile 还保证了 long/double 的读写原子性(32 位机器上普通 long 的读写可能被拆成两次)。
12. 数据到 ES 是怎么流转的
考察点:有没有做过搜索类业务的数据同步,知不知道一致性怎么保证。
主流链路:
业务写 MySQL → binlog → Canal → MQ(Kafka/RocketMQ) → 消费程序做字段加工/聚合 → bulk 写入 ES
关键设计点:
- 全量 + 增量:上线或重建索引时先跑全量(可用临时索引 + 别名切换实现零停机),再切增量。
- 批量写入:用
_bulk,控制批次大小(几千条/几 MB),避免一次压垮集群。 - 幂等:用业务主键作为
_id,重复消费直接覆盖。 - 顺序:同一文档的变更要保证顺序,用同一分区 key(比如订单号)投递到同一队列分区。
- 延迟:ES 的 refresh 默认 1 秒,写入不是立即可搜;需要实时可配
refresh=true,但代价大。 - 一致性:链路是最终一致,必须配定时对账任务,比对 DB 和 ES 的数量与关键字段。
- 不推荐双写:业务代码里同时写 DB 和 ES,一旦 ES 失败要么回滚要么不一致,耦合太重。
13. 如果系统接口响应比较慢,你会从哪些方面排查
方法论:先定位“慢在哪一层”,再深挖。
- 看现象:是全部接口慢还是个别接口?是持续还是尖峰?看监控大盘(RT、QPS、错误率、CPU、内存、GC、线程池、连接池、磁盘 IO)。
- 看链路:链路追踪直接给出耗时分布,判断是应用自身、DB、缓存还是下游 RPC。
- 应用层:
jstack看线程卡在哪、arthas trace看方法耗时、火焰图看 CPU 热点、GC 日志看是不是 Full GC 停顿。 - DB 层:慢查询日志、
explain看执行计划、有没有锁等待、返回数据量是否过大。 - 缓存层:命中率、大 key、热 key、网络往返次数。
- 下游依赖:RPC 超时设置是否合理、有没有重试放大、连接池是否被打满。
- 外部因素:DNS、网络抖动、负载均衡、容器资源 limit 被限流(CPU throttling)。
常见根因清单:慢 SQL / 缺索引、循环里查库(N+1)、大对象序列化、同步调用可以异步化的、锁竞争、线程池队列堆积、Full GC、下游超时没有隔离。
要点展开(续)
二面(80 分钟)
1. 电商平台上订单未支付过期如何实现自动关单
考察点:延时任务的几种实现方式,以及对“关单和支付并发”的边界思考。
方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 定时任务扫表 | 实现简单、可控 | 有延迟、大表扫描压力大(要分批+索引) |
| 延时消息(RocketMQ 延时级别 / RabbitMQ 死信+TTL) | 准时、解耦 | 依赖 MQ 可靠性,消息可能丢失或重复 |
| Redis 过期监听 / ZSet 轮询 | 轻量 | 过期事件不保证送达,不能作为唯一方案 |
| 时间轮(Netty HashedWheelTimer) | 单机高性能 | 只在单机内存,重启丢失,只适合短延时 |
| 分库分表 + 时间分片扫描 | 适合超大规模 | 实现复杂 |
工程上通常是组合:延时消息做主链路 + 定时任务做兜底扫描(防止消息丢失)+ 关单前查一次支付状态。
必须主动提的坑:
- 关单与支付并发:用户在第 30 分 59 秒付款,关单任务同时执行。解决办法是订单状态机的 CAS 更新(
update order set status=closed where id=? and status=unpaid),更新行数为 0 说明已被支付,直接放弃关单;再配合查询支付流水二次确认。 - 幂等:关单消息可能重复投递,状态机保证只会成功一次。
- 库存回滚:关单要释放库存、退优惠券,这些也要幂等。
2. 设计一个分布式链路跟踪系统,你怎么做
考察点:可观测性体系的理解,尤其是跨线程、跨服务、跨 MQ 的上下文传递。
核心概念:一次请求一个 TraceId,每个环节一个 SpanId 并记录 ParentSpanId,拼成树。
关键设计:
- 埋点:Web 入口(Filter/Interceptor)、RPC 客户端与服务端(Filter)、DB/Redis/HTTP 客户端拦截、MQ 生产与消费(把 traceId 放进消息 header)。
- 透传:HTTP 走 header,RPC 走 attachment,MQ 走消息属性;线程池要包装,把上下文带进子线程(否则异步链路就断了,这点和后面第 9 题呼应)。
- 日志关联:把 traceId 打进 MDC,日志平台可按 traceId 串起全链路。
- 采集与传输:本地异步攒批上报,走 Kafka 削峰,不阻塞业务。
- 存储与查询:ES 或 ClickHouse,按 traceId 建索引,按时间分区;链路数据量巨大,通常只保留 7~15 天。
- 采样:全量采集成本太高,用固定比例采样 + 慢请求/异常请求全采样的组合策略。
- 难点:时钟不同步(要用 Span 的相对耗时而不是绝对时间差)、跨语言、异步/线程池上下文丢失、消息积压。
3. Redis 实现分布式锁的时候,哪些问题需要考虑
按“正确性 → 健壮性 → 性能”三层说:
正确性:
- 互斥:
SET key value NX PX 30000一条命令完成“不存在才设置 + 带过期”,不要用setnx+expire两步。 - 解锁原子性:必须用 Lua 脚本校验 value 是自己再删,否则可能删掉别人的锁(A 超时锁过期,B 拿到锁,A 执行完把 B 的锁删了)。
- value 唯一:用 UUID + 线程标识,作为“锁归属”的凭证。
健壮性:
- 锁续期:业务执行时间不确定,用看门狗(Redisson 默认每 10