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

Day 20

Map 阶段闯关变式:多租户风控案件调分与结案索引的契约、全序、快照与一致性。

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

Java 后端每日学习 · Day 20 · 2026-09-13

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

今日主题

Map 阶段闯关变式:多租户风控案件调分与结案索引的契约、全序、快照与一致性。

方向元数据

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

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

今天把业务约束换成“多租户风控案件调分与结案”:案件事实即使结案也要保留,待审队列只容纳开放案件,账户敞口只记录开放案件的正总分;案件调分会同时改变事实、敞口和排序键,结案还会迁移状态计数。大大需要让多个索引在单线程模型中始终一致,并明确生产环境中跨多张 Map 操作需要更强的原子性边界。

可验证目标

  1. 能区分不可变案件身份键、账户聚合键和待审排序键,并说明 equals/hashCode 与 Comparator 全序各自承担的职责。
  2. 能逐步推演开户、调分、结案、敞口归零删键、旧排序键清理、访问顺序缓存淘汰和稳定快照。
  3. 能完成 Java 21 必交编码,编译并产生规定输出;缺失、重复、同分调分或重复结案不能产生副作用。
  4. 能沿 OpenJDK jdk-21+35 的入口、字段与主调用链解释 HashMapTreeMapLinkedHashMapEnumMap 在本题中的行为。
  5. 能说明活视图、只读包装、不可变快照、fail-fast、单键原子 API 与跨 Map 一致性之间的边界。

60~75 分钟学习顺序

  1. 昨日复盘:Day 19 五题完整参考答案与积分流水冲正完整实现(约 12~14 分钟)。
  2. 核心讲解:用案件调分和结案串联事实表、敞口、待审队列、缓存与状态槽位(约 31~33 分钟)。
  3. 编码练习:完成稳定 Top 快照、开放案件调分与结案;这是闯关通过的必交编码(约 18~20 分钟)。
  4. 复盘问题:按四个固定维度提交 Map 阶段闯关答案(约 7~8 分钟)。

总预计用时约 68~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

返回今日索引

昨日复盘:积分流水冲正与余额榜索引

建议用时:12~14 分钟。大大,昨日是 Day 19(challenge.map)的第七个闯关变式。本节先严格区分“参考答案”和“大大的有效证据”,再给出完整实现。

作答证据与结论

  • 固定同步助手拉取 2026-09-12 的结果:remote_missing
  • 本地不存在 2026-09-12/04-复盘作答.md;当前任务也没有能映射到 Day 19 题号及问题源哈希 sha256:27fdd5a1114650e88e4d2fed7595b51f416184cfe0de44030a33a9d37b732738 的聊天答案。
  • 答案来源:none。第 1~5 题均为“未作答”,没有可计分内容;总分为 不足以评分,不是 0 分。
  • 没有证据就不能判断大大命中红线,也不能指定薄弱知识 ID。下文参考答案只用于学习,绝不计作用户证据。
  • 状态变化:无评估状态变化;challenge.map 保持 covered_unverified,方向保持 active。因此今天继续 Map 闯关,不能进入 Set。
题号 昨日分值 有效作答 得分 证据结论
1 25 不评分 无法个性点评
2 12 不评分 无法个性点评
3 13 不评分 无法个性点评
4 30 无完整代码、编译或输出 不评分 编码通过条件未获验证
5 20 不评分 无法个性点评

第 1 题参考答案:契约与方案设计

可用九条不变量回答:

  1. facts: HashMap<EntryKey, PointEntry> 是审计事实源;EntryKey(tenantId, entryId) 的两个字段共同参与值相等和哈希,发布后不可变,冲正只替换 value 的状态,不删除事实。
  2. balances: HashMap<AccountKey, Long> 是可重建聚合;AccountKey(tenantId, accountId) 隔离租户,余额为 0 就删键,所以“键不存在”在此处明确表示净余额为 0。
  3. balanceRank: TreeMap<RankKey, AccountKey> 是非零余额的有序投影;RankKey 依次比较余额降序、租户升序、账户升序,使合法键 compareTo == 0 当且仅当完整排序身份相同。
  4. 每个非零账户必须在余额表和榜中各有且仅有一项,榜键余额必须等于余额表;余额变化要先以完整旧键核对并删除旧榜项,再插入新键。
  5. recentBalances 是容量 3 的 access-order LinkedHashMap,表示访问热度而非事实;它可以缓存最近观察到的 0,命中和已有键更新都会移到尾部,最老项被淘汰不影响余额或榜。
  6. stateCounts: EnumMap<EntryState,Integer> 用枚举的固定键域统计 POSTED/REVERSED;冲正使一项从前者迁移到后者,不能重复迁移。
  7. 入口拒绝 null 键、null value、空白身份和 delta == 0;同一完整 EntryKey 重复发布返回 false 且所有容器无副作用。
  8. balanceRank.entrySet() 是随底层树变化的活视图;Collections.unmodifiableList(view) 只限制写入口,仍可能背靠可变数据;把当时的值投影为字符串后 List.copyOf 才是本题稳定快照。
  9. HashMap/HashSet 的迭代顺序不是业务契约,不能表示排名或访问顺序;WeakHashMap 的键可能随可达性消失,IdentityHashMap 按引用身份而非业务值相等,二者都不适合审计事实。

正负 delta 都是合法事实。冲正的数学语义统一为 newBalance = oldBalance - delta:撤销正流水会扣分,撤销负流水会加回分。五张 Map 的连续写入在单线程练习里可按既定顺序完成,但并不天然构成生产事务。

第 2 题参考答案:复杂度与结构

设流水数为 n、最终非零账户数为 a、实际返回 Top 项数为 k

  • 事实精确查询、发布查重和余额定位在正常散列分布下期望 O(1);成功发布或冲正还要删除、插入 TreeMap 排名,整体为 O(log a)
  • Top 从有序树首定位并投影 k 项为 O(log a + k),独立快照空间为 O(k)
  • EnumMap 的枚举键域固定,单次计数访问为常数级;容量 3 的最近缓存命中、移尾和一次淘汰为常数或期望 O(1)
  • 重建扫描 n 条事实并精确聚合,再把 a 个非零账户插入 TreeMap,时间为 O(n + a log a)。精确临时表也会暂存最终正负抵消为 0 的账户,所以额外空间为 O(n + a)、最坏 O(n),不能只写 O(a)

HashMap 的期望 O(1) 不是每次严格保证:碰撞会形成桶内链,满足条件时可树化,扩容会迁移桶;OpenJDK 21 中树化还受 TREEIFY_THRESHOLDMIN_TREEIFY_CAPACITY 等实现条件约束。它们是实现细节,不应写成 Map 公共契约。

若不知道节点位置,LinkedList 仍须先线性查找,所谓“中间删除天然 O(1)”只适用于已经持有节点/迭代器位置的局部步骤;TreeMap 用完整旧 RankKey 查找删除为 O(log a)。fail-fast 只是尽力检测结构性并发修改并可能抛异常,不提供互斥、可见性、复合操作原子性或一致快照。

第 3 题参考答案:状态推演

xa=t-x/annaxb=t-x/benya=t-y/anna;最近缓存顺序从最老到最新:

操作后 非零余额 榜顺序 最近缓存(容量 2) 计数
发布 P xa:+80 xa=80 [xa=80] [xa=80] POSTED=1
发布 Q ya:+50 xa=80, ya=50 [xa=80, ya=50] [xa=80, ya=50] POSTED=2
发布 R xb:+80 xa=80, xb=80, ya=50 [xa=80, xb=80, ya=50] [ya=50, xb=80] POSTED=3
发布 S xa:-30 xa=50, xb=80, ya=50 [xb=80, xa=50, ya=50] [xb=80, xa=50] POSTED=4
冲正 S xa=80, xb=80, ya=50 [xa=80, xb=80, ya=50] [xb=80, xa=80] POSTED=3, REVERSED=1
冲正 R xa=80, ya=50 [xa=80, ya=50] [xa=80, xb=0] POSTED=2, REVERSED=2
从事实重建 xa=80, ya=50 [xa=80, ya=50] [] POSTED=2, REVERSED=2

