一个成熟的SaaS系统,用户点击“注册”以后,并不是简单地向users表插入一条记录,然后再创建一个数据库。注册动作背后通常需要建立一套完整的租户关系:用户是谁、属于哪个组织、当前进入哪个工作空间、在工作空间里拥有什么角色,以及这个工作空间能够访问哪些业务数据。
如果把这些概念混在一起,系统很快就会出现权限混乱。一个用户可能属于多个公司,一个公司也可能有几十甚至几千名成员;同一个用户可以在A公司的工作空间里是管理员,在B公司的工作空间里只是普通成员。因此,专业SaaS系统通常不会把“用户”和“工作空间”设计成一对一关系,而是把用户、组织、成员关系和业务数据分别建模。
最基本的数据结构可以从四个核心对象开始:
users
organizations
memberships
business tables
users保存身份信息,organizations或者workspaces保存租户信息,memberships保存用户与工作空间之间的关系,而具体业务表则通过organization_id或者tenant_id与工作空间建立归属关系。
例如:
users
---------
id
email
password_hash
created_at
organizations
-------------
id
name
created_at
status
memberships
-----------
id
user_id
organization_id
role_id
status
created_at
用户注册完成以后,应用服务可以在一个数据库事务中完成核心初始化:
创建用户
↓
创建 Organization / Workspace
↓
创建 Membership
↓
赋予 owner/admin 角色
↓
提交事务
这样,注册用户和他的第一个工作空间就建立了明确的数据库关系。
这里有一个非常重要的设计区别:工作空间不一定等于数据库。
大多数中小型SaaS并不会在每个新用户注册时执行CREATE DATABASE。如果有100万个客户,就创建100万个数据库,会产生完全不同于普通业务表设计的运维问题,包括连接管理、备份、迁移、监控、升级、权限、连接池以及跨租户分析。
更常见的方案是共享数据库、共享表,然后使用organization_id或者tenant_id区分租户数据。
例如一个项目表:
projects
--------
id
organization_id
name
created_at
查询项目时,服务器不是简单执行:
SELECT * FROM projects;
而是根据经过服务器端授权确认的当前组织执行:
SELECT *
FROM projects
WHERE organization_id = :organization_id;
这个条件不是为了“方便查询”,而是SaaS数据隔离的核心边界。
如果用户属于组织A,就只能在授权范围内访问组织A的数据;即使数据库中同时存在组织B的数据,也不能因为客户端修改一个organization_id参数就读取组织B的数据。
因此,不能相信浏览器提交的:
organization_id=123
就直接认为用户属于组织123。
正确流程应该是:
登录用户
↓
服务器取得user_id
↓
查询membership
↓
验证user_id是否属于organization_id
↓
验证role和resource权限
↓
执行tenant-scoped查询
这比单纯在数据库查询语句里增加一个WHERE user_id = ...更加准确,因为SaaS权限边界通常不是“某个用户拥有某条记录”,而是“某个用户通过membership进入某个组织,再根据角色访问组织中的资源”。
例如同一个人可能存在:
User A
├── Organization X → Owner
├── Organization Y → Admin
└── Organization Z → Member
这就是为什么用户表里直接增加一个organization_id往往会限制系统未来的发展。
工作空间创建完成后,还需要处理角色权限。最简单的系统可以在membership中直接保存:
role = owner
更复杂的系统可以建立:
roles
permissions
role_permissions
memberships
这样就可以把“谁属于哪个组织”和“这个人在组织里能做什么”分开。
例如:
Owner
├── billing.manage
├── members.manage
├── projects.create
├── projects.delete
└── settings.manage
Member
├── projects.view
└── projects.edit
这就是RBAC的典型结构。
如果业务进一步复杂,例如同一个成员只能管理某些项目,而不能管理整个组织,则还需要资源级权限,甚至ABAC等更细粒度的授权模型。
数据库层面也应该尽量帮助应用阻止跨租户访问。
以PostgreSQL为例,在适合的架构中可以使用Row-Level Security,把tenant isolation进一步落实到数据库层。这样即使应用代码某个查询遗漏了租户过滤条件,数据库策略也可以成为额外的防线。
不过RLS并不是所有SaaS都必须使用的方案。很多系统首先依靠应用层授权、统一repository/data-access层以及严格的tenant-scoped查询实现隔离,然后根据安全要求增加数据库级策略。
真正容易出事故的地方,是开发人员只给某些表增加organization_id,却忘记了其他关联数据。
例如:
organizations
↓
projects
↓
tasks
↓
comments
↓
attachments
如果projects按照organization隔离,但attachments只按照task_id查询,那么附件访问接口仍然必须确认task属于当前组织。
SaaS租户隔离不是在users表里增加一个字段就完成了,而应该沿着整个数据访问链建立一致的tenant boundary。
文件系统、缓存、搜索索引、消息队列同样如此。
例如Redis缓存不能只使用:
project:123
而应该考虑类似:
tenant:456:project:123
对象存储路径、搜索索引、后台任务、Webhook以及日志系统,也必须避免不同租户之间发生数据串线。
数据库架构选择则是另外一个问题。
SaaS常见的数据库隔离模式大致有四类。
第一种是共享数据库、共享表。
Database
├── users
├── organizations
├── projects
├── invoices
└── tasks
每张租户业务表包含 organization_id
这是最常见、成本也相对较低的模式。数据库迁移简单,连接池容易管理,跨租户统计也比较方便,但应用必须非常严格地执行tenant isolation。
第二种是共享数据库、独立Schema。
Database
├── tenant_a
├── tenant_b
└── tenant_c
这种方式可以增加数据库对象层面的隔离,但如果租户数量巨大,Schema数量、migration、权限管理以及运维复杂度都会迅速增加。它并不是“每个用户一个Schema”这么简单,也不一定比共享表方案更适合SaaS。
第三种是每个租户独立数据库。
Tenant A → Database A
Tenant B → Database B
Tenant C → Database C
这种方式提供更强的数据边界,也方便某些客户进行独立备份、恢复、迁移或者数据驻留管理,但代价是数据库数量、连接、迁移、监控和运维复杂度明显增加。
而且“每个客户一个数据库”和“每个客户一台数据库服务器”完全是两回事。多个数据库完全可以运行在共享数据库基础设施上。
第四种是混合架构。
普通客户使用共享数据库和共享表,大客户或者存在特殊合规要求的客户迁移到独立数据库。
┌─ Shared DB
SaaS Tenant ─────────┤
└─ Dedicated DB
对于商业SaaS来说,这种架构往往更加现实,因为它允许系统随着客户规模和需求变化逐渐升级隔离级别,而不是在第一天就为所有客户承担最高的基础设施成本。
用户注册时到底应该同步创建什么,则需要进一步区分。
核心身份数据,例如users、organizations、memberships,通常适合在一个本地数据库事务中完成。
如果注册成功以后还需要创建其他资源,例如对象存储目录、默认项目、搜索索引、第三方服务账户或者发送欢迎邮件,就不应该为了这些外部操作把一个超长事务一直保持打开。
更常见的设计是:
DB Transaction
↓
User
Organization
Membership
Subscription state
↓
Commit
↓
Outbox Event
↓
Background Worker
↓
初始化其他资源
这里可以使用Transactional Outbox模式。
例如事务中同时写入:
organizations
memberships
outbox_events
事务提交以后,后台worker读取outbox_events,再执行:
创建默认项目
创建对象存储前缀
初始化搜索索引
发送邮件
建立第三方集成
这样即使后台worker暂时宕机,也可以在恢复以后继续处理事件,而不会出现“用户已经注册成功,但是系统忘记初始化工作空间”的状态。
因此,原稿中“用户注册、工作空间创建、权限分配必须和所有资源创建放在同一个事务里”的说法需要修改。
数据库事务适合保证本地数据库中的原子性,不适合把数据库、Redis、对象存储、邮件系统、Kafka以及第三方API全部塞进一个所谓的“大事务”。
工作空间本身也最好具有明确的生命周期状态。
例如:
provisioning
active
suspended
deleting
deleted
注册事务完成以后,工作空间可以进入provisioning状态。
后台任务完成初始化以后变成active。
如果客户欠费,可以进入suspended。
删除流程则可以进入deleting,完成数据处理以后再进入deleted或者执行符合业务要求的保留策略。
这样前端和后台服务都不需要猜测“这个workspace到底有没有初始化完成”。
数据库还需要为这些核心关系建立正确索引。
例如:
CREATE UNIQUE INDEX
ON memberships(user_id, organization_id);
可以防止同一个用户在同一个组织产生重复membership。
而业务表通常需要类似:
CREATE INDEX
ON projects(organization_id, created_at);
如果经常根据组织和状态查询,还可以根据实际查询模式设计:
CREATE INDEX
ON projects(organization_id, status, created_at);
索引设计不能脱离真实SQL查询。SaaS数据库到了百万、千万甚至更大规模以后,最危险的事情之一就是所有查询都带着tenant条件,却没有相应索引,最终让数据库在大量数据中扫描寻找一个租户的记录。
还有一个经常被忽略的问题:密码不是AES-256加密保存。
用户密码应该使用专门的密码哈希算法,例如Argon2id或bcrypt,并保存password hash,而不是使用可以解密恢复原文的对称加密。AES适用于需要恢复原始数据的场景,例如某些API secret、第三方凭证等,但密码验证的设计原则不同。
API密钥、OAuth refresh token等敏感凭证则应该根据业务需要进行安全存储,并结合KMS、密钥轮换、访问控制和审计机制。数据库本身并不能替代应用层的密钥管理。
从系统架构来看,一个成熟的SaaS注册流程最终可以抽象成:
User Registration
↓
Authentication
↓
Create User
↓
Create Organization / Workspace
↓
Create Membership
↓
Assign Owner Role
↓
Commit Transaction
↓
Publish Outbox Event
↓
Async Provisioning
↓
Workspace Active
如果未来这个用户邀请同事加入,就不会重新创建一个工作空间,而是增加membership:
User A ── Owner ──┐
User B ── Admin ──┼── Organization X
User C ── Member ─┘
这套模型可以自然扩展到团队、部门、项目、计费和权限系统。
SaaS数据库设计最重要的原则,并不是“每个用户创建一个数据库”,而是把身份、租户、成员关系、角色和业务数据彻底分离。用户负责证明“我是谁”,Organization/Workspace负责定义“我属于哪个业务边界”,Membership负责定义“我以什么身份进入这个边界”,RBAC或更细粒度权限系统负责定义“我能做什么”,而业务表负责保存“这个组织拥有什么数据”。
当系统规模扩大以后,再根据客户数量、数据隔离要求、性能、合规、数据驻留和运维成本,在共享表、独立Schema、独立数据库和混合架构之间进行选择。这样设计出来的SaaS,注册一个用户只是创建租户关系的起点,而不是给每个新用户现场开一台数据库服务器。真正决定系统能否长期扩展的,是tenant boundary能否从数据库一直贯彻到缓存、文件、搜索、队列和权限系统。
用户注册以后如何自动创建自己的 SaaS 工作空间,数据库需要怎样配合
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
分享 Facebook | X | WhatsApp | LinkedIn
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP