未找到和可能存在是不同结果

商品目录很大时,可以先用 BloomFilter 判断某个身份是否可能存在,再决定是否查询精确索引。过滤器返回 false 时可以排除,返回 true 时仍需查询真实目录。若把 true 直接当成“商品存在”,误判就会进入业务结果;若用它判断新商品是否重复并直接拒绝,也会误拒尚未存在的商品。

Guava 33.5.0-jre 的 BloomFilter 提供单侧错误的近似包含测试。这个语义以插入与查询使用一致表示、过滤器包含所需数据等前提为基础。哈希函数、字节编码、对象拆分方式和生命周期都参与结果,不能仅看到 BloomFilter 这个类名就推断整个系统没有漏查。BloomFilter 文档

本文使用 Java 8 基线,在 Zulu 8u472 与 Corretto 21.0.11 分别执行。实验先验证字节输入,再以精确 Set 标注样本真实成员关系,最后观察配置容量与实际插入量不同的误判率。固定种子用于复现输入,不用来证明所有业务分布都满足某个概率。

先确定输入字节

Hashing 不直接理解“商品身份”。字符串必须选择编码,整数必须选择字节顺序,复合对象必须定义字段边界。输入表示不同,即使算法名字相同,也会产生不同摘要;反过来,表示把不同对象合并成相同字节时,再强的摘要算法也无法恢复丢失的字段边界。

实验对字符串显式使用 UTF-8。SHA-256 的空输入向量和 abc 向量分别与固定常量比较;中文“商品”的摘要再与 JDK MessageDigest 对同一 UTF-8 字节数组的结果逐字节比较。这样能区分编码或调用错误与摘要算法本身,而不把两个任意打印的十六进制字符串当成验证。NIST SHA 示例

PrimitiveSink.putInt 按低字节在前的方式写入。测试把整数 0x01020304 与字节数组 [4,3,2,1] 的摘要比较,结果相等;与 [1,2,3,4] 则不同。这项约定不应根据 CPU 大小端猜测,而应按 API 的编码协议理解。PrimitiveSink

跨语言系统若使用网络字节序或已有二进制协议,应先按该协议生成字节,再调用 hashBytes。只在 Java 侧调用 putInt、另一侧直接使用大端编码,会让两边都“正确地计算 SHA-256”,却无法得到相同结果。算法一致不能替代序列化格式一致。

字段边界不能靠连续拼接保留

把商户编号与商品编号依次 putString,看起来比字符串拼接更明确,但 UTF-8 字节流仍然连续。("ab", "c") 与 ("a", "bc") 都变成 abc,摘要必然相同。这属于输入编码碰撞,和寻找摘要算法的密码学碰撞不是同一问题。实验对这两种构造显式断言摘要相等。

本例用长度前缀分隔字段,对纯 ASCII 的两个样本分别写入字段长度和字段内容,得到不同摘要。真实通用协议应使用编码后字节长度,而不是直接使用 Java 字符串 length;非 ASCII 字符的 UTF-8 字节数可能不同。还应规定 null、空串、字段顺序、数字宽度与版本。

Funnel 正是把对象输入转换为 PrimitiveSink 写入过程的接口。它使对象表示规则可以集中复用,但并不自动创造正确边界。商品身份应由商户与 SKU 组成,就需要两个字段都进入 Funnel;省略商户会让不同商户同 SKU 共用过滤器键,和第 03 篇中的身份合并错误相同。Funnel 文档

对象可变性也会影响查询。商品插入过滤器后若改变参与 Funnel 的字段,再用新内容查询,相当于查询另一个字节键。此时得到 false 不能证明 BloomFilter 对原先插入键产生了假阴性。应保存不可变身份,更新身份时按业务规则维护数据版本与过滤器覆盖范围。

位数组为什么允许误判

固定版本的 BloomFilter 先通过 Funnel 与哈希策略确定多个位索引。插入把相应位设为一,查询检查这些位是否全为一。不同元素可能设置重叠位,因此一个未插入元素的全部位也可能已经被别的元素设置,产生 false positive。BloomFilterStrategies 源码

这也解释了为什么重复插入不是精确计数。一个元素第一次插入后,再插入同一表示不会产生新信息;测试确认重复 put 返回 false。但 put 返回 false 不能反推“这个元素一定插入过”,因为其他元素也可能已经覆盖全部位。需要精确判重时,最终仍应依赖 Set、数据库唯一约束或其他精确索引。

普通 BloomFilter 也没有逐元素安全删除接口。把某个元素对应位直接清零,会同时影响共享这些位的其他元素,可能引入漏报。本文没有修改内部位数组,也不提供这种删除算法。需要删除的数据模型可以采用重建、按周期分代或支持计数的其他结构,但每种方案都应重新定义覆盖期限与错误语义。

Guava 的 expectedInsertions 是容量设计输入,fpp 是期望误判概率参数。固定源码据此计算位数与哈希函数数量,再按实际位数组容量组织存储。它不要求运行中第若干次查询必须出现误判,也不保证每一批样本恰好有百分之一错误。BloomFilter.create 源码

精确 Set 提供实验真值

实验使用 Random(20261002L) 生成带 I: 前缀的插入字符串,用 LinkedHashSet 保留生成顺序并去重。配置两个过滤器,expectedInsertions 都为 1000,fpp 都为 0.01。第一个只插入前 1000 个不同值,第二个插入全部 10000 个不同值。

