怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

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

项目背景设定 面向企业客户的 SaaS 平台服务端,Java + Spring Cloud + MySQL + Redis。客户是几百家企业(租户),每家有自己的组织架构、数据和配置。核心能力是多租户隔离对外开放 API按量计费
为什么选这三个模块 B 端和 C 端的技术重点完全不同,这是它最好讲的地方:C 端怕的是高并发,B 端怕的是数据串租户(一家企业看到另一家的数据是致命事故);C 端接口是自己前端调,B 端接口要给客户的程序员调(兼容性、限流、密钥、文档都是硬要求);C 端收钱靠订单,B 端收钱靠用量计量,算错就是纠纷。

模块一:多租户数据隔离

  1. 多租户数据隔离(隔离级别选型 + 强制租户上下文 + 越权兜底)★★★
    简历这样写 多租户数据隔离体系(共享库 + 租户字段 + MyBatis 拦截器自动注入 + 上下文透传 + 越权检测):按客户规模分级隔离(普通租户共享库表、大客户独立库),共享部分在数据访问层统一拦截并强制注入租户条件,业务代码无需也无法绕过;租户上下文在线程与异步链路中透传,缺失即拒绝执行而非默认放行;配套越权扫描定期检查无租户条件的查询。上线后共享库场景的跨租户越权在压测与扫描中未再出现,新增业务表接入隔离的改造成本由逐处手写降为加一个字段与一行配置
    展开完整拆解
    为什么要这么设计

    B 端系统最致命的事故不是宕机,是A 公司在系统里看到了 B 公司的数据。这一次就足以丢掉客户,甚至引发法律问题。所以隔离是这类系统的第一优先级,优先于性能。

    第一版的做法是「每个查询手动加 where tenant_id = ?」。这个做法必然出事,原因很简单:

    一是一定会漏。系统有几百个查询,新人写代码、老代码改需求、临时加个统计接口,只要有一处忘了加条件,那个接口就能查到全部租户的数据。而且漏了不会报错——查询正常返回,只是返回多了,测试环境只有一个租户压根测不出来。

    二是异步链路里租户上下文丢失。主线程有租户信息,丢到线程池里执行的任务、消息队列的消费者、定时任务,这些地方拿不到租户上下文。当时的处理是「拿不到就查全部」,这等于默认放行,是最危险的默认值

    三是所有租户混在一张表里,大客户拖慢小客户。一个数据量特别大的客户做个报表查询,把数据库拖慢,所有客户一起卡。

    所以三个设计:隔离下沉到数据访问层强制执行(不依赖业务代码自觉)、租户上下文缺失时拒绝执行(安全的默认值)、按客户规模分级隔离(大客户独立库)。

    整体链路
    隔离级别分三档(按客户规模与合同要求选,不是一刀切) │ ├─ 共享库共享表 + tenant_id 字段 绝大多数中小租户 │ 成本最低、运维最简单,靠拦截器保证隔离 │ ├─ 共享库独立表 / 独立 schema 数据量大或有合规要求的 │ 物理分开一点,但仍共用实例 │ └─ 独立库独立实例 大客户 / 强合规 / 定制化 彻底隔离,但运维成本高(升级要逐个做) 请求进入 │ ├─ 网关解析身份 → 确定 tenant_id │ 来源:登录会话 / API 密钥 / 子域名,三者都要能识别 │ ├─ 写入租户上下文(ThreadLocal 或请求作用域对象) │ ├─ 路由数据源:按 tenant_id 查路由表决定连哪个库 │ 独立库租户 → 对应实例 │ 共享库租户 → 共享实例 │ ├─ 业务逻辑(业务代码里完全不写 tenant_id 条件) │ └─ 数据访问层拦截器(这一层是隔离的唯一执行点) ├─ 解析 SQL,自动为需隔离的表追加 tenant_id 条件 ├─ 写入时自动填充 tenant_id ├─ 白名单:系统配置表、字典表等无租户概念的表跳过 └─ 上下文缺失 → 直接抛异常,绝不放行 异步链路的上下文透传(最容易出事的地方) │ ├─ 线程池:包装 Runnable,提交时捕获上下文、执行时恢复 ├─ 消息队列:租户 ID 作为消息头传递,消费时重建上下文 ├─ 定时任务:必须显式指定要处理哪个租户,禁止「处理全部」 └─ 原则:拿不到上下文就报错,不允许「默认查全部」 越权兜底(假设拦截器也可能有漏洞) ├─ 静态扫描:CI 里检查是否有绕过 ORM 的裸 SQL ├─ 运行时抽样:采样记录实际执行的 SQL,检查是否带租户条件 ├─ 数据校验任务:抽样检查各表数据的 tenant_id 分布是否异常 └─ 返回结果校验:接口返回的数据里若出现非当前租户的 ID,告警
    分步拆解
    1. 隔离必须下沉到数据访问层,这是唯一可靠的做法。让业务代码自己加条件,几百个查询里必然有漏的。用 MyBatis 拦截器(或 JPA 的过滤器)在 SQL 执行前解析并自动追加条件,业务代码既不需要也无法绕过
    2. 拦截器要有白名单,而且白名单要显式维护。系统配置表、字典表、租户表本身没有租户概念,无脑追加条件会报错。白名单必须是显式列举而不是「找不到 tenant_id 字段就跳过」——后者会让新建的业务表因为忘加字段而悄悄不受保护。
    3. 上下文缺失时必须抛异常,不能默认放行。这是整个模块最重要的一条。「拿不到租户就查全部」看起来是容错,实际是把最危险的行为设成了默认值。安全的默认值是拒绝。
    4. 线程池必须包装以透传上下文。ThreadLocal 不会自动跨线程。做法是包装 Runnable:提交任务时捕获当前上下文,任务执行时恢复,执行完清理。不清理会导致线程复用时把上一个租户的上下文带给下一个请求,这是极隐蔽且极危险的 bug。
    5. 消息队列用消息头传租户 ID。生产时写入消息头,消费时读出来重建上下文。不要把租户 ID 塞进业务消息体——那样每个消息结构都要改,而且容易漏。
    6. 定时任务禁止「处理全部租户」的写法。必须显式遍历租户列表、为每个租户建立上下文后处理。这样也顺便获得了按租户失败隔离的能力——一个租户的数据有问题不会让整个任务挂掉。
    7. 按规模分级隔离,不要一刀切。全部独立库运维成本极高(几百个库的版本升级、备份、监控),全部共享则大客户会影响小客户。按客户规模和合同要求分档,并且要设计成可迁移——租户长大了能从共享库搬到独立库。
    8. 数据源路由要缓存租户到库的映射。每个请求都查路由表会成为瓶颈。缓存起来,租户迁移时主动刷新。
    9. 越权检测要有多层,因为拦截器本身也可能有漏洞。CI 里静态扫描裸 SQL、运行时抽样检查 SQL 是否带条件、定期校验数据分布、接口返回结果里出现非当前租户 ID 就告警。「假设我的防护会失效」这个心态是安全设计的基本要求。
    关键决策与取舍

    共享表加租户字段 vs 独立库,各自的代价要说清。共享表:成本低、升级简单(一次改表全部生效)、但隔离靠代码保证(有风险),且大客户会影响小客户。独立库:隔离彻底、性能互不影响、但运维成本随客户数线性增长——几百个库的版本升级、备份验证、监控告警都要做,而且新功能上线要逐个库执行变更脚本,一旦有几个库失败就出现版本不一致。我们的选择是分级 + 可迁移,这样绝大多数客户走低成本路径,少数需要强隔离的走独立库。

    SQL 解析拦截有性能开销和兼容风险。每条 SQL 都要解析语法树再改写,有 CPU 开销(实测可接受,但要压测确认)。更麻烦的是复杂 SQL 可能解析失败或改写错误——多表关联、子查询、union,改写规则很容易出错。我们的处理是限制复杂 SQL(复杂查询要显式声明并走人工 review),以及解析失败一律拒绝执行而不是放行原 SQL。

    踩过的坑:线程池复用导致租户上下文串了。包装了 Runnable 传递上下文,但任务执行完忘了清理。线程被复用处理下一个请求时,如果那个请求的上下文设置发生在读取之后,就会读到上一个租户的 ID,返回了别的公司的数据。这个 bug 只在特定的并发时序下出现,测试环境几乎不可能复现。修法是在 finally 里强制清理,并且加了一层校验:上下文设置时如果发现已有残留值就告警。这是我讲这个项目时必提的一个坑,因为它体现了 ThreadLocal 在线程池场景的经典陷阱。

    踩过的坑二:新增业务表忘了加 tenant_id,拦截器静默跳过。拦截器早期的逻辑是「表里没有 tenant_id 字段就跳过」,新同事建表时忘了加这个字段,于是那张表的所有查询都不受保护,任何租户都能查到全部数据。修法是把跳过逻辑改成显式白名单——不在白名单里的表如果没有 tenant_id 字段,直接启动失败。「静默跳过」和「显式白名单」的差别,就是「默认不安全」和「默认安全」的差别。

    没做的部分:没做租户级别的资源配额隔离(限制单个租户能占用的数据库连接、CPU、内存)。目前只在应用层做了 API 限流,数据库层面一个租户的慢查询仍会影响他人。彻底解决需要资源组或独立实例,成本较大。

    数字是怎么测的

    「越权未再出现」怎么验证:这是这个模块最该说清的部分,靠三件事而不是一个数字。一是构造性测试——写自动化用例:用租户 A 的身份调所有列表和详情接口,断言返回数据里没有任何属于租户 B 的记录;这套用例进 CI,每次发版都跑。二是静态扫描——CI 检查是否有绕过 ORM 的裸 SQL,有就阻断。三是运行时抽样——采样记录实际执行的 SQL,统计「不含租户条件且不在白名单」的条数,应为 0,非 0 立刻告警。用「三层检查均未发现」而不是「越权率 0%」——后者是绝对化断言且分母说不清。

    接入成本:可以量化——新增一张业务表接入隔离需要做什么。改造前是「每个查询手写 where 条件」(一张表十几个查询就是十几处),改造后是「加一个 tenant_id 字段 + 一行配置」。这是个结构性的对比,比耗时数字更能说明设计质量。

    拦截器的性能开销:压测对比开启和关闭拦截器的接口 P95 差值。这个数字要主动报,否则会被追问「SQL 解析不慢吗」。承认有开销并给出实测值,比声称「几乎没有开销」可信。

    不要报「数据安全性 100%」或「零越权」。安全类的绝对化断言最容易被问穿,而且只要将来出一次就被推翻。正确表述是「哪三层检查、各自覆盖什么、当前均无发现」。

    面试追问
    Q:多租户隔离有哪几种方案?你怎么选的? A:三种,成本和隔离强度递增。共享库共享表加租户字段:成本最低、一次改表全部生效、但隔离靠代码保证,且大客户的慢查询会影响所有人。共享库独立 schema:物理上分开一点,仍共用实例,升级要逐个 schema 执行。独立库独立实例:隔离彻底、互不影响,但运维成本随客户数线性增长——几百个库的版本升级、备份验证、监控都要做,变更脚本逐个执行还可能出现版本不一致。我的选择是分级加可迁移:绝大多数中小租户走共享表(覆盖九成以上客户,成本可控),数据量大或合同有强合规要求的走独立库,并且设计支持「租户长大了从共享库迁到独立库」。一刀切都不合理:全独立库运维会被拖死,全共享则大客户不愿签。
    Q:怎么保证不会有一个查询漏掉租户条件? A:不靠人记得,靠机制强制。核心做法是把隔离下沉到数据访问层——用 MyBatis 拦截器在 SQL 执行前解析并自动追加 tenant_id 条件,业务代码里压根不写这个条件,也就无从遗漏。三个关键细节:白名单必须显式列举(不能用「表里没这个字段就跳过」的逻辑——我们踩过这个坑,新同事建表忘加字段,那张表就悄悄失去保护了;改成不在白名单且没有字段就启动失败);上下文缺失必须抛异常,绝不能默认查全部;SQL 解析失败也要拒绝执行,不能放行原 SQL。再加三层兜底:CI 静态扫描裸 SQL、运行时抽样检查 SQL 是否带条件、自动化用例用 A 租户身份遍历所有接口断言看不到 B 的数据。
    Q:异步任务和消息消费里怎么拿到租户信息? A:这是最容易出事的地方,因为 ThreadLocal 不会自动跨线程。三条路径分别处理:线程池要包装 Runnable——提交时捕获当前上下文,执行时恢复,并且在 finally 里强制清理消息队列把租户 ID 放在消息头(不要塞业务消息体,那样每个消息结构都要改),消费时读出来重建上下文;定时任务禁止「一次处理全部租户」的写法,必须显式遍历租户列表、为每个租户建上下文后处理,这样顺带获得了按租户的失败隔离。我踩过的坑是忘了清理:线程被复用处理下一个请求时读到了上一个租户的 ID,返回了别的公司的数据,而这个 bug 只在特定并发时序下出现,测试环境几乎复现不了。所以除了 finally 清理,我还加了一层校验——设置上下文时如果发现已有残留值就告警。
    Q:一个大客户的报表查询把数据库拖慢了,影响到其他客户,怎么办? A:这是共享库方案的固有代价,要分层解决。短期:把重查询隔离出去——报表类查询走只读库、走独立的连接池(限制它最多占几个连接)、加超时和熔断,避免它吃满资源;对超大结果集强制分页或改成异步导出。中期:应用层做租户级限流和配额,单个租户的请求速率和并发有上限。长期把这个客户迁到独立库——这也是为什么隔离方案一开始就要设计成可迁移的。要诚实承认的是:共享数据库实例的场景下,应用层做不到彻底的资源隔离,一个租户的慢查询总会消耗共享的 IO 和 CPU。彻底解决必须依赖数据库层的资源组能力或者物理隔离。说清「哪些能在应用层解决、哪些必须靠架构变更」,比给一堆优化技巧更对题。

