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

Day 17

Map 阶段闯关变式:多租户限流策略版本切换索引的契约、重排、编码与源码解释。

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

Java 后端每日学习 · Day 17 · 2026-09-10

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

今日主题

Map 阶段闯关变式:多租户限流策略版本切换索引的契约、重排、编码与源码解释。

方向元数据

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

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

今天把重心从“首次登记后领取”移到“排序字段改变时重建派生索引”:事实身份不变,但激活分钟或优先级变化时,必须移除旧 TreeMap 键、替换不可变事实 value、再插入新排序键。这个新约束用于检验大大能否把身份、排序投影、缓存热度和跨 Map 一致性真正分开。

可验证目标

  1. 能从限流策略的复合身份、缺失语义、版本归属、激活顺序和有限状态域推出 Map 选型与事实/派生关系。
  2. 能解释为什么调整参与比较的字段不能只替换事实 value,并逐步推演旧排序投影删除、新投影插入、缓存重新进入与状态计数。
  3. 能完成 Java 21 必做编码,编译并产生规定输出,同时守住不可变复合键、比较器全序、半开范围、严格上界和稳定快照。
  4. 能沿 OpenJDK jdk-21+35 的公开入口、关键字段和主调用链解释 HashMapLinkedHashMapTreeMapEnumMap 与条件更新的可观察行为。
  5. 能明确单 Map 条件 API、fail-fast、多张普通 Map 连续写入与真正业务原子性之间的边界。

