ARTICLE DETAIL

资讯详情

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

MybatisPlus加NOLOCK的四种实践与分页插件避坑指南

MybatisPlus加NOLOCK的四种实践与分页插件避坑指南 1. 为什么要折腾NOLOCK一次线上事故把我逼到MybatisPlus的墙角先说一段真实经历。周四下午三点线上SQL Server的主节点CPU直接冲到99%办公系统全面卡顿。我拉出慢查询日志一看是一条报表统计SQL跑了将近20秒JOIN了6张表其中订单表数据量接近两千万行。DBA的结论相当直接这条查询拿共享锁的时间太长把业务写操作全堵死了必须加WITH (NOLOCK)。我当时的第一个反应不是NOLOCK能不能加而是我们项目全部用MybatisPlus查询大多靠QueryWrapper和LambdaQueryWrapper拼出来怎么才能让生成的SQL带上WITH (NOLOCK)。直接改XML的话几十个统计查询总不能全手写原生SQL吧在实时搜索引擎和社区里翻了一圈发现这个问题要么是断章取义的片段要么只讲了SQL Server端怎么加提示几乎没有人把MybatisPlus侧的落地写法讲透。于是就有了这篇文章。先给结论注解SQL硬写、XML原生SQL、自定义SQL注入器、拦截器自动拼接这几种方式都能让MybatisPlus的查询带上NOLOCK但每一种都有自己的坑尤其是遇到分页插件时处理不好分页计数会直接失效。本文会把验证过的写法、踩过的坑、以及比NOLOCK更优的替代方案完整记录一遍。1.1 慢SQL把主库拖垮的典型场景还原再还原一下那次事故的细节。业务方做大促复盘要拉全量订单的销售报表。后端收到请求后照常调用了一个包装好的统计Service。这个Service内部用LambdaQueryWrapper把订单表、用户表、商品表、店铺表、支付流水表JOIN了六次订单主表接近两千万行。平时筛选时间范围小查询2.7秒跑完大家都没当回事。当天业务方选了大范围时间一条SQL跑了近20秒。肯定是等了很久但真正的原因是这样SQL Server默认事务隔离级别是READ COMMITTED这条20秒的查询在整个执行期间都持有订单表上的共享锁。共享锁和排他锁互斥订单表上的UPDATE、INSERT、DELETE全部在锁后面排队。报表查询一旦跑起来所有写单操作都卡住CPU被并发等待大量耗尽整个库直接像死了一样。DBA给的方案是把报表查询改成WITH (NOLOCK)读取让它不持有共享锁。从锁机制上看这个方案确实能立刻解除查询阻塞写的问题。但在MybatisPlus项目里绕不开的关键问题就是“SQL从哪改、怎么改、改了之后分页插件还认不认”。1.2 动手之前先问业务脏数据能不能接受在追着DBA要NOLOCK之前有一件事比技术方案更重要业务能不能接受脏读NOLOCK在SQL Server里的全称是表提示Table Hint作用等同于把单条查询的事务隔离级别临时降到READ UNCOMMITTED。换句话说它允许这条查询读到事务尚未提交、最终可能回滚的数据也就是脏读。我专门去找业务负责人确认报表金额多几千块或者少几千块能不能接受报表里出现正在删除的订单是否影响决策对方回复“报表本来就是昨天全量的汇总晚一小时、偏差一点点都可以接受”我才开始动代码。这里你千万别跳过如果业务场景是资金对账、库存校验、账户流水NOLOCK从一开始就不该进入讨论范围。先确认业务的容忍度边界再谈技术方案是这次项目中我最大的收获之一。2. NOLOCK的锁原理与使用边界它到底解决了什么2.1 SQL Server锁机制极简版要把NOLOCK讲清楚得先理解SQL Server默认的加锁行为。在READ COMMITTED隔离级别下普通的SELECT语句在执行时会对读取的数据行或者数据页申请共享锁Shared Lock。共享锁与排他锁互斥当SELECT在读取某行只要有UPDATE、DELETE、INSERT试图修改同一行就必须等SELECT执行完、锁释放后才能继续。反过来也一样如果一个事务对某行持有排他锁但尚未提交SELECT想申请共享锁同样会申请不下来于是查询被阻塞。很多系统里“查询和更新互相堵死”的现象本质上都是共享锁与排他锁互斥导致的等待。NOLOCK做的只有一件事查询在读取时不再申请共享锁也不需要等别人的排他锁释放而是直接读取数据页上当前最新的值。哪怕这个值来自一个未提交的事务它也会照读不误。你可以把它理解成“进图书馆不办借阅证也不占座位看到哪本书就拿哪本哪怕管理员正在往书架上塞书也被你抽出来翻”。2.2 NOLOCK读到的数据能脏到什么程度“脏读”听起来比较抽象我举个实际例子。假设一条UPDATE事务正把订单金额从100改成200但还没提交。正常SELECT会被阻塞到事务提交然后读到200。加了NOLOCK后SELECT不会被阻塞直接读到200可如果这个UPDATE最终回滚了这条SELECT读到的200就是“这个世上从未存在过的数据”。更隐蔽的问题是数据库内部的物理结构损坏风险。SQL Server做索引页分裂Page Split时NOLOCK读取可能绕过正在分裂的页读出的数据在物理层面就是“断裂”的结果集中可能出现重复行也可能直接少行。这个不在业务层控制范围内也不是“对一下金额”就能发现的问题。也就是说即便业务能容忍金额偏差NOLOCK读出来的结果仍然可能连行数都对不上。2.3 能加NOLOCK的场景和绝对不能加的场景我一般用三个条件判断一个查询能不能加NOLOCK。场景是纯查询不写入、不更新、不进入后续业务流。典型如报表、后台列表、日志浏览。业务能接受数据非实时、可能有偏差默认偏差几分钟甚至几小时不造成事故。查询是低频高消耗的比如凌晨跑批、手动点开的报表而不是每次用户请求都会触发。反过来有几类场景我坚决不加NOLOCK。资金相关对账、清算、余额汇总、订单金额校验。业务状态判断判断库存是否够扣、判断账户是否重复注册。任何“读出来后要当作后续更新依据”的逻辑。这类逻辑一旦读到脏数据下面的写操作会被一起污染引发的就不是慢查询而是直接的业务数据错误。如果你在代码评审里看到有人对这类场景加NOLOCK一定得拦下来。3. MybatisPlus加NOLOCK的四种可行做法3.1 注解SQL硬写最简单也最可控最直接的方式在Mapper接口里用Select注解写原生SQL。public interface OrderMapper extends BaseMapperOrder { Select(SELECT * FROM orders WITH (NOLOCK) WHERE status #{status}) ListOrder selectOrdersWithNolock(Param(status) Integer status); }执行时SQL长这样SELECT * FROM orders WITH (NOLOCK) WHERE status ?分页插件对这条SQL也能正常处理前提是MybatisPlus版本别太老。这个方式的优点是直白、固定、好排查适合SQL结构简单、条件固定的场景。缺点也很明显一是失去了QueryWrapper的动态拼装能力条件一多就得在注解里写script或者一堆if/else判断二是如果接了多张表每张表都要手动加NOLOCK漏一张就前功尽弃。3.2 XML原生SQL动态条件多时的稳妥选择当查询条件飘忽不定时注解硬写会很难维护。这种情况下把SQL挪到Mapper XML里会更顺手。select idselectOrderReport resultTypecom.example.dto.OrderReportDTO SELECT o.id, o.order_no, u.name AS userName, o.pay_amount FROM orders o WITH (NOLOCK) JOIN users u WITH (NOLOCK) ON o.user_id u.id WHERE o.status #{status} if teststartTime ! null AND o.create_time gt; #{startTime} /if if testendTime ! null AND o.create_time lt; #{endTime} /if /selectXML方案能利用动态SQL标签也方便DBA直接拿SQL去SSMSSQL Server Management Studio里执行分析。要我说MybatisPlus项目里本来就可能混用注解和XML那么这个方案几乎不需要额外学习成本。别觉得XML老土。NOLOCK这种带锁语义的SQL本来就该显式写在配置里方便评审和排查。3.3 自定义SQL注入器让BaseMapper方法带上NOLOCK如果你希望selectList、selectPage这类现成方法生成的SQL也带上NOLOCK就需要扩展MybatisPlus的SQL注入器。先说原理。MybatisPlus的BaseMapper方法本质是通过DefaultSqlInjector在启动时把一组AbstractMethod注入到Mapper中每个方法对应一条预编译SQL。我们可以继承AbstractMethod自定义一条带NOLOCK的SQL再注册到注入器里。第一步定义方法我们起名selectListWithNolockpublic class SelectListWithNolock extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { String tableName tableInfo.getTableName(); // 拼出 SELECT 字段 FROM 表名 WITH (NOLOCK) 逻辑删除条件 wrapper条件 String sql String.format( SELECT %s FROM %s WITH (NOLOCK) %s %s, sqlSelectColumns(tableInfo, false), tableName, tableInfo.getLogicDeleteSql(false), sqlWhereEntityWrapper(true, tableInfo) ); SqlSource sqlSource languageDriver.createSqlSource(configuration, sql, modelClass); return addSelectMappedStatementForTable( mapperClass, selectListWithNolock, sqlSource, tableInfo ); } }第二步自定义注入器替换默认的注入器public class NolockSqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass, TableInfo tableInfo) { ListAbstractMethod methods super.getMethodList(mapperClass, tableInfo); methods.add(new SelectListWithNolock()); return methods; } }第三步在MybatisPlus配置里指定这个注入器Bean public MybatisSqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) { MybatisSqlSessionFactoryBean factoryBean new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); GlobalConfig globalConfig new GlobalConfig(); globalConfig.setSqlInjector(new NolockSqlInjector()); factoryBean.setGlobalConfig(globalConfig); return factoryBean; }注册完成后Mapper接口里直接声明方法public interface OrderMapper extends BaseMapperOrder { ListOrder selectListWithNolock(Param(Constants.WRAPPER) WrapperOrder queryWrapper); }调用时和普通selectList一模一样但SQL里带了NOLOCK。LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getStatus, 1); orderMapper.selectListWithNolock(wrapper);这个方案看起来很优雅保留了Wrapper拼条件的优势但它只对selectListWithNolock这个自定义方法生效原有的selectList、selectPage不会自动带NOLOCK。团队里其他人如果不知道这个约定很容易在后续开发中绕过它写普通查询规则就开始崩坏了。另外自定义注入器一旦和分页插件、多租户插件、逻辑删除插件碰到一起执行顺序上有不少坑后面我会专门展开。3.4 拦截器自动拼接看上去很美实际坑最多还有一种思路写一个Mybatis的Interceptor在SQL执行前拦截StatementHandler把SQL字符串中的表名后面自动加上WITH (NOLOCK)。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class NolockInterceptor implements Interceptor { private static final Pattern TABLE_PATTERN Pattern.compile((?i)(\\bfrom\\s|\\bjoin\\s)([a-zA-Z_][a-zA-Z0-9_]*)); Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); String sql boundSql.getSql(); String newSql appendNolock(sql); replaceBoundSqlSql(boundSql, newSql); return invocation.proceed(); } }核心的正则思路是找到FROM和JOIN后面的表名替换成表名 WITH (NOLOCK)。我一开始也试过这个方案想着以后任何Mapper查询都自动带锁提示省得一个个改。实际跑起来就发现不是那回事。它会伤及无辜子查询里的表名也被匹配UPDATE/INSERT/DELETE语句的FROM如果被改掉直接语法报错同一个SQL被重复拦截时可能重复拼接NOLOCK临时表、表变量加NOLOCK会报错——SQL Server不允许对临时表和表变量指定NOLOCK。正则没法区分这些场景最后我只能在代码里加一大堆黑名单判断越写越复杂果断放弃了。3.5 四种方案横向对比方案侵入性实现复杂度适用场景风险等级注解SQL硬写低低固定SQL、条件少低XML原生SQL低中动态条件多、联表复杂低自定义SQL注入器中高想保留Wrapper拼条件中拦截器自动拼接高高全局统一加提示高我的结论是大多数项目直接用注解SQL或XML原生SQL就够了一个SQL一个SQL老老实实写清楚最好排查、最可控。自定义注入器和拦截器虽然看起来很“智能”但接管了SQL生成逻辑后和MybatisPlus其他插件叠加时排查难度成倍上升。NOLOCK本身是止血方案不要让引入它的复杂度反过来把系统搞得更复杂。4. 实测NOLOCK与MybatisPlus分页插件共存这部分坑最深4.1 分页插件到底怎么改写SQLMybatisPlus的PaginationInnerInterceptor是分页能力的核心。它会在SQL执行前把原SQL改写成“一条count查询”和“一条分页查询”。举个例子业务代码PageOrder page new Page(1, 20); LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getStatus, 1); orderMapper.selectPage(page, wrapper);插件大概会先执行SELECT COUNT(1) FROM orders WHERE status ?拿到总数后再执行分页SQLSELECT * FROM orders WHERE status ? OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY页面上展示的总数和当前页数据都取决于这个解析过程。SQL Server方言下分页最终是通过OFTSET/FETCH实现这和MySQL的LIMIT、Oracle的ROWNUM又不一样所以SQL Server项目里千万不能配错DbType。4.2 分页与NOLOCK的三种碰撞结果把NOLOCK掺进分页SQL后可能出现三种不同情况。第一种用注解SQL写NOLOCK再用Page参数分页。Select(SELECT * FROM orders WITH (NOLOCK) WHERE status #{status}) ListOrder selectOrdersWithNolock(Param(status) Integer status);然后这样调用PageOrder page new Page(1, 20); orderMapper.selectOrdersWithNolock(page, 1);在MybatisPlus 3.5.3以上版本JSQLParser解析FROM orders WITH (NOLOCK)基本没问题插件生成的count SQL也会带上NOLOCKSELECT COUNT(1) FROM orders WITH (NOLOCK) WHERE status ?实测下来总数正确、分页正常是四种场景里最稳的。第二种用Wrapper加NOLOCK这是我在网上看到最多人踩的坑。LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getStatus, 1); wrapper.last(WITH (NOLOCK));这里要特别注意last方法拼接的片段是放在整个SQL末尾的不是放在表名后面。最终生成的SQL可能是SELECT * FROM orders WHERE status ? WITH (NOLOCK)WHERE子句后面跟着WITH (NOLOCK)SQL Server会尝试把它解析成查询提示但NOLOCK并不是合法的查询提示。结果就是要么语法报错要么把WITH当成表名直接报“对象名WITH无效”。很多人说“加NOLOCK后分页失效”十有八九是这里出了问题——NOLOCK被拼错了位置跟分页根本没有直接关系。第三种老版本MybatisPlus的JSQLParser对表提示支持不完整。在3.4.x系列里解析FROM orders WITH (NOLOCK)可能直接抛异常类型通常是JSQLParserCannotParseException整个分页逻辑直接瘫痪。老版本项目如果非要加NOLOCK建议先把MybatisPlus升级到3.5.3.1以上。4.3 一条完整的分页NOLOCK排查链路这种问题非常隐蔽肉眼很难看出来我把排查路径写下来照着走就行。第一步打开SQL日志。在application.yml配置logging: level: com.example.mapper: debug第二步把日志里打印出来的原始SQL复制出来重点看FROM后面NOLOCK的位置对不对。正确的形态是FROM orders WITH (NOLOCK)不应该是WHERE status ? WITH (NOLOCK)。第三步把原始SQL直接丢到SSMS执行验证SQL本身能否跑通。如果SSMS都报“对象名WITH无效”那就是拼写位置的问题。第四步如果原始SQL正常再看分页插件生成的count SQL。把count SQL也复制到SSMS执行看看总数是不是正确的。这一步能定位“总数对不上”的问题。第五步如果是解析异常检查MybatisPlus版本和JSQLParser版本。3.5.3.1是个分水岭低于这个版本建议先升级再谈NOLOCK的事情。还有一个特别容易忽略的细节分页功能依赖PaginationInnerInterceptor正常注册。很多项目把这个拦截器不小心注掉了Page参数完全失效表现就是“查询不分页了”跟NOLOCK没有任何关系。排查时先确认拦截器在不在不要一股脑把锅甩给NOLOCK。5. 用了NOLOCK之后踩过的坑以及比NOLOCK更优的方案5.1 联表JOIN漏加NOLOCK白折腾一场项目初次上线时我只给主表orders加了NOLOCK生产的性能折损还是在。原因在JOIN出来的SQL6张表里只有1张表加了NOLOCK其余5张照样在读取时申请共享锁依然和写操作互相等待。不说别的把整段SQL的锁提示补齐之后情况立刻改善。正确的写法是每张业务表都显式带上SELECT * FROM orders o WITH (NOLOCK) JOIN users u WITH (NOLOCK) ON o.user_id u.id JOIN products p WITH (NOLOCK) ON o.product_id p.id JOIN store s WITH (NOLOCK) ON o.store_id s.id另一个细节当NOLOCK主表去关联一个未加NOLOCK的大表时优化器可能选择在大表上做Key Lookup锁还是集中在大表上。排查思路是看执行计划里每张表的外层运算符以及Join算法是Hash Join还是Nested Loop逐个确认锁来源。5.2 聚合统计的隐蔽错误SUM出来的金额少了两千NOLOCK在聚合统计场景有一个特别隐蔽的问题——它会让SUM结果少数或多算。有一次跑批我用NOLOCK统计出来的订单金额比前一天直接少了整整两千多块查了半天最终定位到统计SQL执行期间另一条UPDATE事务正在修改大批量订单SQL Server分步修改一部分行已经改成新值且持有锁另一部分还是旧值。NOLOCK不需要等事务提交就出现了“一部分新值、一部分旧值”的中途值。这种错误在单行UPDATE时几乎发现不了因为单行要么读旧值、要么读新值波动看不出来。但在批量UPDATE、DELETE、索引维护这些长事务下统计结果必然失真。如果你的报表需要在月中、月底对账完全一致就不要碰NOLOCK。5.3 快照隔离比NOLOCK稳妥得多的替代方案那么有没有既不影响写操作、又能读到一致数据的方案有就是SQL Server的行版本控制快照隔离。在数据库层面开启ALTER DATABASE YourDB SET READ_COMMITTED_SNAPSHOT ON;开启之后数据库默认行为就变了普通SELECT读取的是事务启动时已经提交版本的数据快照读不阻塞写、写不阻塞读而且读到的数据在同一事务内保持一致。MybatisPlus项目完全不需要改代码正常写查询就行比逐个加NOLOCK强太多。但它有三个代价需要评估。tempdb压力明显上升每个事务的旧版本都会保留在tempdb里。更新冲突增多两个事务同时读同一行随后都尝试更新这条记录时后提交方可能报3960错误。长事务会拖慢版本清理tempdb空间容易被耗尽DBA要额外盯容量。实际体验下来报表类应用开快照隔离的收益远大于成本。数据一致性有保障代码不用动不用为每条查询单独加NOLOCK也不用担心分页插件解析失败。快照隔离的代价是运维层面而NOLOCK的代价是数据正确性两者孰轻孰重很明确。5.4 我的最终建议和决策顺序如果你也遇到SQL Server搭配MybatisPlus的阻塞问题我建议按下面的顺序决策。第一评估业务能否接受脏读。不能接受直接排除NOLOCK转向快照隔离。第二确认查询是否高频。如果是凌晨跑批、低频报表可以用NOLOCK但必须用注解SQL或XML显式写出来且JOIN的每一张表都加上提示。第三查询要经过分页插件时优先把MybatisPlus升级到3.5.3.1以上用注解SQL写NOLOCK最稳不要研究wrapper.last(WITH (NOLOCK))这种技巧性写法。最后说说我现在的习惯。线上库我会优先开快照隔离保留极少数固定报表查询用NOLOCK。NOLOCK可以说是止血工具快照隔离才是治本方案。治本之外还要去优化索引、消除表扫描、减少长事务持有锁的时间。毕竟查询只要足够快锁持有时间自然就短大部分阻塞问题不用靠锁提示来解决。慢SQL优化永远排在NOLOCK前面这个顺序不能颠倒。
返回列表