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 索引的使用原则是什么?索引是不是越多越好?有什么弊端?

索引使用原则

  1. 最左前缀原则:联合索引 (a, b, c) 只有查询条件从最左列开始才能命中,如 WHERE a=1 ✅、WHERE a=1 AND b=2 ✅、WHERE b=2
  2. 选择区分度高的列:如用户 ID、订单号,性别这种只有 2-3 个值的列不适合
  3. WHERE、JOIN、ORDER BY、GROUP BY 涉及的列建索引
  4. 覆盖索引:查询列都在索引中,不需要回表,性能最优
  5. 避免在索引列上使用函数或计算WHERE DATE(create_time) = '2024-01-01' ❌,应改为范围查询
  6. 负向查询不命中索引!=NOT INNOT EXISTS 通常不走索引
  7. 模糊查询前缀匹配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 速查

  • 中间操作:filtermapflatMapsorteddistinctlimitskippeek
  • 终端操作:collectforEachreducecountanyMatchallMatchfindFirstmax/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 执行很慢,怎么排查?

排查流程

  1. EXPLAIN 分析执行计划

    • type:连接类型,ALL(全表扫描)最差,ref/eq_ref 较好
    • key:实际使用的索引,NULL 表示没用索引
    • rows:预估扫描行数
    • ExtraUsing filesortUsing temporary 需关注
  2. 检查是否命中索引

    • 索引列上是否有函数/计算?
    • 是否满足最左前缀?
    • 类型是否隐式转换?(如 varchar 字段用 int 类型查)
  3. 慢查询日志

    sql
    复制代码
    SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';

    分析慢查询日志找出问题 SQL

  4. 锁等待

    sql
    复制代码
    SHOW PROCESSLIST; -- 查看当前连接 SELECT * FROM information_schema.INNODB_TRX; -- 当前事务 SELECT * FROM information_schema.INNODB_LOCKS;-- 锁信息 SHOW ENGINE INNODB STATUS; -- InnoDB 状态
  5. 数据量大但索引没问题

    • 考虑分页是否深分页(LIMIT 1000000, 10),改用游标分页
    • 检查返回数据量是否过大,考虑分批
    • 考虑读写分离,把慢查询分流到从库
  6. 其他方向

    • 检查磁盘 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 怎么办?

  1. 分布式锁 + 细粒度锁:每个 key 一把锁,互不影响
    java
    复制代码
    String lockKey = "lock:cache:" + key; boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
  2. 逻辑过期 + 异步刷新:key 不真的过期,value 内含过期时间戳,发现逻辑过期后仍返回旧值,异步起一个线程去更新,所有并发请求都能立刻拿到旧值返回
  3. 限流 + 降级:数据库查询层加限流,超过阈值的请求直接返回降级数据
  4. 缓存预热任务:定时扫描即将过期的热点 key,提前刷新

五、其他面试题

Q12:了解 Dubbo 的序列化机制吗?

Dubbo 支持的序列化协议:

协议特点
Hessian2(默认)跨语言、二进制、字段顺序敏感、性能较好
Fastjson2JSON 格式,可读性好但性能不如二进制
Kryo高性能二进制,需提前注册类
ProtobufGoogle 出品,跨语言、性能最优、需 .proto 定义
Java 原生兼容性最好但性能最差

Hessian2 注意事项

  • 序列化和反序列化依赖字段顺序,增加字段时建议加在末尾否则需兼容处理
  • 父类字段会优先序列化

生产建议:对性能有要求用 Kryo 或 Protobuf,一般场景 Hessian2 够用。


Q13:AI 对话超出上下文限制怎么办?

现象:AI 对话轮次多了以后,超出上下文窗口,AI "忘记"之前的内容。

解决方案

方案说明
拆分任务大需求拆为小任务,一个对话只聚焦一个模块,减少单次对话轮次
分阶段开发先设计(一个会话)→ 讨论通过后写代码(另一个会话)
关键信息总结阶段完成后让 AI 总结当前状态,新建对话时把总结贴回去
外部文档沉淀把架构设计、接口约定、数据库表结构写到项目 md 文件中,新会话直接引用
Spec-Driven 开发先写好详细需求 spec 文档,然后让 AI 严格按 spec 实现
分层沟通不在一轮中既讨论架构又写代码,架构定了后再开新对话写代码
上下文压缩工具使用支持上下文摘要/压缩的工具(部分 IDE 插件支持)

核心思路:不要把 AI 当"一个大项目长对话搞定",而是把它当"按 spec 执行的小粒度任务工具",每次聚焦一个目标。


0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP