Files
examination/topics/thumbup/two-level-cache/code_reading.json
T

144 lines
11 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"topic": "two-level-cache",
"type": "code_reading",
"schema_version": "1.0.0",
"generated": "2026-09-09T21:42:00+08:00",
"questions": [
{
"id": "cr-001",
"type": "code_reading",
"difficulty": 3,
"tags": [
"two-level-cache",
"CacheManager",
"get-flow"
],
"question": "阅读以下 CacheManager.get() 方法的简化实现,回答后续子问题。",
"code": "java\npublic Object get(String key, Supplier<Object> dataLoader) {\n // Step 1: 查询 L1 Caffeine 缓存\n Object value = caffeineCache.getIfPresent(key);\n if (value != null) {\n return value;\n }\n \n // Step 2: 查询空值缓存\n if (nullCache.containsKey(key)) {\n return null;\n }\n \n // Step 3: 加锁 double-check\n Object lock = lockMap.computeIfAbsent(key, k -> new Object());\n synchronized (lock) {\n // double-check L1\n value = caffeineCache.getIfPresent(key);\n if (value != null) {\n return value;\n }\n // double-check 空值缓存\n if (nullCache.containsKey(key)) {\n return null;\n }\n \n // Step 4: 查询 Redis (L2)\n value = redisTemplate.opsForValue().get(key);\n \n if (value == null) {\n // Step 5: 写入空值缓存防止穿透\n nullCache.put(key, Boolean.TRUE);\n } else {\n // Step 6: HeavyKeeper 记录并判断是否提升到 L1\n heavyKeeper.add(key);\n if (heavyKeeper.isHotKey(key)) {\n caffeineCache.put(key, value);\n }\n }\n return value;\n }\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "为什么在 Step 3 中需要先对空值缓存做 double-check?",
"options": {
"A": "防止空值缓存过期导致重复穿透",
"B": "防止多个线程同时查到 Redis 为空后重复写入空值缓存",
"C": "空值缓存的写入不是线程安全的",
"D": "空值缓存可能被其他线程删除"
},
"answer": "B",
"explanation": "在加锁之前,线程 A 可能已经查过空值缓存为 false,然后进入 synchronized 块。此时线程 B 也完成了 Step 2 检查并等待锁。如果线程 A 发现 Redis 为空并写入了空值缓存,线程 B 获得锁后不重新检查空值缓存,就会再次查 Redis。double-check 确保每个线程在持有锁后重新验证,避免重复穿透。"
},
{
"index": 2,
"type": "short_answer",
"question": "如果 HeavyKeeper 的 isHotKey(key) 始终返回 false,会对系统产生什么影响?请从缓存命中率和 Redis 压力两个角度分析。",
"answer": "如果 HeavyKeeper 始终判定所有 Key 都不是热 Key,则 Caffeine(L1)永远不会被主动填充数据。所有请求都必须穿透到 Redis(L2)获取数据。短期来看:缓存命中率急剧下降,Redis QPS 显著上升,系统延迟增加。长期来看:Caffeine 只能通过被动加载(如 get(key, loader) 的 loading cache 模式)或预热填充,失去了热 Key 主动提升的价值。在高并发场景下,Redis 可能成为瓶颈甚至宕机。",
"explanation": "",
"keywords": [
"Caffeine",
"Redis",
"命中率",
"Redis压力",
"热Key"
]
}
],
"explanation": "该代码展示了 CacheManager.get() 的核心流程:L1 查询 → 空值缓存检查 → 加锁 double-check → Redis 查询 → 空值缓存写入 / HeavyKeeper 记录与热 Key 提升。",
"source": null,
"related": [],
"language": "java"
},
{
"id": "cr-002",
"type": "code_reading",
"difficulty": 4,
"tags": [
"HeavyKeeper",
"add",
"bucket-operation"
],
"question": "阅读以下 HeavyKeeper.add() 方法的核心逻辑,回答后续子问题。",
"code": "java\npublic void add(String key) {\n long fingerprint = fingerprint(key);\n for (int i = 0; i < depth; i++) {\n int bucketIdx = hash(key, i) % width;\n Bucket bucket = buckets[bucketIdx];\n \n // 分支 1: 桶为空\n if (bucket.fingerprint == 0) {\n bucket.fingerprint = fingerprint;\n bucket.count = 1;\n return;\n }\n \n // 分支 2: fingerprint 匹配\n if (bucket.fingerprint == fingerprint) {\n bucket.count++;\n if (bucket.count > maxCount) {\n bucket.count = maxCount;\n }\n return;\n }\n \n // 分支 3: 桶冲突,概率衰减竞争\n if (ThreadLocalRandom.current().nextDouble() < decay) {\n bucket.count--;\n if (bucket.count <= 0) {\n bucket.fingerprint = fingerprint;\n bucket.count = 1;\n }\n }\n }\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "在分支 3(桶冲突)中,decay=0.92 的作用是什么?",
"options": {
"A": "92% 的概率直接替换桶中的 fingerprint",
"B": "以 92% 的概率对桶中已有 count 执行减 1 操作",
"C": "将桶中 count 乘以 0.92 进行衰减",
"D": "92% 的概率跳过该桶的处理"
},
"answer": "B",
"explanation": "在分支 3 中,每次遇到桶冲突时,有 decay(0.92 = 92%)的概率对桶中已有的 count 执行减 1 操作。如果衰减后 count ≤ 0,则替换为当前 Key 的 fingerprint。这意味着冷 Key 的 count 会逐渐被衰减至 0 并被替换,而热 Key 由于分支 2 的 count++ 频率更高,能保持较高的 count 值,从而不被淘汰。decay 越大,冷 Key 被淘汰的速度越快。"
},
{
"index": 2,
"type": "short_answer",
"question": "如果 width=100000, depth=5,理论上一个 Key 最多占用几个桶?最坏情况下会占用多少个桶(考虑 fingerprint 碰撞)?请解释原因。",
"answer": "理论上每个 Key 通过 5 个哈希函数映射到 5 个桶,正常情况下最多占用 5 个桶。但如果其他 Key 的 fingerprint 与当前 Key 相同(fingerprint 碰撞),则分支 2 会被触发,那些桶实际上也被当前 Key 的 fingerprint 「占用」。然而在实际中,64-bit fingerprint 碰撞概率极低。因此一个 Key 最多占用 depth=5 个桶。这是 HeavyKeeper 内存效率的关键——每个 Key 只用 5 个桶位置来维护热度信息,而不是像 Count-Min Sketch 那样需要更多空间。",
"explanation": "",
"keywords": [
"fingerprint",
"depth",
"桶",
"碰撞",
"Count-Min Sketch"
]
}
],
"explanation": "HeavyKeeper 的 add() 方法体现了三个分支的设计:空桶直接写入、fingerprint 匹配累加、冲突概率衰减竞争。配合 fading() 的周期性减半,实现了高效的热 Key 探测。",
"source": null,
"related": [],
"language": "java"
},
{
"id": "cr-003",
"type": "code_reading",
"difficulty": 4,
"tags": [
"two-level-cache",
"update",
"putIfPresent",
"inconsistency"
],
"question": "阅读以下二级缓存更新逻辑的简化实现,回答后续子问题。",
"code": "java\npublic boolean update(String key, Object newValue) {\n // Step 1: 先更新 Redis (L2)\n Boolean redisResult = redisTemplate.opsForValue().setIfAbsent(key, newValue);\n if (redisResult == null || !redisResult) {\n return false; // Key 不存在,更新失败\n }\n \n // Step 2: 失效 L1 Caffeine 缓存\n caffeineCache.invalidate(key);\n \n // Step 3: 通知其他实例失效 L1\n notificationService.publishInvalidation(key);\n \n // Step 4: 更新空值缓存\n nullCache.invalidate(key);\n \n return true;\n}\n\npublic boolean delete(String key) {\n // Step 1: 先删除 Redis\n Long count = redisTemplate.delete(key);\n \n // Step 2: 失效 L1\n caffeineCache.invalidate(key);\n \n // Step 3: 通知其他实例\n notificationService.publishInvalidation(key);\n \n // Step 4: 写入空值缓存\n nullCache.put(key, Boolean.TRUE);\n \n return count != null && count > 0;\n}",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "在 delete() 方法中,Step 2(失效 L1)和 Step 1(删除 Redis)之间存在一个时间窗口。在这个窗口内,如果有新的读请求到达同一实例,会发生什么?",
"options": {
"A": "读请求在 L1 命中旧数据并返回",
"B": "读请求在 L1 未命中,去 Redis 查询也被删除,返回 null",
"C": "读请求触发 L1 重新从 Redis 加载,但 Redis 已删除,所以返回 null",
"D": "读请求被阻塞等待 delete 完成"
},
"answer": "A",
"explanation": "delete() 中 Redis 删除(Step 1)和 Caffeine 失效(Step 2)之间存在时间窗口。在这个窗口内,该实例的 Caffeine 中仍然持有旧数据。由于同一实例内的读请求不会经过分布式锁,直接从 Caffeine 读取,因此会命中旧数据并返回。这是一个典型的 L1/L2 不一致窗口,但由于删除操作顺序是「先删 Redis 再删 L1」(而非相反),不一致的影响相对较小——最多读到即将过期的旧数据,不会读到脏数据。"
},
{
"index": 2,
"type": "short_answer",
"question": "update() 方法中使用了 setIfAbsent 而非直接 set。请分析:如果两个线程同时调用 update() 更新同一个 Key 的不同值,最终结果会怎样?这种设计有什么利弊?",
"answer": "由于 setIfAbsent 只在 Key 不存在时写入,两个线程同时调用时:第一个线程成功写入,第二个线程 setIfAbsent 返回 false 导致更新失败。最终结果是只保留第一个线程的值。利:防止了并发更新时的覆盖问题,保证了写入的原子性,是一种乐观锁思路。弊:后续的合法更新会被误拒绝,导致数据丢失。如果业务要求后写入者胜出(last-write-wins),应该使用 set 而非 setIfAbsent,或在 Redis 端使用 Lua 脚本做 CAS 操作。",
"explanation": "",
"keywords": [
"setIfAbsent",
"并发",
"乐观锁",
"last-write-wins",
"CAS"
]
}
],
"explanation": "该代码展示了二级缓存更新/删除的标准流程:先操作 Redis,再失效本地缓存,最后通知其他实例。操作顺序(先 L2 后 L1)决定了不一致窗口的方向和影响。",
"source": null,
"related": [],
"language": "java"
}
]
}