PAYATHON 2026

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 仍要验证:

  1. 当前用户属于该租户;
  2. 该租户已经启用对应模块;
  3. 当前用户拥有执行此操作所需的角色或权限;
  4. 被访问的数据属于当前租户。

如果公司之间的差异只涉及功能开关、配额、显示名称或少量布局,可以使用配置字段或 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,往往既承担了共享数据库的安全风险,又背上了独立数据库的维护成本,却没有完整获得其中任何一种方案的优势。

推荐的落地步骤

可以按照以下顺序实施:

  1. 统一术语,把公司命名为 tenant,把登录人员命名为 app_user。
  2. 选择共享 schema,为所有租户业务表添加不可为空的 tenant_id。
  3. 使用复合唯一约束和复合外键,阻止跨租户引用。
  4. 建立统一的数据访问层,默认限定当前租户。
  5. 数据库支持时启用 RLS,并使用低权限应用账号访问数据库。
  6. 通过 tenant_module、角色和权限表管理功能,不要复制 schema。
  7. 为越权场景编写自动化测试,重点测试修改 URL、ID 和请求体后的行为。
  8. 把租户标识纳入缓存、附件、搜索、队列、日志和导出流程。
  9. 建立审计日志、加密、密钥轮换、备份恢复和异常访问告警。
  10. 保留后续迁移路径。普通客户使用共享表,确有要求的大客户再迁移到独立数据库。

测试应明确覆盖以下情况:

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。后者通常最难维护,容易出现结构漂移,也很难证明其安全性。

备注:内容仅供参考。