128 lines
12 KiB
JSON
128 lines
12 KiB
JSON
{
|
||
"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:<goodsID>}:stock` 与 `{seckill:<goodsID>}:user:<userID>`),利用哈希槽规则把多个 key 强制路由到____(不同/同一个)槽位即同一节点上,从而让跨 key 的 Lua 脚本仍能在该节点内原子执行。",
|
||
"answer": ["跨节点/跨槽位", "hash tag", "同一个"],
|
||
"answer_rule": "ordered",
|
||
"explanation": "在 Redis Cluster 中,key 按 hash 结果(如 CRC16 取模)分布到不同节点/槽位,Lua 脚本操作多个 key 时要求这些 key 必须 hash 到同一个槽,否则会报错无法保证原子。hash tag 用花括号 { } 圈住关键部分,只有花括号内的部分参与 hash 计算,例如 {seckill:<goodsID>}:stock 与 {seckill:<goodsID>}:user:<userID> 花括号内容相同,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": []
|
||
}
|
||
]
|
||
} |