Skip to content

【面试题】1. 数据库乐观锁和悲观锁到底什么区别?秒杀场景各用在哪儿? ​

【标准答案】乐观锁是不加锁,更新时检查版本号;悲观锁是先锁住再操作。

乐观锁(version 号): 更新时 UPDATE stock SET count=count-1, version=version+1 WHERE id=? AND version=? AND count>=1,版本号对不上说明有人先改了,返回 affected_rows=0。优点是没锁开销,并发高;缺点是冲突多时大量重试,浪费 CPU。秒杀里用在库存扣减兜底——Redis 扛住 99% 流量后,DB 乐观锁做第二道防线。

悲观锁(select for update): 先 SELECT * FROM stock WHERE id=? FOR UPDATE 锁住这行,别人等着,然后扣库存、提交事务、释放锁。优点是强一致绝不冲突;缺点是锁等待 → 连接池耗尽 → 系统雪崩。秒杀里绝对不能用在主链路,只能用在需要严格串行的场景——比如生成订单号、分配优惠券名额这种冲突不可避免的操作。

一句话: 读多写少用乐观锁(秒杀库存兜底),冲突大概率用悲观锁(券名额分配)。秒杀主链路用 Redis Lua 原子扣,锁都不用。乐观锁是兜底,不是主力。


【面试题】2. 缓存击穿怎么解决?互斥锁和逻辑过期各有什么优劣? ​

【标准答案】缓存击穿是热点 Key 过期瞬间,大量请求穿透到数据库——像一个钉子扎穿了缓存层。

互斥锁方案(重建时上锁): 查缓存 miss 后,用 SETNX seckill:lock:${key} 1 EX 10 抢锁。抢到的线程查 DB 重建缓存,没抢到的等 50ms 重试查缓存(最多重试 3 次)。优点是逻辑简单、数据一致;缺点是抢到锁的线程被 DB 慢查询拖住,等的人也跟着等。代码量不到 20 行,中小流量够用。

逻辑过期方案(永不过期 + 异步刷新): 缓存 Value 里额外存一个 expireTime 字段,Key 本身不设 TTL。命中缓存后检查 expireTime 是否超时——超时了立刻返回旧数据(保证可用),同时异步开线程抢锁去刷新。优点是用户永远不等待,热点商品永远有数据返回(哪怕是旧的);缺点是需要维护旧数据、代码复杂、短时间可能返回过期数据。

秒杀怎么选? 商品基本信息(标题、价格、图片)用逻辑过期——1 秒的过期数据用户看不出来,可用性优先。库存数量用互斥锁——库存过期 1 秒就可能超卖,一致性优先。

一句话: 互斥锁保一致性,逻辑过期保可用性。秒杀里基本信息永不过期 + 异步刷,库存不走这个逻辑(Redis 是库存真相源)。


【面试题】3. 缓存雪崩怎么破?三个方案怎么组合用? ​

【标准答案】缓存雪崩是大面积 Key 同一时刻过期,所有请求同时穿透到数据库——像雪崩一样把数据库埋了。

方案一:过期时间加随机扰动(最小成本): EXPIRE key TTL + random(0, 300),同一批 Key 的过期时间被随机打散到 5 分钟内,不会同时失效。一行代码就能降低 80% 的雪崩概率,秒杀预热数据全部加随机 TTL。

方案二:多级缓存 + 永不过期(核心): Caffeine 本地缓存 → Redis 分布式缓存 → DB。本地缓存设 TTL 5 分钟,Redis 缓存设随机 TTL 10-15 分钟。Redis 大面积过期了,本地缓存还有 5 分钟的缓冲窗口,足够重建 Redis。本地缓存是 Redis 的兜底,Redis 是 DB 的兜底——两层保险。

方案三:熔断降级兜底(最后一道): Redis 大面积 miss → DB QPS 飙升 → Sentinel 触发熔断 → 返回降级数据(本地缓存的旧数据或静态兜底页)。即使 Redis 全挂、DB 快崩了,用户至少看到"活动火爆,请稍后再试",而不是 500 白屏。

组合: 过期随机化(预防) + 多级缓存(缓冲) + 熔断降级(兜底),三道防线挡住雪崩。面试官问到雪崩,三个方案全讲一遍,展示系统性防御思维。


【面试题】4. 订单取消后库存怎么回滚?Redis 和 DB 的回滚怎么保持一致? ​

