简介:这份PPT资料面向正在或计划将传统软件转型为SaaS的独立软件供应商架构师、技术负责人及云计算学习者,系统梳理了基于AWS构建SaaS平台的整体架构思路与关键技术选型。内容围绕为何选择SaaS、为何AWS是理想平台展开,深入讲解身份管理中的用户ID与租户ID映射、MFA与IAM策略,多租户的Silo、Bridge、Pool三种模式对比,以及应用分层隔离、网络与数据隔离等核心议题。同时覆盖CloudWatch、CloudTrail、S3等管理监控工具,测量计费与租户统一视图,并延伸至DevOps敏捷交付、大数据分析、物联网平台与人工智能服务引擎等场景。资源包共1个pptx文件,约1.21MB,以图文架构图与要点清单呈现,便于快速理解多租户设计与隔离策略的取舍。目前已有143人学习,适合需要搭建SaaS架构或评估AWS技术栈的读者参考。
1. 从一份 PPT 标题说起:AWS 上的 SaaS 平台架构到底在解决什么问题
如果你手里也有一份叫「基于 AWS 的 SaaS 平台架构」的方案要落地,大概率不是要从零写一份 PPT,而是要把这套架构真正跑起来。SaaS 和传统单租户系统最大的区别在于:一套代码、一套基础设施要同时服务成百上千个客户,每个客户的数据要隔离、用量要计量、套餐要能升降级、账单要能算清楚。AWS 提供的托管服务能把这些脏活累活接过去一大半,但前提是你得知道哪些服务组合起来才不踩坑。
这篇内容面向的是正在做 SaaS 架构选型或迁移的工程师,尤其是中小团队里那个「既要画架构图又要写 Terraform」的人。我会按「先想清楚租户模型 → 再落地基础设施 → 然后处理计费与隔离 → 最后排坑」的顺序讲,每一章都给出可以直接抄的配置和代码。读完你应该能判断:自己的 SaaS 产品适不适合上 AWS 多租户架构,以及第一步该动哪里。
2. 租户模型先定死:三种隔离级别怎么选
在写任何 AWS 资源之前,必须先回答一个问题:你的租户数据放在哪一层隔离。这个决定会直接影响后面所有基础设施的形态,改起来代价极大。常见做法是三种:共享数据库共享表(加 tenant_id 字段)、共享数据库独立 schema、独立数据库实例。选哪种不取决于技术偏好,取决于你的客户画像和合规要求。
2.1 三种模型的成本与隔离对比
| 隔离级别 | 数据存储方式 | 单租户月成本量级 | 适用场景 | 迁移难度 |
|---|---|---|---|---|
| 共享表 | 同一张表加 tenant_id | 极低 | 小微客户、免费套餐 | 低 |
| 独立 schema | 同库不同 schema | 低 | 中型客户、有数据导出需求 | 中 |
| 独立实例 | 每租户一个 RDS | 高 | 金融、医疗、强合规 | 高 |
我一般建议起步阶段用共享表加 tenant_id,但在数据访问层强制注入租户过滤条件,不要指望每个开发者自觉写 WHERE。等出现第一个愿意为隔离付溢价的客户,再针对他单独升级到独立 schema 或独立实例。这种「混合隔离」策略在 AWS 上很好实现,因为 RDS 和 Aurora 都支持在同一集群里创建多个数据库。
2.2 用行级安全策略兜住租户过滤
光靠应用层写 WHERE tenant_id = ? 迟早会翻车,一个漏写的查询就可能把 A 客户的数据返回给 B 客户。PostgreSQL 的行级安全策略(RLS)是最后一道防线,Aurora PostgreSQL 原生支持。
-- 开启表的行级安全 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 = '租户UUID';逻辑说明:current_setting('app.current_tenant')从会话变量里读取租户 ID,应用每次获取数据库连接后立刻执行 SET 命令。参数说明:tenant_id字段类型要和会话变量转换后的类型一致,否则策略不生效。注意连接池场景下,归还连接前必须 RESET 这个变量,否则下一个请求可能继承上一个租户的上下文,这是血泪经验里最常见的一种串数据事故。
2.3 租户上下文在应用层的传递方式
应用层需要把租户 ID 从请求入口一路传到数据库会话。以 Spring Boot 为例,常见做法是用 ThreadLocal 存租户上下文,在拦截器里从 JWT 或子域名解析出租户标识。
public class TenantContext { private static final ThreadLocal<String> CURRENT = new ThreadLocal<>(); public static void set(String tenantId) { CURRENT.set(tenantId); } public static String get() { return CURRENT.get(); } public static void clear() { CURRENT.remove(); } }逻辑说明:拦截器在请求进入时调用 set,请求结束时必须调用 clear,否则线程池复用会导致租户串号。参数说明:租户标识建议用 UUID 而不是自增 ID,避免被猜测。这套机制和后面要讲的 IAM 权限、计费计量是打通的——计量服务也需要知道当前请求属于哪个租户,才能把用量记到正确的账上。
3. 在 AWS 上把多租户基础设施搭起来
租户模型定了之后,接下来是把计算、存储、网络这些资源用 AWS 服务拼出来。SaaS 平台和普通 Web 应用在基础设施上的核心差异是:你需要一套自动化的租户开通流程,新客户注册后几分钟内就能用上独立或半独立的环境,而不是靠运维手动建资源。这一章给出可复现的搭建路径。
3.1 用 CDK 定义一套可复用的租户基础设施
AWS CDK 比 Terraform 更适合 SaaS 场景,因为你可以用编程语言写循环,为每个租户生成一套资源栈。下面是一个最小化的租户基础设施定义,包含一个 Aurora Serverless v2 集群和一个 ECS 服务。
import * as cdk from 'aws-cdk-lib'; import * as rds from 'aws-cdk-lib/aws-rds'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import { Construct } from 'constructs'; export class TenantStack extends cdk.Stack { constructor(scope: Construct, id: string, props: { tenantId: string }) { super(scope, id); // 每个租户一个独立的 Aurora Serverless v2 集群 const cluster = new rds.DatabaseCluster(this, 'TenantDB', { engine: rds.DatabaseClusterEngine.auroraPostgres({ version: rds.AuroraPostgresEngineVersion.VER_15_4, }), serverlessV2MinCapacity: 0.5, serverlessV2MaxCapacity: 4, writer: rds.ClusterInstance.serverlessV2('writer'), defaultDatabaseName: 'app', }); // 租户 ID 作为标签,方便成本分摊 cdk.Tags.of(cluster).add('tenant', props.tenantId); } }逻辑说明:每个租户一个 Stack,Stack 名里带租户 ID,资源打上 tenant 标签。参数说明:serverlessV2MinCapacity设为 0.5 ACU 是成本底线,空闲时几乎不花钱;maxCapacity按租户套餐等级调整,免费版给 2,企业版给 16。注意 Aurora Serverless v2 的冷启动比 v1 快很多,但首次连接仍有几百毫秒延迟,对延迟敏感的场景要在连接池里保持最小连接数。
3.2 租户开通流水线:从注册到可用
新租户注册后,需要一个自动化流程把基础设施创建出来。常见做法是用 Step Functions 编排:先调 CDK 部署租户栈,再初始化数据库 schema,最后把租户信息写入控制面数据库。
# 触发租户开通的 Step Functions 执行 aws stepfunctions start-execution \ --state-machine-arn arn:aws:states:us-east-1:123456789012:stateMachine:TenantProvisioning \ --input '{"tenantId":"t-abc123","plan":"pro","region":"us-east-1"}'逻辑说明:Step Functions 的输入里带租户 ID、套餐等级和目标区域。参数说明:plan字段决定后续资源规格,region决定数据驻留位置。整个流程通常 3 到 5 分钟完成,期间租户状态是 provisioning,前端要轮询状态接口。这里有个坑:CDK 部署本身是异步的,Step Functions 里要用 wait 状态轮询 CloudFormation 栈状态,不能直接假设部署完成。
3.3 控制面与数据面的分离
SaaS 架构里必须把控制面(租户管理、计费、配置)和数据面(业务数据处理)分开。控制面用一张全局的 DynamoDB 表存租户元数据,数据面按租户隔离。这样做的好处是控制面故障不影响已有租户的业务运行。
import boto3 dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('TenantRegistry') def get_tenant_config(tenant_id): response = table.get_item(Key={'tenant_id': tenant_id}) item = response.get('Item') if not item: raise ValueError(f'Tenant {tenant_id} not found') return { 'db_endpoint': item['db_endpoint'], 'plan': item['plan'], 'region': item['region'], 'status': item['status'] }逻辑说明:控制面表用 tenant_id 做主键,存数据库连接信息、套餐、状态。参数说明:db_endpoint是租户专属的 Aurora 端点,应用启动时从控制面拉取并缓存。注意这张表是全局单点,要做好备份和跨区域复制,否则控制面挂了新租户无法开通,已有租户虽然能继续用但无法变更配置。
4. 计费与计量:SaaS 的钱袋子怎么管
SaaS 平台和普通项目的本质区别之一就是必须能算清楚每个租户用了多少、该收多少钱。AWS 本身按资源用量计费,但你需要把这层成本映射到租户维度,再加上自己的加价逻辑。这一章讲计量数据的采集、聚合和账单生成。
4.1 用量事件采集:从 API 网关到 Kinesis
每次租户调用 API 都应该产生一条用量事件,包含租户 ID、接口名、时间戳、消耗的计量单位(比如 API 调用次数、处理的数据量)。这些事件先写入 Kinesis Data Streams,再批量落盘。
import json import boto3 from datetime import datetime kinesis = boto3.client('kinesis') def emit_usage_event(tenant_id, api_name, units): event = { 'tenant_id': tenant_id, 'api_name': api_name, 'units': units, 'timestamp': datetime.utcnow().isoformat() } kinesis.put_record( StreamName='usage-events', Data=json.dumps(event), PartitionKey=tenant_id # 同一租户的事件进同一分片 )逻辑说明:PartitionKey 用 tenant_id 保证同一租户的事件有序,方便后续按租户聚合。参数说明:units是计量单位,可以是调用次数、token 数、存储字节数,按你的计费维度定义。注意 Kinesis 单分片写入上限是 1MB/s 或 1000 条/s,租户量大时要提前算好分片数,否则会触发 ProvisionedThroughputExceededException。
4.2 用量聚合:Lambda 按小时汇总
Kinesis 里的原始事件需要聚合成小时或天粒度的用量记录,才能用于出账。常见做法是用 Lambda 消费 Kinesis,按租户和接口维度做窗口聚合,写入 Timestream 或 DynamoDB。
def lambda_handler(event, context): aggregated = {} for record in event['Records']: payload = json.loads(record['kinesis']['data']) key = (payload['tenant_id'], payload['api_name']) aggregated[key] = aggregated.get(key, 0) + payload['units'] # 写入聚合结果表 for (tenant_id, api_name), total in aggregated.items(): table.update_item( Key={'tenant_id': tenant_id, 'api_name': api_name}, UpdateExpression='ADD total_units :val', ExpressionAttributeValues={':val': total} )逻辑说明:Lambda 每次触发处理一批 Kinesis 记录,先在内存里聚合再写库,减少写入次数。参数说明:total_units是累加字段,用 DynamoDB 的 ADD 操作保证原子性。注意 Lambda 的批大小和窗口时间要匹配,批太小聚合效果差,批太大延迟高,一般设 100 条或 60 秒。
4.3 套餐与费用策略的映射
计量数据有了,接下来要映射到套餐。SaaS 套餐通常分免费版、专业版、企业版,每个版本有配额和单价。这张映射表建议放在控制面数据库里,方便运营调整而不需要改代码。
| 套餐 | API 调用配额 | 超出单价 | 存储配额 | 支持租户数 |
|---|---|---|---|---|
| 免费版 | 1 万次/月 | 不可超出 | 1 GB | 不限 |
| 专业版 | 100 万次/月 | 0.01 元/次 | 50 GB | 不限 |
| 企业版 | 不限 | 0.005 元/次 | 500 GB | 按合同 |
逻辑说明:配额检查在 API 网关的 Lambda 授权器里做,每次请求前查当前用量是否超限。参数说明:超出单价按阶梯递减,鼓励大客户多用。注意免费版「不可超出」意味着超限直接拒绝请求,要在响应里明确告诉用户升级套餐,否则体验很差。
5. 避坑与排查:多租户 SaaS 上 AWS 最容易翻车的地方
这一章记录的是我在实际项目里踩过的坑,每一条都按「现象 → 原因 → 解决」写。有些坑看起来是 AWS 的问题,其实是架构设计时没想清楚。
5.1 连接池串租户:A 客户看到 B 客户的数据
现象:压测时偶发数据错乱,A 租户的请求返回了 B 租户的订单。原因:应用用了 HikariCP 连接池,租户上下文存在 ThreadLocal 里,但连接归还时没有 RESET 数据库会话变量,下一个请求复用了同一个连接,继承了上一个租户的app.current_tenant。解决:在连接池的 connectionInitSql 里强制 RESET,或者在每次获取连接后重新 SET。更稳妥的做法是用独立的数据库用户,每个租户一个角色,从连接层面隔离。
5.2 Aurora Serverless v2 冷启动导致首请求超时
现象:新租户开通后第一次访问特别慢,有时直接 504。原因:Aurora Serverless v2 虽然比 v1 快,但从 0.5 ACU 扩容到更高容量仍需几秒,加上应用连接池首次建连,叠加起来超过网关超时。解决:把serverlessV2MinCapacity设到 1 而不是 0.5,或者在租户开通流程里加一步预热,主动发几个查询把容量拉起来。对延迟敏感的业务,直接上 Provisioned 实例更省心。
5.3 Kinesis 分片热键导致计量数据延迟
现象:某个大租户的用量数据延迟几小时才出现在账单里。原因:Kinesis 按 PartitionKey 分片,这个大租户的调用量太大,单个分片写入达到上限,数据被限流后重试。解决:PartitionKey 改成 tenant_id + 随机后缀,把热点打散到多个分片。代价是同一租户的事件不再有序,但计量场景对顺序不敏感,可以接受。
5.4 CDK 部署租户栈时触发 CloudFormation 配额
现象:开到第 200 个租户时,新租户开通失败,报 CloudFormation 栈数量超限。原因:AWS 默认每个账户每个区域最多 2000 个 CloudFormation 栈,每个租户一个栈很快就用完了。解决:改用 CDK 的嵌套栈或者直接用 SDK 调 API 创建资源,不走 CloudFormation。或者把多个小租户合并到一个栈里,用参数区分。
5.5 跨区域数据驻留合规检查缺失
现象:欧洲客户投诉数据被存到了美国区域。原因:租户开通时 region 参数没做校验,默认用了 us-east-1。解决:在控制面加一层合规检查,根据租户所在国家强制指定区域,欧洲客户只能选 eu-west-1 或 eu-central-1。这个检查要在 Step Functions 的第一步做,不通过直接拒绝开通。
6. 进阶技巧:用标签和 Cost Explorer 做租户级成本核算
前面讲的都是怎么把平台搭起来、怎么算钱。但还有一个更实际的问题:你怎么知道每个租户到底花了你多少 AWS 成本?如果不知道,定价就是拍脑袋。这一章讲一个我常用的技巧:用资源标签加 Cost Explorer API 做租户级成本分摊。
核心思路很简单:所有为租户创建的资源都打上tenant标签,然后在每月出账后调 Cost Explorer 的GetCostAndUsage接口,按标签维度拉取成本数据,再和你的计费系统对账。
import boto3 from datetime import datetime, timedelta ce = boto3.client('ce') def get_tenant_cost(tenant_id, start_date, end_date): response = ce.get_cost_and_usage( TimePeriod={'Start': start_date, 'End': end_date}, Granularity='MONTHLY', Metrics=['BlendedCost'], Filter={ 'Tags': { 'Key': 'tenant', 'Values': [tenant_id] } }, GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}] ) return response['ResultsByTime']逻辑说明:Filter按 tenant 标签过滤,GroupBy按服务维度拆分,这样你能看到每个租户在 EC2、RDS、S3 上分别花了多少。参数说明:Granularity用 MONTHLY 出账用,日常监控可以用 DAILY。注意 Cost Explorer 的数据有 24 小时延迟,且标签激活需要时间,新打的标签不会立刻出现在成本报告里。
拿到成本数据后,和你的计费系统做对比。如果某个租户的 AWS 成本已经超过他付的订阅费,要么涨价要么优化架构。我一般会设一个告警:当租户的 AWS 成本超过其套餐收入的 70% 时触发,提醒运营介入。这个比例不是固定的,基础设施占比高的业务可以放宽到 80%,纯软件业务应该控制在 50% 以下。
还有一个细节:共享资源(比如控制面的 DynamoDB、API 网关)的成本没法直接归到某个租户,需要按用量比例分摊。我的做法是单独建一个shared标签,月底按各租户的 API 调用量占比分摊这部分成本。虽然不精确,但比不算强。
最后说一个我自己的习惯:每次给租户开通资源时,除了打 tenant 标签,还会在资源描述里写上开通时间和套餐版本。这样半年后回头看,能快速判断哪些租户是早期低价套餐、现在成本已经倒挂,该谈续约涨价了。这个习惯帮我避免了好几次「客户越用越多、我们越亏越多」的局面。希望帮到你。
本文还有配套的精品资源,点击获取