多租户数据查询如何防串?写入、列表、详情与关联校验的实现方法 多租户系统的数据隔离常被简化成“列表 SQL 加WHERE tenant_id ?”。列表看起来没问题详情却按全局 ID 查询修改接口只按 ID 更新明细表虽然有tenant_id却通过单独的request_id关联了另一家公司的主单。真正发生串数据的地方往往不在最显眼的列表页面。上一篇已经确定了可信的当前租户上下文。本文继续往下走**拿到这个租户身份后数据怎样写进去、查出来、关联起来和更新成功**使用两家虚构公司作为固定案例。甲公司t_a和乙公司t_b都有编号为PR-001的申请单全局记录 ID 分别为1001和2001。账号u_07当前选中乙公司即使它也加入了甲公司这次请求仍只能操作乙公司的数据。示例环境Java 17、Spring Boot 风格服务层、MySQL 8.x/InnoDB。下面的表和代码是可复核的教学模型不包含实际产品的数据结构。目录一个列表正确、详情却串数据的案例先确定数据归属和约束创建和列表不要让客户端决定归属详情与修改记录ID必须带上租户边界关联查询主单和明细不能跨租户拼接Java服务层和自动测试SQL验证、上线检查与边界一、一个列表正确、详情却串数据的案例固定数据如下租户申请单 ID业务编号状态归属部门甲公司t_a1001PR-001DRAFTa_sales乙公司t_b2001PR-001DRAFTb_salesu_07当前在乙公司。乙公司的列表只显示2001看似隔离成功但如果详情接口执行SELECT * FROM request WHERE id 1001用户只需修改 URL 就能看到甲公司的内容。若修改接口也只按id1001更新风险就从信息泄露变成了跨租户篡改。本文要验证四个结果乙公司会话创建申请单时服务端写入tenant_idt_b不接受请求体中的甲公司编号。乙公司列表只能在t_b内再按用户的数据范围缩小甲、乙都可以合法拥有PR-001。乙公司会话请求详情或修改1001得到统一的“资源不可用”甲公司记录不泄露也不变化。乙公司的明细只能关联乙公司的主单不能用request_id1001挂到甲公司的单据上。图1业务编号可以在各租户内重复全局 ID 也不能成为绕过租户过滤的捷径。二、先确定数据归属和约束在共享库示例中主单和明细都显式保存tenant_id。主单有全局唯一的id但业务编号按(tenant_id, request_no)唯一明细通过(tenant_id, request_id)关联主单。这样的模型同时表达两件事同一租户不能重复使用业务编号不同租户可以使用相同编号明细归属必须与主单一致。CREATETABLEdemo_tenant(tenant_idVARCHAR(32)PRIMARYKEY)ENGINEInnoDB;CREATETABLEdemo_request(idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,request_noVARCHAR(40)NOTNULL,owner_account_idVARCHAR(32)NOTNULL,dept_idVARCHAR(32)NOTNULL,statusVARCHAR(20)NOTNULL,versionBIGINTNOTNULLDEFAULT0,UNIQUEKEYuk_tenant_request_no(tenant_id,request_no),UNIQUEKEYuk_tenant_request_id(tenant_id,id),KEYidx_tenant_dept(tenant_id,dept_id,id),CONSTRAINTfk_request_tenantFOREIGNKEY(tenant_id)REFERENCESdemo_tenant(tenant_id))ENGINEInnoDB;CREATETABLEdemo_request_item(idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,request_idBIGINTNOTNULL,item_nameVARCHAR(80)NOTNULL,amountDECIMAL(12,2)NOTNULL,KEYidx_item_request(tenant_id,request_id),CONSTRAINTfk_item_requestFOREIGNKEY(tenant_id,request_id)REFERENCESdemo_request(tenant_id,id))ENGINEInnoDB;这里的组合唯一键(tenant_id, id)看上去与id主键重复但它为明细的复合外键提供了明确、唯一的被引用键。MySQL 对外键和被引用键的索引有要求不要依赖某些旧版本允许引用非唯一索引的兼容行为。若实际系统采用分库或其他存储模型约束实现会不同但“子资源不能挂到另一个租户的父资源”这一规则不能省。可以插入两条同编号的主单预期都成功INSERTINTOdemo_tenant(tenant_id)VALUES(t_a),(t_b);INSERTINTOdemo_request(id,tenant_id,request_no,owner_account_id,dept_id,status)VALUES(1001,t_a,PR-001,u_07,a_sales,DRAFT),(2001,t_b,PR-001,u_07,b_sales,DRAFT);下面两条语句应分别执行并分别失败第一条触发租户内业务编号唯一键第二条触发明细的复合外键。失败本身就是验证结果不要为了让导入脚本“一路绿灯”而关闭外键检查。-- 预期uk_tenant_request_no 唯一键冲突。INSERTINTOdemo_request(id,tenant_id,request_no,owner_account_id,dept_id,status)VALUES(2002,t_b,PR-001,u_07,b_sales,DRAFT);-- 预期fk_item_request 外键冲突1001 是甲公司的主单。INSERTINTOdemo_request_item(id,tenant_id,request_id,item_name,amount)VALUES(3001,t_b,1001,设备,900.00);数据库约束在这里负责挡住错误关联它不能代替接口的账号授权和数据范围校验。图2主单和明细同时携带租户归属让错误关联在写入时就暴露。三、创建和列表不要让客户端决定归属创建接口的 DTO 不需要tenantId字段。服务端从上一篇建立的可信TenantContext中取得当前租户并检查申请部门确实属于该租户、当前成员有创建权限。无论请求体是否被人私自加上tenant_idt_a都不能改变t_b会话的写入归属。创建主单与明细应在同一个本地事务里提交。若主单已插入、明细校验失败事务回滚不能留下只写了一半的申请。外部附件上传不属于这笔数据库事务其归属与交付校验留到下一篇讨论。列表查询要把租户边界放在所有业务数据范围之前。例如普通成员只能看自己提交的单据SELECTid,request_no,status,dept_idFROMdemo_requestWHEREtenant_id:trustedTenantIdANDowner_account_id:authenticatedAccountIdORDERBYidDESC;部门主管可以额外查看授权部门但不能把“本部门”写成一个来自前端的自由参数。部门范围应由成员身份解析出来并与tenant_id同时约束。若可访问部门集合为空应返回空结果不应因为构造 SQL 时省掉IN (...)条件而变成全租户查询。租户管理员有更宽的数据范围也仍被当前tenant_id限定。列表中的过滤、排序和分页同样不能改变边界。建议为高频查询配置以tenant_id开头的复合索引再用真实查询计划确认效果不要仅为“看起来有索引”而给每个字段都建索引。四、详情与修改记录ID必须带上租户边界详情接口不能先按 ID 取整条记录再检查归属并决定是否返回。那样虽然最终可能拒绝但中途日志、缓存、事件或对象映射已经接触到另一租户的数据。更稳的是数据访问层直接把两者作为定位条件SELECTid,tenant_id,request_no,owner_account_id,dept_id,status,versionFROMdemo_requestWHEREtenant_id:trustedTenantIdANDid:requestId;查到记录后还要检查用户在本租户内的数据范围。查询不到时对外统一返回“资源不可用”或 404不区分“这个 ID 不存在”和“属于另一家公司”避免泄露存在性。对内审计可以保留脱敏的拒绝原因但不应向普通调用方暴露另一租户的编号和状态。修改接口除了租户和 ID还要有业务状态与并发版本条件UPDATEdemo_requestSETstatusSUBMITTED,versionversion1WHEREtenant_id:trustedTenantIdANDid:requestIdANDstatusDRAFTANDversion:expectedVersion;影响 1 行表示该记录在本租户且仍为预期状态修改成功。影响 0 行时不能直接说“另一个租户的单据不存在”或“状态冲突”可在本租户范围内重新查询查不到就按资源不可用处理查得到再判断版本或状态变化。任何补查都不应去掉tenant_id。批量更新、删除与状态推进也遵循相同原则不能只有单条接口安全。图3列表隔离只覆盖一个入口详情和修改仍需独立守住相同边界。五、关联查询主单和明细不能跨租户拼接只给明细表加tenant_id还不够。如果关联条件写成item.request_id request.id某些脏数据、迁移数据或外键未启用的环境仍可能拼出跨租户结果。关联查询应同时比较双方的租户归属并从主单入口再次限定当前租户SELECTr.id,r.request_no,i.idASitem_id,i.item_name,i.amountFROMdemo_request rJOINdemo_request_item iONi.request_idr.idANDi.tenant_idr.tenant_idWHEREr.tenant_id:trustedTenantIdANDr.id:requestId;复合外键保护未来写入联表条件保护读取语义。两者叠加尤其适合已有历史数据、人工导入或迁移过程复杂的系统。上线前也要核对原有明细归属不能在脏数据上直接加外键后假定一切正常。若明细属于独立生命周期对象还应限制它自己的创建、修改和删除接口而不是只在主单查询时处理。同样的思路适用于审批记录、评论、标签和文件元数据只要是租户内的子资源就需要说明它归属哪个父资源、能否被跨租户 ID 引用。不过文件内容与下载链接还有额外风险下一篇单独讲不在本文硬塞。六、Java服务层和自动测试下面的服务层示例只保留核心路径。TenantContext由已认证请求解析得到ScopePolicy负责本租户内的部门与动作权限。代码没有让创建命令携带tenantId也没有让详情按全局 ID 直接读取。recordTenantContext(StringtenantId,StringaccountId){}recordCreateCommand(StringrequestNo,StringdeptId,ListItemCommanditems){}recordItemCommand(Stringname,BigDecimalamount){}recordRequestRow(longid,StringtenantId,StringownerId,StringdeptId,Stringstatus,longversion){}interfaceRequestRepository{longinsert(StringtenantId,StringrequestNo,StringownerId,StringdeptId);voidinsertItems(StringtenantId,longrequestId,ListItemCommanditems);OptionalRequestRowfindByTenantAndId(StringtenantId,longid);intsubmitIfDraft(StringtenantId,longid,longexpectedVersion);}interfaceScopePolicy{booleancanCreateIn(StringtenantId,StringaccountId,StringdeptId);booleancanRead(TenantContextcontext,RequestRowrow);booleancanSubmit(TenantContextcontext,RequestRowrow);}finalclassRequestService{privatefinalRequestRepositoryrequests;privatefinalScopePolicyscope;RequestService(RequestRepositoryrequests,ScopePolicyscope){this.requestsrequests;this.scopescope;}Transactionalpubliclongcreate(TenantContextcontext,CreateCommandcommand){if(!scope.canCreateIn(context.tenantId(),context.accountId(),command.deptId()))thrownewAccessDeniedException(DEPARTMENT_UNAVAILABLE);longidrequests.insert(context.tenantId(),command.requestNo(),context.accountId(),command.deptId());requests.insertItems(context.tenantId(),id,command.items());returnid;}publicRequestRowdetail(TenantContextcontext,longid){RequestRowrowrequests.findByTenantAndId(context.tenantId(),id).orElseThrow(()-newResourceUnavailableException());if(!scope.canRead(context,row))thrownewResourceUnavailableException();returnrow;}Transactionalpublicvoidsubmit(TenantContextcontext,longid,longexpectedVersion){RequestRowrowdetail(context,id);if(!scope.canSubmit(context,row))thrownewAccessDeniedException(ACTION_FORBIDDEN);if(requests.submitIfDraft(context.tenantId(),id,expectedVersion)!1)thrownewIllegalStateException(STATE_OR_VERSION_CHANGED);}}ResourceUnavailableException在示例中代表项目自己的统一不可见异常需要在接口层映射为不暴露资源存在性的响应。Transactional用 Spring 声明式事务真实项目还要补请求参数校验、重复提交控制以及ScopePolicy所用的成员和部门数据的一致性策略。若创建命令里出现客户端自行添加的tenantId字段反序列化层也应拒绝或忽略不能覆盖context.tenantId()。最少验证三种路径。示例测试使用 JUnit 5、AssertJ 与 Mockito 风格实际项目应再用 MySQL 集成测试确认唯一键、复合外键和 SQL 查询行为。TestvoidbSessionCannotReadARequestByGuessingItsGlobalId(){varcontextnewTenantContext(t_b,u_07);when(requests.findByTenantAndId(t_b,1001L)).thenReturn(Optional.empty());assertThatThrownBy(()-service.detail(context,1001L)).isInstanceOf(ResourceUnavailableException.class);verify(requests).findByTenantAndId(t_b,1001L);verify(requests,never()).findByTenantAndId(t_a,1001L);}TestvoidcreateUsesTrustedTenantForRequestAndItems(){varcontextnewTenantContext(t_b,u_07);varcommandnewCreateCommand(PR-002,b_sales,List.of(newItemCommand(设备,newBigDecimal(900.00))));when(scope.canCreateIn(t_b,u_07,b_sales)).thenReturn(true);when(requests.insert(t_b,PR-002,u_07,b_sales)).thenReturn(2002L);assertThat(service.create(context,command)).isEqualTo(2002L);verify(requests).insertItems(eq(t_b),eq(2002L),anyList());}TestvoidstaleVersionCannotSubmitTwice(){varcontextnewTenantContext(t_b,u_07);varrownewRequestRow(2001L,t_b,u_07,b_sales,DRAFT,3);when(requests.findByTenantAndId(t_b,2001L)).thenReturn(Optional.of(row));when(scope.canRead(context,row)).thenReturn(true);when(scope.canSubmit(context,row)).thenReturn(true);when(requests.submitIfDraft(t_b,2001L,2L)).thenReturn(0);assertThatThrownBy(()-service.submit(context,2001L,2L)).hasMessage(STATE_OR_VERSION_CHANGED);}第二个测试证明由服务端上下文控制主单和明细写入归属第三个测试证明旧版本不能重复推进。它们不能证明 SQL 一定写对因此还需要真实数据库中的外键失败用例、跨租户详情用例和并发提交用例。尤其要防止在一个 Mock 测试通过后把findByTenantAndId的 SQL 实现写成按 ID 查询。图4服务层明确传递租户边界数据库约束和实际 SQL 仍需独立测试。七、SQL验证、上线检查与边界使用前面的两条固定记录在乙公司上下文运行下面的查询。预期只看到2001详情查询甲公司的1001为 0 行跨租户修改影响 0 行甲公司的记录仍是DRAFT。-- 乙公司列表预期只有 2001。SELECTid,request_noFROMdemo_requestWHEREtenant_idt_bORDERBYid;-- 乙公司尝试读取甲公司 ID预期 0 行。SELECTidFROMdemo_requestWHEREtenant_idt_bANDid1001;-- 乙公司尝试修改甲公司 ID预期影响 0 行。UPDATEdemo_requestSETstatusSUBMITTED,versionversion1WHEREtenant_idt_bANDid1001ANDstatusDRAFTANDversion0;-- 脏数据巡检预期 0 行。上线前应核对历史导入记录。SELECTi.id,i.tenant_idASitem_tenant,r.tenant_idASrequest_tenantFROMdemo_request_item iJOINdemo_request rONr.idi.request_idWHEREi.tenant_idr.tenant_id;由于复合外键最后一个查询在新库中应始终为 0 行它主要用于迁移前、外键未启用或旧数据来源复杂的环境。还要检查 ORM 原生 SQL、报表 SQL、批量更新、管理脚本是否绕过统一数据访问层。只有“正常列表没串”远远不够。上线验收建议至少覆盖以下矩阵操作正向结果必测的拒绝结果创建主单和明细当前租户写入成功同租户关联请求体伪造归属、部门不属于当前租户列表及分页只返回当前租户且符合数据范围的记录空部门范围不能退化成全租户列表详情当前租户授权记录可读猜另一租户的全局 ID 不泄露修改和状态推进租户、状态、版本均符合时影响 1 行跨租户 ID 或旧版本影响 0 行关联查询明细只随本租户主单返回不能把乙公司明细挂到甲公司主单独立数据库或独立 schema 的产品不一定需要在每张表里重复tenant_id但也必须在进入数据库前确认路由租户不能让客户端自由决定数据源。平台运维若确需跨租户查询应使用另行授权、范围受限且可审计的流程不应在普通业务 SQL 里添加“超级管理员就去掉租户条件”的捷径。数据隔离是一组贯穿写入、读取、关联和修改的约束不是一条列表过滤条件。本文把数据库记录守住了下一篇继续处理文件与导出那里的内容可能已经离开数据库归属和下载授权更容易被忽略。图5每条路径都应同时验证允许访问与拒绝越界不能用列表测试代表整个系统。参考资料OWASP多租户安全检查清单MySQL 8.4 官方手册外键约束与索引要求MySQL 8.4 官方手册唯一约束