ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多租户系统怎么设计?一个数据库还是多个数据库

多租户系统怎么设计?一个数据库还是多个数据库 多租户系统最容易被问到的问题是一个数据库还是多个数据库这个问题没有固定答案。小团队做内部多公司管理、SaaS 产品服务几十家客户、集团系统给多个子公司使用、政企项目要求物理隔离它们的答案都不一样。真正要先判断的是租户之间需要隔离到什么程度数据量和运维能力能支撑哪种方案后续是否需要把大客户迁移到独立库。很多系统一开始只是在用户表里加一个tenant_id列表页看起来也能按租户过滤。但真正上线后详情、导出、附件、缓存、定时任务、消息通知、后台运维脚本都可能绕过租户条件。多租户的风险不在“有没有字段”而在“所有读写入口是否都能稳定带上租户边界”。本文用一个通用场景做示例三个客户共用同一套 SaaS 系统。A 公司、B 公司、C 公司都能管理自己的业务数据A 公司的管理员不能看到 B 公司的数据平台管理员可以做运维但不能随意修改客户业务数据后续如果 B 公司数据量变大需要有迁移到独立库的可能。示例环境Java 17、Spring Boot 风格服务层、MySQL 8.x。本文不会堆大量代码重点讲清租户隔离方案、表结构、查询边界、缓存和运维验证。目录先分清租户隔离的三种方案输入案例三个客户共用一套SaaStenant_id不是只加在业务表上MySQL表结构租户、用户和业务数据实现骨架租户上下文和查询条件缓存、附件、定时任务也要带租户SQL验证上线后查跨租户风险异常边界和上线验收小结和延伸阅读一、先分清租户隔离的三种方案常见多租户方案可以粗略分成三类。方案做法优点风险共享库共享表所有租户共用一套表用tenant_id隔离成本低开发和运维简单所有查询都必须带租户条件漏一次就是事故共享库分表或分schema同一个数据库实例不同租户用不同表或 schema隔离更强部分大租户可拆分迁移、统计、升级脚本更复杂独立库每个租户一个数据库隔离最强备份恢复清晰运维成本高版本升级和连接管理复杂大多数早期 SaaS 或内部平台会从共享库共享表开始。它不是不安全而是要求工程纪律足够强所有业务表有tenant_id所有查询和写入有租户上下文所有缓存 key 带租户所有导出和后台任务复用同一套租户边界。独立库也不是银弹。它可以降低跨租户查询风险但会增加数据库数量、连接池、迁移脚本、报表统计、版本升级和故障排查成本。很多团队技术上能做运维上扛不住。更实际的做法通常是默认共享库共享表给大客户或强隔离客户预留独立库迁移通道。这要求系统一开始就不要把租户逻辑散落在页面和 SQL 字符串里。图1共享表、分schema和独立库各有成本先判断隔离要求再选方案。二、输入案例三个客户共用一套SaaS本文固定一组输入用来贯穿后面的模型和验证。租户Atenant_id 1001华东客户 租户Btenant_id 1002华南客户 租户Ctenant_id 1003西南客户 普通用户只能访问自己所属租户 租户管理员只能管理自己租户的用户和业务数据 平台管理员可以查看租户运行状态但默认不能修改客户业务数据 业务对象产品资料、客户资料、合同、附件、操作日志 风险场景 列表带 tenant_id详情忘记带 tenant_id 导出接口绕过 DataScope 缓存 key 只用业务ID没有租户前缀 定时任务扫描全表后误处理其他租户数据这个案例要解决的不是某张业务表怎么建而是所有入口如何保持同一条边界租户A的数据只能被租户A上下文访问。很多跨租户问题不是列表页造成的而是详情接口、导出接口、附件下载、消息发送、后台任务造成的。因为这些地方容易被认为“不是页面查询”于是漏掉租户条件。三、tenant_id不是只加在业务表上tenant_id是租户隔离的基础但不是全部。第一业务主表要有tenant_id。产品、客户、合同、附件、审批实例、消息、操作日志等只要属于某个客户就要能按租户过滤。第二关联表也要考虑租户。比如客户联系人、合同明细、附件表如果只存customer_id或contract_id理论上可以通过主表关联过滤但实际查询和排查会更麻烦。高频表建议冗余tenant_id换取更清晰的边界和索引。第三唯一键要带租户。产品编码P-001在 A 公司和 B 公司可能都存在唯一键应是(tenant_id, product_code)不能只对product_code做全局唯一。第四平台级数据和租户级数据要分开。菜单、系统参数、套餐、租户信息属于平台级客户、合同、产品、工单属于租户级。不要把所有表都机械加tenant_id也不要让租户数据混进平台配置。第五审计日志也要有租户。出了问题时必须能按租户追踪谁查了什么、导出了什么、修改了什么。图2业务表、关联表、日志、附件和导出任务都要纳入租户边界。四、MySQL表结构租户、用户和业务数据下面是一组简化表结构用共享库共享表说明多租户边界。CREATETABLEsys_tenant(idBIGINTPRIMARYKEY,tenant_codeVARCHAR(64)NOTNULL,tenant_nameVARCHAR(120)NOTNULL,statusVARCHAR(32)NOTNULL,isolation_modeVARCHAR(32)NOTNULL,create_timeDATETIMENOTNULL,UNIQUEKEYuk_tenant_code(tenant_code),CHECK(statusIN(ACTIVE,SUSPENDED,CLOSED)),CHECK(isolation_modeIN(SHARED_TABLE,DEDICATED_DB)));CREATETABLEsys_tenant_user(idBIGINTPRIMARYKEYAUTO_INCREMENT,tenant_idBIGINTNOTNULL,user_idBIGINTNOTNULL,tenant_roleVARCHAR(64)NOTNULL,statusVARCHAR(32)NOTNULL,create_timeDATETIMENOTNULL,UNIQUEKEYuk_tenant_user(tenant_id,user_id),KEYidx_tenant_user_user(user_id,status));CREATETABLEbiz_product(idBIGINTPRIMARYKEYAUTO_INCREMENT,tenant_idBIGINTNOTNULL,product_codeVARCHAR(64)NOTNULL,product_nameVARCHAR(160)NOTNULL,category_codeVARCHAR(64)NULL,statusVARCHAR(32)NOTNULL,create_byBIGINTNOTNULL,create_timeDATETIMENOTNULL,update_byBIGINTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_product_code_tenant(tenant_id,product_code),KEYidx_product_tenant_status(tenant_id,status));CREATETABLEbiz_attachment(idBIGINTPRIMARYKEYAUTO_INCREMENT,tenant_idBIGINTNOTNULL,biz_typeVARCHAR(64)NOTNULL,biz_idBIGINTNOTNULL,storage_keyVARCHAR(255)NOTNULL,original_nameVARCHAR(255)NOTNULL,statusVARCHAR(32)NOTNULL,create_timeDATETIMENOTNULL,KEYidx_attachment_tenant_biz(tenant_id,biz_type,biz_id));这里有几个要点。sys_tenant.isolation_mode用来为未来扩展留口。大多数租户可以走共享表大客户可以迁移到独立库。即使早期不做独立库也可以先把这个概念保留下来。sys_tenant_user表示用户和租户的关系。一个平台账号可能属于多个租户也可能同时是 A 公司的管理员和 B 公司的普通成员。登录后必须选择当前租户上下文。业务唯一键要带tenant_id。这会影响导入、校验、接口查询和数据迁移。不要为了省事把编码做成全局唯一否则不同客户之间会互相影响。附件也要带tenant_id。否则下载接口只按附件ID查很容易出现知道ID就跨租户下载的问题。图3共享表方案下tenant_id 要进入业务唯一键、查询索引和附件关系。五、实现骨架租户上下文和查询条件多租户实现不要靠每个开发手工记得写tenant_id ?。更稳的方式是把租户上下文放进统一入口再让 repository 或 SQL 拦截层复用。publicfinalclassTenantContext{privatestaticfinalThreadLocalLongCURRENTnewThreadLocal();publicstaticvoidsetTenantId(LongtenantId){CURRENT.set(tenantId);}publicstaticLongrequireTenantId(){LongtenantIdCURRENT.get();if(tenantIdnull){thrownewServiceException(缺少租户上下文);}returntenantId;}publicstaticvoidclear(){CURRENT.remove();}}业务查询统一读取租户上下文。publicProductgetProductByCode(StringproductCode){LongtenantIdTenantContext.requireTenantId();returnproductRepository.getByTenantAndCode(tenantId,productCode);}publicvoidcreateProduct(CreateProductCommandcommand){LongtenantIdTenantContext.requireTenantId();if(productRepository.existsByTenantAndCode(tenantId,command.productCode())){thrownewServiceException(当前租户下产品编码已存在);}productRepository.insert(Product.create(tenantId,command));}代码不复杂难点在于入口一致。HTTP 请求要从登录态或 token 中解析当前租户异步任务要显式传入租户消息消费要从消息体读取租户平台管理员执行运维操作时要明确选择目标租户并记录审计日志。如果使用 MyBatis、JPA 或数据权限插件可以做自动注入但也不能完全依赖插件。导出 SQL、手写 SQL、批处理脚本、附件下载等地方仍要做专项检查。六、缓存、附件、定时任务也要带租户多租户最容易漏的地方往往不是普通 CRUD。缓存 key 要带租户错误product:P-001 正确tenant:1001:product:P-001如果缓存 key 不带租户A 公司查过P-001后B 公司再查P-001可能拿到 A 公司的缓存。附件路径要带租户tenant-1001/product/2026/09/file.pdf tenant-1002/product/2026/09/file.pdf即使下载接口做了权限校验存储路径也应该带租户前缀方便迁移、清理和排查。定时任务要按租户循环for each active tenant: set TenantContext process expired contracts clear TenantContext不要让后台任务直接扫全表处理业务数据。即使 SQL 写了条件也要让任务日志能看出本次处理的是哪个租户。消息通知、导出任务、操作日志也要带租户。否则用户收到不属于自己的通知或者导出文件混入其他租户数据影响会非常严重。图4缓存 key、附件路径、定时任务和消息都要显式带租户。七、SQL验证上线后查跨租户风险多租户上线后要准备一些检查 SQL持续确认数据边界没有被破坏。检查业务表是否存在空租户SELECTCOUNT(*)AScntFROMbiz_productWHEREtenant_idISNULL;预期结果0检查同一租户内产品编码是否重复SELECTtenant_id,product_code,COUNT(*)AScntFROMbiz_productGROUPBYtenant_id,product_codeHAVINGCOUNT(*)1;预期结果empty set检查附件是否能关联到同租户业务数据SELECTa.id,a.tenant_id,a.biz_id,p.tenant_idASproduct_tenant_idFROMbiz_attachment aLEFTJOINbiz_product pONp.ida.biz_idANDp.tenant_ida.tenant_idWHEREa.biz_typePRODUCTANDp.idISNULL;预期结果empty set检查平台管理员是否直接修改过租户业务数据SELECTtenant_id,operator_id,action_code,COUNT(*)AScntFROMbiz_operation_audit_logWHEREoperator_rolePLATFORM_ADMINANDbiz_typeIN(PRODUCT,CUSTOMER,CONTRACT)ANDcreate_timeDATE_SUB(NOW(),INTERVAL7DAY)GROUPBYtenant_id,operator_id,action_code;这条不一定要求为空但每条都应该有工单、授权或运维原因。如果平台管理员经常直接改客户业务数据说明权限边界不清。图5多租户验收要检查空租户、重复唯一键、附件关联和平台管理员操作。八、异常边界和上线验收多租户系统至少要明确这些边界。缺少租户上下文服务端应该直接拒绝而不是默认查询全部数据。没有租户上下文的业务请求就是高风险请求。平台管理员权限平台管理员可以做租户启停、套餐配置、运维查看但是否能修改客户业务数据要非常谨慎。建议默认只读特殊操作走审批或运维工单。租户停用租户停用后普通用户不能继续访问业务数据后台任务也不能继续处理该租户业务。历史数据是否保留要按合同和合规要求处理。大租户迁移从共享表迁移到独立库时要考虑业务表、附件、日志、导出任务、消息和缓存。只迁移主业务表会留下很多尾巴。跨租户报表平台级统计可以跨租户但要使用专门的只读报表权限和脱敏策略。不要让普通租户接口承担平台报表职责。上线验收可以按下面清单执行所有租户级业务表都有tenant_id。业务唯一键包含tenant_id。列表、详情、导出、附件下载都按同一租户上下文过滤。缓存 key 带租户前缀。定时任务按租户循环并记录处理租户。消息通知和操作日志带租户。缺少租户上下文的请求会被拒绝。平台管理员操作租户业务数据有审计。租户停用后普通用户无法继续访问。SQL验证没有空租户、跨租户附件和异常平台操作。九、小结和延伸阅读多租户设计不是简单回答“一个库还是多个库”。共享库共享表、分 schema、独立库都是工具真正决定系统是否可靠的是租户边界有没有贯穿所有入口。如果团队还在早期默认共享表可以降低成本但必须把tenant_id、唯一键、索引、缓存、附件、任务和日志一起设计。等大客户或强隔离需求出现再把部分租户迁移到独立库。这样既不一开始过度运维也不把未来迁移通道堵死。延伸阅读MicrosoftMultitenant SaaS database tenancy patternsSpring Framework声明式事务管理MySQL 8.4CREATE TABLE
返回列表