商品标签统计需要保留什么

一次商品导入包含三个标签出现记录:sale、sale、new。统计接口可能同时需要回答三个问题:收到多少条标签记录,出现过多少种标签,每种标签各出现多少次。答案分别为 3、2,以及 sale=2、new=1。用 Set 保存会丢失重复次数;用 List 保存可以保留记录,但每次查询频次都要重新统计。Multiset 将元素和出现次数作为一个整体管理,适合这里的频次模型。

频次是否具有业务意义,要先于容器选择。若同一商品重复填两次 sale 应当被视为输入错误,直接把全部标签装进 Multiset 会把错误输入变成合法频次。若要统计“带有 sale 的商品数量”,则应先对单件商品内部的标签去重,再汇总;若要统计“收到多少次 sale 事件”,则不能提前去重。容器能够保持计数,不能决定一条记录应当算几次。

本篇使用 com.google.guava:guava:33.5.0-jre,示例采用 Java 8 API,在 JDK 8 与 JDK 21 分别运行。完整实验为 Chapter11Test.java,复跑说明见 RUN.md。前置的容器共享问题见 第 02 篇。

总数、种类数与单元素计数

Multiset 契约把重复对象称为同一元素的 occurrences。count(element) 返回这种元素的出现数,elementSet() 返回去重后的元素视图,size() 返回所有出现次数之和。普通迭代器也逐次返回元素:计数为三的元素会出现三次。若报表只需要每种标签及其次数,遍历 entrySet() 更直接,不必重新展开全部出现记录。

查询 输入 sale、sale、new 的结果 对应业务含义
size() 3 标签出现总量
elementSet().size() 2 标签种类数
entrySet().size() 2 计数项数
count("sale") 2 sale 出现数
count("missing") 0 尚未出现

单元素计数为零时,该元素不属于 Multiset;这里没有“保留一条值为零的计数记录”这一状态。如果业务需要区分“尚未观察到”和“观察到但净计数为零”,或者允许退款、冲正造成负数,Map<String, Long> 更贴合数据模型。把这种有符号余额硬塞进 Multiset,会在零值清理和负数边界上损失信息。

计数容器的第一种可迁移判断是:先写清一个单位代表什么,再决定是否去重。事件次数、商品数量、不同标签数量是三种量纲,变量名和接口字段必须直接表达它们。size 这个方法名只说明容器契约,不能替业务选择统计口径。

批量修改返回修改前的次数

add(element, occurrences) 与 remove(element, occurrences) 都返回修改前的次数。这个返回值既不是新增数量,也不是修改后的数量。对初始 sale=2、new=1,按顺序手算如下:

操作 返回值 操作后的 sale 总出现数
add("sale", 3) 2 5 6
remove("sale", 2) 5 3 4
remove("sale", 99) 3 0 1

最后一次删除不会把计数减成负数,也不会因为请求删除 99 次而拒绝操作;它移除实际存在的三次出现。这适合“最多移除这些记录”的操作,却不能直接承担库存扣减。如果订单需要五件,而当前只有三件,业务可能要求拒绝整笔扣减。先检查 count 再 remove 在单线程中能表达这种约束,在并发场景中则还需要把检查与修改纳入同一同步边界。

固定版本的 AbstractMapBasedMultiset 源码用 Map<E, Count> 保存每种元素的计数,用独立的 long size 保存总出现数。新增已有元素时先以 long 计算候选次数,确认不超过 Integer.MAX_VALUE,再修改 Count 和总数;删除则从请求量与现有量确定实际移除量,必要时移除整个键。返回旧计数来自这条更新路径。

setCount(element, newCount) 表达覆盖,而不是增量;三参数形式只在当前计数等于预期旧值时修改。它的返回值适合表达条件是否成立,但不能仅凭方法形状推断实现具有并发原子性。Multiset 源码中的默认契约明确把原子性留给具体实现。本文的 HashMultiset 没有并发更新保证,实验也没有覆盖并发冲突。

从哪个视图删除,决定删掉多少

把标签种类交给后台管理界面展示时,elementSet() 很容易被理解成一份独立的去重结果。它实际是关联视图,删除 sale 会把 sale 的全部出现清除。普通 iterator().remove() 只删除最近一次返回的一个出现;entrySet().remove(entry) 还要求元素与计数同时匹配,匹配后删除整个计数项。

1
2
3
4
HashMultiset
sale -> Count(5) iterator.remove: 减少一次
new -> Count(1) elementSet.remove("sale"): 清除整种
entrySet.remove(sale,5): 匹配后清除整种

实验先保存 sale=3 对应的 entry,再增加两次 sale。这个 HashMultiset entry 的 getCount() 随后读到 5,说明“取得 entry”也不等于取得计数快照。使用不可变的 Multisets.immutableEntry("sale", 3) 删除时返回 false;使用计数 5 的 entry 删除时返回 true。若审计记录需要保留修改前的值,应当立即读取并保存标量,而不是把动态 entry 引用保留下来。

这项行为对应 AbstractMapBasedMultiset.entryIterator返回的 entry:getCount() 读取关联 Count,在旧 Count 已清零时还可能回查 backing map。它解释了本实现的活动计数,不应推广成所有 Multiset 实现的 entry 都具有同样生命周期。

第二种可迁移判断是:把视图暴露看成权限暴露。去重视图减少了展示字段,却可能保留整类删除权限;entry 只读也不意味着其中数值固定。接口若只允许查看,返回不可变计数快照比直接返回可修改视图更能表达约束。

Collection 方法不自动获得频次语义

两个 sale 是否足以满足三个 sale 的需求?stock.containsAll(demand) 返回 true,因为 Collection 层面的检查关注元素是否存在,不比较每种元素的次数。若需求确实按次数计算,应使用 Multisets.containsOccurrences。类似地,removeAll(singletonList("sale")) 会清除所有 sale,并非只删一次。

