最近有个项目组找我排查接口越来越慢的问题。翻代码的时候发现一个典型场景Mapper 里是一句select * from order_detail where order_id ?Service 层拿到结果后用.stream().filter(...).map(...).collect(Collectors.toList())做了一大堆内存处理——过滤、去重、还有一处硬编码的时间范围比较。再看一眼 resultType 里的类型映射日期字段全部映射到java.util.Date拿到 Java Stream 里又转成LocalDateTime中间有一行 NPE 就这么被吞了。我心里第一个念头是MyBatis 已经把数据查出来了Java Stream 又在内存里重新实现了一遍 SQL。这类问题正好是标题里说的那条线从 SQL 语法到类型转换再到内存里的流式操作每一层都可能埋雷。这篇文章就围绕我实际踩过、也帮别人捡过的坑来写。适合正在用 MyBatis/MyBatis Plus 做数据访问又在 Service 层喜欢用 Stream 处理集合的开发者。你会看到大量可以直接抄走的配置、写法、排查顺序以及我不会写在公司文档里的教训。1. 为什么 MyBatis 和 Java Stream 这对组合容易踩坑先说一个很容易被忽略的事实MyBatis 是数据库访问层Java Stream 是内存集合处理层两者都在“处理数据”但处理方式完全不同。MyBatis 把一个查询翻译成 SQL 推到数据库执行数据库可以利用索引、分布式存储、并行计算Java Stream 默认就是单机 JVM 内存里跑数据量一大性能差距是数量级的。很多团队习惯把条件写在代码里而不是 SQL 里结果就是数据库那边省了事应用服务器直接被打爆。这个组合的另一层风险是“语义错位”。SQL 里有 where、group by、order byStream 里有 filter、collect、sorted看起来一一对应但 SQL 的优化器会根据表统计信息调整执行计划Stream 只能老老实实把全部数据加载到内存再从头到尾跑一遍。同一个过滤条件写进 SQL 可能走索引只查几十行写在 Stream 里就是几百万行全量加载。1.1 一条 SQL 能解决的事别用 Stream 在内存里硬扛最常见的反模式是selectByExample查出全表然后 Service 层用 Stream 做条件过滤。比如我要查“本周创建的、金额大于 1000 的订单”代码写成ListOrder orders orderMapper.selectAll(); ListOrder result orders.stream() .filter(o - o.getCreateTime().after(weekStart)) .filter(o - o.getAmount().compareTo(new BigDecimal(1000)) 0) .collect(Collectors.toList());这个小项目测试环境可能只有几千条数据跑起来没啥感觉。上线后数据涨到几十万条每次接口请求都把全表捞进内存GC 和响应时间立刻爆炸。正确的做法是把条件压到 SQL 里select idselectRecentLargeOrders resultTypeOrder SELECT * FROM orders WHERE create_time gt; #{weekStart} AND amount gt; 1000 /select我现在的判断原则很简单能在数据库里用索引过滤的绝不用 Stream 过滤分页、聚合、排序这类数据库原生能力也优先交给 SQL。Stream 只处理那些“数据库不方便做”的事比如多数据源结果合并、业务规则组合、或者从多个 Mapper 结果里拼装视图对象。1.2 Stream 的强项与使用边界那 Java Stream 在 MyBatis 场景里到底有什么用我的经验是三个方向。一是 DTO/VO 转换。Mapper 查出实体后需要转成前端展示模型字段裁剪、格式化、枚举转标签stream().map(...).collect(...)比 for 循环简洁得多。二是内存中的多结果集关联。两个查询结果都不大但不想写复杂 JOIN就在内存里用Map Stream 做关联。三是细粒度后置过滤。有些过滤条件依赖外部服务返回的数据没法写进 SQL只能在内存里做。但使用边界必须清楚数据量要小单次查询结果能控制在内存可接受的范围内。我自己会给一个朴素标准——单表结果预期超过一万行就要警惕超过十万行基本不该用 Stream 做全量处理。另外Stream 链式调用越长越要留意每一步的操作复杂度sorted、distinct、filter混在一起时不要以为都是 O(n)sorted就是 O(n log n)大集合上差别很明显。2. SQL 语法与类型映射MyBatis 最容易翻车的区域很多 Stream 相关的问题根子其实在 SQL 层。查询结果类型不对、字段对不上、时间被转成什么类型根本没底后面的 Stream 代码写得再漂亮也白搭。MyBatis 的 SQL 语法坑我按踩的频率排个序resultType 映射规则、#{}与${}的误用、时间类型映射、FOREACH 空集合。2.1 resultType 与 resultMap驼峰、下划线、别名MyBatis 用resultType做自动映射时默认规则是数据库列名直接对应 Java 属性名。如果数据库字段是user_name实体属性是userName不配置任何东西查出来userName就是 null。很多新手第一步就挂在这里。解决办法有两个。第一全局开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true这个配置对绝大多数场景够用但注意它只处理“下划线转驼峰”如果列名是userName数据库里不规范实体属性也叫userName它不加区分也能映射上。第二复杂字段或表别名不一致时老老实实用resultMapresultMap idOrderDetailMap typecom.example.OrderDetail id columnid propertyid/ result columnorder_no propertyorderNo/ result columnpay_amount propertypayAmount/ result columncreate_time propertycreateTime jdbcTypeTIMESTAMP/ /resultMap还有个容易忽略的细节resultType返回MapString, Object时key 默认就是数据库列名Oracle 下还可能是全大写后续用 Stream 取 key 的时候大小写写错直接取到 null。这类问题排查起来非常隐蔽我建议 Map 返回场景尽量用MapKey或者转成 DTO别偷懒。2.2 #{} 与 ${} 别乱用顺序字段和模糊查询是重灾区#{}是预编译参数${}是字符串拼接。安全上#{}几乎无敌${}会有 SQL 注入风险。但有些场景必须用${}比如动态排序字段、动态表名select idselectOrders resultTypeOrder SELECT * FROM orders ORDER BY ${orderByColumn} ${orderDir} /select这里如果改用#{}排序字段会被当成字符串字面量SQL 直接报错。使用${}的底线是对传入值做白名单校验比如在 Service 层用Map或List校验合法字段名绝不能把前端参数直接拼进去。模糊查询是另一个重灾区。like %${keyword}%看着简单但会破坏预编译且 keyword 里的单引号能直接改 SQL 结构。正确写法select idsearchByName resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %) /select如果用的是 OracleCONCAT只能传两个参数嵌套一层CONCAT(%, CONCAT(#{keyword}, %))。在 MySQL 和 Oracle 之间切换时这种语法差异最容易埋雷项目里真见到过测试库 MySQL 好好的生产是达梦/Oracle上线就翻车。2.3 时间类型映射Oracle、达梦和 MySQL 完全不是一回事时间字段映射是“类型转换”里最常出问题的一块。MySQL 的datetime映射到 Java 8 的LocalDateTime很自然MyBatis 3.4 自带支持。但 Oracle 里的DATE类型同时包含日期和时间直接映射到java.util.Date没问题映射到LocalDateTime在某些版本里会报TypeException或者静默丢精度。还有 Oracle 的TIMESTAMP WITH LOCAL TIME ZONE映射过来会带时区信息和 MySQL 的行为完全不同。我现在的处理方式是实体里时间字段统一用LocalDateTime或LocalDateXML 里通过jdbcTypeTIMESTAMP显式指定同时确认 MyBatis 版本里对应的 TypeHandler 存在。如果是老项目还在用java.util.Date那就要小心 Stream 里的比较逻辑Date转LocalDateTime时要先转Instant否则会掉进时区坑。to_char/to_date 这类数据库函数也要注意。![CDATA[包裹的必要性很多人在时间比较时踩了大于小于号的坑select idselectByTime resultTypeOrder SELECT * FROM orders WHERE create_time lt;![CDATA[ gt; ]]#{startTime} /select这个lt;![CDATA[ gt; ]]#平时不写没关系一旦写成或XML 解析直接报错。2.4 FOREACH 与 IN 子句空集合和批量操作的边界foreach循环拼IN条件是 MyBatis 里非常常用的操作但空集合问题会直接让 SQL 语法报错。比如select idselectByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select如果ids是空集合生成的 SQL 是WHERE id IN ()数据库直接给语法错误。代码层必须提前判断if (ids null || ids.isEmpty()) { return Collections.emptyList(); }还有一个容易被忽略的点foreach批量插入时MySQL 可以一条INSERT INTO ... VALUES (...),(...)但 Oracle 支持的是INSERT ALL或者INSERT INTO ... SELECT ... FROM dual UNION ALL ...。如果项目要兼容多数据库别把 SQL 写成只有一种方言能跑的样子。批量 update 用foreach拼多条语句在 MySQL 默认连接参数下要加allowMultiQueriestrue否则直接报错。3. 用 Java Stream 处理 MyBatis 查询结果转换与类型安全的细节假设 SQL 层已经干净了查询结果也正确返回了接下来就是 Java Stream 的战场。这个章节我讲的不是 Stream API 基础用法而是它在 MyBatis 结果集上使用时的特殊注意点。3.1 map collect 做 DTO 转换时的空值、枚举与数字把实体转成 DTO最典型的写法ListUserDTO dtos users.stream() .map(user - UserDTO.builder() .id(user.getId()) .name(user.getName()) .status(StatusEnum.of(user.getStatus())) .build()) .collect(Collectors.toList());这里至少有三个坑。第一个是空值。如果users本身是 null——比如 MyBatis 查询结果没匹配到记录时 Mapper 会返回空 List 但某些老代码会返回 null——users.stream()直接 NPE。建议从源头规范Mapper 查询永远返回非 null 的 List习惯性用Collections.emptyList()兜底。或者代码里加Optional.ofNullable(users).orElse(Collections.emptyList()).stream()。第二个是枚举转换。StatusEnum.of(user.getStatus())如果数据库里有个脏数据状态值是枚举里没有的of方法返回 nullDTO 里就多了一个 null 字段前端拿到后可能直接显示异常。我建议枚举转换方法里对未知值做默认处理或者直接抛业务异常不要在 Stream 里静默吞掉。第三个是数字类型。数据库decimal映射到BigDecimalStream 里做map(Order::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)时别忘了初始值如果集合为空reduce不带初始值返回Optional不判空就崩。金额计算别用Double精度问题能在线上坑哭你。3.2 parallelStream 和 MyBatis 返回对象组合时的坑list.parallelStream()看起来很爽但在 MyBatis 返回的对象上使用时我一般劝退。原因有几个。第一MyBatis 返回的 List 通常是ArrayList并行拆分没问题但如果你用了resultMap里的collection映射嵌套集合得到的可能是自定义LinkedList结构并行流的拆分效率很差性能不升反降。第二如果流里的操作涉及共享可变状态比如groupingBy的并发收集、静态缓存 map 的写入parallelStream 会带来线程安全问题。第三MyBatis 返回的实体如果是被拦截器代理过的对象比如分页插件、乐观锁插件并行流里访问属性可能触发额外查询或代理逻辑这是隐性的性能炸弹。我的建议除非明确知道集合大、操作是纯 CPU 密集且无共享状态否则不要用 parallelStream 处理 Mapper 查询结果。数据库的并行能力远比 JVM 里的并行流可靠。3.3 Stream 和缓存对象要小心你“随手改”了共享数据这一条是我在二级缓存章节之外特别想强调的。MyBatis 一级缓存默认开启二级缓存如果配置了查询结果对象在缓存生命周期内可能是同一个实例。Stream 里做map转换时如果直接改了原对象属性而不是生成新对象下次查询拿到的就是被你改过的数据。比如这段代码orders.stream() .peek(order - order.setStatus(2)) .collect(Collectors.toList());peek里直接修改了 MyBatis 返回的实体对象如果这些对象被一级缓存引用同一 Session 里的第二次查询会拿到被修改后的状态但数据库里根本没变。排查起来相当绕。我的原则是Stream 操作里一律生成新的 DTO 或复制对象绝不在peek、forEach里改原实体。4. 现场实录update 执行慢、Update 参数绑定和分页插件这一章是问题排查的记录。网上搜“mybatis update 执行慢”能搜出一堆但大多数都让你去查数据库索引实际项目里真正的问题往往不在数据库而在 MyBatis 的使用姿势。4.1 update 执行慢先自查这几层我处理过的最典型的一个 case一个Update注解方法执行要 2 秒数据库表就 20 万行主键也是上好的唯一索引。排查后发现XML 里的 update 语句使用了一个set动态语句其中第一条if testcompanyId ! null判断的参数 companyId 是字符串null导致每次 set 的字段数量不一样影响了数据库对执行计划的判断但这不是主因。真正的慢点在于这条 update 的where条件里使用了${}拼接了一个dept_code这个字段在表里有索引但传入的参数带了前导空格索引失效走了全表扫描。去掉空格耗时从 2 秒降到 3 毫秒。排查顺序我总结如下先用日志打印真实执行的 SQL见第 5 章确认 where 条件是否合理。检查传入参数的类型和值尤其字符串前后空格、BigDecimal 的 scale。检查 update 的返回值。MyBatis 返回的是受影响行数如果数据库被配置成了“匹配到即返回成功”而不是“实际变更行数”默认情况下useAffectedRows是 false返回 0 会让你误判为执行失败。最后才去数据库看锁等待、慢查询日志、索引统计信息。4.2 Update 注解写动态 SQL你绕不开的script很多人喜欢在注解里直接写 SQL觉得比 XML 省事。但注解里写动态 SQL 有硬限制比如if标签要写成这样Update({script, UPDATE orders, set, if teststatus ! nullstatus #{status},/if, if testpayTime ! nullpay_time #{payTime},/if, /set, WHERE id #{id}, /script}) int updateOrderStatus(Order order);不包script的话MyBatis 会把if当成字符串直接拼 SQL结果就是语法错误。还有一个小坑注解里写的字符串拼接如果忘了每行后面加空格会把SETstatus这种词连在一起报错的时候看日志很容易懵。我个人的建议是超过两行、带动态条件的 SQL一律放 XML 文件IDE 有语法高亮和标签提示排查也容易。4.3 分页插件的“默认用法”和缓存、序列化冲突分页插件PageHelper 等的核心用法是startPage必须在查询语句之前且只对下一条查询生效PageHelper.startPage(pageNum, pageSize); ListOrder list orderMapper.selectByCondition(cond); PageInfoOrder pageInfo new PageInfo(list);如果startPage之后跟的不是一个 Mapper 查询而是一段业务逻辑里提前查了别的数据分页参数就会作用于错误的查询导致数据莫名其妙变少。这是分页插件最经典的使用失误。分页插件返回的Page对象本质是ArrayList的子类里面带了total、pageNum这些额外属性。如果你把它直接放入 Redis 缓存序列化框架比如 Fastjson会把Page的额外字段一起序列化但反序列化回来就变成普通 Listtotal丢了。这也是很多“分页数据缓存后分页信息没了”的根因。我的做法是缓存对象永远存纯 DTO/List分页信息单独存。5. 让 SQL 自动“开口说话”日志、拦截器和诊断工具前面反复提到“先打印 SQL”这一章把方法给全。很多类型转换问题、参数绑定问题只要看到真实 SQL 和参数类型基本一眼就能定位。5.1 配置打印 SQL把参数补回去最简单的方式是在 MyBatis 配置里设置日志实现mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样控制台能看到这样的输出 Preparing: SELECT * FROM orders WHERE id ? Parameters: 123(Integer) Columns: id, order_no, amount, create_time Row: 123, SO20240101, 1000.00, 2024-01-01 10:00:00这里能看到参数类型非常有价值。比如参数是123String和123Integer虽然很多时候数据库会隐式转换但在 Oracle 里字符串和数字比较可能触发隐式转换导致索引失效。日志里一眼就能看出来。5.2 一个轻量拦截器在日志里看到完整 SQLStdOutImpl只打印占位符和参数不直接拼接完整 SQL。排查问题时我习惯用一个简单拦截器把 PreparedStatement 的占位符替换成真实参数直接在日志里看到可执行的 SQL。核心思路是实现 MyBatis 的Interceptor接口拦截Executor.query/update拿到BoundSql里的ParameterObject和 sql然后用 JDBC 的参数元信息替换?。这个拦截器我只在本地调试环境启用线上不跑因为字符串替换可能对?出现在字符串字面量里的情况处理不干净。如果不想自己写也可以引入 p6spy 之类的第三方工具它能直接输出完整 SQL 和执行耗时。5.3 类型转换排查要善用 TypeHandler 和断点如果日志显示查询成功了但实体里某个字段是 null或者类型转换异常问题多半出在 TypeHandler 上。MyBatis 内建了几十种 TypeHandler但遇到自定义枚举、JSON 字段、加密字段时默认行为可能不符合预期。排查步骤我一般这么走先确认数据库列类型和 Java 属性的实际类型。查一下当前 MyBatis 版本对该数据库类型的 TypeHandler 是否内置。如果内置处理不对写一个自定义 TypeHandler在setNonNullParameter和getNullableResult里打印参数类型和值用日志确认走向。比如 PostgreSQL 的 JSONB 类型默认映射到 String 可能可以拿到字符串但映射到对象就必须自定义 TypeHandler。这种问题在 XML 里加上typeHandler属性就能解。6. 缓存这把双刃剑一级缓存、二级缓存与实体设计网上关于“mybatis 二级缓存”的讨论很多但大部分只讲配置。我在项目中遇到的实际问题往往跟 Stream 和对象引用有关系所以专门开一章。6.1 一级缓存的作用域和偶发“脏读”MyBatis 一级缓存是SqlSession级别的默认开启。同一个 Session 内执行两次相同查询第二次直接走缓存不查数据库。Spring 整合 MyBatis 后每个 mapper 方法可能独立创建和关闭 SqlSession一级缓存本身没有跨方法共享所以很多人对它没感知。但要注意的是如果你在一个事务方法里调用了两次同一个查询中间没有任何 update/insert第二次查询拿到的结果是第一次缓存的对象引用。这时如果第一次的结果被 Stream 里的某个操作修改了第二次拿到的就是脏数据。解决方式和 3.3 一样不要改缓存对象。6.2 二级缓存开启后实体类的序列化约束二级缓存跨 SqlSession 共享默认需要实体类实现Serializable接口否则缓存读写阶段直接抛异常。配置也简单cache evictionLRU flushInterval60000 size512 readOnlytrue/readOnlytrue时性能好但返回的是同一份缓存实例任何一方修改都会影响所有拿到这个对象的调用者。我的建议是readOnlyfalse让 MyBatis 返回序列化副本虽然性能差一点但能避免很多线上灵异问题。实体类里的 BigDecimal、LocalDateTime 字段要确认能序列化不能序列化的自定义类型会打破缓存。6.3 缓存对象被 Stream 修改后的“灵异事件”我遇到过一个特别难查的问题一个查询接口偶尔返回不一样的数据但数据库里数据没变过。查了很久才发现某次调用里有人对查询结果做了一个 Stream 操作用Collectors.toMap转成 Map 后又往原 List 里 add 了一条记录而这个 List 对象来自二级缓存。下一次查询缓存直接命中返回了被改过的集合。从那以后我定了个死规矩所有从 MyBatis 拿回来的集合进入 Stream 链之前先转成不可变集合或者明确复制一份。List.copyOf()或者Collections.unmodifiableList()都行至少能在早期发现谁在试图修改缓存数据。最后分享一个我自己的体会。MyBatis 和 Java Stream 组合本身没有谁对谁错但脑子里要有一根弦SQL 是数据库帮你干活的接口Stream 是 JVM 内存里的“本地计算”。能用 SQL 表达的条件就推给数据库真正剩下的小数据量后处理再用 Stream。遇到任何诡异问题第一件事不是看业务逻辑而是把真实 SQL 和参数类型打出来类型转换不匹配、参数绑定错位日志里基本都是直接暴露的。这个习惯帮我省下了大量定位时间希望也能帮你少踩几个坑。
