小米秋招服务端主观题复盘:秒杀、幂等与分布式锁的坑
2026/9/8 6:20:26 网站建设 项目流程

1. 主观题背后的能力模型:小米在筛选什么样的“稳定器”

刚看到“小米2018秋招服务端工程师主观题合集”这个题目的时候,我第一反应是“老古董了”。但真当我静下心把这几道题过了一遍之后,反而觉得后背有点发凉——这哪是什么面试题,这分明是每个服务端工程师在线上踩过无数坑之后,才会沉淀出来的“肌肉记忆”。

那会儿互联网大厂的秋招主观题,特别喜欢考“场景设计”。不给你标准答案,就给你一个特别容易出问题的业务场景,然后看你怎么接。比如让你设计一个秒杀系统,或者让你聊聊客户端与服务端的数据一致性怎么保证。当时很多应试者觉得这是在刁难人,现在回头看,小米的主观题其实是在帮候选人画像:他们要的并不是一个只会写CRUD、只会调接口的工具人,而是一个能对系统稳定性负责、能在极端流量下保持脑子清醒的“稳定器”。

为什么这么说?因为服务端工程师和前端、客户端工程师最大的区别在于,客户端出了问题,用户骂的是App卡;服务端出了问题,用户骂的是整个公司。你永远在幕后,但又永远在事故的第一现场。主观题里那些看起来“不痛不痒”的小问号,比如“接口超时了怎么办”“MQ重复消费了怎么处理”“客户端拿到了过期的路由配置怎么兜底”,其实都是线上真实事故的高发点。小米把这些东西拿到考场上,就是想看看你有没有那根“弦”。

1.1 拆解主观题常见考察维度

2018年那套题如果我没记错,大体分三个维度:可用性、一致性、扩展性。

可用性问的是“你怎么保证服务不挂”,一致性问的是“数据没错乱”,扩展性问的是“流量翻倍了你怎么办”。这三个维度正好对应服务端工程师的三个成长阶段:初级保证能跑,中级保证不崩,高级保证优雅。所谓“优雅”,就是你不仅要把功能做出来,还得把异常路径、边界条件、降级方案都想清楚。

我见过很多简历上写着“熟悉高并发”的候选人,一聊到具体方案就露馅。比如一提秒杀就说“用Redis”,但你再追问一句“Redis挂了怎么办”,他就开始支支吾吾。主观题最大的价值就在这里——它不是考你背没背过八股文,而是考你在那种“信息不全、时间紧张、非黑即白”的状态下,能不能给出一个逻辑自洽、可落地验证的解决方案。

1.2 服务端认证的“反直觉”逻辑

另一点很有意思,小米主观题里经常穿插一些“反直觉”的设计题。比如服务端接口测试,很多候选人以为就是把接口调通、返回200就完事了。真正写过服务端测试的人会知道,服务端最难测的不是“正常流程”,而是“异常流程”。客户端断网重连了怎么办?数据库超时了怎么办?下游服务返回了一个超大的JSON导致内存溢出怎么办?

这背后的逻辑是:服务端工程师的核心价值,不在于你让正确的事情发生,而在于你让错误的事情不发生。一个接口能跑通,那是基本功;一个接口在极端情况下不拖垮整个系统,那才是功力。主观题想筛选的,就是那些能在脑海中预演“各种死法”的工程师。

2. 真题复盘一:秒杀减库存,为什么你的分布式锁会超卖

2018年小米秋招主观题里,有一道题我印象特别深刻,后来也经常拿来给团队新人做培训,大意是:“一个商品库存只有10件,但有1万人同时抢购,你怎么设计服务端接口保证不超卖?”

很多候选人一看这题就乐了,这不简单吗?加锁啊!于是脱口而出“用synchronized”。但这是单机锁,放到集群环境里根本不生效。接着又有人反应过来,说“用分布式锁,用Redis的setnx”。这时候我会追问一句:“然后呢?”然后很多人就卡住了。

其实这道题背后,隐藏着一个巨大的深坑:Redis分布式锁本身并不能保证绝对安全。

