用 PHP 和 MySQL 开发 SaaS,最容易出现的问题并不是 SQL 写得不够快,而是系统一开始没有把“谁的数据、谁能访问、数据属于哪个租户”设计清楚。SaaS 与普通单用户网站最大的区别,是同一套程序通常同时服务多个客户,而这些客户的数据必须在逻辑上保持隔离。
因此,数据库设计的第一步不是考虑分库、分表或者 Redis,而是确定租户模型、数据归属关系以及应用程序如何保证访问边界。
一、多租户设计首先决定数据库结构
常见的 SaaS 多租户架构大致有三种。
第一种是共享数据库、共享表结构,也就是所有租户的数据放在同一套表中,通过 tenant_id 区分数据。例如 users、orders、projects、invoices 等业务表都保存 tenant_id。这种方案的优势是结构简单、运维成本低,适合大量中小租户共同使用的 SaaS。
第二种是共享数据库、独立 Schema。不同租户使用不同的数据库 Schema,在逻辑上进一步隔离。这种方式可以减少部分数据混淆风险,但迁移、升级和管理大量 Schema 也会增加复杂度。
第三种是每个租户独立数据库。每个客户拥有自己的数据库实例或数据库。这种方式隔离程度更高,也方便针对大型客户提供独立备份、恢复或者合规策略,但数据库数量增加后,升级、连接管理、监控和备份都会变得复杂。
还有一种混合架构,即普通客户使用共享数据库,大型客户使用独立数据库。随着 SaaS 产品规模扩大,这种模式往往比“一开始所有客户全部独立数据库”更加灵活。
所以不存在一种模式适合所有 SaaS。真正应该考虑的是租户规模、数据敏感程度、合规要求、备份恢复需求、客户定制程度以及运维能力。
二、核心不是 tenant_id,而是数据归属关系
共享数据库模式下,tenant_id 通常是最重要的数据隔离字段,但不能理解成“每张表机械增加一个 tenant_id”就完成了多租户设计。
例如一个订单属于某个租户,一个订单明细属于某个订单,那么订单明细是否还需要直接保存 tenant_id,要根据访问模式和约束设计决定。
如果所有查询都必须经过订单表才能确定租户,那么可以通过外键关系建立归属链;如果订单明细经常直接按照租户进行查询、统计或者删除,直接保存 tenant_id 又可能更有价值。
关键在于明确每张业务表的数据所有权。
例如:
tenants 保存租户;
users 保存平台用户;
tenant_users 保存用户与租户之间的关系;
projects 保存项目,并明确项目属于哪个租户;
orders 保存订单,并明确订单属于哪个租户;
order_items 保存订单明细,并通过订单关系确定其归属。
这时数据库设计真正解决的是“这条数据属于谁”,而不仅仅是增加一个字段。
三、用户和租户不一定是一对一
原稿把 users、tenant_users 和 tenants 同时列出来是有价值的,但需要明确一个重要问题:SaaS 中的用户与租户通常不是简单的一对一关系。
一个用户可能属于一个公司,也可能同时参与多个组织。
因此,可以把用户身份和租户成员关系拆开。
users 表负责保存用户自身信息,例如 user_id、email、password_hash、created_at、status。
tenant_users 表负责描述用户属于哪些租户,并保存角色、权限状态等信息。
这样,一个用户可以加入多个租户,而不同租户中的角色也可以不同。
例如同一个人可能在公司 A 是管理员,在公司 B 只是普通成员。
如果产品从一开始就把 tenant_id 直接塞进 users 表,并假设一个用户永远只属于一个租户,后续再增加组织协作、代理商、多公司管理或者集团账户功能时,就可能需要重新调整数据模型。
四、数据隔离不能只靠开发人员记住加 tenant_id
这是共享数据库 SaaS 最重要的安全问题之一。
下面这种查询本身没有 SQL 语法错误:
SELECT * FROM orders WHERE id = 10001;
问题在于,如果订单 ID 是全局唯一,而当前用户又有权限访问另一个租户的数据,那么这条查询就可能成为越权访问的入口。
更合理的方式通常是让租户上下文参与查询:
SELECT *
FROM orders
WHERE tenant_id = ?
AND id = ?;
但仅仅在代码里提醒开发人员“记得写 tenant_id”仍然不够可靠。
大型 SaaS 应该把租户上下文尽量集中处理。例如在请求进入系统时确定当前用户和当前租户,再由 Repository、Service、ORM 查询层或者统一的数据访问组件负责执行租户范围限制。
这样可以减少开发人员在不同业务模块中重复实现权限判断。
同时还要区分“身份认证”和“数据授权”。
用户已经登录,并不意味着用户可以访问当前 URL 对应的数据。系统仍然需要验证用户是否属于该租户,以及该用户在租户中的角色是否允许执行相应操作。
五、索引设计应该从查询路径开始
多租户数据库中,索引设计不能只看单个字段。
例如经常执行:
SELECT *
FROM orders
WHERE tenant_id = ?
ORDER BY created_at DESC
LIMIT 50;
那么 (tenant_id, created_at) 就可能比单独给 tenant_id 和 created_at 各建一个索引更符合这种查询模式。
如果经常按照租户和状态查询,也可能需要:
(tenant_id, status, created_at)
但组合索引并不是越长越好。
MySQL 优化器能否有效利用索引,与查询条件、字段顺序、选择性、排序方式以及数据分布都有关系。应该使用 EXPLAIN 或 EXPLAIN ANALYZE 检查实际执行计划,而不是凭经验给所有字段增加索引。
索引也不是免费的。索引越多,INSERT、UPDATE 和 DELETE 通常需要维护更多索引结构,同时占用更多存储空间。
六、不要过早进行分库分表
“单租户达到500万条数据就必须分片”不是通用规则。
500万条数据对于某些业务来说很小,对于另一些业务来说可能已经非常庞大。决定是否需要分片的因素包括查询模式、数据增长速度、索引大小、单表写入量、锁竞争、磁盘性能以及单个租户和整个系统的流量分布。
很多 SaaS 在相当长时间内使用单个 MySQL 集群,通过合理的索引、分页、归档、缓存和读写优化就可以承受大量数据。
只有当单库架构出现明确的容量或者性能瓶颈时,才应该进一步考虑按租户、按时间或者按业务维度进行分片。
而且分片并不只是“把 tenant_id 拿来切开”。
一旦分片,跨分片查询、聚合统计、事务、唯一 ID、数据迁移和故障恢复都会更加复杂。
因此,分片应该是针对实际瓶颈采取的架构措施,而不是 SaaS 项目上线前必须完成的步骤。
七、读写分离也不是数据库性能优化的默认答案
MySQL 主从复制可以建立读副本,把部分读取请求转移到副本,从而降低主库压力。
但读写分离会引入复制延迟。
例如用户刚刚创建一条订单,然后马上查询订单。如果查询被发送到尚未同步完成的副本,就可能出现“刚刚写入的数据暂时查不到”的情况。
因此,读写分离需要考虑一致性要求,而不能简单地说“日志表读多写少,所以放到从库”。
日志系统通常还可以采用异步写入、批量写入、独立日志存储或者对象存储等方式。具体选择取决于日志用途和查询方式。
八、PHP 的 PDO 并不提供传统意义上的数据库连接池参数
原稿中提到 max_persistent、max_connections 并不准确。
PDO 本身没有一个统一的连接池配置接口,可以像某些数据库客户端那样直接通过 PDO 设置“最大连接池”。
PHP-FPM 环境中的数据库连接管理,通常需要结合 PHP-FPM worker 数量、MySQL 最大连接数以及应用访问模式进行规划。PDO 的持久连接也不能简单理解成完整的连接池解决方案,而且使用持久连接需要考虑连接状态管理。
因此,SaaS 高并发情况下真正应该观察的是 PHP-FPM worker、数据库连接数、慢查询、CPU、锁等待和 I/O,而不是给 PDO 设置一个不存在的通用 max_connections 参数。
九、密码哈希和敏感数据加密必须区分
密码不能使用 AES 加密保存。
密码应该使用专门的密码哈希算法,例如 PHP 的 password_hash(),并通过 password_verify() 验证。
原因很简单:密码验证需要的是不可逆的密码哈希,而不是可以被解密回原文的加密。
对于身份证号码、API 密钥、个人资料等敏感数据,则需要根据业务和威胁模型决定是否加密存储,同时考虑密钥管理、密钥轮换、访问权限以及日志脱敏。
数据库权限同样应该遵循最小权限原则。应用程序使用的 MySQL 账户通常不应该拥有不必要的管理员权限。
十、事务应该先解决本地一致性
绝大多数 SaaS 业务首先需要的是普通 MySQL 事务。
例如创建订单可能同时涉及 orders、order_items、inventory 等多张表。这些操作应该在一个本地事务中完成:
BEGIN;
执行相关 INSERT、UPDATE;
COMMIT;
如果中途失败,则 ROLLBACK。
这类事务并不属于“分布式事务”。
只有当一个业务操作必须同时修改多个独立数据库、多个服务或者不同资源系统,并且需要强一致提交时,才会进入分布式事务领域。
XA、两阶段提交并不是 SaaS 系统的默认方案。它们会增加系统复杂度和故障处理成本。
很多现代 SaaS 更倾向于使用本地事务加事件表、消息队列和幂等处理实现最终一致性。例如订单创建成功后,在同一个本地事务中写入业务数据和事件记录,然后由后台消费者异步处理通知、统计、积分等非核心操作。
这样通常比把所有业务操作塞进一个跨系统分布式事务更加容易维护。
十一、备份设计不能只等于 mysqldump
数据库备份需要考虑两个问题:备份能不能恢复,以及恢复到什么时间点。
mysqldump 可以用于逻辑备份,但对于大型生产数据库,单纯依赖定期 mysqldump 往往不够。
MySQL 的 binlog 可以用于时间点恢复,也可以支持复制架构和其他恢复机制。
生产环境还应该明确 RPO 和 RTO。
RPO 是发生故障后最多可以接受丢失多少数据;RTO 是系统需要在多长时间内恢复。
如果要求非常低的 RPO,就不能只做每天一次全量备份。
此外,多租户 SaaS 还有一个特殊问题:管理员不仅需要能够恢复整个数据库,还可能需要恢复“某一个租户的数据”。
如果系统存在这种需求,从数据库架构、备份格式到数据导出机制,都应该提前设计。
十二、Redis 解决的是特定访问压力,不是数据库替代品
租户配置、权限信息、热点数据等,在合适情况下可以放入 Redis 缓存。
但缓存应该建立在明确的访问模式上。
如果每一次请求都需要查询租户配置,而配置变化频率非常低,那么缓存可以减少 MySQL 查询。
但是如果缓存没有设计失效机制,数据库中的配置修改后可能出现旧数据继续被读取的问题。
所以 SaaS 的缓存设计通常需要考虑缓存键、过期时间、主动失效、更新策略以及缓存击穿等问题。
不要因为“数据库查询慢”就把所有数据全部塞进 Redis。
十三、真正应该监控的是业务瓶颈
一个 SaaS 系统上线后,不能只监控 CPU 和内存。
数据库层面至少应该观察查询延迟、慢查询、连接数、锁等待、事务持续时间、Buffer Pool 使用情况、磁盘 I/O、复制延迟以及数据增长速度。
应用层面则应该观察 PHP-FPM worker、请求延迟、错误率和接口吞吐量。
如果一个接口从500毫秒下降到120毫秒,需要进一步确认究竟是索引优化、缓存命中率提高、减少 SQL 次数,还是数据库服务器升级产生的效果。
类似“增加一个 (tenant_id, created_at) 索引后平均响应时间从500毫秒下降到120毫秒”的案例,如果没有实际测试数据和执行计划,就不应该写成普遍适用的性能结果。
SaaS 数据库设计的核心不是提前把所有未来的规模问题都解决,而是建立清晰的数据边界,并让系统能够随着业务增长逐步演进。
比较稳妥的路线通常是:先明确租户模型和数据归属,再设计核心表和外键关系;随后根据真实查询建立索引;通过事务保证核心业务一致性;用监控找到瓶颈,再决定是否加入缓存、读副本、分区、分片或者独立数据库。
对于 PHP 和 MySQL SaaS 来说,一套结构清晰的共享数据库架构完全可以作为起点。随着客户数量、单租户数据规模和业务复杂度增长,再根据实际瓶颈向读写分离、分库、分片或者混合多租户架构演进,通常比一开始就建立复杂的分布式数据库体系更加容易控制成本和维护风险。
用 PHP 和 MySQL 搭建 SaaS 系统,数据库设计应该从哪里开始
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
分享 Facebook | X | WhatsApp | LinkedIn
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP