Collectors.teeing 算平均值:同一输入,不代表分子分母同一口径
- 发布时间:2026-10-23 23:03:24
- 本文热度:浏览 1 赞 0 评论 0
- 文章标签: Java Collectors 统计口径
- 全文共1字,阅读约需1分钟
有效样本是10和20,算出来的平均值却是10。代码只有一次遍历,两个统计也放在同一个teeing里面,并没有并发修改。错误来自分子只累计有效记录,分母却数了全部三条。
公式比API名字更能解释这个问题:30除以3当然是10;想得到有效样本平均值,应当是30除以2。一次遍历解决的是遍历组织,不会自动验证统计口径。
下面的例子使用Java 17。teeing从Java 12提供,不是Java 8初始Stream API的一部分。
import java.util.*;
import java.util.stream.Collectors;
public class TeeingScope {
record Reading(long value, boolean valid) { }
record Stats(long sum, long count) {
OptionalDouble average() { return count == 0 ? OptionalDouble.empty() : OptionalDouble.of((double)sum / count); }
}
static Stats correct(List<Reading> input) {
return input.stream().filter(Reading::valid).collect(Collectors.teeing(
Collectors.summingLong(Reading::value), Collectors.counting(), Stats::new));
}
public static void main(String[] args) {
List<Reading> input = List.of(new Reading(10, true), new Reading(20, true), new Reading(900, false));
Stats wrong = input.stream().collect(Collectors.teeing(
Collectors.filtering(Reading::valid, Collectors.summingLong(Reading::value)),
Collectors.counting(), Stats::new));
System.out.println("wrong_avg=" + wrong.average().orElseThrow());
System.out.println("valid_avg=" + correct(input).average().orElseThrow());
System.out.println("empty_present=" + correct(List.of(new Reading(900, false))).average().isPresent());
}
}
将代码保存为 TeeingScope.java,执行 java TeeingScope.java:
wrong_avg=10.0
valid_avg=15.0
empty_present=false
把共同范围提到两个统计之前
wrong里的filtering只包住求和下游,另一个counting仍接收每条输入。这个写法没有违反teeing的契约,它确实把所有元素送给两个下游,只是下游自己做了不同筛选。
正确版本先在Stream上筛选有效记录,再把结果交给teeing。求和和计数因此共享同一份范围。类似原则也适用于成功率、命中率和加权平均:先明确哪些事件有资格进入分母,哪些值进入分子,再组织收集器。把一个条件复制进两个不同下游,也可能以后只修改一处,重新产生偏差。
并不是两个下游永远不能采用不同范围。例如总订单数与成功订单数合并成成功率,本来就需要不同统计。那时应把两个口径命名清楚,核对成功事件确实属于总事件集合。问题不在结构对称,而在两个结果是否组成了业务想要的公式。
同一个接口看板,两个平均口径
假设接口看板排除健康检查,再展示成功率与成功请求平均耗时。共同范围是“进入业务统计的请求”,但两个公式并不共享分母:成功率用成功数除以全部合格请求数,成功耗时则用成功请求耗时和除以成功数。若把全部请求数复用给后者,失败越多,图上成功耗时反而越好看。
下面的 RequestMetrics.java 保留这两层范围。先过滤 eligible,让健康检查不进入任何指标;再在成功耗时的两个下游应用相同成功条件。保存并用 Java 17 运行 java RequestMetrics.java:
import java.util.*;
import java.util.stream.Collectors;
public class RequestMetrics {
record Reply(int status, long milliseconds, boolean eligible) {
Reply { if (milliseconds < 0) throw new IllegalArgumentException("negative latency"); }
boolean success() { return status >= 200 && status < 300; }
}
record SuccessLatency(long sum, long count) {
OptionalDouble average() { return count == 0 ? OptionalDouble.empty() : OptionalDouble.of((double) sum / count); }
}
record Report(long requests, SuccessLatency success) {
OptionalDouble successRate() { return requests == 0 ? OptionalDouble.empty() : OptionalDouble.of((double)success.count() / requests); }
}
static Report metrics(List<Reply> input) {
return input.stream().filter(Reply::eligible).collect(Collectors.teeing(
Collectors.counting(),
Collectors.teeing(
Collectors.filtering(Reply::success, Collectors.summingLong(Reply::milliseconds)),
Collectors.filtering(Reply::success, Collectors.counting()), SuccessLatency::new),
Report::new));
}
public static void main(String[] args) {
Report r = metrics(List.of(new Reply(200,10,true), new Reply(500,20,true),
new Reply(503,30,true), new Reply(200,900,false)));
System.out.println("requests=" + r.requests() + ", successes=" + r.success().count()
+ ", avg_success_ms=" + r.success().average().orElseThrow());
System.out.println("success_rate=" + r.successRate().orElseThrow());
System.out.println("all_failed_latency_present=" + metrics(List.of(new Reply(500,10,true))).success().average().isPresent());
System.out.println("empty_rate_present=" + metrics(List.of()).successRate().isPresent());
if (r.requests()!=3 || r.success().count()!=1 || r.success().average().orElseThrow()!=10)
throw new AssertionError("metric scope");
}
}
requests=3, successes=1, avg_success_ms=10.0
success_rate=0.3333333333333333
all_failed_latency_present=false
empty_rate_present=false
第四条记录虽然是200且耗时900毫秒,但它不是合格业务样本,所以不应拉高分母或平均值。三条合格请求只有一条成功,成功率为三分之一,成功耗时为10毫秒。这里的毫秒来自明确构造的测试数据,不是某个线上接口的实测延迟。
全部失败时,成功率为零,但成功耗时没有观测值;空输入时,连成功率也没有分母。这两种看板不能共用一套“所有空值显示0”的适配。可以分别显示零成功率与暂无成功耗时,并把合格请求数量作为解释指标一起输出。
这份程序按请求条目计数。若同一业务请求会产生多次重试日志,需要先决定统计的是每次尝试还是最终业务结果;不补这项规则,即便代码中的分子分母完全一致,指标也可能回答了另一个问题。去重键、时间窗口和资格判断应在进入归约前明确,不能等平均数看起来不合理时再临时排除几条。
嵌套teeing确实只遍历一次,但如果层次已经难以读出公式,可以改成一个命名清晰的累积器或独立结果记录;少一次遍历不是压缩业务模型的理由。本例的Report直接保留请求数和成功数,便于审计公式,而不是只返回两个没有口径说明的double。
空样本不自动意味着平均值为零
样本全部无效时,和与数量都是零。返回0.0会让调用方分不清“观测到的均值为零”与“没有观测值”。这里用OptionalDouble.empty保留缺失状态,由展示层决定显示暂无数据、空值还是其他约定。
这个选择应贯穿后续输出。若序列化或报表又把empty无条件改成零,前面保留的语义就丢了。统计卡片尤其不该把暂无数据画成性能改善或失败率归零。
同时,teeing的合并函数在最终结果上运行,适合做一次纯计算。不要在其中发起写库或通知,然后假设每个并行分区都会触发一次业务动作。收集器的累积、合并与完成是归约协议,外部副作用不应借它隐式编排。
一次遍历也有数值和执行边界
示例求和使用long,数据规模明确很小。真实累计可能溢出时,应选择受控范围、精确累加或更大的数值表示,不能因为counting也返回long就认为所有总和都安全。浮点均值还要考虑精度与加权口径,工具并不会替业务选择舍入。
并行执行时,两个下游都需要满足收集器的组合契约。自定义累积器若共享外部可变对象,出现错误就不再是公式问题。使用标准收集器可以减少这类细节,但仍要验证串行与并行在当前业务所需精度下给出一致语义。
本例保留错误计算作为对照,正常结果15.0和空样本缺失分别给出。一个只检查结果“大于零”的测试会放过10.0;一个把空样本也断言成零的测试则把歧义固定成了协议。比例统计的回归样本应当专门包含会被排除的记录,才能看出分母是否真的跟着范围移动。