查询集继续由同一随机序列生成,使用 Q: 前缀并去重,直到得到 10000 个不同查询。每次查询前都通过精确 Set 断言它不属于插入集合,再统计 mightContain 返回 true 的次数。不同前缀帮助样本分离,精确 Set 断言则明确记录成员真值,避免把真阳性混进假阳性计数。

对所有已插入样本,测试另行断言 mightContain 为 true。这个验收覆盖了当前样本中的零假阴性,与未插入查询集的误判统计是两项不同检查。只检查一边会漏掉编码不一致或统计分母错误;只打印 expectedFpp 也不能证明实际查询结果。

配置容量 实际不同插入量 不在集合中的查询数 假阴性 假阳性 样本假阳性比例
1000 1000 10000 0 110 1.10%
1000 10000 10000 0 9949 99.49%

两个 JDK 在这组固定输入上得到相同计数。第一组的 expectedFpp() 返回约 0.0098838,第二组约 0.9941812;这是根据当前位占用计算的估计,和本次查询样本比例不是同一个量。表格足以显示严重超量插入会让这个过滤器几乎失去排除能力,但不提供新业务数据的置信区间。

把某次观测的 1.10% 写成配置 1% 失效,也没有统计依据。实际误判次数受查询分布与样本数量影响,固定种子使测试可重现,却不能消除这种分布依赖。若业务要求可量化的容量预估,应增加符合真实键分布的独立数据集,并记录样本设计,而不通过挑选种子得到更好看的比例。

用在商品查询的哪一层

典型读取路径是先查过滤器:false 时直接返回不存在,true 时查询精确目录,再根据真实结果回答。这样 false positive 增加一次多余查询,不改变商品是否存在的最终判断。收益取决于不存在请求比例、精确查询成本和过滤器占用,不能只根据误判率一个数字决定是否引入。

写入顺序尤其重要。若商品先写入精确目录,但还没加入过滤器,读取在这个窗口可能得到 false 并错误地绕过目录。BloomFilter 数据结构的单侧错误性质不能自动解决这项应用一致性问题。写入协议、版本切换或兜底查询需要由应用设计;本文的单线程静态样本不证明在线更新无漏查。

同样,持久化过滤器的恢复必须包含一致的 Funnel。恢复后改了字段顺序或编码,再查询旧数据,实际已经改变键表示。过滤器序列化格式与业务对象编码是两层协议,应分别版本化。本文没有运行跨版本过滤器恢复,因此只讨论当前运行中的查询与插入行为。

下面的完整 Java 8 示例只展示精确索引兜底。过滤器中的阳性不直接成为存在性结论;最终返回仍由 Set 决定。测试工程里另有固定种子的完整统计过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;

public final class FilteredCatalog {
public static void main(String[] args) {
Set<String> exact = new HashSet<>();
BloomFilter<CharSequence> filter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), 1000, 0.01);
exact.add("A:SKU-1");
filter.put("A:SKU-1");
String query = "A:SKU-1";
boolean exists = filter.mightContain(query) && exact.contains(query);
if (!exists) { throw new AssertionError(); }
System.out.println("exact member=" + exists);
}
}

哈希用途不能混用

Guava Hashing 同时提供不同用途的哈希方法。非密码哈希适合散列分布等场景,不应拿来储存密码。普通 SHA-256 摘要即使是密码学哈希,也不提供密码存储所需的专用成本设计;本文使用它只是为了展示已知向量和输入字节,并未设计认证系统。

同样,摘要比较不能不加条件地替代原始对象相等。任何固定长度摘要都存在有限输出空间,需要根据业务风险决定是否保存原值并复核。BloomFilter 已经明确允许误判,更不适合独立承担资金去重、授权或不可逆的数据删除判断。

本章不报告吞吐或内存优势。主数据结构、精确 Set、输入字符串都在实验进程中共存,若直接读取总堆大小会混合多种对象。需要比较空间时,应隔离对象布局、容量和辅助数据;需要比较速度时,应先保证最终业务结果一致,再设计基准。

过滤器上线前还需要确定更新协议。若先把商品写入精确目录、稍后才更新过滤器,两个动作之间的读取可能遇到过期阴性;这种错误来自应用维护协议,并不反驳 BloomFilter 对已经插入元素的契约。可在同一发布批次中构建完整快照,再将过滤器与目录快照一起切换。若数据持续变化且无法保持这种一致性,读取路径就不能无条件依赖过滤器跳过精确查询。

运行与练习

完整测试与运行说明覆盖 2 项测试,在两个 JDK 上均无失败、错误或跳过。原始日志包含种子、容量、实际数量、查询分母、误判计数与位占用估计,不仅保留最终百分比。

手算题:过滤器说某个商品可能存在,精确目录说不存在,应返回什么?返回不存在。过滤器只决定是否值得进一步查询,业务存在性仍由精确数据决定。若用于导入去重,直接根据阳性拒绝会把误判转成业务错误。

改动练习:把商品身份改成两个 UTF-8 字段,在 Funnel 中先写字节长度再写字节内容。加入中文、空串和包含分隔符的值,证明不同字段组合不会因简单拼接而变成相同输入;再重跑成员与非成员矩阵。

可迁移做法 适用边界
先固定对象到字节的编码协议 跨语言摘要、复合身份、持久化过滤器
用精确真值分别验收阴性与阳性错误 近似数据结构参与业务读取路径

下一篇:EventBus 的订阅关系与派发边界。