SQL调优与数据建模 面试备战
EXPLAIN分析 → 索引优化 → 查询模式 → 星型/雪花建模 → 数据仓库 → 25道真题 | 全程 Excel 类比对照
1 EXPLAIN 执行计划详解
EXPLAIN 是 MySQL 提供的查询分析工具,不会真正执行 SQL,而是展示 MySQL 打算如何执行这条查询。就像 Excel 里按 Ctrl+~ 看公式而不是看结果——你看的是"计算计划"。
EXPLAIN SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time LIMIT 10;
-- 得到一张"执行计划表", 每一列告诉你一个维度的信息
📋 字段逐一解读
| Extra 值 | 含义 | 好坏 |
|---|---|---|
Using index | 覆盖索引, 索引中包含所有查询列, 不用回表 | 最优 |
Using where | Server层在存储引擎返回后进行过滤 | 一般 |
Using index condition | 索引下推(ICP), 在存储引擎层提前过滤 | 好 |
Using temporary | 使用了临时表 — 通常 GROUP BY / DISTINCT / UNION | 需优化 |
Using filesort | 无法用索引排序, 额外排序操作 | 需优化 |
Using join buffer | 连接没有使用索引, 用了缓存块 | 需优化 |
Select tables optimized away | 优化器直接从索引取值, 不用查表 | 最优 |
🏎 Type 访问类型梯度 — 速度仪表盘
system = 表里只有1行, 相当于Excel只有一个单元格
const = VLOOKUP精确匹配, 一次就找到
eq_ref = 主键/唯一索引关联, 每行精确匹配一次
ref = 普通索引查找, 可能匹配多行
range = Excel的"筛选"功能, 指定范围
index = 只扫描索引(比全表好), 像只翻索引页
ALL = 全表扫描 = 翻遍Excel每一行!!!
🔥 5个实战 EXPLAIN 案例
案例1: 全表扫描 → 加索引
-- 原始查询
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;
-- 结果: type=ALL, key=NULL, rows=500000
-- 优化: 给 user_id 加索引
ALTER TABLE orders ADD INDEX idx_user_id(user_id);
-- 结果: type=ref, key=idx_user_id, rows=50
type=ALL 是最差情况。优先检查 WHERE 条件列有没有索引。添加合适索引后通常能降到 ref 或 range。案例2: 索引存在但不生效 (隐式转换)
-- phone 是 VARCHAR 类型, 有索引
EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- 结果: type=ALL, key=NULL ← 字符串列用数字查, 隐式转换! MySQL对每行做CAST
-- 修复: 加引号!
EXPLAIN SELECT * FROM users WHERE phone = '13800138000';
-- 结果: type=ref, key=idx_phone, rows=1
案例3: 复合索引只用了部分
-- 索引: idx_abc(a, b, c)
EXPLAIN SELECT * FROM t WHERE a = 1 AND c = 3;
-- 结果: type=ref, key=idx_abc, key_len=4 (只用了a列, c无法利用索引)
-- 优化: 补全中间列 或 调整索引顺序
ALTER TABLE t ADD INDEX idx_ac(a, c);
-- 或者修改查询: WHERE a=1 AND b IS NOT NULL AND c=3
案例4: filesort 优化
-- 索引: idx_status(status)
EXPLAIN SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time;
-- Extra: Using where; Using filesort ← 排序没走索引!
-- 优化: 创建复合索引 (status, create_time)
ALTER TABLE orders ADD INDEX idx_status_time(status, create_time);
-- Extra: Using index ← 排序也走索引了, 无需filesort
案例5: 子查询 vs JOIN
-- 慢: 子查询
EXPLAIN SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE vip = 1);
-- 可能出现: DEPENDENT SUBQUERY, 每行执行一次子查询
-- 优化: 改写为 JOIN
EXPLAIN SELECT o.* FROM orders o
JOIN users u ON o.user_id = u.id WHERE u.vip = 1;
-- 结果: type=eq_ref(ref), Using where ← 好多了
2 索引优化实战
📑 最左前缀原则
想象 Excel 中按 省份→城市→区县 三列排序。你必须先按省份排, 同省再按城市排, 同城再按区县排。
如果你跳过省份, 直接按城市排 → 整个排序就废了, 跟没排一样!
索引(A, B, C) 就等于这个三级排序: 你必须先匹配A, 才能利用B的有序性; 先匹配A和B, 才能利用C。
idx_abc(A, B, C) — 查询能否命中?| 查询 WHERE 条件 | 能否用索引? | 用了几列 | Excel 类比 |
|---|---|---|---|
WHERE a = 1 | 命中 | A | 只按省份筛选 ✓ |
WHERE a = 1 AND b = 2 | 命中 | A, B | 按省份+城市筛选 ✓ |
WHERE a = 1 AND b = 2 AND c = 3 | 命中 | A, B, C | 按省+市+区筛选 ✓ |
WHERE a = 1 AND c = 3 | 部分命中 | 仅 A | 跳过城市直接按区筛选, 只能用省份 ✗ |
WHERE b = 2 AND c = 3 | 不命中 | 0 | 跳过省份, 排序完全无效 ✗ |
WHERE b = 2 | 不命中 | 0 | 只按城市, 省份不同没法排 ✗ |
WHERE a > 1 AND b = 2 | 部分命中 | 仅 A(范围) | a是范围查询后b无法利用索引 |
WHERE a = 1 ORDER BY b | 命中(含排序) | A + 排序用B | 筛选后B有序, 不用额外排序 ✓ |
遇到范围查询(>, <, BETWEEN, LIKE)后, 后续列无法利用索引。
所以复合索引中, 等值查询列放前面, 范围查询列放后面。
🔍 覆盖索引 (Covering Index)
你需要"学生姓名和成绩"两项信息。如果索引卡(目录页)上已经印了姓名和成绩, 你就不用翻到正文页(回表)了——这就是覆盖索引!
在 Excel 里, 相当于 VLOOKUP 的返回列就在查找列的右边, 不需要再切到另一个Sheet去取数据。
-- 覆盖索引实战
-- 需求: 查询用户的 name 和 age
SELECT name, age FROM users WHERE name = '张三';
-- 如果只有 INDEX(name) → 需要回表取 age
-- 创建覆盖索引:
ALTER TABLE users ADD INDEX idx_name_age(name, age);
-- EXPLAIN: Extra = Using index ← 索引覆盖, 不回表!
📩 索引下推 (Index Condition Pushdown, ICP)
MySQL 5.6+ 引入的优化。核心思想: 把原本在 Server 层做的索引条件过滤, 下推到存储引擎层提前做, 减少回表次数。
在Excel里找"姓张且年龄>30"的人。没有ICP时, 你先把所有姓张的行都复制出来(回表), 再筛选年龄。有了ICP, 你在索引页就提前判断年龄, 只复制符合条件的行——少搬很多数据!
- InnoDB / MyISAM 引擎
- 联合索引, 且 WHERE 条件只有部分列能走索引
- 不能是覆盖索引的情况 (覆盖索引本身就不回表, 不需要ICP)
- 子查询条件不能下推
⚠️ 索引失效十大场景
Excel 里有"按姓名查找"的目录。如果你把姓名拆开查(只要"名"不要"姓")、拿数字去文字列查、用模糊匹配查一半, 都会让目录失效, 只好翻遍全表。
| # | 失效场景 | 错误示例 | 修复方式 |
|---|---|---|---|
| 1 | 对索引列使用函数 | WHERE YEAR(create_time) = 2025 |
WHERE create_time >= '2025-01-01' AND create_time < '2026-01-01' |
| 2 | 对索引列做运算 | WHERE age + 1 = 30 |
WHERE age = 29 |
| 3 | 隐式类型转换 | WHERE varchar_col = 123 |
WHERE varchar_col = '123' — 类型一致! |
| 4 | LIKE 以 % 开头 | WHERE name LIKE '%张' |
全文索引 / Elasticsearch / 改为 '张%' |
| 5 | OR 连接非索引列 | WHERE indexed_col=1 OR no_index_col=2 |
给 no_index_col 也加索引, 或用 UNION |
| 6 | 不等于 (!= / <>) | WHERE status != 'DELETED' |
改用 IN 列举有效值, 或在MySQL中这个有时能用, 看优化器 |
| 7 | IS NOT NULL | WHERE col IS NOT NULL |
IS NULL 通常可以走索引, IS NOT NULL 优化器可能放弃 |
| 8 | NOT IN / NOT EXISTS | WHERE id NOT IN (1,2,3) |
改写为 LEFT JOIN ... WHERE IS NULL, 或用 IN 列举 |
| 9 | ORDER BY 与索引方向不一致 | INDEX(a ASC, b ASC) 但 ORDER BY a ASC, b DESC |
MySQL 8.0+ 支持降序索引, 或统一排序方向 |
| 10 | 优化器认为全表更快 | 表小 / 数据分布不均 / 选中行超过30% | FORCE INDEX 或 USE INDEX 提示, 但慎用! |
面试官最爱问: "索引什么时候会失效?" — 至少要能说出 函数、计算、隐式转换、LIKE %开头、OR 这5个。
3 查询优化模式
📄 分页优化: 深翻页问题
Excel 有10万行数据, 你要"翻到第1万页, 每页10行" → 相当于先跳过10万行, 再取10行。即使你只要10行, MySQL也必须先扫描跳过的10万行!
-- 问题: 深翻页, OFFSET很大
SELECT * FROM orders ORDER BY id LIMIT 100000, 10;
-- MySQL会扫描100010行, 然后丢掉前100000行! 浪费!
方案1: Deferred Join (延迟关联)
-- 先用子查询+覆盖索引快速拿到10个ID, 再关联回表
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 10
) tmp ON o.id = tmp.id;
-- 子查询只走索引(覆盖索引), 不回表; 外层只回表10次!
方案2: 游标/书签法 (推荐)
-- 上一页最后一条记录的ID为 last_id
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
-- 完美利用主键索引, 不需要 OFFSET, 极快!
-- 缺点: 不支持跳页(只能上一页/下一页)
方案3: BETWEEN (有序ID)
SELECT * FROM orders WHERE id BETWEEN 100001 AND 100010;
📊 COUNT 优化
| 写法 | 含义 | 性能 | Excel 类比 |
|---|---|---|---|
COUNT(*) | 统计总行数(包含NULL) | 最快 | Excel最下方的行号 |
COUNT(1) | 等价于 COUNT(*) | 最快 | 同上, 没区别 |
COUNT(列名) | 统计该列非NULL的行数 | 较慢 | 要逐行检查单元格是否为空 |
COUNT(DISTINCT 列) | 统计去重后的非NULL数量 | 最慢 | 先去重再计数 |
COUNT(*) 和 COUNT(1) 完全等价, InnoDB 会选择最小的索引来扫描(不一定扫主键)。MyISAM 有存储行数所以瞬间返回, 但 InnoDB 因为MVCC不能直接存行数。
🔄 ORDER BY 与 GROUP BY 优化
ORDER BY 优化
Excel排序: 如果数据已经按"姓名"排序好了(索引), 你再要求按姓名排序 → Excel不用做任何事(Using index)。如果数据是乱序的 → Excel需要重新排一遍(filesort)。
-- Using index: ORDER BY 的列与索引顺序一致
-- INDEX(a, b)
SELECT * FROM t WHERE a = 1 ORDER BY b; -- ✓ Using index (a已确定, b在索引中有序)
SELECT * FROM t ORDER BY a, b; -- ✓ Using index
SELECT * FROM t ORDER BY b, a; -- ✗ Using filesort (顺序反了)
SELECT * FROM t WHERE a > 1 ORDER BY b; -- ✗ Using filesort (a是范围查询, b不再有序)
GROUP BY 优化
-- MySQL 默认会对 GROUP BY 的结果排序 (隐式 ORDER BY)
-- 如果不需要排序, 可以显式指定 ORDER BY NULL 避免filesort
SELECT dept, COUNT(*) FROM employees GROUP BY dept ORDER BY NULL;
-- 最好: GROUP BY 的列有索引
-- INDEX(dept) → GROUP BY dept 走索引, 无需临时表
🔅 子查询改写为 JOIN & UNION 优化
-- 慢: 相关子查询 (DEPENDENT SUBQUERY, 每行执行一次)
SELECT * FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.vip = 1);
-- 快: 改写为 JOIN
SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id WHERE u.vip = 1;
UNION vs UNION ALL
| 操作 | 是否去重 | 性能 | 何时用 |
|---|---|---|---|
UNION | 是(像 DISTINCT) | 较慢 | 需要去重时 |
UNION ALL | 否(直接拼接) | 快 | 确定无重复 / 不需要去重 |
UNION ALL。UNION 会触发临时表排序去重。💥 生产案例: "LIMIT 100000,10 查了10秒"
背景: 订单列表页, 第1万页查询 LIMIT 100000, 10 耗时10秒。数据库 CPU 飙升。
ALTER TABLE orders ADD INDEX idx_status_time(status, create_time)SELECT o.* FROM orders o JOIN (SELECT id FROM orders WHERE status='PAID' ORDER BY create_time LIMIT 100000,10) t ON o.id=t.id4 数据建模
⭐ 星型模型 vs 雪花模型
星型模型 = 一个大表(事实表)连接多个小下拉菜单表(维度表)。下拉菜单表直接连到大表, 没有嵌套。
雪花模型 = 下拉菜单表还有自己的下拉菜单表! 比如"产品"下拉表里还引用了"产品分类"下拉表。层层嵌套, 像雪花分叉。
订单明细
fact_orders
dim_date
dim_product
dim_customer
dim_line
dim_shift
订单明细
| 对比维度 | 星型模型 | 雪花模型 |
|---|---|---|
| 维度表结构 | 不规范化, 适度冗余 | 完全规范化, 无冗余 |
| JOIN 次数 | 少 (事实表直接连维度表) | 多 (维度表还要连子维度表) |
| 查询性能 | 快 | 较慢 |
| 存储空间 | 较大 (冗余) | 较小 |
| ETL 复杂度 | 简单 (维度表大而全) | 复杂 (需维护外键关系) |
| 适用场景 | OLAP分析报表 (大多数场景) | 存储敏感 / 层级复杂 |
🗂 事实表与维度表
事实表 = Excel的"产量记录表", 每一行是一次事件(一条产量记录、一次停机), 行数疯狂增长。
维度表 = Excel的"下拉菜单选项表", 记录机台信息、产品信息、班次信息, 很少变化。
| 特征 | 事实表 Fact | 维度表 Dimension |
|---|---|---|
| 内容 | 事件/交易记录的度量值 | 描述性属性信息 |
| 增长速度 | 极快 (每天百万行) | 极慢 (几百到几万行) |
| 列类型 | 外键 + 数值度量 | 主键 + 文本描述 |
| 典型大小 | GB ~ TB 级 | MB ~ GB 级 |
| 制造业示例 | 产量记录、停机记录、质检记录 | 机台、产品、班次、产线、车间 |
📅 缓慢变化维度 (SCD)
维度表的属性不是一成不变的, 比如"员工"从"A部门"调到了"B部门"。如何处理这种变化?
| id | name | dept |
|---|---|---|
| 1 | 张三 | 研发部 |
| sk | id | name | dept | active |
|---|---|---|---|---|
| 1 | 100 | 张三 | 市场部 | N |
| 2 | 100 | 张三 | 研发部 | Y |
| id | name | dept | prev_dept |
|---|---|---|---|
| 1 | 张三 | 研发部 | 市场部 |
Type 2 是生产环境最常用的方案。它通过新增行 + 代理键 + 有效期字段(start_date/end_date 或 is_active)来保留完整变更历史。代价是维度表行数增加, 但换来的是可以对任意时间点做准确分析。
5 数据仓库基础
⚙️ OLTP vs OLAP
OLTP = 每天用的Excel出勤表, 大家轮流填, 一行一行加, 强调快速写入。
OLAP = 月底汇总的Excel透视表, 从多个表拉数据做分析, 强调复杂查询。
| 特征 | 说明 |
|---|---|
| 用途 | 日常业务操作 |
| 操作 | INSERT / UPDATE / DELETE 为主 |
| 单次数据量 | 少 (1~几十行) |
| 并发量 | 极高 (成千上万用户) |
| 响应要求 | 毫秒级 |
| 设计原则 | 规范化 (3NF), 减少冗余 |
| 典型系统 | MySQL, PostgreSQL, Oracle |
| Excel类比 | 日常填写的考勤表 |
| 特征 | 说明 |
|---|---|
| 用途 | 数据分析、报表、决策 |
| 操作 | SELECT / GROUP BY / 聚合为主 |
| 单次数据量 | 海量 (百万~亿行扫描) |
| 并发量 | 低 (分析人员) |
| 响应要求 | 秒~分钟级可接受 |
| 设计原则 | 反规范化 (星型/雪花), 适度冗余 |
| 典型系统 | ClickHouse, Doris, Hive |
| Excel类比 | 月底的透视表/汇总报表 |
🔄 ETL 与数据集市
ETL 流程
读取原始数据
维度关联/SCD处理
ClickHouse/Doris
数据集市 (Data Mart)
数据集市是数据仓库的子集, 面向特定部门或业务线。
数据仓库 = 公司总的Excel数据中心, 所有Sheet都在。
数据集市 = 给生产部单独拷贝了"产量+停机"相关的Sheet, 只关注自己部门的数据。
从大仓库中切出一部分, 给特定用户用。
💥 生产案例: 白天跑报表影响业务查询
背景: 制造业MES系统, MySQL主库同时承担业务写入和报表查询。白天运营人员跑报表(复杂聚合SQL), 导致业务端下单/查询超时, 用户投诉。
6 面试题精选 (25道)
覆盖 EXPLAIN、索引优化、查询模式、数据建模、OLTP vs OLAP。点击展开答案。
从最优到最差:
system → const → eq_ref → ref → range → index → ALL
- system/const: 表中只有一行或通过主键/唯一索引精确匹配一行
- eq_ref: JOIN时通过主键/唯一索引匹配, 每行最多匹配一条
- ref: 普通索引等值匹配, 可能多条
- range: 索引范围扫描 (BETWEEN, >, <, IN)
- index: 全索引扫描 (比ALL好, 但还是扫全部)
- ALL: 全表扫描, 必须优化!
目标: 至少达到 range 级别, 最好 ref 及以上。
覆盖索引: 一个索引包含了查询所需的所有列, 因此不需要回表(不需要去聚簇索引取完整行)。
为什么快: 二级索引通常比聚簇索引小得多(一个页能存更多记录), 且省去了回表的随机IO。EXPLAIN 中 Extra 会显示 Using index。
-- INDEX(name, age) 覆盖了查询
EXPLAIN SELECT name, age FROM users WHERE name = '张三';
-- Extra: Using index ← 不回表!
复合索引 INDEX(a, b, c) 实际创建了 (a), (a,b), (a,b,c) 三种索引能力。查询必须从最左列开始匹配, 不能跳过中间列。
WHERE a=1 AND b=2 AND c=3→ 命中全部三列 ✓WHERE a=1 AND b=2→ 命中 a, b ✓WHERE a=1 AND c=3→ 只命中 a (c无法用, 跳过了b) △WHERE b=2 AND c=3→ 完全不命中 ✗
Excel类比: 像三级排序(省→市→区), 跳过省直接按市排就没有意义。另外, 范围查询后的列也无法使用索引。
- 对索引列使用函数:
WHERE YEAR(date)=2025→ 改为范围查询 - 对索引列做运算:
WHERE age+1=30→ 改为WHERE age=29 - 隐式类型转换: VARCHAR列用数字查
WHERE phone=1380000→ 加引号 - LIKE '%xxx': 以通配符开头 → 改为
'xxx%'或用全文索引 - OR连接无索引列:
WHERE a=1 OR b=2(b无索引) → 给b也加索引或用UNION
其他还有: !=, NOT IN, IS NOT NULL, ORDER BY方向不一致, 优化器判断全表更快等。
COUNT(*): 统计总行数, 包含NULL值, InnoDB会选择最小的索引扫描COUNT(1): 与 COUNT(*) 完全等价, 没有区别COUNT(列名): 统计该列非NULL的行数, 需要逐行判断, 较慢
结论: 优先用 COUNT(*), 它是标准写法且语义最清晰。
索引下推 (Index Condition Pushdown, MySQL 5.6+) 是将 WHERE 中可以用索引过滤但无法完全走索引的条件, 下推到存储引擎层提前执行, 减少回表次数。
例子: INDEX(name, age), 查询 WHERE name LIKE '张%' AND age > 30
- 无ICP: 先用
name LIKE '张%'找到所有姓张的, 全部回表, 再在Server层过滤 age - 有ICP: 在索引中直接判断 age, 只回表满足条件的行
EXPLAIN 标志: Extra 中出现 Using index condition。
MySQL的 LIMIT offset, n 会先扫描 offset + n 行, 再丢掉前 offset 行。offset很大时绝大部分扫描都是浪费。
优化方案:
- 延迟关联 (Deferred Join): 子查询用覆盖索引取ID, 外层只回表少量行
- 游标法:
WHERE id > last_id ORDER BY id LIMIT 10, 完美利用索引, 不需要OFFSET - BETWEEN: 如果ID连续, 直接
WHERE id BETWEEN ... AND ...
-- 延迟关联
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000,10) t ON o.id=t.id;
Using filesort 表示 MySQL 无法利用索引来完成排序, 需要在内存(或磁盘)中额外做排序操作。这是一个性能警告。
消除方法:
- 确保 ORDER BY 的列有索引
- 如果是
WHERE a=1 ORDER BY b, 创建复合索引(a, b) - ORDER BY 的列顺序必须与索引顺序一致, 不能反
- 如果是多列排序, 方向必须一致 (都ASC或都DESC, MySQL 8.0+支持降序索引)
回表: 通过二级索引找到主键值后, 再用主键去聚簇索引查找完整行数据的过程。涉及两次索引查找, 其中聚簇索引查找通常是随机IO。
为什么避免: 回表意味着每条匹配记录都需要一次随机IO, 当匹配行数很多时(比如上万行), 性能急剧下降。
避免方式: 使用覆盖索引, 让查询所需的所有列都在二级索引中, EXPLAIN 显示 Using index。
MySQL优化器基于统计信息(索引基数 cardinality)来估算不同执行计划的成本, 选择成本最低的。但统计信息可能不准确:
- 数据分布不均匀 (比如 status 90%都是 'ACTIVE')
- 统计信息未及时更新
- 对于复杂查询, 优化器的成本估算可能偏差较大
处理方式:
ANALYZE TABLE更新统计信息FORCE INDEX(索引名)强制使用指定索引(慎用)USE INDEX(索引名)建议使用(优化器仍可覆盖)- 调整 SQL 写法让优化器更容易做正确选择
UNION: 合并结果集并去重, 相当于加了 DISTINCT, 需要临时表排序UNION ALL: 合并结果集不去重, 直接拼接, 性能更好
最佳实践: 除非确定需要去重, 否则优先用 UNION ALL。UNION 的去重操作消耗大量资源。
在 MySQL 中, JOIN 通常比子查询快, 尤其是:
- DEPENDENT SUBQUERY: 相关子查询会对外层每一行都执行一次子查询, 极慢 → 改写为JOIN
- IN 子查询: MySQL 5.6+ 优化为 semi-join, 但有时仍不如直接 JOIN
-- 慢: 相关子查询
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip=1);
-- 快: JOIN
SELECT o.* FROM orders o JOIN users u ON o.user_id=u.id WHERE u.vip=1;
星型模型: 事实表居中, 维度表直接连接, 维度表不规范化 (有冗余)。JOIN少, 查询快。
雪花模型: 维度表继续拆分成子维度表, 完全规范化。节省空间, 但JOIN多, 查询复杂。
Excel类比: 星型=大表直连下拉菜单表; 雪花=下拉菜单表还有自己的下拉菜单。
选择建议: 大多数OLAP场景选星型模型, 查询性能优先。存储特别敏感且维度层级复杂时考虑雪花模型。
事实表 (Fact Table):
- 存储业务事件/交易的度量数据 (订单、产量、停机记录)
- 包含外键(关联维度表) + 数值度量(金额、数量、时长)
- 增长极快, 通常百万到数十亿行
维度表 (Dimension Table):
- 存储描述性属性 (产品信息、员工信息、时间信息)
- 包含主键 + 文本描述字段
- 增长缓慢, 通常几百到几万行
Excel类比: 事实表=每天不断追加的记录行; 维度表=固定不变的下拉选项列表。
SCD Type 2 通过新增行来保留维度属性的完整变更历史。每条记录需要:
- 代理键 (Surrogate Key): 自增主键, 唯一标识每个版本
- 自然键 (Natural Key): 原始业务ID (如员工编号)
- 有效期:
start_date/end_date或is_current标记
当属性变更时, 将旧记录的 end_date 设为当前日期, is_current 设为 N, 然后插入新记录。事实表通过代理键关联, 确保历史事实关联的是当时有效的维度值。
| 维度 | OLTP | OLAP |
|---|---|---|
| 全称 | 联机事务处理 | 联机分析处理 |
| 操作类型 | INSERT/UPDATE/DELETE | SELECT/GROUP BY/聚合 |
| 单次数据量 | 少(几行) | 海量(百万~亿行) |
| 并发量 | 极高 | 低 |
| 设计原则 | 规范化(3NF) | 反规范化(星型/雪花) |
| 典型系统 | MySQL, PostgreSQL | ClickHouse, Doris, Hive |
当 ORDER BY 的列顺序与索引列顺序完全一致时, MySQL 可以利用索引的有序性直接返回排序结果, 不需要额外排序。
INDEX(a, b)+ORDER BY a, b→Using index✓INDEX(a, b)+WHERE a=1 ORDER BY b→Using index✓ (a确定后b有序)INDEX(a, b)+ORDER BY b, a→Using filesort✗ (顺序反了)INDEX(a, b)+WHERE a > 1 ORDER BY b→Using filesort✗ (a是范围, b不再有序)
聚簇索引 (Clustered Index):
- InnoDB中主键索引就是聚簇索引, 叶子节点存储完整行数据
- 一张表只有一个聚簇索引
- 数据物理上按主键顺序存储
非聚簇索引 (Secondary Index / 二级索引):
- 叶子节点存储主键值而非完整行
- 查询非索引列时需要回表 (用主键去聚簇索引取数据)
- 可以有多个二级索引
Excel类比: 聚簇索引=Excel按行号排列的完整数据表; 二级索引=按姓名排序的索引卡, 卡上只有姓名和行号。
rows 是优化器的估算值, 基于统计信息, 可能不准确:
- 实际数据分布可能比统计信息更集中 (选择性更好)
- 使用了
LIMIT, 可能在扫描很多行之前就满足了 - 统计信息过期,
ANALYZE TABLE后可能更新 - 覆盖索引场景下虽然 rows 大但全是内存操作, 无随机IO
Using temporary 表示 MySQL 创建了临时表来处理 GROUP BY。通常发生在:
- GROUP BY 的列没有索引
- GROUP BY 的列与索引顺序不一致
- GROUP BY 和 ORDER BY 的列不同
优化方式:
- 给 GROUP BY 的列添加索引
- 如果不需要排序, 加
ORDER BY NULL避免隐式排序 - 调大
tmp_table_size和max_heap_table_size让临时表在内存中 - 考虑使用松散索引扫描 (Loose Index Scan)
事实表 fact_orders:
- order_id (代理键), date_id (FK→dim_date), customer_id (FK→dim_customer), product_id (FK→dim_product)
- 度量: quantity, amount, discount_amount
维度表:
- dim_date: date_id, full_date, year, quarter, month, weekday
- dim_customer: customer_id, name, city, level, industry
- dim_product: product_id, name, category, brand, unit_price
设计原则: 事实表只存ID和度量, 描述信息由维度表提供; 维度表做适度冗余(星型模型)减少JOIN。
核心问题是 OLTP 和 OLAP 共享同一个数据库实例, 复杂查询占用了大量资源。
- 读写分离: MySQL主库写, 从库读, 减轻主库压力
- OLTP/OLAP分离: 引入 ClickHouse/Doris 等OLAP引擎专门跑报表
- 数据同步: 通过 Canal/Debezium 监听 binlog 准实时同步到OLAP系统
- 报表改道: 所有报表查询指向 OLAP 系统
- 报表缓存: 高频报表结果缓存到 Redis, 定时刷新
本质: 业务系统和分析系统物理分离, 各自使用合适的数据库引擎。
判断步骤:
EXPLAIN查看 type 是否为 ALL 或 index- 检查 key 是否为 NULL
- 看 rows 是否远大于预期
- 检查 Extra 是否有 Using filesort / Using temporary
加索引原则:
- WHERE 条件中的等值列 → 放在索引前面
- ORDER BY / GROUP BY 的列 → 接在 WHERE 条件后面
- 范围查询 (> , BETWEEN, LIKE) → 放在索引最后
- SELECT 的列 → 考虑用覆盖索引避免回表
- 优先创建复合索引而非多个单列索引 (遵循最左前缀)
ETL = Extract(抽取) + Transform(转换) + Load(加载)
- Extract: 从MySQL、日志文件、API等数据源读取原始数据
- Transform: 数据清洗(去重、格式化、空值处理)、维度关联、SCD处理、业务计算
- Load: 将处理好的数据写入数据仓库(ClickHouse/Doris/Hive)
为什么需要: 业务系统(OLTP)的数据格式和结构不适合直接做分析。ETL将数据从"事务友好"转换为"分析友好"的形态。
系统化优化步骤:
- EXPLAIN 分析: 查看 type, key, rows, Extra, 定位瓶颈
- 检查表结构: 表有多少行? 哪些列有索引? 数据分布如何?
- 加索引: 根据WHERE/ORDER BY/GROUP BY创建合适的复合索引
- 改写SQL: 子查询→JOIN, UNION→UNION ALL, 深翻页→延迟关联
- 利用覆盖索引: 减少回表次数
- 检查索引是否生效: 隐式转换? 函数操作? 类型不匹配?
- 业务层面: 真的需要查这么多数据? 能否缓存? 能否预计算?
- 架构层面: 数据量是否到了需要分库分表/引入OLAP的程度?
核心思路: 先定位(慢查询日志+EXPLAIN) → 再优化(索引+SQL改写) → 最后考虑架构升级。