本文分析了后端研发和运维在日常工作中所面临的线上SQL定位排查痛点,基于姓名贴的灵感,设计和开发了一款SQL染色标记的MyBatis插件。该插件轻量高效,对业务代码无侵入,接入简单,支持SELECT、INSERT、UPDATE、DELETE等语句,同时也支持无WHERE条件SQL的标记增强。该SQL染色插件并不改变SQL指纹,染色信息内置了statementId、PFinderId,方便分布式跟踪和定位。此外,还提供了附加信息的传递入口,方便用户进行自定义信息染色,例如客户端的执行线程id等。期望在大家面临类似痛点时提供一些实践经验和参考,也欢迎大家合适的场景下接入使用。
作为后端开发,不可避免地与SQL打交道,一个大型复杂系统中往往会有大量的SQL语句支撑业务,而且单表所涉及的不同SQL可能也多达几十个甚至上百个。
当看到一个SQL时,如何快速识别这个SQL是哪块业务的?具体是哪个方法走到了这个SQL?
这些SQL是凭个人大脑无法全部记住的,而且业务在不断发展,SQL语句本身也在不断地变化,可能明天增多一个表的join,后天增多了几个where条件限制,大后天减少了几个字段……
SQL本身也是支持动态拼接形成,当看到一个SQL时,如何快速定位是来自哪块具体业务?这是个问题,也是个难题。
以下面的报表查询SQL为例:
SELECTCOUNT(*)FROMst_stock m
INNERJOINst_lot_shelf_life slsl
ONm.tenant_code =slsl.tenant_code
ANDm.sku =slsl.sku
ANDm.lot_no =slsl.lot_no
ANDslsl.deleted =0WHEREm.deleted =0ANDm.stock_qty >0ANDm.warehouse_no =?
ANDm.lot_no !='-1'ANDm.owner_no IN(?)我经常会面临这种根据SQL定位分析业务来源的问题,尤其是在慢SQL分析治理时,往往会存在类似的痛点。

我们日常看到一些工作人员的制服上会配备姓名贴,这样很有辨识度,通过姓名贴我们可以一看就可以看出来当前的工作人员是哪位同事。
在此启发下,我认为对SQL也可以进行一些染色标记增强,通过这些标记可以一眼看出来这个SQL是哪些业务产生的。
我这里考虑采用MyBatis Plugin机制进行SQL染色增强,可以达到业务零侵入的效果:不改业务代码、不改业务SQL,做到SQL无感增强,自动染色。
用什么来区分SQL的唯一性呢?这个区分的标识区分度越高,越容易达到“一眼就看出来SQL来源”的效果。
对此,我采用SQL statement的id来作为唯一标识。SQL statement是有两部分组成:mapper namespace + SQL id,通过SQL statement的id基本上可以唯一确定程序中的SQL在mapper文件中的位置,顺便可以找到对应的DAO方法,及其追溯到上层调用来源和业务场景。

SQL染色增强,这里是通过将附加信息作为SQL注释,对SQL拼接改写。
因为增加的部分是SQL注释,不影响SQL的执行正确性,也不会改写SQL指纹,对于慢SQL排查定位、死锁日志SQL排查都有帮助。
这里是对SQL执行前进行染色增强,所以拦截StatementHandler的StatementHandler方法即可。

SQL的修改核心代码片段:


插件除了会自动拼接statementId和pFinderId外,还预留了一个ThreadLocal变量,允许使用者执行线程的上线文中向SQL传递附加信息,比如SQL的执行用户ERP、执行线程的id等。

用法示例:
// 其他代码SQLMarkingThreadLocal.put("operator",UserInfoUtil.getUserCode());// 其他代码SQLMarkingThreadLocal.remove();// 其他代码用户也可以通过自定义切面方式自动赋值这些附加信息。

