14 KiB
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 仍然要保留,但它的职责从“业务表行级隔离字段”调整为“运行时租户上下文”:
- 登录前企业码解析;
- Token 签名和校验;
- Session 当前租户固定;
- Hub、缓存、集群路由、Push、文件路径和插件上下文隔离;
- 选择当前请求使用哪个租户业务库;
- 后台平台侧管理租户、配置配额、停启租户。
2. 为什么独立库仍需要 TenantID
独立库解决的是数据库物理隔离,不代表服务端运行时没有租户。服务进程仍然同时服务多个企业,如果内存 Topic、用户缓存、系统 Topic、Push、文件下载授权、Token 校验不带租户上下文,仍可能出现:
- 两个租户都使用逻辑 Topic
sys时共用同一个内存 Topic; - HTTP 文件下载只拿到
file_id,无法判断该去哪个租户库查授权; - Token 只包含 UID 时,无法知道该 UID 属于哪个租户库;
- 定时任务、插件调用、Push 回调无法定位租户库;
- 后台接口可能把平台租户 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. 当前代码如何调整
当前代码已经有一部分独立库路由雏形,可以继续利用:
server/store/store.go已经预留TenantRoutedAdapter分支;server/db/adapter.go已定义TenantAdapterFor(tenantID);server/db/mysql/adapter.go已支持tenant_registry_db、tenant_db_routing、db_name、db_dsn;- 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 拆成两个层次:
registry_schema.sql:只创建控制库表im_tenant、im_tenant_config;tenant_schema.sql:创建 Tinode 核心表和产品业务表,不包含tenant_id;schema.sql可以作为文档索引,说明不能直接混用两套 schema。
CreateDb(reset) 也要拆分:
- 初始化控制库;
- 初始化默认租户业务库;
- 根据
im_tenant.db_name/db_dsn初始化指定租户库; - 禁止在同一个事务中同时创建控制库和多个租户业务库,避免失败后状态难恢复。
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 依赖
需要回退或重写这些位置:
server/db/mysql/schema.sql中核心业务表的tenant_id字段;server/db/mysql/tenant_adapter.go中所有WHERE tenant_id=?、INSERT tenant_id、复合 JOIN;server/db/mysql/tests/mysql_test.go中依赖共享表隔离的测试;docs/sql/mysql-multitenant-tenant-id-review.sql这份共享表迁移脚本应标记为废弃;- 后续产品业务表设计也不再带
tenant_id。
不建议删除 Store、Session、Token、Hub、Push 和插件里的 TenantID,这些属于运行时隔离边界。
5.4 租户创建流程
后台创建租户时建议按以下顺序:
- 在控制库
im_tenant创建记录,状态为provisioning; - 生成业务库名,例如
im_tenant_{id}; - 创建业务库并执行
tenant_schema.sql; - 写入默认系统数据,例如
kvmeta.version、系统账号、sysTopic; - 回写
im_tenant.db_name,状态改为active; - 创建失败时租户保持不可登录状态,并记录错误供后台重试。
第一阶段可以不做自动建库接口,先由运维执行建库 SQL,再在控制库写入 im_tenant。
5.5 认证与登录
登录入口仍然是:
- 客户端携带
X-Tinode-APIKey请求准入; hi或登录前 HTTP 接口提交企业码;- 服务端在控制库查
im_tenant.code; - 租户状态必须是
active; - Session 固定
TenantID; - 登录认证根据
TenantID选择租户业务库,然后查该库内auth、credentials、users; - 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。带租户目录便于权限策略、容量统计、清理和迁移。
文件下载流程:
- HTTP Token 解析得到
tenant_id和uid; - 根据
tenant_id选择租户业务库; - 在该库查
fileuploads和filemsglinks; - 校验当前用户是否有 Topic 或消息访问权;
- 返回文件或预签名 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. 对旧方案的处理
以下内容停止继续开发:
- 给核心业务表增加
tenant_id; - 把唯一索引改成
(tenant_id, xxx); - 所有业务 SQL 加
WHERE tenant_id=?; - 共享库内的数据迁移、回填、复合外键改造;
- 产品业务表中的
tenant_id字段设计。
以下内容继续保留:
- 企业码识别;
- 控制库
im_tenant、im_tenant_config; TenantID类型;- Session TenantID;
- Token TenantID;
- Hub、缓存、集群、Push、插件上下文中的 TenantID;
TenantAdapterFor选库机制。
10. 推荐下一步
下一步代码改造建议从 MySQL 层开始:
- 把
server/db/mysql/schema.sql拆为控制库 schema 和租户业务库 schema; - 把
tenant_adapter.go改成路由包装,不再写tenant_idSQL; - 恢复或复用原
adapter.go中无租户 SQL 作为租户库真实执行逻辑; - 修改 MySQL 测试,创建两个独立业务库验证隔离;
- 再继续补产品业务表,所有业务表按租户库内表设计。
11. 当前代码落地状态
2026-08-13 已开始按独立库方案改造 MySQL 底座:
- 默认业务库为
tinode; - 控制库为
hotgo; CreateDb会在hotgo创建im_tenant、im_tenant_config,默认租户id=1/code=default/db_name=tinode;CreateDb会在tinode创建核心业务表,核心表不再包含tenant_id;TenantAdapterFor开启路由后默认租户也会从控制库解析,不再直接退回根连接;- 租户库 adapter 禁用二次路由,避免递归;
- 用户、认证、凭证、Topic、Subscription、消息、设备、文件等核心 Tenant 方法已改为“先选库,再调用租户库内无 tenant_id 方法”;
- 旧共享表
tenant_id迁移 SQL 已标记废弃。
剩余项:
- 产品扩展表和 CRUD,例如好友、收藏、实名、青少年、系统通知,还需要继续从
tenant_idSQL 改成租户库内无tenant_idSQL; server/db/mysql/schema.sql仍是参考文件,已标注正在从旧共享表草稿拆分,后续应拆成registry_schema.sql和tenant_schema.sql;- 旧版本
UpgradeDb迁移链仍包含共享表历史步骤。因为当前项目不考虑历史数据,优先使用新建库CreateDb;如后续需要升级老库,应单独重写迁移链。