Skip to content

【面试题】1. 秒杀流量漏斗怎么设计?从用户进页面到下单成功,流量怎么一层层被筛掉? ​

【标准答案】流量漏斗的核心思想:让无效请求在越早的层级被拦掉,越省资源。

七层漏斗,数字说话: 假设 100 万人进秒杀页面 → CDN 兜住静态资源,源站只接收 Ajax 动态请求,第一层 CDN 挡掉页面刷新流量,进源站的只有约 30 万 Ajax 请求 → 网关层(IP 限流 + 时间窗口校验)拦掉 50%,剩 15 万 → 应用层前端削峰(按钮防抖 + token 校验)再砍 60%,剩 6 万 → 风控 + 资格校验(布隆过滤 + 频控)拦掉不合格的 80%,剩 1.2 万 → Redis 库存扣减,库存只有 1000 件,剩下来的全返回售罄,1000 个请求进 MQ → 最终 1000 个订单入库。

关键数字: 100 万 UV → 1000 单,漏斗比例 1000:1。每一层职责明确,不做重复校验。网关不做业务,应用不做网关的事。

漏斗可以用漏斗图实时监控: 每层放行量 / 进漏斗总量 = 通过率。某层通过率异常变化(比如风控层突然从 20% 降到 5%),说明那一层出问题了——可能是规则配错。


【面试题】2. 热点库存隔离怎么做?Redis Cluster 的 HashTag 怎么用在秒杀上? ​

【标准答案】一个秒杀商品是绝对的热点 Key。如果它落在 Redis Cluster 的某个节点上,这个节点的 CPU 被打满,其他节点却在看戏——这就是热点不均衡。

HashTag 强制路由: Redis Cluster 默认用 CRC16(key) % 16384 分配 Slot。但你可以用 HashTag 控制路由——{ } 包裹的部分参与哈希,其余忽略。比如秒杀库存 Key 写成 seckill:stock:{hot_item_001},所有包含 {hot_item_001} 的 Key 无论前缀是什么,都落到同一个 Slot。

怎么用? 不是让所有热点挤一个节点,而是反着用——把同一个热点商品的"库存 Key"、"已购用户集合 Key"、"下单 token Key" 用同一个 HashTag 绑定,它们在同一个 Slot 里就能执行 Lua 脚本的多 Key 原子操作。比如:redis.call('decr', KEYS[1]); redis.call('sadd', KEYS[2], ARGV[1]),库存扣了同时标记用户已买。

热点隔离的正确做法: 为秒杀热点商品单独部署一套高配 Redis 哨兵(8 核 32G),不和全站缓存混在一起。这样热点商品打死最多影响秒杀,不影响用户浏览、购物车、推荐等全站功能。架构上叫"热点物理隔离",比在 Cluster 里用 HashTag 凑合强得多。

一句话: HashTag 解决同 Slot 多 Key 原子操作问题,热点隔离靠独立 Redis 实例,不是靠 HashTag 分散热点。


【面试题】3. 布隆过滤器在秒杀资格校验里怎么落地?误判率怎么控制? ​

【标准答案】布隆过滤器在秒杀里最经典的应用:校验用户是否有抢购资格——比如必须注册满 30 天、实名认证、非黑名单用户。

落地方式: 活动前批量把所有"有资格的用户 ID"写入布隆过滤器。用户请求进来时,先 bf.exists seckill:qualified:users userId,返回不存在直接拒绝——"您不符合本次活动参与条件"。存在才继续查 Redis 做频控和库存扣减。这一步把无资格用户拦在扣库存之前,省掉大量 Redis 操作。

误判率控制(关键参数): 布隆过滤器有两个核心参数——error_rate 和 capacity。秒杀场景设 0.001(千分之一)误判率,存 1000 万用户,大概占用 14MB 内存。误判意味着一个没资格的人被判定有资格——他最终也会被后面的 Redis 频控或数据库拦下来,不会多卖。反过来,布隆过滤器不会漏判(说不存在一定不存在),无资格用户 100% 被拦。

为什么不用 Set 存所有资格用户? 1000 万用户 ID 全存 Redis Set 需要几百 MB,而且还有序列化开销。布隆 14MB 搞定,内存差距 20 倍。

