2.1 租户识别:域名、子目录与请求头

场景:第一个请求,系统怎么知道它是谁家的

做了 SaaS 你会发现一个绕不开的哲学问题:每个请求进门时都是匿名状态,系统凭什么判断它属于哪个租户。定制时代每个客户一套代码一套域名,Nginx 配置就是答案;现在一百个租户共用一套代码,识别这件事必须由应用自己完成,而且必须在业务逻辑之前完成,稍晚一步就会拿错数据库、读错配置、写错缓存。

我们最早的做法很糙:在入口文件里解析域名,查库拿到租户号,塞进一个全局变量。后来证明这个糙办法埋了两颗雷——一是识别结果没有和缓存键绑定,导致 A 户的页面把 B 户的缓存渲染出来;二是识别失败时没有兜底,域名解析一旦超时,整个请求带着空租户往下跑,直接报一堆莫名其妙的错误。识别看起来是一行代码的事,但它处在请求链路的最上游,上游脏一寸,下游全盘污。这一节我们把识别方式、代码落点、缓存与兜底一次性讲透。

理解这件事还有一个视角:识别层是多租户系统唯一被所有请求经过的公共地带,投资这里的健壮性回报率最高。我们后来给识别层单独写了二十多个测试用例,覆盖未知域名、停用租户、自有域名、并发解析等场景,是全套代码里测试覆盖率最满的模块之一,每次发版回归必跑。

方案:三种识别方式对比

实践中常见的识别方式有三种:子域名(acme.oursaas.com)、子目录(our-saas.com/acme)、请求头或登录态(单点入口)。我们逐一对比,再给出自己的选择。

方式 优点 缺点 适用场景
子域名 租户感强,便于绑定自有域名 需要泛解析与证书管理 面向 C 端或品牌租户
子目录 部署最简单,一个域名搞定 静态资源与 CDN 路径要小心 内部工具或低客单价
请求头或会话 灵活,适合登录后切换 未登录页面无法识别 平台型后台应用

我们最终以子域名为主、请求头为辅:对外站点用子域名,客户登录后的管理后台靠会话中的租户号二次确认,两者都统一进一个中间件层。顺带一个经验:不要让子目录与子域名混用,混用会让权限校验、CDN 缓存、 Cookie 作用域全部复杂一倍,某个功能出了串号问题你都不知道从哪查起。选定一种主方式,其他方式仅作内部管理用途。

选型时还要考虑托管场景:如果将来要做私有化部署或者让租户绑定自己的备案域名,子域名方案的扩展性最好,只需要在解析层加一条映射。需要托管静态站点的团队可以参考 轻量建站实战 的域名配置做法。

补充说明子目录方案为何被我们放弃:它最大的问题不在路由,而在共享 Cookie 与 CDN 缓存路径。子目录下所有租户共享同一域名,静态资源缓存必须额外加租户维度才能区分,任何一处漏掉就是串号现场。另外 SEO 上子目录的权重归属对多租户站阵也不友好。两害相权,子域名虽然要多管一张证书表,仍是更干净的选择。

扩展:识别层与网关的分工

识别全部压在应用里并不划算,我们把一部分前移到了网关层,应用层只做最终确认,分工如下:

域名形态 网关动作 应用动作
系统子域名 透传,注入通用头 查映射表确认租户
租户自有域名 查映射,重写到内部主机名 同上,走兜底分支
未登记域名 直接 404 拦截页 不进入应用
后台 API 域名 校验内部令牌 从会话读租户号

这样未登记域名在网关就被拦掉,应用少扛一大半无效请求;证书也由网关统一托管通配符,应用服务器彻底告别证书续期这件事。小改动换来的清净,很划算,每个月少操一份证书过期的心。

配置:识别中间件与解析结果缓存

识别逻辑写成一个全局中间件,所有路由之前执行,结果挂到请求上下文里,并带五分钟的本地解析缓存:

class ResolveTenant
{
    public function handle($request, Closure $next)
    {
        $host = $request->getHost();                 // acme.oursaas.com
        $map = TenantMap::remember($host, 300, function () use ($host) {
            return TenantMap::where('host', $host)->firstOrFail();
        });

        if (!$map) {
            return response('未知租户', 404);
        }

        app()->instance(TenantContext::class, new TenantContext(
            $map->tenant_id,      // 租户号,贯穿整个请求
            $map->plan            // 套餐,供限流与功能开关读取
        ));

        return $next($request);
    }
}

