144 lines
11 KiB
JSON
144 lines
11 KiB
JSON
{
|
||
"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"
|
||
}
|
||
]
|
||
} |