蚂蚁集团后端开发面经合集 03:交易一致性与高并发设计
- 轮次
- 多轮面试合集
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
面经 01 · 后端开发岗(2026-01-29)
- 除了对账,还有哪些方式保证交易状态一致性?
- 如何处理超时/失败单据的重试,且避免重复扣款?
- 若己方标记交易成功、渠道侧失败,如何发现并处置?
- 项目中的性能优化手段有哪些?
- 多线程对账时,线程数如何设置?考量因素是什么?
- 线程安全问题的成因,举例说明。
- 保证线程安全的方法有哪些?
- 死锁的成因及解决办法。
- 如何保证 Redis 缓存与 DB 数据一致性?
- 高并发下删除缓存后,如何避免 DB 被击穿?
- Redis 单机支持高并发的原因是什么?单线程 Redis 与多线程对账任务的性能逻辑差异在哪?
- 分库分表后,如何保证交易幂等性(含跨天场景)?
- 高频场景下(单独查 A、单独查 B、联合查 A+B),如何设计索引?
- 索引的底层数据结构是什么?B+ 树为何查询高效?
- MySQL 的请求处理流程是什么?
- Spring 框架中值得借鉴的设计模式/原则有哪些?
面经 02 · Java 后端开发(2025-12-09)
- HashMap 的原理是什么?JDK 1.8 做了哪些优化、解决了之前的什么问题?并发安全问题怎么解?
- JVM 内存模型是什么?方法区和元空间是什么关系,元空间完全替代方法区了吗?
- 说说你对垃圾回收器的理解,G1 的工作流程分哪几步?实际调优时优先调整哪些参数?
- ThreadLocal 的实现原理是什么?内存泄漏的原因是什么,具体怎么防范?
- MySQL 的 MVCC 基于什么机制实现?它能解决幻读吗,具体怎么做到的?
- Redis 处理 Hash 冲突用了什么方式?扩容时会阻塞服务吗,为什么?
- Spring 三级缓存分别存了什么?为什么用三级缓存而不是两级?
- SpringBoot Starter 自动配置的 SPI 机制核心是什么?怎么自定义一个 Starter?
- TCP 拥塞控制和流量控制的目标分别是什么?实现上有什么区别?
- 实现 LFU 缓存淘汰策略的核心思路是什么?怎么处理访问频率相同的键?
- 类加载器双亲委派模型被打破的常见场景有哪些?打破后有什么影响?
- AQS 同步器的核心数据结构是什么?ReentrantLock 怎么利用 AQS 实现可重入?
- 数据库索引下推的适用场景是什么?它能提升性能的原因是什么?
- 分布式 Session 一致性有哪些常见方案?哪种更适合高并发场景?
- 消息队列事务消息的核心流程是什么?怎么保证消息不丢失、不重复?
面经 03 · Java 后端开发(2025-12-09)
- 设计百万并发秒杀系统,核心要解决什么问题?架构如何选型?
- 分布式 ID 生成需要满足哪些要求?不同生成方案的优缺点是什么?
- 微服务链路追踪靠什么收集调用数据?怎么实现全链路关联?
- 数据库分库分表后,跨库查询有哪些方案?哪种方案性能更好?
- 缓存和数据库双写时,先更新缓存还是先更新数据库?怎么避免数据不一致?
- 服务网格的核心组件有哪些?在微服务中落地时会遇到什么问题?
- 线上 Full GC 频繁,可能的原因有哪些?排查时按什么顺序定位问题?
- 分布式锁的 Redlock 算法核心逻辑是什么?争议点主要集中在哪些方面?
- 容器化部署的弹性伸缩依据什么指标?怎么配置伸缩策略更合理?
- 系统容量评估时会考虑哪些因素?性能压测的关键指标有哪些?
- 灰度发布的常见策略有哪些?故障熔断的触发条件怎么设置?
- 大数据量下 Elasticsearch 的查询性能会受什么影响?怎么针对性优化?
- 实时计算框架有哪些?不同框架的适用场景是什么?怎么技术选型?
- 云原生架构下的监控体系要覆盖哪些层面?用什么工具搭建更高效?
- 技术债务重构的优先级怎么划分?系统演进时怎么保证业务不中断?
面经 04 · Java 后端开发(2025-12-07)
- 三个微服务 A/B/C 分别操作不同的数据库和 Redis,要求最终一致且尽量「准实时」;某次网络抖动导致 A 提交成功、B 超时、C 回滚,这种情况下怎么保证最后状态达成一致?
- 跨 IDC(双活)系统必须保证强一致性,但业务方要求写延迟低于 5ms,怎么实现?为什么?
- 对一个百亿级大表做 online DDL 且不影响线上读写,用什么方案?
- MySQL 主从复制延迟 30 秒后主库挂了,从库又丢了 binlog 的最后 10 秒,业务方要求最终数据一致但不允许回滚用户侧可见状态,你怎么做?
- MQ 在多分区、多消费者下怎么防止乱序和重复消费?
- 「真正的 Exactly Once」在分布式系统中存在还是不存在?
- 订单查询链路要调用 8 个服务、每个服务都要查一次 Redis,怎么把这条链路优化到至少 50%?
- 了解 k8s 吗?
- K8s 集群节点资源充足但 Pod 一直 Pending,怎么一步步推断可能的原因?
- 一个 key 对应的 value 是 JSON 结构,其中有多个子任务并发修改这个 key,会有线程安全问题吗?怎么解决?多节点情况下应该怎么加锁?
《参考解析》
1. 交易最终一致:对账之外的三道防线:对账是事后兜底,事中要靠三样东西。一是本地消息表或事务消息,把业务落库与消息投递放进同一个本地事务,让任何状态变更都被可靠驱动;二是状态机加补偿任务,单据只能在「待支付→支付中→成功/失败」这类合法路径上流转,停在中间态的单据按退避策略反复推进,而不是直接判死;三是主动反查渠道,对未达终态的单据定时查询,而不是只等对方推。单边成功(己方成功、渠道失败)多半源于「先落成功再等应答」,正确做法是本地先落处理中、拿到渠道明确应答再置终态;一旦发现单边,先冻结该单据的后续动作,再冲正或补推渠道,并把差异写入差异池人工兜底。
2. 重试与幂等:怎么重试才不重复扣款:重试的前提是错误可重试,参数非法、余额不足这类业务失败不该重试;重试要有次数上限、指数退避加抖动,且先查询再重试。超时只代表结果未知,必须靠查询或对账确认,把超时当失败直接重试就是重复扣款的经典来源。幂等落地分三层:业务单号做唯一键(渠道单号与商户号也传给渠道,让对方识别重复请求)、落库唯一索引兜住并发、本地流水表记录每次尝试并用状态机锁定终态,重复请求命中终态直接返回原结果。跨天场景要小心幂等键设计,用「单号 + 日期」做键会在 0 点后失效,隔夜重试就被当成新单据。
3. 多线程对账:线程数、线程安全与死锁:对账是 IO 密集型,线程数按「并发度 ≈ 峰值 QPS × 单次耗时」估算,再受数据库连接池与渠道限流约束,一般从核数 ×(2~4) 起调、用压测找拐点,而不是拍一个固定值。线程安全的根因是共享可变状态:计数器、批次汇总 Map、同一张单据被两个线程处理。解法优先级是分片加线程封闭(按商户、日期切分,谁的数据谁处理)优于不可变对象,不可变对象优于显式锁,最后才是并发容器。死锁四条件里最实用的是打破循环等待:统一加锁顺序、tryLock 加超时回退,数据库侧坚持按主键顺序更新并保持短事务。
4. 缓存与 DB 一致性、删缓存后的击穿防护:主流选择是 Cache Aside,先更新数据库再删除缓存,而不是更新缓存,把不一致窗口压到最小;删除失败就投递到消息队列或本地重试表异步补偿,或者订阅 binlog 驱动删除,让缓存失效与数据变更解耦。要绝对强一致只能让热点数据不走缓存,或用版本号、读写锁把读写串起来。删缓存瞬间大量请求落到 DB 的防护有三层:互斥重建或逻辑过期,只放一个线程回源、其余返回旧值或短暂等待;单飞合并同一 key 的并发回源;热点 key 干脆不设过期、由后台任务定时刷新。DB 侧再配限流与降级兜底,避免缓存故障直接压垮数据库。
5. Redis 单机高并发与多线程对账的性能逻辑差异:Redis 快在纯内存操作、单线程事件循环省掉锁与上下文切换、IO 多路复用把大量连接压进一个线程,加上跳表、压缩列表这类为场景定制的高效结构和轻量的 RESP 协议。6.0 之后的多线程只负责网络读写与协议解析,命令执行仍然单线程,所以不存在命令级并发。多线程对账走的是另一条路:把 IO 等待并行化,瓶颈在渠道与数据库的响应时间,加线程能把等待时间重叠起来,但超过下游承载后收益迅速下降,反而引入锁竞争与上下文切换。一句话,Redis 用「少线程 + 非阻塞 IO」换吞吐,对账用多线程掩盖 IO 延迟,优化的瓶颈根本不同。
6. 分库分表后的交易幂等(含跨天):幂等键在分库分表之后仍然要全局唯一,所以用业务单号而不是自增 ID;分片键尽量与幂等键对齐(按商户或订单号分片),保证同一张单据永远落到同一个库,否则要引入路由表或做二次分片来定位。落地上三层:唯一索引(分片内唯一,跨片去重靠全局去重表或 Redis SETNX)、状态机(只允许合法流转,重复请求命中终态直接返回原结果)、幂等窗口。跨天的坑在于「单号 + 日期」这类键在 0 点后失效,隔夜重试会被当新单据再扣一次;解法是幂等键只用业务单号,记录保留到业务允许的最大重试窗口(通常一周以上),并用统一的对账批次号把跨天批次串起来。
7. 高频「单查 A / 单查 B / 联合查 A+B」的索引设计:关键是让三条路径都走得上索引,而联合索引 (a, b) 只能覆盖其中两条:单独查 A 走最左前缀、联合查 A+B 能走全列甚至可以做成覆盖索引避免回表,单独查 B 用不上。所以按查询量权衡:B 的单独查询量大就为 B 单独建索引(或再建一条 (b, a)),代价是写入放大;B 基数很低、联合查询占多数时,可以只留 (a, b),让单独查 B 走扫描加过滤。配套细节:把高频返回列放进索引做覆盖、避免在索引列上做函数运算或隐式类型转换、区分度高的列放前面,用 explain 看 type、rows、Extra 验证,同时记住索引越多写越慢、buffer pool 命中率越低。
8. B+ 树与一条 SQL 在 MySQL 中的处理流程:B+ 树适合做索引是因为非叶子节点只存键不存数据,单页能放更多键、树高更低,千万级数据也就三四层,一次查询三四次 IO;数据全在叶子节点并用双向链表相连,范围查询与排序只需顺序扫叶子,不必回退上层;树自平衡,插入删除代价可控。一条 SQL 的旅程是:连接器做鉴权与连接管理,解析器做词法与语法分析生成语法树,优化器基于统计信息选索引、定 JOIN 顺序并产出执行计划,执行器调用存储引擎接口逐行取数过滤,InnoDB 在 buffer pool 里按页读写、必要时回表。理解链路的意义在于慢查询可能出在不同环节:优化器选错索引、执行器回表过多、存储引擎刷脏页,排查手段完全不同。
9. HashMap 的 JDK 1.8 优化与并发安全:1.8 的结构是数组 + 链表 + 红黑树。关键改动有四:链表长度到 8 且容量到 64 时转红黑树,把最坏查找从 O(n) 降到 O(log n),避免劣质 hashCode 或碰撞攻击把性能打穿;扩容时利用容量是 2 的幂,用 hash & oldCap 判断节点留在原位还是移到「原位 + oldCap」,省掉重新计算 hash,扩容成本减半;头插改尾插,消除 1.7 并发扩容时链表成环、CPU 打满的问题;扰动函数从四次移位异或简化为一次高位异或。但 1.8 只是不再成环,并发 put 依然会丢数据、size 计数不准,所以并发场景要用 ConcurrentHashMap(CAS 加锁单个桶)或直接换不可变 Map。
10. 方法区、元空间与 G1 调优:方法区是 JVM 规范里的逻辑概念,存放类元信息、常量与静态变量;永久代是 HotSpot 在 1.7 及以前对它的实现,位于堆内、受 MaxPermSize 限制,容易 OOM: PermGen。1.8 用元空间替代永久代,元空间在本地内存中、默认只受物理内存限制(可用 MaxMetaspaceSize 兜底),类加载器被回收后对应空间才释放。所以被替代的是永久代这个实现,方法区概念仍在。G1 的流程是初始标记(STW,标 GC Roots 直接可达)→ 并发标记(与用户线程并行,SATB 解决漏标)→ 最终标记(STW,处理剩余 SATB 队列)→ 筛选回收(按回收价值排序选一组 Region 复制清理)。调优先定堆大小与 MaxGCPauseMillis,再调并发标记触发时机 IHOP,最后才动 Region 大小与 GC 线程数。
11. ThreadLocal 原理与内存泄漏:每个 Thread 内部持有一个 ThreadLocalMap,key 是 ThreadLocal 的弱引用,value 是强引用。泄漏链路是:ThreadLocal 实例被回收后 key 变成 null,value 仍被 Entry 强引用,只要线程活着(线程池的核心线程几乎永生)这块 value 就回收不掉,Entry 本身也一直挂在 map 里。防范手段:用完必须 remove,并用 try/finally 保证异常路径也能清理;声明成 static final 让 key 不被回收,至少还能通过 key 找到并清理;不要用它存大对象;线程池场景把清理当作任务收尾的一部分。另外 ThreadLocal 不做父子传递,需要传递要用 InheritableThreadLocal,但线程池复用线程会让它失效,要换成可传递的 TTL 实现。
12. MVCC 与幻读:MVCC 靠三件套实现:隐藏列(事务 ID 与回滚指针)、undo log 版本链、ReadView。读已提交在每条 SELECT 时生成新 ReadView,可重复读在事务首次读时生成并复用,这就是两者可见性差异的来源。判断可见性时用 ReadView 里的活跃事务集合、最小与最大事务 ID 去比对版本链上的事务 ID,再沿回滚指针找到可见版本。InnoDB 在可重复读下确实能让快照读看不到别的事务新插入的行,但当前读(SELECT … FOR UPDATE、UPDATE、DELETE)依然读最新数据,所以「先查后插」这类场景必须靠 next-key lock(记录锁 + 间隙锁)堵住间隙,否则并发插入仍会造成逻辑上的幻读,说 MVCC 单独解决了幻读并不严谨。
13. Spring 三级缓存与循环依赖:一级 singletonObjects 存完整的成品 Bean,二级 earlySingletonObjects 存提前暴露的半成品,三级 singletonFactories 存能产出早期引用的 ObjectFactory(通常包着 AOP 代理工厂)。只用两级也能解决普通循环依赖,但遇上 AOP 就出问题:B 注入 A 时如果拿到原始对象,而 A 最终被代理,B 里持有的就是没被增强的 A,切面失效。三级缓存把「提前引用」延迟到真正被依赖时才通过工厂获取,必要时提前生成代理,保证注入方拿到的正是最终进一级缓存的那个对象。它的边界也要清楚:构造器注入的循环依赖、prototype 作用域的循环依赖都解决不了,Spring Boot 2.6 之后默认禁止循环依赖,正规做法仍是解耦。
14. 事务消息:流程与不丢不重:以 RocketMQ 为例,生产者先发半消息(对消费者不可见),Broker 落盘成功返回后,生产者执行本地事务,再按结果提交 commit 或 rollback,commit 之后消息才对消费者可见。如果生产者没回执,Broker 会定时回查生产者的事务状态,按本地事务表决定提交还是回滚,这一步保证「本地事务成功则消息最终一定可见」。不丢要三段接力:生产端同步发送加重试,Broker 端同步刷盘或主从同步复制,消费端处理成功才提交位点并配重试队列与死信队列。不重则不能指望中间件——它只能做到至少一次投递,真正幂等要消费端自己做:业务唯一键加去重表,或 Redis 记录已处理的消息 ID 并设过期时间,即 Exactly Once = 至少一次 + 消费端幂等。
15. 百万并发秒杀的核心矛盾与架构选型:核心矛盾只有三个:瞬时高并发写、库存不能超卖、不能拖垮整个系统。分层削峰:接入层做页面静态化与动静分离、用验证码或答题拦掉脚本流量、网关限流与黑名单;应用层无状态水平扩容,把库存判断前置到 Redis,用 Lua 把「判断库存 + 扣减 + 记用户购买标记」做成一次原子操作,抢不到的直接快速失败;抢到后只写一条消息,由消费者按数据库能力匀速创建订单,DB 侧用条件更新(库存大于 0 才减)兜底,热点行拆分成分段库存;数据层做订单分库分表。配套还需要分布式 ID(要求全局唯一、趋势递增、高可用、低延迟:雪花算法性能好但依赖时钟、要处理回拨,号段模式依赖 DB 但有批量缓冲,Redis INCR 简单但多一次 RPC)、风控防刷,以及按钮置灰加结果轮询的前端体验设计。