发布完成时取得的旧 Top 快照为 [t-x/ben=80, t-x/anna=50, t-y/anna=50];后续冲正和重建不会改变这份字符串副本。最终 P、Q 为 POSTED,R、S 为 REVERSED。80 分并列时先比较租户再比较账户,所以 t-x/annat-x/ben 前;50 分并列时 t-x/annat-y/anna 前。冲正 S 使用 50 - (-30) = 80,冲正 R 使用 80 - 80 = 0,后者从余额表和榜删除,但缓存可以记录 0。重建能从事实恢复余额、榜和计数,却不能从无序事实遍历恢复访问历史,所以必须清空缓存。

第 4 题参考答案:完整可运行实现

下面是昨日三个 TODO 的完整实现。关键点是:发布和冲正先完成所有可恢复的一致性、算术与计数预检,再开始第一次 Map 写入;重建用 BigInteger 消除 HashMap 遍历次序对中间 long 溢出的影响,最后用 longValueExact() 验证最终范围。

import java.math.BigInteger;
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 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("inconsistent old rank");
            }
            if (oldBalance == 0 && balances.containsKey(account)) {
                throw new IllegalStateException("zero balance must be absent");
            }
            long newBalance = Math.addExact(oldBalance, entry.delta());
            int newPosted = Math.addExact(
                    stateCounts.getOrDefault(EntryState.POSTED, 0), 1);
            RankKey newRank = RankKey.from(account, newBalance);
            if (newBalance != 0) {
                AccountKey collision = balanceRank.get(newRank);
                if (collision != null && !collision.equals(account)) {
                    throw new IllegalStateException("duplicate rank key");
                }
            }

            if (oldBalance != 0) {
                balanceRank.remove(oldRank);
            }
            if (newBalance == 0) {
                balances.remove(account);
            } else {
                balances.put(account, newBalance);
                balanceRank.put(newRank, account);
            }
            facts.put(entry.key(), entry);
            stateCounts.put(EntryState.POSTED, newPosted);
            recentBalances.put(account, newBalance);
            return true;
        }

        private List<String> topSnapshot(int limit) {
            if (limit < 0) {
                throw new IllegalArgumentException("limit must not be negative");
            }
            List<String> result = new ArrayList<>(Math.min(limit, balanceRank.size()));
            for (Map.Entry<RankKey, AccountKey> item : balanceRank.entrySet()) {
                if (result.size() == limit) {
                    break;
                }
                RankKey rank = item.getKey();
                AccountKey account = item.getValue();
                Long balance = balances.get(account);
                if (!rank.tenantId().equals(account.tenantId())
                        || !rank.accountId().equals(account.accountId())
                        || balance == null
                        || balance == 0
                        || balance.longValue() != rank.balance()) {
                    throw new IllegalStateException("rank and balance disagree");
                }
                result.add(account.label() + "=" + balance);
            }
            return List.copyOf(result);
        }

        private boolean reverse(EntryKey key) {
            Objects.requireNonNull(key, "key");
            PointEntry entry = facts.get(key);
            if (entry == null || entry.state() == EntryState.REVERSED) {
                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("inconsistent old rank");
            }
            if (oldBalance == 0 && balances.containsKey(account)) {
                throw new IllegalStateException("zero balance must be absent");
            }
            long newBalance = Math.subtractExact(oldBalance, entry.delta());
            int posted = stateCounts.getOrDefault(EntryState.POSTED, 0);
            if (posted <= 0) {
                throw new IllegalStateException("inconsistent state count");
            }
            int reversed = Math.addExact(
                    stateCounts.getOrDefault(EntryState.REVERSED, 0), 1);
            RankKey newRank = RankKey.from(account, newBalance);
            if (newBalance != 0) {
                AccountKey collision = balanceRank.get(newRank);
                if (collision != null && !collision.equals(account)) {
                    throw new IllegalStateException("duplicate rank key");
                }
            }

            if (oldBalance != 0) {
                balanceRank.remove(oldRank);
            }
            if (newBalance == 0) {
                balances.remove(account);
            } else {
                balances.put(account, newBalance);
                balanceRank.put(newRank, account);
            }
            facts.put(key, entry.reversed());
            if (posted == 1) {
                stateCounts.remove(EntryState.POSTED);
            } else {
                stateCounts.put(EntryState.POSTED, posted - 1);
            }
            stateCounts.put(EntryState.REVERSED, reversed);
            recentBalances.put(account, newBalance);
            return true;
        }

        private void rebuildDerived() {
            Map<AccountKey, BigInteger> exactBalances = new HashMap<>();
            EnumMap<EntryState, Integer> newCounts =
                    new EnumMap<>(EntryState.class);
            for (PointEntry entry : facts.values()) {
                newCounts.merge(entry.state(), 1, Math::addExact);
                if (entry.state() == EntryState.POSTED) {
                    exactBalances.merge(
                            entry.accountKey(),
                            BigInteger.valueOf(entry.delta()),
                            BigInteger::add);
                }
            }

            Map<AccountKey, Long> newBalances = new HashMap<>();
            NavigableMap<RankKey, AccountKey> newRank = new TreeMap<>();
            for (Map.Entry<AccountKey, BigInteger> item
                    : exactBalances.entrySet()) {
                long balance = item.getValue().longValueExact();
                if (balance == 0) {
                    continue;
                }
                AccountKey account = item.getKey();
                newBalances.put(account, balance);
                if (newRank.put(RankKey.from(account, balance), account) != null) {
                    throw new IllegalStateException("duplicate rank key");
                }
            }
            if (newRank.size() != newBalances.size()) {
                throw new IllegalStateException("rank size mismatch");
            }

            balances.clear();
            balances.putAll(newBalances);
            balanceRank.clear();
            balanceRank.putAll(newRank);
            stateCounts.clear();
            stateCounts.putAll(newCounts);
            recentBalances.clear();
        }

        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(e -> e.getValue().label() + "=" + e.getKey().balance())
                    .toList();
        }

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

        private List<String> countSummary() {
            return stateCounts.entrySet().stream()
                    .map(e -> e.getKey() + "=" + e.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 aa = new AccountKey("tenant-a", "alice");
        AccountKey ab = new AccountKey("tenant-a", "bob");
        AccountKey ba = 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(aa));
        System.out.println("BALANCE tenant-b/alice=" + index.balance(ba));
        System.out.println("RECENT initial=" + index.recentOrder());
        System.out.println("RECENT access-bob=" + index.recentBalance(ab));
        System.out.println("RECENT after-access=" + index.recentOrder());
        List<String> before = index.topSnapshot(3);
        System.out.println("TOP before=" + before);
        System.out.println("REVERSE D=" + index.reverse(dKey));
        System.out.println("BALANCE tenant-a/alice=" + index.balance(aa));
        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(ab));
        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(ab));
        System.out.println("SNAPSHOT unchanged=" + before);
    }
}

关键步骤:先校验旧余额与旧榜,再用 addExact/subtractExact 和计数预检算出全部新值;之后删除旧榜键、更新聚合/事实/计数/缓存。Top 沿 TreeMap 顺序逐项反查聚合,再复制为独立列表。重建先在临时结构精确完成并校验,才替换派生 Map;最后清空无法从事实推导的访问缓存。

复杂度:成功发布/冲正为 O(log a);Top 为 O(log a + k)、快照空间 O(k);重建为 O(n + a log a),精确临时聚合空间 O(n + a)、最坏 O(n)。代码已按 Java 21 编译并以昨日规定输出运行验证;它仍是单线程演示,最后多张 Map 的替换不是生产级原子提交。

第 5 题参考答案:源码追踪

固定版本均为 OpenJDK jdk-21+35

