03-性能优化

30 阅读10分钟

MySQL 性能优化

MySQL 性能优化和调优实战指南

1. EXPLAIN 执行计划

1.1 EXPLAIN 基本使用

EXPLAIN SELECT * FROM users WHERE age = 25;

-- 输出字段
id            -- 查询序列号
select_type   -- 查询类型
table         -- 表名
partitions    -- 分区信息
type          -- 访问类型(重要)
possible_keys -- 可能使用的索引
key           -- 实际使用的索引(重要)
key_len       -- 索引长度
ref           -- 索引比较的列
rows          -- 扫描行数(重要)
filtered      -- 过滤百分比
Extra         -- 额外信息(重要)

1.2 type 访问类型(从优到差)

-- system: 表中只有一行(系统表)
-- const: 主键或唯一索引查询,最多一行
EXPLAIN SELECT * FROM users WHERE id = 1;

-- eq_ref: 唯一索引扫描,JOIN 时常见
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;

-- ref: 非唯一索引扫描
EXPLAIN SELECT * FROM users WHERE city = 'Beijing';

-- range: 范围扫描(>, <, BETWEEN, IN)
EXPLAIN SELECT * FROM users WHERE age BETWEEN 18 AND 30;

-- index: 索引全扫描
EXPLAIN SELECT id FROM users;

-- ALL: 全表扫描(最差)
EXPLAIN SELECT * FROM users WHERE name LIKE '%alice%';

优化目标:type 至少达到 ref,避免 ALL

1.3 Extra 额外信息

-- Using index: 覆盖索引(好)
EXPLAIN SELECT id, username FROM users WHERE username = 'alice';

-- Using where: 使用 WHERE 过滤(正常)

-- Using temporary: 使用临时表(差)
EXPLAIN SELECT DISTINCT city FROM users;

-- Using filesort: 文件排序(差)
EXPLAIN SELECT * FROM users ORDER BY age;

-- Using index condition: 索引下推(好)
EXPLAIN SELECT * FROM users WHERE age > 18 AND city = 'Beijing';

1.4 优化建议

-- ❌ 全表扫描
EXPLAIN SELECT * FROM users WHERE age + 1 = 26;
-- type: ALL, rows: 100000

-- ✅ 使用索引
EXPLAIN SELECT * FROM users WHERE age = 25;
-- type: ref, rows: 100

2. 索引优化

2.1 索引设计原则

1. 选择性高的列
-- ❌ 选择性低(不适合建索引)
CREATE INDEX idx_gender ON users(gender);  -- 只有 2-3 个值

-- ✅ 选择性高
CREATE INDEX idx_email ON users(email);    -- 几乎唯一
2. 最左前缀原则
-- 联合索引
CREATE INDEX idx_city_age_gender ON users(city, age, gender);

-- ✅ 会使用索引
WHERE city = 'Beijing'
WHERE city = 'Beijing' AND age = 25
WHERE city = 'Beijing' AND age = 25 AND gender = 'male'

-- ❌ 不会使用索引
WHERE age = 25
WHERE gender = 'male'
3. 覆盖索引
-- 创建索引
CREATE INDEX idx_username_email ON users(username, email);

-- ✅ 覆盖索引(无需回表)
SELECT username, email FROM users WHERE username = 'alice';

-- ❌ 需要回表
SELECT * FROM users WHERE username = 'alice';
4. 索引列不要太多
-- ❌ 索引过多
CREATE INDEX idx1 ON users(city);
CREATE INDEX idx2 ON users(age);
CREATE INDEX idx3 ON users(gender);
CREATE INDEX idx4 ON users(status);
-- 维护成本高,写入慢

-- ✅ 合理设计
CREATE INDEX idx_city_age ON users(city, age);  -- 联合索引
5. 前缀索引
-- 对于长字符串,使用前缀索引
CREATE INDEX idx_email ON users(email(20));  -- 只索引前 20 个字符

-- 计算选择性
SELECT 
    COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel10,
    COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS sel20,
    COUNT(DISTINCT LEFT(email, 30)) / COUNT(*) AS sel30
FROM users;

2.2 索引失效场景总结

-- 1. 使用函数
WHERE YEAR(created_at) = 2024  -- ❌

-- 2. 类型转换
WHERE phone = 13800138000      -- ❌ (phone 是 VARCHAR)

-- 3. LIKE 左模糊
WHERE name LIKE '%alice'       -- ❌

-- 4. OR 条件(索引不完整)
WHERE age = 25 OR city = 'Beijing'  -- ❌

