# IM 独立库多租户改造方案 > 编写日期:2026-08-13 > 适用项目:`im_server` > 方案状态:替代原“共享库共享表 + tenant_id”方案,后续多租户开发以本文为准。 ## 1. 方案结论 多租户调整为: > 共享服务集群 + 控制库保存租户元数据 + 每个租户独立业务库 业务库中的 `users`、`auth`、`credentials`、`topics`、`subscriptions`、`messages`、`fileuploads` 以及后续产品业务表都不再增加 `tenant_id` 字段。租户隔离通过运行时选库实现:客户端先用企业码解析租户,服务端得到 `tenant_id` 后,根据控制库中的 `db_name` 或 `db_dsn` 选择对应租户业务库。 `TenantID` 仍然要保留,但它的职责从“业务表行级隔离字段”调整为“运行时租户上下文”: 1. 登录前企业码解析; 2. Token 签名和校验; 3. Session 当前租户固定; 4. Hub、缓存、集群路由、Push、文件路径和插件上下文隔离; 5. 选择当前请求使用哪个租户业务库; 6. 后台平台侧管理租户、配置配额、停启租户。 ## 2. 为什么独立库仍需要 TenantID 独立库解决的是数据库物理隔离,不代表服务端运行时没有租户。服务进程仍然同时服务多个企业,如果内存 Topic、用户缓存、系统 Topic、Push、文件下载授权、Token 校验不带租户上下文,仍可能出现: 1. 两个租户都使用逻辑 Topic `sys` 时共用同一个内存 Topic; 2. HTTP 文件下载只拿到 `file_id`,无法判断该去哪个租户库查授权; 3. Token 只包含 UID 时,无法知道该 UID 属于哪个租户库; 4. 定时任务、插件调用、Push 回调无法定位租户库; 5. 后台接口可能把平台租户 ID 和业务租户数据混在一起。 所以新方案的边界是: ```text TenantID 负责运行时路由和授权上下文 tenant_id 字段不进入租户业务库表结构 ``` ## 3. 数据库拆分 ### 3.1 控制库 控制库可以使用现有 `hotgo`,只保存平台级租户元数据和配额配置。控制库表需要保留 `tenant_id`,因为这些表本身是在管理所有租户;这里的 `tenant_id` 不是业务库行级隔离字段。 ```sql CREATE TABLE im_tenant ( id BIGINT NOT NULL AUTO_INCREMENT, code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, tenant_desc VARCHAR(256), db_name VARCHAR(128) NOT NULL DEFAULT '', db_dsn VARCHAR(1024) NOT NULL DEFAULT '', state SMALLINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_by BIGINT NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_im_tenant_code (code), KEY idx_im_tenant_db_name (db_name) ); CREATE TABLE im_tenant_config ( id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL, max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_by BIGINT NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id), CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY (tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ); ``` `db_name` 适合所有租户共用同一个 MySQL 实例时使用,例如 `im_tenant_1001`。`db_dsn` 适合某些租户需要独立 MySQL 实例时使用。优先级建议为:`db_dsn` 非空时使用完整 DSN,否则使用主 DSN + `db_name`。 如果要求 `db_name` 不能重复,建议在租户创建服务里校验“非空 db_name 唯一”。MySQL 普通唯一索引不能表达“只对非空字符串唯一”,直接给 `db_name` 建唯一索引会导致多个使用独立 `db_dsn` 的租户因为空 `db_name` 冲突。 ### 3.2 租户业务库 每个租户一个业务库,例如: ```text hotgo ├─ im_tenant └─ im_tenant_config im_tenant_1001 ├─ users ├─ auth ├─ credentials ├─ topics ├─ subscriptions ├─ messages ├─ fileuploads └─ 后续产品业务表 ``` 租户业务库表结构恢复为租户内唯一约束,不加 `tenant_id`。同一个手机号、用户名可以在两个租户库中各自存在,不会冲突。 ## 4. 当前代码如何调整 当前代码已经有一部分独立库路由雏形,可以继续利用: 1. `server/store/store.go` 已经预留 `TenantRoutedAdapter` 分支; 2. `server/db/adapter.go` 已定义 `TenantAdapterFor(tenantID)`; 3. `server/db/mysql/adapter.go` 已支持 `tenant_registry_db`、`tenant_db_routing`、`db_name`、`db_dsn`; 4. Token、Session、Hub TopicKey、UserKey、插件上下文已经带 `TenantID`。 需要调整的重点是: | 模块 | 旧共享表方案 | 新独立库方案 | | --- | --- | --- | | 控制库 | 与业务表混在一个库 | 单独保存 `im_tenant`、`im_tenant_config` | | 业务库 schema | 所有表加 `tenant_id` | 所有业务表不加 `tenant_id` | | MySQL Adapter | SQL 使用 `WHERE tenant_id=?` | 先根据 TenantID 选库,再执行租户库内 SQL | | Store 接口 | TenantID 作为 SQL 条件 | TenantID 作为选库参数 | | 唯一索引 | `(tenant_id, business_key)` | 租户库内 `business_key` 唯一 | | 外键 | 复合外键带 `tenant_id` | 普通外键 | | 数据迁移 | 共享库内补列回填 | 新租户直接建独立库,无历史迁移 | ## 5. 代码落地步骤 ### 5.1 拆分 schema 把 MySQL schema 拆成两个层次: 1. `registry_schema.sql`:只创建控制库表 `im_tenant`、`im_tenant_config`; 2. `tenant_schema.sql`:创建 Tinode 核心表和产品业务表,不包含 `tenant_id`; 3. `schema.sql` 可以作为文档索引,说明不能直接混用两套 schema。 `CreateDb(reset)` 也要拆分: 1. 初始化控制库; 2. 初始化默认租户业务库; 3. 根据 `im_tenant.db_name/db_dsn` 初始化指定租户库; 4. 禁止在同一个事务中同时创建控制库和多个租户业务库,避免失败后状态难恢复。 ### 5.2 改 MySQL Adapter 路由 保留 `TenantAdapterFor(tenantID)`,但租户 Adapter 内执行的 SQL 应该是无 `tenant_id` 版本。 推荐结构: ```text mysql.adapter ├─ registryDB: 连接控制库 hotgo ├─ defaultDB: 默认租户库 ├─ tenantAdapters: map[TenantID]*adapter └─ TenantAdapterFor(tenantID): 从 registryDB 读取 db_name/db_dsn 并返回租户库 adapter ``` 租户业务方法仍保留 `TenantUserGet(tenantID, uid)` 这类签名,但实现改为“先选库,后执行租户库内原 SQL”。实际写代码时要避免递归,可以把“无租户 SQL”的方法放在租户库 adapter 上,把“路由包装方法”放在根 adapter 上。 ### 5.3 删除业务表 tenant_id 依赖 需要回退或重写这些位置: 1. `server/db/mysql/schema.sql` 中核心业务表的 `tenant_id` 字段; 2. `server/db/mysql/tenant_adapter.go` 中所有 `WHERE tenant_id=?`、`INSERT tenant_id`、复合 JOIN; 3. `server/db/mysql/tests/mysql_test.go` 中依赖共享表隔离的测试; 4. `docs/sql/mysql-multitenant-tenant-id-review.sql` 这份共享表迁移脚本应标记为废弃; 5. 后续产品业务表设计也不再带 `tenant_id`。 不建议删除 Store、Session、Token、Hub、Push 和插件里的 `TenantID`,这些属于运行时隔离边界。 ### 5.4 租户创建流程 后台创建租户时建议按以下顺序: 1. 在控制库 `im_tenant` 创建记录,状态为 `provisioning`; 2. 生成业务库名,例如 `im_tenant_{id}`; 3. 创建业务库并执行 `tenant_schema.sql`; 4. 写入默认系统数据,例如 `kvmeta.version`、系统账号、`sys` Topic; 5. 回写 `im_tenant.db_name`,状态改为 `active`; 6. 创建失败时租户保持不可登录状态,并记录错误供后台重试。 第一阶段可以不做自动建库接口,先由运维执行建库 SQL,再在控制库写入 `im_tenant`。 ### 5.5 认证与登录 登录入口仍然是: 1. 客户端携带 `X-Tinode-APIKey` 请求准入; 2. `hi` 或登录前 HTTP 接口提交企业码; 3. 服务端在控制库查 `im_tenant.code`; 4. 租户状态必须是 `active`; 5. Session 固定 `TenantID`; 6. 登录认证根据 `TenantID` 选择租户业务库,然后查该库内 `auth`、`credentials`、`users`; 7. Token 签名内容继续包含 `TenantID`,HTTP 后续请求可从 Token 反推出该使用哪个租户库。 客户端不能通过 URL、Header、Body 直接指定业务库名或 `tenant_id`。 ## 6. 产品业务表如何设计 后续根据《IM 产品原型需求文档》补充的业务表也按“租户库内表”设计,不加 `tenant_id`。所有新业务表仍统一要求: ```sql created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ``` 例如好友申请: ```sql CREATE TABLE im_friend_requests ( id BIGINT NOT NULL AUTO_INCREMENT, request_id CHAR(26) NOT NULL, applicant_id BIGINT NOT NULL, target_id BIGINT NOT NULL, message VARCHAR(256), status SMALLINT NOT NULL, handled_by BIGINT, handled_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (id), UNIQUE KEY uk_im_friend_requests_request_id (request_id), KEY idx_im_friend_requests_target_status (target_id, status, created_at), KEY idx_im_friend_requests_applicant_status (applicant_id, status, created_at) ); ``` ## 7. 文件和对象存储 数据库独立库后,文件元数据在租户库内隔离,但对象存储路径仍建议带租户目录: ```text tenants/{tenant_id}/yyyy/mm/{file_id} ``` 对象存储通常仍是共享 Bucket。带租户目录便于权限策略、容量统计、清理和迁移。 文件下载流程: 1. HTTP Token 解析得到 `tenant_id` 和 `uid`; 2. 根据 `tenant_id` 选择租户业务库; 3. 在该库查 `fileuploads` 和 `filemsglinks`; 4. 校验当前用户是否有 Topic 或消息访问权; 5. 返回文件或预签名 URL。 ## 8. 测试策略 需要把旧共享表测试改成独立库测试: | 用例 | 准备 | 预期 | | --- | --- | --- | | 企业码解析 | 控制库有 A、B 两个租户 | A、B 返回不同 `tenant_id` 和不同 `db_name` | | 同手机号注册 | A、B 租户库分别注册同手机号 | 两边都成功 | | 登录选库 | A 用户用 B 企业码登录 | B 库查不到该用户,登录失败 | | Token 选库 | A Token 请求文件接口 | 只访问 A 租户库 | | 内存 Topic 隔离 | A、B 都使用 `sys` | Hub 中是两个 TopicKey | | 租户停用 | 控制库把 A 设为 suspended | 新登录失败 | | 租户库不可用 | A 的 DSN 错误或库不存在 | A 请求失败,B 不受影响 | | 连接池复用 | 多次访问 A | 只创建一个 A 租户 adapter,后续复用 | | 配额检查 | A 配置 max_users=1 | A 超额注册失败,B 不受影响 | ## 9. 对旧方案的处理 以下内容停止继续开发: 1. 给核心业务表增加 `tenant_id`; 2. 把唯一索引改成 `(tenant_id, xxx)`; 3. 所有业务 SQL 加 `WHERE tenant_id=?`; 4. 共享库内的数据迁移、回填、复合外键改造; 5. 产品业务表中的 `tenant_id` 字段设计。 以下内容继续保留: 1. 企业码识别; 2. 控制库 `im_tenant`、`im_tenant_config`; 3. `TenantID` 类型; 4. Session TenantID; 5. Token TenantID; 6. Hub、缓存、集群、Push、插件上下文中的 TenantID; 7. `TenantAdapterFor` 选库机制。 ## 10. 推荐下一步 下一步代码改造建议从 MySQL 层开始: 1. 把 `server/db/mysql/schema.sql` 拆为控制库 schema 和租户业务库 schema; 2. 把 `tenant_adapter.go` 改成路由包装,不再写 `tenant_id` SQL; 3. 恢复或复用原 `adapter.go` 中无租户 SQL 作为租户库真实执行逻辑; 4. 修改 MySQL 测试,创建两个独立业务库验证隔离; 5. 再继续补产品业务表,所有业务表按租户库内表设计。 ## 11. 当前代码落地状态 2026-08-13 已开始按独立库方案改造 MySQL 底座: 1. 默认业务库为 `tinode`; 2. 控制库为 `hotgo`; 3. `CreateDb` 会在 `hotgo` 创建 `im_tenant`、`im_tenant_config`,默认租户 `id=1/code=default/db_name=tinode`; 4. `CreateDb` 会在 `tinode` 创建核心业务表,核心表不再包含 `tenant_id`; 5. `TenantAdapterFor` 开启路由后默认租户也会从控制库解析,不再直接退回根连接; 6. 租户库 adapter 禁用二次路由,避免递归; 7. 用户、认证、凭证、Topic、Subscription、消息、设备、文件等核心 Tenant 方法已改为“先选库,再调用租户库内无 tenant_id 方法”; 8. 旧共享表 `tenant_id` 迁移 SQL 已标记废弃。 剩余项: 1. 产品扩展表和 CRUD,例如好友、收藏、实名、青少年、系统通知,还需要继续从 `tenant_id` SQL 改成租户库内无 `tenant_id` SQL; 2. `server/db/mysql/schema.sql` 仍是参考文件,已标注正在从旧共享表草稿拆分,后续应拆成 `registry_schema.sql` 和 `tenant_schema.sql`; 3. 旧版本 `UpgradeDb` 迁移链仍包含共享表历史步骤。因为当前项目不考虑历史数据,优先使用新建库 `CreateDb`;如后续需要升级老库,应单独重写迁移链。