问题 公开入口 关键字段 主路径(最多 4 个箭头节点) 回到本题的结果/扩展点
复合键查询、条件写入、同键换值和余额增删 getputIfAbsentputremovemerge tablesizemodCount get→getNodeput/putIfAbsent→putValremove→removeNode EntryKey/AccountKeyhashCode+equals 决定同一业务键;同键替换 value 通常不增加 size。单个复合方法也不让五张 Map 成为事务。
碰撞、扩容、树化和迭代修改检测 上述写入口、entrySet().iterator() thresholdloadFactorTREEIFY_THRESHOLDMIN_TREEIFY_CAPACITYmodCount putVal→resize/treeifyBinHashIterator.nextNode→expectedModCount 扩容按高低链迁移,是否树化受容量条件影响;不能猜桶顺序或树形。ConcurrentModificationException 是尽力检测,不是锁。
access-order 命中、更新与最老项淘汰 getput headtailaccessOrder get→afterNodeAccess;已有键 put→HashMap.putVal→afterNodeAccess;新键 put→afterNodeInsertion→removeEldestEntry 命中或已有键更新可移到尾部;removeEldestEntry 是新插入后的容量策略钩子。缓存顺序不是余额排名,也不能从无序事实重建。
排名定位、删除、插入和活视图 getputremoveentrySet rootsizemodCountcomparator 本题自然序查询 get/remove→getEntry→RankKey.compareToput→compareTo→addEntry;删除后 deleteEntry 本题没有显式 Comparator,RankKey.compareTo 的完整全序决定命中与覆盖;余额变化必须删旧键再插新键。集合视图背靠树,字符串复制快照不背靠树。
枚举状态计数 getputremovemerge keyTypekeyUniversevalssize put→typeCheck/ordinal→maskNull→写槽位remove→isValidKey/ordinal→清空槽位→unmaskNull 枚举序号定位固定槽位,迭代按枚举声明顺序;冲正要先验证旧计数再迁移,但多次计数调用仍不是跨 Map 原子事务。

补强建议

由于没有有效作答,本次不指定“大大的薄弱点”。最有价值的准备是先独立复述三件事:排序键为何必须带身份尾字段;为何所有可恢复失败应在第一次 Map 写入前被发现;为何快照、fail-fast 和 ConcurrentHashMap 单键能力都不能替代跨多索引的一致性方案。今天的新案件场景会再次验证这些能力,而不是机械复做积分题。

返回今日索引

Day 20 核心讲解:多租户风控案件调分与结案索引

建议用时:31~33 分钟。今天仍是 challenge.map 的闯关变式;课程生成不代表闯关通过,也不能提前进入 Set。

返回今日索引

一、为什么需要:一份案件事实,为什么不能只放进一张 Map

大大,风控人员看同一批案件时会提出完全不同的问题:按“租户 + 案件号”精确找主档;按账户查看所有开放案件的风险敞口;按风险分从高到低领取下一件;快速回看最近访问的三件案件;统计开放和已结案数量。单张 HashMap 擅长身份查找,却既不承诺顺序,也不能凭空维护账户聚合。若每次领取案件都扫描全部主档再排序,案件量上升后,读路径会反复付出 O(n log n)

因此本题采用“一份事实、四份派生状态”:facts 保存所有案件的当前主档;openExposurereviewQueue 和状态计数回答特定查询;最近缓存只加速访问。难点不在会不会调用 put,而在调分和结案会跨多张 Map:少删一个旧排序键,就会让同一案件在队列出现两次;先改事实再发现整数溢出,则系统已经留下半套新状态。

本课只讨论单线程内存模型。生产系统还要用锁、单线程事件循环、数据库事务或“构造新聚合后一次替换”等外层边界,处理并发、进程崩溃与持久化失败。

返回今日索引

二、前置知识:九个 Map 知识点在本题各做什么

知识点 本题落点
ds.map.contract getnull 歧义、put/remove 返回值、键和值的空值策略
ds.map.equality CaseKeyAccountKey 的值相等语义;键放入 Map 后不可变
ds.map.hashmap-structure 桶、负载因子、阈值及正常散列下的期望 O(1)
ds.map.hashmap-put-get-remove-source getNodeputValremoveNode 与替换旧值
ds.map.hashmap-resize-treeify-iterator-source 扩容拆桶、树化条件、modCount 与 fail-fast
ds.map.compute-merge-views 聚合更新、返回 null 删映射、活视图与快照
ds.map.linkedhashmap-source access-order、访问移尾和容量 3 淘汰
ds.map.treemap-source Comparator 全序、对数级重键和有序遍历
ds.map.specialized EnumMap 状态槽位,以及专用 Map 的适用边界

本题统一禁止业务键、案件值、账户值为 null。这样 facts.get(key) == null 就可明确表示“不存在”;若一个通用 Map 允许存 null 值,就必须用 containsKey 区分“缺键”和“映射到 null”。HashMap 的平均查找快,不等于最坏情况恒定,也不等于迭代顺序稳定。

随堂检查 1

两件案件风险分同为 70,Comparator 只比较风险分并返回 0,可以吗?

**即时答案:**不可以。TreeMap 会把 compare(a, b) == 0 视为同一个树键,后一项可能覆盖前一项。还要依次比较创建序号、租户 ID、案件 ID,得到唯一全序;这些字段都必须保持不可变。

返回今日索引

三、定义:先写不变量,再写操作

使用以下不可变业务对象:

  • CaseKey(tenantId, caseId) 是案件身份;两个分量共同参与 equals/hashCode
  • AccountKey(tenantId, accountId) 是账户聚合身份,避免相同账户号跨租户串账。
  • RiskCase(key, accountId, score, createdSequence, state) 是案件当前态。风险分限定为 1~1000,创建序号一经建案不再变化,状态只有 OPENAPPROVEDREJECTED
  • ReviewKey(score, createdSequence, tenantId, caseId) 按风险分降序、创建序号升序、租户和案件升序比较。不要用 b.score - a.score,它会溢出;使用 Integer.compare(b.score, a.score)

五个容器的不变量是:

  1. facts: HashMap<CaseKey, RiskCase> 保留全部案件。结案只是把不可变 value 替换为 APPROVEDREJECTED 新值,不能删除审计所需的当前主档。
  2. openExposure: HashMap<AccountKey, Integer> 的值等于该账户所有 OPEN 案件当前风险分之和;只保留正数,变成 0 就删键。
  3. reviewQueue: TreeMap<ReviewKey, CaseKey> 对每件 OPEN 案件恰有一项,对 APPROVEDREJECTED 案件没有项。
  4. 容量 3 的 access-order LinkedHashMap<CaseKey, RiskCase> 保存最近访问的最新值。淘汰缓存绝不改变事实或队列。
  5. EnumMap<CaseState, Integer> 统计 facts 中各状态数量;零计数可不存,所有非零计数之和等于 facts.size()

rescore 改的是当前案件分数,不是给账本追加一笔反向流水;新分数与旧分数相同时定义为无副作用返回,避免把一次重试误算成新变更。若业务需要每次调分的历史,还应另建不可变事件日志。close(key, terminalState) 的目标只允许 APPROVEDREJECTED:案件一旦进入任一终态,再次结案或试图改判都返回无变化,不能重复扣减敞口。

返回今日索引

四、心智模型:预检区与提交区之间画一条线

把每个命令想成“读取旧态 → 推导新态 → 全量预检 → 提交写入”四步。预检区可以失败,但不能修改任何 Map;越过提交线后,不再做正常业务校验或可能溢出的计算。预检也不能先调用 access-order 缓存的 get,因为成功命中会移动节点,失败命令连 LRU 热度都不应改变。

open 先校验键、账户、风险分和序号,确认案件不存在;用 Math.addExact 计算新敞口和新状态数,构造排序键并确认不会与其他案件碰撞。全部成功后,才依次写事实、敞口、队列、计数和缓存。

rescore 先取得且确认旧案件仍为 OPEN,再验证旧 ReviewKey 确实映射到该案件、旧敞口足够,并用精确算术算出“旧敞口 - 旧分 + 新分”;同时构造不可变新值与新排序键并检查冲突。提交时必须先删旧排序键,再替换事实、敞口并插新键。创建序号不变,因此同分案件的先后不会被调分伪造。

close 先在任何 Map 写入前拒绝 OPEN 作为目标,只接受 APPROVED/REJECTED;再验证旧案件确为 OPEN、旧队列项、敞口和状态计数,预计算扣减后的敞口及两个新计数,最后构造指定终态的新值。提交时删除队列项、替换事实;敞口为 0 时删账户键;最后把计数从 OPEN 迁移到指定终态并刷新缓存。

这条线只能避免“可预见的校验或算术失败造成半写”。它不是事务:进程可能在第五次写入前崩溃,另一个线程也可能看到中间态。把所有容器换成 ConcurrentHashMap 只保证各自的单键操作,不会自动提供跨键、跨集合原子性。

随堂检查 2

既然全部计算都在提交前完成,为什么仍不能宣称 rescore 是事务?

