Java 后端每日学习 · 2026-09-12

Day 19

Map 阶段闯关变式:多租户积分流水冲正与余额榜索引的契约、重排、重建与源码解释。

请通过本地启动器或已部署的学习地址访问;页面加载后即使服务暂时中断,浏览器草稿与下载备份仍可使用。

Java 后端每日学习 · Day 19 · 2026-09-12

打开今日互动学习页:汇总五个课程章节,支持代码复制、复盘作答、浏览器草稿和本地 Markdown 保存。网页作答优先、聊天提交补充。

今日主题

Map 阶段闯关变式:多租户积分流水冲正与余额榜索引的契约、重排、重建与源码解释。

方向元数据

字段
directionId java-data-structures
directionSession 17
curriculumItemId challenge.map
lessonMode checkpoint
节点进入前状态 covered_unverified
完成本课后的预期状态 covered_unverified;生成第七个变式不等于闯关通过
当前方向状态 active

查看全局 Java 后端知识地图。固定同步助手对最近完整课程 Day 18 的拉取结果为 remote_missing,服务器与本地均没有 2026-09-11/04-复盘作答.md,当前任务也没有能明确映射到 Day 18 题号及问题源哈希 sha256:91a3bfcb17064352a52f1a38fa992740ac7cc436a5f9b3f2b8e8a28bbb60250e 的答案。因此答案来源为 none,不评分、不推断薄弱项或红线;challenge.map 保持 covered_unverified,今天继续在 Map 模块完成新变式。

今天把重心从“到期批处理与共享数量扣减”转到“不可变流水事实、可撤销聚合、余额榜重排与派生索引重建”。积分流水一经发布便保留在事实表中,冲正只迁移状态;账户余额和排行榜可以从事实重算,缓存则可以丢弃。大大需要同时守住事实身份、余额归零删键、旧榜键清理、稳定快照和跨多张 Map 的原子性边界。

可验证目标

  1. 能区分流水事实键、账户聚合键和排行榜排序键,并说明哪些字段参与 equals/hashCode、哪些字段参与 Comparator 全序。
  2. 能逐步推演同账户累计、缓存淘汰、单笔冲正、余额归零删键、排行榜重排、旧快照稳定以及由事实重建派生索引。
  3. 能完成 Java 21 必交编码,编译并产生规定输出;缺失、重复或已冲正流水不能产生二次副作用。
  4. 能沿 OpenJDK jdk-21+35 的入口、字段与主调用链解释 HashMap 条件写入和聚合、TreeMap 排名删除与插入、LinkedHashMap 访问顺序、EnumMap 状态槽位。
  5. 能说明单线程演示、单键原子 API、fail-fast、批量重建与生产环境共同锁、事务或不可变状态交换之间的边界。

60~75 分钟学习顺序

  1. 昨日复盘:Day 18 五题完整参考答案与库存预占过期批处理索引完整实现(约 12~14 分钟)。
  2. 核心讲解:用积分流水冲正串联事实、余额聚合、排行榜、缓存与可重建投影(约 31~32 分钟)。
  3. 编码练习:完成稳定 Top 快照、单笔冲正与从事实重建派生索引;这是闯关通过的必交编码(约 18~20 分钟)。
  4. 复盘问题:按四个固定维度提交 Map 阶段闯关答案(约 8~9 分钟)。

总预计用时约 69~75 分钟。今天只验证 Map 模块,不提前进入 Set,也不把昨日参考答案算成大大的作答证据。

方向进度

  • 课前与课程完成后的标准课覆盖均为 10/3528.57%);今天是同一稳定闯关项的第七个变式,不重复计数。
  • 知识点覆盖保持 9/27;已评估 0、已掌握 0、待补强 0
  • 源码型知识点覆盖保持 4/9,已掌握仍为 0/9
  • Map 闯关保持“已出题待验证”:闯关已开始 1/6、通过 0/6;综合项目 0/1、方向总测 0/1
  • 无作答只表示尚未验证,不等于失败;也不能据此放行到 Set。

课程完成标准

  • 完成四部分学习,并能说明流水事实为何在冲正后仍保留,余额榜为何必须先删旧排序键再按新余额重建。
  • 将编码练习的 3 个 TODO 补全,以 javac --release 21 编译并运行,保存完整代码与规定输出。
  • 回答复盘页全部闯关题;答案必须映射到当天题号和问题源,不能用本文或昨日参考答案代替。
  • 不依赖 HashMap 顺序,不破坏 equals/hashCode 或 Comparator 契约,不混淆活视图、只读包装与快照,不把 fail-fast 或 ConcurrentHashMap 单键原子 API 当成跨 Map 事务。

闯关放行条件

  • 总分至少 80/100;固定维度为:契约与选型 25、复杂度与状态推演 25、编码 30、源码讲解 20。
  • Java 21 编码必须成功编译、运行且产生预期结果;只写思路、缺少完整代码或输出不符均不能通过。
  • 六类红线均未命中。达到分数但编码失败或命中红线,仍视为未通过。
  • 今日课程生成后,challenge.map 仍只记 covered_unverified。只有后续取得完整、有效且满足全部条件的作答证据,才可记为 mastered 并进入 Set。

固定版本与官方资料

条件式下节预告

  • 若仍没有完整有效的闯关答案:challenge.map 保持未通过,下一完整学习日继续用新数据和约束完成 Map 综合练习或闯关变式,绝不进入 Set。
  • 若出现部分有效答案且未暴露明确错误:逐题点评,challenge.map 记为 practicing,保留验证债务并继续 Map 闯关,不虚构总分。
  • 若完整答案低于 80、Java 21 编码不通过,或任何有效答案暴露明确错误或红线:challenge.map 与明确薄弱的 1~3 个知识点记为 needs_review,方向记为 needs_review;下一课优先更换数据、约束和场景补强,再重闯。
  • 只有完整答案达到至少 80、编码编译运行符合预期且无红线:challenge.map 才记为 mastered,并依据充分证据更新相关 Map 知识点;下一课才可进入 ds.set.contract-algebra

返回今日索引

昨日复盘:多仓库存预占过期批处理 Map 闯关核对

复盘对象:Day 18(2026-09-11),评估项 challenge.map。建议用时:12~14 分钟。先用 1~2 分钟确认第 1 节证据与推进边界,遮住答案用 5~6 分钟口述第 2 节,再用约 6 分钟核对第 3 节三个 TODO、规定输出和复杂度。下文全部是教学参考答案,不构成大大的作答、分数、闯关通过或掌握证据

1. 作答证据、逐题评分与推进结论

  • 固定同步助手拉取 Day 18 作答的结果为 remote_missing,服务器上没有 2026-09-11/04-复盘作答.md;本地也没有该文件。
  • Day 18 问题源哈希:sha256:91a3bfcb17064352a52f1a38fa992740ac7cc436a5f9b3f2b8e8a28bbb60250e
  • 当前任务中没有能明确映射到 Day 18、题号和上述问题源哈希的聊天答案。
  • 答案来源:none
  • 五题均为未作答、未评分;没有有效证据可给分,缺答也不能直接记为 0 分。
  • 总分:不足以评分
  • 红线:未知。没有证据证明命中,也没有证据证明已避开六类红线。
  • 薄弱知识 ID:无法从无作答证据中确定,因此不虚构错题或针对性薄弱项。
  • 状态变化:challenge.map 保持 covered_unverified,Map 闯关仍为未通过;方向保持 active
  • 推进边界:今天继续 challenge.map 的新变式“多租户积分流水冲正与余额榜索引的契约、重排、重建与源码解释”,不得进入 Set。阅读、复制或运行下方参考答案均不会改变状态。
题号 固定维度 分值 大大的有效答案 点评与得分
1 契约与选型 25 无法核对预占身份、过期全序、分仓聚合、缓存与快照所有权,未评分
2 复杂度与状态推演 12 无法核对批处理成本、HashMap 退化、LinkedList 对比与 fail-fast 边界,未评分
3 复杂度与状态推演 13 无法核对确认、严格上界、归零删键、缓存重新进入和快照稳定性,未评分
4 编码 30 未提交完整 Java 21 代码、编译证据和规定输出,编码维度未评分,闯关不可放行
5 源码讲解 20 无法核对固定版本入口、字段、主路径和可观察合同,未评分
合计 100 证据不足 不足以评分

无作答时可先独立重做第 1~3 题,再在不看参考实现的情况下完成三个 TODO,最后用第 5 题的源码路径反向解释运行结果。这只是通用复习路线,不表示已经发现大大的确定错误。

2. Day 18 五道闯关题完整参考答案

第 1 题:事实身份、到期顺序、聚合与缓存各守一份合同

  1. 唯一事实表使用 HashMap<ReservationKey,StockReservation>;不可变 ReservationKey(warehouseId,orderId,lineNo) 的三个组件共同参与 equals/hashCode,所以不同仓的同订单同行号、同订单的不同行都能并存。仓、订单拒绝 null/空白,行号为正;事实 value 也拒绝 null,因此 get()==null 可直接表示缺失。
  2. sku、数量、到期分钟和状态是事实属性,不是预占身份;确认或过期只用新的不可变 value 替换,不能缩减或改写事实键。
  3. 到期索引使用 TreeMap<ExpiryKey,ReservationKey>,按分钟、仓、订单、行号升序补足全序;合法业务键上只有四个分量全相等才 compareTo==0,同分钟预占不会互相覆盖。
  4. HashMap<StockKey,Integer> 按不可变 StockKey(warehouseId,sku) 汇总 HELD 数量,不能跨仓混算。value 只允许正数;扣到零时删除映射,使“缺键即当前 HELD 数量为零”保持单一含义。
  5. EnumMap<ReservationState,Integer> 统计封闭状态 HELD/CONFIRMED/EXPIRED,计数归零删槽位;容量 3 的 access-order LinkedHashMap 保存最近事实副本,命中、已有键更新和插入会改变热度,淘汰只影响缓存。
  6. facts 是唯一事实来源;到期树、仓 SKU 汇总和状态计数必须能从 facts 重算,缓存则允许随时丢弃。每个 HELD 事实恰有一个匹配到期项,汇总等于同仓同 SKU 的全部 HELD 数量,状态计数等于 facts 分组数。
  7. 确认和过期都会让一条事实退出 HELD:必须核对并删除到期投影、替换事实 value、扣减对应汇总、迁移计数并更新缓存。多个普通 Map API 的顺序写入仍不是事务,生产需共同锁、单线程所有者、不可变聚合交换或数据库事务。
  8. subMap/headMap 及集合视图背靠根 Map,删除会写穿;只读包装也会随底层变化。跨边界返回应立即复制成拥有独立字符串数据的不可修改快照。
  9. 过期顺序只来自 ExpiryKey 的比较合同,HashMap/HashSet 的偶然遇见顺序不能代替;WeakHashMap 使事实寿命受键可达性和 GC 影响,IdentityHashMap== 区分反序列化得到的等值键,都不适合持久业务事实。

缓存未命中不代表预占不存在,汇总键被删除也不代表历史事实被删除;它只表示当前没有相应 HELD 数量。这种事实与派生投影的分工,是后续确认、过期和重建的基础。

第 2 题:批量上限限制本批数量,不会消除单项树成本

设事实数为 n,窗口命中 k 项,本批实际过期 m 项且 m≤maxItems

  • 事实复合键精确查询的期望时间为 O(1),但碰撞、扩容和桶结构使它不是每次严格 O(1)。
  • 首次预占包含 facts 条件新增、到期树插入、聚合累加、枚举计数和缓存写入;TreeMap 插入主导,整次为 O(log n)。
  • 半开窗口定位并固化 k 项为 O(log n+k) 时间,字符串快照额外空间为 O(k)。
  • 确认包含一次 TreeMap 定位/删除,整次为 O(log n);事实条件替换、聚合扣减、状态迁移和缓存更新为常数或期望 O(1)。
  • 有界过期先定位严格上界,再逐项取首项并删除树节点,时间可精确写为 O(log n+m log n),常写作 O((m+1)log n);返回 m 个标签额外空间为 O(m)。maxItems 只限制一次调用的工作量,不改变总数据规模或每项 O(log n) 的树成本。
  • 仓 SKU 聚合的 get/put/remove 为期望 O(1),EnumMap 单次计数更新按 O(1) 理解,固定容量缓存的命中、移动、插入和一次淘汰为常数或期望 O(1)。facts、到期树和聚合表最坏为 O(n) 空间,固定状态表与缓存为 O(1)。