模块二:开放 API 与接入治理

  1. 开放 API 与接入治理(密钥签名 + 分级限流 + 版本兼容 + Webhook 回调)★★★
    简历这样写 对外开放 API 体系(密钥与签名鉴权 + 租户级分级限流 + 版本化与兼容策略 + Webhook 重试):为客户开发者提供开放 API,鉴权用密钥加请求签名与时间戳防重放,限流按租户与接口双维度分级并返回标准配额响应头;接口版本化且承诺向后兼容,破坏性变更走新版本与废弃期;事件通过 Webhook 推送并带签名、指数退避重试与死信,客户端可自助补投。上线后接口变更导致的客户集成中断未再发生,Webhook 在客户端故障场景下经重试可最终送达,超限请求由限流在网关侧拦截。
    展开完整拆解
    为什么要这么设计

    B 端和 C 端接口的根本差异在这里:C 端接口是自己的前端在调,改了一起改;B 端接口是客户的程序员在调,你一改他就挂。这个差异带来一串完全不同的要求。

    第一版是把内部接口直接暴露出去,问题在接入几家客户后集中爆发。

    一是改字段把客户搞挂了。我们觉得某个字段名不好,重构时改了,三家客户的集成同时报错,而他们的排期根本来不及跟着改。B 端客户的技术改动周期是以周计的,不是我们说改就能改。

    二是没有限流,一个客户把系统打挂。某客户写了个循环调用的脚本没加节流,几分钟打了几十万请求,所有客户一起受影响

    三是鉴权太弱。只用一个固定 token,抓包就能拿到并且能无限重放。而 B 端客户对安全的要求比 C 端高得多,安全评审过不了就签不了合同。

    四是客户要我们主动通知事件,但通知会丢。客户希望「审批通过时通知我的系统」,我们直接调他的地址,他的服务重启一下这个通知就丢了,客户认为我们漏推。

    所以四个设计:签名鉴权加防重放租户级分级限流并暴露配额接口版本化与兼容承诺Webhook 带重试与自助补投

    整体链路
    鉴权(不能只靠一个固定 token) │ ├─ 客户在控制台自助创建密钥对:AccessKey + SecretKey │ SecretKey 只在创建时展示一次,服务端只存哈希 │ 支持多对密钥、支持轮换(新旧共存一段时间)、支持吊销 │ ├─ 客户端签名:把 方法 + 路径 + 时间戳 + 随机串 + 请求体摘要 │ 按约定顺序拼接,用 SecretKey 做 HMAC │ └─ 服务端校验 ├─ AccessKey 是否有效、是否属于某租户 ├─ 时间戳是否在容忍窗口内(防重放的第一道) ├─ 随机串是否已用过(Redis 记录,窗口内去重,第二道) ├─ 签名是否匹配(保证请求未被篡改) └─ IP 白名单(可选,客户要求时开启) 限流(按租户和接口双维度,且要让客户能自我约束) │ ├─ 维度:租户级总配额 + 单接口配额 + 突发桶 │ ├─ 分级:按客户套餐给不同配额(免费版 / 标准版 / 企业版) │ ├─ 超限返回 429,并带标准响应头 │ 配额上限 / 剩余量 / 重置时间 / 建议重试秒数 │ → 让客户的程序能自动退避,而不是盲目重试 │ └─ 限流在网关侧执行,不进业务逻辑(超限请求成本最低) 版本与兼容(B 端的硬约束) │ ├─ 路径带版本号:/v1/orders /v2/orders │ ├─ 兼容承诺(写进文档并严格遵守) │ 可以做:新增可选字段、新增接口、新增枚举值(客户需容错) │ 不可做:删字段、改字段名、改类型、改必填性、改语义 │ ├─ 破坏性变更 → 出新版本,老版本进入废弃期 │ 公告 → 废弃期(数月)→ 监控老版本调用量 → 逐客户推进 → 下线 │ └─ 下线前必须确认老版本调用量归零,且逐个通知到客户 Webhook 事件推送(客户要我们主动通知) │ ├─ 客户在控制台配置回调地址与订阅的事件类型 │ ├─ 推送时带签名(客户要能验证这是我们发的,不是伪造的) │ ├─ 客户返回 2xx 才算成功,否则指数退避重试 │ 重试若干次后进死信,控制台可见 │ ├─ 客户可在控制台自助「重新投递」死信事件 │ └─ 保证「至少一次」,事件带唯一 ID 明确告知客户:可能重复,请按事件 ID 去重
    分步拆解
    1. SecretKey 只存哈希,只展示一次。明文存储的话数据库泄露就等于所有客户的密钥泄露。客户丢了只能重新生成,不能找回——这一点要在界面上写清楚。
    2. 签名要覆盖请求体摘要,不能只签路径和时间。否则攻击者可以改请求体内容而签名仍然有效。把请求体的哈希纳入签名内容是必须的。
    3. 防重放需要时间戳和随机串两道。只有时间戳的话,窗口期内(比如 5 分钟)同一个请求可以无限重放;加上随机串并在 Redis 里记录已用过的值,窗口内就无法重放。Redis 的过期时间要略长于时间戳容忍窗口。
    4. 密钥要支持轮换且新旧共存。客户要换密钥时,不能「一换旧的立刻失效」——他的系统是分批发布的,中间必然有新旧并存的时间。支持同时存在多对有效密钥,客户切换完成后再吊销旧的。
    5. 限流的响应头是给客户的程序看的,很关键。返回 429 的同时告知配额上限、剩余量、重置时间、建议重试秒数,客户的 SDK 就能自动退避。只返回一个 429 而不给信息,客户只会盲目重试,把问题放大。
    6. 限流必须在网关侧执行。超限的请求不应该进入业务逻辑、不应该碰数据库。越靠外层拒绝,成本越低。
    7. 兼容规则要写进文档并严格执行。「可以新增可选字段和新接口,不可删改已有字段」。新增枚举值也要提前告知客户做容错——很多客户的代码用 switch 处理枚举,遇到未知值会直接抛异常。
    8. 老版本下线要看调用量,不能只看公告。发了公告不等于客户改了。下线前必须确认老版本调用量归零,并且逐个客户确认过。有客户没改就硬下线,那是我们的事故不是他的。
    9. Webhook 必须带签名。否则任何人都能伪造事件推给客户的回调地址。客户验签的方法要在文档里给出示例代码,否则大部分客户不会验。
    10. Webhook 明确承诺「至少一次」并要求客户按事件 ID 去重。网络和重试机制下重复投递无法避免,把这个契约写进文档,比假装不会重复更负责。同时提供控制台的死信查看与自助重投,减少客户找客服的次数。
    关键决策与取舍

    签名鉴权比 token 麻烦,但对 B 端是必要成本。客户的程序员要多写签名逻辑,接入门槛上升,我们要提供多语言 SDK 和调试工具(在线签名校验页面)来降低这个成本。但换来的是能通过客户的安全评审——B 端签合同前往往有安全审计,只有固定 token 的方案通常过不了。这个取舍的判断依据是「客户的采购流程」而不是技术优劣,能说出这一点会显得懂 B 端。

    版本化的长期成本很高。同时维护 v1 和 v2 意味着两套代码路径、两套测试、修 bug 要判断影响哪些版本。所以要尽量不出新版本——通过「只新增不修改」的纪律,大部分需求都能在同一版本内满足。出新版本是最后手段,而不是重构的借口。我们的原则是:一年内最多出一个新版本,且必须有明确的、无法兼容实现的业务变化。

    踩过的坑:新增了一个枚举值,客户系统全挂。订单状态枚举里加了一个新值,我们认为「新增不是破坏性变更」,结果三家客户的代码是 switch 全匹配、遇到未知值直接抛异常,他们的系统集体报错。教训是「新增枚举值对客户是破坏性的」,除非文档里明确要求过客户做容错并且客户确实做了。修法:新增枚举值要提前公告并给灰度期,文档里明确写「必须容错处理未知枚举值」,并且在 SDK 里默认做兜底。

    踩过的坑二:Webhook 推送把客户的服务打挂了。某客户的回调接口很慢,我们的推送是并发的,一次批量事件产生几百个并发回调,把客户的服务打崩了,然后全部失败进重试,重试又打一遍。修法两个:按客户维度限制回调并发和速率(不能因为我们要推就不管对方承受能力);重试要指数退避且检测到对方持续失败就暂停推送并告警,而不是死命重试。「调用别人的接口时要为对方的容量负责」是对外集成的基本礼貌,也是避免连带故障的必要措施。

    没做的部分:没做 API 的沙箱环境。客户只能在生产环境测试,容易产生脏数据。理想状态是提供独立的沙箱租户和测试数据,但要维护一套额外环境,成本不小。

    数字是怎么测的

    「接口变更未导致客户中断」怎么验证:兼容性回归测试而不是事后统计。做法是把每个版本的接口契约(字段、类型、必填性)固化成测试用例,CI 里跑契约测试,出现删字段或改类型就直接失败。这比「我们很小心」有说服力得多。可以报的过程指标是「契约测试拦下过多少次不兼容改动」。

    Webhook 送达情况:「首次投递成功数、经重试成功数、最终进死信数」三个绝对数,而不是「送达率 99.9%」。三个绝对数能让人看出重试机制在真实工作,而单一比率既掩盖细节又分母模糊。

    限流效果:报网关侧拦截的超限请求数量,以及「拦截发生时业务侧的 P95 是否稳定」——后者才是限流的目的(保护系统),前者只是手段。

    不要报「API 可用性 99.99%」。这个数字要有严格的统计口径(什么算不可用、统计窗口多长、计划维护算不算),随口报一个一问就穿。如果确实有 SLA 承诺,要能说出口径和统计方式。

    面试追问
    Q:开放 API 的鉴权,为什么不直接用 token? A:固定 token 有三个问题。一是无法防重放——抓到一次请求就能无限重发,对写接口是致命的。二是无法防篡改——token 只证明身份,不保护请求内容,中间人可以改金额、改 ID。三是过不了客户的安全评审,B 端签合同前往往有安全审计,只有固定 token 的方案通常被要求整改。所以用密钥加签名:把方法、路径、时间戳、随机串、请求体摘要按约定拼接后用 SecretKey 做 HMAC,服务端校验签名、时间戳窗口、随机串是否用过。时间戳和随机串是两道防重放——只有时间戳的话窗口期内还能重放。代价是接入门槛上升,所以要配套多语言 SDK 和在线签名调试工具,否则客户接不进来。
    Q:你要改一个接口的返回字段,但已经有五个客户在用,怎么办? A:大概率不改。B 端的兼容承诺是硬约束——客户的技术改动周期以周甚至月计,不是我们说改就能改。判断路径:如果是新增字段,直接加(新增可选字段是兼容的);如果是要删或改字段,先问「能不能通过新增来达到目的」——绝大多数情况可以,比如加一个新字段承载新语义,老字段保留但文档标记废弃。只有确实无法兼容实现的业务变化才出新版本,然后老版本进废弃期:公告 → 废弃期数月 → 监控老版本调用量 → 逐个客户推进 → 调用量归零后才下线。绝不能只发公告就下线,公告不等于客户改了,硬下线是我们的事故不是客户的。还有个容易踩的坑:新增枚举值对客户也是破坏性的——很多客户的代码是 switch 全匹配,遇到未知值直接抛异常,我们踩过这个坑,三家客户同时报错。
    Q:某个客户的脚本疯狂调接口,把你的服务打慢了,怎么处理? A:限流,而且要按租户维度隔离——一个客户超限不能影响其他客户。设计上:租户级总配额 + 单接口配额 + 允许一定突发,按客户套餐分级;限流在网关侧执行,超限请求不进业务逻辑不碰数据库,成本最低。返回 429 时必须带标准响应头:配额上限、剩余量、重置时间、建议重试秒数——这样客户的 SDK 能自动退避,而不是盲目重试把问题放大。只返回一个裸的 429 是不负责的。另外要有可观测和沟通闭环:客户的调用量异常时主动告警并联系他(往往是他的 bug,他自己都不知道),控制台上让客户能看到自己的用量曲线。限流的目的是保护系统,但对 B 端来说「让客户能自我约束」比「硬拦」更重要。
    Q:Webhook 推给客户但他的服务挂了,事件怎么办? A:指数退避重试,重试到上限进死信,控制台可自助重投。具体:客户返回 2xx 才算成功,否则按 1 分钟、5 分钟、30 分钟这样退避重试若干次;仍然失败进死信队列,在控制台里让客户能看到失败的事件列表并点击「重新投递」,这能大幅减少找客服的次数。契约上明确承诺「至少一次」并要求客户按事件 ID 去重——重复投递在重试机制下无法避免,把这个写进文档比假装不会重复更负责。还有两个坑推送必须带签名,否则任何人都能伪造事件打给客户的回调地址;要按客户维度限制回调并发和速率——我们踩过坑,批量事件产生几百个并发回调把客户的服务打崩了,然后全部失败进重试、重试又打一遍。调别人接口时要为对方的容量负责。

