怎么用 八股讲不顺 → 基础环节会挂;讲得顺 → 只是不被这块淘汰,不等于能过。注意本题库不含算法,而算法是大厂应届的硬门槛。场景题难度对标社招两三年,已超出应届考察标准,它的价值是让你知道正确的思考框架——照原文背会被追问穿,要用自己真做过的经历去填充表达。

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

业务 选中业务时会同时显示通用题

一、支付与交易链路(设计)

  1. 核心国际支付链路你怎么分层设计?★★★

    我会拆成四个域,边界清楚是这套系统能长期演进的前提。

    订单域管交易意图:买什么、多少钱、给谁。支付域管收款过程:用哪个渠道、支付单的状态流转。账务域管资金记录:复式记账、可对账、可审计。渠道适配层屏蔽各家渠道的差异,对上提供统一接口。

    为什么支付必须独立成域,不能塞在订单里:一个订单可能发起多次支付尝试(信用卡被拒了换 PayPal),所以订单和支付是一对多;支付能力还要被充值、订阅、打赏等多个业务复用;而且渠道接入、风控、对账这些逻辑跟订单业务毫无关系,混在一起会让订单服务变成垃圾场。订单状态和支付状态必须是两套独立的状态机,这是这题最关键的判断。

    主流程:创建订单(服务端定价,锁汇率)→ 创建支付单(选渠道,生成渠道请求)→ 唤起渠道 → 接收渠道回调 → 更新支付单状态 → 发消息通知订单域 → 订单域扣减库存并发货 → 账务域记账。

    渠道适配层要抽象出五个统一动作:发起支付、查询支付、退款、查询退款、拉取对账单。每接一个新渠道就实现这套接口,上层业务代码完全不用改。渠道的选择要配置化,按地区、币种、金额区间、成功率、费率动态路由——国际业务里这个能力是必需的,因为某个渠道在某个国家的成功率可能只有 60%,得能快速切换,甚至同时挂多个渠道做故障转移。

    一致性方案的取舍要讲清楚。支付成功之后要做的事很多(改订单、扣库存、发卡密、记账、发通知),绝对不能用一个大事务或者 2PC 串起来——链路长、跨服务、还依赖外部渠道,任何一环卡住整条链路都完蛋。所以做法是最终一致:回调处理只做最小的事(更新支付单 + 发消息),下游各自消费并保证幂等,失败靠重试加定时补偿任务,最后用对账兜底。

    「幂等 + 状态机 + 消息 + 定时补偿 + 对账」这五件套是支付系统的骨架,缺任何一个都会出资损。

    另外国际业务特有的两层要提前设计进去:多币种和汇率(下一题细说)、合规(PCI-DSS 不落地卡号、PSD2 的强客户认证、各地区税费)。这些后补的成本极高,因为会动到数据模型。

  2. 核心多货币和汇率怎么处理?★★★

    先立三条规矩:金额用整数最小单位存储金额必须和币种成对出现任何跨币种换算都要留下汇率快照

    不能用浮点数存金额,这是底线。用整数存最小单位(分)或者用 DECIMAL。这里有个国际业务特有的坑:不同币种的小数位数不一样——美元欧元 2 位,日元韩元是 0 位,科威特第纳尔 3 位。所以不能到处硬编码「除以 100」,要有一张币种元数据表定义每个币种的精度,所有格式化和换算都查这张表。这个细节踩过的人才知道。

    一笔交易要记三种金额,这是设计上最容易漏的地方:交易币种金额(用户实际支付的,比如 19.99 USD)、结算币种金额(渠道最终结给你的,可能是 EUR)、记账币种金额(财务统一口径,比如 CNY)。三者之间的换算汇率都要作为快照存下来,否则事后对账和财务核算根本对不上。

    汇率的处理:汇率来源要有主备(第三方汇率服务 + 渠道自己的汇率),定时更新并且保留历史版本。业务侧要区分展示汇率成交汇率,而且通常会在市场汇率上加一个价差(spread)来覆盖汇率波动风险和手续费——这是业务决策,不是技术问题,但要在模型里支持。

    下单锁价是核心机制。用户看到的价格和最终扣款必须一致,所以创建订单时就固定汇率和金额,并给一个有效期(比如 15 分钟)。过期就要重新报价让用户确认。如果不锁价,用户停留半小时后支付,按哪个汇率算就成了纠纷——国际支付里这种纠纷会直接演变成拒付(chargeback),而拒付率高会被渠道关停商户,这是很严重的业务后果。

    结算差异必须容忍。渠道实际结算的金额往往和你算的不完全一致,原因包括渠道用了自己的汇率、扣了手续费、有汇率波动。所以系统不能假设「渠道结算金额 = 订单金额」,要把差异记录下来单独核算,超过阈值才告警。硬性校验相等的话,对账每天都会炸。

    税费也得一起考虑:欧盟的 VAT 要求展示含税价,美国各州销售税规则不同,日本有消费税。含税还是不含税展示是法规要求而不是产品选择,所以价格模型里要能表达「税前金额 + 税额 + 税率 + 税种」,不能只有一个总价。

  3. 核心支付渠道的回调通知怎么设计才可靠?★★★

    回调是整个支付系统最容易出资损的地方,我按防护层次来说。

    第一层:验签和防重放。必须验证签名,否则任何人构造一个「支付成功」的请求就能白拿商品,这是最严重的安全漏洞。签名用渠道提供的密钥和算法,密钥放配置中心或 KMS。同时要校验时间戳,超过时间窗的拒绝,防止重放。还要校验来源 IP 白名单作为补充。

    第二层:金额和归属校验。回调里的金额必须和支付单记录的金额、币种完全一致,不一致就拒绝处理并立刻告警——这可能是渠道配置错误,也可能是攻击。同时校验这个支付单是否属于这笔交易,防止张冠李戴。

    第三层:幂等。渠道一定会重复通知(这是它们的可靠性机制),所以必须幂等。用渠道流水号做唯一索引是最可靠的做法,重复插入直接冲突,捕获后返回成功。不要只靠「先查再改」,并发下会双写。

    第四层:状态机保护,防乱序。回调可能乱序到达——先收到「支付成功」后收到「处理中」。所以状态流转必须走状态机:只允许合法流转,终态不可逆。已成功的支付单收到「失败」通知要直接忽略并告警,绝对不能覆盖。这一条能挡住绝大部分乱序造成的数据错乱。

    第五层:接收和处理解耦。回调接口只做三件事:验签、落库(原始报文也存下来,排查时极其有用)、发消息,然后立刻返回成功。业务处理(改订单、发货、记账)全部异步消费。原因是如果同步处理,业务逻辑慢或者报错会导致渠道判定超时而不断重发,加剧压力;而且渠道通常有严格的响应时间要求,超时会被认为通知失败。

    但这里有个前提:返回成功之前必须确保消息不会丢。所以要么先落库(后续靠定时任务扫描未处理的记录),要么确认 MQ 发送成功。我倾向先落库再发消息,库是最可靠的兜底。

    第六层:不要只依赖回调。回调可能永远不来(渠道故障、我们的地址不可达、跨国网络问题)。所以必须有主动查询的定时补偿任务:扫描处于「处理中」且超过一定时间的支付单,主动调渠道查询接口确认状态。这个任务是回调的对等兜底,不是可选项。

    最后是对账作为最后一道防线,把所有前面漏掉的差异捞出来。这题答完整的标志是把「回调 + 主动查询 + 对账」三层都说出来,只讲验签和幂等只能算及格。

  4. 核心订单状态机和超时关单怎么设计,怎么防止关单和支付撞车?★★★

    状态设计:待支付 → 支付中 → 已支付 → 发货中 → 已完成,另外有已关闭、退款中、已退款这几个分支。

    「支付中」这个状态必须有,很多人会漏。用户跳转到渠道页面之后,系统处于「不知道结果」的状态,这段时间既不能当作未支付去关单,也不能当作已支付去发货。没有这个状态就没法正确处理超时关单的边界。

    状态流转要满足三点:只走定义好的合法路径(用状态机或者 update ... where status = 前置状态 保证)、幂等(重复流转不出错)、留流转日志(谁在什么时候把状态从 A 改成 B,出问题时这是唯一的追溯依据)。

    超时关单的实现有三种,我会组合用。延迟消息(RocketMQ 延迟消息或者延迟队列)最优雅,到点自然触发,没有轮询压力,但消息可能丢。定时扫表最可靠但有延迟,而且大表扫描有压力,要按状态和时间加索引、分批处理。Redis ZSet 用到期时间做 score,性能好但依赖 Redis 可靠性。实践方案是延迟消息为主 + 定时扫表兜底,扫表的频率可以低一些,只负责捞漏掉的。

    关单和支付撞车是这题的真正考点。用户在关单的那一瞬间完成了支付,就会出现「订单已关闭但钱扣了」。这个并发窗口无法完全消除,只能这样处理:

    一是关单前主动查一次渠道。不要只看本地状态就关,先调渠道查询接口确认这笔支付确实没成功,再关。这一步能挡掉大部分情况。

    二是用状态机保证只有一个操作成功。关单是 update ... where status = '待支付',支付成功也是 update ... where status in ('待支付','支付中'),靠数据库的行锁和条件更新保证互斥,谁先成功另一个就失败。失败方必须有明确的后续动作,不能默默忽略。

    三是关单之后收到支付成功,必须自动退款。这是兜底,也是最重要的一条。要有一个明确的处理分支:订单已关闭却收到支付成功通知,走「异常支付」流程——记录、告警、自动发起退款。绝对不能不管,否则就是资损和客诉。

    另外关单要释放预占的库存(虚拟商品是释放预占的卡密),这一步也要幂等,重复关单不能重复释放。

  5. 核心退款链路怎么设计,虚拟商品的退款有什么特殊性?★★★

    模型上退款单要独立,一笔支付可以对应多笔退款单(部分退款、分次退款)。退款单要记录:关联的支付单、退款金额和币种、原因、状态、渠道退款流水号。

    金额校验是硬约束:累计退款金额不能超过支付金额。这个校验要在数据库层面用「已退金额」字段加乐观锁保证,不能只在应用层查一下——并发提交两笔退款申请时,应用层的校验会同时通过。

    流程:申请(幂等号去重)→ 审核(自动规则 + 人工)→ 调渠道退款 → 轮询/回调确认 → 更新状态 → 冲正记账 → 通知用户。

    渠道退款是异步的,而且比支付慢得多——国际信用卡通道常见 5 到 15 个工作日。所以退款单必须有完整的中间态,并且要有查询补偿任务。这里还有个陷阱:调渠道退款接口超时不代表失败,跟支付一样是不确定态,必须靠查询确认,绝对不能重试发起第二笔退款——那会退两次钱。所以退款请求也要带幂等号给渠道。

    虚拟商品的特殊性有几条。商品已消费不可收回:卡密一旦被用户查看(甚至只要展示过),就要视为已交付,能不能退是业务规则问题,技术上要能记录「是否已查看、是否已激活」这些状态来支撑判断。欧盟法规规定数字商品在明确告知并获得用户同意后可以排除 14 天无理由退货权,所以要在下单时保留用户的同意记录,这是法务要求。卡密要能作废:退款后如果卡密还没被使用,要立刻在上游作废,防止用户既退了钱又用了卡。

    汇率问题:退款时汇率可能变了。原则是按原交易的币种和金额原路退回,这样用户收到的是他付出的金额,不产生汇兑纠纷。但你的结算币种损益会有差异,这部分要记入汇兑损益,不能强行让账平。

    拒付(chargeback)是国际业务特有的重头,必须单独设计。用户不走你的退款流程,直接找发卡行发起争议,银行会先把钱扣回去再让你举证。所以要有:接收渠道拒付通知的能力、争议单据管理(订单信息、发货凭证、用户同意记录、IP 和设备信息)、举证材料自动组装、以及拒付率监控——拒付率超过渠道阈值(通常 1%)会被罚款甚至关停商户号,这是生存问题。所以风控要前置,宁可拒掉一些高风险订单。

  6. 核心虚拟商品的卡密库存怎么设计,怎么保证不超发不重发?★★★

    卡密是有具体身份的库存,不是一个可以加减的数字,这是它和普通商品库存最大的区别。所以设计上不能只维护一个计数。

    存储和安全:卡密是高价值敏感数据,必须加密存储,用 KMS 管理密钥,应用层拿到的是密文,只在展示给用户的那一刻解密。数据库泄露也不能直接变现。同时要有完整的审计日志——哪张卡密在什么时候被哪个订单取走、被谁查看过,出了盗取事件这是唯一的追溯手段。内部管理后台查看卡密要单独授权并留痕。

    发放的并发正确性是核心。做法是给卡密表加状态(未使用、已锁定、已发放、已作废),发放时用一条原子的条件更新:

    update card set status = 'LOCKED', order_id = ?, lock_time = now()
    where sku_id = ? and status = 'UNUSED' limit 1

    看影响行数,等于 1 就成功拿到一张,等于 0 就是没库存。靠数据库的行锁保证同一张卡密不会被两个订单拿到,不需要额外的分布式锁。要注意先 selectupdate 的写法在并发下是错的,必须一条语句完成。

    高并发场景下这条 SQL 会成为热点,优化方向是预热到 Redis 队列:提前把可用卡密 ID 批量加载进 Redis 的 List 或 Set,用 lpop 原子取出,再异步落库确认。代价是 Redis 挂了要能回退到数据库模式,而且要处理「Redis 取出了但落库失败」的补偿。不到极高并发不用上这套,数据库方案简单可靠得多。

    锁定和释放:下单时锁定卡密(LOCKED),支付成功后转已发放,超时关单要释放回 UNUSED。释放要用定时任务扫描锁定超时的记录兜底,不能只靠关单流程——关单流程本身可能失败。

    发货失败的回滚要想清楚:卡密已经分配给订单但后续步骤失败了,是回滚释放还是保留重试?我倾向保留并重试,因为卡密已经和订单绑定,释放出去可能被别人拿走,之后这个订单就发不出货了。所以设计成「分配即绑定,重试用同一张」,这样天然幂等。

    库存管理:要有低库存预警和自动补货流程(上游 API 拉取或者供应商推送),因为虚拟商品缺货直接导致「已支付无法发货」,那是最难处理的客诉。还要防止超卖——商品可售数量要以实际可用卡密数为准,不能靠单独维护的计数(两者会不一致),或者用计数做快速拦截、真实分配时再校验一次。

  7. 核心对账系统怎么设计,差异怎么处理?★★★

    对账的定位要说清楚:它是最终一致体系的最后一道防线。前面所有的幂等、重试、补偿都可能有漏网的,对账负责把差异全部捞出来,保证账不错。没有对账的支付系统一定有隐藏资损,只是还没被发现。

    对三方:渠道账单、自己的支付单、账务流水。两两比对,任何两边不一致都要能发现。很多人只做「渠道 vs 支付单」,漏了账务,结果支付对上了但财务账不平。

    流程:每天定时拉取渠道账单(下载文件或者调 API)→ 解析入库 → 和本地数据按渠道流水号匹配 → 输出差异 → 自动处理或人工处理。要注意拉取本身要能重试和补拉,渠道账单可能延迟生成或者当天不完整。

    差异类型要分清,处理方式完全不同:

    长款(渠道有、我们没有):说明用户付了钱但我们没记录,通常是回调丢失且主动查询也漏了。处理是补单——补一笔支付成功并触发发货。这是最需要优先处理的,因为用户已经付钱了。

    短款(我们有、渠道没有):我们记了支付成功但渠道没这笔,可能是状态误判或者渠道账单延迟。要先确认是不是次日账单里出现(跨日交易),排除后要冲正本地状态并追查根因,这种情况说明有严重 bug。

    金额不一致:多数是正常的——汇率差、渠道手续费、分期费用。所以不能简单报错,要按规则计算「预期差异」,只有超出容差范围才算真差异。这一点前面提过,硬性要求金额相等的对账系统每天都在误报。

    状态不一致:比如我们是成功渠道是退款,说明有一方状态没同步。

    处理机制:能自动的一定自动化,比如「长款且订单存在且金额匹配」直接补单,「金额差异在手续费容差内」自动核销。剩下的进人工处理台,要有清晰的差异详情、原始报文、一键处理动作和处理留痕。差异必须有归零流程,不能只是列出来看看——没人跟进的对账报表等于没有对账。

    另外几个实践点:对账要能重跑(数据修正后重新对)、要有对账结果的监控告警(差异笔数和金额超阈值立刻通知)、要保留历史(审计和纠纷举证都要用)。规模大了之后单机跑不完,就要上批处理框架分片跑。

  8. 核心支付安全和风控你会怎么做?★★★

    分成技术安全业务风控两块,前者防篡改和攻击,后者防欺诈和滥用。

    技术安全的要点。金额绝不信前端,服务端按商品和数量重新计算,支付时用支付单里存的金额,前端传什么都不采纳。越权校验,支付单必须校验归属,防止付别人的单或者查别人的订单——这类水平越权在支付系统里后果很严重。回调验签,前面说过。敏感信息不落地,卡号和 CVV 用渠道的 SDK 或托管页面收集,自己的服务器完全不接触,这是 PCI-DSS 的硬性要求,也是出了事故能免责的关键。接口签名加时间戳加 nonce 防篡改和重放。密钥用 KMS 管理,不进代码仓库。

    业务风控要针对虚拟商品的特点设计。虚拟商品的欺诈风险远高于实物,因为卡密即时变现、无物流可追踪、盗刷者拿货后立刻拒付。所以风控必须前置到发货之前,而不是事后追。

    风控信号可以采集:设备指纹、IP 和归属地、IP 地区和账单地址是否一致(不一致是强信号)、时区和语言是否匹配 IP、账号注册时长和历史行为、同设备/同 IP/同卡号的下单频率、金额是否异常、是否用了代理或 VPN。

    决策分层:低风险直接放过;中风险要求二次验证(3DS 验证、邮箱或手机验证);高风险支付成功也不立即发货,转人工审核;确定的黑名单直接拒绝下单。这里有个业务权衡要说出来:风控严了会拦掉正常用户损失转化,松了会被刷单和拒付,所以要按拒付率和转化率两个指标一起调,而不是单纯追求拦得多。

    3DS 和 PSD2是国际业务的合规点:欧洲的强客户认证要求大部分线上交易走 3DS 验证。3DS 会降低转化率但能把拒付责任转移给发卡行,所以它既是合规要求也是风控手段,要支持按地区和金额动态决定是否走 3DS。

    拒付率必须监控。渠道通常要求拒付率低于 1%,超了会被罚款、提高费率甚至关停商户号——对国际业务这是致命的。所以风控的目标不只是减少损失,更是保住渠道资质。这个认知层次能明显区分是否真做过国际支付。

    最后是可追溯:所有支付相关操作留完整审计日志,风控决策要记录命中了哪条规则,出了纠纷或者要举证时这些都是必需的材料。

  9. 核心账务系统怎么设计,为什么要复式记账?★★★

    先说为什么支付单不够用。支付单记录的是「钱有没有收到」,账务记录的是「钱属于谁、流向哪里」。比如一笔 100 美元的支付,渠道扣了 3 美元手续费,实际结算 97,其中商品成本 70 要给供应商,平台留 27。这些关系支付单表达不了,必须有独立的账务体系。缺了它,财务对不上账、供应商结算算不清、也没法审计。

    账户体系要把资金的每个「停留位置」都建成账户:用户账户(余额、赠金)、供应商应付账户、平台收入账户、渠道在途账户(钱已收但渠道还没结算给你)、手续费账户、汇兑损益账户。渠道在途这个账户最容易被漏掉,但它很关键——支付成功的那一刻钱并不在你的银行账上,而是在渠道手里,这段时间的资金必须有地方体现。

    复式记账的核心是每笔业务至少产生两条分录,借贷金额必须相等。用户支付 100 美元记成:借「渠道在途」100,贷「预收账款」100。渠道结算时:借「银行存款」97、借「手续费」3,贷「渠道在途」100。这样每一步资金去哪了都清楚,而且任何时刻所有账户借贷总额必然相等——这个恒等式就是自检机制,一旦不等说明系统有 bug。

    三条硬性原则。一是流水不可修改不可删除,记错了只能新增一条冲正分录抵消,这样审计链条完整。二是账户余额必须等于其所有流水之和,余额字段只是缓存,要有定时任务重算校验(叫「试算平衡」)。三是记账必须幂等,用业务单号加账务事件类型做唯一索引,同一个业务事件重复通知只能记一次账——重复记账是最难发现也最难修的资损。

    多币种的处理:账户要按币种拆分,不同币种的账户之间不能直接借贷平衡。跨币种时要通过汇兑损益账户过渡,并记下当时的汇率快照。这块处理不好,汇率一波动财务账就永远平不了。

    技术实现上几个要点:分录表是只追加的,写入量很大要考虑分库分表(按账户或时间);记账要和业务操作解耦,用可靠消息驱动,但绝对不能丢,所以要有本地消息表加对账双保险;账户余额更新的并发用乐观锁或者行锁,热点账户(平台账户所有交易都会动它)要考虑分片子账户再定时汇总,否则会成为全局瓶颈。

    最后说定位:账务系统的正确性优先级高于性能和可用性。宁可记账慢一点、宁可暂时不可用,也不能记错或记丢。这个取舍和其他业务系统是反的。

  10. 核心订阅制自动续费怎么设计?★★★

    核心难点是后续扣款不需要用户参与,所以要先拿到「可复用的支付凭证」,然后靠调度去扣,而扣款失败的处理策略直接决定营收。

    首次签约:用户授权后,渠道返回一个可复用的凭证(Stripe 的 payment method、PayPal 的 billing agreement)。这个凭证存起来后续扣款用,卡号本身绝对不能落地(PCI-DSS 要求)。签约时通常要做一次小额验证或者零金额授权确认卡有效。

    用渠道的订阅能力还是自己实现扣款,这是个要讲清的选择。渠道托管(Stripe Subscriptions)省事、自动处理重试和卡更新,但灵活性受限、跨渠道方案不统一。自己实现(保存凭证 + 自己调度扣款)灵活、多渠道一致,但要自己处理重试、失效、对账。多渠道的国际业务我倾向自己实现调度,因为不同渠道的订阅能力差异很大,业务逻辑不能被渠道绑死。

    续费调度:不要在到期当天才扣,要提前几天开始尝试,给失败重试留出窗口。扣款任务用延迟消息或定时扫表触发,必须幂等——同一个计费周期只能扣一次,用「订阅 ID + 周期序号」做唯一索引,这是防止重复扣款的硬约束。

    扣款失败的处理是这题的重点,行业里叫 dunning。失败原因要区分:余额不足(可以过几天重试,成功率不低)、卡过期(要引导用户更新,重试没用)、卡被拒(可能是风控,重试可能有效)、卡被销户(直接放弃)。按原因决定重试策略,无脑重试既浪费还会被渠道判定为异常商户。重试通常是递增间隔试三到四次。

    宽限期和降级:扣款失败不应该立刻停服。给一个宽限期(比如 7 天)继续提供服务并持续提醒用户,宽限期过了先降级(比如从会员降成免费用户)而不是直接删数据。这个设计对挽回率影响很大。

    套餐变更要处理按比例计费(proration):用户中途升级,要按剩余天数计算差价;降级通常是下个周期生效。这里的金额计算很容易出舍入问题,要有明确的规则并且和财务对齐。

    合规不能忽略:欧盟等地区要求续费前必须提前通知、取消流程必须简单(不能设置障碍)、并且要明确展示续费金额和周期。这些是法规要求,做不到会被投诉甚至处罚。所以要有扣款前提醒的通知机制,以及一键取消入口。

    取消的语义要设计清楚:是立即终止还是到期终止?通常是到期终止(用户已付费的周期继续享受服务),立即终止要涉及按比例退款。这个规则要在产品和技术上一致。

  11. 核心优惠券和促销活动怎么设计,怎么防止算错和被刷?★★★

    优惠是资损高发区,因为它直接改变金额,算错一位就是真金白银。我按模型、并发、计算、防刷四块说。

    券模型:类型(满减、折扣、固定金额、免邮)、使用门槛、有效期、适用范围(指定商品/分类/地区)、发放限制(总量、每人限领、每人限用)、是否可叠加。国际业务还要加币种和地区限制——一张 10 美元的券不能用在日元订单上,这个校验漏了就会出金额错误。

    领券的并发:券有总量限制,高并发抢券会超发。用 update coupon_stock set issued = issued + 1 where id = ? and issued < total 这种原子条件更新,判断影响行数。量特别大就用 Redis 预扣减再异步落库。每人限领要用「用户 ID + 活动 ID」唯一索引硬约束,不能只靠查询判断。

    用券的并发是更严重的问题:同一张券被用在两个订单上。必须用状态机加条件更新:update user_coupon set status = 'USED', order_id = ? where id = ? and status = 'UNUSED',影响行数为 0 就说明已被使用。订单取消要释放券,释放也要幂等。

    优惠计算是最容易出错的部分。关键是计算顺序必须明确且唯一:先算单品优惠还是先算整单优惠、多张券怎么叠加、折扣和满减的先后顺序。这些规则如果不固定,同样的输入会算出不同结果,对账时就是灾难。我会把计算逻辑做成独立的、纯函数式的计价服务,输入商品和券,输出明细,这样可测试、可回溯,还能在下单和支付时各算一次比对(防篡改)。

    金额分摊必须做,这是很多人漏掉的。整单优惠 30 元,订单里有三个商品,必须把这 30 元按比例分摊到每个商品上。因为退款时可能只退其中一件,要知道这件商品实际收了多少钱。分摊有个经典的舍入问题:三件商品分摊 10 元,每件 3.33,加起来只有 9.99,差的那一分钱必须补给某一件(通常给最后一件或金额最大的一件),否则退款退完了总额对不上。这个细节说出来很能体现做过实际业务。

    防刷:批量注册领券(要有设备指纹和风控,限制同设备同 IP 的领取数)、机器抢券(加验证)、恶意退款套现(券只在实付部分退款,优惠部分不退现金)。最重要的是要有活动的实时监控和熔断——发现某个活动的核销量或优惠金额异常暴增,能立刻关掉。历史上很多「0 元购」事故都是因为规则配置错误加上没有监控和熔断,等发现时损失已经很大了。

    还有一条运营侧的保护:活动配置要有审核和金额上限校验,不能让运营直接配一个「满 1 减 100」上线。技术上要有兜底校验:优惠后金额不能小于某个下限、单笔优惠不能超过阈值,触发就拦截并告警。

  12. 核心金额计算的精度和一致性怎么保证?★★★

    这题看起来基础,但国际业务里的金额计算是资损和对账问题的高发区,涉及舍入、分摊、汇率、税费四层叠加。

    存储和运算的基础规则:用整数最小单位或 DECIMAL绝不用浮点;金额必须带币种;不同币种小数位不同(日元 0 位、美元 2 位、部分中东货币 3 位),所以精度要查币种元数据表而不是硬编码。

    舍入规则必须全局统一并且写进文档。用四舍五入还是银行家舍入、在哪一步舍入,前端、后端、财务系统必须完全一致。最常见的事故是「在不同环节各自舍入」:前端展示时舍入一次、后端计算时舍入一次、对账时又舍入一次,结果三方数字都差一点。正确做法是只在最终结果处舍入一次,中间过程保留高精度。

    分摊的舍入差额必须显式处理。前面提过:总优惠 10 元分给三件商品,每件 3.33,合计 9.99,差 1 分。处理方式是把差额补给最后一项或最大项,保证「各项之和严格等于总额」。这个不变式必须在代码里断言校验,不能靠人工保证。同类问题还有分期金额、多商品运费分摊、税额分摊。

    税费的计算方向要搞清:欧盟展示含税价,所以是从含税价反算税额(税额 = 含税价 × 税率 / (1 + 税率)),这个反算会产生舍入误差;美国是价外税,在结算时叠加。两种模式的计算顺序不同,混淆了就会算错。而且税率要按用户所在地确定,不是按平台所在地。

    汇率换算:任何跨币种换算都要记录汇率快照,否则事后无法复现计算过程。而且要明确「用哪个方向的汇率」(买入价还是卖出价),以及换算后的舍入规则。不要多次换算——从 USD 换 EUR 再换 CNY 会累积两次误差,应该直接换或者以某个基准币种为中心。

    验证机制是这题的加分项:下单和支付时各算一次金额并比对,不一致就拦截(既防篡改也防计算 bug);关键不变式写成断言(明细之和等于总额、优惠不超过原价、实付加优惠等于应付);对账时容忍已知的合理差异(手续费、汇率)但要有阈值告警。

    最后一条实践:金额计算逻辑必须有完整的单元测试,尤其是边界情况——0 元订单、全额抵扣、退款金额恰好等于分摊金额、除不尽的分摊。这块代码的测试覆盖率应该接近 100%,因为它的每一个 bug 都是钱。

  13. 支付渠道路由和故障转移怎么设计?★★★

    国际业务必须支持多渠道,因为没有任何一个渠道能在所有国家都有好的成功率。同一个渠道在美国成功率 95%,在巴西可能只有 60%,这直接就是营收差异。

    路由维度:地区(用户所在国家)、币种、支付方式(信用卡、本地钱包、银行转账)、金额区间(小额和大额的最优渠道可能不同)、卡组织(Visa/Mastercard/本地卡)、以及渠道的实时成功率和费率。规则要能配置化并且热更新,绝对不能写死在代码里——出问题时改配置几秒生效,改代码要发版几十分钟,这个差距在故障时就是营收损失。

    路由策略分两层:硬性约束先过滤(这个渠道支不支持该地区和币种、有没有超限额、是否在维护),剩下的候选再按优先级打分(成功率权重最高、费率次之、也可以按业务策略指定主备)。可以支持按比例灰度——新接入的渠道先给 5% 流量观察,稳定了再加。

    成功率统计要按细粒度维度做:渠道 × 地区 × 支付方式 × 时间窗。用滑动窗口统计,低于阈值自动降权,恢复了自动加回来。这里要注意区分「渠道故障」和「用户原因失败」——余额不足、用户取消这类不该算渠道的锅,否则会误判把好渠道降权了。所以要按渠道返回的错误码分类统计。

    故障转移:渠道调用失败或超时,要判断能不能安全重试到另一个渠道。这里有个关键风险:如果第一个渠道其实已经受理了,切到第二个渠道就变成重复支付。所以只有在明确知道第一次没有成功受理的情况下才能切(比如渠道返回明确的拒绝码、或者连接压根没建立)。超时这种不确定态绝对不能直接切,必须先查询确认状态。这个判断是这题的核心。

    更稳的做法是让用户重新选择:第一次失败后返回「该支付方式不可用,请换一种」,由用户主动触发新的支付单。这样每次支付意图都有独立的支付单,责任清晰不会重复。牺牲一点体验换取正确性,在支付场景是值得的。

    配套能力:渠道限额和配额管理(很多渠道有日限额,超了要自动切换);渠道健康检查(定期探活);统一的错误码映射(各渠道错误码五花八门,要归一化成自己的分类才能统一处理和统计);渠道维度的对账(每接一个渠道就多一套对账逻辑,这是接渠道的隐性成本)。

    最后是监控:按渠道和地区的成功率大盘、以及自动告警。国际支付里「某个渠道在某个国家挂了」是常态,靠人工发现太慢,必须自动检测并切换。

  14. 给上游供应商结算和提现怎么设计?★★★

    虚拟商品平台的卡密来自上游供应商,所以有对外付钱的链路。这条链路的风险比收款更高——收错了可以退,付错了钱可能追不回来

    结算流程:按周期(日结、周结、月结)生成结算单 → 双方对账确认 → 生成付款单 → 打款 → 状态回写。结算单和付款单要分开:结算是算清「该给多少」,付款是执行「实际打出去」,一张结算单可能分多次付款,付款也可能失败重试。

    结算金额的计算要基于账务系统的流水而不是订单表——因为要扣除退款、拒付、手续费、扣款和罚款。这里必须处理跨周期的调整:上个月已结算的订单这个月被退款了,要在本期结算中扣回。所以结算单要能包含「本期调整项」,不能只算本期订单。

    幂等是这条链路的生命线。重复打款是严重资损而且很难追回。防护要多层:付款单有唯一业务号加唯一索引;调支付渠道打款时必须传幂等号(商户付款单号),让渠道也去重;打款接口超时绝对不能自动重试,必须先查询确认状态——这一点和收款的逻辑一样,但后果更严重。

    状态设计要有完整的中间态:待审核 → 待打款 → 打款中 → 成功/失败。「打款中」必须有,因为跨境打款可能要几天,这段时间既不能重复打也不能判定失败。要有查询补偿任务定期核对渠道状态。

    审核和风控:金额超过阈值要人工审核甚至多人审批;收款账户变更要有冷静期和二次验证(这是防内部作案和账户被盗的关键——攻击者拿到供应商账号后第一件事就是改收款账户);异常提现(金额突增、频率异常、新账户大额)要拦截。所有操作留完整审计日志

    跨境付款的特殊问题:手续费和汇率(是谁承担要在合同里明确并在系统里体现)、合规审查(反洗钱 KYC、受制裁名单筛查,这个是硬性要求)、到账时间长且不确定、以及可能被中间行退回。所以要能处理打款失败退回的场景,把钱退回到应付账户并通知供应商更新信息。

    对账:付款也要和渠道对账,确认「我们记录的付款」和「渠道实际执行的付款」一致。付款方向的差异同样会造成资损,很多团队只做收款对账,漏了付款,这是个明显的缺口。

