XBatis 1.10.7 的核心修复指向一个容易被忽略、却会直接影响分页总数和统计结果的问题:多列 DISTINCT 查询在转换为 COUNT 时,原有优化可能产生错误结果。对于依赖 ORM 自动生成统计 SQL 的项目,这类缺陷往往不会报异常,而是悄悄返回一个看似合理的错误数字,因此比语法错误更难发现。
为什么多列 DISTINCT 的 COUNT 容易出错
单列去重计数通常比较直接:
SELECT COUNT(DISTINCT user_id)
FROM login_record;
但业务需要按多个字段的组合去重时,语义就变成了“统计唯一元组的数量”。例如,同一个用户可以存在于多个租户中,真正的唯一键是 (tenant_id, user_id):
SELECT DISTINCT tenant_id, user_id
FROM login_record;
将它优化成计数 SQL 时,不能随意删除字段,也不能只保留其中一列。不同数据库对 COUNT(DISTINCT col1, col2) 的支持和语法也不完全一致。更稳妥、语义更明确的写法是保留原始去重查询,再在外层计数:
SELECT COUNT(*)
FROM (
SELECT DISTINCT tenant_id, user_id
FROM login_record
) AS distinct_rows;
1.10.7 修复的正是摘要中明确提到的“distinct 多列在 count 时优化错误”问题。它对分页插件、报表统计、去重列表总数尤其重要,因为这些场景通常由框架自动把列表查询改写为计数查询。
用最小数据集验证计数语义
升级 ORM 后,不要只检查接口能否返回数据,还要构造会暴露错误优化的重复组合。下面可以直接使用 Docker 和 PostgreSQL 运行;执行前需要安装 Docker,并确保本机的 55432 端口未被占用。
docker run --name xbatis-count-test \
-e POSTGRES_PASSWORD=postgres \
-p 55432:5432 \
-d postgres:16
until docker exec xbatis-count-test pg_isready -U postgres >/dev/null 2>&1; do
sleep 1
done
docker exec -i xbatis-count-test psql -U postgres <<'SQL'
CREATE TEMP TABLE login_record (
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL
);
INSERT INTO login_record (tenant_id, user_id) VALUES
(1, 100),
(1, 100),
(1, 101),
(2, 100);
SELECT COUNT(*) AS expected_count
FROM (
SELECT DISTINCT tenant_id, user_id
FROM login_record
) AS distinct_rows;
SQL
docker rm -f xbatis-count-test
预期结果是 3,而不是只按 tenant_id 计算得到的 2,也不是只按 user_id 计算得到的 2。在真实项目中,可以把 login_record 替换成业务表,并让 XBatis 执行同样的多列去重分页查询,然后核对框架返回的总数。
把生成 SQL 纳入回归测试
ORM 减少了 Mapper、条件拼装和重复 CRUD 代码,但它也把一部分 SQL 决策转移到了框架内部。涉及 DISTINCT、GROUP BY、连接查询和分页时,建议临时开启 MyBatis SQL 日志。Spring Boot 项目可以这样实践:
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
org.xbatis: DEBUG
具体日志分类名可能随项目依赖和包结构变化;如果 org.xbatis 没有输出,可改成项目 Mapper 所在的包名。验证时重点比较两条 SQL:列表查询是否保留预期的去重字段,以及计数查询是否统计完整的字段组合。
可以为分页服务增加一个不依赖具体 ORM API 的断言模板。以下示例假设业务服务返回 total 和当前页记录,方法名需要按项目实际接口替换:
@Test
void shouldCountDistinctTenantAndUserPairs() {
repository.insert(1L, 100L);
repository.insert(1L, 100L);
repository.insert(1L, 101L);
repository.insert(2L, 100L);
PageResult<LoginRecord> page = service.findDistinctUsers(1, 10);
assertEquals(3L, page.getTotal());
assertEquals(3, page.getRecords().size());
}
这段代码是可改造的测试骨架,并非 XBatis 1.10.7 的固定 API。关键在于测试数据必须同时包含“完全重复的组合”和“单列相同但组合不同的记录”,否则旧问题可能无法被触发。
ORM 省代码,但不能省掉边界测试
XBatis 的价值在于减少单表、连表和动态条件查询中的持久层样板代码,让开发者用更紧凑的 API 构建 SQL。1.10.7 的这次修复也提醒我们:生成 SQL 越自动化,越需要用结果断言守住复杂查询的语义。
升级时建议检查以下项目:
- 搜索包含多列
DISTINCT的列表和分页查询。 - 对比升级前后的实际 SQL与执行计划。
- 使用重复组合数据验证
total,不要只验证当前页记录。 - 覆盖项目实际使用的数据库方言,避免只在 H2 等测试数据库上通过。
- 对金额、权限范围和报表指标等关键统计保留手写 SQL 或数据库级对照测试。
如果项目没有多列去重计数,升级风险相对有限;如果分页总数直接影响结算、导出或运营报表,则应把这一修复当作数据正确性变更处理,而不是普通依赖版本更新。