ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot 4 + Vue3 多租户SaaS实战:共享Schema架构设计与实现

Spring Boot 4 + Vue3 多租户SaaS实战:共享Schema架构设计与实现 1. 多租户选型三种方案里我为什么挑了共享 Schema做 SaaS 这些年我遇到过最多的灵魂拷问就是客户越来越多数据库该不该按客户拆钱够不够运维扛不扛得住这背后其实就是一个多租户架构选型的问题。先说结论——我最终选的是“共享数据库、独立 Schema”这条中间路线技术栈锁定 Spring Boot 4 Vue3 PostgreSQL MyBatis-Plus。并不是说它适合所有场景但对于绝大多数“用户量在几千到几万、客单价中等、团队规模不大”的企业级 SaaS 来说这是成本和复杂度最平衡的一个解。1.1 三种主流多租户隔离方案对比行业内讨论多租户绕不开三种方案独立数据库、共享数据库独立 Schema、共享数据库共享表通过 tenant_id 区分。很多人一上来就纠结“哪个最好”实际是“哪个对你的业务最合适”。独立数据库的方案很好理解一个租户一个库数据隔离最彻底备份恢复也简单。但它的问题同样直白数据库实例数量会随着租户数线性增长连接数、内存、备份任务都会爆炸。我见过一家公司跑到 200 个租户时DBA 每天光处理备份就得花两小时机器成本更是高得离谱。共享 Schema 是指所有租户共用一个 PostgreSQL 数据库实例但每个租户拥有自己独立的 schema表结构相同、数据物理隔离。这套方案的隔离级别介于两者之间按 schema 做备份和迁移都可行成本比独立数据库省了一大截。共享表则是所有租户的数据放在同一批表里靠 tenant_id 字段区分。优点是省缺点是风险高一旦某个查询漏写了租户条件就是跨租户数据泄露。我见过不止一次生产事故是这么来的所以除非是个人项目或内部工具我真不建议企业级 SaaS 用这套。维度独立数据库共享数据库独立 Schema共享表tenant_id数据隔离级别最高高低硬件成本最高中最低运维复杂度高中低备份恢复粒度按库按 schema按表条件单租户数据量上限大中小跨租户统计麻烦一般方便典型场景大型客户、金融企业级 SaaS个人工具、MVP 验证这里还想多说一句多租户和权限系统是两码事别混在一起设计。多租户解决的是“不同企业之间的数据隔离”权限解决的是“同一个企业内不同角色的操作范围”。我在方案评审时经常看到有人试图用权限系统去实现租户隔离结果权限越搞越重租户边界反而越来越模糊这是一定要避开的。1.2 为什么最终是共享 Schema 胜出选共享 Schema说白了是算了一笔账。拿我们当时的业务来说租户的平均数据量在 200MB 左右最大的客户也就 2GB完全没必要为一个租户开一个库。但共享表的隔离强度又满足不了企业客户的合规要求审计时人家会问“你们怎么保证隔壁公司的数据我看不到”——单靠 tenant_id 字段回答这个问题多少有点底气不足。共享 Schema 在中间刚好卡住数据库层面就做了隔离查询写错也不会串到别的租户PostgreSQL 的 schema 原生支持让我们可以单独备份、单独恢复某个租户成本上一台 32G 内存的机器跑几百个 schema 毫无压力。再加上 Flyway 可以按 schema 做独立的版本迁移新租户从注册到初始化完成整个过程能压到 10 秒以内。当然共享 Schema 也不是没有缺点最大的坑在于连接复用。数据库连接池里的连接是共享的如果不做处理上一个请求把 search_path 切到了租户 A下一个请求复用这条连接却还在处理租户 B 的数据那就全乱了。这个问题我在下一章详细展开解决办法是有的但确实属于那种“不知道就会炸、知道就很稳”的典型。2. 后端落地Spring Boot 4 动态 Schema 切换全流程架构定好了接下来就是具体干活。这一章我按照从零搭建的顺序来讲从环境准备到租户上下文、动态切换、Flyway 迁移再到 Spring Boot 4 的适配问题完整过一遍。2.1 技术栈与工程结构设计先交代清楚我这套方案选的技术栈和为什么这么选。后端用 Spring Boot 4基于 Jakarta EE 规范那一套JDK 17持久层用 MyBatis-Plus因为它的租户插件和分页能力都成熟在国内团队里认知度也高数据库用 PostgreSQL不光是看中 schema 原生隔离还有它的SET search_path这条命令简直是共享 Schema 的黄金搭档迁移工具用 Flyway版本化控制 database/schema 都很顺手。前端 Vue3 的部分后面单独讲这里先把后端工程的分层结构列出来方便你有一个整体感saas-platform/ ├── saas-common // 通用工具、异常、常量 ├── saas-system // 平台管理端租户管理、套餐管理、用户管理 ├── saas-tenant // 租户业务端动态 schema 切换、业务接口 ├── saas-gateway // 如用微服务则保留单体可省略 ├── sql/ // 初始化脚本 └── pom.xml我见过不少团队一上来就拆微服务十几个服务、几十个表结果项目还没上线部署和联调先耗掉一半人力。其实多租户 SaaS 的初期单体应用加上模块化边界完全够用等真的到了性能瓶颈再拆也不迟。Spring Boot 4 本身就是模块化设计saas-tenant和saas-system分开逻辑边界已经清晰了。另外要提醒一句Spring Boot 4 相对 2.x/3.x 有一些包路径上的调整尤其是自动配置类的迁移。你在网上搜DataSourceAutoConfiguration时如果是 Spring Boot 3.x 的资料路径是org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration到 4.x 阶段包结构变化明显依赖配置时记得以你自己项目里 Maven 拉到的实际包名为准。2.2 租户上下文与动态 Schema 切换的核心实现动态 schema 切换本质就是让“当前请求”知道自己在为哪个租户服务并在数据库连接层面把 search_path 切到对应的 schema 上。拆开来看就是三件事第一用 ThreadLocal 保存当前请求的租户标识。第二在请求进入业务代码之前从请求头、JWT 或参数里解析出租户信息并写入 ThreadLocal。第三在执行 SQL 前确保当前数据库连接指向正确的 schema并且在请求结束后清空 ThreadLocal防止线程复用造成串租户。我先写一个租户上下文的工具类这是整个过程的地基public class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); private static final ThreadLocalString CURRENT_SCHEMA new ThreadLocal(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void setSchema(String schema) { CURRENT_SCHEMA.set(schema); } public static String getSchema() { return CURRENT_SCHEMA.get(); } public static void clear() { CURRENT_TENANT.remove(); CURRENT_SCHEMA.remove(); } }接着是租户解析过滤器。生产环境里租户信息我更推荐放在 JWT 的 claim 里而不是只用请求头传递因为 JWT 本身是签名过的不容易被篡改Component public class TenantFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String tenantId resolveTenantId(request); if (StringUtils.hasText(tenantId)) { TenantContext.setTenantId(tenantId); // 租户编码到 schema 名称的映射建议格式tenant_ 租户编码 TenantContext.setSchema(tenant_ tenantId); } filterChain.doFilter(request, response); } finally { TenantContext.clear(); } } private String resolveTenantId(HttpServletRequest request) { // 优先从请求头取 String tenantId request.getHeader(X-Tenant-Id); if (StringUtils.hasText(tenantId)) { return tenantId; } // 其次从 JWT 中取 String authHeader request.getHeader(Authorization); // 这里用你自己的 JWT 解析工具类 return JwtUtils.parseTenantId(authHeader); } }2.3 MyBatis-Plus 与连接层 schema 切换衔接租户上下文有了接下来是真正的难点怎么让数据库连接知道要去哪个 schema 找表。我最开始踩过一个坑直接在application.yml里设置了spring.datasource.hikari.connection-init-sql: SET search_path TO public心想这样连接初始化时总该对了吧。但连接池里的连接是复用的上一个请求已经把这个连接的 search_path 切到了tenant_a下一个请求拿到的还是这条连接search_path 依然是tenant_a这时候你要是查租户 B 的数据结果大概率是空或者直接报“relation does not exist”。正确的做法是改造后的动态数据源每次获取连接时都主动切换到当前租户的 schema。我用的是AbstractRoutingDataSource的思路但在它基础上加了一步显式切换 search_pathSlf4j public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 动态数据源的 key 固定用 default实际区别在下方的切换 schema return default; } Override public Connection getConnection() throws SQLException { Connection connection super.getConnection(); String schema TenantContext.getSchema(); if (StringUtils.hasText(schema)) { switchSchema(connection, schema); } return connection; } private void switchSchema(Connection connection, String schema) { try (Statement statement connection.createStatement()) { // PostgreSQL 下等价于 SET search_path TO tenant_xxx statement.execute(SET search_path TO schema); } catch (SQLException e) { throw new RuntimeException(切换 schema 失败: schema, e); } } }注意这个实现里有两个细节值得展开。第一SET search_path这个操作是连接级的不是事务级的。也就是说如果你切换了连接却发生异常没有提交或者回滚search_path 依然会对下一个复用这条连接的人产生影响。所以在过滤器里的 try-finally 结构是必须的TenantContext.clear()保证线程池里下次使用前是干干净净的状态。第二SET search_path里的 schema 名一定不能直接用前端传的字符串拼 SQL否则就是 SQL 注入。我上面写了tenant_前缀加上租户编码编码本身再做一层白名单校验比如只允许字母、数字、下划线这样基本就堵死了注入路径public static boolean isValidSchemaName(String schema) { return schema ! null schema.matches(^[a-zA-Z0-9_]$); }2.4 Flyway 多 Schema 迁移新租户上线 10 秒搞定架构层面平台库比如publicschema里放的是租户管理、套餐管理这些平台级表每个租户自己的 schema 里放的是业务表比如订单、项目、成员这些。Flyway 的配置要改成动态的。平台自身的迁移可以走常规方式租户的迁移则需要在新租户创建时动态执行。我是这样封装的Component public class TenantSchemaInitializer { Value(${spring.datasource.url}) private String datasourceUrl; Value(${spring.datasource.username}) private String username; Value(${spring.datasource.password}) private String password; public void initTenantSchema(String tenantId) { String schemaName tenant_ tenantId; // 1. 创建 schema jdbcTemplate.execute(CREATE SCHEMA IF NOT EXISTS schemaName); // 2. 对当前租户 schema 执行 Flyway 迁移 Flyway flyway Flyway.configure() .dataSource(datasourceUrl, username, password) .schemas(schemaName) .locations(classpath:db/migration/tenant) .load(); flyway.migrate(); } }db/migration/tenant目录下放的就是租户业务表的迁移脚本比如V1__create_business_tables.sql、V2__add_order_index.sql。每个新租户创建时执行一次initTenantSchema就能拿到完整的业务表结构。我实际测试过从创建 schema 到迁移完成小规模表结构20 张表左右耗时在 3 秒以内商业化运营完全能接受。这里还要注意一个版本号的问题Flyway 的schema_history表是存在每个 schema 内部的所以不同租户的迁移版本互不干扰。比如租户 A 已经迁移到 V5租户 B 刚创建它会自动从 V1 开始跑。这就意味着你的租户迁移脚本一旦发布出去就不能轻易修改只能新增版本。这一点我会在团队规范里反复强调租户迁移脚本只能追加不能修改否则老租户就再也跑不上去了。2.5 Spring Boot 4 适配Jackson 与自动配置的变化标题既然带了 Spring Boot 4我多说几句这段时间在 Spring Boot 4 上遇到的变化。首先是 Jackson 的配置。Spring Boot 4 里 Jackon 的JsonMapper构建方式有调整JsonMapper.builder()返回的不再是传统的JsonMapper.Builder而是带泛型的JsonMapper.Builder。如果项目里用到了自定义 ObjectMapper 的代码直接按老写法可能编译不过报JsonMapper$Builder相关的错。解决办法也很简单package 和 builder 的类型别写死用JsonMapper.builder().build()这种链式写法再由 Spring 容器统一管理。其次是自动配置类的位置。Spring Boot 3 里面很多自动配置类都在org.springframework.boot.autoconfigure.xxx下到了 4 开始做更深的模块化拆分有些配置类挪到了org.springframework.boot.jdbc这类更“内聚”的包下面。如果你的代码里用了EnableAutoConfiguration的排除项或ConditionalOnClass升级后一定要重新检查类的全限定名。另外有一个很实际的经验Spring Boot 4 对配置属性的校验更严格了以前那种“拼错配置项但只是告警”的情况现在可能直接启动失败。所以升级时第一件事就是把日志里的 Configuration property 校验告警全部清零别留着历史债。我们当时升级时光是修这些配置就花了小半天但换来的是后面少踩很多坑。3. 前端 Vue3 多租户体验从登录到路由权限的设计后端把租户隔离做扎实了前端的工作同样不能含糊。企业级 SaaS 的前端不只是“能登录能展示”还要把多租户的用户体验做顺租户识别、动态路由、套餐状态展示、配额提醒这些一环扣一环。3.1 Vite 初始化与工程目录结构Vue3 项目我建议直接用 Vite 创建别自己配 webpack 了。创建命令很简单npm create vitelatest saas-web -- --template vue-ts进入项目后装好 Vue Router、Pinia、AxiosUI 库我通常选 Element Plus 或者 Naive UI看团队熟悉度。下面的目录结构是我在实际项目里长期迭代后的版本src/ ├── api/ // 接口请求模块 │ ├── auth.ts │ ├── tenant.ts │ └── package.ts ├── assets/ // 静态资源与全局样式 ├── components/ // 通用组件 │ └── business/ // 业务通用组件套餐卡片、配额进度条等 ├── layouts/ // 布局组件 │ ├── PlatformLayout.vue // 平台管理端布局 │ └── TenantLayout.vue // 租户业务端布局 ├── router/ │ ├── index.ts │ └── guards.ts // 路由守卫 ├── stores/ │ ├── user.ts │ ├── tenant.ts │ └── package.ts ├── views/ │ ├── platform/ // 平台端页面 │ └── tenant/ // 租户端页面 └── utils/ ├── request.ts // axios 封装 └── auth.ts一个容易忽略的点布局和路由从项目一开始就要分平台端和租户端两个模块别等到后面再重构。平台端是 SaaS 运营商自己用的管理租户、套餐、平台级配置租户端是客户公司员工用的处理他们自己的业务数据。两者看起来都是后台管理系统但权限模型、页面逻辑完全不同代码放一起后面会互相污染。3.2 租户识别与登录链路设计多租户 SaaS 的登录有个特殊性用户登录时系统要先知道他是哪个企业的员工才能走对应的认证逻辑、进入对应的 schema 数据域。我做了两种方式兼容。第一种是用户在登录页手动输入“企业编码”系统根据企业编码查到租户信息第二种是解析访问域名比如customer-a.example.com直接带出租户 A。具体实现上域名解析更顺滑但需要有个映射表手动输入更通用适合那些不像给每个客户单独配域名的场景。登录请求的返回结构大致是这样{ token: eyJhbGciOiJIUzI1NiJ9..., tenantId: acme_001, tenantName: Acme 科技, packageName: 企业版, expiresAt: 1735689600000 }前端拿到这些信息后需要做两件事把 token 存到本地把租户信息放进 Pinia store。后续每次请求axios 拦截器都会自动从 store 里取租户 ID放到请求头X-Tenant-Id。下面是 axios 封装里的核心拦截逻辑// utils/request.ts import axios from axios import { useTenantStore } from /stores/tenant import { useUserStore } from /stores/user import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config { const tenantStore useTenantStore() const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer ${userStore.token} } if (tenantStore.tenantId) { config.headers[X-Tenant-Id] tenantStore.tenantId } return config }, error Promise.reject(error)) service.interceptors.response.use( response response.data, error { if (error.response?.status 401) { userStore.reset() router.push(/login) } return Promise.reject(error) } )这里有一个我在实际项目里吃过亏的细节单点登录或刷新页面后Pinia 里的数据会丢失所以要加一层“持久化恢复”逻辑。我一般会在应用启动时读一次 localStorage把租户信息和 token 恢复到 store 中再放行进入路由。否则用户一刷新页面前端就以为他没有租户信息直接踢回登录页体验极差。3.3 路由守卫与租户上下文恢复路由守卫的核心作用有两个一是判断用户是否登录二是判断当前用户的租户上下文是否完整。下面是一个简化版的实现// router/guards.ts import router from ./index import { useUserStore } from /stores/user import { useTenantStore } from /stores/tenant router.beforeEach(async (to) { const userStore useUserStore() const tenantStore useTenantStore() // 免登录白名单 if (to.meta.public) { return true } // 未登录跳转登录页 if (!userStore.token) { return { path: /login, query: { redirect: to.fullPath } } } // 登录后但没有租户上下文尝试从本地恢复 if (!tenantStore.tenantId) { tenantStore.restoreFromLocal() } // 已经进入租户端但缺少租户信息视为异常 if (to.meta.requiresTenant !tenantStore.tenantId) { return { path: /error, query: { code: NO_TENANT } } } return true })路由守卫里要注意性能不要每次跳转都发请求去查租户信息那是后端接口该干的事。前端的职责是“快速判断能放行就放行”真正到后端接口时租户不合法自然会被拦截并返回错误码。前端把这一层做得太重只会拖慢首屏速度。3.4 租户端视觉与交互组件封装多租户 SaaS 的前端还有一块容易被忽略套餐状态、配额剩余这些信息最好在前台页面里自然展示出来而不是等接口报错。我在项目里封装了两个高频组件。一个是PackageCard.vue用来在套餐选择页展示不同套餐的对比另一个是QuotaProgress.vue用来在租户端页面顶部展示“用户数 28/50”“存储空间 6.2GB/10GB”这类信息。配额条一旦超过 80%组件内部会自动切换颜色并在文案里给出升级提示这对转化率和客户续费都有帮助。还有个小问题是移动端适配。Vue3 项目里很多人用postcss-pxtorem把 px 转 rem但实测下来 ECharts 这类图表库的样式经常不受控制。原因很简单图表是在 canvas 里绘制的rem 转换不会作用到 canvas 内部的像素大小。我处理的方式是rem 适配只用于布局和常规 DOM 元素图表尺寸用window.innerWidth动态计算或者用echarts.resize配合容器尺寸监听。别指望一个 pxtorem 解决所有适配问题分类治理才是正解。4. 商业化套餐设计租户隔离之上如何做计费与配额多租户架构稳定之后SaaS 能不能赚钱拼的就是商业化设计。很多技术团队把套餐设计理解成“建个表存套餐名和价格”真上线后才发现配额扣减、套餐变更、到期处理、赠送额度这些东西远比想象中复杂。我这一章专门讲讲这一块怎么设计。4.1 套餐模型与核心表结构套餐设计的第一件事是把“套餐”和“订阅”这两个概念分清楚。套餐是静态的“商品”比如免费版、专业版、企业版订阅是某个租户当前生效的“购买记录”它关联了一个套餐但还包含到期时间、赠送额度、手动调整项这些动态数据。我常用的核心表结构如下字段上按实际业务可以增删但主干就这些-- 套餐表 CREATE TABLE sys_package ( id BIGSERIAL PRIMARY KEY, code VARCHAR(50) NOT NULL UNIQUE, -- 套餐编码free / pro / enterprise name VARCHAR(100) NOT NULL, price_monthly NUMERIC(10,2) DEFAULT 0, -- 月付价格 price_yearly NUMERIC(10,2) DEFAULT 0, -- 年付价格 max_users INTEGER NOT NULL DEFAULT 0, -- 用户数上限 max_storage_gb INTEGER NOT NULL DEFAULT 0, -- 存储空间上限(GB) max_projects INTEGER NOT NULL DEFAULT 0, -- 项目数上限 api_quota_monthly INTEGER NOT NULL DEFAULT 0, -- 每月 API 调用额度 is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- 租户订阅表 CREATE TABLE sys_tenant_subscription ( id BIGSERIAL PRIMARY KEY, tenant_id VARCHAR(50) NOT NULL, package_id BIGINT NOT NULL REFERENCES sys_package(id), status VARCHAR(20) NOT NULL DEFAULT active, -- active / expired / suspended started_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, extra_users INTEGER NOT NULL DEFAULT 0, -- 增购用户数 extra_storage_gb INTEGER NOT NULL DEFAULT 0, -- 增购存储 remark TEXT );我在早期设计里犯过一个错误把配额字段直接写在订阅表上每次套餐改价改额度时都要同步历史订阅数据混乱得要命。后来改成订阅表只存套餐 ID 和增购额度配额计算统一通过视图或者服务层聚合这样套餐调整只影响新订阅历史订阅的基本盘不会被动财务对账也清爽很多。4.2 配额校验拦截器还是切面有了套餐模型接下来要解决的是配额校验的时机问题。我的做法是分两层接口层用拦截器做“粗校验”适合用户数、项目数这类简单限制业务层用 Spring AOP 做“细校验”适合 API 调用次数这种跟业务动作强相关的计量。下面是一个用注解加切面实现 API 配额校验的简化示例Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireQuota { String type() default api; // 配额类型api / storage / users / projects int amount() default 1; // 每次调用消耗的配额数 }接着在切面里统一处理。我这里只做最简单的查询当前租户剩余配额并校验实际项目里还要加 Redis 计数器的原子扣减避免并发场景下超额使用Aspect Component public class QuotaAspect { Around(annotation(quota)) public Object checkQuota(ProceedingJoinPoint joinPoint, RequireQuota quota) throws Throwable { String tenantId TenantContext.getTenantId(); QuotaResult result quotaService.getRemainingQuota(tenantId, quota.type()); if (result.getRemaining() quota.amount()) { throw new QuotaExceededException(配额不足当前剩余: result.getRemaining()); } try { return joinPoint.proceed(); } finally { // 调用成功后才扣减失败不扣 quotaService.consumeQuota(tenantId, quota.type(), quota.amount()); } } }这里有个细节值得强调配额消耗最好用异步或者延迟队列来记账不要在业务接口的同步链路里频繁 UPDATE 计数表。我见过一个团队把配额扣减直接写进主业务事务里结果高峰期数据库锁等待直接拖垮了接口性能。我的方案是在 Redis 里做原子自增计数定时任务再批量把 Redis 的计量结果同步到 PostgreSQL既保证了实时性又不会给数据库增加压力。4.3 套餐变更、到期降级与数据保留策略套餐变更这件事最容易出问题的不是代码而是业务规则定义。你在设计阶段就要跟产品明确几个问题的答案升级是立即生效还是下个周期生效降级呢到期没续费数据是冻结还是删除冻结期间用户能不能登录我的默认策略是这样升级立即生效按剩余天数比例补差价降级下个计费周期生效当前周期不受影响到期后进入 15 天的宽限期宽限期内只读不写过了宽限期自动降级到免费版数据保留但部分功能不可用。这样的好处是客户体验相对平滑也不会因为立即降级导致数据写入中断的投诉。技术上到期处理可以用定时任务扫描订阅表把 status 从 active 改成 expired然后给前端返回一个subscription: expired的状态码前端据此切换只读模式Scheduled(cron 0 5 0 * * ?) // 每天 00:05 执行 public void processExpiredSubscriptions() { ListSubscription expiredList subscriptionMapper.findExpiredActive(); for (Subscription sub : expiredList) { subscriptionService.downgradeToFree(sub); notificationService.notifyTenantAdmin(sub.getTenantId(), 订阅已到期); } }4.4 多租户视角下的活动、赠送与自定义配额商业化套餐如果只做到“固定套餐卖多少钱”其实还停留在最基础的水平。企业级 SaaS 客户经常会有这类需求买了企业版 100 个用户但项目上线期需要临时扩容 20 个用户或者谈年度合同时希望赠送 500GB 存储空间。为了支撑这类诉求我在订阅表里加了extra_users和extra_storage_gb两个增购字段。套餐本身的下限由sys_package决定最终可用的上限等于套餐配额加上增购配额。这样销售在 CRM 里谈好的附加条款能在后台配置界面直接落库不需要改代码。多租户模式下配额统计天然以租户维度聚合。比如跑一张报表看每个租户的用量与套餐上限的占比用一条 SQL 就能拉出来SELECT s.tenant_id, p.name AS package_name, u.user_count AS current_users, p.max_users s.extra_users AS allowed_users, ROUND((u.user_count * 100.0) / NULLIF(p.max_users s.extra_users, 0), 2) AS usage_percent FROM sys_tenant_subscription s JOIN sys_package p ON s.package_id p.id LEFT JOIN ( SELECT tenant_id, COUNT(*) AS user_count FROM tenant_user_metrics GROUP BY tenant_id ) u ON u.tenant_id s.tenant_id;实际运营中我还发现一个现象客户对“配额剩余 %”的关注度远大于价格本身。所以商业化后台里需要让客户随时看到自己用了多少、还剩多少这个信息越透明客户越倾向在配额耗尽前主动升级或买增购。5. 实战中容易踩的坑与排查实录最后这部分是我最想讲的因为方案文档写得再漂亮生产环境里遇到的问题才是真正的老师。我把这一年多来在这套 Spring Boot 4 Vue3 多租户 SaaS 上踩过的坑做个速查清单每一个都是真金白银堆出来的。5.1 连接池复用导致串租户数据现象租户 A 的请求偶尔能查到租户 B 的数据没有任何规律时好时坏。排查过程这类问题最吓人因为它是随机的而且一旦出现就是事故级别。我当时第一反应是去查 MyBatis 的 SQL 是不是漏了租户条件查了一圈没发现问题后来才把怀疑点放到连接池上。我们在 Filter 里通过SET search_path切换 schema但连接池的getConnection()返回的是物理连接的代理对象如果代码里没有在每次获取连接时重置 search_path上一个请求的改动就会被下一个请求继承。解决方案我自己在TenantRoutingDataSource里重写了getConnection()拿到连接后强制执行SET search_path。另外还要保证 Filter 或拦截器里务必将 ThreadLocal 清干净避免连接被归还后还带着上一个租户的 schema。补充建议如果不想改数据源也可以用 MyBatis-Plus 的拦截器在每条 SQL 执行前动态拼接 schema 前缀比如把user拼成tenant_a.user但那样 SQL 可读性会差很多而且很容易漏。能改数据源层就尽量改数据源层一劳永逸。5.2 Redis 缓存里的跨租户脏数据现象租户 A 修改了产品分类名称租户 B 的页面上分类也跟着变了。排查过程原因是缓存 key 没有带租户维度。起初我以为大家都会注意这点但实际生产中漏网之鱼比想象中多。尤其是公用的字典表、配置表开发时很自然就会写category:list这种 key忘了加tenantId前缀。解决方案定一个硬性规范——多租户场景下所有缓存 key 必须以租户 ID 作为前缀例如tenant:acme_001:category:list。同时建议在封装的 Redis 工具类里强制校验 key 中必须包含租户上下文否则直接抛异常。这个规则一开始会觉得“管得太宽”但坚持下来能避免大量线上事故。5.3 慢查询与索引设计现象租户数量涨到 500 以后管理端的租户列表页越查越慢甚至出现锁等待。排查过程通过pg_stat_statements拉慢 SQL发现瓶颈在于订阅表对tenant_id和status条件的查询没有走索引全表扫描越来越吃力。另一个常见问题是跨租户统计时把所有 schema 的数据聚在一起导致 SQL 复杂度和 IO 压力一起涨。解决方案给sys_tenant_subscription的tenant_id、status字段建联合索引把跨租户的聚合统计抽成定时任务把结果提前算好落到汇总表里查询时直接读汇总表。多租户系统还有一个天然优势每个租户 schema 的数据量都不大只要路由正确单租户业务查询几乎不会慢。慢通常都慢在平台端的跨租户运营报表上建议能异步就异步、能预聚合就预聚合。5.4 Spring Boot 4 升级后的启动失败与配置问题现象从 Spring Boot 3 升到 4 后项目启动直接报错日志里一堆Could not resolve placeholder或者ClassNotFoundException: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration。排查过程Spring Boot 4 改了不少自动配置的包路径和配置绑定策略。我之前在网上搜到的大部分资料还停留在 2.x/3.x照搬后踩了不少坑。解决方案升级时不要只看版本号要重点关注官方发布说明列出的 Breaking Changes。具体到我们项目主要是三个修改点一是把自定义的DataSourceAutoConfiguration排除路径改成新包名二是检查application.yml里的配置项是否符合 Spring Boot 4 新的 Configuration Properties 约束三是如果自定义了 Jackson 的ObjectMapper用JsonMapper.builder()的新链式写法避免直接依赖具体的 Builder 内部类。5.5 前端多租户白屏与路由跳转问题现象用户从平台端跳转到租户端偶尔出现白屏刷新后恢复正常。排查过程这类问题多半是 Pinia 状态在路由切换后丢失或者路由懒加载的组件在打包后被浏览器缓存住导致加载异常。多租户场景下还有一个特有原因切换租户时旧的租户上下文还残留在内存里部分组件的动态数据没能正确清空。解决方案在全局路由守卫里离开租户端路由前调用一次清理逻辑把租户相关 store 数据重置。同时给动态导入的组件路径加上版本号参数避免浏览器缓存旧 chunk。另外排查时打开浏览器的 Network 面板如果看到加载路由 chunk 返回 404那基本就是部署时没有正确配置 history 模式的 fallback。下面是这段时间整理出来的问题速查表方便你遇到类似情况时快速定位问题现象可能原因快速处置建议请求偶尔查到其他租户数据连接池连接复用search_path 未重置重写动态数据源 getConnection强制切换 schema平台端页面越查越慢订阅表缺少索引 / 跨租户聚合查询太重加联合索引指标预聚合到汇总表缓存数据串租户缓存 key 缺少租户维度缓存 key 统一加租户前缀工具类强制校验升级 Spring Boot 4 启动失败自动配置类路径变化或配置项不再兼容检查官方 Breaking Changes更新包名和配置前端多租户切租户后白屏Pinia 状态残留 / 懒加载 chunk 缓存路由守卫清理状态加版本号防缓存新租户创建后业务表缺失Flyway 租户脚本未执行检查初始化流程必须显式触发 migrate这些问题的共同点在于单独看每个环节似乎都合理但组合在一起就容易出边界问题。多租户系统的调试思路尤其要有整体性——看到数据串了不要只查 SQL看看连接层看到页面白屏不要只查代码看看状态管理和路由设计。最后再分享一个小技巧。如果你打算在公司内部推广这套共享 Schema 的多租户架构一定要先准备一个“租户生命周期管理清单”租户注册时创建 schema、执行 Flyway、初始化套餐订阅、发送欢迎通知。租户使用中监控配额使用情况、跟踪关键业务表的数据量增长。租户到期时进入宽限期、只读模式、自动降级。租户彻底销毁时备份 schema、归档数据、回收资源。这四个阶段每一个都要有对应的自动化脚本和监控告警。我在实际运维中发现把“租户开通”从人工操作变成自动化流程整个平台的交付效率能提升好几倍而且基本消除了“手动误操作导致新租户缺表”这类低级故障。这比任何花哨的架构都更能直接提升客户满意度也让我自己省下了大量半夜救火的时间。
返回列表