
简介这份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 字段隔离最低中小客户、快速上线共享库独立 SchemaSchema 级隔离中等中大型客户、数据敏感独立库物理隔离最高大客户、合规要求高我一般建议起步阶段用共享库共享表但在文档里必须预留升级路径。具体做法是所有业务表强制带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 拦截请求头写入TenantContextHolderThreadLocal异步任务和消息消费场景手动从消息头恢复上下文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 缓存然后发广播事件各服务收到后清本地缓存。下次请求回源到 RedisRedis 没有再回源到数据库。// 权限缓存刷新监听 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 写变更走 MRReview 通过才合并。每个章节标注负责人和最后更新日期。演进时重点维护三张图领域上下文图、服务调用链路图、数据流向图。这三张图能对齐架构就不会散。另外文档里预留「已知问题」和「待决策」两个小节把当前没想清楚的点记下来下次迭代优先解决。验证文档是否落地我一般做两件事一是让新入职开发只看文档搭环境卡住的地方就是文档缺失的地方二是每季度做一次架构一致性检查对比文档描述和实际部署偏差超过 20% 就触发文档大版本更新。踩过的最大坑是文档写得太完美和实际代码完全两张皮。后来学乖了文档里每个决策都附上代码位置或配置路径找不到对应实现的描述一律删掉。希望帮到你。本文还有配套的精品资源点击获取