模块三:用量计量与订阅计费

  1. 用量计量与订阅计费(幂等计量 + 配额控制 + 账期结算 + 可追溯账单)★★★
    简历这样写 用量计量与订阅计费链路(幂等计量事件 + Redis 实时配额 + 账期跑批 + 明细可下钻):把计费拆成「计量 → 配额控制 → 账期结算」三段,计量事件带幂等键只增不改作为账单的唯一依据,实时配额用缓存计数并在超限时按套餐执行限流或拒绝;账单可从总额逐笔下钻到计量明细,套餐变更与退订按比例折算并快照规则。日终计费跑批处理百万级计量事件约 6 分钟,账单与计量明细逐笔可对,计费争议由明细回放定位。
    展开完整拆解
    为什么要这么设计

    B 端收钱和 C 端收钱是两回事。C 端是一笔订单一次支付,清清楚楚;B 端是按用量计费——调了多少次接口、用了多少存储、有多少活跃用户,月底结账。这里的风险是客户会拿自己的日志来和你的账单对账,差一点就是纠纷。

    第一版的做法是「每次调用直接给租户的计数器加一,月底读计数器出账单」。三个问题。

    一是不可追溯。客户说「我这个月只调了 8 万次,你收我 10 万次的钱」,我们只有一个总数,说不清那 2 万次是哪些、什么时候、调了哪个接口。这种争议只能靠减免了事,直接损失收入和信任。

    二是重复计量。客户端超时重试,同一次业务操作被计了两次。计量必须幂等,否则账单必然虚高。

    三是配额和计费混在一起。「实时判断有没有超额」和「月底算钱」用了同一套数据,导致实时判断要查很重的聚合,而计费又依赖可能被缓存污染的数字。

    第四个是套餐变更。客户月中从标准版升到企业版,这个月怎么算?按新价还是老价、按天折算还是整月,规则没定清就出不了账单。

    所以设计成:计量事件明细化且幂等(只增不改,是账单唯一依据)实时配额用缓存计数(快,允许小误差)与计费分离账期跑批出账单且可逐笔下钻套餐规则快照到账单

    整体链路
    计量(明细事件,只增不改,是账单的唯一依据) │ ├─ 业务动作发生(API 调用 / 存储写入 / 用户激活) │ ├─ 生成计量事件(异步写,不阻塞业务) │ 租户 / 计量项 / 数量 / 时间 / 业务单据号 / 幂等键 │ 幂等键 = 业务单据号 + 计量项(重试不会重复计) │ ├─ 写入计量明细表(唯一索引兜底幂等) │ 这张表只允许 INSERT,不允许 UPDATE / DELETE │ → 保证账单永远可复算、争议永远可回放 │ └─ 同时 INCR Redis 的实时用量计数(供配额判断用) 配额控制(实时,允许小误差,与计费分离) │ ├─ 请求进来 → 读 Redis 实时用量 vs 套餐配额 │ ├─ 未超 → 放行 ├─ 接近上限 → 放行 + 响应头提示剩余量 + 触发通知(邮件 / 站内) └─ 已超 → 按套餐策略处理(不同套餐不同策略) ├─ 免费版:直接拒绝 ├─ 标准版:降级限速(降低速率但不断服) └─ 企业版:放行并计入超量费用(合同约定) 关键:配额判断用缓存(快、允许小误差) 计费出账用明细表(准、可追溯) 两者数据源不同,职责不同 账期结算(日终 / 月结跑批) │ ├─ 1 按账期截取计量明细(时间边界用事件发生时间,定死) ├─ 2 按租户 + 计量项聚合 ├─ 3 套用价格规则 │ 阶梯定价(前 10 万次单价 A,超出部分单价 B) │ 套餐内包含量先抵扣,超出部分才计费 │ 规则版本快照到账单(客户中途改套餐也能复算) ├─ 4 处理套餐变更:按生效日期切分,前后分段计价 ├─ 5 生成账单(单头 + 明细行,明细可下钻到计量事件) ├─ 6 风控与人工审核:金额异常波动的账单先拦下 └─ 7 出账 → 通知客户 → 收款 / 扣款(幂等键 = 账单号) 争议处理(这是计费系统的核心价值) └─ 客户质疑 → 按账单下钻到计量明细 展示每一条:什么时间、哪个接口、哪个业务单据 → 能对到单据就能解释,对不上就是我们的问题
    分步拆解
    1. 计量必须是明细事件,不能只有计数器。这是整个模块最重要的决定。一个总数无法应对争议,而明细能逐条回放「什么时间、哪个接口、对应哪个业务单据」。存储成本确实上升(百万级事件每月),但相比计费争议的损失完全值得。
    2. 计量明细表只允许插入,不允许修改和删除。它是账单的事实依据,一旦可以改就失去了可信性(客户会问「你们是不是改过数据」)。需要修正时用冲正事件(负数)而不是改原记录。
    3. 幂等键用「业务单据号 + 计量项」。同一次业务操作无论被重试多少次,只产生一条计量。唯一索引兜底——应用层判重可能因为并发或缓存失效而漏,数据库约束是最后防线。
    4. 计量要异步写,不能阻塞业务。业务动作成功了就该返回,计量失败不应该导致业务失败。但要有补偿——计量事件先进队列,消费失败进死信并告警,不能静默丢弃(丢了就是少收钱)。
    5. 实时配额和计费出账用不同的数据源,这个分离很关键。配额判断在每个请求的关键路径上,必须是内存级读取(Redis 计数器),允许小误差(超一点点无所谓);出账必须准确,走明细表聚合。混用会导致「为了准而慢」或「为了快而不准」。
    6. 超限的处理策略要按套餐分级,不能一律拒绝。免费版直接拒绝是合理的;付费客户直接断服会引发严重投诉甚至违约,应该降速或计入超量费用(按合同)。这是业务规则而不是技术选择,要和商务一起定。
    7. 接近上限时要主动通知。客户超额后才知道就晚了。在达到某个比例时就邮件加站内通知,并在控制台展示用量曲线。这既是体验也是减少纠纷的手段。
    8. 价格规则要快照到账单上。阶梯单价、套餐包含量、折扣,这些会随商务谈判变化。用当前规则算历史账单必然出错,必须把当期生效的完整规则快照进账单,保证任何时候都能复算出同样的结果。
    9. 套餐变更按生效日期分段计价。月中升级要把账期切成两段,各按各的套餐算。「按天折算还是整月」必须和商务定死并写进规则,不能由代码默认某种行为。
    10. 账单金额异常要先拦下再出账。某个租户的账单突然是上月的十倍,很可能是计量 bug 或者客户被攻击。出账前做同比校验,异常的转人工确认——错误的账单发出去,收回来的成本远高于晚一天出账。
    关键决策与取舍

    明细存储的成本是明确代价。百万级事件每月,一年就是千万级行,需要分表和归档策略(热数据保留几个月,历史归档到低成本存储)。但这个成本必须付——没有明细的计费系统在第一次争议时就会暴露,而 B 端的一次计费争议可能意味着丢掉一个客户。能说出「我知道成本在哪、为什么值得」比只说「我做了明细」更有说服力。

    实时配额允许误差,这个误差的边界要说清。Redis 计数器可能因为主从延迟、重启、异步写入而略微偏低,导致客户能超出配额一点点。这个误差对业务的影响是「少收一点钱」,可以接受;反过来如果误差方向是「误判超额而拒绝了正常请求」,那是不可接受的。所以配额判断要向宽松方向倾斜——宁可放过一点,不可误拦。方向的选择要说出来。

    踩过的坑:计量事件丢失导致少收费。计量走消息队列异步写,某次消费者故障,积压的消息在队列过期策略下被清理,那段时间的用量永久丢失,只能按估算给客户出账。修法两个:队列不设过期或设很长加对账任务——以业务单据数(订单数、API 访问日志数)为参照,抽样校验计量事件是否完整,缺失就告警并补录。「计量丢了就是少收钱」,所以计量链路的可靠性要求和支付一样高,这个认知要有。

    踩过的坑二:套餐升级当月账单算重了。客户月中从标准版升到企业版,跑批时按「当前套餐」把整月的用量都按企业版的阶梯算,而企业版的包含量更大、单价更低,结果算出来比按分段计价少收了不少(也可能反过来算多)。总之是错的,客户对账时发现。修法是按生效日期把账期切段,各段用各自的套餐规则,并且把分段依据展示在账单明细里。「变更的生效边界」是计费系统里最容易出错的地方,比价格计算本身更容易错。

    没做的部分:没做用量预测与预警(根据历史趋势预测客户本月会不会超额并提前建议升级)。这对续费和客户体验都有价值,但需要数据分析能力,当时只做了简单的阈值通知。

    数字是怎么测的

    跑批耗时:用百万级计量事件的数据集跑一次月结,记录墙钟时间,约 6 分钟。必须说明事件量、租户数、是否分片并行、机器规格。还要说明可重入性——跑批失败重跑不会重复出账(账单表用「租户 + 账期」唯一索引,扣款用账单号做幂等键)。

    「账单与明细逐笔可对」怎么验证:写自动化校验——每次跑批后自动抽样账单,把明细行求和与单头总额比对,不一致直接告警并阻止出账。这比人工抽查可靠。可以报的是「校验拦下过多少次不一致」。

    计量完整性:报对账任务发现的缺失事件数(绝对数)。不要报「计量准确率 99.99%」——百万分母下这个比率永远好看,掩盖了「有几十条丢了」这个真正重要的信息。

    争议处理:可以报「计费争议工单数」以及「其中通过明细回放定位到原因的数量」。后者才体现明细设计的价值——如果争议来了还是说不清,明细就白存了。

    不要报「计费零差错」。绝对化断言且不可能成立。正确表述是「明细可逐笔回放、校验自动化、争议可定位」,这些是可核查的能力描述而不是结果吹嘘。

    面试追问
    Q:客户说你们的账单算多了,你怎么证明? A:靠计量明细逐笔回放,这也是为什么计量必须是明细事件而不是一个计数器。从账单总额下钻到每一条计量记录,展示什么时间、哪个计量项、对应哪个业务单据号。客户拿自己的日志一条条对,能对上就能解释;对不上就说明我们有 bug,该退就退。关键设计有三个:明细表只允许插入(一旦能改就失去可信性,客户会怀疑我们改过数据),需要修正时用负数冲正事件;计量事件带业务单据号,这是和客户对账的锚点;价格规则快照到账单,保证任何时候都能复算出同样结果。如果只有一个总数,这类争议只能靠减免了事,既损失收入也损失信任。
    Q:为什么实时配额判断和月底算钱要用两套数据? A:因为两者的要求正好相反。配额判断在每个请求的关键路径上,要求极快,允许小误差(客户超出配额一点点,损失是「少收一点钱」,可以接受);出账要求绝对准确可追溯,允许慢(月底跑批,几分钟没问题)。用一套数据必然要牺牲一头:用明细聚合做实时判断会让每个请求都很慢;用缓存计数出账则数字不可信、无法应对争议。所以分开:Redis 计数器管配额,明细表管出账。两者会有小偏差,这是设计接受的。还有个方向性的判断要说出来:配额判断的误差要向宽松倾斜——宁可放过一点点超额,也不能误判超额而拒绝正常请求,后者会引发投诉甚至违约。
    Q:客户月中从标准版升级到企业版,这个月的账单怎么算? A:按生效日期把账期切成两段,各段用各自的套餐规则算,然后相加。我们踩过这个坑——跑批时直接用「当前套餐」把整月用量都按企业版算,因为企业版包含量更大单价更低,结果金额和分段计价差了不少,客户对账时发现。修法是按变更生效时间切段,并且把分段依据展示在账单明细里(「1 日至 15 日按标准版、16 日至月末按企业版」),让客户看得懂。更重要的是:「按天折算还是算整月」「升级当天算哪个套餐」这些规则必须和商务定死并写进规则表,不能由代码默认某种行为——这类边界规则是计费系统里最容易出错的地方,比价格计算本身更容易错。而且规则要快照到账单,否则将来规则改了历史账单就复算不出来了。
    Q:计量事件是异步写的,万一丢了怎么办? A:丢了就是少收钱,所以计量链路的可靠性要求和支付一样高。我们真丢过——消费者故障,积压消息被队列的过期策略清理,那段时间的用量永久丢失,只能按估算给客户出账。修法三层队列不设过期或设很长,别让积压的消息被清掉;消费失败进死信并告警,绝不静默丢弃;加对账任务——以业务侧的原始记录(订单数、网关的 API 访问日志)为参照,抽样校验计量事件是否完整,缺失就告警并支持补录。补录要走冲正/补记事件而不是改历史记录,保持明细表只增不改的性质。另外计量必须幂等(幂等键用业务单据号加计量项,唯一索引兜底),因为重试是常态——丢了少收钱,重了多收钱引发争议,两个方向都要防。

没有匹配的内容,换个关键词试试。

项目拆解 · 企业服务 SaaS(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据