固定 OpenJDK jdk-21+35 中,HashMap 新增超过负载阈值会执行 resize 并处理旧 table 中的节点;碰撞查询还可能沿链或树继续定位。碰撞桶申请树化时,table 容量小于 MIN_TREEIFY_CAPACITY=64 会优先扩容,容量足够后才可能树化,所以期望 O(1) 不能写成单次绝对保证。

若改用 LinkedList 且不知道目标节点位置,必须先线性查找 O(n);即使取得节点后的改链是 O(1),完整“找出并删除下一到期项”仍为 O(n)。TreeMap 用比较树进行边界导航和删除,单项为 O(log n)。迭代器 fail-fast 只尽力检测部分结构修改,不提供互斥、内存可见性、复合操作原子性或一致快照,更不提供批次回滚。

第 3 题:确认先退出 HELD,两个严格批次再依次处理 Y 和 X

用 X、Y、Z 表示:

  • X:w-x/o-1/1:book@700x2
  • Y:w-y/o-1/1:book@680x3
  • Z:w-x/o-2/2:pen@680x4

缓存容量为 2,逐步状态如下:

动作 过期树顺序 access-order 缓存(左旧右新) 状态计数 HELD 聚合
预占 X [X@700] [X] HELD=1 w-x/book=2
预占 Y [Y@680,X@700] [X,Y] HELD=2 再加 w-y/book=3
预占 Z [Z@680,Y@680,X@700] [Y,Z],X 被淘汰 HELD=3 再加 w-x/pen=4
命中 Y 不变 [Z,Y] 不变 不变
确认 Z [Y@680,X@700] [Y,Z],Z 更新后最热 HELD=2, CONFIRMED=1 删除 w-x/pen
expireBatch(700,1) [X@700] [Z,Y],Y 更新后最热 HELD=1, CONFIRMED=1, EXPIRED=1 删除 w-y/book
expireBatch(701,2) [] [Y,X],X 重新进入并淘汰 Z CONFIRMED=1, EXPIRED=2 删除 w-x/book

同一分钟 680 时仓库 w-x 按字典序先于 w-y,因此 Z 排在 Y 前。先取得的 [680,701) 活视图与复制时刻快照顺序都是 [Z,Y,X]。确认 Z 后,它从 HELD 退出,数量 4 把 w-x/pen 恰好扣到零,所以聚合键必须删除而不是保留 0。

第一次批处理使用严格上界 700,X 恰在 700 因而被排除,只返回 [w-y/o-1/1] 并过期 Y。第二次使用严格上界 701,X 合格且是唯一候选,返回 [w-x/o-1/1];X 早在预占 Z 时已被缓存淘汰,过期后的新 value 插入 [Z,Y] 尾部并淘汰最老的 Z,最终缓存为 [Y,X]

最终 facts 中 X、Y 为 EXPIRED,Z 为 CONFIRMED;过期树为空,三个仓 SKU 聚合键都不存在,状态计数为 CONFIRMED=1, EXPIRED=2。旧 [680,701) 活视图当前为空,因为确认和两次过期删除都写穿根树;旧字符串快照仍为 [w-x/o-2/2:pen@680x4:HELD, w-y/o-1/1:book@680x3:HELD, w-x/o-1/1:book@700x2:HELD]

每项操作虽按单线程顺序维护五张 Map,仍不能抵御进程在中途退出。若整批要求全有或全无,就必须预校验并原子交换完整状态,或交给数据库事务;ConcurrentHashMap 的单键方法也不会让不同事实键和其他 Map 自动成为一个事务。

第 4 题:三个 TODO 的最小完整实现策略

  • 重复预占由 facts.putIfAbsent 决定;失败后立即返回,不建立重复投影,也不刷新缓存。
  • expirySnapshot 以两个边界键和四参数 subMap 表达 [fromInclusive,toExclusive),逐项核对 HELD 事实及完整排序键后固化为不可修改字符串列表。
  • confirm 对 null、缺失和终态先分流,核对“完整过期键 → 同一事实键”后删除到期项、条件替换事实、扣减聚合、迁移计数并更新缓存。
  • expireBatch 用严格上界 headMap,每轮只取当前首项并最多处理 maxItems 条;maxItems=0 空返回,负数参数在业务读写前失败。
  • 聚合扣减必须先证明当前量足够,剩余为零就删除键,不能制造负数或留下 0 映射。
  • 确认和过期写入最新 value 会使缓存项升温,已淘汰事实也可以重新进入;淘汰始终不删除 facts。
  • 批次循环和单 Map 条件 API 都不提供跨五张 Map 原子性,生产仍需共同事务或可恢复边界。

第 3 节给出与 Day 18 起始代码一致的完整 Java 21 参考实现和规定输出。

第 5 题:源码路径必须解释返回值、树序、聚合和缓存结果

固定版本为 OpenJDK jdk-21+35

问题 公开入口 关键字段 最多四个箭头节点的主路径 可观察结果或扩展点
HashMap 复合键查询、首次写入、事实替换与聚合增减 getputIfAbsentreplacemergeremove tablesizemodCount get→getNode→桶首/链/树匹配putIfAbsent→putVal→相等返回或新增replace→getNode→替换 valueremove→removeNode→摘除节点 等值复合键命中同一事实;重复不增 size;替换 value 不改键;聚合归零的显式 remove 让“缺键即零”成立
阈值、扩容高低链拆分、树化容量门槛与迭代检测 put、集合视图 iterator thresholdloadFactortablemodCountexpectedModCount putVal→超阈值→resize→高低链拆分treeifyBin→容量判断→扩容或树化nextNode→比较 modCount table 容量不足 64 时长桶优先扩容;扩容否定单次严格 O(1);fail-fast 只尽力发现结构修改
LinkedHashMap access-order 命中、已有键更新与最老项钩子 getput headtailaccessOrder get→afterNodeAccess→节点移尾putVal→afterNodeAccess/afterNodeInsertion→removeEldestEntry 命中和已有键更新改变热度;插入后子类可淘汰最老缓存项,但不影响 facts、树或聚合
TreeMap 比较定位、范围视图、当前首项与视图删除 putsubMapheadMapfirstEntryremove rootcomparatorsizemodCount、范围端点 put→逐节点 compare→插入并平衡subMap/headMap→NavigableSubMap→边界导航firstEntry→首个合法节点remove→deleteEntry→再平衡 compare==0 决定树键同一性;范围按全序遇见;视图删除写穿根树,批次顺序不来自 HashMap
EnumMap 枚举槽位与三状态迁移 getputmergeremove keyTypekeyUniversevalssize put→typeCheck→key.ordinal→写入 vals 槽位remove→ordinal 槽位→清空并减 size 同一枚举常量定位同一槽位,遇见顺序为声明顺序;业务代码负责 HELD 减一、目标态加一和归零删键

这些私有实现路径只解释固定版本现象,不能升级为 Map 的永久公共合同:不能猜红黑树具体形状,不能把内部阈值写成业务顺序,也不能把 modCount、fail-fast、普通 Map 或 ConcurrentHashMap 的单键能力扩张成线程安全或跨键、跨 Map 事务。可核对 HashMap.javaLinkedHashMap.javaTreeMap.javaEnumMap.java

3. Day 18 主练习完整可运行参考实现

下面只补全原起始代码的三个 TODO,不改变测试数据,不添加第三方依赖、日志、无恢复意义的 try/catch 或多余公开 API。嵌套类型和方法保持最小可见性;关键注释只解释事实与派生投影的一致性边界。

import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

public final class WarehouseReservationExpiryChallenge {
    private enum ReservationState {
        HELD,
        CONFIRMED,
        EXPIRED
    }

    private record ReservationKey(
            String warehouseId,
            String orderId,
            int lineNo) {
        ReservationKey {
            Objects.requireNonNull(warehouseId, "warehouseId");
            Objects.requireNonNull(orderId, "orderId");
            if (warehouseId.isBlank() || orderId.isBlank() || lineNo <= 0) {
                throw new IllegalArgumentException("invalid reservation key");
            }
        }

        String label() {
            return warehouseId + "/" + orderId + "/" + lineNo;
        }
    }

    private record StockKey(String warehouseId, String sku) {
        StockKey {
            Objects.requireNonNull(warehouseId, "warehouseId");
            Objects.requireNonNull(sku, "sku");
            if (warehouseId.isBlank() || sku.isBlank()) {
                throw new IllegalArgumentException("invalid stock key");
            }
        }
    }

    private record StockReservation(
            ReservationKey key,
            String sku,
            long expiresMinute,
            int quantity,
            ReservationState state) {
        StockReservation {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(sku, "sku");
            Objects.requireNonNull(state, "state");
            if (sku.isBlank() || expiresMinute < 0 || quantity <= 0) {
                throw new IllegalArgumentException("invalid reservation");
            }
        }

        StockKey stockKey() {
            return new StockKey(key.warehouseId(), sku);
        }

        StockReservation confirmed() {
            return new StockReservation(
                    key, sku, expiresMinute, quantity,
                    ReservationState.CONFIRMED);
        }

        StockReservation expired() {
            return new StockReservation(
                    key, sku, expiresMinute, quantity,
                    ReservationState.EXPIRED);
        }

        String snapshotLine() {
            return key.label() + ":" + sku + "@" + expiresMinute
                    + "x" + quantity + ":" + state;
        }
    }

    private record ExpiryKey(
            long expiresMinute,
            String warehouseId,
            String orderId,
            int lineNo) implements Comparable<ExpiryKey> {
        static ExpiryKey from(StockReservation reservation) {
            return new ExpiryKey(
                    reservation.expiresMinute(),
                    reservation.key().warehouseId(),
                    reservation.key().orderId(),
                    reservation.key().lineNo());
        }

        static ExpiryKey boundary(long minute) {
            return new ExpiryKey(minute, "", "", Integer.MIN_VALUE);
        }

        @Override
        public int compareTo(ExpiryKey other) {
            int byMinute = Long.compare(expiresMinute, other.expiresMinute);
            if (byMinute != 0) {
                return byMinute;
            }
            int byWarehouse = warehouseId.compareTo(other.warehouseId);
            if (byWarehouse != 0) {
                return byWarehouse;
            }
            int byOrder = orderId.compareTo(other.orderId);
            return byOrder != 0
                    ? byOrder
                    : Integer.compare(lineNo, other.lineNo);
        }
    }

    private static final class ReservationIndex {
        private static final int RECENT_LIMIT = 3;