【标准答案】库存回滚最怕两件事:回滚了不该回滚的(多回滚),该回滚的没回滚(少回滚)。

标准回滚链路: ①订单取消/超时未支付触发回滚 → ②执行 Redis Lua 脚本 redis.call('incrby', KEYS[1], ARGV[1]) 加回库存 → ③发 MQ 回滚消息 → ④消费端用乐观锁更新 DB 库存 UPDATE stock SET count=count+? WHERE id=? AND version=? → ⑤记录回滚流水 INSERT INTO stock_rollback_log(order_id, sku_id, quantity, reason)。

防重复回滚: 用 Redis Set 记录已回滚的订单 ID——SISMEMBER seckill:rollback:set ${orderId},存在就跳过。回滚日志表加唯一索引 UNIQUE KEY uk_order_id (order_id),DB 层也拦一道。双重防重,一条订单绝不可能回滚两次。

回滚失败了怎么办? 回滚 Lua 脚本里库存加回了但 DB 更新失败(网络闪断),MQ 消费者重试 3 次。3 次都失败进死信队列,人工介入查 DB 库存是否已更新。对账脚本每 5 分钟跑一次:Redis库存数 = DB库存数 - 未支付订单数,不等就告警。

特殊场景:超卖回滚。 万一 Redis 多扣了(比如主从切换丢数据),回滚时 incrby 会把库存加超。Lua 脚本加检查:if tonumber(stock) + tonumber(quantity) > total_stock then return -1 end,超过总库存说明数据异常,报警人工处理。

一句话: incrby 加库存 + 已回滚 Set 防重 + 回滚日志留痕 + 对账脚本查漏。回滚不是 undo,是一个新的正向操作,每一步都要幂等和可追踪。


【面试题】5. 秒杀 URL 怎么隐藏?防止有人提前拿到链接用脚本刷? ​

【标准答案】秒杀 URL 隐藏分三步:动态化、时效化、一次一密。

第一步——动态 URL: 不是 /seckill/item123 这种能猜的,是 /seckill/abc123def456?token=xxxx 带一段随机字符串。秒杀开始前 URL 不存在,活动开始前 1 秒由后端生成并推送给前端,前端渲染按钮的 href。黄牛提前不知道完整 URL,脚本无从刷起。

第二步——URL Token 绑定用户和时效: Token 的生成逻辑——HMAC-SHA256(userId + itemId + timestamp, secretKey),后端校验时反向验证。一个 Token 只能一个人用,复制给别人校验不过。Token 有效期 5 秒,过期作废。即使被截获,也只有一个用户在 5 秒内有效。

第三步——时间窗口前置拦截: 网关层检查请求时间戳,秒杀开始前 1 秒内的请求才放行,提前请求直接返回"活动未开始"。Nginx+Lua 一行 if ngx.time() < start_time then return ngx.exit(403) end。这是秒杀 URL 的最后保障——即使 URL 泄露了,时间不到也刷不了。

前端怎么拿到 URL? 页面秒杀按钮初始是灰色倒计时,"距离开始还有 00:00:03"。倒计时到 0 时,前端发 Ajax 请求 /seckill/getUrl?itemId=xxx,后端校验活动时间已到、用户已登录、不在黑名单,返回一次性 URL。整个过程中,URL 跟用户强绑定、时效 5 秒、用完作废。

一句话: 动态 URL(不可猜)+ Token 绑定(不可冒用)+ 时间窗口(不可提前),三重保护让黄牛对着空气刷。


【面试题】6. 秒杀线上出故障了,你的排查流程是什么?降级开关怎么救火? ​

【标准答案】故障排查六个字:发现、止血、定位、修复、验证、复盘。

发现(30 秒内): 监控大盘报警——成功率跌到 90% 以下、P99 RT 超过 500ms、Redis 连接数打满。告警渠道:成功率 < 90% 钉钉群 + 电话双通道。

止血(1 分钟内): 第一反应不是找原因,是切降级。打开降级开关——Nginx 返回静态"活动火爆"页面,拦截 80% 流量。应用层 Sentinel 降级非核心链路(优惠券、积分、短信全熔断)。数据库连接池只保留主链路(扣库存 + 创订单)。口诀:先砍流量保核心,再找根因。

