2134 字
约 7 分钟
18
S04-A25 · `Collectors`:三个"看着对"却会算错的分组

一段报表代码用 groupingBy 按"地区"分组、再按"金额"求和,结果"总计比明细少"。查下来:groupingBy 默认丢弃 null 键(于是"地区为空"的 200 条不见了),而 summingDouble 在"空组"上返回 0(于是"没数据的地区"看起来"有数据".

Collectors 的坑集中在一处:它们对"空值、空组、顺序、可变性"的处理,都有默认行为——而默认行为"不报错",只"算出一个不同的数".

1. "分组丢数据"的三种默认

默认 后果
groupingBy 丢弃 null 键 统计总额少
summingX 空组给 0 无法区分"0"与"无数据"
toMap 键冲突抛异常 偶发失败

第一项最"贵":"地区为空"的记录"默默消失"第一项的"反向陷阱"是"用 toMap 代替 groupingBy":toMap 遇到 null 键"直接抛 NPE"——于是"改法"把"静默丢"换成"启动即炸",两者都不算"处理".
——而它往往正是"需要关注的脏数据".
第三项的"偶发性"来自"数据分布":
"同一天两条记录"平时没有,遇到"补数据/重跑"就有
——**于是它"上线半年才炸一次".
第三项在"key 重复"时直接抛 IllegalStateException——而生产上"重复"是常态(如"同一天两条记录").

"分组前先规范化键"是最省心的做法,而它只要一个 map:

// 三种"键规范化"(按场景选)
1 补默认值:o -> o.region() == null ? "未知" : o.region()
2 用"空字符串":o -> Objects.toString(o.region(), "")
3 先过滤"脏数据"并告警:filter(o -> o.region() != null)
// 判据:报表要"总数对得上" → 选 1;要对账"脏数据" → 选 3

2. "键为 null"的三种处理

处理 说明
先"补默认值"("未知") 最常用(可统计)
用 HashMap 允许 null 键的分组 需自定义
单独筛出来"告警" 最稳(暴露脏数据)

第一项是"报表场景"的正解:"未知地区"作为一个分类出现第一项的"额外收益"是"脏数据可见":"未知 200 条"本身就是"该修数据"的信号——**而"丢了"就"永远不知道有 200 条".
——于是"总数对得上",而"脏数据"也可见.

"计数 + 求和"的组合可以写成"一次遍历",而不必"两组流":

// 一次遍历:同时得到 计数 / 总额 / 最大值
record Stat(int n, double sum, double max) {}
var stats = orders.stream().collect(groupingBy(Order::region,
    Collector.of(() -> new double[]{0,0,0},
        (a, o) -> { a[0]++; a[1]+=o.amount(); a[2]=Math.max(a[2], o.amount()); },
        (a, b) -> { a[0]+=b[0]; a[1]+=b[1]; a[2]=Math.max(a[2], b[2]); return a; },
        a -> new Stat((int)a[0], a[1], a[2]))));
// 判据:n==0 → 无数据;n>0 && sum==0 → 真 0 元

3. "空组 vs 0 值"的三种区分

做法 效果
用 Map<K, List<T>> 自己算 能区分
用"计数 + 求和"两个收集器 能区分(c=0 表示无)
只看和 无法区分

第二项的"代价"是"多一个字段":但"多一个字段"换来"能区分",在"对账"场景里是刚需——**因为"对账"的第一句话就是"这个 0 是没数据还是真的 0".
第二项是"最省"的:同时收集"条数"与"总额"——于是"总额 0 且条数 0"= 无数据,"总额 0 且条数 >0"= 真 0 元.

// 两组收集器:同时拿"条数"与"总额"(能区分"无数据"与"0")
var stat = orders.stream().collect(groupingBy(
    o -> o.region() == null ? "未知" : o.region(),
    collectingAndThen(toList(), l -> new Stat(l.size(), l.stream().mapToDouble(Order::amount).sum()))
));
// 判据:Stat(0, 0)=无数据;Stat(3, 0)=三笔 0 元

"唯一性"要"能被打断",否则"冲突"就是"静默覆盖":

// 三种"唯一性"的表达
1 toMap(k, v)                     // 冲突即抛(默认,最安全)
2 toMap(k, v, (a,b) -> b)         // 后写覆盖(最危险)
3 toMap(k, v, (a,b) -> { throw new IllegalStateException("dup: "+a+" / "+b); })
// 判据:不确定"是否唯一"就用 3 —— 让"重复"显式失败

4. "toMap 冲突"的三种处理

处理 说明
给"合并函数"((a,b) -> b) 最常用(后写覆盖)
收集成 List 再归并 明确语义
抛异常(默认) 只适合"确信唯一"

第一项的"风险"是"静默覆盖":"同一天两条记录"时,只留了一条第一项的"选择语义"要写清:(a,b) -> b 是"后写覆盖",(a,b) -> a 是"先写保留"——**而"哪条在前"取决于"流顺序",于是"留哪条"可能"两次运行不同".
——而"留哪条"取决于"流顺序",可能不稳定.

"有序收集"要么"换容器",要么"最后排序",两者成本不同:

// 两种"稳定顺序"
1 groupingBy(k, LinkedHashMap::new, toList())   // 保"首次出现顺序"
2 collect(...).entrySet().stream().sorted(...)   // 显式排序(语义最清楚)
// 判据:报表"行序"要稳定 → 用 1(便宜);要"按业务字段排" → 用 2

5. "并行流"的三种失真

失真 原因
顺序丢(groupingBy 的 map 无序) 并行 + 无序容器
累加器"非结合" 用 reduce 做减法
有副作用 在 map 里"改外部集合"

第一项的"暴露方式"是"导出文件的行序":"上周的报表按 A/B/C 排,这周按 C/A/B"——而"人眼对行序敏感",于是"以为数据变了".
第一项在"需要稳定顺序"的报表里很实际:
"同样数据两次运行,分组顺序不同"**——**解法是"用 LinkedHashMap 收集"或"最后排序".

"内存暴涨"这条,是因为"默认收集器"会"物化中间结果":

// 三种"重"的收集器(大流慎用)
1 toList() / toSet():全部物化(内存 = 元素数)
2 groupingBy(..., toList()):物化"每个组"
3 counting()/summingX():只要"累加器"(内存 = 组数)
// 判据:只要"数"就不要"物化"——后者会让"10 万条流"吃掉几百 MB

6. 排错:五种"收集器问题"

现象 成因 处理方向
总计少 null 键被丢 补默认值
空地区显示 0 无法区分 加计数
偶发异常 toMap 冲突 给合并函数
结果顺序不稳 并行 + HashMap LinkedHashMap
内存暴涨 中间 List 太大 用"累加器"收集器

"两行断言"是"报表"最便宜的保险,而它比"单元测试"更贴业务:

// 一条"分组自洽"断言
var total = orders.stream().mapToDouble(Order::amount).sum();
var grouped = orders.stream().collect(groupingBy(
    o -> o.region() == null ? "未知" : o.region(), summingDouble(Order::amount)));
assert Math.abs(grouped.values().stream().mapToDouble(Double::doubleValue).sum() - total) < 1e-6;
// 判据:这条断言"失败"就说明"有数据没进分组"

7. 边界:什么情况"默认行为"就是对的

场景 为什么 做法
数据"保证无 null 键"(非空约束) 无丢失风险 默认即可
"唯一键"已由约束保证 无冲突 默认 toMap
单线程、小数据 顺序与并行无关 默认即可
只要"总额"(不区分空组) 0 与"无"等价 默认 summing

判断口径一句话:"这份统计里,"缺失"与"为零"要不要分开"。最后一条与"验收"有关的边界:"统计"要"和明细对一次":"分组之和 == 总数" 是一条"两行代码"的断言——而它正好能抓住"null 键被丢""toMap 覆盖"这两类问题;**"没写这条断言"的报表,错误会"一直存在直到有人对账".
要(报表、对账、告警),就必须"补默认值 + 同时收计数";不要(内部看板、粗略趋势),默认收集器最省事——而"默认"的问题从来不是"错",而是"它替你做了决定而你没察觉".

8. 自测

  1. groupingBy 对 null 键的默认行为是什么?为什么它"最贵"?
  2. summingX 在空组上返回什么?为什么它让"0"与"无数据"混在一起?
  3. 怎么用"两个收集器"区分"无数据"与"真 0 元"?
  4. toMap 键冲突默认会怎样?给合并函数的风险是什么?
  5. 并行流为什么会让"分组顺序不稳"?两种解法是什么?
  6. 判断"默认行为是否可接受"的那个问题是什么?
S04-A25 · `Collectors`:三个"看着对"却会算错的分组
https://www.clxhxhhr.top/posts/6692/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。