        private final Map<ReservationKey, StockReservation> facts =
                new HashMap<>();
        private final NavigableMap<ExpiryKey, ReservationKey> expirySchedule =
                new TreeMap<>();
        private final Map<StockKey, Integer> heldQuantityBySku =
                new HashMap<>();
        private final LinkedHashMap<ReservationKey, StockReservation>
                recentCache = new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<
                                    ReservationKey,
                                    StockReservation> eldest) {
                        return size() > RECENT_LIMIT;
                    }
                };
        private final EnumMap<ReservationState, Integer> stateCounts =
                new EnumMap<>(ReservationState.class);

        private boolean reserve(StockReservation reservation) {
            Objects.requireNonNull(reservation, "reservation");
            if (reservation.state() != ReservationState.HELD) {
                throw new IllegalArgumentException(
                        "new reservation must be held");
            }
            if (facts.putIfAbsent(reservation.key(), reservation) != null) {
                return false;
            }

            // 事实新增成功后才建立派生投影;生产中需共同原子边界。
            ReservationKey previous = expirySchedule.put(
                    ExpiryKey.from(reservation), reservation.key());
            if (previous != null) {
                throw new IllegalStateException("duplicate expiry key");
            }
            heldQuantityBySku.merge(
                    reservation.stockKey(),
                    reservation.quantity(),
                    Math::addExact);
            stateCounts.merge(ReservationState.HELD, 1, Math::addExact);
            recentCache.put(reservation.key(), reservation);
            return true;
        }

        private List<String> expirySnapshot(
                long fromInclusive,
                long toExclusive) {
            if (fromInclusive < 0 || toExclusive < 0
                    || fromInclusive > toExclusive) {
                throw new IllegalArgumentException("invalid expiry window");
            }
            NavigableMap<ExpiryKey, ReservationKey> window =
                    expirySchedule.subMap(
                            ExpiryKey.boundary(fromInclusive), true,
                            ExpiryKey.boundary(toExclusive), false);
            return window.entrySet().stream()
                    .map(entry -> {
                        StockReservation reservation =
                                facts.get(entry.getValue());
                        if (reservation == null
                                || reservation.state()
                                        != ReservationState.HELD
                                || !ExpiryKey.from(reservation)
                                        .equals(entry.getKey())) {
                            throw new IllegalStateException(
                                    "expiry schedule is inconsistent "
                                            + "with facts");
                        }
                        return reservation.snapshotLine();
                    })
                    .toList();
        }

        private boolean confirm(ReservationKey key) {
            Objects.requireNonNull(key, "key");
            StockReservation current = facts.get(key);
            if (current == null || current.state() != ReservationState.HELD) {
                return false;
            }

            ExpiryKey expiryKey = ExpiryKey.from(current);
            if (!key.equals(expirySchedule.get(expiryKey))) {
                throw new IllegalStateException(
                        "expiry schedule is inconsistent with facts");
            }
            if (!expirySchedule.remove(expiryKey, key)) {
                throw new IllegalStateException(
                        "expiry schedule changed while confirming");
            }

            StockReservation confirmed = current.confirmed();
            if (!facts.replace(key, current, confirmed)) {
                throw new IllegalStateException(
                        "fact changed while confirming reservation");
            }
            // 确认会退出 HELD,聚合、计数和缓存必须随事实同步迁移。
            decreaseHeldQuantity(current);
            moveState(ReservationState.HELD, ReservationState.CONFIRMED);
            recentCache.put(key, confirmed);
            return true;
        }

        private List<String> expireBatch(
                long beforeExclusive,
                int maxItems) {
            if (beforeExclusive < 0 || maxItems < 0) {
                throw new IllegalArgumentException(
                        "expiry boundary and batch size must not be negative");
            }
            NavigableMap<ExpiryKey, ReservationKey> candidates =
                    expirySchedule.headMap(
                            ExpiryKey.boundary(beforeExclusive), false);
            List<String> expiredLabels = new ArrayList<>();
            while (expiredLabels.size() < maxItems) {
                Map.Entry<ExpiryKey, ReservationKey> first =
                        candidates.firstEntry();
                if (first == null) {
                    break;
                }

                ReservationKey key = first.getValue();
                StockReservation current = facts.get(key);
                if (current == null
                        || current.state() != ReservationState.HELD
                        || !ExpiryKey.from(current).equals(first.getKey())) {
                    throw new IllegalStateException(
                            "expiry schedule is inconsistent with facts");
                }
                if (!candidates.remove(first.getKey(), key)) {
                    throw new IllegalStateException(
                            "expiry schedule changed while expiring batch");
                }

                StockReservation expired = current.expired();
                if (!facts.replace(key, current, expired)) {
                    throw new IllegalStateException(
                            "fact changed while expiring reservation");
                }
                // 每项独立维护派生投影;整批原子性仍需更外层边界。
                decreaseHeldQuantity(current);
                moveState(ReservationState.HELD, ReservationState.EXPIRED);
                recentCache.put(key, expired);
                expiredLabels.add(key.label());
            }
            return List.copyOf(expiredLabels);
        }

        private void decreaseHeldQuantity(StockReservation reservation) {
            StockKey stockKey = reservation.stockKey();
            Integer current = heldQuantityBySku.get(stockKey);
            if (current == null || current < reservation.quantity()) {
                throw new IllegalStateException(
                        "held quantity is inconsistent with facts");
            }
            int remaining = current - reservation.quantity();
            if (remaining == 0) {
                heldQuantityBySku.remove(stockKey);
            } else {
                heldQuantityBySku.put(stockKey, remaining);
            }
        }

        private void moveState(
                ReservationState from,
                ReservationState to) {
            Integer current = stateCounts.get(from);
            if (current == null || current <= 0) {
                throw new IllegalStateException(
                        "state count is inconsistent with facts");
            }
            if (current == 1) {
                stateCounts.remove(from);
            } else {
                stateCounts.put(from, current - 1);
            }
            stateCounts.merge(to, 1, Math::addExact);
        }

        private int heldQuantity(StockKey stockKey) {
            return heldQuantityBySku.getOrDefault(
                    Objects.requireNonNull(stockKey, "stockKey"), 0);
        }

        private String recentState(ReservationKey key) {
            StockReservation reservation = recentCache.get(
                    Objects.requireNonNull(key, "key"));
            return reservation == null ? "MISS" : reservation.state().name();
        }

        private List<String> scheduleOrder() {
            return expirySchedule.entrySet().stream()
                    .map(entry -> entry.getValue().label()
                            + "@" + entry.getKey().expiresMinute())
                    .toList();
        }

        private List<String> recentOrder() {
            return recentCache.keySet().stream()
                    .map(ReservationKey::label)
                    .toList();
        }

        private List<String> countSummary() {
            return stateCounts.entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue())
                    .toList();
        }
    }

    public static void main(String[] args) {
        ReservationKey aKey = new ReservationKey("w-a", "o-7", 1);
        ReservationKey bKey = new ReservationKey("w-b", "o-7", 1);
        ReservationKey cKey = new ReservationKey("w-a", "o-8", 2);
        ReservationKey dKey = new ReservationKey("w-a", "o-9", 1);

        StockReservation a = new StockReservation(
                aKey, "book", 600, 3, ReservationState.HELD);
        StockReservation b = new StockReservation(
                bKey, "book", 580, 4, ReservationState.HELD);
        StockReservation c = new StockReservation(
                cKey, "pen", 580, 2, ReservationState.HELD);
        StockReservation d = new StockReservation(
                dKey, "book", 620, 5, ReservationState.HELD);

        ReservationIndex index = new ReservationIndex();
        System.out.println("RESERVE A=" + index.reserve(a));
        System.out.println("RESERVE B=" + index.reserve(b));
        System.out.println("RESERVE C=" + index.reserve(c));
        System.out.println("RESERVE D=" + index.reserve(d));
        System.out.println("RESERVE duplicate-A=" + index.reserve(a));
        System.out.println("CACHE initial=" + index.recentOrder());
        System.out.println("CACHE access-B=" + index.recentState(bKey));
        System.out.println("CACHE after-access=" + index.recentOrder());

        List<String> beforeChanges = index.expirySnapshot(580, 601);
        System.out.println("WINDOW before=" + beforeChanges);
        System.out.println("CONFIRM C=" + index.confirm(cKey));
        System.out.println("CACHE after-confirm=" + index.recentOrder());
        System.out.println("HELD w-a/pen="
                + index.heldQuantity(new StockKey("w-a", "pen")));
        System.out.println("COUNTS after-confirm=" + index.countSummary());

        System.out.println("EXPIRE before-580 max-2="
                + index.expireBatch(580, 2));
        System.out.println("EXPIRE before-601 max-1="
                + index.expireBatch(601, 1));
        System.out.println("CACHE after-first-expire="
                + index.recentOrder());
        System.out.println("EXPIRE before-601 max-3="
                + index.expireBatch(601, 3));
        System.out.println("CACHE after-second-expire="
                + index.recentOrder());

        System.out.println("COUNTS final=" + index.countSummary());
        System.out.println("SCHEDULE final=" + index.scheduleOrder());
        System.out.println("HELD w-a/book="
                + index.heldQuantity(new StockKey("w-a", "book")));
        System.out.println("HELD w-b/book="
                + index.heldQuantity(new StockKey("w-b", "book")));
        System.out.println("HELD w-a/pen="
                + index.heldQuantity(new StockKey("w-a", "pen")));
        System.out.println("SNAPSHOT unchanged=" + beforeChanges);
    }
}

三个待补位置的关键步骤

  1. expirySnapshot 用同一分钟内排在所有合法业务键之前的边界键构造半开视图。下界包含边界会收进下界整分钟,上界排除边界会排除上界整分钟;按树序回查 facts、核对 HELD 和完整排序键,再复制字符串。
  2. confirm 对 null、缺失和终态先分流,再证明树中完整过期键确实指向同一事实。成功路径从日历删除、以新 record 条件替换事实、扣减仓 SKU 汇总、迁移状态并更新缓存;聚合为零由既有辅助方法删键。
  3. expireBatch 用严格上界活视图反复获取当前首项,不在增强 for 中绕过迭代器修改根树。每项核对后依次删除日历项、替换事实、扣聚合、迁计数和写缓存,处理数达到上限立即停止,并复制返回标签。

精确编译与规定输出

javac --release 21 WarehouseReservationExpiryChallenge.java
java WarehouseReservationExpiryChallenge

运行输出必须为:

RESERVE A=true
RESERVE B=true
RESERVE C=true
RESERVE D=true
RESERVE duplicate-A=false
CACHE initial=[w-b/o-7/1, w-a/o-8/2, w-a/o-9/1]
CACHE access-B=HELD
CACHE after-access=[w-a/o-8/2, w-a/o-9/1, w-b/o-7/1]
WINDOW before=[w-a/o-8/2:pen@580x2:HELD, w-b/o-7/1:book@580x4:HELD, w-a/o-7/1:book@600x3:HELD]
CONFIRM C=true
CACHE after-confirm=[w-a/o-9/1, w-b/o-7/1, w-a/o-8/2]
HELD w-a/pen=0
COUNTS after-confirm=[HELD=3, CONFIRMED=1]
EXPIRE before-580 max-2=[]
EXPIRE before-601 max-1=[w-b/o-7/1]
CACHE after-first-expire=[w-a/o-9/1, w-a/o-8/2, w-b/o-7/1]
EXPIRE before-601 max-3=[w-a/o-7/1]
CACHE after-second-expire=[w-a/o-8/2, w-b/o-7/1, w-a/o-7/1]
COUNTS final=[HELD=1, CONFIRMED=1, EXPIRED=2]
SCHEDULE final=[w-a/o-9/1@620]
HELD w-a/book=5
HELD w-b/book=0
HELD w-a/pen=0
SNAPSHOT unchanged=[w-a/o-8/2:pen@580x2:HELD, w-b/o-7/1:book@580x4:HELD, w-a/o-7/1:book@600x3:HELD]

第四次预占使容量 3 的缓存从 [A,B,C] 变为 [B,C,D],只淘汰 A 的缓存副本;事实、到期项和聚合仍在。重复 A 在 putIfAbsent 失败后立即返回,不能让 A 升温;命中 B 后缓存成为 [C,D,B]。同为 580 时 w-a 的 C 先于 w-b 的 B;确认 C 把唯一的 w-a/pen=2 扣到零并删键。严格上界 580 排除 B/C;随后上界 601 的两批因上限分别只处理 B、A。旧快照已复制为字符串,所以仍全部显示 HELD

时间与空间复杂度

  • reserve 的事实和聚合写入期望 O(1),到期树插入 O(log n),计数与缓存更新为常数或期望 O(1),整次为 O(log n)。
  • expirySnapshot 的边界定位与 k 项核对、投影为 O(log n+k),稳定字符串快照额外空间为 O(k)。
  • confirm 包含一次 TreeMap 删除,事实条件替换、聚合扣减、状态迁移与缓存更新为常数或期望 O(1),整次为 O(log n)。
  • expireBatch 实际处理 m≤maxItems 项时为 O(log n+m log n) 时间,常写作 O((m+1)log n);不可修改的返回标签额外空间为 O(m)。
  • heldQuantity 和缓存命中的期望时间为 O(1),scheduleOrder 遍历剩余候选为 O(n),固定状态域和固定容量缓存按 O(1) 空间理解。
  • facts、到期树和仓 SKU 聚合总空间上界为 O(n),状态表和缓存为 O(1)。这些复杂度结论不代表最坏碰撞、线程安全或跨 Map 事务保证。

