Files
im_manager/docs/im/IM-独立库多租户改造方案.md
T
2026-08-17 09:52:02 +08:00

14 KiB
Raw Blame History

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 和业务租户数据混在一起。

所以新方案的边界是:

TenantID 负责运行时路由和授权上下文
tenant_id 字段不进入租户业务库表结构

3. 数据库拆分

3.1 控制库

控制库可以使用现有 hotgo,只保存平台级租户元数据和配额配置。控制库表需要保留 tenant_id,因为这些表本身是在管理所有租户;这里的 tenant_id 不是业务库行级隔离字段。

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 租户业务库

每个租户一个业务库,例如:

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 版本。

推荐结构:

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。所有新业务表仍统一要求:

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)

例如好友申请:

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. 文件和对象存储

数据库独立库后,文件元数据在租户库内隔离,但对象存储路径仍建议带租户目录:

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;如后续需要升级老库,应单独重写迁移链。