feat: support tenant database routing
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
# 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`;如后续需要升级老库,应单独重写迁移链。
|
||||
Reference in New Issue
Block a user