边界复核

  • 不同仓库的同订单同行号、同订单的不同行可以并存;状态、SKU、数量与分钟只属于 value。
  • 完全相同事实键的重复预占返回 false,且日程、聚合、计数和缓存热度不变。
  • 快照的负数边界或下界大于上界在读取业务 Map 前失败;[580,580) 合法且为空,返回列表不可增删。
  • confirm(null) 先失败;事实缺失或已终态返回 false 且无副作用,事实与完整过期键失配则明确失败。
  • 聚合缺失或小于待扣数量是索引不一致,不能扣成负数;减到零删除聚合键,但终态事实仍保留。
  • expireBatch(580,2) 排除恰在 580 的全部业务键;maxItems=0 空返回且无副作用,负数边界或上限先失败。
  • 同分钟以仓、订单、行号补足全序;批处理反复取当前首项,不依赖 HashMap/HashSet 顺序,也不把 fail-fast 当同步机制。
  • 缓存淘汰不删除事实、候选或汇总;确认和过期会写入最新 value,使已有项升温或让已淘汰项重新进入。
  • 批量循环和 ConcurrentHashMap 单键方法不提供跨事实键、到期树、聚合、状态及缓存的一致提交;生产必须配置共同事务或恢复策略。

4. 今日变式前的最短自检

  • 能否说明 ReservationKeyStockKeyExpiryKey 分别回答“哪笔事实”“哪个仓 SKU 小计”“谁先过期”?
  • 能否从分钟、仓、订单、行号推出完整树序,不借助 HashMap 或 HashSet 的遇见顺序?
  • 能否逐步推演容量为 2 的缓存,包括 X 被淘汰后过期时重新进入并淘汰 Z?
  • 能否区分严格上界 700、上界 701、批量上限 1/2、旧活视图和旧字符串快照?
  • 能否解释聚合扣到零为何删键,以及事实为何仍保留为 CONFIRMED/EXPIRED
  • 能否明确承认没有有效作答,所以 challenge.map 仍是 covered_unverified,今天必须继续 Map 而不能进入 Set?

如有一项含糊,先换一组租户、积分流水、冲正关系和余额榜顺序重新推演,再开始今天的“多租户积分流水冲正与余额榜索引”变式。这里的完整参考答案始终只用于教学核对,不会被记录为大大的答案证据。

返回今日索引

返回今日索引

核心讲解:积分流水冲正、余额榜重排与派生索引重建

Day 19|directionSession 17|challenge.mapcheckpoint。建议用时 31~32 分钟。Day 18 的远端拉取结果为 remote_missing,本地与当前聊天也没有有效作答,因此 challenge.map 保持 covered_unverified,方向保持 active。今天继续取得 Map 闯关证据,不能提前进入 Set;阅读本文或参考过程本身不算大大的作答。

1. 为什么需要:冲正不是删流水,而是让所有派生答案一起改变

积分系统收到“订单完成 +100”“退款 -40”等流水后,要能按流水号查事实、按账户查余额、按余额取排行榜、观察最近访问账户,并统计已入账与已冲正数量。财务审计要求原流水永久可追溯,因此冲正不能删除事实,也不能把旧金额改成 0;它只能把一条 POSTED 事实替换为同身份、同金额、状态为 REVERSED 的新 value。随后该金额不再贡献余额。

难点不在一次 put,而在一条事实同时投影到四处:余额变化后,排行榜中的旧 RankKey 必须删除,新余额非零时再插入新键;余额恰好归零时,余额表和排行榜都不保留该账户;状态计数从 POSTED 迁移到 REVERSED;最近余额缓存更新热度。若进程曾在中途失败,派生索引还要能仅凭事实重建。任何一处漏改,都可能出现“账户详情 100 分,榜单仍显示 60 分”的矛盾。

这正是 Map 阶段闯关要验证的能力:把键契约、散列表、排序树、视图与快照、专用 Map、源码路径以及外层一致性边界连成一条可推演的因果链,而不是为每张表各写一个看似正确的方法。

2. 前置知识:九项 Map 能力各自承担什么职责

  • Map 的核心合同是“一个键至多映射一个值”。事实表禁止 null 键和值,所以 get(key) == null 才能无歧义地表示缺失;余额表也禁止 null,并约定缺键等价于 0。
  • EntryKey(tenantId, entryId) 是流水身份,AccountKey(tenantId, accountId) 是账户身份。两个键都用不可变 record,让全部身份分量参与 equals/hashCode;不同租户即使复用同一流水号或账户号也不能覆盖。
  • HashMap 在散列正常时查询和更新的期望时间为 O(1),但碰撞、扩容、链表或树桶意味着不能承诺每次严格 O(1),更不能依赖迭代顺序表示排名。
  • RankKey(balance, tenantId, accountId) 的 Comparator 先按余额降序,再按租户、账户升序补足全序。Comparator 返回 0 就意味着 TreeMap 认为是同一个树键,因此绝不能只比较余额。
  • putIfAbsent 适合只有单张事实 Map 的条件写入;本题要先完成余额与计数的可失败预检,单线程示例因此先 containsKey、后统一写入。生产并发场景不能把这两步当原子操作,而要把事实和全部派生结构放进共同一致性边界。replace(key, old, new) 可表达一次性状态迁移;merge/compute 可做数值聚合,但必须处理溢出、零值删键和映射函数副作用。
  • entrySet/keySet/values 以及 TreeMap 的 headMap/subMap 都是背靠原 Map 的活视图。只读包装只是禁止调用者写入,根 Map 变化仍会穿透;历史 Top 结果必须复制成不可变快照。
  • access-order LinkedHashMap 的成功 get 与已有键 put 会把条目移到最新端,容量 3 的淘汰只影响缓存,不能删除流水、余额或榜项。
  • EnumMap 用枚举序号定位 POSTED/REVERSED 的封闭槽位;WeakHashMap 的键可能因 GC 消失,IdentityHashMap 按 == 判断,均不适合作为稳定业务事实。

这些能力覆盖 Map 九项知识:基础契约、键相等性、HashMap 结构、增删查源码、扩容树化与迭代、条件 API 和视图、LinkedHashMap、TreeMap、专用 Map。它们各有边界,不能互相冒充。

3. 定义:一张事实表、三张可重建索引和一张可丢弃缓存

今天使用以下模型:

  1. HashMap<EntryKey, PointEntry> facts 保存全部流水。PointEntry 包含事实键、账户号、非零 delta 与状态;冲正后条目仍在,只有状态由 POSTED 变为 REVERSED
  2. HashMap<AccountKey, Long> balances 只保存非零余额。某账户余额等于其全部 POSTED 流水增量之和;和为 0 时删除键。
  3. TreeMap<RankKey, AccountKey> balanceRank 对每个非零账户恰有一项。RankKey 使用余额降序,并以租户、账户补足全序;同一账户绝不能同时留下旧余额键和新余额键。
  4. 容量 3 的 access-order LinkedHashMap<AccountKey, Long> recentBalances 保存最近成功读取或更新的余额,包括刚归零后的 0。它可缺失、可淘汰,也不能作为事实来源。
  5. EnumMap<EntryState, Integer> stateCounts 统计事实状态;零计数项删除。

由此得到四条可验证不变量:balances[a] 等于 facts 中账户 a 的所有 POSTED 增量之和;每个非零余额账户在榜中恰有一项且键内余额一致;榜中每一项都能反查同一账户与余额;状态计数之和等于 facts.size()。缓存不参与这些等式,因为访问先后无法从事实恢复。

4. 心智模型:不可擦除主账、可撤销小计和可整体换新的投影

把 facts 看成审计主账:键是页码,已发布内容不能消失。balances 是按账户汇总的小计,balanceRank 是把小计重新排版后的目录,stateCounts 是主账状态的分组页,recentBalances 只是三个座位的观察窗。

发布流水时,先确认事实键从未出现,再根据账户旧余额推导新余额:删除旧榜键、更新或删除余额、为非零新余额插入新榜键,最后建立 POSTED 事实与计数并刷新缓存。真实生产代码必须让这些步骤处于共同的一致性边界;顺序只能减少暴露窗口,不能把多个 Map 变成事务。

一次性冲正先验证事实存在且仍为 POSTED,并保存旧事实、旧余额和旧榜键。新余额为 oldBalance - delta,因为撤销 +120 就减 120,撤销 -20 则加回 20。然后清理旧榜投影、写入新余额及新榜投影、把事实替换为 REVERSED、迁移计数并刷新缓存。第二次冲正看到 REVERSED 必须返回失败且零副作用。

重建不能在共享 Map 上边遍历边 clear/put,否则读者可能看到半张榜。更安全的模型是:从只读事实构造一组临时余额、临时榜和临时计数;先用 BigInteger 做与 HashMap 遍历顺序无关的精确聚合,再以 longValueExact() 验证最终余额能放入 long,确认榜项数等于非零账户数,最后在锁、事务或不可变聚合引用交换下整体发布。最近访问顺序不是事实,重建时应清空缓存,而不是伪造热度。

随堂检查 1:一笔 delta=-40 的 POSTED 流水被冲正,账户旧余额是 60,新余额是多少?能否删除事实?

**即时答案:**新余额是 60 - (-40) = 100。不能删除事实;应保留同一 EntryKey,把不可变 value 的状态替换为 REVERSED,以满足审计与幂等判断。

5. 完整数值与状态推演:两次合法冲正、一次重复请求和一次重建

使用与今日编码题不同的数据。按顺序发布五条流水:

代号 流水键 账户 delta 状态
E1 north/p-101 north/ava +120 POSTED
E2 north/p-102 north/ava -20 POSTED
E3 west/p-201 west/bo +90 POSTED
E4 north/p-301 north/cy +100 POSTED
E5 south/p-401 south/dee +60 POSTED

发布后 facts 有 5 条,POSTED=5。余额为 ava=100、bo=90、cy=100、dee=60。榜序是 [north/ava=100, north/cy=100, west/bo=90, south/dee=60]:100 分相同时由租户、账户决胜,不依赖 HashMap 的遇见顺序。缓存变化为:E1 后 [ava];E2 更新同账户仍为 [ava];E3 后 [ava,bo];E4 后 [ava,bo,cy];读取 ava 后 [bo,cy,ava];E5 后淘汰 bo,得到 [cy,ava,dee]

此时复制 Top 3 字符串快照 S=[north/ava=100,north/cy=100,west/bo=90]。它不再连接 balanceRank。

现在冲正 E1。旧 ava=100,先精确删除 RankKey(100,north,ava);新余额为 100-120=-20,因此 balances 保留 ava=-20,并插入 RankKey(-20,north,ava)。facts 中 E1 替换为 REVERSED;计数变为 POSTED=4, REVERSED=1;缓存更新 ava,使顺序由 [cy,ava,dee] 变为 [cy,dee,ava]。新榜为 [north/cy=100, west/bo=90, south/dee=60, north/ava=-20],而旧快照 S 仍保留 ava=100。

再次请求冲正 E1,状态已是 REVERSED,返回失败:五张 Map 的 size、值和缓存顺序全部不变。这是幂等结果,不是把第二次请求“再加回 120”。

接着冲正 E3。bo 的旧余额是 90,新余额 90-90=0。删除旧榜键后,不再插入新榜键,并从 balances 删除 bo;缓存记录 bo=0,新增热项导致最老的 cy 淘汰,顺序为 [dee,ava,bo]。计数变为 POSTED=3, REVERSED=2。最终榜是 [north/cy=100, south/dee=60, north/ava=-20],facts 仍有 5 条。

假设一次故障遗留了幽灵榜项 ava=100,同时丢了 cy 的榜项。重建时只读取五条 facts:E1、E3 已 REVERSED,不贡献余额;E2 得出 ava=-20,E4 得出 cy=100,E5 得出 dee=60。临时 balances 为 {ava=-20,cy=100,dee=60},再从最终小计一次性建立临时榜 [cy=100,dee=60,ava=-20],临时计数为 POSTED=3、REVERSED=2。校验通过后整体替换旧投影并清空缓存,幽灵项自然消失。注意:若边扫流水边向榜里插键,同一账户多条流水会留下多个中间余额,重建仍然错误;必须先聚合,后建榜。

