为什么不能"等有数据了再改"
多租户改造有三条路:
- 每个客户一套库:最简单,管理成本随客户数线性上升;
- 共享库 + 租户字段:改造一次,长期省事;
- 共享库 + 分表/分库:适合超大规模,运维复杂度高。
这套系统选了第 2 条,而且是在还没有真实业务数据的时候做。
原因很直接:
- 加字段本身不难,难的是唯一键重构——业务上线后,改唯一键要先解决存量重复数据;
- 更难的是所有查询都要带租户条件,越晚改,漏掉的地方越多;
- 一旦有客户的数据混在同一张表里而无法区分,迁移成本会变成"数据清洗项目"。
结论写在设计文档里:表结构现在就按多租户可用建,运行期可以不启用。
两张表的命运不同
167 张表被分成两类:
- T 类(租户数据表)153 张:订单、商品、会员、资金、内容等,全部加列;
- P 类(平台共享表)12 张:字典、菜单、权限项、短信模板、迁移台账、行政区划等,一律不加列。
分类标准是数据是否属于某个租户。字典与菜单属于平台定义、租户共用,加 tenant_id 只会制造冗余。
加了哪两列
| 列 | 类型 | 作用 |
|---|---|---|
tenant_id | BIGINT UNSIGNED NOT NULL DEFAULT 1 | 归属租户;1 表示平台自身/独立实例 |
tenant_biz_no | VARCHAR(32) 冗余 | 与 tenant_id 同源双写,用于 ID 漂移校正,不建索引 |
第二列容易被问"为什么不只留一个"。答案是:对外接口与导入导出都用业务号,内部查询用整型 ID。两者同源写入,出现不一致时(例如手工改库)可以互相校正。不建索引是因为它不承担查询职责,只为校验存在。
每张 T 类表另加 idx_tenant(tenant_id)。
唯一键:56 个全部前置租户
这是改造真正的工作量。多租户下,"唯一"的含义变了:
- 独立版:用户名唯一;
- SaaS 版:同一租户内用户名唯一,不同租户可以有同名账号。
所以唯一键要从 (username) 改成 (tenant_id, username)。这次统一处理了 56 个:
| 类别 | 数量 | 例子 |
|---|---|---|
| 业务键 | 14 | 账号、用户名、配置键 |
| 单号类 | 31 | 订单号、结算单号、账单号 |
| 日聚合类 | 11 | 按日统计的唯一键 |
改造方式是脚本生成 + 实库比对:先用脚本列出所有唯一键及其列,逐一判断是否属于 T 类表,再批量生成迁移。生成后与 information_schema 双向核对,确认没有遗漏也没有多加。
必须承认的代价
最左前缀原则决定了:唯一键前置 tenant_id 之后,不带租户条件的单列等值查询不再走这个唯一键。
例如原来 WHERE username = ? 能命中唯一索引,现在会退化为全表扫描。实测执行计划确认 key 为 NULL。
这不是 bug,是取舍。解决方案分两步:
- 过渡期:各表数据量都很小(最大表几千行),性能影响可忽略;
- 正式期:查询层统一注入租户条件(
TenantDb作用域),恢复最左前缀。
之所以接受这个过渡期,是因为"改造越早越便宜"的收益远大于短期的索引效率损失。
形态切换:只允许单向升级
部署形态在安装时确定(独立版 / SaaS 版),并且只允许"独立 → SaaS"单向升级,禁止反向降级、禁止运行期切换。
原因:升级是把 tenant_id 从"全是 1"变成"按租户分配",可逆但会产生语义混乱(降级后哪些数据属于谁?)。把不可逆性写进产品规则,比事后靠操作规范约束可靠。
小结
多租户改造里,加字段是体力活,改唯一键是设计活,注入租户条件是耐力活。三件事的顺序不能乱:结构先行、代码跟上、数据最后。等到业务跑起来再动,每一件都会变成事故来源。