MyBatis只查两次,为什么还会查到别家客户?

在这组本地实验里,同样只查两次数据库,错误版给甲公司的两条订单都填上了乙的客户名称;修正版返回甲自己的客户,缺失的那条则明确标出来。

第一条 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 缺失时,订单是否保留,结果里有没有出现乙的数据。

相关推荐
编程老船长1 小时前
模型中立——把大模型做成"可替换零件",而不是焊死在业务里
java·前端·后端
用户2181697049301 小时前
java List Set Map
java
明月_清风1 小时前
Tomcat 到底是什么?一篇文章搞懂 Java Web 的 Tomcat
java·后端
SelectDB1 小时前
日志成本打不下来?Doris 降本实操笔记:建表、参数、冷热分层与四个排错现场
大数据·数据库·数据分析
xiaoqiMikko1 小时前
JVM 线上排查实战(三):grep BLOCKED 找不到的死锁,和 jstack 根本不报的死锁
java·jvm
这个DBA有点耶1 小时前
数据库迁移不停机方案:双轨并行技术架构与3TB核心系统落地实践
数据库·架构·dba
SelectDB1 小时前
Doris vs ClickHouse:企业级分析场景下,OLAP 的能力边界正在如何变化?
大数据·数据库·数据分析
用户3721574261351 小时前
如何使用 Java 从 PDF 中提取图片及获取图片信息
java
野生数据人1 小时前
多表 Join 慢、Upsert 把机器写崩:一份能直接抄的 Apache Doris 命令清单
大数据·数据库