随堂检查 2:为何冲正 E3 后 balances 不能保留 west/bo -> 0,而缓存却可以短暂保存 west/bo -> 0

**即时答案:**余额表定义“缺键即零、只存非零”,保留 0 会破坏可重算不变量和榜项一一对应;缓存表达最近观察到的结果,0 是合法结果,其存在与淘汰都不改变业务事实。

6. 源码映射:从公开入口走到本题可观察结果

固定基线为 Java 21、OpenJDK jdk-21+35。源码学习按“问题 → 入口 → 字段 → 主调用链 → 扩展点 → 调试练习”追踪:

问题 入口类/方法 关键字段 主调用链 扩展点 调试练习
事实为何精确命中、重复发布为何不覆盖 HashMap.get/putIfAbsent/replace/remove table,size,threshold,modCount get→getNode→hash/equalsputIfAbsent→putVal;结构删除走 removeNode record 的 equals/hashCode 决定候选节点相等性;返回值让业务判断是否首次发布或条件替换 给两个租户使用同一 entryId,观察不同复合键能并存;再重复同一键,观察 size 不增
余额聚合为何不是严格常数时间 HashMap.merge/compute 桶数组、链节点、TreeNodethreshold merge/compute→定位桶→更新或新增→必要时 resize 映射函数可表达归零删键,但不能在函数里再次结构修改同一 Map;业务要用 checked arithmetic 初始容量 16、负载因子 .75 时阈值为 12;观察第 13 个新增映射触发扩容到 32
碰撞、树化、扩容和 fail-fast 分别说明什么 HashMap.resize/treeifyBin、迭代器 nextNode TREEIFY_THRESHOLD=8MIN_TREEIFY_CAPACITY=64expectedModCount putVal→treeifyBin/resize;扩容按旧容量位拆成 low/high 链;迭代检查 modCount 这些是固定实现细节,不是 Map 公共合同,也不是线程安全机制 让哈希 6 与 22 在容量 16 时同桶,扩容后按旧容量位拆到索引 6 与 22;并在迭代期间结构写入观察异常
Top 顺序、旧榜键清理和快照如何落到树上 TreeMap.put/remove/firstEntry/entrySet root,size,modCount 与节点父子/颜色 put→compare→fixAfterInsertionremove→getEntry→deleteEntry;迭代从首节点后继推进 RankKey.compareTo 必须余额降序且身份全序;headMap/subMap 是活视图,复制后才是历史快照 在同为 100 的两个账户间断点观察比较链;删旧 100 键、插入 -20 键,再确认旧字符串列表不变
最近缓存为何会因命中重排 LinkedHashMap.get/putremoveEldestEntry head,tail,accessOrder get→afterNodeAccess;已有键写入也触发访问钩子;新增后由 afterNodeInsertion 判断淘汰 子类只定义容量策略;淘汰缓存不能回写删除事实或榜项 容量 3 依次放 A/B/C、命中 A、再放 D,观察顺序 B,C,A→C,A,D
状态计数为何适合封闭枚举 EnumMap.get/put/remove keyType,vals,size put→typeCheck→key.ordinal→数组槽位 枚举声明顺序决定遍历顺序;计数迁移仍是业务协议,不是 EnumMap 事务 观察 POSTED 计数降零后删键、REVERSED 槽位增加,验证计数和仍等于事实数

HashMap 扩容例中,size 从 12 增至 13 才越过阈值;哈希 6 与 22 的差值正好是旧容量 16,因此旧桶拆分而不必重新计算完整哈希。树化还受容量至少 64 的实现门槛约束,小表中的长链通常先扩容。上述数值只解释该固定源码版本,不能写成所有 Map 实现的永久合同。

源码直链:HashMap.javaLinkedHashMap.javaTreeMap.javaEnumMap.java

随堂检查 3:HashMap 迭代器检测到 modCount 变化并尽力抛出 ConcurrentModificationException,能否证明冲正过程线程安全?

**即时答案:**不能。fail-fast 只是尽力暴露部分结构修改,既不提供互斥、内存可见性,也不保证五张 Map 的原子提交或一致快照;即使把单表换成 ConcurrentHashMap,跨键、跨 Map 不变量仍需外层协议。

7. 真实后端应用:审计事实、查询投影和恢复流程要分层

在积分服务中,发布入口通常先凭租户与业务流水号做幂等,再写审计事实;账户详情读取 balances,榜单读取 balanceRank,recentBalances 只用于降低热点查询成本。冲正入口还需鉴权、关联原交易和记录原因,但原因属于审计事实或单独事件,不应塞进排序键。

生产一致性可选三类方案:单分区事件循环让同一账户变更串行;共同锁保护一组内存索引;或数据库事务提交事实与版本,再异步构建可重放投影。批量重建时,先锁定或取得事实版本,构建新状态,校验后一次交换;若事实版本已变化就放弃本轮重建重试。直接把五张表改成 ConcurrentHashMap 既不能让“删旧榜键 + 写余额 + 插新榜键”原子,也不能提供跨账户一致 Top。

对外 Top API 应返回 DTO 或字符串的不可变副本,并明确同分时的租户/账户次序。若把 TreeMap 活视图或条目对象直接交给序列化线程,后续冲正可能让一次响应前后不一致。缓存命中率、重建耗时和不变量校验失败可以监控,但日志不得记录完整租户敏感标识或整笔业务载荷。

8. 错误示例:看似少写几行,实际破坏四类合同

下面是典型错误思路,使用 text 表示不可采用的伪代码:

// 错误 1:只按余额比较,同分账户会互相覆盖
compare(a, b) = Long.compare(b.balance, a.balance)

// 错误 2:先改余额再用“新余额”构造删除键,旧榜项成为幽灵
balances.put(account, newBalance)
rank.remove(RankKey.from(account, balances.get(account)))

// 错误 3:冲正直接删事实,重复请求无法判断,审计链断裂
facts.remove(entryKey)

// 错误 4:只读包装被误当历史快照
result = Collections.unmodifiableMap(rank.headMap(boundary, true))

// 错误 5:换成 ConcurrentHashMap 后宣称整个冲正原子
facts.replace(...); balances.compute(...); rank.remove(...); rank.put(...)

错误 2 必须先从旧事实与旧余额构造旧榜键并精确删除,再计算、保存新投影;若删除失败,应暴露索引不一致,而不是静默叠加第二个榜项。错误 4 仍是活视图,根树变化会反映到结果。错误 5 只有若干单键操作各自具备并发语义,组合过程依然可被其他线程观察到中间状态。

9. 正确示例:独立验证全序、稳定快照和访问顺序

以下 Java 21 程序使用与今日编码题不同的租户、账户和金额,只验证合同:同分全序、旧排名键清理、不可变 Top 快照与容量 3 的访问顺序缓存。它没有实现今日练习的 topSnapshotreverserebuildDerived 三个 TODO,不能替代闯关编码。

import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

public final class PointIndexContractDemo {
    private enum EntryState {
        POSTED,
        REVERSED
    }

    private record EntryKey(String tenantId, String entryId) {
        EntryKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(entryId, "entryId");
        }
    }

    private record AccountKey(String tenantId, String accountId) {
        AccountKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(accountId, "accountId");
        }

        private String label() {
            return tenantId + "/" + accountId;
        }
    }

    private record PointEntry(
            EntryKey key,
            String accountId,
            long delta,
            EntryState state) {
        PointEntry {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(accountId, "accountId");
            Objects.requireNonNull(state, "state");
            if (delta == 0) {
                throw new IllegalArgumentException("delta must be non-zero");
            }
        }
    }

    private record RankKey(
            long balance,
            String tenantId,
            String accountId) implements Comparable<RankKey> {
        private static RankKey from(AccountKey account, long balance) {
            return new RankKey(balance, account.tenantId(), account.accountId());
        }

        @Override
        public int compareTo(RankKey other) {
            int byBalance = Long.compare(other.balance, balance);
            if (byBalance != 0) {
                return byBalance;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            return byTenant != 0
                    ? byTenant
                    : accountId.compareTo(other.accountId);
        }
    }

    private static final class RecentCache
            extends LinkedHashMap<AccountKey, Long> {
        private RecentCache() {
            super(4, 0.75f, true);
        }

        @Override
        protected boolean removeEldestEntry(Map.Entry<AccountKey, Long> eldest) {
            return size() > 3;
        }
    }

    private static List<String> rankLines(
            NavigableMap<RankKey, AccountKey> rank) {
        return rank.entrySet().stream()
                .map(entry -> entry.getValue().label()
                        + "=" + entry.getKey().balance())
                .toList();
    }

    public static void main(String[] args) {
        AccountKey x = new AccountKey("tenant-x", "zoe");
        AccountKey y = new AccountKey("tenant-y", "amy");
        AccountKey z = new AccountKey("tenant-z", "lee");
        AccountKey w = new AccountKey("tenant-x", "amy");

        Map<EntryKey, PointEntry> facts = new HashMap<>();
        facts.put(new EntryKey("tenant-x", "px-1"),
                new PointEntry(new EntryKey("tenant-x", "px-1"),
                        "zoe", 40, EntryState.POSTED));
        facts.put(new EntryKey("tenant-y", "py-1"),
                new PointEntry(new EntryKey("tenant-y", "py-1"),
                        "amy", 40, EntryState.POSTED));
        facts.put(new EntryKey("tenant-z", "pz-1"),
                new PointEntry(new EntryKey("tenant-z", "pz-1"),
                        "lee", 15, EntryState.POSTED));
        facts.put(new EntryKey("tenant-x", "px-2"),
                new PointEntry(new EntryKey("tenant-x", "px-2"),
                        "amy", -10, EntryState.POSTED));

        Map<AccountKey, Long> balances = new HashMap<>();
        balances.put(x, 40L);
        balances.put(y, 40L);
        balances.put(z, 15L);
        balances.put(w, -10L);

        NavigableMap<RankKey, AccountKey> rank = new TreeMap<>();
        balances.forEach((account, balance) ->
                rank.put(RankKey.from(account, balance), account));
        List<String> topBefore = rank.entrySet().stream()
                .limit(3)
                .map(entry -> entry.getValue().label()
                        + "=" + entry.getKey().balance())
                .toList();

        RecentCache cache = new RecentCache();
        cache.put(x, 40L);
        cache.put(y, 40L);
        cache.put(z, 15L);
        cache.get(x);

        // 独立演示一次已校验余额变化:旧榜键先删,新榜键后建。
        rank.remove(RankKey.from(z, 15));
        balances.put(z, 55L);
        rank.put(RankKey.from(z, 55), z);
        cache.put(z, 55L);
        cache.put(w, -10L);

        EnumMap<EntryState, Integer> counts =
                new EnumMap<>(EntryState.class);
        counts.put(EntryState.POSTED, facts.size());

        System.out.println("FACTS=" + facts.size());
        System.out.println("RANK after=" + rankLines(rank));
        System.out.println("TOP snapshot=" + topBefore);
        System.out.println("CACHE=" + cache.entrySet().stream()
                .map(entry -> entry.getKey().label() + "=" + entry.getValue())
                .toList());
        System.out.println("COUNTS=" + counts);
    }
}

预期输出为:

FACTS=4
RANK after=[tenant-z/lee=55, tenant-x/zoe=40, tenant-y/amy=40, tenant-x/amy=-10]
TOP snapshot=[tenant-x/zoe=40, tenant-y/amy=40, tenant-z/lee=15]
CACHE=[tenant-x/zoe=40, tenant-z/lee=55, tenant-x/amy=-10]
COUNTS={POSTED=4}

同为 40 时 tenant-x 在 tenant-y 前,证明身份补序生效;z 从 15 变 55 前先删除旧键,所以榜中只有一项;旧 Top 快照仍显示 z=15;缓存命中 x、更新 z、加入 w 后,y 被淘汰但事实仍有四条。

