322 lines
12 KiB
Markdown
322 lines
12 KiB
Markdown
|
|
---
|
|||
|
|
tags: [MySQL, 踩坑, FileSort, 隐式转换, TIMESTAMP, DATETIME, AUTO_INCREMENT, FULLTEXT, 幻读, LEFT JOIN]
|
|||
|
|
create time: 2026-05-16 00:30
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 常见踩坑
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
MySQL 的设计有许多「反直觉」的行为。本章汇总最常见的陷阱和误区,帮助你在编码阶段就规避这些问题。
|
|||
|
|
|
|||
|
|
## 1. ORDER BY 产生 FileSort
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ❌ 看似有索引,但实际上触发了 FileSort
|
|||
|
|
CREATE INDEX idx_status ON orders(status);
|
|||
|
|
|
|||
|
|
EXPLAIN SELECT * FROM orders
|
|||
|
|
WHERE status = 'pending'
|
|||
|
|
ORDER BY created_at DESC;
|
|||
|
|
-- Extra: Using where; Using filesort
|
|||
|
|
|
|||
|
|
-- 为什么?idx_status 只按 status 排序,created_at 是无序的
|
|||
|
|
-- 虽然 WHERE 能用索引过滤,但 ORDER BY 无法利用该索引
|
|||
|
|
|
|||
|
|
-- ✅ 解决方法:创建联合索引
|
|||
|
|
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
|
|||
|
|
|
|||
|
|
-- 现在的 EXPLAIN:
|
|||
|
|
-- key: idx_status_created
|
|||
|
|
-- Extra: Using where ← No filesort! 因为索引里已经是有序的
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
IDX["联合索引 idx(status, created_at)"]
|
|||
|
|
|
|||
|
|
IDX -->|"WHERE status=? ORDER BY created_at"| PERF["✅ 完全利用索引顺序"]
|
|||
|
|
IDX -->|"WHERE status=? LIMIT 100"| PERF
|
|||
|
|
|
|||
|
|
single_idx["单列索引 idx(status)"]
|
|||
|
|
single_idx -->|"WHERE status=? ORDER BY created_at"| SORT["❌ Filesort<br/>内存排序"]
|
|||
|
|
|
|||
|
|
style PERF fill:#00D866,color:#fff
|
|||
|
|
style SORT fill:#EE5A24,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] 教学要点
|
|||
|
|
> **问题**:为什么已有索引还不够?
|
|||
|
|
> **回答**:B+树索引只能保证第一列有序。当 WHERE 固定了第一列的值后,剩余行的第二列仍然保持插入顺序,而非目标字段的排序顺序。
|
|||
|
|
|
|||
|
|
## 2. COUNT(*) vs COUNT(1) vs COUNT(column)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 三者语义不同,但在 InnoDB 中 COUNT(*) 最快
|
|||
|
|
SELECT COUNT(*) FROM users; -- 统计行数(忽略 NULL)
|
|||
|
|
SELECT COUNT(1) FROM users; -- 同 COUNT(*)(InnoDB 优化相同)
|
|||
|
|
SELECT COUNT(email) FROM users; -- 统计 email 非 NULL 的行数
|
|||
|
|
SELECT COUNT(DISTINCT city) FROM users; -- 去重计数
|
|||
|
|
|
|||
|
|
-- InnoDB 优化细节
|
|||
|
|
-- COUNT(*) 直接扫描聚簇索引的最左叶子页开始计数(O(N))
|
|||
|
|
-- COUNT(column) 除了扫描还需要检查是否为 NULL → 稍慢
|
|||
|
|
-- COUNT(DISTINCT) 需要额外排序/去重 → 最慢
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TB
|
|||
|
|
subgraph "性能对比 (由快到慢)"
|
|||
|
|
A["COUNT(*)<br/>只数行数"] -->|快 10\%~30\%| B["COUNT(1)<br/>优化器等价"]
|
|||
|
|
B -->|稍慢| C["COUNT(col)<br/>需判 NULL"]
|
|||
|
|
C -->|最慢| D["COUNT(DISTINCT col)<br/>需排序去重"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style A fill:#00D866,color:#fff
|
|||
|
|
style B fill:#7BED9F,color:#000
|
|||
|
|
style C fill:#FFA500,color:#fff
|
|||
|
|
style D fill:#EE5A24,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] 结论
|
|||
|
|
> - 如果你只是想统计行数 → 用 `COUNT(*)`
|
|||
|
|
> - `COUNT(1)` 和 `COUNT(*)` 在 InnoDB 中是完全等价的(优化器视为同一计划)
|
|||
|
|
> - 不要在 COUNT 中放列名除非你真的要排除 NULL 值
|
|||
|
|
>
|
|||
|
|
> **问题思考**:为什么 `COUNT(*)` 不需要逐行判断字段值?因为 InnoDB 内部维护了行计数器,直接从聚簇索引叶子节点遍历即可。
|
|||
|
|
|
|||
|
|
## 3. TIMESTAMP 自动更新陷阱
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE events (
|
|||
|
|
id BIGINT PRIMARY KEY,
|
|||
|
|
name VARCHAR(100),
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|||
|
|
ON UPDATE CURRENT_TIMESTAMP -- ← 这里!
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
INSERT INTO events (name) VALUES ('Meeting');
|
|||
|
|
-- created_at = 2026-05-16 10:00:00
|
|||
|
|
-- updated_at = 2026-05-16 10:00:00
|
|||
|
|
|
|||
|
|
-- 即使只修改 name,updated_at 也会自动更新
|
|||
|
|
UPDATE events SET name = 'New Meeting' WHERE id = 1;
|
|||
|
|
-- updated_at = 2026-05-16 10:05:00 ← 自动更新了!
|
|||
|
|
|
|||
|
|
-- 但如果修改的是 created_at 呢?
|
|||
|
|
UPDATE events SET created_at = '2026-01-01 00:00:00' WHERE id = 1;
|
|||
|
|
-- updated_at 同样会自动更新为当前时间!
|
|||
|
|
-- 这就是陷阱——修改任何字段都会触发 ON UPDATE
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] 独立创建时间和更新时间
|
|||
|
|
> ```sql
|
|||
|
|
> -- 推荐模式:created_at 不使用 ON UPDATE,updated_at 用 ON UPDATE
|
|||
|
|
> CREATE TABLE good_events (
|
|||
|
|
> id BIGINT PRIMARY KEY,
|
|||
|
|
> name VARCHAR(100),
|
|||
|
|
> created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 不受 ON UPDATE 影响
|
|||
|
|
> updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|||
|
|
> );
|
|||
|
|
> ```
|
|||
|
|
|
|||
|
|
> [!TIP] 为什么修改 created_at 也会触发 ON UPDATE?
|
|||
|
|
> `ON UPDATE CURRENT_TIMESTAMP` 作用于**整行**而非特定列。任何字段的更新都会让 `updated_at` 刷新为当前时间。这不是 bug,是 MySQL 的设计语义——"此行被修改了"。
|
|||
|
|
|
|||
|
|
## 4. VARCHAR 长度计算
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- utf8mb4 下,VARCHAR(n) 的最大字节数 = n × 4
|
|||
|
|
-- MySQL 一行最大 65535 字节
|
|||
|
|
|
|||
|
|
-- ❌ 看起来合理,实际超限
|
|||
|
|
CREATE TABLE bad_table (
|
|||
|
|
col1 VARCHAR(16383), -- 16383 × 4 = 65532 bytes + 2 bytes length prefix = 65534 ✓
|
|||
|
|
col2 CHAR(1) -- 1 × 4 = 4 bytes + overhead = ❌ 超出 65535 行限制
|
|||
|
|
);
|
|||
|
|
-- ERROR 1118: Row size too large
|
|||
|
|
|
|||
|
|
-- ✅ 解决方案
|
|||
|
|
CREATE TABLE good_table (
|
|||
|
|
col1 VARCHAR(16382), -- 留余量
|
|||
|
|
col2 LONGTEXT -- 大文本走外存
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] 关于 65535 的常见误区
|
|||
|
|
> - 65535 是**行级别**的硬限制(不含 TEXT/BLOB 等外部存储字段)
|
|||
|
|
> - `VARCHAR(n)` 需要 **1~2 字节**的长度前缀来记录实际数据长度
|
|||
|
|
> - `innodb_large_prefix=ON` 时可突破到约 8KB(配合 DYNAMIC 页格式),但跨引擎兼容性差,不建议依赖
|
|||
|
|
>
|
|||
|
|
> **问题思考**:为什么 TEXT/BLOB 不计入行大小限制?因为它们的数据存储在溢出页(overflow pages)中,行内只保留一个 20 字节的指针。
|
|||
|
|
|
|||
|
|
## 5. LEFT JOIN 条件位置
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ❌ 把 LEFT JOIN 的过滤条件放在 WHERE,等效于 INNER JOIN
|
|||
|
|
SELECT u.name, o.amount
|
|||
|
|
FROM users u LEFT JOIN orders o ON u.id = o.user_id
|
|||
|
|
WHERE o.status = 'paid';
|
|||
|
|
-- WHERE o.status 会把 LEFT JOIN 的结果过滤掉不匹配的行 → 变成 INNER JOIN
|
|||
|
|
|
|||
|
|
-- ✅ 正确:把条件放在 ON 子句
|
|||
|
|
SELECT u.name, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.amount ELSE 0 END), 0)
|
|||
|
|
FROM users u
|
|||
|
|
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';
|
|||
|
|
|
|||
|
|
-- ✅ 或者:过滤右表应该在 WHERE 中显式检查 NULL
|
|||
|
|
SELECT u.name, o.amount
|
|||
|
|
FROM users u LEFT JOIN orders o ON u.id = o.user_id
|
|||
|
|
WHERE o.status = 'paid' OR o.id IS NULL;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] LEFT JOIN vs INNER JOIN 的快速判断
|
|||
|
|
> **口诀**:左表的过滤条件放 WHERE(不影响左表完整性),右表的过滤条件放 ON(保留左表全量)。
|
|||
|
|
>
|
|||
|
|
> **问题思考**:如果 WHERE 中的列来自两张表(`WHERE u.city = 'Beijing' AND o.status = 'paid'`)会怎样?
|
|||
|
|
> **回答**:此时 WHERE 先执行 JOIN,再对结果集做二次过滤。这仍然是 LEFT JOIN,只是部分数据被 WHERE 筛掉了——它不会退化为 INNER JOIN。
|
|||
|
|
|
|||
|
|
## 6. 隐式类型转换导致索引失效
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- phone 是 VARCHAR(20)
|
|||
|
|
ALTER TABLE users ADD INDEX idx_phone (phone);
|
|||
|
|
|
|||
|
|
-- ❌ 传入了 INT,MySQL 自动把 phone 转成 INT 再比较
|
|||
|
|
SELECT * FROM users WHERE phone = 13800138000;
|
|||
|
|
-- EXPLAIN: type=ALL, Rows=1000000 → 索引失效
|
|||
|
|
|
|||
|
|
-- ✅ 始终传入字符串
|
|||
|
|
SELECT * FROM users WHERE phone = '13800138000';
|
|||
|
|
-- EXPLAIN: type=ref, key=idx_phone → 走索引
|
|||
|
|
|
|||
|
|
-- 类似陷阱
|
|||
|
|
SELECT * FROM users WHERE id = '12345';
|
|||
|
|
-- varchar 列传 int 同理,MySQL 会做隐式转换
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] 隐式转换的核心规则
|
|||
|
|
> - MySQL 的转换优先级:**INT > VARCHAR**。当列类型为 VARCHAR、值为 INT 时,MySQL 会把**每一行的 VARCHAR 值转为 INT**再比较 → 索引失效。
|
|||
|
|
> - 反之(列是 INT、传入 VARCHAR),MySQL 会将传入值转为 INT → **索引依然生效**。
|
|||
|
|
> - **结论**:永远让参数类型与列类型严格一致。这是后端 ORM 框架最容易忽略的点。
|
|||
|
|
|
|||
|
|
## 7. datetime 的精度与范围
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- MySQL 8.0 默认 datetime(0) — 不带小数秒
|
|||
|
|
CREATE TABLE t1 (dt DATETIME);
|
|||
|
|
INSERT INTO t1 VALUES ('2026-05-16 10:30:45.123456');
|
|||
|
|
SELECT dt FROM t1;
|
|||
|
|
-- 输出: 2026-05-16 10:30:45 ← 微秒丢失!
|
|||
|
|
|
|||
|
|
-- ✅ 如果需要保留精度
|
|||
|
|
CREATE TABLE t2 (dt DATETIME(6));
|
|||
|
|
INSERT INTO t2 VALUES ('2026-05-16 10:30:45.123456');
|
|||
|
|
SELECT dt FROM t2;
|
|||
|
|
-- 输出: 2026-05-16 10:30:45.123456
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph LR
|
|||
|
|
subgraph "DATETIME vs TIMESTAMP"
|
|||
|
|
A["DATETIME<br/>存储: 8 bytes<br/>范围: 1000~9999<br/>不受时区影响"] --> B["适合业务时间<br/>如订单创建时间"]
|
|||
|
|
C["TIMESTAMP<br/>存储: 4 bytes<br/>范围: 1970~2038<br/>受 server 时区影响"] --> D["适合审计字段<br/>如最后登录时间"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style B fill:#00D866,color:#fff
|
|||
|
|
style D fill:#FFA500,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] TIMESTAMP 有上限问题
|
|||
|
|
> TIMESTAMP 的最大值是 `2038-01-19 03:14:07`。如果你的系统需要在 2038 年之后使用,务必改用 `DATETIME`,否则会遇到无法插入数据的诡异 bug。这也是为什么很多团队统一只用 DATETIME。
|
|||
|
|
|
|||
|
|
## 8. AUTO_INCREMENT 删除后的断号问题
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE users (
|
|||
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
|||
|
|
name VARCHAR(50)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Charlie'), ('Dave');
|
|||
|
|
DELETE FROM users WHERE id IN (2, 3);
|
|||
|
|
-- 当前 id: 1, 4
|
|||
|
|
INSERT INTO users (name) VALUES ('Eve');
|
|||
|
|
-- Eve 的 id = 5(不会复用 2 或 3)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] 自动递增号的特性
|
|||
|
|
> - AUTO_INCREMENT **不会回收已删除的值**。这是设计使然——保证全局唯一且有序追加写友好。
|
|||
|
|
> - 如果业务需要**紧凑编号**(如内部工号),必须手动处理(如重建表或使用序列表),但**不建议在生产系统这样做**。
|
|||
|
|
> - InnoDB 的 AUTO_INCREMENT 锁在内存中(mysql.auto_increment_helper 表),即使删除所有行也不会归零(除非 TRUNCATE)。
|
|||
|
|
|
|||
|
|
## 9. LIKE 前缀通配符导致全表扫描
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
ALTER TABLE products ADD INDEX idx_name (name);
|
|||
|
|
|
|||
|
|
-- ❌ 前缀通配符 → 索引失效
|
|||
|
|
SELECT * FROM products WHERE name LIKE '%手机%';
|
|||
|
|
-- EXPLAIN: type=ALL → 全表扫描!
|
|||
|
|
|
|||
|
|
-- ✅ 前缀固定 → 可以利用索引(最左前缀匹配)
|
|||
|
|
SELECT * FROM products WHERE name LIKE '手机%';
|
|||
|
|
-- EXPLAIN: type=range → 走索引范围扫描
|
|||
|
|
|
|||
|
|
-- ✅ 替代方案:使用全文索引
|
|||
|
|
ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
|
|||
|
|
SELECT * FROM products WHERE MATCH(name) AGAINST('手机');
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] LIKE 索引利用规则速查
|
|||
|
|
> | 模式 | 是否走索引 | 原因 |
|
|||
|
|
> |------|-----------|------|
|
|||
|
|
> | `'abc%'` | ✅ | 可定位起始位置 |
|
|||
|
|
> | `'%abc'` | ❌ | 未知起始位置 |
|
|||
|
|
> | `'%abc%'` | ❌ | 同上 |
|
|||
|
|
>
|
|||
|
|
> **建议**:对中文搜索需求优先使用 Elasticsearch 等专业搜索引擎,不要依赖 LIKE。
|
|||
|
|
|
|||
|
|
## 10. 可重复读(RR)下的幻读陷阱
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- Session A -- Session B
|
|||
|
|
BEGIN; BEGIN;
|
|||
|
|
INSERT INTO orders ...; ← 成功!
|
|||
|
|
COMMIT; COMMIT;
|
|||
|
|
SELECT COUNT(*) FROM orders; -- 结果包含了 B 插入的行
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant A as Session A
|
|||
|
|
participant DB as InnoDB
|
|||
|
|
participant B as Session B
|
|||
|
|
|
|||
|
|
A->>A: BEGIN
|
|||
|
|
A->>DB: SELECT COUNT(*)
|
|||
|
|
Note over A,DB: 读到 10 行
|
|||
|
|
|
|||
|
|
B->>B: BEGIN
|
|||
|
|
B->>DB: INSERT row_11
|
|||
|
|
B->>DB: COMMIT
|
|||
|
|
|
|||
|
|
A->>DB: SELECT COUNT(*)
|
|||
|
|
Note over A,DB: RR 级别下仍读到 11 行<br/>(Read View 未刷新)
|
|||
|
|
|
|||
|
|
A->>DB: UPDATE ... WHERE id > 100
|
|||
|
|
Note over A,DB: 间隙锁发现新行<br/>→ 阻塞等待 B 的事务释放
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] RR 隔离级别的幻读是「有条件」的
|
|||
|
|
> - **普通 SELECT** 不会看到其他事务的新数据吗?**不一定**。InnoDB 的快照读只读第一次创建 Read View 时的状态,但 DML(UPDATE/DELETE)会刷新视图并感知新行。
|
|||
|
|
> - `SELECT ... FOR SHARE / FOR UPDATE` 会使用 next-key lock 阻止幻读——这才是真正的「不可幻读」。
|
|||
|
|
> - MySQL 的 RR ≠ SQL 标准定义的「完全隔离幻读」。这是最容易产生误解的地方之一。
|
|||
|
|
|
|||
|
|
## 11. 关联笔记
|
|||
|
|
|
|||
|
|
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 EXPLAIN 识别上述问题
|
|||
|
|
- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引失效的典型原因
|
|||
|
|
- [[hhs/MySQL/21-查询改写技巧]] — 各种问题的改写方案
|