反向查询与二维查询是两种需求

商品导入需要维护内部 SKU 与外部商品编号的对应关系。如果每个外部编号只能归属一个内部 SKU,查询方向既包括“内部编号找外部编号”,也包括“外部编号找内部编号”,则数据本身是一对一关系。另一个需求是按商家与 SKU 查询价格,同一 SKU 可以在不同商家下有不同价格;这里的标识是两个坐标构成的组合。

第一种关系适合讨论 BiMap,第二种适合讨论 Table。前者约束值唯一,并提供可反向访问的同一份映射;后者把一对键作为单元格位置,值可以重复。若只因为都需要“多一种查询方式”就把两者混为额外索引,会误解重复约束,也会错误估计列查询代价。

实验使用 Guava 33.5.0-jre,源码固定到 8868c096cfdabbe38170b6e395369c315cfb72a1,基础代码保持 Java 8。完整可编译的 Chapter12Test.java 与 复跑说明覆盖五组场景;JDK 8、JDK 21 分别运行。容器别名的前置内容见 第 02 篇。

BiMap 把值唯一变成写入约束

普通 Map 允许两个不同键映射到同一个值。若另建一个反向 Map 保存 value -> key,第二次写入会覆盖第一次写入,原来的正向 Map 却仍保留两条记录。两个容器单独都满足 Map 契约,合在一起却不再构成互逆关系。调用方还必须定义覆盖、删除、失败回滚和并发可见性的协调规则。

BiMap 的固定版本契约要求值也唯一。初始映射为 sku-a -> external-1、sku-b -> external-2,再执行 put("sku-a", "external-2") 会抛出 IllegalArgumentException。实验同时断言原来的两条关系仍存在,避免只检查异常而遗漏失败后的状态。

普通 put 允许替换同一键的旧值,前提是新值没有归属另一个键。重复写入完全相同的键值对也不会制造新的关系。区别在于冲突对象:键已存在是 Map 常见的更新路径,值已属于其他键则触及一对一约束。这项约束必须符合业务身份,不能只因为反向查询方便就强行施加。

例如多个内部 SKU 允许映射到同一个外部商品主编号,那么外部编号的反向查询应得到 SKU 集合。这个关系需要 Multimap,或者 Map<ExternalId, Set<Sku>>。使用 BiMap 再捕获异常丢掉一条记录,会把合法的多对一关系变成数据损失。

forcePut 是有损的冲突解决规则

forcePut 会移除占用了目标值的旧关系,再执行当前写入。对于两条初始关系,forcePut("sku-a", "external-2") 的结果只有 sku-a -> external-2 一条:sku-a 原来的 external-1 被替换,sku-b 原来的 external-2 被转移。

1
2
3
4
5
6
写入前: sku-a <-> external-1
sku-b <-> external-2

forcePut(sku-a, external-2)

写入后: sku-a <-> external-2

该调用返回 external-1,即当前键之前关联的值;它不会把被移除的 sku-b 作为返回值交还。因此,审计系统不能只记录方法返回值来恢复全部被替换关系。若业务需要可追溯的重新绑定,应先显式确定新旧两端归属,将变更作为业务命令记录,再在受控边界内执行。这里的实验仅演示单线程内存修改,没有宣称它完成数据库事务或并发迁移。

固定版本 HashBiMap.put 的实现先分别按键与值查询已有 entry。找到目标值的旧 entry 时,普通路径抛异常,force 路径删除这条 entry;随后处理当前键的旧 entry,并把新 entry 写入两套哈希查找结构。源码中先后维护的是同一个关系集合的两个访问方向,不是两个互不相关的 Map。

由此可以提炼一项判断:索引冲突的解决方式也是业务规则。拒绝重复、覆盖当前键、转移值的归属,分别允许不同的数据变化。API 名称中的 force 并不表达“更可靠”,它表达的是更强的覆盖权限。

inverse 返回另一条修改入口

inverse() 返回共享数据的反向 BiMap,修改任一方向都会影响另一方向。在实验中,通过 inverse 写入 external-2 -> sku-b 后,正向查询立即得到 sku-b -> external-2;通过 inverse 删除 external-1 后,正向 sku-a 同时消失。反向再取 inverse 得到原 BiMap 本身。

这与复制一份反向 Map 的区别发生在后续修改阶段。如果将 inverse 交给只应查询外部编号的组件,该组件也获得了删除、重新绑定关系的能力。仅改变泛型参数顺序不会收窄权限。对外只读接口应当采用受控包装或独立不可变快照;若需要保留“同一份持续变化的数据”,则必须明确视图的生命周期。

HashBiMap 源码允许 null 键和值,但值唯一仍适用于 null:一条 missing-reference -> null 已存在时,再写另一条指向 null 的关系会触发重复值异常。此时 get("missing-reference") 和 get("absent") 都返回 null,只有 containsKey 能区分已有空值与缺失键。

业务若把“暂无外部编号”作为很多商品的正常状态,就不应直接把它们都写成 BiMap 的 null 值。可以只维护已经完成绑定的关系,把绑定状态留在独立业务对象中。这样反向映射的定义域也更清晰。样例中的字符串是不可变标识;可变键或可变值若改变 equals/hashCode,会破坏相应方向的哈希查找。

Table 的一个单元格由两个键定位

Table<MerchantId, Sku, Price> 中,商家为行键,SKU 为列键,价格为单元格值。size() 计算非空单元格数量,并非商家数量或所有可能坐标的笛卡尔积。商品只在两个商家出售,就只需要保存两个单元格,不需要补齐全部商家与全部 SKU 的组合。