-- 5. 不等于
WHERE age != 25                -- ❌

-- 6. IS NOT NULL
WHERE email IS NOT NULL        -- ❌

-- 7. NOT IN、NOT EXISTS
WHERE id NOT IN (1,2,3)        -- ❌

3. SQL 优化

3.1 SELECT 优化

避免 SELECT *
-- ❌ 查询所有字段
SELECT * FROM users WHERE id = 1;

-- ✅ 只查询需要的字段
SELECT id, username, email FROM users WHERE id = 1;

-- 好处
1. 减少网络传输
2. 可能使用覆盖索引
3. 减少内存占用
使用 LIMIT
-- ❌ 查询所有
SELECT * FROM users;

-- ✅ 限制返回数量
SELECT * FROM users LIMIT 100;
避免子查询
-- ❌ 子查询
SELECT * FROM users 
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

-- ✅ JOIN
SELECT DISTINCT u.* 
FROM users u 
JOIN orders o ON u.id = o.user_id 
WHERE o.amount > 1000;

3.2 INSERT 优化

批量插入
-- ❌ 逐条插入
INSERT INTO users (username) VALUES ('user1');
INSERT INTO users (username) VALUES ('user2');
-- ...

-- ✅ 批量插入
INSERT INTO users (username) VALUES 
    ('user1'), ('user2'), ('user3'), ... ('user1000');

-- 建议:每批 1000-5000 条
禁用索引
-- 大量数据导入时
ALTER TABLE users DISABLE KEYS;
-- 导入数据...
ALTER TABLE users ENABLE KEYS;
使用 LOAD DATA
-- 从文件导入(最快)
LOAD DATA INFILE '/path/to/data.csv' 
INTO TABLE users 
FIELDS TERMINATED BY ',' 
LINES TERMINATED BY '\n';

3.3 UPDATE 优化

使用索引
-- ❌ 全表扫描
UPDATE users SET age = 26 WHERE name = 'alice';

-- ✅ 使用索引
UPDATE users SET age = 26 WHERE id = 1;
批量更新
-- ❌ 逐条更新
UPDATE users SET status = 1 WHERE id = 1;
UPDATE users SET status = 1 WHERE id = 2;

-- ✅ 批量更新
UPDATE users SET status = 1 WHERE id IN (1, 2, 3, ...);

3.4 DELETE 优化

分批删除
-- ❌ 一次性删除大量数据
DELETE FROM logs WHERE created_at < '2023-01-01';  -- 可能锁表

-- ✅ 分批删除
DELETE FROM logs WHERE created_at < '2023-01-01' LIMIT 1000;
-- 循环执行直到删除完

4. 慢查询优化

4.1 开启慢查询日志

-- 查看配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 开启慢查询
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 2;  -- 2 秒
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

4.2 分析慢查询

# 使用 mysqldumpslow 分析
mysqldumpslow -s t -t 10 slow.log  # 按时间排序,显示前 10 条

# 参数
-s: 排序方式(t-时间,c-次数,l-锁时间)
-t: 显示条数
-g: 正则过滤

4.3 优化案例

案例 1:未使用索引
-- 问题
SELECT * FROM users WHERE YEAR(created_at) = 2024;
-- 扫描行数:100万,耗时:2秒

-- 优化
SELECT * FROM users 
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- 扫描行数:1万,耗时:0.01秒
案例 2:JOIN 未使用索引
-- 问题
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users.id 有索引,但 orders.user_id 没有

-- 优化
CREATE INDEX idx_user_id ON orders(user_id);
-- 性能提升 100 倍
案例 3:深分页慢
-- 问题
SELECT * FROM users ORDER BY id LIMIT 1000000, 10;
-- 需要扫描 100 万行

-- 优化:使用子查询
SELECT * FROM users 
WHERE id >= (SELECT id FROM users ORDER BY id LIMIT 1000000, 1) 
LIMIT 10;

5. 表结构优化

5.1 选择合适的数据类型

原则
  1. 更小的更好:TINYINT vs INT
  2. 简单更好:INT vs VARCHAR
  3. 避免 NULL:NULL 需要额外存储
-- ❌ 类型过大
CREATE TABLE users (
    id BIGINT,           -- 实际只需 INT
    age INT,             -- 实际只需 TINYINT
    status VARCHAR(20)   -- 实际只需 TINYINT (0/1)
);

-- ✅ 合适的类型
CREATE TABLE users (
    id INT UNSIGNED AUTO_INCREMENT,
    age TINYINT UNSIGNED,
    status TINYINT DEFAULT 1
);

