ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus selectBatchIds传参全解析:Collection类型、空集合与批量查询优化

MyBatis-Plus selectBatchIds传参全解析:Collection类型、空集合与批量查询优化 做后端开发这几年总有人跑来问我同一个问题“selectBatchIds 方法里的 Collection 参数到底该怎么传”更常见的是有人把它拼成 electBatchIds 去搜结果越搜越懵。这个方法的签名看着简单真正用起来空集合、null、Set 乱序、Oracle 1000 上限、结果集顺序不一致……每个坑我都踩过今天把这套传值方式的来龙去脉一次讲清楚。如果你用的是 MyBatis-Plus 3.x 体系日常要按主键批量查数据那么这个方法就是你绕不开的基础能力。这篇文章默认你的项目里有 BaseMapper 继承、有 3.3 以上的 mybatis-plus-boot-starter。我会从方法签名开始讲逐步拆到源码行为、传参姿势、底层 SQL 生成再讲几个我实际排查过的问题最后给一套可以直接抄的自定义方案。1. selectBatchIds 到底是什么方法先吃透签名和设计意图1.1 方法签名到底长什么样很多人拿着 selectBatchIds 去写代码第一行就懵因为方法参数写的是Collection? extends Serializable不是常见的ListLong。签名原话是ListT selectBatchIds(Param(Constants.COLL) Collection? extends Serializable idList);它定义在com.baomidou.mybatisplus.core.mapper.BaseMapper里返回的 List 是实体列表入参是 Collection泛型上界是 Serializable。为什么是 Serializable因为主键类型在数据库里要么是数字、要么是字符串都是可序列化类型MyBatis-Plus 在底层要把这些值一个个绑定到 PreparedStatement 的占位符上不走序列化也得保证类型能识别。Param(Constants.COLL)里的Constants.COLL就是字符串coll这是给底层 SQL 注入用的占位名。默认情况下你不需要关心它但如果你要自己在 XML 里写类似方法一会我会说到这个参数名的坑。1.2 这个方法的真正定位按主键批量查询看起来是件小事但如果没有这个方法很多新手的写法是这样的ListUser users new ArrayList(); for (Long id : idList) { User user userMapper.selectById(id); users.add(user); }这个写法在数据量只有几条时没问题一旦循环里出现几十个、几百个 id数据库就被反复打穿十几二十次加上网络往返接口耗时直线上升。selectBatchIds 做的事情就是把这些单条查询合并成一条 SQLSELECT id, name, age FROM user WHERE id IN (1, 2, 3, ..., N);一条 SQL 查完再通过 MyBatis 的行映射把结果组装回 List。这是最典型的“用一次网络往返换 N 次网络往返”的优化思路相比循环单查在 100 条数据以内的场景通常能快一个数量级以上。1.3 和 selectByIds 的关系需要提醒一点如果你用的 MyBatis-Plus 版本比较新你在 IDE 里敲方法时可能会发现还有一个selectByIds也是按主键批量查询。我在 3.5.x 的项目里做过对比方法名参数类型引入版本说明selectBatchIdsCollection? extends Serializable很早就有了经典实现内部基于 IN 查询selectByIdsCollection? extends Serializable官方推荐的新替代方案语义更清晰后续版本趋势是新代码用它所以标题里的 electBatchIds 不管是手误还是旧习惯只要项目还在用旧版 APIselectBatchIds 就是必须掌握的基础方法。2. Collection 参数的类型选择List、Set、数组到底该传哪个2.1 为什么参数设计成 Collection 而不是 List一个很自然的疑问既然批量查直接用List?不就行了为什么非要写 Collection答案其实在面向接口编程的思路上。方法的调用方手里可能有 ArrayList、LinkedList、HashSet、TreeSet甚至是一个从 Redis 里读出来的 Set如果参数写死 List这些调用方都得先 copy 一份才能用。把参数放宽到 Collection意味着只要是一个“元素的集合”不管是 List 还是 Set传进去就能跑。底层遍历用的就是迭代器foreach 循环对 List 和 Set 一视同仁不存在哪个类型不能用的限制。但别高兴太早Collection 不等于数组。我见过有人这么写Long[] ids new Long[]{1L, 2L, 3L}; userMapper.selectBatchIds(ids); // 编译直接报错数组不是 Collection这个传值方式在 Java 类型系统上就走不通。想传数组要么先用Arrays.asList(ids)包一层要么直接改方法设计。这也是 new 版 selectByIds 为什么提供了数组重载的原因之一。2.2 三种最常见的正确传值姿势第一种传 ArrayList适合你已经有一个 List 的场景最自然ListLong idList new ArrayList(); idList.add(1L); idList.add(2L); ListUser users userMapper.selectBatchIds(idList);第二种传 HashSet适合需要对 id 去重的场景。很多业务接口允许前端传重复 id如果你在 Service 层已经去重过一次这里传 Set 也能正常工作SetLong idSet idList.stream().collect(Collectors.toSet()); ListUser users userMapper.selectBatchIds(idSet);第三种临时造数据用Arrays.asList或者 Java 9 的List.ofListLong ids Arrays.asList(10L, 11L, 12L); ListUser users userMapper.selectBatchIds(ids);这里有个细节Arrays.asList返回的 List 是定长的不能 add但对只读查询来说没有影响。List.of更严格连 null 元素都不许有如果你确定数据都合法它是最省事的。2.3 Set 传值的隐藏问题结果集顺序变了传 Set 能过但你要知道一个隐藏行为IN 查询的结果集顺序不受传入集合顺序控制。MySQL 在WHERE id IN (3, 1, 2)时返回的行顺序大概率还是主键索引顺序也就是 1、2、3不是 3、1、2。你传入的是 HashSet那迭代顺序本身就乱查出来的结果乱上加乱。之前有个同事用 HashSet 接了一组 id查出来之后直接按 index 和前端数据配对线上数据偶尔错位查了半天才定位到是调用方传了 HashSet。排错的时候建议先看入参集合类型只要是 Set结果顺序就别指望保持一致。想严格按传入顺序返回要么查出来后内存里按 id 手动排序要么写自定义 SQL 用ORDER BY FIELD(id, 3, 1, 2)后者可以看我后面第六部分的做法。2.4 千万别拿字符串当 Collection还有一种经典错误是把 id 拼成字符串再传比如String ids 1,2,3; userMapper.selectBatchIds(ids); // 编译过不了类型都不对有些人会想办法在 XML 里用${ids}直接拼接把 SQL 变成IN (1,2,3)。这种动态拼接字符串的方式要么有 SQL 注入风险要么因为 id 本身是字符串类型时多一层引号问题非常容易翻车。selectBatchIds 设计成 Collection目的就是让你用 JDBC 占位符而不是拼字符串。所以传值方式第一条铁律Collection 对象别碰字符串。3. 传值前必须做的三道检查判空、去重、分批3.1 空集合到底会不会报错这是我想重点讲的一个坑。不同版本、不同实现空集合的行为不一样如果你只测了本地一个版本就以为稳了生产环境很可能出事。我最开始在一个 3.4.x 项目里遇到的情况是前端批量操作没勾选任何记录传了个空数组进来Service 层没判空直接调用 selectBatchIds底层生成的 SQL 变成了WHERE id IN ()。数据库给了一行语法错误接口直接 500。后来升级到 3.5.3同样的代码返回了空列表不报错了。说明框架在不同版本里对空集合的容错策略并不统一。源码层面翻下来其实新版在注入方法时加了一些防御逻辑但我不建议把可靠性寄托在框架版本上。最稳的写法是在 Service 入口统一处理if (CollectionUtils.isEmpty(idCollection)) { return Collections.emptyList(); }这样不管底层 SQL 是空 IN 还是别的行为你的业务层永远拿到的都是安全的空列表对外接口统一返回空集合而不是 null。不要试图让调用方各自判断因为每个调用方都会漏。3.2 null 入参和 null 元素调用 selectBatchIds(null) 的行为同样因版本而异但没必要去探究“它会不会抛异常”因为这本身就是调用方的问题。真正防不胜防的是集合里混入 null 元素ListLong ids Arrays.asList(1L, null, 3L); userMapper.selectBatchIds(ids);底层绑定参数时null 会被 MyBatis 当成java.sql.Types.NULL处理IN 子句里出现一个 NULL 会导致查询结果不符合预期某些数据库下还可能出现奇怪的类型转换异常。过滤掉最省心ListLong safeIds ids.stream() .filter(Objects::nonNull) .distinct() .collect(Collectors.toList());3.3 去重重复 id 对 IN 查询的影响没那么简单如果你传入的 List 里有重复 id比如[1, 2, 2, 3]数据库执行IN (1,2,2,3)不会返回两遍 id2 的行结果集也不会有重复。表面看“去不去重无所谓”但有两个副作用第一SQL 里多了一个无效占位符数据库解析和执行计划计算的成本略高虽然单次查询差别很小但高并发接口放大后能感知到。第二如果底层不是 IN 查询而是某种按 id 逐条 join 的批量策略重复 id 可能造成结果集膨胀。所以我的习惯是在入口统一去重不把脏数据带到 SQL 层。3.4 分批IN 子句的数量红线是真实存在的很多数据库对 IN 子句的项数有硬性或隐性限制。最典型的是 OracleIN 列表超过 1000 直接报ORA-01795: maximum number of expressions in a list is 1000。SQL Server 的预编译参数上限是 2100但你别真压到 2000占位符一多执行计划缓存和参数嗅探都会变慢。MySQL 没有硬性的 1000 限制但受max_allowed_packet限制并且 IN 子句写几千个值时会严重拖慢优化器索引回表次数暴增最终退化成扫全表。我推荐的单批上限是 500 到 1000再大就分批去查。一个可以直接抄的封装public T ListT selectBatchIdsSafely(List? extends Serializable ids, FunctionList? extends Serializable, ListT queryFunction) { if (CollectionUtils.isEmpty(ids)) { return Collections.emptyList(); } ListT result new ArrayList(); for (int i 0; i ids.size(); i BATCH_SIZE) { List? extends Serializable part ids.subList(i, Math.min(i BATCH_SIZE, ids.size())); result.addAll(queryFunction.apply(part)); } return result; }调用时传入userMapper::selectBatchIds返回的结果顺序和分批拼接顺序保持一致业务层感知不到分批过程。这个封装我用了很长时间比在业务代码里到处写 for 循环干净得多。4. 底层 SQL 是怎么生成的IN 子句、逻辑删除与结果顺序的谜题4.1 一条 selectBatchIds 的 SQL 拼装过程MyBatis-Plus 在启动时会通过 SQL 注入器往方法绑定 SQL 语句。selectBatchIds 实际生成的模板类似SELECT id, name, age FROM user WHERE id IN foreach collectioncoll itemitem open( separator, close)#{item}/foreachforeach 循环遍历你传入的 Collection每一项生成为一个?占位符。这说明参数数量是动态的而不是某个固定值。动态参数多的直接后果就是数据库每次都要重新解析 SQL无法命中 prepared statement 缓存。这也是为什么要分批不只是绕开 1000 限制还为了尽量复用执行计划。另外如果你开启了逻辑删除字段TableLogic底层模板会自动加上AND deleted 0这样的条件。我见过排查很久的脏数据问题最后发现是逻辑删除字段没配好导致批量查询把已删数据查出来了。如果你也遇到“用 selectById 正常、用 selectBatchIds 异常”的情况先去检查实体里逻辑删除字段的注解是否配好。4.2 结果集顺序为什么总不听话这是刚用这个方法时最容易困惑的地方。SQL 执行完返回的行序数据库根本不保证和 IN 列表顺序一致。MySQL 通常按主键索引顺序返回Oracle 可能按 block 顺序PostgreSQL 也有自己的策略。总之把传入顺序和查询结果顺序画等号是非常危险的做法。我踩过一次很深的坑需要一个按传入 id 顺序展示商品列表的接口直接用了 selectBatchIds结果线上商品顺序每次刷新都可能变。最后方案是在内存里重建顺序MapLong, User userMap users.stream() .collect(Collectors.toMap(User::getId, Function.identity())); ListUser orderedUsers idList.stream() .map(userMap::get) .filter(Objects::nonNull) .collect(Collectors.toList());idList 的遍历顺序是确定的userMap 的查询复杂度是 O(1)整体排序开销很小。如果你查出来的数据量巨大且服务端内存吃紧再考虑用ORDER BY FIELD这种数据库侧排序但这对驱动版本和库类型要求多我一般不用。4.3 分页需求怎么办selectBatchIds 这个 API 本身是不带分页的你传什么 Collection它就全量查什么。业务上要做“按 id 集合过滤后分页”有两个选择一是配合 QueryWrapperLambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.in(User::getId, idList) .orderByDesc(User::getCreateTime) .last(limit offset , size); ListUser page userMapper.selectList(wrapper);二是走自定义 Mapper 方法加 Page 参数。第二种方式我放在第六部分详写。注意用第一种方式时wrapper.in也会遇到同样的空集合问题空集合会导致最终 SQL 没有 IN 条件可能把全表数据捞出来所以判空逻辑在哪个 API 上都不能省。5. 传值踩坑实录从空集合到百万级 IN 的完整排查链路这一部分我挑四个真实处理过的问题按“现象-猜测-定位-解决”的顺序写一遍希望能帮你在类似问题上少走弯路。5.1 空集合导致 SQL 语法错误现象前端批量删除时没选数据接口返回 500日志里有一条 SQL 异常SQL 最后一段是WHERE id IN ()。第一反应我以为是谁改动了 XML看了 mapper 接口发现就是一行 selectBatchIds。怀疑框架有 bug于是去 GitHub 翻 issues发现同样问题不少结论是版本差异导致空集合处理不一致。后来在 Service 层统一加空集合守卫问题消失。这个坑的核心教训是框架层的行为变化不可控业务层入口防御必须自己写死。我后来每次发布新版本依赖升级都会补跑一遍空集合、null 集合、单元素集合的冒烟用例。5.2 Oracle 1000 上限引发的生产告警现象一个数据同步任务跑批处理 id 超过两千个Oracle 直接报 ORA-01795任务失败。排查过程比较快因为报错信息已经点名是表达式数量超限。难点在于定位到“哪些调用链没有走分批封装”。当时项目里有两个同事分别用 selectBatchIds 查数据一个走了统一封装一个自己另写了一套循环导致封装形同虚设。最后把入口收敛只允许走唯一封装方法才算彻底解决。如果你遇到的是 MySQL 超大数据量 IN 导致慢查询建议分批之外还可以考虑临时表 join 方案但先别追求最优化分批到 1000 内往往已经能解决 80% 的问题。5.3 HashSet 打乱结果顺序的线上事故现象活动页面偶尔出现奖品顺序错乱刷新后不同。出问题的接口逻辑是先从 Redis 里读出中奖用户 idHashSet再用 selectBatchIds 查出用户信息按 List 顺序渲染。我接手时先怀疑 Redis 顺序后来确认 Hash 迭代确实无序于是把去重改成了 LinkedHashSet但问题依旧。最终定位到结果集顺序不跟随传入顺序Redis 里的顺序只在入参阶段保持查回来后数据库顺序把它覆盖了。修复就是我在 4.2 节写的内存重排方案用传入 idList 的顺序驱动结果排序。这个 case 的教训是即便调用方保证了集合顺序selectBatchIds 也不保证结果顺序。想要稳定就必须自己动手重排。5.4 超大 idList 把 MySQL 打崩现象数据清洗任务里把全量用户 id 一次性传入 selectBatchIds数据库连接直接耗尽CPU 飙升。定位时我抓了慢查询日志发现 IN 子句里有两万多个参数执行计划直接走了全表扫描查询时间到了分钟级。解决办法是分批加并发批次大小 500用固定线程池提交查询任务统一合并结果。这里要特别提醒并发分批一定要控制好线程数和数据库连接池大小不然每次请求开 50 个线程同时打库池子瞬间被占满。我当时的配置是单次任务查询线程数 8每批 500结果合并后按 id 排序返回。压测结果是整体耗时从分钟级降到秒级数据库负载稳定。6. 进阶把传值方式握在自己手里自定义批量查询的几种实践6.1 自定义 Mapper 方法foreach 的正确姿势selectBatchIds 能满足 80% 场景但带条件、带排序、带分页时就要自定义了。在 Mapper 接口里增加方法ListUser selectByIdsWithCondition(Param(ids) Collection? extends Serializable ids, Param(status) Integer status);对应的 XMLselect idselectByIdsWithCondition resultTypecom.example.User SELECT * FROM user WHERE status #{status} AND id IN foreach collectionids itemid open( separator, close) #{id} /foreach ORDER BY id /select注意两点第一Mapper 方法参数如果你加了Param(ids)XML 里 collection 的值就必须是ids别写成 list 或 collection否则运行期会报“There is no getter for property named”之类的绑定错误。第二foreach 的 open 和 close 别漏掉左右括号这种 SQL 拼错了找起来很痛苦而且你在日志里看到的 SQL 可能已经是被数据库格式化过的。6.2 在自定义 SQL 里加排序和分页如果你要对大批量 id 按原序返回可以在 XML 里这样写以 MySQL 为例SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach ORDER BY FIELD(id foreach collectionids itemid open, separator, close) #{id} /foreach不过这个写法不方便复用我更推荐把排序逻辑放内存。分页则可以借助 MyBatis-Plus 的分页插件IPageUser page new Page(current, size); userMapper.selectUserPageByIds(page, idList, status);XML 里查总数和查列表都基于同一个 WHERE 条件分页插件会自动拦截 count 查询。6.3 分批并发查询的成熟封装当你需要批量查询的数据量很大又想尽量快我建议分两层外层定义统一的批量查询入口内层把分批和并发封装好。伪代码如下public T ListT batchQueryConcurrent( List? extends Serializable ids, FunctionList? extends Serializable, ListT queryFunction, int batchSize, ExecutorService executor) throws InterruptedException { ListT result new ArrayList(); ListCompletableFutureListT futures new ArrayList(); for (int i 0; i ids.size(); i batchSize) { List? extends Serializable part ids.subList(i, Math.min(i batchSize, ids.size())); futures.add(CompletableFuture.supplyAsync(() - queryFunction.apply(part), executor)); } for (CompletableFutureListT future : futures) { result.addAll(future.join()); } return result; }这个封装只适合后端批处理任务不建议直接用在对外接口的同步链路里那会放大延迟和连接池压力。对外接口宁可多等一点也要保证稳定。6.4 和缓存结合时的注意点最后提醒一点缓存场景。selectBatchIds 的结果如果缓存到 Rediskey 的设计要考虑 id 列表的稳定性。我见过同事用原始 idList 字符串做 keyid 顺序一变缓存命中率骤降重新回源时又是一次大 IN 查询。更好的做法是先排序再生成 key或者直接把批量查询结果按单条 id 分开缓存查询时逐 id 取缓存未命中的 id 再拼成一个 mini 的 selectBatchIds。第二种方式有额外的重建顺序成本但对于读多写少、单条数据访问频繁的业务收益非常明显。不过要注意逐 id 缓存清理会麻烦一些最好在更新数据时用管道批量删除 key。我在实际项目里的体会是selectBatchIds 最大的价值不是让你少写几行代码而是逼你把数据访问从“循环单查”提升到“集合思维”。真正稳定好用的不是这个 API 本身而是你围绕它造出的统一入口判空、过滤 null、去重、分批、顺序重排缺一不可。如果你也在封装这类公共方法建议从这几个点入手比一味追求 SQL 技巧实在得多。
返回列表