ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

小米秋招服务端主观题复盘:秒杀、幂等与分布式锁的坑

小米秋招服务端主观题复盘:秒杀、幂等与分布式锁的坑 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;收到回调后先尝试插入这条记录。如果插入成功说明是第一次回调可以继续执行后续流程如果插入时抛出了“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年的小米主观题虽然已经过去了好几年但里面涉及到的秒杀、幂等、配置分发、集群容灾到今天依然是服务端工程师最核心的日常。哪怕你现在不去面试只是把这些题目当成自我练习对着空气讲一遍自己的方案也能发现很多自己平时没想明白的地方。服务端这门手艺就是在这种一次次的“自问自答”里磨出来的。
返回列表