原创

Collectors.teeing 算平均值:同一输入,不代表分子分母同一口径

有效样本是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;一个把空样本也断言成零的测试则把歧义固定成了协议。比例统计的回归样本应当专门包含会被排除的记录,才能看出分母是否真的跟着范围移动。

正文到此结束
评论插件初始化中...
Loading...
本文目录