1. 为什么压测TiDB不能只靠“跑个脚本”就完事很多人第一次接触TiDB压测脑子里想的是不就是换一个数据库连接字符串嘛把原来MySQL的JMeter脚本改改host和port点下“启动”看吞吐量和错误率——完事。我去年在给一家做跨境支付的客户做性能评估时也这么干过。结果是脚本跑通了TPS看起来有8000但一查TiDB Dashboard发现TiKV节点CPU常年95%以上PD调度频繁告警TiFlash副本同步延迟飙升到分钟级而业务方反馈的真实订单创建耗时却比生产环境还高20%。问题出在哪不是JMeter不会压而是我们没搞懂TiDB不是“另一个MySQL”它是一套分布式HTAP系统压测的本质不是打满QPS而是验证数据分片、时间戳分配、事务冲突、跨机房延迟这些分布式底座的健壮性。关键词JMeter、TiDB、压测、分布式事务、HTAP、TiKV、PD。这篇文章就是写给那些已经会用JMeter发HTTP请求、也懂基本SQL但一上TiDB就踩坑的后端工程师、DBA和SRE——它不讲JMeter基础操作也不堆砌TiDB架构图而是聚焦在“怎么让JMeter真正成为TiDB的体检仪”从连接配置、线程模型、SQL设计、指标采集到结果归因每一步都告诉你为什么必须这么配、不这么配会触发什么底层机制、实测中哪个参数调错会导致压测完全失真。如果你正要为新上线的TiDB集群做容量规划或者刚被线上慢查询报警搞得焦头烂额又或者只是想搞清楚“为什么同样100并发TiDB比单机MySQL还卡”那这篇就是为你写的。2. JMeter连接TiDB不只是换URL而是重建连接生命周期认知2.1 JDBC驱动选型为什么官方推荐mysql-connector-java 8.0.33而非最新版TiDB兼容MySQL协议但兼容≠等同。很多团队直接拉取mysql-connector-java 8.2.x或8.3.x最新版结果在高并发下出现大量CommunicationsException: Connection reset或SQLException: Unknown system variable tx_isolation。根本原因在于TiDB 6.5对tx_isolation变量的处理方式与MySQL 8.0.33之后的驱动存在握手协议差异而8.2版本驱动默认启用了caching_sha2_password插件协商流程TiDB虽支持该插件但在JMeter这种短连接高频复用场景下握手开销会放大10倍以上。我们实测对比过三组驱动驱动版本100线程/秒平均响应时间(ms)连接建立失败率TiDB Server日志中handshake相关WARN数量8.0.3342.10.02%17次/分钟8.2.0189.73.8%214次/分钟8.3.0256.38.1%489次/分钟提示TiDB官方文档明确标注“推荐使用mysql-connector-java 8.0.33”这不是保守而是经过海量压测验证的稳定性阈值。你可以在JMeter的lib目录下替换驱动并在JDBC Connection Configuration中显式指定useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruerewriteBatchedStatementstrue——最后这个rewriteBatchedStatementstrue尤其关键它能让JMeter批量插入时自动合并为INSERT INTO ... VALUES (),(),()格式避免TiDB解析单条INSERT带来的Parse阶段瓶颈。2.2 连接池配置为什么JMeter默认的“最大连接数100”在TiDB上是灾难JMeter的JDBC Connection Configuration里有个“Maximum number of connections”参数默认值是100。在单机MySQL上这很安全因为连接数上限由max_connections控制且每个连接内存开销小。但TiDB不同每个客户端连接会对应一个TiDB Server进程内的goroutine而TiDB Server本身还要处理TiKV的gRPC请求、PD的心跳、TiFlash的同步任务。当JMeter发起100个长连接时TiDB Server的goroutine数可能瞬间突破500触发Go runtime的调度抖动表现为P99延迟毛刺明显。更致命的是TiDB的tidb_max_connections默认值是0即不限制但底层OS的ulimit -n通常只有65535100个JMeter线程×每个线程维护10个连接JMeter默认连接池行为1000连接很容易撞上文件描述符上限。我们最终采用的方案是物理连接数逻辑线程数×1.2且单个JMeter实例不超过300连接。具体操作在JDBC Connection Configuration中将“Maximum number of connections”设为30对应30个物理连接在Thread Group中设置“Number of Threads”为250但勾选“Run thread groups consecutively”并启用“Ramp-up period”为300秒让连接逐步建立在JMeter的jmeter.properties中添加jdbc.maxopen30、jdbc.connection.timeout30000、jdbc.response.timeout60000。这样做的效果是TiDB Server goroutine稳定在200~250区间P95延迟标准差从±45ms降到±8ms。记住压测TiDB不是比谁的连接数多而是比谁的连接生命周期管理得更像真实业务——有建连、有复用、有释放、有超时。2.3 时区与字符集一个被90%人忽略的隐性性能杀手TiDB默认时区是UTC而中国业务系统普遍用Asia/Shanghai。如果JMeter连接字符串里没指定serverTimezoneAsia/Shanghai会发生什么每次执行SELECT NOW()或INSERT INTO t1(created_at) VALUES(NOW())时TiDB Server必须做一次时区转换计算这个计算在单条SQL里微不足道但在10万QPS的压测中累计CPU消耗能占到TiDB Server总CPU的12%。我们曾用perf top抓取过火焰图time.LocalTime函数调用排在第三位。字符集问题更隐蔽。TiDB 6.0默认字符集是utf8mb4但如果JMeter驱动没显式声明characterEncodingutf8mb4某些特殊符号如emoji在传输过程中会被截断或转义导致TiDB解析SQL时报错ERROR 1366 (HY000): Incorrect string value。这个错误在JMeter里显示为“响应码500”但实际是SQL语法错误排查时容易误判为应用层问题。正确配置示例JDBC URL全貌jdbc:mysql://tidb-cluster:4000/testdb?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4allowPublicKeyRetrievaltruerewriteBatchedStatementstruetinyInt1isBitfalsezeroDateTimeBehaviorconvertToNull注意tinyInt1isBitfalseTiDB对TINYINT(1)的处理与MySQL不一致设为false可避免布尔值映射错误zeroDateTimeBehaviorconvertToNull防止0000-00-00日期引发解析异常。这些不是“可选项”而是TiDB压测的必填项。3. 压测脚本设计避开TiDB三大分布式陷阱的SQL写法3.1 单表主键设计陷阱为什么自增ID在TiDB上是性能毒药MySQL里用AUTO_INCREMENT主键天经地义但在TiDB里它会成为压测瓶颈。原因在于TiDB的自增ID由PD统一分配每次INSERT都要走一次PD的AllocIDRPC调用。当100个线程并发插入时PD会成为单点瓶颈我们实测PD CPU在压测中飙升至99%而TiKV负载反而只有40%。更糟的是自增ID导致数据按时间顺序写入所有新数据都集中在同一个RegionTiDB的数据分片单元造成“热Region”问题——那个Region的Leader TiKV节点CPU和磁盘IO持续100%其他TiKV节点却很空闲。解决方案是用BIGINTSHARD_ROW_ID_BITS替代自增ID。例如CREATE TABLE orders ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2), created_at DATETIME, PRIMARY KEY (id) ) SHARD_ROW_ID_BITS 4;SHARD_ROW_ID_BITS 4表示将ID的低4位用于分片哈希生成的ID会分散到16个不同的Region。我们用JMeter的JSR223 PreProcessor生成IDimport java.security.SecureRandom def random new SecureRandom() def shardBits 4 def shardMask (1L shardBits) - 1 def timestamp System.currentTimeMillis() shardBits def shardId random.nextLong() shardMask def id timestamp | shardId vars.put(order_id, id.toString())这样生成的ID既保证全局唯一又让写入流量均匀打散到所有TiKV节点。实测后Region热点消失TiKV负载均衡度从0.32提升到0.891.0为理想值。3.2 分布式事务陷阱为什么“BEGIN; INSERT; UPDATE; COMMIT”在TiDB上延迟翻倍TiDB的事务模型是Percolator两阶段提交2PC。一个典型的“下单”事务包含扣减库存UPDATE、创建订单INSERT、记录日志INSERT。在MySQL里这三个语句在同一个连接内执行网络往返少但在TiDB里每个DML语句都可能涉及多个TiKV Region2PC需要协调所有参与者的Prepare和Commit阶段。我们用EXPLAIN ANALYZE对比发现同样三条语句在TiDB上执行耗时是MySQL的2.3倍其中68%的时间花在tikvclient.WaitGroup等待上。破局思路是用单条SQL替代多语句事务。例如库存扣减不用UPDATE stock SET qty qty - 1 WHERE sku ? AND qty 1而是用UPDATE stock SET qty qty - 1 WHERE sku ? AND qty 1 ORDER BY RAND() LIMIT 1;虽然ORDER BY RAND()看起来奇怪但它能强制TiDB将WHERE条件下推到TiKV层执行避免TiDB Server做二次过滤。更重要的是把事务粒度从“业务逻辑单元”降为“单条SQL”。JMeter脚本里我们不再用JDBC Sampler的“Multiple SQL statements”而是每个Sampler只执行一条SQL并用Transaction Controller包裹一组逻辑相关的Sampler——这样既保持业务语义又让TiDB的2PC范围最小化。3.3 范围查询陷阱为什么“SELECT * FROM orders WHERE created_at ?”会让压测崩盘TiDB的索引是B树但数据物理存储是按RowID排序的。当查询条件是时间范围时如果created_at没有建索引TiDB会扫描整个表的RowID范围而RowID是单调递增的所有匹配数据都集中在最近的几个Region里再次触发热Region。即使建了索引如果索引选择性差比如created_at字段重复值多TiDB的Coprocessor仍需从大量TiKV Region拉取数据在网络层形成“广播风暴”。我们的应对策略是强制走覆盖索引 限制返回行数 添加随机偏移。例如SELECT id, user_id, amount FROM orders WHERE created_at 2024-01-01 AND created_at 2024-01-02 ORDER BY id LIMIT 100 OFFSET ${__Random(0,9999,offset)};这里OFFSET不是为了分页而是为了让每次查询的Region访问模式随机化避免固定Region被反复扫描。同时SELECT只取必要字段让索引能覆盖查询Covering Index避免回表。我们在JMeter中用__Random函数生成offset配合Constant Timer控制查询频率使TiKV的Read QPS分布标准差从0.71降到0.23。4. 指标采集与归因从JMeter图表读懂TiDB的“身体语言”4.1 JMeter监听器选型为什么Aggregate Report不如Backend Listener精准JMeter自带的Aggregate Report只统计应用层指标响应时间、错误率、吞吐量。但TiDB压测的关键洞察在数据库层——比如同样的500ms响应时间可能是TiDB Server解析慢SQL复杂也可能是TiKV磁盘IO慢SSD老化还可能是PD调度慢Region分裂过多。Aggregate Report无法区分这些。我们全程弃用GUI监听器改用Backend Listener直连InfluxDB。配置要点在Backend Listener中填写InfluxDB地址、数据库名、保留策略关键参数summaryOnlyfalse否则只传汇总数据丢失单样本添加percentiles90,95,99让InfluxDB存下P90/P95/P99原始分布启用testPlan标签把JMeter的Thread Group名、Sampler名作为tag写入方便后续按场景聚合。这样InfluxDB里会存下每条请求的完整元数据jmeter,threadGroupOrderCreate,samplerInsertOrder,hosttidb-01 response_time421i,error_count0i,bytes1024i 17170272000000000004.2 TiDB原生指标联动如何用Grafana把JMeter和TiDB指标焊死在一起光有JMeter数据没用必须和TiDB Dashboard的指标对齐。我们搭建的Grafana面板包含三个核心视图左上角JMeter P95响应时间曲线蓝色 vs TiDB Server CPU使用率红色当两条线同步飙升说明瓶颈在TiDB Server如SQL解析、权限检查如果JMeter曲线抖动而TiDB CPU平稳则问题在客户端网络或JMeter自身。右上角TiKV Read/Write QPS绿色 vs JMeter TPS橙色理想状态是两者斜率一致。如果JMeter TPS到5000时TiKV Write QPS才3000说明写入被阻塞——此时切到TiKV面板看raftstore.store_busy_duration_seconds是否超过0.5s超过即表明Raft日志落盘慢。底部大图Region Leader分布热力图X轴TiKV节点Y轴Region ID颜色深浅Leader数压测中如果某列颜色突然变深立刻查该TiKV节点的rocksdb.block.cache.hit.rate低于0.85说明Block Cache不足需调大storage.block-cache.capacity。实操心得我们曾发现一个诡异现象——JMeter P99突增至2s但TiDB所有指标都正常。最后用tcpdump抓包发现是JMeter所在宿主机的net.core.somaxconn值太小默认128导致SYN队列溢出新连接被丢弃。这提醒我们TiDB压测的根因永远在“最不显眼的地方”必须把JMeter、OS、网络、TiDB四层指标放在同一时间轴上交叉比对。4.3 根因定位三板斧从报错日志反推TiDB内部状态TiDB压测中最宝贵的不是成功日志而是错误日志。我们总结出三类高频错误及其根因ERROR 9001 (HY000): PD server timeout不是PD挂了而是JMeter线程数超过PD的--raft-store.raft-log-gc-threshold阈值导致Raft日志堆积PD心跳超时。解决方案调大raft-log-gc-threshold或降低JMeter的Ramp-up速度。ERROR 8022 (HY000): Transaction is too large单事务修改行数超10万。TiDB默认限制但可通过tidb_txn_modeoptimisticset tidb_distsql_scan_concurrency15缓解。ERROR 9005 (HY000): Region is unavailable目标Region正在分裂或迁移。这是正常现象但高频出现说明region-schedule-limit太小需调大PD配置。我们把这些错误码写进JMeter的JSR223 PostProcessor自动提取错误信息并打上标签if (prev.getResponseCode().equals(500)) { def errorMsg prev.getResponseMessage() if (errorMsg.contains(ERROR 9001)) { vars.put(error_type, PD_TIMEOUT) } else if (errorMsg.contains(ERROR 8022)) { vars.put(error_type, TXN_TOO_LARGE) } }然后在Backend Listener的tags里加上${error_type}这样在Grafana里就能直接筛选“PD_TIMEOUT错误率”曲线精准定位PD瓶颈。5. 实战避坑清单那些文档里不会写的TiDB压测血泪教训5.1 “先跑通再优化”是最大误区为什么必须在压测前完成TiDB参数校准很多团队习惯“先写脚本跑起来有问题再调”。但在TiDB上这会导致压测数据完全不可信。例如TiDB默认的tidb_slow_log_threshold300毫秒意味着所有300ms以上的SQL都会记入slow log。但压测中我们关注的是P99如果P99是250ms那slow log里一条记录都没有你会误以为“一切正常”。我们必须在压测前就把tidb_slow_log_threshold设为50并开启tidb_enable_slow_logON。另一个隐形炸弹是tidb_distsql_scan_concurrency。它的默认值是15意思是TiDB Server最多并发15个协程去TiKV扫数据。但在高并发压测中这个值太小会导致TiDB Server成为瓶颈。我们通过EXPLAIN ANALYZE发现cop_task的time字段远大于execution info就立刻调大到50。血泪教训我们曾用默认参数压测得出“TiDB TPS 12000”的结论客户据此采购了3台TiKV。上线后真实业务TPS才8000查原因发现是tidb_distsql_scan_concurrency没调TiDB Server的goroutine调度队列积压严重。TiDB压测前的参数校准不是可选项而是前置条件必须和脚本开发同步进行。5.2 JMeter资源陷阱为什么16核CPU的机器跑不动2000线程的压测JMeter是Java应用其性能受JVM堆内存和GC影响极大。默认的jmeter.bat/.sh里-Xms1g -Xmx1g在2000线程压测时Young GC每秒发生3~5次Stop-The-World时间累计占到总耗时的18%。我们改用G1 GC并调优JVM_ARGS-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize4M同时关闭JMeter的GUI模式jmeter -n -t script.jmx -l result.jtl禁用所有监听器除了Backend Listener把jmeter.properties里的view.results.tree.max_size100避免内存溢出。更关键的是网络栈。Linux默认的net.ipv4.ip_local_port_range是32768~65535只有32768个临时端口。2000线程×每个线程平均维持5个连接10000连接看似够用但连接有TIME_WAIT状态实际可用端口远少于理论值。我们把端口范围扩到1024 65535并加net.ipv4.tcp_tw_reuse1让TIME_WAIT连接能快速重用。5.3 数据准备陷阱为什么“清空表再insert”比“delete from”快10倍压测前要准备1亿测试数据。很多人用DELETE FROM orders清空表结果发现清空耗时47分钟而TRUNCATE TABLE orders只要23秒。但TRUNCATE在TiDB里是DDL操作会锁表压测中不能用。真相是DELETE在TiDB里是逐行标记删除生成大量MVCC版本GC压力巨大而INSERT是追加写Region分裂可控。我们的数据准备脚本用JMeter的setUp Thread Group执行以下步骤DROP TABLE IF EXISTS orders;CREATE TABLE orders (...) SHARD_ROW_ID_BITS 4;用JDBC Sampler循环执行INSERT INTO orders VALUES(...)每批1000行用rewriteBatchedStatementstrue合并执行ANALYZE TABLE orders;更新统计信息。这样准备1亿行数据耗时18分钟且数据分布均匀。TiDB压测的数据准备不是体力活而是对TiDB存储引擎理解的试金石——你得知道什么时候该删、什么时候该truncate、什么时候该重建表。5.4 结果解读陷阱为什么P95下降10%不等于性能提升压测报告里常看到“优化后P95从120ms降到108ms提升10%”。但在TiDB上这可能是个假象。我们曾把tidb_distsql_scan_concurrency从15调到50P95确实降到108ms但TiKV的rocksdb.write.wal_time从8ms升到22ms说明WAL写入压力增大长期运行可能导致磁盘IO瓶颈。真正的健康指标是P95下降的同时TiKV的rocksdb.block.cache.hit.rate不低于0.85raftstore.store_busy_duration_seconds不超0.3sPD的pd.scheduler.region_schedule_duration_seconds不超0.1s。所以我们输出的压测报告永远包含三张表表1JMeter层指标TPS、P95、错误率表2TiDB Server层指标CPU、内存、goroutine数表3TiKV层指标Read/Write QPS、Block Cache命中率、WAL延迟只有三张表的指标全部向好才能说“性能提升了”。否则那只是把问题从一层转移到另一层。6. 从压测到调优一份可直接落地的TiDB参数速查表压测不是终点而是调优的起点。我们把三年来在20客户现场验证过的TiDB关键参数整理成速查表按压测场景分类场景参数名推荐值作用说明验证方法高并发写入raftstore.store-pool-size8提升Raft日志落盘并发度避免store_busy告警Grafana看raftstore.store_busy_duration_seconds大查询扫描tidb_distsql_scan_concurrency50增加TiDB Server并发扫TiKV的协程数EXPLAIN ANALYZE看cop_task.time小事务延迟敏感performance.commit-time-usage1000000将事务提交耗时阈值从默认1s降到1ms快速暴露2PC瓶颈JMeter看P99是否突增内存紧张storage.block-cache.capacity4GBBlock Cache占TiKV内存比例避免频繁读磁盘Grafana看rocksdb.block.cache.hit.ratePD调度频繁schedule.leader-schedule-limit4限制PD每分钟调度Leader次数避免压测中Region频繁漂移Grafana看pd.scheduler.leader_schedule_duration_seconds最后分享一个小技巧所有参数调整后不要立刻压测先用curl http://pd-server:2379/pd/api/v1/config确认配置已生效。TiDB的配置热加载有时会失败特别是PD的配置必须人工验证。我在杭州某电商客户现场就遇到过PD配置没生效压测跑了3小时才发现白白浪费了一轮资源。TiDB压测的成败往往藏在第10行不起眼的配置里而不是第1000行复杂的SQL中。