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

488 lines
35 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": "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:<goodsID>} 固定哈希槽\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 强制同槽。"
}
]
}