加分注意: 布隆过滤器的数据不能删(只增不减),用户资格变动需要重建。秒杀活动前建一次就行,活动期间资格不变。


【面试题】4. 秒杀系统 Redis 怎么配?内存多大、淘汰策略、持久化方式怎么选? ​

【标准答案】秒杀的 Redis 配置和常规业务配置不同,核心诉求是:快、不丢库存数据、扛住尖刺。

内存规划: 秒杀核心数据的量很小——几个商品的库存数字、几千个已购用户的 Set、几十万个 token——500MB 足够。但 Redis 实例配 8GB,留足内存给 fork 子进程(RDB/AOF rewrite)和网络缓冲区。

淘汰策略: 秒杀 Redis 设 maxmemory-policy noeviction——内存满了直接拒绝写入,不淘汰任何 Key。库存数字、已购用户集合这些一条都不能丢,丢了就是事故。如果内存扛不住,加机器,不要用淘汰策略糊弄。

持久化: AOF everysec + RDB 混合。AOF 每秒刷盘一次,最多丢 1 秒数据。RDB 每小时一次全量备份(crontab 触发 BGSAVE),用于快速恢复和冷备。为什么不纯 RDB?因为 RDB 间隔期 Redis 挂了丢的数据太多。为什么不开 always AOF?每操作都刷盘,性能掉一半,秒杀扛不住。

其他硬配置: ①关闭 transparent_hugepage(THP),会导致 fork 慢和内存翻倍,echo never > /sys/kernel/mm/transparent_hugepage/enabled。②tcp-backlog 调到 65535,防止秒杀瞬间连接队列溢出。③timeout 设 300 秒,防止空闲连接占着连接数。

一句话: noeviction 不淘汰数据 + AOF everysec 保数据 + 关 THP 保性能,秒杀 Redis 三件套。


【面试题】5. Caffeine 本地缓存和 Redis 在秒杀里怎么分工?什么数据放本地、什么放 Redis? ​

【标准答案】两级缓存的核心原则:变的数据放 Redis,不太变的数据放本地。

Caffeine(本地缓存)适合放: 商品基本信息(标题、图片 URL、规格参数)、秒杀活动配置(开始/结束时间、限购数量)、熔断开关配置。这些数据一小时不变一次,放本地缓存读一次管一小时,完全不出 JVM。Caffeine 容量设 10000 条,TTL 设 5 分钟,过期自动刷新。

Redis(分布式缓存)适合放: 实时库存数字、已购用户集合、下单 token、用户黑名单。这些数据要么实时在变(库存),要么需要跨实例共享(token 只能一个实例消费一次)。放本地缓存的后果——A 实例扣了 token,B 实例本地缓存里还有,重复下单拦不住。

刷新策略: 本地缓存的数据怎么更新?两种:①被动过期——TTL 到了自动失效,下次请求查 Redis 重建。②主动通知——商品信息修改后发 MQ 广播,各实例的 Caffeine 监听 MQ 消息主动清除对应 Key(invalidate)。

为什么不用 Redis 存一切? 每次查 Redis 都是一个网络 RTT(0.1-0.5ms),一百万次就是 500 秒。本地缓存是纳秒级,性能差距 1000 倍。但本地缓存吃内存,每个实例一份,全量的全站商品数据放本地就 OOM 了。热点数据本地化,长尾数据 Redis 共享——这个平衡就是分层的艺术。


【面试题】6. 秒杀系统做灰度发布和切流,怎么保证上线不出事故? ​

【标准答案】新功能在秒杀当天上线 = 找死。灰度发布做三件事:小流量验证、新旧版本对比、一键回滚。

灰度切流方案: 用 Nginx 或网关做流量染色。通过 Header 里的 version 或用户 ID 哈希来分流——比如 userId % 100 < 10 的用户流量打到新版本,其余走旧版本。更精细的用 Apollo/ACM 配置灰度规则,动态调整灰度比例,不需要重启 Nginx。

灰度监控比全量更敏感: 灰度阶段只看三个指标:版本间的成功率差异、RT 差异、错误率差异。如果新版本成功率比旧版本低 0.5%,立刻暂停灰度。0.5% 看起来小,全量后就是几千个失败订单。灰度监控要分钟级更新,不能在灰度跑了 30 分钟才发现有问题。

