面试复盘:left join 底层算法 & 主从复制
这次面试被问了"left join 的底层算法是什么"和"主从复制有几种模式,分别如何实现"。left join 我不会,现场瞎猜了个嵌套循环,主从复制答得还行,但细节不够。复盘后,我强化了 Hash Join 和 Block Nested Loop 的细节描述,同时把主从复制的实现讲得更透彻。这次特别针对 Block Nested Loop 的"分块"机制加深理解,补充具体实现细节。
left join 底层算法是啥?
当时的表现
面试官问"left join 底层算法是什么"时,我完全没底。先说了 left join 是左外连接,左表全保留,右表无匹配补 NULL,然后猜"应该是嵌套循环吧,遍历左表每行,去右表找匹配,复杂度 O(n*m)"。回答得很虚,面试官没深究,可能觉得我已经尽力了。
复盘:三种算法的细节
MySQL 的 join 算法由优化器选择(EXPLAIN
可见),常见有 Nested Loop Join、Hash Join(8.0+)和 Block Nested Loop。left join 因要保留左表所有行,底层实现会稍有调整。
-
Nested Loop Join(嵌套循环连接):
- 原理:外层循环左表每行,内层扫描右表找匹配。
- 细节 :如果右表有索引,内层用 B+ 树定位,复杂度降为 O(nlog m);无索引则全表扫描,O(nm)。
- left join 特点:左表每行至少输出一次,右表无匹配时补 NULL。
- 底层:内存维护游标遍历左表,右表索引查找靠 InnoDB 的 B+ 树。
-
Hash Join(哈希连接,MySQL 8.0+):
- 强化细节 :
- 构建阶段 :先扫描右表(内表),根据 join 条件(如
t1.id = t2.id
)的字段建哈希表。- 哈希表用内存存储,key 是右表的 join 字段(如
t2.id
),value 是整行或行指针。 - 如果右表太大,内存装不下,会分片(partition)到磁盘,变成多轮处理。
- 构建时可能用多线程并行扫描右表(视优化器)。
- 哈希表用内存存储,key 是右表的 join 字段(如
- 探测阶段 :遍历左表(外表)每行,用 join 字段(如
t1.id
)在哈希表中查找。- 查找是 O(1),匹配时返回右表行,无匹配补 NULL。
- 左表顺序读,哈希表用散列函数(如 MurmurHash)定位。
- 内存管理 :哈希表分配在
join_buffer_size
定义的缓冲区,溢出时写临时文件。
- 构建阶段 :先扫描右表(内表),根据 join 条件(如
- 适用场景:大表连接,无需索引,内存够用时效率高。
- left join 特点:哈希表查不到时直接补 NULL,逻辑简单。
- 复杂度:O(n+m),n 和 m 是左右表行数,优于嵌套循环。
- 底层猜想:C++ 实现,哈希表可能是 std::unordered_map 或自定义结构,磁盘分片靠临时表。
- 强化细节 :
-
Block Nested Loop Join(块嵌套循环):
- 强化细节(分块实现深化) :
- 分块读取的原理 :
- 普通嵌套循环是左表逐行读,每行触发一次右表扫描,IO 开销高。
- Block Nested Loop 优化了这点,把左表分成多个"块"(block),每次加载一块到内存缓冲区,然后对这块内的所有行一次性扫描右表。
- 块大小由参数
join_buffer_size
控制(默认 256KB),能装多少行取决于行宽(比如每行 100 字节,能装约 2600 行)。
- 具体实现步骤 :
- 缓冲区分配 :
- MySQL 在内存中分配一块
join_buffer
,可能是连续数组或链表结构。 - 缓冲区存左表行的原始数据(列值)或指针(指向存储引擎的记录)。
- MySQL 在内存中分配一块
- 左表分块读取 :
- 从存储引擎(InnoDB)顺序读取左表数据,按
join_buffer_size
大小填充缓冲区。 - 如果左表有 10 万行,每块装 2600 行,则分成约 38 块,最后一块可能不满。
- 读取靠游标或迭代器,可能是 InnoDB 的 page 级读取(每 page 16KB),多 page 拼成一块。
- 从存储引擎(InnoDB)顺序读取左表数据,按
- 右表扫描 :
- 对缓冲区每块,扫描右表一次,逐行对比 join 条件(如
t1.id = t2.id
)。 - 右表无索引时顺序扫描,有索引时用 B+ 树定位。
- 匹配结果存临时结构(内存或磁盘),左表行无匹配补 NULL。
- 对缓冲区每块,扫描右表一次,逐行对比 join 条件(如
- 块切换 :
- 当前块处理完,清空缓冲区,加载下一块,重复右表扫描。
- 最后一块处理完,join 结束。
- 缓冲区分配 :
- 底层推测 :
- 缓冲区管理 :可能是 C 实现的内存池,动态分配
join_buffer_size
大小的空间。 - 数据结构:缓冲区存行数据时,可能用数组(连续内存访问快),或链表(动态扩展方便)。
- IO 交互:左表分块读靠 InnoDB 的 buffer pool,右表扫描可能是全表顺序读(扫 disk page)或索引遍历。
- 匹配逻辑:join 条件对比可能是逐字段 memcmp,或者提前提取 join 字段存哈希表加速(但不完全是 Hash Join)。
- 临时结果 :匹配行可能写内存链表,溢出时用磁盘临时表(
tmp_table_size
限制)。
- 缓冲区管理 :可能是 C 实现的内存池,动态分配
- 分块的好处 :
- 减少右表扫描次数,比如 10 万行左表,逐行扫描需 10 万次,分 38 块只需 38 次。
- IO 成本从 O(n*m) 的常数级降到 O((n/k)*m),k 是每块行数。
- 限制 :
- 缓冲区太小(
join_buffer_size
不足),块数增多,退化成普通嵌套循环。 - 右表无索引,扫描仍是全表,效率依赖 m。
- 缓冲区太小(
- 分块读取的原理 :
- 适用场景:右表无索引,左表可分块时。
- left join 特点:每块处理完,左表行全输出,右表无匹配补 NULL。
- 复杂度:仍为 O(n*m),但常数级优化,IO 成本降低。
- 底层猜想:缓冲区管理靠内存池,右表扫描可能是顺序文件读或 B+ 树遍历。
- 强化细节(分块实现深化) :
优化器选择
left join 的算法取决于统计信息(表大小、索引)、join 条件和内存。EXPLAIN
的 type
字段(如 ALL
、index
)和 Extra
(如 Using join buffer
)能看到具体选择。我当时没提优化器,太片面了。
反思
Block Nested Loop 的分块机制我之前没理解透,现在推测是内存缓冲+分批扫描的组合,下次可以重点讲这个。得补优化器和 InnoDB 的源码,至少搞清楚 join_buffer
的分配逻辑。
主从复制:三种模式及其详细实现
1. 异步复制
- 流程 :
- 主库写 binlog(row 格式每行变更),事务提交刷盘。
- 从库 IO 线程拉 binlog 存 relay log,SQL 线程重放。
- 细节 :
- binlog 事件含时间戳、位点,IO 线程用 TCP 拉取。
- SQL 线程 5.7 后多线程,按 schema 或逻辑时钟并行。
- 位点管理靠
master_log_file
和master_log_pos
。
2. 半同步复制
- 流程 :
- 主库写 binlog,等至少一个从库 IO 线程写 relay log 后提交。
- SQL 线程异步重放。
- 细节 :
- 主库 dump 线程发 binlog,从库 ACK 后事务线程继续。
- 超时(
rpl_semi_sync_master_timeout
)退化异步。 - 插件
rpl_semi_sync_master
控制。
3. 全同步复制(MGR/Galera)
- 流程 :
- 主库写 binlog,组内节点共识后提交。
- MGR 用 GTID 和 Paxos,Galera 用写集和组通信。
- 细节 :
- MGR certification 查冲突,Galera TOI 模式同步。
- 多数节点确认才提交,网络延迟敏感。
反思与改进
left join 的 Block Nested Loop 分块细节补齐后,我对 IO 优化理解更深,但源码级验证还缺。主从复制已很细,下次可挑 GTID 或线程调度再深入。计划看 MySQL 8.0 的 join_buffer 实现和 replication 源码,争取全面掌握。