大厂 Java 面试实录:谢飞机挑战电商秒杀系统(Redis、Lua、Kafka 与幂等设计)
场景:某互联网公司的电商平台正在招聘高级 Java 工程师。面试围绕秒杀活动展开,从基础接口设计逐步深入到并发、缓存、消息队列和故障恢复。
面试官姓严,表情严肃。候选人谢飞机坐下后,先把简历往桌上一推。
“严老师,我做过很多高并发项目。”
面试官看了一眼简历:“你简历上写着,最高并发是同时三个人点外卖?”
谢飞机:“对,三个人都没抢到,是因为我忘了点支付。”
第一轮:先把业务说清楚
问题一:设计秒杀接口
**面试官:**用户点击“立即抢购”,后端需要提供什么接口?你会怎样设计?
**谢飞机:**我会做一个POST /seckill,传商品 ID 和用户 ID,然后查一下库存,库存大于零就创建订单。为了防止用户重复点,可以在按钮上加个 loading。
**面试官:**接口方向是对的。前端限制重复点击可以改善体验,但不能承担服务端防重复的职责。攻击者可以绕过页面直接发请求。服务端要校验用户身份、活动状态、购买资格和幂等性。
谢飞机点点头:“明白,前端按钮负责礼貌,后端校验负责法律。”
问题二:如何防止超卖
**面试官:**多个请求同时读取到库存为 1,再各自创建订单,会发生什么?如何避免超卖?
**谢飞机:**大家都看到库存还有 1,就都下单了,最后库存可能变成负数。可以加锁。
**面试官:**判断正确。具体会用什么锁?锁的范围多大?数据库更新能否通过条件语句保证原子性?
谢飞机:“可以给整个数据库加把锁,这样谁也超不了。”
面试官停顿片刻:“锁整个数据库确实很有约束力,问题是其他商品也不用卖了。”
问题三:为什么不能只依赖数据库
**面试官:**如果秒杀瞬间涌入大量请求,每个请求都直接访问数据库,会有哪些风险?
**谢飞机:**数据库可能扛不住,连接池耗尽,查询变慢,订单也写不进去。可以多买几台数据库。
**面试官:**你抓到了连接池耗尽和数据库过载的问题。扩容有帮助,但成本高,也不一定能跟上突发流量。我们还需要在请求到达数据库之前做流量控制。
谢飞机:“那数据库前面放个保安,让它少接点客人。”
面试官:“可以这么理解,但保安还得知道库存和排队规则。”
第二轮:进入高并发设计
问题四:Redis 扣减库存
**面试官:**如果把秒杀库存放进 Redis,怎样做到并发扣减时不超卖?
**谢飞机:**先GET库存,判断大于零,再DECR。负数了就加回来。
**面试官:**这两步之间存在并发窗口。两个请求都可能先读到 1,然后都执行扣减。补偿加回也会引入复杂状态。可以把判断和扣减放在一个原子操作里,例如 Redis Lua 脚本。
谢飞机:“Lua 我不太熟。不过我知道 Lua 有月亮,秒杀一般晚上搞,挺匹配。”
面试官:“语言名字匹配不是技术方案。”
问题五:为什么要引入消息队列
**面试官:**Redis 扣减成功后,是不是马上同步写数据库、创建订单、调用积分服务和短信服务?
**谢飞机:**同步做最简单。失败了就重试,重试到成功为止。
**面试官:**同步链路越长,用户等待越久,任意一个下游服务变慢都可能拖垮入口。通常会先快速完成资格检查和库存预扣,再通过 Kafka 或其他消息队列异步创建订单。你要考虑消息重复、消费失败和状态不一致。
谢飞机:“那用户点完以后先告诉他抢到了,后面再看能不能真的抢到?”
面试官:“不能把‘预扣成功’说成‘订单成功’。可以明确返回处理中,等订单创建结果再更新状态。”
问题六:消息重复消费怎么办
**面试官:**Kafka 的消息可能重复投递。订单消费者如何避免重复创建订单?
**谢飞机:**给消费者加一个if,查一下有没有这个订单,有的话就不创建。
**面试官:**方向正确,但只做先查后写也有并发竞争窗口。幂等性最好由稳定的业务键和数据库唯一约束共同保证。消费者收到重复消息时,发现唯一键冲突,就可以按“已处理”完成确认。
谢飞机:“所以数据库唯一索引像门票,一张票只能进一次场。”
面试官:“这个比喻可以。还要注意:唯一约束防的是重复订单,不会自动解决所有消息和库存一致性问题。”
第三轮:可靠性与故障恢复
问题七:库存预扣后服务崩溃
**面试官:**Redis 库存预扣成功了,但应用在发送 Kafka 消息之前崩溃,会发生什么?怎样补救?
**谢飞机:**库存扣了,订单没了,用户的钱……还没付,所以用户应该没损失。
**面试官:**用户可能抢不到库存,但系统也不知道这次预扣需要继续处理还是释放。需要记录请求状态,并设计超时检查和补偿机制;更严格的一致性方案可以使用 Redis Stream、可靠消息表或事务消息等模式。
谢飞机:“那我定时扫 Redis,把扣过库存但没下单的人加回去。”
面试官:“思路是补偿,但不能只凭‘没查到订单’就加库存。还要区分消息延迟、订单处理中、订单创建失败等状态,否则可能把别人的库存加回来。”
问题八:服务超时与重试
**面试官:**订单服务调用库存服务超时,你会怎样处理?重试是不是越多越好?
**谢飞机:**超时就重试十次,十次还不行再重启服务器。
**面试官:**无限重试会放大故障。应设置合理超时、有限重试和退避策略,并对可恢复与不可恢复错误进行区分。重试还必须有幂等保障,否则一次超时可能导致请求实际上已成功,再重试就重复扣库存。
谢飞机:“那就是服务已经做完了,但消息还没来得及告诉我,我又让它做一遍。”
面试官:“对,这就是分布式系统里很常见的‘结果未知’问题。”
问题九:如何监控秒杀链路
**面试官:**活动开始后,你关注哪些指标?如果用户反馈“抢到了却没订单”,怎么排查?
**谢飞机:**先看服务器是不是红了,再问用户是不是点错了。
面试官:“服务器颜色和用户操作都不是指标。”
谢飞机:“那我看错误日志。”
面试官:“日志有用,但还要能沿着同一个请求追踪全链路。至少要观察请求成功率和延迟、Redis 扣减结果、Kafka 积压、订单创建耗时、数据库连接池使用率,以及超时和补偿数量。用 traceId 串联入口、消息和订单服务,排查才有依据。”
谢飞机:“我之前日志里只有‘出错了’,traceId 是不是就是给错误起个名字?”
面试官:“更准确地说,是给一次跨服务请求一个可关联的标识。”
面试结束
面试官合上笔记本:“今天先到这里。你对接口、并发和幂等有一些基础认识,但消息可靠性和故障恢复还需要继续梳理。”
谢飞机:“那我算抢到 offer 了吗?”
面试官:“你这次只是完成了预扣。”
“那订单什么时候创建?”
“回家等通知。”
参考答案:从入口到订单的完整思路
1. 秒杀接口:用户身份与业务幂等
秒杀接口不应信任客户端传来的用户 ID。用户身份应从登录态或访问令牌中获取;商品、活动和购买资格也要在服务端校验。
可以为一次购买生成稳定的业务幂等键,例如“活动 ID + 用户 ID”。同一用户重复提交时,系统应识别为同一笔业务,而不是创建多个订单。
public record SeckillRequest(Long activityId, Long skuId) {} public record SeckillResponse(String requestId, String status) {} @PostMapping("/seckill") public SeckillResponse seckill( @AuthenticationPrincipal AppUser user, @RequestBody SeckillRequest request) { String requestId = seckillService.submit( user.id(), request.activityId(), request.skuId()); return new SeckillResponse(requestId, "PROCESSING"); }示例返回PROCESSING,是因为库存预扣和订单落库可能分属不同步骤。接口需要明确表达当前状态,不能把“进入异步处理”误报成“订单已创建”。
生产实现还要校验活动时间、商品状态、用户限购、参数合法性和权限,并对请求进行限流。
2. 数据库防超卖:条件更新优先于先查后改
单纯执行“查询库存,再修改库存”不是原子操作。并发请求可能同时读到相同库存。
数据库可以使用带库存条件的原子更新:
UPDATE sku_inventory SET available = available - 1 WHERE sku_id = ? AND available > 0;应用检查受影响的行数:
- 更新 1 行:扣减成功。
- 更新 0 行:库存不足。
这种方法能避免库存被扣成负数。它仍然需要考虑数据库写入压力、事务边界和订单创建的整体一致性。对于高峰流量,通常不会让所有请求都直接打到数据库。
3. Redis Lua:原子检查并扣减
Redis 的单条命令具有原子性,但“读取、判断、扣减”由多条命令组成时,整体不是原子操作。Lua 脚本可以把这段逻辑放进 Redis 的一个原子执行单元:
local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock <= 0 then return 0 end redis.call('DECR', KEYS[1]) return 1Java 侧执行脚本时,传入库存键。返回1表示预扣成功,0表示库存不足。
这个脚本解决的是 Redis 中的并发扣减问题,并不自动保证 Redis 和数据库之间永远一致。应用还需要处理 Redis 故障、消息发送失败、进程崩溃和超时等情况。
4. 消息队列:削峰与异步处理
高峰请求进入系统后,可以将处理拆为几个阶段:
- 校验活动、用户和商品资格。
- 使用 Redis 原子预扣库存。
- 生成稳定的业务请求 ID。
- 将订单创建事件可靠地交给消息系统。
- 消费者异步创建订单,并更新请求状态。
- 前端查询状态,或通过推送机制接收结果。
消息队列可以削峰并解耦服务,但它不意味着“消息一定只投递一次”。消费者仍需要应对重复消息、消费失败和消息积压。
5. 幂等消费:业务键加数据库约束
消费者可以用请求 ID 作为订单业务唯一键,并在数据库中设置唯一索引:
CREATE TABLE seckill_order ( id BIGINT PRIMARY KEY, request_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, status VARCHAR(24) NOT NULL, created_at TIMESTAMP NOT NULL, UNIQUE KEY uk_request_id (request_id) );消费消息时,即使收到重复事件,也只允许同一个request_id创建一笔订单。遇到唯一键冲突后,消费者应确认这条消息已完成处理,而不是无限重试。
如果“查是否存在,再插入”而没有唯一约束,两个消费者并发处理同一业务时仍可能同时通过检查。因此,数据库唯一约束是幂等设计的重要保障。
6. 故障恢复:记录状态,再做补偿
库存预扣后,如果消息没有可靠送达,系统需要知道这笔请求处于什么阶段。常见做法包括:
- 记录请求状态,如
RESERVED、ORDER_CREATED、FAILED。 - 为处理过程设置超时与重试策略。
- 定期扫描超时请求,核对订单和消息处理状态。
- 确认订单无法创建后,再执行库存释放。
- 为释放操作本身设计幂等键,避免多次补偿导致库存增加过量。
补偿不是简单地“没订单就加库存”。订单可能仍在处理中,消息也可能只是延迟。补偿前需要明确状态并确认操作可重复执行。
7. 超时、重试与熔断
调用下游服务时应设置合理的连接和读取超时。对短暂故障可进行有限重试,并采用退避策略,避免所有请求在同一时刻集中重试。
重试之前要回答两个问题:
- 错误是否值得重试?例如参数错误一般不值得重试。
- 操作是否幂等?如果一次请求实际上已成功,只是响应丢失,再执行一次是否会重复扣库存或创建订单?
对于持续失败的依赖,可以使用 Resilience4j 等工具配置熔断、隔离和限流。熔断的目标是阻止故障扩散,不是让失败请求自动变成功。
8. 可观测性:指标、日志和链路追踪各有用途
秒杀链路至少应关注:
- 接口请求量、成功率、错误率和延迟分位数。
- Redis 库存预扣成功、失败和异常数量。
- Kafka 消息积压、消费延迟和重试次数。
- 订单创建成功率与耗时。
- 数据库连接池使用率和慢查询。
- 超时请求、库存补偿和状态长期未完成的数量。
日志用于记录具体事件,指标用于发现整体趋势,分布式链路追踪用于关联跨服务调用。三者结合,才能从“用户没收到订单”继续定位到是入口校验、Redis 预扣、消息投递还是订单消费出了问题。
面试要点总结
秒杀系统的关键不是单独使用 Redis、Kafka 或数据库,而是明确每个阶段的状态与责任:数据库通过条件更新保护最终库存;Redis 原子操作承接高并发预扣;消息队列负责异步削峰;幂等键和唯一约束阻止重复订单;超时扫描与补偿处理不完整流程;指标、日志和链路追踪帮助定位生产问题。
面试时,与其只回答“加锁、上缓存、上消息队列”,不如说明并发窗口在哪里、失败后状态是什么、重试会不会重复执行,以及系统如何恢复。