2025-02-1100:27:19.982[http-nio-8082-exec-7]DEBUG [pfinderId:4630283.56667.17392048399060130]org.apache.ibatis.logging.jdbc.BaseJdbcLogger-debug:137-c.j.w.s.i.j.r.d.S.selectStockShelfLifeReport
==>Preparing: SELECTm.id,m.sku,m.location_no locationNo,m.container_level_1 containerLevel1,m.container_level_2 containerLevel2,m.lot_no lotNo,m.sku_level skuLevel,m.owner_no ownerNo,m.pack_code packCode,m.stock_qty stockQty,m.prepicked_qty prePickedQty,m.premoved_qty preMovedQty,m.frozen_qty frozenQty,m.diff_qty diffQty,m.broken_qty brokenQty,m.status,m.create_time ascreateTime,m.update_time asupdateTime,m.update_user asupdateUser,m.create_user ascreateUser,stock_qty -(prepicked_qty +premoved_qty +frozen_qty +diff_qty +broken_qty)availableQty,(prepicked_qty +premoved_qty +frozen_qty +diff_qty +broken_qty)noAvailableQty,m.zone_no zoneNo,m.zone_type zoneType,slsl.shelf_life_status shelfLifeStatus,slsl.left_days leftDays,slsl.production_date productionDate,slsl.expiration_date expirationDate,slsl.shelf_life_days shelfLifeDays,slsl.warning_days warningDays,slsl.regular_advent_days regularAdventDays,slsl.urgent_advent_days urgentAdventDays,slsl.advent_days adventDays,slsl.extend_content extendContent FROMst_stock m INNERJOINst_lot_shelf_life slsl ONm.tenant_code =slsl.tenant_code ANDm.sku =slsl.sku ANDm.lot_no =slsl.lot_no ANDslsl.deleted =0WHEREm.deleted =0ANDm.stock_qty >0ANDm.warehouse_no =? ANDm.lot_no !='-1'LIMIT? /* [SQLMarking] statementId: com.jdwl.wms.stock.infrastructure.jdbc.report.dao.StockShelfLifeReportDao.selectStockShelfLifeReport, pFinderId: 4630283.56667.17392048399060130, operator: guozhongqiang5, traceId: 59f48d4d-5346-4ffe-9837-693a090090fc */2025-02-1100:27:19.982[http-nio-8082-exec-7]DEBUG [pfinderId:4630283.56667.17392048399060130]org.apache.ibatis.logging.jdbc.BaseJdbcLogger-debug:137-c.j.w.s.i.j.r.d.S.selectStockShelfLifeReport
==>Parameters: 6_975(String),10(Integer)2025-02-1100:27:19.988[http-nio-8082-exec-7]DEBUG [pfinderId:4630283.56667.17392048399060130]org.apache.ibatis.logging.jdbc.BaseJdbcLogger-debug:137-c.j.w.s.i.j.r.d.S.selectStockShelfLifeReport
<==Total: 10SELECTm.id,m.sku,m.location_no locationNo,m.container_level_1 containerLevel1,m.container_level_2 containerLevel2,m.lot_no lotNo,m.sku_level skuLevel,m.owner_no ownerNo,m.pack_code packCode,m.stock_qty stockQty,m.prepicked_qty prePickedQty,m.premoved_qty preMovedQty,m.frozen_qty frozenQty,m.diff_qty diffQty,m.broken_qty brokenQty,m.status,m.create_time AScreateTime,m.update_time ASupdateTime,m.update_user ASupdateUser,m.create_user AScreateUser,stock_qty -(prepicked_qty +premoved_qty +frozen_qty +diff_qty +broken_qty)availableQty,(prepicked_qty +premoved_qty +frozen_qty +diff_qty +broken_qty)noAvailableQty,m.zone_no zoneNo,m.zone_type zoneType,slsl.shelf_life_status shelfLifeStatus,slsl.left_days leftDays,slsl.production_date productionDate,slsl.expiration_date expirationDate,slsl.shelf_life_days shelfLifeDays,slsl.warning_days warningDays,slsl.regular_advent_days regularAdventDays,slsl.urgent_advent_days urgentAdventDays,slsl.advent_days adventDays,slsl.extend_content extendContent
FROMst_stock m
INNERJOINst_lot_shelf_life slsl
ONm.tenant_code =slsl.tenant_code
ANDm.sku =slsl.sku
ANDm.lot_no =slsl.lot_no
ANDslsl.deleted =0WHEREm.deleted =0ANDm.stock_qty >0ANDm.warehouse_no =?
ANDm.lot_no !='-1'LIMIT?
/* [SQLMarking] statementId: com.jdwl.wms.stock.infrastructure.jdbc.report.dao.StockShelfLifeReportDao.selectStockShelfLifeReport, pFinderId: 4630283.56667.17392048399060130, operator: xxx, traceId: 59f48d4d-5346-4ffe-9837-693a090090fc */通过这个染色标记后的SQL我们可以一眼看出来,这个SQL是来自StockShelfLifeReportDao中的selectStockShelfLifeReport方法,其中StockShelfLifeReportDao对应于mapper文件中的namespace,selectStockShelfLifeReport 对应于 SQL id。
除了statementId和pFinderId外,还允许用户在线程上下文中自定义传输一些附加信息到SQL中,并体现在SQL注释信息中。



