From 4b242e50467d74cf3775e78dc689f4e4f2137d19 Mon Sep 17 00:00:00 2001 From: wonder Date: Fri, 4 Sep 2026 12:05:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=AB=98=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=A7=92=E6=9D=80=E7=B3=BB=E7=BB=9F=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=2040=E9=A2=98(=E9=80=89=E6=8B=A9/=E5=A1=AB?= =?UTF-8?q?=E7=A9=BA/=E7=AE=80=E7=AD=94/=E4=BB=A3=E7=A0=81=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E5=90=8410)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seckill-design/code_reading.json | 488 ++++++++++++++++++ .../seckill-design/fill_blank.json | 128 +++++ topics/architecture/seckill-design/meta.json | 64 +++ .../seckill-design/short_answer.json | 277 ++++++++++ .../seckill-design/single_choice.json | 216 ++++++++ topics/index.json | 24 +- 6 files changed, 1196 insertions(+), 1 deletion(-) create mode 100644 topics/architecture/seckill-design/code_reading.json create mode 100644 topics/architecture/seckill-design/fill_blank.json create mode 100644 topics/architecture/seckill-design/meta.json create mode 100644 topics/architecture/seckill-design/short_answer.json create mode 100644 topics/architecture/seckill-design/single_choice.json diff --git a/topics/architecture/seckill-design/code_reading.json b/topics/architecture/seckill-design/code_reading.json new file mode 100644 index 0000000..a91cb9e --- /dev/null +++ b/topics/architecture/seckill-design/code_reading.json @@ -0,0 +1,488 @@ +{ + "topic": "seckill-design", + "type": "code_reading", + "schema_version": "1.0.0", + "generated": "2026-09-04T00:00:00+08:00", + "questions": [ + { + "id": "cr-001", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "Redis原子性", + "Lua", + "超卖", + "单线程" + ], + "question": "请阅读下面的 Redis Lua 扣库存脚本,并结合 Redis 单线程模型理解其原子性来源。", + "code": "local stock = redis.call('get', KEYS[1])\nif not stock or tonumber(stock) < tonumber(ARGV[1]) then\n return 0\nend\nredis.call('decrby', KEYS[1], ARGV[1])\nreturn 1", + "language": "lua", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "这段 Lua 脚本相比直接用两条 Redis 命令(先 GET 再 DECRBY)解决超卖问题的根本原因是什么?", + "options": { + "A": "Lua 执行速度比两条命令快,因此并发下不容易出错", + "B": "Redis 主线程是单线程 event loop,执行 Lua 脚本期间不会插入其他任何命令,读-判-扣成为一个整体原子操作", + "C": "DECRBY 命令本身是原子的,所以不会超卖", + "D": "GET 返回的是 Lua 的本地变量,天然隔离了并发" + }, + "answer": "B", + "explanation": "两条独立命令 GET 与 DECRBY 之间会被其他并发请求插入,导致多个请求都先读到“有库存”再各自扣减造成超卖。而 Redis 主线程是单线程 event loop,执行 Lua 脚本的整个过程中不会接管任何其他命令,因此“读库存、判断够不够、扣减”成为不可切分的原子步骤。单条 DECR 原子并不能解决跨命令之间的竞态,所以 C 错误;与脚本执行速度和本地变量无关,A/D 错误。" + }, + { + "index": 2, + "type": "short_answer", + "question": "脚本在什么情况下返回 0?这种返回值在秒杀调用方通常代表什么业务语义?", + "answer": "当库存 key 不存在(not stock 为真)或者当前库存值小于需要扣减的数量 ARGV[1] 时返回 0。秒杀业务中返回 0 表示库存不足,调用方通常将 0 解释为“售罄”,从而结束前置购买流程,不再进行后续扣减。", + "explanation": "条件 `not stock` 处理 key 不存在的情况,`tonumber(stock) < tonumber(ARGV[1])` 处理库存不够的情况,满足任一分支即返回 0。这与返回 1 表示扣减成功的语义恰好相反;库存不足在秒杀设计中基本等价于售罄。", + "keywords": [ + "库存不足", + "不存在", + "售罄", + "0" + ], + "scoring_rubric": "答出“库存不存在或库存不足返回 0”得 1 分;答出 0 代表售罄/库存不足得完整 2 分。" + } + ], + "explanation": "该 Lua 把\"读库存→判断够不够→扣减\"封装为一个脚本原子执行。Redis 主线程单线程(event loop)在运行该脚本期间不会接管任何其他命令,因此库存的\"读-判-扣\"在三步之间不会有其它请求插入,从 10 扣到 0 的过程中第 11 个并发必然判到库存不足而返回 0,物理上杜绝超卖。" + }, + { + "id": "cr-002", + "type": "code_reading", + "difficulty": 3, + "tags": [ + "Redis原子性", + "Lua", + "Go", + "限购", + "一人一件" + ], + "question": "阅读下面这个 Go 秒杀函数,它把“一人限购一件”的判断与扣库存合并到一个 Lua 脚本里完成。请理解其返回码约定。", + "code": "func Seckill(ctx context.Context, rdb *redis.Client, goodsID, userID string) int {\n // KEYS[1]=库存key, KEYS[2]=已购标记key, ARGV[1]=扣减数量(恒为1)\n script := redis.NewScript(`\n if redis.call('exists', KEYS[2]) == 1 then\n return -1 -- 已购过\n end\n local stock = tonumber(redis.call('get', KEYS[1]) or '0')\n if stock < 1 then\n return 0 -- 售罄\n end\n redis.call('decrby', KEYS[1], 1)\n redis.call('set', KEYS[2], 1)\n redis.call('expire', KEYS[2], 86400)\n return 1\n `)\n res, _ := script.Run(ctx, rdb,\n []string{\"seckill:stock:\" + goodsID, \"seckill:bought:\" + goodsID + \":\" + userID}, 1).Result()\n return int(res.(int64))\n}", + "language": "go", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "short_answer", + "question": "为什么“判断是否已购 + 扣库存 + 打已购标记”必须放进同一个 Lua 脚本一次性完成,而不能用分步执行的命令?", + "answer": "因为“判已购”和“扣库存”必须严格保持一致的原子操作。若分步执行,并发下可能出现“扣了库但没写已购标记”,导致同一用户重复购买或多扣库存;也可能出现“写了已购标记但没扣库存”,导致库存错乱。把它们放进同一个 Lua 脚本,由 Redis 单线程整体执行,才能保证每个用户最多成功购买一次,且库存扣减与标记生效同时发生。", + "explanation": "一人限购的核心是“判已购 + 扣库存”必须原子完成。分开执行必然存在中间窗口,并发请求会各自判断“未购”再分别扣减,破坏统计一致性。放进 Lua 后不可切分。", + "keywords": [ + "原子", + "一致性", + "重复购买", + "扣了没标", + "标了没扣" + ], + "scoring_rubric": "答出“必须原子、避免重复购买”得 1 分;能具体说明两种不一致后果得 2 分。" + }, + { + "index": 2, + "type": "single_choice", + "question": "脚本对 KEYS[2](已购标记 key)设置的 86400 秒过期,主要解决什么问题?", + "options": { + "A": "防止 Redis 内存被无限增长的已购标记占满", + "B": "避免已购标记永久存在,让标记在活动窗口结束后自然失效,条件允许时用户仍可重新参与后续购买", + "C": "让库存每 24 小时自动恢复一次", + "D": "给 KEYS[1] 库存 key 的过期提供一个参照时间" + }, + "answer": "B", + "explanation": "对已购标记 key 设置 86400 秒(一天)过期,是为了避免该 key 永久存在导致用户被永久锁定为“已购”。活动/窗口结束后标记自然失效,为重新购买留出空间。A 的内存回收不是该过期的设计主旨(已购标记本身数量可控);C 把库存恢复误认为是目标;D 无关。" + } + ], + "explanation": "该 Go 函数把\"判断用户是否已购 + 扣库存 + 标记已购\"放进同一个 Lua 脚本一次性原子完成。返回码约定:-1 表示该用户已购过(限购拦截),0 表示库存售罄,1 表示抢购成功。将判购和扣库存合并为同一原子脚本,是为了避免\"库存扣了却没标记(可能重复买)\"或\"标了却没扣(库存不准确)\"的不一致。" + }, + { + "id": "cr-003", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "限流", + "429", + "令牌桶", + "Retry-After", + "Go" + ], + "question": "阅读下面的 Go 令牌桶限流中间件。它基于 time/rate 每秒补充固定令牌,请回答限流失败时 429 响应的设计。", + "code": "func RateLimit(limiter *rate.Limiter) gin.HandlerFunc {\n return func(c *gin.Context) {\n if !limiter.Allow() {\n // 被限流:返回 429,并告诉客户端多久后重试\n c.Header(\"Retry-After\", \"5\")\n c.Header(\"X-RateLimit-Remaining\", \"0\")\n c.JSON(429, gin.H{\"error\": \"请求过于频繁,已自动排队\"})\n c.Abort()\n return\n }\n // 放行,并输出剩余令牌数量\n remaining := limiter.Tokens()\n c.Header(\"X-RateLimit-Remaining\", fmt.Sprintf(\"%.0f\", remaining))\n c.Next()\n }\n}", + "language": "go", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "为什么被限流时要返回 429(而不是 404、503 或 200)?", + "options": { + "A": "因为 429 会让客户端立刻无等待地重试", + "B": "因为 429 表示“请求过多被限流,但资源仍可能还有机会”,用状态码把“被限流(还有机会)”与“售罄、系统故障”区分开,客户端可走不同处理逻辑", + "C": "因为默认 Nginx 把超限请求转成 503,只能用 429 覆盖", + "D": "因为 429 是 HTTP 标准里唯一能表达排队状态的状态码" + }, + "answer": "B", + "explanation": "限流表示请求被临时拒绝,而非资源耗尽。用 429(Too Many Requests)与“售罄(业务码或 404)”“系统过载(503)”区分开,让客户端知道被限流后仍有机会去排队等待。C 只是 Nginx 覆盖配置的导引,不是设计主旨;A/D 无依据。" + }, + { + "index": 2, + "type": "short_answer", + "question": "响应中的 `Retry-After: 5` 头与 `X-RateLimit-Remaining` 头各自的设计意图是什么?前端拿到 429 应展示什么文案?", + "answer": "Retry-After: 5 告诉客户端应在 5 秒后再重试,避免客户端立即疯狂重试造成重试风暴再次打崩系统;X-RateLimit-Remaining 表示当前剩余可用配额,便于调用方判断当前的风控情况。前端拿到 429 应展示“已自动排队,请稍候”之类的友好文案,而不是“系统繁忙”——后者会诱导用户手动反复刷新,加重服务压力。", + "explanation": "Retry-After 是 429 响应要求的补偿头,客户端应遵守它平滑退避重试;X-RateLimit-Remaining 用于辅助节流/熔断判断。文案上“已自动排队”能安抚用户避免手动刷新,呼应“被限流 ≠ 售罄 ≠ 故障”的思路。", + "keywords": [ + "Retry-After", + "重试", + "重试风暴", + "已排队", + "不要刷新" + ], + "scoring_rubric": "答出 Retry-After 用于控制重试、防止重试风暴得 1 分;答出 Remaining 表示剩余配额得 1 分;答出展示“已排队”而非“系统繁忙”得 1 分。" + } + ], + "explanation": "该 GO 用 time/rate 实现令牌桶限流:令牌以固定速率补充,桶容量有限。Allow() 拿不到令牌时返回 false,此时应返回 429 + Retry-After 头,告知客户端\"还有机会、稍后再试\",与售罄 404、系统故障 503 相区分,避免重试风暴。" + }, + { + "id": "cr-004", + "type": "code_reading", + "difficulty": 3, + "tags": [ + "乐观锁", + "超卖", + "SQL", + "兜底", + "版本号" + ], + "question": "阅读以下数据库乐观锁兜底 UPDATE 语句。它在 Redis 扣库存之后仍被调用,作为最后一层防超卖的防线。", + "code": "UPDATE stock\nSET stock = stock - 1, version = version + 1\nWHERE goods_id = ? AND stock > 0 AND version = ?;", + "language": "sql", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "short_answer", + "question": "这条 SQL 如何防超卖?`stock > 0` 与 `version = ?` 两个条件各承担什么职责?", + "answer": "它把【库存剩余判断】与【版本校验】放进 WHERE 条件中:只有当该行仍有余量(stock > 0)且版本号与本次读取时一致(version = ?)才允许执行 stock-1、version+1。UPDATE 会对这一行串行化,并发时后到者要么因 stock 不满足、要么因 version 不匹配而影响结果为 0 行。其中 `stock > 0` 保证库存不会变成负数(不出现负库存超卖);`version = ?` 保证本次写之前的读操作与写之间的窗口里,没有其他并发事务已修改过该行。", + "explanation": "乐观锁通过 WHERE 条件里的版本条件判断读写之间是否发生了并发冲突,用 RowsAffected()==0 判定为售罄或冲突并结束流程。version 每次成功更新+1,后到者携旧版本号必然匹配不上。", + "keywords": [ + "stock > 0", + "version = ?", + "RowsAffected", + "行数", + "原子" + ], + "scoring_rubric": "能区分 stock>0 与 version 两个条件各自职责并说明 UPDATE 对行的串行作用得 2 分;只笼统说防并发但没讲清两个条件得 1 分。" + }, + { + "index": 2, + "type": "single_choice", + "question": "用 Go 的 database/sql 执行这条 UPDATE 时,如何判断本次就因为库存不足或版本冲突而失败(被乐观锁拒绝)?", + "options": { + "A": "捕获 Exec 返回的 error,非空就视为售罄", + "B": "检查 Exec 返回的 result 中 RowsAffected() 是否为 0", + "C": "重新 SELECT 一次比较库存是否发生变化", + "D": "检查受影响行数是否等于传入的 stock 值" + }, + "answer": "B", + "explanation": "乐观锁 UPDATE 即使未命中 WHERE 条件也不会报错,只是影响行数为 0。通过 `result.RowsAffected() == 0` 判定“库存不足或版本冲突”,结束为售罄。A 错误:条件不满足并非 SQL 错误。C 额外查询没必要且有竞态;D 无意义。" + } + ], + "explanation": "该 UPDATE 是数据库层的乐观锁终极防线:通过 WHERE stock>0 保证库存不足时影响 0 行,通过 WHERE version=? 保证并发修改时版本冲突影响 0 行,旧版本的请求更新失败。RowsAffected()==0 即认为售罄,这样即使有人绕过 Redis 或 Redis 异常,DB 也不会扣成负数,物理上杜绝超卖。" + }, + { + "id": "cr-005", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "限流", + "Redis", + "INCR", + "EXPIRE", + "固定窗口" + ], + "question": "阅读下面用 Redis INCR + EXPIRE 实现限流的逻辑,它实现“一定时间窗口内仅放行一次”。请重点看首次访问时才设置过期的时机。", + "code": "key := \"seckill:limit:\" + userID\ncount, _ := rdb.Incr(ctx, key).Result()\nif count == 1 {\n rdb.Expire(ctx, key, 5*time.Second) // 首次访问时设置过期,形成 5 秒窗口的起点\n}\nif count > 1 {\n return 429 // 窗口内重复访问被限流\n}", + "language": "go", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "为什么要在 count == 1(首次访问)时才设置 EXPIR,而不是每次访问都设置?", + "options": { + "A": "每次设置会持续延长过期时间,让窗口被不断滑动,导致窗口期内持续访问的用户在某些情况下被长期压制,或在换窗时造成双倍突刺", + "B": "每次访问都设置 Expire 会增加 Redis 的写负担,这是唯一原因", + "C": "只有 count==1 时 Redis 才会返回正确计数", + "D": "首次设置是为了让 key 立即带上 TTL,避免 key 永久留在内存" + }, + "answer": "A", + "explanation": "只在首次访问设置过期,让 5 秒窗口从第一次访问开始计时。若每次访问都重置过期,窗口就会被无限延长/滑动:只要窗口内持续有流量,key 就被不断续命,一个后续不同用户始终会压制,且窗口切角处容易出现突发 2 倍请求。因此“首次设置”是固定窗口实现的关键。" + }, + { + "index": 2, + "type": "short_answer", + "question": "这个方案的局限是什么?如果要实现“每 5 秒最多 N 次”而不是 1 次,应如何修改?", + "answer": "局限:它固定为每 5 秒只放行 1 次,属于粗略的固定窗口,窗口切换边界比特可能出现突刺(两个窗口瞬时叠加放行更多请求)。要实现“每 5 秒最多 N 次”,保留首次设置过期的逻辑,把判断阈值从 1 提高到 N:即 `count > N` 才返回 429,允许窗口前 N 次放行。若想更精确可用滑动窗口(如 ZSET 或 Lua)规避边界突刺。", + "explanation": "固定窗口存在窗口边界突刺问题(在窗口切换瞬间可能放行 2N 个请求)。改动方法简单地把阈值由 1 提高 N;更精确的方案是波浪滑动窗口。", + "keywords": [ + "N", + "count > N", + "固定窗口", + "滑动窗口", + "窗口边界" + ], + "scoring_rubric": "指出固定窗口边界缺陷得 1 分;给出现把阈值改为 N(count>N)得 1 分;能提到更精确的滑动窗口方案得 1 分。" + } + ], + "explanation": "该段用 INCR+EXPIRE 实现\"每 N 秒限一次\":首次访问时 count 恰为 1,此时给 key 设置过期;后续访问 count 递增,达到上限即拒绝(返回 429)。INCR 是原子自增,多协程并发下不会重复;首次时设过期可用较简单避免用固定 TTL 被反复刷新,实现窗口限流。" + }, + { + "id": "cr-006", + "type": "code_reading", + "difficulty": 5, + "tags": [ + "hash tag", + "Redis Cluster", + "Lua", + "跨节点", + "原子" + ], + "question": "在 Redis Cluster 下用 Lua 一次性操作多个 key 会因跨节点无法保证原子而报错。阅读下面用 hash tag 改造的 key 设计。", + "code": "// 单实例写法(Cluster 下会报 CROSSSLOT 错误)\nseckill:stock:1001\nseckill:bought:1001:42\n\n// 关键:hash tag 写法,用 {seckill:} 固定哈希槽\nstockKey := \"{seckill:1001}:stock\"\nuserKey := \"{seckill:1001}:user:42\"", + "language": "go", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "short_answer", + "question": "为什么单实例键 `seckill:stock:1001` 与 `seckill:bought:1001:42` 在 Cluster 中执行 Lua 会报 CROSSSLOT 错误?hash tag 如何解决?", + "answer": "Redis Cluster 根据 key 的哈希(CRC16)取模把不同 key 分布到不同节点。普通 `seckill:stock:1001` 与 `seckill:bought:1001:42` 计算出的槽位很可能不在同一节点;而 Lua 脚本在执行时所有 key 必须位于同一节点(同一哈希槽),跨节点会触发 CROSSSLOT 错误且无法保证原子。hash tag 用一对大小括号 `{seckill:1001}` 让 CRC 计算只基于大括号内的内容计算,于是两个 key 被派发到同一节点(同一槽),从而 Lua 可在该节点上原子执行。", + "explanation": "hash tag 的 `{...}` 让参与哈希计算的只有 vol括号内的部分,业务散列结果一致,强制多 key 落同一节点。这是 Cluster 下多 key Lua/事务的通用解决法。", + "keywords": [ + "CROSSSLOT", + "哈希槽", + "同一节点", + "大括号", + "CRC" + ], + "scoring_rubric": "说明 Cluster 分槽/分节点、跨节点无法原子得 1 分;说明 hash tag 的 {} 让两 key 落到同一槽得 2 分。" + }, + { + "index": 2, + "type": "single_choice", + "question": "当 Redis 是单实例(非 Cluster)时,是否还需要 hash tag?", + "options": { + "A": "需要,否则 Lua 无法访问多个 key", + "B": "不需要,单实例所有 key 天然都在同一进程内,Lua 可直接访问不存在跨节点问题", + "C": "需要,hash tag 是 Lua 原子性的唯一保证", + "D": "单实例下完不能对多个 key 用 Lua" + }, + "answer": "B", + "explanation": "单实例 Redis 的所有 key 天然都在同一进程中,无跨节点分散问题,Lua 直接操作多个 key 即可保证原子,无需 hash tag。秒杀常用的单实例场景正是如此。A/C/D 误解了 hash tag 的适用范围(只在 Cluster 下需要)。" + } + ], + "explanation": "该题强调 Redis Cluster 下 Lua 多 key 的坑:不同 key 若被哈希到不同节点,跨节点无法保证原子,Lua 直接报 CROSSSLOT 错误。解法是用 hash tag 即大括号 {},Redis 仅对花括号内字符串计算哈希槽,因此 {seckill:1001}:stock 与 {seckill:1001}:user:42 落在同一槽/节点,可被同一个 Lua 原子操作。" + }, + { + "id": "cr-007", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "一人一件", + "唯一约束", + "数据库兜底", + "Duplicate" + ], + "question": "阅读下面创建秒杀订单表的 DDL。它通过唯一约束把“一人一件”落到数据库物理层,防止代码逻辑漏洞导致重复购买。", + "code": "CREATE TABLE seckill_order (\n id BIGINT PRIMARY KEY AUTO_INCREMENT,\n goods_id BIGINT NOT NULL,\n user_id BIGINT NOT NULL,\n created_at DATETIME,\n UNIQUE KEY uk_goods_user (goods_id, user_id) -- 物理上杜绝一人多次下单\n);", + "language": "sql", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "同一用户对同一商品并发重复提交秒杀订单时会发生什么?", + "options": { + "A": "两条记录都可正常插入,互不影响", + "B": "只有第一条插入成功,第二条触发唯一键 Duplicate entry 冲突", + "C": "数据库自动把两条合并成一条", + "D": "必须后端先查询再插入才能规避约束" + }, + "answer": "B", + "explanation": "UNIQUE (goods_id, user_id) 让“同用户+同商品”在物理上唯一。并发两条插入只有一条成功,另一条违反唯一约束返回 Duplicate entry 错误,从物理层面保证一人一件,即使代码曾出现漏洞也效果兜底。C 不会自动合并;D 与约束无关。" + }, + { + "index": 2, + "type": "short_answer", + "question": "如果应用层/Redis 层因并发或 BUG 漏掉了“一人一件”的一次性判断,这个唯一约束如何作为最后防线?它与 Lua 的一人一件标记设计是重复的关系吗?", + "answer": "唯一约束作为存储层兜底:即使应用层或 Redis 层因并发、BUG 漏判断,重复 INSERT 仍会被唯一约束拒绝,物理上不可能一人得两件。它与 Lua 标记不是重复,而是互补、双层的防线:Lua 在内存层快速挡住绝大多数重复请求、保护本层资源,而异构唯一约束作为最终物理防线,兜住极限并发或异常带来的漏网之鱼,保证“不限购”最终一致。", + "explanation": "前面的 Lua 拦截能挡住绝大多数请求(快速且低资源),但不会绝对;数据库唯一约束无法被并发绕过,是从存储层保证的一致性底线,与 Lua 共同构成两级防御。", + "keywords": [ + "兜底", + "Duplicate", + "双层防线", + "最终防线", + "幂等" + ], + "scoring_rubric": "指出唯一约束是最终防线、能拦截重复插入得 1 分;能说明与 Lua 标记互补、构成双层防线得 2 分。" + } + ], + "explanation": "该 DDL 通过 (goods_id, user_id) 唯一约束,把\"一人一件\"落到数据库物理层。即使代码存在逻辑漏洞或 Redis 限购标记丢失,重复插入同 (goods_id,user_id) 会触发 Duplicate key 报错,从而被 DB 拦截,保证物理上不可能重复购买,是\"一人一件\"的最终保险丝。" + }, + { + "id": "cr-008", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "异步", + "消息队列", + "批量", + "落库", + "削峰" + ], + "question": "秒杀扣库存成功后不直接写库,而是投递到消息队列,由消费者批量异步异步落库。下面是一段消费端攒批落库逻辑。", + "code": "// 消费者:攒批后再批量落库\nvar batch []Order\nfor msg := range mq.Consume(\"seckill_orders\") {\n batch = append(batch, parse(msg))\n if len(batch) >= 100 { // 攒满 100 条再写一次\n db.BatchInsert(batch) // 1 条批量 INSERT 写入多条\n batch = batch[:0]\n }\n}", + "language": "go", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "short_answer", + "question": "为什么设计成“扣库存成功后入队”而不是“同步写库”?攒批 100 条再批量 INSERT 的好处是什么?", + "answer": "秒杀瞬时流量巨大,若每个请求都同步写 MySQL,数据库会被海量的高频写入打崩。所以扣库存成功后只投递到消息队列,让后端在削峰后把稀缺的写库请求积闷在 MQ 里,平滑地吐给数据库异步执行。攒批 100 条再批量 INSERT 能大幅减少数据库交互次数(把 100 次单条写入合并为 1 次批量写入),显著降低数据库连接与 IO 压力,保护数据库不被击穿。", + "explanation": "落库从基于请求数翻译为基于攒批批次数,配合 MQ 缓冲实现了削峰。同步写库会把瞬时高峰直接压到 MySQL,无法承受。", + "keywords": [ + "削峰", + "异步", + "批量", + "MQ", + "保护数据库" + ], + "scoring_rubric": "答出避免同步压垮数据库得 1 分;答出攒批减少交互次数、平滑异步得 2 分。" + }, + { + "index": 2, + "type": "short_answer", + "question": "批量落库出现部分成功部分失败(如其中某条触发唯一约束冲突)时应如何处理?一般配合原则,保证整体一致性?", + "answer": "批量落库一般通过事务(例如 BEGIN...COMMIT 包住这一批 INSERT)保证整体原子性:要么整批全部生效,要么全部回滚;同时可配合“唯一索引去跳”识别异常的这一条,单独跳过并记录,而不是让整批跟着失败。若某些业务允许局部成功,则分批逐条落并单条报错,才不会因一条脏数据拖垮全部。整体一致性还需结合订单状态机(或最终落库成功、失败及补偿)来保证每个“扣过库存”的请求最终真正对库生效。", + "explanation": "事务保证一批要么全成要么全回;对于由 Duplicate 冲突这类可预期的异常,应捕获后单条处理,不让整批回退,保证不超卖和林(一人一件)同时成立。", + "keywords": [ + "事务", + "全成或全回", + "Duplicate", + "补偿", + "最终一致性" + ], + "scoring_rubric": "提到用事务保证整批原子性得 1 分;能说明冲突行单条跳过、不整批回滚、并配合重试得 2 分。" + } + ], + "explanation": "本代码演示秒杀扣库存后的异步削峰落库:秒杀服务只负责扣 Redis 库存并投递消息,真正写数据库由消费者攒够一批后批量 INSERT,避免瞬时高并发写爆 MySQL。攒批(把到达的消息先缓存进内存 batch slice),当超过阈值(如攒满指定条数)再统一落库,可成倍降低数据库交互次数;代价是订单落库有秒级延迟,且需配合消息去重/至少一次投递保证最终一致。" + }, + { + "id": "cr-009", + "type": "code_reading", + "difficulty": 4, + "tags": [ + "Lua", + "ZSET", + "滑动窗口", + "限流", + "原子" + ], + "question": "除固定窗口外,也可用 Lua 实现滑动窗口限流。下面是一个基于 ZSET 的限流脚本,请阅读(local now 用 TIME 命令取秒,与真实项目可用毫秒不同、仅供参考)。", + "code": "-- KEYS[1]=限流key, ARGV[1]=窗口(秒), ARGV[2]=窗口内最大次数\nlocal now = redis.call('TIME')[1]\nlocal start = tonumber(now) - tonumber(ARGV[1])\nredis.call('zremrangebyscore', KEYS[1], 0, start) -- 清理窗口外的旧记录\nlocal count = redis.call('zcard', KEYS[1]) -- 统计窗口内次数\nif count >= tonumber(ARGV[2]) then\n return 0 -- 被限流\nend\nredis.call('zadd', KEYS[1], now, now) -- 记录本次(member 去重注意)\nredis.call('expire', KEYS[1], ARGV[1])\nreturn 1", + "language": "lua", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "相比 cr-005 中用 INCR+EXPIRE 的固定窗口,用 ZSET 实现滑动窗口的核心区别在于?", + "options": { + "A": "ZSET 一定更快、内存占用更小", + "B": "每次请求都清理窗口外的旧记录,再统计当前窗口内准确的数量,规避固定窗口在窗口切换边界瞬时放行 2 倍请求的边界突刺问题", + "C": "滑动窗口不需要 TTL,可以省去 expire", + "D": "ZSET 天然支持跨多个 Redis 节点扩展" + }, + "answer": "B", + "explanation": "固定窗口在窗口切换的边界比特可能把前后两个窗口的突发都放行,出现 2 倍突发。ZSET 滑动窗口则每次请求时先移除窗口外成员,只统计 [now-window, now] 内的成员,把实际爆发始终限制在 N 以内,规避边界突刺。且滑动窗口同样需要 expire 清理,所以 C 错误;A、D 均不成立。" + }, + { + "index": 2, + "type": "short_answer", + "question": "脚本末尾对 KEYS[1] 单独设置 expire 的目的?为什么“ZADD + 清理 + 计数 + 设置过期”要封装在同一 H 个 Lua 脚本,而不是拆成多条命令?", + "answer": "expire 用于让一段较长时间没人访问而废弃的限流 key 自动被回收,避免 key 无限积聚占用内存。至于原子性,把 ZADD、zremrangebyscore、zcard、expire 多条分散命令封装进同一个 Lua,由 Redis 单线程一次性执行,可使“清理→统计→是否越限→写入”这段往返作为一个原子步骤执行,不准中途被并发请求插入,避免超阈值放行或计数错乱;若拆成多条独立网络往返命令,并发请求会交错执行,导致窗口统计不准、限流失效。", + "explanation": "滑动窗口中每一步都必须正确,尤其是 zcard 统计与后续写入之间不能被其他并发请求打断,否则会放行超额请求。", + "keywords": [ + "回收", + "过期", + "原子", + "单线程", + "限流失效" + ], + "scoring_rubric": "说明 expire 用于回收 key 得 1 分;说明在同一 Lua 中保证原子、防并发交错得 2 分。" + } + ], + "explanation": "该 Lua 基于有序集合(ZSET)实现滑动窗口限流:以当前时间为 score 写入 ZSET,清理窗口之外的历史记录,再统计窗口内请求数决定放行或拦截。与固定窗口相比,滑动窗口按时间戳精确计数,能平滑突发边界、避免窗口边界的“双倍放行”问题;缺点是每条请求都要写 ZSET,内存与耗时略高于 INCR 固定窗口方案。" + }, + { + "id": "cr-010", + "type": "code_reading", + "difficulty": 2, + "tags": [ + "Redis Key", + "命名规范", + "冒号", + "限购", + "限流" + ], + "question": "分析下面秒杀相关的 Redis key 命名。请站在 Redis 底层数据结构的层面理解其中的冒号含义。", + "code": "seckill:stock:1001 -- 商品1001剩余库存,共享计数\nseckill:bought:1001:42 -- 用户42 是否已购商品1001的标记\nseckill:limit:1001 -- 控制商品/用户的访问频率", + "language": "lua", + "source": null, + "related": [], + "sub_questions": [ + { + "index": 1, + "type": "single_choice", + "question": "关于这些 key 中冒号 `:`,下列说法正确的是?", + "options": { + "A": "冒号让 Redis 把 key 组织成文件夹/层级结构,碰到冒号会自动建目录", + "B": "Redis 的 key 底层是平坦的哈希字典,冒号只是开发者的命名约定,对 Redis 而言只是普通字符", + "C": "冒号可以提升 key 的查找速度", + "D": "冒号是 Redis 用来分割哈希槽的符号" + }, + "answer": "B", + "explanation": "Redis 的 key 存储在一个平坦的 dict(字典)中,并没有文件夹或层级概念。`seckill:stock:1021` 在 Redis 看来只是共享关系的一个字符串,冒号只是开发者约定,用于分类、统一前缀便于查询与统计。C、D 均无依据。" + }, + { + "index": 2, + "type": "short_answer", + "question": "这三个 key分别承担什么职责?它们分别保障了秒杀的哪些关键能力?", + "answer": "seckill:stock:1021 是商品的共享库存计数,通过 DECR 扣减,用于防超卖;seckill:bought:1021:user1 是每位用户独立的已购标记,检查 exists/set,用于一人一件限购;seckill:limit:1001 通过 INCR 或者计数控制访问频率,用于限流防刷。三者合起来正好响应秒杀的三个核心能力:不超卖、一人限购、限流防刷。", + "explanation": "对应知识点中的场景 key 表:stock 不超卖、bought 一人一件、limit 限流,ABC 各司其职。", + "keywords": [ + "stock", + "bought", + "limit", + "不超卖", + "限购", + "限流" + ], + "scoring_rubric": "答出 stock 防超卖得 1 分、bought 保证一人一件得 1 分、limit 限流得 1 分。" + } + ], + "explanation": "该题考察对 Redis key 本质的理解:Redis 底层用一张平坦的字典(dict)保存所有 key,key 本身只是普通字符串,没有文件夹/目录层级。冒号“:”只是开发者约定俗成的命名分隔符(业务:对象:标识),用于按前缀 scan、方便人工阅读,Redis 本身不解析冒号为任何层级。同时注意:同一商品的库存 key 是共享计数,限购标记按用户粒度拆分;在 Cluster 下若 Lua 操作多个不同前缀的 key,还需用 hash tag 强制同槽。" + } + ] +} \ No newline at end of file diff --git a/topics/architecture/seckill-design/fill_blank.json b/topics/architecture/seckill-design/fill_blank.json new file mode 100644 index 0000000..be7d048 --- /dev/null +++ b/topics/architecture/seckill-design/fill_blank.json @@ -0,0 +1,128 @@ +{ + "topic": "seckill-design", + "type": "fill_blank", + "schema_version": "1.0.0", + "generated": "2026-09-04T00:00:00+08:00", + "questions": [ + { + "id": "fb-001", + "type": "fill_blank", + "difficulty": 1, + "tags": ["分层削峰", "分层", "削峰", "CDN", "网关", "异步", "消息队列"], + "question": "秒杀系统的核心矛盾是:极小并发窗口 vs 极大瞬时流量。其设计本质是通过____,逐层把海量请求挡在外层,只让极少数\"真买家\"穿透到后端。典型的请求链路为:CDN/Nginx(静态资源) → 网关(限流/限购/风控) → 秒杀服务 → Redis(原子扣库存) → ____ → 数据库(异步落库)。", + "answer": ["分层削峰", "消息队列"], + "answer_rule": "ordered", + "explanation": "秒杀系统面临的核心问题是:几秒内可能涌入几万到几百万 QPS,但真正能卖出的商品只有十几件。因此绝不能把所有流量都打到数据库,而是采用分层削峰策略:每一层都把海量请求挡在外面,只让极少数真买家穿透到后端。在链路上,Redis 负责原子扣库存(保证不超卖),扣减成功后把订单写入消息队列,再由消费者异步批量落库,数据库只承接削峰后极少量的写入,避免了高并发直接压垮 MySQL。第一空应填\"分层削峰\"描述设计本质,第二空按链路顺序应填\"消息队列\"(在 Redis 与数据库之间异步削峰)。", + "source": null, + "related": [] + }, + { + "id": "fb-002", + "type": "fill_blank", + "difficulty": 2, + "tags": ["限流", "429", "Retry-After"], + "question": "秒杀限流时,被限流 ≠ 售罄 ≠ 故障,应用不同状态码区分\"还有机会\"与\"已经没了\"。当请求被限流(还有机会)时,应返回 HTTP 状态码 ____,并且该响应必须携带 ____ 响应头,让客户端平滑重试,避免重试风暴二次打穿系统;而售罄通常用 404 或业务码表示,服务过载用 503 表示。", + "answer": ["429", "Retry-After"], + "answer_rule": "ordered", + "explanation": "429(Too Many Requests)表示\"请求过多被限流,但还有机会\",与售罄、故障在语义上区分开。429 响应必须携带 Retry-After 响应头(如 Retry-After: 5),告诉客户端几秒后重试,从而把客户端重试的节奏拉到系统可承受的范围,避免所有被挡住的用户同时疯狂重试形成重试风暴,再次打穿系统。Nginx 下用 limit_req_status 429 覆盖默认的 503;前端拿到 429 应展示\"已自动排队\"的友好文案,而不是\"系统繁忙\"(否则会诱导用户手动刷新)。", + "source": null, + "related": [] + }, + { + "id": "fb-003", + "type": "fill_blank", + "difficulty": 2, + "tags": ["Lua", "Redis原子性", "超卖", "单线程"], + "question": "在秒杀扣库存场景,用两条普通命令(先读库存、再 DECR)在并发下会发生____(超卖/欠卖)问题。解决方法是把\"读库存→判断够不够→扣减\"封装进一个____脚本整体原子执行,因为 Redis 主线程是____(并发/单线程)event loop,执行该脚本期间不会插入任何其他命令,从物理上保证了原子性。", + "answer": ["超卖", "Lua", "单线程"], + "answer_rule": "all", + "explanation": "普通两条命令会超卖:并发下多个请求都先读到\"库存还够\",然后各自 DECR,总数就会超过库存。解法是写 Lua 脚本,把读库存、判断、扣减作为一个整体放进一个脚本(if...return 0...decrby),Redis 主线程是单线程 event loop,一次只执行一个命令,执行 Lua 时主线程被占用、绝不插入其他命令,因此整个脚本是原子的、不可被切分。这也是与 MULTI/EXEC 的本质区别:MULTI/EXEC 只能连续执行命令,无法写 if 判断逻辑,而秒杀的\"够才扣\"必须依赖 Lua 的判断能力。", + "source": null, + "related": [] + }, + { + "id": "fb-004", + "type": "fill_blank", + "difficulty": 3, + "tags": ["乐观锁", "超卖", "数据库"], + "question": "即便 Redis Lua 已做到原子扣减,数据库层仍需做兜底防超卖。可用乐观锁:在 stock 表增加____字段,执行扣减时同时让它 +1,并通过 WHERE ____来约束,只有当某行的库存>(0) 且版本号匹配时才更新成功;当 RowsAffected()==0 就表示库存不足或版本冲突,应返回售罄。", + "answer": ["version", "stock>0 AND version=?"], + "answer_rule": "ordered", + "explanation": "数据库乐观锁通过版本号字段控制并发写:每次更新都让 version 自增 1,并在 WHERE 里同时校验 stock>0 和 version=?(等于读出来的版本)。这样多个并发事务只有第一个能匹配到当前版本并更新成功,其余因为版本不匹配而被更新 0 行(RowsAffected()==0),从而避免数据层超卖。它兜底了绕过 Redis 或 Redis 异常导致的超卖风险,是防超卖的最后一道防线。", + "source": null, + "related": [] + }, + { + "id": "fb-005", + "type": "fill_blank", + "difficulty": 3, + "tags": ["限购", "Lua", "Redis原子性", "一人限购"], + "question": "秒杀\"一人限购一件\"时,\"判已购 + 扣库存\"必须放在____(单个方式,如 Lua 脚本/两条独立命令)中一次性完成,否则会出现\"扣了库但没标记\"(可重复买)或\"标了但没扣\"(库存错乱)。若在数据库兜底,可对 ____表建唯一约束 UNIQUE KEY(goods_id, user_id),重复 INSERT 会触发 Duplicate entry,使一人一件在物理上不可能被破坏。", + "answer": ["同一个 Lua 脚本(一次原子操作)", "已购记录"], + "answer_rule": "all", + "explanation": "\"判已购 + 扣库存\"必须放进同一个 Lua 脚本原子完成:脚本先检查已购标记 key(如 seckill:bought:{goodsID}:{userID})是否存在,存在则返回 -1(已购过);否则再判断库存是否够,够则 decrby 扣库存并 set 已购标记、设置过期时间,返回 1。若拆成两条独立命令,并发下会出现\"扣了库存但还没标记\"(可重复购买)或\"标记了但没扣库存\"(库存错乱)。而数据库层建 (goods_id, user_id) 唯一约束则把一人一件落到物理层面,任何 ID 插到已存在的用户商品组合都会因唯一索引冲突失败。", + "source": null, + "related": [] + }, + { + "id": "fb-006", + "type": "fill_blank", + "difficulty": 4, + "tags": ["hash tag", "Redis Cluster", "跨节点"], + "question": "当 Redis 采用 Cluster 模式时,Lua 脚本同时操作 stock 与 user 两个不同 key 会因____(多个 key 落在不同节点导致)而无法原子执行并报错。解决方法是使用____(如 `{seckill:}:stock` 与 `{seckill:}:user:`),利用哈希槽规则把多个 key 强制路由到____(不同/同一个)槽位即同一节点上,从而让跨 key 的 Lua 脚本仍能在该节点内原子执行。", + "answer": ["跨节点/跨槽位", "hash tag", "同一个"], + "answer_rule": "ordered", + "explanation": "在 Redis Cluster 中,key 按 hash 结果(如 CRC16 取模)分布到不同节点/槽位,Lua 脚本操作多个 key 时要求这些 key 必须 hash 到同一个槽,否则会报错无法保证原子。hash tag 用花括号 { } 圈住关键部分,只有花括号内的部分参与 hash 计算,例如 {seckill:}:stock 与 {seckill:}:user: 花括号内容相同,hash 结果相同,就会被强制分配到同一个节点(同一槽位),从而保证同一个 Lua 脚本内对这两个 key 的操作仍然原子。若用单实例 Redis(秒杀常用)则没有这个限制。", + "source": null, + "related": [] + }, + { + "id": "fb-007", + "type": "fill_blank", + "difficulty": 2, + "tags": ["异步", "消息队列", "削峰", "落库"], + "question": "秒杀扣库存成功后,________(同步/异步)直接写库,而是先写入____(如 MQ),由消费者____(把多条记录批量合在一起)写入数据库(如攒一批后多条 INSERT),以大幅降低数据库交互次数、保护数据库。", + "answer": ["不要", "消息队列", "批量"], + "answer_rule": "ordered", + "explanation": "秒杀扣库存成功只是 Redis 侧扣减完成,此时若每条秒杀成功都同步写一次库,会在瞬间产生大量 DB 写请求,把好不容易靠分层削峰保护下来的 MySQL 再次压垮。正确做法是把扣减成功的订单推进消息队列,由消费者攒批(例如积累 100 条)再一次性批量 INSERT 落库,把海量单条写换成少数批量写,大幅减少 DB 交互次数,实现对数据库的异步削峰保护。", + "source": null, + "related": [] + }, + { + "id": "fb-008", + "type": "fill_blank", + "difficulty": 1, + "tags": ["Redis", "key设计", "命名规范"], + "question": "Redis 的 key 底层是一个平坦的____(map/dict),没有文件夹或层级,冒号 : 只是开发者命名约定,对 Redis 而言只是字符串。秒杀中\"商品库存\"推荐使用 key 模式____(如 seckill:stock:1001)来表示,\"每用户限购标记\"则用____(如 seckill:bought:1001:9527)这样的 key。", + "answer": ["字典(dict)", "seckill:stock:{商品ID}", "seckill:bought:{商品ID}:{用户ID}"], + "answer_rule": "ordered", + "explanation": "Redis 的 key 实际存储在一个平坦的字典(dict)中,不存在文件系统那样的文件夹或层级结构。冒号 : 只是业界约定的命名分隔符,对 Redis 来说就是普通字符串。规范的 key 命名是\"业务:对象:标识\",例如库存 key 用 seckill:stock:{商品ID}(多用户共享、可减量),而\"一人一件\"的已购标记用 seckill:bought:{商品ID}:{用户ID}(按用户独立标记,避免互相污染)。限流 key 则是 seckill:limit:{用户ID/IP}。", + "source": null, + "related": [] + }, + { + "id": "fb-009", + "type": "fill_blank", + "difficulty": 3, + "tags": ["限流", "Redis", "INCR", "EXPIRE"], + "question": "用 Redis key 计数实现\"每X秒限N次\"的限流:key = seckill:limit:{userID},用____命令进行原子计数,并在首次访问(count==1)时执行____命令设置过期时间(如 5 秒),当计数超过阈值则返回 HTTP 状态码____(此时还应附带排重响应头如 Retry-After)。", + "answer": ["INCR", "EXPIRE", "429"], + "answer_rule": "ordered", + "explanation": "基于 Redis 的计数限流:用 INCR 对 seckill:limit:{userID} 原子自增,当返回值 count==1 说明是这一轮首次访问,立即给它设置 EXPIRE(比如 5 秒),这样窗口一过 key 自动过期清零,从而做到\"每 N 秒限 M 次\"。当 INCR 后的 count 超过阈值时返回 429(Too Many Requests),表示被限流但还有机会。这个方案简单高效,且 INCR 本身原子,天然适合高并发的频控场景,不需要额外加锁。", + "source": null, + "related": [] + }, + { + "id": "fb-010", + "type": "fill_blank", + "difficulty": 5, + "tags": ["Redis原子性", "Lua", "单线程", "性能"], + "question": "Redis 的 Lua 脚本虽然能解决原子性,但也必须保持短小、____(不阻塞/支持返回值),不可包含死循环、文件 IO、网络 IO 等阻塞操作,否则会长时间下霸 Redis 的____(主线程被占用)导致其他请求无法服务,最终触发 ____(如超时配置)。", + "answer": ["不阻塞", "单线程", "lua-time-limit"], + "answer_rule": "ordered", + "explanation": "Redis 主线程是单线程 event loop,执行 Lua 脚本时主线程被脚本完全占用,期间不处理其他命令。因此 Lua 脚本必须尽量短小且无阻塞,绝不能有 while 死循环、文件 IO、网络 IO、sleep 等操作,否则会让整个 Redis 卡住、其他请求全部排队甚至超时。Redis 对 Lua 脚本执行有 lua-time-limit 保护(默认几秒,如 5000ms),达到后会尝试向脚本抛出错误并中断,防止恶意脚本拖垮在线服务。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/architecture/seckill-design/meta.json b/topics/architecture/seckill-design/meta.json new file mode 100644 index 0000000..bdd69e2 --- /dev/null +++ b/topics/architecture/seckill-design/meta.json @@ -0,0 +1,64 @@ +{ + "slug": "seckill-design", + "name": "高并发秒杀系统设计", + "description": "高并发秒杀系统:分层削峰、限流(429)、Redis Lua 原子扣减、不超卖、一人限购、异步落库、Redis Key 设计", + "tags": [ + "429", + "Duplicate", + "EXPIRE", + "Go", + "INCR", + "Lua", + "Redis", + "Redis Cluster", + "Redis Key", + "Redis原子性", + "Retry-After", + "SQL", + "ZSET", + "hash tag", + "一人一件", + "乐观锁", + "令牌桶", + "兜底", + "冒号", + "削峰", + "单线程", + "原子", + "命名规范", + "唯一约束", + "固定窗口", + "异步", + "批量", + "数据库兜底", + "消息队列", + "滑动窗口", + "版本号", + "落库", + "超卖", + "跨节点", + "限流", + "限购" + ], + "difficulty_range": [ + 2, + 5 + ], + "schema_version": "1.0.0", + "updated": "2026-09-04", + "question_files": [ + "single_choice", + "fill_blank", + "short_answer", + "code_reading" + ], + "stats": { + "total": 40, + "by_type": { + "single_choice": 10, + "fill_blank": 10, + "short_answer": 10, + "code_reading": 10 + } + } +} diff --git a/topics/architecture/seckill-design/short_answer.json b/topics/architecture/seckill-design/short_answer.json new file mode 100644 index 0000000..7a00cd8 --- /dev/null +++ b/topics/architecture/seckill-design/short_answer.json @@ -0,0 +1,277 @@ +{ + "topic": "seckill-design", + "type": "short_answer", + "schema_version": "1.0.0", + "generated": "2026-09-04T12:00:24+08:00", + "questions": [ + { + "id": "sa-001", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "分层", + "削峰", + "CDN", + "Gateway", + "消息队列", + "异步落库" + ], + "question": "请画出并说明高并发秒杀系统的分层削峰架构(从请求入口到数据库),并解释为什么数据库层只接待极少量写入。", + "answer": "分层削峰架构由外到内依次是:\nCDN/Nginx(静态资源、简单防护)→ 网关(限流/限购/风控)→ 秒杀服务 → Redis(Lua 原子扣库存)→ 消息队列 → 数据库(异步落库)。\n\n各层职责:\n1. CDN/Nginx 层:承载静态页面、商品图、按钮状态等,命中缓存即可返回,挡掉海量重复请求。\n2. 网关层:基于用户/IP 做限流、令牌桶、风控、黑名单,把极大部分非法与重复流量拦截在系统入口。\n3. 秒杀服务:处理业务规则与前置校验。\n4. Redis 层:用 Lua 脚本原子地“读库存→判断够不够→扣减→标记已购”,是真正的防超卖防线。\n5. 消息队列层:秒杀成功后不直接写库,把成交事件发到 MQ 异步削峰。\n6. 数据库层:消费者批量异步写入,数据库只接待削峰后极少量的真实成交写入。\n\n这样设计的原因:秒杀窗口极小(几秒到几十秒)、瞬时 QPS 极高(数万到百万),但最终能成交的只有十几件。若把海量请求直接打到数据库,MySQL 必然被打穿。通过分层削峰,让海量请求在外层就被拦截丢弃,数据库只处理极少数“真买家”的写入,从而保护后端。", + "keywords": [ + "分层", + "削峰", + "CDN/Nginx", + "网关限流风控", + "Redis原子扣库存", + "消息队列", + "异步落库", + "数据库保护" + ], + "scoring_rubric": "共 4 分。①画出/说明完整链路并按正确顺序(CDN→网关→服务→Redis→MQ→DB)得 1 分;②指出每层主要职责(尤其 Redis 原子扣减、MQ 异步削峰)得 1 分;③说明数据库采用异步落库避免直接压库得 1 分;④点出“极小成交量 vs 极大瞬时流量”矛盾与逐层削峰思路得 1 分。", + "explanation": "本题考查对秒杀整体架构的把控。秒杀系统设计核心就是分层削峰。回答须体现从外到内的每一层职责,并强调最终只有极少量数据写入数据库、MySQL 不直接承受高并发。缺任一层或说不出各层职责都会扣分。", + "source": null, + "related": [] + }, + { + "id": "sa-002", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "429", + "售罄", + "Retry-After", + "限流", + "重试风暴", + "状态码" + ], + "question": "秒杀系统中被限流与售罄为什么要用不同状态码区分?分别用哪个状态码,以及 429 响应必须携带 Retry-After 头的原因。", + "answer": "被限流与售罄代表完全不同的业务状态:“被限流”表示系统还有机会,稍后重试可能成功;而“售罄”意味着库存已经没了,无论怎么重试都不会有货。若混用,客户端或用户会误判——把“还有机会”当成“没货”而放弃,或把“没货”当成“被限流”反复请求加重后端负担。\n\n具体约定:\n- 429 Too Many Requests=被限流(还有成交机会);\n- 404 或自定义业务码=售罄(已无库存);\n- 503=服务过载/后端故障。\n\n429 必须带 Retry-After 的原因:\n1. Retry-After 告诉客户端“多久之后再来”,让客户端平滑、有节奏地重试,而不是立刻再来。\n2. 若没有 Retry-After,客户端会立刻并发重试,形成重试风暴(把请求量放大数倍到数十倍),把已经过载的服务二次打穿。\n3. 前端拿到 429 时展示“已自动排队”等友好文案,避免诱导用户手动刷新(手动刷新同样放大风暴)。\n\n示例:响应头 Retry-After: 5,意为 5 秒后重试;可配合 X-RateLimit-Remaining、X-RateLimit-Reset 提供剩余额度信息。", + "keywords": [ + "429", + "Retry-After", + "限流", + "售罄", + "重试风暴", + "503" + ], + "scoring_rubric": "共 5 分。①说明被限流与售罄是不同业务状态(有机会/没机会)得 1 分;②答对 429=限流、售罄用 404/业务码、503=过载得 2 分;③答对 Retry-After 让客户端平滑重试、避免重试风暴得 2 分。", + "explanation": "本题考查状态码语义与重试风暴防护。重点:429 与售罄的区分是用户体验与后端稳定性双重需要;Retry-After 是防止重试风暴的关键手段,属于秒杀设计常考点。", + "source": null, + "related": [] + }, + { + "id": "sa-003", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "Redis原子性", + "Lua", + "超卖", + "单线程", + "事件循环" + ], + "question": "为什么把“读库存→判断够不够→扣减”封装成 Lua 脚本执行就能保证不会超卖?请从 Redis 单线程模型的角度解释其原子性原理。", + "answer": "保证不超卖的本质是让“判断+扣减”这个不可分割的临界区变成原子操作,避免并发下的竞态条件。\n\n原子性原理:\n1. 若把“先 GET 读库存→再 DECR 扣减”分开执行,不同并发请求会先各自 GET 到“还有库存”,然后各自 DECR,累加卖出就会超过库存——这就是超卖。\n2. Redis 服务器主线程是一个单线程的 event loop,同一时刻只执行一个命令/一个脚本,普通命令之间不并发。\n3. Lua 脚本被 Redis 当作一个整体执行:脚本执行期间,Redis 主线程被该脚本独占,不会在处理过程中插入任何其他命令。\n4. 因此把“读库存→判断 stock 是否足够→DECRBY 扣减”写进同一个 Lua 脚本,脚本要么整体执行、要么不执行,中间不会被切分,天然原子。\n5. 脚本执行期间即便并发请求到达,也只能在队列中等待该脚本跑完,从根本上杜绝了“都读到有货、然后都扣”造成的超卖。\n\n补充:为保证 Redis 主线程不被打挂,Lua 脚本必须短小、无阻塞(不写死循环、不做文件/网络 IO),否则会阻塞单线程并触发 lua-time-limit(默认 5000ms)。", + "keywords": [ + "Lua", + "原子性", + "单线程", + "event loop", + "超卖", + "临界区", + "DECRBY" + ], + "scoring_rubric": "共 5 分。①指出“先读后扣”分开执行会因并发竞态而超卖得 1 分;②说出 Redis 底层是单线程 event loop 得 1 分;③解释 Lua 独占主线程期间不插入其他命令、整体原子得 2 分;④点明并发请求排队等脚本执行、杜绝重复扣减得 1 分。", + "explanation": "本题考查 Lua 原子性这一核心考点。关键在于“判断+扣减”写进单个脚本后在单线程 Redis 里整体执行、不可被切分,这与“先读后扣拆分两次命令”的竞态形成对比。答题时强调单线程事件循环与命令不插入。", + "source": null, + "related": [] + }, + { + "id": "sa-004", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "Lua", + "MULTI/EXEC", + "事务", + "原子性", + "if判断" + ], + "question": "为什么秒杀“库存够才扣”的场景必须用 Lua 脚本而非 Redis 的 MULTI/EXEC?请对比两者差异并说明理由。", + "answer": "1. Redis 的 MULTI/EXEC 事务是把一批命令“从头到尾连续执行”,保证这批命令之间不被其他命令插入;但它不能在命令之间写“基于值的判断逻辑”(没有 if 分支/条件选择),也做不到根据判断结果中途放弃某一整批操作。\n2. 秒杀要求“有库存才扣”,需要先读库存、判断数值是否大于等于购买数量,满足才 DECR、不满足返回售罄——这是一个带 if 分支的逻辑。\n3. MULTI/EXEC 的局限:不支持条件判断,一旦把 DECR 放进事务就会无条件执行(即使库存已为 0 也会照扣,导致超卖);而 Lua 是完整脚本语言,可在脚本内写 if 分支,在条件不满足时直接 return 0(不执行扣减)。\n4. 结论:要实现“库存够才扣”这种“带判断的原子操作”,必须用 Lua 脚本;MULTI/EXEC 只能做“无条件的批量原子执行”,无法满足秒杀防超卖需求。\n\n补充:Lua 的 if 判断配合 Redis 单线程原子执行,比“乐观锁配合多次重试”更简单高效,也更常用。", + "keywords": [ + "Lua", + "MULTI/EXEC", + "if条件", + "原子性", + "防超卖", + "事务" + ], + "scoring_rubric": "共 5 分。①正确概括 MULTI/EXEC 为“连续执行、不支持中间条件判断”得 2 分;②说明 Lua 可在脚本内写 if 分支完成“够才扣”得 2 分;③结论“此题需用 Lua 而非 MULTI/EXEC”得 1 分。", + "explanation": "本题考察 Redis 两种原子手段的适用差异。重点:二者都原子,但 MULTI/EXEC 无 if 分支、无法依据读到的值做选择;秒杀“够才扣”需要条件分支故必须 Lua。答出“都是原子、差异在能否条件判断”易得高分。", + "source": null, + "related": [] + }, + { + "id": "sa-005", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "乐观锁", + "唯一约束", + "兜底", + "防超卖", + "限购" + ], + "question": "Redis Lua 原子扣减之外,为什么还要在数据库层用乐观锁 + 唯一约束做双保险兜底?请分别说明乐观锁和唯一约束各自守护什么。", + "answer": "兜底的总体原因:\n1. Redis 是秒杀的主要防线,但并非绝对可靠:若 Redis 异常、被绕过(例如业务直接调用 DB 接口),或日后流量路径改变直接打到数据库,单靠 Redis 无法全局兜住。因此在数据库层再加一层防线。\n\n2. 乐观锁守护“总库存不超卖”:\n UPDATE stock SET stock=stock-1, version=version+1 WHERE goods_id=? AND stock>0 AND version=?;\n - WHERE 中限定“库存仍 >0 且 version=当前值”。\n - 其中“版本号 + 库存上限判断”共同保证只有符合条件的 update 生效。\n - 若 RowsAffected()==0,说明库存不足或版本号已变化(被别的事务先改过),直接返回售罄,不回滚,不夸售。\n\n3. 唯一约束守护“一人一件限购”:\n 建数据库唯一约束 UNIQUE KEY(goods_id, user_id),当同一用户对同一商品再次插入下单记录时发生冲突(Duplicate entry),数据库在该物理层面拒绝重复,从而锁死一人只能谋购一次。\n\n4. 两者“防线不同”构成双保险:乐观锁防“总量超卖”,唯一约束防“单人多买”,与 Redis 叠加形成多层防线,把超卖与重复购买都做了底层兜底。", + "keywords": [ + "乐观锁", + "version", + "唯一约束", + "Duplicate entry", + "兜底", + "双保险", + "防超卖" + ], + "scoring_rubric": "共 6 分。①说明单靠 Redis 不保险、需要 DB 兜底得 1 分;②说出乐观锁利用 WHERE stock>0 AND version=? 判断、RowsAffected()==0 则返回售罄得 2 分;③说出唯一约束 (goods_id,user_id) 挡重复插入、物理上拒绝一人多买得 2 分;④用一句话总结“乐观锁防超卖,唯一约束防限量购”得 1 分。", + "explanation": "本题既考察“Redis 之外还要 DB 兜底”的动机,也考察两道防线各自的差异。乐观锁(version 比较 + stock>0 判断 + RowsAffected)守超卖,唯一约束守一人一件。写出 SQL 主张变更尤其得分高。", + "source": null, + "related": [] + }, + { + "id": "sa-006", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "同一Lua", + "扣库存", + "限购", + "原子性", + "防双写" + ], + "question": "秒杀中为什么“扣减库存”和“标记一人已购”必须放在同一个 Lua 脚本内一次性完成?如果分开实现会出什么问题?返回约定 -1/0/1 各代表什么?", + "answer": "必须放在同一个 Lua 的原因:\n“扣库存”和“标记已购”本质上是同一笔交易的两个状态变更,需要原子、连贯地发生,中途不被打断。\n\n如果分开两次执行(不同时刻发出命令)会出两类问题:\n1. “扣了库但没标记已购”:库存被扣掉,但用户没有被正确记录,用户重试时仍可能再次买入,导致一人多件、账实不一致;\n2. “标记了但没扣库”:用户被标记已购,但库存没有减,库存虚高,更坏的是会把真实成交数据搞乱。\n\n这两个状态变更必须“要么同时成功、要么同时不做”,写进同一个 Lua 脚本、由单线程一次性完成,中间不插入其他命令,从根上避免“扣而没标记一可重复买”或“标记没扣一库存错乱”。\n\n典型返回约定:\n- -1:已购过(该用户已被限购,不能重复购买);\n- 0:售罄/库存不足,无法扣减;\n- 1:扣减成功且同时完成限购标记。\n\n伪流程:if exists(已购key) then return -1; 若库存<1 return 0; DECRBY 库存; SET 已购key; EXPIRE 已购key; return 1。\n\n另外,在数据库层再建 UNIQUE(goods_id,user_id) 唯一约束作为物理兜底,把“一人一件”钉死。", + "keywords": [ + "同一Lua", + "原子", + "扣库存", + "限购标记", + "-1已购", + "0售罄", + "1成功", + "防双写" + ], + "scoring_rubric": "共 6 分。①说出“扣库+已购标记”需原子连贯得 1 分;②分别指出分开实现的两类问题(先扣后没标记→可重复买 / 先标后没扣→库存错乱)各得 1 分、共 2 分;③说明写成单个 Lua 的整体原子性得 1 分;④正确说明 -1→已购 /0→售罄 /1→成功 得 2 分。", + "explanation": "超级库存与限购标记塞进同一个 Lua 是防重复购买、防库存错乱的核心点,难度最高。答题须说明分开后会产生的两类问题,以及 -1/0/1 三个返回语义。", + "source": null, + "related": [] + }, + { + "id": "sa-007", + "type": "short_answer", + "difficulty": 2, + "tags": [ + "Redis key", + "无层级", + "冒号命名", + "扁平字典" + ], + "question": "描述 Redis 的 key 为什么叫“无层级”。用冒号分级的 key 名(如 seckill:stock:1001)在 Redis 眼里到底是什么?命名应遵循什么规范?", + "answer": "1. Redis 底层用一个平坦的字典(hash 表)保存所有 key,key 本质上就是一个普通字符串,不存在文件系统里的“文件夹/目录/层级”概念。\n2. 你看到的冒号【:】并不是 Redis 给它做了目录分级;Redis 只把它当作普通字符串的一部分。\n3. 因此 `seckill:stock:1001` 在 Redis 中只是一个平铺的字符串键(key 名为“seckill:stock:1001”),是扁平的、元树状结构。\n4. 冒号只是开发者约定俗成的命名分隔方式,用来表达逻辑层次、方便前缀扫描(如 SCAN 按前缀续航)和人工可读性。\n\n推荐命名规范:`业务:对象:标识`,例如:\n- seckill:stock:1001 商品 1001 的库存\n- seckill:bought:1001:user123 商品 1001 的某用户已购标记\n- seckill:limit:user123 对用户 user123 限流计数\n\n因此 key 命名有“逻辑分层”,但底层存储并无层级,性能也并不会因冒号分层而不同。", + "keywords": [ + "Redis", + "key", + "字典", + "冒号", + "无层级", + "命名规范" + ], + "scoring_rubric": "共 4 分。①说 Redis 底层为平坦 dict、key 只是普通字符串得 2 分;②点出冒号只是命名约定而非真实目录层级得 1 分;③给出“业务:对象:标识”命名规范得 1 分。", + "explanation": "exam 考点为“Redis key 无层级”这一易混淆点。重点是澄清:并无目录树,冒号只是字符串约定。回答时用底层是 flat dict 更准确。", + "source": null, + "related": [] + }, + { + "id": "sa-008", + "type": "short_answer", + "difficulty": 5, + "tags": [ + "hash tag", + "Redis Cluster", + "槽位", + "多键", + "Lua" + ], + "question": "秒杀 Lua 脚本同时操作“库存 key”和“用户已购 key”两个 key 时单实例可正常执行;为什么在 Redis Cluster 下会报警?如何用 hash tag 解问题?", + "answer": "说明 Cluster 的跨节点问题:\n1. Redis Cluster 会对 key 做 CRC16 哈希,并对 16384 取模得到槽位(slot),再把不同槽位的数据分布到不同节点上。\n2. 若同一个 Lua 脚本操作多个 key 时,这些 key 被算到不同槽位/不同节点,Cluster 无法在单个节点内原子地跨 key 执行该脚本,此时会直接报错(最典型为 CROSSSLOT Keys in request don't hash to same slot)。\n\n解决方案——hash tag `{...}`:\n1. 在多个 key 的同一节用一对花括号包住共同照段,如都写成 {seckill:1001} 前缀,Redis 只对花括号内的内容计算 CRC16 槽位,因此这些 key 会落到同一个槽位/同一个节点。\n2. 例:\n stock key:{seckill:1001}:stock\n user key :{seckill:1001}:user:1001\n 由于每两个 key 的花括号部分都是“seckill:1001”,被分配在同一 slot 同一节点,Lua 即可在该节点原子处理。\n\n补充:\n- 秒杀单实例 Redis 常见,不存在跨槽限制,无需 hash tag。\n- Cluster 且需一次操作多个 key 时,才必须用 hash tag 强制同槽;同时 hash tag 可能把相关 key 集中到同一节点,造成热点集中,需权衡。", + "keywords": [ + "hash tag", + "Redis Cluster", + "CRC16", + "槽位", + "CROSSSLOT", + "同一节点" + ], + "scoring_rubric": "共 5 分。①正确说明 Cluster 按 CRC16 把 key 分布到不同槽/节点,跨槽无法原子执行(报 CROSSSLOT 错误)得 2 分;②说明用同名花括号 `{...}` 强制同 hash 得 2 分;③给出 hash tag 具体例子({seckill:商品ID} 前缀)得 1 分。", + "explanation": "hash tag 是 Cluster 下解决“多键在单个 Lua 中原子执行”的关键。答题要点是结合 CRC16/槽位:不同槽位无法在同一脚本原子写多个 key,用 `{}` 强制让若干 key 落到同一槽同一节点即可。", + "source": null, + "related": [] + }, + { + "id": "sa-009", + "type": "short_answer", + "difficulty": 3, + "tags": [ + "异步", + "消息队列", + "削峰", + "批量落库", + "MQ" + ], + "question": "秒杀扣库存成功后为什么不应“同步写库”?请说明用消息队列做异步削峰、批量落库的流程与好处。", + "answer": "不同步写库的核心原因:\n1. 秒杀瞬间可能有极高并发产生大量成功扣库存请求,若每个都同步写库,数据库会在瞬间被大批同步连接与事务命中,直接被打穿,数据库成为瓶颈。\n2. 而最终要写入数据库其实是“成功登录成交”的少量结果,时延可以被接受,没必要同步写。\n\n异步削峰 + 批量落库流程:\n1. 扣库存成功后不下库,而是把成交事件丢到消息队列(MQ);\n2. 秒杀服务先快速给前端返回成功,把 DB 时延从主链路“剥离”出来;\n3. 消费者从 MQ 消费,是“攒一批”(例如每攒 100 条)再批量 INSERT/批量 upsert 写入数据库。\n\n好处:\n1. 削峰:瞬时高峰的 DB 压力被“摊平”到消费者的稳定消费时段,流量被缓冲而不是一次性打穿;\n2. 大幅减少交互次数:把 N 条单条写合并为 N/批 次批量写,连接、事务与 IO 开销都显著降低;\n3. 缓冲与解耦:即使 DB 短暂波动,MQ 可积压、秒杀核心不阻塞不丢数据;\n4. 提升数据库吞吐:数据库仅面对稳定批量的写。", + "keywords": [ + "异步", + "消息队列", + "削峰", + "批量", + "解耦", + "DB保护" + ], + "scoring_rubric": "共 5 分。①说明同步大量写会打爆 DB 得 1 分;②描述扣后入 MQ、消费者异步消费得 1 分;③说明攒批批量入库(降低交互至少数倍)得 2 分;④点出“削峰/缓冲/解耦”好处得 1 分。", + "explanation": "async 已是秒杀批量落库的常考点。核心避免 N 次单条写同步 DB,改“攒一批再批量写”,在削峰的同时大幅降 DB 压力。", + "source": null, + "related": [] + }, + { + "id": "sa-010", + "type": "short_answer", + "difficulty": 4, + "tags": [ + "INCR", + "EXPIRE", + "限流", + "原子性", + "Redis", + "429" + ], + "question": "如何用 Redis 的 INCR + EXPIRE 实现“每 5 秒针对单用户限流 1 次”请求?请说明正确流程,并解释为什么必须用 INCR(原子)而不能用 GET + SET。", + "answer": "算法流程:\n1. 以用户维度建 key(如 `seckill:limit:`),值作为计数。\n2. 对该 key 做 INCR 自增并获取返回值。\n3. 若这是首次访问(返回值为 1),说明是新窗口的开始,设置 EXPIRE 5 秒,从而每个 5 秒形成一个计数窗口。\n4. 判断 count 是否超过阈值(比如限制 5 秒 1 次则阈值为 1);超过即返回 429(被限流)。\n\nGo 核心代码:\nkey := \"seckill:limit:\" + userID\ncount,_ := rdb.Incr(ctx,key).Result()\nif count == 1 { _ = rdb.Expire(ctx,key, 5*time.Second).Err() }\nif count > 1 { return 429 }\n\n为何用 INCR 而不用 GET + SET:\n- 若先 GET 后 SET(或 GET 后根据值决定SET),会出现 TOCTOU 竞态:多个并发请求同时读到相同的旧值,分别 +1 再写回,计数会丢失/失真——请求次数根本没被记准,限流可能被绕过或误判。\n- 而 INCR 是 Redis 原子操作:底层单条命令完成“自增并返回”,并发请求会排队,各次都能拿到正确且唯一的值。\n- 加上“首次访问设置 EXPIRE”,可实现窗口到点自动复位、不再需要手动清理。\n\n注意:只有 count==1(新窗口首个请求)时才设置 EXPIRE,否则会把同一窗口的过期时间不断延长,破坏限流。", + "keywords": [ + "INCR", + "原子性", + "EXPIRE", + "窗口", + "429", + "竞态", + "限流" + ], + "scoring_rubric": "共 5 分。①描述以用户维度建 key 并用 INCR 自增得 1 分;②正确给出首次访问(count==1)时设置 EXPIRE 的窗口逻辑得 2 分;③说明阈值判断超限返回 429 得 1 分;④解释为什么用 INCR(原子)可比 GET+SET 避免 TOCTOU 竞态失真得 1 分。", + "explanation": "这是限流计数的实现。核心用 INCR 的原子性避免并发计数竞态,并配合首次访问设 EXPIRE 控制窗口过期复位。能写出 Go 或 Lua 伪代码更容易得满分。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/architecture/seckill-design/single_choice.json b/topics/architecture/seckill-design/single_choice.json new file mode 100644 index 0000000..8de96a3 --- /dev/null +++ b/topics/architecture/seckill-design/single_choice.json @@ -0,0 +1,216 @@ +{ + "topic": "seckill-design", + "type": "single_choice", + "schema_version": "1.0.0", + "generated": "2026-09-04T00:00:00+08:00", + "questions": [ + { + "id": "sc-001", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "削峰", + "分层", + "CDN", + "消息队列" + ], + "question": "秒杀场景的特点是:极大瞬时流量与极小售卖数量并存(几秒内几十万请求,但能卖出的只有十几件)。为保护后端数据库,系统设计应采用的核心思想是?", + "options": { + "A": "把所有请求无差别直接打到数据库,依靠 MySQL 性能硬扛", + "B": "分层削峰,逐层把海量请求挡在外层,只让极少数真买家穿透到后端", + "C": "把秒杀商品全部预加载进内存,让每个请求都同步写库", + "D": "关闭静态资源缓存,保证所有流量都触发后端实时校验" + }, + "answer": "B", + "explanation": "秒杀的核心矛盾是极小窗口 vs 极大瞬时流量。正确做法是分层削峰:链路通常为 CDN/Nginx(静态资源) → 网关(限流/限购/风控) → 秒杀服务 → Redis(原子扣库存) → 消息队列 → 数据库(异步落库)。这样数据库只接待削峰后的极小写入,绝不让高并发直接压 MySQL。A 把高档并发直接打向数据库是错误的;C 所有请求都同步写库同样压垮数据库;D 关闭缓存反而放大流量。", + "source": null, + "related": [] + }, + { + "id": "sc-002", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "限流", + "429" + ], + "question": "HTTP 状态码 429 以及它必须携带的响应头,正确含义是什么?", + "options": { + "A": "429 表示库存已售罄,携带 Retry-After 头告知客户商品下架时间", + "B": "429 表示服务过载故障,让客户端随机重试", + "C": "429 表示被限流(还有机会),必须携带 Retry-After 头让客户端平滑重试", + "D": "429 表示请求参数非法,应携带 X-RateLimit-Error 描述错误原因" + }, + "answer": "C", + "explanation": "429 Too Many Requests 语义是被限流而非售罄或故障,表示暂时还有机会,稍后重试可能成功。被限流、售罄、故障应用不同状态码区分:429=被限流,404或业务码=售罄,503=服务过载。429 响应必须带 Retry-After 头(如 Retry-After: 5)让客户端在指定时间后平滑重试,从而避免大量客户端同时重放造成重试风暴二次打穿系统。前端拿到 429 应展示'已自动排队'文案而非'系统繁忙'。", + "source": null, + "related": [] + }, + { + "id": "sc-003", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "超卖", + "Redis原子性" + ], + "question": "用纯 Redis 两条普通命令(先 GET 读库存再 DECR 扣减)实现扣库存,高并发下为何会超卖?", + "options": { + "A": "因为 Redis 默认关闭持久化,重启后库存丢失导致超卖", + "B": "因为并发多个请求都先读到'还有库存',然后各自 DECR,总扣减量超出库存", + "C": "因为 DECR 返回负数时会致 Redis 卡死,引发雪崩", + "D": "因为 GET 与 DECR 之间网络延迟过大导致事务或回漏" + }, + "answer": "B", + "explanation": "两条普通命令不是原子的:并发请求都先执行 GET 读到库存仍有剩余,随后各自执行 DECR,总卖出数量超过库存上限造成超卖。正确做法是把'读库存→判断够→减库存'封装进 Lua 脚本由 Redis 单线程整体不可切分执行。A 是持久化问题与超卖无关;C 中 DECR 返回负数不会导致 Redis 崩溃;D 表示事务,错误在于本来就无原子性保障。", + "source": null, + "related": [] + }, + { + "id": "sc-004", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "Redis原子性", + "Lua", + "乐观锁" + ], + "question": "为什么秒杀中'读→判→扣'必须用 Lua 而不能用 MULTI/EXEC 事务?", + "options": { + "A": "因为 MULTI/EXEC 不支持管道复用,网络往返比 Lua 慢很多", + "B": "因为 MULTI/EXEC 只是把命令连续打包执行,无法在中间写 if 判断逻辑,而 Lua 可以写条件", + "C": "因为 MULTI/EXEC 不是原子的,Redis 会穿插其他请求打断它", + "D": "因为 MULTI/EXEC 执行期间会阻塞主线程导致超时,而 Lua 严格不阻塞" + }, + "answer": "B", + "explanation": "MULTI/EXEC 只是把一组命令连续打包执行(要么全成要么全不),但无法插入判断逻辑。秒杀'库存够才扣'这种带条件分支必须用 Lua:Lua 在 Redis 单线程中作为一个整体执行,期间主线程不接管其他命令,因此既原子又带 if 判断。A 网络往返不是关键原因;C 错误,MULTI/EXEC 本身也是原子的连续执行;D 不实,两者都会占用主线程。", + "source": null, + "related": [] + }, + { + "id": "sc-005", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "乐观锁", + "超卖" + ], + "question": "在 Reddis 之后,数据库层仍推荐用乐观锁做超卖兜底。下面哪条 SQL 写法符合乐观锁思路?", + "options": { + "A": "INSERT INTO stock(goods_id, stock) VALUES(?, ?) 用先查再插入", + "B": "UPDATE stock SET stock=stock-1 WHERE goods_id=? 不加任何条件", + "C": "UPDATE stock SET stock=stock-1, version=version+1 WHERE goods_id=? AND stock>0 AND version=?,RowsAffected()==0 则视为售罄", + "D": "SELECT stock ...; 应用层判断后直接 UPDATE,不依赖版本号" + }, + "answer": "C", + "explanation": "乐观锁通过 WHERE 中的 version 和 stock 校验作版本与库存的双重条件:UPDATE stock SET stock=stock-1, version=version+1 WHERE goods_id=? AND stock>0 AND version=?; 命中影响行数 RowsAffected()==0 表示库存不足或版本冲突,返回售罄。A 无明显扣减;B 无任何条件,并发会互相覆盖致超卖;D 是典型的读后改,无法防止并发改写导致超卖。", + "source": null, + "related": [] + }, + { + "id": "sc-006", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "限购", + "Redis原子性", + "超卖", + "Lua" + ], + "question": "一人限购一件中,为什么必须把'判已购 + 扣库存'放在同一个 Lua 脚本一次性完成?", + "options": { + "A": "为了缩减脚本字节数,降低网络下载开销", + "B": "为了避开事故,拆成两条独立命令可增加审计日志", + "C": "否则会出现'扣了库却没标记巳购'(可重复买)或'标了已购却没扣库存'(库存错乱)的不一致", + "D": "为了绕过数据库 UNIQUE 约束避免插入冲突" + }, + "answer": "C", + "explanation": "若'判已购'与'扣库存'由两独立命令执行,高并发会交叉:先扣库存但未标记已购,其他请求再次扣减实现重复购买;或先标已购但没扣库存,库存错乱。合并进一个 Lua 整体执行,Redis 单线程不插入其他命令,两者严格一致。A/B 与正确性无关;D 反向——数据库用 UNIQUE KEY (goods_id, user_id) 做兜底。", + "source": null, + "related": [] + }, + { + "id": "sc-007", + "type": "single_choice", + "difficulty": 3, + "tags": [ + "异步", + "消息队列", + "削峰" + ], + "question": "秒杀扣库存成功后,为什么推荐进消息队列由消费者批量异步写库,而不是直接同步写库?", + "options": { + "A": "异步可把海量请求阻塞在缓存层,让缓存读取更快", + "B": "直接读 MQ 消息即可,数据库可以彻底移除", + "C": "把数据库即时压力转化为可队列的可控吞吐,消费者攒批批量 INSERT,降低 DB 交互次数", + "D": "同步写库会死锁,异步写库则数据库完全无锁" + }, + "answer": "C", + "explanation": "库只负责接收削峰后的极小写入。扣库存成功后入消息队列,消费者把消息攒成一泡池(如 100 条)再批量 INSERT,大幅减少与数据库的交互次数,把瞬间峰值压力转化为可控吞吐,保护数据库不被高并发打穿。A 与 B 概念错误,数据库仍是最终落账的权威存;D 过于绝对,异步不减锁开销。", + "source": null, + "related": [] + }, + { + "id": "sc-008", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "hash tag", + "Redis原子性", + "Lua" + ], + "question": "Redis Cluster 中一个 Lua 脚本作多个 key 时可能因跨槽位报错,常用的解法是?", + "options": { + "A": "给脚本手动加 LOCK key 做全局锁", + "B": "把脚本拆成多条分别提交分步执行", + "C": "使用 hash tag,使用{}包裹相同内容使 key 落到同一槽位", + "D": "把所有 key 随机平均分到不同槽位以分散负载" + }, + "answer": "C", + "explanation": "Redis Cluster 中 key 通过 CRC16 计算槽位,不同 key 常落不同节点,跨节点多 key 的 Lua 无法原子执行报错。解法 hash tag:大括号 `{...}` 内的内容参与哈希计算,因此 `{seckill:1001}:stock` 与 `{seckill:1001}:user:01` 哈希一致,定位同一槽位节点,可原子操作。A 不通用;B 拆开生丢原子;D '分散'正与需求相反。单实例 Redis(秒杀常用)无此限制。", + "source": null, + "related": [] + }, + { + "id": "sc-009", + "type": "single_choice", + "difficulty": 2, + "tags": [ + "Redis Key设计", + "分层" + ], + "question": "关于 Redis key 的分层/冒号设计,下列说法正确的是?", + "options": { + "A": "Redis 的 key 有真正文件夹层级,冒号会被解析成目录,依赖它做权限", + "B": "Redis 的 key 底层是平坦字典,没有文件夹;冒号只是开发者命名约定,对 Redis 只是普通字符串如 seckill:stock:1001", + "C": "冒号 key 会被自动渲染成 bucket,导致内存膨胀,应全部改为数字命名", + "D": "Redis 的 key 必须使用多个节点分片,映射到对应磁盘桶" + }, + "answer": "B", + "explanation": "Redis 的 key 是全局扁平字典,无层级结构。`seckill:stock:1001` 中的冒号是开发者用于可读性和业务分层的命名约定,对 Redis 而言只是一个普通字符串。规范一般是'业务:对象:标识'。A 将其当作真实目录、C/D 引入不存在的层级/桶概念,都是对 Redis 存储模型的误解。", + "source": null, + "related": [] + }, + { + "id": "sc-010", + "type": "single_choice", + "difficulty": 4, + "tags": [ + "单线程", + "限流", + "Redis原子性" + ], + "question": "使用 INCR + EXPIRE 实现每 5 秒限一次(如 seckill:limit:userID),下述逻辑写法正确的是?", + "options": { + "A": "必须先 EXPIRE 再 INCR,否则 key 永不过期累计失效", + "B": "count := INCR(key);若 count==1 单独 EXPIRE(key, 5s);返回 count>1 即 429", + "C": "用 SET key value EX 5 后再 GETSET 判断,与 INCR 效果相同,无需区分首次", + "D": "使用 INCRNX 命令并无需判断是否第一次" + }, + "answer": "B", + "explanation": "经典写法:先 INCR 拿到当前窗口内访问次数,若返回 1 说明该 key 是首次创建,此时为其设置 EXPIRE 5s;窗口结束 key 过期,新窗口重新计。count>1 表示该窗口内频率超限返回 429。A 顺序错误,EXPIRE 应在 INCR 首次返回后;C 用 SET+GETSET 无法自然实现滑动窗口计数;D INCRNX 并不存在这种方式来进行窗口限流(窗口内需多次递增)。", + "source": null, + "related": [] + } + ] +} \ No newline at end of file diff --git a/topics/index.json b/topics/index.json index a1ddb99..2161ff4 100644 --- a/topics/index.json +++ b/topics/index.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "updated": "2026-09-03", + "updated": "2026-09-04", "topics": [ { "slug": "qunar-ai-fullstack", @@ -220,6 +220,28 @@ } } ] + }, + { + "slug": "architecture", + "name": "系统架构", + "description": "高并发系统架构设计:秒杀系统、限流、削峰、数据一致性等", + "subtopics": [ + { + "slug": "seckill-design", + "name": "高并发秒杀系统设计", + "description": "分层削峰、限流(429)、Redis Lua 原子扣减、不超卖、一人限购、异步落库、Redis Key 设计", + "path": "topics/architecture/seckill-design", + "stats": { + "total": 40, + "by_type": { + "single_choice": 10, + "fill_blank": 10, + "short_answer": 10, + "code_reading": 10 + } + } + } + ] } ] }