第一次把 TPC-DS 的 99 条查询分别在 MySQL 和 TiDB 上跑完,对着两份耗时表看了很久:同一台机器、同一份数据、同一批 SQL,整体差了一个数量级以上,但其中十几条查询 MySQL 反而更快,还有几条 TiDB 慢得反常。如果只截一张总耗时柱状图就下结论,那张图大概率是错的。TPC-DS 这套基准真正难的不是“跑起来”,而是让两边的结果具备可比性:造数怎么切、建表怎么对齐、方言怎么改、计时口径怎么定、失败重试算不算,任何一环处理不当,出来的数字就只能内部自嗨。
这篇内容围绕 TPC-DS 在 MySQL 与 TiDB 上的对比测试展开,把整个链路上真正影响结论的环节拆开讲:造数与规模因子怎么选、建表和加载怎么把两边的起跑线拉平、查询方言改写的坑在哪、计时怎么定才服人、结果该按什么维度分组去读,最后是我踩过的几个印象最深的坑。适合正在做数据库选型评估、准备跑一遍 OLAP 基准、或者已经跑完但对结果心里没底的同学参考。不涉及具体厂商结论,只讲方法论和实操。
1. TPC-DS 的测试语义:先明确你要证明的到底是什么
动手之前得先把一件事想清楚:这次测试要回答的问题是什么。是“MySQL 能不能扛住分析型负载”,还是“同等硬件下 TiDB 的 OLAP 能力比 MySQL 强多少”,又或者是“我们现有这套 MySQL 报表系统迁移到 TiDB 能省多少时间”。三个问题对应三套完全不同的测试设计,混在一起做,出来的结论谁也说服不了。
我见过最常见的错误,是把 TPC-DS 当成一个“跑分软件”——装好、造数、跑完、看总耗时,结束。这种做法的结果往往经不起追问:为什么这条查询慢?改一个参数会不会翻盘?换一个规模因子结论还成立吗?答不上来,报告就白写了。
1.1 99 条查询不是 99 个独立考题
TPC-DS 的查询集不是手写死的 99 条 SQL,而是从一组查询模板加上参数替换生成的。同一个模板,换一组日期区间、换一个州、换一个类别,生成的 SQL 结构一样但过滤条件完全不同,执行计划可能天差地别。这意味着“我把 99 条都跑了一遍”这句话本身信息量很低——你跑的是哪一组参数变体?这组变体对数据分布友好还是苛刻?
官方把查询大致分成几类:报表类(大量分组聚合、结果集较小)、迭代类(带WITH或临时结果的反复扫描)、分析类(多维聚合、GROUPING SETS风格)、数据挖掘类(复杂关联与窗口计算)。这个分类很有用,因为它对应完全不同的数据库能力:
- 报表类主要考察聚合和排序效率,考验内存和临时表;
- 迭代类考验优化器对重复子表达式的处理,以及中间结果的落盘策略;
- 分析类考验多表 join 的执行顺序选择;
- 数据挖掘类考验窗口函数和复杂子查询的展开能力。
我建议在测试设计阶段就把 99 条按这四类打上标签,后面出结果时按类看,而不是只看总数。单看总数会被几条极端慢的查询带偏,按类看才能看出“到底是并行能力差,还是优化器差,还是内存不够”。
1.2 七个事实表与三条渠道决定了负载的重心
TPC-DS 的数据模型是典型的雪花结构,24 张表里 7 张事实表、17 张维度表。三条销售渠道——门店、目录、网络——各自对应销售表和退货表,再加一张库存表。这些表的数据量分布极不均匀:门店销售事实表通常是最大的,目录和网络渠道小一些,库存表行数介于中间但更新语义特殊。
这个分布直接决定了查询的重心。绝大多数查询都会命中至少一张大的销售事实表,而且经常是多张大表互相关联。所以真正吃资源的是“大表扫 + 大表 join”,而不是维度表的过滤。理解了这一点,你就知道为什么单机数据库在这类负载上先天吃亏:一条查询要扫几十 GB 的事实表,单线程扫描就是硬伤,跟优化器聪明不聪明关系不大。
反过来,这也解释了为什么有些查询在 MySQL 上表现不差——那些只涉及小维度表或过滤后结果集很小的查询,压根没触及扫描瓶颈,比的就是常量级开销和索引命中的效率,这时候轻量单机反而有优势。
1.3 “跑完”和“跑对”是两件完全不同的事
这是我最想强调的一点。TPC-DS 的查询里有大量窗口函数、CTE、多表外连接、INTERSECT/EXCEPT这类集合运算。MySQL 8.0 之前的版本连窗口函数和 CTE 都不支持,8.0 早期版本也不支持INTERSECT/EXCEPT,FULL OUTER JOIN至今没有原生语法。这意味着如果你不做方言改写,MySQL 侧会有相当一部分查询直接语法报错。
报错了怎么办呢?很多人的处理方式是“跳过”。跳过之后跑出来的“平均耗时”是残缺样本的平均值,跟 TiDB 那边完整 99 条的统计根本不可比。更隐蔽的情况是改写之后语义悄悄变了——本来该是外连接的地方写成了内连接,结果集少了一批行,查询变快了,但快得毫无意义。
所以一个负责任的 TPC-DS 测试,至少要回答三个问题:MySQL 侧有多少条查询需要改写、改写了哪些部分、改写后怎么验证语义等价。这三件事不做,后面的所有数字都站不住。
2. 造数环节:规模因子与并行切分决定后续一切
造数看起来是最没技术含量的一步,实际上它埋的雷最多。数据生成不完整、文件切分不均匀、行尾格式没对齐,这些问题在加载阶段不一定暴露,等查询跑出来结果对不上才发现,回头重来一遍就是几个小时。
2.1 规模因子怎么选才不是自欺欺人
规模因子直接决定数据量。经验换算关系大致是:规模因子 1 对应约 1 GB 的原始文本数据,因子 100 对应约 100 GB 文本。导入数据库之后因为索引、行开销、页填充率等因素,实际占用通常是文本体积的 1.2 到 1.5 倍。所以选因子 100,MySQL 侧 InnoDB 表空间大概要预留 130 GB 以上,主键索引和二级索引另算。
这里有个很现实的取舍:数据量必须显著大于内存,否则整个数据集都缓存在缓冲池里,测出来的就是纯 CPU 性能,跟磁盘 IO 能力毫无关系,而真实生产环境里的分析查询几乎不可能全内存命中。我的经验是让数据集达到可用内存的 2 到 4 倍,这样既能测出缓存命中率的影响,又不至于让测试跑一整天。
还有一点容易被忽略:规模因子不同,查询的执行计划可能不同。小规模下优化器可能选择全表扫描,规模上去之后才走索引或者改变 join 顺序。所以如果你打算用因子 1 跑一遍就外推到因子 100,结论很可能是错的。要么直接在目标规模上测,要么至少在两个不同规模上各跑一遍,确认趋势一致。
2.2 dsdgen 的并行切分与文件碎片
数据生成工具支持并行切分,通过指定并行度和子进程编号,多个进程各生成一部分数据文件。这里要注意两点。
第一,并行度不要超过物理核数的一半。造数本身是 CPU 密集型的字符串拼接,开太满会跟系统其他进程抢资源,反而变慢。而且每个进程都要写文件,磁盘顺序写带宽会成为瓶颈。
第二,并行生成会产生大量小文件,每个子进程对每张表输出一个独立文件。表多、并行度高的时候,文件数量会膨胀到几百个。加载阶段如果按文件逐个导入,光是打开关闭文件的开销就很可观;更麻烦的是某些加载工具对文件数量敏感,容易出现句柄耗尽。我一般会在生成完之后先把同一张表的分片文件按顺序合并成一个大文件,再统一加载。合并的时候要严格保证顺序,否则自增主键的连续性会被破坏,虽然不影响正确性,但会让后续的聚簇索引局部性变差。
生成命令的典型形态是这样,参数含义需要按自己的环境调整:
./dsdgen -SCALE 100 \ -DIR /data/tpcds/sf100 \ -TERMINATE N \ -FORCE Y \ -PARALLEL 8 \ -CHILD 0-TERMINATE N表示字段之间用分隔符结尾而不是换行,-FORCE Y表示覆盖已有文件,-PARALLEL和-CHILD配合做切分。这几项配错了,后面加载阶段会莫名其妙地少行或者多列。
2.3 加载前的数据完整性自检
我的习惯是加载之前先做一轮轻量自检,成本很低但能省下大量返工时间。具体做法是统计每张表的数据文件行数,跟理论行数对照。TPC-DS 各表的行数跟规模因子基本是线性关系,偏差超过 1% 就说明生成过程有问题,通常是并行切分时子进程编号没覆盖全,或者某个进程中途失败没被发现。
另一个必查项是行尾格式。文本文件每行末尾带一个分隔符,如果用标准的行终止符去加载,最后一列会多出一个空字段,导致列数不匹配。这个问题在加载时报错还算好的,怕的是某些宽松模式下被静默截断,数据少了一列你还不知道。解决办法后面加载章节会讲。
3. 建表与加载:把两种引擎的起跑线拉平
这一段是全场最影响结论可信度的部分,也是最容易被敷衍过去的部分。很多对比测试之所以结论离谱,就是因为两边建表方式、索引策略、加载顺序完全不对等,跑出来的差异里有一大半是配置差异而不是引擎能力差异。
3.1 MySQL 侧:字符集、聚簇索引与 sql_mode
MySQL 的 InnoDB 是索引组织表,主键即聚簇索引,所有二级索引的叶子节点都存主键值。这个特性意味着主键的选择对性能影响巨大。TPC-DS 的原始表定义里主键大多是一个自增的代理键,用它做聚簇主键,插入是顺序的,页分裂少,这是比较好的选择。但也有人为了“优化”把主键改成业务组合键,结果插入随机化,页分裂严重,加载时间翻几倍,查询也因为二级索引变大而变慢。
字符集建议统一用utf8mb4,排序规则用通用的那个。不要用utf8mb3图省空间,一旦后续要关联的字段排序规则不一致,join 时会触发隐式转换,索引直接失效,这种问题排查起来非常痛苦。建表时显式写上字符集和排序规则,别依赖服务端默认值,因为不同环境的默认值可能不一样。
sql_mode必须提前统一。严格模式下非法日期、超长字段会直接报错,非严格模式下会被截断成警告。TPC-DS 数据里有些字段长度是压着上限设计的,宽松模式下静默截断会导致后续 join 匹配不上,结果集少行。我的做法是在会话级别强制严格模式,让问题在加载阶段就暴露出来。
另外一个细节是ONLY_FULL_GROUP_BY。TPC-DS 查询里有些聚合语句的SELECT列表包含非聚合列,标准 SQL 下这是非法的,但某些数据库容忍。MySQL 开启ONLY_FULL_GROUP_BY会直接拒绝这类查询,关掉又会让结果不确定。这里的处理方式是改写查询而不是改sql_mode,理由后面方言改写章节细说。
3.2 TiDB 侧:主键选择、TiFlash 副本与 region 分布
TiDB 这边最关键的决策是:TPC-DS 这类分析负载,到底走行存还是列存。默认的 TiKV 是行存,擅长点查和短事务,跑 99 条大聚合查询会很吃力——不是因为算不动,而是因为行存要做全表扫描再聚合,IO 放大严重。
真正的 OLAP 场景要给表加列存副本。建表之后对每张表执行加副本操作,然后等副本同步完成:
ALTER TABLE store_sales SET TIFLASH REPLICA 1; SELECT TABLE_NAME, AVAILABLE, PROGRESS FROM information_schema.tiflash_replica WHERE TABLE_SCHEMA = 'tpcds';AVAILABLE变成 1 才算同步完成,PROGRESS是进度百分比。这一步不能省,很多人加完副本立刻就跑查询,结果优化器根本没选到列存,测出来的还是行存性能,然后得出“TiDB 跑 OLAP 不行”的结论,实际上是自己配错了。
副本数方面,测试环境 1 个就够,生产环境考虑高可用才需要 2 个。副本数越多,写入放大越严重,加载时间越长。
主键选择上,TiDB 支持聚簇索引和非聚簇索引。自增主键做聚簇索引时,写入是顺序追加的,region 切分均匀;如果主键是随机值(比如 UUID 或者哈希值),写入会打散到所有 region,虽然并发度高,但容易出现热点和 region 分裂频繁。TPC-DS 的代理键是递增的,用聚簇索引比较合适,但要注意递增主键在写入时可能集中打到最后一个 region 形成写热点——这是加载阶段的瓶颈,不是查询阶段的。
3.3 批量加载的参数取舍与统计信息初始化
MySQL 侧加载的核心思路是减少每次提交的开销。关掉唯一性检查、外键检查,把日志刷盘策略调宽松,事务批量提交。这几个参数只在加载期间临时改,加载完必须改回来,否则后面的查询测试会受持久化策略影响。
SET GLOBAL local_infile = 1; SET GLOBAL innodb_flush_log_at_trx_commit = 2; SET SESSION unique_checks = 0; SET SESSION foreign_key_checks = 0; LOAD DATA LOCAL INFILE '/data/tpcds/sf100/store_sales.dat' INTO TABLE store_sales FIELDS TERMINATED BY '|' LINES TERMINATED BY '|\n';注意LINES TERMINATED BY '|\n'这个写法,它把行尾那个多余的分隔符一起吃掉了,避免最后一列多出空值。这是处理 TPC-DS 数据文件最省事的方式,比建一个占位列再丢弃干净得多。如果没有本地文件权限,也可以先把文件放到服务器上再加载,但LOCAL方式在客户端侧读取,省去了一次上传。
TiDB 侧大表加载建议用物理导入方式,走批量写 SST 的路径,比逐条INSERT快一到两个数量级。关键配置是导入模式选local后端、关闭表结构版本检查、适当调大并发和区域大小。导入完成之后一定要手动触发一次全量统计信息收集,因为刚导入的数据在优化器眼里还是“未知分布”,默认估算会严重偏离实际,导致执行计划全部走偏。
统计信息收集完成后,建议抽查几张关键大表的行数估算误差。误差超过 20% 就要考虑调大采样比例或者调高统计信息的桶数。这一步很多人跳过,结果所有查询都因为行数估算错误选了错误的 join 顺序,白白背了性能差的锅。
4. 查询生成与方言改写:最耗时间也最容易出错的一环
查询生成这一步的复杂度被严重低估。很多人以为跑个生成命令就完事了,实际上从模板生成到能真正在两个引擎上执行,中间要做的事相当多。
4.1 dsqgen 的方言选项与现实妥协
查询生成工具支持若干种方言,但这些方言里没有专门针对 MySQL 的。通常的做法是选一个相对保守的通用方言,生成标准 SQL,再手工适配目标引擎。
./dsqgen -DIRECTORY ../query_templates \ -INPUT ../query_templates/templates.lst \ -VERBOSE Y \ -QUALIFY Y \ -SCALE 100 \ -DIALECT ansi \ -OUTPUT_DIR ./queries_ansi选方言的时候要注意,不同方言生成的 SQL 差异主要在于日期处理、字符串函数、分页语法和类型转换。选错方言会导致大量函数名不兼容,改写工作量翻倍。通用方言生成的 SQL 相对干净,但也会有部分查询生成标准外连接或者集合运算,这些在 MySQL 上得单独处理。
还有一个隐藏问题:生成查询时用的规模因子,会影响某些查询里日期区间的取值。如果造数用因子 100 而生成查询用因子 1,某些查询的过滤条件会落在数据范围之外,导致返回空结果——查询秒回,看起来“性能极好”,实际是什么都没算。这种坑非常隐蔽,一定要保证生成查询和造数用同一个规模因子。
4.2 MySQL 侧绕不开的语法改写清单
我把实际遇到的改写需求整理成一张表,这是最花时间的部分:
| 语法特性 | TPC-DS 中出现的形态 | MySQL 8.0 的处理方式 |
|---|---|---|
| 窗口函数 | row_number() over (partition by ... order by ...) | 8.0 原生支持,无需改写,但要注意排序缓冲开销 |
| 公共表表达式 | WITH x AS (SELECT ...) | 8.0 原生支持非递归 CTE |
| 集合运算 | INTERSECT/EXCEPT | 需用JOIN+NOT EXISTS或IN+NOT IN改写 |
| 全外连接 | FULL OUTER JOIN | 无原生语法,需拆成左连接与反连接UNION ALL |
| 日期字面量 | cast('2000-01-01' as date) | 兼容,但日期加减建议统一改成DATE_ADD |
| 字符串截取 | substring(x from 1 for 4) | 需改成SUBSTRING(x, 1, 4) |
| 空值排序 | ORDER BY x NULLS FIRST | 无原生语法,需用ISNULL(x)或COALESCE辅助排序 |
| 分组集 | GROUPING SETS/ROLLUP | ROLLUP支持,GROUPING SETS需拆成多次聚合再UNION ALL |
表里每一项改写都有代价。改写FULL OUTER JOIN会让一条查询变成两条扫描再合并,耗时自然上去;改写GROUPING SETS会让原本一次扫描完成的多维聚合变成多次扫描。这些成本是语法差异带来的,不是引擎能力差异,解读结果时必须心里有数,否则会把改写成本错算成引擎劣势。
还有一个更隐蔽的问题:某些改写会让优化器失去走索引的机会。比如把EXCEPT改写成NOT IN子查询,如果子查询返回大量行且含空值,MySQL 会退化成逐行比较,性能断崖式下跌。这种情况下改成LEFT JOIN ... IS NULL往往更稳。具体选哪种,得看子查询的基数和空值情况,没有万能答案。
4.3 改写后怎么确认语义没跑偏
这是最容易被跳过的一步。改写完 SQL,语法通过了,跑出结果了,就默认它跟原版等价——这个假设非常危险。
我的做法是分层验证。第一层是结构验证,把改写前后的 SQL 都做一次语法解析,对比表引用集合、过滤条件集合是否一致。这一步能抓出漏写WHERE条件、多写连接条件这类低级错误。
第二层是小数据集对拍。挑一个很小的规模因子(比如因子 1 或者更小),在 MySQL 和 TiDB 上分别跑改写后和原始的查询,把结果集按所有输出列排序后逐行比对。这一步能抓出聚合逻辑错误、连接类型写反、空值处理不一致这类语义问题。小数据集跑得快,99 条全对拍也就几分钟。
第三层是聚合校验。对大数据集,不逐行比对了,改成对每条查询的结果算几个统计量——行数、若干数值列的求和与计数。改写前后这些统计量必须完全一致。这个方法的盲区是行内容变了但统计量恰好一样,概率很低但存在,所以只适合作为大数据集上的补充验证。
这三层都过了,我才会认为改写是可信的。跳过验证直接跑,最后结果差一倍你都不知道该信谁。
5. 执行与计时:让两边的数字具备可比性
查询能跑通之后,怎么跑、怎么计时,直接决定结论的可信度。这块的争议最多,因为不同的计时方式能得出完全相反的排名。
5.1 冷跑、热跑与并发档位怎么定
首先要区分冷跑和热跑。冷跑是指清空缓存后执行,反映的是磁盘 IO 能力和冷启动开销;热跑是缓存预热后执行,反映的是内存计算能力和 CPU 效率。两者的结论经常不一致:某个引擎冷跑慢但热跑快,说明它初次加载数据效率低但计算效率高。
我的做法是两个都测,分开报告。冷跑的做法是先重启数据库服务,或者把数据文件挪走再挪回来(更彻底但更慢),确保缓冲池是空的。热跑的做法是每条查询先跑一遍丢弃结果,再连续跑三遍取后两遍的平均。
这里有个坑:某些引擎有查询结果缓存或者计划缓存,连着跑同一条查询,第二遍可能压根没真正执行。要确认缓存机制是否开启,如果开了,要么关掉,要么在查询里加一个不影响结果的随机条件破坏缓存命中(但这会改变执行计划,慎用)。最稳妥的是在测试前明确记录缓存相关参数的取值,别让它成为隐藏变量。
并发档位也要想清楚。单并发测的是单条查询的极致性能,反映引擎的并行度和优化器能力;多并发测的是吞吐和资源竞争,反映真实混合负载下的表现。TPC-DS 官方有吞吐量测试的定义,但那套流程很重。做选型评估的话,我一般测三档:单并发、四个并发、八个并发,看吞吐量曲线随并发增长是否线性,拐点在哪。
5.2 计时口径:客户端 wall time 与服务端耗时
这两个口径经常差很多。客户端计时包含网络往返、结果集传输、客户端解析,服务端耗时只算引擎内部执行。做引擎能力对比应该用服务端耗时,做端到端体验评估应该用客户端时间。
问题在于,不同引擎的服务端耗时统计范围不一样。有的只算执行阶段,有的把解析、优化、执行分开统计。如果直接拿两个引擎的“执行耗时”对比,可能一个含了优化时间一个没含,结论就歪了。
我的处理方式是统一用客户端 wall time 作为主口径,因为它定义清晰、两边一致、可复现。同时在结果里附上服务端耗时作为辅助参考,用于定位慢在哪一段。这样即使服务端统计口径有差异,主结论也是站得住的。
另外必须明确失败和超时的处理规则。一条查询跑了半小时没出结果,算超时还是继续等?超时之后算 1800 秒还是直接剔除?我的规则是设一个明确的超时阈值(比如 30 分钟),超时就记为该阈值,并单独标注。绝不剔除——剔除会让平均值变好看,但那是欺骗自己。同时报告里要写清楚有几条超时,读者自己判断。
5.3 参数清单要克制
调参这件事很容易失控。为了让某一方表现好,不断加参数、改配置,最后测出来的不是引擎的默认能力,而是你调参水平的对比。这种结果对选型毫无参考价值,因为生产环境没人能保证调到同样程度。
我的原则是:只调那些“不调就没法正常工作”的参数,比如内存上限、临时空间位置、连接数上限。性能相关的优化参数,除非两方都能对应调整,否则一律用默认值,并在报告里声明。比如给 TiDB 开了列存副本,那 MySQL 侧就不能只给一个裸库,得相应地把缓冲池调到合理大小、把临时表引擎配好,这样才算对等。
还有一个常被忽略的点:两边的硬件资源上限要明确限制在相同水平。如果 TiDB 是分布式部署占了 3 台机器,MySQL 是 1 台机器,那对比的就不是引擎而是集群规模。要么把 TiDB 也压到单机上跑,要么把 MySQL 部署到同等总资源的机器上。这一点不说清楚,所有数字都失去意义。
6. 结果分段解读:差异到底出在哪些查询形态上
拿到两份耗时表之后,直接看总和是最偷懒的读法。正确的读法是先分组,再找组内的异常点,最后才看整体。
6.1 大事实表扫描与聚合:并行度是分水岭
这一类查询的特征是扫描亿级行的事实表,做过滤、聚合、分组,结果集通常只有几十行。耗时几乎完全由扫描吞吐决定。
MySQL 在这类查询上的瓶颈非常明确:单条查询基本是单线程执行。虽然有并行读取的相关参数,但它的适用范围很窄,主要针对无过滤条件的全表计数和表检查,普通查询用不上。所以一条要扫 50 GB 数据的聚合查询,MySQL 就是一个线程硬啃,耗时跟数据量线性相关。
TiDB 在这类查询上的优势来自分布式的扫描并行:一条查询会被拆成多个扫描任务分发给多个存储节点,再汇总。加了列存副本之后,列式存储只读需要的列,IO 量还能再降一个量级。这两项叠加,同样是扫 50 GB,实际读的数据量可能只有行存的几分之一,而且并行执行。
这是一边倒的差距,也是最容易得出“某某引擎碾压”结论的地方。但要注意,这个差距反映的是架构差异(单机单线程 vs 分布式并行 + 列存),而不是优化器水平的差异。如果你的真实负载里这类全表聚合查询占比很低,那这个差距对你意义不大。
6.2 多表 join:优化器选路的分水岭
多表 join 的查询最能体现优化器水平。TPC-DS 里不少查询涉及五六张表关联,join 顺序的选择空间很大,选错了中间结果会膨胀几个数量级。
这里的观察点是执行计划而不是耗时。我会把两边的执行计划都拉出来,对比三件事:join 顺序是否一致、每步的估算行数与实际行数偏差多大、有没有出现意外的全表扫描或者笛卡尔积。
实际测下来,MySQL 在星型模型上的表现比想象中好。因为维度表通常很小,可以直接放进 join buffer 做哈希连接,事实表只扫一次。MySQL 8.0 引入哈希连接之后,这类查询的改善很明显。真正出问题的是那些多张大表互相关联、维度表也要参与过滤的场景——这种时候 join 顺序一旦选错,中间结果会大到需要落盘,耗时直接失控。
TiDB 这边因为统计信息更细、有代价模型的持续优化,加上可以选择广播小表或者重分布大表,join 顺序的选择通常更优。但也不是没有翻车的时候:统计信息过期、或者列存副本还没同步完,优化器会基于错误的行数估算做出糟糕决策,这时候表现可能还不如 MySQL。
6.3 窗口函数、CTE 与子查询展开
这一类的差异比较微妙。两边的窗口函数实现都要先按分区键排序再计算,排序开销是主要成本。区别在于排序能不能利用并行,以及内存不够时怎么落盘。
我在实测中遇到的典型现象是:窗口函数的分区键如果跟表的排序键或者索引顺序一致,两边都快;如果不一致,都要做一次全量排序,这时候数据量大的那一方会明显吃亏,因为排序缓冲区不够就要落盘,落盘之后性能断崖。
CTE 的处理差异更值得注意。有的引擎会把 CTE 物化成临时结果复用,有的会直接内联展开成子查询。物化省了重复计算但多了写临时表的开销,内联省了写临时表但可能重复扫描。对于被引用多次的 CTE,物化通常更好;只引用一次的,内联更好。实际表现取决于引擎的默认策略,而这个策略往往不能通过参数控制,属于需要实测确认的黑盒行为。
子查询展开是另一个坑。关联子查询在某些引擎上会被改写成半连接,性能提升一个量级;改写不了就退化成逐行执行,慢得离谱。TPC-DS 里有几条查询故意用了这种写法,如果某一方没有做展开优化,那几条的耗时会异常突出,把整体平均值拉高。
6.4 几个反直觉的观察
第一个反直觉:小结果集的查询,MySQL 有时更快。原因是它的执行路径短,没有分布式调度和网络传输开销。一条查询在 TiDB 上要经过 SQL 层解析、优化、下发到存储层、多个节点并行执行、结果汇总,这套流程的固定开销对于毫秒级的查询来说是纯负担。而 MySQL 一次函数调用就进去了。所以如果负载里短查询占比高,整体平均下来差距会明显缩小。
第二个反直觉:中等选择率的查询,TiDB 不一定比 MySQL 快。列存适合高选择率的大扫描,但如果过滤条件能筛掉 99% 的数据,行存的索引反而更高效——直接定位到少数几行,而列存要做块级扫描再过滤。这种场景下加了列存副本反而可能变慢,因为优化器可能误判选择了列存路径。
第三个反直觉:同一份数据、同样的查询,跑第二遍和第一遍的排名可能反过来。这就是缓存和统计信息在起作用。所以单次测试的结论非常脆弱,至少要在不同时间点跑三轮,看排名是否稳定。不稳定的话,说明有未受控的变量,得先把它找出来。
7. 踩坑清单:统计信息、内存与结果一致性
这一段是我自己踩过的、也是最容易让别人重复踩的坑。每个坑单拎出来都不复杂,但在测试过程中撞上,往往要花半天才能定位。
7.1 统计信息缓存的幻觉
MySQL 8.0 有个默认行为:information_schema里的表统计信息会被缓存,默认缓存时间长达一天。也就是说你手动执行了统计信息收集,再去查表的行数,看到的还是旧的缓存值。这会带来一个极其误导人的现象:你以为统计信息已经更新了,实际上优化器看到的还是老数据,执行计划一直不对。
-- 查看当前缓存有效期 SHOW VARIABLES LIKE 'information_schema_stats_expiry'; -- 会话级别关掉缓存,确保读到最新统计 SET SESSION information_schema_stats_expiry = 0;我第一次遇到这个问题时,反复执行统计信息收集、反复查执行计划,怎么都不对,最后才发现是缓存。这个坑的隐蔽之处在于,它不影响数据正确性,只影响你对“统计信息是否更新”的判断,进而影响你对执行计划的归因。
TiDB 这边的问题是另一个方向:自动统计信息收集有触发阈值,表的数据变更量没到阈值就不会自动收集。大批量导入之后如果没手动触发,统计信息可能还停留在导入前的状态(空表或者极少数行),优化器估算出来的行数是几十行而不是几亿行,join 顺序完全乱套。所以大批量导入之后手动跑一次全量收集是必须的动作,别指望自动机制。
7.2 内存水位与落盘
分析型查询的内存消耗很容易失控。一条带大分组聚合或者大排序的查询,峰值内存可能是数据量的几倍。
MySQL 侧的内存相关参数很多,容易配错。排序缓冲、连接缓冲、临时表内存上限、内存临时表引擎的容量上限,这几个参数相互作用。特别是内存临时表超限之后的降级路径:有的版本会转成磁盘临时表,有的会转成内存映射文件。转成磁盘临时表意味着走 InnoDB,每次读写都要过缓冲池,性能下降非常明显;转成内存映射文件则依赖操作系统的页缓存,表现介于两者之间。测试时要把这个降级行为搞清楚,否则同一条查询在两次执行中的耗时差异会让你怀疑人生。
TiDB 侧有单条查询的内存配额限制,超出后可以选择落盘或者直接报错终止。测试环境下我倾向于允许落盘,因为报错终止会让你丢失这条查询的耗时数据;生产环境则要谨慎,落盘可能把磁盘打满。落盘目录要单独配置到大容量盘上,别跟数据目录混在一起,否则落盘 IO 会跟正常读写抢带宽。
还有一个细节:内存配额不是越大越好。配额调得很大,多条查询并发时可能把物理内存吃光,触发系统的内存回收,整个实例都会卡住。比较稳妥的做法是配额乘以最大并发数不超过物理内存的七成。
7.3 结果比对时的排序与类型陷阱
最后说结果比对,这是判断“跑对了没有”的唯一手段,但坑不少。
第一个坑是排序不稳定。如果查询的ORDER BY列存在重复值,相同值之间的相对顺序是不确定的,两次执行结果的物理顺序可能不同。逐行比对就会误判为不一致。解决办法是比对前按所有输出列重新排序,或者只比对聚合后的统计量。
第二个坑是空值处理。不同引擎在聚合函数里对空值的处理、在排序里对空值位置的处理、在连接条件里对空值匹配的处理,都有差异。这些差异有的是标准规定,有的是实现选择。比对时如果某一列大量为空,很容易出现“行数一样但值对不上”的情况,根源往往是空值参与聚合的方式不同。
第三个坑是数值类型精度。金额类字段有的是定点数有的是浮点数,浮点数在不同引擎上的舍入行为可能有微小差异,累加之后偏差会被放大。比对时不要用精确相等,用相对误差阈值判断,比如百万分之一以内算一致。但阈值不能随便放宽,放得太宽会把真实的计算错误也放过。
第四个坑是时间字段的时区。如果表里有时间戳字段,而两边的会话时区设置不同,同一份数据读出来会差几个小时。这种差异在结果比对时会表现为整列偏移,很好发现但如果没意识到时区这个概念,会往数据类型错误的方向去查,浪费大量时间。
8. 一点个人体会
跑这类对比测试,最耗时间的从来不是执行阶段,而是前面的对齐工作。造数、建表、方言改写、参数校准,这四件事占了我八成的精力,真正跑查询可能就几个小时。但这个投入是值得的——前面不对齐,后面的数字就是废纸。
我现在做这类测试有个固定习惯:先把 MySQL 侧的改写清单和验证记录整理成文档,把每条改写的理由、验证方式、验证结果都写清楚。这份文档比最终的耗时表重要得多,因为过两个月有人质疑某个数字,你能立刻翻出依据;而耗时表本身,换个硬件、换个版本就作废了。
另外提醒一句,别迷信任何公开的基准测试报告,包括看起来很权威的那种。硬件配置、版本号、参数设置、方言改写方式,任何一个不一样,结论都可能反过来。自己跑一遍,哪怕规模小一点、配置糙一点,得到的是你自己能解释清楚的数据,这比引用别人的数字有价值得多。真要引用,也只引用方法和趋势,别引用具体倍数。