二、短视频业务(设计)

  1. 视频上传和转码链路怎么设计?★★★

    核心思路是让视频文件不经过自己的业务服务器,以及把转码当成一个可能失败的异步长任务

    上传客户端直传对象存储:客户端向业务服务申请一个有时效和权限限制的上传凭证(STS 临时凭证或者预签名 URL),然后直接把文件传给 OSS/S3。业务服务器只发凭证,不承载文件流量。这样做的好处是省掉了服务器的带宽和内存压力,还能利用对象存储的分片上传和断点续传能力。凭证要限制路径、大小、类型和有效期,防止被滥用当免费网盘。

    转码触发:上传完成后由客户端通知业务服务,或者监听对象存储的事件通知(更可靠,因为不依赖客户端)。然后创建转码任务投递到队列。

    转码要出多档:不同分辨率和码率(比如 360p/720p/1080p),生成 HLS 或 DASH 切片支持自适应码率,同时抽取封面图和几帧用于审核。国际业务尤其需要多档,因为各地区网络差异巨大,弱网用户必须有低码率版本才能播起来。

    转码集群是 CPU 密集型,要和业务服务隔离部署,用队列削峰,按优先级调度(比如付费用户或热门创作者优先)。可以用云服务商的转码服务省掉运维,量大了再考虑自建加弹性伸缩。转码任务要有超时和重试,也要能识别「压根转不了」的文件(损坏、格式不支持)并给出明确原因。

    状态流转:上传中 → 待转码 → 转码中 → 待审核 → 已发布 / 转码失败 / 审核不通过。转码完成不等于可发布,还要过审核,这两步要分开,否则没法支持「先审后发」的合规要求。

    分发:转码产物存对象存储,通过 CDN 分发,播放地址要带鉴权(防盗链)——用时效签名 URL,防止别人盗链你的视频白占带宽。热门内容主动预热到边缘节点。

    成本是这题容易被忽略但很重要的一面:视频业务的存储和 CDN 带宽是主要成本。优化手段有冷热分层(老视频转低频存储或归档)、按播放量决定保留档位(没人看的视频不必保留 1080p 转码产物)、转码用更高效的编码(H.265/AV1 能省三成以上带宽,代价是转码成本和兼容性)。能主动提到成本控制,会显得考虑得比较完整。

  2. 短视频 Feed 流怎么设计,怎么保证翻页不重复不遗漏?★★★

    先区分两类 Feed,架构完全不同:关注流(看关注的人发的,有明确的时间序)和推荐流(算法决定,没有固定顺序)。短视频平台的主场是推荐流。

    关注流的经典问题是推拉选择。推模式(写扩散):发布时写进所有粉丝的收件箱,读的时候直接读自己的箱子,读快写慢,大 V 有百万粉丝时一次发布要写百万条,扛不住。拉模式(读扩散):读的时候实时聚合所关注的人的最新内容,写快读慢。实践是推拉结合:普通用户用推,大 V 用拉,活跃用户优先推、不活跃的用拉。

    推荐流的链路是召回 → 过滤 → 粗排 → 精排 → 重排。后端要做的工程部分是:从多路召回源取候选(热门、兴趣标签、协同过滤、地理位置)、过滤掉已看过和不该看的(已看、已屏蔽、地区不可见、审核不通过)、然后交给排序服务打分、最后做多样性重排(避免连续几条都是同一作者或同一类型)。

    翻页一致性是这题的重点。推荐流不能用传统的 offset 分页,因为底层数据在不断变化,翻页时会出现重复和遗漏。正确做法有两种:

    一是游标(cursor)分页:返回一个不透明的 cursor,里面编码了上一页的位置信息(最后一条的 ID、时间戳、或者排序分数)。下一页带着 cursor 来,服务端据此定位。

    二是会话级快照加已读集合:更适合推荐流。用户开启一次浏览会话时生成一个 sessionId,服务端记住这个会话已经下发过哪些视频 ID(存 Redis,用 Set 或者布隆过滤器),每次取新的候选时把已下发的过滤掉。这样天然不重复。已读集合要设过期时间,而且长期的「已看过」记录要单独存(几个月内不重复推荐),用布隆过滤器控制存储成本——布隆过滤器的假阳性在这里是可以接受的,误判只是少推一个视频。

    性能和兜底:整条链路要有超时控制和降级——排序服务超时就退化成按热门返回,召回源部分失败就用剩下的,绝对不能因为某一路挂了就整个 Feed 出不来。首屏要快,可以提前预生成一部分候选放缓存,用户进来直接取。

    国际化的额外考虑:内容要按地区和语言过滤(各国法规不同,某些内容在某些国家不能展示),这个过滤必须在召回之后强制执行,不能只靠算法。多地区部署时推荐服务和内容库要就近,跨洋调用的延迟会毁掉 Feed 的响应速度。

  3. 播放量、点赞数这类高并发计数怎么设计?★★★

    关键判断是先按「精度要求」和「读写比」把计数分类,不同类型用完全不同的方案。用一套方案处理所有计数是设计失误。

    播放量这类只增不减、可以有误差的:走Redis 计数 + 定期落库。客户端上报(要节流合并,不要每秒报)到消息队列,消费端在 Redis 里 incr,然后定时批量刷回数据库。展示时读 Redis。可以接受少量丢失(Redis 挂了丢几秒的数据不影响业务)。量特别大时还可以先在本地内存聚合再批量提交,进一步减少 Redis 压力。

    点赞数这类需要和用户状态关联的:既要有总数,又要知道「我有没有赞过」。总数走 Redis 计数,用户的点赞关系存数据库(用户 ID + 视频 ID 唯一索引,天然幂等防重复点赞)。点赞接口要设计成「设置为某个状态」而不是「切换」,否则网络重试会导致状态翻转。判断「我是否赞过」如果 QPS 很高,用 Redis 的 Set 或者布隆过滤器加速。

    余额、库存这类必须精确的不能用 Redis 计数,必须走数据库事务,用 update ... where balance >= ? 这种原子条件更新。这个边界要划清楚——绝对不要为了性能把资金类计数放到 Redis 里。

    热点问题是这题的深水区。一个爆款视频的计数 key 会打满单个 Redis 分片。解法是把计数分片:把一个 key 拆成 N 个子 key(count:vid:0count:vid:9),写的时候随机选一个 incr,读的时候把 N 个加起来。写压力被分散到不同分片,代价是读需要 N 次操作(可以用 pipeline 或者定期聚合成一个总数缓存)。这个思路和 Java 里 LongAdder 完全一样。

    展示层的优化:大数字用「1.2万」这种缩写,天然掩盖了小误差;热门内容的计数可以缓存几秒,不用每次请求都读最新——用户压根感知不到 3 秒的延迟,但服务端压力能降一个数量级。

    一致性的兜底:Redis 和数据库最终要能对上,所以要有定时校准任务,按真实数据(比如点赞关系表的 count)重算并修正缓存计数。数据不一致时以数据库为准。这个校准任务在计数系统里是必需的,因为 Redis 的丢失和并发问题一定会累积误差。

  4. 商品搜索怎么设计,为什么不能直接用数据库 like?★★★

    like '%关键词%' 用不了索引(前缀通配符),几十万商品就是全表扫描;而且它只能做字面匹配——搜「游戏点卡」匹配不到标题里写「游戏充值卡」的商品,更别提拼写错误、同义词、多语言。所以搜索必须走搜索引擎(ES 或 OpenSearch)。

    核心是倒排索引:正常索引是「文档 → 内容」,倒排索引是「词 → 包含它的文档列表」。搜索时把查询词分词后,直接取出各个词的文档列表求交集,天然适合全文检索,复杂度和文档总数基本无关。

    分词是国际业务的最大难点,这块最能体现有没有真做过。中文没有天然空格,要用 IK 这类分词器,而且要维护业务词典(商品名、游戏名、品牌名不能被切碎,「英雄联盟」不该被切成「英雄」和「联盟」)。不同语言要用不同的分析器:英文要做词干还原(running → run)和大小写归一,日文韩文各有专门的分词器,阿拉伯语还要处理变形。所以多语言字段要分开建索引title_entitle_ja),用一套分析器处理所有语言必然效果差。

    数据同步:ES 是近实时的(默认 1 秒刷新),而且它不是数据源,数据库才是。同步方式首选订阅 binlog(Canal),好处是解耦、不漏、业务代码不用管。要注意同步延迟——商品刚上架搜不到是正常的,产品上要能接受,或者「我的商品」这类列表直接查数据库而不查 ES。还要有全量重建的能力(索引结构变更、数据修复时用),一般用别名切换实现无缝重建。

    相关性排序是搜索质量的关键。ES 默认的 BM25 只考虑词频,业务上远远不够——用户搜「点卡」,你要综合考虑销量、评分、库存、是否在售、地区可售、以及付费推广权重。做法是用 function_score 或者脚本打分把业务因子加权进去。还要过滤掉不该出现的:下架的、当前地区不可售的、审核未通过的,这些必须是硬过滤而不是降权。

    体验功能:搜索建议(completion suggester,要支持拼音和首字母)、纠错(fuzzy 匹配,容忍一两个字母错误)、高亮、聚合筛选(按分类、价格区间、面额做 facet 统计)。搜索无结果时要给推荐,这直接影响转化。

    还有两个实践要点。热门搜索词要缓存——搜索请求的头部效应很强,前几十个词占了大部分流量,缓存几秒能挡掉大量 ES 查询。搜索词要埋点并回流:用户搜了什么、点了第几条、有没有下单,这些数据是优化相关性和发现选品缺口(用户搜了但你没有的商品)的依据,业务价值往往比技术优化更大。

  5. 内容审核链路怎么设计?★★

    国际业务的审核比国内更复杂,因为各地区的合规标准不同——同一个内容在一个国家合法、在另一个国家违法。所以审核结果不能是简单的「通过/不通过」,而是「在哪些地区可见」。这是设计上最关键的一点。

    分层审核:机器审核打头阵(覆盖 95% 以上的量),命中明确违规的直接拒;不确定的进人工队列;低风险的先发后审。先审后发还是先发后审是业务和合规的权衡:先审后发安全但发布延迟高、影响创作者体验;先发后审体验好但有风险窗口。实践中常按创作者信用分级——新号和低信用的先审后发,高信用的先发后审加抽检。

    机审要多模态:视频抽帧做图像识别、音频转文字做文本识别、标题和描述做文本审核、还要检测音乐版权(国际平台的版权诉讼风险很高)。要注意多语言文本审核——违规词库要覆盖所有目标语言,包括变体和谐音绕过,这块投入很大。

    人工审核系统要考虑:任务分发和优先级(举报多的、流量高的优先)、审核员的地区和语言匹配(审阿拉伯语内容要懂阿拉伯语和当地文化)、双人复核机制(争议内容)、审核质量抽检、以及审核员的心理健康保护(这不是玩笑,是这个行业的真实问题)。

    技术架构:审核是异步长任务,用队列驱动。审核状态和内容状态分开(内容可以是「已发布但仅部分地区可见」)。审核结果要能回溯和复议——创作者申诉后要能重审,所以要留完整的审核记录和依据。还要支持事后下架:已发布的内容因为举报或法规变化需要撤下,要能快速全网生效(清 CDN 缓存、更新 Feed 过滤)。

    举报链路:用户举报要能快速聚合(同一内容被多人举报要提权),高危举报(涉及未成年人、暴恐)要立刻自动下架再审,不能等人工排队——这类内容多留一分钟都是风险。

    最后是可观测性:审核通过率、机审准确率、人工处理时长、误判申诉率都要监控。国际平台还要能按地区出合规报告,因为监管机构会要求。

