Database design:应为每个 user 创建独立 Table(s) 吗?
结论
通常没有必要为每家公司复制一套业务表。
更合适的默认设计是:
- 公司建模为
tenant,登录人员建模为user,两者不要混为一谈。 - 所有租户共用一套标准化业务表,每条业务记录都带有不可为空的
tenant_id。 - 应用层必须限定租户范围。数据库支持时,再用 Row-Level Security(RLS)增加一道防线。
- 通过配置表管理各租户可用的 modules、menu-items、views 和权限。
- 只有在法规、数据驻留、超大客户性能隔离或独立备份恢复等要求明确存在时,才考虑“一租户一数据库”或“一租户一 schema”。
“每个租户创建一套 tables”并不会自动形成可靠的安全隔离。它只是改变了数据的存放位置,却会大幅增加 schema 变更、迁移、备份、监控和故障处理的复杂度。如果系统遭遇 SQL injection、数据库管理员凭据泄露或高权限账号失陷,攻击者仍有可能读取所有租户的表。
先区分 tenant 和 user
公司是租户,公司的员工才是用户。至少可以建立以下关系:
tenant
├── tenant_user
│ └── app_user
├── product
├── customer
├── invoice
├── payment
└── sales_order
同一个用户可能属于多家公司,所以成员关系最好通过关联表表达,不要直接把 user 当成一家公司。
CREATE TABLE tenant (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(200) NOT NULL,
status varchar(30) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE app_user (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email varchar(320) NOT NULL UNIQUE,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tenant_user (
tenant_id bigint NOT NULL REFERENCES tenant(id),
user_id bigint NOT NULL REFERENCES app_user(id),
role varchar(50) NOT NULL,
PRIMARY KEY (tenant_id, user_id)
);
业务表统一通过 tenant_id 隔离:
CREATE TABLE sales_order (
tenant_id bigint NOT NULL REFERENCES tenant(id),
id bigint GENERATED ALWAYS AS IDENTITY,
order_number varchar(50) NOT NULL,
total_amount numeric(18, 2) NOT NULL,
status varchar(30) NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, id),
UNIQUE (tenant_id, order_number)
);
CREATE INDEX sales_order_tenant_created_idx
ON sales_order (tenant_id, created_at);
这里采用复合主键,是为了在验证依赖关系时一并检查租户归属。例如,订单明细不能引用另一个租户的订单:
CREATE TABLE sales_order_item (
tenant_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
order_id bigint NOT NULL,
product_id bigint NOT NULL,
quantity numeric(18, 4) NOT NULL,
PRIMARY KEY (tenant_id, id),
FOREIGN KEY (tenant_id, order_id)
REFERENCES sales_order (tenant_id, id)
);
即使应用代码误传了其他公司的 order_id,这个外键也会拒绝建立跨租户关联。
共享 tables 是否安全
安全性主要取决于隔离机制,而不是建了多少张表。
每个租户使用独立表,确实可以缩小某些误查询的影响。例如,代码访问了错误的表名时,可能只影响一个租户。但这种设计无法防御许多更重要的攻击场景:
- SQL injection 仍可枚举和读取多个租户表。
- 数据库高权限凭据泄露后,所有表依然可见。
- 备份、日志、导出文件和数据仓库仍可能包含所有租户的数据。
- 动态拼接表名会增加 SQL injection 和权限管理风险。
- 运维脚本一旦出错,可能批量影响所有租户表。
共享表最常见的风险是查询时漏掉 tenant_id 条件。例如:
-- 危险:可能返回全部租户的数据
SELECT *
FROM sales_order
WHERE id = :order_id;
查询必须同时限定当前租户:
SELECT *
FROM sales_order
WHERE tenant_id = :tenant_id
AND id = :order_id;
但只靠开发人员记住添加条件仍不够可靠。租户隔离应落实到多个层次。
建立纵深隔离
1. 由服务端确定 tenant_id
不要信任浏览器提交的 tenant_id。当前租户应由已经验证的登录状态、域名、服务端 session 或签名 token 推导出来,同时确认该用户确实属于这个租户。
错误做法:
GET /orders/938?tenant_id=12
如果服务端直接采用这个参数,攻击者就可能把 tenant_id 改成其他值。
更合理的处理流程如下:
认证用户
→ 解析当前租户
→ 验证 tenant_user 成员关系
→ 在数据库连接或事务中设置租户上下文
→ 执行查询
2. 封装租户范围查询
Repository、ORM scope 或数据访问层应默认附加 tenant_id,避免业务代码直接执行没有租户范围的查询。
伪代码示例:
async function getOrder(context, orderId) {
return db.salesOrder.findOne({
where: {
tenantId: context.tenantId,
id: orderId
}
});
}
后台管理功能也不应默认绕过租户限制。跨租户查询需要使用独立权限和独立接口,并留下明确的审计记录。
3. 使用 Row-Level Security
如果数据库支持 RLS,可以由数据库直接拒绝跨租户访问。下面是 PostgreSQL 风格的示例,具体语法需要根据实际数据库确认:
ALTER TABLE sales_order ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales_order FORCE ROW LEVEL SECURITY;
CREATE POLICY sales_order_tenant_policy
ON sales_order
USING (
tenant_id = current_setting('app.tenant_id', true)::bigint
)
WITH CHECK (
tenant_id = current_setting('app.tenant_id', true)::bigint
);
每个事务开始后设置当前租户:
BEGIN;
SET LOCAL app.tenant_id = '42';
SELECT *
FROM sales_order
WHERE id = 938;
COMMIT;
这样即使查询遗漏了 tenant_id,RLS 也只允许访问租户 42 的记录。
使用连接池时要格外小心。租户上下文必须限制在事务内,并在连接归还连接池之前可靠清除。数据库 owner、超级用户和具备绕过 RLS 权限的角色也要严格限制。RLS 只是其中一道防线,不能代替认证、授权和输入验证。
4. 加入数据库约束
数据库设计可以遵循以下规则:
- 租户业务表中的
tenant_id一律设为NOT NULL。 - 唯一约束通常应包含
tenant_id。 - 跨表关系尽量使用包含
tenant_id的复合外键。 - 常用索引以
tenant_id为前导列。 - 不允许客户端自行指定未经授权的
tenant_id。 - 跨租户管理操作要记录操作者、目标租户、操作时间和变更内容。
5. 保护租户隔离之外的数据路径
即使在线查询没有问题,数据仍可能从以下位置泄露:
- 对象存储中的附件路径和访问策略
- Redis 等缓存的 key
- 搜索引擎索引
- 消息队列和异步任务
- 日志、监控和错误报告
- CSV、PDF 等导出文件
- 数据仓库、备份和测试环境
缓存键至少要包含租户标识:
tenant:42:order:938
异步任务也要携带经过验证的租户上下文,不能只传递一个可能在不同租户中重复的业务 ID。
个性化 modules、menu-items 和 views
功能配置和数据存储是两个不同的问题。某家公司可以使用库存模块,并不代表它必须拥有一套独立的库存表。
可以通过模块目录和租户模块关联表控制功能:
CREATE TABLE module (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code varchar(100) NOT NULL UNIQUE,
display_name varchar(200) NOT NULL
);
CREATE TABLE tenant_module (
tenant_id bigint NOT NULL REFERENCES tenant(id),
module_id bigint NOT NULL REFERENCES module(id),
enabled boolean NOT NULL DEFAULT true,
settings jsonb NOT NULL DEFAULT '{}',
PRIMARY KEY (tenant_id, module_id)
);
菜单可以关联模块或权限:
CREATE TABLE menu_item (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
module_id bigint NOT NULL REFERENCES module(id),
code varchar(100) NOT NULL,
route varchar(300) NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
UNIQUE (module_id, code)
);
CREATE TABLE tenant_menu_item (
tenant_id bigint NOT NULL REFERENCES tenant(id),
menu_item_id bigint NOT NULL REFERENCES menu_item(id),
visible boolean NOT NULL DEFAULT true,
PRIMARY KEY (tenant_id, menu_item_id)
);
菜单是否显示只影响用户体验,不能代替服务端授权。即使菜单已隐藏,对应 API 仍要验证:
- 当前用户属于该租户;
- 该租户已经启用对应模块;
- 当前用户拥有执行此操作所需的角色或权限;
- 被访问的数据属于当前租户。
如果公司之间的差异只涉及功能开关、配额、显示名称或少量布局,可以使用配置字段或 feature flags。若不同公司要求完全不同的业务流程,应优先设计可配置规则、插件接口或稳定的扩展点,而不是复制整个应用和数据库结构。
三种多租户存储方式
共享数据库、共享 schema、共享 tables
所有租户共用一套表,通过 tenant_id 隔离。
优点:
- schema 只需维护一份;
- 数据库迁移可以统一执行;
- 连接池、监控和备份相对简单;
- 适合大量中小租户;
- 跨租户统计和平台运营分析比较方便。
缺点:
- 必须系统性防止查询遗漏租户过滤;
- 大租户可能影响其他租户的性能;
- 单租户恢复、迁移和删除需要专门设计;
- 部分法规或合同可能不允许共享存储。
对大多数 SaaS 应用来说,这是合适的起点。
共享数据库、每租户一个 schema
每个租户拥有结构相同但相互独立的 schema。
这种方式的逻辑隔离更直观,也可以为不同 schema 设置不同权限。不过,随着租户数量增加,数据库迁移、连接配置、系统目录膨胀和运维自动化都会越来越复杂。
它更适合租户数量有限、隔离要求较高,而且团队已经具备成熟自动化能力的场景。
每租户一个数据库
每家公司使用独立数据库。
优点:
- 隔离边界更加清楚;
- 可以独立执行备份、恢复和迁移,也方便设置数据驻留位置;
- 能为大型客户配置独立资源;
- 普通租户的数据库凭据泄露时,通常不会直接暴露其他数据库。
缺点:
- 数据库迁移必须在所有实例上可靠执行;
- 连接池、监控、告警、备份和容量管理更复杂;
- 跨租户分析比较困难;
- 大量小租户会带来明显的运维成本。
这种方式适合高价值客户、受监管数据或需要强资源隔离的场景。与“在同一个数据库里复制几千套 tables”相比,它才是一套相对完整的隔离方案。
为什么不建议每租户复制 tables
假设系统有 30 张业务表和 1,000 家公司,最终会产生约 30,000 张业务表。增加一个字段时,需要修改 1,000 张同类表。创建索引、修复约束或回滚迁移也要分别执行 1,000 次。
由此会出现一系列问题:
- 一部分租户迁移成功,另一部分迁移失败;
- 不同租户的 schema 版本逐渐偏离;
- ORM 和迁移工具难以正常工作;
- 动态表名不能像普通查询参数那样安全绑定;
- 数据库元数据、执行计划和权限对象数量大幅增加;
- 监控、备份、恢复和故障排查更加困难;
- 每次新增租户都要执行一整套建表流程;
- 跨租户统计需要动态拼接大量
UNION; - 删除租户表时,表名或脚本错误可能影响其他租户。
每租户复制 tables,往往既承担了共享数据库的安全风险,又背上了独立数据库的维护成本,却没有完整获得其中任何一种方案的优势。
推荐的落地步骤
可以按照以下顺序实施:
- 统一术语,把公司命名为
tenant,把登录人员命名为app_user。 - 选择共享 schema,为所有租户业务表添加不可为空的
tenant_id。 - 使用复合唯一约束和复合外键,阻止跨租户引用。
- 建立统一的数据访问层,默认限定当前租户。
- 数据库支持时启用 RLS,并使用低权限应用账号访问数据库。
- 通过
tenant_module、角色和权限表管理功能,不要复制 schema。 - 为越权场景编写自动化测试,重点测试修改 URL、ID 和请求体后的行为。
- 把租户标识纳入缓存、附件、搜索、队列、日志和导出流程。
- 建立审计日志、加密、密钥轮换、备份恢复和异常访问告警。
- 保留后续迁移路径。普通客户使用共享表,确有要求的大客户再迁移到独立数据库。
测试应明确覆盖以下情况:
it("rejects access to another tenant's order", async () => {
const order = await createOrder({ tenantId: tenantB.id });
const response = await requestAs(userFromTenantA)
.get(`/orders/${order.id}`);
expect(response.status).toBe(404);
});
这里返回 404 通常比 403 更合适,因为它不会向调用者确认其他租户的数据是否存在。最终选择仍要服从系统统一的安全策略。
最终建议
这个需求适合采用“共享数据模型 + 强制租户隔离 + 配置化模块”的设计:
共享 schema
↓
所有业务表包含 tenant_id
↓
服务端成员关系验证
↓
数据库 RLS 与复合约束
↓
tenant_module 控制模块启停
↓
角色和权限控制用户操作
不能把“系统可能被攻破”简单理解成“多建一些表就会更安全”。有效的安全措施包括最小权限、纵深防御、数据库约束、租户范围查询、RLS、审计和完整的数据生命周期管理。
如果业务确实需要比共享表更强的隔离,可以评估一租户一数据库,而不是在同一个数据库中为每家公司手工复制一套 tables。后者通常最难维护,容易出现结构漂移,也很难证明其安全性。
备注:内容仅供参考。
