Java 后端面试题(二)
Java 后端核心面试题
一、设计题
Q1:从数据库同步导出 1000 万条用户数据成 Excel 文件,请给出完整设计解决方案
核心挑战:千万级数据一次性加载会 OOM,Excel 单个 Sheet 最多约 104 万行,需考虑内存、性能、文件大小。
方案设计:
1. 数据读取:分页流式读取
- 使用 游标分页(基于主键 ID)而非 offset 分页,避免深分页性能问题
▼sql复制代码
SELECT * FROM user WHERE id > #{lastId} ORDER BY id LIMIT 10000 - 每次取 10000 条,分批处理,避免一次性加载全部数据进内存
2. Excel 写入:流式写入
- 使用 Apache POI SXSSFWorkbook(Streaming Workbook),内存中只保留固定行数(如 100 行),其余刷到磁盘临时文件
- 或用 EasyExcel(阿里开源),更简单,专为大文件设计:
▼java复制代码
EasyExcel.write(fileName, UserVO.class) .sheet("用户数据") .doWrite(dataList); - 数据量大时拆分为多个 Sheet 或多个文件(如每 100 万行一个文件)
3. 异步 + 进度通知
- 接口收到请求后立即返回"任务已提交",后台异步执行
- 用消息队列(或线程池 + Future)异步处理导出任务
- 导出完成后通知用户下载(站内信 / 邮件 / WebSocket 推送下载链接)
4. 完整流程
▼text复制代码用户请求 → Controller 提交异步任务 → 返回"处理中" → 异步线程分页查询 + 流式写入 Excel → 上传到 OSS/MinIO → 通知用户下载链接
5. 可优化点
- 并行分页查询 + 多 Sheet 并行写入(注意分页有序性)
- 如果数据库是从库,注意主从延迟,建议读主库或强制走主库
- 超大文件考虑压缩(zip)后提供下载
Q2:如何防止重复请求?
前端防重
| 方案 | 说明 |
|---|---|
| 按钮禁用 + loading | 点击后立即 disabled = true,显示 loading 状态,接口返回后恢复 |
| 防抖(debounce) | 搜索类输入,用户停止输入 N 毫秒后再发请求 |
| 节流(throttle) | 滚动加载等高频场景,固定间隔只发一次 |
| requestId 去重 | 每次请求生成唯一 requestId,相同 requestId 的请求拦截 |
| 路由切换取消请求 | 组件卸载时 AbortController 取消 pending 请求 |
后端防重
| 方案 | 说明 |
|---|---|
| 幂等设计 | 业务本身支持幂等(如 INSERT 前先查是否存在),重复请求返回相同结果 |
| 唯一索引 | 数据库唯一约束,重复数据直接报错 |
| Token 机制 | 提交表单前先获取 token,提交时校验并删除 token(一次性) |
| 分布式锁 | Redis SETNX 锁住"用户ID + 业务标识",处理完释放 |
| AOP + 注解 | 自定义 @RepeatSubmit 注解,切面校验参数签名/时间窗口内是否重复 |
| 消息队列 | 请求入队时根据业务 key 去重(RocketMQ 支持消息去重) |
通用推荐:前端按钮防重点 + 后端 Token 机制(简单有效)+ 业务幂等设计(根本解决)。
Q3:MySQL 索引的使用原则是什么?索引是不是越多越好?有什么弊端?
索引使用原则
- 最左前缀原则:联合索引
(a, b, c)只有查询条件从最左列开始才能命中,如WHERE a=1✅、WHERE a=1 AND b=2✅、WHERE b=2❌ - 选择区分度高的列:如用户 ID、订单号,性别这种只有 2-3 个值的列不适合
- WHERE、JOIN、ORDER BY、GROUP BY 涉及的列建索引
- 覆盖索引:查询列都在索引中,不需要回表,性能最优
- 避免在索引列上使用函数或计算:
WHERE DATE(create_time) = '2024-01-01'❌,应改为范围查询 - 负向查询不命中索引:
!=、NOT IN、NOT EXISTS通常不走索引 - 模糊查询前缀匹配:
LIKE 'abc%'✅ 走索引,LIKE '%abc'❌ 不走
索引不是越多越好,弊端如下:
| 弊端 | 说明 |
|---|---|
| 写入性能下降 | INSERT/UPDATE/DELETE 需要同步维护所有索引,索引越多越慢 |
| 占用磁盘空间 | 每个索引都是一个 B+ 树,数据量大时索引本身占用可观的磁盘 |
| 优化器选择困难 | 索引太多可能导致优化器选错索引,反而变慢 |
| 内存占用 | 索引页加载到 buffer pool,挤占数据页的缓存空间 |
Q4:用 Stream API 对 List 做过滤、计算、排序、打印
▼java复制代码List<User> users = Arrays.asList( new User("张三", 25, 8000), new User("李四", 30, 12000), new User("王五", 22, 6000), new User("赵六", 28, 15000) ); // 过滤:年龄 > 24 List<User> filtered = users.stream() .filter(u -> u.getAge() > 24) .collect(Collectors.toList()); // 计算:工资总和 int totalSalary = users.stream() .mapToInt(User::getSalary) .sum(); // 计算:平均工资 double avgSalary = users.stream() .mapToInt(User::getSalary) .average() .orElse(0); // 排序:按年龄升序 List<User> sorted = users.stream() .sorted(Comparator.comparingInt(User::getAge)) .collect(Collectors.toList()); // 排序:按工资降序 List<User> sortedBySalary = users.stream() .sorted(Comparator.comparingInt(User::getSalary).reversed()) .collect(Collectors.toList()); // 分组:按年龄分组 Map<Integer, List<User>> groupByAge = users.stream() .collect(Collectors.groupingBy(User::getAge)); // 最大值 User maxSalaryUser = users.stream() .max(Comparator.comparingInt(User::getSalary)) .orElse(null); // 遍历打印 users.stream() .sorted(Comparator.comparingInt(User::getAge)) .forEach(u -> System.out.println(u.getName() + ": " + u.getSalary()));
常用 API 速查:
- 中间操作:
filter、map、flatMap、sorted、distinct、limit、skip、peek- 终端操作:
collect、forEach、reduce、count、anyMatch、allMatch、findFirst、max/min- Collectors:
toList()、toMap()、groupingBy()、joining()、summarizingInt()
二、算法题:树结构
建议系统学习:二叉树遍历(前中后序递归+迭代)、层序遍历(BFS)、二叉搜索树、平衡二叉树(AVL)、深度/高度计算、路径问题。
常见树题型速查
1. 二叉树遍历
▼java复制代码// 前序遍历:根 → 左 → 右 void preOrder(TreeNode root) { if (root == null) return; System.out.println(root.val); preOrder(root.left); preOrder(root.right); } // 中序遍历:左 → 根 → 右(BST 中序遍历结果有序) void inOrder(TreeNode root) { if (root == null) return; inOrder(root.left); System.out.println(root.val); inOrder(root.right); } // 后序遍历:左 → 右 → 根 void postOrder(TreeNode root) { if (root == null) return; postOrder(root.left); postOrder(root.right); System.out.println(root.val); } // 层序遍历(BFS) List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> res = new ArrayList<>(); if (root == null) return res; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); List<Integer> level = new ArrayList<>(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); level.add(node.val); if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } res.add(level); } return res; }
2. 二叉树最大深度
▼java复制代码int maxDepth(TreeNode root) { if (root == null) return 0; return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; }
3. 翻转二叉树
▼java复制代码TreeNode invertTree(TreeNode root) { if (root == null) return null; TreeNode left = invertTree(root.left); TreeNode right = invertTree(root.right); root.left = right; root.right = left; return root; }
4. 判断对称二叉树
▼java复制代码boolean isSymmetric(TreeNode root) { return check(root, root); } boolean check(TreeNode p, TreeNode q) { if (p == null && q == null) return true; if (p == null || q == null) return false; return p.val == q.val && check(p.left, q.right) && check(p.right, q.left); }
5. 路径总和
▼java复制代码boolean hasPathSum(TreeNode root, int targetSum) { if (root == null) return false; if (root.left == null && root.right == null) { return root.val == targetSum; } return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val); }
三、MySQL 面试题
Q5:MySQL 查询没命中索引时是什么锁?
- 在 RC(读已提交) 隔离级别下:只锁住实际扫描到的行,不匹配的行会立即释放锁
- 在 RR(可重复读) 隔离级别下(MySQL 默认):
- 如果走了索引,行锁锁住索引记录 + Gap Lock
- 如果 没走索引,行锁退化为 表锁(所有记录加锁 + 全部 Gap),导致大面积锁等待,极易死锁
结论:RR 级别下没命中索引非常危险,update/delete 会锁全表,务必让 SQL 走索引。
Q6:讲讲事务隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 会 | ❌ 会 | ❌ 会 | 不加锁 |
| READ COMMITTED | ✅ 解决 | ❌ 会 | ❌ 会 | 快照读(每次读最新快照) |
| REPEATABLE READ(默认) | ✅ 解决 | ✅ 解决 | ⚠️ 部分解决 | 一致性快照 + Gap Lock |
| SERIALIZABLE | ✅ 解决 | ✅ 解决 | ✅ 解决 | 所有读加共享锁 |
概念解释:
- 脏读:读到其他事务未提交的修改
- 不可重复读:同一事务内两次读取同一行,结果不同(其他事务 UPDATE 了)
- 幻读:同一事务内两次范围查询,记录数不同(其他事务 INSERT 了)
RR 如何解决幻读:使用 Next-Key Lock(行锁 + Gap Lock),锁定索引记录之间的间隙,阻止其他事务插入。
Q7:SQL 执行很慢,怎么排查?
排查流程:
-
EXPLAIN分析执行计划type:连接类型,ALL(全表扫描)最差,ref/eq_ref较好key:实际使用的索引,NULL表示没用索引rows:预估扫描行数Extra:Using filesort、Using temporary需关注
-
检查是否命中索引
- 索引列上是否有函数/计算?
- 是否满足最左前缀?
- 类型是否隐式转换?(如
varchar字段用int类型查)
-
慢查询日志
▼sql复制代码SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';分析慢查询日志找出问题 SQL
-
锁等待
▼sql复制代码SHOW PROCESSLIST; -- 查看当前连接 SELECT * FROM information_schema.INNODB_TRX; -- 当前事务 SELECT * FROM information_schema.INNODB_LOCKS;-- 锁信息 SHOW ENGINE INNODB STATUS; -- InnoDB 状态 -
数据量大但索引没问题
- 考虑分页是否深分页(
LIMIT 1000000, 10),改用游标分页 - 检查返回数据量是否过大,考虑分批
- 考虑读写分离,把慢查询分流到从库
- 考虑分页是否深分页(
-
其他方向
- 检查磁盘 IO、CPU 是否瓶颈
- 大字段(TEXT/BLOB)是否影响
- 表设计是否合理(是否需分库分表)
四、Redis 面试题
Q8:Redis 淘汰策略
| 策略 | 说明 |
|---|---|
| noeviction | 不淘汰,内存满时写入报错(默认) |
| allkeys-lru | 所有 key 中 LRU(最近最少使用)淘汰 |
| volatile-lru | 设了过期时间的 key 中 LRU 淘汰 |
| allkeys-random | 所有 key 中随机淘汰 |
| volatile-random | 设了过期时间的 key 中随机淘汰 |
| volatile-ttl | 设了过期时间的 key 中,TTL 越短越先淘汰 |
| allkeys-lfu | 所有 key 中 LFU(最不经常使用)淘汰(Redis 4.0+) |
| volatile-lfu | 设了过期时间的 key 中 LFU 淘汰(Redis 4.0+) |
LRU vs LFU:
- LRU:最近被访问过,说明热,保留
- LFU:被访问频率高,说明热,保留(避免偶发性访问误判为热数据)
生产建议:通常用 allkeys-lru,缓存场景下最通用。
Q9:Redis 默认设置下,服务器突然断电会不会丢数据?
会丢数据。
原因分析:
- Redis 默认 RDB 和 AOF 都不开启(或 RDB 按固定间隔触发),两次持久化之间写入的数据全在内存中
- 即使开启 RDB,默认
save 900 1(900 秒内至少 1 次修改才触发),断电可能丢失最近 15 分钟的数据 - AOF 默认
appendfsync everysec(每秒刷盘),最多丢 1 秒数据,但也不是完全实时
解决方案:
- 开启 AOF
appendfsync always(每条命令刷盘)—— 最安全但性能最差 - 开启 AOF
appendfsync everysec(每秒刷盘)—— 平衡方案,最多丢 1 秒 - 主从 + Sentinel/Cluster,如果主挂了从库可以补上(异步复制仍有少量丢失)
Q10:Redis 结合 Lua 脚本的经验
使用场景:
- 原子性操作:多个 Redis 命令需要原子执行,如"先判断库存再扣减"
- 减少网络往返:多条命令打包一次执行
示例(库存扣减):
▼lua复制代码local key = KEYS[1] local qty = tonumber(ARGV[1]) local stock = tonumber(redis.call('get', key)) if stock == nil or stock < qty then return -1 -- 库存不足 end redis.call('decrby', key, qty) return stock - qty -- 返回剩余库存
注意事项:
- Lua 脚本执行时是阻塞的,不能执行过于耗时的操作
- 脚本需要提前在开发环境充分测试,避免线上脚本 bug
- Redis 6.2+ 支持
FUNCTION,可以更好地管理脚本
Q11:怎么解决缓存击穿和缓存雪崩?
缓存雪崩
现象:大量缓存 key 同时过期,请求全部打到数据库,数据库压力暴增可能宕机。
解决方案:
| 方案 | 说明 |
|---|---|
| 随机过期时间 | 在基础 TTL 上加随机值,如 TTL = 3600 + random(0, 600) |
| 缓存预热 | 提前将热点数据加载到缓存,避免冷启动打崩 |
| Redis 集群/哨兵 | 避免单点 Redis 挂掉导致缓存全不可用 |
| 多级缓存 | 本地缓存(Caffeine)+ Redis 多级,Redis 挂了本地还能顶 |
| 熔断降级 | 数据库压力过大时限流,返回兜底数据 |
缓存击穿
现象:单个热点 key 过期瞬间,大量并发请求穿透到数据库。
解决方案:
| 方案 | 说明 |
|---|---|
| 互斥锁 | 第一个线程获取锁去查数据库,其他线程等待锁释放后直接读缓存 |
| 逻辑过期 | key 本身不过期,value 中存一个"逻辑过期时间",发现过期后异步更新,返回旧值 |
| 永不过期 | 热点 key 不设过期时间,后台线程定时刷新 |
追问:如果并发量很大,同时查多个 key 怎么办?
- 分布式锁 + 细粒度锁:每个 key 一把锁,互不影响
▼java复制代码
String lockKey = "lock:cache:" + key; boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); - 逻辑过期 + 异步刷新:key 不真的过期,value 内含过期时间戳,发现逻辑过期后仍返回旧值,异步起一个线程去更新,所有并发请求都能立刻拿到旧值返回
- 限流 + 降级:数据库查询层加限流,超过阈值的请求直接返回降级数据
- 缓存预热任务:定时扫描即将过期的热点 key,提前刷新
五、其他面试题
Q12:了解 Dubbo 的序列化机制吗?
Dubbo 支持的序列化协议:
| 协议 | 特点 |
|---|---|
| Hessian2(默认) | 跨语言、二进制、字段顺序敏感、性能较好 |
| Fastjson2 | JSON 格式,可读性好但性能不如二进制 |
| Kryo | 高性能二进制,需提前注册类 |
| Protobuf | Google 出品,跨语言、性能最优、需 .proto 定义 |
| Java 原生 | 兼容性最好但性能最差 |
Hessian2 注意事项:
- 序列化和反序列化依赖字段顺序,增加字段时建议加在末尾否则需兼容处理
- 父类字段会优先序列化
生产建议:对性能有要求用 Kryo 或 Protobuf,一般场景 Hessian2 够用。
Q13:AI 对话超出上下文限制怎么办?
现象:AI 对话轮次多了以后,超出上下文窗口,AI "忘记"之前的内容。
解决方案:
| 方案 | 说明 |
|---|---|
| 拆分任务 | 大需求拆为小任务,一个对话只聚焦一个模块,减少单次对话轮次 |
| 分阶段开发 | 先设计(一个会话)→ 讨论通过后写代码(另一个会话) |
| 关键信息总结 | 阶段完成后让 AI 总结当前状态,新建对话时把总结贴回去 |
| 外部文档沉淀 | 把架构设计、接口约定、数据库表结构写到项目 md 文件中,新会话直接引用 |
| Spec-Driven 开发 | 先写好详细需求 spec 文档,然后让 AI 严格按 spec 实现 |
| 分层沟通 | 不在一轮中既讨论架构又写代码,架构定了后再开新对话写代码 |
| 上下文压缩工具 | 使用支持上下文摘要/压缩的工具(部分 IDE 插件支持) |
核心思路:不要把 AI 当"一个大项目长对话搞定",而是把它当"按 spec 执行的小粒度任务工具",每次聚焦一个目标。
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
内容推荐
找实习从7月22号开始投,现在也总算是有offer了
5
怎么会有笨蛋从一月到现在,背了7个月的面试题,还啥都不会呢,到底背哪里去了,该怎么办
3
Spring Boot 如何处理跨域请求(CORS):深度调研报告
3
【入职求助贴】萌新刚入职某大厂做后端开发,目前还在试用期。最近遇到一个棘手的问题,想向大家求助一下。入职不久,leader 给我派了一个任务。跟我说是0.5天就可以解决,我刚毕业入职,做了一个星期没有做出来。实现一个收集定时成功任务的案例。听起来好像不复杂,但我自己摸索着做了一整个星期,到现在还没达到预期效果。这一周我基本是“边学边做”的状态,遇到卡点也会每天主动找 leader 沟通进度和疑问。
2
#字节内推# 实习、校招、社招均有岗位
4
作者分享
试用下最近很火的阿里云秒悟
5
Java 后端面试题(一)
4
嗨害嗨,又是连续上班7天的一周
6
项目要求要用 dify 做一个ai助手,感觉这东西难在特别灵活,摸清楚之后其实还是挺简单的;
目前为了应付产品经理的需求先开放几个支付相关的接口给dify调用,让大模型根据接口的返回值来整理回复;
其实调用接口算是下下策吧,原来是打算创建一个只读的数据库账号给ai调用,然后使用 metaData 组件来维护表结构之间的关系,让ai理解表结构然后拼接成sql去查询,这样更灵活(但是也能想象得到工作量非常的大);
不过因为 甲方的服务器内存不足够再部署一个 metaData组件,所以就暂时这样子先啦
6
再给点token吧😭😭😭😭😭😭😭😭快不够用了
4
