简介:这份SAAS平台业务架构文档面向产品经理、架构师及后端研发人员,系统梳理了多租户SaaS平台从业务到技术的整体设计思路,可用于架构评审、方案选型或团队内部培训参考。文档围绕UPMS统一用户权限管理、账户中心、应用中心、订单中心、促销中心、消息中心与客服中心七大功能模块展开,并给出业务总体架构图与系统分层架构图,同时覆盖性能、可靠性、安全性、易用性、可维护性与可扩展性等非功能性需求。资源包内仅含1个docx文件,约401KB,内容包含修订记录、目录结构及用户管理、角色管理、资源管理等业务分层架构说明,便于按章节查阅。目前已有489人学习下载,适合需要理解SaaS权限体系与分布式服务平台设计的中高级技术人员参考借鉴。
1. 一份 SaaS 业务架构文档到底该写什么:从 UPMS 到租户隔离的全局视角
很多团队在启动 SaaS 项目时,第一份文档往往写成功能清单——登录、权限、订单、报表挨个列一遍,结果开发到第三个月发现租户数据串了、套餐计费对不上、微服务拆完反而更慢。问题不在代码,在于业务架构文档没有把「谁在用、按什么规则隔离、钱怎么算」这三件事定死。一份能落地的 SaaS 业务架构文档,核心要回答的是:UPMS 权限模型怎么设计、租户数据在哪一层隔离、微服务按什么维度拆分、套餐费用策略如何映射到技术实现。它面向的是技术负责人和一线开发,不是给投资人看的 PPT。下面按我实际写过多份这类文档的经验,把每个模块拆到能直接照着画图、建表、写接口的程度。
2. 业务架构文档的骨架:从领域划分到微服务边界
2.1 先定领域边界,再谈微服务拆分
SaaS 平台最常见的翻车方式,是先拆微服务再想业务。正确顺序是:先画领域边界,再映射到服务。我一般按「租户生命周期」来切:入驻开通、权限配置、业务使用、计费结算、数据归档。每个阶段对应一个或多个限界上下文。
以 UPMS(统一权限管理系统)为例,它不是一个服务,而是横跨多个上下文的能力集合。租户管理、用户管理、角色权限、菜单资源,这四个子域可以独立成服务,但共享同一套租户上下文。拆分时遵循一个原则:同一事务边界内的数据不跨服务。比如「创建租户 + 初始化管理员 + 分配默认角色」必须在一个服务内完成,否则分布式事务会让你痛不欲生。
文档里这一节要写清楚三样东西:领域清单(每个领域的职责一句话)、上下文映射图(谁依赖谁、用什么方式通信)、服务清单(每个服务对应哪些领域)。不要写「用户服务负责用户相关功能」这种废话,要写到「用户服务管理 tenant_id 维度下的账号生命周期,对外暴露 gRPC 接口,内部通过事件总线通知权限服务做角色绑定」。
2.2 微服务架构图的画法与通信约定
架构图不是装饰,是契约。我见过太多文档里的架构图只有方框和箭头,没有协议、没有数据流向、没有同步异步标注,开发看完还是不知道该怎么调。
一张合格的微服务架构图至少包含:服务名、通信协议(HTTP/gRPC/消息队列)、数据存储(每个服务独立库还是共享库)、同步调用链路、异步事件链路。同步调用用实线箭头标注接口名,异步通信用虚线箭头标注事件名。
通信约定要在文档里写死:内部服务间同步调用统一走 gRPC,对外 API 走 REST;跨服务的状态变更一律走事件驱动,不允许 A 服务直接写 B 服务的库。事件命名规范建议用「领域.实体.动作」格式,比如tenant.account.created、order.payment.completed。
# 服务通信配置示例(文档中应附此类配置片段) services: tenant-service: protocol: grpc port: 50051 database: tenant_db publishes: - tenant.account.created - tenant.account.suspended subscribes: - billing.payment.confirmed upms-service: protocol: grpc port: 50052 database: upms_db publishes: - upms.role.assigned subscribes: - tenant.account.created这段配置说明每个服务的通信方式和事件订阅关系。publishes列出该服务发出的事件,subscribes列出它关心的事件。文档里每个服务都应该有这样一段,开发照着建工程骨架就行。参数上注意:gRPC 端口要避开常用端口段,数据库一律独立,事件名全局唯一。
2.3 租户模型的技术选型:共享库还是独立库
这是 SaaS 架构文档里最关键的决策之一,没有之一。三种主流方案:
| 方案 | 隔离级别 | 成本 | 适用场景 |
|---|---|---|---|
| 共享库共享表 | tenant_id 字段隔离 | 最低 | 中小客户、快速上线 |
| 共享库独立 Schema | Schema 级隔离 | 中等 | 中大型客户、数据敏感 |
| 独立库 | 物理隔离 | 最高 | 大客户、合规要求高 |
我一般建议起步阶段用共享库共享表,但在文档里必须预留升级路径。具体做法是:所有业务表强制带tenant_id字段,所有查询强制走租户拦截器,DAO 层不允许手写不带tenant_id的 SQL。
// 租户拦截器核心逻辑(MyBatis 插件示例) @Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})}) public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; Object parameter = invocation.getArgs()[1]; // 从上下文获取当前租户ID String tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { throw new TenantNotFoundException("租户上下文缺失,拒绝执行"); } // 拼接租户条件到 SQL BoundSql boundSql = ms.getBoundSql(parameter); String sql = boundSql.getSql(); String newSql = sql + " AND tenant_id = '" + tenantId + "'"; // 反射替换 SQL ReflectUtil.setFieldValue(boundSql, "sql", newSql); return invocation.proceed(); } }这段拦截器的逻辑是:每次 SQL 执行前,从TenantContextHolder取出当前租户 ID,强制拼接到 WHERE 条件。参数说明:TenantContextHolder用 ThreadLocal 存储,在网关层解析 JWT 后写入,请求结束清除。注意这个示例是简化版,生产环境要用参数化查询防注入,还要处理 JOIN 场景下的多表租户条件。
文档里这一节要写清楚:选了哪种方案、为什么选、升级路径是什么、拦截器在哪个层生效、绕过拦截器的白名单有哪些(比如系统表、字典表)。
3. UPMS 权限模型落地:RBAC 到租户级权限的映射
3.1 权限模型设计:RBAC 够不够用
标准 RBAC 是用户-角色-权限三层,但 SaaS 场景下不够。因为同一个角色在不同租户下权限可能不同,甚至同一租户下不同组织单元也需要数据权限隔离。我一般用 RBAC + 数据权限 + 租户上下文的组合模型。
核心表结构:
-- 租户表 CREATE TABLE sys_tenant ( id BIGINT PRIMARY KEY, tenant_code VARCHAR(64) UNIQUE NOT NULL, tenant_name VARCHAR(128) NOT NULL, status TINYINT DEFAULT 1, expire_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 用户表(带租户维度) CREATE TABLE sys_user ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, username VARCHAR(64) NOT NULL, password_hash VARCHAR(256) NOT NULL, org_id BIGINT, status TINYINT DEFAULT 1, UNIQUE KEY uk_tenant_username (tenant_id, username) ); -- 角色表 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, role_code VARCHAR(64) NOT NULL, role_name VARCHAR(128) NOT NULL, data_scope TINYINT DEFAULT 1, -- 1全部 2本部门 3本部门及以下 4仅本人 UNIQUE KEY uk_tenant_role (tenant_id, role_code) ); -- 用户角色关联 CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); -- 权限表(菜单+按钮+API) CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY, parent_id BIGINT DEFAULT 0, perm_code VARCHAR(128) NOT NULL, perm_type TINYINT NOT NULL, -- 1菜单 2按钮 3接口 path VARCHAR(256), UNIQUE KEY uk_perm_code (perm_code) ); -- 角色权限关联 CREATE TABLE sys_role_permission ( role_id BIGINT NOT NULL, perm_id BIGINT NOT NULL, PRIMARY KEY (role_id, perm_id) );关键设计点:sys_user和sys_role都带tenant_id,唯一索引包含tenant_id,保证租户间不冲突。sys_permission是全局表,不带租户 ID,因为权限定义是平台级的,租户只做分配。data_scope字段控制数据权限范围,在查询时动态拼接组织条件。
文档里要写清楚:权限校验发生在哪一层(网关做接口级、服务做数据级)、权限缓存怎么刷新(角色变更发事件、各服务监听后清本地缓存)、超级管理员怎么处理(平台级超管跨租户,租户级管理员仅本租户)。
3.2 租户上下文传递:从网关到 DAO 的全链路
租户 ID 的传递链路必须写死在文档里,否则每个开发按自己理解传,迟早出乱子。标准链路:
- 网关解析 JWT,提取
tenant_id和user_id - 写入请求头
X-Tenant-Id、X-User-Id - 下游服务通过 Filter 拦截请求头,写入
TenantContextHolder(ThreadLocal) - 异步任务和消息消费场景,手动从消息头恢复上下文
- DAO 层拦截器从
TenantContextHolder取值拼接 SQL - 请求结束,Filter 清除 ThreadLocal
// 租户上下文 Filter public class TenantContextFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; String tenantId = req.getHeader("X-Tenant-Id"); String userId = req.getHeader("X-User-Id"); try { if (tenantId != null) { TenantContextHolder.setTenantId(Long.parseLong(tenantId)); } if (userId != null) { TenantContextHolder.setUserId(Long.parseLong(userId)); } chain.doFilter(request, response); } finally { // 必须清理,防止线程池复用导致租户串数据 TenantContextHolder.clear(); } } }这段代码的核心是finally块里的clear()。线程池复用是租户数据串门的头号元凶,血泪经验:不加这个,压测时必现跨租户数据泄露。参数上注意X-Tenant-Id必须由网关强制覆盖,不允许客户端直接传,否则恶意用户改个头就能访问别人数据。
异步场景要特别处理。消息消费时,生产者在消息头带上tenant_id,消费者从消息头恢复上下文。定时任务按租户遍历,每次循环设置上下文再执行。
3.3 权限缓存与实时刷新策略
权限数据读多写少,必须缓存。但缓存刷新是难点:角色权限变更后,怎么让所有服务立刻生效?
我一般用两级缓存 + 事件通知:本地 Caffeine 缓存 + Redis 集中缓存。权限变更时,先更新数据库,再删 Redis 缓存,然后发广播事件,各服务收到后清本地缓存。下次请求回源到 Redis,Redis 没有再回源到数据库。
// 权限缓存刷新监听 @Component public class PermissionCacheListener { @EventListener public void onRolePermissionChanged(RolePermissionChangedEvent event) { // 清除 Redis 中该租户的权限缓存 String cacheKey = "perm:tenant:" + event.getTenantId(); redisTemplate.delete(cacheKey); // 广播本地缓存清除事件 localCache.invalidateAll(); // 通过消息队列通知其他实例 rocketMQTemplate.convertAndSend("perm-cache-refresh", new CacheRefreshMessage(event.getTenantId())); } }参数说明:cacheKey按租户维度组织,避免全量刷新。本地缓存用invalidateAll简单粗暴但有效,因为权限数据量不大。消息队列保证最终一致性,允许短暂延迟(通常秒级)。文档里要写明缓存过期时间建议值:Redis 30 分钟,本地 5 分钟,作为兜底。
4. 套餐费用策略的技术映射:从定价模型到计费引擎
4.1 套餐模型设计:功能包 + 用量 + 周期
SaaS 套餐的费用策略在文档里不能只写「按月收费」,要拆到可配置的数据模型。我一般用三个维度描述一个套餐:功能包(能用哪些模块)、用量限制(用户数、存储、API 调用次数)、计费周期(月付、年付、一次性)。
-- 套餐定义 CREATE TABLE biz_plan ( id BIGINT PRIMARY KEY, plan_code VARCHAR(64) UNIQUE NOT NULL, plan_name VARCHAR(128) NOT NULL, billing_cycle TINYINT NOT NULL, -- 1月 2年 3一次性 base_price DECIMAL(12,2) NOT NULL, status TINYINT DEFAULT 1 ); -- 套餐功能项 CREATE TABLE biz_plan_feature ( id BIGINT PRIMARY KEY, plan_id BIGINT NOT NULL, feature_code VARCHAR(64) NOT NULL, feature_value VARCHAR(256), -- 配置值,如"100"表示100用户 UNIQUE KEY uk_plan_feature (plan_id, feature_code) ); -- 租户订阅 CREATE TABLE biz_subscription ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, plan_id BIGINT NOT NULL, start_at DATETIME NOT NULL, end_at DATETIME NOT NULL, status TINYINT DEFAULT 1, -- 1生效 2过期 3取消 auto_renew TINYINT DEFAULT 0 ); -- 用量记录 CREATE TABLE biz_usage_record ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, feature_code VARCHAR(64) NOT NULL, usage_amount BIGINT NOT NULL, record_date DATE NOT NULL, UNIQUE KEY uk_tenant_feature_date (tenant_id, feature_code, record_date) );关键设计:feature_code是功能标识,与权限系统的perm_code解耦但可映射。biz_usage_record按天聚合,避免实时计数压力。订阅表带auto_renew支持自动续费。
文档里要写清楚:套餐变更时怎么处理(升级立即生效按比例补差价、降级下周期生效)、用量超限怎么处理(软限制提醒还是硬限制阻断)、试用期怎么实现(特殊套餐类型或独立字段)。
4.2 计费引擎的核心逻辑与幂等保障
计费引擎最怕重复扣款和对不上账。核心原则:每次计费操作必须有唯一业务单号,且状态机严格流转。
// 计费核心流程 @Service public class BillingService { @Transactional public void processBilling(Long tenantId, String billingNo) { // 1. 幂等检查 if (billingRecordMapper.existsByBillingNo(billingNo)) { log.warn("重复计费请求,billingNo={}", billingNo); return; } // 2. 获取订阅信息 Subscription sub = subscriptionMapper.getActiveByTenant(tenantId); if (sub == null || sub.getEndAt().before(new Date())) { throw new BillingException("订阅无效或已过期"); } // 3. 计算费用 Plan plan = planMapper.selectById(sub.getPlanId()); BigDecimal amount = calculateAmount(plan, sub); // 4. 生成账单 BillingRecord record = new BillingRecord(); record.setBillingNo(billingNo); record.setTenantId(tenantId); record.setAmount(amount); record.setStatus(BillingStatus.PENDING); billingRecordMapper.insert(record); // 5. 调用支付(异步) paymentGateway.requestPayment(record); } }参数说明:billingNo由调用方生成,建议格式「业务类型 + 租户ID + 时间戳 + 随机数」。calculateAmount要处理按比例计费、折扣、优惠券等逻辑。支付调用异步化,通过回调更新账单状态。文档里要定义清楚账单状态机:PENDING → PAID / FAILED / CANCELLED,每个状态允许的操作。
4.3 用量采集与配额控制
用量采集有两种模式:客户端上报和服务端统计。我一般用服务端统计为主、客户端上报为辅。API 调用次数在网关层统计,存储用量在文件服务层统计,用户数在用户服务层统计。
配额控制用 Redis 计数器 + 定时持久化:
// 配额检查与扣减 public boolean checkAndConsume(Long tenantId, String featureCode, long amount) { String key = "quota:" + tenantId + ":" + featureCode; String limitKey = "quota:limit:" + tenantId + ":" + featureCode; Long limit = redisTemplate.opsForValue().get(limitKey); if (limit == null) { // 回源到数据库加载配额上限 limit = loadLimitFromDb(tenantId, featureCode); redisTemplate.opsForValue().set(limitKey, limit, 1, TimeUnit.HOURS); } Long current = redisTemplate.opsForValue().increment(key, amount); if (current > limit) { // 超限回滚 redisTemplate.opsForValue().decrement(key, amount); return false; } return true; }注意:Redis 计数器要设置过期时间(按计费周期),且要有定时任务持久化到biz_usage_record,防止 Redis 故障丢数据。文档里要写明各功能的配额检查点位置和超限处理策略。
5. 避坑与排查:SaaS 架构落地中最容易翻车的五个点
5.1 租户数据串门:线程池复用导致上下文污染
现象:A 租户用户偶尔看到 B 租户的数据,压测时必现,生产环境偶发。
原因:ThreadLocal 存了租户 ID,但线程池复用线程时没有清理,下一个请求复用了上一个请求的租户上下文。
解决:Filter 的finally块强制clear();异步任务手动传递上下文;消息消费从消息头恢复。加监控:每次 SQL 执行前校验tenant_id是否为空,为空直接抛异常。
5.2 微服务拆分过细:一个请求跨 8 个服务
现象:接口响应时间从 200ms 涨到 2s,链路追踪一看跨了 8 个服务。
原因:按技术分层拆服务(用户服务、权限服务、角色服务、菜单服务),一个登录请求要调一圈。
解决:按业务能力拆,不按技术分层拆。UPMS 相关的能力合并到一个服务,对外暴露聚合接口。跨服务调用能并行就并行,能缓存就缓存。文档里画架构图时就要评估调用链深度,超过 3 跳的同步调用要重新设计。
5.3 套餐变更后的权限没刷新
现象:用户升级了套餐,但新功能还是用不了,要等下次登录才生效。
原因:套餐变更只更新了订阅表,没有触发权限缓存刷新。
解决:套餐变更事件要同时通知权限服务刷新缓存。在文档里定义清楚:哪些事件触发权限刷新、刷新范围是单租户还是全局、刷新延迟要求是多少。我一般要求秒级生效,通过消息队列广播。
5.4 计费对不上账:浮点数精度和时区问题
现象:月底对账发现金额差几分钱,或者跨时区客户计费日期差一天。
原因:金额用 double 计算丢精度;服务器时区和客户时区不一致。
解决:金额一律用DECIMAL或分为单位的BIGINT;所有时间存 UTC,展示时转客户时区;计费周期按客户时区的自然月计算。文档里要写死这些规范,代码 review 时重点检查。
5.5 网关层租户识别失败导致全站 403
现象:网关升级后,所有请求返回 403,日志显示租户上下文缺失。
原因:网关解析 JWT 的逻辑变更,tenant_id字段名改了但下游没同步。
解决:JWT 的 claim 命名作为契约写进文档,变更要走版本管理。网关加兜底逻辑:解析失败时返回明确错误码而非 403。监控告警:租户上下文缺失率超过阈值立即报警。
6. 文档版本管理与演进:V1.1 之后怎么迭代
业务架构文档不是写完就锁进柜子的。V1.1 之后,每次架构变更都要走文档更新流程。我的习惯是:文档和代码同仓库管理,用 Markdown 写,变更走 MR,Review 通过才合并。每个章节标注负责人和最后更新日期。
演进时重点维护三张图:领域上下文图、服务调用链路图、数据流向图。这三张图能对齐,架构就不会散。另外,文档里预留「已知问题」和「待决策」两个小节,把当前没想清楚的点记下来,下次迭代优先解决。
验证文档是否落地,我一般做两件事:一是让新入职开发只看文档搭环境,卡住的地方就是文档缺失的地方;二是每季度做一次架构一致性检查,对比文档描述和实际部署,偏差超过 20% 就触发文档大版本更新。
踩过的最大坑是文档写得太完美,和实际代码完全两张皮。后来学乖了:文档里每个决策都附上代码位置或配置路径,找不到对应实现的描述一律删掉。希望帮到你。
本文还有配套的精品资源,点击获取