在这组本地实验里,同样只查两次数据库,错误版给甲公司的两条订单都填上了乙的客户名称;修正版返回甲自己的客户,缺失的那条则明确标出来。
第一条 SQL 已经带了 tenant_id。问题出在第二条批量查询:只拿客户 ID 去查,丢掉了租户范围。给这条 SQL 补上条件后,还要确认租户身份来自哪里。
下面用三条订单、三条客户数据,把查询范围、可信身份和客户缺失放在一起检查。文末有完整程序与七组运行结果,可以对照自己的列表接口复查。
实验使用 JDK 21、MyBatis 3.5.19、H2 2.3.232。甲、乙公司都是教学数据,没有连接真实业务库。
客户 ID 相同,不代表是同一个客户
订单表有三条记录:
| 租户 | 订单 ID | 客户 ID |
|---|---|---|
| 甲(1) | 1 | 101 |
| 甲(1) | 2 | 102 |
| 乙(2) | 3 | 101 |
客户表则放入下面三条:
| 租户 | 客户 ID | 客户名称 |
|---|---|---|
| 甲(1) | 101 | 甲客户101 |
| 乙(2) | 101 | 乙客户101 |
| 乙(2) | 102 | 乙客户102 |
客户表的主键是 (tenant_id, id),客户编号只在租户内部唯一。这里留了两个需要检查的情况:甲、乙都有客户 101;甲的客户 102 缺失,乙却有同号客户。
先查甲的订单:
sql
SELECT id, customer_id
FROM orders
WHERE tenant_id = 1
ORDER BY id
LIMIT 20;
结果没有问题。收集并去重客户 ID,得到 [101, 102],再用它们批量查客户:
sql
SELECT id, name
FROM customers
WHERE id IN (101, 102)
ORDER BY tenant_id, id;
这一条漏掉了租户条件,会返回客户表中的全部三条记录。再用 Map<Integer, String> 按客户 ID 组装,后写入的"乙客户101"覆盖"甲客户101","乙客户102"也被放进结果:
text
订单1 → 乙客户101
订单2 → 乙客户102

