2.2 数据隔离:独立库、共享库加租户号

场景:一条 SQL 查错,全公司加班

这是整本书技术含量最高的一节,因为数据隔离是多租户的命脉。我们最严重的一次事故就发生在这里:某个同事在排查慢查询时手工写了一条带 LIMIT 的 SQL,忘了带 where 条件里的租户号,直接查到了别的租户订单数据。那天晚上全公司加班到凌晨做影响面评估,第二天给所有客户发致歉邮件。从那以后我们明白:数据隔离不能依赖人的细心,必须在架构里让它物理上不可能查错。

事故当晚全员无眠的场景我至今记得:两个同事在导出审计日志,三个在给客户打电话,剩下的人在写道歉信。一次事故能改变一个团队的工程文化,从那天起隔离设计在我们这里不再有灰色地带,要么做了要么没做,没有大概做了这种说法。

选型篇里我们定了混合分层:大客户独立库,小客户共享库加 tenant_id。这一节讲它具体怎么落地——共享库怎么保证每条 SQL 都带租户号、独立库的连接怎么切换、以及升降级迁移怎么不停机地跑通。涉及到的细节比其他节都多,因为这里容不下任何侥幸。共享库的逻辑隔离做不扎实,独立库就成了用来擦屁股的补丁;连接切换做不干净,两个模式就会互相污染。

我们团队里有句话流传很广:代码会撒谎,表结构不会。所有隔离设计最终都要落到表结构、约束、索引这些数据库层的客观事实上。这一节你会看到,最有效的防线往往不是最精巧的代码,而是非空约束、联合索引和审计钩子这些笨办法。

方案:两种模式在一套代码里共存

第一步是把差异收敛到连接解析,这个在 1.2 已经见过:独立库户返回专属连接名,共享库户返回统一连接。第二步是共享库这侧的查询层改造。我们对比过三种做法:

做法 隔离强度 开发成本 我们的取舍
手工拼 where 条件 极弱,全靠自觉 低 否决,第一个事故就栽在这
模型基类加全局作用域 强,自动注入 中 采纳,所有表统一走这套
数据库代理层改写 最强,但引入新组件 高 暂缓,留作容量瓶颈后的加固

全局作用域的意思是:模型的每次查询自动追加 where tenant_id = 当前租户,写入时自动填充字段。关键在于它是默认行为,而不是可选行为——想绕过必须显式声明,代码评审里显式声明会被重点盘问。独立库那侧表里没有 tenant_id 列,基类检测到连接模式后跳过注入,业务代码完全无感。这一套机制的精妙之处在于:隔离的开关在模型基类这一个文件里,给审计和排查都留下了清晰的落点。

量化:隔离效用的日常巡检

隔离机制再精巧也需要持续的度量来证明它还活着。我们给数据隔离设了三组日常指标,每天由值班同事扫一眼,异常立刻进工单:

指标 采集方式 健康阈值
作用域命中率 采样慢查询日志统计 无租户号查询占比零
探测用例通过率 CI 回归 百分之百,否则禁止发版
跨库异常告警量 数据库审计日志 单日零条

跟踪半年后我们发现一个反直觉的结论:出问题最多的不是共享库,而是独立库户的联表导入脚本。绕过模型直接拼 SQL 的场景几乎都发生在数据导入工具里,于是把导入工具也纳入了作用域强制的改造范围,从那以后指标全绿。数据要好看,工具管和代码管一样重要。

配置:模型基类与写入兜底

模型基类的核心逻辑如下,读和写两条路径都带上租户号:

abstract class TenantModel extends Model
{
    protected static function boot()
    {
        parent::boot();

        // 查询:自动追加租户条件
        static::addGlobalScope('tenant', function (Builder $q) {
            $ctx = app(TenantContext::class);
            if ($ctx->isShared()) {
                $q->where($q->getModel()->getTable() . '.tenant_id', $ctx->id());
            }
        });

        // 写入:自动填充租户号,防止手工漏填
        static::creating(function ($model) {
            $ctx = app(TenantContext::class);
            if ($ctx->isShared()) {
                $model->tenant_id = $ctx->id();
            }
        });
    }
}

两个细节决定成败。第一,where 条件里要带上表名前缀,否则联表查询时 tenant_id 会歧义,联表里两个表都有同名字段时数据库直接报错,而这个报错恰恰是保护而非 bug。第二,creating 钩子兜住手工漏填的情况——有人绕过模型直接拼 SQL 插入怎么办?我们在数据库层给共享库所有表都建了 tenant_id 的非空约束加联合索引,非法插入会被数据库直接拒绝,双保险就是这么朴素。