HashBasedTable使用嵌套的关联结构。这里不允许 null 行键、列键和值;对缺失坐标 get 返回 null,因此在该具体实现中,null 可以表示没有这个单元格。若业务价格本身可能缺失,宜用显式状态值或不创建单元格,避免把“零价格”“未知价格”“不存在商品”混成同一种状态。

访问方式 缺失时结果 修改影响
get(merchant, sku) null 单次读取
row(merchant) 空视图 put 可创建该商家的单元格
rowMap().get(merchant) null 该商家没有已保存的行
column(sku) 空视图 put 可写入对应商家单元格

空 row 视图不等于已经存在一个空行。实验先取得 merchant-a 的 row,此时 containsRow 为 false;向 row 写入 sku-a 后,Table 才包含该行。通过 column 修改同一坐标的价格,先前拿到的 row 立即读到新值。移除该行最后一个单元格后,containsRow 又变成 false。

行列视图对称,存储代价并不对称

Table 同时提供 row 和 column 接口,不能据此推断它为两个方向各维护一份同等性能的索引。StandardTable 的 Column 实现会遍历底层各行寻找指定列;containsColumn 也检查各行,column 的 size 需要累计包含该列的行数。按完整坐标读一个单元格则可以先定位行,再定位列。

这个实现差异直接影响报表设计。频繁读取单件商品在所有商家的价格,涉及跨行列遍历;频繁读取一个商家的全部商品,则直接使用一行的视图。若真实工作量主要沿列访问,可以重新选择行列方向,或在有明确同步策略的前提下建立独立索引。本文没有基准数据,不用“O(1)”的口号代替包含行数、哈希分布和遍历量的成本分析。

部分修改入口也不对称。row 支持增加单元格,但 rowMap().put 不支持直接替换整行;实验要求它抛出 UnsupportedOperationException。这避免把任意外部 Map 直接塞进内部结构,却不代表 Table 不可修改。调用方应按单元格或受支持视图操作,不能从一个方法被拒绝推断整个对象只读。

第二项可迁移判断是:区分逻辑访问维度与物理索引。API 允许按列查看,说明列关系有定义;是否维护列索引、是否需要扫描所有行,必须检查具体实现和工作量。BiMap 的双向唯一关系与 Table 的稀疏二维关系,也应分别设计更新规则。

一致性维护集中后仍有生命周期边界

HashBiMap 集中了两条访问路径的结构维护,调用方不必每次分别更新正向 Map 和反向 Map。但这不意味着跨系统的一对一关系已经得到保证。如果数据库另有一份绑定关系,内存更新成功而数据库更新失败,仍然会出现两个存储版本不一致。容器解决的是自身内部关系,持久化、事务和多实例协同仍属于应用边界。

批量导入也不能仅凭若干 put 调用推断整批原子性。前几条成功、后一条遇到重复值抛异常时,是否允许保留前面的成功结果,要由导入契约决定。需要整批校验后发布的场景,可以先在隔离的临时结构中验证所有关系,再按应用规定替换活动快照。本文只验证单次 put 的冲突路径,没有把它扩大为任意批量操作的回滚保证。

对于 Table,价格 value 如果是可变对象,row、column 和完整 Table 读到的仍是同一个对象引用。将价格对象中的状态直接改掉,会绕过单元格 put 的入口;如果审计仅监控 Table.put,就会漏掉这种内部修改。使用不可变金额值、通过显式替换更新单元格,更容易让变更发生在统一入口。

对象释放也受视图影响。只保留某个商家的 row,并不等于只保留那一行的一份独立数据;视图可能通过外部结构引用继续关联整个 Table。长期缓存小视图前,应判断是否真正需要持续联动。若只是保留一次查询结果,复制需要的单元格可以缩短对源结构的依赖。这里是源码引用关系分析,没有进行堆转储或内存占用测量。

更新边界应同时覆盖正向和反向查询的观察时机。只给一条访问路径加锁,并不足以证明另一条路径不会看到修改中的状态。

验证、替代与练习

五项测试分别覆盖重复值拒绝后的状态、forcePut 的关系缩减、inverse 修改与 null、row/column 联动、Table 非法 null 与整行替换。JDK 8 与 JDK 21 均为 Tests run: 5, Failures: 0, Errors: 0, Skipped: 0。这些结果验证功能契约,没有覆盖并发写入或堆占用,因此不构成线程安全和性能结论。

仅需要一条查询方向时,普通 Map 已足够;反向查询很少且数据量可控时,显式扫描可能比长期维护索引更简单。JDK 的 Map<R, Map<C,V>> 可以表达 Table,但调用方需统一缺失行、清理空行与视图暴露的规则。引入专用类型的收益在于使这些语义集中,而不是减少几个泛型字符。

手算练习:从 a->x、b->y 开始执行 forcePut("c", "y"),结果应为 a->x、c->y,大小仍为 2;再执行 forcePut("a", "y"),结果只剩 a->y。改动练习是为每次转移增加审计对象,同时记录旧键值关系和目标值的原归属。验收重点是能够还原关系变更,不是只输出一个返回值。

判断关键词 可迁移模式 具体选择
值重复 冲突解决属于业务规则 一对一用 BiMap,多对一用集合反向关系
inverse、row、column 视图携带修改权限 明确读写边界与快照时间
列查询 访问维度不等于物理索引 检查扫描方向与真实工作量
缺失与 null 缺失模型跟随具体实现 HashBiMap 用 containsKey,Table 明确单元格状态

前篇:Multiset 频次。系列起点:可复现基线。