切流操作手册(必须提前写好): ①紧急回滚——把灰度比例直接拉回 0%,Nginx reload 生效,全程不超过 30 秒。②版本对照——灰度时新老版本各自打上版本号 tag,日志和监控按版本分组,一眼看出差异。③数据兼容——新老版本可能写不同的字段,新版本写的单老版本要能读,反之亦然。数据库加字段时必须有默认值,不能 NOT NULL 没有 DEFAULT。

最关键的一条: 秒杀上线当天的灰度窗口只放 1% 流量,盯 10 分钟。没有问题再逐步放宽到 10% → 50% → 100%。千万不要"灰度没问题,一把切全量"——1% 没问题不代表 100% 没问题,并发放大后锁竞争、连接池满这些问题 1% 不暴露。


【面试题】7. 秒杀库存预热脚本怎么写?预热失败会有什么后果? ​

【标准答案】缓存预热不是"把数据提前塞进 Redis 就完了",是要保证秒杀开始瞬间 Redis 里有完整的热数据。

预热脚本做什么: ①从数据库加载商品库存到 Redis SET seckill:stock:${skuId} ${totalCount}。②加载用户资格数据到布隆过滤器(如果用了)。③加载商品基本信息到 Redis HSET seckill:item:${skuId} title price image limitPerUser。④加载秒杀活动配置(开始/结束时间、限购规则)。⑤预创建所有用户的 token 待领取池(或至少确认 Redis 数据结构已初始化)。

预热脚本的健壮性设计: ①先检查 Redis 是否已预热(EXISTS seckill:stock:${skuId}),已预热就跳过,防止重复跑覆盖数据。②预热的库存数据从数据库读完后,对比运营配置的总库存数,数量不一致告警。③预热完成后,脚本打一个标记 SET seckill:warmup:done ${skuId} {timestamp},监控系统读这个标记判断预热是否完成。

预热失败会怎样? 最严重的——库存没预热到 Redis,秒杀开始瞬间所有请求 miss,全部穿透到数据库。秒杀 5000 QPS 直接打穿 MySQL,不只是秒杀崩,全站都挂。所以预热脚本必须配监控——秒杀开始前 5 分钟,监控系统自动执行 GET seckill:stock:${skuId},库存为 nil 或 0 立刻 P0 告警,活动延迟。

预热时机: 秒杀开始前 30 分钟执行,太早可能被运维操作覆盖,太晚来不及发现问题。预热完到开始之间,Redis 上的库存数据加个只读锁,防止误操作。


【面试题】8. OpenResty(Nginx+Lua)和 Spring Cloud Gateway 在秒杀网关层怎么选? ​

【标准答案】两个都能做网关限流,但性能和定位完全不同,不是替代关系。

OpenResty: 在 Nginx 里嵌入 LuaJIT,请求还不进 Java 进程就能处理。一台 4 核 OpenResty 做 IP 限流 + 时间校验,轻松跑 5 万 QPS,CPU 不到 30%。用 lua-resty-redis 直连 Redis 做黑名单校验,延迟 0.3ms,比 Java 少一层网络跳转。缺点是 Lua 生态弱,复杂业务逻辑写起来痛苦。

Spring Cloud Gateway: 基于 WebFlux 的响应式网关,和 Java 微服务体系无缝集成。好处是能和 Sentinel、Nacos 零成本打通——限流规则从 Sentinel 控制台动态下发,路由规则从 Nacos 热更新。缺点是跑在 JVM 上,即使 WebFlux 非阻塞,同等硬件跑 5000 QPS RT 就上来了,性能比 Nginx 差 5-10 倍。

秒杀系统的推荐组合(不是二选一): OpenResty 做第一层——IP 限流、时间窗口校验、黑名单拦截、静态 token 校验。Spring Cloud Gateway 做第二层——和 Sentinel 联动,按秒杀商品做热点参数限流,做路由转发和负载均衡。OpenResty 是快刀手,拦 80% 无效流量;Gateway 是指挥官,管剩下的 20% 合法流量的分发。