借助IDE的reference功能,我们可以很快找到调用入口:

继续向上追溯,流量源头是来自一张报表查询:


既然是代理增强,多少会有一些性能开销,目前根据我这边使用的情况来看,单个SQL基本上是0-1ms左右,个别在3-4ms,正常情况下,不会影响业务响应时长。
已支持的场景:
•使用MyBatis的SQL,包含select、insert、update、delete,同时也支持无where条件的SQL。支持MyBatis-Plus。
SELECT SQL效果:
SELECTCOUNT(DISTINCTito.transfer_order_no)ASqty
FROMinv_transfer_order ASito
LEFTJOINinv_transfer_order_detail itd
ONito.warehouse_no =itd.warehouse_no
ANDito.transfer_order_no =itd.transfer_no
ANDitd.deleted =0WHEREito.deleted =0ANDito.warehouse_no =?
ANDito.transfer_status IN(?,?,?,?,?,?,?,?)/* [SQLMarking] statementId: com.jdwl.wms.inventory.xxx.infrastructure.jdbc.dao.TransferOrderDao.selectOverstockOrderQty, pFinderId: 4900300.56689.17397685906403801, traceId: abc53cd3-e814-451e-a771-5d8caae861a7, operator: xxx */UPDATE SQL效果:
UPDATEinv_transfer_task_detail
SETtask_status =?,task_user =?,update_user =?,update_time =now(),receive_time =now()WHEREwarehouse_no =?
ANDdeleted =0ANDorder_detail_id IN(?)ANDtask_status IN(?,?,?)/* [SQLMarking] statementId: com.jdwl.wms.inventory.xxx.infrastructure.jdbc.dao.TransferTaskDetailDao.updateStatusAndTaskUserByOrderDetailAndStatus, pFinderId: 4900300.56689.17397685881342999, traceId: 41366c16-2e10-4c45-a10c-c84326e201b4, operator: xxx */INSERT SQL效果:
INSERTINTOinv_transfer_task_result
(id,result_no,transfer_type,task_type,location_no,container_level_1,container_level_2,container_full,extend_content,warehouse_no,create_user,create_time,update_user,update_time,task_no,tenant_code
)VALUES(?,?,?,?,?,?,?,?,?,?,?,now(),?,now(),?,'TC26473419')/* [SQLMarking] statementId: com.jdwl.wms.inventory.xxx.infrastructure.jdbc.dao.TransferTaskResultDao.insert, pFinderId: 4900300.56689.17397685845562352, traceId: 7cc0eebf-c4c5-4fc1-b5de-ae1f14ba29ba, operator: xxx */无WHERE条件的SQL效果:
SELECTNOW()/* [SQLMarking] statementId: com.jdwl.wms.stock.xxx.jdbc.main.dao.StockQueryDao.dbTime, pFinderId: 2033056.56579.17392526509236705 */该插件暂不支持的场景如下:
•ORM非MyBatis的SQL,例如通过 connection statement execute 操作的SQL,通过JdbcTemplate 操作的SQL等。
慢SQL分析

会话管理


PFinder SQL分析