定位(5 分钟内): 看监控链路哪个环节掉得最多——Nginx 层没事但应用层 RT 飙升 → 查 Redis 慢查询 SLOWLOG GET 100;应用层没事但 DB 慢 → 查 SHOW PROCESSLIST 看有没有锁等待;都正常但 MQ 消费积压 → 看消费者线程是否阻塞。90% 的秒杀故障出在三个地方:Redis 热点、DB 锁等待、MQ 积压。

降级开关设计(救火工具): 需要提前在代码里埋好开关——@SentinelResource(value="seckill", fallback="seckillFallback")。开关从配置中心(Apollo/Nacos)实时下发,30 秒内全集群生效。四种降级级别:①L1 关闭非核心功能(积分/短信);②L2 返回静态页面代替动态渲染;③L3 全局限流从 10000 QPS 砍到 1000;④L4 暂停秒杀活动,全员返回"已售罄"。

一句话: 出事先降级保命,别沉迷找原因。降级开关要提前埋好,不能出事了再改代码发布。1 分钟止血 > 30 分钟定位。


【面试题】7. 秒杀项目从 0 到 1,开发全流程时间线怎么排?每个阶段产出什么? ​

【标准答案】面试官问你从 0 到 1,不是听你报技术名词,是看你能不能按"上线不翻车"的逻辑排期。

第一周——方案评审: 出架构图、接口文档、数据库设计、Redis 数据结构设计、MQ Topic 规划。产出:《秒杀技术方案》+ 评审记录。这一周最重要的事是和运营对齐:库存多少、限购规则、活动时间、预期 QPS。QPS 算不出来方案就是瞎设计。

第二周——核心开发: 扣库存 Lua 脚本(这是心脏,必须第一个写)、下单接口、订单 MQ 消费、前端秒杀页面 + 倒计时。产出:可运行的秒杀核心链路(能下单成功就算过关)。

第三周——周边补齐: 限流、风控、熔断降级、幂等 token、缓存预热脚本、售罄逻辑、监控埋点。产出:完整的防御体系(不怕刷、不怕崩、不怕重复)。

第四周——压测 + 修复: 全链路压测跑 3 轮,每轮修复一个瓶颈。第一轮测通链路,第二轮压到目标 QPS 的 3 倍测极限,第三轮混合场景(秒杀 + 普通浏览)模拟真实压力。产出:《压测报告》+ 性能优化记录。

上线前一天——checklist 核对: ①预热脚本跑了吗?②降级开关测了吗?③监控大盘正常吗?④缓存数据对吗?⑤数据库索引对吗?⑥回滚方案准备好了吗?(最关键的——链接还是旧版本,流量一切过去就能回。)产出:《上线 Checklist》全部打勾。

上线日: 灰度 1% → 盯 10 分钟 → 10% → 盯 10 分钟 → 50% → 盯 5 分钟 → 100%。秒杀开始前全员待命,盯着监控大盘直到活动结束。

一句话: 方案定方向、开发搞核心、压测验容量、上线走灰度。四周排期,一周都不能省。


【面试题】8. 秒杀限购规则怎么设计?"一人一单""一个设备一单"怎么落地? ​

【标准答案】限购不是简单的 if count > 1 then reject,要考虑用户/设备/收货地址三个维度。

用户维度(最核心): 扣库存 Lua 脚本里加判断——local bought = redis.call('sismember', 'seckill:bought:'..itemId, userId); if bought == 1 then return -2 end。扣成功的同时 redis.call('sadd', 'seckill:bought:'..itemId, userId)。Set 存所有已购用户 ID,O(1) 查重,绝无漏网。注意 Set 数据量——100 万已购用户约 40MB,小意思。

设备维度(防多账号): 下单时带上设备指纹(deviceId),Redis 里维护 deviceId → userId 的映射。同一 deviceId 绑了 3 个不同 userId 都下了同一个商品 → 触发风控,标记为可疑设备,后续该 deviceId 所有请求走滑块验证码。

多 SKU 互斥限购(比如手机只能抢一款): 下单时检查 seckill:bought:${activityId} Set 是否已有任意 SKU 的购买记录。有则返回"您已参与过本次活动,每人限购一件"。

限购数据的清理与对账: 活动结束后已购 Set 保留 7 天用于客服查询,7 天后删除。对账时查 SCARD seckill:bought:${itemId} 得到的已购人数应等于 DB 中该商品订单数,不等就查差异。

