Files
examination/topics/architecture/seckill-design/short_answer.json
T
2026-09-04 12:05:07 +08:00

277 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"topic": "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:<userID>`),值作为计数。\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": []
}
]
}