《Java Stream 中 toMap 的生产级用法:一次 Duplicate key 的异常问题复盘》

Java Stream 生产级代码编写

------ 从一次 Duplicate key 异常问题说起

Stream 很优雅,但它从来不是"安全的"

大多数 Stream 的坑,只有在业务数据复杂之后才会暴露


一、事故背景:一个看似无害的 toMap

项目提测后,接口在运行过程中突然大量报错:

复制代码
java.lang.IllegalStateException: Duplicate key

堆栈指向一行非常"正常"的代码:

java 复制代码
Map<Long, UserDTO> userMap = users.stream()
        .collect(Collectors.toMap(
            UserDTO::getUserId,
            Function.identity()
        ));

这行代码在开发环境 未出过问题,但在测试环境业务数据下直接导致接口 500。


二、问题根因:Stream 并不会替你做"业务判断"

1️⃣ Collectors.toMap() 的真实语义

java 复制代码
toMap(keyMapper, valueMapper)

等价于:

  • 你告诉 Java:

    • key 怎么算
    • value 是什么
  • 但你没告诉它:

    • 如果 key 重复怎么办

Java 的默认策略是:

我不猜,直接抛异常


2️⃣ 真正导致事故的原因

异常中的对象内容(简化):

复制代码
UserDTO(
  id = 620344009420726272,
  userId = null
)

而代码使用的是:

java 复制代码
UserDTO::getUserId

结果就是:

  • 第一个 UserDTO → key = null
  • 第二个 UserDTO → key = null
  • Map 中 null key 冲突
  • toMap 直接抛 IllegalStateException

👉 这不是 Stream 的 bug,而是业务假设错误


三、Function.identity() 到底是干嘛的?

很多人会把这行当"魔法代码":

java 复制代码
Function.identity()

其实它只是:

java 复制代码
t -> t

也就是说:

value 就是当前 Stream 中的元素本身

在下面这段代码里:

java 复制代码
Collectors.toMap(
    UserDTO::getId,
    Function.identity()
)

含义非常明确:

Map<Long, UserDTO>


四、真正的关键:mergeFunction

1️⃣ 什么是 mergeFunction?

java 复制代码
toMap(keyMapper, valueMapper, mergeFunction)

第三个参数的含义是:

当 key 冲突时,如何合并两个 value


2️⃣ 常见 mergeFunction 写法

java 复制代码
(a, b) -> a   // 保留第一个
(a, b) -> b   // 使用后者覆盖

没有 mergeFunction = 默认抛异常


3️⃣ 修复后的生产级写法

java 复制代码
Map<Long, UserDTO> userMap = users.stream()
        .filter(u -> u.getId() != null)
        .collect(Collectors.toMap(
                UserDTO::getId,
                Function.identity(),
                (u1, u2) -> u1
        ));

这段代码具备:

  • ✅ key 非空保护
  • ✅ key 冲突不炸
  • ✅ 行为可预期

五、为什么开发环境没问题,测试却炸了?

这是 Stream 最危险的地方。

环境 数据特点
开发 数据干净、量小
测试 历史测试数据、脏数据、兼容数据

Stream 的很多异常:

  • 不会在编译期暴露
  • 不会在小数据集出现
  • 只会在偏向真实业务数据中触发

六、生产级 Stream 编写的 5 条铁律

✅ 1️⃣ 任何 toMap,必须思考 key 是否可能重复

不能 100% 确定 → 一定要写 mergeFunction


✅ 2️⃣ 永远不要相信 key 一定非 null

java 复制代码
.filter(x -> key != null)

是 Stream 中非常便宜、但非常重要的一行。


✅ 3️⃣ 不要用"看起来像 ID 的字段"

java 复制代码
getUserId()
getId()
getUid()

名字相似 ≠ 语义一致


✅ 4️⃣ Stream 不是业务校验工具

Stream 不会帮你判断:

  • 数据是否合理
  • 字段是否完整
  • 是否符合业务约束

👉 这些必须在你写 Stream 之前处理


✅ 5️⃣ 线上代码,宁可"多写一行",不要"省一行"

对比:

java 复制代码
Collectors.toMap(UserDTO::getId, Function.identity())

和:

java 复制代码
Collectors.toMap(
    UserDTO::getId,
    Function.identity(),
    (a, b) -> a
)

第二种 不是啰嗦,而是专业


七、总结一句话

Stream 是表达力工具,不是容错工具。

写 Stream 时你要明确三件事:

  1. key 从哪里来
  2. value 是什么
  3. 发生冲突你要谁

如果这三点你没想清楚,

那这个 Stream 迟早会在线上出事故


相关推荐
侠客行03172 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪2 小时前
深入浅出LangChain4J
java·langchain·llm
老毛肚4 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
风流倜傥唐伯虎4 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
Yvonne爱编码5 小时前
JAVA数据结构 DAY6-栈和队列
java·开发语言·数据结构·python
Re.不晚5 小时前
JAVA进阶之路——无奖问答挑战1
java·开发语言
你这个代码我看不懂5 小时前
@ConditionalOnProperty不直接使用松绑定规则
java·开发语言
fuquxiaoguang5 小时前
深入浅出:使用MDC构建SpringBoot全链路请求追踪系统
java·spring boot·后端·调用链分析
琹箐5 小时前
最大堆和最小堆 实现思路
java·开发语言·算法
__WanG5 小时前
JavaTuples 库分析
java