**即时答案:**因为提交仍由多次独立 Map 修改组成;崩溃、线程交错或持久化失败都可能暴露中间态。预检解决确定性的业务失败,不解决提交过程的原子可见性。

返回今日索引

五、完整数值与状态推演:调分必须重键,结案必须退队

依次建案,字母只为推演方便:

代号 案件 账户 风险分 创建序号
D west/c-20 west/u3 70 99
A north/c-10 north/u1 70 100
B south/c-10 south/u2 85 101
C north/c-11 north/u1 30 102

四次 open(D, A, B, C) 后,facts.size() 为 4,计数为 OPEN=4。敞口为 north/u1=100south/u2=85west/u3=70。队列按全序排列为:

[B:85#101, D:70#99, A:70#100, C:30#102]

D 和 A 同为 70,序号 99 的 D 在前;案件号相同的 A 与 B 因租户不同仍是不同身份。缓存按 D、A、B、C 插入,第四次插入淘汰 D,顺序为 [A, B, C];读取 A 会把 A 移到末尾,变为 [B, C, A]。这个次序是 LinkedHashMap 的访问顺序,不能拿 HashMap 模拟。

此时复制 Top 3,得到稳定快照 [B:85, D:70, A:70]。它是新列表,而不是 reviewQueue.entrySet() 的活视图。

现在把 C 从 30 调到 90。预检算出 north/u1 新敞口为 100 - 30 + 90 = 160,并确认旧键 C:30#102 存在、新键 C:90#102 不冲突。提交后队列变为 [C:90, B:85, D:70, A:70];事实中的 C 被不可变新值替换;状态计数仍是 OPEN=4;缓存更新 C 后由 [B, C, A] 变为 [B, A, C]。先前 Top 3 快照仍是 [B, D, A],不会随队列变化。

接着以 APPROVED 结案 A。预检得到 north/u1 敞口 160 - 70 = 90。提交后 A 仍留在 facts,但状态为 APPROVED;队列为 [C:90, B:85, D:70];计数为 OPEN=3, APPROVED=1;缓存成为 [B, C, A],其中 A 是最新 APPROVED 值。

最后以 REJECTED 结案 D。west/u3 的敞口从 70 变 0,所以删除该账户键;队列剩 [C:90, B:85],计数为 OPEN=2, APPROVED=1, REJECTED=1facts.size() 仍为 4。D 已不在缓存,写入后淘汰最老的 B,缓存为 [C, A, D]。最终敞口只剩 north/u1=90south/u2=85。再次用任一终态结案 D 都必须无副作用。

返回今日索引

六、源码映射:从业务问题走到 jdk-21+35 主调用链

下面固定以 OpenJDK jdk-21+35 为基线;字段和调用链是当前实现细节,公开 API 契约才是业务代码可以依赖的边界。

问题 入口类 / 方法 关键字段 主调用链 可验证的扩展点
身份查找与替换 HashMap.get/put/remove tablesizethresholdmodCount get → getNodeput → putValremove → removeNode 自定义键的 equals/hashCode 必须稳定
扩容、冲突与遍历 HashMap.putVal/resize/treeifyBin loadFactor、桶链表/树节点 达阈值后 resize,旧桶按 oldCap 位拆到原位置或 +oldCap 初始容量、负载因子和好散列只能优化,不给顺序承诺
风险队列全序 TreeMap.put/remove/firstEntry rootsizemodCountcomparator 本题 get/remove → getEntry → ReviewKey.compareTo;插入/删除后做红黑树修复 自然序或显式 Comparator 都必须覆盖唯一尾部字段
最近缓存 LinkedHashMap.get/put headtailaccessOrder HashMap 定位 → afterNodeAccess 移尾;新插入后 afterNodeInsertion 已有键 put 也会走 afterNodeAccess;覆盖 removeEldestEntry 实现容量 3
状态计数 EnumMap.get/put keyType、按 ordinal 定位的数组 枚举序号直接定位槽位 状态集合封闭且不会动态增加键类型

举一个不同于业务数据的扩容观察:容量 32、默认负载因子 0.75 时阈值约为 24,第 25 个映射会触发扩容到 64。若两个经扰动后的哈希在旧表落到桶 11,源码用 (e.hash & oldCap) 检查值为 32 的掩码位:结果为 0 的节点仍在索引 11,结果为 32 的节点移动到 11 + 32 = 43,无需重新计算完整取模。TREEIFY_THRESHOLD=8MIN_TREEIFY_CAPACITY=64 共同约束树化:表太小时优先扩容,不能看到长链就断言已经变成红黑树。

computemerge 很适合表达单张 Map 的聚合,但回调返回 null 会删除映射,且回调不应反向结构性修改同一 Map。本题还要预检另一张队列,所以应先用局部变量和 Math.addExact/subtractExact 算好,再进入提交区,不能让一次 merge 先改敞口。

源码直链:HashMap.javaLinkedHashMap.javaTreeMap.javaEnumMap.java。调试练习:在 HashMap.resizeTreeMap.putLinkedHashMap.afterNodeAccess 各设断点,观察桶拆分、ReviewKey.compareTo 的自然序路径,以及一次 get 如何改变 access-order。遍历期间的结构修改可能因 modCount 触发 ConcurrentModificationException;fail-fast 只是尽力发现错误,绝不是线程安全机制。

随堂检查 3

Collections.unmodifiableMap(reviewQueue) 返回给调用方,能叫“稳定快照”吗?

**即时答案:**不能。它禁止调用方经包装写入,却仍能看见底层 Map 后续变化;entrySetkeySetvalues 也是活视图。稳定快照要复制所需数据,例如先生成独立列表,再用 List.copyOf 固化。

返回今日索引

七、真实后端应用:领取下一件案件时,读快、写稳、可核验

在多租户风控工作台中,案件详情接口读 facts;账户页读 openExposure;领取接口从 reviewQueue.firstEntry() 取得最高风险开放案件;仪表盘读 EnumMap;热点详情先查最近缓存。Top K 可从树首顺序复制 K 项,时间复杂度约为 O(log o + k),其中 o 是开放案件数;建案、调分、结案的主成本是树插入或删除 O(log o),事实和敞口的 HashMap 操作在正常散列下为期望 O(1)。空间为 O(n + o),容量 3 缓存与枚举状态槽位是常量级。

生产实现可以让同一租户的命令进入一个串行执行器,或在数据库事务中写主档、敞口和待审索引;若快照读与命令写并发,还要定义一致性级别。恢复时可以从当前案件事实校验或重建派生索引,但这仍不同于昨日的积分流水冲正:案件主档保存的是当前状态,完整调分审计应由独立事件表承担。

接口边界还要校验租户归属,不能只凭全局案件号取数。任何缓存命中都要返回与 facts 当前版本一致的不可变值;缓存丢失或淘汰只影响性能,不得改变业务答案。

上线前还应提供只读一致性审计:遍历 facts.values(),在局部容器中重新计算 OPEN 敞口、应有的 ReviewKey 集合和状态计数,再用 Map/集合的内容相等比较正式索引。审计不能依赖 HashMap 的访问顺序,也不能边遍历边修正式 Map;否则既可能触发 fail-fast,也会把诊断和修复混成一次不可回退操作。若审计发现差异,应记录案件身份和不变量名称,由受控恢复流程处理,而不是悄悄相信缓存或任选一张派生表。这种检查能发现历史半写,却不能替代写命令本身的原子性边界。

返回今日索引

八、错误示例:看似少写几行,实际破坏索引

以下都是错误思路:

1. Comparator 只比较 score;同分案件被 TreeMap 当成同一键。
2. 把可变 RiskCase 本身当 HashMap 键,调分后 hashCode 改变,原映射可能再也找不到。
3. 调分只插入新 ReviewKey,不删除旧键;同一案件在队列出现两次。
4. 结案直接 facts.remove;当前主档和状态总数失去审计依据。
5. 先 openExposure.merge,再验证旧队列键;后续失败时已经产生半写。
6. 把 reviewQueue.entrySet() 或只读包装当快照;之后调分让历史结果悄悄变化。
7. 依赖 HashMap/HashSet 的打印顺序;换容量、JDK 或数据后结果可能变化。
8. 捕获 ConcurrentModificationException 后继续遍历,或换 ConcurrentHashMap 就宣称跨 Map 原子。

同样需要挡住两个旁支误区:不知道节点位置时,LinkedList 的中间插删仍要先遍历,不能天然称为 O(1)WeakHashMap 可能因 GC 丢业务事实,IdentityHashMap 比较对象身份而非业务值,都不适合案件主档。

返回今日索引

九、正确示例:只演示全序、快照与访问顺序合同

下面是与今日三个编码 TODO 独立的小程序。它手工演示一次重键,不提供完整的 open/rescore/close 服务实现;重点是让合同可以编译、运行和观察。

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.TreeMap;

public final class ReviewOrderContractDemo {
    enum CaseState { OPEN, APPROVED, REJECTED }

    record CaseKey(String tenantId, String caseId) {
        CaseKey {
            if (tenantId == null || tenantId.isBlank()
                    || caseId == null || caseId.isBlank()) {
                throw new IllegalArgumentException("blank case identity");
            }
        }
    }

    record RiskCase(CaseKey key, int score, long createdSequence, CaseState state) {
        RiskCase {
            if (key == null || score < 1 || score > 1000
                    || createdSequence < 0 || state == null) {
                throw new IllegalArgumentException("invalid case");
            }
        }
    }

    record ReviewKey(int score, long createdSequence, String tenantId, String caseId)
            implements Comparable<ReviewKey> {
        @Override
        public int compareTo(ReviewKey other) {
            int result = Integer.compare(other.score, score);
            if (result != 0) return result;
            result = Long.compare(createdSequence, other.createdSequence);
            if (result != 0) return result;
            result = tenantId.compareTo(other.tenantId);
            return result != 0 ? result : caseId.compareTo(other.caseId);
        }
    }

    static final class RecentCache extends LinkedHashMap<CaseKey, RiskCase> {
        private final int maxEntries;

        RecentCache(int maxEntries) {
            super(16, 0.75f, true);
            this.maxEntries = maxEntries;
        }

        @Override
        protected boolean removeEldestEntry(Map.Entry<CaseKey, RiskCase> eldest) {
            return size() > maxEntries;
        }
    }

    private static ReviewKey reviewKey(RiskCase riskCase) {
        return new ReviewKey(riskCase.score(), riskCase.createdSequence(),
                riskCase.key().tenantId(), riskCase.key().caseId());
    }

    private static String label(RiskCase riskCase) {
        return riskCase.key().tenantId() + "/" + riskCase.key().caseId()
                + "=" + riskCase.score() + "#" + riskCase.createdSequence();
    }

    private static List<String> first(TreeMap<ReviewKey, CaseKey> queue,
                                      Map<CaseKey, RiskCase> facts,
                                      int limit) {
        var result = new ArrayList<String>();
        for (CaseKey key : queue.values()) {
            if (result.size() == limit) break;
            result.add(label(facts.get(key)));
        }
        return List.copyOf(result); // 复制后不再追随活队列变化。
    }

    private static List<String> cacheLines(RecentCache cache) {
        var result = new ArrayList<String>();
        for (RiskCase value : cache.values()) {
            result.add(value.key().tenantId() + "/" + value.key().caseId()
                    + "=" + value.state() + ":" + value.score());
        }
        return List.copyOf(result);
    }

    public static void main(String[] args) {
        var facts = new HashMap<CaseKey, RiskCase>();
        var queue = new TreeMap<ReviewKey, CaseKey>();
        var cache = new RecentCache(3);
        var counts = new EnumMap<CaseState, Integer>(CaseState.class);

        var a = new RiskCase(new CaseKey("tenant-x", "k-1"), 75, 40, CaseState.OPEN);
        var b = new RiskCase(new CaseKey("tenant-y", "k-2"), 75, 39, CaseState.OPEN);
        var c = new RiskCase(new CaseKey("tenant-x", "k-3"), 95, 41, CaseState.OPEN);
        var d = new RiskCase(new CaseKey("tenant-z", "k-4"), 50, 38, CaseState.OPEN);

        for (RiskCase value : List.of(a, b, c, d)) {
            facts.put(value.key(), value);
            queue.put(reviewKey(value), value.key());
            counts.merge(value.state(), 1, Math::addExact);
        }

        var snapshot = first(queue, facts, 3);
        cache.put(a.key(), a);
        cache.put(b.key(), b);
        cache.put(c.key(), c);
        cache.get(a.key());
        cache.put(d.key(), d);

        var rescoredA = new RiskCase(a.key(), 99, a.createdSequence(), a.state());
        // 先删除由旧分数构成的树键,身份键和创建序号保持不变。
        queue.remove(reviewKey(a));
        facts.put(a.key(), rescoredA);
        queue.put(reviewKey(rescoredA), rescoredA.key());
        cache.put(rescoredA.key(), rescoredA);

        System.out.println("FACTS_SIZE=" + facts.size());
        System.out.println("QUEUE=" + first(queue, facts, 10));
        System.out.println("SNAPSHOT=" + snapshot);
        System.out.println("CACHE=" + cacheLines(cache));
        System.out.println("COUNTS=" + counts);
    }
}

预期输出:

FACTS_SIZE=4
QUEUE=[tenant-x/k-1=99#40, tenant-x/k-3=95#41, tenant-y/k-2=75#39, tenant-z/k-4=50#38]
SNAPSHOT=[tenant-x/k-3=95#41, tenant-y/k-2=75#39, tenant-x/k-1=75#40]
CACHE=[tenant-x/k-3=OPEN:95, tenant-z/k-4=OPEN:50, tenant-x/k-1=OPEN:99]
COUNTS={OPEN=4}

注意:示例故意不输出 facts.entrySet(),因为它的迭代顺序不是业务合同。完整题还必须在首个 Map 写入之前验证旧事实、旧队列、旧敞口、计数、溢出和新键冲突。

返回今日索引

十、边界总结:用六条红线验收 Map 阶段

大大可以用下面的口径自检:

  1. HashMapHashSet 没有稳定业务顺序;需要顺序就显式使用 TreeMapLinkedHashMap 或复制后排序。
  2. 业务身份键必须不可变并遵守 equals/hashCode;树键 Comparator 必须全序,不能让不同案件比较为 0。
  3. keySet/values/entrySet 是活视图;只读包装仍会跟随底层变化;稳定快照必须复制。
  4. 未知节点位置时,LinkedList 中间操作包含查找成本,不是天然 O(1)
  5. fail-fast 不是并发控制,只是尽力暴露错误用法。
  6. ConcurrentHashMap 的单键原子方法不能升级为跨键、跨 Map 事务。

此外,access-order 缓存的一次 get 也会重排;compute/mergenull 结果可能删键;EnumMap 只适合已知枚举键;HashMap 容量规划影响扩容成本却不改变契约。真正过关的标准不是背出类名,而是能从不变量推导每次建案、调分、结案的旧键删除、新值替换、聚合更新、状态迁移与快照边界,并用 Java 21 编码和预期输出证明这些状态始终一致。

返回今日索引

返回今日索引

编码闯关:多租户风控案件调分与结案索引

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

你要完成一个单线程风控案件索引。案件事实一经创建便保留;调分只能替换同一不可变案件键对应的 value,结案只能把 OPEN 迁移为 APPROVEDREJECTED。待复核队列把可变分数编码进排序键,因此调分时必须先删除旧键,再插入新键。

1. 业务背景与五张 Map

不同租户都可能存在 case-1alice,所以案件身份是 CaseKey(tenantId, caseId),账户身份是 AccountKey(tenantId, accountId)。分数、创建序号和状态属于案件 value,不属于身份。

  • factsHashMap<CaseKey, RiskCase>,保留全部案件,是唯一事实来源;结案不删除事实。
  • openExposureHashMap<AccountKey, Integer>,聚合同账户全部 OPEN 案件的正分数;总分归零时删除键。
  • reviewQueueTreeMap<ReviewKey, CaseKey>,只收录 OPEN 案件;按分数降序、创建序号升序、租户与案件号升序形成全序。
  • recentCases:容量 3 的 access-order LinkedHashMap<CaseKey, RiskCase>;创建、调分、结案和显式观察都会更新热度,但它不是事实表。
  • stateCountsEnumMap<CaseState, Integer>,统计 OPEN/APPROVED/REJECTED 案件数,零计数不保留。

已实现的 open 会先完成查重、旧聚合读取、排序键碰撞检查和算术溢出预检,再进行第一次 Map 写入。你只补全 Top 快照、开放案件调分、幂等结案三个位置。

2. 约束与验收边界

  • 基线为 Java 21,仅使用 JDK;不添加依赖、日志、调试输出或吞掉异常的 try/catch
  • tenantId/caseId/accountId 不能为空白;riskScore 必须在 1..1000createdSequence 必须非负。键进入 Map 后不可改变,Map 不接收 null 键或 null value。
  • 新案件必须为 OPEN。完整 CaseKey 重复时,open 返回 false,五张 Map 均无副作用,缓存热度也不变。
  • openExposure 只保留正总分。在线累计、调分差值和扣减均用 Math.addExact/subtractExact;算术检查、状态计数检查、旧队列键核对和新键碰撞检查必须在本次第一次 Map 写入前完成。
  • ReviewKey.compareToriskScore 降序、createdSequence 升序、tenantId/caseId 升序比较。合法对象上,比较结果为 0 必须恰好表示排序字段与完整案件身份都相等。
  • topSnapshot(limit) 按当前队列顺序取最多 limit 项;逐项回查事实、状态、完整旧排序键与账户聚合,返回独立、不可修改的字符串快照。limit == 0 返回空快照;负数在读取业务数据前失败。
  • rescore(key, newScore) 仅处理存在且状态为 OPEN 的案件;缺失、已结案或同分返回 false,所有容器无副作用。成功时先核对旧队列键和旧暴露总分,预算分数差、新总分及新排序键,之后删除旧队列键、替换不可变事实 value、写回聚合、插入新键并刷新缓存。
  • close(key, terminalState) 的目标只允许 APPROVED/REJECTED。缺失或已结案返回 false 且无副作用;成功时从队列删除旧键,从账户暴露扣除本案分数(归零删键),保留相同案件键的终态新 value,迁移枚举计数并刷新缓存。
  • 本题为单线程模型。普通 Map 的连续写入不是事务;即使改成多个 ConcurrentHashMap,也不会自动获得跨键、跨集合原子性。生产实现需要共同锁、数据库事务,或校验完毕后原子替换不可变聚合引用。
  • 禁止依赖 HashMap/HashSet 遍历顺序,破坏 equals/hashCode 或 Comparator 契约,混淆活视图、只读包装与快照,把 fail-fast 当线程安全,或把 ConcurrentHashMap 单键能力扩大成业务事务。

3. 目标拆解与实施顺序

  1. 先读 open 和三个不可变 record,写下事实、聚合、排序、缓存、计数各自的不变量。
  2. 完成 Top:沿 reviewQueue.entrySet() 的既定顺序遍历,逐项交叉核对后投影字符串,最后冻结结果。
  3. 完成调分:所有可失败检查都放在写入前;同分是严格的无副作用幂等分支。
  4. 完成结案:先预算账户新暴露和两个状态的新计数,再一次性迁移事实与四张派生 Map。
  5. 用 Java 21 编译运行,逐行核对规定输出;再说明多 Map 生产原子性边界。

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

保存为 TenantRiskCaseChallenge.java。未补代码时仍可编译;直接运行会完成 A~E 创建、重复去重和一次缓存命中,然后在第一个待补位置明确失败。起始代码恰好只有 3 个待补位置,当天不提供完整实现。

import java.util.ArrayList;
import java.util.Comparator;
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 TenantRiskCaseChallenge {
    private enum CaseState {
        OPEN,
        APPROVED,
        REJECTED
    }

    private record CaseKey(String tenantId, String caseId) {
        CaseKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(caseId, "caseId");
            if (tenantId.isBlank() || caseId.isBlank()) {
                throw new IllegalArgumentException("invalid case key");
            }
        }

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

    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 RiskCase(
            CaseKey key,
            String accountId,
            int riskScore,
            long createdSequence,
            CaseState state) {
        RiskCase {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(accountId, "accountId");
            Objects.requireNonNull(state, "state");
            if (accountId.isBlank()
                    || riskScore < 1
                    || riskScore > 1000
                    || createdSequence < 0) {
                throw new IllegalArgumentException("invalid risk case");
            }
        }

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

        RiskCase withScore(int newScore) {
            return new RiskCase(
                    key, accountId, newScore, createdSequence, state);
        }

        RiskCase closedAs(CaseState terminalState) {
            return new RiskCase(
                    key, accountId, riskScore, createdSequence, terminalState);
        }

        String cacheLabel() {
            return key.label() + "=" + state + ":" + riskScore;
        }
    }

    private record ReviewKey(
            int riskScore,
            long createdSequence,
            String tenantId,
            String caseId) implements Comparable<ReviewKey> {
        static ReviewKey from(RiskCase riskCase) {
            return new ReviewKey(
                    riskCase.riskScore(),
                    riskCase.createdSequence(),
                    riskCase.key().tenantId(),
                    riskCase.key().caseId());
        }

        @Override
        public int compareTo(ReviewKey other) {
            int byScore = Integer.compare(other.riskScore, riskScore);
            if (byScore != 0) {
                return byScore;
            }
            int byCreated = Long.compare(createdSequence, other.createdSequence);
            if (byCreated != 0) {
                return byCreated;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            return byTenant != 0
                    ? byTenant
                    : caseId.compareTo(other.caseId);
        }
    }

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

        private final Map<CaseKey, RiskCase> facts = new HashMap<>();
        private final Map<AccountKey, Integer> openExposure = new HashMap<>();
        private final NavigableMap<ReviewKey, CaseKey> reviewQueue =
                new TreeMap<>();
        private final LinkedHashMap<CaseKey, RiskCase> recentCases =
                new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<CaseKey, RiskCase> eldest) {
                        return size() > RECENT_LIMIT;
                    }
                };
        private final EnumMap<CaseState, Integer> stateCounts =
                new EnumMap<>(CaseState.class);

        private boolean open(RiskCase riskCase) {
            Objects.requireNonNull(riskCase, "riskCase");
            if (riskCase.state() != CaseState.OPEN) {
                throw new IllegalArgumentException("new case must be open");
            }
            if (facts.containsKey(riskCase.key())) {
                return false;
            }

            AccountKey account = riskCase.accountKey();
            int oldExposure = openExposure.getOrDefault(account, 0);
            if (oldExposure < 0) {
                throw new IllegalStateException("negative open exposure");
            }
            int newExposure = Math.addExact(
                    oldExposure, riskCase.riskScore());
            int newOpenCount = Math.addExact(
                    stateCounts.getOrDefault(CaseState.OPEN, 0), 1);
            ReviewKey reviewKey = ReviewKey.from(riskCase);
            if (reviewQueue.containsKey(reviewKey)) {
                throw new IllegalStateException("duplicate review key");
            }

            // 所有可恢复校验与算术预检均已完成,下面才开始写 Map。
            facts.put(riskCase.key(), riskCase);
            openExposure.put(account, newExposure);
            reviewQueue.put(reviewKey, riskCase.key());
            stateCounts.put(CaseState.OPEN, newOpenCount);
            recentCases.put(riskCase.key(), riskCase);
            return true;
        }

        private List<String> topSnapshot(int limit) {
            if (limit < 0) {
                throw new IllegalArgumentException("limit must not be negative");
            }
            // TODO 1:沿 TreeMap 榜序核对事实与聚合,返回不可修改快照。
            throw new UnsupportedOperationException("topSnapshot");
        }

        private boolean rescore(CaseKey key, int newRiskScore) {
            Objects.requireNonNull(key, "key");
            if (newRiskScore < 1 || newRiskScore > 1000) {
                throw new IllegalArgumentException("invalid risk score");
            }
            // TODO 2:同分无副作用;其余先预检,再迁移旧/新排序键。
            throw new UnsupportedOperationException("rescore");
        }

        private boolean close(CaseKey key, CaseState terminalState) {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(terminalState, "terminalState");
            if (terminalState != CaseState.APPROVED
                    && terminalState != CaseState.REJECTED) {
                throw new IllegalArgumentException("target must be terminal");
            }
            // TODO 3:仅 OPEN 可结案;预算聚合和计数后再统一写入。
            throw new UnsupportedOperationException("close");
        }

        private String observe(CaseKey key) {
            Objects.requireNonNull(key, "key");
            RiskCase riskCase = facts.get(key);
            if (riskCase == null) {
                return "MISSING";
            }
            RiskCase cached = recentCases.get(key);
            if (!riskCase.equals(cached)) {
                recentCases.put(key, riskCase);
            }
            return riskCase.cacheLabel();
        }

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

        private List<String> exposureSummary() {
            return openExposure.entrySet().stream()
                    .sorted(Comparator.comparing(
                            entry -> entry.getKey().label()))
                    .map(entry -> entry.getKey().label()
                            + "=" + entry.getValue())
                    .toList();
        }

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

        private List<String> recentOrder() {
            return recentCases.values().stream()
                    .map(RiskCase::cacheLabel)
                    .toList();
        }

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

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

        RiskCase a = new RiskCase(
                aKey, "alice", 70, 20, CaseState.OPEN);
        RiskCase b = new RiskCase(
                bKey, "alice", 90, 10, CaseState.OPEN);
        RiskCase c = new RiskCase(
                cKey, "bob", 90, 12, CaseState.OPEN);
        RiskCase d = new RiskCase(
                dKey, "alice", 30, 15, CaseState.OPEN);
        RiskCase e = new RiskCase(
                eKey, "zoe", 60, 11, CaseState.OPEN);

        RiskIndex index = new RiskIndex();
        System.out.println("open-a=" + index.open(a));
        System.out.println("open-b=" + index.open(b));
        System.out.println("open-c=" + index.open(c));
        System.out.println("open-d=" + index.open(d));
        System.out.println("open-e=" + index.open(e));
        System.out.println("duplicate-a=" + index.open(a));
        System.out.println("observe-c=" + index.observe(cKey));
        System.out.println("recent-before=" + index.recentOrder());

        List<String> initialTop = index.topSnapshot(5);
        System.out.println("initial-top=" + initialTop);
        System.out.println("initial-exposure=" + index.exposureSummary());
        System.out.println("top-zero=" + index.topSnapshot(0));

        System.out.println("rescore-d=" + index.rescore(dKey, 95));
        System.out.println("rescore-d-same=" + index.rescore(dKey, 95));
        System.out.println("top-after-rescore=" + index.topSnapshot(5));
        System.out.println("close-b="
                + index.close(bKey, CaseState.APPROVED));
        System.out.println("close-b-again="
                + index.close(bKey, CaseState.REJECTED));
        System.out.println("close-a="
                + index.close(aKey, CaseState.REJECTED));

        System.out.println("facts=" + index.factCount());
        System.out.println("final-exposure=" + index.exposureSummary());
        System.out.println("final-queue=" + index.queueSummary());
        System.out.println("final-counts=" + index.countSummary());
        System.out.println("recent-after=" + index.recentOrder());
        System.out.println("old-snapshot=" + initialTop);
    }
}

5. 三级提示

提示一级:先写不变量

  • Top 中的每个 ReviewKey 都应能定位一个 OPEN 事实,且 ReviewKey.from(fact) 与当前键相等。
  • 一个账户的 openExposure 可能包含多案总分,调分只增加 newScore - oldScore,结案只扣除本案旧分。
  • 缺失、非 OPEN 和同分分支必须在任何缓存 get/put 之前返回;access-order 的一次命中本身也会改变顺序。

提示二级:把可失败步骤前移

  • 调分前先拿到旧事实、旧 ReviewKey、账户旧暴露;核对 reviewQueue.get(oldKey),再用 exact 算术预算差值和新暴露,并检查新键不与别案冲突。
  • 结案前先核对旧队列键、旧暴露和 OPEN 计数;预算扣减后暴露、OPEN 新计数与目标终态新计数。新暴露只能大于等于 0。
  • 预检完成后再按“删旧队列键 → 更新事实/聚合 → 插新队列键(若仍开放)→ 更新计数/缓存”的顺序写入。

提示三级:快照与计数

  • Top 可使用 ArrayList 收集,达到 limit 即停止,最终用 List.copyOf 冻结;不要返回 entrySet()keySet() 或其只读包装。
  • 调分不迁移状态计数;结案将 OPEN 减 1,并把目标终态加 1。所有新计数先用局部变量和 exact 算术算完。
  • 成功调分和结案写入新的不可变 RiskCase;不要原地改变键或排序字段。

6. 预期精确输出

补全三个位置后,运行结果必须逐行一致:

open-a=true
open-b=true
open-c=true
open-d=true
open-e=true
duplicate-a=false
observe-c=tenant-a/case-2=OPEN:90
recent-before=[tenant-a/case-3=OPEN:30, tenant-c/case-9=OPEN:60, tenant-a/case-2=OPEN:90]
initial-top=[tenant-b/case-1@alice=90, tenant-a/case-2@bob=90, tenant-a/case-1@alice=70, tenant-c/case-9@zoe=60, tenant-a/case-3@alice=30]
initial-exposure=[tenant-a/alice=100, tenant-a/bob=90, tenant-b/alice=90, tenant-c/zoe=60]
top-zero=[]
rescore-d=true
rescore-d-same=false
top-after-rescore=[tenant-a/case-3@alice=95, tenant-b/case-1@alice=90, tenant-a/case-2@bob=90, tenant-a/case-1@alice=70, tenant-c/case-9@zoe=60]
close-b=true
close-b-again=false
close-a=true
facts=5
final-exposure=[tenant-a/alice=95, tenant-a/bob=90, tenant-c/zoe=60]
final-queue=[tenant-a/case-3=95, tenant-a/case-2=90, tenant-c/case-9=60]
final-counts=[OPEN=3, APPROVED=1, REJECTED=1]
recent-after=[tenant-a/case-3=OPEN:95, tenant-b/case-1=APPROVED:90, tenant-a/case-1=REJECTED:70]
old-snapshot=[tenant-b/case-1@alice=90, tenant-a/case-2@bob=90, tenant-a/case-1@alice=70, tenant-c/case-9@zoe=60, tenant-a/case-3@alice=30]

7. 复杂度要求

设事实数为 n、开放案件数为 q、某账户开放案件数为 m、实际返回 Top 数为 k

  • 完整案件键的事实查询,平均期望 O(1),最坏情况不能承诺为严格常数;空间 O(n)
  • openrescore/close 的 HashMap 部分平均期望 O(1),TreeMap 删除或插入为 O(log q);单次操作额外空间 O(1)
  • topSnapshot 从 TreeMap 根定位首项并投影 k 项,为 O(log q + k) 时间与 O(k) 独立快照空间;limit == 0 的提前返回分支为 O(1),逐项聚合核对仍是平均期望常数查询。
  • access-order 缓存命中/更新平均期望 O(1),容量固定为 3,但它不改变事实或队列复杂度。
  • 不要为了扣除本案分数扫描某账户全部 m 个案件;也不要声称 LinkedList 在未知节点位置时中间查找/删除天然为 O(1)

8. 边界用例

至少自行验证以下情况;不得为了“通过”而捕获并忽略预期异常:

  • 两租户使用相同案件号和账户号,仍是不同案件、不同账户聚合。
  • 两案同分、同创建序号时,租户与案件号仍给出稳定全序,且不覆盖彼此。
  • topSnapshot(0) 为空;负数失败;返回列表不能 add/remove,旧快照不随调分或结案变化。
  • 重复创建、缺失调分、已结案调分、同分调分、缺失结案和重复结案均无副作用,尤其不刷新 LRU 顺序。
  • 某账户只有一个开放案件时结案,总分归零并删除 openExposure 键;其他账户与其他租户不受影响。
  • 调分加法、结案减法或计数增量溢出时,在首次 Map 写入前暴露异常,不能留下半更新业务状态。
  • 迭代期间结构修改可能 fail-fast,但这不是互斥、可见性、线程安全或一致快照保证。

9. 提交与自检清单

  • 三个待补位置全部完成,提交的是完整 TenantRiskCaseChallenge.java,而非代码片段。
  • javac --release 21 TenantRiskCaseChallenge.java 成功,java TenantRiskCaseChallenge 输出逐行一致。
  • 完整租户键不可变,equals/hashCodeReviewKey.compareTo 契约未被破坏。
  • 所有可恢复校验、旧索引核对与 exact 算术都发生在该操作首次 Map 写入之前。
  • Top 是独立不可修改快照,没有依赖 HashMap 顺序或泄露活视图。
  • 调分删除旧队列键、插入新键并按差值更新账户聚合;同分严格无副作用。
  • 结案保留事实、移出队列、扣减暴露、迁移计数;重复结案严格无副作用。
  • 说明普通 Map、fail-fast 与 ConcurrentHashMap 都不能自动提供本题的跨键、跨 Map 原子性。

返回今日索引

返回今日索引

Map 阶段闯关题:多租户风控案件调分与结案索引

建议用时:约 7~8 分钟。请优先在 互动页面 作答,聊天提交仅作补充。当天不提供答案。 第 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 结案删事实、开放总分归零仍留键、失败分支刷新 LRU;把 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 原子方法或多次替换当业务事务
合计 契约与选型 25;复杂度与状态推演 25;编码 30;源码讲解 20 覆盖 Map 9 个知识项 100 六类红线均需检查

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

1. 契约与方案设计:事实、开放聚合、全序与缓存(25 分)

用最多 10 条定义 facts/openExposure/reviewQueue/recentCases/stateCounts 的选型、所有权和不变量,并覆盖:CaseKeyAccountKey 的租户边界,null 策略,不可变键与 value 替换,调分和结案语义,ReviewKey 如何补足全序,开放总分归零为何删键,access-order 缓存为何不是事实,队列活视图与 Top 字符串快照的差别。解释为何 HashMap/HashSet 的偶然遍历顺序不能表示优先级,以及 WeakHashMap、IdentityHashMap 为何不适合作为审计案件事实表。

2. 复杂度与结构推演:调分、结案和 Top 的成本(12 分)

设事实数为 n、开放案件数为 q、某账户开放案件数为 m、Top 实际返回 k。分别给出完整键事实查询、创建、调分、结案、Top 快照、状态计数与容量 3 最近缓存的时间/额外空间复杂度。说明 HashMap 碰撞、扩容、OpenJDK 21 树化条件为何否定“每次严格 O(1)”;再比较不知道目标节点时 LinkedList 的中间查找/删除与 TreeMap 按完整旧键删除的成本,并指出 fail-fast 没有提供的互斥、可见性、原子性与一致快照性质。

3. 状态推演与代码分析:同分全序、账户聚合和旧快照(13 分)

最近缓存容量为 2。依次创建 A=t-a/c-1→amy:80,seq=9、B=t-b/c-1→amy:80,seq=7、C=t-a/c-2→bob:60,seq=8、D=t-a/c-3→amy:20,seq=6;取得 Top 4 字符串快照;把 D 调为 95,再把 B 结为 APPROVED,把 A 结为 REJECTED,最后重复结案 A。

逐步写出每个操作后的开放账户总分、待复核队列、最近缓存和三状态计数,并写出最终四个事实状态、旧 Top 快照及当前队列。说明初始同为 80 时创建序号为何先决定顺序,仍相同时租户/案件尾字段如何防止覆盖;自行计算 t-a/amy 在创建、D 调分和 A 结案后的聚合值,并标出每一步加减所依据的案件分数;说明重复结案为什么连 LRU 热度也不能改变。最后区分队列活视图、只读包装和已取得的字符串快照,不得把多张 Map 的迁移称为自动事务。

4. 必交编码:完成 Top 快照、开放案件调分与幂等结案(30 分)

补全 编码练习 的 3 个待补位置,提交完整 TenantRiskCaseChallenge.javajavac --release 21 TenantRiskCaseChallenge.java 的成功证据,以及 java TenantRiskCaseChallenge 的完整且逐行一致输出。再用不超过 8 句话说明:失败分支为何必须在缓存访问和首次 Map 写入前返回;Top 如何同时核对事实、队列和账户聚合并冻结;调分如何预算差值、删除旧键、插入新键;结案如何归零删聚合键、保留事实并迁移枚举计数;生产环境为何需要共同锁、事务或不可变聚合原子替换。

评分拆分:Top 榜序核对与不可修改快照 7 分;调分的幂等分支、旧/新键和暴露差值 8 分;结案的事实保留、队列/聚合/计数迁移和缓存 9 分;Java 21 编译、规定输出和生产边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:从固定入口解释本题可观察结果(20 分)

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

每行 4 分。必须从源码路径回到本题的返回值、size、队列顺序、旧键清理、快照、缓存淘汰和计数结果;不能猜红黑树具体形状,不能把内部阈值当公共契约,也不能把 fail-fast、普通 Map 或 ConcurrentHashMap 的单键能力扩张成业务顺序、线程安全或跨键/跨 Map 事务。

返回今日索引

课后作答

复盘问题与编码作答

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

1. 契约与方案设计:事实、开放聚合、全序与缓存(25 分)

用最多 10 条定义 facts/openExposure/reviewQueue/recentCases/stateCounts 的选型、所有权和不变量,并覆盖:CaseKeyAccountKey 的租户边界,null 策略,不可变键与 value 替换,调分和结案语义,ReviewKey 如何补足全序,开放总分归零为何删键,access-order 缓存为何不是事实,队列活视图与 Top 字符串快照的差别。解释为何 HashMap/HashSet 的偶然遍历顺序不能表示优先级,以及 WeakHashMap、IdentityHashMap 为何不适合作为审计案件事实表。

2. 复杂度与结构推演:调分、结案和 Top 的成本(12 分)

设事实数为 n、开放案件数为 q、某账户开放案件数为 m、Top 实际返回 k。分别给出完整键事实查询、创建、调分、结案、Top 快照、状态计数与容量 3 最近缓存的时间/额外空间复杂度。说明 HashMap 碰撞、扩容、OpenJDK 21 树化条件为何否定“每次严格 O(1)”;再比较不知道目标节点时 LinkedList 的中间查找/删除与 TreeMap 按完整旧键删除的成本,并指出 fail-fast 没有提供的互斥、可见性、原子性与一致快照性质。

3. 状态推演与代码分析:同分全序、账户聚合和旧快照(13 分)

最近缓存容量为 2。依次创建 A=t-a/c-1→amy:80,seq=9、B=t-b/c-1→amy:80,seq=7、C=t-a/c-2→bob:60,seq=8、D=t-a/c-3→amy:20,seq=6;取得 Top 4 字符串快照;把 D 调为 95,再把 B 结为 APPROVED,把 A 结为 REJECTED,最后重复结案 A。

逐步写出每个操作后的开放账户总分、待复核队列、最近缓存和三状态计数,并写出最终四个事实状态、旧 Top 快照及当前队列。说明初始同为 80 时创建序号为何先决定顺序,仍相同时租户/案件尾字段如何防止覆盖;自行计算 t-a/amy 在创建、D 调分和 A 结案后的聚合值,并标出每一步加减所依据的案件分数;说明重复结案为什么连 LRU 热度也不能改变。最后区分队列活视图、只读包装和已取得的字符串快照,不得把多张 Map 的迁移称为自动事务。

4. 必交编码:完成 Top 快照、开放案件调分与幂等结案(30 分)

补全 编码练习 的 3 个待补位置,提交完整 TenantRiskCaseChallenge.javajavac --release 21 TenantRiskCaseChallenge.java 的成功证据,以及 java TenantRiskCaseChallenge 的完整且逐行一致输出。再用不超过 8 句话说明:失败分支为何必须在缓存访问和首次 Map 写入前返回;Top 如何同时核对事实、队列和账户聚合并冻结;调分如何预算差值、删除旧键、插入新键;结案如何归零删聚合键、保留事实并迁移枚举计数;生产环境为何需要共同锁、事务或不可变聚合原子替换。

评分拆分:Top 榜序核对与不可修改快照 7 分;调分的幂等分支、旧/新键和暴露差值 8 分;结案的事实保留、队列/聚合/计数迁移和缓存 9 分;Java 21 编译、规定输出和生产边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:从固定入口解释本题可观察结果(20 分)

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

每行 4 分。必须从源码路径回到本题的返回值、size、队列顺序、旧键清理、快照、缓存淘汰和计数结果;不能猜红黑树具体形状,不能把内部阈值当公共契约,也不能把 fail-fast、普通 Map 或 ConcurrentHashMap 的单键能力扩张成业务顺序、线程安全或跨键/跨 Map 事务。

返回今日索引

可选:编码作答

尚未保存