三、社交与互动(设计)

  1. 直播打赏和虚拟礼物怎么设计?★★★

    打赏本质是资金业务,所以正确性要求和支付一致,但它又有高并发和实时性要求,这是设计上的主要矛盾。

    资金模型:先充值成平台虚拟币(钻石、金币),再用虚拟币打赏。这么做有几个实际原因:减少支付次数(打赏是高频小额,每次都走支付渠道手续费吃不消,而且渠道会把高频小额判定为可疑)、规避汇率复杂度(虚拟币统一定价,各币种只在充值时换算一次)、合规更清晰(虚拟币不是货币,减少金融监管敏感度)。充值走标准支付链路,打赏走内部账务扣减。

    打赏的并发正确性:扣减用户虚拟币余额必须强一致,用数据库 update ... where balance >= amount 原子扣减,绝对不能放 Redis。同时要防重复提交(幂等号)。这一步不能为了性能妥协,因为余额是钱。

    主播收益侧可以异步:扣款成功后发消息,异步累加主播收益、写流水、更新榜单。收益不需要实时精确到毫秒,但不能丢,所以走可靠消息加对账。

    直播间的实时展示是另一条路径,和资金链路解耦:打赏成功后往直播间推送特效和消息。这条链路可以丢消息、可以合并、可以降级——用户看不到一个特效不影响资金正确性。所以用 WebSocket 广播,高并发时合并推送(一秒内的多个礼物聚合成一条)、降级(只推大额礼物,小额只更新计数)。热门直播间几十万人在线,逐条推送会直接打爆网关。

    连击和批量要特殊处理:用户连点送 99 个礼物,不能产生 99 次扣款和 99 条推送。做法是前端聚合成一次请求带数量,后端一次扣减。

    榜单用 Redis ZSet,按小时/日/总榜分开。要注意榜单的一致性——Redis 挂了要能从流水重建,所以流水是唯一真相,榜单只是缓存。

    风控和合规是国际业务的重点:洗钱风险(大额打赏后主播提现是典型的洗钱路径,必须有 KYC 和异常监控)、未成年人保护(多国法规要求限制未成年人消费,需要年龄验证和消费限额,出事故的话平台责任很大)、诱导消费的合规(部分地区对虚拟礼物的营销方式有限制)。这几条不是技术问题但优先级极高。

    另外退款和纠错要有流程:误打赏、被盗号打赏、主播违规后的退款,都需要能冲正——扣回主播收益(可能已提现,要处理欠款)、返还用户虚拟币。这个链路很麻烦但必须有。

  2. 私信 / IM 消息系统怎么设计?★★★

    核心是三件事:消息可靠不丢、多端同步、离线消息拉取。实时推送反而是相对简单的部分。

    连接层:用 WebSocket 长连接,网关层要能管理海量连接(单机几万到十几万),并维护用户到连接的路由表(存 Redis,记录用户当前连在哪台网关)。发消息时查路由找到目标网关再推送。跨地区部署时要处理跨机房推送——用户 A 在欧洲、用户 B 在亚洲,消息要跨洋转发,这块延迟不可避免,架构上要接受。

    消息的可靠性「先存储再推送」:消息到达服务端先落库并分配递增的序列号(会话内单调递增),然后才推送。这样即使推送失败,消息也没丢,客户端可以靠序列号拉取。序列号是整个方案的基础——客户端知道自己收到了第 100 条,就能判断有没有漏(缺号了就补拉),也能实现增量同步。

    多端同步:用拉模式为主推模式为辅。推送只是「通知有新消息」的信号,客户端收到信号后按自己本地的最大序列号去拉增量。这样多端各自维护进度,天然一致,也不怕推送丢失。纯推模式在多端场景下几乎必然出现不一致。

    离线消息:用户不在线时消息存着,上线后按序列号拉。要有拉取上限和分页(离线一个月不能一次拉十万条),超过阈值只拉最近的并提示。

    已读和未读数:未读数不要实时精确计算(每次查数据库 count 扛不住),用「会话最大序列号 - 用户已读序列号」来算,两个数字都是 O(1) 读取。已读回执要考虑是否需要(国际产品对已读状态的隐私敏感度不同,有些市场用户不喜欢被看到已读)。

    存储:消息量巨大且只增,要按会话 ID 分库分表,并且冷热分离(近期消息在快存储、历史消息归档)。要考虑存储成本——国际平台的消息量可能是几十亿条,全放在线库成本很高。

    内容安全是必须做的:文本和图片要过审核(多语言违规检测),要有举报和封禁机制。私信是骚扰、诈骗、色情内容的高发渠道,很多国家对平台有法定的处置义务,做不好会有合规风险。还要能按监管要求留存和调证

    体验细节:消息发送要有本地回显(先显示成功再确认,失败标红重发)、客户端去重(用客户端生成的唯一 ID 防重复插入)、顺序保证(按序列号排序而不是按到达时间)。

  3. 关注关系和社交图谱怎么设计?★★

    数据模型要双向存储:关注表(谁关注了谁)和粉丝表(谁的粉丝有谁),本质是同一份关系的两个视图。为什么要冗余存两份——因为「我关注的人列表」和「我的粉丝列表」是两种完全不同的查询,各自需要不同的索引。单表加两个索引在数据量大时性能不如拆开。

    关注操作要保证两张表的一致性,用本地事务(同库)或者可靠消息(分库)。要幂等:重复关注不能产生两条记录,靠唯一索引(关注者 + 被关注者)。

    计数(关注数、粉丝数)不要实时 count,单独维护计数字段或者放 Redis。大 V 的粉丝数是热点,几百万粉丝的账号被关注时计数更新很频繁,要用前面说的计数分片。计数要有定时校准任务按真实关系重算,因为异步更新一定会累积误差。

    关系判断(我有没有关注他、是否互关)是高频查询,用 Redis 的 Set 或者布隆过滤器加速。互关判断要注意不能查两次数据库,可以在关注时就标记互关状态。

    大 V 问题是这题的核心:粉丝数千万的账号,「拉取全部粉丝」这个操作压根不可行。所以设计上要避免任何需要遍历全部粉丝的逻辑——发布内容不能推给所有粉丝(这就是前面 Feed 那题说的推拉结合,大 V 用拉模式);粉丝列表只支持分页游标查询,不支持随机跳页;粉丝数只显示不精确的缩写(「1.2 亿」)。

    存储和分片:按用户 ID 分库分表。这里有个矛盾——按关注者分片则「查我的粉丝」要跨库,按被关注者分片则「查我关注的人」要跨库。所以两张表用不同的分片键:关注表按关注者分片、粉丝表按被关注者分片,各自的主查询都在单库内完成。这是冗余存储的另一个理由。

    衍生功能:共同关注(求交集,大 V 场景要限制计算规模)、推荐关注(基于二度关系,通常离线计算)、拉黑(要和关注互斥,拉黑时自动取消关注,并且要在 Feed 和消息各处生效)。

    国际化的考虑:多地区部署时关系数据要就近存储,但跨地区关注是存在的(欧洲用户关注亚洲创作者),所以要么全球同步关系数据,要么接受跨地区查询的延迟。这个取舍要根据用户跨区关注的实际比例来定。

  4. 消息推送系统怎么设计?★★

    先分清两类推送,架构差异很大:业务推送(订单发货、有人回复你、卡密到账)针对单个用户、要求可靠到达;营销推送(活动通知、内容推荐)是海量群发、允许丢失、但要控制打扰。

    通道:移动端走系统推送服务(APNs、FCM),国内 Android 还要接各厂商通道;Web 端用 Web Push;应用内用长连接或者拉取。国际业务的关键是 FCM 和 APNs 的可达性——某些地区 FCM 不可用(比如中国大陆),需要备用方案或者降级成应用内消息。

    设备令牌管理:一个用户可能有多个设备,令牌会失效(卸载重装、系统更新),要处理推送返回的无效令牌并及时清理,否则无效令牌越积越多,推送成功率统计失真,还浪费配额。

    海量群发的架构:用队列削峰,分批推送并限速。不能一次性把千万条推送灌给 FCM,一是会被限流,二是会造成瞬间的服务端流量洪峰(用户同时点开 App)。所以要控制推送速率并错峰——这一点很多人只考虑推送本身,忽略了推送带来的反向流量冲击,实际上大规模推送后的流量峰值可能比推送本身更危险,必须提前评估服务端容量。

    个性化和时区:国际业务必须按用户本地时间推送——给欧洲用户在他们的凌晨推营销消息,转化率极低还会导致卸载。所以群发任务要按时区分组,在各自的合适时间窗执行。这需要知道用户时区,从注册地或行为推断。

    频次控制和用户偏好:要有全局的打扰控制(每人每天最多几条、同类消息不重复推),以及用户可配置的订阅偏好(分类型开关)。合规上营销推送需要用户同意(GDPR),且必须提供便捷的退订方式。做不好的后果是大量卸载和应用商店的差评。

    可靠性:业务推送要有降级链路——推送失败或用户没开推送权限时,降级成应用内消息、站内信、或者邮件。重要通知(支付成功、账户异常)不能只依赖推送,必须有站内可查的记录,因为推送本身是不保证到达的。

    监控:按通道、地区、消息类型统计发送量、到达率、点击率。到达率异常下降往往是令牌失效积累或者通道故障,需要告警。