2.1 场景复现与常规解法

我们先看最基本的实现。库存扣减,很多人会写成这样:

// 伪代码:不推荐的生产写法 public Boolean deductStock(Long skuId, Integer num) { String lockKey = "lock:stock:" + skuId; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1"); if (!locked) { return false; // 没拿到锁,直接返回失败 } try { int stock = stockMapper.selectStock(skuId); if (stock < num) { return false; } stockMapper.deductStock(skuId, num); return true; } finally { redisTemplate.delete(lockKey); // 释放锁 } }

看着似乎没什么问题,实际上至少有三个坑。第一个坑:没有设置过期时间,如果服务在try块里抛异常宕机了,锁永远不会释放,后续所有请求全部失败,这就是死锁。第二个坑:即使加了过期时间,比如设置10秒过期,但业务执行超过了10秒,锁自动过期了,另一个线程又获取到了锁,两个线程同时执行扣减,依然会超卖,这就是锁失效。第三个坑:线程A删锁的时候,可能把线程B的锁给删了。因为线程A执行超时锁已过期,线程B拿到了锁,然后线程A finally里执行delete,把线程B的锁删掉了。

2.2 锁失效与超卖问题:RedLock的门道

针对上面第三点,最简单的处理办法是:在设置value的时候,塞入一个唯一标识(比如UUID),删除的时候先判断这个标识是不是自己的,是才删。这就是很多公司内部Redis锁工具的雏形。

但还有一个更致命的问题无法回避——锁过期。Redis的setnx锁如果设置了过期时间,业务执行一旦超时,锁就自动释放了。这时候另一个线程拿着新锁进来了,两个线程同时写库存,超卖依然会发生。

有人会提RedLock,也就是Redis官方推荐的分布式锁红锁方案。简单说,就是多节点Redis实例(奇数个),客户端向半数以上节点同时申请锁,如果成功数量过半,才算拿到锁。但RedLock也不是万能的,它依赖了“时钟漂移”这个非常不靠谱的假设,还依赖GC暂停时长不能超过锁过期时间,这在生产环境里很难100%保证。而且RedLock的运维成本很高,很多中小团队根本不会为了一个秒杀场景去部署5个Redis节点。

我在实际项目中,更推荐“锁+数据库乐观锁兜底”的双保险策略。也就是先用分布式锁做一道拦截,拦截掉大多数请求;数据库层再做一个“CAS式扣减”,用更新行数作为扣减成功的判定条件:

UPDATE stock SET remaining = remaining - #{num} WHERE sku_id = #{skuId} AND remaining >= #{num}

这条SQL利用数据库的行锁和原子性,在最后一道关口保证不会扣成负数。如果更新影响行数为0,说明库存不足或并发冲突,直接返回失败。这样的好处是:即使Redis锁失效了,数据库也能兜住底。至于Redis锁,就当它是一个“流量拦截器”,减轻数据库压力的。

2.3 深入答好“如果Redis挂了”的追问

面试官很喜欢在你去掉一个Bug之后,马上给你制造一个新的灾难:“Redis挂了怎么办?”

这个问题其实没有标准答案,但考察的是你有没有降级意识。最稳妥的降级方案是:做一层多级缓存。比如在本地内存Caffeine里缓存一个“秒杀开关”,一旦Redis不可用,本地缓存直接拉起“熔断开关”,所有秒杀请求直接返回“活动太火爆”,防止流量穿透到数据库。等Redis恢复后,再通过配置中心下发“关闭熔断”的指令。

另外,秒杀场景还应该做请求削峰。不是说用户点了一下按钮,就必须立刻同步调用扣减接口。服务端完全可以把“请求接收”和“请求处理”分离开:用户秒杀请求进来后,先返回“排队中”,把请求体丢进MQ,后端异步消费、依次扣减。这样即使Redis挂了,MQ兜底,消息在队列里不会丢,也能保证最终一致性。

3. 真题复盘二:客户端回调重试,服务端如何守住幂等底线