错误查询里的 ORDER BY tenant_id, id,只是为了固定实验的覆盖顺序。把排序倒过来,或者让 Map 保留第一个值,都没有限制查询范围;甲缺少客户 102 时,仍可能拿到乙的同号客户。
补上租户条件,还要确认租户从哪里来
客户查询也需要限定租户:
sql
SELECT id, name
FROM customers
WHERE tenant_id = ?
AND id IN (?, ?);
范围限制在单个租户后,客户 ID 才能单独作为 Map 的键。如果业务确实要同时查询多个租户,就要另行设计授权和复合键,不能直接沿用这个组装方法。
但如果这个 tenantId 直接取自 ?tenantId=2,请求者仍然可以指定别人的租户。两条 SQL 都带条件,只会让它们一起查向同一个错误范围。
所以,两条查询都使用服务端已确认身份中的租户。本例为兼容旧接口保留了租户参数,但它不能覆盖身份:参数与身份不一致时,在查询前拒绝。
java
List<View> list(AuthenticatedUser user, Integer requestedTenant) {
if (user == null) throw new SecurityException("未认证");
if (requestedTenant != null && requestedTenant != user.tenantId)
throw new SecurityException("租户参数与身份不符");
return query(factory, user.tenantId, true);
}
这里的 AuthenticatedUser 来自测试夹具,模拟认证完成后的服务端上下文。它不是登录接口,也不能由客户端提交后直接获得信任。接入实际应用时,应从已经校验过的会话、令牌或框架认证上下文取得身份;允许切换租户的业务,还要检查成员资格。这些生产认证流程不在本例中。
身份确定后,再把租户和客户 ID 交给 MyBatis:参数通过 #{} 绑定,foreach 负责生成 IN 列表。它解决的是参数展开,不能替代前面的授权判断。订单为空时直接返回,既少一次查询,也避免进入空 IN 分支。语法可查 MyBatis 动态 SQL 文档。
查询次数不变,结果应该发生什么变化
对照程序的实际运行结果如下。计数器拦截 StatementHandler.query,统计执行的查询;每次进入查询方法都会新建 SqlSession,避免前一次查询的会话缓存影响比较。
| 场景 | 返回结果 | SQL 次数 |
|---|---|---|
| 甲:客户查询漏租户 | 两条订单都使用乙的客户名称 | 2 |
| 甲:修正后 | 甲客户101;客户资料缺失 | 2 |
| 乙:正常访问 | 乙客户101 | 2 |
| 甲身份,请求指定乙租户 | 拒绝 | 0 |
| 没有认证身份 | 拒绝 | 0 |
| 已认证,但没有订单 | 空列表 | 1 |
| 甲身份,请求也指定甲租户 | 与甲正常结果一致 | 2 |
修正后,甲的第二条订单仍然保留,客户名称显示"客户资料缺失"。不能因为关联数据没查到就丢掉订单,更不能借用乙的客户 102。业务也可以选择报错,但应该先约定好,不能在优化列表查询时顺手决定。
组装时,代码遍历原订单,再按客户 ID 从 Map 取名称,没有按两个查询结果的下标拼接。
这七组结果检查了数据范围、身份和组装行为,没有测线上耗时;H2 的实验也不能代替目标数据库的执行计划。下面的完整程序保留错误查询,运行时可以直接对照修正前后的结果。
完整复现代码
新建 Maven 项目,放入下面两个文件。在项目目录执行:
bash
mvn -q compile exec:java
末尾应打印 PASS: 7 scenarios;前面会列出每组结果。程序用完会关闭连接,数据只存在于本次进程的 H2 内存库。
pom.xml:
xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId>
<artifactId>tenant-batch-lab</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.19</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>demo.TenantLab</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
src/main/java/demo/TenantLab.java:
java
package demo;
import java.sql.Statement;
import java.util.*;
import org.apache.ibatis.annotations.*;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
public class TenantLab {
public record Order(int id, int customerId) {}
public record Customer(int id, String name) {}
public record View(int id, String name) {}
// 只在测试认证夹具中创建;不把请求参数反序列化成这个身份。
public static final class AuthenticatedUser {
private final int tenantId;
private AuthenticatedUser(int tenantId) { this.tenantId = tenantId; }
}
static AuthenticatedUser fixtureIdentity(String login) {
return switch (login) {
case "alice" -> new AuthenticatedUser(1);
case "bob" -> new AuthenticatedUser(2);
case "empty" -> new AuthenticatedUser(3);
default -> throw new SecurityException("未认证");
};
}
public interface Mapper {
@Select("SELECT id,customer_id FROM orders WHERE tenant_id=#{tenant} ORDER BY id LIMIT 20")
@ConstructorArgs({@Arg(column="id",javaType=int.class),
@Arg(column="customer_id",javaType=int.class)})
List<Order> page(@Param("tenant") int tenant);
// 错误对照:故意漏掉客户查询的租户条件。
@Select("""
<script>SELECT id,name FROM customers WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY tenant_id,id</script>
""")
@ConstructorArgs({@Arg(column="id",javaType=int.class),
@Arg(column="name",javaType=String.class)})
List<Customer> unsafe(@Param("ids") List<Integer> ids);
@Select("""
<script>SELECT id,name FROM customers WHERE tenant_id=#{tenant} AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY id DESC</script>
""")
@ConstructorArgs({@Arg(column="id",javaType=int.class),
@Arg(column="name",javaType=String.class)})
List<Customer> safe(@Param("tenant") int tenant,
@Param("ids") List<Integer> ids);
}
@Intercepts(@Signature(type=StatementHandler.class, method="query",
args={Statement.class, ResultHandler.class}))
public static class Counter implements Interceptor {
int count;
public Object intercept(Invocation invocation) throws Throwable {
count++;
return invocation.proceed();
}
}
static class OrderEndpoint {
private final SqlSessionFactory factory;
OrderEndpoint(SqlSessionFactory factory) { this.factory = factory; }
List<View> list(AuthenticatedUser user, Integer requestedTenant) {
if (user == null) throw new SecurityException("未认证");
// 兼容保留了 tenant 参数的旧入口,但不允许它覆盖认证身份。
if (requestedTenant != null && requestedTenant != user.tenantId)
throw new SecurityException("租户参数与身份不符");
return query(factory, user.tenantId, true);
}
}
static List<View> query(SqlSessionFactory factory, int tenant, boolean safe) {
try (var session = factory.openSession()) {
var mapper = session.getMapper(Mapper.class);
var orders = mapper.page(tenant);
if (orders.isEmpty()) return List.of(); // 避免生成 IN (),也省掉无用查询。
var ids = orders.stream().map(Order::customerId).distinct().toList();
var customers = safe ? mapper.safe(tenant, ids) : mapper.unsafe(ids);
var names = new HashMap<Integer, String>();
customers.forEach(c -> names.put(c.id(), c.name()));
// 按订单顺序组装;客户缺失不能把整条订单丢掉。
return orders.stream().map(o -> new View(o.id(),
names.getOrDefault(o.customerId(), "客户资料缺失"))).toList();
}
}
public static void main(String[] args) throws Exception {
var ds = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:tenant_lab", "sa", "");
try (var keeper = ds.getConnection(); var sql = keeper.createStatement()) {
sql.execute("CREATE TABLE orders(tenant_id INT,id INT,customer_id INT,PRIMARY KEY(tenant_id,id))");
sql.execute("CREATE TABLE customers(tenant_id INT,id INT,name VARCHAR(50),PRIMARY KEY(tenant_id,id))");
sql.execute("INSERT INTO orders VALUES(1,1,101),(1,2,102),(2,3,101)");
sql.execute("INSERT INTO customers VALUES(1,101,'甲客户101'),(2,101,'乙客户101'),(2,102,'乙客户102')");
var config = new Configuration(new Environment("lab", new JdbcTransactionFactory(), ds));
var counter = new Counter();
config.addInterceptor(counter);
config.addMapper(Mapper.class);
var factory = new SqlSessionFactoryBuilder().build(config);
var endpoint = new OrderEndpoint(factory);
counter.count = 0;
var wrong = query(factory, 1, false);
require(wrong.equals(List.of(new View(1,"乙客户101"), new View(2,"乙客户102")))
&& counter.count == 2, "错误对照");
System.out.println("BAD tenant=1 queries=2 " + wrong);
counter.count = 0;
var alice = endpoint.list(fixtureIdentity("alice"), null);
require(alice.equals(List.of(new View(1,"甲客户101"), new View(2,"客户资料缺失")))
&& counter.count == 2, "甲租户及缺失客户");
System.out.println("ALICE queries=2 " + alice);
counter.count = 0;
var bob = endpoint.list(fixtureIdentity("bob"), null);
require(bob.equals(List.of(new View(3,"乙客户101"))) && counter.count == 2, "乙租户");
System.out.println("BOB queries=2 " + bob);
counter.count = 0;
denied(() -> endpoint.list(fixtureIdentity("alice"), 2));
require(counter.count == 0, "伪造参数不得查询数据库");
System.out.println("SPOOF tenant=2 denied queries=0");
counter.count = 0;
denied(() -> endpoint.list(null, 1));
require(counter.count == 0, "未认证不得查询数据库");
System.out.println("ANONYMOUS denied queries=0");
counter.count = 0;
require(endpoint.list(fixtureIdentity("empty"), null).isEmpty()
&& counter.count == 1, "空页");
System.out.println("EMPTY queries=1");
counter.count = 0;
require(endpoint.list(fixtureIdentity("alice"), 1).equals(alice)
&& counter.count == 2, "同租户参数");
System.out.println("MATCHING tenant=1 queries=2");
System.out.println("PASS: 7 scenarios");
}
}
static void denied(Runnable action) {
try { action.run(); } catch (SecurityException expected) { return; }
throw new AssertionError("应该拒绝却成功了");
}
static void require(boolean ok, String message) {
if (!ok) throw new AssertionError(message);
}
}
项目里的租户条件无论是手写,还是由插件统一补,都可以用这两组同 ID 数据做一次检查,尤其是自定义 JOIN、跳过租户拦截的查询:甲的客户 102 缺失时,订单是否保留,结果里有没有出现乙的数据。