开发文档

鲜花交易系统与鲜花交易平台(PHP / ThinkPHP 版)的研发记录:功能上线、架构演进与实施经验,按发布日期持续更新。

多租户改造:153 张表加租户列、56 个唯一键重排,为什么现在做

为什么不能"等有数据了再改"

多租户改造有三条路:

  1. 每个客户一套库:最简单,管理成本随客户数线性上升;
  2. 共享库 + 租户字段:改造一次,长期省事;
  3. 共享库 + 分表/分库:适合超大规模,运维复杂度高。

这套系统选了第 2 条,而且是在还没有真实业务数据的时候做。

原因很直接:

  • 加字段本身不难,难的是唯一键重构——业务上线后,改唯一键要先解决存量重复数据;
  • 更难的是所有查询都要带租户条件,越晚改,漏掉的地方越多;
  • 一旦有客户的数据混在同一张表里而无法区分,迁移成本会变成"数据清洗项目"。

结论写在设计文档里:表结构现在就按多租户可用建,运行期可以不启用。

两张表的命运不同

167 张表被分成两类:

  • T 类(租户数据表)153 张:订单、商品、会员、资金、内容等,全部加列;
  • P 类(平台共享表)12 张:字典、菜单、权限项、短信模板、迁移台账、行政区划等,一律不加列。

分类标准是数据是否属于某个租户。字典与菜单属于平台定义、租户共用,加 tenant_id 只会制造冗余。

加了哪两列

列类型作用
tenant_idBIGINT UNSIGNED NOT NULL DEFAULT 1归属租户;1 表示平台自身/独立实例
tenant_biz_noVARCHAR(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,是取舍。解决方案分两步:

  1. 过渡期:各表数据量都很小(最大表几千行),性能影响可忽略;
  2. 正式期:查询层统一注入租户条件(TenantDb 作用域),恢复最左前缀。

之所以接受这个过渡期,是因为"改造越早越便宜"的收益远大于短期的索引效率损失。

形态切换:只允许单向升级

部署形态在安装时确定(独立版 / SaaS 版),并且只允许"独立 → SaaS"单向升级,禁止反向降级、禁止运行期切换。

原因:升级是把 tenant_id 从"全是 1"变成"按租户分配",可逆但会产生语义混乱(降级后哪些数据属于谁?)。把不可逆性写进产品规则,比事后靠操作规范约束可靠。

小结

多租户改造里,加字段是体力活,改唯一键是设计活,注入租户条件是耐力活。三件事的顺序不能乱:结构先行、代码跟上、数据最后。等到业务跑起来再动,每一件都会变成事故来源。

本文属于「开发文档」系列,记录鲜花交易系统(PHP + ThinkPHP 8 + MySQL 8)的实际研发进展。想了解功能清单与报价,可前往系统报价或联系我们。