{ "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 强制同槽。" } ] }