feat: add ThumbUP 项目 — 70 questions across 5 subtopics (cache-protection, two-level-cache, lua-bucketing-sync, heavykeeper-topk, distributed-lock)
Deploy Examination / deploy (push) Successful in 20s

This commit is contained in:
2026-09-09 21:59:46 +08:00
parent 3447dbd4ee
commit 85710ded73
21 changed files with 2222 additions and 0 deletions
@@ -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 分。"
}
]
}
]
}
@@ -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
}
}
}
@@ -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 分。"
}
]
}
@@ -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": []
}
]
}