B 端系统最致命的事故不是宕机,是A 公司在系统里看到了 B 公司的数据。这一次就足以丢掉客户,甚至引发法律问题。所以隔离是这类系统的第一优先级,优先于性能。
第一版的做法是「每个查询手动加 where tenant_id = ?」。这个做法必然出事,原因很简单:
一是一定会漏。系统有几百个查询,新人写代码、老代码改需求、临时加个统计接口,只要有一处忘了加条件,那个接口就能查到全部租户的数据。而且漏了不会报错——查询正常返回,只是返回多了,测试环境只有一个租户压根测不出来。
二是异步链路里租户上下文丢失。主线程有租户信息,丢到线程池里执行的任务、消息队列的消费者、定时任务,这些地方拿不到租户上下文。当时的处理是「拿不到就查全部」,这等于默认放行,是最危险的默认值。
三是所有租户混在一张表里,大客户拖慢小客户。一个数据量特别大的客户做个报表查询,把数据库拖慢,所有客户一起卡。
所以三个设计:隔离下沉到数据访问层强制执行(不依赖业务代码自觉)、租户上下文缺失时拒绝执行(安全的默认值)、按客户规模分级隔离(大客户独立库)。
ThreadLocal 不会自动跨线程。做法是包装 Runnable:提交任务时捕获当前上下文,任务执行时恢复,执行完清理。不清理会导致线程复用时把上一个租户的上下文带给下一个请求,这是极隐蔽且极危险的 bug。共享表加租户字段 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%」或「零越权」。安全类的绝对化断言最容易被问穿,而且只要将来出一次就被推翻。正确表述是「哪三层检查、各自覆盖什么、当前均无发现」。
tenant_id 条件,业务代码里压根不写这个条件,也就无从遗漏。三个关键细节:白名单必须显式列举(不能用「表里没这个字段就跳过」的逻辑——我们踩过这个坑,新同事建表忘加字段,那张表就悄悄失去保护了;改成不在白名单且没有字段就启动失败);上下文缺失必须抛异常,绝不能默认查全部;SQL 解析失败也要拒绝执行,不能放行原 SQL。再加三层兜底:CI 静态扫描裸 SQL、运行时抽样检查 SQL 是否带条件、自动化用例用 A 租户身份遍历所有接口断言看不到 B 的数据。
ThreadLocal 不会自动跨线程。三条路径分别处理:线程池要包装 Runnable——提交时捕获当前上下文,执行时恢复,并且在 finally 里强制清理;消息队列把租户 ID 放在消息头(不要塞业务消息体,那样每个消息结构都要改),消费时读出来重建上下文;定时任务禁止「一次处理全部租户」的写法,必须显式遍历租户列表、为每个租户建上下文后处理,这样顺带获得了按租户的失败隔离。我踩过的坑是忘了清理:线程被复用处理下一个请求时读到了上一个租户的 ID,返回了别的公司的数据,而这个 bug 只在特定并发时序下出现,测试环境几乎复现不了。所以除了 finally 清理,我还加了一层校验——设置上下文时如果发现已有残留值就告警。
B 端和 C 端接口的根本差异在这里:C 端接口是自己的前端在调,改了一起改;B 端接口是客户的程序员在调,你一改他就挂。这个差异带来一串完全不同的要求。
第一版是把内部接口直接暴露出去,问题在接入几家客户后集中爆发。
一是改字段把客户搞挂了。我们觉得某个字段名不好,重构时改了,三家客户的集成同时报错,而他们的排期根本来不及跟着改。B 端客户的技术改动周期是以周计的,不是我们说改就能改。
二是没有限流,一个客户把系统打挂。某客户写了个循环调用的脚本没加节流,几分钟打了几十万请求,所有客户一起受影响。
三是鉴权太弱。只用一个固定 token,抓包就能拿到并且能无限重放。而 B 端客户对安全的要求比 C 端高得多,安全评审过不了就签不了合同。
四是客户要我们主动通知事件,但通知会丢。客户希望「审批通过时通知我的系统」,我们直接调他的地址,他的服务重启一下这个通知就丢了,客户认为我们漏推。
所以四个设计:签名鉴权加防重放、租户级分级限流并暴露配额、接口版本化与兼容承诺、Webhook 带重试与自助补投。
签名鉴权比 token 麻烦,但对 B 端是必要成本。客户的程序员要多写签名逻辑,接入门槛上升,我们要提供多语言 SDK 和调试工具(在线签名校验页面)来降低这个成本。但换来的是能通过客户的安全评审——B 端签合同前往往有安全审计,只有固定 token 的方案通常过不了。这个取舍的判断依据是「客户的采购流程」而不是技术优劣,能说出这一点会显得懂 B 端。
版本化的长期成本很高。同时维护 v1 和 v2 意味着两套代码路径、两套测试、修 bug 要判断影响哪些版本。所以要尽量不出新版本——通过「只新增不修改」的纪律,大部分需求都能在同一版本内满足。出新版本是最后手段,而不是重构的借口。我们的原则是:一年内最多出一个新版本,且必须有明确的、无法兼容实现的业务变化。
踩过的坑:新增了一个枚举值,客户系统全挂。订单状态枚举里加了一个新值,我们认为「新增不是破坏性变更」,结果三家客户的代码是 switch 全匹配、遇到未知值直接抛异常,他们的系统集体报错。教训是「新增枚举值对客户是破坏性的」,除非文档里明确要求过客户做容错并且客户确实做了。修法:新增枚举值要提前公告并给灰度期,文档里明确写「必须容错处理未知枚举值」,并且在 SDK 里默认做兜底。
踩过的坑二:Webhook 推送把客户的服务打挂了。某客户的回调接口很慢,我们的推送是并发的,一次批量事件产生几百个并发回调,把客户的服务打崩了,然后全部失败进重试,重试又打一遍。修法两个:按客户维度限制回调并发和速率(不能因为我们要推就不管对方承受能力);重试要指数退避且检测到对方持续失败就暂停推送并告警,而不是死命重试。「调用别人的接口时要为对方的容量负责」是对外集成的基本礼貌,也是避免连带故障的必要措施。
没做的部分:没做 API 的沙箱环境。客户只能在生产环境测试,容易产生脏数据。理想状态是提供独立的沙箱租户和测试数据,但要维护一套额外环境,成本不小。
「接口变更未导致客户中断」怎么验证:靠兼容性回归测试而不是事后统计。做法是把每个版本的接口契约(字段、类型、必填性)固化成测试用例,CI 里跑契约测试,出现删字段或改类型就直接失败。这比「我们很小心」有说服力得多。可以报的过程指标是「契约测试拦下过多少次不兼容改动」。
Webhook 送达情况:报「首次投递成功数、经重试成功数、最终进死信数」三个绝对数,而不是「送达率 99.9%」。三个绝对数能让人看出重试机制在真实工作,而单一比率既掩盖细节又分母模糊。
限流效果:报网关侧拦截的超限请求数量,以及「拦截发生时业务侧的 P95 是否稳定」——后者才是限流的目的(保护系统),前者只是手段。
不要报「API 可用性 99.99%」。这个数字要有严格的统计口径(什么算不可用、统计窗口多长、计划维护算不算),随口报一个一问就穿。如果确实有 SLA 承诺,要能说出口径和统计方式。
B 端收钱和 C 端收钱是两回事。C 端是一笔订单一次支付,清清楚楚;B 端是按用量计费——调了多少次接口、用了多少存储、有多少活跃用户,月底结账。这里的风险是客户会拿自己的日志来和你的账单对账,差一点就是纠纷。
第一版的做法是「每次调用直接给租户的计数器加一,月底读计数器出账单」。三个问题。
一是不可追溯。客户说「我这个月只调了 8 万次,你收我 10 万次的钱」,我们只有一个总数,说不清那 2 万次是哪些、什么时候、调了哪个接口。这种争议只能靠减免了事,直接损失收入和信任。
二是重复计量。客户端超时重试,同一次业务操作被计了两次。计量必须幂等,否则账单必然虚高。
三是配额和计费混在一起。「实时判断有没有超额」和「月底算钱」用了同一套数据,导致实时判断要查很重的聚合,而计费又依赖可能被缓存污染的数字。
第四个是套餐变更。客户月中从标准版升到企业版,这个月怎么算?按新价还是老价、按天折算还是整月,规则没定清就出不了账单。
所以设计成:计量事件明细化且幂等(只增不改,是账单唯一依据)、实时配额用缓存计数(快,允许小误差)与计费分离、账期跑批出账单且可逐笔下钻、套餐规则快照到账单。
明细存储的成本是明确代价。百万级事件每月,一年就是千万级行,需要分表和归档策略(热数据保留几个月,历史归档到低成本存储)。但这个成本必须付——没有明细的计费系统在第一次争议时就会暴露,而 B 端的一次计费争议可能意味着丢掉一个客户。能说出「我知道成本在哪、为什么值得」比只说「我做了明细」更有说服力。
实时配额允许误差,这个误差的边界要说清。Redis 计数器可能因为主从延迟、重启、异步写入而略微偏低,导致客户能超出配额一点点。这个误差对业务的影响是「少收一点钱」,可以接受;反过来如果误差方向是「误判超额而拒绝了正常请求」,那是不可接受的。所以配额判断要向宽松方向倾斜——宁可放过一点,不可误拦。方向的选择要说出来。
踩过的坑:计量事件丢失导致少收费。计量走消息队列异步写,某次消费者故障,积压的消息在队列过期策略下被清理,那段时间的用量永久丢失,只能按估算给客户出账。修法两个:队列不设过期或设很长;加对账任务——以业务单据数(订单数、API 访问日志数)为参照,抽样校验计量事件是否完整,缺失就告警并补录。「计量丢了就是少收钱」,所以计量链路的可靠性要求和支付一样高,这个认知要有。
踩过的坑二:套餐升级当月账单算重了。客户月中从标准版升到企业版,跑批时按「当前套餐」把整月的用量都按企业版的阶梯算,而企业版的包含量更大、单价更低,结果算出来比按分段计价少收了不少(也可能反过来算多)。总之是错的,客户对账时发现。修法是按生效日期把账期切段,各段用各自的套餐规则,并且把分段依据展示在账单明细里。「变更的生效边界」是计费系统里最容易出错的地方,比价格计算本身更容易错。
没做的部分:没做用量预测与预警(根据历史趋势预测客户本月会不会超额并提前建议升级)。这对续费和客户体验都有价值,但需要数据分析能力,当时只做了简单的阈值通知。
跑批耗时:用百万级计量事件的数据集跑一次月结,记录墙钟时间,约 6 分钟。必须说明事件量、租户数、是否分片并行、机器规格。还要说明可重入性——跑批失败重跑不会重复出账(账单表用「租户 + 账期」唯一索引,扣款用账单号做幂等键)。
「账单与明细逐笔可对」怎么验证:写自动化校验——每次跑批后自动抽样账单,把明细行求和与单头总额比对,不一致直接告警并阻止出账。这比人工抽查可靠。可以报的是「校验拦下过多少次不一致」。
计量完整性:报对账任务发现的缺失事件数(绝对数)。不要报「计量准确率 99.99%」——百万分母下这个比率永远好看,掩盖了「有几十条丢了」这个真正重要的信息。
争议处理:可以报「计费争议工单数」以及「其中通过明细回放定位到原因的数量」。后者才体现明细设计的价值——如果争议来了还是说不清,明细就白存了。
不要报「计费零差错」。绝对化断言且不可能成立。正确表述是「明细可逐笔回放、校验自动化、争议可定位」,这些是可核查的能力描述而不是结果吹嘘。
没有匹配的内容,换个关键词试试。
项目拆解 · 企业服务 SaaS(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据