简介:这份PPT资料面向正在或计划将传统软件转型为SaaS的独立软件供应商架构师、技术负责人与云计算学习者,系统梳理了基于AWS构建SaaS平台的整体架构思路与关键设计决策。内容围绕为何选择SaaS、为何AWS是最佳平台展开,深入讲解身份管理中的用户ID与租户ID映射、MFA及IAM策略,多租户的Silo、Bridge、Pool三种模式对比,以及应用分层、网络层与数据隔离方案,并覆盖CloudWatch、CloudTrail等监控计费工具与DevOps敏捷实践。资源包内含1个pptx文件,约1.21MB,以图文架构图与要点清单呈现,便于快速理解与复用。目前已有143人学习,适合需要搭建多租户SaaS平台、评估AWS服务选型或准备技术方案汇报的读者参考借鉴。
1. 从一份 PPT 标题说起:AWS 上的 SaaS 平台架构到底在解决什么问题
如果你手上也有一份叫「基于 AWS 的 SaaS 平台架构」的方案要落地,先别急着画框。真正卡人的从来不是「用不用 AWS」,而是多租户隔离、计费口径、部署流水线这三件事怎么在 AWS 的原生组件上拼成一套能跑、能扩、能算清账的东西。我见过太多团队把架构图画得漂漂亮亮,结果第一个付费客户进来就发现租户数据串了、账单对不上、灰度发布要靠人肉登机器。这篇笔记就按一线落地的顺序,把「基于 AWS 的 SaaS 平台架构」拆成能照着做的步骤:先讲清楚租户模型和选型理由,再落到账号结构、数据隔离、计费埋点、CI/CD 和排错。适合正在做 SaaS 从 0 到 1 的后端和架构同学,也适合已经上线但被多租户问题反复折磨的团队。下面所有内容都围绕一个目标:让你看完能动手搭出最小可用的骨架,而不是又收藏一份 PPT。
2. 租户模型与 AWS 账号结构:先定隔离级别再谈组件
SaaS 平台架构的第一刀,永远切在「租户怎么隔离」上。这个决定一旦定错,后面换数据库、换部署方式都是伤筋动骨。AWS 给了你从「共享一切」到「一租户一账号」的完整光谱,选哪一档取决于你的客户画像和合规要求,而不是取决于哪个听起来高级。
2.1 三种隔离模型与选型判断
常见做法是把隔离分成三档,我一般让团队先对着客户名单过一遍再定:
| 模型 | 隔离手段 | 适用场景 | 代价 |
|---|---|---|---|
| 池化(Pool) | 共享表 + tenant_id 列 | 中小客户、成本敏感 | 行级隔离,查询必须带租户条件 |
| 桥接(Bridge) | 共享库、独立 schema | 中大型客户、要独立备份 | 连接管理复杂,迁移成本中等 |
| 孤岛(Silo) | 独立数据库或独立账号 | 大客户、强合规 | 成本高,运维自动化要求高 |
判断标准很直接:客户愿不愿意为隔离付钱、有没有数据驻留或审计要求、单租户数据量级是否已经影响到邻居。多数 SaaS 起步阶段用池化模型,等出现第一个要求独立环境的客户时,再往桥接或孤岛迁移。这里有个血泪经验:从第一天起就在所有业务表里带上 tenant_id,并且把它作为联合主键或索引前缀,否则后期迁移会痛到怀疑人生。
2.2 用 AWS Organizations 和 Control Tower 搭账号骨架
如果走孤岛或混合模型,账号结构就是架构的一部分。我一般用 AWS Organizations 做多账号管理,生产、测试、共享服务各一个 OU,租户账号按需挂在单独的 OU 下。Control Tower 负责基线管控,避免每个新账号都要手工配一遍日志和护栏。
# 用 AWS CLI 创建一个新成员账号(需在管理账号下执行) aws organizations create-account \ --email "tenant-abc@example.com" \ --account-name "tenant-abc-prod" \ --role-name "OrganizationAccountAccessRole" # 创建后拿到 AccountId,再把它移动到指定 OU aws organizations move-account \ --account-id 111122223333 \ --source-parent-id r-xxxx \ --destination-parent-id ou-xxxx-yyyy这段命令做两件事:先开一个独立账号,再把它归到对应 OU。--email每个账号必须唯一,很多团队用tenant-<id>@yourdomain这种别名邮箱统一管理。--role-name是管理账号跨账号切换时用的角色名,建议保持默认,方便后续用aws sts assume-role做自动化。移动 OU 这一步别省,否则 Control Tower 的护栏和日志配置不会自动生效。
2.3 账号内的网络与计算基线
账号建好后,每个租户账号里我一般固定三样东西:一个 VPC、一套 ECS 或 EKS 的计算基线、一个 RDS 实例。VPC 用/16网段,子网按可用区分公共和私有,NAT 网关按需开——注意 NAT 网关是按小时和流量计费的,租户多了这笔钱很可观,能走 VPC Endpoint 的流量就别绕 NAT。
# 用 CDK 定义一个最小 VPC + RDS 骨架(TypeScript 片段) const vpc = new ec2.Vpc(this, 'TenantVpc', { maxAzs: 2, natGateways: 1, // 成本敏感时只开一个,牺牲部分可用性 subnetConfiguration: [ { name: 'public', subnetType: ec2.SubnetType.PUBLIC }, { name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, ], }); new rds.DatabaseInstance(this, 'TenantDb', { engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_15 }), vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, multiAz: false, // 起步阶段单 AZ,客户要求 SLA 再开 allocatedStorage: 100, });natGateways: 1是成本与可用性的权衡,生产环境如果 SLA 要求高就设成和 AZ 数一致。multiAz: false同理,起步阶段单 AZ 够用,等有客户签 SLA 再开多可用区。RDS 放在私有子网,安全组只允许来自计算层的流量,这是底线,别图省事放公共子网。
3. 多租户数据隔离与访问控制:把 tenant_id 焊进每一层
数据层是 SaaS 架构里最容易翻车的地方。隔离模型定了之后,真正的难点在于「怎么保证任何一次查询都不会漏掉租户条件」。靠人写代码自觉是不可靠的,得靠机制。
3.1 池化模型下的行级隔离实现
池化模型最常见,也最考验纪律。我一般用 PostgreSQL 的 Row Level Security(RLS)兜底,配合应用层的 tenant_id 注入。
-- 开启行级安全,并绑定当前租户 ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::uuid); -- 应用连接建立后设置当前租户 SET app.current_tenant = 'a1b2c3d4-...';current_setting('app.current_tenant')从会话变量里读租户 ID,应用层每次拿到连接后先SET一次。这样即使某条 SQL 忘了写WHERE tenant_id = ?,数据库也会自动过滤。注意连接池场景下会话变量会复用,必须在每次借出连接时重置,否则会出现租户串数据的玄学问题——这是我在两个项目里都踩过的坑。
3.2 用 Cognito 做租户感知的认证授权
认证层要把「用户属于哪个租户」这件事固化下来。AWS Cognito 的用户池可以存自定义属性,我一般把custom:tenant_id和custom:role放进去,签发 JWT 时带上。
// 校验 JWT 并提取租户上下文(Node.js 中间件示例) const jwt = require('jsonwebtoken'); const jwksClient = require('jwks-rsa'); const client = jwksClient({ jwksUri: process.env.COGNITO_JWKS_URI }); function getKey(header, callback) { client.getSigningKey(header.kid, (err, key) => { callback(null, key.getPublicKey()); }); } function tenantContext(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => { if (err) return res.status(401).json({ error: 'invalid token' }); req.tenantId = decoded['custom:tenant_id']; req.role = decoded['custom:role']; next(); }); }jwksUri从 Cognito 用户池的.well-known/jwks.json拿,别硬编码公钥,密钥轮换时会失效。custom:tenant_id在用户注册或管理员创建用户时写入,之后不可由用户自己修改。algorithms: ['RS256']必须显式指定,否则存在算法混淆风险。拿到req.tenantId后,后续所有数据库操作都从这里取租户,绝不从请求体里读——请求体是用户可控的,这是安全红线。
3.3 跨租户访问的边界与审计
有些场景需要跨租户访问,比如平台管理员看全局数据、或者租户间共享某些资源。我的做法是:默认禁止,显式开白。管理员角色走单独的 API 路径,并且所有跨租户操作写审计日志到 CloudTrail 或独立的审计表。
# 跨租户操作必须显式声明并记录审计 def admin_query(admin_ctx, target_tenant_id, query): if admin_ctx.role != 'platform_admin': raise PermissionError('not allowed') audit_log.write({ 'actor': admin_ctx.user_id, 'target_tenant': target_tenant_id, 'action': 'cross_tenant_query', 'timestamp': now(), }) return db.execute(query, tenant_id=target_tenant_id)审计日志要包含操作者、目标租户、动作和时间,缺一不可。很多合规审计就是查这几项。别把审计日志和业务库放一起,租户数据删除时容易误删,我一般单独放一个只追加的日志库或 S3 桶。
4. 计费埋点与套餐策略:让每一分用量都能对上账
SaaS 的商业模式最终要落到计费上。架构如果没在早期埋好计量点,后期补计费就是灾难。这一章讲怎么在 AWS 上把用量采集、聚合、出账串起来。
4.1 计量点的选择与埋点位置
计费维度一般分三类:席位(按用户数)、用量(API 调用、存储、计算)、功能(套餐等级)。我一般把计量点放在 API 网关和业务服务两层:
- API 网关层:记录请求次数、来源租户、响应状态,用于按调用量计费。
- 业务服务层:记录业务事件,比如「生成了一份报告」「处理了一条订单」,用于按业务量计费。
- 存储层:定期扫描 S3 和数据库用量,用于按存储计费。
# 在 API 网关后置的 Lambda 里写计量事件 import boto3, json, time firehose = boto3.client('firehose') def handler(event, context): tenant_id = event['requestContext']['authorizer']['tenant_id'] record = { 'tenant_id': tenant_id, 'endpoint': event['rawPath'], 'method': event['requestContext']['http']['method'], 'status': event['statusCode'] if 'statusCode' in event else 200, 'ts': int(time.time()), } firehose.put_record( DeliveryStreamName='saas-usage-stream', Record={'Data': json.dumps(record) + '\n'} ) return event用 Kinesis Firehose 而不是直接写数据库,是因为计量事件量大且允许最终一致,Firehose 能自动批量写入 S3,成本低且不阻塞主流程。tenant_id从 authorizer 上下文取,保证可信。ts用秒级时间戳,聚合时按小时或天滚动。注意 Firehose 有 5KB 单条限制和缓冲策略,高频小事件要合并发送,否则会被限流。
4.2 用量聚合与套餐映射
原始事件落到 S3 后,用 Athena 或 Glue 做聚合,再映射到套餐规则。套餐策略上,我一般设计成「基础席位费 + 超额用量费」,这样收入可预测,客户也容易理解。
-- 用 Athena 按租户和小时聚合 API 调用量 SELECT tenant_id, date_trunc('hour', from_unixtime(ts)) AS hour_bucket, count(*) AS api_calls FROM saas_usage WHERE ts >= to_unixtime(now() - interval '1' day) GROUP BY 1, 2 ORDER BY 1, 2;date_trunc('hour', ...)把事件按小时归桶,方便和套餐里的「每小时调用上限」对比。count(*)是调用次数,如果要按不同 endpoint 分别计费,再加一列endpoint分组。Athena 按扫描数据量计费,所以 S3 上的数据要按时间分区,查询时带上时间条件,否则账单会让你后悔。
4.3 出账与对账
聚合结果写入一张计费表,每月生成账单。对账环节我一般做两件事:一是抽样比对原始事件和账单金额,二是给租户提供用量明细查询接口。客户对账单有疑问时,能查到具体哪条调用被计费,比任何解释都有说服力。
-- 生成月度账单快照 INSERT INTO monthly_bills (tenant_id, billing_month, seat_fee, usage_fee, total) SELECT u.tenant_id, '2025-01' AS billing_month, s.seat_price * s.seat_count AS seat_fee, u.total_calls * s.per_call_price AS usage_fee, s.seat_price * s.seat_count + u.total_calls * s.per_call_price AS total FROM usage_summary u JOIN tenant_subscriptions s ON s.tenant_id = u.tenant_id WHERE u.billing_month = '2025-01';seat_fee和usage_fee分开存,方便后续调整套餐时只改其中一项。total冗余存储是为了查询快,但要保证和分项一致,建议用生成列或触发器维护。账单快照一旦生成就不改,调整走红冲或补差,这是财务的基本要求。
5. 部署流水线与多租户发布:别让一次上线影响所有客户
SaaS 的部署比单租户系统复杂,因为一次发布可能影响成百上千个租户。流水线要解决两个问题:怎么快速把代码推到所有环境,怎么在出问题时只影响一小部分租户。
5.1 用 CDK 和 CodePipeline 搭多环境流水线
我一般用 AWS CDK 定义基础设施,CodePipeline 串起构建、测试、部署。环境分 dev、staging、prod,prod 再按租户分组做分批发布。
// CDK 定义一条最小流水线 const pipeline = new codepipeline.Pipeline(this, 'SaasPipeline', { pipelineName: 'saas-platform', stages: [ { stageName: 'Source', actions: [new codepipeline_actions.CodeCommitSourceAction({ actionName: 'Source', repository: repo, branch: 'main', output: sourceOutput, })], }, { stageName: 'Build', actions: [new codepipeline_actions.CodeBuildAction({ actionName: 'Build', project: buildProject, input: sourceOutput, outputs: [buildOutput], })], }, { stageName: 'DeployStaging', actions: [new codepipeline_actions.CloudFormationCreateUpdateStackAction({ actionName: 'DeployStaging', stackName: 'saas-staging', templatePath: buildOutput.atPath('template.yaml'), adminPermissions: false, })], }, ], });adminPermissions: false是安全要求,部署角色只给必要权限,别图省事给管理员。DeployStaging之后再加DeployProd阶段,prod 阶段可以挂手动审批或自动分批。CDK 的好处是基础设施即代码,环境差异用参数控制,避免「测试环境能跑生产跑不了」的经典问题。
5.2 按租户分批发布与回滚
孤岛模型的租户可以逐个账号发布,池化模型则要靠功能开关(Feature Flag)做灰度。我一般用 AppConfig 或参数存储管理开关,按租户 ID 或百分比控制。
// 用 AppConfig 判断某租户是否启用新功能 const { AppConfigClient, GetConfigurationCommand } = require('@aws-sdk/client-appconfig'); async function isFeatureEnabled(tenantId, feature) { const client = new AppConfigClient({ region: process.env.AWS_REGION }); const res = await client.send(new GetConfigurationCommand({ Application: 'saas-platform', Environment: 'prod', Configuration: 'features', ClientId: tenantId, })); const config = JSON.parse(res.Content.toString()); return config[feature]?.enabled === true; }ClientId传租户 ID,AppConfig 支持按 ClientId 做定向配置,这样同一个开关对不同租户可以有不同的值。开关配置变更不用重新部署,秒级生效,出问题时关掉开关就能回滚,比重新发布快得多。注意 AppConfig 有缓存,默认 30 秒,紧急回滚时可以用GetConfiguration的ClientConfigurationVersion强制刷新。
5.3 数据库迁移的多租户处理
数据库 schema 变更在多租户下要格外小心。池化模型一次迁移影响所有租户,必须保证向后兼容;孤岛模型要遍历所有租户库执行迁移。
# 用 Flyway 对多个租户库执行迁移 for tenant in $(cat tenants.txt); do flyway -url="jdbc:postgresql://$tenant-db.internal:5432/app" \ -user=admin \ -password="$DB_PASSWORD" \ migrate donetenants.txt存租户库地址列表,循环执行。迁移脚本必须幂等,重复执行不报错。生产环境先在一个租户库上跑,验证通过再全量。DB_PASSWORD从 Secrets Manager 取,别写在脚本里。迁移失败要能中断并告警,别让循环默默跑完留下一堆半迁移的库。
6. 避坑与排查:多租户 SaaS 上线后最容易翻车的五件事
这一章是我和团队在真实项目里踩出来的,每条都按「现象 → 原因 → 解决」写,照着排查能省不少时间。
租户数据串了。现象是 A 客户看到了 B 客户的数据,通常发生在连接池复用场景。原因是会话变量app.current_tenant没在每次借出连接时重置,上一个请求的租户 ID 残留。解决是在连接池的beforeAcquire钩子里强制SET app.current_tenant = '',或者干脆用连接级参数而不是会话级。上线前一定要做并发压测,模拟多租户交叉请求。
账单金额对不上。现象是客户投诉用量比实际多。原因是计量事件重复发送,比如 Lambda 重试导致同一条记录写两次。解决是在事件里带唯一 ID,聚合时用count(distinct event_id)去重,或者用 Firehose 的幂等写入。另外检查 API 网关的重试策略,别把客户端重试也算成新调用。
发布后部分租户 500。现象是灰度发布时某些租户报错,其他正常。原因是新代码依赖了某个只有部分租户库有的字段或索引。解决是数据库迁移和代码发布解耦,先迁移后发布,迁移脚本保证向后兼容。用功能开关控制新逻辑,出问题先关开关再排查。
Cognito JWT 校验失败。现象是用户登录后请求被拒,日志显示签名无效。原因是 JWKS 缓存过期或密钥轮换后没刷新。解决是给jwks-rsa配cache: true和rateLimit: true,缓存时间设短一点比如 10 分钟。别把公钥硬编码,轮换时必挂。
NAT 网关账单超预期。现象是月底发现 NAT 费用比计算费用还高。原因是租户账号里大量流量走了 NAT 出公网,比如拉取容器镜像、访问外部 API。解决是能用 VPC Endpoint 的服务(S3、ECR、DynamoDB 等)全走 Endpoint,镜像拉取走 ECR 的 Interface Endpoint,能省一大笔。定期用 Cost Explorer 按 NAT 网关维度看账单,发现异常流量及时查。
7. 一个进阶技巧:用租户级成本标签把架构账算清楚
前面讲的都是怎么让系统跑起来,这一章讲怎么知道它跑得值不值。SaaS 架构做久了会发现,最大的问题不是技术,而是「这个租户到底赚不赚钱」。AWS 的成本标签(Cost Allocation Tag)能把这件事量化。
核心做法是给每个租户相关的资源打上tenant_id标签,然后在 Cost Explorer 里按标签维度看成本。计算层用 ECS 任务标签,数据库用 RDS 标签,S3 用桶标签,Lambda 用函数标签。标签要统一命名,我一般用tenant_id和env两个键。
# 给 ECS 任务定义打租户标签 aws ecs tag-resource \ --resource-arn arn:aws:ecs:us-east-1:111122223333:task-definition/tenant-abc:5 \ --tags key=tenant_id,value=abc key=env,value=prod # 给 RDS 实例打标签 aws rds add-tags-to-resource \ --resource-name arn:aws:rds:us-east-1:111122223333:db:tenant-abc-db \ --tags Key=tenant_id,Value=abc Key=env,Value=prod标签打完不是马上生效,Cost Explorer 的标签数据有 24 小时延迟,当月数据可能不完整,看趋势别看单日。tenant_id用短标识,别用 UUID 全称,标签值有长度限制且查询时不好读。共享资源比如 NAT 网关、负载均衡没法按租户打标签,我一般按用量比例分摊,在成本报表里单独列一项「共享成本」。
有了租户级成本,再对比每个租户的收入,就能算出毛利。毛利为负的租户要么提价,要么引导升级套餐,要么劝退。这件事听起来像财务,但架构师必须参与,因为很多成本是架构决策造成的——比如给一个小客户开了独立 RDS 多可用区,成本自然下不来。我现在的习惯是每个季度拉一次租户成本报表,和销售、财务一起过一遍,把架构调整和商业策略对齐。这个习惯帮我避免了好几次「越做越亏」的扩张。
希望帮到你。
本文还有配套的精品资源,点击获取