10. 边界总结:闯关答案必须同时守住合同、复杂度与一致性

  • 事实键、账户键均不可变且包含租户;delta、状态和余额不是事实身份。禁止 null,重复流水、缺失流水与已冲正流水必须有明确且无副作用的结果。
  • HashMap 与 HashSet 没有业务顺序合同;排名只能来自完整全序。Comparator 用 Long.compare,不能用减法造成溢出,也不能忽略租户/账户而破坏与树键身份的一致性。
  • 冲正先保存旧余额并删除旧榜键,再替换不可变事实 value 和派生投影;新余额为零时删除余额键且不建榜项。重建要先聚合全部 POSTED 事实,再建榜并校验。
  • Top 返回稳定副本。活视图、只读包装和不可变集合不是同一概念;access-order 缓存的命中和写入会改顺序,淘汰不影响事实。
  • 发布或冲正的期望成本由 HashMap 的期望 O(1) 与 TreeMap 的 O(log a) 组成,整体 O(log a);Top k 为 O(log a + k),重建 n 条事实、a 个非零账户为 O(n + a log a),空间 O(n+a)。不得把 HashMap 成本说成每次严格 O(1)。重建若直接按 HashMap 的未定义遍历顺序用 long 连续累加,可能因中间溢出得到顺序相关结果;应先用 BigInteger 精确聚合,再用 longValueExact() 校验最终范围。
  • 不知道节点位置时,LinkedList 的中间查找加删除仍为 O(n),不能借“链表删除 O(1)”偷换前提;它也不能替代 TreeMap 的排名导航。
  • fail-fast 不是线程安全;ConcurrentHashMap 也不让跨键或跨集合更新自动原子。多 Map 冲正、批量重建和一致 Top 必须由共同锁、单线程所有权、事务或不可变聚合交换覆盖。
  • WeakHashMap、IdentityHashMap 不适合审计事实;EnumMap 适合封闭状态,但状态迁移与计数守恒仍由业务协议负责。

只有大大提交完整有效答案、总分至少 80、Java 21 必交代码编译运行符合预期且六类红线均未命中,challenge.map 才能通过并进入 Set;否则继续在 Map 内补强或更换变式重闯。

返回今日索引

返回今日索引

编码闯关:多租户积分流水冲正与余额榜索引

评估项:challenge.map。建议用时:18~20 分钟。必须提交完整 Java 21 代码、成功编译证据与逐行一致的运行输出;只写思路不能通过 Map 阶段闯关。

你要为积分服务完成一个单线程流水索引。发布后的流水是审计事实,冲正时不能删除;只能把其状态从 POSTED 迁移到 REVERSED,并撤销该笔对账户余额的影响。余额榜以可变聚合值排序,因此每次余额变化都要删除旧 RankKey、更新余额,再按新余额插入新键。今天还要用不可变流水事实重建所有派生索引,而不是信任可能损坏的旧聚合。

1. 业务背景

不同租户可以都有 e-1 流水和 alice 账户,所以事实键是不可变 EntryKey(tenantId, entryId),账户键是不可变 AccountKey(tenantId, accountId)。流水的积分变化量 delta 可正可负但不能为 0;流水状态属于 value,不能改变事实身份。

五张 Map 分工如下:

  • factsHashMap<EntryKey, PointEntry>,唯一事实来源。冲正保留流水,只用新的不可变 value 标记 REVERSED
  • balancesHashMap<AccountKey, Long>,只保留非零账户余额;对应余额归零时删键。
  • balanceRankTreeMap<RankKey, AccountKey>,按余额降序,再按租户、账户升序补足全序;每个非零账户恰好有一项。
  • recentBalances:容量为 3 的 access-order LinkedHashMap;发布、命中与冲正都更新热度。缓存允许保留“最近观察到余额为 0”,但它不是余额事实表。
  • stateCountsEnumMap<EntryState, Integer>,统计 POSTED/REVERSED 流水数。

已实现的 post 用 A~E 种子流水展示同账户累计、旧榜键删除、余额归零不上榜和缓存淘汰。你恰好只补全 Top 快照、幂等单笔冲正、从事实重建派生状态三个位置。五张普通 Map 连续写入不是事务;即使重建先用临时结构算完并校验,最后替换多张 Map 仍需生产级共同锁或不可变聚合引用的原子交换。

2. 约束与验收边界

  • 基线为 Java 21,仅使用 JDK;不添加依赖、日志、调试输出或无法恢复问题的 try/catch
  • EntryKey 的租户/流水号、AccountKey 的租户/账户号都不能为空白。键进入 Map 后不可改变;null 键、null value 和 delta == 0 在边界被拒绝。
  • 新流水必须是 POSTED。已实现的单线程 post 先用完整 EntryKey 查重并完成余额、计数的可失败预检,再开始写 Map;重复发布返回 false,不重复累计、不覆盖榜项、不增加计数且不刷新缓存。并发生产代码不能把 containsKey 与后续 put 当作原子去重。
  • 每次余额改变前都要用旧余额和完整账户键核对旧 RankKey;更新时先删旧键,余额非零才插新键。
  • RankKey.compareTobalance 降序、tenantId/accountId 升序比较;合法业务键上,比较为 0 必须恰好表示余额与完整账户身份都相等。
  • topSnapshot(limit) 按榜序取前 limit 项,逐项回查余额聚合并返回稳定、不可修改的字符串列表。limit == 0 返回空快照;负数在业务读取前失败。
  • reverse(key) 仅冲正存在且状态为 POSTED 的流水。缺失或已 REVERSED 返回 false 且所有容器无副作用;成功时新余额等于旧余额减去该笔 delta
  • 冲正不删流水。成功后必须保留相同 EntryKeyREVERSED 新 value,从 POSTED 迁移计数,并把冲正后余额(包括 0)写入最近缓存。
  • rebuildDerived() 只信任 facts:先用临时 BigInteger 余额 Map 做与事实遍历顺序无关的精确聚合,再以 longValueExact() 校验最终余额范围,然后计算非零余额、榜和状态计数;校验“每个非零账户恰好一个榜项”后再替换旧派生结构。访问历史不能从事实推出,因此必须清空缓存。
  • 在线余额累加和撤销使用 Math.addExact/subtractExact 暴露溢出,重建最终余额使用 BigInteger.longValueExact() 暴露越界;不能捕获后假装成功。
  • 禁止依赖 HashMap/HashSet 顺序,破坏 equals/hashCode 或 Comparator 契约,混淆集合活视图/只读包装/快照,把 fail-fast 当线程安全,或把 ConcurrentHashMap 误当跨键、跨 Map 自动原子。

3. 目标拆解与实施顺序

  1. 阅读已实现的 post:用旧余额核对并删除旧榜键,再存新余额和新榜键;余额为 0 时两者都不保留。
  2. 完成 Top 快照:按 TreeMap 当前顺序取最多 limit 项,核对账户与余额,投影为独立字符串。
  3. 完成幂等冲正:只对 POSTED 流水执行一次撤销,保留事实、重建该账户榜项、迁移计数并更新缓存。
  4. 完成派生重建:扫描事实时只把 POSTED 流水的 delta 精确累加到临时 BigInteger,却要对两种状态都计数;最终余额经 longValueExact() 校验,归零删键,再一次性建榜并校验。
  5. 用 Java 21 编译运行,逐行核对规定输出;提交完整代码、编译证据和输出。

4. 完整可编译的 Java 21 起始代码

保存为 TenantPointReversalChallenge.java。未补代码时仍能通过编译;直接运行会完成 A~E 发布、重复去重和一次缓存命中,然后在第一个待补位置以 TODO 1: topSnapshot 明确失败。起始代码恰好只有 3 个待补位置,当天绝不提供完整实现。

import java.math.BigInteger;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

public final class TenantPointReversalChallenge {
    private enum EntryState {
        POSTED,
        REVERSED
    }