这不是 HashMultiset 丢失了计数,而是同一个对象同时实现了 Collection 接口和额外的频次操作。接口继承不意味着每个旧方法都被重新解释成多重集代数。Multisets 的固定源码提供了按次数包含、移除与保留的辅助操作。调用点应直接体现使用哪套语义,避免将需求 Multiset 隐式当成普通 Collection 传递。

int 计数与饱和的 size

某一种元素最多出现 Integer.MAX_VALUE 次;在这个计数上继续增加会抛出 IllegalArgumentException,原计数保持不变。负的新增量、删除量和目标计数同样不合法。批量计数 API 不需要真的建立二十多亿个对象引用,因此可以用少量内存验证这些边界。

总出现数则可能超过 int:sale 计数设为 2147483647,再加入一次 new,数学总和为 2147483648。实验中 size() 返回 2147483647,而对 entry 计数做 long 求和得到 2147483648。这对应源码的 Ints.saturatedCast(size),也符合 Collection.size 的 Java 8 契约对超出 int 容量时的规定。把 size 转成 long 并不能恢复已经饱和的信息。

大型频次报表若要精确总数,可以在稳定快照中对 entry 的计数以 long 累加。若单种标签本身就可能超过 int,则必须更换计数模型;仅更换汇总变量类型无效。这里没有进行超大规模性能测试,边界实验只验证 API 的数值行为。

有序版本还改变了相等关系

HashMultiset 依赖元素的 equals/hashCode,不能在入集合后修改影响这两个方法的字段。TreeMultiset 则按比较器确定等价类。实验使用忽略大小写的比较器放入 SKU 与 sku,结果只有一种元素,查询 SkU 的计数为 2。这既提供排序,也定义了聚合身份;若商品编号本来区分大小写,换用这个比较器会错误合并编号。

HashMultiset允许 null 元素;自然顺序的 TreeMultiset在新增 null 时抛出异常。HashMultiset 不提供所需的业务排序保证,有序报表应显式选择比较规则或在导出阶段排序。本文没有用 HashMultiset 的打印顺序作断言。

计数模型不能替代事件历史

两个导入文件可能包含相同标签和相同次数,却来自完全不同的商品记录。聚合成 Multiset 后,这些输入具有相同的频次状态,原来的记录顺序、事件来源和每次出现的时间都没有保留。需要追溯一条错误标签时,不能从计数反推究竟是哪条商品记录产生了它。频次报表应与原始明细或可定位的导入批次关联,不能让计数容器成为唯一存储。

这也影响重复消息的处理。同一批导入被执行两次,addAll 会把每个出现再次累加,最终结果翻倍。Multiset 没有事件标识,无法自行判断两次相同标签来自不同商品还是同一消息重放。若上游提供唯一事件编号,幂等检查应当放在计数更新之前,并与更新纳入业务要求的一致性边界;仅在最终 elementSet 上去重,会同时丢掉应保留的合法频次。

对于可变业务对象,保存完整 Product 作为标签计数的元素通常也值得重新检查。统计维度是标签文本,使用稳定且经过规范化的标签键更容易解释;如果把商品的价格、描述或状态都纳入 equals,后续更新这些字段可能改变同一元素的身份。聚合键应只包含统计口径实际需要的字段,不应顺手复用某个字段很多的业务实体。

Map 替代方案需要承担哪些规则

Java 8 的 Map.merge 可以把单次出现累计到整数或长整数计数中。这种表示更自由,能够额外保存零值和业务元数据,但更新代码必须主动维护计数非负、零值清理和溢出规则。若使用普通整数加法而不检查边界,超过上限可能得到负数;不能因为类型仍是 Integer 就认为已经获得 Multiset 的上限校验。

移除次数的含义也需要一致。Map 中的 value 为 3 时,是允许请求减去 5 并截断到零,还是拒绝请求,或者保存负债值,需要在更新函数中规定。Multiset 已经选择“最多删掉现有出现”的语义,适合符合这条规则的统计任务。替代方案的比较应建立在相同语义之上,而不是只比较一次 add 与一次 merge 的代码行数。

若报表要按频次降序展示,应按 count 排序,而不是误用 TreeMultiset 的元素排序。TreeMultiset 排列的是标签本身的顺序,计数只附着在标签上;字典序第一个标签并不必然出现最多。相同频次还需要明确第二排序键,才能在分页、导出与回归测试中得到稳定结果。这项导出排序不在本组测试范围内,练习实现时应增加并列频次样本。

实验结果与改动练习

Chapter11Test 包含五项测试,覆盖手算增删、三种删除入口、计数溢出与 size 饱和、Collection 方法反例、比较器身份及 null 边界。JDK 8 与 JDK 21 均为 Tests run: 5, Failures: 0, Errors: 0, Skipped: 0。原始报告随实验工程保存,测试无需网络请求或外部服务。

改动练习:把输入改成商品 A 的标签 sale、sale,商品 B 的标签 sale、new,分别实现“标签事件频次”和“带标签的商品数”。前者应为 sale=3、new=1,后者应为 sale=2、new=1。去重必须发生在单件商品的边界内;把所有标签全局去重,会把后者也错误算成 sale=1。

判断关键词 可迁移模式 具体选择
次数还是种类 先定义统计单位 count、size 与 elementSet 分开命名
删除一个还是一类 视图暴露即权限暴露 明确 iterator、elementSet、entrySet 入口
零值、负数、超大单项 数值域决定模型 使用适合业务范围的 Map 计数
比较器相等 聚合身份先于排序 检查 compare=0 是否允许合并

系列起点:可复现基线。