简介:这是一套面向集团级企业与SaaS运营方的多租户办公自动化系统,支持各入驻公司独立配置个性化流程、人员权限、角色菜单及单据申请信息,实现从组织管理到审批流转的全流程数字化。系统基于点狮后台管理平台构建,可无缝集成HRM、CRM、ERP、IM等模块,并可作为APP与小程序的统一后台。内置Flowable流程引擎,支持并行、串行、会签、回退、取回,同时提供转办、委托、抄送等任务协作能力。资源包共2000个文件,压缩包37.61MB,涵盖850个HTML页面、485个Java后端类、414个JS脚本以及必要的CSS、SQL、XML等配置,前后端代码与设计资源齐全,适合二次开发与流程定制学习。目前已有77人学习,适合需要快速搭建多租户OA系统或研究工作流引擎集成的Java开发人员。
1. 多租户OA看着是权限问题,实际是数据边界问题
点狮OA这类多租户企业办公系统,标题里的关键信息其实就两个:既能给集团级企业自己用,又能打包成 SaaS 让外部公司入驻,而每家租户都要有自己独立的流程、人员、角色和单据申请信息。很多团队第一次接手这种项目,第一反应是把用户表加一个 tenant_id 字段就完事,结果跑到第三家租户入驻时就开始出乱子——A 公司的流程跑到 B 公司的待办里,角色权限互相串,单据模板被改得七零八落。这不是权限配置的锅,是数据边界从第一天就没设计对。
这篇文章要解决的就是三件事:多租户的数据隔离到底选哪种方案、租户自己的流程和表单怎么做到「可配置而不改代码」、以及人员和角色管理在租户维度下要怎么重新组织。适合正在做 SaaS 化改造、或者想把内部 OA 系统对外提供服务的开发团队读,偏后端架构和业务建模方向,前端交互只会在必要时提到。我默认你已经有一个能跑的基础 OA 或类似的业务系统,读这篇文章是来要方案和踩坑经验的,不是来从头学写增删改查的。
2. 多租户数据隔离的三种选型:先决定独立库、独立Schema还是共享表
2.1 三种隔离方案的成本边界与适用场景
多租户系统的地基是数据隔离方案,后边的所有功能都要在这套边界上长出来。常见的做法有三种:独立库、独立 Schema、共享表加 tenant_id 行级隔离。独立库是最老实的做法,每个租户一个数据库实例,物理隔离最干净,备份恢复互不影响,但资源成本高,一家租户一个库,一百家租户就要管理一百个库的连接池和备份任务,运维开销直线上升。独立 Schema 是在同一个数据库实例里为每个租户建一个 schema,逻辑隔离,数据不互通,管理比独立库轻一些,但迁移和跨租户统计仍然不省心。共享表加 tenant_id 是最主流的多租户 SaaS 做法,所有租户的数据在同一个物理表里,靠 tenant_id 区分,成本低、扩展容易,但隔离完全依赖代码层面的纪律。
我见过一个真实的踩坑案例:某团队做内部系统时先用共享表,后来要对外提供服务,为了给大客户承诺数据隔离,临时改成独立库,结果所有业务代码里的 SQL 都要跟着适配数据源切换,忙了三个多月才稳下来。所以选型要在项目起步阶段就定死,不要指望后期切换。衡量边界就三句话:租户数量级、客户对隔离的敏感度、以及你的运维人力。租户几十家、以大企业客户为主的,独立库或独立 Schema 更省心;租户几百上千家、以中小客户为主的,共享表几乎是唯一现实的选择。
2.2 共享表方案的库表设计与 tenant_id 强制注入
用共享表方案时,最核心的原则是:tenant_id 不能被业务代码自由传参,必须在数据访问层强制注入。这个强制注入怎么做,直接决定了这个系统能撑多久。基础的表设计很简单,每张业务表都带 tenant_id 字段,索引必须建组合索引,比如 (tenant_id, create_time) 或 (tenant_id, status),因为租户维度永远是查询的第一过滤条件。只建单列索引的话,一旦某个查询漏过滤了 tenant_id,全表扫描会拖垮所有租户的接口。
我一般会在数据访问层封装一个 TenantContext,用 ThreadLocal 保存当前登录用户的租户 ID,然后在框架的查询拦截器里自动拼接 tenant_id = ? 条件。以 MyBatis 为例,可以写一个拦截器对所有 SQL 做注解式处理:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = handler.getBoundSql(); String sql = boundSql.getSql(); // 只处理包含业务表特征的SQL,比如前缀 biz_ 的表 if (sql.contains("from biz_") || sql.contains("join biz_")) { Long tenantId = TenantContext.getTenantId(); if (tenantId != null && !sql.toLowerCase().contains("tenant_id")) { String newSql = injectTenantCondition(sql, tenantId); // 通过反射替换BoundSql中的sql Field field = BoundSql.class.getDeclaredField("sql"); field.setAccessible(true); field.set(boundSql, newSql); } } return invocation.proceed(); } }这段代码的逻辑不复杂:拦截 StatementHandler 准备阶段,检查 SQL 是否包含业务表,如果包含且当前上下文里有租户 ID,就在原 SQL 上拼接 tenant_id 过滤条件。重点在于三个参数的设计:一是排除系统表的判断,用表名前缀区分业务表和公共表;二是注入条件的位置,一定要在 where 之后拼接而不是随便插在语句中间;三是 TenantContext 的清理时机,必须在请求结束的 finally 块里清空,否则线程池复用会串租户。
但拦截器方案有一个先天短板:如果团队里有人写自定义 SQL 或者用存储过程绕过框架,拦截器就管不到了。所以我会同时做一个代码评审红线——所有 mapper 里的 SQL 必须显式带 tenant_id 条件,拦截器只是兜底不是依赖。这听起来有点双保险的笨,但多租户系统里「漏一次」的代价远大于「多写一遍」的成本。
2.3 租户开通与数据迁移的初始化流程
不管选哪种隔离方案,租户开通的初始化流程都要做成自动化脚本,否则每来一家新租户都要手工建库建表设权限,早晚出错。租户开通的标准步骤是:创建租户记录、初始化数据源或 Schema、执行建表脚本、插入默认角色和数据字典、创建超级管理员账号。以共享表方案为例,不需要建库建表,只需要往租户表插入一条记录,然后执行初始化租户配置的代码:
public void initTenant(Tenant tenant) { // 1. 插入租户基本信息 tenantMapper.insert(tenant); // 2. 创建租户默认角色 Role adminRole = Role.createDefault(tenant.getId(), "租户管理员"); roleMapper.insert(adminRole); // 3. 创建租户的数据字典配置 DictConfig config = DictConfig.createDefault(tenant.getId()); dictConfigMapper.insert(config); // 4. 创建超级管理员账号,初始密码强制修改 User adminUser = User.createAdmin(tenant.getId(), tenant.getAdminPhone()); userMapper.insert(adminUser); }这里的核心是初始化动作的原子性:四步操作必须放在一个事务里,任何一步失败都要整体回滚,并且要留初始化日志表,记录每步的执行结果和时间。租户ID的生成策略也要注意,不要用自增ID,因为有些客户会从单号里反推出业务量,用雪花ID或UUID更稳妥。
2.4 数据备份与恢复策略
共享表方案的运维责任比想象中重,因为所有租户的数据搅在一起,某个租户误删数据要恢复时,你不能直接恢复整个库。常见的补救做法是:每天做全量备份,同时业务表开启按 tenant_id 维度的定期快照,或者至少保证所有变更操作记录有审计日志和 binlog,才能在出问题时按租户维度回放。这个听起来像运维的事,但在架构设计阶段就要预留支持字段,比如所有业务表必须带 created_at 和 updated_at 时间戳,所有删除尽量用逻辑删除,否则真到了要恢复某个租户数据的那天,会发现在共享表里做定点恢复几乎是不可能的。
3. 租户自定义流程:把流程定义变成租户自己的元数据
3.1 为什么说流程个性化是OA系统SaaS化最难的一步
多租户OA里最核心的差异化能力,是每个租户能配置自己独有的审批流程。这个难点不在于工作流引擎本身,而在于「流程定义」从「全局模板」变成了「租户元数据」。传统OA系统的流程是工程师在代码里写死的或者后台全局配置的,而多租户场景下,A公司可能有多级审批加会签,B公司可能只需要一级审批,C公司还要在审批中动态加签。如果每家公司都靠改代码实现,系统根本撑不到第十个租户。
正确的做法是要有一套「流程定义元数据」体系:流程本身是一份租户维度的配置数据,包括流程节点、节点之间的流转关系、每个节点的审批人类型、以及节点上的表单字段约束。运行时引擎负责解释这份元数据,而不是执行业务代码。这句话听起来简单,但落地时最难的是——你要做的是一个微型流程引擎,而不是在现有系统里加一张流程配置表。
3.2 流程定义的数据模型与节点设计
流程定义的核心表结构需要覆盖四件事:流程基本信息、节点信息、节点间连线、每个节点的审批规则。流程基本信息表记录流程名称、所属租户、状态、版本号;节点表记录节点名称、节点类型(开始/审批/条件分支/结束)、审批人配置;连线表记录从哪个节点到哪个节点的流转关系。审批人配置至少要支持三种模式:指定角色、指定人员、发起人的上级,前两种是基础能力,第三种「发起人的上级」是OA系统的高频需求,很多租户的个性化差异就体现在这里。
一个流程定义至少需要这几张表:
CREATE TABLE flow_definition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL COMMENT '租户ID', flow_code VARCHAR(64) NOT NULL COMMENT '流程编码', flow_name VARCHAR(128) NOT NULL COMMENT '流程名称', version INT NOT NULL DEFAULT 1 COMMENT '版本号', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', created_by VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_tenant_flow_version (tenant_id, flow_code, version) ); CREATE TABLE flow_node ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, flow_id BIGINT NOT NULL COMMENT '流程定义ID', node_id VARCHAR(64) NOT NULL COMMENT '节点唯一标识,如start/approve1/end', node_name VARCHAR(128) NOT NULL, node_type VARCHAR(32) NOT NULL COMMENT 'start/approve/condition/end', approver_type VARCHAR(32) COMMENT 'role/user/initiator_manager', approver_value VARCHAR(255) COMMENT '角色ID或用户ID的JSON数组', sort_no INT NOT NULL, KEY idx_flow_id (flow_id) );这套模型的核心设计是 node_id 字符串标识而不是数字主键,目的是让连线表的配置更直观可读。比如 start 节点通过一条连线指向 approve1 节点,条件节点的连线上要配置条件表达式,这部分在第三小节展开。原理解释一句话:流程定义是用数据在描述业务流程,而不是用代码在描述——这个认知转变是整个模块的起点。
3.3 条件分支与动态审批人的配置实现
任何公司的审批流都绕不开条件分支,最常见的是「金额大于多少走总经理审批,小于多少走部门主管审批」。在流程引擎里这就是一个条件节点加两条出边。条件节点的配置数据通常长这样:
{ "nodeType": "condition", "conditions": [ { "id": "c1", "field": "totalAmount", "operator": ">=", "value": 5000, "targetNodeId": "approve2" }, { "id": "c2", "field": "totalAmount", "operator": "<", "value": 5000, "targetNodeId": "approve1" } ] }这份 JSON 存的是条件定义,运行时引擎比较表单提交的数据和条件表达式,取第一个命中的分支往下走。这里的参数关键是 operator 的语义:支持等于、不等于、大于、小于、包含、为空、不为空等基础操作符就够覆盖绝大多数租户需求,不要一上来就做复杂的组合条件,否则配置界面会难用到没人用。审批人类型里最常出问题的是「发起人的上级」,这要配合组织架构的汇报关系表来实现,而不是简单的角色匹配。我的做法是维护一张 user_report_line 表记录每个用户的上级,然后用递归查询拿到审批链,复杂度不高但必须保证组织架构变动时能同步更新汇报关系。
3.4 流程引擎设计的两个铁律
做流程引擎两个铁律是经验换来的。第一,状态机保存在流程实例表里,而不是流程定义表里。每个发起的新申请都会生成一个流程实例记录,实例上带着当前节点、流程定义版本和流转状态。这样做的好处是流程定义了新的版本之后,已经在途的审批单不受影响,继续按旧版本跑完。第二,审批操作要基于「任务的悲观锁」来做并发控制,同一个审批节点两个人同时操作时,只有先拿到锁的操作生效,否则会出现一个审批单被重复审批的状态错乱。这两条看着是基础,但很多从普通OA转过来的团队都会在这里翻车。
4. 租户维度下的人员、用户、角色管理:从树变成森林
4.1 组织架构和汇报关系在租户内的重建
传统OA的部门表是一棵树,根节点是公司本身。到了多租户场景,每一个租户都要维护一棵自己的组织树,这棵树在数据上的表现就是部门表多了 tenant_id 维度。另外一个隐藏的需求是集团级企业:集团下面有子公司,子公司有自己的组织架构,但也要能被集团统一管到。这个在数据模型上不复杂,只要允许部门节点挂多级父节点,或者引入「集团-公司-部门」三层结构就可以,麻烦的是权限边界——集团能看到所有子公司的数据,子公司只能看到自己的,这种跨租户的权限模型是在单租户OA里不存在的。
解决思路是引入「可见范围」的概念,比如给用户增加一个 data_scope 字段,取值是 ALL_COMPANY、SELF_COMPANY、SELF_DEPARTMENT 等。查询数据时先根据用户的 data_scope 拼出一个可见租户ID列表,再和租户隔离条件叠加。这个逻辑要放在公共查询层,否则每个业务模块都要单独实现一遍可见范围过滤,代码会重复到难以维护。
4.2 租户自定义角色的存储与授权边界
每个租户可以建自己的角色、给角色分配自己的菜单和按钮权限,这要求角色表、菜单表、角色菜单关联表全部带 tenant_id。但有一个容易忽略的坑:平台的公共功能和管理功能不能混在租户角色体系里,比如租户的创建、订单计费、平台公告这类功能应该属于平台管理员,而不是租户管理员。常见做法是把权限分为平台级和租户级两套,平台级角色写在独立表里,租户登录时只加载租户级权限,平台管理员登录时加载的是平台级权限加全部租户的数据可见权。
权限数据模型上要特别注意「角色隔离但权限码共享」的设计:租户创建的角色名字随便起,但底层权限码必须引用平台的统一权限码表。比如「审批通过」这个权限码在平台层面是 APPROVE_PASS,租户A的角色叫「总监」可以拥有它,租户B的角色叫「主管」也可以拥有它,但底层引用的权限码是同一个。这样才能避免后续做平台级运营管理时,面对几百个租户各自维护的权限体系数不清。
4.3 用户体系和租户间数据隔离的执行细节
用户表的隔离有两条路线:一条是 user 表带 tenant_id,所有租户用户在一个表里;另一条是每个租户有自己的 user 表或 schema。共享表方案下,user 表带 tenant_id 是自然的,但要特别注意两个问题:手机号唯一性约束不能做全局唯一,因为不同租户可能有同一个手机号注册;登录名也同理。正确做法是 (tenant_id, phone) 联合唯一索引,登录时先根据手机号查出这个手机号所属的租户列表,再让用户选择要登录的租户,或者租户域名直接定位。
这个细节在标题里看起来是「用户管理」,实际做起来牵扯到认证流程的改造。租户维度下,登录请求要先定位租户,再校验密码,再加载该租户下的角色权限。如果做错了顺序,比如先全局校验密码再按照租户过滤,一旦手机号在多个租户下存在,用户输入密码后就有可能被路由到错误的租户上下文里。我见过一个真实案例,某系统的用户表和 role 表在一个库,tenant_id 漏了 role 表,结果一个租户管理员登录后能看到另一家租户的全部角色列表,这在合规上是不可接受的。
4.4 用户管理界面上的一个反向需求
租户自己管理用户时会有一个反向需求:管理员希望批量创建账号、批量分配角色,最好还能导入Excel。这要求在用户管理模块里预留批量导入的接口,同时要控制租户管理员的权限边界——租户管理员可以创建用户、停用用户、重置密码,但不能给用户赋予超过自己权限等级的角色,否则会出现权限逃逸。通常的做法是引入「角色层级」概念,租户的管理员角色被定义为最高层级,只有该角色能分配同级或下级角色,其他角色不能创建或分配比自己层级高的角色。这个层级在角色表里加一个 level 字段就能实现,范围校验放在角色分配的服务层统一处理。
5. 所有单据申请信息在线可配:表单引擎与自定义字段的落地
5.1 表单引擎的两种实现路线及取舍
租户要能自定义自己的单据申请信息,这在产品能力上叫做「表单引擎」或者「动态表单」。实现路线有两条:JSON Schema 方案和 EAV(实体-属性-值)方案。JSON Schema 方案是指每个表单模板是一个 JSON 结构定义,包括字段名、控件类型、校验规则、布局信息,提交的数据也以 JSON 形式存储,一条单据记录对应一个 JSONB 字段或 MySQL 的 JSON 字段。EAV 方案是把表单字段做成元数据行,提交的具体值一行行存到属性值表里。这两条路线各有应用场景,EAV 在SQL查询和统计上更灵活,但表结构复杂,联表多的时候性能下降明显;JSON 方案存储直观、实现快、对开发友好,但历史数据的字段变更和统计查询会麻烦一些。
如果是接在新项目上,从零搭建选 JSON 方案成本最低。做电商、库存这类强统计需求的应用场景,EAV 方案更容易支撑灵活报表。OA 系统的单据申请信息,核心诉求是「存得住、查得着、能导出」,JSON 方案配合索引和导出工具完全够用。
5.2 自定义表单的JSON结构设计与存储实现
一个表单模板的 JSON 结构至少要包含表单名称、字段列表、字段的控件类型、是否必填、校验规则。下面是一个请假申请单的模板定义:
{ "formName": "请假申请单", "formCode": "leave", "fields": [ { "fieldKey": "leaveType", "label": "请假类型", "controlType": "select", "required": true, "options": ["年假", "事假", "病假", "调休"] }, { "fieldKey": "startDate", "label": "开始日期", "controlType": "date", "required": true, "validation": "notEmpty" }, { "fieldKey": "endDate", "label": "结束日期", "controlType": "date", "required": true, "validation": "notEmpty" } ] }表单模板存到 form_template 表,租户提交的单据存到 business_form_data 表,结构就是 template_id + form_data JSON + 流程实例ID。这样做的好处是:任何一种新单据都不需要建新表,表单引擎自动解析 JSON 渲染出表单界面,流程引擎读同一份 JSON 做条件分支判断。字段校验全部在 JSON 的 validation 属性里声明,前后端通用一套规则,不搞两套校验逻辑。
5.3 单据数据的查询与统计场景怎么处理
JSON 存储的最大痛点在于按字段搜索。MySQL 的 JSON 字段支持虚拟列和函数索引,可以提取某个字段建立索引来解决查询需求。比如经常要按请假类型统计,可以在表单表上建一个虚拟列:
ALTER TABLE business_form_data ADD COLUMN leave_type VARCHAR(32) GENERATED ALWAYS AS (json_extract(form_data, '$.leaveType')) STORED; CREATE INDEX idx_leave_type ON business_form_data (tenant_id, leave_type);这段 SQL 的意思是:从 form_data 字段里提取 leaveType 的值,作为一个索引列固化存储。查询时 where leave_type = '病假' 就能走索引,不会全表扫描 JSON。要注意的关键参数有两个:json_extract 的路径写法是 $.fieldKey,字段名必须带单引号;STORED 关键字决定这个列是物理存储还是虚拟计算。一般选 STORED,因为查询性能好,代价是占一点磁盘空间,在多租户场景下这个代价是完全值得的。历史数据的结构变更也要预先有策略,比如租户改了表单模板,老数据按旧 JSON 结构存储,新数据按新结构存储,代码里要做好兼容处理,查询时优先取新字段,取不到再回退旧字段位置。
6. 多租户OA项目实施避坑指南:五个高频事故与对策
6.1 线程池串号:ThreadLocal 没有在请求结束时清理
现象:一个租户的请求偶尔会查出另一个租户的数据,而且不是每次都出问题,只在并发量高的时候偶发,追踪日志时发现同一个线程里前后两次请求的租户ID不同。
原因:用了线程池处理异步任务,而 TenantContext 使用 ThreadLocal 保存租户 ID,线程执行完任务后没有被清理,线程池复用时带着上一个租户的上下文处理了下一个请求。
解决:所有异步入口和请求过滤器里的 finally 块统一执行 TenantContext.clear(),同时在线程池提交任务时显式把租户ID作为参数传入任务,而不是依赖线程上下文传递。这是我给团队定的铁律:跨线程不传递租户上下文,只传租户ID参数。
6.2 SQL漏加租户条件:拦截器不是银弹
现象:平台运营后台导出一份统计报表,数据里混进了某个租户的私有单据,或者某张表的查询结果只返回了一部分,排查发现是某个新同事手写的自定义 SQL 没有拼 tenant_id。
原因:自定义 SQL 和报表查询绕过了通用 mapper 层,拦截器无法覆盖到这些语句,或者拦截器对表名判断的正则表达式没覆盖到新加的业务表。
解决:做三层防线:第一层,SQL 审查插件在预发环境自动扫描所有 mapper XML,发现查询条件里没有 tenant_id 的表直接报错;第二层,代码评审时审计所有自定义 SQL,没有租户条件的合并请求不允许通过;第三层,数据查询统一走平台提供的 QueryService,禁止在业务代码里直接用 JdbcTemplate 裸查。这套守则执行半年后,租户数据串号的工单量基本清零。
6.3 流程版本混乱:改完流程后老单据全部按新流程跑
现象:租户修改了一个审批流程的节点配置,结果所有在途的审批单都立刻切换到新流程执行,审批节点和审批人全乱了,用户投诉。
原因:流程实例表里没有保存发起时的流程版本号,运行时引擎拿的是流程定义的最新版本,导致存量实例被强制升级。
解决:发起流程时把 flow_definition 表的 version 字段记录到流程实例表里,引擎每一步都根据实例上的版本号去读对应的流程节点配置。新版本流程只能在发起新申请时生效,旧实例按旧定义跑完。
6.4 表单模板改字段导致老数据读取报错
现象:租户在自定义表单里删除或重命名了一个字段,提交新数据没问题,但打开老单据详情页时报错,页面直接白屏。
原因:代码里对 form_data JSON 取值用的是强引用方式,比如 json_data.get("oldField"),老数据里没有这个字段,反序列化时出错。
解决:表单数据读取统一走 FieldValueService,内部实现 getOrDefault,取不到字段时返回空值而不是抛异常。同时字段重命名时要在表单模板里保存字段变更记录,详情页展示老数据时按字段变更记录做字段名映射,旧值展示在旧字段名下,新表单不强制兼容老数据。
6.5 初始化租户时部分步骤失败但没有回滚
现象:新租户开通后,管理员登录系统发现菜单是空的,但是用户账号能登录,查日志发现初始化过程里菜单初始化步骤抛了异常,但账号已经创建成功。
原因:初始化事务边界划分不当,账号创建事务提前提交,后续的菜单初始化在新事务里执行,失败后没有联动回滚。
解决:租户初始化做成一个完整的事务流程:校验租户信息、创建核心配置、初始化菜单和角色、创建管理员账号,每一大步记录执行状态到 tenant_init_log 表,任一步失败则标记初始化失败,后台提供重试机制。上线以来最实用的一次优化就是这个重试入口,运营同事处理租户开通工单的时间从小时级降到了分钟级。
7. 回到一线:把多租户OA做稳的四个落地习惯
做多租户OA,最大的风险往往不在技术难点上,而在「你以为做完了」的地方。这里分享四个我保留到现在的落地习惯,新项目直接套用能少走很多弯路。
第一个习惯是所有租户维度字段必须组合索引,不允许单字段索引散落在业务表里。每次建表评审时都要确认 tenant_id 一定在组合索引的最左列。很多查询慢的问题到最后都是索引设计的问题,而租户系统的索引设计起点就是 tenant_id 放在第一位。第二个习惯是所有涉及租户数据的接口,测试用例必须包含「跨租户访问」的反向用例——用一个租户的 token 去访问另一个租户的资源,预期的返回必须是无权限或空数据。很多团队只测正向流程,串租户的 bug 都是上线后被真实验户发现的,到那时候代价就大了。
第三个习惯是给每个租户配上独立的日志标记。在日志框架里把租户ID自动注入到 MDC,这样排障时按租户ID过滤日志就能把一个租户的完整请求链路拉出来。这个改动很小,但运营排查问题的效率能提升一个量级。第四个习惯是定期做租户数据隔离的自动化巡检,写一个定时任务随机抽查几张核心业务表,用 SQL 校验是否存在同一条数据被两个租户ID同时引用的记录,发现异常及时告警。
前阵子有一个租户在做数据迁移时误操作,把人员表的部分数据覆盖了,因为提前做了逻辑删除和审计表,最终按时间和操作人维度追回数据,没有造成实质性损失。这让我更确认一点:多租户系统里「数据可追溯」的设计优先级,要排在「功能丰富」之前——功能不够可以迭代补,数据出了问题连后悔药都没有。
如果你正要在现有系统上做多租户改造,我的建议是:先用共享表方案把数据隔离和租户自定义流程这两条主线跑通,再逐步补独立部署、混合隔离这类重方案。不要一开始就追求所有租户完全隔离,那会让开发节奏拖慢到失去先发机会。多租户架构的复杂度不是一天建成的,但每一步选型都要想清楚它能不能支持你至少两年的增长。希望这篇笔记能帮你把关键路径上的坑提前填平。
本文还有配套的精品资源,点击获取