MyBatis搭配Java Stream的线上事故避坑指南:类型映射、缓存与SQL方言
大概半个多月前我负责的一个订单查询接口突然被线上告警轰炸服务调用耗时从平均 200ms 飙升到 2s 多整整十倍。初步排查 SQL 没变、索引没失效、数据库负载也不高最后翻到业务代码才发现同事在 MyBatis 返回的 List 上用了 Stream 的toMapkey 用的是枚举字段数据库里存的却是字符串 0/1类型对不上导致分组结果全部错乱后面又拿错误数据做了几次数据库回查直接把接口拖垮。这其实就是今天想聊的主题——MyBatis 和 Java Stream 单独用都没问题放一起用SQL 语法、类型转换、缓存、Stream 特性这些环节任何一个出纰漏都会变成线上事故。这篇文章不打算讲官网文档里能查到的基础 CRUD 用法直接说坑、说排查过程、说修复方案。适合正在用 Spring Boot MyBatis 做业务开发的兄弟也适合刚接手老项目、被各种“灵异现象”折磨的新人。你会看到动态 SQL 的语法陷阱、分页插件和不同数据库方言的边界、结果集转 Map 时的类型爆炸、JDBC 类型到 Java 类型的映射规则以及缓存和慢 SQL 的排查思路全部来自我踩过的真实案例。1. 项目里为什么总把 MyBatis 和 Stream 放一起用1.1 最常见的组合场景先说场景。现在项目里基本逃不出两种玩法第一种查一张大表把数据全部捞到内存里然后用 Stream 做过滤、排序、分组、去重美其名曰“减轻数据库压力”第二种查多张表的数据在 Service 层用 Stream 做关联、转换把 N 条 SQL 化成一条或者干脆避开复杂的嵌套查询。第一种玩法的典型代码长这样ListOrderDO orderList orderMapper.selectByStatus(status); MapLong, ListOrderDO userOrderMap orderList.stream() .collect(Collectors.groupingBy(OrderDO::getUserId));看起来没毛病但线上数据量一大第一个问题就是内存占用直接炸掉。我见过一张 5000 万行的流水表同事直接select * from t_payment_flow全量捞出来再分组结果应用 JVM 老年代蹭蹭往上涨几分钟后 Full GC 频繁到把 CPU 打满。这不是 Stream 的锅但确实是“MyBatis Stream”组合最容易犯的错误——把本该在 SQL 层做的聚合搬到了内存里。第二种玩法也坑不少。比如从订单表查出订单列表从用户表查出用户列表然后用 Stream 把两个 List join 起来MapLong, UserDO userMap userMapper.selectBatchIds(userIds).stream() .collect(Collectors.toMap(UserDO::getId, u - u, (a, b) - a));这种做法的好处是减少数据库连接次数避免 N1 查询。但坏处是如果左边 list 很大、右边 map 也很大join 在内存里的复杂度是 O(nm)看着挺美实际上一旦 userId 列表里混进几个重复 idtoMap直接抛IllegalStateException接口就 500 了。1.2 三个最容易爆的雷区把两个技术放一起坑基本集中在三条线第一是类型映射线。数据库返回的类型和 Java 实体的类型不一致比如数据库是VARCHARJava 这边映射成IntegerMyBatis 会做自动转换但遇到空字符串、脏数据就翻车。第二是 Stream 行为线。Stream 是惰性求值的中间操作不会立刻执行直到遇到collect、forEach这种终端操作才真正遍历。所以一旦中间某个 map 或 filter 抛了 NPE日志里排查起来特别费劲因为报错位置和问题数据的产生位置经常不在一起。第三是 SQL 方言线。MyBatis 的 XML 里写 SQL 要兼容不同数据库MySQL 能用limitOracle 要用rownum达梦数据库又是另一套方言分页插件能屏蔽一部分差异但动态 SQL 里一旦写了不兼容的语法测试环境 MySQL 跑得好好的生产切了数据库就当场暴毙。我后面会分别展开这三条线每条线都会配上真实案例和修复后的代码。2. SQL 语法层面动态 SQL 和分页查询的坑2.1if标签里的 null 和空串是两码事MyBatis 动态 SQL 里最常用的就是if但恰恰是这个最基础的标签翻车率极高。先看一个典型错误写法select idselectByCondition resultTypecom.example.OrderDO SELECT * FROM t_order WHERE 11 if teststatus ! null and status ! AND status #{status} /if /select如果status是Integer类型当它等于 0 的时候OGNL 表达式status ! 会触发一个非常诡异的逻辑。OGNL 字符串比较和 Java 的equals不是一回事这里的会被解析成空字符串而Integer和String做!比较时OGNL 会把两边都转换成BigDecimal再比。Integer 0和空字符串转换出来的BigDecimal不相等所以条件成立——看起来没问题。但反过来当你把status传成nullOgnl 表达式status ! null and status ! 时前半句已经短路了不会执行后半句也没问题。真正的坑出现在status是字符串类型、且你传的是0这个值时0 ! 成立SQL 正常拼上了。那问题在哪在于很多开发者会在if里放两个字段一个整型一个字符串共用同一个条件判断结果整型字段为 0 时判定为“空”从而被过滤掉数据就查不出来了。比如if testtype ! null and type ! AND order_type #{type} /if这里type是Integer传入0表示“全部订单类型”但type ! 在 OGNL 里对0的处理非常隐晦不同 MyBatis 版本行为还不完全一致有的版本会直接把0当成空值处理。这种问题没有日志根本看不出来排查的时候一脸懵SQL 打印出来没有order_type这个条件但代码里明明传了值。我的建议是字符串字段才用! 判断数字类型只用! null。分开写别嫌啰嗦if testtype ! null AND order_type #{type} /if另外注意一个细节if判空时如果字段是List类型要先用if testlist ! null and list.size() 0或者直接用foreach的collection配合空集合判断不要写list ! List和String的!比较在 OGNL 里结果不可控。2.2 XML 里的特殊字符转义和 CDATA 的边界XML 里写、、这种符号第一反应是用lt;、gt;转义。这个大家都懂但很少有人注意 CDATA 的用法。有些兄弟图省事把整段 SQL 都包在![CDATA[ ... ]]里结果里面再写if动态标签就失效了因为 CDATA 里的内容会被当作纯文本MyBatis 根本不会解析里面的动态标签。正确做法是把 CDATA 只包裹需要特殊字符的片段动态标签放在 CDATA 外面。比如select idselectByAmount resultTypecom.example.OrderDO SELECT * FROM t_order WHERE 11 if testminAmount ! null ![CDATA[ AND amount #{minAmount} ]] /if /select这样既保证了不被 XML 解析器报错又能让if正常工作。还有一个细节在if里做字符串比较时比如teststatus SUCCESS注意单引号和双引号的嵌套——XML 属性本身用双引号OGNL 表达式里字符串必须用单引号很多新手在这里被编译期错误反复折磨。而且字段名和关键字冲突的问题也得提一嘴。我接手过一个项目表里有个字段叫desc在 MySQL 里不是保留字但很多方言里是结果写SELECT desc FROM t_order在国产数据库达梦上直接语法报错。稳妥做法是给字段加反引号或双引号取决于数据库方言或者在 SQL 里用表别名限定比如o.desc。换数据库的时候这种隐藏的语法坑比业务 bug 还难找。2.3 分页插件不是简单的“加个 limit”关于分页插件很多人只知道加个PageHelper.startPage()就完事但实际上有不少注意点。分页插件的原理是通过 MyBatis 拦截器改写 SQL它会在你查询之前把原始 SQL 包一层变成SELECT count(*)和带分页的 SQL。所以它的第一条铁律是startPage 之后必须紧跟第一条查询语句中间不能有任何其他数据库操作否则分页条件就会被应用到错误的 SQL 上。我见过一个线上事故就是下面这种写法PageHelper.startPage(pageNum, pageSize); ListOrderDO list orderMapper.selectOrder(); // 中间又查了一次用户信息 ListUserDO users userMapper.selectUsers();这会导致第二个查询也被分页插件拦截用户列表被莫名其妙地 limit 了一下订单分页倒是对的但用户列表丢了数据。排查半天才发现 startPage 的“生效范围”是下一个查询而不是下一个方法。分页插件还有一个容易被忽略的点count 查询的优化。如果你用了多表 join 或者distinct原生的select count(*)可能性能很差。PageHelper 支持手写 count SQL用SelectProvider或者 XML 里写select idcountByCondition来显式覆盖插件生成的 count。我的经验是分页表数据超过千万级别count 查询的代价不亚于主查询一定要把手写 count 作为标配。另外如果要兼容 Oracle、MySQL、达梦这些不同数据库分页插件能帮你屏蔽rownum、limit语法差异但要注意 order by 的字段在不同数据库里的大小写敏感度不一样排序字段如果是汉字或者在 Oracle 里 varchar2 默认排序规则容易跟 MySQL 的排序结果不一致。或者说得更直接一点如果项目可能切换数据库前期就要把分页插件版本定好别等到上线前才发现分页 SQL 在 Oracle 上rownum嵌套层级不对临时换方案成本极高。3. Java Stream 处理 MyBatis 结果集从 List 到 Map 的陷阱3.1toMap的 key 冲突和 NPE一次遇到一个Stream 里最常用也最容易出事的就是Collectors.toMap。它的签名有几种重载很多人图省事用两个参数的版本MapLong, OrderDO orderMap orderList.stream() .collect(Collectors.toMap(OrderDO::getId, Function.identity()));结果就是只要列表里有两个相同id直接抛IllegalStateException: Duplicate key。为什么因为两个参数的toMap背后默认的合并策略是“抛异常”。实际场景里多表 join 查出来的数据、分页查出来的数据、或者定时任务查出来的增量数据都很容易带出重复 id。一旦重复接口就挂。我在项目里强制规定所有用到toMap的地方一律使用三个参数的版本第三个参数明确 merge 策略MapLong, OrderDO orderMap orderList.stream() .collect(Collectors.toMap(OrderDO::getId, Function.identity(), (a, b) - a));如果你细心一点会发现三个参数版本的toMap还有一个隐藏问题value 不能为 null。当Function.identity()对应的 value 本身就是 null 时toMap会抛NullPointerException因为Map.merge方法底层不接受 null value。这在 MyBatis 查询结果里非常常见——数据库字段允许为 nullDO 里的属性自然就是 null。解决方法要么在 SQL 层用COALESCE兜底要么在 Stream 里先filter(Objects::nonNull)再 collect要么干脆用HashMap手动 put。我之前有个项目就是在导出报表时踩了这坑一条订单的remark字段是 null结果Collectors.toMap(OrderDO::getId, OrderDO::getRemark)整个崩了排查日志发现 NPE 发生在 collect 阶段跟业务代码完全对不上号。之后但凡遇到 Stream 转 Map我都先问一句这里面有没有可能为 null 的字段3.2groupingBy、parallelStream和 Stream 的“惰性”特性groupingBy是另一个高频操作。它默认会把分组的 key 存成HashMap如果结果需要保持顺序你得用groupingBy(Function.identity(), LinkedHashMap::new, Collectors.toList())。这个细节一般不致命但配合 MyBatis 的分页查询就有意思了分页插件返回的数据顺序是 SQL 的order by决定如果你在 Java 里用groupingBy再包装一层顺序就乱了很多人没意识到分组后还要再排序。还有一个必须强调的点groupingBy的 value 集合是可变的 ArrayList如果你后续对这个 List 做并发操作比如多个线程同时往同一个 key 的 List 里加元素就会触底ConcurrentModificationException。实际场景里定时任务用parallelStream处理分组后的数据经常炸出这种并发问题。说到parallelStream这是 MyBatis 结果集处理里最凶险的一个。很多人觉得查出来的数据量大用并行流能加速处理。但要注意并行流底层用的是 ForkJoinPool 公共线程池如果你的业务代码里在这个池子里又调用了 MyBatis 的 mapper 方法比如每条数据查一次关联表那相当于公共池子里塞满了阻塞的数据库连接请求线程池直接被打满线程饥饿整个应用的其他并行任务全部卡死。更糟糕的是parallelStream的线程上下文拿不到 Spring 事务里的 ThreadLocal 参数Transactional传播行为完全失效——事务可能不在预期的方法边界内生效。我自己踩过这个坑之后立下规矩数据量超过 1 万才考虑并行流且并行流里严禁再去查数据库。如果非要并行处理就结合CompletableFuture自定义一个线程池把线程池和数据库连接池的参数配好不要碰公共的 ForkJoinPool。最后一定得聊聊 Stream 的惰性求值。很多新手以为list.stream().map(...).filter(...)执行完就出结果了其实中间操作只是描述了一个操作管线终端操作才真正启动求值。这意味着如果 map 或 filter 里面引用了外部可变状态比如一个计数器你很难确定这个状态在哪个节点被修改。更隐蔽的是MyBatis 的查询结果集如果是通过resultMap做的嵌套查询嵌套查询是延迟加载的你在 Stream 的map里访问关联对象属性时MyBatis 会再去执行一条 SQL。如果这段逻辑恰好又在parallelStream里就可能同时打开很多个数据库连接直接把连接池打爆。血泪教训先把所有需要的数据一次性查出来再进 Stream 处理不要在 Stream 操作里触发 MyBatis 懒加载。4. 类型转换全解析从 JDBC 类型到 Java 类型的映射4.1 MyBatis 默认的类型映射规则你知道多少MyBatis 之所以能让我们不用手写 JDBC 的getString、getInt靠的是 TypeHandler 体系。默认规则大概可以整理成一张表JDBC 类型Java 类型CHAR / VARCHAR / LONGVARCHARStringNUMERIC / DECIMALBigDecimalBIT / BOOLEANboolean / BooleanTINYINTbyte / ByteSMALLINTshort / ShortINTEGERint / IntegerBIGINTlong / LongREAL / FLOATfloat / FloatDOUBLEdouble / DoubleDATEjava.sql.DateTIMEjava.sql.TimeTIMESTAMPjava.sql.TimestampBLOB / CLOBbyte[] / String看着挺清晰但遇到两个特殊情况就乱了。第一个是数据库返回DECIMAL(10, 0)MyBatis 默认映射成BigDecimal可你的实体字段是Long自动转换大多数时候能成功但如果数据库里存了超出 Long 范围的数字转换结果会静默变成错误值——不报错但要到业务层才能发现数据不对。第二个是TINYINT(1)这个类型MySQL 里很常见MyBatis 默认映射为Byte可业务往往想把它当Boolean用。有些版本会自动转有些版本不转表现就是有的环境0/1正常有的环境抛ClassCastException。我建议在实体类上显式声明Boolean字段或者干脆在 SQL 里CAST(status AS SIGNED)别依赖框架的隐式行为。还有一个很容易踩的DATE类型默认映射成java.sql.Date如果你实体用的是java.util.DateMyBatis 也能处理但通过 getter 取出来的值可能被截断到日期时间部分丢了。这个问题的表现就是列表页显示正常详情页显示00:00:00排查半天发现是实体字段类型不对。4.2 LocalDateTime、枚举类型和自定义 TypeHandlerJava 8 时间类型LocalDateTime在旧项目里是个高频翻车点。MyBatis 3.4 之后内置了LocalDateTimeTypeHandler但如果你用的是老版本或者中间件做了特殊封装就可能出现No typehandler found for property createTime这个报错。解决办法有两种升级 MyBatis 版本或者手动注册Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - { configuration.getTypeHandlerRegistry().register(LocalDateTime.class, new LocalDateTimeTypeHandler()); }; }Spring Boot 场景下也可以通过mybatis.configuration.default-enum-type-handler指定枚举的默认处理器。再说枚举。MyBatis 默认的EnumTypeHandler会把枚举存成name()字符串也就是说OrderStatusEnum.SUCCESS存的是SUCCESS。问题是大部分业务表里枚举字段存的可能是0/1/2这种数字或者你在前端展示的是已支付这种中文。这时候默认处理器就不够用了。你需要自定义一个 TypeHandlerMappedTypes(OrderStatusEnum.class) MappedJdbcTypes(JdbcType.INTEGER) public class OrderStatusTypeHandler extends BaseTypeHandlerOrderStatusEnum { private final MapInteger, OrderStatusEnum mapping new HashMap(); public OrderStatusTypeHandler() { for (OrderStatusEnum value : OrderStatusEnum.values()) { mapping.put(value.getCode(), value); } } Override public void setNonNullParameter(PreparedStatement ps, int i, OrderStatusEnum parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } Override public OrderStatusEnum getNullableResult(ResultSet rs, String columnName) throws SQLException { return mapping.get(rs.getInt(columnName)); } Override public OrderStatusEnum getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return mapping.get(rs.getInt(columnIndex)); } Override public OrderStatusEnum getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return mapping.get(cs.getInt(columnIndex)); } }然后在 XML 里显式指定result columnorder_status propertystatus typeHandlercom.example.handler.OrderStatusTypeHandler/这里有个细节TypeHandler 的注册顺序和范围。如果你在application.yml里配置了mybatis.type-handlers-package让框架扫描注意MappedTypes和MappedJdbcTypes的组合关系——MappedJdbcTypes指定了JdbcType.INTEGER之后这个 handler 只对 INTEGER 类型的 JDBC 结果生效如果你对接的字段实际类型是VARCHAR它不会生效得再注册一个或者去掉JdbcType限定让它全局生效。实际经验告诉我能写进 XML resultMap 的 typeHandler就别依赖全局配置。全局注册看着省事但不同表、不同字段可能对同一个枚举有不同的映射含义全局一覆盖某个查询就读取错误枚举值问题特别隐蔽。显式在 resultMap 里指定虽然代码啰嗦一点但每个字段的映射一目了然。5. 缓存、参数绑定和慢 SQL 排查实录5.1 一级缓存和二级缓存能用但别乱用MyBatis 的缓存是面试最爱问、开发最容易理解错的一个点。一级缓存默认是开启的作用域是SqlSession。在 Spring 里每次 mapper 方法调用其实会创建一个新的SqlSession所以一级缓存基本只有“同一个方法内部多次查询”才有意义。比如OrderDO order orderMapper.selectById(1L); OrderDO orderAgain orderMapper.selectById(1L);如果中间没有任何更新操作第二次查询会直接命中一级缓存不会发 SQL。这个特性看起来提升性能但有个藏得很深的坑如果同一个 SqlSession 内先查了一个对象的列表然后又查它的详情再把这个对象修改后调用 update但 update 语句没有包含所有字段可能会把缓存里的旧数据带出来。因为你 update 之后一级缓存会清掉但清掉的只是当前 SqlSession 的缓存如果你用了 Spring 的Transactional整个事务内部是同一个 SqlSession事务里先查后改再查容易读到改之前的旧数据。比如说Transactional public void updateOrder(Long orderId, Integer newStatus) { OrderDO order orderMapper.selectById(orderId); // 业务处理... orderMapper.updateStatus(orderId, newStatus); OrderDO afterUpdate orderMapper.selectById(orderId); }updateStatus是一个只更新 status 字段的 SQLMyBatis 执行 update 后会清空一级缓存所以第三次查询会重新查库看到的是新状态——这个没问题。但如果你用的是Transactional又嵌套了多个 mapper 方法一级缓存没被正确清理时afterUpdate可能拿到的是旧对象。这种 bug 的隐蔽性在于不是每次都复现要看事务边界和缓存清理时机的组合。二级缓存则是 mapper 级别的默认关闭需要手动开启cache/。开启后要注意所有被缓存的实体类必须可序列化否则序列化时会报NotSerializableException。踩过一次项目开了二级缓存实体类没实现Serializable平时读写测试都正常一到并发量大、缓存过期重新回填时就偶发出现序列化异常排查很久才找到。更重要的一点是二级缓存会绕过 Spring 事务的可见性控制。A 事务提交后更新了数据并刷新缓存B 事务尚未提交此时查询走了二级缓存可能把 A 事务未提交的数据读到。这在强一致性要求的金融项目里完全不可接受。所以二级缓存适合配置基本不变的数据比如字典表、省份列表而不是业务订单这种高频变更的数据。5.2#{}和${}不只是 SQL 注入的区别MyBatis 里#{}和${}的区别面试题常客但实际项目里还是有人写错。#{}会生成预编译的占位符?参数值通过PreparedStatement设置能防止 SQL 注入${}是字符串拼接直接把值塞进 SQL 里有注入风险。但在某些场景下${}有它的存在意义比如动态表名、动态列名、ORDER BY字段。这种地方如果只能用${}必须对传入值做白名单校验。我见过一个案例一个排序功能前端把排序字段名传过来后端直接拼进ORDER BY ${sortField}结果有个实习生传了个id; DROP TABLE t_order;--虽然最终没有造成事故但 SQL 日志里已经出现了拼接痕迹把团队吓得不轻。正确的白名单写法String safeSortField switch (sortField) { case createTime - create_time; case amount - amount; case status - status; default - create_time; };再一个跟#{}相关的经典问题MyBatis 参数绑定里的param1、param2。当 mapper 方法有多个参数时XML 里可以直接用#{param1}、#{param2}引用但可读性极差。推荐的做法是加Param注解ListOrderDO selectByUserAndStatus(Param(userId) Long userId, Param(status) Integer status);XML 里用#{userId}、#{status}一眼就懂。如果方法参数是对象直接用#{属性名}即可但要注意对象里嵌套对象的属性访问比如#{user.address.city}如果address为 null会直接 NPE。处理方式是在 XML 里写if testuser ! null and user.address ! null先判空。5.3 打印 SQL 和Update执行慢的排查思路排查 MyBatis 问题第一件事就是能看到 SQL 日志。Spring Boot 配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl日志级别配上logging: level: com.example.mapper: debug这样控制台会打印完整的 SQL 和执行参数 Preparing:和 Parameters:两行信息能帮你确认到底执行的 SQL 是不是你以为的那条。这是个老生常谈的配置但真的很多线上问题靠这个就能定位比如 SQL 打出来发现条件没拼上、参数值不对、类型不对。关于Update执行慢我总结过几个常见原因按概率排序第一更新语句没有走索引尤其按非唯一字段更新时MySQL 可能先全表扫描找到目标行再更新数据量大时很容易锁表。Update(UPDATE t_order SET status #{status} WHERE user_id #{userId})如果user_id没有索引并发更新会把行锁升级成表锁数据库连接被卡住应用侧看到的就是“update 执行慢”。第二参数类型错误导致索引失效。比如表里user_id是varchar代码里传了个LongMySQL 会自动做类型转换结果可能导致索引失效更新直接全表扫。这种问题光看 SQL 发现不了得看执行计划。第三update 语句在事务里被其他长事务阻塞。A 事务更新了某行但没提交B 事务更新同一行B 就会一直等待。很多人只看接口响应慢忘了排查有没有并发冲突。排查手段SHOW PROCESSLIST看当前数据库连接状态确认是否有Waiting for table metadata lock或Row lock wait查information_schema.innodb_trx看事务持有锁的情况。这些经验在线上救了我好几次比埋头看代码快得多。6. 几个提升效率的私房建议把前面这些坑串起来我最后分享几个自己在实践中沉淀出来的小习惯虽然零碎但非常实用。第一个习惯新接手的项目先打开 SQL 日志跑一遍所有列表接口人工看一下实际执行的 SQL 长什么样。很多老项目的 XML 里埋着大量select *和隐式转换一条接口打出来 10 条 SQL你一眼就能看出哪些是懒加载触发的、哪些是缓存失效导致的重复查询这份清单就是你优化代码的第一手资料。第二个习惯复杂查询先在数据库客户端跑通再写 MyBatis XML。尤其是那种多表 join、带 group by、带子查询的 SQL先在客户端把结果集看一遍确认字段、类型、边界值都符合预期再往 XML 里填。不要直接写进代码然后靠启动调试去“猜”效率完全不在一个量级。第三个习惯Stream 操作里凡是涉及 map/flatMap 的一律把 null 值过滤写在前。MyBatis 查出来的数据带着 null 是常态Objects::nonNull一行代码能挡掉九成的 NPE 深夜告警。而parallelStream我在四年的项目里真正用到且带来明确收益的场景不超过 3 个绝大多数时候老老实实用stream()就够了。第四个习惯参与 MyBatis 面试或者评审别人代码时多问一句“这条 SQL 在目标数据库的方言下会怎么执行”。MySQL 跑通了不等于 Oracle、达梦、PostgreSQL 都能跑通提前用方言兼容性检查工具过一遍比上线前换数据库再手忙脚乱强太多。这几点基本都是靠“摔一次长一智”换来的。写代码时多留一份心、多打一条日志比自己半夜爬起来盯着告警群舒服的多希望这条路你能走得更顺。