diff --git a/topics/index.json b/topics/index.json index 48bac8b..ae3fe60 100644 --- a/topics/index.json +++ b/topics/index.json @@ -442,6 +442,83 @@ } } ] + }, + { + "slug": "thumbup", + "name": "ThumbUP 项目", + "description": "高并发点赞系统技术调研:缓存穿透防护、二级缓存、Lua原子性、HeavyKeeper热点探测、分布式锁", + "subtopics": [ + { + "slug": "cache-protection", + "name": "缓存穿透与击穿防护", + "description": "布隆过滤器、空值短缓存、互斥锁防击穿的三重防护机制", + "path": "topics/thumbup/cache-protection", + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } + }, + { + "slug": "two-level-cache", + "name": "Caffeine + Redis 二级缓存", + "description": "CacheManager 二级缓存架构、HeavyKeeper 热Key探测、putIfPresent 写一致性", + "path": "topics/thumbup/two-level-cache", + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } + }, + { + "slug": "lua-bucketing-sync", + "name": "Lua 脚本 + 分桶同步", + "description": "Lua脚本原子性、10秒时间片分桶、定时批量落库、补偿兜底", + "path": "topics/thumbup/lua-bucketing-sync", + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } + }, + { + "slug": "heavykeeper-topk", + "name": "HeavyKeeper Top-K 探测", + "description": "HeavyKeeper算法原理、指数衰减、vs CMS对比、Top-K堆管理", + "path": "topics/thumbup/heavykeeper-topk", + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } + }, + { + "slug": "distributed-lock", + "name": "单机锁与分布式锁", + "description": "synchronized到Redis SETNX演进、Lua脚本释放锁、锁续期问题", + "path": "topics/thumbup/distributed-lock", + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } + } + ] } ] } \ No newline at end of file diff --git a/topics/thumbup/cache-protection/code_reading.json b/topics/thumbup/cache-protection/code_reading.json new file mode 100644 index 0000000..bff7c98 --- /dev/null +++ b/topics/thumbup/cache-protection/code_reading.json @@ -0,0 +1,151 @@ +{ + "topic": "cache-protection", + "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": [ + "cache-protection", + "bloom-filter" + ], + "question": "阅读以下 BloomFilterManager 代码片段,回答后续问题:", + "code": "@Component\npublic class BloomFilterManager {\n private BloomFilter thumbBloomFilter;\n private static final long EXPECTED_INSERTIONS = 1_000_000L;\n private static double FPP = 0.01;\n\n @PostConstruct\n public void init() {\n thumbBloomFilter = BloomFilter.create(\n Funnels.stringFunnel(StandardCharsets.UTF_8),\n EXPECTED_INSERTIONS, FPP);\n }\n\n public void add(Long userId, Long blogId) {\n thumbBloomFilter.put(userId + \":\" + blogId);\n }\n\n public boolean mightContain(Long userId, Long blogId) {\n return thumbBloomFilter.mightContain(userId + \":\" + blogId);\n }\n}", + "language": "java", + "sub_questions": [ + { + "sub_id": "cr-001-1", + "type": "single_choice", + "question": "thumbBloomFilter 没有标注 @PostConstruct 以外的任何并发控制注解。在多线程环境下直接调用 add() 和 mightContain(),以下哪种说法正确?", + "options": { + "A": "Guava BloomFilter 内部使用了锁,线程安全,无需额外保护", + "B": "Guava BloomFilter 不是线程安全的,但位数组的原子性写入在实践中不会导致严重问题", + "C": "必须在 add() 和 mightContain() 方法上加 synchronized,否则会出现数据竞争", + "D": "BloomFilter 在多线程环境下会直接抛出 ConcurrentModificationException" + }, + "answer": "B", + "explanation": "Guava BloomFilter 不是线程安全的(官方文档明确说明)。但由于 BloomFilter 的底层是一个 bitset,put 和 mightContain 操作都是对单个 bit 的读写,在大多数 JVM 实现中对 long 数组元素的读写是原子的(64 位 JVM),实践中通常不会导致严重问题(可能出现少量误判率上升)。但这不等于它是「线程安全的」——理论上仍存在可见性问题。选项 A 错误,Guava 没有内置锁;选项 C 虽然安全但不是必须的;选项 D 错误,BloomFilter 不会抛此异常。", + "index": 1 + }, + { + "sub_id": "cr-001-2", + "type": "short_answer", + "question": "init() 方法在 @PostConstruct 中执行,用 Funnels.stringFunnel 创建了布隆过滤器。如果需要支持不同用户维度的独立布隆过滤器(例如按年份隔离),你会如何修改这个设计?简要说明方案。", + "answer": "将 thumbBloomFilter 从单个实例改为 Map>,以维度标识(如年份)为 key,每个 BloomFilter 独立维护 expectedInsertions 和 FPP。也可以改为懒加载模式,在首次访问某维度时动态创建对应的 BloomFilter 实例。", + "keywords": [ + "Map", + "多实例", + "按维度隔离", + "懒加载", + "dynamic creation" + ], + "scoring_rubric": "评分标准:提出使用 Map 或类似容器存储多个 BloomFilter 实例(2分);说明维度标识(如年份)作为 key 的设计(1分);提及懒加载或动态创建策略(1分);答案结构清晰、表述准确(1分)", + "index": 2, + "explanation": "init() 方法在 @PostConstruct 中执行,用 Funnels.stringFunnel 创建了布隆过滤器。如果需要支持不同用户维度的独立布隆过滤器(例如按年份隔离),你会如何修改这个设计?简要说明方案。" + } + ], + "source": null, + "related": [], + "explanation": "阅读以下 BloomFilterManager 代码片段,回答后续问题:" + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "cache-protection", + "mutex-lock", + "null-cache" + ], + "question": "阅读以下 CacheManager.get() 方法的缓存穿透与击穿防护部分,回答后续问题:", + "code": "private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>();\nprivate static final Object NULL_PLACEHOLDER = new Object();\nprivate static final long NULL_CACHE_TTL_SECONDS = 30;\nprivate final Cache nullValueCache = Caffeine.newBuilder()\n .maximumSize(10000)\n .expireAfterWrite(NULL_CACHE_TTL_SECONDS, TimeUnit.SECONDS)\n .build();\n\npublic Object get(String hashKey, String key) {\n String compositeKey = this.buildCacheKey(hashKey, key);\n // 1. 查本地缓存\n Object value = localCache.getIfPresent(compositeKey);\n if (value != null) { hotKeyDetector.add(key, 1); return value; }\n // 2. 查空值短缓存\n Object nullMarker = nullValueCache.getIfPresent(compositeKey);\n if (nullMarker != null) { return null; }\n // 3. 加互斥锁\n Object lock = lockMap.computeIfAbsent(compositeKey, k -> new Object());\n synchronized (lock) {\n // double-check\n value = localCache.getIfPresent(compositeKey);\n if (value != null) { hotKeyDetector.add(key, 1); return value; }\n // 4. 查 Redis\n Object redisValue = redisTemplate.opsForHash().get(hashKey, key);\n if (redisValue == null) {\n nullValueCache.put(compositeKey, NULL_PLACEHOLDER);\n return null;\n }\n AddResult addResult = hotKeyDetector.add(key, 1);\n if (addResult.isHotKey()) { localCache.put(compositeKey, redisValue); }\n return redisValue;\n }\n}", + "language": "java", + "sub_questions": [ + { + "sub_id": "cr-002-1", + "type": "single_choice", + "question": "当 Redis 返回的 redisValue 为 null 时,代码执行了 nullValueCache.put(compositeKey, NULL_PLACEHOLDER)。但此时并没有对 nullValueCache 做 double-check。这意味着在极端并发场景下可能出现什么问题?", + "options": { + "A": "多个线程同时 put 相同的 NULL_PLACEHOLDER,导致 nullValueCache 中出现重复条目", + "B": "不存在问题,因为 Caffeine 是线程安全的,且 NULL_PLACEHOLDER 是常量", + "C": "可能导致短时间内多个线程同时穿透到 Redis 查询同一个不存在的 key", + "D": "会导致 nullValueCache 的 TTL 被重置,延长空值缓存时间" + }, + "answer": "C", + "explanation": "在加锁之前,线程已经检查了 nullValueCache(步骤 2)。但在步骤 4(锁内)发现 Redis 返回 null 后才 put 空值。如果多个线程几乎同时进入 synchronized 块(对不同 compositeKey),它们会在各自的锁释放后才将 NULL_PLACEHOLDER 写入 nullValueCache。但对于同一个 compositeKey,由于 synchronized 锁的存在,只有一个线程能进入锁内执行 put,后续线程会在 double-check 中命中本地缓存或重新从 Redis 读取(此时仍为 null)再 put。所以对同一个 key,最多有两次无效的 Redis 查询(第一个进入锁的 + double-check 命中本地缓存前的一个)。选项 A 不对,Caffeine put 不会产生「重复条目」;选项 B 虽然 Caffeine 线程安全,但 put 在锁内,不影响并发穿透的可能性;选项 D 不成立,TTL 是基于首次写入时间的。", + "index": 1 + }, + { + "sub_id": "cr-002-2", + "type": "single_choice", + "question": "代码中步骤 2 在加锁之前检查了 nullValueCache,但步骤 4 中又在锁内将 null 值写入 nullValueCache。以下哪种设计改进可以减少对 Redis 的无效查询?", + "options": { + "A": "将 nullValueCache 的检查移到 synchronized 块内部", + "B": "将 nullValueCache.put 移到 synchronized 块外部,紧跟 Redis 查询返回 null 之后", + "C": "在步骤 2 和步骤 4 之间增加一层对 nullValueCache 的二次检查(在锁内、Redis 查询前)", + "D": "删除步骤 2 的 nullValueCache 检查,全部依赖锁内的逻辑" + }, + "answer": "C", + "explanation": "当前流程中,线程 A 在锁内查 Redis 得到 null 并 put nullValueCache 后释放锁。但如果线程 B 在线程 A 获得锁之前已经通过了步骤 2 的 nullValueCache 检查(此时 nullValueCache 尚无数据),线程 B 会在锁上等待。线程 A 完成后,线程 B 获得锁,double-check 本地缓存未命中(因为 null 值未放入本地缓存),然后查 Redis(仍为 null)。如果在 synchronized 块内、Redis 查询之前再做一次 nullValueCache 检查,线程 B 就能在锁内直接返回 null,避免重复查询 Redis。选项 A 会让所有线程串行化查 nullValueCache,降低性能;选项 B 将 put 移到锁外并不能减少 Redis 查询;选项 D 去掉前置检查会增加所有请求的延迟。", + "index": 2 + } + ], + "source": null, + "related": [], + "explanation": "阅读以下 CacheManager.get() 方法的缓存穿透与击穿防护部分,回答后续问题:" + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "cache-protection", + "bloom-filter", + "null-cache" + ], + "question": "阅读以下 ThumbServiceImpl.hasThumb() 方法,结合 BloomFilterManager 和 CacheManager 的上下文,回答后续问题:", + "code": "public Boolean hasThumb(Long blogId, Long userId) {\n if (!bloomFilterManager.mightContain(userId, blogId)) {\n return false; // 布隆过滤器拦截\n }\n Object thumbIdObj = cacheManager.get(\n ThumbConstant.USER_THUMB_KEY_PREFIX + userId, blogId.toString());\n if (thumbIdObj == null) return false;\n Long thumbId = ((Number) thumbIdObj).longValue();\n return !thumbId.equals(ThumbConstant.UN_THUMB_CONSTANT);\n}", + "language": "java", + "sub_questions": [ + { + "sub_id": "cr-003-1", + "type": "single_choice", + "question": "假设布隆过滤器的误判率为 1%(FPP=0.01),且有 90% 的 blogId 确实存在点赞记录。在一次查询中,布隆过滤器返回 true 但该 blogId 实际不存在点赞记录的概率约为多少?", + "options": { + "A": "1%", + "B": "0.1%", + "C": "10%", + "D": "无法确定,取决于具体的哈希函数" + }, + "answer": "B", + "explanation": "FPP=0.01 意味着在元素不存在时,mightContain 返回 true 的概率为 1%。实际不存在点赞记录的 blogId 占比为 10%(1-90%)。因此,查询一个不存在的 blogId 且被布隆过滤器误判为存在的联合概率 = 10% × 1% = 0.1%。换言之,所有查询中约有 0.1% 会因为布隆过滤器误判而穿透到 CacheManager 层。选项 A 是 FPP 本身,不是联合概率;选项 C 是不存在记录的比例;选项 D 虽然理论上有影响,但在实践中 0.1% 是合理的估算。", + "index": 1 + }, + { + "sub_id": "cr-003-2", + "type": "short_answer", + "question": "分析 hasThumb() 方法中的防御层次:布隆过滤器拦截了什么?CacheManager 又分别用哪些机制防护?各层之间是否存在冗余或遗漏?", + "answer": "布隆过滤器拦截了「一定不存在」的 key,避免其进入缓存查询链路。CacheManager 用三层防护:(1) 本地缓存命中则直接返回,避免查 Redis;(2) nullValueCache 缓存空值 30 秒,防止对不存在数据的反复 Redis 查询(防穿透);(3) synchronized 互斥锁 + double-check,防止热点 key 过期时的缓存击穿。两层之间存在少量重叠:布隆过滤器的 false positive 会漏过少量不存在的 key,但 nullValueCache 会接住它们,形成互补而非冗余。潜在遗漏:布隆过滤器的误判数据没有回写到布隆过滤器中(即只读不写),且 nullValueCache 的 TTL 较短,30 秒后同一不存在的 key 仍可能再次穿透。", + "keywords": [ + "布隆过滤器前置拦截", + "本地缓存", + "nullValueCache 空值缓存", + "synchronized 互斥锁", + "double-check", + "互补关系", + "false positive 漏网" + ], + "scoring_rubric": "评分标准:准确说明布隆过滤器拦截「一定不存在」的 key(1分);列出 CacheManager 的三层防护机制(本地缓存、空值缓存、互斥锁)(2分);分析各层之间的互补/重叠关系(1分);指出潜在遗漏或改进点(1分)", + "index": 2, + "explanation": "分析 hasThumb() 方法中的防御层次:布隆过滤器拦截了什么?CacheManager 又分别用哪些机制防护?各层之间是否存在冗余或遗漏?" + } + ], + "source": null, + "related": [], + "explanation": "阅读以下 ThumbServiceImpl.hasThumb() 方法,结合 BloomFilterManager 和 CacheManager 的上下文,回答后续问题:" + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/cache-protection/meta.json b/topics/thumbup/cache-protection/meta.json new file mode 100644 index 0000000..83dd036 --- /dev/null +++ b/topics/thumbup/cache-protection/meta.json @@ -0,0 +1,30 @@ +{ + "slug": "cache-protection", + "name": "缓存穿透与击穿防护", + "description": "布隆过滤器、空值短缓存、互斥锁防击穿的三重防护机制", + "tags": [ + "bloom-filter", + "cache-protection", + "mutex-lock", + "null-cache" + ], + "difficulty_range": [ + 3, + 5 + ], + "schema_version": "1.0.0", + "updated": "2026-09-09", + "question_files": [ + "single_choice", + "code_reading", + "short_answer" + ], + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } +} \ No newline at end of file diff --git a/topics/thumbup/cache-protection/short_answer.json b/topics/thumbup/cache-protection/short_answer.json new file mode 100644 index 0000000..f952c9c --- /dev/null +++ b/topics/thumbup/cache-protection/short_answer.json @@ -0,0 +1,84 @@ +{ + "topic": "cache-protection", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "cache-protection", + "null-cache", + "bloom-filter" + ], + "question": "缓存穿透(Cache Penetration)是指查询一个数据库中也不存在的数据,导致请求每次都打到数据库。请结合提供的代码,说明这套系统中有哪些机制可以防止缓存穿透,并分析各机制的优缺点。", + "answer": "系统中有两层防穿透机制:\n1. **布隆过滤器(BloomFilterManager)**:在最前置层快速判断 key 是否可能存在,false 则直接返回,减少对缓存层的无效访问。优点:O(1) 时间复杂度,内存占用极低。缺点:存在 false positive(误判),且不支持删除操作,容量固定不能动态扩容。\n2. **空值短缓存(nullValueCache)**:对 Redis 返回 null 的 key,缓存 NULL_PLACEHOLDER 30 秒。优点:实现简单,有效拦截同一 key 的短时间重复穿透。缺点:TTL 过长会浪费内存,过短则防穿透效果有限;不适用于攻击者使用大量随机 key 的场景(会被 Caffeine 淘汰)。\n\n两层互补:布隆过滤器拦截「一定不存在」的 key,nullValueCache 接住布隆过滤器 false positive 漏过的少量 key。", + "keywords": [ + "布隆过滤器", + "false positive", + "O(1)", + "空值缓存", + "NULL_PLACEHOLDER", + "TTL", + "互补" + ], + "scoring_rubric": "评分标准:准确描述布隆过滤器的防穿透原理(1.5分);准确描述 nullValueCache 的防穿透原理(1.5分);分析各机制的优缺点(1分);说明两层机制的互补关系(1分)", + "source": null, + "related": [], + "explanation": "系统中有两层防穿透机制:\n1. **布隆过滤器(BloomFilterManager)**:在最前置层快速判断 key 是否可能存在,false 则直接返回,减少对缓存层的无效访问。优点:O(1) 时间复杂度,内存占用极低。缺点:存在 false positive(误判),且不支持删除操作,容量固定不能动态扩容。\n2. **空值短缓存(nullValueCache)**:对 Redis 返回 null 的 key,缓存 NULL_PLACEHOLDER 30 秒。优点:实现简单,有效拦截同一 key 的短时间重复穿透。缺点:TTL 过长会浪费内存,过短则防穿透效果有限;不适用于攻击者使用大量随机 key 的场景(会被 Caffeine 淘汰)。\n\n两层互补:布隆过滤器拦截「一定不存在」的 key,nullValueCache 接住布隆过滤器 false positive 漏过的少量 key。" + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "cache-protection", + "mutex-lock" + ], + "question": "缓存击穿(Cache Breakdown)是指某个热点 key 在缓存过期的瞬间,大量并发请求同时穿透到后端。请结合 CacheManager.get() 的代码,详细解释互斥锁 + double-check 模式如何解决缓存击穿问题,并指出该方案在多实例部署下的局限性。", + "answer": "互斥锁 + double-check 模式的防护流程:\n1. 线程 A 检测到本地缓存未命中,在 lockMap 中通过 computeIfAbsent 为该 key 创建锁对象。\n2. 线程 A 获得 synchronized 锁,执行 double-check(再次检查本地缓存)。\n3. double-check 未命中,线程 A 从 Redis 加载数据,写入本地缓存,释放锁。\n4. 线程 B(在 A 持锁期间等待)获得锁后执行 double-check,此时本地缓存已有数据,直接返回,不查 Redis。\n\n通过这种方式,即使热点 key 同时过期,也只有一个线程穿透到 Redis,其余线程在锁上等待后命中 double-check。\n\n**多实例部署局限性**:lockMap 是 JVM 内存中的 ConcurrentHashMap,锁对象仅在当前 JVM 实例内有效。多实例部署时,实例 A 和实例 B 的线程各自持有独立的锁,无法互斥,仍可能同时穿透到 Redis。需要引入分布式锁(如 Redis SETNX)来解决跨实例的互斥问题。", + "keywords": [ + "互斥锁", + "double-check", + "本地锁", + "computeIfAbsent", + "热点key", + "多实例局限性", + "分布式锁" + ], + "scoring_rubric": "评分标准:准确描述互斥锁的获取和释放流程(1.5分);解释 double-check 的作用和必要性(1.5分);指出多实例部署下本地锁的局限性(1分);提出分布式锁等替代方案(1分)", + "source": null, + "related": [], + "explanation": "互斥锁 + double-check 模式的防护流程:\n1. 线程 A 检测到本地缓存未命中,在 lockMap 中通过 computeIfAbsent 为该 key 创建锁对象。\n2. 线程 A 获得 synchronized 锁,执行 double-check(再次检查本地缓存)。\n3. double-check 未命中,线程 A 从 Redis 加载数据,写入本地缓存,释放锁。\n4. 线程 B(在 A 持锁期间等待)获得锁后执行 double-check,此时本地缓存已有数据,直接返回,不查 Redis。\n\n通过这种方式,即使热点 key 同时过期,也只有一个线程穿透到 Redis,其余线程在锁上等待后命中 double-check。\n\n**多实例部署局限性**:lockMap 是 JVM 内存中的 ConcurrentHashMap,锁对象仅在当前 JVM 实例内有效。多实例部署时,实例 A 和实例 B 的线程各自持有独立的锁,无法互斥,仍可能同时穿透到 Redis。需要引入分布式锁(如 Redis SETNX)来解决跨实例的互斥问题。" + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "cache-protection", + "bloom-filter", + "null-cache", + "mutex-lock" + ], + "question": "假设你需要将这套缓存穿透与击穿防护方案从单体应用迁移到微服务架构(10+ 实例),请分析现有方案中哪些组件需要改造,给出具体的改造方案,并评估每种改造的利弊。", + "answer": "需要改造的组件及方案:\n\n1. **布隆过滤器 → 分布式布隆过滤器**\n - 现状:每个实例维护独立的 BloomFilter,数据不同步。\n - 方案:使用 Redis Bloom(RedisBloom 模块)或自建基于 Redis 的分布式布隆过滤器,所有实例共享同一个过滤器。\n - 利:数据一致,任意实例都能拦截不存在的 key。弊:引入 Redis 依赖,增加一次网络往返延迟。\n\n2. **本地锁 → 分布式锁**\n - 现状:lockMap 是 JVM 内存锁,无法跨实例互斥。\n - 方案:使用 Redis SETNX 或 Zookeeper 实现分布式锁替代 synchronized。\n - 利:真正实现跨实例互斥。弊:锁获取/释放需要网络通信,延迟增加;需要处理锁超时、续期等复杂场景。\n\n3. **nullValueCache → 共享空值缓存或保留本地**\n - 现状:每个实例独立维护空值缓存,可能导致多实例同时穿透。\n - 方案 A:将 nullValueCache 也存入 Redis(带短 TTL),实现跨实例共享。利:一致性好。弊:增加 Redis 读写压力。\n - 方案 B:保持本地 nullValueCache 不变,接受少量重复穿透。利:简单、无额外网络开销。弊:一致性略差。\n\n4. **本地缓存 → 分布式缓存或本地+二级缓存架构**\n - 需要评估是否保留本地缓存层,或使用 Caffeine + Redis 的二级缓存架构,通过消息总线(如 Redis Pub/Sub 或 Kafka)实现缓存失效通知。\n\n综合评估:改造的核心权衡是「一致性 vs 延迟/复杂度」。布隆过滤器和互斥锁建议优先改造为分布式方案,空值缓存可根据业务容忍度选择保留本地或共享。", + "keywords": [ + "分布式布隆过滤器", + "Redis Bloom", + "分布式锁", + "SETNX", + "空值缓存共享", + "二级缓存", + "一致性", + "延迟" + ], + "scoring_rubric": "评分标准:准确识别布隆过滤器、本地锁、空值缓存三个需要改造的组件(1分);为布隆过滤器提出分布式方案(如 Redis Bloom)(1分);为互斥锁提出分布式锁方案(如 SETNX)(1分);为空值缓存提出两种可选方案并分析利弊(1分);整体分析有结构,权衡合理,结论清晰(1分)", + "source": null, + "related": [], + "explanation": "需要改造的组件及方案:\n\n1. **布隆过滤器 → 分布式布隆过滤器**\n - 现状:每个实例维护独立的 BloomFilter,数据不同步。\n - 方案:使用 Redis Bloom(RedisBloom 模块)或自建基于 Redis 的分布式布隆过滤器,所有实例共享同一个过滤器。\n - 利:数据一致,任意实例都能拦截不存在的 key。弊:引入 Redis 依赖,增加一次网络往返延迟。\n\n2. **本地锁 → 分布式锁**\n - 现状:lockMap 是 JVM 内存锁,无法跨实例互斥。\n - 方案:使用 Redis SETNX 或 Zookeeper 实现分布式锁替代 synchronized。\n - 利:真正实现跨实例互斥。弊:锁获取/释放需要网络通信,延迟增加;需要处理锁超时、续期等复杂场景。\n\n3. **nullValueCache → 共享空值缓存或保留本地**\n - 现状:每个实例独立维护空值缓存,可能导致多实例同时穿透。\n - 方案 A:将 nullValueCache 也存入 Redis(带短 TTL),实现跨实例共享。利:一致性好。弊:增加 Redis 读写压力。\n - 方案 B:保持本地 nullValueCache 不变,接受少量重复穿透。利:简单、无额外网络开销。弊:一致性略差。\n\n4. **本地缓存 → 分布式缓存或本地+二级缓存架构**\n - 需要评估是否保留本地缓存层,或使用 Caffeine + Redis 的二级缓存架构,通过消息总线(如 Redis Pub/Sub 或 Kafka)实现缓存失效通知。\n\n综合评估:改造的核心权衡是「一致性 vs 延迟/复杂度」。布隆过滤器和互斥锁建议优先改造为分布式方案,空值缓存可根据业务容忍度选择保留本地或共享。" + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/cache-protection/single_choice.json b/topics/thumbup/cache-protection/single_choice.json new file mode 100644 index 0000000..c2c47a6 --- /dev/null +++ b/topics/thumbup/cache-protection/single_choice.json @@ -0,0 +1,168 @@ +{ + "topic": "cache-protection", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "cache-protection", + "bloom-filter" + ], + "question": "BloomFilterManager 中 EXPECTED_INSERTIONS 设为 1,000,000,FPP 设为 0.01。这意味着当实际插入量远超 1,000,000 时,最可能出现什么后果?", + "options": { + "A": "布隆过滤器自动扩容,无任何影响", + "B": "false positive rate(误判率)显著上升,导致更多不存在的 key 被错误放行", + "C": "布隆过滤器抛出 IllegalStateException 异常", + "D": "insert 操作被静默丢弃,不影响查询结果" + }, + "answer": "B", + "explanation": "Guava BloomFilter 创建后容量固定,不会自动扩容。当实际插入量远超 expectedInsertions 时,位数组的每个 bit 被置 1 的比例升高,查询时误判率(false positive rate)会远高于预期的 1%(0.01),导致大量不存在的 key 也能通过 mightContain 检查,削弱布隆过滤器对缓存穿透的拦截效果。选项 A 错误,BloomFilter 没有扩容机制;选项 C 错误,不会抛异常;选项 D 错误,put 操作不会被丢弃。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "cache-protection", + "bloom-filter" + ], + "question": "关于 BloomFilter.mightContain() 方法的返回值语义,下列哪项描述是正确的?", + "options": { + "A": "返回 true 表示该元素一定存在", + "B": "返回 false 表示该元素一定不存在", + "C": "返回 true 表示该元素一定不存在", + "D": "无论返回 true 还是 false 都是不确定的" + }, + "answer": "B", + "explanation": "布隆过滤器的核心特性:mightContain 返回 false 则元素一定不存在(无 false negative),返回 true 则元素可能存在也可能不存在(有 false positive)。因此 ThumbServiceImpl.hasThumb() 中判断 !bloomFilterManager.mightContain(userId, blogId) 为 true 时直接返回 false,是安全的。选项 A 错误,true 不是确定性结论;选项 C 错误,true 表示可能存在;选项 D 错误,false 是确定性结论。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "cache-protection", + "null-cache" + ], + "question": "CacheManager 中 NULL_CACHE_TTL_SECONDS 设为 30 秒。这个设计的核心目的是什么?", + "options": { + "A": "防止缓存雪崩,让空值在 30 秒后自然失效", + "B": "防止缓存穿透,避免同一个不存在的 key 反复穿透到 Redis", + "C": "防止缓存击穿,保护热点 key 的本地缓存", + "D": "防止内存溢出,限制 nullValueCache 的最大条目数" + }, + "answer": "B", + "explanation": "当 Redis 中某个 key 不存在时,nullValueCache 会在 30 秒内缓存 NULL_PLACEHOLDER,后续相同 key 的查询直接在空值短缓存层返回 null,不再穿透到 Redis。这是典型的「缓存穿透防护」手段——通过缓存空值来拦截对不存在数据的反复查询。选项 A 描述的是缓存雪崩场景(大量 key 同时过期);选项 C 是击穿场景(热点 key 过期);选项 D 虽然 maximumSize 确实能防止内存溢出,但不是 TTL 设置的直接目的。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "cache-protection", + "mutex-lock" + ], + "question": "CacheManager.get() 方法中使用了 synchronized(lock) 加互斥锁。结合代码分析,这种互斥锁设计主要解决的是什么问题?", + "options": { + "A": "防止缓存穿透:拦截不存在的 key 访问 Redis", + "B": "防止缓存击穿:避免热点 key 过期瞬间大量请求同时打到 Redis", + "C": "防止缓存雪崩:保证同一时刻只有一个线程写入缓存", + "D": "防止数据库连接池耗尽:限制并发数据库查询数" + }, + "answer": "B", + "explanation": "这是经典的「互斥锁防击穿」模式:当本地缓存未命中时,先用 computeIfAbsent 在 lockMap 中为该 key 创建一把锁对象,然后 synchronized 加锁。锁内再次 double-check 本地缓存(防止其他线程已经完成加载)。如果仍未命中则查 Redis。这样即使热点 key 同时过期,也只有一个线程能穿透到 Redis,其余线程在锁上等待,避免了「击穿」导致的 Redis/数据库压力骤增。选项 A 是 nullValueCache 的职责;选项 C 场景不对;选项 D 的目标对象不对。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "cache-protection", + "mutex-lock" + ], + "question": "CacheManager.get() 中在 synchronized 块内部先做了一次 localCache.getIfPresent(compositeKey) 检查(double-check)。这一步的作用是?", + "options": { + "A": "减少锁竞争时间,因为 synchronized 块内可以更快获取本地缓存", + "B": "防止线程在等待锁期间,另一个线程已完成加载,导致重复查 Redis", + "C": "防止 nullValueCache 被多个线程并发写入导致数据不一致", + "D": "防止 hotKeyDetector 被多次 add 导致热点误判" + }, + "answer": "B", + "explanation": "这是经典的 double-check locking 模式:线程 A 持锁加载数据完成后释放锁,线程 B(或 C、D…)获得锁后,如果不做 double-check 就直接查 Redis,会导致同一个 key 被重复加载。double-check 在锁内再次检查本地缓存,如果已被前一个线程加载好,直接返回,避免重复查询。选项 A 描述的是减少持锁时间,但不是 double-check 的核心目的;选项 C 不涉及 nullValueCache 的并发写入问题;选项 D 虽然有关联但不是主要目的。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "cache-protection", + "null-cache" + ], + "question": "CacheManager 中 nullValueCache 的 maximumSize 设为 10000。如果攻击者用大量不存在的 key 发起请求,以下哪种情况最可能发生?", + "options": { + "A": "nullValueCache 持续增长直到 OOM,因为不存在的 key 总是不同", + "B": "Caffeine 使用 W-TinyLFU 策略自动淘汰最不常用的空值条目,内存不会无限增长", + "C": "新写入的空值条目直接覆盖最旧的空值条目,类似于 LRU", + "D": "nullValueCache 会抛出 IllegalStateException 并阻止后续写入" + }, + "answer": "B", + "explanation": "Caffeine 的 maximumSize 采用 W-TinyLFU(Window Tiny Least Frequently Used)淘汰策略。当条目数接近 10000 时,会自动淘汰访问频率最低的条目,腾出空间给新条目。因此即使攻击者用大量不存在的不同 key 来请求,nullValueCache 的内存也不会无限增长,旧的低频空值条目会被淘汰。选项 A 错误,Caffeine 不会 OOM;选项 C 描述的是简单 FIFO/LRU,不是 Caffeine 的策略;选项 D 错误,Caffeine 不会抛异常。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "cache-protection", + "bloom-filter" + ], + "question": "ThumbServiceImpl.hasThumb() 中布隆过滤器与 CacheManager 的协作关系是:先用布隆过滤器判断 key 是否可能存在,再用 CacheManager 查缓存。如果把这两层的顺序反转(先查缓存,再查布隆过滤器),以下哪种说法最准确?", + "options": { + "A": "功能完全等价,因为两层都是拦截,顺序不影响最终结果", + "B": "布隆过滤器层失去意义,因为 CacheManager 本身已有空值缓存和互斥锁来防穿透", + "C": "仍然有效,但布隆过滤器的拦截时机后移,无法在第一时间过滤不存在的 key,增加了对空值缓存的无效访问", + "D": "会导致功能错误,因为 CacheManager 返回的 null 无法被布隆过滤器识别" + }, + "answer": "C", + "explanation": "原设计中布隆过滤器作为最前置的「粗筛」,能在 O(1) 时间内快速排除大量不存在的 key,减少对 CacheManager/Redis 的无效访问。如果反转顺序,所有请求(包括不存在的 key)都会先走 CacheManager 的多层查询链路(本地缓存 → nullValueCache → 锁 + Redis),只有在 CacheManager 返回非 null 时才走布隆过滤器。此时布隆过滤器的前置拦截价值大打折扣。选项 A 错误,两层职责不同不能简单等价;选项 B 说法过于绝对,布隆过滤器仍有价值;选项 D 不成立,null 本身就是一个合法的返回值。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "cache-protection", + "mutex-lock" + ], + "question": "CacheManager 的 lockMap 使用 ConcurrentHashMap,且锁对象通过 computeIfAbsent 创建。以下哪种场景可能导致该互斥锁机制失效?", + "options": { + "A": "多个 JVM 实例部署时,每个实例的 lockMap 独立,不同实例的线程无法互斥", + "B": "Redis 集群模式下,不同 slot 的 key 命中不同 Redis 节点,导致锁失效", + "C": "Caffeine 本地缓存被驱逐时,lockMap 中对应的锁对象也被清除", + "D": "synchronized 关键字在高并发下会导致 CPU 100%,锁机制自动降级为无锁模式" + }, + "answer": "A", + "explanation": "lockMap 是 JVM 进程内的 ConcurrentHashMap,锁对象仅在当前 JVM 实例内有效。在多实例部署场景下,实例 A 的线程持有 lockMap 中的锁不会阻塞实例 B 的线程,因此多个实例可能同时穿透到 Redis。这是单机互斥锁的固有局限,通常需要配合分布式锁(如 Redis SETNX)来解决。选项 B 描述的是 Redis 分片问题,与锁无关;选项 C 不成立,lockMap 和 localCache 是独立的数据结构;选项 D 荒谬,synchronized 不会自动降级。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/distributed-lock/code_reading.json b/topics/thumbup/distributed-lock/code_reading.json new file mode 100644 index 0000000..9879673 --- /dev/null +++ b/topics/thumbup/distributed-lock/code_reading.json @@ -0,0 +1,143 @@ +{ + "topic": "distributed-lock", + "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": [ + "distributed-lock", + "redis-lock" + ], + "question": "阅读以下 RedisLockUtil 的解锁 Lua 脚本和 unlock 方法代码,回答子问题。", + "code": "// Lua 脚本:解锁时验证锁持有者\nprivate static final DefaultRedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>(\"\"\"\n if redis.call('get', KEYS[1]) == ARGV[1] then\n return redis.call('del', KEYS[1])\n else\n return 0\n end\n \"\"\", Long.class);\n\npublic boolean unlock(String lockKey) {\n String fullLockKey = buildLockKey(lockKey);\n String lockValue = LOCK_VALUE_HOLDER.get();\n Long result = stringRedisTemplate.execute(UNLOCK_SCRIPT,\n Collections.singletonList(fullLockKey), lockValue);\n LOCK_VALUE_HOLDER.remove();\n return Long.valueOf(1L).equals(result);\n}", + "language": "java", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "如果线程 A 获取锁后因 GC 暂停导致锁过期,线程 B 随后获取了同名锁,此时线程 A 恢复执行调用 unlock,Lua 脚本的返回值是什么?", + "options": { + "A": "1,线程 A 成功删除了线程 B 的锁", + "B": "0,因为 KEYS[1] 的值已经是线程 B 的 lockValue,与 ARGV[1] 不匹配", + "C": "抛出异常", + "D": "返回 nil" + }, + "answer": "B", + "explanation": "这是经典的 GC 停顿导致的安全隐患。线程 A 的锁因 TTL 过期被 Redis 自动删除后,线程 B 获取锁并写入自己的 lockValue。线程 A 恢复后调用 unlock,Lua 脚本 GET 到的是线程 B 的 lockValue,与线程 A 的 lockValue(ARGV[1])不匹配,条件不成立,执行 else 分支返回 0。这意味着 unlock 不会误删线程 B 的锁。但问题在于:线程 A 在锁已失效后仍认为自己持有锁,继续执行临界区代码(如重复点赞),这才是真正的风险所在。" + }, + { + "index": 2, + "type": "single_choice", + "question": "为什么解锁操作要在 Lua 脚本执行完毕后才调用 LOCK_VALUE_HOLDER.remove(),而不是在执行前?", + "options": { + "A": "没有区别,顺序无所谓", + "B": "确保如果 Lua 脚本执行失败,ThreadLocal 中仍保留 lockValue 以便后续重试", + "C": "Lua 脚本需要通过 ThreadLocal 读取 lockValue", + "D": "remove 必须在 execute 之后才能释放数据库连接" + }, + "answer": "B", + "explanation": "虽然在当前代码中,如果 Lua 脚本执行成功但 remove 失败(极端情况),ThreadLocal 泄露的影响有限。但如果反过来先 remove 再执行 Lua 脚本,一旦 Lua 脚本因网络异常等原因失败,lockValue 已经丢失,后续将无法重试解锁。保持 '先执行、后清理' 的顺序符合防御性编程原则:确保清理操作不会影响核心业务逻辑的执行。另外,从 ThreadLocal 的生命周期管理角度,remove 放在 finally 块中会更安全(当前代码放在 finally 之外,如果 execute 抛异常则不会 remove)。" + } + ], + "explanation": "本题考查对 Redis 分布式锁解锁机制的理解,包括 Lua 脚本的原子性保证、GC 停顿场景下的安全性分析,以及 ThreadLocal 清理时机的设计考量。", + "source": null, + "related": [] + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "distributed-lock", + "redis-lock" + ], + "question": "阅读以下 tryLock 方法和 doThumb 方法的代码,回答子问题。", + "code": "public boolean tryLock(String lockKey, long acquireTimeout, long lockTimeout) {\n String fullLockKey = buildLockKey(lockKey);\n String lockValue = generateLockValue(); // UUID + threadId\n long startTime = System.currentTimeMillis();\n while (true) {\n Boolean result = stringRedisTemplate.opsForValue()\n .setIfAbsent(fullLockKey, lockValue, lockTimeout, TimeUnit.MILLISECONDS);\n if (Boolean.TRUE.equals(result)) {\n LOCK_VALUE_HOLDER.set(lockValue);\n return true;\n }\n if (System.currentTimeMillis() - startTime >= acquireTimeout) return false;\n Thread.sleep(RETRY_INTERVAL); // 50ms\n }\n}\n\n// ThumbServiceImpl 中的使用\npublic Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) {\n String lockKey = \"USERID:\" + userId;\n return redisLockUtil.executeWithLock(lockKey, () -> {\n return transactionTemplate.execute(status -> {\n Boolean exists = this.hasThumb(blogId, userId);\n if (exists) throw new BusinessException(ErrorCode.OPERATION_ERROR, \"用户已点赞\");\n // ... 更新数据库、Redis、布隆过滤器 ...\n });\n });\n}", + "language": "java", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "tryLock 中 Thread.sleep(RETRY_INTERVAL) 的自旋重试策略在高并发场景下有什么缺点?", + "options": { + "A": "sleep 时间太短(50ms),CPU 空转导致资源浪费", + "B": "固定的 50ms 重试间隔无法自适应负载,可能导致大量线程在锁释放瞬间同时竞争(惊群效应)", + "C": "sleep 会导致线程永久阻塞", + "D": "retry 间隔必须等于 lockTimeout 的 1/200" + }, + "answer": "B", + "explanation": "固定间隔自旋重试的核心问题是惊群效应(thundering herd):当锁释放时,所有等待的线程会在下一个 50ms 周期几乎同时尝试获取锁,只有一个成功,其余全部失败并再等 50ms。更好的方案包括:(1) 随机化重试间隔(jitter);(2) 使用 Redis 的发布/订阅(Pub/Sub)通知锁释放事件;(3) 使用 Redisson 等成熟框架的信号量机制。A 选项描述不准确,50ms 的 sleep 期间线程不占 CPU。C 选项错误,sleep 有超时且可被中断。D 选项无依据。" + }, + { + "index": 2, + "type": "short_answer", + "question": "doThumb 方法中,分布式锁的粒度是按 userId 设置的。这种设计有什么优点和局限?如果两个不同用户同时对同一篇博客点赞,锁会互斥吗?", + "answer": "优点:锁粒度是用户级别,同一用户的并发点赞操作被串行化,避免同一用户重复点赞或并发导致数据不一致。局限:不同用户对同一博客的点赞操作也会因为使用 executeWithLock 时 lockKey 为各自 userId 而不互斥,这其实是正确的——不同用户的点赞可以并行。但 lockKey 是 \"USERID:\" + userId,而不是 \"BLOGID:\" + blogId,意味着同一用户对不同博客的点赞会被互斥(串行),这是过度限制。两个不同用户同时点赞不会互斥,因为 lockKey 不同。", + "keywords": [ + "用户级锁粒度", + "并发点赞不互斥", + "同一用户串行", + "不同用户可并行", + "过度限制" + ], + "scoring_rubric": "满分(5分):准确指出锁粒度为 userId 级别(1分),说明同用户并发被串行化是正确设计(1分),说明不同用户点赞不互斥(1分),指出同用户对不同博客过度限制(1分),回答了锁互斥判断(1分)。每少一个要点扣1分,概念错误扣2分。", + "explanation": "doThumb 方法中,分布式锁的粒度是按 userId 设置的。这种设计有什么优点和局限?如果两个不同用户同时对同一篇博客点赞,锁会互斥吗?" + } + ], + "explanation": "本题考查 tryLock 的自旋重试机制在高并发下的表现,以及分布式锁粒度设计对业务的影响。", + "source": null, + "related": [] + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 5, + "tags": [ + "distributed-lock", + "redis-lock" + ], + "question": "阅读以下 executeWithLock 方法和旧方案对比代码,回答子问题。", + "code": "// 新方案:Redis 分布式锁\npublic T executeWithLock(String lockKey, LockAction action) {\n boolean locked = false;\n try {\n locked = tryLock(lockKey);\n if (!locked) throw new RuntimeException(\"获取分布式锁失败\");\n return action.execute();\n } finally {\n if (locked) unlock(lockKey);\n }\n}\n\n// 旧方案:synchronized + String.intern()\nsynchronized ((\"LOCK-USERID:\" + loginUser.getId().toString()).intern()) {\n return transactionTemplate.execute(status -> { ... });\n}", + "language": "java", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "executeWithLock 中,如果 action.execute() 抛出未检查异常,锁会被释放吗?", + "options": { + "A": "不会,异常会阻止 finally 块执行", + "B": "会,因为 finally 块在 try 块正常或异常退出时都会执行", + "C": "取决于异常类型,RuntimeException 会被释放,Checked Exception 不会", + "D": "会释放,但 Redis 会记录一个错误日志" + }, + "answer": "B", + "explanation": "Java 语言规范保证 finally 块无论 try 块是正常返回还是抛出异常都会执行。doThumb 中的 BusinessException 是未检查异常,action.execute() 抛出后,finally 块中 if (locked) unlock(lockKey) 会正常执行释放锁。这是 try/finally 的核心价值——确保资源(这里是分布式锁)在异常场景下也能正确清理。选项 A 是初学者常见误解,选项 C 混淆了 Checked/Unchecked Exception 对 finally 的影响,实际上两者都会执行 finally。选项 D 无意义。" + }, + { + "index": 2, + "type": "short_answer", + "question": "对比新方案(RedisLockUtil.executeWithLock)和旧方案(synchronized + String.intern()),除了跨进程能力外,新方案还有哪些设计优势?请至少列出两点。", + "answer": "1. 锁自动过期:Redis 锁设置了 TTL(10秒),即使进程崩溃锁也会自动释放,避免死锁;synchronized 锁依赖 JVM 正常退出或代码显式释放。2. 锁持有者验证:Redis 锁通过 UUID+threadId 作为 value,解锁时用 Lua 脚本验证身份,防止误删其他线程/进程的锁;synchronized 无法做到跨进程的身份验证。3. 可配置超时:tryLock 支持 acquireTimeout 参数,获取锁超时可控制;synchronized 要么获取到,要么阻塞,没有超时机制。4. 业务解耦:executeWithLock 封装了锁逻辑,业务代码通过 LockAction 接口实现,职责分离更清晰;synchronized 将锁逻辑嵌入业务代码中。", + "keywords": [ + "锁自动过期", + "TTL防死锁", + "锁持有者验证", + "Lua脚本原子性", + "acquireTimeout可配置", + "职责分离", + "LockAction接口" + ], + "scoring_rubric": "满分(5分):正确列出至少两点设计优势(2分),每点解释合理且准确(每点1.5分,最高3分)。仅说'跨进程'不得分(题目已排除该点)。每列一点但解释不准确扣0.5分。", + "explanation": "对比新方案(RedisLockUtil.executeWithLock)和旧方案(synchronized + String.intern()),除了跨进程能力外,新方案还有哪些设计优势?请至少列出两点。" + } + ], + "explanation": "本题考查分布式锁的设计模式对比,包括异常安全性(finally 保证)、自动过期机制、身份验证等方面的设计考量。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/distributed-lock/meta.json b/topics/thumbup/distributed-lock/meta.json new file mode 100644 index 0000000..236e819 --- /dev/null +++ b/topics/thumbup/distributed-lock/meta.json @@ -0,0 +1,29 @@ +{ + "slug": "distributed-lock", + "name": "单机锁与分布式锁", + "description": "synchronized到Redis SETNX演进、Lua脚本释放锁、锁续期问题", + "tags": [ + "distributed-lock", + "lock-design", + "redis-lock" + ], + "difficulty_range": [ + 3, + 5 + ], + "schema_version": "1.0.0", + "updated": "2026-09-09", + "question_files": [ + "single_choice", + "code_reading", + "short_answer" + ], + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } +} \ No newline at end of file diff --git a/topics/thumbup/distributed-lock/short_answer.json b/topics/thumbup/distributed-lock/short_answer.json new file mode 100644 index 0000000..dbcca6f --- /dev/null +++ b/topics/thumbup/distributed-lock/short_answer.json @@ -0,0 +1,77 @@ +{ + "topic": "distributed-lock", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "distributed-lock", + "lock-design" + ], + "question": "请解释 RedisLockUtil 中 DEFAULT_LOCK_TIMEOUT(10秒)和 DEFAULT_ACQUIRE_TIMEOUT(3秒)两个参数的区别,以及它们分别在什么场景下需要调整。", + "answer": "DEFAULT_LOCK_TIMEOUT(10秒)是锁的持有时间上限,即 Redis key 的 TTL。业务必须在此时间内完成操作,否则锁自动释放,可能导致并发问题。如果业务逻辑执行时间较长(如涉及远程调用、大量数据处理),需要适当增大该值。DEFAULT_ACQUIRE_TIMEOUT(3秒)是等待获取锁的最长时间,即客户端自旋等待的上限。在高并发场景下,如果多个客户端竞争同一把锁,超过此时间未获取到就返回失败。如果希望提高请求成功率而非快速失败,可以增大该值,但会增加线程阻塞时间。", + "keywords": [ + "锁持有时间", + "TTL", + "锁等待时间", + "acquireTimeout", + "业务执行时间", + "高并发", + "自旋等待" + ], + "scoring_rubric": "满分(5分):准确区分两个超时的含义(1分),说明 lockTimeout 是锁 TTL 自动释放(1分),说明 acquireTimeout 是自旋等待上限(1分),各给出一个调整场景(1分),区分概念清晰无混淆(1分)。混淆两者含义扣3分,只回答一个扣2分。", + "explanation": "DEFAULT_LOCK_TIMEOUT(10秒)是锁的持有时间上限,即 Redis key 的 TTL。业务必须在此时间内完成操作,否则锁自动释放,可能导致并发问题。如果业务逻辑执行时间较长(如涉及远程调用、大量数据处理),需要适当增大该值。DEFAULT_ACQUIRE_TIMEOUT(3秒)是等待获取锁的最长时间,即客户端自旋等待的上限。在高并发场景下,如果多个客户端竞争同一把锁,超过此时间未获取到就返回失败。如果希望提高请求成功率而非快速失败,可以增大该值,但会增加线程阻塞时间。" + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "distributed-lock", + "redis-lock" + ], + "question": "RedisLockUtil 使用 setIfAbsent(SET NX)实现锁的获取。请解释该方案的\"安全性\"(Safety)和\"活性\"(Liveness)分别由什么保证,以及各自的局限是什么。", + "answer": "安全性(Safety)——互斥性保证:由 Redis 的 SET NX 原子操作保证。NX 参数确保只有当 key 不存在时才能设置成功,从而保证同一时刻最多只有一个客户端持有锁。局限:在 Redis 主从架构下,主节点写入锁后未同步到从节点就宕机,从节点提升为主后锁丢失,安全性被破坏。活性(Liveness)——最终可获取性保证:由锁的 TTL(lockTimeout)自动过期保证。即使持有锁的客户端崩溃或网络分区,锁最终会过期释放,其他客户端可以获取。局限:如果业务执行时间超过 TTL,锁提前释放,安全性又被打破(活性的保证反而威胁了安全性)。这是一个经典的两难困境,也是 Redisson 引入看门狗(watchdog)自动续期机制的原因。", + "keywords": [ + "SET NX原子操作", + "互斥性", + "主从同步", + "TTL自动过期", + "锁续期", + "watchdog", + "活性与安全性的权衡" + ], + "scoring_rubric": "满分(5分):准确解释安全性由 SET NX 保证(1分),说明主从架构下的局限(1分),准确解释活性由 TTL 保证(1分),说明业务超时与安全性的矛盾(1分),提及看门狗/续期机制作为解决方案(1分)。仅回答一半内容扣2分,概念错误扣3分。", + "explanation": "安全性(Safety)——互斥性保证:由 Redis 的 SET NX 原子操作保证。NX 参数确保只有当 key 不存在时才能设置成功,从而保证同一时刻最多只有一个客户端持有锁。局限:在 Redis 主从架构下,主节点写入锁后未同步到从节点就宕机,从节点提升为主后锁丢失,安全性被破坏。活性(Liveness)——最终可获取性保证:由锁的 TTL(lockTimeout)自动过期保证。即使持有锁的客户端崩溃或网络分区,锁最终会过期释放,其他客户端可以获取。局限:如果业务执行时间超过 TTL,锁提前释放,安全性又被打破(活性的保证反而威胁了安全性)。这是一个经典的两难困境,也是 Redisson 引入看门狗(watchdog)自动续期机制的原因。" + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "distributed-lock", + "lock-design" + ], + "question": "如果要将 RedisLockUtil 升级为支持看门狗自动续期和可重入的生产级分布式锁,需要做哪些改造?请从架构设计角度列出至少三项关键改造点并简述实现思路。", + "answer": "1. 看门狗自动续期(Watchdog):在后台启动一个定时任务(如每 lockTimeout/3 时间),检查当前线程是否仍持有锁,如果持有则续期(重新 SET EX)。Redisson 的做法是在获取锁成功后启动一个 Netty 时间轮,定时续期,锁释放时取消定时器。2. 可重入锁支持:当前实现不支持可重入。改造思路是用 Hash 结构存储锁信息(field=锁持有者标识,value=重入次数),获取锁时如果已是持有者则 incr 重入计数,解锁时 decr,计数归零才真正删除 key。Lua 脚本需要相应改造以支持重入逻辑。3. 看门狗参数可配置化:当前的 lockTimeout 硬编码为 10 秒,应支持自定义续期间隔、最大续期时间等参数,以适配不同业务场景。4. 公平锁支持:当前实现是非公平锁(先到不一定先得),可引入 Redis 有序集合(Sorted Set)实现 FIFO 队列,按请求时间戳排序获取锁。5. 线程安全优化:移除 ThreadLocal 存储 lockValue 的设计,改为将 lockValue 作为 tryLock 的返回值传递给 unlock,避免线程池复用导致的 lockValue 错乱问题。", + "keywords": [ + "看门狗watchdog", + "自动续期", + "可重入锁", + "Hash结构", + "重入计数", + "Lua脚本改造", + "公平锁", + "Sorted Set", + "参数可配置", + "ThreadLocal替代" + ], + "scoring_rubric": "满分(5分):列出至少三项改造点(1分),每项有实现思路描述(每项1分,共3分),至少一项提到 Redisson 作为参考实现(0.5分),回答逻辑清晰、无概念错误(0.5分)。只列改造点无实现思路扣2分,概念错误(如说可重入用 List 实现)扣2分。", + "explanation": "1. 看门狗自动续期(Watchdog):在后台启动一个定时任务(如每 lockTimeout/3 时间),检查当前线程是否仍持有锁,如果持有则续期(重新 SET EX)。Redisson 的做法是在获取锁成功后启动一个 Netty 时间轮,定时续期,锁释放时取消定时器。2. 可重入锁支持:当前实现不支持可重入。改造思路是用 Hash 结构存储锁信息(field=锁持有者标识,value=重入次数),获取锁时如果已是持有者则 incr 重入计数,解锁时 decr,计数归零才真正删除 key。Lua 脚本需要相应改造以支持重入逻辑。3. 看门狗参数可配置化:当前的 lockTimeout 硬编码为 10 秒,应支持自定义续期间隔、最大续期时间等参数,以适配不同业务场景。4. 公平锁支持:当前实现是非公平锁(先到不一定先得),可引入 Redis 有序集合(Sorted Set)实现 FIFO 队列,按请求时间戳排序获取锁。5. 线程安全优化:移除 ThreadLocal 存储 lockValue 的设计,改为将 lockValue 作为 tryLock 的返回值传递给 unlock,避免线程池复用导致的 lockValue 错乱问题。" + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/distributed-lock/single_choice.json b/topics/thumbup/distributed-lock/single_choice.json new file mode 100644 index 0000000..81490eb --- /dev/null +++ b/topics/thumbup/distributed-lock/single_choice.json @@ -0,0 +1,168 @@ +{ + "topic": "distributed-lock", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 1, + "tags": [ + "distributed-lock", + "redis-lock" + ], + "question": "在 RedisLockUtil 中,setIfAbsent 方法的底层对应的 Redis 命令是什么?", + "options": { + "A": "SET key value", + "B": "SETNX key value", + "C": "SET key value NX EX ttl", + "D": "INCR key" + }, + "answer": "C", + "explanation": "Spring Data Redis 的 setIfAbsent(key, value, timeout, unit) 底层会拼接 NX(不存在才设置)和 EX/PX(过期时间)参数,等价于 Redis 的 SET key value NX EX ttl 原子命令。SETNX 命令(B 选项)是 Redis 2.6.12 之前的旧方式,不具备原子性地设置过期时间的能力;单纯 SET(A 选项)不带 NX 参数会直接覆盖已有值。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "distributed-lock", + "redis-lock" + ], + "question": "RedisLockUtil 中 unlock 方法使用 Lua 脚本的核心原因是什么?", + "options": { + "A": "提高执行性能,减少网络往返", + "B": "保证 get 和 del 操作的原子性,防止误删其他线程的锁", + "C": "避免 Redis 主从切换导致锁丢失", + "D": "支持可重入锁的实现" + }, + "answer": "B", + "explanation": "unlock 中的 Lua 脚本先 GET 锁的值,与当前线程持有的 lockValue 比较,匹配才 DEL。如果不使用 Lua 脚本,GET 和 DEL 是两条独立命令,在两步之间锁可能已过期并被其他线程获取,此时 DEL 会误删新线程的锁。Lua 脚本在 Redis 中是原子执行的,消除了这个竞态条件。选项 C 是 Redis 锁的已知局限(红锁解决),不是使用 Lua 脚本的原因;D 需要额外设计,不是 Lua 脚本本身的功能。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "distributed-lock", + "synchronized" + ], + "question": "旧方案 synchronized + String.intern() 的主要缺陷是什么?", + "options": { + "A": "String.intern() 会污染永久代/元空间,导致 OOM", + "B": "synchronized 无法跨 JVM 进程工作,在分布式部署下无效", + "C": "String.intern() 返回的是不可变引用,无法释放锁", + "D": "synchronized 是公平锁,性能极差" + }, + "answer": "B", + "explanation": "synchronized 是 JVM 级别的内置锁,只能在同一 JVM 进程内的线程之间互斥。在分布式部署(多实例)场景下,不同 JVM 中的线程持有各自的 monitor,无法实现全局互斥,可能导致重复点赞等并发问题。虽然 A 选项(intern 污染字符串常量池)也是一个缺点,但它不是迁移到分布式锁的根本原因。C 选项错误,intern 返回的字符串可以正常 GC。D 选项错误,synchronized 默认是非公平锁。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "distributed-lock", + "lock-design" + ], + "question": "RedisLockUtil 中的锁超时时间(DEFAULT_LOCK_TIMEOUT = 10000L)的作用是什么?如果业务执行超过该时间会发生什么?", + "options": { + "A": "锁永远不会释放,需要手动调用 unlock", + "B": "Redis 自动删除锁 key,其他线程可获取锁,当前线程仍持有 lockValue 但实际已失去保护", + "C": "Redis 会向当前线程发送中断信号", + "D": "锁会自动续期,直到业务完成" + }, + "answer": "B", + "explanation": "DEFAULT_LOCK_TIMEOUT 是 Redis 锁 key 的过期时间(TTL),由 SET 命令的 EX 参数控制。当业务执行时间超过该 TTL,Redis 会自动删除锁 key,此时其他线程可以成功获取锁,破坏互斥性。而当前线程的 ThreadLocal 中仍保留着 lockValue,但该值在 Redis 中已不存在,Lua 脚本的 get 比较会失败。这正是没有看门狗(watchdog)机制的经典问题——可参考 Redisson 的自动续期方案。该实现没有锁续期机制。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "distributed-lock", + "lock-design" + ], + "question": "tryLock 方法中使用 ThreadLocal 存储 lockValue 的设计有什么潜在问题?", + "options": { + "A": "ThreadLocal 存储的值会被其他线程读取,导致数据竞争", + "B": "如果 tryLock 成功但 unlock 未被调用(异常路径),ThreadLocal 不会被清理,可能导致线程池复用时锁泄露", + "C": "ThreadLocal 不支持序列化,无法在分布式环境使用", + "D": "ThreadLocal 会在每次 set 时触发 GC,影响性能" + }, + "answer": "B", + "explanation": "executeWithLock 方法虽然在 finally 中调用了 unlock(会清理 ThreadLocal),但如果直接调用 tryLock 后忘记调用 unlock,或者中间发生未捕获的异常导致 unlock 未执行,ThreadLocal 中的 lockValue 就不会被 remove。在使用线程池的场景下,线程被复用时旧的 lockValue 仍在,可能导致后续锁操作使用错误的值。虽然 executeWithLock 封装了 try/finally,但作为一个独立工具类,这仍是设计上的隐患。实际生产中通常将 lockValue 作为方法返回值而非存储在 ThreadLocal 中。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "distributed-lock", + "redis-lock" + ], + "question": "在 Redis 主从架构下,RedisLockUtil 的 tryLock 方法存在什么已知缺陷?", + "options": { + "A": "主节点宕机后从节点提升为主,但锁数据可能未同步,导致多个客户端同时获得锁", + "B": "从节点不支持 SETNX 命令", + "C": "主从同步延迟会导致 setIfAbsent 返回 false 但实际已写入", + "D": "哨兵模式下无法连接 Redis" + }, + "answer": "A", + "explanation": "这是 Redis 单节点/主从模式分布式锁的经典问题(Martin Kleppmann 在分析 Redisson 时指出):客户端 A 在主节点写入锁 key,但主节点在同步到从节点之前宕机;从节点提升为主后,该锁 key 不存在,客户端 B 可以成功获取锁——此时 A 和 B 同时持有锁,互斥性被打破。这就是 RedLock 算法(Redis 官方提出)试图解决的问题,但其本身也存在争议。选项 B 错误,从节点支持所有写命令(通过主节点转发)。选项 C 描述的是主从异步复制的延迟现象,但不会导致 setIfAbsent 行为异常。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "distributed-lock", + "lock-design" + ], + "question": "generateLockValue 方法返回的是 UUID + threadId 组合。为什么不直接用 userId 作为 lockValue?", + "options": { + "A": "userId 是数字,不能作为 Redis 的 value", + "B": "如果用 userId 作为 lockValue,持有锁的线程无法唯一标识,unlock 时的 Lua 脚本无法区分不同请求来源", + "C": "UUID 生成速度比 userId 快", + "D": "userId 不满足 Redis key 的命名规范" + }, + "answer": "B", + "explanation": "unlock 的 Lua 脚本通过比较锁的 value 来判断是否为当前持有者的锁。如果用 userId 作为 lockValue,那么同一用户的多次请求(可能在不同线程甚至不同 JVM 中)会产生相同的 lockValue,导致:(1) 后来的请求的 SETNX 会失败(锁已被自己的旧请求持有);(2) 或者在锁过期后,新请求获取锁后用 userId 解锁时,可能误删已过期但被其他实例获取的锁。UUID + threadId 能保证每个锁持有者有唯一标识,确保 unlock 时精确匹配。选项 A 错误,Redis value 可以是任何字符串。选项 D 错误,lockKey 用 userId,lockValue 是锁的凭证。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 5, + "tags": [ + "distributed-lock", + "lock-design" + ], + "question": "RedisLockUtil 的 tryLock 方法在获取锁失败时会抛出异常吗?如果 acquireTimeout 内未获取到锁,executeWithLock 会如何表现?", + "options": { + "A": "tryLock 返回 false,executeWithLock 抛出 RuntimeException", + "B": "tryLock 返回 false,executeWithLock 返回 null", + "C": "tryLock 抛出 InterruptedException,executeWithLock 捕获并重试", + "D": "tryLock 会无限等待直到获取锁" + }, + "answer": "A", + "explanation": "tryLock 方法在 acquireTimeout(默认 3000ms)内未获取到锁时返回 false(而非抛异常)。executeWithLock 中,locked 变量为 false 时会主动抛出 RuntimeException(\"获取分布式锁失败\")。这意味着获取锁超时被视为错误场景,需要调用方处理该异常。在 doThumb 场景中,用户点赞操作获取锁失败会直接报错,而不是静默返回 false——这是合理的降级策略,避免在高并发下静默失败。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/heavykeeper-topk/code_reading.json b/topics/thumbup/heavykeeper-topk/code_reading.json new file mode 100644 index 0000000..50897e8 --- /dev/null +++ b/topics/thumbup/heavykeeper-topk/code_reading.json @@ -0,0 +1,176 @@ +{ + "topic": "heavykeeper-topk", + "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": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "阅读以下 HeavyKeeper.add() 方法的核心逻辑,回答子问题。", + "code": "public AddResult add(String key, int increment) {\n byte[] keyBytes = key.getBytes();\n long itemFingerprint = hash(keyBytes);\n int maxCount = 0;\n for (int i = 0; i < depth; i++) {\n int bucketNumber = Math.abs(hash(keyBytes)) % width;\n Bucket bucket = buckets[i][bucketNumber];\n synchronized (bucket) {\n if (bucket.count == 0) {\n bucket.fingerprint = itemFingerprint;\n bucket.count = increment;\n } else if (bucket.fingerprint == itemFingerprint) {\n bucket.count += increment;\n } else {\n for (int j = 0; j < increment; j++) {\n double decay = bucket.count < LOOKUP_TABLE_SIZE ?\n lookupTable[bucket.count] :\n lookupTable[LOOKUP_TABLE_SIZE - 1];\n if (random.nextDouble() < decay) {\n bucket.count--;\n if (bucket.count == 0) {\n bucket.fingerprint = itemFingerprint;\n bucket.count = increment - j;\n break;\n }\n }\n }\n }\n }\n maxCount = Math.max(maxCount, bucket.count);\n }\n total += increment;\n return new AddResult(maxCount, ...);\n}", + "language": "java", + "explanation": "这段代码展示了 HeavyKeeper 的核心插入逻辑。对于每个 Key,在 depth 层中分别定位桶,然后根据桶的状态(空/匹配/冲突)执行不同操作。冲突时通过概率衰减尝试腾空桶。", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "当 `bucket.fingerprint == itemFingerprint` 为真时,说明什么?", + "options": { + "A": "发生了哈希冲突,需要进行衰减淘汰", + "B": "该桶当前存储的正是当前 Key,直接累加计数", + "C": "桶已被清空,可以写入新指纹", + "D": "需要将该 Key 移到下一层的桶中" + }, + "answer": "B", + "explanation": "bucket.fingerprint == itemFingerprint 表示桶中已存储的指纹与当前 Key 的指纹一致,说明该桶属于当前 Key,此时直接将计数累加 increment,无需任何衰减操作。这是最理想的分支——热 Key 被再次命中。" + }, + { + "index": 2, + "type": "single_choice", + "question": "在冲突衰减分支中,decay 值的计算方式为 `lookupTable[bucket.count]`(当 count < 256 时),这意味着桶计数越高,衰减概率如何变化?", + "options": { + "A": "衰减概率越大,更容易被淘汰", + "B": "衰减概率越小,越难被淘汰", + "C": "衰减概率保持不变", + "D": "衰减概率先增后减" + }, + "answer": "B", + "explanation": "lookupTable[i] = 0.92^i,这是一个单调递减函数。bucket.count 越大,lookupTable[bucket.count] 越小,意味着递减概率越低。因此高频热 Key 的桶计数大、衰减概率小,很难被冷 Key 冲掉;而冷 Key 的桶计数小、衰减概率大,容易被清零淘汰。" + }, + { + "index": 3, + "type": "single_choice", + "question": "以下哪个场景会触发 `bucket.count = increment - j` 这行代码?", + "options": { + "A": "桶为空时直接写入新 Key", + "B": "当前 Key 的指纹与桶指纹匹配时累加计数", + "C": "冲突衰减过程中桶计数被减到 0,桶被腾空", + "D": "minHeap 中的最小元素被淘汰时" + }, + "answer": "C", + "explanation": "bucket.count == 0 发生在冲突衰减循环中:经过 j 次成功的衰减递减后,桶计数从原来的状态被减到了 0。此时桶已腾空,写入当前 Key 的指纹,并将 count 设为 increment - j(因为已经衰减了 j 次,还剩 increment - j 次未使用)。break 跳出衰减循环。" + } + ] + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "阅读以下 HeavyKeeper 的 add() 方法完整片段(含 minHeap 管理),回答子问题。", + "code": "// ... 前面的桶操作逻辑 ...\n// 经过 depth 层桶操作后,得到 maxCount\n\ntotal += increment;\n\n// === minHeap 管理 ===\nsynchronized (minHeap) {\n if (maxCount >= minCount) {\n // 检查 Key 是否已在堆中\n Node existing = heapIndex.get(key);\n if (existing != null) {\n existing.count = maxCount;\n minHeap.remove(existing);\n minHeap.add(existing);\n } else {\n if (minHeap.size() < k) {\n Node node = new Node(key, maxCount);\n minHeap.add(node);\n heapIndex.put(key, node);\n } else {\n Node min = minHeap.peek();\n if (maxCount > min.count) {\n heapIndex.remove(min.key);\n minHeap.poll();\n Node node = new Node(key, maxCount);\n minHeap.add(node);\n heapIndex.put(key, node);\n }\n }\n }\n }\n}", + "language": "java", + "explanation": "这段代码展示了 minHeap 的完整管理逻辑:先检查 maxCount 是否超过 minCount 门槛,再判断 Key 是否已在堆中,最后根据堆大小决定是直接插入还是替换堆顶最小元素。", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "为什么要检查 `maxCount >= minCount` 才能进入 minHeap 操作?", + "options": { + "A": "防止整数溢出", + "B": "过滤低频 Key,避免大量低频元素占用堆空间", + "C": "确保 fingerprint 已初始化", + "D": "保证哈希分布均匀" + }, + "answer": "B", + "explanation": "minCount 是一个入门门槛。数据流中绝大多数 Key 是低频的,如果每个 Key 都加入堆,堆的大小会无限膨胀。只有频率达到 minCount 的 Key 才值得参与 Top-K 竞争,这大大减少了堆的操作次数和内存占用。" + }, + { + "index": 2, + "type": "single_choice", + "question": "当 minHeap.size() == k 且 maxCount > min.count 时,代码执行了什么操作?", + "options": { + "A": "在堆中插入新节点,堆大小变为 k+1", + "B": "移除堆顶(最小元素),将新 Key 插入堆中,堆大小保持为 k", + "C": "忽略新 Key,不做任何操作", + "D": "将堆顶元素的 count 更新为 maxCount" + }, + "answer": "B", + "explanation": "当堆已满(size == k)且新 Key 的频率大于堆顶最小元素时,先 poll() 移除堆顶,再 add() 插入新 Key。这样堆大小保持为 k,同时将更高频的 Key 纳入 Top-K。这是维护最小堆实现 Top-K 的标准操作。" + }, + { + "index": 3, + "type": "single_choice", + "question": "heapIndex(Map)的作用是什么?", + "options": { + "A": "存储所有 Key 的指纹", + "B": "记录 Key 到堆节点的映射,支持快速查找和更新已在堆中的 Key", + "C": "记录每层桶的访问频率", + "D": "缓存 fading() 操作的中间结果" + }, + "answer": "B", + "explanation": "Java 的 PriorityQueue 不支持高效的随机查找和删除。heapIndex 维护了 Key → Node 的映射,当一个已在堆中的 Key 频率更新时,可以通过 heapIndex 快速找到对应 Node,执行 remove + add 更新堆。如果不维护这个索引,每次更新都需要 O(n) 遍历堆。" + } + ] + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 5, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "阅读以下 HeavyKeeper.fading() 方法的完整实现,回答子问题。", + "code": "public void fading() {\n // 第一阶段:对所有桶执行右移减半\n for (Bucket[] row : buckets) {\n for (Bucket bucket : row) {\n synchronized (bucket) {\n bucket.count = bucket.count >> 1;\n }\n }\n }\n // 第二阶段:同步衰减 minHeap 中所有节点\n synchronized (minHeap) {\n PriorityQueue newHeap = new PriorityQueue<>(\n k, Comparator.comparingLong(n -> n.count)\n );\n for (Node node : minHeap) {\n newHeap.add(new Node(node.key, node.count >> 1));\n }\n minHeap.clear();\n minHeap.addAll(newHeap);\n }\n // 第三阶段:全局计数减半\n total = total >> 1;\n}", + "language": "java", + "explanation": "fading() 实现了全局指数衰减,通过三阶段操作:桶计数减半、堆节点计数减半、全局 total 减半。这使 HeavyKeeper 能适应数据流分布的时间变化。", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "fading() 为什么要新建一个 newHeap 而不是直接修改 minHeap 中的 Node.count?", + "options": { + "A": "因为 Node.count 是 final 字段,不可修改", + "B": "因为直接修改 Node 的 count 会破坏 PriorityQueue 的堆序性质,导致堆结构失效", + "C": "为了提高内存分配效率", + "D": "因为 minHeap 不支持迭代器" + }, + "answer": "B", + "explanation": "PriorityQueue 是基于堆序(每个父节点 ≤ 子节点)维护的。如果直接修改 Node 的 count 值,堆中元素的顺序关系会被破坏,后续的 peek/poll 操作会返回错误结果。正确做法是创建新堆,将衰减后的节点逐个插入,重新建立堆序。" + }, + { + "index": 2, + "type": "single_choice", + "question": "如果某个 Key 的桶 count 在 fading() 前为 3,执行 fading() 后变为多少?", + "options": { + "A": "3", + "B": "1", + "C": "0", + "D": "1.5" + }, + "answer": "B", + "explanation": "3 >> 1 = 1(二进制 11 右移一位变成 1,即整数除以 2 向下取整)。fading() 后该桶的 count 从 3 变为 1。如果原来是 1,则 1 >> 1 = 0,桶被清空。这种衰减让低频 Key 逐渐被淘汰。" + }, + { + "index": 3, + "type": "single_choice", + "question": "fading() 执行后,一个频率为 100 的热 Key 经过多次 fading() 后频率变为约 12(≈100/8),这意味着大约执行了几次 fading()?", + "options": { + "A": "2 次", + "B": "3 次", + "C": "4 次", + "D": "5 次" + }, + "answer": "B", + "explanation": "每次 fading() 将计数减半:100 → 50 → 25 → 12,经过 3 次 fading() 后频率约为 12(精确值为 12,因为 100>>1=50, 50>>1=25, 25>>1=12)。这体现了 fading 的指数衰减效果——频率每经过一次 fading() 就减半。" + } + ] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/heavykeeper-topk/meta.json b/topics/thumbup/heavykeeper-topk/meta.json new file mode 100644 index 0000000..bafe38d --- /dev/null +++ b/topics/thumbup/heavykeeper-topk/meta.json @@ -0,0 +1,30 @@ +{ + "slug": "heavykeeper-topk", + "name": "HeavyKeeper Top-K 探测", + "description": "HeavyKeeper算法原理、指数衰减、vs CMS对比、Top-K堆管理", + "tags": [ + "cms", + "heavykeeper", + "heavykeeper-topk", + "hot-key-detection" + ], + "difficulty_range": [ + 3, + 5 + ], + "schema_version": "1.0.0", + "updated": "2026-09-09", + "question_files": [ + "single_choice", + "code_reading", + "short_answer" + ], + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } +} \ No newline at end of file diff --git a/topics/thumbup/heavykeeper-topk/short_answer.json b/topics/thumbup/heavykeeper-topk/short_answer.json new file mode 100644 index 0000000..0a12be0 --- /dev/null +++ b/topics/thumbup/heavykeeper-topk/short_answer.json @@ -0,0 +1,76 @@ +{ + "topic": "heavykeeper-topk", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "heavykeeper-topk", + "cms" + ], + "question": "请对比说明 HeavyKeeper 和 Count-Min Sketch (CMS) 在处理「冷 Key 与热 Key 哈希冲突」时的行为差异,以及这种差异对 Top-K 探测准确性的影响。", + "answer": "CMS:冷 Key 与热 Key 哈希到同一桶时,冷 Key 的频率会累加到热 Key 上,且 CMS 只做加法不做减法,导致热 Key 的频率被持续高估(正偏差),冷 Key 的存在会'污染'热 Key 的计数。HeavyKeeper:采用概率衰减淘汰策略——当冷 Key 冲突到热 Key 的桶时,会以较高概率递减桶计数(因为冷 Key 频率低对应桶 count 小,衰减概率高);而热 Key 自身频繁命中会保持高 count,衰减概率低,不易被淘汰。结果是冷 Key 的污染被逐步清除,热 Key 的计数更准确。", + "keywords": [ + "冷热冲突", + "概率衰减", + "计数累加", + "正偏差", + "污染" + ], + "scoring_rubric": "评分标准:1. 准确描述 CMS 在冷热冲突时的行为——冷 Key 计数累加到热 Key 导致正偏差(2分);2. 准确描述 HeavyKeeper 的概率衰减淘汰机制——低 count 衰减概率高、高 count 衰减概率低(2分);3. 对比两者对 Top-K 准确性的影响——CMS 会误判、HeavyKeeper 更准确(1分)", + "explanation": "CMS 的核心缺陷是只增不减,冷 Key 的污染是永久性的。HeavyKeeper 通过指数衰减(0.92^count)让冷 Key(小 count)容易被淘汰,而热 Key(大 count)几乎不受影响,实现了'自清洁'效果。这是 HeavyKeeper 相比 CMS 在 Top-K 场景下的核心优势。", + "source": null, + "related": [] + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 的 fading() 机制是如何实现时间窗口适应性的?请解释其工作原理以及为什么需要定期调用。", + "answer": "fading() 通过全局衰减实现时间窗口适应:每次调用时,将所有桶的计数、minHeap 中节点的计数、以及全局 total 都执行右移一位(除以 2)。效果等价于指数衰减的时间窗口——历史数据的权重随时间指数衰减。定期调用 fading() 的必要性:1. 数据流分布会随时间变化(如电商大促导致热点切换),不衰减会导致旧热点长期占据 Top-K 位置;2. 衰减让低频的旧 Key 逐渐被淘汰(count 趋向 0),腾出位置给新的热点 Key;3. 模拟滑动时间窗口,使算法只关注近期的数据分布。", + "keywords": [ + "fading", + "指数衰减", + "时间窗口", + "右移减半", + "分布变化" + ], + "scoring_rubric": "评分标准:1. 正确解释 fading() 的操作——对桶计数、堆节点、全局 total 统一执行右移减半(2分);2. 说明指数衰减的时间窗口效果——历史权重随时间递减(1分);3. 解释定期调用的必要性——适应数据分布变化、淘汰旧热点、腾出空间给新热点(2分)", + "explanation": "fading() 是 HeavyKeeper 区别于 CMS 的关键机制之一。CMS 没有内置衰减,数据分布变化后只能重建。HeavyKeeper 通过简单的位运算实现了优雅的指数衰减,兼顾了实现简洁性和运行效率。", + "source": null, + "related": [] + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "heavykeeper-topk", + "heavykeeper", + "hot-key-detection" + ], + "question": "假设需要在生产环境中使用 HeavyKeeper 实现 Top-100 热 Key 检测(width=100000, depth=5, minCount=10),请分析:(1) 该配置的内存开销大约是多少?(2) 与同等精度的 CMS 相比,HeavyKeeper 的内存开销有何差异?(3) 在什么场景下应该选择 HeavyKeeper 而非 CMS?", + "answer": "(1) 内存开销分析:每个 Bucket 存储一个 fingerprint(8 字节 long)+ 一个 count(4 字节 int)+ 对象开销,假设约 24-32 字节。总桶数 = 5 × 100000 = 500000,桶内存 ≈ 500000 × 24 ≈ 12MB。加上 lookupTable(256 × 8 = 2KB)、minHeap(100 个节点,可忽略)、其他元数据,总计约 12-15MB。(2) 与 CMS 对比:CMS 每个计数器通常用 4 字节(int),相同 width×depth 配置下 CMS 只需 5 × 100000 × 4 ≈ 2MB。HeavyKeeper 多了 fingerprint 存储和对象开销,内存约为 CMS 的 5-7 倍。但 HeavyKeeper 的 Top-K 结果更准确(冷 Key 自清洁),这是用额外内存换精度。(3) 选择 HeavyKeeper 的场景:需要实时 Top-K 热 Key 探测且数据流分布会随时间变化(如 CDN 访问热点、电商流量分析);选择 CMS 的场景:只需要频率估计且内存极度受限,或数据分布稳定不需要衰减。", + "keywords": [ + "内存开销", + "width", + "depth", + "fingerprint", + "CMS 对比", + "场景选择" + ], + "scoring_rubric": "评分标准:1. 正确计算 HeavyKeeper 内存开销——桶数 width×depth、每桶 fingerprint+count 约 24-32 字节、总计约 12-15MB(2分);2. 正确对比与 CMS 的内存差异——CMS 仅存计数约 2MB,HeavyKeeper 约 5-7 倍但精度更高(2分);3. 准确区分适用场景——HeavyKeeper 适合动态变化的 Top-K,CMS 适合静态频率估计或内存极度受限场景(1分)", + "explanation": "内存分析是工程实践中的关键考量。HeavyKeeper 的额外内存主要来自 fingerprint 存储和 Java 对象开销。在生产环境中,如果内存预算允许且需要高质量的 Top-K 结果,HeavyKeeper 是更好的选择。如果内存极度紧张或只需要粗粒度的频率估计,CMS 可能更合适。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/heavykeeper-topk/single_choice.json b/topics/thumbup/heavykeeper-topk/single_choice.json new file mode 100644 index 0000000..71cad3e --- /dev/null +++ b/topics/thumbup/heavykeeper-topk/single_choice.json @@ -0,0 +1,168 @@ +{ + "topic": "heavykeeper-topk", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 算法的核心设计目标是什么?", + "options": { + "A": "对所有元素进行精确频率计数", + "B": "从海量数据流中高效探测 Top-K 热点 Key", + "C": "替代 Bloom Filter 实现集合成员判定", + "D": "对数据流进行无损压缩存储" + }, + "answer": "B", + "explanation": "HeavyKeeper 是一种面向流式数据的 Top-K 热 Key 探测算法,其核心设计目标是在有限内存下,从海量数据流中高效、准确地找出频率最高的 K 个 Key。它不是做全量精确计数(A),也不替代 Bloom Filter(C),更不是通用压缩(D)。其名字中的 'Heavy' 直接指向对热点(heavy hitter)的检测。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 中 depth 参数(例如 depth=5)的含义是什么?", + "options": { + "A": "每个 Key 最多被计数 5 次", + "B": "数据结构维护 5 层独立的桶数组,每层独立散列", + "C": "Top-K 排序时保留前 5 个候选", + "D": "衰减淘汰的最大轮数为 5" + }, + "answer": "B", + "explanation": "depth 表示 HeavyKeeper 的行数(层数)。整个数据结构是 depth × width 的二维桶矩阵(Bucket[][]),每一层使用相同的哈希算法但独立维护一组桶。一个 Key 会被散列到每一层的对应桶中,查询时取各层计数的最大值作为频率估计。多层设计通过冗余降低误判概率。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "在 HeavyKeeper 的 add() 方法中,当某个桶已有指纹不同的其他 Key(即发生冲突)时,算法如何处理?", + "options": { + "A": "直接覆盖桶中原有指纹,计数重置", + "B": "将两个 Key 的频率取平均值存入桶中", + "C": "按指数衰减概率递减桶的计数,有机会腾空桶后替换指纹", + "D": "将冲突 Key 溢出到相邻桶中" + }, + "answer": "C", + "explanation": "HeavyKeeper 面对冲突时采用概率衰减淘汰策略:对于 increment 次尝试,每次以 lookupTable[bucket.count] 的概率(即约 0.92^count)递减桶计数。桶计数越大,衰减概率越低,热 Key 越难被淘汰。如果桶计数被减到 0,则腾空该桶并写入新 Key 的指纹和计数。这与 CMS 的简单计数累加形成鲜明对比。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "heavykeeper-topk", + "cms" + ], + "question": "与 Count-Min Sketch (CMS) 相比,HeavyKeeper 在处理冷 Key 冲突时的关键优势是什么?", + "options": { + "A": "HeavyKeeper 使用更多的哈希函数,冲突概率更低", + "B": "HeavyKeeper 的冷 Key 会通过概率衰减被自动淘汰,不会持续污染热 Key 的计数", + "C": "HeavyKeeper 不会产生任何误判", + "D": "HeavyKeeper 使用更大的桶数组,冲突概率更低" + }, + "answer": "B", + "explanation": "CMS 的致命缺点是:冷 Key 与热 Key 哈希到同一位置时,冷 Key 的计数会累加到热 Key 上,且永远不会减少,导致热 Key 频率被持续高估(污染)。HeavyKeeper 通过概率衰减机制,冷 Key(频率低)对应的桶计数小,衰减概率高,容易被清零淘汰,从而避免对热 Key 的长期污染。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 中 lookupTable 数组预计算 0.92^i 的主要目的是什么?", + "options": { + "A": "用于对桶计数进行归一化处理", + "B": "避免运行时反复调用 Math.pow(),用查表法加速指数衰减概率的计算", + "C": "作为桶计数的上界阈值", + "D": "用于计算 Top-K 的排序分数" + }, + "answer": "B", + "explanation": "lookupTable 是一个预计算的查找表,存储了 0.92^0, 0.92^1, ..., 0.92^255 的值。在 add() 的衰减逻辑中,每次需要计算衰减概率 decay = 0.92^count。如果每次用 Math.pow() 计算,性能开销很大。通过预计算并查表,将指数运算降为 O(1) 数组访问,显著提升吞吐量。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 的 fading() 方法对所有桶执行 `bucket.count = bucket.count >> 1`,这种右移操作的实际效果是?", + "options": { + "A": "将计数除以 2 并向上取整", + "B": "将计数除以 2 并向下取整(等价于整数除法)", + "C": "将计数乘以 2", + "D": "将计数清零" + }, + "answer": "B", + "explanation": "Java 中的右移运算符 `>>` 对正整数等价于整数除以 2 并向下取整。例如:5 >> 1 = 2,7 >> 1 = 3,1 >> 1 = 0。fading() 利用这一特性实现全局衰减,让所有桶的计数减半,模拟时间窗口滑动的效果,使算法能适应数据流分布的变化。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 在桶中同时存储 fingerprint(指纹)和 count(计数),fingerprint 的核心作用是什么?", + "options": { + "A": "加速桶的哈希定位过程", + "B": "作为轻量级身份标识,用于验证桶中存储的是否是当前 Key,避免全 Key 比较", + "C": "替代 Bloom Filter 做存在性判定", + "D": "用于 Top-K 排序的优先级计算" + }, + "answer": "B", + "explanation": "如果桶中只存计数而不标识属于哪个 Key,则无法区分当前桶是属于当前 Key 还是冲突的其他 Key。存储完整 Key 字符串又太耗内存。fingerprint 是 Key 的哈希值(如 64 位),作为轻量级身份标识,在 add() 和查询时用于快速判断桶是否属于当前 Key。虽然存在指纹碰撞可能,但概率极低。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "heavykeeper-topk", + "heavykeeper" + ], + "question": "HeavyKeeper 中 minCount 参数(例如 minCount=10)的作用是什么?", + "options": { + "A": "桶计数低于此值时触发 fading 衰减", + "B": "只有累计频率超过此阈值的 Key 才有资格进入 Top-K 最小堆", + "C": "设置每个桶的计数上限", + "D": "控制哈希函数的最小散列粒度" + }, + "answer": "B", + "explanation": "minCount 是一个入门门槛:当某个 Key 的估计频率达到 minCount 时,它才有资格被插入到维护 Top-K 的最小堆(minHeap)中。这一设计避免了将大量低频 Key 放入堆中,节省内存和排序开销。只有真正可能成为热点的 Key 才会参与 Top-K 竞争。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/lua-bucketing-sync/code_reading.json b/topics/thumbup/lua-bucketing-sync/code_reading.json new file mode 100644 index 0000000..16f139c --- /dev/null +++ b/topics/thumbup/lua-bucketing-sync/code_reading.json @@ -0,0 +1,137 @@ +{ + "topic": "lua-bucketing-sync", + "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": [ + "lua-bucketing-sync", + "lua" + ], + "question": "分析以下点赞 Lua 脚本,回答子问题。", + "explanation": "本题考查对 Redis Lua 脚本原子操作细节的理解,包括 Key 设计和空值处理。", + "code": "local tempThumbKey = KEYS[1]\nlocal userThumbKey = KEYS[2]\nlocal userId = ARGV[1]\nlocal blogId = ARGV[2]\n\nif redis.call('HEXISTS', userThumbKey, blogId) == 1 then\n return -1\nend\nlocal hashKey = userId .. ':' .. blogId\nlocal oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)\nlocal newNumber = oldNumber + 1\nredis.call('HSET', tempThumbKey, hashKey, newNumber)\nredis.call('HSET', userThumbKey, blogId, 1)\nreturn 1", + "language": "lua", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "脚本中 hashKey 的拼接格式是什么?这样设计的目的是什么?", + "options": { + "A": "userId:blogId — 将临时计数按用户维度隔离,避免不同用户对同一博客的计数互相覆盖", + "B": "blogId:userId — 方便按博客维度批量查询", + "C": "userId — 简化 Key 结构,牺牲并发性", + "D": "tempThumbKey:userId — 组合键防止碰撞" + }, + "answer": "A", + "explanation": "hashKey = userId .. ':' .. blogId,使用 '用户ID:博客ID' 格式。tempThumbKey 是按时间片分桶的 Hash,桶内需要区分不同用户的计数。以 'userId:blogId' 为 field,既能保证不同用户对同一博客的计数互不干扰,也便于后续落库时按用户维度解析。" + }, + { + "index": 2, + "type": "short_answer", + "question": "这段脚本的 'or 0' 在 HGET 返回值处理中起什么作用?如果去掉会有什么风险?", + "explanation": "or 0 是 Lua 的短路求值语法,HGET 不存在时返回 nil,nil or 0 得到 0,确保 tonumber 正常运行。", + "answer": "or 0 的作用是在 HGET 返回 nil(field 不存在)时提供默认值 0,确保 tonumber 不会收到 nil 参数。如果去掉,当用户第一次对某博客点赞时 HGET 返回 nil,tonumber(nil) 会报错导致整个 Lua 脚本执行失败,点赞功能不可用。", + "keywords": [ + "or 0", + "默认值", + "nil", + "tonumber", + "第一次点赞" + ], + "scoring_rubric": "答出 nil 处理/默认值给定给 2 分;答出 tonumber 对 nil 会报错给 2 分;答出会导致脚本执行失败/点赞不可用给 1 分。满分 5 分。" + } + ] + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "lua-bucketing-sync", + "sync-job" + ], + "question": "分析以下定时落库核心逻辑,回答子问题。", + "explanation": "本题考查定时落库任务的时间片计算逻辑、边界条件处理以及 Spring 事务管理对 Redis+DB 双写的影响。", + "code": "@Scheduled(fixedRate = 10000)\n@Transactional(rollbackFor = Exception.class)\npublic void run() {\n DateTime nowDate = DateUtil.date();\n int second = (DateUtil.second(nowDate) / 10 - 1) * 10;\n if (second == -10) {\n second = 50;\n nowDate = DateUtil.offsetMinute(nowDate, -1);\n }\n String date = DateUtil.format(nowDate, \"HH:mm:\") + second;\n syncThumb2DBByDate(date);\n}", + "language": "java", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "假设当前时间是 10:00:05,run() 方法计算出的 date 值是什么?", + "options": { + "A": "10:00:00", + "B": "09:59:50", + "C": "10:00:10", + "D": "09:59:00" + }, + "answer": "B", + "explanation": "second = (5 / 10 - 1) * 10 = (0 - 1) * 10 = -10。触发边界处理:second = 50, nowDate 前推一分钟为 09:59:xx。date = '09:59:' + 50 = '09:59:50'。这处理的是上一分钟最后一个 10 秒桶(09:59:50~09:59:59)的数据。" + }, + { + "index": 2, + "type": "single_choice", + "question": "方法上的 @Transactional 注解对落库操作意味着什么?如果 syncThumb2DBByDate 中批量写入 DB 成功但批量删除 Redis Key 失败,会发生什么?", + "options": { + "A": "事务会回滚,DB 写入被撤销,Redis Key 保留,后续补偿任务会重新同步", + "B": "事务只回滚 DB 操作,Redis 操作不受事务管理所以不受影响", + "C": "事务会覆盖 Redis 操作,导致 Redis 数据也被回滚", + "D": "不会发生异常,因为 Redis 删除在虚拟线程中执行" + }, + "answer": "A", + "explanation": "@Transactional 注解使得 syncThumb2DBByDate 方法在同一个事务中执行。如果 DB 操作成功但 Redis Key 删除失败(抛异常),Spring 事务管理器会回滚 DB 的所有写入。Redis 删除使用虚拟线程异步执行,不受 Spring 事务管理——如果 Redis 删除在同步块中失败,事务回滚确保 DB 一致性;如果 Redis 删除在异步块中失败,事务已完成提交,Redis Key 残留但数据已写入 DB,补偿任务会再次处理(可能产生少量重复写入但不会丢数据)。" + } + ] + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "lua-bucketing-sync", + "sync-job", + "lua" + ], + "question": "对比点赞和取消点赞两个 Lua 脚本,分析取消点赞的实现逻辑。", + "explanation": "本题考查对两个互逆 Lua 脚本的对比分析能力,包括幂等性保障和落库阶段的数据处理。", + "code": "-- 取消点赞 Lua 脚本\nif redis.call('HEXISTS', userThumbKey, blogId) ~= 1 then\n return -1\nend\nlocal hashKey = userId .. ':' .. blogId\nlocal oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)\nlocal newNumber = oldNumber - 1\nredis.call('HSET', tempThumbKey, hashKey, newNumber)\nredis.call('HDEL', userThumbKey, blogId)\nreturn 1\n\n-- 点赞 Lua 脚本\nif redis.call('HEXISTS', userThumbKey, blogId) == 1 then\n return -1\nend\nlocal hashKey = userId .. ':' .. blogId\nlocal oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)\nlocal newNumber = oldNumber + 1\nredis.call('HSET', tempThumbKey, hashKey, newNumber)\nredis.call('HSET', userThumbKey, blogId, 1)\nreturn 1", + "language": "lua", + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "取消点赞脚本中计数可能变为负数(oldNumber=0 时 newNumber=-1),这在落库阶段是如何处理的?", + "options": { + "A": "负数表示取消点赞,落库时执行数据库中的 DELETE 操作", + "B": "负数会被忽略,不做任何处理", + "C": "负数表示取消操作,落库时执行数据库中的反向扣减(如 thumb_count - 1)", + "D": "脚本中已有保护逻辑,计数不会为负" + }, + "answer": "C", + "explanation": "落库阶段会区分正数(新增点赞)和负数(取消点赞)。正数对应数据库 INSERT,负数对应数据库 DELETE(取消用户点赞记录)或反向更新(blog 的 thumb_count 减去绝对值)。脚本本身不做负数保护,因为如果用户之前通过临时 Key 累加了计数但尚未落库,此时取消点赞,计数变为负值是正确的语义——表示'撤销之前的一次点赞'。" + }, + { + "index": 2, + "type": "short_answer", + "question": "两个脚本都先检查 HEXISTS 再做后续操作。请解释这两个脚本中 HEXISTS 检查条件的区别(== 1 和 ~= 1),以及各自保证了什么语义。", + "explanation": "两个脚本通过 HEXISTS 前置条件检查实现互斥语义,配合 Lua 原子性保证幂等操作。", + "answer": "点赞脚本检查 HEXISTS == 1(已存在),如果存在直接返回 -1,保证幂等性——用户已赞过的博客不会重复计数。取消点赞脚本检查 HEXISTS ~= 1(不存在),如果不存在直接返回 -1,保证幂等性——用户未赞过的博客不能执行取消操作。两个脚本通过前置条件检查,在 Lua 原子性保证下实现了操作的互斥语义。", + "keywords": [ + "== 1", + "~= 1", + "幂等性", + "HEXISTS", + "互斥", + "原子性" + ], + "scoring_rubric": "答出 == 1 表示'已赞则拒绝'给 2 分;答出 ~= 1 表示'未赞则拒绝'给 2 分;答出两者共同保证幂等性/互斥语义给 1 分。满分 5 分。" + } + ] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/lua-bucketing-sync/meta.json b/topics/thumbup/lua-bucketing-sync/meta.json new file mode 100644 index 0000000..06993c5 --- /dev/null +++ b/topics/thumbup/lua-bucketing-sync/meta.json @@ -0,0 +1,30 @@ +{ + "slug": "lua-bucketing-sync", + "name": "Lua 脚本 + 分桶同步", + "description": "Lua脚本原子性、10秒时间片分桶、定时批量落库、补偿兜底", + "tags": [ + "lua", + "lua-bucketing-sync", + "sync-job", + "time-bucket" + ], + "difficulty_range": [ + 3, + 5 + ], + "schema_version": "1.0.0", + "updated": "2026-09-09", + "question_files": [ + "single_choice", + "code_reading", + "short_answer" + ], + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } +} \ No newline at end of file diff --git a/topics/thumbup/lua-bucketing-sync/short_answer.json b/topics/thumbup/lua-bucketing-sync/short_answer.json new file mode 100644 index 0000000..2aa522e --- /dev/null +++ b/topics/thumbup/lua-bucketing-sync/short_answer.json @@ -0,0 +1,76 @@ +{ + "topic": "lua-bucketing-sync", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "lua-bucketing-sync", + "time-bucket" + ], + "question": "请说明时间片分桶(Bucketing)机制在此点赞系统中的设计思路,包括为什么需要分桶、分桶粒度如何选择,以及分桶对读写分离的贡献。", + "explanation": "本题考查对时间片分桶架构设计的综合理解,需要从动机、参数选择和架构价值三个维度回答。", + "answer": "时间片分桶将同一 10 秒窗口内的点赞请求聚合到同一个 Redis Key 中,核心目的是将高频写操作(每次点赞都写 DB)转化为低频批量写(每 10 秒批量同步一次)。分桶粒度选择 10 秒是在实时性和写入压力之间的平衡——太短则批量收益小、Key 数量多,太长则内存占用大、数据丢失风险增加。分桶实现了'热数据在 Redis、冷数据在 DB'的读写分离:用户的点赞状态实时从 Redis 读取保证即时性,写 DB 延迟不超过 10 秒保证最终一致性,大幅降低了数据库的写入 QPS。", + "keywords": [ + "分桶", + "聚合写入", + "10 秒", + "读写分离", + "最终一致性", + "降低 DB QPS", + "批量同步" + ], + "scoring_rubric": "答出聚合写入/减少 DB 写入次数给 1 分;答出 10 秒粒度及选择理由给 1 分;答出 Redis 与 DB 分工(热/冷数据分离)给 1 分;答出最终一致性语义给 1 分;答出分桶 Key 的命名规则(时间片作为 Key)给 1 分。满分 5 分。" + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "lua-bucketing-sync", + "sync-job", + "lua" + ], + "question": "请分析此系统中可能出现数据不一致的三种场景,以及系统如何通过补偿机制保证最终一致性。", + "explanation": "本题考查对分布式系统数据一致性问题的分析能力,需要列举具体故障场景并说明系统的容错设计。", + "answer": "场景一:定时同步任务宕机或重启,导致某些时间片的 Key 未被处理。补偿任务每天凌晨扫描所有残留 Key 并重新同步,确保数据不丢失。场景二:同步任务在 DB 写入后、Redis Key 删除前崩溃。下次同步会重复处理(因为 Key 还在),落库阶段需要做幂等处理(如 UPSERT 而非 INSERT)避免重复计数。场景三:并发取消点赞和同步同时发生——Lua 脚本保证了 Redis 内操作原子性,但落库阶段读取快照后到写入 DB 之间存在时间差,可能导致少量数据偏差,补偿任务的定时清理可以纠正。系统通过'Redis Lua 原子操作 + 定时批量落库 + 每日补偿兜底'三层机制保证最终一致性。", + "keywords": [ + "宕机", + "残留 Key", + "补偿任务", + "幂等处理", + "UPSERT", + "最终一致性", + "三层机制" + ], + "scoring_rubric": "每答出一个场景给 1 分(共 3 分),每答出对应的补偿/解决手段给 1 分(共 3 分),答出'最终一致性'总概念给 -1 分(扣分项不影响,此处视为加分)。满分 5 分。" + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "lua-bucketing-sync", + "sync-job", + "lua" + ], + "question": "如果要将此系统的 10 秒时间片粒度调整为 1 秒以提高实时性,需要改动哪些组件?会带来什么新的问题?请从代码层面和架构层面分别阐述。", + "explanation": "本题考查对系统参数调整的全局影响分析能力,需要从代码改动和架构代价两个角度深入思考。", + "answer": "代码层面需要改动:1) getTimeSlice() 方法的分桶公式从 (second/10)*10 改为直接使用秒数,或改为更细的计算逻辑;2) SyncThumb2DBJob 的 fixedRate 从 10000 改为更短的值(如 1000),但要注意任务执行时间不能超过调度间隔,否则任务堆积;3) SyncThumb2DBJob 的 second 计算逻辑需要同步调整。架构层面的问题:1) Key 数量增加 10 倍,内存占用显著增加;2) 批量合并效果减弱,数据库写入频率接近原来的 10 倍,失去了分桶的主要价值;3) 虚拟线程删除 Key 的并发压力增大;4) 补偿任务扫描的 Key 数量也增加 10 倍。建议的折中方案是采用滑动窗口或按热度分区,而非简单缩小时间片。", + "keywords": [ + "getTimeSlice", + "fixedRate", + "Key 数量", + "内存", + "批量合并", + "任务堆积", + "滑动窗口" + ], + "scoring_rubric": "代码改动点答出 2 个以上给 2 分;架构问题答出 2 个以上给 2 分;提出合理替代方案给 1 分。满分 5 分。" + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/lua-bucketing-sync/single_choice.json b/topics/thumbup/lua-bucketing-sync/single_choice.json new file mode 100644 index 0000000..a7b8dc2 --- /dev/null +++ b/topics/thumbup/lua-bucketing-sync/single_choice.json @@ -0,0 +1,168 @@ +{ + "topic": "lua-bucketing-sync", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 1, + "tags": [ + "lua-bucketing-sync", + "lua" + ], + "question": "在点赞 Lua 脚本中,当用户已经对某博客点过赞时,脚本返回什么值?", + "options": { + "A": "0", + "B": "1", + "C": "-1", + "D": "2" + }, + "answer": "C", + "explanation": "点赞 Lua 脚本开头通过 HEXISTS 检查 userThumbKey 中是否已存在该 blogId 的记录,如果已存在(即用户已点赞),则直接 return -1,不再执行后续的计数累加操作。这保证了幂等性——重复调用点赞不会重复计数。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "lua-bucketing-sync", + "lua" + ], + "question": "点赞 Lua 脚本中使用了哪两个 Redis 数据结构来存储临时计数和去重状态?", + "options": { + "A": "String 和 List", + "B": "Hash 和 String", + "C": "Hash 和 Hash", + "D": "Sorted Set 和 Hash" + }, + "answer": "C", + "explanation": "脚本中 tempThumbKey(KEYS[1])使用 HSET 存储 'userId:blogId' 到点赞次数的映射,是一个 Hash;userThumbKey(KEYS[2])使用 HEXISTS/HSET/HDEL 存储 blogId 到 1 的映射,也是 Hash。两个都是 Hash 结构,分别用于临时计数和去重标记。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "lua-bucketing-sync", + "lua" + ], + "question": "为什么点赞和取消点赞的操作必须使用 Lua 脚本而不是分步执行多条 Redis 命令?", + "options": { + "A": "为了减少网络往返次数,提升吞吐量", + "B": "Redis 单线程执行 Lua 脚本时保证原子性,避免并发下计数不一致", + "C": "Redis 不支持事务中的 HEXISTS 操作", + "D": "Lua 脚本比MULTI/EXEC事务的性能更好" + }, + "answer": "B", + "explanation": "Redis 保证 Lua 脚本的原子执行——脚本执行期间不会有其他命令插入。点赞操作需要先检查是否已赞(HEXISTS),再累加计数(HGET + HSET),再设置去重标记(HSET),这三步必须作为一个原子操作。如果分步执行,两个并发请求可能同时通过 HEXISTS 检查,导致重复计数。MULTI/EXEC 事务虽然也保证原子性,但 Lua 脚本支持条件分支逻辑,更适合这类复合操作。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "lua-bucketing-sync", + "time-bucket" + ], + "question": "getTimeSlice() 方法将时间按每 10 秒分桶,当当前时间为 14:35:47 时,生成的时间片字符串是什么?", + "options": { + "A": "14:35:40", + "B": "14:35:50", + "C": "14:35:47", + "D": "14:35:00" + }, + "answer": "A", + "explanation": "代码逻辑为 `(DateUtil.second(nowDate) / 10) * 10`,整数除法 47/10=4,再乘以 10 得到 40。拼接格式为 'HH:mm:' + 秒数,即 '14:35:40'。这是向下取整到最近的 10 秒边界。例如 14:35:40 ~ 14:35:49 之间的请求都会归入同一个桶 '14:35:40'。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "lua-bucketing-sync", + "sync-job" + ], + "question": "SyncThumb2DBJob 每隔 10 秒执行一次,为什么它计算的是上一个时间片而非当前时间片?", + "options": { + "A": "为了与数据库的写入延迟保持一致", + "B": "因为当前时间片的请求还在进行中,写入会导致数据丢失", + "C": "因为上一个时间片的 Redis Key 已自动过期", + "D": "为了在整点触发补偿任务" + }, + "answer": "B", + "explanation": "定时任务取 (second / 10 - 1) * 10,即上一个完整 10 秒窗口。当前时间片可能仍有进行中的请求,如果立即写入 DB 再删除 Key,会导致那些未完成的请求数据丢失。取上一个时间片保证该窗口内的所有写入已经结束,是一个已完结的时间段,可以安全持久化。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "lua-bucketing-sync", + "sync-job" + ], + "question": "SyncThumb2DBJob 在跨分钟边界时(second 计算结果为 -10),代码做了什么处理?", + "options": { + "A": "跳过本次执行,等待下一个调度周期", + "B": "将 second 设为 50,并将 nowDate 前推一分钟", + "C": "将 second 设为 0,直接处理当前小时的 00 秒片", + "D": "抛出异常并回滚事务" + }, + "answer": "B", + "explanation": "当秒数在 0~9 之间时,(second/10 - 1) = -1,乘以 10 得到 -10。此时需要处理的是上一分钟的 50 秒桶。代码将 second 设为 50,并用 DateUtil.offsetMinute(nowDate, -1) 将时间前推一分钟,这样拼出的 Key 例如 '14:35:50' 就是上一分钟的最后一个桶。这是时间片分桶边界条件的典型处理。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "lua-bucketing-sync", + "sync-job" + ], + "question": "syncThumb2DBByDate 方法在同步完成后,使用什么方式删除 Redis 中的临时 Key?", + "options": { + "A": "直接在当前线程执行 redisTemplate.delete()", + "B": "启动虚拟线程异步删除", + "C": "依赖 Redis Key 的 TTL 过期自动清理", + "D": "通过 MQ 消息通知清理服务" + }, + "answer": "B", + "explanation": "代码使用 Thread.startVirtualThread(() -> redisTemplate.delete(tempThumbKey)) 启动一个虚拟线程来异步删除。这样做是因为删除操作不在核心业务逻辑中,将其异步化可以避免阻塞主流程,减少落库任务的执行时间。这也说明落库时使用的是 copy 模式——先 HGETALL 读取所有数据到 Java 内存,再遍历写 DB,最后异步清理 Redis。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "lua-bucketing-sync", + "sync-job" + ], + "question": "SyncThumb2DBCompensatoryJob 的补偿任务触发条件是什么?它解决了什么问题?", + "options": { + "A": "每 60 秒执行一次,补偿数据库写入失败的数据", + "B": "每天凌晨 2 点执行,扫描残留的时间片 Key 并重新同步,防止落库遗漏", + "C": "在每次点赞操作后触发,实时补偿 Redis 与 DB 的不一致", + "D": "当 Redis 内存超过阈值时触发,强制清理过期数据" + }, + "answer": "B", + "explanation": "cron 表达式 '0 0 2 * * *' 表示每天凌晨 2:00 执行。它用 KEYS 命令扫描所有 tempThumbKey 前缀匹配的残留 Key,对每个未被正常同步的 Key 重新调用 syncThumb2DBByDate。这解决的问题是:定时同步任务可能因宕机、重启或网络异常等原因漏掉某些时间片的 Key,补偿任务作为兜底机制确保所有数据最终都能落库,实现了最终一致性。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/two-level-cache/code_reading.json b/topics/thumbup/two-level-cache/code_reading.json new file mode 100644 index 0000000..97d6114 --- /dev/null +++ b/topics/thumbup/two-level-cache/code_reading.json @@ -0,0 +1,144 @@ +{ + "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 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" + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/two-level-cache/meta.json b/topics/thumbup/two-level-cache/meta.json new file mode 100644 index 0000000..017ba4b --- /dev/null +++ b/topics/thumbup/two-level-cache/meta.json @@ -0,0 +1,36 @@ +{ + "slug": "two-level-cache", + "name": "Caffeine + Redis 二级缓存", + "description": "CacheManager 二级缓存架构、HeavyKeeper 热Key探测、putIfPresent 写一致性", + "tags": [ + "CacheManager", + "HeavyKeeper", + "L1-L2", + "decay", + "fading", + "get-flow", + "inconsistency", + "parameters", + "two-level-cache", + "write-consistency" + ], + "difficulty_range": [ + 3, + 5 + ], + "schema_version": "1.0.0", + "updated": "2026-09-09", + "question_files": [ + "single_choice", + "code_reading", + "short_answer" + ], + "stats": { + "total": 14, + "by_type": { + "single_choice": 8, + "code_reading": 3, + "short_answer": 3 + } + } +} \ No newline at end of file diff --git a/topics/thumbup/two-level-cache/short_answer.json b/topics/thumbup/two-level-cache/short_answer.json new file mode 100644 index 0000000..aa700bf --- /dev/null +++ b/topics/thumbup/two-level-cache/short_answer.json @@ -0,0 +1,78 @@ +{ + "topic": "two-level-cache", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "two-level-cache", + "CacheManager", + "get-flow" + ], + "question": "请完整描述 CacheManager.get() 的查询流程,说明每一步的目的。", + "answer": "CacheManager.get() 的完整流程如下:\n1. 查询 L1(Caffeine 本地缓存):命中则直接返回,这是最快的路径,零网络开销。\n2. 查询空值缓存(null cache):如果 Key 被标记为空值缓存,说明之前确认 Redis 中不存在该 Key,直接返回 null,防止缓存穿透。\n3. 加锁并 double-check:获取 Key 级别的锁后,重新检查 L1 和空值缓存,防止并发请求重复穿透(防止惊群效应)。\n4. 查询 Redis(L2 缓存):从 Redis 获取数据。\n5. 写入空值缓存或 HeavyKeeper 记录:Redis 返回 null 则写入空值缓存防止穿透;Redis 有数据则通过 HeavyKeeper.add() 记录访问频次。\n6. 热 Key 提升 L1:如果 HeavyKeeper.isHotKey() 返回 true,将数据写入 Caffeine,后续相同 Key 的请求可在 L1 直接命中。", + "explanation": "", + "keywords": [ + "L1", + "Caffeine", + "空值缓存", + "double-check", + "Redis", + "HeavyKeeper", + "热Key提升" + ], + "scoring_rubric": "满分10分。描述完整6步流程得8分,每少一步扣1.5分。每步有正确的目的说明额外加0.5分,最多加2分。出现概念性错误(如将空值缓存描述为缓存 Redis 空值对象)扣2分。" + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "HeavyKeeper", + "fading", + "decay", + "parameters" + ], + "question": "请解释 HeavyKeeper 的双重衰减机制(add() 中的冲突衰减和 fading() 的周期衰减)是如何协同工作的,以及为什么要同时使用两种衰减。", + "answer": "HeavyKeeper 的双重衰减机制包括:\n\n1. add() 中的冲突衰减(per-operation decay):当新 Key 的 fingerprint 与桶中已有 Key 冲突时,以概率 decay(0.92)对已有 count 执行 -1 操作。这是一种细粒度的、事件驱动的衰减,每次冲突都可能淘汰冷 Key。\n\n2. fading() 的周期衰减(periodic decay):每 20 秒对所有桶的 count 执行右移一位(除以 2)。这是一种粗粒度的、时间驱动的衰减,确保热度统计反映的是近期行为而非历史累积。\n\n协同工作原理:\n- 如果只有冲突衰减:冷 Key 不会被冲突到的桶永远不会衰减,导致历史热 Key 永远占据桶位。\n- 如果只有周期衰减:两个冷 Key 之间不会互相淘汰,桶位被低频 Key 长期占用。\n- 两者结合:冲突衰减在局部竞争中快速淘汰冷 Key,周期衰减在全局范围内将所有 Key 的热度重置为半衰期模型。冲突衰减处理 Key 之间的横向竞争,fading() 处理时间维度的纵向衰减。这种设计让 HeavyKeeper 既能快速感知热度变化,又能防止历史数据干扰。", + "explanation": "", + "keywords": [ + "冲突衰减", + "周期衰减", + "decay", + "fading", + "right-shift", + "半衰期" + ], + "scoring_rubric": "满分10分。正确描述两种衰减各2分(共4分)。正确分析只有一种衰减时的缺陷各1.5分(共3分)。正确说明协同工作原理2分。表述清晰、逻辑连贯1分。出现概念性错误(如将右移描述为除以4)扣2分。" + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "two-level-cache", + "inconsistency", + "L1-L2", + "write-consistency" + ], + "question": "在分布式环境下,Caffeine + Redis 二级缓存面临哪些数据不一致风险?请分别从「写后读」和「并发写」两个场景分析,并给出至少两种缓解方案。", + "answer": "一、写后读不一致风险:\n当一个实例更新/删除 Redis 后,需要通知其他实例失效本地 Caffeine。在通知发出到其他实例收到并执行失效之间,存在时间窗口。在这个窗口内,其他实例的 Caffeine 仍持有旧数据,读请求会返回过期值。\n\n二、并发写不一致风险:\n多个实例同时更新同一 Key 时,由于操作顺序不可控(实例 A 先删 L1 后写 Redis,实例 B 可能在 A 写 Redis 之前读到旧 L1 并缓存),可能导致最终 L1 和 L2 数据不一致。如果使用 putIfPresent 还可能导致合法更新被拒绝。\n\n缓解方案:\n1. 延迟双删:更新 Redis 后先失效 L1,sleep 一段时间后再次失效 L1,确保窗口期的脏数据被清理。\n2. 消息队列通知:使用可靠的 MQ(如 RocketMQ)替代 Redis Pub/Sub 发送失效通知,保证通知不丢失。\n3. 版本号/CAS:在 Redis 中为每个 Key 维护版本号,读取时校验版本号,版本不一致则重新加载。\n4. 短 TTL 兜底:L1 的 expireAfterWrite 设置较短(如几秒),即使通知丢失,脏数据也会很快自然过期。\n5. 订阅 Redis 消费 binlog:使用 Canal 等工具监听 Redis 的数据变更事件,实现最终一致。", + "explanation": "", + "keywords": [ + "写后读", + "并发写", + "延迟双删", + "消息队列", + "版本号", + "TTL", + "Canal" + ], + "scoring_rubric": "满分10分。正确描述写后读不一致场景2分。正确描述并发写不一致场景2分。给出至少两种有效缓解方案,每种1.5分(最多6分)。方案描述含糊或有概念错误扣1分。超过5种方案不再加分。" + } + ] +} \ No newline at end of file diff --git a/topics/thumbup/two-level-cache/single_choice.json b/topics/thumbup/two-level-cache/single_choice.json new file mode 100644 index 0000000..539f0a0 --- /dev/null +++ b/topics/thumbup/two-level-cache/single_choice.json @@ -0,0 +1,176 @@ +{ + "topic": "two-level-cache", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-09T21:42:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "two-level-cache", + "CacheManager", + "architecture" + ], + "question": "在 Caffeine + Redis 二级缓存架构中,CacheManager.get() 的第一步查询目标是什么?", + "options": { + "A": "直接查询 Redis(L2 缓存)", + "B": "查询 Caffeine 本地缓存(L1 缓存)", + "C": "查询空值缓存(null cache)", + "D": "加锁后执行 double-check 查询 Redis" + }, + "answer": "B", + "explanation": "CacheManager.get() 的完整流程为:查 L1(Caffeine)→ 查空值缓存 → 加锁 double-check → 查 Redis → HeavyKeeper 记录 → 热 Key 提升 L1。第一步始终是查询本地 Caffeine 缓存,命中则直接返回,未命中才进入后续流程。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "two-level-cache", + "null-cache", + "lock" + ], + "question": "CacheManager.get() 流程中,空值缓存(null cache)的作用是什么?", + "options": { + "A": "缓存 Redis 中存储的空值对象,防止穿透", + "B": "记录已查询过但 Redis 中不存在的 Key,避免重复穿透到 Redis", + "C": "作为 L1 和 L2 之间的一致性校验缓存", + "D": "存储 HeavyKeeper 探测到的热 Key 列表" + }, + "answer": "B", + "explanation": "空值缓存用于记录已确认在 Redis 中不存在的 Key,后续相同 Key 的查询命中空值缓存后直接返回,不再穿透到 Redis,有效防止缓存穿透。注意这里缓存的不是 Redis 中的空值对象,而是对 Key 不存在这一事实的标记。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "Caffeine", + "W-TinyLFU", + "eviction" + ], + "question": "Caffeine 使用的 W-TinyLFU 淘汰策略中,「W」代表什么含义?", + "options": { + "A": "Write-through(写穿透)", + "B": "Window(窗口)", + "C": "Weighted(加权)", + "D": "Waiting(等待)" + }, + "answer": "B", + "explanation": "W-TinyLFU 中的 W 代表 Window,即窗口缓存。W-TinyLFU 将缓存分为 Window(窗口)和 Main(主)两个区域。新数据先进入 Window 区,通过准入策略(admission filter)决定是否提升到 Main 区。Main 区内部再分为 TinyLFU 的 probation 和 protected 两个子区。这种分层设计兼顾了频率统计精度和内存效率。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "HeavyKeeper", + "hot-key", + "detection" + ], + "question": "HeavyKeeper 热 Key 探测算法的 add() 方法在遇到桶冲突时,执行的是什么操作?", + "options": { + "A": "直接覆盖桶中原有的 fingerprint 和 count", + "B": "累加已有 fingerprint 对应桶的 count", + "C": "以概率 decay 对已有 count 进行衰减,若衰减后 count < minCount 则替换为新 fingerprint,否则保留原 fingerprint", + "D": "将冲突 Key 放入溢出队列,异步处理" + }, + "answer": "C", + "explanation": "HeavyKeeper 的 add() 有三个分支:1) 桶为空时直接写入 fingerprint 和 count=1;2) fingerprint 匹配时直接 count++;3) 桶冲突时以概率 decay 对已有 count 衰减,衰减后若 count < minCount(阈值为 10)则替换为当前 fingerprint 和 count=1,否则保留原 fingerprint。这一设计利用概率衰减自然淘汰冷 Key,保留热 Key。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "HeavyKeeper", + "fading", + "decay" + ], + "question": "HeavyKeeper 的 fading() 方法每 20 秒执行一次,其核心操作是什么?", + "options": { + "A": "清空所有桶的 count,重新统计", + "B": "将所有桶的 count 右移一位(等效于除以 2,减半衰减)", + "C": "只对 count > minCount 的桶执行衰减", + "D": "删除所有 fingerprint 为空的桶" + }, + "answer": "B", + "explanation": "fading() 每 20 秒触发一次,对 HeavyKeeper 的所有 bucket 的 count 执行右移一位操作,即 count >>= 1,等效于将所有计数值减半。这实现了时间维度的指数衰减,使 HeavyKeeper 能够反映 Key 的近期热度而非累积热度。配合 add() 中的冲突衰减(decay=0.92),形成双重衰减机制。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "two-level-cache", + "write-consistency", + "putIfPresent" + ], + "question": "在二级缓存架构中,更新数据时使用 putIfPresent 写入 Redis 的主要目的是什么?", + "options": { + "A": "提高写入性能,减少 Redis 操作次数", + "B": "保证只有在 Key 已存在时才更新,防止误覆盖其他线程新写入的数据", + "C": "确保 Redis 和 Caffeine 的写入顺序一致", + "D": "自动触发 L1 缓存的失效通知" + }, + "answer": "B", + "explanation": "putIfPresent 是 Redis 的条件写入命令,只有当 Key 已存在时才会执行更新操作并返回 OK,否则返回 nil。在二级缓存更新场景中,这可以防止并发更新时的误覆盖问题——如果另一个线程已经删除并重建了该 Key,当前线程的写入不会覆盖新数据。这是一种乐观锁思路的写一致性保障。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "two-level-cache", + "inconsistency", + "L1-L2" + ], + "question": "在 Caffeine + Redis 二级缓存中,以下哪种场景最容易导致 L1 和 L2 之间的数据不一致?", + "options": { + "A": "L1 缓存容量设置过小导致频繁淘汰", + "B": "多个应用实例同时更新 Redis 中的同一 Key,但各实例的 Caffeine 失效通知未及时送达", + "C": "Caffeine 的 expireAfterWrite 设置过长", + "D": "Redis 采用主从架构导致读写延迟" + }, + "answer": "B", + "explanation": "多实例场景下,当一个实例更新了 Redis 数据后,需要通过消息广播(如 Redis Pub/Sub、RocketMQ 等)通知其他实例失效本地 Caffeine 缓存。如果通知延迟或丢失,其他实例的 Caffeine 中仍持有旧数据,而 Redis 已经是新数据,造成 L1/L2 不一致。这是分布式二级缓存最经典的一致性问题。A 和 C 影响的是缓存命中率,D 影响的是主从一致性而非 L1/L2 一致性。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 5, + "tags": [ + "HeavyKeeper", + "parameters", + "configuration" + ], + "question": "HeavyKeeper 当前配置为 k=100, width=100000, depth=5, decay=0.92, minCount=10。当一个新 Key 被 add() 时,该 Key 的 fingerprint 需要经过几个哈希函数映射到几个桶中进行检测?", + "options": { + "A": "1 个(随机选一个桶)", + "B": "5 个(depth 个桶)", + "C": "100 个(k 个桶)", + "D": "100000 个(width 个桶)" + }, + "answer": "B", + "explanation": "HeavyKeeper 中,depth 决定了每个 Key 需要检测的桶数量。对于每个被 add 的 Key,先计算其 fingerprint,然后通过 depth(此处为 5)个不同的哈希函数映射到 width 个桶中的 5 个位置。在这 5 个桶中,按 add() 的三分支逻辑处理。depth 越大,对热 Key 的识别精度越高,但内存开销也相应增加。k 是返回的 Top-K 热 Key 数量,width 是桶数组大小,decay 和 minCount 控制衰减和淘汰。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file