美团 2
类型:Java 后端面试问题整理
面试问题
Redis
- Redis 的数据结构和特点有哪些?
答:
Redis 的数据结构需要分两层理解:对外暴露的数据类型,以及 Redis 为了节省内存和提高性能采用的底层编码。
-
String
String 是最基础的类型,底层核心是 SDS,也就是简单动态字符串。它会记录当前长度和剩余空间,支持二进制安全,获取长度是
O(1)。Redis 会根据值的大小和内容选择
int、embstr、raw等编码:纯整数可以直接按整数保存;短字符串通常将对象和 SDS 一次分配;较长字符串使用独立 SDS。它适合保存字符串、数字和序列化后的对象,也支持原子自增、自减和位操作。
-
Hash
Hash 用于保存 field-value 结构。字段较少、值较小时通常使用紧凑的
listpack;数据变大后转为哈希表。它的特点是可以只读写某一个字段,不必每次读写整个对象;适合表示对象的多个属性。
-
List
List 是有序且允许重复的双端列表,底层使用
quicklist,可以理解为多个listpack组成的双向链表。它支持从两端高效插入和弹出,适合按顺序消费或维护最近数据。按下标访问中间元素需要遍历,复杂度随距离增长。
-
Set
Set 是无序且元素唯一的集合。元素全是整数且数量较少时使用
intset;其他情况使用哈希表。它支持成员判断、去重、交集、并集和差集,成员查询平均为
O(1)。 -
ZSet
ZSet 是带 score 的有序去重集合。小数据量时使用
listpack;数据变大后使用跳表和哈希表的组合。哈希表用于按成员快速查 score,跳表用于按 score 排序和范围查询。因此它既能快速判断成员是否存在,也能高效获取排名或指定分数区间的数据。
-
特殊结构
- Bitmap:基于 String 的位操作,适合大量布尔状态统计。
- HyperLogLog:用较小固定内存做基数估算,结果存在可接受误差。
- Geo:底层基于 ZSet,利用 Geohash 编码完成附近位置查询。
- Stream:支持消费者组、消息确认和 pending list,适合需要消费进度管理的消息流。
总结:面试中先区分五种基础类型 String、Hash、List、Set、ZSet,再补充其底层编码。Redis 会随着数据规模和数据特征在紧凑结构与通用结构之间切换,在节省内存的同时保证常用操作的效率。
- Redis 分布式锁的原理是什么?使用过程中遇到过什么问题?还了解哪些其他的分布式锁?
答:
Redis 分布式锁的核心是利用 Redis 的原子命令,保证同一时刻只有一个客户端获得某个资源的锁。
-
加锁
一般使用:
SET lock:order:1001 <requestId> NX PX 30000NX:Key 不存在才创建,保证互斥。PX 30000:设置过期时间,防止持锁服务宕机后锁永久存在。requestId:每次加锁生成唯一值,如 UUID,用来标识锁的持有者。
返回
OK说明加锁成功;返回空说明锁已被其他请求占用。 -
解锁
解锁必须校验锁的值,确认当前请求就是持锁者,再删除 Key。校验和删除通过 Lua 脚本原子执行:
if redis.call('get', KEYS[1]) == ARGV[1] thenreturn redis.call('del', KEYS[1])endreturn 0直接删除锁 Key 会带来误删风险:锁过期后可能已被其他请求重新获取,旧请求仍在执行时会删除新请求的锁。
-
锁超时问题
固定过期时间存在风险:业务执行时间超过锁的有效期时,锁提前过期,其他请求可能进入临界区。
实际常用 Redisson。它会在持锁期间通过看门狗定时续期,业务结束后主动释放锁。业务仍要设置合理的最大执行时间,并做好幂等和状态校验。
-
常见问题
- 锁 Key 粒度太大,吞吐量下降。通常按资源维度加锁,如
lock:order:{orderId}、lock:stock:{skuId}。 - 未校验持有者就释放锁,可能误删其他线程的锁。
- 业务时间过长,锁过期后发生并发执行。
- Redis 主从切换时,主节点刚写入锁还未完成复制就故障,切换后可能出现两个客户端都认为自己持锁。
- 锁只保证互斥,关键业务仍要依靠数据库事务、唯一约束、幂等和状态机校验兜底。
- 锁 Key 粒度太大,吞吐量下降。通常按资源维度加锁,如
-
其他分布式锁方案
- ZooKeeper:通过临时顺序节点实现锁。会话断开后临时节点自动删除,一致性和锁语义更强,适合协调类场景。
- 数据库:通过唯一索引、条件更新或悲观锁实现。实现简单,高并发竞争时数据库压力较大。
- etcd:通过 Lease、事务和 Watch 实现,适合云原生和基础设施协调场景。
总结:Redis 锁一般使用 SET NX PX 加锁、Lua 脚本校验删除解锁,生产中常用 Redisson 处理自动续期。它适合高并发、短临界区;涉及扣款、库存最终扣减等关键数据时,还需要数据库事务、唯一约束和幂等机制兜底。
- Redis 的事务。
答:
Redis 事务通过 MULTI、EXEC、DISCARD 和 WATCH 实现,核心能力是将多条命令放入队列后按顺序执行。
-
基本流程
MULTISET account:A 900DECRBY stock:1001 1EXECMULTI开启事务;后续命令进入队列;EXEC按顺序执行队列中的所有命令;DISCARD放弃队列中的命令。EXEC执行期间,其他客户端命令不会插入其中。 -
Redis 事务没有自动回滚
某条命令在执行阶段报错时,已经成功执行的命令会保留,后续命令也会继续尝试执行。因此,涉及关键业务状态时,需要配合 Lua 脚本、幂等控制、数据库事务或状态机保证完整性。
-
WATCH实现乐观锁“版本变化”是便于理解的说法。Redis 不会给每个 Key 暴露一个版本号;执行
WATCH stock:1001后,Redis 记录当前客户端正在关注这个 Key。EXEC前只要该 Key 被任意客户端修改或删除,Redis 就将当前客户端标记为数据已变化。客户端 A 客户端 BWATCH stock:1001GET stock:1001 -> 1DECR stock:1001库存变为 0MULTIDECR stock:1001EXEC -> (nil)A 查询时库存是
1,B 在 A 提交前已经修改库存,A 的判断依据已过期。此时EXEC返回空结果,事务队列不执行;业务端需要重新读取数据、重新判断并重试。 -
与 Lua 脚本的关系
事务适合固定命令组合。Lua 脚本能够在 Redis 内部完成“读取、判断、更新”,适合库存预扣、限流、抢购资格判断等场景。两者都没有自动回滚;Lua 脚本应保持短小,避免影响其他 Redis 请求。
-
集群限制
Redis Cluster 中,事务或 Lua 脚本涉及多个 Key 时,这些 Key 需要位于同一个 Hash Slot,通常使用 Hash Tag 保证,例如
stock:{1001}和order:{1001}。 -
Redis 的缓存一致性如何解决?
分库分表
- 分库分表如何设计?如何选择分库分表组件?为什么使用该方案?
答:
分库分表前先判断瓶颈来源。索引优化、归档历史数据、读写分离、缓存和批处理已经无法满足单库容量、单表数据量或写入 TPS 时,再进行水平拆分。
-
拆分方式
- 垂直分库:按业务域拆分,例如用户库、订单库、商品库和支付库。
- 垂直分表:将大字段、低频字段拆到扩展表,例如文章正文和商品详情。
- 水平分库分表:同一逻辑表的数据按规则拆到多个库表,解决数据量和写入压力问题。
分表可减小单表和索引规模;数据仍在同一个 MySQL 实例时,CPU、内存、Buffer Pool、磁盘 IO、连接数和日志写入能力仍由该实例承担。需要解决单机资源瓶颈时,分片应分布到多个 MySQL 实例。
-
分片键选择
分片键应当基数高、数据分布均匀、值稳定,并且出现在大多数查询条件中,保证 SQL 能精确路由。订单通常按
user_id或可推导出用户分片的订单号拆分;流水表可以按时间或用户维度拆分。例如,订单表采用 8 个数据库、每库 16 张表:
库序号 = hash(user_id) % 8表序号 = hash(user_id) % 16用户查询自己的订单时携带
user_id,请求即可路由到一个库的一张表。 -
绑定表
订单表和订单明细表使用相同的分片键、相同的路由规则:
user_id = 1001-> order_db_1.orders_01-> order_db_1.order_item_01两张表会位于同一个 MySQL 实例,可以执行本地 Join。这类表称为绑定表。订单明细表需要保存
user_id,或让order_id能推导出所属分片;否则系统无法确定明细所在的库表。 -
配套设计
- 使用雪花算法等全局唯一 ID,避免不同分片主键冲突。
- 小型低频配置表可作为广播表,写入会同步到所有分片。
- 避免跨库事务、跨库 Join、跨库排序和深分页。
- 提前规划扩容。简单取模扩容会导致大量数据迁移,可预分更多逻辑表、按范围分片或使用一致性哈希降低迁移量。
-
组件选择
ShardingSphere-JDBC:以 Jar 包嵌入 Java 应用,应用内完成 SQL 路由、改写和结果归并。ShardingSphere-Proxy:独立代理层,通过 MySQL 协议提供分片能力,适合多语言接入和集中治理。MyCat:代理型方案,适合已有 MyCat 体系或需要多语言统一接入的团队。
Java 微服务项目通常优先选
ShardingSphere-JDBC:链路更短,和 Spring Boot、JDBC、MyBatis 集成方便,分片规则随应用代码和配置管理,SQL 路由排查更直接。多语言服务较多、需要统一以 MySQL 协议接入时,可选择ShardingSphere-Proxy,同时建设代理层的高可用、监控和运维能力。 -
分库分表场景下,跨库查询如何解决?
答:
核心思路是减少跨库查询,将不同查询类型交给合适的方案处理。
-
关联查询:使用绑定表
订单表和订单明细表使用同一个分片键和路由规则,让相关数据位于同一个 MySQL 实例,在本地完成 Join。
-
按主键查询:建立路由能力
如果订单按
user_id分片,而请求只传入order_id,系统无法直接确定库表位置。常见方案包括:接口同时携带user_id;维护order_id -> db_index、table_index的路由表并缓存到 Redis;或在订单 ID 中编码分片信息。目标是将全库全表扫描变为一次精准路由。 -
小表关联:使用广播表
字典表、地区表、配置表等数据量小、更新频率低的表可以复制到每个分片。每个库都拥有这类表,关联查询仍由本地 MySQL 完成。广播表写入需要同步到所有分片。
-
列表展示:应用层批量查询后组装
例如订单列表需要展示用户昵称时,先查订单得到一页
user_id,再批量查询用户服务或用户库,最后在 Java 内存中组装结果。查询必须批量执行,避免 N+1 问题。 -
全局搜索和复杂统计:同步到专用查询系统
分片 MySQL 不适合处理全站搜索、全文检索、大范围筛选和复杂聚合。可以将 MySQL 的变更通过 Binlog、Canal 或 Debezium 捕获,经由 Kafka 等消息队列同步到专用查询系统:Elasticsearch 用于关键词搜索和多条件筛选;ClickHouse 用于报表和大规模聚合;Redis 用于实时计数、排行榜和热点汇总。
查询系统中的数据存在短暂同步延迟,适合搜索和统计。支付结果、库存、订单状态等关键查询仍以 MySQL 主库为准。
-
中间件广播查询:限制使用范围
ShardingSphere 可以将一条 SQL 广播到多个分片,分别执行后归并结果。该方式适合小数据量管理后台或临时查询;高频接口和大数据量查询会产生大量 SQL、网络传输和内存归并,应避免使用。
-
跨库分页:使用游标分页
查询全局最新数据时,服务会并行查询各分片,再归并排序返回前 N 条。全局
LIMIT 100000, 20会导致每个分片读取大量数据,深分页成本很高。常用游标分页:以
create_time + order_id作为稳定联合游标。下一页让每个分片查询游标之后的 N 条数据,服务层归并后返回全局前 N 条,并以本页最后一条生成下一页游标。分片查询可并行执行;全局搜索和深分页需求很重时,优先使用 Elasticsearch。
总结:核心交易查询通过分片键和绑定表实现本地查询;展示类查询通过批量查询后在应用层组装;全局搜索、报表和复杂聚合交给 Elasticsearch、ClickHouse 或数仓。
消息队列
- 介绍一下对消息队列的了解。
答:
消息队列是生产者和消费者之间的异步通信组件。生产者将消息发送给 Broker,Broker 负责存储和分发,消费者再按自己的处理能力消费消息。
生产者-> 消息队列 Broker-> 消费者-
核心价值
- 异步解耦:订单创建后通过消息触发积分、通知、搜索索引等下游处理,订单服务无需同步等待所有下游完成。
- 削峰填谷:高并发请求先进入 MQ,消费者按下游数据库可承受的速度处理,保护数据库。
- 异步处理:适合发送短信、生成报表、文件转码、日志处理和数据同步等耗时任务。
-
核心能力
- 持久化和副本机制:消息可恢复,节点故障后仍能继续消费。
- 消费确认:消费者业务处理成功后再 ACK 或提交 Offset,避免消息丢失。
- 重试和死信队列:失败消息重试,多次失败后进入死信队列,由人工或补偿任务处理。
- 顺序消息:同一业务 Key 路由到同一个队列或分区,由单线程顺序消费。
- 消费幂等:消息可能重复投递,消费者通过唯一业务键、状态机或唯一索引保证重复处理没有副作用。
-
可靠消息流程
本地事务提交成功-> 发送消息或写入本地消息表-> Broker 持久化成功-> 消费者处理业务-> 消费者业务事务提交成功-> ACK / 提交 Offset消费者在 ACK 前失败时,消息会再次投递,因此消费端需要保证幂等。
总结:消息队列主要用于异步、解耦和削峰。使用时重点关注可靠投递、消费幂等、顺序性、重复消费、消息积压监控和失败补偿。
- 消息队列如何保证消费有序性?
答:
消息顺序分为全局有序和局部有序。业务中通常要求局部有序,即同一个订单、用户或商品的消息严格有序;不同业务 Key 可以并行处理。全局有序需要所有消息进入同一个队列并由一个消费者处理,吞吐量较低。
-
生产端:相同业务 Key 路由到同一分区
例如按
orderId路由:hash(orderId) % 分区数同一个
orderId的创建、支付、发货消息始终进入同一个 Queue 或 Partition。Kafka 可用key=orderId;RocketMQ 可用shardingKey=orderId选择同一 MessageQueue。 -
Broker:单个分区内部保持顺序
Kafka 的同一个 Partition 按 Offset 递增保存消息;RocketMQ 的同一个 MessageQueue 按写入顺序保存消息。不同分区或队列之间可以并行,不保证全局顺序。
-
消费端:同一分区顺序处理
一个消费者组内,一个 Partition 同一时刻只分配给一个消费者。同一分区内不能再提交给线程池并发执行,否则会破坏顺序;多个分区可以由多个消费者并行处理。
-
消费失败处理
同一订单的支付消息失败时,后续发货消息不能直接越过它执行。通常原地重试,成功后继续消费后续消息;持续失败时告警并进入人工或自动补偿流程。
-
业务层状态校验
消息重试和异常恢复仍可能带来重复消息。订单状态更新通过状态机或版本号校验,例如:
UPDATE ordersSET status = 'PAID'WHERE order_id = ?AND status = 'CREATED';
总结:按业务 Key 路由到同一分区,同一分区由一个消费者顺序处理,可以保证局部有序;通过多个分区实现不同业务 Key 的并行消费。
- Kafka、RabbitMQ、RocketMQ 之间的区别。
答:
三者都能做异步、解耦和削峰,核心差异在定位:Kafka 偏大数据事件流和日志平台;RabbitMQ 偏复杂路由和任务分发;RocketMQ 偏高并发业务消息。
-
架构与存储模型
- Kafka:Topic 被拆成多个 Partition,每个 Partition 是按 Offset 递增的追加日志。消息按保留策略保存,消费者组独立维护 Offset,可以回放历史消息。
- RabbitMQ:消息经 Exchange 按 Binding Rule 路由到一个或多个 Queue,再由消费者消费。支持 Direct、Topic、Fanout、Headers 等灵活路由。
- RocketMQ:Topic 下有多个 MessageQueue,消费者组按队列消费,提供面向业务消息的顺序、延迟、事务、重试和死信等能力。
-
消费与消息保留
Kafka 消费者提交 Offset 后,消息仍保留到过期时间;多个 Consumer Group 可以独立消费和回放。
RabbitMQ 的消息在消费者 ACK 后通常从对应 Queue 删除。多个下游订阅同一事件时,通常让 Exchange 绑定多个独立 Queue。
RocketMQ 和 Kafka 都支持消费者组和消费进度管理;RocketMQ 内置业务消费常用的重试和死信队列能力。
-
主要特性
- Kafka:高吞吐、大消息积压、消息回放、流处理生态、单 Partition 有序,以及 Kafka 内多 Topic/Partition 写入和 Offset 提交的事务能力。
- RabbitMQ:复杂路由、Producer Confirm、Consumer ACK、TTL、死信队列和优先级队列。
- RocketMQ:按业务 Key 的局部顺序、延迟消息、事务消息、内置重试、死信队列以及 Tag/属性过滤。
-
选型
日志采集、埋点、Binlog 同步、实时计算、需要回放-> Kafka通知、邮件、短信、异步任务、复杂消息路由-> RabbitMQ订单、支付、库存、延迟关单、事务消息、顺序消息-> RocketMQ
性能受消息大小、副本数量、刷盘策略、批量参数、硬件和网络影响,不能只用绝对吞吐量排名做选型。
总结:Kafka 适合事件流和大数据管道,优势是高吞吐、消息保留和回放;RabbitMQ 适合复杂路由和可靠任务分发;RocketMQ 面向高并发业务消息,顺序、延迟、事务和重试能力更完整。实际选型还要结合团队已有基础设施、运维能力、消息规模和业务一致性要求。
JVM
-
JVM 内存结构。
-
类加载机制。
答:
类加载机制指 JVM 将 .class 字节码加载进内存,并转化为可执行 Class 对象的过程。
类生命周期:
加载-> 连接(验证、准备、解析)-> 初始化-> 使用-> 卸载双亲委派属于加载阶段中的类加载器策略。
-
加载
加载阶段根据类的全限定名获取字节码二进制流,将字节码转为 JVM 的运行时数据结构,并创建对应的
Class对象。字节码可以来自.class文件、Jar 包、网络或动态代理生成的字节码。双亲委派发生在加载阶段:当前 ClassLoader 收到请求后,先检查是否已加载,再委托父加载器;父加载器无法加载时,当前加载器才自行
findClass和defineClass。Bootstrap ClassLoader-> Extension ClassLoader(JDK 8)-> Application ClassLoaderJDK 9 以后:Bootstrap ClassLoader-> Platform ClassLoader-> Application ClassLoader双亲委派可以防止核心类被篡改、避免同一个类被重复加载,并保证核心类的统一性。
-
连接
连接包括验证、准备和解析。
- 验证:检查字节码格式、语义和类型安全,避免非法字节码危害 JVM。
- 准备:为
static变量分配内存并设置默认值。例如static int count = 10在准备阶段通常为0,初始化阶段才赋值为10;编译期常量static final int COUNT = 10在准备阶段可以直接赋值为10。 - 解析:将常量池中的符号引用替换为直接引用。解析中发现某个类尚未加载时,会发起该类的加载请求;新类加载时同样使用双亲委派。
-
初始化
初始化阶段执行类初始化方法
<clinit>,即静态变量的显式赋值和静态代码块。静态变量和静态代码块按源码顺序执行;初始化子类前会先初始化父类;JVM 保证同一个类的初始化过程线程安全。常见主动初始化时机:
new创建对象、读取或修改非编译期常量的静态字段、调用静态方法、反射调用类、启动main方法所在类,以及初始化子类前初始化父类。ClassLoader.loadClass、Class.forName("Demo", false, loader)、访问编译期常量和创建类数组通常不会触发类初始化。 -
使用与卸载
初始化完成后,类进入使用阶段。类卸载通常要求自定义类加载器、该加载器加载的类以及相关实例都不再被引用。Tomcat 热部署中,旧 Web 应用的 ClassLoader 被回收后,相关类才有机会卸载。
-
调整默认委派方式的场景
Tomcat 为支持多个 Web 应用使用不同版本的同名 Jar,会由 WebAppClassLoader 优先加载当前应用的类,实现类隔离。JDBC SPI 使用线程上下文类加载器,使由 Bootstrap ClassLoader 加载的
DriverManager能加载应用 ClassPath 中的数据库驱动实现。
总结:类生命周期是加载、连接、初始化、使用和卸载。连接包括验证、准备和解析;双亲委派属于加载阶段,用于决定由哪个类加载器查找和定义类。
- JVM 常见垃圾回收器的特点和应用场景。
答:
常见回答可以围绕 Serial、CMS、G1、ZGC 展开。
-
Serial 收集器
Serial 使用单个 GC 线程回收,回收时 Stop The World。它实现简单、额外线程开销低,适合小堆、单核或 CPU 资源有限、对停顿不敏感的简单应用;堆变大后停顿会明显增长。
-
CMS 收集器
CMS 主要回收老年代,目标是降低停顿时间。流程为:初始标记(STW)-> 并发标记 -> 重新标记(STW)-> 并发清除。
优点是大部分工作与用户线程并发,老年代回收停顿较短;缺点是标记清除会产生内存碎片,并发 GC 会占用 CPU,也会产生浮动垃圾。老年代空间不足时可能发生 Concurrent Mode Failure 并退化为 Full GC。
CMS 已在 JDK 14 移除,面试考察它主要用于理解并发标记和 G1 的设计演进,新项目不再选择 CMS。
-
G1 收集器
G1 将堆划分为多个 Region,每个 Region 可以动态作为 Eden、Survivor 或 Old。它优先回收垃圾比例高、收益大的 Region,并可通过
-XX:MaxGCPauseMillis设置期望最大停顿时间。G1 适合中大堆、希望停顿较稳定的服务端应用,能够在吞吐和停顿之间平衡;维护 Region 和 Remembered Set 存在额外开销,小堆场景优势不明显。
-
ZGC 收集器
ZGC 通过染色指针、读屏障、并发标记、并发转移和并发重映射,将大部分 GC 工作与用户线程并发执行。它适合大堆、低延迟在线服务,例如交易、推荐、网关等。
优点是停顿很短且受堆大小影响较小;缺点是并发回收会消耗更多 CPU,需要结合 JDK 版本和压测结果评估吞吐代价。
总结:Serial 简单且适合小堆;CMS 通过并发标记清除降低停顿但已移除;G1 通过 Region 和可预测停顿适合大多数服务端应用;ZGC 通过读屏障和并发整理适合大堆、低延迟场景。
- 遇到过 Full GC 或内存泄漏吗?如何排查和解决?
答:
Full GC 是一次 GC 事件,内存泄漏是无用对象仍被引用、GC 无法回收的根因之一。Full GC 频繁也可能由堆配置不足、流量突增、大对象或对象晋升过快导致,需要通过趋势和现场证据判断。
-
监控发现
重点看老年代使用率、Full GC 次数和停顿时间、接口 P99、CPU 与错误率。
正常情况下,堆内存使用曲线呈锯齿形:上涨后经过 GC 会明显回落。内存泄漏常表现为 Full GC 后老年代基线持续抬高:
第一次 Full GC 后:老年代 40%第二次 Full GC 后:老年代 55%第三次 Full GC 后:老年代 70%业务流量没有明显增长而基线持续抬高时,需要怀疑长期引用。
-
GC 日志确认
开启
-Xlog:gc*,关注 Full GC 前后的老年代大小、Full GC 频率、停顿时间和对象晋升速度。例如 Full GC 前老年代
7.8G、之后仍为7.2G,且连续多次回收量都很小,说明大量对象仍存活。还要检查 Metaspace、Direct Memory 是否持续增长。 -
对象直方图定位增长对象
Terminal window jcmd <pid> GC.class_histogram对比间隔一段时间采集的两到三份直方图,观察某类对象的实例数和占用是否持续增长。单次直方图只能说明对象占用大,持续增长才是泄漏的重要证据。
-
Heap Dump 确认引用链
Terminal window jcmd <pid> GC.heap_dump filename=/data/heap.hprof使用 MAT、VisualVM 或 JProfiler 分析 Dominator Tree、Histogram 和 Path to GC Roots。
Path to GC Roots用于定位对象为何仍然存活,例如:OrderDTO-> HashMap$Node-> static ConcurrentHashMap CACHE-> 单例 Bean-> GC Root这说明静态缓存持有对象,可能缺少容量上限或过期清理。
-
常见根因与修复
- 无上限本地缓存或 Map:设置最大容量、TTL 和淘汰策略。
- ThreadLocal 在线程池中未清理:在
finally中调用remove()。 - 无界消息、任务或请求队列:设置队列容量、拒绝策略和消费速率。
- 大对象、超大 JSON、一次性查大量数据:使用分页、流式处理和对象瘦身。
- 监听器、回调或连接对象未释放:修复注册与注销、关闭资源的生命周期。
- 类加载器泄漏或堆外内存增长:分别检查 Metaspace、ClassLoader 和 Direct Memory。
-
线上处理与验证
先限流、降级、暂停大批任务或扩容止血,同时保留 GC 日志、对象直方图和 Heap Dump 现场。修复后观察 Full GC 频率、Full GC 后老年代基线、接口 P99 和对象数量趋势是否恢复稳定。
启动时可增加:
-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/data/dump
总结:通过“老年代基线趋势 -> GC 日志 -> 多次对象直方图对比 -> Heap Dump 的 Path to GC Roots”确认内存泄漏,再按引用链修复缓存、ThreadLocal、队列或资源生命周期问题。
并发编程
- 线程池的核心参数。
答:
线程池一般通过 ThreadPoolExecutor 手动创建,核心有 7 个参数:
new ThreadPoolExecutor( corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);corePoolSize:核心线程数。当前线程数小于核心线程数时,任务到来会创建核心线程执行。maximumPoolSize:最大线程数。核心线程都忙且任务队列满时,才会继续创建非核心线程,直到达到该上限。keepAliveTime:非核心线程的空闲存活时间,超过后会被回收。unit:keepAliveTime的时间单位。workQueue:任务队列。核心线程都忙时,新任务先进入队列等待。常见有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue和PriorityBlockingQueue;生产中通常使用有界队列,避免任务无限堆积。threadFactory:线程工厂,用于设置业务线程名、是否守护线程和未捕获异常处理器,方便排查问题。handler:拒绝策略。线程数达到最大值且队列已满时触发。AbortPolicy抛异常,CallerRunsPolicy由提交任务的线程执行,DiscardPolicy丢弃任务,DiscardOldestPolicy丢弃最早任务后再尝试提交。
任务处理顺序:
提交任务-> 当前线程数 < corePoolSize:创建核心线程-> 核心线程已满:任务进入 workQueue-> workQueue 已满且线程数 < maximumPoolSize:创建非核心线程-> 队列和最大线程数都满:触发拒绝策略非核心线程空闲超过 keepAliveTime 会被回收;默认核心线程不回收,调用 allowCoreThreadTimeOut(true) 后核心线程也可以超时回收。
总结:核心是核心线程数、最大线程数、任务队列和拒绝策略。扩容顺序是先核心线程,再队列,队列满后才创建非核心线程,最后拒绝任务。
MySQL
- MySQL 索引的类型有哪些?哪种使用最多?
答:
MySQL 索引可以从业务约束和 InnoDB 存储结构两个维度回答。
-
按业务约束分类
- 主键索引
PRIMARY KEY:唯一且不能为空。 - 唯一索引
UNIQUE:保证索引列值不重复。 - 普通索引
INDEX / KEY:仅用于加速查询。 - 联合索引
INDEX(a, b, c):多个列组成,遵循最左前缀原则。 - 全文索引
FULLTEXT:用于文本检索。 - 空间索引
SPATIAL:用于地理空间数据,使用较少。
- 主键索引
-
按 InnoDB 存储结构分类
- 聚簇索引:通常是主键索引,叶子节点保存整行数据,一张 InnoDB 表只有一个。
- 二级索引:普通、唯一、联合索引通常属于二级索引,叶子节点保存索引列和主键值;查询整行数据时可能需要回表。查询字段都在二级索引中时可以走覆盖索引。
-
按底层数据结构分类
InnoDB 的常规索引主要是 B+ 树,支持等值、范围、排序和最左前缀匹配。Hash 索引主要由 Memory 引擎支持;InnoDB 的自适应 Hash 索引由内部自动维护。全文索引使用倒排结构,空间索引使用 R 树。
实际开发使用最多的是主键索引、普通二级索引和联合二级索引,底层绝大多数是 B+ 树。唯一索引用于业务唯一性约束,全文和空间索引用于特定场景。
- MySQL 的事务及其特性。
答:
事务是一组数据库操作的最小执行单元。事务中的操作要么全部成功提交,要么全部失败回滚。MySQL 中主要由 InnoDB 支持事务,MyISAM 不支持事务。
START TRANSACTION;
UPDATE accountSET balance = balance - 100WHERE user_id = 1;
UPDATE accountSET balance = balance + 100WHERE user_id = 2;
COMMIT;任意一步失败时执行 ROLLBACK,已执行的修改会撤销。
事务的四个特性是 ACID:
-
原子性 Atomicity
事务中的多个操作作为整体执行。例如 A 扣款成功、B 加款失败时,整个事务回滚,A 的扣款也撤销。InnoDB 主要通过 Undo Log 支持回滚。
-
一致性 Consistency
事务执行前后,数据都要满足业务规则和数据约束,例如总金额保持不变、账户余额不能小于零、订单状态按规定流转。一致性依赖事务机制、业务代码、数据库约束、唯一索引和外键等共同保证。
-
隔离性 Isolation
多个事务并发执行时,一个事务的中间状态不能被其他事务随意看到或影响。InnoDB 通过锁和 MVCC 实现隔离性。
-
持久性 Durability
事务提交成功后,即使 MySQL 宕机,已提交的数据也应能恢复。InnoDB 主要依靠 Redo Log 保证持久性;Redo Log 和 Binlog 的两阶段提交保证崩溃恢复结果与主从复制日志一致。
总结:事务保证一组操作整体执行。原子性依靠 Undo Log 回滚,一致性依靠事务和业务约束共同保证,隔离性依靠锁和 MVCC,持久性依靠 Redo Log。
- 介绍脏读、不可重复读、幻读。
答:
三者都是并发事务下的读一致性问题。
-
脏读
一个事务读到了另一个事务尚未提交的数据。例如事务 A 修改余额但未提交,事务 B 读取到修改后的余额,随后事务 A 回滚,事务 B 读到的数据就成了无效数据。
-
不可重复读
同一个事务中,多次查询同一行数据,读到的结果不一致。例如事务 A 第一次读余额为
100,事务 B 修改为200并提交,事务 A 第二次读取到200。 -
幻读
同一个事务中,多次按相同范围条件查询,第二次查询出现了第一次不存在的新行,或少了原有的行。例如事务 A 查询某用户订单得到 10 条,事务 B 插入一条该用户订单并提交,事务 A 再查得到 11 条。
区别:
脏读:读到未提交数据不可重复读:同一行数据被修改幻读:范围查询的结果集合发生变化隔离级别与问题的关系:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 避免 | 可能 | 可能 |
| REPEATABLE READ | 避免 | 避免 | SQL 标准下仍可能 |
| SERIALIZABLE | 避免 | 避免 | 避免 |
InnoDB 默认隔离级别是 REPEATABLE READ。普通 SELECT 使用 MVCC 的一致性读,在同一事务中读取同一个 Read View;范围当前读,例如 SELECT ... FOR UPDATE、UPDATE、DELETE,通过 Gap Lock 和 Next-Key Lock 阻止其他事务在范围内插入,从而处理幻读问题。
- 千万级数据表 CRUD 效率低,如何优化?
答:
千万级数据量本身不一定需要分库分表。优化要先定位瓶颈,再按 SQL、索引、写入、表结构和架构分层处理。
-
定位问题
先通过慢 SQL、APM 链路、
EXPLAIN、锁等待、长事务、Buffer Pool、IO、CPU 和连接池指标确认瓶颈。重点看执行计划的type、key、rows、Extra,以及是否出现Using filesort、Using temporary。 -
索引优化
按真实查询条件设计联合索引,例如按
user_id、status查询并按create_time排序时可考虑INDEX(user_id, status, create_time)。遵循最左前缀,优先高区分度和过滤性强的列,尽量使用覆盖索引,避免索引列函数计算和隐式类型转换,避免冗余索引。索引会增加写入维护成本,不能无限增加。 -
SQL 优化
避免
SELECT *、大范围 Join、N+1 查询和索引列上的函数。深分页避免LIMIT 1000000, 20,使用基于主键或时间的游标分页,例如WHERE id > ? ORDER BY id LIMIT 20。 -
写入优化
使用批量
INSERT并控制批量大小,避免超大事务,减少不必要的二级索引,主键尽量递增或趋势递增,使用主键或唯一索引精确更新,缩短事务时间。库存、计数器等热点行可通过 Redis 原子预扣、分段计数和 MQ 削峰降低锁竞争,数据库负责最终一致性。 -
表结构和数据生命周期
大字段拆到扩展表,历史数据归档,按时间分区或分表,冷热数据分离,避免在主表存储超大 JSON、TEXT、BLOB。
-
读扩展
读多写少时使用 Redis 缓存热点数据、读写分离和连接池。写后立即读取、订单状态、支付结果等一致性要求高的场景读主库或绕过缓存,避免主从延迟导致旧数据。
-
分库分表
单表、单库的数据量、写入 TPS、磁盘 IO、CPU 或连接数接近单机上限时,再考虑垂直拆分、水平分表和分库到多个 MySQL 实例。分库分表会带来跨库查询、全局 ID、分布式事务和扩容迁移等复杂度。
总结:先从慢 SQL 和执行计划入手,通过合适索引、减少扫描行数、游标分页、控制事务和锁竞争优化;再做缓存、读写分离、归档和冷热分离;单机资源确实达到瓶颈时再分库分表。
设计模式
- 了解哪些设计模式?平时开发中使用过哪些设计模式?
答:
Web 开发中可以重点讲单例模式、代理模式、策略模式和责任链模式。
-
单例模式
Spring 容器中的 Bean 默认是单例,常见于 Service、Controller、Mapper、配置类和无状态工具类。单例 Bean 中避免保存请求级别的可变成员变量,防止并发线程安全问题。
-
代理模式
Spring AOP、事务、缓存和异步方法都使用代理模式。例如
@Transactional会由代理对象在方法前开启事务,正常结束后提交,异常时回滚。日志、权限校验、接口耗时统计也是常见场景。 -
策略模式
策略模式适合一个业务有多种可替换处理规则,用于消除大量
if else。例如满减券、折扣券、立减券实现同一个CouponStrategy接口,再按优惠券类型从Map<String, CouponStrategy>中选择对应实现。支付方式、运费计算、消息渠道和风控规则也常使用策略模式。 -
责任链模式
责任链模式让多个处理器按顺序处理请求。典型链路为:参数校验 -> 登录校验 -> 权限校验 -> 限流校验 -> 风控校验。Spring MVC 的 Filter 链和 Interceptor 链是典型应用。
总结:单例用于复用无状态对象;代理用于事务、日志、缓存等横切能力;策略用于多种规则选择;责任链用于多步骤校验和处理。
- 哪些中间件使用了单例模式?
答:
这里的单例指一个应用进程中只创建一份共享对象,由多个业务线程复用。它不表示中间件服务端只有一个实例,也不表示只有一个线程或连接。
适合单例复用的通常是客户端、连接池和工厂对象,因为这些对象内部管理网络连接、IO 线程、连接池、元数据或配置,频繁创建成本较高。
-
Redis
RedissonClient通常作为单例 Bean 使用,内部维护 Redis 连接池和 Netty 线程,多个业务线程共享调用。JedisPool适合单例;具体Jedis连接从池中借取,用完归还,不能全局共享给多个线程。 -
Kafka 和 RocketMQ
KafkaProducer通常作为应用级单例复用,它线程安全,内部维护发送缓冲区、网络连接、后台 IO 线程和 Broker 元数据。RocketMQ Producer 也通常按 Producer Group 创建并由 Spring 统一管理。KafkaConsumer不支持多线程并发访问,通常一个消费线程对应一个 Consumer 实例;它保存分区分配、Offset 和poll状态,不应作为多个线程共用的全局单例。 -
数据库与 MyBatis
DataSource、HikariCP、Druid 等连接池通常是单例,内部维护多个数据库连接;每个请求或事务从池中借一个Connection,用完归还。SqlSessionFactory和 MyBatis-Spring 的SqlSessionTemplate通常是单例;SqlSession持有连接、事务和一级缓存等状态,按请求或事务使用,不共享。MyBatis 本身不通常称为线程池;处理 SQL 的线程一般是 Web 容器线程池中的请求线程。
总结:客户端、连接池和工厂对象适合单例;连接、会话、事务和 Consumer 这类带请求或线程状态的对象按自身生命周期创建和释放。