摘要:同样是按键统计,哈希聚合和排序聚合会把成本付在完全不同的位置。本文用可运行的 Java 程序让两种方案处理同一批订单,逐项核对结果、内存压力、输入有序性和退化条件,并给出能落到评审清单里的选择规则。
两张账本同时开场
一批订单按城市汇总金额,看起来只是把相同键加在一起。事故往往发生在数据量从十万涨到一亿之后:原本很快的哈希表开始扩容、溢写,另一套看似更慢的排序方案却因为上游已经有序而只需顺序扫描。讨论聚合不能只问谁的平均复杂度更低,而要先问键有多少、输入是否有序、可用内存多大,以及结果能否边读边产出。
哈希桶与有序游标
HashAggregate 像给每个城市准备一个抽屉。读到记录就算哈希值,找到抽屉并累加;抽屉数量近似等于不同键数。SortAggregate 则先把订单按城市排成队,相同城市必然连续,只保留当前城市和当前小计即可。前者用随机定位换掉全局排序,后者用顺序性换掉常驻哈希表。若输入本来按键排好,排序聚合的"排序费"已经由上游支付;若键很少且输入混乱,哈希聚合通常更直接。
把成本写成可比较的式子
设记录数为 n,不同键数为 g。哈希方案平均进行 n 次查找,状态规模为 O(g);但哈希冲突、装载因子和扩容会放大常数,g 接近 n 时内存账本最危险。排序方案若必须自行排序,时间是 O(n log n),排序后的扫描为 O(n);若数据已按键有序,整个聚合就是 O(n)。外部排序还会把内存不足转化为多趟磁盘归并,因此比较时不能把 I/O 隐藏在一个符号里。真正的决策变量是"输入顺序保证"和"分组状态能否装入预算"。
代码刻意让两条路线共享同一组订单。哈希版使用 LinkedHashMap 便于观察首次出现顺序;排序版复制输入,按城市排序后用一个游标累计。测试不比较 Map 的遍历顺序,只比较键到金额的映射,这避免把实现细节误当语义。第二组数据已经有序,直接调用顺序扫描函数,展示执行器若能继承上游排序属性,就不应再次排序。金额使用 long,避免把业务溢出误判为聚合算法错误。
同一输入的双路线实现
java
import java.util.*;
public class Main {
static class Order {
private final String city;
private final long amount;
Order(String city, long amount) {
this.city = Objects.requireNonNull(city);
this.amount = amount;
}
String city() { return city; }
long amount() { return amount; }
}
static Map<String, Long> hashAggregate(List<Order> orders) {
Map<String, Long> result = new LinkedHashMap<>();
for (Order order : orders) {
result.merge(order.city(), order.amount(), Long::sum);
}
return result;
}
static Map<String, Long> sortAggregate(List<Order> orders) {
List<Order> sorted = new ArrayList<>(orders);
sorted.sort(Comparator.comparing(Order::city));
return aggregateSorted(sorted);
}
static Map<String, Long> aggregateSorted(List<Order> sorted) {
Map<String, Long> result = new LinkedHashMap<>();
String current = null;
long sum = 0;
for (Order order : sorted) {
if (!order.city().equals(current)) {
if (current != null) result.put(current, sum);
current = order.city();
sum = 0;
}
sum = Math.addExact(sum, order.amount());
}
if (current != null) result.put(current, sum);
return result;
}
public static void main(String[] args) {
List<Order> input = List.of(
new Order("上海", 20), new Order("北京", 12),
new Order("上海", 8), new Order("深圳", 15),
new Order("北京", 30)
);
Map<String, Long> expected = Map.of("北京", 42L, "上海", 28L, "深圳", 15L);
if (!hashAggregate(input).equals(expected)) throw new AssertionError("hash mismatch");
if (!sortAggregate(input).equals(expected)) throw new AssertionError("sort mismatch");
if (!aggregateSorted(List.of()).isEmpty()) throw new AssertionError("empty input");
if (hashAggregate(List.of(new Order("杭州", 3_000_000_000L))).get("杭州") != 3_000_000_000L)
throw new AssertionError("long amount");
System.out.println(hashAggregate(input));
System.out.println("aggregation tests passed");
}
}
胜负不只看大 O:复杂度分析
哈希聚合平均时间 O(n)、额外空间 O(g),最坏冲突情形可能退化;排序聚合在无序输入上是 O(n log n) 时间,复制与排序通常占 O(n) 空间,扫描阶段只需 O(1) 聚合状态。有序输入上的排序聚合为 O(n) 时间、O(1) 额外聚合空间。若排序实现采用外部归并,还需把读写趟数计入成本。
容易漏掉的四类输入:边界条件
- 空输入应返回空映射,不能凭空生成一个空键。
- 只有一条记录时,两条路线必须得到相同金额。
- 键可以重复且顺序交错,排序前不能提前结算。
- 金额累加可能超过 int,示例统一使用 long。
- 若键允许为 null,比较器和哈希策略必须先定义语义;示例明确拒绝 null。
评审中最常见的误判:常见错误
- 只看平均 O(n) 就认定哈希永远获胜。
- 排序后按对象地址而不是业务键判断分组边界。
- 为了比较结果而依赖 Map 迭代顺序。
- 上游已有序仍复制并全量排序,白白制造内存峰值。
可复制的对跑方法:可复制的测试用例
运行程序会打印两个结果并输出 aggregation tests passed。断言覆盖乱序重复键、已排序输入、空输入和大金额累加。进一步压测时可把城市基数从 10 逐步增加到记录数,分别记录耗时、哈希表峰值和排序临时空间;这比只测固定基数更能看出方案交叉点。
落到执行器之前
在查询执行器中,选择通常发生在物理计划阶段。统计信息若低估基数,哈希表可能在运行中溢写;排序聚合虽然前置成本高,却能稳定地顺序输出。工程实现应把估算基数、内存阈值、是否继承排序属性、溢写次数一起写入执行指标。若系统允许自适应切换,还要定义切换后如何合并已经积累的哈希状态,而不是简单重跑整批数据。
进一步复核
复核时先构造"记录很多但键很少"和"每条记录几乎都是新键"两端数据。前者考验哈希定位常数,后者暴露状态规模;只使用均匀随机数据会把最关键的基数变化抹平。
如果结果需要按键排序输出,哈希方案最终仍要排序 g 个结果键。此时总成本应写成 O(n)+O(g log g),不能把输出契约从比较中删掉。
并行聚合还会增加局部表与全局合并两层状态。局部先聚合能减少网络传输,但倾斜键会让某个分区成为热点,测试应记录每个分区的输入量而非只看总量。
哈希桶与有序游标之后的专项复盘
正确性不应依赖容器顺序
聚合的语义是"同一业务键的值被结合一次",不是"结果按照首次出现顺序排列"。因此验证应把输出转换为键值映射后比较;若接口另外承诺有序输出,再独立检查顺序。结合函数还要满足结合律,否则并行局部聚合改变括号位置后会得到不同结果。金额求和满足结合律,但浮点求和会受舍入顺序影响,金融场景更适合定点整数或明确误差范围。这个问题与选择哈希还是排序无关,却会直接决定并行计划能否合法执行。
内存预算要落到字节
O(g) 只说明状态随分组数增长,没有告诉我们一百万个键究竟占多少内存。评估时应测量键对象、哈希桶、装载空槽、聚合值以及运行时对象头;若键是长字符串,真正占用可能远超数值本身。排序路线同样不能只写 O(n):原地排序、对象数组排序和外部归并的临时空间完全不同。可靠的压测应逐步增加 g,找到首次扩容、首次垃圾回收抖动和首次溢写发生的位置,而不是只报告最后一次耗时。
选择规则写成可执行条件
可以把决策整理成三条:上游保证按键有序时直接顺序聚合;无序且估计状态低于内存阈值时使用哈希;基数估计不可靠或状态可能溢出时准备排序或可溢写哈希方案。阈值必须保留安全余量,因为同一进程还承载输入缓冲和输出对象。统计信息过期时,执行日志应同时记录估计 g 与实际 g,下一轮才能修正计划。这样的规则比"哈希通常更快"更容易复现,也更能解释一次线上切换。
可复制的对跑方法的验证矩阵
- 验证矩阵 1:构造最小输入,把"空输入应返回空映射,不能凭空生成一个空键。"设为通过契约;随后故意模拟"只看平均 O(n) 就认定哈希永远获胜。"。测试需要同时记录返回值、关键状态和终止位置,不能只凭程序没有异常就判定通过。这一项应单独运行,也应与前后正常操作组合,防止局部正确掩盖状态污染。
- 验证矩阵 2:固定执行顺序,把"只有一条记录时,两条路线必须得到相同金额。"设为通过契约;随后故意模拟"排序后按对象地址而不是业务键判断分组边界。"。测试需要把期望结果写成独立断言,并在失败时打印触发分支所需的最短上下文。这一项应单独运行,也应与前后正常操作组合,防止局部正确掩盖状态污染。
- 验证矩阵 3:放大数据规模,把"键可以重复且顺序交错,排序前不能提前结算。"设为通过契约;随后故意模拟"为了比较结果而依赖 Map 迭代顺序。"。测试需要分别观察正确性与资源曲线,避免性能变化掩盖已经出现的语义偏差。这一项应单独运行,也应与前后正常操作组合,防止局部正确掩盖状态污染。
- 验证矩阵 4:注入一次错误,把"金额累加可能超过 int,示例统一使用 long。"设为通过契约;随后故意模拟"上游已有序仍复制并全量排序,白白制造内存峰值。"。测试需要确认错误能被测试稳定捕获,再恢复实现验证用例不会产生偶然通过。这一项应单独运行,也应与前后正常操作组合,防止局部正确掩盖状态污染。
- 验证矩阵 5:重放完整状态,把"若键允许为 null,比较器和哈希策略必须先定义语义;示例明确拒绝 null。"设为通过契约;随后故意模拟"只看平均 O(n) 就认定哈希永远获胜。"。测试需要使用相同输入重复运行,检查结果、排序规则和日志字段是否保持可复现。这一项应单独运行,也应与前后正常操作组合,防止局部正确掩盖状态污染。
擂台结论:总结
哈希与排序并非快慢标签,而是两种成本支付方式。键少、内存足、输入无序时优先哈希;输入有序、基数大或内存紧时,顺序聚合更可控。先把 n、g、顺序保证和内存预算写清楚,选择才有依据。