如果小伙伴也有类似痛点和使用诉求,可以接入这个简易的SQL染色标记插件。
目前该组件已在多个大型复杂系统的生产环境中接入使用,大家可以先在测试、UAT环境接入试用,然后再逐步推广线上生产环境。
接入方法也非常简单,如下。
注:目前发布的版本是京东内部版本,京东外部伙伴若有引入需要,可联系 [email protected] 发布一个外部版本,或者按照文中的解决方案和思路定制开发一个自己需要的SQL染色插件。 1、引入Maven坐标:
<!-- http://sd.jd.com/article/42942?shareId=105168&isHideShareButton=1 -->
<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-mybatis-plugins</artifactId>
<version>1.0.7-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
<exclusion>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-utils-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
对于其中的间接依赖,例如lombok等,大家可以使用自己工程中的已有依赖,在这里可以通过exclusion排掉,如果自己工程中没有这些依赖,可以不exclusion。
2、在mybatis config xml中引入SQLMarking插件:
<!-- http://sd.jd.com/article/42942?shareId=105168&isHideShareButton=1 -->
<!-- SQLMarking Plugin,放在第一个Plugin的位置,不影响其他组件,但不强要求位置,也可以灵活调整顺序位置 -->
<plugin interceptor="com.jd.sword.mybatis.plugin.sql.SQLMarkingInterceptor">
<!-- 是否开启SQL染色标记插件 -->
<property name="enabled" value="true"/>
<!-- 是否开启方法调用栈跟踪 -->
<property name="stackTraceEnabled" value="true"/>
<!-- 指定需要方法调用栈跟踪的package,减少信息量,value配置为自己工程的package路径,多个路径用英文逗号分割 -->
<property name="specifiedStackTracePackages" value="com.jdwl.wms.stock"/>
<!-- 结合CPU利用率和性能考虑,方法调用栈跟踪采集率配置采集率,配置示例: m/n,表示n个里面抽m个进行采集跟踪 -->
<!-- 预发环境和测试环境可以配置全采集,例如配置1/1,生产环境可以结合CPU利用率和性能考虑按需配置采集率 -->
<property name="stackTraceSamplingRate" value="1/2"/>
</plugin>
1、支持 Mybatis-Plus 吗?
答:支持,Mybatis-Plus是在MyBatis基础上的增强,MyBatis插件可以得到执行。
2、SQLMarking Plugin 在 plugins中的位置有严格要求吗,比如必须第一个位置?
答:没有严格要求,理论上放上放下都可以。有的小伙伴工程里依赖了多种 MyBatis Plugin,多种Plugin之间可能会有冲突,比如有些 Plugin 会对SQL的开头INSERT/SELECT/UPDATE/DELETE关键词进行前缀判断,大家如果遇到报错可以灵活调整 SQLMarking Plugin 的位置,向上或向下调整,不一定非得放在第一个位置。
3、报错信息:There is no getter for property named 'delegate' in 'class com.sun.proxy.$Proxy211'
答:这种是多个插件之间有先后顺序依赖,别的插件先行执行,影响了delegate的获取,调整 SQLMarking Plugin 的位置,向上或向下调整,可解决冲突。
4、报错信息关键词:NoClassDefFoundError RoutingStatementHandlerUtils
答:缺少依赖,添加以下依赖:
<dependency>
<groupId>mybatis-plugins</groupId>
<artifactId>mybatis-plugins</artifactId>
<version>2.2.3</version>
</dependency>5、染色信息中如何添加一些个性化的附加信息?
答:可以用下这个
SQLMarkingThreadLocal.put(key, value)SQL 执行完 remove 掉。一个方法同时执行多个SQL时,如果 SQLMarkingThreadLocal 可共享,也可以在方法维度上 put 和 remove,就不用每个SQL put remove一下。主要是看线程上下文是否应该传递SQLMarkingThreadLocal的信息。
SQLMarkingThreadLocal.remove()6、报错信息 java.lang.AbstractMethodError: com.jd.sword.mybatis.plugin.sql.SQLMarkingInterceptor.plugin(Ljava/lang/Object;)Ljava/lang/Object;
答:org.mybatis:mybatis 版本较低。可以将sword-mybatis-plugins 升级至 1.0.5-SNAPSHOT 或之上版本,对低版本 mybatis 做了兼容。
同时需要注意,sword-mybatis-plugins 在 1.0.4-SNAPSHOT 或之上版本时,需要新引入下面的依赖:
<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-utils-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>7、sword-mybatis-plugins包down不下来
答:如果是京东科技的小伙伴,请注意检查私服配置,该包deploy在京东公共的私服上,没在京东科技私有的私服上。
8、报错 Caused by: org.apache.ibatis.plugin.PluginException: Could not find method on interface org.apache.ibatis.executor.statement.StatementHandler named prepare. Cause: java.lang.NoSuchMethodException: org.apache.ibatis.executor.statement.StatementHandler.prepare(java.sql.Connection, java.lang.Integer)
答:mybatis 版本过低,低于 3.4.0,mybatis 3.4.0 之后 StatementHandler 中的 prepare 变成2个参数的方法,不再是只有1个入参的方法,如下图:

解决方法就是将mybatis版本升级至3.4.0之上,例如:
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.6</version>