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 操作需要更强的原子性边界。
可验证目标
能区分不可变案件身份键、账户聚合键和待审排序键,并说明 equals/hashCode 与 Comparator 全序各自承担的职责。
能逐步推演开户、调分、结案、敞口归零删键、旧排序键清理、访问顺序缓存淘汰和稳定快照。
能完成 Java 21 必交编码,编译并产生规定输出;缺失、重复、同分调分或重复结案不能产生副作用。
能沿 OpenJDK jdk-21+35 的入口、字段与主调用链解释 HashMap、TreeMap、LinkedHashMap 与 EnumMap 在本题中的行为。
能说明活视图、只读包装、不可变快照、fail-fast、单键原子 API 与跨 Map 一致性之间的边界。
60~75 分钟学习顺序
昨日复盘 :Day 19 五题完整参考答案与积分流水冲正完整实现(约 12~14 分钟)。
核心讲解 :用案件调分和结案串联事实表、敞口、待审队列、缓存与状态槽位(约 31~33 分钟)。
编码练习 :完成稳定 Top 快照、开放案件调分与结案;这是闯关通过的必交编码(约 18~20 分钟)。
复盘问题 :按四个固定维度提交 Map 阶段闯关答案(约 7~8 分钟)。
总预计用时约 68~75 分钟 。今天只验证 Map 模块,不提前进入 Set,也不把昨日参考答案算成大大的作答证据。
方向进度
课前与课程完成后的标准课覆盖均为 10/35(28.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 题参考答案:契约与方案设计
可用九条不变量回答:
facts: HashMap<EntryKey, PointEntry> 是审计事实源;EntryKey(tenantId, entryId) 的两个字段共同参与值相等和哈希,发布后不可变,冲正只替换 value 的状态,不删除事实。
balances: HashMap<AccountKey, Long> 是可重建聚合;AccountKey(tenantId, accountId) 隔离租户,余额为 0 就删键,所以“键不存在”在此处明确表示净余额为 0。
balanceRank: TreeMap<RankKey, AccountKey> 是非零余额的有序投影;RankKey 依次比较余额降序、租户升序、账户升序,使合法键 compareTo == 0 当且仅当完整排序身份相同。
每个非零账户必须在余额表和榜中各有且仅有一项,榜键余额必须等于余额表;余额变化要先以完整旧键核对并删除旧榜项,再插入新键。
recentBalances 是容量 3 的 access-order LinkedHashMap,表示访问热度而非事实;它可以缓存最近观察到的 0,命中和已有键更新都会移到尾部,最老项被淘汰不影响余额或榜。
stateCounts: EnumMap<EntryState,Integer> 用枚举的固定键域统计 POSTED/REVERSED;冲正使一项从前者迁移到后者,不能重复迁移。
入口拒绝 null 键、null value、空白身份和 delta == 0;同一完整 EntryKey 重复发布返回 false 且所有容器无副作用。
balanceRank.entrySet() 是随底层树变化的活视图;Collections.unmodifiableList(view) 只限制写入口,仍可能背靠可变数据;把当时的值投影为字符串后 List.copyOf 才是本题稳定快照。
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_THRESHOLD 和 MIN_TREEIFY_CAPACITY 等实现条件约束。它们是实现细节,不应写成 Map 公共契约。
若不知道节点位置,LinkedList 仍须先线性查找,所谓“中间删除天然 O(1)”只适用于已经持有节点/迭代器位置的局部步骤;TreeMap 用完整旧 RankKey 查找删除为 O(log a)。fail-fast 只是尽力检测结构性并发修改并可能抛异常,不提供互斥、可见性、复合操作原子性或一致快照。
第 3 题参考答案:状态推演
记 xa=t-x/anna、xb=t-x/ben、ya=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/anna 在 t-x/ben 前;50 分并列时 t-x/anna 在 t-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 个箭头节点)
回到本题的结果/扩展点
复合键查询、条件写入、同键换值和余额增删
get、putIfAbsent、put、remove、merge
table、size、modCount
get→getNode;put/putIfAbsent→putVal;remove→removeNode
EntryKey/AccountKey 的 hashCode+equals 决定同一业务键;同键替换 value 通常不增加 size。单个复合方法也不让五张 Map 成为事务。
碰撞、扩容、树化和迭代修改检测
上述写入口、entrySet().iterator()
threshold、loadFactor、TREEIFY_THRESHOLD、MIN_TREEIFY_CAPACITY、modCount
putVal→resize/treeifyBin;HashIterator.nextNode→expectedModCount
扩容按高低链迁移,是否树化受容量条件影响;不能猜桶顺序或树形。ConcurrentModificationException 是尽力检测,不是锁。
access-order 命中、更新与最老项淘汰
get、put
head、tail、accessOrder
get→afterNodeAccess;已有键 put→HashMap.putVal→afterNodeAccess;新键 put→afterNodeInsertion→removeEldestEntry
命中或已有键更新可移到尾部;removeEldestEntry 是新插入后的容量策略钩子。缓存顺序不是余额排名,也不能从无序事实重建。
排名定位、删除、插入和活视图
get、put、remove、entrySet
root、size、modCount、comparator
本题自然序查询 get/remove→getEntry→RankKey.compareTo;put→compareTo→addEntry;删除后 deleteEntry
本题没有显式 Comparator,RankKey.compareTo 的完整全序决定命中与覆盖;余额变化必须删旧键再插新键。集合视图背靠树,字符串复制快照不背靠树。
枚举状态计数
get、put、remove、merge
keyType、keyUniverse、vals、size
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 保存所有案件的当前主档;openExposure、reviewQueue 和状态计数回答特定查询;最近缓存只加速访问。难点不在会不会调用 put,而在调分和结案会跨多张 Map:少删一个旧排序键,就会让同一案件在队列出现两次;先改事实再发现整数溢出,则系统已经留下半套新状态。
本课只讨论单线程内存模型。生产系统还要用锁、单线程事件循环、数据库事务或“构造新聚合后一次替换”等外层边界,处理并发、进程崩溃与持久化失败。
返回今日索引
二、前置知识:九个 Map 知识点在本题各做什么
知识点
本题落点
ds.map.contract
get 的 null 歧义、put/remove 返回值、键和值的空值策略
ds.map.equality
CaseKey、AccountKey 的值相等语义;键放入 Map 后不可变
ds.map.hashmap-structure
桶、负载因子、阈值及正常散列下的期望 O(1)
ds.map.hashmap-put-get-remove-source
getNode、putVal、removeNode 与替换旧值
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,创建序号一经建案不再变化,状态只有 OPEN、APPROVED、REJECTED。
ReviewKey(score, createdSequence, tenantId, caseId) 按风险分降序、创建序号升序、租户和案件升序比较。不要用 b.score - a.score,它会溢出;使用 Integer.compare(b.score, a.score)。
五个容器的不变量是:
facts: HashMap<CaseKey, RiskCase> 保留全部案件。结案只是把不可变 value 替换为 APPROVED 或 REJECTED 新值,不能删除审计所需的当前主档。
openExposure: HashMap<AccountKey, Integer> 的值等于该账户所有 OPEN 案件当前风险分之和;只保留正数,变成 0 就删键。
reviewQueue: TreeMap<ReviewKey, CaseKey> 对每件 OPEN 案件恰有一项,对 APPROVED、REJECTED 案件没有项。
容量 3 的 access-order LinkedHashMap<CaseKey, RiskCase> 保存最近访问的最新值。淘汰缓存绝不改变事实或队列。
EnumMap<CaseState, Integer> 统计 facts 中各状态数量;零计数可不存,所有非零计数之和等于 facts.size()。
rescore 改的是当前案件分数,不是给账本追加一笔反向流水;新分数与旧分数相同时定义为无副作用返回,避免把一次重试误算成新变更。若业务需要每次调分的历史,还应另建不可变事件日志。close(key, terminalState) 的目标只允许 APPROVED 或 REJECTED:案件一旦进入任一终态,再次结案或试图改判都返回无变化,不能重复扣减敞口。
返回今日索引
四、心智模型:预检区与提交区之间画一条线
把每个命令想成“读取旧态 → 推导新态 → 全量预检 → 提交写入”四步。预检区可以失败,但不能修改任何 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=100、south/u2=85、west/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=1,facts.size() 仍为 4。D 已不在缓存,写入后淘汰最老的 B,缓存为 [C, A, D]。最终敞口只剩 north/u1=90、south/u2=85。再次用任一终态结案 D 都必须无副作用。
返回今日索引
六、源码映射:从业务问题走到 jdk-21+35 主调用链
下面固定以 OpenJDK jdk-21+35 为基线;字段和调用链是当前实现细节,公开 API 契约才是业务代码可以依赖的边界。
问题
入口类 / 方法
关键字段
主调用链
可验证的扩展点
身份查找与替换
HashMap.get/put/remove
table、size、threshold、modCount
get → getNode;put → putVal;remove → removeNode
自定义键的 equals/hashCode 必须稳定
扩容、冲突与遍历
HashMap.putVal/resize/treeifyBin
loadFactor、桶链表/树节点
达阈值后 resize,旧桶按 oldCap 位拆到原位置或 +oldCap
初始容量、负载因子和好散列只能优化,不给顺序承诺
风险队列全序
TreeMap.put/remove/firstEntry
root、size、modCount、comparator
本题 get/remove → getEntry → ReviewKey.compareTo;插入/删除后做红黑树修复
自然序或显式 Comparator 都必须覆盖唯一尾部字段
最近缓存
LinkedHashMap.get/put
head、tail、accessOrder
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=8 与 MIN_TREEIFY_CAPACITY=64 共同约束树化:表太小时优先扩容,不能看到长链就断言已经变成红黑树。
compute、merge 很适合表达单张 Map 的聚合,但回调返回 null 会删除映射,且回调不应反向结构性修改同一 Map。本题还要预检另一张队列,所以应先用局部变量和 Math.addExact/subtractExact 算好,再进入提交区,不能让一次 merge 先改敞口。
源码直链:HashMap.java 、LinkedHashMap.java 、TreeMap.java 、EnumMap.java 。调试练习:在 HashMap.resize、TreeMap.put、LinkedHashMap.afterNodeAccess 各设断点,观察桶拆分、ReviewKey.compareTo 的自然序路径,以及一次 get 如何改变 access-order。遍历期间的结构修改可能因 modCount 触发 ConcurrentModificationException;fail-fast 只是尽力发现错误,绝不是线程安全机制。
随堂检查 3
把 Collections.unmodifiableMap(reviewQueue) 返回给调用方,能叫“稳定快照”吗?
**即时答案:**不能。它禁止调用方经包装写入,却仍能看见底层 Map 后续变化;entrySet、keySet、values 也是活视图。稳定快照要复制所需数据,例如先生成独立列表,再用 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 阶段
大大可以用下面的口径自检:
HashMap、HashSet 没有稳定业务顺序;需要顺序就显式使用 TreeMap、LinkedHashMap 或复制后排序。
业务身份键必须不可变并遵守 equals/hashCode;树键 Comparator 必须全序,不能让不同案件比较为 0。
keySet/values/entrySet 是活视图;只读包装仍会跟随底层变化;稳定快照必须复制。
未知节点位置时,LinkedList 中间操作包含查找成本,不是天然 O(1)。
fail-fast 不是并发控制,只是尽力暴露错误用法。
ConcurrentHashMap 的单键原子方法不能升级为跨键、跨 Map 事务。
此外,access-order 缓存的一次 get 也会重排;compute/merge 的 null 结果可能删键;EnumMap 只适合已知枚举键;HashMap 容量规划影响扩容成本却不改变契约。真正过关的标准不是背出类名,而是能从不变量推导每次建案、调分、结案的旧键删除、新值替换、聚合更新、状态迁移与快照边界,并用 Java 21 编码和预期输出证明这些状态始终一致。
返回今日索引
返回今日索引
编码闯关:多租户风控案件调分与结案索引
评估项:challenge.map。建议用时:18~20 分钟。Map 阶段闯关必须提交完整 Java 21 代码、成功编译证据与逐行一致的运行输出;只写设计思路不能通过。
你要完成一个单线程风控案件索引。案件事实一经创建便保留;调分只能替换同一不可变案件键对应的 value,结案只能把 OPEN 迁移为 APPROVED 或 REJECTED。待复核队列把可变分数编码进排序键,因此调分时必须先删除旧键,再插入新键。
1. 业务背景与五张 Map
不同租户都可能存在 case-1 和 alice,所以案件身份是 CaseKey(tenantId, caseId),账户身份是 AccountKey(tenantId, accountId)。分数、创建序号和状态属于案件 value,不属于身份。
facts:HashMap<CaseKey, RiskCase>,保留全部案件,是唯一事实来源;结案不删除事实。
openExposure:HashMap<AccountKey, Integer>,聚合同账户全部 OPEN 案件的正分数;总分归零时删除键。
reviewQueue:TreeMap<ReviewKey, CaseKey>,只收录 OPEN 案件;按分数降序、创建序号升序、租户与案件号升序形成全序。
recentCases:容量 3 的 access-order LinkedHashMap<CaseKey, RiskCase>;创建、调分、结案和显式观察都会更新热度,但它不是事实表。
stateCounts:EnumMap<CaseState, Integer>,统计 OPEN/APPROVED/REJECTED 案件数,零计数不保留。
已实现的 open 会先完成查重、旧聚合读取、排序键碰撞检查和算术溢出预检,再进行第一次 Map 写入。你只补全 Top 快照、开放案件调分、幂等结案三个位置。
2. 约束与验收边界
基线为 Java 21,仅使用 JDK;不添加依赖、日志、调试输出或吞掉异常的 try/catch。
tenantId/caseId/accountId 不能为空白;riskScore 必须在 1..1000,createdSequence 必须非负。键进入 Map 后不可改变,Map 不接收 null 键或 null value。
新案件必须为 OPEN。完整 CaseKey 重复时,open 返回 false,五张 Map 均无副作用,缓存热度也不变。
openExposure 只保留正总分。在线累计、调分差值和扣减均用 Math.addExact/subtractExact;算术检查、状态计数检查、旧队列键核对和新键碰撞检查必须在本次第一次 Map 写入前完成。
ReviewKey.compareTo 按 riskScore 降序、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. 目标拆解与实施顺序
先读 open 和三个不可变 record,写下事实、聚合、排序、缓存、计数各自的不变量。
完成 Top:沿 reviewQueue.entrySet() 的既定顺序遍历,逐项交叉核对后投影字符串,最后冻结结果。
完成调分:所有可失败检查都放在写入前;同分是严格的无副作用幂等分支。
完成结案:先预算账户新暴露和两个状态的新计数,再一次性迁移事实与四张派生 Map。
用 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)。
open 和 rescore/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. 提交与自检清单
返回今日索引
返回今日索引
Map 阶段闯关题:多租户风控案件调分与结案索引
建议用时:约 7~8 分钟。请优先在 互动页面 作答,聊天提交仅作补充。当天不提供答案。 第 1~3、5 题提交结论与最短必要推理;第 4 题必须提交完整 Java 21 代码、编译证据和规定输出。
题号
固定维度
知识点 ID
分值
可能触发的红线
1
契约与选型
ds.map.contract、ds.map.equality、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
25
假设 HashMap/HashSet 顺序稳定;破坏 equals/hashCode 或 Comparator;混淆案件事实、账户聚合、队列键和缓存身份
2
复杂度与状态推演
ds.map.hashmap-structure、ds.map.hashmap-put-get-remove-source、ds.map.hashmap-resize-treeify-iterator-source、ds.map.treemap-source
12
把期望复杂度写成严格保证;认为未知节点位置时 LinkedList 中间操作天然 O(1);把 fail-fast 当线程安全
3
复杂度与状态推演
ds.map.contract、ds.map.compute-merge-views、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
13
混淆活视图、只读包装与快照;调分漏删旧键;把连续 Map 写入当事务
4
编码
ds.map.contract、ds.map.equality、ds.map.compute-merge-views、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
30
结案删事实、开放总分归零仍留键、失败分支刷新 LRU;把 ConcurrentHashMap 误认为跨键或跨集合自动原子
5
源码讲解
ds.map.hashmap-structure、ds.map.hashmap-put-get-remove-source、ds.map.hashmap-resize-treeify-iterator-source、ds.map.compute-merge-views、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.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 的选型、所有权和不变量,并覆盖:CaseKey 与 AccountKey 的租户边界,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.java、javac --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 事务。
返回今日索引
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);
}
}
互动保存需要 JavaScript。请启用 JavaScript,并通过本地启动器或已部署的学习地址访问此页面。