首页 / 资讯中心 / 文章详情

TiDB三类JOIN算法原理与实战选型指南

TiDB三类JOIN算法原理与实战选型指南 ★ FEATURED ARTICLE
1. 为什么“join方式选错”会让TiDB查询慢得像在等泡面我第一次在某电商后台系统里看到一个报表SQL跑出17秒响应时第一反应是查执行计划——结果发现EXPLAIN ANALYZE输出里赫然写着HashJoin但右表扫描行数高达2300万而左表才不到5000行。更诡异的是这个SQL在MySQL上跑得飞快换到TiDB集群后却成了性能瓶颈。当时团队里有位老同事直接说“别调参数了先看看join是不是选错了。”这句话让我记了三年。这就是TiDB SQL优化里最隐蔽、也最容易被忽视的深水区join方式不是数据库自动“猜对”的而是你用SQL写法、索引设计、统计信息质量、甚至数据分布特征共同“投票”决定的。TiDB支持三种物理join算法HashJoin、IndexJoin和MergeJoin它们不是并列选项而是存在明确的适用边界和成本博弈关系。一旦选错轻则多扫几百万行重则触发大量网络Shuffle、OOM Killer介入、甚至拖垮整个TiKV节点。很多人误以为“加了索引就万事大吉”但我在某金融风控系统的压测中亲眼见过给右表加了覆盖索引IndexJoin确实被选中了可因为左表驱动列的值分布极度倾斜95%的请求都查同一个用户ID导致单个TiKV Region被反复高频访问QPS没上去P99延迟反而翻了4倍。这说明——join方式的选择本质是数据访问模式、计算资源分配、网络IO开销三者之间的动态权衡。你不需要背熟所有算法源码但必须建立一套可验证的判断链路第一步看执行计划里实际用了哪种join第二步确认它是否符合该算法的理想前提比如HashJoin要求小表能全量载入内存第三步反向验证如果强制换一种join代价是否真会更高这不是玄学而是可以通过EXPLAIN FORMAT VERBOSE、SHOW STATS_HISTOGRAMS、甚至手动SELECT COUNT(*) GROUP BY探查数据分布来实证的。接下来我们就从这三类join的底层行为讲起不讲理论只讲你在TiDB控制台上敲命令时真正需要盯住的那几个数字。2. HashJoin不是“小表驱动大表”而是“内存够不够塞下小表”很多资料把HashJoin简单总结为“小表驱动大表”这是对TiDB实现机制的重大误解。TiDB的HashJoin是Build-Side必须完全加载进内存构建哈希表Probe-Side逐行探测。关键不在“小”而在“能否一次性装下”。2.1 内存阈值与实际占用的差距TiDB默认tidb_mem_quota_query 32GB但HashJoin实际可用内存受两个硬约束单个TiDB实例的全局内存池限制由tidb_server_memory_limit控制默认无限制但生产环境建议设为物理内存的70%单个HashJoin算子的局部内存上限由tidb_hash_join_concurrency×tidb_mem_quota_query共同决定但更关键的是tidb_hash_join_concurrency默认值仅为5——这意味着即使你有32GB内存配额TiDB最多只允许5个并发HashJoin任务每个任务分到的内存远低于理论值。我在某物流调度系统中遇到过真实案例右表运单明细约800万行单行平均120字节理论哈希表大小≈800万×120B≈960MB。看起来远低于32GB但执行时频繁OOM。排查发现EXPLAIN FORMAT VERBOSE显示HashJoin的build_side_concurrency: 5即5个goroutine并行构建哈希表每个goroutine需独立维护哈希桶、溢出链表、内存管理元数据实测单goroutine内存开销达理论值的2.3倍当5个goroutine同时运行峰值内存达960MB×2.3×5≈11GB叠加其他算子瞬间突破TiDB实例内存上限。提示不要依赖EXPLAIN里“Estimated Memory Usage”字段那是基于统计信息的粗略估算。真实内存压力必须通过SHOW PROCESSLIST观察Mem列或在Prometheus中监控tidb_server_memory_usage_bytes指标。2.2 小表≠驱动表当“小”只是假象另一个常见陷阱是误判“小表”。TiDB的“小”指Build-Side在Apply Filter后剩余的行数足够小而非原始表大小。例如这条SQLSELECT /* HASH_JOIN(t1, t2) */ * FROM orders t1 JOIN order_items t2 ON t1.order_id t2.order_id WHERE t1.status shipped AND t2.created_at 2024-01-01;表面看orders表千万级比order_items亿级小但WHERE t1.status shipped可能只过滤出5%订单50万行而t2.created_at 2024-01-01可能过滤出30%明细3000万行。此时若强制HASH_JOIN(t1, t2)Build-Side是50万行的orders内存可控但若TiDB因统计信息陈旧误判t1.status shipped命中率高达80%就会错误选择t2为Build-Side导致3000万行进内存——灾难就此开始。验证方法很简单-- 查看t1在过滤条件下的实际行数 EXPLAIN SELECT * FROM orders WHERE status shipped; -- 查看t2在过滤条件下的实际行数 EXPLAIN SELECT * FROM order_items WHERE created_at 2024-01-01;对比rows列数值谁小谁该做Build-Side。如果两者接近比如都在100万~500万行优先考虑IndexJoin或MergeJoin避免内存风险。2.3 HashJoin的隐藏杀手数据倾斜即使Build-Side总行数达标若JOIN KEY存在严重倾斜HashJoin性能仍会断崖下跌。TiDB的哈希函数无法规避热点Key所有匹配该Key的Probe行都会打到同一个哈希桶形成单goroutine瓶颈。典型场景用user_id关联用户表和行为日志表但user_id 0匿名用户占日志表70%用tenant_id关联租户配置表和业务表但tenant_id 1系统租户数据量是其他租户总和的10倍。检测方法-- 找出top 10热点join key SELECT order_id, COUNT(*) c FROM order_items GROUP BY order_id ORDER BY c DESC LIMIT 10;如果第一行计数超过第二行10倍以上且该Key在Build-Side也存在HashJoin大概率会卡顿。此时必须改用IndexJoin利用索引B树的有序性分散访问或添加/* USE_INDEX(t2, idx_order_id) */提示强制走索引。3. IndexJoin不是“有索引就能用”而是“索引能否支撑嵌套循环”IndexJoin常被称作“嵌套循环连接”但TiDB的实现远比传统数据库精细。它的核心价值在于将Probe-Side的每行转化为对Build-Side索引的点查或范围查从而规避全表扫描但前提是索引设计必须精准匹配访问模式。3.1 索引覆盖度为什么“联合索引顺序”决定生死假设我们有如下SQLSELECT t1.name, t2.amount FROM users t1 JOIN payments t2 ON t1.user_id t2.user_id WHERE t1.city beijing AND t2.status success;要让IndexJoin生效Build-Sidepayments表的索引必须满足第一列必须是JOIN KEYuser_id这是嵌套循环的驱动基础TiDB需要根据t1.user_id值快速定位t2中对应记录后续列应覆盖WHERE条件status和SELECT字段amount避免回表。因此最优索引是ALTER TABLE payments ADD INDEX idx_user_status_amount (user_id, status, amount);注意user_id必须在最左status次之用于过滤amount最后用于覆盖查询。如果建成了idx_status_user_amount (status, user_id, amount)TiDB将完全无法使用该索引进行IndexJoin因为status无法作为Probe的输入值。我在某SaaS平台优化时踩过这个坑原索引是(status, user_id)EXPLAIN显示IndexJoin未被选用执行计划退化为HashJoin。重建索引为(user_id, status)后IndexJoin立即生效查询耗时从8.2秒降至0.35秒——提升23倍。根本原因在于IndexJoin的Probe过程是“对t1的每一行用t1.user_id去t2索引中查找”索引最左列不匹配查找就无从谈起。3.2 驱动表选择为什么左表不一定是驱动表TiDB的IndexJoin默认以左表为Probe-Side驱动表但可通过STRAIGHT_JOIN或/* TIDB_INLJ(t2) */提示强制指定Build-Side。这在驱动表数据量大但过滤性强时至关重要。例如SELECT /* TIDB_INLJ(t2) */ t1.*, t2.log_time FROM big_log_table t1 JOIN small_config_table t2 ON t1.config_id t2.id WHERE t1.event_type login AND t1.created_at 2024-05-01;big_log_table虽是左表但WHERE条件过滤后仅剩2000行small_config_table虽小但无有效过滤条件。若按默认逻辑TiDB会尝试用2000行big_log_table去驱动small_config_table看似合理。但问题在于small_config_table的id索引是主键B树高度为2每次点查IO成本极低而big_log_table的event_type和created_at组合索引若未包含config_id则2000次Probe需2000次回表总IO飙升。此时强制TIDB_INLJ(t2)让small_config_table成为Build-SideTiDB会先全量扫描small_config_table假设1000行构建内存中的“配置ID→配置内容”映射再对big_log_table过滤后的2000行直接查内存映射零IO完成关联。实测耗时从1.8秒降至0.09秒。关键洞察当Build-Side极小1万行且Probe-Side过滤后仍较大时内存映射比索引点查更高效。3.3 IndexJoin的临界点何时该放弃IndexJoin并非万能。当Probe-Side过滤后行数超过一定阈值其“N次点查”的累积开销会超过HashJoin的一次哈希构建。这个阈值没有固定值取决于Build-Side索引的B树高度高度越低越好Probe-Side行数与Build-Side总行数的比例网络延迟跨TiKV节点时每次点查都有RTT开销。经验法则若Probe-Side行数 1000且Build-Side有合适索引IndexJoin几乎总是最优若Probe-Side行数在1000~10000需实测对比若Probe-Side行数 10000且Build-Side行数 10万IndexJoin大概率不如HashJoin或MergeJoin。验证方法-- 强制使用IndexJoin并计时 SELECT /* TIDB_INLJ(t2) */ ... FROM t1 JOIN t2 ON ...; -- 强制使用HashJoin并计时 SELECT /* HASH_JOIN(t1, t2) */ ... FROM t1 JOIN t2 ON ...; -- 对比执行时间与EXPLAIN ANALYZE中的execution info重点关注execution info里的probe timeProbe总耗时和build timeBuild总耗时而非单纯看total time。4. MergeJoin不是“排序就行”而是“双流有序性的精密对齐”MergeJoin是TiDB中最易被低估的算法。它要求Probe-Side和Build-Side都按JOIN KEY严格升序排列然后像归并排序一样双指针扫描。优势是零内存占用、零随机IO、线性时间复杂度劣势是对数据序有强依赖且一旦序乱代价极高。4.1 排序来源索引 vs Sort算子成本天壤之别MergeJoin的排序可以来自两种途径天然有序表按JOIN KEY聚簇如PRIMARY KEY(user_id)或存在INDEX(user_id)且查询能利用该索引扫描显式排序TiDB执行Sort算子对数据重排。二者成本差异巨大。以users表主键id和orders表索引idx_user_id(user_id)为例若SQL为SELECT * FROM users u JOIN orders o ON u.id o.user_idusers按id升序扫描天然有序orders可走idx_user_id升序扫描MergeJoin零额外成本若SQL为SELECT * FROM users u JOIN orders o ON u.id o.user_id ORDER BY u.nameTiDB必须对users按name排序破坏了id的天然序MergeJoin失效退化为HashJoin。我在某教育平台优化课表查询时发现原SQL带ORDER BY class_nameEXPLAIN显示MergeJoin未被选用。去掉ORDER BY后MergeJoin立即生效耗时从3.1秒降至0.42秒。根本原因ORDER BY触发了Sort算子使Probe-Side失去有序性。注意MergeJoin对ORDER BY极其敏感。即使ORDER BY字段与JOIN KEY无关只要它导致TiDB无法保证JOIN KEY的扫描序MergeJoin就会被排除。4.2 数据分布陷阱为什么“部分有序”比“完全无序”更危险最危险的情况是数据在物理上部分有序但统计信息未反映此特征。TiDB的统计信息ANALYZE TABLE默认采样率100%但对超大表会降为10%或更低。若采样恰好错过有序段TiDB会误判数据“无序”拒绝MergeJoin。例如orders表按order_id递增写入理论上idx_user_id是近似有序的新订单集中在少数用户。但ANALYZE TABLE orders采样时只扫了老数据统计信息显示user_id分布均匀TiDB便认为idx_user_id扫描无法保证升序弃用MergeJoin。解决方案对写入模式已知的表手动指定高采样率ANALYZE TABLE orders WITH 20000000 ROWS或强制提示SELECT /* MERGE_JOIN(t1, t2) */ ...但需确保EXPLAIN中sort算子消失否则提示无效。验证MergeJoin是否真正生效不能只看EXPLAIN类型更要检查EXPLAIN FORMAT VERBOSE中merge_join的left_sort_key和right_sort_key是否非空EXPLAIN ANALYZE中execution info是否有sort time字段有则说明走了Sort非真·MergeJoin。4.3 MergeJoin的杀手锏处理超大数据集当Probe-Side和Build-Side都超千万行时MergeJoin往往是唯一可行方案。HashJoin内存爆炸IndexJoinN次点查IO雪崩而MergeJoin只需常量内存且能充分利用TiKV的Range Scan能力。典型案例某广告系统需关联10亿级曝光日志与5000万级用户画像。HashJoinBuild-Side 5000万行内存需求超100GBTiDB直接OOMIndexJoinProbe-Side 10亿行即使10%命中率也有1亿次点查网络IO彻底打满MergeJoin两表均按user_id排序TiDB启动双流Scan内存占用500MB耗时稳定在22秒。实现关键exposure_log表必须有INDEX(user_id)且写入时按user_id分片TiDB默认按PK分片需确认PK是否为user_id或含user_iduser_profile表主键为user_id天然聚簇有序SQL中避免任何破坏user_id序的操作如GROUP BY、DISTINCT、非user_id的ORDER BY。5. 实战决策树从执行计划到最终优化的完整闭环纸上谈兵终觉浅。我把过去三年在数十个TiDB生产环境积累的join优化流程浓缩成一张可直接执行的决策树。它不依赖“感觉”每一步都有命令验证每一个分支都有明确退出条件。5.1 第一步捕获真实执行计划不是EXPLAIN是EXPLAIN ANALYZE永远不要用EXPLAIN代替EXPLAIN ANALYZE。前者是预估后者是实测。尤其在join场景预估行数误差常达10倍以上。操作步骤在业务低峰期对慢SQL执行EXPLAIN ANALYZE FORMAT VERBOSE SELECT /* your sql here */;重点提取三组数据join_type:HashJoin/IndexJoin/MergeJoin/LeftHashJoin等rows_read: Probe-Side和Build-Side各自扫描的行数execution info:build time,probe time,sort time,mem_max等。提示若EXPLAIN ANALYZE耗时过长30秒先用EXPLAIN快速看join_type和rows再针对性加/* ... */提示测试。5.2 第二步诊断当前join方式是否合理三问法对execution info中的join_type连续问三个问题Q1Build-Side行数是否在安全阈值内HashJoin: Build-Siderows_read 500万行内存保守估计IndexJoin: Probe-Siderows_read 1万行避免点查风暴MergeJoin: 双方rows_read无绝对上限但需确认sort time为0。Q2索引是否被正确利用IndexJoin: Build-Side的index列是否显示实际使用的索引名若为PRIMARY或NULL说明索引未命中MergeJoin:left_sort_key/right_sort_key是否为user_id等JOIN KEY若为unknown说明序丢失。Q3是否存在数据倾斜执行-- 对JOIN KEY做频次分析 SELECT /* AGG_TO_COP() */ join_key, COUNT(*) c FROM build_side_table GROUP BY join_key ORDER BY c DESC LIMIT 5;若c[0] / c[1] 5判定为倾斜HashJoin需规避。若任一问题答案为“否”进入第三步。5.3 第三步强制切换join方式并量化对比不要猜测要实测。用Hint强制指定join并记录精确耗时Hint写法适用场景验证要点/* HASH_JOIN(t1, t2) */Build-Side确定较小且无倾斜监控mem_max是否突增probe time是否线性增长/* TIDB_INLJ(t2) */Build-Side有完美索引Probe-Side过滤后1万行检查execution info中probe time是否500ms/* MERGE_JOIN(t1, t2) */双表均有JOIN KEY索引且无ORDER BY干扰确认sort time为0left_sort_key非空关键技巧用SELECT SLEEP(0.001)注入微小延迟隔离网络抖动影响-- 测试HashJoin SELECT /* HASH_JOIN(t1,t2) */ COUNT(*) FROM t1 JOIN t2 ON ...; -- 立即执行避免缓存干扰 SELECT SLEEP(0.001); -- 测试IndexJoin SELECT /* TIDB_INLJ(t2) */ COUNT(*) FROM t1 JOIN t2 ON ...;记录每次执行的Query_time来自Slow Log或Duration来自EXPLAIN ANALYZE取5次平均值。差异10%即具统计意义。5.4 第四步根治——索引与统计信息的协同优化Hint是止痛药索引和统计信息才是手术刀。根据第三步结论执行根治操作若IndexJoin未被选用检查Build-Side索引是否以JOIN KEY为最左列若涉及多条件用SHOW CREATE TABLE确认索引列顺序重建索引DROP INDEX idx_old ON t2; CREATE INDEX idx_new ON t2(join_key, where_col, select_col);若MergeJoin未被选用执行SHOW STATS_HISTOGRAMS WHERE table_name t2 AND column_name join_key确认直方图是否平坦平坦分布均匀TiDB认为无需Merge若业务上实际倾斜强制更新统计ANALYZE TABLE t2 WITH 5000000 ROWS;确认查询无ORDER BY、GROUP BY等破坏序的操作。若HashJoin内存溢出降低tidb_hash_join_concurrency至3减少goroutine争抢增加tidb_mem_quota_query至64GB需评估实例内存终极方案改写SQL用WHERE提前过滤Build-Side如JOIN (SELECT * FROM t2 WHERE statusvalid) t2 ON ...。最后把优化后的SQL加入回归测试集每周用pt-query-digest扫描Slow Log确保无新SQL落入同一陷阱。TiDB的join优化不是一锤子买卖而是持续的数据治理过程。6. 超越语法理解TiDB的分布式基因如何重塑join逻辑所有关系型数据库的join优化都绕不开“数据本地性”这一终极命题。而TiDB的分布式架构让这个问题变得既简单又复杂——简单在于数据天然分片复杂在于跨分片join引入了不可忽视的网络开销。6.1 Region与Join为什么“同Region”是性能黄金标准TiDB将数据按KEY切分为多个Region每个Region由3副本组成分布在不同TiKV节点。当JOIN的两表数据落在同一Region时TiDB可将join计算下推到该Region的TiKV节点执行全程无网络传输。但现实很骨感users表按id分片orders表按order_id分片user_id与order_id无数学关系99%的join必然跨Region。此时TiDB必须将Probe-Side数据如users的1000行序列化通过gRPC发送到Build-Side所在TiKV节点在目标节点执行joinHashJoin构建哈希表IndexJoin做点查将结果发回TiDB汇总。这个过程引入了三次网络往返TiDB → TiKV发送Probe数据TiKV内部Build ProbeTiKV → TiDB返回结果。我在某实时风控系统中做过测量跨Region的HashJoin网络耗时占总耗时的65%而同Region的HashJoin网络耗时5%。这意味着——优化join的最高境界不是选算法而是让数据物理靠近。6.2 解决方案分区表与Shard Row IDTiDB提供两种“让数据靠近”的手段方案1分区表Partitioned Table对orders表按user_id哈希分区CREATE TABLE orders ( order_id BIGINT, user_id BIGINT, ... ) PARTITION BY HASH (user_id) PARTITIONS 64;这样user_id相同的订单必然落在同一Partition进而大概率在同一Region。当JOIN users ON users.id orders.user_id时TiDB能识别user_id的等值条件将Probeusers和Buildorders都路由到同一TiKV节点。方案2Shard Row ID推荐对orders表启用Shard Row IDALTER TABLE orders SHARD_ROW_ID_BITS 4 PRE_SPLIT_REGIONS 4;SHARD_ROW_ID_BITS 4表示将_tidb_rowid的高4位作为Shard生成2^416个Shard数据按Shard分散。配合user_id作为主键前缀可实现user_id相近的数据物理聚集。注意Shard Row ID需在建表时启用已有表需重建。但它对应用透明无需修改SQL是长期演进的首选。6.3 最后一道防线物化Join结果当上述所有优化仍无法满足SLA时终极方案是用空间换时间预计算join结果。例如某BI系统需频繁查询“用户最近3笔订单金额”可创建物化视图CREATE TABLE user_recent_orders AS SELECT u.id as user_id, u.name, o1.amount as amount1, o2.amount as amount2, o3.amount as amount3 FROM users u LEFT JOIN orders o1 ON u.id o1.user_id AND o1.rank 1 LEFT JOIN orders o2 ON u.id o2.user_id AND o2.rank 2 LEFT JOIN orders o3 ON u.id o3.user_id AND o3.rank 3;然后对user_recent_orders建PRIMARY KEY(user_id)。查询时直接SELECT * FROM user_recent_orders WHERE user_id ?毫秒级响应。代价是存储空间和数据新鲜度需定时刷新。但在报表、监控等场景这是最可靠的兜底方案。TiDB的join优化最终指向一个朴素真理没有银弹只有对数据、算法、硬件的深刻理解与持续权衡。每一次EXPLAIN ANALYZE的凝视都是与TiDB内核的一次对话。当你能从execution info的数字里读出内存的喘息、网络的脉搏、磁盘的叹息你就真正掌握了这门手艺。
阅读完成 · 觉得有帮助?
咨询建站