还有一个联表的坑值得说:跨表 join 时作用域只作用于主表。如果关联表也带 tenant_id 而没被作用域覆盖,数据就可能串。我们的做法是统一约定关联键里必然包含 tenant_id(比如外键是 tenant_id 加 id 的联合键),从表结构上让联表 natural 带上隔离条件。

迁移:升降级不停机的三步法

租户从共享库升到独立库,是这套架构最有商业价值的时刻,也是最容易翻车的时刻。我们摸索出三步:先按表分批搬历史数据,再追增量(搬的过程中记录共享库的写入位点),最后切换连接并校验行数,全程业务不断。整个过程的脚本参数都存在配置中心里,任何一个租户的迁移进度、位点、校验结果都随时可查。整套流程跑通后,销售在大单上承诺的数据升级服务才真的敢拍胸脯。

校验环节我们做了行数对齐与关键字段抽样比对两道,两道都通过才切连接,任何一道失败都会自动回滚脚本,租户继续在共享库跑,不影响业务。迁移全过程还会以租户为单位写一条进度日志,客户问起来能精确到百分比,比一句请稍等专业得多。

这里分享一个真实的小故事:第一次给客户做升级迁移时,我们全组人在办公室守到凌晨两点,脚本是半自动的,每一步都要人盯。第三次迁移时已经可以点个按钮去吃晚饭了,第八次之后迁移变成常规操作,甚至放在白天工作时间跑。技术成熟度就是从这些具体的场景里长出来的,没有第一次的狼狈就没有后面的从容。

踩坑:数据隔离的四个深渊

第一个是刚才说的手工 SQL 越权。修复方式是双管齐下:所有查库入口统一走模型,DB 门面直接 raw SQL 的权限在代码评审里一票否决;同时数据库层做了审计日志,任何一次跨库或异常查询都会触发告警。

第二个是全局作用域失效。某次框架小版本升级,作用域的注册方式变了,我们没注意到,共享库查询开始漏掉 tenant_id,正好被自动化回归用例当场逮住(就是 1.1 那节写的 A B 户互探脚本)。从那以后,多租户探测用例纳入发版强制流程,Scope 失效这类问题再也没有漏到线上。

第三个是联表查询的 tenant_id 歧义。错误信息里两个表都有同名字段,排查了一小时才发现是作用域没加表名前缀。所有 where 条件统一带表名前缀之后这类错误消失得干干净净。

第四个是迁移过程中的写入双写问题。从旧库到新库搬数据时,如果业务仍在写旧库,必须先开关切到双写,再追增量,否则新旧库会不一致。我们踩过一次,迁移完成后新库少了半小时的订单,补了三个小时的数据。从此迁移脚本里必须先开双写开关,再启动搬运,写死在流程里。

再补一个预防性的设计:独立库户的连接配置只在服务端进程内构造,不落本地文件。曾有同事把独立库连接信息导出到 CSV 里做分析,文件在共享盘里躺了一周,吓得我们连夜改了导出接口,敏感字段一律脱敏后才允许下载。数据隔离不只是防查询越权,也包括防数据搬运过程中的泄露。

小结

数据隔离的核心是让正确成为默认行为:查询自动带租户号、写入自动填充、联表天然带条件、数据库层兜兜底。独立库与共享库的共存靠的是连接解析与模型基类两个收敛点,迁移靠三步法不停机。这一节的每一行代码都值得用自动化测试钉住,因为它是最不该心存侥幸的地方。验收我们还有一条不成文的规矩:任何涉及隔离的改动,提交者要在评审会上口头把 A 户 B 户互相探测的路径走一遍,说不清楚就打回,这条规矩比任何文档都有效。再三确认:一次请求只有一个租户上下文。这一点在异步任务里尤其容易破,队列任务的上下文必须在入队时序列化租户号、出队时还原,我们也是在 4.1 节讲灰度时才把它补完整。数据隔离不是单点的代码问题,而是全链路的纪律问题。再给一组让团队安心的机制:数据隔离的改动上线前,我们会在影子上跑一轮「交叉写探针」——自动化脚本冒充 A 户向 B 户的表写一条标记数据,正常情况下必然被拦截,写入成功则流水线标红。这个探针每天随回归一起跑,两年里从未成功写入过一次,但它每天绿色通过本身就是团队睡得着觉的原因——守夜人不需要开枪,他在岗就够了。下一节我们往上一层,看配置中心怎么让每个租户有自己的开关与套餐。

延伸阅读