5.2 范式设计

第一范式(1NF):字段不可分
-- ❌ 违反 1NF
CREATE TABLE users (
    id INT,
    name VARCHAR(100),
    address VARCHAR(200)  -- "北京市朝阳区xx路xx号" 可分
);

-- ✅ 符合 1NF
CREATE TABLE users (
    id INT,
    name VARCHAR(100),
    province VARCHAR(50),
    city VARCHAR(50),
    district VARCHAR(50),
    street VARCHAR(100)
);
第二范式(2NF):非主属性完全依赖主键
-- ❌ 违反 2NF
CREATE TABLE order_items (
    order_id INT,
    product_id INT,
    product_name VARCHAR(100),  -- 依赖 product_id,不依赖主键
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- ✅ 符合 2NF
CREATE TABLE order_items (
    order_id INT,
    product_id INT,
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(100)
);
第三范式(3NF):非主属性不依赖其他非主属性
-- ❌ 违反 3NF
CREATE TABLE users (
    id INT PRIMARY KEY,
    city VARCHAR(50),
    province VARCHAR(50)  -- 依赖 city
);

-- ✅ 符合 3NF
CREATE TABLE users (
    id INT PRIMARY KEY,
    city_id INT
);

CREATE TABLE cities (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    province VARCHAR(50)
);

5.3 反范式设计

何时反范式

  • 查询性能要求高
  • 读多写少
  • 可接受数据冗余
-- 订单表存储用户名(反范式)
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT,
    username VARCHAR(50),  -- 冗余字段,避免 JOIN
    amount DECIMAL(10,2)
);

-- 好处:查询订单时无需 JOIN users 表
-- 坏处:用户改名需要同步更新所有订单

6. 分库分表

6.1 垂直拆分

垂直分库
-- 按业务拆分数据库
user_db:      users, user_profiles
order_db:     orders, order_items
product_db:   products, categories
垂直分表
-- 按字段拆分表(冷热分离)
-- 热数据表
CREATE TABLE users (
    id INT PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100),
    status TINYINT
);

-- 冷数据表
CREATE TABLE user_profiles (
    user_id INT PRIMARY KEY,
    bio TEXT,
    avatar BLOB,
    settings JSON
);

6.2 水平拆分

水平分表
-- 按范围分表
users_2023
users_2024

-- 按哈希分表
users_0  -- user_id % 10 = 0
users_1  -- user_id % 10 = 1
...
users_9

-- 按地区分表
users_beijing
users_shanghai
分表策略
// 按 user_id 哈希
$tableIndex = $userId % 10;
$tableName = "users_{$tableIndex}";

// 按时间范围
$year = date('Y');
$tableName = "orders_{$year}";

6.3 分库分表中间件

  • ShardingSphere:Apache 开源
  • MyCat:国内开源
  • Vitess:YouTube 开源

7. 读写分离

7.1 架构

应用层
    ↓
  中间件(代理)
    ↓
┌───────┴────────┐
│                │
Master (写)    Slave (读)

7.2 实现方式

方式 1:应用层
class Database {
    private $master;  // 主库连接
    private $slaves;  // 从库连接池
    
    public function query($sql) {
        if ($this->isWriteSQL($sql)) {
            return $this->master->query($sql);
        } else {
            $slave = $this->getRandomSlave();
            return $slave->query($sql);
        }
    }
    
    private function isWriteSQL($sql) {
        return preg_match('/^\s*(INSERT|UPDATE|DELETE)/i', $sql);
    }
}
方式 2:中间件
  • ProxySQL
  • MySQL Router
  • MaxScale

7.3 读写分离问题

主从延迟
问题:刚写入数据,立即从从库读取,可能读不到

解决方案:
1. 强制主库读
2. 延迟读取
3. 缓存
4. 半同步复制

8. 查询优化技巧

8.1 避免回表

-- ❌ 需要回表
SELECT * FROM users WHERE username = 'alice';

-- ✅ 覆盖索引
SELECT id, username, email FROM users WHERE username = 'alice';
-- 索引:idx_username_email(username, email)

8.2 使用索引下推(ICP)

-- MySQL 5.6+ 支持索引下推

-- 索引:idx_city_age
SELECT * FROM users WHERE city = 'Beijing' AND age > 18;

-- 没有 ICP:
-- 1. 使用 city 索引查找所有 Beijing 的记录
-- 2. 回表获取完整行
-- 3. 过滤 age > 18

-- 有 ICP:
-- 1. 使用 city 索引查找
-- 2. 在索引中过滤 age > 18(减少回表)
-- 3. 回表获取完整行

