蚂蚁集团 Java 后端面经合集 05:秒杀项目与分布式事务
- 轮次
- 多轮面试合集
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
面经 01 · Java 后端开发(2025-04-21)
- UV 统计怎么做的?为什么要这么实现?
- 用 HyperLogLog 为什么不会占用很大空间?16KB 怎么去存储这个数据?
- 好友关注怎么做的?
- 秒杀为什么要用消息队列?
- Redis 挂了怎么办?整个集群都挂了怎么办?
- 库存信息都在 Redis 里,限流取不到库存了怎么办?
- 怎么保证 Redis 里的库存和 DB 里的库存一致,不会出错?
- Redis 做库存扣减怎么保障原子性?不用 Lua 脚本会带来什么问题?
面经 02 · Java 后端开发(2025-04-19)
- 缓存与数据库一致性问题。
- JDK 8 有哪些新特性?
- JDK 8 下 Lambda 表达式在哪些情况下不要使用,有什么缺点?
- 项目中用到的注解有哪些?
- ArrayList 与 LinkedList 的区别与适用场景是什么?
- LinkedList 的底层原理是什么?
- 使用原生 JDK 的方法去实现一个线程安全的 List,有哪几种选择?
- 有没有了解最近的 AI 热点?
面经 03 · Java 后端开发(2025-04-16)
- 秒杀面临的问题和难点是什么?
- 为什么要用延迟队列渐进式实现超时订单自动取消?和直接设置 15 分钟再判断是否付款的差别是什么?
- 假设秒杀场景没有分布式锁,怎么去实现秒杀活动?
- RabbitMQ 延迟队列底层怎么实现的?
- 这个延迟队列还是队列模型吗?怎么对消息进行排序?每一条消息过来都要重新排序吗?
- 如果让你去设计实现一个定时器或类似延迟队列的功能,怎么实现?如果用数据结构保存在内存里,出现异常导致数据丢失,怎么保证数据可靠性?
- 结合堆排序以及数据持久化,怎么同时兼顾性能和可靠性?
- RPC 的序列化方式及其区别?
- RPC 框架的原理?怎么找到远程调用的实现?
- Redis 和 Kafka 都可以实现队列,为什么使用 RabbitMQ?
- 手撕:多线程交叉打印 “Hello” 和 “World”。
- 项目里用到了 Redis,如果 Redis 存满了该怎么办?
面经 04 · C++ 后端开发(2025-04-09)
- TCP 和 UDP 的区别与使用场景?
- HTTP 和 HTTPS 的区别?
- MySQL 的 ACID 分别是什么?
- MVCC 如何解决不可重复读和幻读问题?
- MySQL 的三大日志是什么?如何记录、如何崩溃恢复、如何解决不一致问题?
- 分布式事务中的 2PC、3PC、Raft 协议分别是什么?
- Raft 协议中网络分区故障情况下怎么处理?
- 是否了解 Paxos、Saga、TCC?
- 针对分布式事务的网络传输时延和日志记录如何优化?
面经 05 · Java 后端开发(2025-04-09)
- HashMap 如何解决 hash 冲突?
- 用 HashMap 举例说明什么是线程安全?
- Java 如何解决 HashMap 的线程安全问题?
- CAS 是什么?
- 线程池的好处是?
- 创建线程池时需要考虑哪些参数?
- 线程池的工作原理?
- B+ 树索引原理是什么?为什么能提高查询效率?
- HTTPS 实现安全通信的原理是什么(SSL/TLS 握手流程)?
- 非对称加密原理?
- 对称加密与非对称加密的区别?
- 手撕:数组中三个连续子数组的最大和。
面经 06 · Java 后端开发(2025-04-07)
- 如何处理数据量大的情况?
- 项目中为什么要用到 Redis?哪些情境需要用到缓存?为什么一定要缓存而不直接用数据库?
- 介绍一下 Redis 中高效的数据结构,为什么 Redis 快?
- 数据库与缓存一致性问题,如果删除缓存中的数据失败了怎么办?
- 缓存击穿、缓存穿透、缓存雪崩如何解决?
- 如果 Redis 集群集体挂掉了怎么办?
- 工程开发过程中出现异常怎么办?
- CPU 占用率高如何解决?
《参考解析》
1. UV 统计与 HyperLogLog 的空间魔法:UV 要去重计数,用 Set 存全量用户 ID 在千万级流量下内存直接爆掉,而 HyperLogLog 用概率算法把空间压到固定 12KB 左右(Redis 实现 16384 个 6bit 桶,约 12KB,加上头部开销常被说成 16KB),代价是约 0.81% 的标准误差且只能估基数、不能取回元素。原理是:把每个元素的哈希值看成比特串,取前若干位决定桶号,剩下部分统计「第一个 1 出现的位置」,位置越靠后说明见过的元素越多;每个桶只保存自己见过的最大位置(6bit 够用),最后用调和平均数把所有桶的估计值聚合起来,并做小基数与稀疏表示校正。它支持并集(桶内取 max 合并)所以可以按天合并成周/月 UV;需要精确值的场景就得换回 Set 或位图。
2. 好友关注与 Feed 流的数据结构选型:关注关系用两个 Set(following、followers)存双向关系,判断「是否已关注」「互相关注」都是 O(1),若需要按关注时间排序或做共同关注,用 ZSet(score 存时间戳)并配合 SINTER 求交集。Feed 流有推拉两种:写扩散(发帖时把帖子 ID 写进所有粉丝的收件箱 ZSet)读快写慢,适合粉丝少的普通用户;读扩散(读时拉取所关注人的发帖列表做归并)写快读慢,适合大 V。工程上常见组合是普通用户推、大 V 拉,读时合并两边并按时间戳归并分页;收件箱只存 ID、按需回表取内容,并限制长度(保留最近 N 条)避免内存无界增长。注意分页别用 offset,用 score 游标避免重复与漏项。
3. 秒杀为什么要消息队列,以及没有分布式锁怎么做:消息队列解决的是「削峰 + 解耦 + 异步」,把瞬时并发写转成按数据库能承受的速率匀速消费,请求进来先落队列立刻返回「排队中」,前端轮询结果,数据库不会被瞬时流量打垮。若不允许用分布式锁,幂等与不超卖仍要保证:库存判断与扣减放进 Redis 的 Lua 脚本里,用「判断 + 自减」的原子性替代锁;用户维度用 SETNX 或 Hash 标记「已抢到」做一人一单的幂等去重;DB 侧最终用条件更新 stock = stock - 1 where stock > 0 兜底,即使 Redis 多扣了也不会卖出超量。核心思想是把「互斥」从显式锁换成单线程原子执行加数据库约束,锁只是实现互斥的一种手段,不是必需项。
4. Redis 扣减库存的原子性与 Lua 的价值:库存扣减要同时做「读库存、判断是否大于 0、减一、失败回滚标记」四件事,如果拆成多条命令,两次网络往返之间就会有并发窗口:两个线程都读到 1,各自减一,库存变成 -1,超卖由此发生。Lua 脚本在 Redis 里以单线程方式整体执行、中间不会插入其他命令,天然把这段复合逻辑变成原子操作,同时把多次 RTT 压缩成一次,还能顺带用返回值区分「扣减成功 / 库存不足 / 重复下单」三种结果。不用 Lua 时的替代品是 DECR 后判负再补偿(补偿期间仍有脏读)、WATCH + MULTI 乐观事务(高并发下重试率极高)或把扣减压到单分片串行执行,但它们要么牺牲一致性要么牺牲吞吐,这也是 Lua 成为标准解的原因。
5. Redis 库存与 DB 库存的一致性:两者不可能靠双写强一致,正确目标是把差异收敛在可对账的范围内。做法是让 Redis 只做「预扣」的准入闸门,DB 是唯一权威:Lua 扣减成功后发消息异步创建订单并扣 DB 库存,DB 用条件更新防止减成负数;若 DB 扣减失败(真的没库存或订单已存在),就把 Redis 库存回补(INCR)并把该用户标记清掉,这条补偿链路要能重试。为防止 Redis 与 DB 长期漂移,要用对账任务定期比对「DB 剩余库存 + 未支付占用」与 Redis 的库存值,不一致就按 DB 校正;另外给 Redis 库存设过期时间,活动结束后自动失效,避免脏数据留到下一场活动。
6. Redis 挂掉与整集群不可用的降级:单实例挂了靠哨兵或 Cluster 自动主从切换,客户端要配好超时、重试与拓扑刷新,避免切换窗口内请求全砸在旧主上。整集群不可用时不能让它拖着整个秒杀一起死,要提前设计降级:本地缓存兜住读多写少的商品信息,库存判断退化为「数据库条件更新 + 队列排队」这条慢但安全的路径,同时把闸门收紧(限流、排队页、只放部分流量进来),无状态的应用层与 Redis 之间加熔断,快速失败并返回「活动火爆,请稍后再试」。核心原则是把「Redis 挂掉」定义成容量问题而不是数据问题:宁可少卖、拒绝一部分流量,也绝不能让请求穿透到 DB 直接把系统打挂。
7. 缓存与数据库一致性,以及删缓存失败怎么办:常用的是 Cache Aside:先更新 DB 再删缓存,把并发窗口压到最小;反过来「先删缓存再更库」在并发读下更容易把旧值写回缓存,所以一般不用。删缓存失败(网络抖动、Redis 短暂不可用)时的补偿有三条路:把待删 key 投进 MQ 或本地重试表,由消费端反复重试直到成功;订阅 binlog(Canal)在数据变更后异步删缓存,让缓存失效不依赖业务代码;折中方案是延时双删,更新后延时再删一次以覆盖「读请求把旧值写回」的窗口。若业务要求读到的数据绝对新,只能对热点数据加版本号校验或干脆绕过缓存;能接受短暂不一致的,就靠过期时间兜底,别追求双写强一致。
8. 缓存穿透、击穿、雪崩的区分与解法:穿透是查一个数据库里也不存在的 key,请求每次都落到 DB,解法是缓存空值(短过期时间)加布隆过滤器前置拦截,并校验参数合法性挡掉恶意构造的 ID;击穿是某个热点 key 恰好在高并发时过期,大量请求同时回源,解法是互斥重建或逻辑过期(只让一个线程去加载,其余返回旧值),配合热点 key 不设过期、后台定时刷新;雪崩是大批 key 同时过期或缓存整体宕机,解法是过期时间加随机扰动打散、多级缓存(本地 Caffeine + Redis)、缓存集群高可用,以及 DB 侧限流与降级兜底。三者的共同底线是:缓存层出问题时,DB 只能承接被限流后的那部分流量。
9. 超时订单取消:延迟队列的实现与可靠性:直接「下单时起一个 15 分钟后的定时任务再判断是否付款」在订单量大时会有海量定时任务、重启即丢、扫描成本也不可控;延迟队列把「什么时候该处理」变成队列语义,消费端只在到期时收到一条消息。RabbitMQ 用 TTL 加死信交换机实现(消息或队列设 TTL,过期后进 DLX 再被业务队列消费),缺点是队列级 TTL 只对队首消息生效,要每条消息不同延迟得靠延迟插件或每档一个队列。自己实现可以用最小堆,堆顶即最近到期,但堆崩溃即丢,所以要先把任务 ID 与到期时间落 DB、堆只当加速索引,重启后重建,再配 WAL 或快照降低丢窗口。更好的选择是 Redis ZSet(score 存到期时间戳)或时间轮。
10. 线程池的参数与工作原理:核心参数是核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略。提交流程:核心线程未满就新建核心线程执行;核心线程满了入队;队列满了且线程数未到最大就新建非核心线程;仍然满则触发拒绝策略(AbortPolicy 抛异常、CallerRunsPolicy 回退给提交线程、Discard 静默丢弃、DiscardOldest 丢最老的)。线程池的价值是复用线程省掉创建销毁开销、限制并发数保护下游、统一管理生命周期与监控。工程要点:别用 Executors 的快捷方法(无界队列会 OOM、无限线程会打满),用有界队列加自定义命名与拒绝策略;IO 密集型线程数按「核数 × (1 + 等待时间/计算时间)」估算,CPU 密集型接近核数;队列积压要监控,否则拒绝策略和排队时延会一起爆掉。
11. CAS 与线程安全 List 的选择:CAS 是「比较并交换」,靠 CPU 的原子指令在「内存值等于预期值」时才写入新值,失败就重试,属于无锁的乐观并发策略;ABA 问题用带版本号的 AtomicStampedReference 解决,自旋开销大时用 LongAdder 这类分段累加的思路分摊热点。用原生 JDK 实现线程安全 List 有几档选择:最省事的是 Collections.synchronizedList(方法级加锁,复合操作仍需外部同步)、写读均衡可用 ReentrantReadWriteLock 自己包一层、读多写少用 CopyOnWriteArrayList(写时复制整个数组,读不加锁、写开销大且弱一致)、完全无锁的可基于 AtomicReference 指向不可变数组做 CAS 替换,最灵活的则是自己用 AQS 定义锁粒度。选型看读写比与数据量,没有普适最优。
12. HTTPS 与对称、非对称加密的分工:TLS 握手的目标是「在不安全的信道上协商出一个双方都知道、别人不知道的对称密钥」,然后用它加密后续数据。非对称加密(RSA/ECDHE)解决了密钥分发问题,但运算慢,所以只用于握手阶段的身份认证与密钥交换;对称加密(AES)快,用于真正的数据传输,两者是分工而不是替代关系。握手大致流程是:客户端发 ClientHello(支持的版本、密码套件、随机数)→ 服务端回 ServerHello 并出示证书 → 客户端验证证书链与域名、确认公钥可信 → 用服务端公钥加密预备主密钥(ECDHE 则是双方交换参数各自算出共享密钥,具备前向安全性)→ 双方由随机数与预备主密钥导出会话密钥 → 交换 Finished 校验握手完整性,之后进入对称加密通信。证书体系与 CA 信任链是整套方案能成立的前提。
13. 分布式事务协议与 Raft 网络分区:2PC 是协调者先询问所有参与者能否提交、全部同意才发 commit,问题是同步阻塞、协调者单点、参与者超时未收到决定时会一直占锁;3PC 加了询问阶段与参与者超时,缓解阻塞但没解决数据不一致。Raft 用「选主 + 日志复制 + 多数派确认」保证强一致:写请求进 Leader,追加日志后并行复制,超过半数确认即提交并返回,因此天然满足 CP。网络分区时,少数派一侧的旧 Leader 无法拿到多数派确认,写入不会提交;多数派一侧会因心跳超时发起选举并选出新 Leader(任期递增),分区恢复后旧 Leader 收到更高任期的心跳就降级为 Follower,并把本地未提交的日志回滚、与 Leader 的日志对齐,之前未提交的写请求对客户端表现为失败,需要上层重试。
14. MySQL 三大日志与崩溃恢复:redo log 是 InnoDB 的物理逻辑日志,记录「某页做了什么修改」,用 WAL 让事务提交时只需日志落盘,脏页慢慢刷,崩溃重启后按 redo 前滚恢复已提交但未落盘的数据;undo log 记录反向操作,用于事务回滚与多版本读,它本身也写 redo 以保证持久;binlog 是 Server 层的逻辑日志,记录所有修改(STATEMENT/ROW/MIXED),用于主从复制与按时间点恢复,与 redo 不同步。两阶段提交解决的就是 redo 与 binlog 的一致性:先写 redo 置 prepare、再写 binlog、最后 redo 置 commit,崩溃恢复时若 redo 处于 prepare 且 binlog 完整就提交,否则回滚,避免主从数据分叉。
15. 手撕:交叉打印与三段子数组:交叉打印 “Hello” 和 “World” 的核心是线程间精确的交替唤醒:一把锁加两个 Condition(或 wait/notifyAll)配一个轮次标志位,打印完唤醒对方、否则挂起,等待条件要用 while 包住以应对虚假唤醒;也可以用两个 Semaphore 互相 release,代码更短。三段子数组最大和:长度固定为 k 时,先用滑动窗口预处理 left[i](以 i 结尾且长 k 的最大和)与 right[i](以 i 开始的最大和),再枚举中间段起点,答案为 left[j-1] + 中间段和 + right[j+k],整体 O(n);长度不限则用 DP,f[t][i] 表示前 i 个元素分 t 段的最大和,接续上一段或新起一段,前缀和优化后是 O(n·t)。