“Java TDSQL-PG 全链路性能诊断引擎”。今天我把这套方案的底裤都扒干净✅ TDSQL-PG分布式架构下pg_stat_statements的隐藏坑点✅ Java APM耗时与DB耗时“对不上”的5大元凶✅ 可直接复用的MyBatis TraceID注入拦截器保姆级代码✅ Python全自动甩锅终结脚本拉取CN/DN数据做关联分析✅ 3个让我熬秃头的真实翻车案例 第一章TDSQL-PG版的pg_stat_statements水有多深老铁们如果你用的是单机PostgreSQLpg_stat_statements简称PSS闭着眼睛用就行。但TDSQL-PG是分布式架构啊它由CNCoordinator Node协调节点 和DNData Node数据节点 组成。这俩节点的PSS看到的“世界”是完全不一样的。1.1 CN与DN的“罗生门”维度 CN节点的PSS DN节点的PSS记录的SQL 用户原始SQL逻辑SQL 被CN改写后的物理分片SQL包含网络耗时 ✅ 包含DN间数据shuffle耗时 ❌ 只记录本地执行耗时包含解析耗时 ✅ 包含语法解析分布式计划生成 ❌ 只记录执行耗时数据倾斜可见性 ❌ 看不到只能看到总耗时 ✅ 能看到哪个分片跑得最慢排查价值 找“整体慢”的SQL 找“数据倾斜/分片不均”的SQL⚠️ 重点DBA如果只查DN的PSS就会完美漏掉那些“DN跑得快但CN网络传输慢”的幽灵SQL开头那个甩锅大会就是这么来的。1.2 TDSQL-PG PSS的正确配置姿势在TDSQL-PG里PSS必须在CN和DN上都开启而且参数有讲究。– – TDSQL-PG 协调节点(CN) postgresql.conf 核心配置– 设计思想CN是流量入口必须记录全量SQL和完整耗时– – 1️⃣ 必须加到共享预加载库改完要重启shared_preload_libraries ‘pg_stat_statements’– 2️⃣ 追踪级别必须设为 ‘all’– ⚠️ 坑点如果设为 ‘top’存储过程内部的SQL不会被记录pg_stat_statements.track ‘all’– 3️⃣ 最大记录SQL数建议调大– 默认5000太小大促期间高并发热点SQL很快就会被挤出内存pg_stat_statements.max 20000– 4️⃣ 是否记录规划时间TDSQL-PG分布式计划生成很耗时必须开pg_stat_statements.track_planning on– 5️⃣ 是否落盘防止重启丢失历史数据pg_stat_statements.save on– – 启用插件在CN和DN的业务库上都要执行– CREATE EXTENSION IF NOT EXISTS pg_stat_statements;1.3 揪出“幽灵慢查询”的CN专属SQL别再用网上那些单机版的PSS查询模板了在TDSQL-PG的CN节点你需要关注分布式计划耗时和网络IO。– – TDSQL-PG CN节点排查“网络传输/重分布”导致的慢SQL– 核心逻辑总耗时 - (规划耗时 DN执行耗时) ≈ 网络Shuffle耗时– SELECT– 基础信息queryid,LEFT(query, 100) AS sql_snippet, – 截取前100个字符防止刷屏-- ⏱️ 耗时拆解毫秒 calls AS exec_count, -- 执行次数 round(total_plan_time::numeric, 2) AS total_plan_ms, -- 总规划耗时 round(total_exec_time::numeric, 2) AS total_exec_ms, -- 总执行耗时 round(mean_time::numeric, 2) AS avg_total_ms, -- 平均总耗时 -- 核心指标单次阅读块数判断是否发生了大面积Broadcast shared_blks_read AS phy_read_blks, -- 辅助判断如果临时块(temp_blks)极高说明CN在做大规模Hash Join的内存溢出 temp_blks_read temp_blks_written AS temp_io_blks, -- 缓存命中率低于90%就要警惕了 round( (shared_blks_hit::numeric / NULLIF(shared_blks_hit shared_blks_read, 0)) * 100, 2 ) AS hit_ratio_pctFROM pg_stat_statementsWHERE– 过滤掉系统自身的统计查询和PSS重置操作query NOT ILIKE ‘%pg_stat_statements%’AND query NOT ILIKE ‘%pg_catalog%’– 只找平均耗时 100ms 的SQLAND mean_time 100ORDER BY– 排序策略按“总耗时”降序找出真正的“时间黑洞”total_exec_time DESCLIMIT 20; 金句在分布式数据库里慢的往往不是计算而是“搬家”数据Shuffle。 第二章Java端的“盲区”——为什么APM和DB对不上APM如SkyWalking/Pinpoint显示DB调用耗时500ms。DBA查PSS平均耗时只有20ms。中间那480ms去哪了被狗吃了吗老铁这480ms通常藏在Java到DB的“黑盒”里。2.1 耗时差异的5大元凶graph LRJ[☕ Java应用] --|1. 获取连接| P[ HikariCP连接池]P --|2. 网络传输| N[ 网络层]N --|3. 解析规划| C[ TDSQL-PG CN]C --|4. 执行Shuffle| D[ TDSQL-PG DN]D --|5. 结果集返回| Jstyle P fill:#ff6b6b,color:#fff style N fill:#ffd43b,color:#333 style C fill:#845ef7,color:#fff元凶 现象 怎么查连接池等待 APM耗时高DB没记录 查HikariCP的pending指标结果集过大 DB执行快但网络传了半分钟 查PSS的rows字段和Java端内存事务未提交 SQL早跑完了但事务挂起 查DB的pg_stat_activity状态JDBC隐式转换 索引失效全表扫描 查执行计划EXPLAIN批量插入未开启 1000条Insert变成1000次网络IO 查JDBC URL是否加了reWriteBatchedInserts 血泪教训第5点我踩过巨坑MyBatis-Plus的saveBatch默认是假批量如果不加JDBC参数TDSQL-PG会被打出屎。一定要在URL加上reWriteBatchedInsertstrue 第三章全链路诊断引擎复制就能用终结甩锅既然两边都有盲区那我们就把Java和DB打通。核心思路在Java端给SQL注入TraceID在DB端通过TraceID反查PSS。3.1 Java端MyBatis拦截器注入TraceID我们要在SQL执行前把SkyWalking的TraceID以SQL注释的形式拼接到SQL末尾。这样DBA在PSS里就能直接看到这条SQL对应的链路ID// // 文件TraceIdSqlInterceptor.java// 用途MyBatis拦截器自动在SQL尾部注入TraceID// 设计思想// 1. 使用 /* trace_idxxx */ 注释不影响SQL语法和执行计划// 2. 兼容TDSQL-PG的CN节点解析CN会保留注释记录到PSS// 3. 性能开销极低仅字符串拼接// import org.apache.ibatis.executor.statement.StatementHandler;import org.apache.ibatis.mapping.BoundSql;import org.apache.ibatis.plugin.*;import org.apache.skywalking.apm.toolkit.trace.TraceContext;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Component;import java.lang.reflect.Field;import java.sql.Connection;import java.util.Properties;ComponentIntercepts({Signature(type StatementHandler.class,method “prepare”,args {Connection.class, Integer.class})})public class TraceIdSqlInterceptor implements Interceptor {private static final Logger log LoggerFactory.getLogger(TraceIdSqlInterceptor.class); // 反射获取BoundSql的Field缓存起来避免每次反射影响性能 private static final Field BOUND_SQL_FIELD; static { try { // 技巧MyBatis的RoutingStatementHandler内部包装了真实的Handler // 需要拿到delegate里的boundSql BOUND_SQL_FIELD BoundSql.class.getDeclaredField(sql); BOUND_SQL_FIELD.setAccessible(true); } catch (NoSuchFieldException e) { throw new RuntimeException(初始化TraceId拦截器失败, e); } } Override public Object intercept(Invocation invocation) throws Throwable { try { // 1️⃣ 获取StatementHandler StatementHandler handler (StatementHandler) invocation.getTarget(); // 边界处理如果是RoutingStatementHandler需要解包 // (这里为了代码简洁假设直接拿到了BaseStatementHandler) BoundSql boundSql handler.getBoundSql(); String originalSql boundSql.getSql(); // 2️⃣ 获取SkyWalking的TraceID // ⚠️ 易错点如果没引入SkyWalking依赖这里会报错 // 建议用try-catch兜底或者换成MDC.get(traceId) String traceId TraceContext.traceId(); if (traceId null || traceId.isEmpty() || N/A.equals(traceId)) { traceId unknown; } // 3️⃣ 清理SQL去掉多余的换行和空格防止注释被截断 String cleanSql originalSql.replaceAll([\s], ).trim(); // 4️⃣ 拼接TraceID注释 // 格式SELECT ... /* trace_idabc123, apporder-service */ // 为什么放尾部因为有些ORM框架会截断SQL头部 String enhancedSql String.format( %s /* trace_id%s, app%s */, cleanSql, traceId, order-service // 建议从环境变量读取应用名 ); // 5️⃣ 反射替换原始SQL BOUND_SQL_FIELD.set(boundSql, enhancedSql); } catch (Exception e) { // ️ 兜底拦截器绝对不能影响主流程出错就放行原始SQL log.warn(⚠️ TraceID注入失败降级使用原始SQL: {}, e.getMessage()); } // 6️⃣ 继续执行MyBatis后续流程 return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 暂无额外配置 }} 技巧加上这个拦截器后DBA在TDSQL-PG的CN节点查PSSquery字段就会带上/* trace_idxxx */。出了慢SQLDBA直接把TraceID甩给Java开发“自己去看APM链路”甩锅闭环完美3.2 DB端Python全自动“甩锅终结”脚本DBA不可能天天盯着PSS看。我写了个Python脚本定时拉取TDSQL-PG的PSS数据结合Java APM的阈值自动生成“诊断报告”。 文件tdsql_pg_diagnostic.py 用途TDSQL-PG Java 全链路性能自动诊断脚本 依赖pip install psycopg2-binary requestsimport reimport psycopg2import loggingfrom dataclasses import dataclassfrom typing import Optionallogging.basicConfig(levellogging.INFO, format“%(asctime)s [%(levelname)s] %(message)s”) 配置区域TDSQL_CN_DSN “host10.0.0.10 port15432 dbnameorder_db userreadonly passwordxxx”TDSQL_DN_DSN “host10.0.0.11 port15433 dbnameorder_db userreadonly passwordxxx” 诊断阈值根据业务调整SLOW_SQL_THRESHOLD_MS 200 # 慢SQL阈值毫秒HIGH_TEMP_IO_THRESHOLD 10000 # 高临时IO阈值块LOW_HIT_RATIO_THRESHOLD 80.0 # 低缓存命中率阈值%dataclassclass SlowQueryReport:“”“慢查询诊断报告”“”queryid: strsql_snippet: strtrace_id: Optional[str]avg_time_ms: floatcalls: inttemp_io_blks: inthit_ratio: floatdiagnosis: str # 诊断结论def extract_trace_id(sql: str) - Optional[str]:“”从SQL注释中提取TraceID 正则匹配/* trace_idxxx */“”match re.search(r’/strace_id([a-zA-Z0-9_.-]), sql)return match.group(1) if match else Nonedef analyze_cn_slow_queries(conn) - list[SlowQueryReport]:“”分析CN节点的慢查询侧重网络Shuffle和规划耗时“”query “”SELECTqueryid::text,query,calls,round(mean_time::numeric, 2) AS avg_time_ms,temp_blks_read temp_blks_written AS temp_io,round((shared_blks_hit::numeric / NULLIF(shared_blks_hit shared_blks_read, 0)) * 100, 2) AS hit_ratioFROM pg_stat_statementsWHERE mean_time %sAND query NOT ILIKE ‘%pg_stat_statements%’ORDER BY total_exec_time DESCLIMIT 50;“”reports [] with conn.cursor() as cur: cur.execute(query, (SLOW_SQL_THRESHOLD_MS,)) for row in cur.fetchall(): queryid, sql, calls, avg_time, temp_io, hit_ratio row trace_id extract_trace_id(sql) # 自动诊断逻辑核心干货 diagnosis [] # 1. 检查临时IO判断是否发生大规模数据重分布/Hash溢出 if temp_io HIGH_TEMP_IO_THRESHOLD: diagnosis.append( [分布式Shuffle警告] 临时块IO极高({}) 大概率是分布式Join触发了Broadcast/Redistribute 请检查Join字段是否为分布键(Shardkey).format(temp_io) ) # 2. 检查缓存命中率 if hit_ratio is not None and hit_ratio LOW_HIT_RATIO_THRESHOLD: diagnosis.append( ⚠️ [缓存击穿警告] 命中率仅{}% 可能存在全表扫描或大量冷数据查询请检查索引。.format(hit_ratio) ) # 3. 检查执行频次高频慢 灾难 if calls 10000 and avg_time 500: diagnosis.append( [雪崩警告] 高频({})高耗时({}ms) 正在拖垮CN节点CPU建议立即限流或加缓存.format(calls, avg_time) ) if not diagnosis: diagnosis.append( 常规慢查询建议结合TraceID查看APM链路耗时。) reports.append(SlowQueryReport( queryidqueryid, sql_snippetsql[:150].replace(n, ), trace_idtrace_id, avg_time_msavg_time, callscalls, temp_io_blkstemp_io, hit_ratiohit_ratio or 0.0, diagnosis | .join(diagnosis) )) return reportsdef generate_markdown_report(reports: list[SlowQueryReport]):“”“生成Markdown格式的甩锅终结报告”“”md [“# TDSQL-PG 慢查询全链路诊断报告n”]md.append(f 扫描阈值: 平均耗时 {SLOW_SQL_THRESHOLD_MS}msnn)if not reports: md.append(✅ 恭喜未发现超过阈值的慢查询DBA今天可以准时下班。n) else: md.append(f 发现 {len(reports)} 个高危慢查询请相关开发/DBA立即认领nn) md.append(| 严重度 | TraceID | 平均耗时 | 频次 | 诊断结论 | SQL片段 |n) md.append(|---|---|---|---|---|---|n) for r in reports: severity if r.avg_time_ms 1000 else trace_link f{r.trace_id} if r.trace_id else ❌ 未注入 md.append( f| {severity} | {trace_link} | {r.avg_time_ms}ms | {r.calls} f| {r.diagnosis} | {r.sql_snippet[:80]}... |n ) report_content n.join(md) with open(tdsql_slow_query_report.md, w, encodingutf-8) as f: f.write(report_content) print( 报告已生成: tdsql_slow_query_report.md)if name “main”:try:logging.info(“ 正在连接 TDSQL-PG CN节点…”)cn_conn psycopg2.connect(TDSQL_CN_DSN)reports analyze_cn_slow_queries(cn_conn)generate_markdown_report(reports)except Exception as e:logging.error(f诊断脚本执行失败: {e})finally:if ‘cn_conn’ in locals():cn_conn.close() 金句没有TraceID的慢SQL就像没有指纹的凶器谁都不认账。把TraceID刻进SQL里让证据自己说话。 第四章3个真实翻车案例血泪教训5.1 翻车1JDBC隐式转换把TDSQL-PG的索引干碎了场景订单表ordersorder_no字段类型是VARCHAR(64)并且是分布键Shardkey 主键。Java代码MyBatisSELECT * FROM orders WHERE order_no #{orderNo}翻车点TDSQL-PG的CN节点在解析时发现参数类型和字段类型不完全匹配偷偷加了一个隐式转换– CN改写后的实际执行SQLSELECT * FROM orders WHERE order_no ($1)::text后果因为带了类型转换函数索引直接失效更恐怖的是order_no是分布键。分布键失效意味着CN无法确定数据在哪个DN只能向所有DN发起Broadcast广播全表扫描一个本来0.1ms的点查变成了500ms的全集群广播。修复在MyBatis中显式指定jdbcTypeSELECT * FROM orders WHERE order_no #{orderNo, jdbcTypeVARCHAR} 教训在PostgreSQL/TDSQL-PG里永远不要相信JDBC驱动的自动类型推断。VARCHAR、BIGINT、TIMESTAMP统统给我显式写上jdbcType5.2 翻车2分布式Join的“数据倾斜”惨案场景– 用户表按user_id分片 JOIN 订单表按order_id分片SELECT u.name, COUNT(o.id)FROM users uJOIN orders o ON u.id o.user_idWHERE u.create_time ‘2023-01-01’GROUP BY u.name;现象APM显示这个接口耗时3秒。DBA看CN的PSS耗时3秒。看DN的PSS发现DN-1跑了2.9秒DN-2到DN-8只跑了0.01秒。原因两个表的分布键不同一个是user_id一个是order_id。TDSQL-PG的CN为了做Join必须把数据Redistribute重分布 到同一个节点。偏偏有个“测试账号”user_id0下面挂了200万条订单这200万条数据全部Shuffle到了DN-1DN-1直接被CPU打满其他DN在看戏。修复业务层清理脏数据禁止测试账号参与真实统计。SQL层如果必须查加上过滤条件剔除极端倾斜的Key。架构层对于高频Join的表建表时尽量使用相同的分布键Colocation Group让Join在本地DN完成避免网络Shuffle。 魔性比喻数据倾斜就像吃火锅有人碗里全是肉DN-1有人碗里只有汤DN-2。建表时选好分布键就是给每个人分个公平的漏勺。5.3 翻车3长事务连接池拖垮整个集群场景Java端有个导出Excel的功能。Transactional // ❌ 大事务public void exportAllOrders() {List list orderMapper.selectAll(); // 查出50万条数据// 然后开始用POI写Excel写了整整2分钟…excelWriter.write(list);}翻车点Transactional开启了事务查完50万条数据后事务没有提交Java线程在慢吞吞地写Excel耗时2分钟。这2分钟内TDSQL-PG的这个连接一直处于idle in transaction事务空闲状态。TDSQL-PG的MVCC机制导致这2分钟内其他事务产生的旧版本数据无法被Vacuum回收并发一高连接池瞬间耗尽数据库表膨胀全盘崩溃。修复// ✅ 修复剥离大事务流式查询 分批处理public void exportAllOrders() {// 1. 去掉Transactional// 2. 使用MyBatis的ResultHandler流式读取不占满JVM内存orderMapper.streamSelectAll(new ResultHandler() {Overridepublic void handleResult(ResultContext? context) {Order order (Order) context.getResultObject();excelWriter.writeRow(order); // 边查边写}});} 教训永远不要在事务里做耗时的非DB操作如网络请求、写文件、发邮件 事务的生命周期必须像闪电一样快。✅ 第五章TDSQL-PG Java 性能调优CheckList建议打印贴工位 Java / MyBatis 层[ ] 检查JDBC URL是否开启reWriteBatchedInsertstrue批量插入必开[ ] 检查MyBatis XML中的#{}是否显式指定了jdbcType[ ] 检查HikariCP的maximumPoolSize是否合理别盲目设大推荐公式CPU核数 * 2 磁盘数[ ] 检查是否存在Transactional包裹了RPC调用或文件IO[ ] 确认是否已部署TraceID注入拦截器 TDSQL-PG 数据库层[ ] 确认pg_stat_statements在CN和DN均已开启且trackall[ ] 检查高频Join的表是否属于同一个Colocation Group同分布键[ ] 检查是否存在大量idle in transaction的长连接SELECT * FROM pg_stat_activity WHERE state ‘idle in transaction’[ ] 检查DN节点是否存在严重的数据倾斜通过pg_class和分片统计查看[ ] 确认work_mem配置是否足够防止Hash Join溢出到磁盘临时文件 结论调优不是调参数是懂全链路回到开头那个甩锅大会。为什么Java和DBA总是互相指责因为他们都只看到了大象的一条腿。Java看到了APM的耗时DBA看到了DN的耗时。中间的网络Shuffle、连接池等待、隐式转换成了没人管的“三不管地带”。金句在分布式时代没有全链路视角的性能调优都是盲人摸象。把TraceID注入SQL把CN和DN的PSS数据拉通把Java的JDBC参数规范化。这不是在写代码这是在建立团队的“技术信任”。当证据链闭环了甩锅大会自然就变成了复盘大会。这才是老码农该有的工程格局。