8.3 优化 JOIN

小表驱动大表
-- ❌ 大表驱动小表
SELECT * FROM users u 
JOIN orders o ON u.id = o.user_id;
-- users: 100 万,orders: 1000

-- ✅ 小表驱动大表
SELECT * FROM orders o 
JOIN users u ON o.user_id = u.id;
确保 JOIN 字段有索引
-- ✅ 两个表的 JOIN 字段都要有索引
CREATE INDEX idx_user_id ON orders(user_id);
-- users.id 已有主键索引

8.4 优化 GROUP BY

-- ❌ 未使用索引
SELECT city, COUNT(*) FROM users GROUP BY city;

-- ✅ 创建索引
CREATE INDEX idx_city ON users(city);
SELECT city, COUNT(*) FROM users GROUP BY city;

-- ✅ 使用 ORDER BY NULL 禁用排序
SELECT city, COUNT(*) FROM users GROUP BY city ORDER BY NULL;

8.5 优化 ORDER BY

-- ❌ 未使用索引(Using filesort)
SELECT * FROM users ORDER BY age;

-- ✅ 使用索引
CREATE INDEX idx_age ON users(age);
SELECT * FROM users ORDER BY age;

-- ✅ 覆盖索引
CREATE INDEX idx_age_username ON users(age, username);
SELECT id, age, username FROM users ORDER BY age;

8.6 优化分页

-- ❌ 深分页慢
SELECT * FROM users ORDER BY id LIMIT 1000000, 10;
-- 需要扫描 100 万行

-- ✅ 使用子查询
SELECT * FROM users 
WHERE id >= (SELECT id FROM users ORDER BY id LIMIT 1000000, 1) 
LIMIT 10;

-- ✅ 记录上次查询的最大 ID
SELECT * FROM users WHERE id > 1000000 ORDER BY id LIMIT 10;

9. 配置优化

9.1 连接配置

# my.cnf

# 最大连接数
max_connections = 1000

# 连接超时
wait_timeout = 28800
interactive_timeout = 28800

9.2 缓存配置

# InnoDB 缓冲池(最重要)
innodb_buffer_pool_size = 8G  # 建议设置为物理内存的 50-70%

# 每个缓冲池实例大小
innodb_buffer_pool_instances = 8

# 查询缓存(MySQL 8.0 已移除)
# query_cache_size = 0

9.3 日志配置

# redo log
innodb_log_file_size = 1G
innodb_log_files_in_group = 2
innodb_flush_log_at_trx_commit = 1  # 1-最安全,2-性能好

# binlog
log-bin = mysql-bin
binlog_format = ROW
max_binlog_size = 1G
expire_logs_days = 7

9.4 InnoDB 配置

# 每表一个文件
innodb_file_per_table = ON

# IO 线程数
innodb_read_io_threads = 4
innodb_write_io_threads = 4

# 刷新邻接页
innodb_flush_neighbors = 0  # SSD 设为 0

# 自适应哈希索引
innodb_adaptive_hash_index = ON

10. 监控和诊断

10.1 查看状态

-- 查看连接数
SHOW STATUS LIKE 'Threads_connected';
SHOW PROCESSLIST;

-- 查看 InnoDB 状态
SHOW ENGINE INNODB STATUS;

-- 查看表状态
SHOW TABLE STATUS LIKE 'users';

-- 查看索引使用情况
SELECT * FROM sys.schema_unused_indexes;

10.2 分析表

-- 分析表(更新索引统计信息)
ANALYZE TABLE users;

-- 优化表(重组表数据)
OPTIMIZE TABLE users;

-- 检查表
CHECK TABLE users;

-- 修复表
REPAIR TABLE users;

11. 总结

MySQL 性能优化核心:

  1. EXPLAIN 分析:type、key、rows、Extra
  2. 索引优化:选择性、最左前缀、覆盖索引
  3. SQL 优化:避免全表扫描、减少回表
  4. 慢查询优化:开启日志、分析、优化
  5. 表结构优化:合适的数据类型、范式设计
  6. 分库分表:垂直拆分、水平拆分
  7. 读写分离:主写从读
  8. 配置优化:缓冲池、连接数、日志

优化步骤

  1. 使用 EXPLAIN 分析
  2. 查看慢查询日志
  3. 添加索引或优化 SQL
  4. 调整配置参数
  5. 考虑分库分表

性能指标

  • type: 至少 ref
  • rows: 越小越好
  • Extra: 避免 Using temporary、Using filesort

下一步:实战案例和题!