跳至正文
Starone

SaaS

构建生产级 SaaS 基础设施

多租户产品与一个带 tenant_id 列的应用之间的差别在于:隔离、租户级限额、控制平面、安全的迁移,以及单独恢复某一个客户数据的能力。

· 阅读约 5 分钟 · Starone 工程团队

SaaS 平台是为众多客户运营的同一套系统,这些客户绝不能看到彼此的数据、拖慢彼此或搞坏彼此。加一个 tenant_id 列,能让应用走到可以演示的程度。要为付费客户运营它,则必须回答更难的问题:租户如何隔离,某个租户的负载如何被限制在自身范围内,schema 变更如何做到不停机发布,以及单个客户的数据如何恢复。

我们的默认方案是池化架构,在数据库和平台边缘强制执行隔离,同时保留把特定租户放到专属基础设施上的选项。它保持了共享系统的成本和运维投入水平,又为那些需求足以支撑独立技术栈的客户留出了一条路。

逐层选择租户模型

租户模型通常被描述成一次二选一:silo 模型下每个租户拥有专属资源,池化(pool)模型下所有租户共享资源。实际上,这个选择是针对计算、数据库、缓存、队列和存储分别做出的,最终得到混合的结果很正常。

  • 池化:以租户为键的共享表。成本最低、运维最简单,隔离性则依赖于工程实现是否正确。
  • 每租户一个 schema 或数据库:隔离更强,按租户恢复也很直接,但迁移必须跑遍成千上万个 schema,连接池的数量也会成倍增加。
  • Silo:每个租户一整套技术栈。隔离最强、可以独立升级,但成本和集群管理的工作量会随每一个新客户而增长。

租户上下文是一道安全边界

在池化模型里,漏掉一个 WHERE 子句就是一次数据泄露。指望每位开发者在每条查询里都记得加上过滤条件,算不上一种控制手段。我们在边缘层从已认证的令牌中解析一次租户,绝不取自客户端可以控制的请求参数或请求头,然后在应用代码之下的层面强制执行。

PostgreSQL 的行级安全(row-level security)是我们偏好的机制。应用为当前事务设置租户,每张表上的策略把每一条语句都限制在该租户的行上,包括那些有人忘了加过滤条件的查询。应用使用的数据库角色不能是表的属主,也不能带有 BYPASSRLS 属性,否则策略会被悄无声息地跳过。使用事务模式的连接池时,租户设置必须限定在事务范围内,以免泄漏到同一连接上的下一个请求。

同样的上下文还必须到达所有其他存放数据的地方:缓存 key 以租户为前缀,对象存储路径配合限定范围的访问权限,队列消息携带租户信息,搜索索引按租户过滤,日志行记录租户。

吵闹的邻居与租户级限制

共享基础设施意味着共享容量。某个租户跑一次大批量导入,或者接入了一个写得很糟的集成,就可能耗尽数据库连接或塞满队列,其他所有租户则要承受一场与自己毫不相干的故障。全局限流帮不上忙,因为它保护平台的方式是让所有人一起受罚。

  • 以租户为键的速率限制和并发限制,在网关层强制执行
  • 后台任务采用公平调度,使某个租户的积压不会饿死其他租户
  • 存储、席位和 API 用量的配额,与租户的套餐挂钩
  • 数据层的语句超时和每租户连接数上限
  • 按租户拆分的延迟与错误指标,使单个受影响的客户也能被看见

把控制平面与应用平面分开

应用平面是租户使用的产品。控制平面是管理租户的系统:开通与资源供给、身份配置、套餐与权益、用于计费的用量计量、把租户放置到池化或专属基础设施上,以及支持人员使用的运维工具。

把两者分开有两个好处。开通流程变成一条自动化、可重复的工作流,而不是一份检查清单;一旦有 silo 租户需要为其创建基础设施,这一点就很关键。应用平面还可以通过缓存自己所需的租户配置和权益,做到在控制平面不可用时继续提供服务。计费或资源供给出了故障,应该挡住的是新注册,而不是现有客户。只有少数几个租户的产品可以把这些工作大部分往后放,前提是租户元数据已经集中存放在一处,而不是散落在各个配置文件里。

零停机迁移

共享数据库不能为了一次 schema 变更而下线,而且在滚动部署期间,新旧两个版本的应用会同时运行在同一个 schema 上。因此每一次迁移都必须与它之前的那个版本兼容。对应的模式是 expand and contract(先扩展后收缩):新增结构,双写,回填,切换读取,再在之后的某个版本中移除旧结构。

在大表上,细节决定成败。在 PostgreSQL 中,索引用 CREATE INDEX CONCURRENTLY 创建,约束先以 NOT VALID 方式添加再单独验证,并且要设置锁超时,这样一个排在长事务后面等待的迁移会快速失败,而不是把排在它后面的所有查询都堵住。回填以小批量进行并带有间歇,同时监视复制延迟。原地重命名一个列从来都不是一步能完成的事。

这比直接修改要多花几个发布周期。对于一个拥有客户认可的维护窗口的早期产品,用一次短暂的计划内停机来换取速度是正当的取舍。一旦客户跨越多个时区,这个选项就不存在了。

租户级备份与恢复

在池化数据库上做时间点恢复,会把所有租户都恢复到同一时刻。这是应对平台级灾难的正确工具,却不适合那类最常见的请求:某个客户删掉了重要的东西,想把它找回来。让其他所有人跟着一起回滚是不可行的。

这项能力必须经过专门设计。典型的做法是把备份恢复到一个独立实例上,提取出该租户的行,再用一个理解数据模型及其外键关系的工具把它们合并回生产环境。带保留期的软删除可以在需要动用恢复之前就解决很多情况。同样的租户级导出能力,也使客户退出和可验证的数据删除成为可能。恢复应当按计划定期演练,因为没有测试过的备份只是一个假设。

按环分批向租户发布

当很多客户共用一套部署时,一次有问题的发布会同时波及所有人。我们倾向于渐进式交付:按租户评估的功能开关,由内部租户和低风险租户组成的第一环,以及在错误预算燃烧率上升时自动回滚。代价是开关的治理,因为过期的开关会累积成未经测试的组合,所以每个开关都需要有负责人和移除日期。

正在做类似的事情

我们很乐意交流心得。欢迎与撰写本文的工程师聊聊。