两个细节值得注意。第一,TenantMap 的查询走的是独立缓存键,键里直接包含 host 本身,杜绝了不同域名命中同一条映射的可能;第二,租户不存在时立即 404,而不是让请求带着空上下文继续跑,这是越权防御的第一道闸门。整个系统里所有拿租户号的地方统一从 TenantContext 取,禁止再从域名重新解析一遍——一次请求只有一个真相来源。

再补一个并发细节:中间件里对同一个 host 的并发解析可能同时穿透到数据库,打出一波重复查询。我们给解析加了本地短时锁与单飞机制,同一 host 的并发只放一个去查库,其余共享结果,识别高峰时数据库压力平滑了很多。

自有域名绑定也是在这层做的。租户把自己域名 CNAME 过来后,我们在 TenantMap 表里加一条,中间件先查 host 命中,再跳子域名的兜底分支,证书由网关层统一托管通配符。上线这套机制后,绑自定义域名成了增值功能,产品还多了一条收费项。

小提醒:绑自定义域名要检查备案与合规要求,我们做了简单的归属校验,要求租户提供域名所有权证明后再开通绑定,避免被拿来做钓鱼站点连累我们的服务器被运营商下架。

踩坑:识别环节的四个事故

第一个事故是缓存键没绑租户。早期我们把页面渲染结果按 URL 缓存,两个租户恰好用了相同的路径别名,A 户首页直接渲染了 B 户的轮播图。修复方式是所有缓存键强制拼上租户号,并在代码评审里加了一条红线:任何不带租户号的缓存写入一律打回。这套键前缀规范我们在 3.4 那一节展开。

第二个事故是泛解析导致的串号。Nginx 配置里 server_name 写得宽松,一个未注册的子域名被默认路由到了 root 租户的后台。虽然没造成数据泄露,但安全部门吓出一身冷汗。之后所有未知域名一律打到专门的拦截页,并在日志里记录尝试访问的域名,用来看有没有扫描者在试域名爆破,日志里果然每周都能翻到几批扫描流量。

第三个事故是识别缓存的过期问题。租户被停用后,映射缓存最多五分钟才失效,这五分钟里他们还能登录操作。后来对风险的租户操作(开通、停用、换套餐)做了缓存主动失效,管理动作一旦发生,立即广播清除对应键,与缓存过期时间形成双保险,宁可多查一次库也不冒这一分钟的风险。

第四个事故是请求头伪造。我们曾为了调试方便允许通过请求头 X-Tenant 直接指定租户,结果某个离职同事拿这个头直接访问了别人的数据。教训:调试用的后门必须限定来源 IP 与内部令牌,线上默认关闭。多租户的信任边界一旦打开一个口子,早晚有人会钻进去,安全章节我们还会系统聊这个话题,可以对照 PHP API 实战 里的鉴权设计。

识别层上线大半年后,我们没有再遇到串号事故,但新增了一个声响很吓人的现象:个别租户偶尔整个域名访问 404。查下来是映射缓存的高可用节点在主从切换时丢了几秒数据,remember 的缓存与库短暂不一致。最终的解法是给映射表加了一层本地文件兜底,应用启动时把整张映射表落盘,查不到库就先查文件,等于给识别层多了一个永远不会空的备份。

小结

租户识别是多租户系统的第一道门:方式上以子域名为主、请求头为辅,代码上收敛进一个中间件一个上下文,原则上任何缓存与渲染都不得脱离租户号独立存在。识别层做得干净,后面的隔离、配置、计费才有稳固的抓手,哪些环节出问题你都能立刻定位到是哪一个请求、哪一个域名、哪一个租户。

如果让我给新接手多租户项目的同学一条建议:把识别层当作产品而不是脚手架来写——给它专门的测试、专门的监控、专门的回滚预案,这一层的每一分投入都会在事故时百倍奉还。顺便一提,我们后来把整套识别能力做成了内部开源组件,其他项目组接入时发现适用性出奇好,这也侧面说明识别层的抽象是经得起考验的。再补一条监控层面的经验:识别层上线后,我们专门加了一个「未知域名访问量」告警,任何打到平台上却没有映射记录的域名都会被计数。这个指标帮我们提前发现过两次事故苗头——一次是客户改了域名解析忘了通知我们,一次是有人拿平台域名做钓鱼跳转。识别层的价值不止于防串号,它同时是平台流量卫生的第一道哨兵。下一节进入全书技术含量的深水区:数据隔离,独立库与共享库加租户号如何在同一套代码里共存。

延伸阅读