
一、前言在之前的系列文章中我写了 LikeShop 多商户版的架构、分账对接和权限体系。这一篇聚焦单商户 SaaS 版聊一个在多开场景下最核心的技术问题数据隔离。先厘清一个容易混淆的概念多商户版和 SaaS 版不是一回事。多商户版是“一个平台多个商家入驻”用户在一个商城里可以跨店购物资金走平台统一结算。SaaS 版是“一套系统多个独立租户”每个租户拥有自己独立的商城、独立的会员体系、独立的订单数据彼此之间完全隔离。前者是 B2B2C 平台后者是“商城即服务”。SaaS 版的核心诉求是“无限多开”——一套代码部署一次可以给多个客户开通独立的商城账号每个客户用自己的域名或子域名访问自己的商城后台。这带来的技术挑战是如何在一套代码、一个数据库实例中保证多个租户的数据严格隔离同时让每个租户拥有独立的后台体验。这篇文章就从租户识别、数据隔离、独立后台和配置隔离四个层面把 SaaS 版二开的核心要点拆开讲清楚。二、SaaS 版与多商户版的本质差异在动手二开之前先把两个版本的差异理清楚避免用错方案。维度多商户版SaaS 版业务模型一个平台多个商家入驻一套系统多个独立租户用户归属用户是平台级的可跨店购物用户是租户级的只能在所属租户商城购物数据隔离按shop_id隔离商家数据按tenant_id隔离租户数据后台体系平台后台 商家后台平台运营后台 租户独立后台资金结算平台统一收款分账给商家每个租户独立收款平台只收 SaaS 服务费域名平台一个域名商家共用每个租户可绑定独立域名或子域名关键区别在于“用户归属”。多商户版的用户是全平台通用的一个用户可以在多个商家下单SaaS 版的用户是租户私有的A 租户的会员在 B 租户的商城里不存在。这意味着 SaaS 版的隔离粒度比多商户版更细——多商户版隔离的是“商家数据”SaaS 版隔离的是“整个商城的数据”。三、无限多开的技术实现租户识别“无限多开”的第一步是识别当前请求属于哪个租户。没有租户识别后面的数据隔离和独立后台都无从谈起。3.1 租户识别的三种方式SaaS 版通常支持三种租户识别方式按优先级从高到低方式一独立域名识别。每个租户绑定自己的域名比如shop-a.com对应租户 Ashop-b.com对应租户 B。系统根据请求的Host头识别租户。方式二子域名识别。平台提供一个主域名租户使用子域名访问比如a.platform.com对应租户 Ab.platform.com对应租户 B。系统根据子域名前缀识别租户。方式三请求参数识别。在 URL 或 Header 中携带租户标识比如?tenant_id1或X-Tenant-Id: 1。这种方式一般用于 API 调试或内部调用。3.2 租户识别中间件的实现在 LikeShop 的 ThinkPHP 架构中租户识别应该封装在中间件中在请求进入 Controller 之前完成租户解析。// server/app/common/middleware/TenantMiddleware.phpclassTenantMiddleware{publicfunctionhandle($request,Closure$next){// 1. 优先从域名识别$host$request-host();$tenantTenantModel::where(domain,$host)-where(status,1)-find();// 2. 域名未匹配尝试从子域名识别if(!$tenant){$subdomainexplode(.,$host)[0]??;if($subdomain$subdomain!www){$tenantTenantModel::where(subdomain,$subdomain)-where(status,1)-find();}}// 3. 仍未匹配从请求参数识别仅用于调试if(!$tenant){$tenantId$request-param(tenant_id,0);if($tenantId){$tenantTenantModel::where(id,$tenantId)-where(status,1)-find();}}// 4. 租户不存在或已禁用直接拦截if(!$tenant){returnjson([code0,msg租户不存在或已停用]);}// 5. 将租户信息注入到请求上下文$request-tenantId$tenant[id];$request-tenantInfo$tenant;return$next($request);}}关键设计点租户识别必须在所有业务逻辑之前完成识别结果注入到请求上下文中后续的 Model 查询和 Logic 处理都从上下文中读取tenant_id而不是从请求参数中获取。这样可以避免前端篡改tenant_id导致的越权访问。3.3 租户表的字段设计租户表是 SaaS 版的核心表存储每个租户的基本信息字段名数据类型说明idint租户idnamevarchar租户名称domainvarchar独立域名subdomainvarchar子域名前缀statustinyint状态0-停用1-启用expire_timeint到期时间db_namevarchar独立数据库名如果采用多库方案create_timeint创建时间四、数据隔离单库多租户 vs 多库多租户数据隔离是 SaaS 版二开最核心的决策点。有两种主流方案各有取舍。4.1 单库多租户共享数据库共享表所有租户的数据存在同一个数据库的同一张表中通过tenant_id字段区分。优点维护成本低一套表结构服务所有租户跨租户统计方便平台可以快速汇总所有租户的经营数据。缺点数据隔离依赖代码约束一旦某处查询漏了tenant_id就会导致数据泄露单表数据量随租户数量增长需要分库分表。适用场景租户数量中等几十到几百对数据隔离要求不是极致严格。4.2 多库多租户独立数据库每个租户拥有独立的数据库数据完全物理隔离。优点隔离性最强租户之间互不影响单租户数据量可控便于按租户备份和迁移。缺点维护成本高新增租户需要初始化一套表结构跨租户统计需要汇总多个数据库数据库连接数随租户数量增长。适用场景租户数量较少但数据敏感度高或租户对数据隔离有强合规要求。4.3 LikeShop SaaS 版的隔离方案LikeShop 单商户 SaaS 版采用的是单库多租户 租户字段隔离的方案。所有核心表都增加tenant_id字段查询时自动带上租户过滤条件。这个方案的核心实现思路是在 Model 基类中统一注入tenant_id条件而不是在每个业务查询中手动拼接。// server/app/common/model/BaseTenantModel.phpabstractclassBaseTenantModelextendsBaseModel{/** * 全局查询条件自动注入 tenant_id */protectedstaticfunctionbase($query){$tenantIdrequest()-tenantId??0;if($tenantId){$query-where(tenant_id,$tenantId);}}/** * 新增数据时自动写入 tenant_id */publicstaticfunctioncreate($data){if(!isset($data[tenant_id])){$data[tenant_id]request()-tenantId??0;}returnparent::create($data);}}所有需要租户隔离的 Model 都继承BaseTenantModel这样查询和写入时都会自动带上tenant_id业务代码中不需要关心隔离逻辑。注意平台级的表如租户表、平台配置表不继承BaseTenantModel因为这些表是跨租户共享的。五、独立后台的实现SaaS 版的“独立后台”不是指每个租户部署一套后台代码而是同一套后台代码通过租户识别展示不同的数据。5.1 后台登录的租户隔离租户管理员登录时系统需要根据当前访问的域名识别租户然后校验该管理员是否属于这个租户。// server/app/adminapi/logic/LoginLogic.phpclassLoginLogic{publicstaticfunctionlogin(array$params):array{$tenantIdrequest()-tenantId;// 查询管理员必须属于当前租户$adminAdminModel::where(account,$params[account])-where(tenant_id,$tenantId)-find();if(!$admin){thrownewException(账号或密码错误);}// 校验密码if(!password_verify($params[password],$admin[password])){thrownewException(账号或密码错误);}// 生成 token写入会话$tokenself::createToken($admin[id],$tenantId);return[token$token];}}关键约束管理员的account可以在不同租户中重复。租户 A 有一个admin账号租户 B 也可以有一个admin账号登录时通过tenant_id区分。这意味着account字段不能做全局唯一索引唯一索引应该是(tenant_id, account)的组合。5.2 后台菜单的租户隔离租户后台的菜单权限体系需要按租户隔离。每个租户可以自定义自己的角色和权限A 租户的角色配置不影响 B 租户。菜单表、角色表、管理员表都需要增加tenant_id字段。租户管理员登录后只能看到自己租户的菜单和角色。5.3 后台配置的租户隔离每个租户的商城配置是独立的包括店铺名称、Logo、支付配置、短信配置、存储配置等。这些配置存储在配置表中通过tenant_id隔离。// 读取配置时自动带上租户条件classConfigService{publicstaticfunctionget(string$key,$defaultnull){$tenantIdrequest()-tenantId??0;$valueConfigModel::where(key,$key)-where(tenant_id,$tenantId)-value(value);return$value??$default;}}六、二开实战新增一个租户隔离的业务模块假设要新增一个“租户公告”模块每个租户可以发布自己的公告公告只在所属租户的商城中展示。6.1 创建数据表CREATETABLEls_tenant_notice(idint(11)NOTNULLAUTO_INCREMENT,tenant_idint(11)NOTNULLDEFAULT0COMMENT租户id,titlevarchar(255)NOTNULLDEFAULTCOMMENT公告标题,contenttextCOMMENT公告内容,statustinyint(1)NOTNULLDEFAULT1COMMENT状态0-禁用1-启用,create_timeint(11)NOTNULLDEFAULT0,update_timeint(11)NOTNULLDEFAULT0,delete_timeint(11)NOTNULLDEFAULT0,PRIMARYKEY(id),KEYidx_tenant(tenant_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT租户公告表;6.2 创建 Model// server/app/common/model/TenantNotice.phpclassTenantNoticeextendsBaseTenantModel{protected$nametenant_notice;protected$autoWriteTimestamptrue;}继承BaseTenantModel后查询和写入都会自动带上tenant_id不需要在业务代码中手动处理。6.3 创建 Logic// server/app/shopapi/logic/TenantNoticeLogic.phpclassTenantNoticeLogic{publicstaticfunctiongetLists(array$params):array{$pageNo$params[page_no]??1;$pageSize$params[page_size]??15;$offset($pageNo-1)*$pageSize;$listsTenantNotice::where(status,1)-where(delete_time,0)-order(create_time,desc)-limit($offset,$pageSize)-select()-toArray();$countTenantNotice::where(status,1)-where(delete_time,0)-count();return[lists$lists,count$count,page_no$pageNo,page_size$pageSize,];}}注意这里的查询没有手动加tenant_id因为BaseTenantModel的base()方法已经自动注入了。这就是统一隔离机制的价值——业务代码不需要关心隔离逻辑只需要关注业务本身。6.4 创建 Controller// server/app/shopapi/controller/TenantNoticeController.phpclassTenantNoticeControllerextendsBaseLikeShopController{publicfunctionlists(){$params$this-request-get();$resultTenantNoticeLogic::getLists($params);return$this-success(获取成功,$result);}}七、二开避坑清单坑一漏加tenant_id导致数据泄露。这是 SaaS 版最严重的业务风险。如果新增的 Model 没有继承BaseTenantModel或者查询时手动写了Db::name()而不是通过 Model就会绕过租户隔离。坑二租户识别中间件没有全局注册。如果中间件只在部分路由生效某些接口的request()-tenantId为空会导致查询到所有租户的数据。坑三管理员账号的唯一索引没有加上tenant_id。如果account字段做了全局唯一索引租户 B 就无法创建和租户 A 同名的管理员账号。坑四平台级配置被租户级配置覆盖。如果配置读取没有区分“平台级”和“租户级”租户可能修改到平台级的配置影响所有租户。坑五租户到期后没有拦截。租户的expire_time到期后中间件应该拦截该租户的所有请求而不是继续提供服务。坑六跨租户统计时忘记绕过隔离。平台运营后台需要汇总所有租户的数据时需要显式绕过BaseTenantModel的自动隔离否则只能看到当前租户的数据。八、总结LikeShop 单商户 SaaS 版二开的核心可以概括为四条线租户识别通过域名、子域名或请求参数识别当前请求属于哪个租户在中间件中完成结果注入请求上下文。数据隔离采用单库多租户方案所有核心表增加tenant_id字段。通过BaseTenantModel基类统一注入隔离条件业务代码不关心隔离逻辑。独立后台同一套后台代码通过租户识别展示不同数据。管理员账号、菜单权限、商城配置都按tenant_id隔离。配置隔离平台级配置和租户级配置分开管理租户只能修改自己的配置。二开时守住三条底线新增 Model 必须继承租户基类、租户识别中间件必须全局生效、管理员账号唯一索引必须包含 tenant_id。把这三点处理好SaaS 版的多开隔离就能稳定运行。