这道题我在面试中问过别人,也在模拟面试里被人追着问过。刚开始我以为"缓存加速秒杀系统"考的是 Redis 命令背得熟不熟,后来才明白,面试官真正想看的,是你面对瞬时高并发时,脑子里有没有一张完整的流量治理地图。你背一百个缓存知识点,不如讲清一个"流量到了数据库之前怎么被挡住"的逻辑。
秒杀系统的核心痛点其实就一句话:短时间内的海量请求,落在有限的处理资源上。缓存不是用来"加速"某个查询的,它是用来改变请求的路径——让绝大多数流量在最前端就被消化掉,只有少量真正有效的请求才继续往后走。这篇文章我不打算按教科书目录给你一条条过 Redis 八股文,而是从一道真实面试题的追问链出发,把一个能过关的回答长什么样、每一步背后的理由是什么,完完整整拆给你看。
1. 秒杀系统的压力到底在哪个环节:缓存该在哪一层做事
很多人一开口就说"秒杀用 Redis 缓存商品数据",这句话没错,但层级太低。面试官听完只会觉得你还是个只会写增删改查的选手。要回答到位,得先算清楚一笔账:一场秒杀,系统到底在承受什么。
假设一个商品 10000 件库存,开售瞬间涌入 500 万请求。这 500 万请求里,真正能买到货的最多 10000 人,剩下的 499 万都是陪跑。数据库其实只需要处理 10000 次真实的库存扣减和订单创建,让 499 万次请求直接打到 MySQL,是对数据库最大的浪费。
我们把这一层的账算得更细一点。单台 MySQL 在简单查询下大概能扛 2000 到 5000 QPS,你上主从、分库分表也就撑到几万。而 500 万 QPS 的瞬时流量,如果不做拦截,数据库在零点零几秒内就会被压垮。Redis 单实例的读能力能到 10 万 QPS 以上,三五台 Redis 集群,读 QPS 轻松过百万——所以缓存在这里承担的不是"加速"职责,而是"流量衰减器"的职责:把 500 万请求衰减成 10000 个有效请求再放行到后端。
理解了这一层,你就会明白为什么缓存方案不是"加上就行",而是要在不同位置放不同职责的缓存:
| 请求路径 | 缓存载体 | 核心职责 |
|---|---|---|
| 浏览器/客户端 → 接入层 | CDN / 浏览器本地 | 拦截静态资源与页面请求 |
| 接入层 → 应用层 | Redis / 本地缓存 | 拦截热点商品元数据的重复查询 |
| 应用层 → 库存服务 | Redis(Lua 原子操作) | 拦截超量扣减,控制放行数量 |
| 库存服务 → 数据库 | 消息队列 / 异步批量 | 把有效请求落库,避免瞬时写入压力 |
这张表就是整个回答的骨架。每一层都在做同一件事:用更便宜的存储系统,去处理更大量的请求,只让极小比例进入下一层昂贵的系统。你在面试里把这张表画出来,面试官基本就知道你不是背题的。
2. 从"挡流量"到"管库存":缓存加速的五个关键落点
面试官让你"用缓存加速秒杀系统",如果你只讲"商品详情放 Redis",大概只会得到一句"还有呢"的追问。真正能拿高分的回答,是把缓存用到秒杀链路的每个关键节点上。我按请求从用户到数据库的完整链路,给你拆五个落点。
2.1 第一落点:页面和静态资源——拦住第一波无效流量
秒杀页面 90% 以上的内容(商品图、标题、参数、按钮样式)根本不变化,没必要让每个用户都打到应用服务器。这部分交给 CDN 和浏览器缓存就够了。页面静态化之后,秒杀页面本身的请求压力已经与应用系统无关。
但这里有个容易忽略的细节:秒杀开始前后的页面状态不同。开始前是"等待中",开始后是"立即抢购"。如果 CDN 缓存了整个页面,用户开售时看到的还是"等待中",体验就很差了。所以真正做法是:主体静态资源走 CDN 缓存,但抢购按钮的状态、倒计时、剩余库存数这些动态元素,通过异步接口从 Redis 读取。这就是不少团队说的"静态页面 + 动态数据分离"。面试里提到这一层,马上显得你处理过真实大促页面,而不是只在本地写完 Demo。
2.2 第二落点:商品信息的读缓存——消除重复查询
用户点击抢购后,后端第一个动作往往是要拿到商品信息、活动配置、库存状态。这三类数据有个共同特征:秒杀期间几乎不变化。商品信息在活动开始前就固定了,活动配置是运营配好的,库存状态虽然变,但没必要让每个用户都实时查库。
实战里的做法是:活动开始前把商品信息、活动配置预热到 Redis,用内部约定的 JSON 结构存储;查询时直接 Redis 读取,命中率能到 99.9% 以上。缓存 Key 的设计要注意区分活动维度,比如seckill:activity:{activityId}:item:{itemId},同时配合本地缓存(如 Caffeine)再做一层进程内命中,避免多个用户同时请求同一 Key 时全部穿透到 Redis。
这一层其实已经有"缓存预热 + 多级缓存"的味道了。但你要是只讲到这,面试官会问下一个问题——所有请求都去 Redis 抢库存,Redis 扛得住吗?这就引出第三个落点。
2.3 第三落点:库存预扣与原子扣减——把 Redis 当成库存的"总闸门"
秒杀最核心的诉求是"不能超卖"。通常的做法是:数据库扣减之前,先在 Redis 里完成名额预扣。Redis 里存一个 Key 记录剩余库存,每次请求进来用原子操作扣减,扣减成功的请求才允许进入后续下单流程,扣减失败的直接返回"已售罄"。
这里我会给你一段几乎所有秒杀系统都绕不开的 Lua 脚本:
-- KEYS[1]: 库存key, ARGV[1]: 扣减数量, ARGV[2]: 最大库存 local stock = tonumber(redis.call('get', KEYS[1])) if not stock then return -1 -- 库存key不存在,未初始化或已删除 end if stock < tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call('decrby', KEYS[1], ARGV[1]) return 1为什么必须用 Lua?因为"查库存 + 扣库存"是一个典型的复合操作,如果不打包成原子脚本,在高并发下两个请求同时读到剩余库存为 1、同时通过判断、同时扣减,最终结果就是库存变负数——超卖。Redis 的 Lua 脚本执行期间不会有其他命令插入,脚本整体的原子性天然解决了这个问题。给 Redis 打了个形象的比方:Lua 脚本就是给 Redis 的"手术操作"——整台手术一个医生做完,中间不会有别人来抢手术刀。
库存预扣完成后,已经拦掉了绝大多数无效流量。但你以为这就完了?还没。因为所有"预扣成功"的请求如果不加限制,可能 20000 人预扣成功了,结果只有 8000 人真正完成了支付下单——剩余库存被占用,其他人又买不了,活动就"假热实冷"了。所以后面两个落点更重要。
2.4 第四落点:异步落库与队列削峰——让数据库从容收尾
预扣成功不代表订单立刻写库。秒杀链路的正常状态应该是:Redis 把有效请求放行后,请求进入消息队列(如 RabbitMQ / 消息队列 RocketMQ),由消费端异步批量创建订单、扣减数据库库存。
这一步有两个目的。第一是削峰填谷:数据库的写入能力是固定的,比如每秒 1000 条订单,但 Redis 放行的请求可能是每秒 5000 次,直接落库必然打满数据库连接。通过消息队列,流量变成均匀的 1000/s 输出,数据库始终在自己的能力上限内工作。第二是解耦:下单时还要做风控校验、积分计算、渠道通知一堆事,这些都可以异步做,用户不需要同步等待。
最关键的细节是消息的幂等性。同一个用户反复提交、或者消费端处理失败重试,都可能产生重复订单。所以消费端一定要有幂等校验(比如用户ID+活动ID+商品ID作为唯一键,查重后再插入订单)。这一条你在面试中主动讲出来,属于"这人是真的处理过线上丢消息、重复消息"的信号。
2.5 第五落点:防重与限流前置——把"无效流量"挡在业务判断之前
最后一个落点很多答案不会提,但它决定了整个系统能不能撑住:限流和防重必须前置到最前面。用户点击抢购按钮,第一道关卡不是查库存,而是:
- 接口限流:对用户维度做限流,比如每个用户每秒最多 1 次抢购请求、每个 IP 每秒最多 5 次,超出直接返回"操作太频繁"。
- 防重校验(防抖):同一用户同一场活动只能抢一次。这个状态在用户第一次进活动页面时就可以写入 Redis(比如记录 userId + activityId 的集合),后续请求重复则直接拦截。
限流和防重为什么算"缓存加速"的一部分?因为它们的目的同样是"减少达到后端业务逻辑的请求量"。限流拦截恶意刷单和脚本请求,防重拦截用户自己反复点击产生的冗余请求。你想想,500 万请求里至少 200 万是同一个人反复点的,不前置挡掉,后面的缓存和队列挡得也没意义。
到这里,五个落点闭环了。但多数候选人在第五个落点说完时,会因为"方案看起来很完整"而放松,恰恰此时面试官会开始真正的追问。
3. 追问陷阱:缓存穿透、击穿、雪崩在秒杀场景的真身
面试官听到你完整讲完五个落点,大概率会微笑着来一句:"那缓存雪崩、击穿、穿透这三个问题,你在秒杀场景里怎么防?"很多人就开始背八股:穿透用布隆过滤器、击穿用互斥锁、雪崩用随机过期时间。背得没错,但不够。你要能说出来这三个问题在秒杀场景里是具体长什么样的。
3.1 缓存穿透:不是"查不到",而是"一直有人查不存在的东西"
秒杀里的穿透场景比较特殊。正常商品不会出现"查了缓存没有、查了库也没有"的情况——商品数据是预热好的。真正的穿透发生在上架前或活动结束后:比如用户凭着老链接反复请求一个已经下架的商品,缓存里没有,数据库里也没有,每一次请求都直接打到数据库。如果此时正好是活动后的回访流量小高峰,数据库白白被无效查询拖累。
防御手段我的排序是:参数合法性校验优先,布隆过滤器兜底。商品ID格式、活动ID是否存在、活动是否在有效期内,这些前置判断能拦掉 90% 的穿透请求。布隆过滤器只用来兜底那些前置拦截过滤不掉的随机碰撞请求。为什么不无脑上布隆?因为它存在误判率,而且秒杀场景的 Key 数量不大,完全不值得为它增加一层维护复杂度。
3.2 缓存击穿:热点 Key 失效的那几秒钟,才是生死关
秒杀场景的击穿和教科书定义不完全一样。经典击穿是"某个热 Key 失效瞬间大量请求同时打到数据库",秒杀里的热 Key 主要指库存 Key。如果库存 Key 因为过期时间到了或者 Redis 内存淘汰策略把它清掉了,后续请求全部打到数据库查库存,结果就是——你以为有缓存保护,其实已经裸奔了。
秒杀库存 Key 的防击穿和普通场景不一样,它不应该设置过期时间。库存 Key 的生命周期跟活动绑定:活动开始前预热,活动结束后删除。你设了个 10 分钟过期,活动还有 20 分钟,中间就有一阵子库存 Key 是裸奔的,这就是自己给自己埋雷。如果你实在担心内存长期占用,那就用"逻辑过期":代码里存一个过期时间字段,读取时自行判断,而不是依赖 Redis 的 TTL。反正记住一句话:秒杀场景的库存 Key,别用 TTL,用逻辑生命周期管理。
3.3 缓存雪崩:不是缓存集体失效,是 Redis 整个不可用
秒杀场景里雪崩往往不是"缓存设置了相同过期时间",而是Redis 节点宕机或网络抖动。Redis 作为秒杀系统的核心"总闸门",一旦不可用,所有流量以原始形态涌向数据库和订单服务,那才是真·雪崩。
面试中建议这样回答,防御分三层:
- Redundant 层:加一个多级降级策略,Redis 挂了自动切到本地缓存 + 数据库直连的降级逻辑。库存扣减降级成数据库乐观锁扣减,虽然性能差但保证核心功能还能用。
- 隔离层:秒杀 Redis 集群必须与日常业务 Redis 物理隔离。不然日常业务的一个慢查询拖垮了共享 Redis,秒杀跟着全崩,这就是典型的"一个脏数据害死全链路"。
- 预案层:提前准备好"限流熔断阈值"——当 Redis 访问异常率达到一定比例(比如 5%),立刻启动该活动软关闭开关,只保留少量灰度流量维持观察,不硬扛。
这三个层次是教科书不教的,但面试官听了会眼睛一亮——因为这是运维事故之后复盘出来的东西。
4. 库存一致性:缓存与数据库的账怎么才算对得上
你以为讲了防穿透防击穿就能过关?太天真了。面试官还有一张王牌——"你的库存先扣 Redis 后扣数据库,如果数据库扣减失败了,两边不一致怎么办?"这个问题答不好,前面讲的再好也白搭。
库存扣减链路里,Redis 和数据库不是关系型数据库主从复制那样的强一致关系,而是"前端预扣 + 后端确认"的最终一致模型。核心机制是三段式闭环:
- Redis 预扣:扣减 Redis 库存,标记该用户"抢到名额"。这里只是占坑,不代表订单创建成功。
- 异步落库确认:MQ 消费端创建订单、扣减数据库库存。
- 对账补偿:如果数据库扣减失败(比如数据库连接超时、唯一键冲突),订单创建失败,消费端必须回补 Redis 库存,把名额释放出来给其他用户。
这里的难点是第 3 步的可靠性和一致性。消费端处理完数据库扣减后,不管成功失败都要回写一个结果给另一个"对账组件"。对账组件扫描"已预扣但未最终确认"的用户列表,超时未确认的一律回补库存。整个过程类似预授权 + 确认 + 退款。
面试中你把这三段式说出来,然后主动补一句:"极端情况下我们会用对账任务兜底,比如每分钟跑一次,把超时未支付的预扣名额释放回库存池。"面试官就知道你对分布式事务的"最终一致性"是真的理解,而不只是会背 CAP 定理。
另外一个容易翻车的细节是:浏览器端的秒杀按钮状态、Redis 里的预扣库存、数据库里的真实库存,三者数量天然不同。浏览器显示"已抢到"不代表订单已生成;Redis 库存减少不代表数据库库存已扣。面试时切忌把三个状态混为一谈,这就像把账面的钱和口袋里的现金画等号一样,是做系统设计的大忌。
5. 我给面试官的一段完整回答范式:Lua + 异步 + 对账怎么串起来
讲了这么多拆解,最后我给你一个可以直接在面试里用的"从题目到答案"的 3 分钟回答结构。这个结构我总结成"一个中心,两个分岔,一个兜底"。
一个中心:开篇先讲清楚,缓存加速秒杀系统的本质不是"让系统更快",而是"让流量在系统中的分布更合理"。先把总量算清楚(X 万 QPS、Y 库存),马上就能推导出必然要用 Redis 裁剪流量。
两个分岔:分岔一是"读"——商品与活动信息的缓存预热、多级缓存、消灭重复查询;分岔二是"写——库存预扣的 Lua 原子脚本、MQ 异步落库、削峰。
一个兜底:最后讲一致性兜底——预扣/确认/回补的三段式闭环、超时对账任务。然后说一句"最后一步是监控告警和降级预案",收尾利索,不拖泥带水。
我给你一段实际可用的伪代码串一下这个方案,面试时可以边说这段话边画这个流程:
// 秒杀入口伪代码 public Result seckill(SeckillRequest req) { // 1. 前置:用户限流 + 防重判断 if (!rateLimit(req.getUserId())) return Result.tooFrequent(); if (killRedis.hasAlreadyKilled(req.getUserId(), req.getActivityId())) { return Result.duplicate(); } // 2. 预扣库存(Redis Lua 原子操作) Long stockResult = redisExecutor.executeSeckillLua(stockKey, 1); if (stockResult == null || stockResult <= 0) return Result.soldOut(); // 3. 标记用户占坑成功(防重标记) killRedis.markKilled(req.getUserId(), req.getActivityId()); // 4. 丢进 MQ 异步落库(真正创建订单、扣减 DB 库存) killProducer.sendKillOrderMsg(req); // 5. 返回"抢购成功,等待确认" return Result.accepted(); }这端伪代码不用真的写出来,面试时你边讲边在纸上勾勒就行。它的目的是让面试官直观看到:Redis 只负责两层事——"你是不是真用户"和"库存名额是否还有",业务确认全部异步化,数据库永远只在可控负载下工作。
6. 从面试回到工程现场:这套方案上线时踩过的三个教训
文章最后,分享几个我在学习和复盘别人秒杀方案时印象最深的教训。它不是观感层面的小贴士,而是真实影响过线上稳定性的那种。
第一个教训:Lua 脚本重则 Redis 单线程卡顿,别把所有逻辑都塞进去。很多团队尝到 Lua 原子性的甜头后,恨不得把活动上架、状态流转、日志上报全写进脚本,结果脚本执行时间一长,Redis 单线程模型下其他命令全线排队,秒杀还没开始,Redis 自己先被打成慢查询。Lua 脚本只干"读 + 判断 + 写"的核心三件事,别贪多。Redis 的原子性不等于"把所有事都塞进一个脚本",而是"核心复合操作必须拆分干净"。
第二个教训:库存 Key 的预热时机足够重要。活动当天早上预热和活动前 5 分钟预热,效果天差地别。预热太早,库存 Key 在内存里白白占用不说,还可能被淘汰;预热太晚,用户已经点进页面看到"暂无库存"了。我见过最稳妥的做法是:活动开始前 10 分钟预热 + 小程序端提前拉倒计时,到点自动刷新按钮状态,这样既避免备货信息泄露,又保证库存 Key 全程新鲜。
第三个教训:兜底对账不能只出现在答辩稿里,要真写定时任务。很多人讲方案时漂亮,一到实现就只做"Redis 预扣 + MQ 落库"两步,落库失败的场景完全没处理。结果就是一场活动下来,Redis 卖了 10000 件,数据库只创建了 9500 单——500 个用户倒霉。对账任务以 1 分钟为周期去拉"未确认"的预扣记录,把超时记录回补库存,这步写到调度平台里,才算闭环。
回头看这道面试题,它表面在考缓存,实际上在考你能不能把一个极端流量场景抽象成"分层裁剪 + 异步消化 + 最终兜底"的系统思维。第一层 CDN 挡住页面流量,第二层 Redis 挡住商品查询,第三层 Lua 脚本挡住库存超卖,第四层 MQ 削峰,第五层对账兜底。把这个模型讲透了,面试官能追的问题你已经提前想到了,这比多背十个 Redis 命令有用得多。