60~75 分钟学习顺序

  1. 昨日复盘:Day 16 五题完整参考答案与对账差异索引完整实现(约 12~14 分钟)。
  2. 核心讲解:用限流策略版本切换推演身份与可变排序投影的分离(约 31~32 分钟)。
  3. 编码练习:完成策略重排、窗口快照和到期激活;这是闯关通过的必交编码(约 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/hashCodeComparator 契约,不混淆活视图、只读包装与快照,也不把 fail-fast 当线程安全。

闯关放行条件

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

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

  • 固定同步助手拉取 Day 16 作答的结果为 remote_missing,服务器上没有 2026-09-09/04-复盘作答.md;本地也没有该文件。
  • Day 16 问题源哈希:sha256:abe224ecee2ff4ac40945660714e72104b91bc15fa8bd5f07ef12df85fb5ebc8
  • 当前任务中没有能明确映射到 Day 16、题号和上述问题源哈希的聊天答案。
  • 答案来源: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 16 五道闯关题完整参考答案

第 1 题:先固定事实身份,再为三个派生查询选型

  1. 事实主表使用 HashMap<DifferenceKey, ReconciliationDifference>。不可变 DifferenceKey(merchantId, ticketNo) 的两个组件共同参与 equals/hashCode;只有商户与差异单号都相同才是同一业务键,因此不同商户的 D-41 可以并存。键和值均禁止 null,本题的 get()==null 才能直接表示缺失;若未来允许 null value,必须用 containsKey 消歧。
  2. 正负 deltaCents、严重级别、截止分钟和状态都是会变化的事实属性,应放入不可变 value 的新版本,不能进入事实 key。金额为正或负都不改变“这是哪一笔差异”。
  3. 升级索引使用 TreeMap<UpgradeKey, DifferenceKey>UpgradeKey 按截止分钟升序、严重级别降序、商户升序、差异单号升序比较;合法键上 compareTo==0 恰好表示四个排序组件相同,避免同分钟差异相互覆盖。升级顺序来自这个比较合同,不能用 HashMap 或 HashSet 的遇见顺序代替。
  4. 状态计数使用 EnumMap<DifferenceState, Integer>,封闭键域按枚举声明顺序遇见;OPEN 减到零时删除槽位。诊断缓存使用容量为 3 的 access-order LinkedHashMap,命中、已有键更新或插入会影响热度;淘汰只删除缓存投影,不能删除事实或调度项。
  5. TreeMap 的 subMap/headMap 是背靠根树的活视图,适合所有者内部导航或写穿;对外报告要立即投影成独立不可变字符串快照。只读包装仍可能随原容器改变,不能冒充快照。
  6. byKey 是唯一事实来源,升级树、计数和缓存均为可校验、可重建的派生索引。四张普通 Map 的连续写入不是事务,生产环境需由单线程事件所有者、共同锁、不可变聚合交换或数据库事务覆盖完整转换。
  7. WeakHashMap 会让事实寿命受键可达性与非确定性 GC 影响;IdentityHashMap== 区分键,反序列化得到的等值新实例会查不到旧事实。二者都不符合稳定财务业务身份。

这些约束把“映射关系、升级顺序、诊断热度、状态计数”明确分开。缓存未命中只表示不在热窗口;调度项存在也必须回查事实,任何派生投影都不能被提升为第二事实来源。

第 2 题:复杂度回答完整动作,并写清退化条件

设事实表有 n 条差异,时间窗命中 k 条:

  • 复合键精确查询的期望时间为 O(1),但哈希碰撞和当前桶结构使它不是每次严格 O(1)。
  • 首次登记包含事实表条件写入、调度树插入、枚举计数和诊断缓存写入;TreeMap 插入 O(log n) 主导,整次为 O(log n)。
  • 升级最早候选需要按边界定位、删除调度树首项,并同步事实、计数和缓存;树操作主导,成功分支为 O(log n)。
  • 半开范围定位并固化 k 条字符串为 O(log n + k),快照额外空间为 O(k)。
  • EnumMap 的键域固定,单次计数读写按 O(1) 理解;容量固定为 3 的诊断缓存命中、已有键移动、插入与一次最老项淘汰的期望时间均为 O(1)。
  • 事实表和升级树随数据量增长,总索引空间为 O(n);固定状态表与有界缓存为 O(1)。

固定 OpenJDK jdk-21+35 中,新增节点超过负载阈值会触发 resize,该次操作必须处理旧 table 中的节点;碰撞查询还可能沿链或树前进。碰撞桶请求树化时,table 容量小于 MIN_TREEIFY_CAPACITY=64 会优先扩容,容量满足条件后才可能树化。因此正常分布下的期望 O(1) 不能写成单次绝对保证。

TreeMap 利用比较树定位与删除候选,单项成本为 O(log n)。若改用 LinkedList 且事先不知道目标节点位置,就必须先线性查找 O(n);即使拿到节点后链接删除可为 O(1),整次“查找并删除”仍是 O(n)。迭代器的 fail-fast 只会尽力比较结构修改计数,不提供互斥、内存可见性、原子性或一致快照,因此不能当作线程安全方案。

第 3 题:被淘汰的缓存项可重新进入,但事实始终存在

用 A、B、C、D 表示:

  • A:merchant-a/T1@900#S2
  • B:merchant-b/T1@880#S4
  • C:merchant-a/T2@880#S5
  • D:merchant-c/T9@920#S3

诊断缓存容量为 2。逐步变化如下:

  1. 登记 A:[A]
  2. 登记 B:[A,B]
  3. 登记 C:插入 C 后淘汰最久未访问的 A,得到 [B,C]
  4. 诊断访问 B:命中后 B 移到尾部,得到 [C,B]
  5. 登记 D:插入 D 后淘汰 C,得到 [B,D]
  6. 取得窗口和快照不访问诊断缓存,仍为 [B,D]
  7. 重复登记 A:事实表已存在,立即返回 false,不能借重复请求把 A 装回或升温,仍为 [B,D]

登记完成后的升级树为 [C@880#S5, B@880#S4, A@900#S2, D@920#S3][880,901) 活视图与复制时刻的快照均按 [C,B,A] 排列。执行 escalateNext(900) 时,严格上界排除截止恰为 900 的 A;同一分钟严重级别 5 高于 4,所以领取并升级 C,即 merchant-a/T2

升级后的结果为:

  • 升级索引顺序:merchant-b/T1@880#S4merchant-a/T1@900#S2merchant-c/T9@920#S3
  • 状态计数:OPEN=3ESCALATED=1
  • C 在登记 D 时已从缓存淘汰;升级把新 value 插入缓存尾部,使 [B,D,C] 超过容量,再淘汰最老的 B,最终为 [D,C],即 [merchant-c/T9, merchant-a/T2]
  • A、B、C 即便曾被缓存淘汰,仍由 byKey 保存;缓存淘汰没有事实删除语义。
  • [880,901) 活视图当前为 [B,A],因为删除 C 写穿根 TreeMap,且范围上界 901 仍包含分钟 900 的 A。
  • 旧快照仍是 [C@880#S5:OPEN, B@880#S4:OPEN, A@900#S2:OPEN],保存复制时刻的状态,不随升级改变。

事实替换、导航删除、计数迁移和缓存插入跨越四张 Map。单线程题设避免同进程内并发交错,却不能抵御进程在中途退出;putIfAbsentcomputeIfPresent 或 ConcurrentHashMap 的单键能力也不会自动覆盖其他 Map。因此真实服务必须提供共同事务或可重建投影。

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

  • 登记:先检查 difference 非空且为 OPEN,再让 byKey.putIfAbsent 决定复合键是否首次出现。重复时立即返回,三个派生投影都不能改变;首次成功后才维护升级树、计数和诊断缓存。
  • 窗口快照:用两个排在同分钟所有合法业务键之前的边界键建立 subMap(lower, true, upper, false),按比较顺序回查事实并生成字符串;回查缺失要暴露索引失配,返回结果不再背靠内部树。
  • 升级:从严格上界 headMap 中取得第一项,核对事实存在、仍为 OPEN 且完整 UpgradeKey 一致;用新的 record 替换事实,从活视图删除导航项,迁移枚举计数并将升级后的 value 写入访问顺序缓存。
  • 缓存:已有键更新会移到尾部,被淘汰键升级时会重新插入;容量钩子只影响缓存,不能操作事实或调度树。
  • 原子性:这些步骤在练习的单线程所有权中按顺序执行,不代表跨 Map 自动原子;生产仍需更外层共同边界。

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

第 5 题:源码路径必须解释本题的公开现象

固定版本为 OpenJDK jdk-21+35

问题 公开入口 关键字段 最多四个箭头节点的主路径 可观察结果或扩展点
HashMap 精确查询与新键、同键更新、删除 getputremove tablesizemodCount get→getNode→桶首/链/树匹配put→putVal→相等替换或新增remove→removeNode→摘链/树删除 等值键更新返回旧值且 size 不增;新增和成功结构删除改变结构;遇见顺序无保证
阈值、扩容高低链拆分、树化条件与迭代检测 put、集合视图的 iterator thresholdloadFactortablemodCountexpectedModCount putVal→size 超阈值→resize→按旧容量位拆高低链treeifyBin→容量判断→扩容或树化nextNode→比较 modCount 容量不足 64 时长链优先扩容;快速失败仅尽力检测结构修改,不提供线程安全
LinkedHashMap access-order 命中、已有键更新与淘汰钩子 getput headtailaccessOrder get→afterNodeAccess→节点移尾putVal→afterNodeAccess/afterNodeInsertion→removeEldestEntry 成功命中和已有键更新会改变热度;插入后子类可淘汰最老项,但不会影响另一张事实表
TreeMap 比较定位、范围视图与写穿边界 putsubMapheadMap rootsizecomparator、范围端点 put→逐节点 compare→替换或插入并平衡subMap/headMap→NavigableSubMap→边界导航/写穿 compare==0 视为同一排序键;视图按比较顺序遇见,删除写穿根树,越界写入失败
EnumMap 枚举键域表示与计数更新 putgetmerge keyTypekeyUniversevalssize put→typeCheck→key.ordinal→maskNull 后写槽位 遇见顺序为枚举声明顺序;同一常量使用同一槽位,null key 被拒绝,null value 由内部哨兵区分

这些路径要收束回返回值、size、比较顺序、范围和状态结果。私有节点形状、树化阈值、当前打印顺序及 fail-fast 都不能升级为业务排序合同、线程安全或跨键、跨 Map 事务。可核对 HashMap.javaLinkedHashMap.javaTreeMap.javaEnumMap.java

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

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

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 ReconciliationDifferenceChallenge {
    private enum DifferenceState {
        OPEN,
        ESCALATED
    }

    private record DifferenceKey(String merchantId, String ticketNo) {
        DifferenceKey {
            Objects.requireNonNull(merchantId, "merchantId");
            Objects.requireNonNull(ticketNo, "ticketNo");
            if (merchantId.isBlank() || ticketNo.isBlank()) {
                throw new IllegalArgumentException(
                        "merchantId and ticketNo must not be blank");
            }
        }

        String label() {
            return merchantId + "/" + ticketNo;
        }
    }

    private record ReconciliationDifference(
            DifferenceKey key,
            long dueMinute,
            int severity,
            long deltaCents,
            DifferenceState state) {
        ReconciliationDifference {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(state, "state");
            if (dueMinute < 0
                    || severity < 1
                    || severity > 5
                    || deltaCents == 0) {
                throw new IllegalArgumentException(
                        "invalid reconciliation difference");
            }
        }

        ReconciliationDifference escalated() {
            return new ReconciliationDifference(
                    key,
                    dueMinute,
                    severity,
                    deltaCents,
                    DifferenceState.ESCALATED);
        }

        String snapshotLine() {
            return key.label() + "@" + dueMinute
                    + "#S" + severity + ":" + state + ":" + deltaCents;
        }
    }

    private record UpgradeKey(
            long dueMinute,
            int severity,
            String merchantId,
            String ticketNo) implements Comparable<UpgradeKey> {
        static UpgradeKey from(ReconciliationDifference difference) {
            return new UpgradeKey(
                    difference.dueMinute(),
                    difference.severity(),
                    difference.key().merchantId(),
                    difference.key().ticketNo());
        }

        static UpgradeKey boundary(long minute) {
            return new UpgradeKey(
                    minute,
                    Integer.MAX_VALUE,
                    "",
                    "");
        }

        @Override
        public int compareTo(UpgradeKey other) {
            int byMinute = Long.compare(dueMinute, other.dueMinute);
            if (byMinute != 0) {
                return byMinute;
            }
            int bySeverity = Integer.compare(other.severity, severity);
            if (bySeverity != 0) {
                return bySeverity;
            }
            int byMerchant = merchantId.compareTo(other.merchantId);
            return byMerchant != 0
                    ? byMerchant
                    : ticketNo.compareTo(other.ticketNo);
        }
    }

    private static final class DifferenceIndex {
        private static final int DIAGNOSTIC_LIMIT = 3;

        private final Map<DifferenceKey, ReconciliationDifference> byKey =
                new HashMap<>();
        private final NavigableMap<UpgradeKey, DifferenceKey> upgradeSchedule =
                new TreeMap<>();
        private final EnumMap<DifferenceState, Integer> stateCounts =
                new EnumMap<>(DifferenceState.class);
        private final LinkedHashMap<
                DifferenceKey, ReconciliationDifference> diagnostics =
                new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<
                                    DifferenceKey,
                                    ReconciliationDifference> eldest) {
                        return size() > DIAGNOSTIC_LIMIT;
                    }
                };

        private boolean register(ReconciliationDifference difference) {
            Objects.requireNonNull(difference, "difference");
            if (difference.state() != DifferenceState.OPEN) {
                throw new IllegalArgumentException(
                        "new difference must be open");
            }
            if (byKey.putIfAbsent(difference.key(), difference) != null) {
                return false;
            }

            // 事实登记成功后才维护投影;生产环境需覆盖整段的原子边界。
            upgradeSchedule.put(
                    UpgradeKey.from(difference),
                    difference.key());
            stateCounts.merge(DifferenceState.OPEN, 1, Integer::sum);
            diagnostics.put(difference.key(), difference);
            return true;
        }

        private List<String> dueSnapshot(
                long fromInclusive,
                long toExclusive) {
            if (fromInclusive > toExclusive) {
                throw new IllegalArgumentException(
                        "fromInclusive must be <= toExclusive");
            }
            NavigableMap<UpgradeKey, DifferenceKey> window =
                    upgradeSchedule.subMap(
                            UpgradeKey.boundary(fromInclusive), true,
                            UpgradeKey.boundary(toExclusive), false);
            return window.values().stream()
                    .map(key -> Objects.requireNonNull(
                            byKey.get(key),
                            "upgrade schedule must reference an existing fact"))
                    .map(ReconciliationDifference::snapshotLine)
                    .toList();
        }

        private String escalateNext(long beforeExclusive) {
            if (beforeExclusive < 0) {
                throw new IllegalArgumentException(
                        "beforeExclusive must be >= 0");
            }
            NavigableMap<UpgradeKey, DifferenceKey> candidates =
                    upgradeSchedule.headMap(
                            UpgradeKey.boundary(beforeExclusive), false);
            Map.Entry<UpgradeKey, DifferenceKey> first =
                    candidates.firstEntry();
            if (first == null) {
                return "NONE";
            }

            DifferenceKey key = first.getValue();
            ReconciliationDifference current = byKey.get(key);
            if (current == null
                    || current.state() != DifferenceState.OPEN
                    || !UpgradeKey.from(current).equals(first.getKey())) {
                throw new IllegalStateException(
                        "upgrade schedule is inconsistent with fact map");
            }

            ReconciliationDifference escalated = byKey.computeIfPresent(
                    key,
                    (ignored, existing) -> existing.escalated());
            // 删除子视图条目会写穿根树,随后同步计数和诊断投影。
            candidates.remove(first.getKey());
            int open = stateCounts.merge(
                    DifferenceState.OPEN, -1, Integer::sum);
            if (open == 0) {
                stateCounts.remove(DifferenceState.OPEN);
            }
            stateCounts.merge(
                    DifferenceState.ESCALATED,
                    1,
                    Integer::sum);
            diagnostics.put(key, escalated);
            return key.label();
        }

        private String deltaOf(DifferenceKey key) {
            ReconciliationDifference difference = byKey.get(
                    Objects.requireNonNull(key, "key"));
            return difference == null
                    ? "MISSING"
                    : Long.toString(difference.deltaCents());
        }

        private String diagnosticState(DifferenceKey key) {
            ReconciliationDifference difference = diagnostics.get(
                    Objects.requireNonNull(key, "key"));
            return difference == null ? "MISS" : difference.state().name();
        }

        private List<String> scheduleOrder() {
            return upgradeSchedule.entrySet().stream()
                    .map(entry -> entry.getValue().label()
                            + "@" + entry.getKey().dueMinute()
                            + "#S" + entry.getKey().severity())
                    .toList();
        }

        private List<String> diagnosticOrder() {
            return diagnostics.keySet().stream()
                    .map(DifferenceKey::label)
                    .toList();
        }

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

    public static void main(String[] args) {
        DifferenceKey merchantAFirst =
                new DifferenceKey("merchant-a", "D-41");
        DifferenceKey merchantBFirst =
                new DifferenceKey("merchant-b", "D-41");
        DifferenceKey merchantASecond =
                new DifferenceKey("merchant-a", "D-42");
        DifferenceKey merchantDFirst =
                new DifferenceKey("merchant-d", "D-9");

        ReconciliationDifference first = new ReconciliationDifference(
                merchantAFirst, 720, 2, 1_500, DifferenceState.OPEN);
        ReconciliationDifference second = new ReconciliationDifference(
                merchantBFirst, 700, 4, -800, DifferenceState.OPEN);
        ReconciliationDifference third = new ReconciliationDifference(
                merchantASecond, 700, 5, 250, DifferenceState.OPEN);
        ReconciliationDifference fourth = new ReconciliationDifference(
                merchantDFirst, 740, 3, 4_000, DifferenceState.OPEN);

        DifferenceIndex index = new DifferenceIndex();
        System.out.println("REGISTER first=" + index.register(first));
        System.out.println("REGISTER second=" + index.register(second));
        System.out.println("REGISTER third=" + index.register(third));
        System.out.println("REGISTER fourth=" + index.register(fourth));
        System.out.println("DIAG evicted-first="
                + index.diagnosticState(merchantAFirst));
        System.out.println("DIAG access-second="
                + index.diagnosticState(merchantBFirst));
        System.out.println("REGISTER duplicate=" + index.register(first));
        System.out.println("BY-KEY merchant-a/D-41="
                + index.deltaOf(merchantAFirst));
        System.out.println("BY-KEY merchant-b/D-41="
                + index.deltaOf(merchantBFirst));
        System.out.println("DIAG before=" + index.diagnosticOrder());

        List<String> beforeEscalation = index.dueSnapshot(700, 721);
        System.out.println("WINDOW before=" + beforeEscalation);
        System.out.println("COUNTS before=" + index.countSummary());
        System.out.println("ESCALATE before-700="
                + index.escalateNext(700));
        System.out.println("ESCALATE before-720="
                + index.escalateNext(720));
        System.out.println("COUNTS after=" + index.countSummary());
        System.out.println("SCHEDULE after=" + index.scheduleOrder());
        System.out.println("DIAG after=" + index.diagnosticOrder());
        System.out.println("SNAPSHOT unchanged=" + beforeEscalation);
    }
}

三个待补位置的关键步骤

  1. register 在任何 Map 写入前校验非空和 OPEN,再让事实表的 putIfAbsent 决定复合键是否首次出现。重复时立刻返回,避免调度覆盖、重复计数或借重复请求刷新缓存;首次成功后才维护三个派生投影。
  2. dueSnapshot 用同一分钟内排在所有合法差异键之前的边界键表达半开区间。下界包含边界会收进下界分钟,上界排除边界会排除上界整分钟;按树顺序回查事实并生成字符串,返回列表不再背靠内部 Map。
  3. escalateNext 在严格上界活视图中读取第一项,核对事实状态与完整导航键,再用新 record 替换事实。子视图删除写穿根树,计数从 OPEN 迁移到 ESCALATED;将新 value 写入 access-order 缓存会移动已有键或让已淘汰键重新进入。

精确编译与规定输出

javac --release 21 ReconciliationDifferenceChallenge.java
java ReconciliationDifferenceChallenge

运行输出必须为:

REGISTER first=true
REGISTER second=true
REGISTER third=true
REGISTER fourth=true
DIAG evicted-first=MISS
DIAG access-second=OPEN
REGISTER duplicate=false
BY-KEY merchant-a/D-41=1500
BY-KEY merchant-b/D-41=-800
DIAG before=[merchant-a/D-42, merchant-d/D-9, merchant-b/D-41]
WINDOW before=[merchant-a/D-42@700#S5:OPEN:250, merchant-b/D-41@700#S4:OPEN:-800, merchant-a/D-41@720#S2:OPEN:1500]
COUNTS before=[OPEN=4]
ESCALATE before-700=NONE
ESCALATE before-720=merchant-a/D-42
COUNTS after=[OPEN=3, ESCALATED=1]
SCHEDULE after=[merchant-b/D-41@700#S4, merchant-a/D-41@720#S2, merchant-d/D-9@740#S3]
DIAG after=[merchant-d/D-9, merchant-b/D-41, merchant-a/D-42]
SNAPSHOT unchanged=[merchant-a/D-42@700#S5:OPEN:250, merchant-b/D-41@700#S4:OPEN:-800, merchant-a/D-41@720#S2:OPEN:1500]

两个商户的 D-41 同时存在,证明复合身份没有串商户;正负差额保留原符号。同为 700 分钟时 S5 先于 S4;严格上界 720 排除截止恰为 720 的差异。第一条虽被缓存淘汰仍能从事实表查到;命中第二条和升级第三条分别改变访问热度;旧快照继续保留升级前的 OPEN

时间与空间复杂度

  • register 的事实表条件写入期望 O(1),调度树插入 O(log n),计数与缓存更新为常数或期望 O(1),整次为 O(log n)。
  • dueSnapshot 的边界定位与 k 项投影为 O(log n + k),稳定字符串快照额外空间为 O(k)。
  • escalateNext 的候选定位和树删除为 O(log n),事实替换、计数迁移和缓存更新为常数或期望 O(1),整次为 O(log n)。
  • deltaOf 与诊断命中的期望时间为 O(1);scheduleOrder 遍历剩余候选为 O(n);固定容量缓存输出与固定枚举摘要按 O(1) 理解。
  • 事实表与升级树的总空间为 O(n),固定状态表和有界缓存为 O(1)。这些结论不等于最坏碰撞、并发安全或跨 Map 事务保证。

边界复核

  • 不同商户的同号差异并存,正负差额均为 value;完全相同复合键重复登记返回 false,且不能刷新缓存热度。
  • [720,720) 返回空快照;下界大于上界必须在读取或写入业务状态前失败。
  • escalateNext(700) 返回 NONE;严格上界 720 不包含截止恰为 720 的差异。
  • 分钟和严重级别相同时,商户与单号仍补足全序;比较为 0 不能误覆盖不同差异。
  • 快照列表不可增删,升级不会改写旧字符串;导航活视图则跟随根树删除。
  • 缓存未命中不代表事实缺失;淘汰不删除事实或候选,升级已淘汰键时允许它重新进入缓存。
  • 生产环境需要覆盖四张 Map 的共同所有权、锁、事务或补偿边界;fail-fast 与单 Map 条件 API 都不能替代它。

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

  • 能否说明正负差额为何属于 value,而商户和单号为何组成稳定事实 key?
  • 能否从截止分钟、严重级别、商户和单号推出全序,不借助 HashMap 或 HashSet 的遇见顺序?
  • 能否逐步推演容量为 2 的 access-order 缓存,包括已淘汰 C 在升级时重新进入并淘汰 B?
  • 能否区分严格上界 900、范围上界 901、旧活视图和旧不可变快照的结果?
  • 能否比较 TreeMap 的 O(log n) 候选定位与未知节点位置时 LinkedList 的 O(n) 查找删除?
  • 能否明确承认没有有效作答,所以 challenge.map 仍是 covered_unverified,今天必须继续 Map 而不能进入 Set?

如有一项含糊,先换一组租户策略键、版本生效时间和重排动作重新推演,再开始今天的限流策略变式。这里的完整参考答案始终只用于教学核对,不会被记录为大大的答案证据。

返回今日索引

返回今日索引

核心讲解:多租户限流策略版本切换中的 Map 合同与重排

Day 17|directionSession 15|challenge.mapcheckpoint。建议用时 31~32 分钟。Day 16 没有有效作答,因此本课用于继续闯关取证:challenge.map 仍是 covered_unverified,方向仍为 active;阅读参考过程本身不等于通过。

1. 为什么需要:策略事实稳定,激活顺序却会改变

网关控制台允许不同租户都定义名为 api-core 的限流策略,同一租户也会同时保存多个候选版本。平台既要按“租户 + API + 版本”精确找到事实,又要按切换分钟和优先级挑选下一条待激活策略,还要保存最近预览的三个策略以及 SCHEDULED/ACTIVE 数量。真正困难的不是选出四种 Map,而是处理计划改变:运营人员可能把某个已提交版本从 15:00 提前到 14:30,并把优先级从 2 调成 4。

如果把可变的切换分钟直接放进事实键,改时间就等于改身份;如果把一个可变对象直接作为 TreeMap 键,字段改变后对象留在原树路径,比较器却认为它应在另一条路径,查找、删除和迭代会互相矛盾。可靠做法是把两个空间分开:事实键终身稳定,排序键只是当前事实的一份不可变投影。投影字段一旦变化,必须显式删除旧投影、替换不可变事实 value、插入新投影。

这也解释了今天为什么不能进入 Set:Map 闯关尚未获得“至少 80 分、Java 21 编码成功、无红线”的证据。第 14 次会话应有的阶段复习不能越过未通过的 Map 门禁。

2. 前置知识:先把九项 Map 能力放回各自边界

  • Map 的核心是键到值的映射合同。若允许 null value,get(key)==null 不能区分“缺键”和“存在但值为 null”;本题在边界拒绝 null 键和值,让 null 唯一表示缺失。
  • equals/hashCode 决定 HashMap 中的业务同一性。PolicyKey(tenantId, apiCode, version) 三个字段都不可变并共同参与相等判断,才能避免跨租户或跨版本覆盖。
  • HashMap 用桶定位事实,正常分布下精确访问期望 O(1),但扩容、碰撞链和树桶意味着单次并非严格 O(1),遇见顺序也不是业务合同。
  • TreeMap 用 Comparator 定义排序键的同一性与全序。比较为 0 就会被当成同一个树键,所以分钟、优先级、租户、策略编码都必须进入比较。
  • putIfAbsentcomputemerge 只约束当前 Map 的当前调用;映射函数还应避免修改同一 Map。它们不会自动覆盖另一张树、计数表或数据库。
  • keySet/values/entrySetsubMap/headMap 是背靠根容器的视图。Collections.unmodifiableMap 只是只读包装;跨边界需要历史快照时应复制数据,例如投影成字符串后用 toList() 固化。
  • insertion-order LinkedHashMap 表示登记先后,access-order LinkedHashMap 表示成功访问热度。容量限制是缓存策略,不是事实删除策略。
  • EnumMap 适合封闭状态域,按枚举声明顺序遇见键;WeakHashMap 受键可达性和 GC 影响,IdentityHashMap 用 ==,都不适合作为稳定业务事实表。

随堂检查 1:两个租户都有 api-core/v1,同一租户又有 v2,能否只用 apiCode 做事实键,再依靠 TreeMap 补出租户和版本?

**即时答案:**不能。事实表会先把多条策略覆盖成一条,后续索引无法恢复被覆盖事实。身份必须是不可变的 (tenantId, apiCode, version) 复合键。

3. 定义:四张 Map、两个键空间和一个共同不变量

设稳定事实键为 PolicyKey(tenantId, apiCode, version);设激活排序键为 ActivationKey(activationMinute, priority, tenantId, apiCode, version),比较顺序是切换分钟升序、优先级降序、租户升序、API 升序、版本升序。合法键上 compareTo==0 恰好意味着五个组件都相同。

四张 Map 的职责如下:

  1. HashMap<PolicyKey, PolicyVersion> byKey 是唯一事实来源。value 是不可变 record,保存切换分钟、优先级、限流值和状态;版本属于稳定事实键。
  2. TreeMap<ActivationKey, PolicyKey> activationSchedule 只保存 SCHEDULED 候选,是可重建的排序投影。
  3. 容量为 3 的 access-order LinkedHashMap<PolicyKey, PolicyVersion> previews 是诊断预览缓存;命中或已有键更新会移到尾部,淘汰只影响缓存。
  4. EnumMap<PolicyState, Integer> stateCounts 是状态计数投影,枚举声明顺序为 SCHEDULED, ACTIVE

核心不变量是:每个待激活事实必须恰好对应一条由当前 value 计算出的 TreeMap 项;每条 TreeMap 项必须能回查同一个事实键;计数等于事实状态的聚合;缓存可以缺项,但缓存中的 value 不应冒充事实来源。四个容器一起满足不变量,任何单个容器“写成功”都不能证明整次版本切换成功。

4. 心智模型:主账、日程卡、三席预览台、状态牌

byKey 想成主账:tenant-a/search/v7 无论何时切换,身份都不改变;若另有 v8,它是另一行事实。TreeMap 像按时间排序的日程卡,每张卡抄写当前切换分钟和优先级;改计划不是在卡片入树后拿笔涂字段,而是取出旧卡、生成新事实 value、放入一张新卡。预览缓存只有三个座位,谁最近成功预览或被更新,谁坐到末席;被挤走只代表不再缓存。EnumMap 是状态牌,只汇总主账状态。

因此一次重排的逻辑顺序是:先从主账取得旧 value,并由它重建 ActivationKey;确认旧日程卡存在且指向同一事实键;构造新的不可变 value;在同一外层一致性边界内删除旧卡、替换主账、插入新卡,再更新预览投影。若先改主账再从新 value 计算“旧键”,删除会落空,旧卡将成为幽灵候选。

随堂检查 2:旧计划是 900#P2,新计划是 870#P4。应该用哪个键调用 activationSchedule.remove?删除返回 null 又意味着什么?

**即时答案:**必须用旧 value 计算出的 900#P2 完整键。返回 null 表示事实与派生索引已经失配,不能若无其事地再插新键;应在共同原子边界内失败、回滚或触发重建/补偿。

5. 完整数值与状态推演:提前切换怎样让树真正重排

假设四条待切换策略为:

  • A:tenant-a/search/v7,切换 900 分钟,P2,100 次/秒。
  • B:tenant-b/search/v3,切换 880 分钟,P5,50 次/秒。
  • C:tenant-a/report/v11,切换 880 分钟,P1,20 次/秒。
  • D:tenant-c/pay/v2,切换 930 分钟,P3,200 次/秒。

四次首次登记后,主账大小为 4,状态计数为 {SCHEDULED=4}。TreeMap 比较顺序为 [B@880#P5, C@880#P1, A@900#P2, D@930#P3]:B、C 同分钟时优先级高的 B 在前,身份尾字段又保证任意同分钟同优先级策略仍可并存。若预览缓存依次放入 A、B、C,随后命中 B,再放入 D,热度变化是 [A,B,C]→[A,C,B]→[C,B,D];A 被淘汰却仍存在于主账和日程树。

此时取得覆盖 B 到旧 A 的导航活视图,并固化字符串快照,两者最初都是 [B,C,A]。现在运营保持 A 的事实身份 tenant-a/search/v7 不变,把计划改为 870 分钟、P4;限额仍为 100 次/秒。正确转换逐步为:

  1. 以稳定 PolicyKey 从主账读出旧 A,重建旧键 A@900#P2
  2. 从树中删除旧键,返回值必须是 A;树暂为 [B,C,D]
  3. 新建 value A(870,P4,100,SCHEDULED),替换主账旧 value。相等事实键更新不增加 HashMap 的 size;版本没有被偷偷改成另一条身份。
  4. 从新 value 生成 A@870#P4 并插树,树变为 [A,B,C,D];这才是真正的重排。
  5. 用新 value 更新预览。A 原先已被淘汰,所以插入末尾并挤走 C,缓存成为 [B,D,A];状态仍是 SCHEDULED=4

重排后,旧导航活视图变为 [B,C]:旧 A 已删除,新 A 又落在视图下界之前。旧字符串快照仍为 [B,C,A@900#P2],因为它拥有复制时刻的数据。接着执行“只激活分钟严格小于 880 的首项”,A@870 被选中,B、C@880 都因严格上界而排除。A 从树删除,主账以 ACTIVE 新 value 替换,计数变为 {SCHEDULED=3, ACTIVE=1},缓存更新已有 A 仍把它置于尾部 [B,D,A]。最终树为 [B,C,D]

复杂度也由整次操作推导:重排含一次 TreeMap 删除和一次插入,为 O(log n),HashMap 与缓存访问通常为期望 O(1),EnumMap 为固定键域常数规模,因此整次仍由树操作主导为 O(log n)。复制包含 k 项的窗口是 O(log n+k) 时间和 O(k) 额外空间。四张 Map 的常数次调用不会神奇地变成事务。

反例推演同样重要:若先把主账 A 替换成 870/P4,再用“当前 A”计算要删除的键,就会尝试删除尚未存在的 A@870#P4;随后插入它,树同时留下 A@900#P2A@870#P4。下一次领取旧卡会回查到新事实,导航键与事实不符。这个错误不是排序偶然性,而是不变量已经被破坏。

6. 源码映射:从固定入口解释可观察结果

固定基线是 Java 21、OpenJDK jdk-21+35。阅读源码遵循“问题 → 入口类/方法 → 关键字段 → 主调用链 → 扩展点 → 调试练习”,私有实现只用于解释这一版本的 API 现象,不能升级为业务合同。

6.1 HashMap:相等键为何替换,扩容时碰撞节点为何可能分开

  • **问题:**等值的新 PolicyKey 为什么返回旧 value 且 size 不增?
  • 入口类/方法:HashMap.get/put/remove关键字段:table、size、threshold、loadFactor、modCount
  • 主调用链:get→getNode→桶首/链/树匹配put→putVal→相等键替换或新增;新增后超过阈值再进入 resizeremove→removeNode→摘除节点
  • **扩展点:**容量 8、阈值 6、size 5 时,新增第 6 个键不扩容;相等键替换仍是 6;再新增第 7 个才超过阈值并扩到 16。散列值低位索引同为 3、但一个带旧容量位 8 的两节点,在新表中可留在 3 或移动到 11。桶达到树化请求条件但表容量小于 64 时,固定实现优先扩容而不是立即树化。
  • **调试练习:**断点观察相等键更新前后 size/modCount,再观察新增节点触发 resize;不要从 table 迭代结果推导业务顺序。

6.2 TreeMap:为什么只能“删旧键再插新键”

  • **问题:**比较字段变化时,怎样保持红黑树搜索路径与全序一致?
  • 入口类/方法:TreeMap.put/remove/subMap/headMap关键字段:root、size、comparator、modCount 与范围端点。
  • 主调用链:put→沿 comparator 逐节点下降→compare==0 替换或新增→插入平衡remove→按旧键比较定位→删除节点→删除平衡;范围方法返回带边界检查、写穿根树的导航视图。
  • **扩展点:**优先级降序可用 Integer.compare(other.priority, priority),而不能用相减;尾部租户与策略编码防止不同策略比较为 0。已入树键必须不可变。
  • **调试练习:**在删除旧 A 前后检查 remove 返回值和树序列;故意用新键删除一次,观察旧项为何仍能迭代却无法代表当前事实。

6.3 LinkedHashMap、EnumMap 与视图:顺序和所有权从哪里来

  • **问题:**为什么成功预览会改缓存顺序,状态表又为何按枚举声明顺序输出?
  • 入口类/方法:LinkedHashMap.get/putEnumMap.get/put/merge、Map 的集合视图;**关键字段:**前者的 head、tail、accessOrder,后者的 keyType、keyUniverse、vals、size
  • **主调用链:**访问顺序表中 get→getNode→afterNodeAccess→命中节点移到尾部;插入后进入 afterNodeInsertion,子类可用 removeEldestEntry 淘汰最老项。EnumMap 先校验枚举类型,再由 ordinal 定位数组槽。迭代器以 expectedModCount 对照 modCount,只能尽力发现结构修改误用。
  • 扩展点:entrySet 与导航子图是活视图;只读包装仍随根表变化;复制成独立列表才获得时间点所有权。
  • **调试练习:**在 access-order 表命中中间键,观察 modCount、首尾链及旧迭代器;得到异常也不能宣称容器线程安全。

官方源码直链:HashMap.javaLinkedHashMap.javaTreeMap.javaEnumMap.java。API 合同见 Java SE 21 MapNavigableMap

7. 真实后端应用:配置发布、崩溃恢复与并发边界

真实限流平台通常把数据库或配置中心作为持久事实源,内存 Map 是单进程读优化投影。从 v7 发布 v8 时,两者应是两个不同 PolicyKey;数据库还可对“当前生效版本”做期望版本条件更新。今天的重排只修改某个尚未激活版本的计划字段,不改它的身份。若只依次调用四张普通 Map,线程可能在“旧树项已删、主账尚未替换”时读到空窗,进程也可能在“主账已换、树尚未插入”时崩溃。

可选边界取决于系统需求:低并发单分片可由同一线程串行拥有整个聚合;单进程并发可用覆盖四张 Map 的共同锁,读路径也遵守同一规则;读多写少可构建包含四个不可变投影的新聚合并一次交换引用;需要跨进程恢复则让数据库事务与版本号承担事实原子性,内存索引可由事件日志或主账重建。把 byKey 换成 ConcurrentHashMap 只能改善该表的特定单键操作,不会保护 TreeMap、EnumMap、预览缓存,更不会让跨策略切换自动原子。

随堂检查 3:对 byKey.compute(policyKey, ...) 加上 ConcurrentHashMap,能否保证旧调度项删除、事实替换、新调度项插入和计数变化一起成功?

**即时答案:**不能。单键 compute 的原子边界只属于那张 Map 的一个键;必须再选择共同锁、单线程所有者、不可变聚合交换或持久化事务,并为投影失配准备重建或补偿。

8. 错误示例:把身份、排序、缓存和事务混成一个概念

factKey = apiCode                            // 漏 tenantId/version,跨事实覆盖
treeKey = mutablePolicy                      // 入树后修改 minute/priority
compare = activationMinuteOnly               // 同分钟策略 compare==0,静默替换
facts.put(key, newValue)
schedule.remove(keyFrom(newValue))           // 用新投影删旧项,留下幽灵键
report = unmodifiableMap(schedule)            // 只读包装仍随根树变化
cache.removeEldestEntry -> facts.remove(...)  // 缓存淘汰反向删除事实
catch ConcurrentModificationException        // 把 fail-fast 当并发控制

另一个隐蔽错误是用 LinkedList 保存时间顺序,然后声称“中间删除 O(1)”。只有已经持有目标节点或 ListIterator 位置时局部链接修改才是 O(1);从业务键查找未知位置仍需 O(n)。本题用 TreeMap 是为了有界导航和完整排序合同,不是为了追求某次样例更短。

9. 正确示例:独立合同演示,而非三个 TODO 的答案

下面程序只手工展示等值事实、树重排、活视图、稳定快照、容量 3 的访问顺序缓存和 EnumMap 计数。它没有 rescheduleactivationSnapshotactivateNext 服务方法,不处理输入命令,也没有给出今日三个 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 PolicyProjectionContractsDemo {
    private enum PolicyState {
        SCHEDULED,
        ACTIVE
    }

    private record PolicyKey(
            String tenantId,
            String policyCode,
            long version) {
        PolicyKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(policyCode, "policyCode");
            if (tenantId.isBlank() || policyCode.isBlank() || version <= 0) {
                throw new IllegalArgumentException("invalid policy identity");
            }
        }

        String label() {
            return tenantId + "/" + policyCode + "/v" + version;
        }
    }

    private record PolicyVersion(
            PolicyKey key,
            long activationMinute,
            int priority,
            int permitsPerSecond,
            PolicyState state) {
        PolicyVersion {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(state, "state");
            if (activationMinute < 0
                    || priority < 1 || priority > 9
                    || permitsPerSecond <= 0) {
                throw new IllegalArgumentException("invalid policy version");
            }
        }
    }

    private record ActivationKey(
            long activationMinute,
            int priority,
            String tenantId,
            String policyCode,
            long version) implements Comparable<ActivationKey> {
        static ActivationKey from(PolicyVersion policy) {
            return new ActivationKey(
                    policy.activationMinute(),
                    policy.priority(),
                    policy.key().tenantId(),
                    policy.key().policyCode(),
                    policy.key().version());
        }

        @Override
        public int compareTo(ActivationKey other) {
            int byMinute = Long.compare(
                    activationMinute, other.activationMinute);
            if (byMinute != 0) {
                return byMinute;
            }
            int byPriority = Integer.compare(other.priority, priority);
            if (byPriority != 0) {
                return byPriority;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            if (byTenant != 0) {
                return byTenant;
            }
            int byPolicy = policyCode.compareTo(other.policyCode);
            return byPolicy != 0
                    ? byPolicy
                    : Long.compare(version, other.version);
        }

        String label() {
            return tenantId + "/" + policyCode + "/v" + version
                    + "@" + activationMinute + "#P" + priority;
        }
    }

    public static void main(String[] args) {
        PolicyKey a = new PolicyKey("tenant-a", "search", 7);
        PolicyKey b = new PolicyKey("tenant-b", "search", 3);
        PolicyKey c = new PolicyKey("tenant-a", "report", 11);
        PolicyKey d = new PolicyKey("tenant-c", "pay", 2);

        PolicyVersion oldA = new PolicyVersion(
                a, 900, 2, 100, PolicyState.SCHEDULED);
        PolicyVersion policyB = new PolicyVersion(
                b, 880, 5, 50, PolicyState.SCHEDULED);
        PolicyVersion policyC = new PolicyVersion(
                c, 880, 1, 20, PolicyState.SCHEDULED);
        PolicyVersion policyD = new PolicyVersion(
                d, 930, 3, 200, PolicyState.SCHEDULED);

        Map<PolicyKey, PolicyVersion> facts = new HashMap<>();
        facts.put(a, oldA);
        facts.put(b, policyB);
        facts.put(c, policyC);
        facts.put(d, policyD);

        NavigableMap<ActivationKey, PolicyKey> schedule = new TreeMap<>();
        schedule.put(ActivationKey.from(oldA), a);
        schedule.put(ActivationKey.from(policyB), b);
        schedule.put(ActivationKey.from(policyC), c);
        schedule.put(ActivationKey.from(policyD), d);

        ActivationKey oldAKey = ActivationKey.from(oldA);
        ActivationKey bKey = ActivationKey.from(policyB);
        NavigableMap<ActivationKey, PolicyKey> liveWindow =
                schedule.subMap(bKey, true, oldAKey, true);
        List<String> snapshot = liveWindow.keySet().stream()
                .map(ActivationKey::label)
                .toList();

        LinkedHashMap<PolicyKey, PolicyVersion> previews =
                new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<PolicyKey, PolicyVersion> eldest) {
                        return size() > 3;
                    }
                };
        previews.put(a, oldA);
        previews.put(b, policyB);
        previews.put(c, policyC);
        previews.get(b);
        previews.put(d, policyD);

        EnumMap<PolicyState, Integer> counts =
                new EnumMap<>(PolicyState.class);
        counts.put(PolicyState.SCHEDULED, 4);

        System.out.println("facts-size=" + facts.size());
        System.out.println("schedule-before=" + labels(schedule));
        System.out.println("window-before=" + labels(liveWindow));
        System.out.println("cache-before=" + keyLabels(previews));

        PolicyKey removed = schedule.remove(oldAKey);
        PolicyVersion newA = new PolicyVersion(
                a, 870, 4, 100, PolicyState.SCHEDULED);
        facts.put(new PolicyKey("tenant-a", "search", 7), newA);
        ActivationKey newAKey = ActivationKey.from(newA);
        schedule.put(newAKey, a);
        previews.put(a, newA);

        System.out.println("reschedule-remove=" + removed.label());
        System.out.println("schedule-after-reschedule=" + labels(schedule));
        System.out.println("live-after-reschedule=" + labels(liveWindow));
        System.out.println("snapshot-stable=" + snapshot);
        System.out.println("cache-after-reschedule=" + keyLabels(previews));
        System.out.println("counts-before=" + counts);

        schedule.remove(newAKey);
        PolicyVersion activeA = new PolicyVersion(
                a, 870, 4, 100, PolicyState.ACTIVE);
        facts.put(a, activeA);
        counts.merge(PolicyState.SCHEDULED, -1, Integer::sum);
        counts.merge(PolicyState.ACTIVE, 1, Integer::sum);
        previews.put(a, activeA);

        System.out.println("schedule-after-activate=" + labels(schedule));
        System.out.println("state-a=" + facts.get(a).state());
        System.out.println("counts-after=" + counts);
        System.out.println("cache-after-activate=" + keyLabels(previews));
    }

    private static List<String> labels(
            Map<ActivationKey, PolicyKey> schedule) {
        return schedule.keySet().stream().map(ActivationKey::label).toList();
    }

    private static List<String> keyLabels(
            Map<PolicyKey, PolicyVersion> policies) {
        return policies.keySet().stream().map(PolicyKey::label).toList();
    }
}

预期输出:

facts-size=4
schedule-before=[tenant-b/search/v3@880#P5, tenant-a/report/v11@880#P1, tenant-a/search/v7@900#P2, tenant-c/pay/v2@930#P3]
window-before=[tenant-b/search/v3@880#P5, tenant-a/report/v11@880#P1, tenant-a/search/v7@900#P2]
cache-before=[tenant-a/report/v11, tenant-b/search/v3, tenant-c/pay/v2]
reschedule-remove=tenant-a/search/v7
schedule-after-reschedule=[tenant-a/search/v7@870#P4, tenant-b/search/v3@880#P5, tenant-a/report/v11@880#P1, tenant-c/pay/v2@930#P3]
live-after-reschedule=[tenant-b/search/v3@880#P5, tenant-a/report/v11@880#P1]
snapshot-stable=[tenant-b/search/v3@880#P5, tenant-a/report/v11@880#P1, tenant-a/search/v7@900#P2]
cache-after-reschedule=[tenant-b/search/v3, tenant-c/pay/v2, tenant-a/search/v7]
counts-before={SCHEDULED=4}
schedule-after-activate=[tenant-b/search/v3@880#P5, tenant-a/report/v11@880#P1, tenant-c/pay/v2@930#P3]
state-a=ACTIVE
counts-after={SCHEDULED=3, ACTIVE=1}
cache-after-activate=[tenant-b/search/v3, tenant-c/pay/v2, tenant-a/search/v7]

10. 边界总结:闯关时必须守住的六条红线

  1. HashMap、HashSet 没有稳定业务顺序;激活顺序来自完整 TreeMap 比较器,预览热度来自明确的 access-order LinkedHashMap。
  2. 事实键的 equals/hashCode 必须使用同一组不可变身份字段;Comparator 必须形成全序,已经入树的比较字段不得改变。可变计划必须“删旧键、换不可变 value、插新键”。
  3. 活视图会随根容器变化,只读包装也不等于不可变集合;跨边界历史结果要复制,并说明复制的是 value、字符串还是深层对象。
  4. 不知道节点位置时,LinkedList 中间查找或删除仍是 O(n),不能把局部改链的 O(1) 冒充完整业务操作成本。
  5. fail-fast 是尽力发现迭代期间结构修改误用,不提供互斥、可见性或线程安全;access-order 的成功读取本身还可能改变结构。
  6. ConcurrentHashMap 不会让跨键、跨集合或跨数据库操作自动原子。版本切换必须由共同锁、单线程所有者、不可变聚合交换或持久化事务覆盖,并核对旧 TreeMap 删除返回值。

普通 put/compute/merge 的返回值是状态转换证据,而不是可忽略的装饰;缓存未命中不等于事实不存在;WeakHashMap 和 IdentityHashMap 也不能替代稳定业务身份。最终必须能同时解释“谁是事实、何时激活、为何重排、看到哪个时刻、哪里保证原子性”。只有提交完整闯关答案、Java 21 编译运行成功、总分至少 80 且无红线,challenge.map 才能从 covered_unverified 变为 mastered 并放行 Set。

返回今日索引

返回今日索引

编码闯关:多租户限流策略版本切换索引

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

你要为 API 网关完成一个单线程限流策略索引。平台既要按“租户 + API + 版本”定位每份策略事实,又要按“计划激活分钟 + 优先级”决定版本切换顺序,还要维护状态计数和容量受限的预览缓存。与前几次只新增后领取的模型不同,本题增加了真正的重排操作:计划中的策略允许修改激活分钟与优先级,必须删除旧 TreeMap 投影,再用新的不可变 value 建立新投影,不能在树中遗留幽灵键。

1. 业务背景

同一租户、同一 API 可以并存多个版本;不同租户也可以使用相同 API 名和版本号。因此事实键必须完整包含 tenantIdapiCodeversion,并且进入 Map 后不可改变。激活顺序先按 activateMinute 升序;同一分钟优先级越高越先激活;仍相同时再按租户、API 和版本补足全序。这个顺序来自业务合同,不能使用 HashMap 的偶然遍历顺序代替。

四张 Map 分别承担:

  • byKey:唯一事实来源,以不可变 PolicyKey 精确定位一个策略版本。
  • activationSchedule:只保存 SCHEDULED 策略的排序投影,支持重排、半开窗口和严格上界首项。
  • stateCounts:用 EnumMap 统计封闭状态域 SCHEDULED/ACTIVE
  • previewCache:容量为 3 的 access-order LinkedHashMap;提交、重排和激活会写入最新 value,预览命中也会升温,超限只淘汰缓存项。

种子数据通过已经实现的 submit 写入,以便把编码时间集中在重排、快照和激活三个难点。四张 Map 仍不是事务:练习的单线程顺序只能避免并发交错,不能抵抗进程在“删旧排序键、换事实 value、插新排序键”中途退出。生产环境必须让共同锁、单线程事件所有者、不可变聚合状态交换或数据库事务覆盖完整转换,并准备从事实重建派生索引。

2. 约束与验收边界

  • 基线为 Java 21;只使用 JDK 集合,不添加依赖、日志或无法恢复问题的 try/catch
  • PolicyKey 是不可变 record;租户和 API 非空白,版本为正数。激活时间、优先级、限额和状态只属于 value。
  • 新提交策略必须为 SCHEDULEDactivateMinute 非负,优先级限定 1..9,每分钟配额为正数。
  • 已实现的 submit 对复合键去重;重复提交返回 false,不能修改调度、计数或缓存热度。
  • reschedule(key,newMinute,newPriority) 仅允许修改存在且仍为 SCHEDULED 的策略;缺失或已激活返回 false 且无副作用。成功时必须移除旧 ActivationKey,构造新的不可变策略 value,再重建排序投影并让缓存项重新进入或升温。
  • ActivationKey.compareTo 已给出:激活分钟升序、优先级降序、租户/API/版本升序;合法业务键上,比较为 0 与五个组件相等一致。
  • activationSnapshot(fromInclusive,toExclusive) 必须从 [fromInclusive,toExclusive) 的 TreeMap 活视图投影出不可变字符串快照,不能扫描事实 HashMap。
  • activateNext(beforeExclusive) 只激活分钟严格小于上界的第一个 SCHEDULED 候选;没有候选返回 NONE
  • 缓存淘汰不删除事实或调度项;重排已被淘汰的策略会重新放入缓存并可能淘汰另一缓存项。
  • 禁止依赖 HashMap 顺序、破坏键或 Comparator 合同、混淆活视图/只读包装/快照、把 fail-fast 当线程安全,或把跨 Map 操作称为自动事务。

3. 目标拆解与实施顺序

  1. 阅读已实现的 submit,先确认事实新增成功后才建立调度、计数和缓存投影;重复分支必须立即结束。
  2. 完成重排:验证边界与当前状态,保存旧排序键,移除旧投影,构造并替换不可变 value,插入新排序键,最后更新预览缓存。
  3. 完成快照:用两个边界键获得半开导航视图,按当前激活顺序固化所有字段。
  4. 完成激活:用严格上界视图选择首项,核对事实和排序投影,再迁移状态、移除调度项、更新计数与缓存。
  5. 用 Java 21 编译运行并逐行核对规定输出;提交完整代码、编译证据和输出。

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

保存为 RatePolicySwitchChallenge.java。未补代码时仍能通过编译;直接运行会完成种子提交,然后在第一次重排时以 TODO 1: reschedule 明确失败。起始代码只有 3 个待补位置,当天不提供完整实现。

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 RatePolicySwitchChallenge {
    private enum PolicyState {
        SCHEDULED,
        ACTIVE
    }

    private record PolicyKey(
            String tenantId,
            String apiCode,
            long version) {
        PolicyKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(apiCode, "apiCode");
            if (tenantId.isBlank() || apiCode.isBlank() || version <= 0) {
                throw new IllegalArgumentException("invalid policy key");
            }
        }

        String label() {
            return tenantId + "/" + apiCode + "/v" + version;
        }
    }

    private record RatePolicy(
            PolicyKey key,
            long activateMinute,
            int priority,
            int permitsPerMinute,
            PolicyState state) {
        RatePolicy {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(state, "state");
            if (activateMinute < 0
                    || priority < 1
                    || priority > 9
                    || permitsPerMinute <= 0) {
                throw new IllegalArgumentException("invalid rate policy");
            }
        }

        RatePolicy rescheduled(long newMinute, int newPriority) {
            return new RatePolicy(
                    key,
                    newMinute,
                    newPriority,
                    permitsPerMinute,
                    state);
        }

        RatePolicy activated() {
            return new RatePolicy(
                    key,
                    activateMinute,
                    priority,
                    permitsPerMinute,
                    PolicyState.ACTIVE);
        }

        String snapshotLine() {
            return key.label() + "@" + activateMinute
                    + "#P" + priority + ":" + state
                    + ":L" + permitsPerMinute;
        }
    }

    private record ActivationKey(
            long activateMinute,
            int priority,
            String tenantId,
            String apiCode,
            long version) implements Comparable<ActivationKey> {
        static ActivationKey from(RatePolicy policy) {
            return new ActivationKey(
                    policy.activateMinute(),
                    policy.priority(),
                    policy.key().tenantId(),
                    policy.key().apiCode(),
                    policy.key().version());
        }

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

        @Override
        public int compareTo(ActivationKey other) {
            int byMinute = Long.compare(activateMinute, other.activateMinute);
            if (byMinute != 0) {
                return byMinute;
            }
            int byPriority = Integer.compare(other.priority, priority);
            if (byPriority != 0) {
                return byPriority;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            if (byTenant != 0) {
                return byTenant;
            }
            int byApi = apiCode.compareTo(other.apiCode);
            return byApi != 0
                    ? byApi
                    : Long.compare(version, other.version);
        }
    }

    private static final class PolicyIndex {
        private static final int PREVIEW_LIMIT = 3;

        private final Map<PolicyKey, RatePolicy> byKey = new HashMap<>();
        private final NavigableMap<ActivationKey, PolicyKey>
                activationSchedule = new TreeMap<>();
        private final EnumMap<PolicyState, Integer> stateCounts =
                new EnumMap<>(PolicyState.class);
        private final LinkedHashMap<PolicyKey, RatePolicy> previewCache =
                new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<PolicyKey, RatePolicy> eldest) {
                        return size() > PREVIEW_LIMIT;
                    }
                };

        private boolean submit(RatePolicy policy) {
            Objects.requireNonNull(policy, "policy");
            if (policy.state() != PolicyState.SCHEDULED) {
                throw new IllegalArgumentException(
                        "new policy must be scheduled");
            }
            if (byKey.putIfAbsent(policy.key(), policy) != null) {
                return false;
            }

            // 事实新增后才建立派生投影;生产环境仍需外层一致性边界。
            activationSchedule.put(ActivationKey.from(policy), policy.key());
            stateCounts.merge(PolicyState.SCHEDULED, 1, Integer::sum);
            previewCache.put(policy.key(), policy);
            return true;
        }

        private boolean reschedule(
                PolicyKey key,
                long newMinute,
                int newPriority) {
            // 删除旧排序投影,用新不可变 value 重建事实、调度和缓存。
            throw new UnsupportedOperationException("TODO 1: reschedule");
        }

        private List<String> activationSnapshot(
                long fromInclusive,
                long toExclusive) {
            if (fromInclusive > toExclusive) {
                throw new IllegalArgumentException(
                        "fromInclusive must be <= toExclusive");
            }
            // 将半开导航活视图投影为稳定、不可变的版本快照。
            throw new UnsupportedOperationException(
                    "TODO 2: activationSnapshot");
        }

        private String activateNext(long beforeExclusive) {
            if (beforeExclusive < 0) {
                throw new IllegalArgumentException(
                        "beforeExclusive must be >= 0");
            }
            // 严格上界内激活首项,并迁移事实与派生投影。
            throw new UnsupportedOperationException("TODO 3: activateNext");
        }

        private String policyLine(PolicyKey key) {
            RatePolicy policy = byKey.get(Objects.requireNonNull(key, "key"));
            return policy == null ? "MISSING" : policy.snapshotLine();
        }

        private String previewState(PolicyKey key) {
            RatePolicy policy = previewCache.get(
                    Objects.requireNonNull(key, "key"));
            return policy == null ? "MISS" : policy.state().name();
        }

        private List<String> scheduleOrder() {
            return activationSchedule.entrySet().stream()
                    .map(entry -> entry.getValue().label()
                            + "@" + entry.getKey().activateMinute()
                            + "#P" + entry.getKey().priority())
                    .toList();
        }

        private List<String> previewOrder() {
            return previewCache.keySet().stream()
                    .map(PolicyKey::label)
                    .toList();
        }

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

    public static void main(String[] args) {
        PolicyKey aKey = new PolicyKey("tenant-a", "search", 1);
        PolicyKey bKey = new PolicyKey("tenant-b", "search", 1);
        PolicyKey cKey = new PolicyKey("tenant-a", "pay", 2);
        PolicyKey dKey = new PolicyKey("tenant-c", "report", 1);

        RatePolicy a = new RatePolicy(
                aKey, 520, 2, 100, PolicyState.SCHEDULED);
        RatePolicy b = new RatePolicy(
                bKey, 500, 1, 80, PolicyState.SCHEDULED);
        RatePolicy c = new RatePolicy(
                cKey, 500, 5, 40, PolicyState.SCHEDULED);
        RatePolicy d = new RatePolicy(
                dKey, 540, 3, 200, PolicyState.SCHEDULED);

        PolicyIndex index = new PolicyIndex();
        System.out.println("SUBMIT A=" + index.submit(a));
        System.out.println("SUBMIT B=" + index.submit(b));
        System.out.println("SUBMIT C=" + index.submit(c));
        System.out.println("SUBMIT D=" + index.submit(d));
        System.out.println("SUBMIT duplicate-A=" + index.submit(a));
        System.out.println("PREVIEW before-reschedule="
                + index.previewOrder());

        System.out.println("RESCHEDULE A="
                + index.reschedule(aKey, 510, 4));
        System.out.println("POLICY A=" + index.policyLine(aKey));
        System.out.println("PREVIEW after-reschedule="
                + index.previewOrder());

        List<String> beforeActivation = index.activationSnapshot(500, 521);
        System.out.println("WINDOW before=" + beforeActivation);
        System.out.println("COUNTS before=" + index.countSummary());
        System.out.println("ACTIVATE before-500="
                + index.activateNext(500));
        System.out.println("ACTIVATE before-510="
                + index.activateNext(510));
        System.out.println("COUNTS after=" + index.countSummary());
        System.out.println("SCHEDULE after=" + index.scheduleOrder());
        System.out.println("PREVIEW after=" + index.previewOrder());
        System.out.println("SNAPSHOT unchanged=" + beforeActivation);
    }
}

直接编译运行:

javac --release 21 RatePolicySwitchChallenge.java
java RatePolicySwitchChallenge

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

位置 1:重排必须同时处理旧投影与新投影

  • keynull、新分钟为负或新优先级不在 1..9 时,在任何 Map 修改前失败。
  • 缺失或状态已经是 ACTIVE 时返回 false,调度、计数和缓存均无副作用。
  • 保存当前 ActivationKey 后,先以“旧排序键 + 当前事实键”条件删除旧投影;删除失败表示索引不一致,应明确失败而不是继续写入。
  • 构造包含新分钟、新优先级的不可变 RatePolicy,条件替换事实 value,再插入新排序键并更新预览缓存。重排不改变 SCHEDULED 计数。
  • 整段顺序依赖题设单线程所有权;生产中外层原子边界必须覆盖删旧、换事实、建新和缓存更新,不能把单次条件删除称作事务。

位置 2:半开激活窗口转稳定快照

  • 通过两个 ActivationKey.boundary 和四参数 subMap 表达 [fromInclusive,toExclusive),不能遍历 HashMap 再排序。
  • 按范围视图当前顺序回查事实,核对它仍为 SCHEDULEDActivationKey.from(policy) 与视图键一致;索引失配应明确失败。
  • 将每项投影为 snapshotLine() 并返回不可修改列表;后续激活或重排不能改变旧字符串内容。
  • [x,x) 合法且为空;下界大于上界必须在业务读取前失败。

位置 3:严格上界内激活第一项

  • headMap(ActivationKey.boundary(beforeExclusive), false) 建立严格上界活视图;无候选返回 NONE,所有 Map 不变。
  • 回查首项事实,确认状态仍为 SCHEDULED 且完整排序键一致;不一致时明确失败。
  • 以新的 ACTIVE record 条件替换事实,从活视图删除旧调度项;SCHEDULED 减一并在归零时移除,再合并增加 ACTIVE
  • 用激活后的 value 更新预览缓存;已有项应升温,已淘汰项可重新进入,但淘汰只作用于缓存。返回被激活的完整策略键标签。

6. 三级提示

一级提示:重排先保存旧键,不能只改 value

TreeMap 不会观察事实 value 的字段变化。先从旧事实构造旧 ActivationKey,再创建新事实和新排序键;成功路径中必须能证明旧键不再存在、新键恰好存在。

二级提示:边界键为什么使用极高优先级

同一分钟高优先级排前。边界键的优先级高于所有合法策略,因此排在该分钟真实键之前:包含下界边界能收进下界整分钟,排除上界边界能排除上界整分钟。

三级提示:三个顺序分别来自三个合同

激活顺序来自 ActivationKey 比较器,缓存热度来自 access-order,事实 HashMap 没有业务顺序。重排 A 会让它从旧树位置移动到新位置,也会让被淘汰的 A 回到缓存尾部;两个变化的原因不同。

7. 精确预期输出

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

SUBMIT A=true
SUBMIT B=true
SUBMIT C=true
SUBMIT D=true
SUBMIT duplicate-A=false
PREVIEW before-reschedule=[tenant-b/search/v1, tenant-a/pay/v2, tenant-c/report/v1]
RESCHEDULE A=true
POLICY A=tenant-a/search/v1@510#P4:SCHEDULED:L100
PREVIEW after-reschedule=[tenant-a/pay/v2, tenant-c/report/v1, tenant-a/search/v1]
WINDOW before=[tenant-a/pay/v2@500#P5:SCHEDULED:L40, tenant-b/search/v1@500#P1:SCHEDULED:L80, tenant-a/search/v1@510#P4:SCHEDULED:L100]
COUNTS before=[SCHEDULED=4]
ACTIVATE before-500=NONE
ACTIVATE before-510=tenant-a/pay/v2
COUNTS after=[SCHEDULED=3, ACTIVE=1]
SCHEDULE after=[tenant-b/search/v1@500#P1, tenant-a/search/v1@510#P4, tenant-c/report/v1@540#P3]
PREVIEW after=[tenant-c/report/v1, tenant-a/search/v1, tenant-a/pay/v2]
SNAPSHOT unchanged=[tenant-a/pay/v2@500#P5:SCHEDULED:L40, tenant-b/search/v1@500#P1:SCHEDULED:L80, tenant-a/search/v1@510#P4:SCHEDULED:L100]

验收时还要解释:第四次提交为何只淘汰 A 的缓存而不删事实;重复提交为何不能让 A 回到缓存;重排 A 为何从 520#P2 移到 510#P4 且计数不变;同为 500 时 C 为何先于 B;严格上界 510 为何排除重排后的 A;旧快照为何仍显示 C 为 SCHEDULED。精确输出不授权依赖任何 HashMap 遇见顺序。

8. 复杂度要求

  • byKey 精确查询、条件新增与条件替换的期望时间为 O(1);碰撞、扩容和树化使“每次严格 O(1)”不成立。
  • activationSchedule 单项插入、删除和边界导航为 O(log n);重排包含一次删除和一次插入,仍为 O(log n)
  • 窗口边界定位加 k 项固化为 O(log n + k),返回快照额外空间为 O(k)
  • stateCounts 键域固定;previewCache 命中、已有键移动、插入及一次最老项淘汰均为常数或期望 O(1),缓存空间上限为 O(1)
  • 事实表与调度树总空间为 O(n)。提交、重排和成功激活均由 TreeMap 步骤主导为 O(log n),但复杂度结论不等于跨 Map 原子性。

9. 边界用例

补全后至少自行验证:

  1. 同租户同 API 的不同版本、以及不同租户的同 API/版本均可并存;事实键不会因重排而改变。
  2. 重复提交不增加 SCHEDULED,不覆盖排序项,也不能刷新已淘汰的预览缓存项。
  3. 缺失或 ACTIVE 策略重排返回 false 且无副作用;非法新分钟或优先级在任何写入前失败。
  4. 重排后旧 ActivationKey 不再存在,新键按分钟、优先级和全部身份字段参与全序;计数保持不变。
  5. [510,510) 返回空快照;下界大于上界不读取或修改业务状态。
  6. activateNext(500) 返回 NONE;严格上界 510 不包含激活分钟恰为 510 的 A。
  7. 激活后旧导航视图反映 C 被删除,旧字符串快照保持 C 的 SCHEDULED 状态;快照列表不可增删。
  8. 缓存重排、命中与淘汰不改变事实存在性;生产环境必须给重排和激活完整流程提供共同事务边界。

10. 闯关提交与自检

  • 提交补全后的完整 RatePolicySwitchChallenge.java,不是三个零散片段。
  • 提交 javac --release 21 成功证据与完整、逐行一致的运行输出。
  • 仅补全 3 个 TODO,没有用测试数据特判绕过合同。
  • PolicyKey 同时包含租户、API、版本并保持不可变;重排只产生新的 value。
  • 重排删除旧排序键并插入新键,比较器包含全部区分量。
  • 能区分 TreeMap 激活顺序、LinkedHashMap 访问顺序、HashMap 无序及稳定快照。
  • 重排不改状态计数,激活准确迁移 SCHEDULED/ACTIVE,重复和失败分支无副作用。
  • 没有把多 Map 更新、fail-fast 或单 Map 条件 API 描述成线程安全事务。
  • 没有无意义的 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-source 13 混淆活视图、只读包装与快照;只改事实而遗留旧排序键;把缓存或多 Map 更新当事务
4 编码 ds.map.contractds.map.equalityds.map.compute-merge-viewsds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 30 重排遗漏旧键、比较器或范围错误、计数误迁移;把单 Map 条件操作扩张成跨 Map 原子性
5 源码讲解 ds.map.hashmap-structureds.map.hashmap-put-get-remove-sourceds.map.hashmap-resize-treeify-iterator-sourceds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 20 猜测源码、把实现当公共合同、把 fail-fast 当线程安全;误认为 ConcurrentHashMap 跨键或跨集合自动原子
合计 契约与选型 25;复杂度与状态推演 25;编码 30;源码讲解 20 覆盖 Map 9 个知识项 100

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

1. 契约与方案设计:版本身份、排序投影与所有权(25 分)

限流平台要求按“租户 + API + 版本”精确定位策略,按激活分钟和优先级切换版本,统计有限状态,并维护容量为 3 的预览访问缓存。用最多 8 条写出四张 Map 的选型和事实/派生关系,同时定义:PolicyKey 的相等与 null 边界、可变字段为什么不能进入事实键、ActivationKey 的全序、access-order 与淘汰、重排旧/新投影、导航视图和对外快照所有权。解释为什么 HashMap/HashSet 顺序不能排激活队列,以及 WeakHashMap、IdentityHashMap 不适合持久策略事实。

2. 复杂度与结构推演:重排不是一次 HashMap 替换(12 分)

设事实表有 n 个策略版本,窗口命中 k 个。给出精确查询、提交、重排、首项激活、范围转快照、枚举计数与有界预览缓存操作的时间复杂度和空间复杂度。解释重排为何包含 TreeMap 的旧键删除与新键插入、仍为 O(log n);说明碰撞、扩容与 OpenJDK 21 树化容量门槛为何否定 HashMap“每次严格 O(1)”。再比较未知候选位置时用 LinkedList 查找/删除与 TreeMap 导航的成本,并指出 fail-fast 没有提供的线程安全性质。

3. 状态推演与代码分析:重排、缓存重新进入与严格上界(13 分)

预览缓存容量为 2。依次提交 A=tenant-a/orders/v1@640#P2、B=tenant-b/orders/v1@620#P1、C=tenant-a/pay/v2@620#P4;随后把已被缓存淘汰的 A 重排为 630#P5,再提交 D=tenant-c/report/v1@660#P3。接着取得 [620,641) 的导航活视图并投影成不可变字符串快照,最后执行 activateNext(630)

逐步写出调度树和预览缓存从每次提交、A 重排、D 提交到激活后的顺序;写出被激活键、最终 SCHEDULED/ACTIVE 计数、事实表中 A 的新 value、旧 A 排序键是否存在、旧活视图当前内容和旧快照内容。说明同分钟 P4/P1 顺序、严格上界 630 为什么排除 A、激活一个已被缓存淘汰的 C 如何重新进入并触发淘汰,以及为何“删旧键 + 换事实 + 建新键 + 缓存更新”仍不是自动事务。不得依据 HashMap 遍历顺序。

4. 必交编码:完成限流策略重排与激活(30 分)

补全 编码练习 的 3 个待补位置,提交完整 RatePolicySwitchChallenge.javajavac --release 21 RatePolicySwitchChallenge.java 的成功结果,以及 java RatePolicySwitchChallenge 的完整且逐行一致输出。再用不超过 6 句话说明重复提交、重排旧键清理、半开快照、严格激活上界、缓存重新进入和跨 Map 原子性边界。

评分拆分:重排与旧/新投影不变量 10 分;范围活视图转稳定快照 7 分;首项激活、计数迁移与缓存热度 7 分;Java 21 编译、规定输出和边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:由固定入口解释重排后的现象(20 分)

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

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

返回今日索引

课后作答

复盘问题与编码作答

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

1. 契约与方案设计:版本身份、排序投影与所有权(25 分)

限流平台要求按“租户 + API + 版本”精确定位策略,按激活分钟和优先级切换版本,统计有限状态,并维护容量为 3 的预览访问缓存。用最多 8 条写出四张 Map 的选型和事实/派生关系,同时定义:PolicyKey 的相等与 null 边界、可变字段为什么不能进入事实键、ActivationKey 的全序、access-order 与淘汰、重排旧/新投影、导航视图和对外快照所有权。解释为什么 HashMap/HashSet 顺序不能排激活队列,以及 WeakHashMap、IdentityHashMap 不适合持久策略事实。

2. 复杂度与结构推演:重排不是一次 HashMap 替换(12 分)

设事实表有 n 个策略版本,窗口命中 k 个。给出精确查询、提交、重排、首项激活、范围转快照、枚举计数与有界预览缓存操作的时间复杂度和空间复杂度。解释重排为何包含 TreeMap 的旧键删除与新键插入、仍为 O(log n);说明碰撞、扩容与 OpenJDK 21 树化容量门槛为何否定 HashMap“每次严格 O(1)”。再比较未知候选位置时用 LinkedList 查找/删除与 TreeMap 导航的成本,并指出 fail-fast 没有提供的线程安全性质。

3. 状态推演与代码分析:重排、缓存重新进入与严格上界(13 分)

预览缓存容量为 2。依次提交 A=tenant-a/orders/v1@640#P2、B=tenant-b/orders/v1@620#P1、C=tenant-a/pay/v2@620#P4;随后把已被缓存淘汰的 A 重排为 630#P5,再提交 D=tenant-c/report/v1@660#P3。接着取得 [620,641) 的导航活视图并投影成不可变字符串快照,最后执行 activateNext(630)

逐步写出调度树和预览缓存从每次提交、A 重排、D 提交到激活后的顺序;写出被激活键、最终 SCHEDULED/ACTIVE 计数、事实表中 A 的新 value、旧 A 排序键是否存在、旧活视图当前内容和旧快照内容。说明同分钟 P4/P1 顺序、严格上界 630 为什么排除 A、激活一个已被缓存淘汰的 C 如何重新进入并触发淘汰,以及为何“删旧键 + 换事实 + 建新键 + 缓存更新”仍不是自动事务。不得依据 HashMap 遍历顺序。

4. 必交编码:完成限流策略重排与激活(30 分)

补全 编码练习 的 3 个待补位置,提交完整 RatePolicySwitchChallenge.javajavac --release 21 RatePolicySwitchChallenge.java 的成功结果,以及 java RatePolicySwitchChallenge 的完整且逐行一致输出。再用不超过 6 句话说明重复提交、重排旧键清理、半开快照、严格激活上界、缓存重新进入和跨 Map 原子性边界。

评分拆分:重排与旧/新投影不变量 10 分;范围活视图转稳定快照 7 分;首项激活、计数迁移与缓存热度 7 分;Java 21 编译、规定输出和边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:由固定入口解释重排后的现象(20 分)

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

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

返回今日索引

可选:编码作答

尚未保存