    private record EntryKey(String tenantId, String entryId) {
        EntryKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(entryId, "entryId");
            if (tenantId.isBlank() || entryId.isBlank()) {
                throw new IllegalArgumentException("invalid entry key");
            }
        }

    }

    private record AccountKey(String tenantId, String accountId) {
        AccountKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(accountId, "accountId");
            if (tenantId.isBlank() || accountId.isBlank()) {
                throw new IllegalArgumentException("invalid account key");
            }
        }

        String label() {
            return tenantId + "/" + accountId;
        }
    }

    private record PointEntry(
            EntryKey key,
            String accountId,
            long delta,
            EntryState state) {
        PointEntry {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(accountId, "accountId");
            Objects.requireNonNull(state, "state");
            if (accountId.isBlank() || delta == 0) {
                throw new IllegalArgumentException("invalid point entry");
            }
        }

        AccountKey accountKey() {
            return new AccountKey(key.tenantId(), accountId);
        }

        PointEntry reversed() {
            return new PointEntry(key, accountId, delta, EntryState.REVERSED);
        }
    }

    private record RankKey(
            long balance,
            String tenantId,
            String accountId) implements Comparable<RankKey> {
        static RankKey from(AccountKey account, long balance) {
            return new RankKey(
                    balance, account.tenantId(), account.accountId());
        }

        @Override
        public int compareTo(RankKey other) {
            int byBalance = Long.compare(other.balance, balance);
            if (byBalance != 0) {
                return byBalance;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            return byTenant != 0
                    ? byTenant
                    : accountId.compareTo(other.accountId);
        }
    }

    private static final class PointIndex {
        private static final int RECENT_LIMIT = 3;

        private final Map<EntryKey, PointEntry> facts = new HashMap<>();
        private final Map<AccountKey, Long> balances = new HashMap<>();
        private final NavigableMap<RankKey, AccountKey> balanceRank =
                new TreeMap<>();
        private final LinkedHashMap<AccountKey, Long> recentBalances =
                new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<AccountKey, Long> eldest) {
                        return size() > RECENT_LIMIT;
                    }
                };
        private final EnumMap<EntryState, Integer> stateCounts =
                new EnumMap<>(EntryState.class);

        private boolean post(PointEntry entry) {
            Objects.requireNonNull(entry, "entry");
            if (entry.state() != EntryState.POSTED) {
                throw new IllegalArgumentException(
                        "new entry must be posted");
            }
            if (facts.containsKey(entry.key())) {
                return false;
            }

            AccountKey account = entry.accountKey();
            long oldBalance = balances.getOrDefault(account, 0L);
            RankKey oldRank = RankKey.from(account, oldBalance);
            if (oldBalance != 0
                    && !account.equals(balanceRank.get(oldRank))) {
                throw new IllegalStateException(
                        "balance rank is inconsistent with balances");
            }
            long newBalance = Math.addExact(oldBalance, entry.delta());
            int newPostedCount = Math.addExact(
                    stateCounts.getOrDefault(EntryState.POSTED, 0), 1);

            // 余额变化必须先清理旧榜键,归零账户不保留余额或榜项。
            if (oldBalance != 0) {
                balanceRank.remove(oldRank);
            }
            if (newBalance == 0) {
                balances.remove(account);
            } else {
                balances.put(account, newBalance);
                AccountKey previous = balanceRank.put(
                        RankKey.from(account, newBalance), account);
                if (previous != null) {
                    throw new IllegalStateException("duplicate rank key");
                }
            }
            // 单线程题设下,所有可失败的数值检查均在首次写 Map 前完成。
            facts.put(entry.key(), entry);
            stateCounts.put(EntryState.POSTED, newPostedCount);
            recentBalances.put(account, newBalance);
            return true;
        }

        private List<String> topSnapshot(int limit) {
            if (limit < 0) {
                throw new IllegalArgumentException("limit must not be negative");
            }
            // 按榜序核对余额后,投影为稳定、不可修改的 Top 快照。
            throw new UnsupportedOperationException("TODO 1: topSnapshot");
        }

        private boolean reverse(EntryKey key) {
            // 幂等冲正保留流水事实,并重建账户的余额与榜项。
            throw new UnsupportedOperationException("TODO 2: reverse");
        }

        private void rebuildDerived() {
            // 只从事实先构建临时结构,全部校验后再替换派生状态。
            throw new UnsupportedOperationException("TODO 3: rebuildDerived");
        }

        private void moveState(EntryState from, EntryState to) {
            Integer current = stateCounts.get(from);
            if (current == null || current <= 0) {
                throw new IllegalStateException(
                        "state count is inconsistent with facts");
            }
            if (current == 1) {
                stateCounts.remove(from);
            } else {
                stateCounts.put(from, current - 1);
            }
            stateCounts.merge(to, 1, Math::addExact);
        }

        private long balance(AccountKey account) {
            return balances.getOrDefault(
                    Objects.requireNonNull(account, "account"), 0L);
        }

        private String recentBalance(AccountKey account) {
            Long balance = recentBalances.get(
                    Objects.requireNonNull(account, "account"));
            return balance == null ? "MISS" : balance.toString();
        }

        private String entryState(EntryKey key) {
            PointEntry entry = facts.get(Objects.requireNonNull(key, "key"));
            return entry == null ? "MISSING" : entry.state().name();
        }

        private int factCount() {
            return facts.size();
        }

        private List<String> rankOrder() {
            return balanceRank.entrySet().stream()
                    .map(entry -> entry.getValue().label()
                            + "=" + entry.getKey().balance())
                    .toList();
        }

        private List<String> recentOrder() {
            return recentBalances.entrySet().stream()
                    .map(entry -> entry.getKey().label()
                            + "=" + entry.getValue())
                    .toList();
        }

        private List<String> countSummary() {
            return stateCounts.entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue())
                    .toList();
        }
    }

    public static void main(String[] args) {
        EntryKey aKey = new EntryKey("tenant-a", "e-1");
        EntryKey bKey = new EntryKey("tenant-b", "e-1");
        EntryKey cKey = new EntryKey("tenant-a", "e-2");
        EntryKey dKey = new EntryKey("tenant-a", "e-3");
        EntryKey eKey = new EntryKey("tenant-c", "e-9");

        PointEntry a = new PointEntry(
                aKey, "alice", 100, EntryState.POSTED);
        PointEntry b = new PointEntry(
                bKey, "alice", 70, EntryState.POSTED);
        PointEntry c = new PointEntry(
                cKey, "bob", 60, EntryState.POSTED);
        PointEntry d = new PointEntry(
                dKey, "alice", -40, EntryState.POSTED);
        PointEntry e = new PointEntry(
                eKey, "carol", 90, EntryState.POSTED);

        AccountKey tenantAAlice = new AccountKey("tenant-a", "alice");
        AccountKey tenantABob = new AccountKey("tenant-a", "bob");
        AccountKey tenantBAlice = new AccountKey("tenant-b", "alice");

        PointIndex index = new PointIndex();
        System.out.println("POST A=" + index.post(a));
        System.out.println("POST B=" + index.post(b));
        System.out.println("POST C=" + index.post(c));
        System.out.println("POST D=" + index.post(d));
        System.out.println("POST E=" + index.post(e));
        System.out.println("POST duplicate-A=" + index.post(a));
        System.out.println("BALANCE tenant-a/alice="
                + index.balance(tenantAAlice));
        System.out.println("BALANCE tenant-b/alice="
                + index.balance(tenantBAlice));
        System.out.println("RECENT initial=" + index.recentOrder());
        System.out.println("RECENT access-bob="
                + index.recentBalance(tenantABob));
        System.out.println("RECENT after-access=" + index.recentOrder());

        List<String> beforeReversal = index.topSnapshot(3);
        System.out.println("TOP before=" + beforeReversal);
        System.out.println("REVERSE D=" + index.reverse(dKey));
        System.out.println("BALANCE tenant-a/alice="
                + index.balance(tenantAAlice));
        System.out.println("RANK after-D=" + index.rankOrder());
        System.out.println("RECENT after-D=" + index.recentOrder());

        System.out.println("REVERSE C=" + index.reverse(cKey));
        System.out.println("REVERSE C again=" + index.reverse(cKey));
        System.out.println("BALANCE tenant-a/bob="
                + index.balance(tenantABob));
        System.out.println("RANK after-C=" + index.rankOrder());
        System.out.println("RECENT after-C=" + index.recentOrder());
        System.out.println("COUNTS before-rebuild=" + index.countSummary());
        System.out.println("FACTS before-rebuild=" + index.factCount());
        System.out.println("ENTRY C=" + index.entryState(cKey));
        System.out.println("ENTRY D=" + index.entryState(dKey));

        index.rebuildDerived();
        System.out.println("REBUILD complete");
        System.out.println("RANK rebuilt=" + index.rankOrder());
        System.out.println("COUNTS rebuilt=" + index.countSummary());
        System.out.println("RECENT rebuilt=" + index.recentOrder());
        System.out.println("BALANCE tenant-a/bob="
                + index.balance(tenantABob));
        System.out.println("SNAPSHOT unchanged=" + beforeReversal);
    }
}

直接编译运行:

javac --release 21 TenantPointReversalChallenge.java
java TenantPointReversalChallenge

5. 三个待补位置的验收条件

位置 1:稳定的 Top 快照

  • 直接按 balanceRank.entrySet() 的比较顺序取最多 limit 项,不遍历 balances 或缓存后自行猜排序。
  • 每项都核对榜 value 与 RankKey 中的账户相同,余额表中的非零值与榜键余额相同;失配时明确失败。
  • 输出 tenant/account=balance 字符串并返回不可修改列表;冲正和重建不得改变旧快照。
  • limit == 0 得到空快照,大于榜大小时返回全榜,负数在业务读取前失败。

位置 2:幂等单笔冲正

  • key 为 null 时先失败;流水缺失或已为 REVERSED 时返回 false,事实、余额、榜、计数和缓存全部不变。
  • 读取该账户旧余额并核对完整旧 RankKey,先用 Math.subtractExact(oldBalance, delta) 算出新余额;所有可能失败的检查完成后才删除旧榜键并开始写 Map。
  • 用相同 EntryKeyREVERSED 新 value 替换事实;新余额为 0 时删除余额键且不插榜,否则写余额并以新 RankKey 上榜。
  • 计数从 POSTED 迁移到 REVERSED,缓存写入冲正后余额。冲正负流水 D 会把 40 分加回账户;冲正正流水 C 会使账户归零并下榜。
  • 这些步骤只在题设单线程中不受并发交错;生产中需覆盖全部 Map 的事务、共同锁或单一聚合原子替换。

位置 3:从事实重建派生索引

  • 新建临时 HashMap<AccountKey,BigInteger>EnumMap<EntryState,Integer>,遍历全部事实:两种状态都计数,只对 POSTEDBigInteger.add 精确累加 delta;不能让 HashMap 的未定义遍历顺序决定中间 long 是否溢出。
  • 扫描完后逐账户调用 longValueExact() 校验最终余额范围,并生成临时 HashMap<AccountKey,Long>;删除余额为 0 的键,再由非零余额一次性建立临时 TreeMap<RankKey,AccountKey>,检查无比较器覆盖,且榜大小等于非零账户数。
  • 只有临时结构全部计算并校验成功后,才 clear/putAll 替换余额、榜和计数;不允许一边遍历事实一边清改旧派生表。
  • 清空 recentBalances:缓存的访问历史不是流水事实,无法从 HashMap 遍历次序正确恢复。
  • 临时计算并不使最后多张 Map 的 clear/putAll 自动原子;生产可在共同锁下替换,或构造单个不可变派生状态并一次交换引用。

6. 三级提示

一级提示:Top 顺序只来自 RankKey

balances 只回答“某账户有多少分”,recentBalances 只回答“谁最近被观察”。Top 的业务顺序只由余额降序与完整账户尾字段决定,不能从 HashMap 或缓存顺序推出。

二级提示:冲正是撤销 delta,不是删流水

对于正流水,oldBalance - delta 会降低余额;对于负流水,减去负数会把分数加回。重复冲正首先看到 REVERSED 事实并返回 false,所以不会再撤销一次或刷新缓存。

三级提示:重建要“先算完,后替换”

遍历 facts.values() 的次序没有业务意义:余额来自可交换的精确加法,最终排名来自新建的 TreeMap,计数来自 EnumMap。先在临时结构完成校验,再替换旧派生表;缓存直接清空。

7. 精确预期输出

补全三个待补位置后,实际输出必须逐行一致:

POST A=true
POST B=true
POST C=true
POST D=true
POST E=true
POST duplicate-A=false
BALANCE tenant-a/alice=60
BALANCE tenant-b/alice=70
RECENT initial=[tenant-a/bob=60, tenant-a/alice=60, tenant-c/carol=90]
RECENT access-bob=60
RECENT after-access=[tenant-a/alice=60, tenant-c/carol=90, tenant-a/bob=60]
TOP before=[tenant-c/carol=90, tenant-b/alice=70, tenant-a/alice=60]
REVERSE D=true
BALANCE tenant-a/alice=100
RANK after-D=[tenant-a/alice=100, tenant-c/carol=90, tenant-b/alice=70, tenant-a/bob=60]
RECENT after-D=[tenant-c/carol=90, tenant-a/bob=60, tenant-a/alice=100]
REVERSE C=true
REVERSE C again=false
BALANCE tenant-a/bob=0
RANK after-C=[tenant-a/alice=100, tenant-c/carol=90, tenant-b/alice=70]
RECENT after-C=[tenant-c/carol=90, tenant-a/alice=100, tenant-a/bob=0]
COUNTS before-rebuild=[POSTED=3, REVERSED=2]
FACTS before-rebuild=5
ENTRY C=REVERSED
ENTRY D=REVERSED
REBUILD complete
RANK rebuilt=[tenant-a/alice=100, tenant-c/carol=90, tenant-b/alice=70]
COUNTS rebuilt=[POSTED=3, REVERSED=2]
RECENT rebuilt=[]
BALANCE tenant-a/bob=0
SNAPSHOT unchanged=[tenant-c/carol=90, tenant-b/alice=70, tenant-a/alice=60]

验收时还要解释:A 与 D 如何在 tenant-a/alice 累计成 60,而另一租户的 B 不会串账;E 发布为何淘汰 tenant-b/alice 缓存却不删余额或榜项;命中 bob 如何移动热度;冲正负流水 D 为何让 alice 从 60 重排到 100 的榜首;冲正 C 为何删除 bob 的余额键和榜项,但在最近缓存中记录 0;为何重复冲正无副作用;旧 Top 快照为何稳定;为何重建后榜和计数一致而缓存为空。

8. 复杂度要求

  • 设流水数为 n、非零账户数为 a、Top 实际返回 k。事实去重和余额定位期望 O(1),榜删除/插入 O(log a) 主导,因此成功 post/reverseO(log a)
  • Top 树首定位与 k 项投影为 O(log a + k) 时间,返回快照额外空间为 O(k);快照不背靠榜。
  • 重建扫描 n 条事实为 O(n),将 a 个非零账户插入新 TreeMap 为 O(a log a),合计 O(n + a log a) 时间。精确余额临时表还会暂存最终抵消为 0 的账户,因此额外空间按 O(n + a)、最坏 O(n) 估算,不能只写 O(a)
  • 容量 3 的 access-order 缓存命中、已有键移尾、插入与一次最老项淘汰为常数或期望 O(1)EnumMap 的键域固定。
  • HashMap 正常分布下期望 O(1) 不等于单次严格保证;碰撞、扩容和树化条件都必须在复杂度说明中保留。

9. 边界用例

补全后至少自行验证:

  1. 不同租户的同流水号/账户号可并存;完全相同的 EntryKey 重复发布返回 false 且不刷新缓存。
  2. 正、负流水都可发布;余额变为 0 时 balances 与榜都删键,但流水事实仍保留。
  3. 流水缺失或已冲正时 reverse 返回 false,不重复撤销、不移动缓存、不迁移计数。
  4. 账户当前净余额为 0 时也可冲正其中一笔 POSTED 流水;撤销后的非零余额要重新上榜。
  5. topSnapshot(0) 为空,上限超过榜大小时返回全榜,负数先失败;旧快照不随冲正或重建变化。
  6. 余额相同时依租户、账户补足全序,不会比较为 0 而覆盖另一账户;不依赖 HashMap/HashSet 遇见顺序。
  7. 事实重建与原派生状态一致,不受 facts 遍历顺序影响;重建清空访问缓存。
  8. 在线 Math.addExact/subtractExact 溢出、重建 BigInteger.longValueExact() 越界都必须失败,不吞异常或返回假成功;即使事实迭代顺序变化,最终余额的可表示性也不应改变。
  9. 普通 Map 的 fail-fast、单个 compute/merge、ConcurrentHashMap 单键操作或一组 clear/putAll 都不能代替跨事实/余额/榜/计数/缓存的原子边界。

