尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

MyBatis + Stream 避坑指南:从 SQL 下推到类型转换的全面总结

发布时间:2026/9/26 8:25:56

资讯中心
01
ARTICLE

MyBatis + Stream 避坑指南:从 SQL 下推到类型转换的全面总结

MyBatis + Stream 避坑指南:从 SQL 下推到类型转换的全面总结
接手过一套库存报表模块那次的故障让我印象特别深接口偶发超时监控面板上看不到任何 SQL 问题数据库负载也不高。后来把日志捞出来才发现有人把一张二十万行的订单明细表整表查出来然后用 Java Stream 在内存里做了一层过滤和分组再塞给前端。MyBatis 查出数据、Java Stream 处理数据这个组合本身没错但用错了场景就是拿数据库当内存用后果从接口变慢到内存溢出都有可能。这篇文章就是围绕这套组合展开的避坑总结范围覆盖 SQL 语法的坑、动态 SQL 类型判断的坑、Stream 处理 MyBatis 结果集时的陷阱以及类型转换层面容易被忽视的细节。如果你平时写 Java 后端、天天跟 MyBatis 打交道又经常在 Service 层用 Stream 做二次加工那这篇文章应该能帮你省下不少排查时间准备面试的话里面很多点也是面试官喜欢追问的常见“八股”变种。1. 最常见的翻车现场全表查询交给 Stream 之前先想清楚三件事1.1 “先查出来再说”的成本谁付过谁知道很多开发者的第一反应是SQL 写复杂了容易出错不如先把数据查出来再用 Stream 慢慢过滤。这个思路在小数据量下是没问题的但问题在于“小数据量”的边界很容易被突破。我见过一个真实案例接口要按 30 天统计每个渠道的订单金额原始 SQL 已经写了GROUP BY channel但后来需求加了“排除退款订单、排除测试渠道、只保留特定支付方式”等一堆条件。接手的人觉得动态 SQL 太麻烦直接在查询里去掉所有过滤条件查全量订单然后在 Service 层写了一大段filter()和collect(Collectors.groupingBy())。表面上看代码很干净实际上产生了几个连锁问题。首先全量查询意味着 MyBatis 要把所有字段从数据库传到应用服务器网络开销、ResultSet 解析开销、List 对象的内存占用全部放大。其次Stream 链路过长会导致 GC 压力上升二十万行数据可能不算什么但如果是多表 JOIN 后的大结果集再叠加接口的 QPSJVM 堆很快就吃紧。所以我在代码评审里一直强调一个原则能下推到 SQL 的过滤、分组、排序就不要搬到 Java Stream 里做。SQL 擅长集合运算和聚合数据库引擎对这类操作有深度优化而 Java Stream 的强项是处理那些无法用 SQL 表达的逻辑比如多个查询结果之间的内存关联、动态可变的条件拓扑、脱敏转换等。1.2 分页插件和 Stream 不能互相替代热词里有人搜“MyBatis 分页插件的用法”这个点放在这里讲非常合适。分页插件最常见的是 PageHelper的原理是用 ThreadLocal 保存分页参数然后拦截执行器在下一句 SQL 上拼接LIMIT相关语法。也就是说分页是发生在数据库层面的它的意义在于只查当前页需要的数据。但我在不少项目里看到过这种写法不分页把全表数据查出来然后用Stream.skip((pageNum - 1) * pageSize).limit(pageSize)模拟分页。这种写法在小数据量下没什么感觉一旦数据量涨到几十万每次请求都要全量传输、全量构建 List再丢弃大部分数据纯属浪费。更隐蔽的坑是分页插件配合 Stream 二次过滤。比如第一页查了 20 条拿回来用filter()过滤了一些记录页面上显示不足 20 条。这还不算严重严重的是一些开发者发现 PageHelper 没生效怀疑是插件配置问题反复调整配置最后发现是分页参数被某个 Stream 方法或者别的查询给“吃掉”了。PageHelper 的 ThreadLocal 参数只作用于紧接着的下一条查询如果有多个 Mapper 方法调用分页参数就可能作用到错误的那条查询上。我给出的建议很简单分页就规规矩矩用分页插件或者手写LIMIT #{offset}, #{size}。不要在分页之后再用 Stream 做行数级别的过滤如果确实需要过滤那过滤条件应该在 SQL 的WHERE里提前表达。1.3 什么时候该用 Stream 处理结果集说了这么多“别用”那到底什么时候该用我自己总结了一个判断标准供你参考。适合用 Stream 的场景大概有几类数据量受控比如配置表、字典表、几千行的基础数据需要按某种规则做内存关联或转换SQL 无法优雅表达的逻辑比如多个查询结果交叉合并、按层次结构组装树形数据对查询结果做展示层的加工比如脱敏手机号、拼接名称、过滤某些空值字段。不适合用 Stream 的场景也很清晰需要对全表做聚合统计时应该用 SQL 的COUNT、SUM、GROUP BY数据量在万级以上且 Stream 链路里有filter、distinct、groupingBy等多步操作时分页、排序需求这些在数据库里做有索引加持在内存里做毫无优势。这里给一个简要的决策对比表场景建议做法原因单表条件查询SQL WHERE 过滤有索引传输量小分组统计SQL GROUP BY数据库聚合效率高跨结果集内存关联先查询再 Stream 合并多表 JOIN 可能带来笛卡尔积中大型数据二次清洗尽量在 SQL 层完成避免内存压力和 GC 问题小数据量脱敏/转换Stream map 处理代码简洁性能影响小说到底MyBatis 和 Stream 是两套工具核心在于让工具做它们擅长的事。数据库擅长的是大规模数据的集合运算Java Stream 擅长的是灵活的内存态转换。把两者的边界划清楚很多事故从一开始就不会发生。2. 动态 SQL 里那些“看起来对跑起来炸”的语法与类型坑2.1 test 表达式中的类型比较陷阱MyBatis 的动态 SQL 基于 OGNL 表达式if test里的判断看似简单实际上类型问题特别多。最常见的一种是数据库字段是INTJava 参数里传的是Integer然后在 test 里写status 1。这个在某些版本下工作正常但换成Long类型的参数或者接收的是字符串1比较结果就可能变成永远为false然后整个条件被丢弃查询范围被意外放大。我自己踩过更阴的坑一个状态字段在数据库中是小整型Java 侧定义成了String前端传过来的是1。MyBatis 的 OGNL 对字符串和数字的比较有自己的一套规则有时候它会做类型转换有时候不会。比如在 XML 里写if teststatus 1和if teststatus 1看起来是一回事实际上要看你用的是单引号还是双引号、目标字段是 char 还是 String在 OGNL 语境下结果可能完全不同。为了避免这些问题我后来的习惯是test 表达式里只做空值判断不做业务值的等值判断。也就是if teststatus ! null这个层面用动态 SQL具体的等值条件全部拼进参数里或者用choose/when配合显式类型转换。如果你确实要在 test 里比较那就写成if teststatus ! null and status.toString() 1把类型先统一到字符串再比这样至少行为是确定的。日期类型的比较是另一个高频坑。在 XML 里写和时需要注意 XML 转义。很多人会直接用和在 XML 里会被解析成标签开始直接报错。正确做法是用![CDATA[ ]]包起来或者用lt;、gt;转义。更推荐的方式是用 MyBatis 提供的gt、lt、geq、leq这些运算符代码可读性也好很多。2.2 #{} 与 ${}一条 SQL 注入漏洞是如何形成的热词里有“SQL 注入”和“万能密码绕过”这类话题在开发圈永远不过时。很多人觉得 SQL 注入是老古董了但我在实际项目里还是见到过用${}拼接用户输入的代码。#{}的作用是生成预编译占位符MyBatis 会把参数通过PreparedStatement的setObject绑定进去数据库看到的是参数值不是 SQL 片段。${}则是直接拼接字符串等价于在 Java 代码里手动拼 SQL风险完全暴露。举一个很直白的例子如果用${keyword}去拼一个 LIKE 条件用户传入 OR 11 --整个查询条件就被改写了。所谓“万能密码”漏洞的原理也类似登录 SQL 是WHERE username ${username} AND password ${password}输入一个精心构造的用户名后面的密码校验直接被注释掉。正确姿势是业务参数一律用#{}。${}只允许用在表名、字段名、排序关键字这类结构位置并且这些位置的取值必须来自代码内部的白名单不能直接拿外部输入来用。比如根据前端传的排序字段你在 Java 侧维护一个MapString, String只允许createTime - create_time这种映射后的值进入${}。另外处理IN查询时foreach的collection一定要判空。如果传入的集合是空的加了判断则整段 SQL 可以跳过不加判断就会生成WHERE id IN ()这种非法 SQL。我见过生产环境因为这个直接报 500排查了半天最后发现是上游传了个空列表。2.3 like 参数和日期字符串的传参坑模糊查询是一个比想象中更容易出问题的地方。很多人习惯在 Java 侧拼好%keyword%再传给#{keyword}。这种做法本身没问题但如果你用的是${keyword}再在 SQL 里写LIKE %${keyword}%那不仅是注入问题万一用户输入里有%或_这两个字符在 LIKE 语法里是通配符查询结果会莫名其妙多出来。我建议的做法是在 SQL 侧用CONCAT(%, #{keyword}, %)让参数保持纯粹。如果业务要求用户输入的%和_也要被当作普通字符匹配那还需要在传参前对这两个字符做转义处理并在 SQL 里加上ESCAPE关键字。这属于比较偏门的场景但遇到了得知道怎么处理。日期传参的坑主要集中在字符串和日期对象混用上。如果数据库字段是DATETIMEJava 侧传的是String类型的2024-01-01 00:00:00MySQL 通常能隐式转换但一旦格式不对比如2024/01/01索引可能失效甚至直接报错。更稳妥的做法是 Java 侧直接用LocalDateTime或java.util.Date对象传参由 JDBC 驱动处理类型映射。范围查询尽量用created_at #{startTime} AND created_at #{endTime}这种半开区间避免BETWEEN在秒级精度上出现边界漏数据的问题。3. Stream 处理 MyBatis 结果集时的高频事故从 null 到连接断开3.1 Collectors.toMap 重复 key 问题这是我在代码审查里见到频率最高的一个 Stream 坑。场景很典型从数据库查出一批用户列表需要转成MapLong, Stringkey 是用户 IDvalue 是用户名称。开发时测试数据恰好没有重复 ID上了生产才发现同一个用户在多张表中都会出现直接抛出IllegalStateException: Duplicate key。很多人第一反应是加distinct()这能解决一部分问题但distinct()作用的是整行对象如果两条记录的用户 ID 相同但其他字段不同distinct()根本挡不住。正确做法是给Collectors.toMap传入第三个参数也就是合并函数(v1, v2) - v1表示保留第一条(v1, v2) - v2表示保留最后一条。如果业务上不允许重复那就应该在 SQL 层用DISTINCT或者GROUP BY提前去重而不是在 Stream 层补救。另外Collectors.toMap并不能容忍value为 null。如果数据库里用户名称允许为空查出来的 List 里有一条记录 name 是 nulltoMap 会直接 NPE。我常用的处理方式是在 map 之前先filter(v - v.getName() ! null)或者用Optional.ofNullable包一层默认值。还有个细节默认的toMap返回的是HashMap不保证顺序。如果你的业务里对顺序有要求比如前端期望按照某个字段排序显示那需要用LinkedHashMap::new作为第四个参数确保结果保留插入顺序。3.2 分组计算与 null 值的边界Collectors.groupingBy和partitioningBy有一个显著差异groupingBy的 key 不能为 null而partitioningBy的分组键必须是布尔值。如果你用groupingBy(SomeEntity::getType)而数据里有一条记录的 type 是 null运行时会直接抛出 NPE。处理方式有两种要么在传入groupingBy之前用filter(item - item.getType() ! null)过滤掉脏数据要么给分组键做一个兜底比如item - Optional.ofNullable(item.getType()).orElse(UNKNOWN)。这个看起来很小的细节在数据质量不可控的线上环境里几乎是必然踩到的。分组之后再做sum、average这类聚合操作时也要小心 null 值的影响。Collectors.summingInt(Item::getAmount)对 null 是什么表现它会 NPE因为拆箱的时候getAmount()返回的Integer对象为 null自动拆箱成 int 就会抛异常。所以聚合之前要么在 SQL 层用IFNULL处理好要么在 Stream 里先map成默认值。我最后还是想提醒一句如果这个分组聚合的需求是“按状态统计订单数”“按渠道求和金额”第一步应该想的是 SQL 的GROUP BY而不是把数据全部拉回来用 Stream 做。除非数据量真的小到可以忽略否则内存聚合的成本远高于数据库聚合。3.3 流式查询Cursor/ResultHandler与 Stream 结合时的连接管理热词里有好几条类似“stream disconnected before completion: stream closed before response.completed”“error running remote compact task: stream disconnected before completion: transport error: network error”的搜索记录看着像是网络传输层的报错。其实这类错误在 MyBatis 里也可能遇到尤其是在使用流式查询Cursor的时候。MyBatis 的CursorT允许你逐条从数据库读取记录而不需要一次性把整个结果集加载到内存。配合 Java 的 Stream可以写出try (CursorUser cursor mapper.scanUsers()) { return cursor.stream().map(...).collect(...); }这种很优雅的代码。但这里有一个关键约束Cursor 依赖于底层 SqlSession 和 Connection 保持打开状态一旦连接被关闭或者被连接池回收流式读取就会中断。常见的报错场景是在 Spring 事务里开了 Cursor然后在循环里做了耗时操作比如调用外部接口、睡眠、甚至执行另一条 SQL导致数据库连接空闲超时被服务端或中间层断开。等游标继续读取下一条数据时底层连接已经死了异常信息往往就是“stream disconnected”之类的网络错误。归根到底游标查询不是用来做长时间事务的它要求你尽快消费完所有数据并释放连接。我这里有几个实操建议用try-with-resources确保 Cursor 和 SqlSession 一定被关闭Cursor 的 Stream 消费逻辑里不要嵌套调用其他 Mapper 查询尤其是不要执行耗时操作如果确实需要在一个事务里同时做大批量读取和写入考虑分页查询而不是游标查询Cursor 的 Stream 不可以直接.parallel()并行消费分布式数据库连接会造成更多混乱。3.4 parallelStream 与数据库连接并发放大的隐患Java Stream 的并行流底层用的是ForkJoinPool.commonPool()它是 JVM 级别的共享线程池。如果某个业务方法用list.parallelStream().map(...)对 MyBatis 查询结果做并行处理而 map 里又调用了 Mapper 查询那么并发量会瞬间放大到公共线程池的线程数通常默认是 CPU 核数减一。这个并发量直接反映到数据库连接池上很容易把连接数耗尽。更麻烦的是公共线程池被大量阻塞任务占满后JVM 里其他用到 parallelStream 的代码也会跟着排队产生“蝴蝶效应”。我在一个微服务里排查过一个问题一个基础数据接口突然从 20ms 变成 2s最后发现是另一个业务接口在并行流里调了几个慢 SQL把公共线程池搞挂了。正确的姿势是并行流不要和数据库访问混用。要么先一次性查出所有需要的数据再在内存里并行转换要么用自定义线程池但自定义线程池也需要严格限制核心线程数否则同样会放大并发。说到底MyBatis 查询本来就是一个 IO 操作并行流并不能让数据库变快只是增加了并发压力。4. 类型转换全解析从 JDBC 映射到 Stream map 的每一处“隐形强转”4.1 JdbcType 与 Java 类型的映射关系类型转换是编程语言的通病C 有隐式整型提升和格式化输出陷阱Python 的str和int混用会报错Java 这边不仅在语言层有强转问题MyBatis 的结果映射层也会埋雷。MyBatis 的resultType底层依赖 JDBC 驱动的getXxx()方法列的类型和 Java 属性的类型不匹配时行为并不是每次都报错更多的是静默转换出错误值。下面是一张常见的映射关系参考表JDBC 类型MyBatis 默认映射 Java 类型需要注意的点CHAR/VARCHARString长度对比、空格问题INTEGERInteger超出范围时用 LongBIGINTLongcount() 结果是 LongDECIMAL/NUMERICBigDecimal金额精度不要用 DoubleBIT/BOOLEANBooleantinyint(1) 会被某些驱动映射成 BooleanDATE/TIMESTAMPDate/LocalDateTime时区问题版本差异大有一个经典问题是 MySQL 的TINYINT(1)。很多团队为了省空间用TINYINT(1)表示布尔状态结果 MyBatis 映射到 Java 侧时某些版本的 MySQL JDBC 驱动会把它当作BIT进而映射成Boolean。如果你的实体类属性是Integer isDelete反序列化时就会报Cannot convert Boolean to Integer之类的错误。这种问题排查起来特别费劲因为不是所有环境都能复现跟驱动版本和连接参数都有关系。我的建议是状态字段就不要用TINYINT(1)直接用TINYINT配合0/1传值或者用INT。如果数据库已经定死了那实体属性就用Boolean业务层需要数字的时候再手动转换至少类型是一致的。4.2 数值类型溢出、精度和 Boolean 三兄弟数值类型的坑比起映射错误更隐蔽的是溢出和精度问题。最典型的例子是COUNT(*)的返回值。MyBatis 里SELECT COUNT(*) FROM user返回的是Long但很多人的实体属性或接收参数用的是Integer运行时会报ClassCastException。原因很简单Long 和 Integer 之间不能自动向下转型。有些人会写(int) countMapper.countAll()但其实这段代码在 MyBatis 的映射阶段就已经失败了因为 MyBatis 在把 JDBC 的Long转换成对象属性时会尝试使用Long对应的 TypeHandler赋给Integer属性时依赖的是它的反射 setter类型不匹配就会在 setter 调用时直接抛异常。金额字段建议一律使用BigDecimal。如果你在数据库里用DECIMAL(10,2)Java 用Double接收那金额精度问题迟早会暴露。BigDecimal的divide方法在不指定 scale 和舍入模式时遇到除不尽的场景会直接抛ArithmeticException。所以凡是除法运算务必写成a.divide(b, 2, RoundingMode.HALF_UP)。Stream 里的数值转换也有类似风险。IntStream的sum()默认返回int如果业务数据的总和超过Integer.MAX_VALUE结果就是溢出后的负数而且不报错。mapToInt在遇到 null 对象拆箱时也会 NPE。我的习惯是只要数据量上到一定级别金额和数量相关字段一律用mapToLong或者BigDecimal不为别的就为求个安心。4.3 时间类型时区、字符串和时间戳时间类型是另一个容易出幺蛾子的地方。热词里有好几条关于类型转换的搜索比如“python 类型转换”“matlab 字符类型转换”都是在讨论不同语言里类型转换的坑。Java 侧的时间类型转换问题通常出在时区、格式和 MyBatis 版本兼容三方面。时区问题的典型表现是数据库存的是TIMESTAMPJava 侧用LocalDateTime接收但应用服务器的时区设置和数据库不一致查出来的时间差了 8 个小时。这个问题的解决要点在于连接串里设置清楚serverTimezone比如serverTimezoneAsia/Shanghai并且 Java 服务所在容器的时间也要保持一致。最怕的是数据库用 UTC、服务器用 CST两边都没统一排查起来就会非常痛苦。另一个常见场景是数据库把日期存成了VARCHAR比如2024-06-01 12:00:00MyBatis 查出来直接就是 String。这时候如果要在 Stream 里转成LocalDateTime用DateTimeFormatter是常规操作但要注意解析失败的情况。线上数据什么格式都有可能出现2024/06/01和2024-06-01 12:00混在一起一旦LocalDateTime.parse抛异常整个接口就挂了。稳妥的做法是封装一个parseTime小工具先做格式匹配解析失败时返回 null 或者默认值而不是直接把异常抛出去。MyBatis 对 Java 8 时间类型的支持也值得留意。比较新的 MyBatis 3.5 原生支持LocalDateTime、LocalDate但老项目如果用的 MyBatis 3.4 甚至 3.2需要手动注册LocalDateTimeTypeHandler不然会报 “No typehandler found for property xxx” 这类错误。升级依赖前最好确认一下版本兼容。4.4 Stream 类型转换的简洁写法与坑终于说到 Stream 里的类型转换。处理 MyBatis 结果集时Stream 的map操作承担了大部分类型转换工作但这里也有几个很容易出错的细节。首先是map里的方法引用失效问题。比如「把所有 ID 转成字符串」list.stream().map(String::valueOf)很安全但如果数据里有 nullString.valueOf(null)返回的是字符串null这可不是四种 null 处理方式里的任何一种你得到的是字面量。如果写成map(User::getId).map(String::valueOf)而某个 User 对象本身就是 null那第一行 NPE 就出来了。Integer.parseInt和Integer.valueOf的区别也是经典面试题。前者返回基本类型int后者返回包装类型Integer。在 Stream 里用map(Integer::valueOf)再collect(Collectors.toList())拿到的 List 里的元素不会为 null但你以为拿到的是ListInteger实际泛型在运行时被擦除了如果后面做intValue()拆箱遇到 null 就 NPE。所以我的习惯是Stream 处理完结果集之后能在实体层就处理好类型就不要依赖后期强转。还有一个小技巧Collectors.toMap的 value 泛型容易在链式调用中“变形”。比如list.stream().collect(Collectors.toMap(Order::getOrderNo, item - item.getAmount()))如果getAmount()返回的是BigDecimal那 Map 的 value 确实就是BigDecimal。但如果你写的是item - String.valueOf(item.getAmount())value 就不是原来的类型了。这种隐式类型变化在复杂链路中很容易被忽略等用到的时候才发现 ClassCastException。建议在关键转换点显式声明一下变量类型宁可多写两行也不要让编译器帮你“猜”。5. 缓存、二级缓存与更新语句数据一致性的隐形坑5.1 一级缓存的“假新鲜”MyBatis 的一级缓存是 SqlSession 级别的默认开启。同一个 SqlSession 里执行两条完全相同的 SQL第二次直接走缓存不会真正查数据库。这个机制本身没毛病但放到 Spring 管理的事务里就有一个隐藏体验问题如果你在一个长事务里先查询一个数据然后依赖另一个业务流程修改了数据再查一次却发现还是旧值原因可能不是并发问题而是一级缓存没有失效。一级缓存的失效条件有三个SqlSession 关闭、执行了任意更新操作update/insert/delete、手动调用clearCache()。在 Spring 整合 MyBatis 的场景下SqlSession 是每次数据库操作都可能重新获取的所以一级缓存的问题通常不明显。但如果你在代码里直接通过SqlSessionTemplate手动管理 SqlSession或者使用了Transactional大事务一级缓存就会变成一个隐性干扰源。排查的时候有个技巧如果发现某个查询“永远查不到刚更新的数据”可以先用sqlSession.clearCache()验证一下是不是缓存问题而不要一上来就怀疑数据库隔离级别。另外如果你用 MyBatis 同时访问多个 SqlSession一级缓存之间是互相隔离的不存在共享问题这点和二级缓存有本质区别。5.2 二级缓存实现与序列化限制二级缓存是 Mapper 级别的多个 SqlSession 共享同一个 namespace 的缓存。它的好处是跨会话命中但坑也更多。第一个坑是脏数据。二级缓存以 namespace 为单位如果你用UserMapper查询了用户然后另一个AdminUserMapper更新了同一张 user 表由于不同 namespace 的缓存互不知情UserMapper的缓存可能还在返回旧数据。解决思路是让操作同一张表的 Mapper 放到同一个 namespace或者在更新接口上配置flushCachetrue清掉相关缓存。第二个坑是序列化。默认的二级缓存如果选择可序列化存储缓存对象必须实现Serializable。很多实体类没有实现序列化接口一开二级缓存应用启动没问题一旦缓存被写入并尝试序列化报错信息会很奇怪。更隐蔽的是如果你缓存的是一个ListUser并发读取时拿到的是同一个引用某个业务方对这个 List 做了 Stream 修改比如filter、sort等于直接改了缓存里的数据其他请求再取缓存时就拿到了被篡改的对象。所以我在启用二级缓存前通常会问三个问题数据是否对实时性要求不高缓存对象是否不可变更新频率是否很低如果有一个答案是否定的就别开二级缓存。下面是三种缓存的简单对比缓存级别作用范围常见问题一级缓存SqlSession长事务下读到旧值二级缓存Mapper namespace跨 Mapper 脏数据、序列化问题自定义缓存如 Redis全局需要自己管理失效和序列化5.3 Update 执行慢的排查路径热词里有“mybatis update 执行慢”这个问题的排查路径比较固定按顺序走一遍基本能定位。第一步打开 MyBatis SQL 日志。在配置里加mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者用logback输出 SQL 到日志文件先确认慢的是不是 SQL 本身。如果 SQL 执行很快但接口整体慢那大概率是事务提交、锁等待或者连接获取环节的问题。第二步对 SQL 做EXPLAIN看是不是缺索引。UPDATE语句的WHERE条件如果没走索引会变成全表扫描而全表更新时 MySQL 会加锁在线上环境就是灾难。第三步排查锁等待。如果慢的 SQL 本身没问题但每次执行都要等很久很可能是有其他长事务持有了行锁。可以查information_schema.innodb_trx看看当前事务列表找到一直未提交的事务。我遇到过很多次都是因为前面的业务在事务里调了第三方接口事务迟迟不提交把后面所有更新操作都堵住了。还有一个容易被忽略的点Update注解里写动态 SQL需要套script标签。很多人在Update里写if条件忘了包script结果整个 SQL 解析失败。而一旦你用了script如果参数里某个条件没传可能整个WHERE块被跳过更新语句变成全表更新。这类事故非常危险我见过有人因为一个空参数把整张表的几百条记录全部更新成了同一个值。防呆的做法在Update的动态 SQL 最后手动加一个校验比如where里面保证至少有一个条件或者干脆要求调用方必须传入主键 ID否则抛出异常。5.4 缓存与 Stream 的交叉坑缓存和 Stream 叠加起来问题会更隐蔽。最经典的场景是接口先查缓存如果缓存命中了某个 List就直接对这个 List 做stream().map()然后返回。看起来没什么问题但实际上你操作的是缓存对象本身并不是副本。一旦你在转换过程中修改了对象属性比如把某个字段置成 null 或者改变状态缓存对象就永久性地被改了。这种情况在 MyBatis 一级缓存里也可能出现。一级缓存虽然到 SqlSession 关闭就失效但在 SqlSession 存活期间如果缓存对象是同一个 List 引用你调用list.stream().sorted()即便没有修改元素本身排序产生的顺序变化也会影响后续代码的读取顺序。我的建议是从缓存拿出来的集合如果要经过 Stream 做转换或过滤第一步先复制成新的 List。用new ArrayList(cachedList)把引用断开后续的操作就只影响副本不影响缓存。这个习惯花不了几行代码但能避免很多难以复现的诡异问题。6. 避坑自检清单开发与面试都能直接用的模板6.1 我每次写 MyBatis Stream 代码时的九条自检很多线上问题其实可以在代码评审阶段就被发现。我把每次写这类代码时的自检点整理成一个清单分享给你平时写代码时顺带过一遍能挡掉大部分低级事故查询的数据量大不大超过万级且要经过 Stream 过滤/分组的默认认为不应该这么做。分页、排序、COUNT、SUM、GROUP BY 是否还在 SQL 层如果被搬到了 Stream改成 SQL 实现。动态 SQL 的 test 表达式里有没有跨类型等值比较有就改成 toString() 比较或者干脆只判空。有没有用${}拼接用户输入用的话改成#{}结构位置用白名单映射。查询结果转 Map 之前确认业务上 key 是否会重复重复的话传入合并函数。有没有在 parallelStream 里访问数据库连接有就改掉或者限制线程池大小。实体属性和数据库字段类型是否一致特别是 Long、BigDecimal、Boolean 这类容易踩坑的类型。时间字段的时区是否统一连接串有没有设置 serverTimezone从缓存取出的集合会不会被 Stream 操作修改会就先复制一份。6.2 面试高频“八股”现场热词里有不少“java面试八股文”“mybatis面试题”的搜索记录既然涉及 MyBatis 和 Stream 的避坑话题面试题也就不难预判。整理几个常见的问#{} 和 ${} 的区别#{}是预编译占位符走 PreparedStatement 参数绑定安全${}是字符串拼接有 SQL 注入风险只能用于表名、排序字段等结构位置。问MyBatis 一级缓存和二级缓存分别是什么失效条件是什么一级缓存是 SqlSession 级别的默认开启SqlSession 关闭或执行更新操作后失效二级缓存是 Mapper namespace 级别需要手动开启存在跨 Mapper 脏数据和序列化限制。问PageHelper 的原理有没有踩过坑原理是用 ThreadLocal 存分页参数拦截 SQL 生成 limit。坑点在于参数会作用于紧随其后的查询多条查询混用时容易作用错对象复杂 SQL 改写也可能出问题。问MyBatis 的流式查询Cursor要注意什么游标依赖 SqlSession 和 Connection必须及时关闭游标消费过程中避免嵌套 Mapper 查询和耗时操作否则底层连接断开后会抛异常。问怎么排查慢 SQL打开 SQL 日志、EXPLAIN 看执行计划、查锁等待表按顺序排查。6.3 一句收尾的个人习惯我个人的习惯是每次接到一个查询需求先问自己一句话——这段逻辑能不能用 SQL 表达如果能就把它留在 SQL 里如果不能在 SQL 里优雅表达我再考虑用 Java Stream。这个习惯看起来简单但真的帮我躲过了很多次线上事故希望你也能用得上。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。