如果用户申诉"我没买过,为什么显示已购"? 查回滚记录——可能是之前下了单又取消,Redis Set 没清。处理方案:人工确认订单确实取消后,SREM seckill:bought:${itemId} ${userId} + 补偿一个购买资格。

一句话: 购买资格用 Redis Set O(1) 判重,设备指纹防多账号,多 SKU 互斥活动级 Set。限购规则不透传 DB,Redis 一锤定音。


【面试题】9. 热点 Key 怎么自动识别?秒杀里除了商品库存还有哪些隐藏热点? ​

【标准答案】热点 Key 不能等出了问题再人肉找,要自动发现、自动隔离、自动保护。

自动识别方案: 在应用层用滑动窗口统计——每个请求访问的 Redis Key 记录到本地 ConcurrentHashMap<Key, QPS>,每 10 秒汇总一次。QPS 超过阈值(比如单 Key 超过 1000 QPS)标记为热点,上报到 Sentinel 热点规则,触发热点参数限流。也可以直接用 Redis 的 --hotkeys 扫描:redis-cli --hotkeys,但这是离线分析,不能实时发现。

秒杀里的隐藏热点: ①商品详情 Key——不管有没有库存,100 万人都会查这个商品信息,是绝对热点。②秒杀活动配置 Key——每单都读活动开始/结束时间,也是热点。③用户黑名单 Key——风控每次都查,也是热点。④已购用户 Set——每次扣库存前查,同样热点。秒杀活动涉及的所有 Key 几乎全是热点。

热点保护三件套: ①Caffeine 本地缓存兜底——商品基本信息、活动配置这些静态数据本地缓存一份,Redis 扛不住时本地扛。②Redis Cluster 多副本——热点 Slot 增加从节点数量,读请求分流到各 slave。③Sentinel 热点限流——单 Key QPS 超过容量线直接限流返回排队,宁慢不崩。

面试加分的认知: 秒杀场景下,不是"有没有热点"的问题,是"所有 Key 都是热点"的问题。架构要按极端情况设计——假设每个 Key 都会被 10 万 QPS 打,提前铺好本地缓存 + 多副本 + 限流。

一句话: 滑动窗口实时统计发现热点 → 本地缓存 + 多副本 + 热点限流三件套保护。秒杀里没有"正常 Key",全按热点处理。


【面试题】10. 面试常考对比:Kafka、RocketMQ、RabbitMQ 在事务消息能力上有什么本质区别?秒杀下单场景选谁? ​

【标准答案】事务消息的核心诉求——"本地操作"和"发消息"要么都成功,要么都不做。三者在这件事上能力完全不同。

RocketMQ(原生事务消息 ⭐): 半消息机制——Producer 先发一条对消费者不可见的 half 消息,然后执行本地事务(扣 Redis 库存),成功就 commit 消息让消费者可见,失败就 rollback 消息永不投递。一轮 RPC 搞定,代码里加 @RocketMQTransactionListener 注解就行。自带回查机制——Broker 发现 half 消息长时间没 commit/rollback,定期回查 Producer 状态。秒杀"扣库存 + 发订单消息"的原子绑定,RocketMQ 是天选。

Kafka(不支持原生事务消息): Kafka 的事务是"跨分区原子写入",保证多条消息要么都成功要么都不成功——解决的是 Producer 发多个 Partition 的原子性问题,不是"发消息+本地事务"的原子性。秒杀场景下,如果要用 Kafka 保证一致性,只能走本地消息表(outbox pattern)——扣库存的同时写 outbox 表,定时任务扫 outbox 发送到 Kafka。多一张表多一次 IO,可靠性靠自己写。

RabbitMQ(Publisher Confirm + 手动补偿): 没有事务消息概念。靠 channel.confirmSelect() + channel.waitForConfirms() 做发送确认,保证消息到了 Broker。但"发消息确认成功,本地事务失败"这种情况没法自动回滚——消息已经进去了。解决方案是消费者做幂等(查 DB 判重),但消息已经投递了,架构上不干净。

选型结论: 有 RocketMQ → 事务消息,天然匹配秒杀场景。只有 Kafka → 本地消息表 outbox 模式,成本高但可行。只有 RabbitMQ → 小量秒杀用,大量秒杀慎重——事务一致性要自己在业务层补偿。

一句话: RocketMQ 事务消息是"内建的",Kafka 要"外挂 outbox",RabbitMQ 只能"事后补偿"。面试官听完就知道你真的用过,不是背的。