四、社区与内容(设计)

  1. 多级评论(楼中楼)怎么设计存储和查询?★★★

    先明确一个产品事实:真正无限层级的评论树体验很差,主流社区(小红书、B 站)实际都是两级结构——一级评论加它下面的回复,回复之间只标注「回复某人」而不再嵌套。这个决定直接简化了整个技术方案,所以第一句话应该是确认层级策略而不是急着设计递归表。

    存储设计:一张评论表,关键字段是 root_id(所属的一级评论,一级评论自己的 root_id 就是自己)、parent_id(直接回复的对象,用于展示「回复 @某人」)、reply_count(一级评论下的回复数)。root_id 而不是靠递归查 parent_id,是这个设计的核心——一次查询就能把某条一级评论下的所有回复捞出来,不需要递归。

    查询链路分两步:先分页查这篇内容的一级评论(按热度或时间排序),再批量查这批一级评论下的前几条回复(where root_id in (...),每条取 3 条预览)。绝对不能循环里逐条查回复,那就是 N+1 问题。用户点「展开更多回复」时才单独分页查某个 root_id 下的全部回复。

    排序是难点。一级评论要按热度排(点赞数、回复数、时间衰减的综合分),而热度是动态变化的,不能直接 order by 一个实时计算的表达式。做法是维护一个 hot_score 字段,由点赞和回复事件异步更新,加索引后直接排序。热门内容的前几页可以缓存。

    楼主置顶和精选要单独字段,排序时优先。

    计数一致性:评论数、回复数、点赞数都是冗余字段,会和实际数据不一致(并发、异步更新失败)。所以要有定时校准任务按真实数据重算。前端展示用缩写(「1.2 万条评论」)能掩盖小误差。

    删除的处理要想清楚:一级评论被删了,它下面的回复怎么办?通常是软删除并展示「该评论已删除」占位,保留下面的回复,因为直接级联删除会让其他用户的内容莫名消失。而且软删除也是合规和申诉的需要——被举报删除的内容要能追溯和恢复。

    写入侧的保护:评论是 UGC 高频写入,要防刷(频率限制、同内容去重)、要过审核(先发后审或先审后发)、要处理热门内容的评论洪峰(几万人同时评论一条爆款笔记,写入要削峰、计数要用前面说的分片)。

  2. 关注流和推荐流怎么混合,新用户冷启动怎么办?★★★

    这题的关键是理解两种流解决的是不同问题:关注流保证确定性(我关注的人发的我一定能看到,这是社交契约),推荐流保证丰富度(不断给我新内容,撑住停留时长)。混合的目标是既不辜负社交关系,又不让内容枯竭。

    混合策略有几种做法。分 Tab最简单直接(「关注」和「发现」两个入口),用户明确知道自己在看什么,实现也解耦。单流混排体验更连贯但复杂:需要按比例插入(比如每 5 条推荐插 1 条关注内容),而且要处理「关注内容不够」和「关注内容太多」两种情况。

    我倾向分 Tab 为主,在发现流里少量插入关注内容——因为关注流的时效性要求高(好友发的内容隔天才看到就没意义了),混在推荐流里容易被算法压下去。

    关注流的实现就是前面 Feed 那题的推拉结合:普通用户写扩散(发布时推进粉丝收件箱),大 V 用读扩散(读时实时聚合),活跃用户优先推、沉默用户用拉。因为社区的头部创作者粉丝量极大,纯推模式会在发布时产生百万级写入。

    冷启动是这题的重点,也是社区产品的生死线。新用户没有关注关系、没有行为数据,推荐系统无从下手。分几层解决:

    一、注册时的兴趣选择。让用户勾选几个兴趣标签,直接拿到初始的内容偏好。这是最直接有效的,代价是多一步注册流程(要权衡流失率)。

    二、非个性化的优质内容池。全局热门 + 高质量内容兜底。这个池子要人工加算法维护,标准是「大多数人都会觉得不错」——所以要过滤掉小众、争议、时效性强的内容。

    三、快速试探与收敛。新用户的前几十次滑动是探索期,要故意给多样化的内容(不同类型、不同话题),根据他的停留时长、点赞、划过速度快速判断兴趣,几分钟内收敛到个性化。这个「探索与利用」的平衡是推荐系统的核心命题。

    四、可用的外部信号:注册地区、设备型号(间接反映消费能力)、来源渠道(从哪个投放广告进来的,暗示了兴趣)、通讯录或社交关系导入(如果用户授权)。

    还要注意反向的冷启动新创作者的内容没人看,会导致创作者流失。所以推荐系统要给新内容保底的曝光量(进入一个小流量池试探),表现好的再逐级放大。这个「流量池赛马」机制是内容社区的标准做法,也直接影响创作者生态的健康度。

  3. 「我有没有点赞过这条内容」这类互动状态怎么设计?★★★

    这题看起来简单,但它是社区里最高频的查询之一——Feed 流一次返回 20 条内容,每条都要知道当前用户的点赞、收藏、关注状态,等于一次请求要判断几十个状态。设计不好会直接成为瓶颈。

    错误做法是循环里逐条查数据库,20 条内容就是 20 次查询(甚至 60 次,因为有三种状态)。必须批量查:一次 where user_id = ? and note_id in (...) 拿回全部,在内存里组装。这是最基础也最容易被忽略的一点。

    存储:点赞关系表用「用户 ID + 内容 ID」建唯一索引,天然防重复点赞,也是幂等的保证。点赞接口要设计成「设置为某个状态」而不是「切换」,否则网络重试会导致状态翻转——用户点了赞,超时重试一次,结果变成取消赞。

    高频查询的优化按数据量分级。量小时把用户的点赞集合整个缓存进 Redis 的 Set,判断用 SISMEMBER(批量用 SMISMEMBER),O(1) 且一次网络往返。量大时(重度用户点赞几万条)整个 Set 太占内存,改用布隆过滤器做前置判断——「说没赞过就一定没赞过」直接返回,「说赞过」再查数据库确认。布隆的假阳性在这里可以接受,因为只是多一次查询。

    计数和状态要分开看:内容的总点赞数是一个数字(走计数分片,前面讲过),当前用户是否点赞是一个关系判断,两者的存储和优化方式完全不同。而且总数和关系可能短暂不一致——用户刚点赞,关系写入了但计数还没更新完,前端要用乐观更新掩盖这个延迟。

    前端配合是必须提的:点击立刻改本地状态和计数(乐观更新),失败再回滚。连点要合并成一次请求(记录「用户期望的最终状态」,防抖后提交)。请求乱序要靠版本判断丢弃过期响应。这些细节直接决定交互流畅度,短视频和社区场景对反馈速度极其敏感。

    取消点赞的存储选择值得一提:是物理删除还是软删除加状态字段?物理删除省空间,但丢失了「曾经点赞过」的信息——而这个信息对推荐算法有价值(用户对这类内容有过正反馈)。所以数据分析要求高的场景会用软删除,或者把行为流水单独存进数仓。

  4. 话题标签体系怎么设计?★★

    标签是社区里连接内容、用户、流量的枢纽,设计上要分清三种来源的标签,它们的治理方式完全不同。

    用户自建标签(发布时随手打的 #话题):自由度高、能捕捉新趋势,但会产生大量同义和垃圾标签(「美食」「美食分享」「吃货」「好吃的」)。运营维护的官方标签:规范、可运营(做活动、给流量),但覆盖不全。算法生成的标签:从内容里自动提取(图像识别、文本抽取),覆盖全但准确率有限。

    实践中三者结合:用户输入时做联想推荐(优先引导用到已有标签,这是控制标签爆炸最有效的手段)、后台做同义词合并(把「吃货」映射到「美食」,展示用户输入的但索引用标准标签)、算法标签作为补充用于推荐而不对外展示。

    存储:标签表加「内容-标签」关联表。关联表要双向索引——按内容查标签(详情页展示)和按标签查内容(话题页),这两个查询方向都是高频。热门话题页的内容列表要缓存。

    热度计算是话题运营的核心:按时间窗口内的发布量、浏览量、互动量算热度,而不是累计值(否则永远是那几个老话题占榜)。要有时间衰减,让新兴话题能冒出来。榜单用 Redis ZSet 按小时和天分别维护。

    必须做的治理违规标签拦截(敏感词、恶意引流、诱导性话题),这是合规要求;刷量识别(有人批量发内容刷话题热度做营销);标签合并与废弃的流程,而且合并时要能批量迁移历史内容的关联关系。

    国际化的额外问题:同一个话题在不同语言下是不同的字符串(#美食 / #food / #グルメ),要不要打通?打通了能聚合全球内容但文化语境差异大(不同地区对同一话题的理解和内容完全不同),不打通则各语言市场割裂。实践上通常按语言或地区分开维护话题,只在少数全球性话题上做映射。这个取舍要说出来,它是国际社区产品的真实难题。

  5. 内容去重和洗稿检测怎么做?★★★

    要分三个层次,难度和手段递增。

    一、完全相同的内容(搬运转载)。最简单:对内容算哈希,图片和视频算文件哈希或者内容哈希,直接比对。这层能挡掉一键搬运。但攻击者稍微改一下(加个水印、裁剪、改几个字)就绕过了。

    二、近似重复(改动后的搬运)。这层是重点。图片用感知哈希(pHash、dHash)——它对缩放、压缩、轻微裁剪、加水印不敏感,两张图的哈希汉明距离小于阈值就判定相似。文本用 SimHash 或 MinHash——把文本转成指纹,改动几个词指纹变化很小。视频抽取关键帧后用图片的方法比对,或者算音频指纹。

    检索是工程难点:库里有几亿条内容,不可能逐个比对哈希。做法是把指纹分段建索引(SimHash 分成几段,只要有一段完全相同就作为候选,再精确算距离),或者用向量检索引擎(Faiss、Milvus)做近邻搜索。这一步的性能设计比算法选择更关键。

    三、语义级洗稿(改写、换表述、拼接多篇)。这层最难,传统哈希完全失效。手段是用向量化模型把内容转成语义向量,用余弦相似度判断。这能识别「同样的意思换了说法」。代价是计算成本高、而且误判风险大——同一个话题的正常内容本来就相似(都在写同一家餐厅的探店笔记),判定为洗稿会伤害正常创作者。

    所以策略上必须分级处置,这是这题最该讲清的部分:完全重复的直接拦截;高度相似的降权限流(不封杀,只是少给推荐);疑似洗稿的转人工审核或者标注「相似内容」让用户自己判断。绝对不要对语义相似直接封号,误伤成本远高于漏放。

    还要处理「谁是原创」的判定:不是先发布的就一定是原创(可能是从站外搬来的)。所以要综合发布时间、创作者历史信用、内容完整度、以及站外检索。争议内容要有申诉通道,创作者能提供证据。

    业务价值要说出来:内容去重直接关系社区生态健康——如果搬运的内容能轻易获得流量,原创者就会流失,而原创内容是社区的核心资产。同时它还是版权风险的防线,国际平台在这块的法律风险很实际(DMCA 投诉、集体诉讼)。

  6. 反垃圾和水军识别怎么做?★★★

    先明确对手是谁,不同对手的手法和治理成本差别很大:广告营销号(发导流内容)、刷量水军(刷赞刷评论刷粉)、黑产账号(批量注册用于出售或诈骗)、恶意攻击(人身攻击、有组织举报)。

    识别的信号分三层。

    内容层:文本的敏感词和变体(拼音、谐音、拆字、夹符号、图片里放文字,攻击者会不断变形所以词库要持续更新)、外链和联系方式识别(微信号、电话、二维码,这是导流的核心特征)、图片 OCR 识别文字。

    行为层(比内容层更有效):发布频率异常(正常人不会一分钟发五条)、行为过于规律(每天固定时间、间隔完全一致,机器特征明显)、内容高度同质(同一账号反复发几乎相同的内容)、互动模式异常(只点赞不浏览、浏览时长极短就点赞、点赞对象高度集中)。

    关系层(识别有组织行为的关键):设备和 IP 聚集(一批账号共用设备指纹或同网段)、注册时间聚集(同一批注册的账号)、互动图谱异常(一批账号总是互相点赞、或者集中给同一个人点赞,形成明显的团伙结构)。用图算法做社群发现能把整个水军团伙一网打尽,这比逐个账号判断有效得多——这是反垃圾里最有价值的手段。

    处置要分级而且要有延迟。这一点很重要:不要一识别到就立刻封号,因为那会给攻击者即时反馈,他们能快速试探出你的规则边界然后绕过。更好的做法是「静默降权」——内容照样能发但只有自己可见,或者只给极少曝光。攻击者看不出被识别了,会继续投入成本做无效的事。这个「延迟且模糊的反馈」策略在对抗中非常关键。

    要接受误判存在,所以必须有申诉通道和人工复核,而且要监控误判率。风控指标不能只看「拦了多少」,还要看「误伤了多少」——把正常用户当水军封掉,一个案例在社交平台上传播开的代价就很大。

    架构上:内容层用同步的规则引擎(发布时实时拦截明显违规),行为和关系层用离线或近线计算(跑图算法、聚类分析,天级或小时级),两者结合。规则要能热更新,因为对抗是持续的,改规则要分钟级生效而不是发版。

  7. 内容社区的「种草到成交」链路怎么设计?★★★

    这是内容社区做电商的核心命题,技术上的难点是把「内容」和「商品」这两套原本独立的体系打通,并且能把成交归因回内容

    第一步是建立关联。内容里要能挂商品,来源有三种:创作者主动挂载(选择商品库里的 SKU)、算法识别(从图片和文本里识别出商品,推荐关联)、运营配置(活动内容批量挂载)。存储上是「内容-商品」关联表,要支持一篇内容挂多个商品、一个商品被多篇内容挂。

    第二步是链路的完整性。用户路径是:看内容 → 点商品卡 → 商品详情 → 下单 → 支付。每一步都要带上来源标识(内容 ID、创作者 ID、位置),并且这个标识必须贯穿到订单——这是归因的基础。技术上通常是生成一个带追踪参数的链接或者在会话里透传上下文。

    第三步是归因,这是最容易做错的部分。用户可能看了 A 的笔记、又看了 B 的视频,最后下单了,这笔佣金给谁?归因模型的选择是业务决策末次归因(给最后接触的,最简单也最常用)、首次归因(给最早种草的)、多触点分配(按权重拆分,最公平但最复杂且容易引发争议)。还要定归因窗口——用户三天前看的笔记今天下单还算不算?窗口太短创作者不满,太长则归因失真。

    这里必须提跨端归因的技术难点:用户在 App 里看内容,跳到浏览器完成支付,或者看完之后隔天从别的入口下单,怎么把行为串起来?靠登录用户 ID 最可靠,未登录时只能靠设备指纹加时间窗口做模糊匹配,准确率有限。这是行业公认的难题,方案上只能尽力而为并明确告知规则。

    第四步是佣金结算。订单成交后按归因结果算佣金,但不能立刻结算——要等过了退货期(虚拟商品也有退款窗口),确认交易最终成立才结。所以要有预估佣金结算佣金两个状态,创作者能看到预估但要等一段时间才到账。退款要能冲回佣金,如果已经结算出去了就变成欠款,要在下次结算中扣回——这套逻辑和前面供应商结算那题是同一类问题。

    还有个容易忽略的合规点:很多国家强制要求标注广告和商业合作(欧盟、美国 FTC 都有明确规定),未标注的商业推广会被处罚。所以系统要能识别商业内容并强制打标,这不是产品选项而是法律要求。

    反作弊也要有:刷单套佣金(自己买自己推的商品)、虚假种草、恶意点击刷曝光。要监控异常的转化率和订单模式。

  8. 创作者激励和流量分配怎么设计?★★

    这题本质是用技术手段实现生态调控,所以要先说清目标:既要让优质创作者获得回报(不然会流失),又要让新人有机会冒头(不然生态僵化),还要防止刷量套利。

    流量分配的核心机制是「流量池赛马」。新内容先进入一个小流量池试探(给几百次曝光),根据表现(完播率、互动率、转化率)决定是否升级到更大的池子。表现好的层层放大,表现差的自然沉底。

    这个机制的好处是新人和大 V 在同一起跑线上——你的内容好就能拿到流量,不完全依赖粉丝量。这是内容社区能持续产生新爆款的根本原因,也是它和纯关注流产品的区别。

    关键指标不能只看点赞。要用综合质量分:完播率或阅读完成率(内容是否真的吸引人)、互动率(赞藏评的比例而不是绝对值)、负反馈率(划过、不感兴趣、举报,这个权重要高)、以及时长分布只看绝对点赞数会让大 V 永远占优势,因为他们的基数大;用「率」才能公平比较。

    激励的形式:现金分成(按播放量或互动量)、流量券(帮你推内容)、商业合作机会(撮合品牌)、平台身份和权益。现金分成最直接但最容易被刷,所以通常和内容质量、账号信用绑定,而且有反作弊门槛。

    反作弊是激励设计的必要配套,否则激励金就是给黑产送钱:刷播放(要识别异常的播放行为,比如无浏览时长的播放)、刷互动(团伙识别,前面反垃圾那题讲过)、内容农场(批量生产低质内容薅激励)、搬运(去重检测)。结算前必须过风控,而且要有延迟结算期给风控留出识别时间。

    技术实现:质量分和激励金额的计算是离线批处理(天级),因为要用完整的行为数据且计算复杂。但流量池的升降级要近实时(分钟级),否则错过内容的爆发窗口。所以这是一套「离线算分 + 近线调度」的混合架构。

    还有一点要提:激励规则必须公开透明且稳定。创作者会研究规则并调整创作策略,规则频繁变动或者不透明会引发大规模不满——历史上多个平台都因为悄悄改分成规则引发过创作者集体抗议。所以规则变更要提前公示,这是产品和运营的约束,但技术上要支持规则版本化和灰度(新老规则并行一段时间)。

五、本地生活与 LBS(设计)

  1. 「附近的店」怎么实现,海量 POI 的地理检索怎么做?★★★

    核心问题是二维的地理坐标没法直接用 B+ 树索引。经度和纬度各建索引再取交集效率很差——查「附近 3 公里」会变成两个大范围扫描后求交集,扫描量巨大。

    主流方案是 GeoHash:把二维坐标降维成一维字符串。原理是把地球递归地二分(经度和纬度交替),用 Base32 编码成字符串。它的关键性质是前缀相同则位置相近——所以「找附近」就变成了「找前缀相同的记录」,可以直接用普通的 B+ 树索引做前缀匹配。字符串越长精度越高,一般用 6 到 8 位(6 位约 1.2 公里,7 位约 150 米)。

    GeoHash 有个必须处理的缺陷边界问题。两个物理上很近的点可能落在不同的网格里,前缀完全不同(正好在网格边界两侧)。所以查询时必须同时查中心网格加周围 8 个网格,一共 9 个前缀,再对候选结果精确计算距离并排序。不处理这个的话,会出现「明明就在马路对面却搜不到」的问题。

    Redis GEO 是更省事的选择,底层就是 GeoHash 加 ZSet(把 GeoHash 编码成分数存进有序集合),提供 GEOADDGEOSEARCH 直接查半径或矩形范围内的点,还自动算距离和排序。中小规模直接用它,不用自己实现。局限是数据量大时内存占用高,而且不好和业务条件组合查询——「附近 3 公里且正在营业且评分 4.5 以上且支持配送」这种复合条件它做不了。

    所以生产方案通常是 ES:ES 原生支持 geo_point 类型和地理查询(底层是 BKD 树,比 GeoHash 更高效),而且能把地理条件和业务条件、全文检索、排序聚合组合在一起。这才是「附近的店」真实的实现方式,因为实际查询几乎从不只有距离条件。

    排序不只按距离。这是业务重点:真实的「附近」列表要综合距离、评分、销量、是否营业、配送时长、以及推广权重。纯按距离排的体验其实很差(隔壁一家差评店排在 500 米外的好店前面)。所以距离是过滤条件加排序因子之一,而不是唯一排序依据。

    还有几个实践点店铺位置基本不变,所以索引更新频率低、可以充分缓存;但「附近的人」或「附近的骑手」位置高频变动,写入压力大得多,通常用 Redis GEO 加短过期时间,而不是持久化索引。热门区域的查询结果要缓存(同一个商圈的用户查询结果高度相似,按 GeoHash 网格做缓存 key 效果很好)。

  2. 地理围栏(配送范围)怎么设计和判断?★★★

    业务需求是:判断用户地址是否在某个商家的配送范围内。看着简单,但围栏的形状决定了实现难度。

    三种围栏形态。圆形(以店为中心的半径)最简单,算两点距离和半径比较就行,但不符合实际——河流、高架、单行道会让直线距离近但实际到不了。多边形(运营手工画的范围)最常用,能贴合真实路网和商圈边界。行政区或商圈(按区县、街道划分)适合粗粒度业务。

    多边形的判断算法射线法(ray casting):从待判断的点向任意方向发一条射线,数它和多边形边的交点个数,奇数在内、偶数在外。复杂度是 O(n),n 是多边形顶点数。

    性能优化是关键,因为一个城市可能有几万个商家围栏,不能逐个做射线判断。做法是两级过滤:先用外接矩形(bounding box)做粗筛——把每个多边形的最小外接矩形存下来并建索引,先按矩形快速排除绝大多数候选(这一步可以用 GeoHash 或 ES 的 geo 查询);剩下的少数候选再做精确的射线判断。这个「粗筛加精算」的两级结构是空间计算的通用套路。

    存储和查询:ES 支持 geo_shape 类型,能直接存多边形并做 geo_shape 查询(判断点是否在形状内),省掉自己实现。PostGIS(PostgreSQL 的空间扩展)功能更强,专业的地理业务会用它。MySQL 8 也有基本的空间函数(ST_Contains)但性能和功能都弱一些。

    业务上要处理的复杂情况,这些比算法更能体现经验:围栏重叠(多个商家都能配送到,要按距离或运力排序);分时段围栏(午高峰缩小范围保运力,深夜可能停配);动态调整(暴雨天或运力不足时临时收缩,所以围栏配置要能热更新且分钟级生效);围栏内的分级(近距离免运费、远距离加价,所以要支持多层围栏)。

    还有一个高频的实际问题用户地址的经纬度是怎么来的?用户填的文本地址要做地理编码(geocoding)转成坐标,而这一步本身就可能出错——地址写得含糊、小区重名、新建楼盘还没入库。所以定位不准是「明明在范围内却下不了单」这类投诉的主要来源之一。缓解手段是让用户在地图上手动拖动确认位置,并保存这个精确坐标而不是每次重新编码。

  3. 骑手实时位置上报和轨迹存储怎么设计?★★★

    这是典型的高频写入场景,也是本地生活业务里数据量最大的一块。核心矛盾是上报频率、定位精度、电量消耗、存储成本四者之间的平衡。

    上报频率要动态调整,这是第一个设计要点。骑手接单配送中要密集上报(3 到 5 秒,因为用户在实时看骑手位置);空闲待接单时可以稀疏(30 秒到 1 分钟,只需要知道大概位置用于派单);静止不动时进一步降频甚至暂停。固定高频上报会显著耗电,骑手会因此关掉定位权限,那整个调度系统就瘫了——所以省电是硬需求而不是优化项

    写入链路:客户端批量上报(攒几个点一起发,减少请求数和耗电),服务端接收后写 Redis 作为最新位置(用 GEO 结构,供派单和实时展示查询),同时投递到消息队列异步落库存轨迹。不要同步写数据库——几万骑手每 3 秒一次就是每秒上万写入,数据库扛不住而且没必要。

    最新位置和历史轨迹要分开存,这是关键的架构决策:最新位置只有一份、高频覆盖、要求低延迟读,放 Redis;历史轨迹只增不改、量极大、查询频率低,用时序数据库(InfluxDB、TDengine)或者按时间分区的表,并做冷热分层(近期在线、历史归档到对象存储)。

    数据量要主动估算:一万骑手,配送中平均 5 秒一个点,一天工作 10 小时,就是一万乘七千两百个点,每天七千多万条。所以存储成本必须算清楚,而且要定明确的保留策略(原始点保留 7 天,之后只保留抽稀后的轨迹)。

    轨迹的处理:原始 GPS 点有噪声(漂移、隧道里丢失、室内不准),直接连线画出来会很乱。所以要做轨迹纠偏(把点吸附到路网上,叫 map matching)和抽稀(用道格拉斯-普克算法减少点数但保持形状)。展示给用户的轨迹一定是处理过的。

    业务用途决定了设计取舍:实时位置用于派单和用户查看(要求低延迟、精度中等);历史轨迹用于异常检测(是否绕路、是否提前点送达、是否代人跑单)、路径优化分析纠纷举证(用户投诉未送达时的证据)。最后这个用途意味着轨迹数据不能随意删除,要满足一定的保留期。

    隐私合规要提:位置是敏感个人信息,采集要有明确告知和同意,非工作时间不应采集,数据访问要授权和留痕。国际业务里这是硬性的法律要求(GDPR 明确把位置列为个人数据)。

  4. 订单履约链路怎么设计(到店 / 配送)?★★★

    本地生活的订单和电商订单最大的区别是有一个线下履约过程,而且这个过程涉及多方(用户、商家、骑手),状态多、异常多、时效要求强。

    状态机是核心,而且要比电商订单复杂得多。配送单的主链路是:待接单 → 商家已接单 → 制作中 → 待取货 → 骑手已接单 → 已取货 → 配送中 → 已送达 → 已完成。每个状态都有超时和异常分支:商家不接单(自动取消或转其他商家)、无人接单(加价召回骑手)、超时未取货、配送超时、用户不在家、地址错误。

    「多方状态不一致」是最难的部分。商家说做好了、骑手说没取到货、用户说没收到——三方各有各的操作入口,状态可能冲突。设计上的应对:状态流转必须有明确的操作方和前置条件(谁能把状态改成什么、从什么状态改),关键节点要有双方确认或客观凭证(骑手取货扫码、送达拍照或用户确认码)。这些凭证在纠纷时是唯一依据。

    到店和配送的差异要拆开设计:到店(自提、扫码点餐、预约)没有配送环节,核心是核销(凭证怎么生成、怎么防重复使用、过期怎么处理);配送多出取货和送达环节,核心是运力和时效。两者最好共用订单主体、拆分履约模块,而不是做成两套系统。

    时效预估是体验的关键指标:预计送达时间要综合商家制作时长(按品类和历史数据)、骑手取货距离、配送距离和路况、当前运力紧张度预估不准的代价很大——说 30 分钟结果 60 分钟到,投诉率会飙升。所以要持续用实际数据校准模型,并且宁可保守预估(说 40 分钟 35 分钟到,用户满意度更高)。

    异常处理要占设计的大头,这是和电商最不同的地方:取消(不同阶段的责任和退款规则完全不同——制作前可以全退,制作后商家有损失,配送中还要付骑手费用);缺货(部分商品没有,要支持部分退款或换货);配送失败(联系不上用户,要有明确的处置流程和责任判定)。这些规则要能配置化,因为各城市各品类的政策不同。

    技术实现上:状态流转用状态机保证合法性和幂等;超时用延迟消息加定时扫表兜底(和订单超时关闭同一套机制);多方通知用推送加短信兜底(骑手错过通知会直接影响时效);要有完整的操作流水——履约纠纷比电商多得多,没有流水根本查不清。

  5. 骑手派单和调度怎么设计?★★★

    派单的本质是实时的资源匹配优化问题:把订单分配给骑手,同时满足多个互相冲突的目标。

    目标是多元的而且互相矛盾,这一点要先说清:时效(尽快送达)、成本(少跑空路、多顺路合单)、骑手收入公平(不能让某些人一直没单)、用户体验(准时率)。纯按距离最近派单是错的——会导致某个区域的骑手一直忙、别的闲着,也不能利用顺路合单。

    两种派单模式。抢单(把订单广播出去,骑手自己抢)实现简单、骑手自主性强,但差单没人要(远、难送、金额低),而且抢单会造成骑手分心和不公平。系统派单(算法直接指定)能全局优化、能保证差单也被消化,但要求算法足够好,否则骑手体验差。实践中是混合:主流用系统派单,部分场景开放抢单。

    算法核心是「批量匹配」而不是「逐单分配」。这是关键设计点:不要订单一来就立刻派,而是累积一小段时间(几秒到几十秒)的订单和骑手,做一次批量最优匹配。因为逐单贪心分配的全局结果很差,而批量匹配能做顺路合单(一个骑手带 3 单同方向的),运力利用率能提升很多。数学上这是二分图匹配或指派问题,实际实现用启发式算法(成本函数打分加贪心或局部搜索),因为要在几百毫秒内出结果。

    成本函数要综合:骑手到商家的距离、商家出餐时间(早到也要等)、送达点是否顺路、骑手当前手上的单量、骑手历史准时率和熟悉度、以及订单的紧急程度(快超时的优先)。

    高峰期的策略调整是必答点:午晚高峰运力不足时,要主动降级——延长预估送达时间、缩小配送范围(前面地理围栏那题提过)、限制新订单进入、加价召回运力、暂停部分低优先级订单。宁可少接单也不能大面积超时,因为超时的连锁反应(投诉、退款、骑手也被拖累)比拒单的损失大得多。

    技术架构:需要一个常驻内存的实时状态视图(当前所有可用骑手的位置、载单量、状态),因为匹配计算要毫秒级访问这些数据,走数据库不可能。所以是 Redis 加本地缓存,配合位置上报流实时更新。匹配服务要能按城市或区域分片,因为跨城市的骑手和订单不需要一起计算,分片能让计算量可控并且支持水平扩展。

    还要有兜底:算法服务挂了怎么办?要有降级策略——退化成简单的「就近派单」或者开放抢单,绝对不能因为算法服务不可用导致订单派不出去。这个降级在真实系统里救过命。

  6. 城市维度的配置和灰度怎么做?★★

    本地生活业务的一个根本特点是强地域性:每个城市的运力、商家结构、用户习惯、竞争态势、甚至监管政策都不同。所以「一套配置全国生效」根本行不通,配置的维度设计是这类业务的基础设施

    配置的层级要设计成可继承可覆盖:全局默认 → 大区 → 城市 → 商圈 → 单个商家。查询时从最细粒度往上回溯,找到第一个有值的层级。这样绝大多数城市用默认配置,只有需要特殊处理的才单独配,维护成本才可控。

    典型需要按城市配的东西:配送费规则和起送价、配送范围半径、时效预估的基础参数、骑手补贴标准、营业时间(不同城市作息差异大)、可售品类(有些品类在某些城市受限)、以及各种运营活动。

    灰度也要按城市做,这是和纯线上业务最大的区别:新的派单算法、新的定价策略、新的履约流程,先在一两个小城市试点,跑通了再逐步扩大。因为这类改动直接影响线下履约,出问题不是「页面报错」而是「大量订单超时、骑手和商家投诉」,影响是实体的、不可撤销的。

    试点城市的选择有讲究:要有代表性但体量不能太大(出问题损失可控)、要有完整的业务形态(不能挑一个只有几十个商家的城市)、最好有配合度高的本地团队能快速反馈。

    技术实现:配置中心要支持多维度的配置管理和热更新(改完分钟级生效,不能发版)、配置变更要审批和留痕(配错一个配送费规则可能造成直接资损)、要能一键回滚。配置读取要有本地缓存,因为它在每个请求路径上都会被读到,不能每次都查配置中心。

    还有个容易忽略的点配置的一致性。同一个订单的生命周期跨越几十分钟,期间配置可能被改了。如果下单时算的是旧配送费、结算时用新配置,就会出现金额不一致。所以订单要在创建时把关键配置快照下来,后续流程都用快照而不是实时读配置。这和电商订单锁价是同一个道理。

    监控也要按城市拆:全局指标正常但某个城市的准时率崩了,看总量是发现不了的。所以核心指标(订单量、准时率、取消率、骑手接单率)都要有城市维度的看板和告警。

  7. 午晚高峰的容量和降级怎么做?★★

    本地生活的流量特征极其鲜明:每天两个尖锐的高峰(11 点到 13 点、17 点到 20 点),峰谷比可能有五倍到十倍,而且时间完全可预测。这个「可预测的尖峰」决定了应对策略和电商大促不同。

    因为可预测,所以要提前扩容而不是靠自动伸缩。自动伸缩有冷启动延迟(实例拉起、JVM 预热、连接池建立),而高峰是十几分钟内就冲上来的,等伸缩生效已经晚了。所以做法是按时间表定时扩容——每天 10:30 扩、13:30 缩,这比响应式伸缩可靠得多,成本也可控。

    容量评估要针对高峰:全链路压测要按高峰的流量模型压(下单、派单、位置上报、实时查询的比例和日常完全不同),而且要压出木桶短板。本地生活的瓶颈通常在派单服务和位置上报——前者是计算密集,后者是写入密集。

    降级要分层,而且顺序要提前定好。核心链路是「下单 → 派单 → 履约」,这个不能降。可以降的按优先级:推荐和榜单(换成缓存的静态结果)、评价和图文详情(延迟加载或简化)、非关键的实时性(骑手位置刷新频率从 3 秒降到 10 秒,用户基本感知不到但能省大量请求)、营销和优惠计算(走简化规则)。

    业务层面的降级更有效,这是本地生活特有的手段:缩小配送范围(保住近距离订单的时效)、延长预估送达时间(管理用户预期,比超时投诉好得多)、暂时关闭部分商家或品类限制新订单进入(宁可显示「运力紧张,请稍后」也不要接了单送不到)。这些是产品和技术配合的决策,效果比纯技术优化大。

    缓存策略要针对高峰调整:高峰前主动预热热门商家和商圈的数据(这一点很有效,因为高峰的查询高度集中在热门区域);适当延长缓存时间换取更高的命中率(商家信息晚几分钟更新可以接受)。

    还有一个容易忽略的点高峰过后的「退潮」也有风险。大量订单在同一时段完成,会触发集中的结算、通知、评价请求;定时任务如果排在这个时间点会雪上加霜。所以所有定时任务都要避开高峰和刚过高峰的时段,这是运维规范里必须写清的一条。

  8. POI 数据怎么管理和更新?★★

    POI(兴趣点,也就是商家、地点、建筑)是本地生活和地图业务的基础数据资产,它的质量直接决定产品可用性——地址错了、店关门了没更新,用户体验就崩了。

    数据来源是多方的,这决定了管理复杂度:商家自主入驻填写(最准但覆盖不全,且商家可能填错或夸大)、采购第三方数据(覆盖广但时效差、有错漏)、爬取或众包采集用户反馈纠错算法挖掘(从订单地址、用户搜索里发现新 POI)。

    核心难题是「同一个地点的多份数据怎么合并」,也就是 POI 去重与融合。同一家店可能在不同来源里叫「星巴克(万达店)」「Starbucks 万达广场」「星巴克咖啡万达」,坐标还差几十米。判断是否为同一个 POI 要综合名称相似度(要处理简称、别名、翻译)、坐标距离、地址文本相似度、电话号码、品类。这是个典型的实体对齐问题,规则加模型结合,而且要保留人工审核通道,因为误合并会把两家店变成一家。

    数据模型要能表达层级和关系:商场和商场内的店铺是父子关系(用户搜万达要能出里面的店);连锁品牌和各门店是品牌关系(搜「星巴克」要出附近所有门店);同一栋楼的不同出入口。这些关系不做的话,搜索体验会明显差。

    更新与生效:POI 变更(改名、迁址、关店)要走审核流程并留版本历史,因为错误的变更影响面大(把一家店的地址改错,所有订单都送错)。关店的识别是个实际难题——商家不会主动来告知,要靠信号推断:长期无订单、用户反馈、骑手上报「找不到店」、电话不通。通常做法是先降权或标记「疑似停业」而不是直接下架,然后人工核实。

    索引与查询:POI 数据要同步到 ES 支持地理加文本的组合检索(前面「附近的店」那题讲过)。数据量在千万到亿级,更新是低频的,所以读多写少、可以充分缓存,和位置上报那种高频写入的数据完全是两种架构。

    要提的一个业务价值:POI 数据质量是护城河。高德和美团这类公司在这上面投入巨大(自建采集团队、街景车、众包),因为它无法靠技术短期追赶。理解这一点比记住技术方案更重要——它解释了为什么这类业务的核心竞争力在数据而不在算法。

六、旅游与 OTA(设计)

  1. 酒店库存和电商库存有什么本质不同,怎么设计?★★★

    电商库存是一个可加减的数字,酒店库存是「房型 × 日期」的二维模型——这是根本差别,也是这类面试题的第一句话。

    为什么是二维的:用户住三晚,需要连续三天每一天都有余量,缺任何一天整个预订就不成立。所以库存不能存一个总量,要按「房型 + 日期」逐天存。

    并发扣减的关键是区间原子性:多日扣减必须全成功或全失败。如果逐天扣,很容易出现「前两天扣成功、第三天余量不足」的中间态,这时候已扣的两天就成了碎片——它们被占着但订单没成立,别人也订不到。用 Redis Lua 脚本把多日扣减做成一个原子操作,或者在数据库里用一条带条件的批量更新。

    定价也是按日期的:总价要逐日累加而不是「均价乘天数」——周末和平日价格不同,均价乘天数在跨周末的预订上必然算错。

    还要支持业务规则:最少连住几晚、某些日期关房、提前预订天数限制。这些规则和库存判定是一起生效的。

    验证方式:构造「三天区间里第三天余量为 1」的场景高并发抢,检查失败的请求一天都没扣。这类正确性用确定性场景验证,不要用比率。

  2. 核心打包产品(机票+酒店+门票)怎么下单,中间某一项失败了怎么办?★★★

    这题的答案里有一个和电商分布式事务不一样的关键点,答出来就赢了:补偿本身有成本。

    电商里 Saga 的补偿是免费的——回滚库存就是把数字加回去。而打包产品里机票占位之后取消要付退票费,酒店预占释放是免费的。所以「要不要回滚」不再是技术问题,它是一个花钱的决策。

    由此得出编排顺序:按补偿成本升序执行,免费的先做、最贵的最后做。这样前序失败时后面还没执行,压根不需要补偿。这个调整不需要任何新技术,只是换了顺序。

    结构上是预占加确认两阶段:预占同步、快、可撤销且带 TTL;确认异步、慢、不可撤销(酒店等供应商确认、机票走出票,都是几十秒级)。整单状态由各子项状态聚合而成,不独立维护。

    幂等键用「打包订单号 + 子项标识」,不能用请求 id——用请求 id 时重试会被当成新请求,这是幂等做错的经典形态。

    部分成功的策略不能由技术决定,因为它决定退票费由谁承担。三条:保留已成功子项换绑重下(优先,不需要补偿)、整单退并明确退票费承担方、转人工。策略写进配置,因为它随供应商合同变化。

    兜底:定时扫描长期处于「部分确认」的订单推人工队列——用户往往以为订好了,到出行前才发现问题。

  3. 多个供应商提供同一家酒店,怎么归一成一个房源?★★★

    这是实体归一问题。同一家酒店在供应商 A 叫「北京王府井希尔顿欢朋」、在 B 叫英文名、在 C 叫「希尔顿欢朋(王府井店)」,地址格式不同、坐标差几十米。不归一的话用户搜出三个几乎一样的结果,而「同一家酒店的最低价」这个核心功能压根做不出来。

    第一步是分块,这是唯一能让问题变可解的一步。两两比较是平方级,十万条就是五十亿次比较还要算字符串相似度。用地理网格(只比同格与相邻格,相邻格必须带上,否则边界两侧的同一家酒店永远比不到)加名称指纹(品牌词做分块键),取两种分块的并集——单一分块键必然漏。

    第二步是多特征加权打分:强标识(电话、邮箱、互认编码,权重压倒性地大,因为几乎不会碰巧相同)、名称相似度、地址相似度(分层比较,门牌号权重最高,省市区相同说明不了什么)、坐标距离(只在近距离有区分力)。比较前必须归一化,不归一化直接比毫无意义。

    第三步也是最能体现判断力的:用双阈值三档,不要用单阈值二分。因为不存在一个阈值能同时避免漏合并和误合并——同品牌的两家店在同一条街上,机器给的分数一定落在中间。三档是:高于上阈值自动合并、低于下阈值自动排除、中间档进人工审核

    阈值往哪偏取决于代价对比:漏合并只是体验差(比不了最低价),误合并是「订了 A 店到了 B 店」的恶性事故,所以上阈值要保守。而且映射必须版本化且支持定点拆分——误合并一定会发生,不可逆的合并本身就是设计缺陷。

    审核结论要回流成样本调整权重与阈值,否则人工是纯消耗、系统永远不会变好。

  4. 价格和库存要实时问多个供应商,怎么控制响应时间?★★★

    核心是并行加整体超时预算,而不是串行等。同一房型可能来自四五家供应商,串行的总耗时是各家之和,压根不可用。

    并行编排:同时请求各供应商,设一个整体超时预算,到点就把已返回的结果拿去用,慢的那一路直接放弃。关键是放弃的语义要明确——该供应商这一次按「不可售」处理,宁可少卖不可超卖。

    适配层归一:各家的字段、错误码、日期格式、税费口径都不一样,必须在适配层统一成一个模型后再进业务逻辑。否则业务代码里会长满 if。

    熔断与降级:某家供应商持续超时就熔断一段时间,不要每次都去等它耗掉超时预算。

    分层价格策略,这是最重要的一层列表页用缓存价(标注为参考价)→ 详情页实时查 → 下单前再向供应商确认一次。因为价格在供应商手上随时可变,设计重点是「让不一致被拦住而不是让它不发生」。价格变了要明确提示「已变为 X,是否继续」并要求用户重新确认,绝不能静默按新价扣款;反过来如果新价更低可以直接按低价成交并告知。

    不要报「库存准确率 100%」——异步链路和缓存副本必然有偏差窗口,正确表述是「每日对账偏差在个位数以内、均在当日处理完」。

  5. 用户下单付了钱,供应商却拒单了,怎么设计?★★★

    这题的前提要先说清:OTA 的下单不是即时成交——预订单要等供应商异步确认,可能几十秒也可能失败。这是和电商最大的流程差别。

    状态机要覆盖这个中间态:待确认 / 已确认 / 确认失败 / 已入住 / 已取消。状态流转一律用条件更新(where 当前状态等于期望值),非法流转直接拒绝。

    拒单后的补偿要编排而不是直接退款了事:优先换供应商重下(同房型别家还有的话,用户体验最好且不需要退款);不行则全额退款并主动通知;金额大或涉及多方的转人工

    用户侧的中间态展示很关键:不能显示成「已成功」,要明确是「等待酒店确认,通常几分钟内」;而且要给一个明确的兜底承诺(超时未确认会怎么处理),否则用户会反复刷新和打客服。

    超时要有主动发现:定时扫描超过阈值仍未确认的订单转人工,不能等用户来问——用户可能提前一个月订,到出行前才检查,那时发现问题已经很晚。

    回调要幂等:以「订单号 + 确认轮次」做幂等键,供应商重复回调只生效一次。

    数字上不要报「拒单率 0」——拒单主要由供应商侧决定。可以报「供应商确认的中位耗时」「超时转人工的每日笔数(绝对数)」「拒单后自动补偿的覆盖比例」,这三个是我们能控制的。

  6. 「距入住 24 小时可免费取消」,这个 24 小时按哪个时区算?★★★

    按酒店所在地的当地时间算——这是业务事实:入住时间是酒店当地的下午三点,那么「距入住 24 小时」自然是当地时间的前一天下午三点。

    而这题真正考的是「时间的语义不只是哪一瞬间,还包括按谁的时钟表达」。所以时间存储要绝对时刻加时区标识两样都存:绝对时刻保证唯一性(跨时区指的是同一瞬间),时区标识用于正确展示与计算。只存本地时间字符串会随读取方的时区漂移;只存时间戳则不知道该按哪个时区渲染。

    展示上要消歧义:跨时区场景同时标注两个时间(「当地 15:00(你所在地 21:00)」)。这只是展示层改动,但它直接消掉了「几点是谁的几点」这个歧义。

    退改规则本身要快照到订单:规则会改版,历史订单必须能按当时的规则复算,否则争议时说不清。这和金额分摊快照、审批规则版本快照是同一条原则——凡是会变的规则参与了计算,就要在成交时刻固化。

    还有两个容易漏的夏令时(部分地区有,切换日的计算必须用标准时区库,不要自己算偏移);提醒调度要用绝对时刻而不是本地时间到点触发,否则用户跨时区后提醒会在错误的时刻响。

    一般化的结论:任何面向本地业务的时间判断都要先问一句「谁的时间」——营业时段、账期日、免费取消截止时间,都是同一类问题。

七、企业服务与多租户(设计)

  1. 核心多租户的数据隔离怎么做,怎么防止越权?★★★

    先说隔离级别的选型:独立数据库(隔离最强、成本最高、适合大客户)、共享库独立 schema、共享库共享表加 tenant_id(成本最低、隔离最弱)。多数 SaaS 从第三种起步,关键是要设计成「可以为大客户单独迁出去」——方案一开始就要考虑可迁移性。

    共享表方案的核心是强制租户上下文:不能靠每个查询手写 where tenant_id = ?——那样漏一处就是越权。做法是在数据访问层统一拦截,自动为需要隔离的表追加租户条件,写入时自动填充。

    而这里有个必须讲的坑:跳过逻辑要用显式白名单而不是「表里没有 tenant_id 就跳过」。我们踩过——新同事建表忘了加 tenant_id 字段,那张表的所有查询都不受保护,任何租户都能查到全部数据。改成显式白名单后,不在白名单里的表如果没有 tenant_id,直接启动失败「静默跳过」和「显式白名单」的差别,就是「默认不安全」和「默认安全」的差别。

    第二个坑是线程池复用导致租户上下文串了。用 ThreadLocal 存上下文、包装 Runnable 传递,但任务执行完忘了清理;线程被复用处理下一个请求时读到了上一个租户的 ID,返回了别的公司的数据。修法是 finally 里强制清理,并且上下文设置时发现已有残留值就告警

    兜底层:查询结果返回前再校验一次归属;关键接口加越权检测的埋点与告警。

    不要报「数据安全性 100%」或「零越权」——安全类绝对化断言最容易被问穿。正确表述是「哪三层检查、各自覆盖什么、当前均无发现」。

  2. 核心客户已有自己的账号体系,单点登录怎么接?安全上有哪些坑?★★★

    协议本身照文档实现不难,重点是安全校验——做错的后果是可以伪造任意人的身份登录。三重校验,缺一不可。

    一、验签。断言是通过浏览器转发的,用户完全可以自己构造一个。而且算法必须由我们的配置决定,绝不读断言里自带的算法字段——否则攻击者把算法改成 none 就绕过了验签,这是个经典陷阱(验了签仍然被绕过)。

    二、校验 Audience 与 Issuer。这一点最容易漏:验签只证明「是某个可信方签的」,不证明「是发给我们的」。如果同一个身份提供方也服务别的系统,攻击者可以把发给别家的合法断言拿来登录我们。Audience 必须等于我们自己的标识,Issuer 必须在该租户配置的白名单内(否则可能出现用 A 租户的 IdP 登录 B 租户)。「可信来源」和「可信目标」是两个独立条件,只验一个等于没验。

    三、防重放。时间窗校验(同时拒绝过期的和签发时间在未来的)加断言 ID 一次性消费,否则截获一次就能反复登录。

    建模上还有一条重要的外部身份与内部账号要用独立绑定表关联,不要拿外部 ID 当内部主键。客户换身份供应商时所有外部 ID 都会变,用它做主键会导致历史数据全部失联;而且多身份源并存(总部用 AD、分公司用钉钉)压根表达不了。

    工程实践:把每项安全校验写成独立的自动化用例纳入 CI——安全校验被删掉之后功能完全正常,只是不安全了,没有任何自然信号。

  3. 客户的组织架构一直在变,怎么同步?★★★

    先说最重要的一条:以客户系统的稳定外部 ID 建模,绝不用部门路径。我们踩过——用「公司/技术中心/后端组」这样的路径做部门标识,客户把「技术中心」改名后整棵子树路径全变,同步误判为「删旧建新」:权限配置失效、审批人解析不到部门主管、成员可见范围变空,第二天客户的报销流程全部卡住标识的唯一要求是稳定,可读性靠展示层解决。

    第二是增量为主、定期全量校准兜底,而且两者都走「算差异、发事件」的同一条链路。全量清空重建有两个致命问题:执行期间权限是空的(那几秒所有鉴权都失败);丢掉变更语义——下游要的不是「现在长什么样」而是「发生了什么变化」(离职要回收权限、调岗要重算可见范围、主管换人要更新在途审批的解析)。

    所以要把差异转成结构化变更事件:入职、离职、调岗、汇报线变更、部门增删改移、主管变更。下游订阅事件做增量处理,不要让每个下游各自做全量比对

    第三是批次级异常拦截,因为客户侧也会成批出错。真实发生过:客户 HR 系统导出异常,一次增量里带来大批「离职」记录——照单执行就是全公司第二天登录不了。做法是统计整批画像(离职占比、部门删除占比、调岗占比),超阈值整批挂起并告警,而不是逐条执行阈值按客户分别设(快速扩张的公司入职占比天然高)。异常检测的粒度要和异常发生的粒度一致。

    第四是平台侧手工调整要独立分层,同步只写客户系统层、合并时手工层优先——否则运营手工配的角色第二天就被同步抹掉。

    另外同步任务本身要有心跳监控:同步停了也是静默的(权限不再更新、离职的人还能登录),而表面上一切正常。

  4. 核心审批流程定义改了,已经在跑的实例怎么办?★★★

    定义必须版本化,实例在发起时绑定版本,生命周期内只读这个版本。这是审批引擎设计的分水岭。

    不这么做会出两类事故,性质完全不同。一是实例中断:租户删掉一个审批节点,正停在该节点的实例一读定义发现自己所在节点不存在,引擎直接抛异常,既走不下去也退不回来。二是更严重且静默的绕批:新增一个合规审批节点后,部分在途实例的「下一步」被重新计算,直接走到了结束,等于绕过了新增节点——单子看起来正常走完了,没人发现少批了一道。审批被绕过在企业软件里不是 bug 是合规问题,审计要看的是「这笔钱是不是按当时的规则批的」。

    根因是同一个:流程定义是有状态实例的依赖,而依赖被原地替换了——和「代码热更新时正在执行的方法被改掉」是同一类问题。任何「有状态执行体依赖一份可变配置」的结构,配置都必须版本化。

    旧版本不能删:要继续服务在途实例,而且审计要复现当时的规则。

    产品上要正面回答「改了怎么没生效」:控制台明确显示「本次修改仅对新发起的申请生效」、提供在途实例列表、并提供强制迁移作为例外能力(需人工逐个确认并留痕)。绝不提供静默全量迁移——那正是事故的成因。

    顺带答一个高频追问:发布前要做静态校验(可达性、出口性、并行必须汇聚、环路检测),其中「条件分支必须配置默认出口」是硬性规则——「所有条件都不满足」是卡单的首位原因(配了「小于 1000 走 A、大于 1000 走 B」忘了等于 1000)。

  5. 开放 API 给客户调用,限流和版本兼容怎么做?★★

    鉴权与防重放:AccessKey 加签名,校验四件事——密钥是否有效且属于该租户、时间戳是否在容忍窗口内(防重放第一道)、随机串是否已用过(Redis 记录、窗口内去重,第二道)、签名是否匹配。IP 白名单作为可选项。

    限流要分级而不是一个全局阈值:按租户、按接口、按套餐档位分别限。因为一个大客户的批量调用不该影响其他客户,而不同套餐本身就是不同的服务承诺。

    超限的处理要按套餐区分且这个不对称很重要:免费版直接拒绝;标准版降级限速(降低速率但不断服);企业版放行并按合同计入超量费用。直接拒绝和降速对客户的影响完全不同,不能一刀切。

    版本兼容的核心原则是「字段只增不改不删」。要改语义就新增字段并保留旧字段的读取。因为开放 API 的调用方是客户的代码,我们不能要求他们跟着改。确实要废弃时走显式流程:标记废弃 → 统计谁还在用 → 通知与迁移 → 才真正下掉。而这个流程的前提是「能从接口反查哪些客户在用」,没有这个反查能力,废弃流程压根走不动。

    Webhook 回调要考虑客户侧的可靠性:客户的接收端会挂、会超时、会返回非预期状态码。所以要重试加指数退避回调要带签名让客户能验来源提供回调日志与手动重推(客户排查时一定会要)、并且要求客户侧幂等(我们会重推,这一点要写在文档里)。

  6. 员工离职后权限怎么回收,怎么向客户证明回收及时?★★★

    关键设计是把回收拆成两段,而这是踩坑之后想清楚的。第一版把离职处理当成一个完整事务:待办转交完、资产移交完、然后停用。顺序很「干净」,但它让安全底线依赖人工进度——转交需要有人决定「转给谁」,可能要等主管回复,结果停用被拖了好几天,等于回收失效

    第一段「可立即执行」:停用登录、主动失效该用户的全部会话与刷新令牌、撤销他名下的开放 API 凭据。不依赖任何人工,秒级完成,这一段才是合规意义上的回收。

    第二段「需要编排」:在途审批待办转交、数据资产移交、定时任务与订阅接管。进流程可以慢,但绝不能阻塞第一段

    会话必须主动失效,不能等自然过期。我们被客户安全评估直接指出过——当时的理解是「账号停用了下次鉴权就会失败」,但会话是已经签发的,鉴权时读的是会话而不是每次都查账号状态撤销权限时必须同时处理「已经签发的凭据」:会话、令牌、API 密钥、预签名 URL 都成立。

    最容易漏的一条撤销他名下的开放 API 凭据。我们漏过——某离职员工申请过一个 AccessKey 给他的脚本用,账号停用、会话失效之后那个 AccessKey 还在正常调用好几周权限回收要枚举「这个人拥有的所有访问路径」而不只是他的登录入口。

    账号一律停用而非删除:要保留历史审批记录的审批人。我们踩过——离职即删除,客户内审问「这笔十万块采购是谁批的」,我们答不上来,而审批链不完整等于审批无效。有审计意义的记录必须自包含(存审批人姓名快照而不只是 ID)。

    怎么证明:靠埋点与报表,而且要分段报——客户 HR 到客户身份系统(不由我们控制)、身份系统到我们同步拉到(受客户接口推送频率影响)、我们收到事件到执行完回收(这一段完全由我们控制,是我们能承诺的部分)。只报总时长会被追问「哪段是你的责任」。另外提供面向客户内审的回收报表——客户要的不是「你说你做了」,而是「拿数据给我看」。

八、架构与稳定性(设计)

  1. 多地区部署怎么做,数据合规(GDPR)怎么落地?★★★

    先分清哪些数据必须本地化、哪些可以全球共享,这个划分决定了整个架构。

    用户个人数据(身份信息、联系方式、支付信息、行为日志)受各地区法规约束,GDPR 要求欧盟用户数据原则上留在欧盟境内,跨境传输需要合法依据。内容数据(视频、商品信息)通常可以全球分发。元数据和配置可以全球同步。

    部署模式一般是多区域独立部署 + 少量全局服务:每个区域(比如欧洲、北美、亚太)有独立的应用集群和数据库,用户按归属地路由到对应区域。全局只保留少数必须统一的服务(比如全局唯一 ID、全局配置、内容分发元数据)。这比「单点部署 + CDN 加速」正确得多,因为后者解决不了数据主权问题,也解决不了跨洋写延迟。

    路由:用户注册时确定归属区域并记录,之后的请求按归属路由(不是按当前 IP,因为用户会出国旅行)。跨区访问要能正确转发或者提示。DNS 层面做地理调度,减少跨洋链路。

    数据同步:区域之间只同步必要且允许的数据。比如内容库全球同步,用户表不同步。不要为了「架构统一」搞全球多主复制——既有合规风险又有一致性噩梦。

    GDPR 的具体技术要求要能落地:被遗忘权(用户要求删除数据,要能真正删除并级联到所有副本、备份、日志、数据仓库,这比想象中难得多)、数据可携带权(导出用户全部数据)、知情同意(cookie 和数据使用的同意记录要留痕,并且能追溯用户当时同意的是哪个版本的条款)、数据最小化(不必要的个人数据不收集不留存)、泄露通知(72 小时内上报)。

    其中删除权是最难的工程问题:日志系统、数据仓库、备份、缓存、搜索索引、第三方服务里都可能有用户数据。实践做法是提前设计「个人数据在哪些地方」的清单,并且尽量用用户 ID 替代实际个人信息存储(假名化),删除时只需删掉 ID 到真实信息的映射,其他地方的数据自动失去身份关联。这个设计要一开始就做,后补的成本极高。

    另外要提数据本地化的其他国家要求:一些国家有更严格的数据不出境要求,可能需要完全独立的部署和本地运营主体。这属于合规和业务决策,但技术架构要能支持「某个区域完全独立运行」。

  2. 支付这条链路你为什么不用分布式事务,最终一致怎么保证不出错?★★★

    先说为什么不用。2PC/XA 要求所有参与者在事务期间持锁等待协调者,而支付链路的参与者包括外部支付渠道——你压根没法让渠道加入你的事务。而且链路长(订单、支付、库存、账务、通知)、涉及跨服务甚至跨机房,一旦某个环节慢或挂,全链路资源锁死,吞吐直接崩掉。国际业务还有跨洋延迟,2PC 的两次往返成本更不可接受。

    TCC 理论上可行但代价大:每个操作要写 try/confirm/cancel 三份逻辑,还要处理空回滚、悬挂、幂等三个经典问题,业务侵入极强。只在真正需要「资源预留 + 强一致」的核心环节才值得,比如扣减余额。

    所以主链路用最终一致,靠这几件套保证不出错:

    一、可靠消息。支付成功后要触发的下游动作用消息驱动。关键是保证「本地状态更新」和「消息发出」这两件事的原子性——用本地消息表(业务操作和消息记录在同一个本地事务里,再由任务扫表发送)或者 RocketMQ 事务消息(半消息 + 回查)。绝对不能在事务里直接发 MQ,那样会出现「消息发了但事务回滚」或者「事务提交了但消息没发」。

    二、消费幂等。所有下游消费者必须幂等,因为消息一定会重复投递。用业务唯一键加唯一索引,或者用去重表。这一条是最终一致方案的地基,不幂等就全盘崩。

    三、状态机约束。每个环节的状态流转只允许合法路径、终态不可逆,这样即使消息乱序也不会产生错误状态。

    四、定时补偿。扫描处于中间态且超时的记录,主动推进或者回滚。比如「支付成功但未发货超过 5 分钟」的订单要重新触发发货。这是消息丢失和消费失败的兜底。

    五、对账。最后一道防线,把所有漏掉的差异捞出来。

    然后是关键的取舍表述:哪些环节可以最终一致,哪些必须强一致。「支付成功」到「发货」可以最终一致——晚几秒发货用户能接受。但「扣减库存」和「分配卡密」这一步内部必须强一致,靠单库事务加行锁保证,不能超发。账务记账必须准确,可以异步但绝对不能丢,所以走可靠消息加对账双保险。

    能把「哪里用强一致、哪里用最终一致、为什么」讲清楚,比背分布式事务方案要有说服力得多。面试官真正想看的是这个判断力。

  3. 缓存架构怎么设计,热点和一致性怎么处理?★★★

    分层设计:本地缓存(Caffeine)→ 分布式缓存(Redis)→ 数据库。本地缓存挡热点、Redis 挡大部分读、数据库兜底。

    什么该放本地缓存:变更频率低、数据量小、允许短暂不一致的,比如商品分类、汇率表、配置、开关。本地缓存的核心问题是多实例不一致——一个实例更新了别的实例还是旧的。解决方式是设短过期时间(几十秒,容忍这段时间的不一致),或者用消息广播通知所有实例失效。我倾向短过期加广播双管,广播丢了还有过期兜底。

    Redis 层放热数据:商品详情、用户信息、Feed 候选、计数。要注意key 设计(有清晰前缀便于批量管理和统计)、过期时间加随机值(防止集中过期造成雪崩)、大 key 拆分

    一致性策略用 Cache Aside:先更新数据库,再删除缓存。为什么是删而不是更新——更新缓存有并发写覆盖的问题,而且如果缓存值需要复杂计算,每次写都算一遍很浪费。为什么是先写库后删缓存——先删缓存的话,删完到写库完成之间的读请求会把旧值加载回缓存,不一致窗口反而更长。

    更可靠的做法是订阅 binlog(Canal)驱动缓存失效,好处是彻底解耦、不会漏(业务代码忘了删缓存这种问题直接消失)、还能配合 MQ 重试保证一定删掉。核心业务我会用这个方案。

    但要说清强一致做不到:删缓存和写库之间总有窗口,主从延迟也会导致读到旧值。所以兜底手段是给缓存设一个不太长的过期时间,就算所有失效机制都失效了,最多不一致到过期为止。这是成本最低最有效的保险。

    三个经典问题的处理穿透用缓存空值加布隆过滤器;击穿用互斥锁只让一个线程重建,或者用逻辑过期(key 永不过期,value 里带过期时间,发现逻辑过期就异步更新、当前请求先返回旧值)——热点数据我倾向逻辑过期,完全不阻塞;雪崩靠过期时间打散加多级缓存加数据库限流。

    热点 key是重点:一个爆款商品或视频会打满单个分片。解法是本地缓存(最有效,绝大部分请求不出进程)、多副本打散(一个 key 复制成 N 份,随机读一份)、热点自动发现(统计上报,自动把热 key 推到本地缓存)。国际业务还要考虑各地区独立的缓存集群,避免跨洋读缓存。

  4. 大促或者流量突增,限流降级和容量规划怎么做?★★

    先做容量评估,再谈限流。限流阈值必须来自压测数据,凭感觉设的阈值要么形同虚设要么误伤正常流量。

    容量规划的做法:先确定业务目标(预计峰值 QPS、下单量),然后全链路压测找出每个环节的瓶颈和上限——注意要压出整条链路的木桶短板,单接口压测没意义。特别要压数据库和下游依赖,它们通常是真正的天花板。压测要用影子库或者线上流量回放,尽量贴近真实。

    限流分层做接入层(网关)按 IP、用户、接口做粗粒度限流,挡住明显异常流量;应用层用 Sentinel 这类做接口和方法级限流,还能做热点参数限流(比如单个商品 ID 的下单频率);资源层限制数据库连接池和线程池大小,这本身就是一道保护。

    算法选择:一般用令牌桶,允许一定突发更符合真实流量特征。需要严格保护下游(比如调用第三方有固定 QPS 配额)用漏桶做匀速整流。分布式限流精确但每次多一跳 Redis,单机限流简单但集群总量不好控——实践上常用单机限流按实例数分配总量,或者 Sentinel 的集群限流模式。

    降级要提前设计并演练。核心原则是保住主链路,牺牲非核心:推荐、评论、榜单、个性化这些可以直接降级成兜底数据或者关掉;商品详情可以降级成缓存的静态版本;但支付和下单不能降级,只能限流排队。降级开关要能远程一键切换,不能靠发版。

    这里最重要的一点是:降级逻辑必须演练过。我见过太多写了降级代码但从没验证过的系统,真出事时降级代码本身报错,结果雪上加霜。所以要定期做故障演练(混沌工程),主动关掉依赖看系统表现。

    熔断针对下游故障:失败率超阈值就快速失败,避免线程堆积拖垮自己,半开状态自动探测恢复。要配合合理的超时——没有超时的调用是最危险的,一个慢下游能拖死整个服务。

    弹性伸缩:无状态服务按 CPU 和 QPS 自动扩容,但要注意扩容有冷启动时间(JVM 预热、连接池建立),突发流量可能来不及,所以大促要提前预扩容而不是完全依赖自动伸缩。数据库扩容更慢,所以数据库容量必须提前准备。

    最后是排队和柔性:实在扛不住时,让用户排队比直接报错好(秒杀场景),或者把同步改异步(下单成功后异步处理,告诉用户「处理中」)。这些是产品和技术配合的手段,能显著提高系统能承受的峰值。

  5. 订单表数据量太大,分库分表怎么落地?★★★

    先确认真的需要拆。能靠归档冷数据、加索引、读写分离、加缓存解决的就不要拆——分库分表之后开发和运维复杂度是数量级上升的。我的判断标准是:单表已经上亿、慢查询优化不动了、DDL 变更时间长到不可接受、且数据还在快速增长。

    分片键的选择是最关键的决定,选错了几乎无法挽回。订单表的候选是用户 ID 和订单号。用用户 ID 分片:最高频的查询是「我的订单列表」,按用户分片后单库就能查完,这是对的选择。如果按订单号分片,用户查自己的订单要扫所有分片再聚合,性能和复杂度都差得多。

    但用用户 ID 分片会带来一个必须解决的问题:按订单号查单怎么办(客服系统、渠道回调、对账都是拿订单号来的)。解法是基因法——把用户 ID 的若干位编码进订单号,这样从订单号就能反算出分片位置,不需要额外查询。这个技巧答出来是明显的加分项。备选方案是维护一张「订单号到用户 ID」的路由表,但多一次查询。

    分片数量要一次规划够:一开始就分足够多的表(比如 1024 张),物理库少的时候多张表放一个库,扩容时按库迁移而不改变分片规则。如果一开始只分 8 张表,将来扩容就要重新哈希、全量迁移数据,代价极大。

    衍生问题要一并解决:主键不能用自增,改用雪花算法或号段模式;跨分片的分页和排序只能各分片查完在内存归并,所以要限制翻页深度并优先用游标分页;跨分片聚合统计不要在线做,走离线数仓;跨分片事务没有,只能靠最终一致。

    数据迁移是落地时最难的部分,不能停机的话要走这套流程:双写(新老库同时写,以老库为准)→ 存量数据迁移(分批同步)→ 数据校验(比对新老库一致性)→ 读切换(灰度把读流量切到新库)→ 写切换 → 观察一段时间后下线老库。每一步都要可回滚。这个过程通常要几周,要有完整的校验和对比工具。

    还有个实际的替代思路值得提:订单这类数据有明显的时间衰减特征——用户几乎只查最近几个月的订单。所以先做冷热分离(热库放近三个月、冷库放历史,冷库可以用更便宜的存储甚至归档到对象存储)往往比分库分表成本低得多,而且能解决大部分问题。真的挡不住了再上分片。能提出这个判断,比直接背分库分表方案更能体现工程判断力。

  6. 幂等的统一方案怎么落地?★★★

    先明确幂等的粒度和边界,这是设计的前提。幂等号代表的是「一次业务意图」,而不是「一次 HTTP 请求」。同一个意图的所有重试共用一个幂等号,不同意图必须用不同的号。这个概念不清的话,方案怎么做都是错的。

    幂等号谁生成由发起方生成并在重试时复用。前端发起的支付、上游服务调用下游、MQ 消息消费,都是发起方带号过来。服务端不能自己生成——那样每次请求都是新的号,压根去不了重。

    几种实现方式,按可靠性排序。

    唯一索引是最可靠的:把业务唯一键(订单号、幂等号)做成数据库唯一索引,重复插入直接冲突,捕获异常后返回第一次的结果。它的优势是数据库层面的硬保证,不受应用层并发和缓存失效影响。核心业务必须用这个兜底。

    状态机加条件更新适合更新场景:update ... where status = 前置状态,判断影响行数。天然幂等且防乱序。

    Redis 的 SETNX 适合做前置快速拦截,性能好,但不能单独作为最终保证——Redis 可能丢数据、可能主从切换、key 可能过期。所以它只是第一道门,后面还要有唯一索引。

    去重表是通用方案:单独一张表记录处理过的幂等号和处理结果,进来先查(或者直接插入靠唯一索引),已存在就返回之前的结果。关键是要存结果而不只是标记——第二次请求要能返回和第一次一样的响应,而不是返回一个「重复请求」的错误,否则调用方无法区分「重复了但成功了」和「真失败了」。

    统一落地的做法:做成注解加拦截器的形式,业务方声明幂等键的来源(从参数里取哪个字段),框架负责查重、加锁、存结果。这样业务代码干净,也避免每个人各写一套。

    几个容易踩的坑处理中的状态:第一个请求还在处理,第二个来了怎么办?直接返回成功不对(业务还没成功),直接拒绝也不好。正确做法是记录三态(处理中、成功、失败),处理中就返回「正在处理,请稍后查询」。幂等记录的过期时间要比业务最大重试周期长,否则记录过期后重试的请求又被当成新的。失败的请求要不要记幂等:业务失败(余额不足)应该允许重试,系统失败要看情况,所以幂等记录要区分状态而不是简单标记「已处理」。

    最后要说清幂等不是万能的:它只能保证「同一个号不会被处理两次」,但如果调用方每次生成新号(比如前端 bug),幂等就失效了。所以核心业务还要有业务层面的约束——同一订单已有成功支付时拒绝创建新支付单,以及事后的重复检测和自动退款。多层防护才可靠。

  7. 灰度发布和 AB 实验怎么做?★★

    两件事目标完全不同,别混为一谈。灰度是控制风险——新代码先给少量用户,出问题影响面小、能快速回滚。AB 实验是验证效果——两个版本同时跑,用数据决定哪个更好。灰度关注技术指标(错误率、延迟),AB 关注业务指标(转化率、留存)。

    灰度的几种粒度。实例级(先发布一部分机器,最简单,但同一个用户可能一会儿命中新版本一会儿旧版本,只适合无状态且兼容的改动);流量级(网关按规则把一部分请求路由到新版本,可以按用户 ID 取模、按地区、按设备);功能开关级(代码全量发布但新逻辑用开关包起来,这是最灵活的——出问题关开关就行,不用回滚发布,秒级生效)。

    我最推荐功能开关,因为它把「代码发布」和「功能上线」解耦了。代价是要维护开关,而且废弃的开关必须及时清理,否则几十个开关交叉之后没人搞得清系统当前是什么行为。

    灰度的关键是分流要稳定:同一个用户每次都应该落到同一个版本(用用户 ID 哈希,不要用随机数),否则体验会闪烁,数据也没法分析。

    AB 实验的要点,这块容易做错。分流必须随机且均匀,而且要能做互斥实验(两个实验都改结算页,同时跑会互相干扰,需要分层实验设计——同一层内的实验互斥,不同层之间正交)。要有 AA 测试:两组都用旧版本,验证分流机制本身没有偏差,如果 AA 都能测出显著差异,说明分流有问题。

    指标要预先定义:主指标(要优化的,比如支付转化率)、副指标(不能变差的,比如客单价)、护栏指标(不能崩的,比如错误率)。不能实验跑完再挑一个好看的指标说成功,那是自欺欺人。

    要算显著性并且提前估算需要的样本量和时长。样本不够就得出结论是最常见的错误——转化率从 3% 变 3.2%,在几百个样本下完全可能是噪音。而且实验要跑完整的业务周期(至少一周,覆盖工作日和周末),国际业务还要注意不同地区的时区和节假日差异

    技术实现:分流决策放在网关或 SDK,结果要下发给前端和后端保持一致(前端展示 A 版页面、后端按 B 版逻辑算价,那就乱了);实验标识要透传到埋点和日志里,否则没法归因;要能紧急停止实验并全量切回。

    最后一个实践提醒:灰度和 AB 的监控必须按分组拆开看。整体指标正常但新版本组错误率翻倍这种情况,看总量是发现不了的——这是灰度机制最容易失效的地方。

  8. 全链路压测怎么做,怎么避免影响线上?★★

    为什么必须全链路压。单接口压测只能测出单点上限,测不出真实瓶颈——真实瓶颈往往在共享资源上:数据库连接、缓存带宽、消息队列、下游第三方、甚至日志写入。而且单点压测测不出木桶效应,A 接口能扛一万,B 能扛两千,链路总能力就是两千。

    核心难点是数据隔离,压测流量绝对不能污染线上真实数据(不能产生假订单、不能给真实用户发通知、不能真的调渠道扣款)。方案有两种:

    影子表 / 影子库:压测流量带上压测标识(在链路上下文里透传),数据访问层识别到标识就路由到影子表(表结构相同的另一套表)。好处是压的是真实的生产环境和真实的中间件,测出来的数据最准。这是主流做法。

    独立压测环境:完全隔离的环境,安全但成本高且不真实——很难和生产的机器规格、数据量、流量特征完全一致,测出来的结论可信度打折扣。

    压测标识的透传是技术关键:HTTP 走 header、MQ 走消息属性、RPC 走上下文,而且要能穿透线程池和异步调用(跟链路追踪的 traceId 一样的问题,要用 TTL 这类工具)。一旦某个环节丢了标识,压测流量就会写进真实表,那就是事故。所以这块必须有严格的测试和兜底校验。

    外部依赖的处理:支付渠道、短信、推送这些绝对不能真调,要做Mock 挡板,并且挡板要模拟真实的延迟和失败率,否则压出来的结果偏乐观。

    压测数据的准备:要有足够量的压测账号和商品数据,而且要模拟真实的数据分布——比如热点商品集中访问、用户 ID 的分布要符合实际(全部用连续 ID 会让分库分表的压测结果失真)。

    执行策略低峰期做逐步加压(阶梯式而不是一上来就打满)、设置熔断(关键指标异常自动停止压测)、全程监控并准备好一键停止。第一次做一定要有回滚预案和值班。

    要压什么:核心链路(下单支付)、以及大促场景的组合流量(不是单一接口,而是按真实比例混合的流量模型)。国际业务还要考虑分地区的流量分布

    最后是压测的产出:不只是一个 QPS 数字,而是各环节的容量水位、瓶颈点、以及扩容方案。压完要形成容量基线,后续每次大促前对比验证。压完不落地成容量规划的压测等于白压。

九、线上问题排查

  1. 核心用户支付成功了但订单还是未支付状态,你怎么排查?★★★

    沿着「渠道 → 回调 → 支付单 → 消息 → 订单」这条链逐段确认,找到断点。这个顺序很重要,能快速缩小范围。

    第一段:渠道那边真的成功了吗。拿支付单号调渠道查询接口或者查渠道后台。如果渠道显示未支付,那是用户误解或者只是预授权,问题不在系统。如果确实成功,继续。

    第二段:回调收到了吗。查回调接收日志(这就是为什么要把原始报文落库)。几种情况:压根没收到——渠道通知失败、回调地址不可达、被防火墙或 WAF 拦了、跨国网络问题、或者渠道那边配置的回调地址是旧的;收到但验签失败——密钥配置错误或渠道换了证书;收到但处理抛异常——业务逻辑报错。

    第三段:支付单状态更新了吗。如果回调处理成功但支付单还是处理中,看是不是状态机拦住了(前置状态不对)、或者更新语句的条件没匹配上。

    第四段:消息发出去了吗、消费了吗。支付单已成功但订单未更新,最常见的断点就在这里。查消息是否发送成功(本地消息表有没有记录、MQ 里有没有)、消费者有没有消费(消费位点、有没有进死信队列)、消费时有没有报错。消费失败进死信队列而没人处理是很典型的场景。

    第五段:订单侧的处理。消费到了但更新失败,可能是并发冲突(乐观锁失败没重试)、或者卡在了前置校验上。

    还有两个容易被忽略的原因主从延迟——订单实际已更新,但查询走从库读到旧数据,表现为「过一会儿就好了」;缓存未失效——数据库已更新但缓存里还是旧状态。这两个的特征是「直连主库查是对的」,所以排查时要区分「数据错了」和「读到的数据错了」。

    处理和长期改进:当场先手动补单让用户正常拿到商品。然后必须补上主动发现机制——定时任务扫描「支付单成功但订单未更新超过 N 分钟」的记录自动修复并告警,以及每日对账。这类问题不应该靠客诉发现。另外死信队列必须有监控和处理流程,很多团队配了死信队列但从来没人看,等于没配。

  2. 核心用户投诉重复扣款,你怎么定位和处理?★★★

    先确认是真重复还是误解,然后定位重复产生在哪一层,最后处理资金和修复根因。

    确认阶段:查这个订单下的支付单和渠道流水。要区分三种情况:真的有两笔渠道流水都扣款成功(真重复);只有一笔扣款但银行显示了两条记录(一条是预授权一条是实扣,用户误解);一笔扣款一笔失败但银行冻结了额度(会自动解冻)。后两种占客诉的比例其实不低,判断清楚能省很多事。

    真重复的话,定位产生原因,按可能性排:

    前端重复提交。用户连点、或者超时后按钮恢复又点了一次(这是最常见的)。特征是两笔支付单创建时间接近、幂等号不同。根因是幂等号设计有问题——如果每次点击都生成新的幂等号,服务端压根没法去重。

    幂等失效。幂等号传了但服务端没正确校验,或者用了「先查再插」的非原子写法,并发下两个请求都查到「不存在」然后都插入了。正确做法必须是唯一索引兜底,不能只靠应用层判断。

    渠道侧重复。我们发起了一次,但因为超时重试导致渠道那边创建了两笔。所以发给渠道的请求也必须带幂等号(商户订单号),让渠道去重。

    回调重复处理。渠道重复通知,我们没幂等,导致重复记账或重复发货——这种是「钱没多扣但货发了两份」,同样是资损。

    处理:确认多余的那笔后立刻自动退款,不要等用户催。退款要走正常的退款流程留下记录。同时检查是否重复发货,如果多发了卡密要作废。全程留审计记录,因为可能涉及后续纠纷。

    根因修复要分层做:幂等号在前端一次支付意图内固定;服务端唯一索引(订单号 + 幂等号)做硬约束;发给渠道的商户单号唯一;回调处理幂等;再加一层业务规则——同一订单已有成功支付时,拒绝创建新的支付单。

    还要有主动发现:定时扫描「一个订单对应多笔成功支付」的情况,自动退款并告警。对账时也要能捞出这类情况。因为无论防护做多严,总会有漏的(用户用两个设备、清缓存重进),必须有事后发现和自动处理的能力。这一点是这题的关键,光讲防护不讲兜底是不完整的。

  3. 核心卡密发重了或者发少了,怎么排查?★★★

    这两个是不同的问题,成因和后果都不一样。

    发重了(一张卡密给了两个订单),说明并发控制出了问题。查两个方向:

    一是分配逻辑不是原子的。如果代码是「先 select 一张未使用的,再 update 标记为已用」,并发下两个请求会选到同一张。正确写法必须是一条 update ... where status = 'UNUSED' limit 1 然后判断影响行数。查代码就能确认。

    二是重试导致的重复分配。发货操作失败后重试,但重试时没有复用原来已分配的卡密,而是又分配了一张——用户拿到两张,或者第一张成了孤儿。所以发货必须幂等:先查这个订单是否已有绑定的卡密,有就直接返回同一张。

    三是缓存/队列方案的坑。如果用了 Redis 预加载卡密的方案,「从 Redis 取出成功但落库确认失败」时,如果补偿逻辑写错,可能把这张卡密又放回池子里,而它其实已经给了用户。

    发少了(订单已支付但没拿到卡密),查这几种:库存真的空了——支付时没有预占库存,支付成功才去分配,结果被别人抢完了。这是设计缺陷,正确做法是下单时就预占(锁定卡密),支付成功只是把锁定转为发放。发货流程失败且没有重试——异步发货的消息丢了或者消费失败进了死信队列。卡密被错误作废或者状态被其他流程改坏。

    排查手段:卡密的操作审计日志是关键——哪张卡密什么时候被哪个订单以什么操作改了状态。没有这个日志这类问题基本查不清。所以卡密表要有完整的变更流水,不能只有当前状态。

    处理:发重了要判断哪个订单该保留、另一个补发新卡密并作废重复的那张(如果还没被使用);发少了立刻补发,没库存就退款并告知用户。都要通知用户,不要静默处理——卡密类商品用户会立刻去用,晚了就来不及了。

    长期改进库存和卡密的一致性校验任务(可售数量和实际未使用卡密数是否一致)、孤儿卡密清理(分配了但订单已关闭的要释放)、发货成功率监控(支付成功到发货成功的转化率,掉了立刻告警)。虚拟商品业务里,发货成功率是核心指标之一。

  4. 接口 P99 突然飙高,怎么定位?★★★

    先看是全局还是局部,这一步决定了排查方向,能省掉大量无用功。

    所有接口都慢→ 问题在共享资源或基础设施:数据库、缓存、网络、宿主机、或者应用自身(GC、线程池、连接池耗尽)。只有某个接口慢→ 问题在这个接口的逻辑或它的专属依赖。只有某个地区/某个实例慢→ 网络或者单机问题。

    按层排查应用层:看 GC 日志有没有频繁 Full GC 或者停顿变长(这会让所有接口一起慢);看线程池是否打满、队列是否堆积;看有没有锁竞争(jstack 看大量 BLOCKED);看数据库连接池是否耗尽(等待获取连接的时间会直接体现在 P99 上)。

    数据库层:慢查询日志、当前正在执行的语句(show processlist)、锁等待、主从延迟、Buffer Pool 命中率。常见诱因是数据量增长导致某个 SQL 突然走不了索引(优化器判断变了),或者有个大事务/DDL 在跑堵住了别人。

    缓存层:命中率有没有下降(下降会把压力全压给数据库)、有没有慢命令、有没有大 key 操作、Redis 自身是否变慢。缓存击穿导致数据库被打满是很典型的连锁反应。

    下游依赖:调用的其他服务或第三方接口是否变慢。国际业务里跨洋调用和第三方渠道是高频诱因,而且往往不是自己的问题,但表现在自己的接口上。

    基础设施:宿主机 CPU/内存/磁盘 IO、网络丢包、容器被限流(CPU throttling 很容易被忽略,表现为莫名的延迟毛刺)。

    关键工具链路追踪是最有效的——直接看慢请求的火焰图,哪一段耗时长一目了然,比逐层猜快得多。没有链路追踪的话,先看监控大盘的分层耗时。P99 要结合 P50 一起看:P50 也涨说明整体劣化,P50 正常只有 P99 涨说明是长尾问题(少量请求异常,比如某些数据量大的用户、缓存未命中的请求、或者恰好遇到 GC 的请求)。

    还要看变更:P99 突然变化,第一时间问「刚才发布了什么、改了什么配置、有没有搞活动」。绝大多数突发问题都和最近的变更有关,这个排查习惯比技术手段更省时间。

  5. 数据库连接池耗尽,怎么查根因?★★★

    连接池耗尽几乎总是「症状」而不是「原因」,真正的原因是有东西长时间占着连接不放。所以不要急着调大连接池,那通常只是把问题推迟几分钟。

    先确认连接被谁占着、在干什么。看数据库侧的活跃连接和正在执行的语句(show full processlist),看状态和已执行时长。这一步基本就能定性。

    几类典型根因,按出现频率:

    慢 SQL。某个查询突然变慢(索引失效、数据量增长、统计信息过期),每个请求占用连接的时间从 10ms 变成 5s,并发一上来连接立刻被占满。这是最常见的原因。

    长事务。@Transactional 方法里做了 RPC 调用、发消息、读写文件、或者跑大循环。数据库操作可能只要几毫秒,但事务持续了几秒甚至几十秒,连接全程被占着。这个在代码里很难一眼看出来,因为注解写在外层方法上,内部调用了什么要跟着看。

    连接泄漏。手动获取连接后异常路径没归还(没用 try-with-resources),或者用了自定义的数据源没正确关闭。特征是连接数只增不减,重启就好

    锁等待堆积。一个事务持有行锁不放,后面一堆事务排队等锁,每个都占着连接。这时候连接池耗尽是锁问题的表象,要去查锁的源头。

    连接池配置不合理。最大连接数设得比数据库的 max_connections 还大(数据库先扛不住)、或者多个服务共享一个数据库但各自都配了很大的池子(总量超了)。还有超时配置缺失——获取连接不设超时会让线程无限等待,把整个应用拖死。

    排查工具:连接池自身的监控指标(活跃数、等待数、等待时长)要接进监控,很多团队压根没监控这个所以出事只能靠猜;HikariCP 有泄漏检测(leakDetectionThreshold),超时未归还会打警告日志,这个非常好用;jstack 看线程栈能看到大量线程卡在获取连接上,以及卡在什么业务方法里。

    处理和预防:紧急时先找到并杀掉罪魁祸首的慢 SQL 或长事务,恢复服务。长期上要限制事务范围(事务里绝对不放 RPC)、设置合理的超时(获取连接超时、SQL 执行超时、事务超时都要设)、监控连接池和慢 SQL、以及核心业务用独立的连接池隔离,避免非核心业务把连接抢光影响支付这类关键链路。

  6. 核心某个国家的支付成功率突然骤降,怎么排查?★★★

    这是国际业务特有的问题,排查的第一原则是先确定「降在哪一步」,而不是先猜原因。因为支付链路很长,每一步都可能掉。

    先看漏斗:创建订单 → 创建支付单 → 唤起渠道 → 渠道返回 → 收到回调 → 最终成功,每一步的转化率按地区拆开看。哪一步的降幅最明显,问题就在那附近。这个分地区的漏斗监控是国际业务的必备基建,没有它这题基本无从查起,只能等客诉。

    然后按可能性排查。

    渠道侧问题(最常见):该地区的支付渠道故障或者策略变了。比如渠道对某个国家的风控收紧、某个卡组织拒付率上升导致批量拒绝、渠道在当地的收单行出问题。查渠道返回的错误码分布——如果集中在某几个拒绝码,基本就是渠道或发卡行的风控。这时候要联系渠道,同时切换到备用渠道,这也是为什么要支持多渠道路由。

    我们自己的风控误伤:风控规则更新后误判了该地区的正常用户。特征是被拦截的比例突增、且集中在某几条规则上。所以风控命中要按规则和地区维度打点,否则查不出来。

    网络和链路问题:该地区到我们服务的网络劣化,表现为创建支付单接口的超时率上升。也可能是回调收不到——渠道的通知发不到我们的服务(被墙、DNS 问题、证书问题)。这种情况下用户其实付成功了,但我们判定失败,需要靠主动查询兜底,而这时候主动查询也可能因为网络问题失败。

    配置和合规变更:该国家新的监管要求(比如强制 3DS 验证、要求本地支付方式),我们没跟上导致交易被拒。或者汇率、税费配置有误导致金额校验失败。

    本地支付习惯:某些国家用户主要用本地钱包而不是信用卡,如果只提供了信用卡,成功率天然低——如果是这类问题,它不是「突然」降的,要注意区分。

    排查手段:按地区、渠道、卡种、错误码、金额区间多维度切分数据,这是最有效的手段。同时对比历史同期排除正常波动。要有渠道错误码的字典和聚合,原始错误码几十种,要归类成「用户原因/风控拒绝/系统故障/配置错误」才好定位。

    应急处理:切换备用渠道、临时放宽风控规则、给受影响用户推送提示和替代支付方式。要有一键切渠道的能力而不是改代码发版,因为这类问题的止损速度直接影响营收。

  7. MQ 消息积压了几百万条,怎么处理?★★★

    先判断原因再动手,因为「消费慢」和「消费停了」的处理方式完全不同,盲目加机器可能毫无效果甚至更糟。

    看两个数据:消费者是否还在消费(消费速率是否为 0)、生产速率是否异常上升。消费速率为 0 说明消费者挂了或者卡死了;消费速率正常但生产暴涨说明是流量问题;消费速率下降说明消费变慢了。

    消费者挂了或卡死:查日志和线程栈。常见的是消费逻辑里调用的下游超时或挂了,导致每条消息都要等超时,吞吐降到几乎为零;或者消费时抛异常无限重试同一条消息(毒消息),卡住整个分区;或者消费者 OOM 反复重启。处理是先修复或者把毒消息跳过(记录下来单独处理),恢复消费。

    消费慢:先加消费者实例,但要注意受分区数限制——Kafka 里一个分区只能被同组的一个消费者消费,消费者数超过分区数没用。如果分区不够,有个经典应急方案:写一个临时消费者,只负责把消息快速转发到一个分区更多的新 topic,然后用大量消费者消费新 topic。这个方案在真正的大积压时很实用。

    同时优化单个消费者的吞吐:批量拉取批量处理、把逐条数据库操作改成批量写、跳过非关键逻辑(先落库,复杂加工后置)、把同步调用改异步。

    如果业务允许,可以丢弃。日志类、统计类、行为埋点这类消息,积压太多时直接把消费位点跳到最新比慢慢追赶更实际。但订单、支付相关的绝对不能丢,必须全部消费完。这个判断要基于消息的业务价值,能说出这个区分说明有实际经验。

    处理完之后的改进更重要积压监控和告警(按积压量和消费延迟两个维度设阈值,早发现成本低得多);分区数要留余量(Kafka 分区只能增不能减,一开始就要按峰值规划);消费逻辑要轻,把耗时操作从消费链路里拆出去;要有死信队列处理毒消息,避免一条坏消息卡住整个队列;消费失败要限制重试次数并落库,不能无限重试。

    另外要说一个容易被忽略的点:积压恢复时的流量冲击。几百万条消息突然被快速消费,下游数据库会瞬间承受远超日常的写入压力,可能把数据库打挂。所以追赶时要控制消费速率,宁可慢一点也不要引发二次故障。

  8. 缓存出问题导致数据库被打满,怎么快速止损和定位?★★★

    先止损,再定位。数据库被打满时整个服务不可用,恢复优先级高于查原因。

    止损手段:立刻对数据库前面加限流(应用层或网关层),宁可拒绝一部分请求也要保住数据库不彻底挂——数据库一旦挂了恢复时间会长得多。同时降级非核心查询,把推荐、榜单、统计这类先关掉,把数据库容量留给核心链路。如果是某个具体接口引发的,直接把它降级或者关掉。

    然后定位是哪一类缓存问题,四种可能,特征不同:

    缓存雪崩:大量 key 同时过期,或者 Redis 整个挂了/重启了。特征是缓存命中率断崖式下跌到接近 0,数据库 QPS 瞬间上涨到平时的几十倍。查 Redis 的状态和 key 的过期时间设置。

    缓存击穿:某个热点 key 过期,大量并发同时去重建。特征是命中率下降不多但某个特定查询的 QPS 暴增

    缓存穿透:查询大量不存在的数据,缓存里没有、数据库也没有,每次都打到库。特征是数据库查询大量返回空结果,而且请求参数往往是随机或者递增的(典型的爬虫或攻击特征)。

    缓存失效逻辑出错:代码 bug 导致缓存没写进去、或者写进去立刻被删、或者 key 拼接错误导致每次都是新 key。特征是命中率一直很低(不是突然下降),往往是新发布引入的。

    对应的修复:雪崩要过期时间加随机值打散、Redis 做高可用、加本地缓存做二级兜底;击穿用互斥锁只让一个线程重建,或者逻辑过期(key 不过期,异步更新);穿透用缓存空值布隆过滤器,同时加参数校验和限流挡掉恶意请求;逻辑 bug 就直接修并回滚发布。

    长期建设缓存命中率必须接进监控并设告警,这是最关键的指标,命中率一掉就能提前发现问题,而不是等数据库告警;数据库要有限流保护,不能完全依赖上游自觉;本地缓存作为二级兜底,Redis 挂了还能顶一阵;定期演练——主动把 Redis 停掉看系统能不能扛住,这个演练很多团队不敢做,但不做就永远不知道真出事会怎样。

  9. 用户反馈「刚提交的数据查不到」,怎么排查?★★

    这类问题的特征是「过一会儿就好了」,所以要按「哪一层读到了旧数据」来排查,而不是怀疑写入失败。

    第一步确认写入到底成功没有:直连主库查一次。如果主库也没有,那是写入问题(事务回滚、异常被吞、参数错误),走另一条排查路线。如果主库有,就是读取路径的问题。

    最常见的原因是主从延迟。写主库读从库,主从同步有延迟(正常几毫秒到几十毫秒,异常时可能几秒甚至几分钟)。用户提交后立刻查询,从库还没同步到。验证方法是对比主从的数据和延迟指标。

    解决办法分几种:写后立刻读的场景强制走主库(最常用,在代码里标记这类查询);用会话一致性(同一用户的读在一段时间内固定走主库);或者写完之后把数据放进缓存,读的时候先看缓存。要注意不要粗暴地把所有读都改成主库,那样读写分离就白做了。

    延迟异常大的话要查原因:从库有大查询占用资源、主库有大事务(一次更新几百万行产生巨量 binlog)、从库回放单线程跟不上(要开并行复制)、或者表没有主键——row 格式下没主键的表在从库回放时要全表扫描,延迟会放大成百倍,这个坑很隐蔽。

    第二类原因是缓存。数据库已更新但缓存还是旧值,缓存失效逻辑没执行或者失败了。特征是「一直不对,直到缓存过期」。这时候要查缓存删除的逻辑有没有被调用、有没有报错、以及是否存在「删缓存后又被旧数据回填」的并发问题。

    第三类是搜索索引延迟。如果查询走的是 ES 这类搜索引擎,从数据库同步到索引有延迟(可能几秒到几分钟)。这个延迟通常比主从延迟大得多,产品上要能接受或者做特殊处理(比如「我的发布」列表直接查数据库而不查 ES)。

    第四类是前端/客户端缓存,页面没刷新、接口被 HTTP 缓存、或者客户端本地缓存没更新。

    排查这类问题最有效的做法是:让上报带上 traceId,能看到用户那次查询实际走了哪个库、命中了缓存还是数据库。有链路追踪的话一眼就能看出来,没有的话就只能逐层验证。

  10. 核心库存超卖了,怎么定位?★★★

    先确认超卖的形态,不同形态指向不同的根因:是库存变成负数(扣减逻辑没兜底)、还是库存为 0 但仍然下单成功(校验和扣减不是原子的)、还是卡密被重复分配(分配逻辑并发问题)。

    最常见的根因是「先查再扣」不是原子操作。代码写成先 select 查库存够不够,再 update 扣减,两个语句之间有窗口期,并发下多个请求都查到「够」然后都扣了。正确写法必须一条语句完成update stock set num = num - 1 where sku_id = ? and num >= 1,然后判断影响行数。查代码就能确认是不是这个问题。

    第二类是缓存和数据库不一致。为了性能用 Redis 扣减库存,但 Redis 扣成功后落库失败、或者 Redis 数据被重置(重启、主从切换丢数据、key 被误删)导致库存「凭空恢复」。这类问题的特征是超卖量和 Redis 的某次异常时间点对应。所以 Redis 扣减必须有数据库层的最终校验,不能完全信任缓存。

    第三类是分布式锁失效。如果扣减逻辑靠分布式锁保护,那锁失效就会超卖。失效原因通常是:业务执行时间超过锁过期时间(锁自动释放了但业务还在跑,第二个请求进来了)、锁的粒度错了(不同 SKU 用了同一个 key 或者同一个 SKU 用了不同 key)、主从切换导致锁丢失。注意:单机库存扣减压根不需要分布式锁,用数据库的原子更新就够了——用锁反而引入了新的失效点,这是常见的过度设计。

    第四类是补偿逻辑写错。订单取消要返还库存,如果返还不幂等(重复执行了)或者判断条件错误(不该返还的返还了),库存就会虚增,表现出来就是超卖。

    第五类是多个入口。除了正常下单,可能还有秒杀、预约、后台手动、批量导入等入口,其中某个入口的扣减逻辑没走统一的方法,绕过了校验。这类问题最难查,要梳理所有会改库存的代码路径。

    排查手段库存变更流水是关键——每次扣减和返还都要记流水(谁、什么时候、因为哪个订单、从多少变成多少),没有流水这类问题基本查不清。有流水就能把变更序列拉出来,一眼看出哪一步不对。所以库存表必须配套流水表,这是设计要求。

    处理:超卖已经发生了,要么补货(虚拟商品向上游采购卡密),要么取消部分订单并退款(按下单时间或用户等级决定取消谁,这是业务决策)。要主动通知用户并给补偿,不能等用户发现。

    长期防护:数据库层面加非负约束num 字段无符号或者加 check,让超卖直接报错而不是静默发生);统一收敿所有库存变更到一个方法;定时校验(库存数 + 已售数是否等于总数);库存变化的监控告警。

  11. 分布式锁失效导致并发问题,怎么排查?★★★

    先确认锁到底有没有生效,最直接的办法是在加锁和释放时打日志(带上 key、持有者标识、时间戳),看是不是出现了「两个请求同时持有同一个 key」。

    失效原因按出现频率排。

    一、业务执行时间超过锁的过期时间。这是最常见的。锁设 30 秒,平时业务 2 秒跑完,某天数据量大或者下游变慢跑了 40 秒,锁在第 30 秒自动释放,第二个请求进来了——此时两个请求同时在执行。而且更糟的是第一个请求执行完还会去释放第二个请求持有的锁。防护要两条:用 Redisson 这类带看门狗自动续期的实现;释放锁时校验持有者标识(value 存 UUID,用 Lua 脚本原子地「比对再删除」)。

    二、锁的粒度或 key 设计错误。不同业务用了同一个 key(互相阻塞,看起来是性能问题)、或者同一个业务用了不同 key(压根没锁上)。常见错误是 key 里带了不该带的变量,比如把时间戳或者请求 ID 拼进 key,那每次都是新锁,等于没锁。

    三、加锁不是原子的。setnx 之后再单独 expire,中间宕机就成了永久锁;或者用「先 get 判断再 set」的写法,并发下都能成功。必须用带 NX 和 EX 参数的单条命令。

    四、Redis 主从切换。锁写到主节点还没同步到从节点,主节点挂了从节点升主,锁就丢了,两个客户端会同时持有。这是 Redis 分布式锁的固有缺陷,RedLock 试图解决但实现复杂且有争议。正确的态度是:Redis 锁只能作为「大概率互斥」,不能作为正确性的唯一保证。

    五、锁的作用范围不覆盖真正的临界区。比如锁住了「查询 + 计算」但没锁住「写入」,或者在事务提交之前就释放了锁——事务未提交时释放锁,第二个请求进来读到的还是旧数据,这个坑很隐蔽。所以锁的释放必须在事务提交之后。

    关键结论要说出来:如果一个逻辑的正确性完全依赖分布式锁,那这个设计本身就是脆弱的。更可靠的做法是用数据库的唯一索引或者原子条件更新做最终保证,分布式锁只用来减少无效竞争、提升性能。这样即使锁失效了,数据也不会错。这个判断比会用 Redisson 更重要。

    排查手段:加锁释放的日志、Redis 的慢查询和主从切换记录、以及用数据层面的证据反推(比如出现了两条本该互斥的记录,看它们的创建时间差,如果差值接近锁的过期时间,基本就是超时失效)。

  12. 服务频繁 Full GC,怎么定位到具体原因?★★★

    先看 GC 日志确认形态,这决定了排查方向:Full GC 之后老年代能降下来说明只是内存不够或者晋升太快(容量/参数问题);降不下来说明有对象一直被引用,是内存泄漏。这一步判断错了后面全是白费功夫。

    降不下来(泄漏)的排查jmap -histo:live 先看对象数量排行,快速判断是什么类型的对象在堆积。然后 jmap -dump 出堆快照用 MAT 分析,看支配树找出占用最大的对象,再看它到 GC Root 的引用链

    业务场景里的典型泄漏源本地缓存无界增长(用 HashMap 当缓存不设上限和过期,这是最常见的);ThreadLocal 忘了 remove(线程池的线程复用,value 一直挂着);监听器和回调注册了不注销静态集合越积越多连接或流未关闭大对象缓存(比如把整个商品列表缓存在内存里,数据涨了就撑爆)。

    降得下来(容量或晋升问题)的排查:看是不是大对象直接进老年代(一次查询返回几十万条数据、一次批量处理加载太多、反序列化了一个巨大的 JSON);看是不是Survivor 太小导致提前晋升(动态年龄判定频繁命中);看是不是新生代太小导致 Young GC 过于频繁、对象年龄涨得快。

    还有几类容易忽略的原因元空间不足触发 Full GC(动态代理、字节码增强大量生成类,或者类加载器泄漏);显式调用 System.gc()(自己的代码或者某个依赖库里);堆外内存问题(DirectByteBuffer 泄漏会触发 Full GC 来回收 Cleaner)。

    结合业务时间线看,这一步很实用:Full GC 是不是集中在某个时间点(定时任务在跑,一次加载了大量数据)、某次发布之后(新代码引入)、或者某个接口的高峰期(某个查询返回的数据量随数据增长而变大)。「一次查全表」类的代码是最常见的元凶——上线时数据少没问题,数据涨到几十万就开始爆。

    处理:紧急时先重启或者扩容顶住,同时把有问题的接口限流或降级。但重启只是止损,一定要拿到 dump 再重启,否则现场就没了——所以生产环境必须提前配好 -XX:+HeapDumpOnOutOfMemoryErrorHeapDumpPath

    长期:GC 指标(频率、耗时、老年代使用率)接入监控告警;本地缓存一律用有界的实现(Caffeine 设 maximumSize 和过期);查询必须分页,禁止无限制的全表查询;上线前对新接口做数据量增长的评估。

  13. 定时任务把数据库打挂了,怎么处理?★★

    先止损:找到并停掉那个任务(调度平台一键停止,或者直接 kill 掉正在执行的 SQL),然后确认数据库恢复。不要一边查原因一边让它继续跑

    然后定位为什么会打挂,几类典型原因。

    一、全表扫描或者大范围查询。任务写了 select * from orders where status = ? 这种没有时间范围限制的查询,随着数据增长扫描量越来越大,某天就把 Buffer Pool 冲光、IO 打满。特征是这个任务跑了很久一直没事,突然某天出问题——因为数据量过了临界点。

    二、批量操作没分批。一次 updatedelete 影响几十万行,产生巨大的事务和 binlog,长时间持锁阻塞线上业务,还会造成严重的主从延迟。

    三、没有限速,全速跑。任务用多线程或者循环快速发出大量查询,瞬间把连接池和数据库 QPS 打满,挤占了线上业务的资源。

    四、在高峰期执行。任务本身不重,但和业务高峰叠加就超了。或者时区配置错误导致本该凌晨跑的任务在白天跑了(国际业务里很常见)。

    五、多实例并发执行。本该单实例跑的任务因为分布式锁失效在多个实例同时跑,压力翻倍。

    正确的写法按主键分批,每批几百到几千条,用 where id > last_id order by id limit N 游标方式而不是 offset(深分页本身就是慢查询);批次之间 sleep,主动让出资源;监控主从延迟,延迟超阈值就自动暂停;走从库(只读任务没必要压主库);限制并发度加时间范围条件,只处理增量而不是每次全量。

    架构层面的改进:核心业务和后台任务用不同的数据库账号和连接池,甚至独立的从库实例,这样任务再怎么折腾也影响不到线上交易。这是成本最低、效果最好的隔离手段。

    还有个思路值得提:很多定时任务其实是在做「扫表找需要处理的数据」,这种模式天生不可扩展。更好的方式是事件驱动——数据状态变化时就发消息,由消费者处理,配合延迟消息处理定时需求。这样压根不需要扫全表。能提出这个替代方案,比只讲怎么优化 SQL 更有价值。

  14. 上游重试把下游打爆了(重试风暴),怎么处理?★★★

    先理解为什么会形成风暴:下游变慢或部分失败,上游开始重试,重试让下游流量翻倍,下游更慢更多失败,触发更多重试——正反馈循环。如果是多层调用链,每层重试 3 次,三层就是 27 倍流量,下游必死。而且它最容易在故障恢复的瞬间发生:下游刚恢复,积压的重试一起涌上来,又把它打挂。

    止损手段,按优先级:先在下游入口限流,保住下游不彻底挂(拒绝一部分总比全挂好);关掉上游的重试开关(所以重试必须可配置可动态关闭,不能写死在代码里);必要时熔断,让上游快速失败而不是排队等待。

    然后从设计上根治,几个关键点。

    一、区分能不能重试。只对明确的可重试错误(连接失败、503、明确的超时且操作幂等)重试。业务错误(参数不对、余额不足)重试毫无意义只是加压。写操作尤其支付类不能自动重试,必须靠幂等号加显式逻辑。

    二、指数退避加随机抖动。固定间隔重试会让所有客户端在同一时刻一起重试,形成周期性的流量尖峰。加了随机抖动才能把重试打散。

    三、限制重试次数并且只在一层重试。多层链路里只在最外层(或者最靠近用户的一层)重试,中间层直接失败上抛。否则重试次数会指数级放大。这一条很多团队没有明确约定,结果每层都自作主张重试。

    四、重试预算(retry budget)。这是 Google SRE 推荐的做法:限制重试请求占总请求的比例(比如不超过 10%),超了就不再重试。它的好处是自适应——正常时少量失败可以重试,大面积故障时自动停止重试,避免雪上加霜。比固定次数限制更聪明。

    五、熔断器。失败率超阈值就快速失败,给下游恢复的时间,半开状态用少量请求探测。熔断的本质就是「主动停止重试和调用」,是防风暴的关键组件。

    六、超时要合理且逐层递减。如果上游超时 3 秒、下游超时 5 秒,那上游断开后下游还在傻算,纯属浪费资源。而且超时太长会让线程堆积,加速资源耗尽。

    排查手段:看下游的请求量突增曲线和上游的重试次数指标(重试要单独打点,否则你看不出流量里有多少是重试);链路追踪能看到同一个 traceId 下的重复调用。「重试率」应该是一个常态监控指标,它异常上升往往是故障的早期信号。

  15. 用户反馈「我发的笔记别人看不到」,怎么排查?★★★

    这是内容社区最高频的客诉,而且原因分布很广。按「内容存在吗 → 可见吗 → 能被发现吗」三层排查,这个顺序能最快缩小范围。

    第一层:内容本身的状态。查这条笔记的记录。可能压根没发布成功(客户端显示成功但服务端失败、或者还在草稿状态);可能审核未通过或审核中(这是最常见的原因,尤其先审后发的策略下);可能被举报后下架;可能命中了反垃圾规则被静默降权——注意这一类的特征是作者自己能看到、别人看不到,因为静默处置就是这么设计的(前面反垃圾那题讲过,故意不给作者明确反馈)。

    第二层:可见性规则。笔记可能设了仅自己可见或仅粉丝可见;国际业务里还有地区可见性——某些内容按合规要求在特定国家不展示,作者在 A 国能看、朋友在 B 国看不到;还有拉黑关系,被作者拉黑的人看不到他的内容。

    第三层:分发链路,这一层最容易被忽略。内容状态正常、可见性也没问题,但用户「找不到」,可能是:

    推荐系统没给曝光。新内容进小流量池试探,如果初期表现差就沉底了——这不是 bug 而是机制,但用户会理解成「被限流了」。这种情况要能给作者解释清楚(有些平台会提供「内容表现」面板)。

    关注流的写扩散失败或延迟。如果用推模式,发布时要把内容写进粉丝收件箱,这一步的消息如果消费失败或积压,粉丝的关注流里就没有。要查消息队列有没有积压、有没有进死信

    搜索索引没同步。用户是通过搜索找的,而 ES 同步有延迟或者失败,搜不到。查 binlog 同步链路。

    缓存问题。粉丝的 Feed 列表被缓存了,新内容没进去。或者作者主页的列表缓存没失效。

    排查工具:最有效的是做一个内容诊断工具——输入笔记 ID,一次性输出它的审核状态、可见性配置、风控标记、各分发渠道的投递记录、索引状态。有了它客服能自助排查,不用每次都找研发。这个工具的投入产出比极高,因为这类客诉量非常大。

    长期改进:审核状态和处置结果要对作者透明(在合规允许的范围内),至少让他知道「在审核中」而不是以为出了 bug;分发链路的关键节点要可观测(这条内容投递给了多少粉丝、进了几次推荐池)。

  16. 评论区加载慢、点赞数忽大忽小,怎么查?★★

    两个现象,成因不同,一起说因为它们常同时出现在爆款内容上。

    评论区加载慢,按可能性排:

    N+1 查询。查了一级评论后循环里逐条查每条的回复和用户信息,20 条一级评论变成几十次查询。这是最常见的原因,改成批量查(where root_id in (...))立刻见效。

    排序导致的慢查询。按热度排序如果是实时计算表达式(比如 order by like_count * 2 + reply_count),无法用索引,只能全部取出再排序。要改成维护 hot_score 字段并加索引。

    深分页。爆款内容有几万条评论,用户翻到后面 limit 10000, 20 就慢了。改游标分页。

    热点内容没缓存。一条爆款笔记的评论首页被反复查询,应该缓存住。

    用户信息没批量拿。每条评论要显示头像昵称,如果逐个查用户服务就是几十次 RPC。要批量查加缓存。

    点赞数忽大忽小是另一类问题,核心原因是多个数据源不一致

    缓存和数据库不一致。计数在 Redis 里累加、定期落库,如果读的时候有时命中缓存有时回源数据库,两个值不一样就会跳变。要统一读取路径——要么一直读缓存(缓存挂了才降级),不要混着读。

    计数分片聚合的时机差异。如果用了分片计数(一个 key 拆成 N 份),聚合时如果部分分片读取失败或者用了不同的聚合快照,结果会波动。

    乐观更新和服务端值冲突。前端点赞后本地加 1,服务端返回的权威值可能还没更新完(异步聚合),前端用旧值覆盖了本地值,数字就跳回去了。解法是在短时间内以本地值为准,或者服务端返回时带版本号让前端判断。

    主从延迟。写主库读从库,刚点的赞读不到。

    排查手段:把同一时刻的多个数据源都拉出来对比(Redis 计数、分片各自的值、数据库计数字段、关系表的真实 count),一眼就能看出是哪一层不对。要有定时校准任务按关系表重算并修正缓存,这是这类问题的标准兜底——因为异步聚合的误差一定会累积,不校准只会越来越偏。

    另外前端展示上用缩写(「1.2 万」)能掩盖小的不一致,这是成本最低的缓解手段。

  17. 用户说「明明在配送范围内却下不了单」,怎么排查?★★★

    这类问题的关键认知是:「在范围内」是用户的主观判断,系统的判断依赖一串数据和计算,任何一环出错都会导致结论不同。所以要沿着判断链逐段验证。

    第一步也是最常见的原因:用户地址的坐标不对。用户填的文本地址要经过地理编码转成经纬度,这一步本身就可能出错——地址写得含糊、小区重名、新建楼盘不在地址库里、或者用户选了同名的另一个地点。拿到系统里存的坐标,在地图上标出来看,往往一眼就看出偏到几公里外了。

    第二步:定位方式的差异。用户当前 GPS 定位和他保存的收货地址是两个不同的坐标。他看到「附近的店」是按 GPS 算的,下单校验可能按收货地址算——如果他不在收货地址附近,两个结果自然不一致。这个逻辑不一致是很常见的产品设计缺陷。

    第三步:围栏本身的问题。商家的配送围栏可能刚被调整过(运力不足时缩小、或者运营改错了);可能是分时段围栏,用户白天能下单晚上不行;可能围栏的多边形画得有问题(自相交、顶点顺序错误会导致射线法判断异常)。

    第四步:判断逻辑的边界。用户在围栏边界上时,浮点计算的精度差异会导致结果不稳定。还要确认是直线距离还是路径距离——用户看地图觉得很近(直线 1 公里),但中间隔着河要绕 5 公里,系统按路径算就超出了。这个差异要能给用户解释。

    第五步:不是围栏问题。下不了单的原因可能压根和距离无关,只是错误提示写得含糊:商家已打烊、暂停营业、超出营业时间运力不足被临时限流(前面高峰降级那题提过,会主动缩范围或限单);商品缺货用户账号异常。这时候需要检查错误码而不是继续查围栏。

    排查工具同样是关键:做一个配送可达性诊断——输入用户坐标和商家 ID,输出判断链的每一步结果(用户坐标来源、商家围栏配置、当前生效的时段规则、直线距离、路径距离、判断结论、以及所有其他校验项的结果)。这类问题客诉量大,没有工具靠人工查根本查不完。

    产品侧的改进比技术修复更有效:错误提示要具体(「超出配送范围约 800 米」比「无法配送」好得多,用户至少知道原因);让用户能在地图上手动确认位置并保存精确坐标,从根上减少地理编码的误差。

  18. 「附近的店」搜索变慢或者结果不准,怎么查?★★

    先分清是慢还是不准,两者的排查方向完全不同,而用户反馈往往把它们混在一起说。

    变慢的排查。

    查询条件组合导致索引失效。「附近的店」实际是地理条件加一堆业务条件(营业中、支持配送、品类、评分、价格区间)。如果走 ES,要看是不是某个 filter 没能利用索引、或者聚合(品类统计、价格分布)太重。ES 的 profile 参数能看到每个查询子句的耗时,这是最直接的定位手段。

    候选集太大。市中心商圈半径 3 公里内可能有几千家店,如果先取出全部再在内存里算距离排序,就慢了。要靠索引层面就完成过滤和排序,或者缩小初始半径逐步扩大(先查 1 公里,不够再扩)。

    缓存失效。热门商圈的结果本该缓存,如果缓存 key 设计得太细(把用户精确坐标放进 key,每个人的 key 都不同),命中率接近 0。正确做法是按 GeoHash 网格做 key,同一网格内的用户共用缓存。这是个很典型的缓存设计错误。

    高峰期资源竞争,或者 ES 集群本身有问题(分片不均、GC、慢节点)。

    不准的排查,要进一步分清「距离不对」还是「排序不合期望」。

    距离不对:用户定位坐标偏了(GPS 漂移、室内定位不准、用了缓存的旧位置);店铺的 POI 坐标录错了(数据质量问题,前面 POI 那题讲过);GeoHash 边界问题没处理——只查了中心网格没查周围 8 格,导致马路对面的店搜不到,这是实现层面的经典 bug;直线距离和路径距离的差异。

    排序不合期望:用户以为是按距离排,实际系统综合了评分、销量、推广权重。这往往不是 bug 而是产品设计,但要确认权重配置有没有被改错(比如推广权重设太高,导致远处的推广店排在最前面,用户会觉得「不准」甚至质疑公平性)。

    还有一类是数据同步延迟:新入驻的店搜不到、已关店的还在展示、营业状态没实时更新。查 binlog 到 ES 的同步链路和延迟。营业状态这种高频变化的字段如果走全量同步会很重,通常单独用缓存维护而不是每次改都更新索引。

    长期改进:搜索质量要有可量化的评估——人工标注一批「理想结果」定期回归,或者看线上指标(点击率、点击位置分布、无结果率、搜索后下单率)。没有评估体系的话,「不准」永远是主观争论,改了也不知道有没有变好。这一点是搜索类需求最容易缺失的环节。

  19. 定时任务重复执行或者漏执行,怎么查?★★

    重复执行的原因基本就这几类。

    多实例同时跑:服务部署了多个实例,每个实例的定时任务都触发了。这是最常见的原因,尤其是从单机部署改成集群部署后突然出现。解法是用分布式调度框架(XXL-Job、Elastic-Job)由调度中心统一触发,或者用分布式锁保证只有一个实例执行。

    分布式锁没生效:锁的粒度错了(不同任务用了同一个 key,或者同一个任务用了不同 key)、锁提前释放(业务执行时间超过锁的过期时间,看门狗没配好)、或者用了非原子的加锁方式。业务执行超过锁过期时间是很隐蔽的坑——任务平时 10 秒跑完,某天数据量大跑了 40 秒,锁 30 秒过期,第二个实例就进来了。

    任务本身不幂等 + 重试:调度框架失败重试,或者人工重跑,任务没幂等就重复处理了数据。所以定时任务必须幂等,这是底线要求。

    漏执行的原因:

    上一次还没跑完:任务执行时间超过了调度间隔,调度框架的策略是「跳过」而不是「排队」,就漏了。要么优化任务性能,要么改调度策略,要么分片并行执行

    实例宕机或重启:正在执行的任务被中断,而调度框架没有故障转移,这次就漏了。所以任务要支持断点续跑——记录处理进度,重跑时从上次的位置继续,而不是从头开始或者直接跳过。

    调度中心问题:调度中心自己挂了、时钟不同步、cron 表达式配错(时区问题在国际业务里特别常见——服务器时区和业务时区不一致,任务在错误的时间点执行)。

    任务抛异常静默失败:执行时报错但没有告警,没人知道它失败了。

    排查手段:查调度框架的执行日志和执行记录(每次触发的实例、开始结束时间、结果),这是最直接的证据。所以任务必须有执行记录落库,不能只打日志。

    长期设计要点任务必须幂等要有执行记录和监控告警(失败告警、超时告警、以及「应该执行但没有执行记录」的告警);大任务要分片,避免单次执行时间过长;要能手动重跑并指定参数(补数据是常见需求);业务上要能容忍重复和延迟——不要设计成「必须精确执行一次」的强依赖,那在分布式环境下很难保证。

  20. 用户反馈「明明显示有房,下单却失败」,怎么排查?★★★

    这类问题的排查顺序是「他看到的是哪一层的数据 → 下单校验的是哪一层 → 两层为什么不一致」,因为 OTA 的价格与库存是分层的(列表用缓存、详情实时查、下单再确认),不确认是哪一层就无从下手。

    第一步先分清是「真的没房」还是「系统判错」。直接用同样的房型与日期区间去查供应商实时接口。如果供应商本来就没房,那问题在展示层的缓存过期策略(用户看到的是过期的缓存价与可售状态),这是设计上接受的窗口,要看窗口是否过长

    第二步查跨日区间的扣减。OTA 特有的失败模式是「区间里某一天没余量」——用户订三晚,前两天有房第三天没有,而列表页展示的可能只是「首日有房」。查那三天各自的余量,很多这类反馈的真相就在这里。

    第三步查预占碎片。如果发现某天余量为 0 但没有对应的有效订单,可能是预占没有正确释放——下单流程中断后预占记录既没确认也没释放,一直占着库存。症状是「明明有房却卖不出去」,而这是运营视角看到的同一个现象。查预占记录的 TTL 与扫描任务是否正常。

    第四步查供应商侧的拒单与超时。如果下单其实提交成功了但供应商拒单,那用户看到的「下单失败」实际是「确认失败」,这两种要在文案和状态上区分开,否则用户和客服都会误解。

    长期防护:列表页明确标注为「参考可售」、下单前强制向供应商再确认一次、预占一律带 TTL 并有独立扫描兜底、以及把「某天余量为 0 但无对应订单」做成自动对账告警——这类库存碎片不主动查是发现不了的。

  21. 供应商推来一批数据把线上房源搞乱了,怎么定位和恢复?★★★

    第一步是止损而不是定位。先判断影响面(多少房源、哪些字段被改),能整批回滚就先回滚——线上正在承接流量,恢复正确状态比查清原因优先。这要求同步链路本身有批次概念(见下)。

    第二步查原始推送。如果同步流程把供应商的原始响应先落存储再处理,直接调出那一批原始数据看,就能立刻分清是「供应商推错了」还是「我们解析错了」——这是划分责任的依据,也决定后续找谁修。如果压根没留原始数据,这一步就查不下去,只能靠猜。

    第三步看变更画像。统计那一批的关键字段变更占比、下架占比、坐标平均位移。成批出错的特征在画像上极其明显——比如一次推送里三成酒店改名,这在正常业务里几乎不可能。

    我们被打穿过一次,教训很具体:某供应商导出程序编码配置错了,一次推送里几千家酒店名称全变成乱码;我们所有的校验都在单条粒度,而乱码名称每一条单独看格式都是合法的,所以全部放行。根本教训是:异常检测的粒度必须和异常发生的粒度一致——供应商是成批出错的,单条校验对成批错误结构上就无能为力,不是校验写得不够严,是层次不对。

    恢复的前提是有批次概念:每次同步生成批次号、所有变更挂在批次下、保留变更前的值,回滚就是按批次反向应用。当时我们没有这个,改动是逐条落库的,只能从备份捞,恢复花了几个小时全量推送也要先算差异转成变更记录,不要直接覆盖——直接覆盖等于放弃了审计与回滚能力。

    长期防护:批次级异常占比拦截(超阈值整批挂起转人工)、关键字段变更转人工确认、拦截队列必须配责任人与处理时限——否则只是把「数据错」换成了「数据不更新」,后者更隐蔽。

  22. 核心某个租户说他看到了别的公司的数据,怎么查?★★★

    这是最高优先级的故障,处理顺序是先止损、再定位、最后评估影响面并对客户交代——而且影响面评估不能省,客户一定会问「还有谁受影响」。

    第一步拿到具体证据:哪个账号、什么时间、哪个页面或接口、看到了哪条数据。没有具体的一次请求,后面的排查全是猜。有请求 id 最好,能直接串起整条链路的日志。

    第二步查那条数据的归属与那次请求的租户上下文。两者对不上就确认是越权,接着看是哪一层没生效

    第三步按可能性排查,我按踩过的坑排序。

    一、某张表压根不在隔离范围内。如果拦截逻辑是「表里没有 tenant_id 字段就跳过」,新建的表忘了加这个字段就会完全不受保护——我们真踩过。查那张表是否有 tenant_id、是否在白名单里。

    二、租户上下文串了。典型原因是线程池复用加 ThreadLocal 没清理:包装了 Runnable 传递上下文但任务执行完忘了清,线程被复用处理下一个请求时读到了上一个租户的 ID。这个 bug 只在特定并发时序下出现,测试环境几乎不可能复现——所以要查代码而不是试图复现。异步任务、消息消费、定时任务都是高发点。

    三、为性能做的旁路绕过了隔离。预聚合表、缓存、只读副本、导出接口——这些是最容易漏的,因为它们不走主查询路径。特别查缓存 key 里有没有带租户维度:缓存 key 漏了 tenant_id 会导致 A 租户缓存的结果被 B 租户读到

    四、越权发生在出口而不是查询。页面查询过滤了但导出接口没过滤——我们也漏过这一处

    长期防护把跳过逻辑改成显式白名单,不在白名单又没有 tenant_id 的表直接启动失败(默认安全而不是默认不安全);上下文设置时发现残留值就告警;权限过滤收敛到数据出口的唯一收敛点,散落在各接口里一定会漏一个,而漏一个就等于全部白做。

  23. 客户反馈审批单卡住不动,怎么排查?★★★

    先建立一个认知:审批卡单是静默故障。实例停在某节点,系统不报错、不告警、监控上一切正常——因为从引擎角度看「等待审批」是完全合法的状态,它无法区分「正常在等人审」和「永远等不到人审」。所以这类问题几乎都是用户来问才知道的,而排查要按成因分类去查。

    第一步看实例当前停在哪个节点、待办挂在谁名下、停留多久。这三个信息基本能指向成因。

    第二步按成因排查,我按发生频率排序。

    一、审批人已停用或离职。待办挂在一个已停用账号名下,没人能处理。如果审批人是在发起时解析的,这个概率很高——因为从发起到走到该节点可能过了一周多。正确做法是到达节点时才解析

    二、审批人规则解析不到人。部门没设主管、角色下一个人都没有、上级链断了(发起人就是老板)。查组织数据而不是查引擎

    三、条件分支的所有条件都不满足。这是卡单的首位原因:运营配了「金额小于 1000 走 A、大于 1000 走 B」,忘了等于 1000——正好 1000 的单子走到这个节点就永远停住。

    四、会签的应审人数凑不齐。很隐蔽:某会签节点解析出 3 个人,一人审完后又有新人被加入该角色,应审人数变成 4,而完成判定按「当前角色人数」算,于是永远差一个——看起来「就差一个人审」非常正常。判定完成的基数必须在开始时固化。

    五、外部回调未达(流程里有调外部系统的节点,对方没回)。六、调度任务挂了——超时提醒与自动流转全停,而这件事本身也是静默的,表现是「卡单突然变多」但量的变化不容易察觉。

    长期防护按成因分类的卡单扫描(混在一个队列里运维只能一个个点开看);发布前强制校验条件分支必须有默认出口(把这一类从结构上消掉);审批人解析失败一律兜底到租户管理员并告警(宁可让不太合适的人收到待办,也不能让实例静默卡死——待办发错人会被投诉从而暴露,卡死没有任何信号);给调度任务加心跳监控——兜底机制自己也需要被兜底。

没有匹配的题目,换个关键词试试。

场景题 · 后端 · 共 82 题 · 设计题看方案与权衡,排查题看思路与分层