一句话: 不选 OpenResty "或" Gateway,选 OpenResty "然后" Gateway。分层防御,各自发挥长板。


【面试题】9. 秒杀活动中库存卖完了,怎么通知用户?"已售罄"状态怎么在三端(前端/Redis/数据库)保持一致? ​

【标准答案】售罄不是一个静态状态,是一个会被并发覆盖的临界态——处理不好会出现"先显示售罄又突然有货"的 UX 灾难。

怎么判断售罄? Redis Lua 扣库存脚本返回 0(库存不足)就是售罄。Lua 里判断: local stock = redis.call('get', KEYS[1]); if tonumber(stock) <= 0 then return 0 end。一旦发现库存为 0,额外做两件事:① SET seckill:soldout:${skuId} 1 EX 3600 打一个售罄标记。②返回特定的售罄 code(比如 0 = 售罄,-1 = 活动未开始)。

怎么让用户立即看到售罄? 短期(5 秒内)——前端 Ajax 轮询库存接口,后端返回库存为 0,前端立即显示"已售罄"并置灰按钮。这个轮询接口后端先读 seckill:soldout 标记,命中直接返回售罄,不用走 Lua 扣库存逻辑。中期(秒杀期内)——秒杀页面定时读售罄标记,售罄后停止发起下单请求,减轻 Redis 压力。

万一售罄后有订单取消,库存释放了怎么办? 取消订单回滚库存后,DEL seckill:soldout:${skuId} 清除售罄标记。前端下次轮询发现库存 > 0,重新点亮按钮。这个场景比较少但必须有——否则取消的库存就真浪费了。

数据库怎么对齐? 售罄后异步把数据库库存也同步为 0(或数据库存实际剩余的兜底值)。下一次对账脚本跑的时候,如果发现 Redis 库存 0 但 DB 库存 > 0,查出差异订单人工处理——通常是有回滚没成功的情况。

一句话: Redis 设售罄标记 → 前端轮询感知 → 回滚时清除标记。售罄是"当前库存为 0"的快照,不是永久的墓碑。


【面试题】10. 面试常考对比:本地消息表 vs RocketMQ 事务消息 vs TCC,三个方案在秒杀下单场景下各有什么不可替代的价值? ​

【标准答案】面试官问这个不是让你背定义,是看你有没有"根据资源选方案"的判断力。

RocketMQ 事务消息(最优选,但前提是有 RocketMQ): 半消息机制——先发一条对消费者不可见的 half 消息,然后执行本地事务(扣 Redis 库存),成功则提交消息(消费者可见),失败则回滚消息(消费者永远看不到)。一轮 RPC 搞定"发消息 + 扣库存"的原子绑定。缺点是依赖 RocketMQ 4.3+,RabbitMQ 和 Kafka 原生不支持。

本地消息表(没 RocketMQ 时的救星): 扣库存的同时在本库写一条"待发送消息"记录——INSERT INTO outbox (id, topic, payload, status) VALUES (?, ?, ?, 'PENDING')。定时任务每 100ms 扫表取 status='PENDING' 的记录,发到 MQ 后更新为 SENT。利用数据库的事务保证"扣库存 + 写消息记录"原子,缺点是多了一次数据库写和对业务侵入(每个要发消息的地方都要建 outbox 表)。

TCC(强一致性场景用,秒杀太重): Try 阶段冻结库存(把库存从 available 移到 frozen),Confirm 阶段提交(删除冻结记录),Cancel 阶段回滚(冻结转回 available)。好处是一致性最高,坏处是每个参与方都要实现 TCC 接口、库存表设计要支持"冻结态"、业务代码量翻倍。秒杀里杀鸡用牛刀。

不可替代的价值: RocketMQ 事务消息是"无侵入"的(一行注解搞定),本地消息表是"无中间件依赖"的(任何 MQ 都能用),TCC 是"实时强一致"的(交易场景绕不开)。选哪个取决于:有没有 RocketMQ?允不允许秒级延迟?愿不愿意改表结构?

一句话: 有 RocketMQ 用事务消息,没有就用本地消息表,扣款级别的强一致才上 TCC。三选一是资源约束下的最优解,不是技术鄙视链。