Java Stream、List 转 Map、Redis 用途与 MySQL 优化
后端一面常见组合题:Stream 用过吗、集合怎么转 Map、Redis 在项目里干啥、MySQL 除了加索引还能怎么优化。我按口述顺序记下来,每题都补了代码和单体/大厂两种语境下的取舍。
题目一:Stream 了解吗,常用在什么场景?
参考答案(口述要点)
- Stream 是 Java 8 的声明式集合处理 API,不存数据,一次遍历完成过滤、映射、聚合
- 常用:过滤 + 映射 + 收集、分组统计、
distinct去重、排序、找最大最小- 项目里多用来替代多层 for 循环,让「查出来 → 过滤 → 转 DTO → 分组」一条链写完
- 并行流
parallelStream()适合无共享状态、CPU 密集的批处理,IO 密集或数据量小别乱用
详细解析
Stream 分中间操作(filter、map、sorted…,可链式懒执行)和终端操作(collect、forEach、count…,触发实际计算)。和 Iterator 比,写法更接近 SQL 的「先筛再投影再聚合」。
| 场景 | 典型 API | 说明 |
|---|---|---|
| 条件过滤 | filter | 只要状态为启用的用户 |
| 字段映射 | map / flatMap | Entity → VO;flatMap 展开嵌套列表 |
| 去重 | distinct | 按 equals,对象去重常配合 map 先取 key |
| 排序 | sorted | 按时间、金额排序后 limit 取 Top N |
| 聚合 | count / max / reduce | 统计、求和 |
| 分组 | Collectors.groupingBy | 按部门、状态分组 |
| 转集合 | collect(toList/toSet/toMap) | 面试高频,见下一题 |
和 for 循环怎么选:数据量不大、逻辑简单,for 完全没问题;当一步套一步(过滤完映射再分组),Stream 可读性更好。注意 Stream 只能消费一次,不能重复使用。
代码与实践
订单列表:过滤 + 映射 + 收集(单体项目里很常见)
List<OrderVO> vos = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.map(o -> new OrderVO(o.getId(), o.getAmount(), o.getUserId()))
.collect(Collectors.toList());按状态分组统计金额
Map<OrderStatus, BigDecimal> sumByStatus = orders.stream()
.collect(Collectors.groupingBy(
Order::getStatus,
Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add)
));flatMap:一个用户多个地址,摊平成地址列表
List<String> cities = users.stream()
.flatMap(u -> u.getAddresses().stream())
.map(Address::getCity)
.distinct()
.collect(Collectors.toList());并行流只在数据量大、纯计算、无共享可变状态时考虑;普通 Web 接口里 99% 用串行 stream() 就够。
题目二:一个固定的 List 怎么转成 Map?
参考答案(口述要点)
- 最常用:
Collectors.toMap(keyMapper, valueMapper)- key 重复要提供 merge 函数,否则抛
IllegalStateException- 要保序用
LinkedHashMap::new作为第四个参数- 一对多用
groupingBy,不是toMap- 面试可以补一句:小数据量 for 循环也 OK,关键是说清楚 key、value、冲突策略
详细解析
固定 List 转 Map,本质是先定 key 从哪来、value 放什么、key 冲突怎么办。
| 需求 | 写法 |
|---|---|
| id → 实体 | toMap(User::getId, Function.identity()) |
| id → 名称 | toMap(User::getId, User::getName) |
| 重复 id 保留后者 | (a, b) -> b |
| 重复 id 保留前者 | (a, b) -> a |
| 按部门分组 | groupingBy(User::getDeptId) |
| 分组后只要名称列表 | groupingBy(User::getDeptId, mapping(User::getName, toList())) |
Function.identity() 等价于 x -> x,value 就是元素本身。
代码与实践
id → User(项目里查完列表转 Map 做 O(1) 查找)
Map<Long, User> userMap = userList.stream()
.collect(Collectors.toMap(
User::getId,
Function.identity(),
(oldVal, newVal) -> oldVal // 重复 id 保留第一个
));保序:按列表顺序放入 LinkedHashMap
Map<Long, User> ordered = userList.stream()
.collect(Collectors.toMap(
User::getId,
Function.identity(),
(a, b) -> a,
LinkedHashMap::new
));一对多:部门 → 员工列表
Map<Long, List<User>> byDept = userList.stream()
.collect(Collectors.groupingBy(User::getDeptId));实际场景:接口先 select * from user where id in (...) 拉回 List,再 toMap 成 Map<Long, User>,后面组装订单、评论列表时按 userId 取昵称,避免 N 次单条查询。如果 key 可能为 null,toMap 会 NPE,要先 filter(Objects::nonNull) 或换循环写法。
传统 for 写法面试也可以口述:
Map<Long, User> map = new HashMap<>();
for (User u : userList) {
map.putIfAbsent(u.getId(), u);
}题目三:Redis 你常用来干嘛?
参考答案(口述要点)
- 缓存:热点数据、配置、字典、首页接口,减轻 MySQL 压力
- 登录态:Token / Session,配合过期时间做单点登录、踢人下线
- 验证码、防重复提交:短 TTL 的 key
- 分布式锁:库存扣减、定时任务抢执行权(要设过期 + 唯一 value,防误删)
- 限流:滑动窗口、计数器
- 排行榜 / 计数:ZSet、INCR
- 单体项目里前三个最常见;锁和限流看业务量再上
详细解析
面试官问这道题,想听你真用过什么,不是背八大数据结构。可以按「我们项目里」讲 2~3 个具体例子,再补一句缓存三兄弟(穿透、击穿、雪崩)怎么防——这块我另文写过,这里不展开。
| 用途 | 典型 key 设计 | 注意点 |
|---|---|---|
| 业务缓存 | product:{id} | TTL 加随机;空值短缓存防穿透 |
| 登录 Token | login:token:{userId} | 过期续期;多端可 Hash 存设备 |
| 验证码 | sms:code:{phone} | 60s 过期;校验后删除 |
| 分布式锁 | lock:order:{id} | SET NX EX + Lua 安全释放 |
| 接口限流 | rate:api:{ip} | INCR + EXPIRE 或 Redisson RR |
代码与实践
字典 / 配置缓存(单体 CRUD 项目最常见)
public String getDictLabel(String type, String code) {
String key = "dict:" + type;
String cached = redisTemplate.opsForHash().get(key, code);
if (cached != null) {
return cached.toString();
}
Dict dict = dictMapper.selectByTypeAndCode(type, code);
if (dict != null) {
redisTemplate.opsForHash().put(key, code, dict.getLabel());
redisTemplate.expire(key, Duration.ofHours(24));
}
return dict != null ? dict.getLabel() : "";
}登录 Token
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"login:token:" + userId,
token,
Duration.ofDays(7)
);
// 网关或拦截器:请求头 token 与 Redis 中比对防重复下单(短 TTL)
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent("submit:order:" + userId, "1", Duration.ofSeconds(5));
if (Boolean.FALSE.equals(ok)) {
throw new BizException("请勿重复提交");
}题目四:不用索引,或者说除了索引之外,怎么优化 MySQL?(大厂 vs 单体)
参考答案(口述要点)
- SQL 与表结构:少
select *、避免对索引列做函数、深分页改游标或延迟关联、冷热数据拆分- 架构层:读写分离、分库分表、缓存、搜索走 ES、异步写 MQ——大厂常做
- 单体小项目:优先 SQL 改写 + Redis 缓存 + 连接池与事务收敛,读写分离往往性价比不高
- 运维与监控:慢查询日志、
EXPLAIN、归档历史表、合理字段类型与行宽- 面试要说清:不是不用索引,而是索引加完还慢时,下一步做什么,以及小团队资源有限时的优先级
详细解析
这道题容易答成「索引大全」。面试官常是想听:索引已经加了还慢,或者小项目没钱上中间件时你怎么办。
除了索引,还能动哪些地方
| 方向 | 做法 | 适用 |
|---|---|---|
| SQL 改写 | 只查必要列;JOIN 改 EXISTS;子查询改关联;避免 %like 左模糊 | 通用 |
| 分页 | 深分页 limit 100000,20 改「上次最大 id」游标或延迟关联 | 列表接口 |
| 表设计 | 宽表拆表;历史订单归档到 orders_2024;冗余少量字段换 JOIN | 读多写少 |
| 字段类型 | varchar(500) 改合适长度;金额用 decimal;时间用 datetime | 建表阶段 |
| 减少往返 | MyBatis 批量 insert;禁止循环里单条查(N+1) | 单体通病 |
| 缓存 | 热点查询走 Redis;本地 Caffeine 做二级缓存 | 单体见效快 |
| 读写分离 | 主写从读 | 读压力大、有 DBA |
| 分库分表 | 按用户 id、时间分片 | 单表千万级、大厂 |
| 异步 | 下单后写库改 MQ 异步落库 | 可接受最终一致 |
| 连接与锁 | Hikari 池大小合理;事务里不调外部 HTTP;缩短锁持有时间 | 通用 |
| 监控 | 慢 SQL、锁等待、rows examined | 上线后 |
大厂 vs 单体:优先级不一样
大厂 / 高并发
- 基础设施齐全:读写分离、Redis 集群、MQ、APM、专职 DBA
- 优化路径往往是:慢 SQL 治理 → 缓存 → 读写分离 → 分库分表 → 异构存储(ES)
- 强调 SLA、容量规划、全链路压测、故障演练
- 团队能承担中间件运维成本
单体 / 小项目(几十万人日活以内很常见)
- 人力有限,先动代码和 SQL,再上 Redis,最后才考虑读写分离
- 典型有效手段:
- 慢查询日志 +
EXPLAIN把最痛的 3 条 SQL 改掉 - 接口级缓存(字典、首页、商品详情)
- 去掉 Service 里 for 循环查库(改
in批量查 +toMap) - 历史数据定期归档,主表控制在百万级
- 连接池别配太大(反而拖垮 DB),一般 CPU 核数 * 2 量级起步再压测
- 慢查询日志 +
- 读写分离、分库分表:数据量没到、团队没人运维就别硬上,复杂度比收益高
可以跟面试官说一句:「我们当时是单体 Spring Boot,索引加完后列表还是慢,最后是 SQL 去掉隐式类型转换 + Redis 缓存列表第一页,QPS 就下来了,读写分离评估过但没必要。」这种比背概念加分。
代码与实践
深分页:游标方式(避免 offset 过大)
-- 差:offset 越大越慢
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 较好:记住上一页最大 id
SELECT * FROM orders
WHERE id > #{lastId}
ORDER BY id
LIMIT 20;延迟关联:先查 id 再回表
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20
) t ON o.id = t.id;避免 N+1:批量查用户再 toMap(和第二题呼应)
List<Long> userIds = orders.stream().map(Order::getUserId).distinct().toList();
Map<Long, User> userMap = userMapper.selectByIds(userIds).stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
orders.forEach(o -> o.setUserName(
userMap.getOrDefault(o.getUserId(), new User()).getName()
));归档冷数据(小项目可手工脚本 + 定时任务)
-- 把一年前的已完成订单迁到历史表,主表变小
INSERT INTO orders_archive SELECT * FROM orders
WHERE status = 'DONE' AND create_time < '2025-01-01';
DELETE FROM orders WHERE status = 'DONE' AND create_time < '2025-01-01';延伸 / 易错点
- Stream 的
toMap遇到nullkey/value 会挂;groupingBy默认HashMap,要序用TreeMap或LinkedHashMap供应器 - Redis 当缓存要记得一致性策略:更新 DB 后删缓存还是双写,面试能说「我们删缓存 + 延迟双删」即可
- MySQL 优化别忽略 ORM 生成的 SQL:MyBatis-Plus 条件构造、
like全模糊往往是真凶 - 「不用索引」在面试里通常是口误,应理解成「除了加索引还有什么」
写在最后
这四题串起来其实是后端日常:集合处理、缓存、数据库。复习时按「怎么答 → 代码例子 → 我们项目里干过啥」三条线准备,比单背概念稳得多。