10. 闯关提交与自检

  • 提交补全后的完整 TenantPointReversalChallenge.java,不是三个零散片段。
  • 提交 javac --release 21 成功证据与完整、逐行一致的运行输出。
  • 恰好补全 3 个 TODO,没有用 A~E 特判绕过合同。
  • 事实键与账户键都包含租户并保持不可变,冲正不删除流水。
  • 余额变化时先核对并删旧榜键,归零时余额键与榜项都不留存。
  • Top 只依赖完整 RankKey 全序,能区分榜活视图、只读包装与稳定快照。
  • 重建只信任事实,先用临时结构算完并校验,再替换派生表和清缓存。
  • 没有把 merge/compute、fail-fast、ConcurrentHashMap 或重建的 clear/putAll 称为跨 Map 事务。
  • 没有无意义 try/catch、调试日志、多余公开 API、敏感值或第三方依赖。
  • 能解释时间、空间复杂度与至少 4 个边界用例。

11. Java 21 与固定源码资料

返回今日索引

返回今日索引

Map 阶段闯关题:多租户积分流水冲正与余额榜索引

建议用时:约 8~9 分钟。请优先在 互动页面 作答,聊天提交仅作补充。当天不提供答案。 第 1~3、5 题提交结论与最短必要推理;第 4 题必须提交完整 Java 21 代码、编译证据和规定输出。

题号 固定维度 知识点 ID 分值 可能触发的红线
1 契约与选型 ds.map.contractds.map.equalityds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 25 假设 HashMap/HashSet 顺序稳定;破坏 equals/hashCode 或 Comparator;混淆流水事实、账户聚合和榜键身份
2 复杂度与状态推演 ds.map.hashmap-structureds.map.hashmap-put-get-remove-sourceds.map.hashmap-resize-treeify-iterator-sourceds.map.treemap-source 12 把期望复杂度写成严格保证;认为未知节点位置时 LinkedList 中间操作天然 O(1);把 fail-fast 当线程安全
3 复杂度与状态推演 ds.map.contractds.map.compute-merge-viewsds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 13 混淆活视图、只读包装与快照;余额归零不删键;把冲正或重建当成跨 Map 事务
4 编码 ds.map.contractds.map.equalityds.map.compute-merge-viewsds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 30 冲正删事实、漏删旧榜键、归零仍上榜、从 HashMap 顺序重建缓存;把 ConcurrentHashMap 误认为跨键/跨集合自动原子
5 源码讲解 ds.map.hashmap-structureds.map.hashmap-put-get-remove-sourceds.map.hashmap-resize-treeify-iterator-sourceds.map.compute-merge-viewsds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 20 猜测源码或红黑树形状;把实现细节当公共契约;把 fail-fast、单 Map 方法或多次 clear/putAll 当并发事务
合计 契约与选型 25;复杂度与状态推演 25;编码 30;源码讲解 20 覆盖 Map 9 个知识项 100

闯关放行必须同时满足:总分至少 80/100、第 4 题 Java 21 完整代码成功编译运行且产生规定输出、六类红线均未命中。只达到分数、只交片段、编译失败或命中任一红线,都不能进入 Set;应根据有效证据补强并重闯。

1. 契约与方案设计:不可变流水与可重建榜(25 分)

多租户积分服务要支持流水审计查询、账户余额、余额 Top、容量 3 的最近余额缓存和两状态计数。用最多 9 条定义五张 Map 的选型、事实/派生关系和不变量,并说明:EntryKeyAccountKey 各自的租户边界,null 键/value 策略,正负 delta 与冲正语义,RankKey 如何补足全序,余额归零为何不留键,access-order 缓存为何可记录 0 却不是事实,榜活视图与 Top 快照的所有权。解释为何 HashMap/HashSet 遇见顺序不能表示排名,以及 WeakHashMap、IdentityHashMap 为何不适合审计事实。

2. 复杂度与结构推演:冲正与重建的完整成本(12 分)

设流水数为 n、非零账户数为 a、Top 实际返回 k。给出事实精确查询、首次发布、冲正、Top 快照、状态计数、最近缓存和从事实重建的时间/空间复杂度。说明 HashMap 碰撞、扩容与 OpenJDK 21 树化容量门槛为何否定“每次严格 O(1)”;再比较不知账户榜节点位置时 LinkedList 查找/删除与 TreeMap 按完整旧键删除的成本,并指出 fail-fast 没有提供的互斥、可见性、原子性与一致快照性质。

3. 状态推演与代码分析:负流水撤销、归零下榜与重建(13 分)

最近缓存容量为 2。依次发布 P=t-x/j-1→anna:+80、Q=t-y/j-1→anna:+50、R=t-x/j-2→ben:+80、S=t-x/j-3→anna:-30;随后取当前 Top 3 字符串快照,依次冲正 S 与 R,最后从全部流水事实重建余额、榜和计数并清缓存。

逐步写出每次发布、两次冲正及重建后的账户余额、余额榜、最近缓存和 POSTED/REVERSED 计数;写出最终四条流水的事实状态、旧 Top 快照内容与当前榜。说明同为 80 时账户全序、同为 50 时租户尾字段、冲正负流水 S 为何让 t-x/anna 回到 80,冲正 R 为何让 t-x/ben 归零下榜,以及为何重建不能用 HashMap 遍历顺序恢复缓存。区分榜活视图、只读包装和旧字符串快照,不得把冲正或多张派生 Map 替换称为自动事务。

4. 必交编码:完成 Top 快照、幂等冲正与派生重建(30 分)

补全 编码练习 的 3 个待补位置,提交完整 TenantPointReversalChallenge.javajavac --release 21 TenantPointReversalChallenge.java 的成功结果,以及 java TenantPointReversalChallenge 的完整且逐行一致输出。再用不超过 8 句话说明重复发布、Top 稳定快照、旧榜键删除、正负流水冲正、归零删键、状态迁移、缓存 0 值与清空、BigInteger 精确重建后以 longValueExact() 校验最终范围,以及生产原子交换边界。

评分拆分:Top 榜序校验与稳定快照 7 分;幂等冲正、旧/新榜键与余额 9 分;与事实遍历顺序无关的精确临时重建、最终范围校验、计数和清缓存 8 分;Java 21 编译、规定输出和边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:用固定入口解释冲正与重建(20 分)

固定版本为 OpenJDK jdk-21+35。用 5 行表格作答,每行包含“问题、公开入口、关键字段、最多 4 个箭头节点的主路径、可观察结果或扩展点”,分别覆盖:HashMap 复合键查询、putIfAbsent、同键 value 替换、余额 get/put/remove/merge;阈值、扩容高低链拆分、树化容量条件与迭代器结构修改检测;LinkedHashMap access-order 命中、已有键更新与最老项钩子;TreeMap 的降序比较定位、旧榜键删除、新榜键插入与集合活视图;EnumMap 的枚举槽位与计数迁移。

每行 4 分。必须从源码路径回到本题的返回值、size、排名顺序、旧榜键清理、快照、缓存和重建结果;不能把方法名清单当答案,不能猜红黑树具体形状,也不能把内部阈值、fail-fast、普通 Map 或 ConcurrentHashMap 单键能力扩张成业务顺序、线程安全或跨键/跨 Map 事务。

返回今日索引

课后作答

复盘问题与编码作答

草稿保存在当前浏览器;点击保存或按 ⌘/Ctrl + S 后,才会写入当天的 04-复盘作答.md

1. 契约与方案设计:不可变流水与可重建榜(25 分)

多租户积分服务要支持流水审计查询、账户余额、余额 Top、容量 3 的最近余额缓存和两状态计数。用最多 9 条定义五张 Map 的选型、事实/派生关系和不变量,并说明:EntryKeyAccountKey 各自的租户边界,null 键/value 策略,正负 delta 与冲正语义,RankKey 如何补足全序,余额归零为何不留键,access-order 缓存为何可记录 0 却不是事实,榜活视图与 Top 快照的所有权。解释为何 HashMap/HashSet 遇见顺序不能表示排名,以及 WeakHashMap、IdentityHashMap 为何不适合审计事实。

2. 复杂度与结构推演:冲正与重建的完整成本(12 分)

设流水数为 n、非零账户数为 a、Top 实际返回 k。给出事实精确查询、首次发布、冲正、Top 快照、状态计数、最近缓存和从事实重建的时间/空间复杂度。说明 HashMap 碰撞、扩容与 OpenJDK 21 树化容量门槛为何否定“每次严格 O(1)”;再比较不知账户榜节点位置时 LinkedList 查找/删除与 TreeMap 按完整旧键删除的成本,并指出 fail-fast 没有提供的互斥、可见性、原子性与一致快照性质。

3. 状态推演与代码分析:负流水撤销、归零下榜与重建(13 分)

最近缓存容量为 2。依次发布 P=t-x/j-1→anna:+80、Q=t-y/j-1→anna:+50、R=t-x/j-2→ben:+80、S=t-x/j-3→anna:-30;随后取当前 Top 3 字符串快照,依次冲正 S 与 R,最后从全部流水事实重建余额、榜和计数并清缓存。

逐步写出每次发布、两次冲正及重建后的账户余额、余额榜、最近缓存和 POSTED/REVERSED 计数;写出最终四条流水的事实状态、旧 Top 快照内容与当前榜。说明同为 80 时账户全序、同为 50 时租户尾字段、冲正负流水 S 为何让 t-x/anna 回到 80,冲正 R 为何让 t-x/ben 归零下榜,以及为何重建不能用 HashMap 遍历顺序恢复缓存。区分榜活视图、只读包装和旧字符串快照,不得把冲正或多张派生 Map 替换称为自动事务。

4. 必交编码:完成 Top 快照、幂等冲正与派生重建(30 分)

补全 编码练习 的 3 个待补位置,提交完整 TenantPointReversalChallenge.javajavac --release 21 TenantPointReversalChallenge.java 的成功结果,以及 java TenantPointReversalChallenge 的完整且逐行一致输出。再用不超过 8 句话说明重复发布、Top 稳定快照、旧榜键删除、正负流水冲正、归零删键、状态迁移、缓存 0 值与清空、BigInteger 精确重建后以 longValueExact() 校验最终范围,以及生产原子交换边界。

评分拆分:Top 榜序校验与稳定快照 7 分;幂等冲正、旧/新榜键与余额 9 分;与事实遍历顺序无关的精确临时重建、最终范围校验、计数和清缓存 8 分;Java 21 编译、规定输出和边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:用固定入口解释冲正与重建(20 分)

固定版本为 OpenJDK jdk-21+35。用 5 行表格作答,每行包含“问题、公开入口、关键字段、最多 4 个箭头节点的主路径、可观察结果或扩展点”,分别覆盖:HashMap 复合键查询、putIfAbsent、同键 value 替换、余额 get/put/remove/merge;阈值、扩容高低链拆分、树化容量条件与迭代器结构修改检测;LinkedHashMap access-order 命中、已有键更新与最老项钩子;TreeMap 的降序比较定位、旧榜键删除、新榜键插入与集合活视图;EnumMap 的枚举槽位与计数迁移。

每行 4 分。必须从源码路径回到本题的返回值、size、排名顺序、旧榜键清理、快照、缓存和重建结果;不能把方法名清单当答案,不能猜红黑树具体形状,也不能把内部阈值、fail-fast、普通 Map 或 ConcurrentHashMap 单键能力扩张成业务顺序、线程安全或跨键/跨 Map 事务。

返回今日索引

可选:编码作答

尚未保存