第二类高频主观题,是关于接口幂等设计的。我记得有一道题的大意是:“订单支付成功后,支付平台会回调商户服务端,但回调可能会重复发送多次,服务端如何保证订单状态不被重复修改?请设计一个可靠的方案。”

这题其实比秒杀还贴近日常。做过支付系统的同学都知道,支付回调这种外部依赖,根本不可能保证“绝对只通知一次”。支付平台的SLA再高,也可能出现网络抖动、回调超时、服务端重启,然后触发它的重试机制。所以服务端必须默认:凡是外部回调,都当“无限重试”来处理。

3.1 支付回调重复通知的幂等陷阱

有些人会想,“这还不简单?我收到回调后,先查一下订单状态,如果是已支付就直接返回成功。”

这个思路方向是对的,但代码落地时很容易写歪。比如:

// 伪代码:存在并发问题的写法 public void handlePayCallback(PayNotify notify) { Order order = orderMapper.selectByOrderId(notify.getOrderId()); if ("PAID".equals(order.getStatus())) { return; // 已处理过,直接返回 } order.setStatus("PAID"); order.setPayTime(notify.getPayTime()); orderMapper.updateById(order); }

这段代码在“单线程顺序处理”下没问题,但如果在并发场景下,两个线程同时查到订单状态都是“UNPAID”,然后都执行了update,状态就被重复修改了。虽然结果可能一样,但如果回调里除了改状态,还有加积分、发优惠券、通知WMS发货等一堆操作,就会造成“重复发货”“积分重复到账”等严重事故。

3.2 状态机:服务端治理数据一致性的利器

在很多实际的项目里,服务端最怕的不是外部调用不可用,而是调用方“不老实”,你说好了回调一次,他偏给你回调十次;你说好了先下单再支付,他偏要支付完了再取消下单。这种混乱的调用逻辑,单靠接口文档去约束,根本不现实。所以服务端必须把自己的核心数据,设计成状态机驱动的模型。

什么叫“状态机”?就是给订单定义一个状态流转的路径地图——哪些状态能到哪些状态,不能到哪些状态,由服务端统一校验,而不是任由客户端随意修改。比如一个标准订单的状态流转是:

待支付 -> 已支付 -> 已发货 -> 已完成 待支付 -> 已取消 已支付 -> 退款中 -> 已退款

在这个模型里,“已支付”只能从“待支付”流转过来。如果回调到达时,订单已经处于“已支付”状态,那这个回调就是一个重复通知,直接忽略掉。如果订单已经到了“已完成”或者“已取消”,那就更不用说了,直接返回成功给支付平台。

落实到代码上,可以这样实现:

public void handlePayCallback(PayNotify notify) { // 用条件更新代替“先查后改”,从源头避免并发交错 int rows = orderMapper.updateStatusIfAllowed( notify.getOrderId(), "WAIT_PAY", // 期望的旧状态 "PAID", // 要更新成的新状态 notify.getPayTime() ); if (rows == 1) { // 更新成功,说明这是首次支付回调 doPostPayActions(notify.getOrderId()); } else { // 更新失败,说明订单状态已被其他请求修改过 log.warn("重复支付回调或状态非法, orderId: {}", notify.getOrderId()); } }

对应的SQL就是:

UPDATE order SET status = #{newStatus}, pay_time = #{payTime} WHERE order_id = #{orderId} AND status = #{oldStatus}

这种“乐观锁+状态机”的组合,是服务端应对无效重复请求最有效的武器。它把“判断”和“执行”合并成一条原子操作,根本不给竞赛条件留机会。

3.3 数据库唯一键兜底与SQL示例

除了状态机,还有一种更“硬核”的幂等方案,就是利用数据库唯一键来兜底。典型的场景是别外部订单号了,比如支付回调里带了一个“transactionId”,我们可以建一张独立表:

CREATE TABLE pay_callback_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, transaction_id VARCHAR(64) NOT NULL, order_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_transaction_id (transaction_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

收到回调后,先尝试插入这条记录。如果插入成功,说明是第一次回调,可以继续执行后续流程;如果插入时抛出了“Duplicate entry”异常,说明之前已经处理过这个transactionId了,直接返回成功。

这个方案的好处是,它不依赖“先查后改”,而是利用数据库的最底层约束来保证幂等。就算你的应用层出现了并发问题、重复消费问题,数据库唯一键也会拦住第二条有效数据。坏处是,多了一张表,多了一次写入,会增加一点延迟,但对支付这种对账准确性要求极高的场景,这点延迟完全值得。

4. 扩展题拆解:路由菜单下发与集群配置分发,要的是集群思维

除了纯业务场景,小米主观题里还有一类很考验“集群思维”的扩展题。比如有一道题问的是“一个中后台系统,登录后需要根据用户的角色,从服务端动态获取路由菜单,服务端应该怎么设计接口和数据结构?如果菜单在用户使用过程中发生变化,客户端如何感知到最新的路由配置?”这道题如果只是站在客户端角度做做接口就算了,但题意明显是在问服务端要怎么管理这些配置、如何推送到各个节点。

4.1 网关路由频繁变更如何在线生效

刚看到“从服务端获取路由菜单”这个话题,你可能会觉得很简单——这不就是一个查询接口吗?用户在登录后下拉菜单权限列表,服务端返回给他不就行了?但实际上,把这个功能放到“集群架构”里看,水就深了。

一个公司往往有多个微服务。“动态路由”不只是给前端的菜单用的,它还会用在服务端内部的网关层。比如,你有一个营销活动服务,双十一的时候活动A上线了,需要一个新路由来承载;活动结束下架,路由也要同步下线。如果这个路由信息是各个服务节点的本地配置文件,那就意味着每次路由变化,你都要一台一台地去改配置、重启服务。在集群节点很多的时候,这种方式会耗费大量时间,而且极易出现“改了A没改B”的问题。

正确的做法是:把路由配置从本地剥离,统一收口到一个配置中心。服务端各节点启动时,从配置中心拉取全量路由,建立本地缓存。配置中心发布新版本路由后,会通过长轮询或WebSocket推动变更通知。各服务节点收到通知后,拉取最新路由并热加载到本地内存,整个过程不需要重启服务。

这里可以参考很多开源实现,比如Nacos、Apollo、Consul等。这些配置中心本质上干的就是同一件事:动态配置的管理和推送。面试时能提到这层,就已经比只说接口要加分很多。

4.2 对比几类注册中心的选型逻辑

顺着配置中心往下聊,难免会聊到注册中心。注册中心和配置中心看上去有点像,但本质完全不一样。注册中心解决的是“服务在哪里”的问题,配置中心解决的是“配置怎么变”的问题。

在小米那种体量的技术体系里,注册中心和配置中心往往绑定在一起,形成一套完整的微服务基础设施。面试时如果能顺手对比一下几类常见注册中心的优劣,是很加分的。比如:

组件一致性协议优势劣势适用场景
ZooKeeperZAB(类似Paxos)数据强一致、社区成熟、节点角色清晰需要自己维护会话,临时节点有羊群效应分布式协调、分布式锁、元数据存储
NacosRaftAP和CP模式可切换、内置配置中心、支持HTTP/gRPC性能和大规模场景需压测验证服务发现与配置管理一体化场景
ConsulRaft多数据中心、自带健康检查、DNS接口运维成本稍高,依赖Agent多数据中心的微服务架构
etcdRaft性能好、Watch机制强大、云原生生态好需要搭配其他组件实现服务发现完整逻辑云原生Kubernetes基础设施

这里要提醒一句:选型一定要结合自己团队的规模和运维能力。不要因为Nacos支持AP/CP切换就无脑上,也不要因为ZooKeeper“老派”就嫌弃。真实的生产环境里,稳定、易用、团队熟悉,比技术本身的新旧更重要。

4.3 服务端接口测试的辅助与验证闭环

回到“路由菜单下发”这道题,还有一处容易漏掉的考点,就是接口的测试闭环。

很多候选人答完接口设计就停了,完全没提“你怎么验证这个动态路由是正确且完整地落在每一台服务节点上的”。这其实是一个很要命的遗漏。服务端是集群架构,接口返回的数据在每一台节点上可能都有缓存。如果只有其中一台节点缓存了旧数据,用户请求打到那台节点上时,就会看到异常页面。

正确的做法是,在动态路由下发后,服务端要做一次“全量节点一致性校验”。最简单的方式是,节点更新完本地路由后,向配置中心上报一个版本号;配置中心对比所有节点的上报版本号,如果发现某个节点版本落后,就向它重新推送一次。这个过程可以用定时任务来做,也可以做成事件驱动。除了服务端自检,客户端侧也要做一层兜底:每次拿到路由后,记录一个版本号;前端菜单的接口里带上这个版本号,一旦发现版本不一致,就重新拉取全量路由。双端都做好校验,才能形成一个完整的验证闭环。

5. 答题避坑:从“能用”到“优雅”,主观题的高分动作

聊到这里,相信你已经看出来了,小米主观题说到底,考的就一件事:你有没有一套完整的、体系化的服务端思维框架。你给出的方案不一定要多炫酷,但一定得业务可落地、状态可感知、故障可降级、数据可恢复。

5.1 “答非所问”的典型死法

我在面试别人的时候,最常见的死法就是“答非所问”。

面试官问“Redis分布式锁在秒杀场景下怎么保证不超卖”,这是一个开放性问题,但核心考点很明确,是“并发一致性”。很多人上来讲了一堆Redis的数据结构,甚至开始介绍Redis的持久化机制,讲得头头是道,但就是不往“锁失效”“CAS扣减”“消息队列削峰”这些关键点上靠。这种回答,技术深度可能够了,但方向完全跑偏。

主观题的评审官,要的不是你背了多少技术选型,而是你在面对一个具体业务问题的时候,能不能精准定位到“这里需要什么能力”。秒杀需要的是“高并发读”和“极端写”的保护;支付回调需要的是“幂等”和“最终一致性”;路由下发需要的是“配置管理和全链路验证”。先定位核心考点,再展开技术方案,这是答主观题的第一步。

5.2 如何从“能做”描述到“做好”

很多候选人做题的时候都有个通病:只给结论,不给过程和代价。比如“我可以用Redis做分布式锁”这句话,如果只说这么一句,在面试官眼里等于没说。一个合格的方案,至少应该包含三块内容:

  • 为什么选它:选Redis做锁,是因为它性能高、实现简单,能满足大多数场景的互斥需求。
  • 有哪些副作用:Redis锁存在锁失效和主从切换丢锁的风险,不能作为唯一的安全防线。
  • 怎么规避副作用:数据库乐观锁兜底,本地降级开关,MQ异步削峰。

把这三块都答全了,才是从“能做”升级到了“做好”。换句话说,面试官要的不是一个孤立的答案,而是一个完整的决策树。

5.3 写在最后的一道防线

最后再分享一个我个人的经验。答主观题的时候,一定要给自己留一条“保命后路”。什么叫保命后路?就是当你的方案在极端情况下确实出问题的时候,你有没有Plan B。

比如你设计了Redis分布式锁,那Redis挂了怎么办?比如你设计了MQ异步削峰,那MQ本身堆积了怎么办?比如你设计了配置中心热加载路由,那配置中心挂了怎么办?这些问题不一定要你全部完美解决,但你至少要给出一层兜底。告诉面试官:我知道这里会挂,我挂了之后会有监控报警,报警之后我会用降级开关把流量切走,或者通过管理后台手动干预。这层“对故障的敬畏之心”,往往才是主观题真正的加分项。

说到底,2018年的小米主观题虽然已经过去了好几年,但里面涉及到的秒杀、幂等、配置分发、集群容灾,到今天依然是服务端工程师最核心的日常。哪怕你现在不去面试,只是把这些题目当成自我练习,对着空气讲一遍自己的方案,也能发现很多自己平时没想明白的地方。服务端这门手艺,就是在这种一次次的“自问自答”里磨出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询