Engineering Database
数据库性能优化实战:从索引设计到查询调优
为什么你的应用越来越慢?80% 的性能问题来自数据库。分享我 6 年来在 PostgreSQL 和 MySQL 上的优化经验,包括索引策略、查询优化和架构设计模式。
Ioodu · ·
Updated: Feb 12, 2026 · 22 min read
#Database
#PostgreSQL
#MySQL
#Performance
#Optimization
#Indexing
那个拖垮系统的查询
2022 年,我们的用户dashboard页面加载时间从 2 秒变成了 15 秒。
排查发现罪魁祸首是一个看似简单的查询:
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
问题?orders 表有 5000 万行,这个查询全表扫描,每次执行 12 秒。
加一个索引后:查询时间降到 5 毫秒。
这次经历让我明白:数据库性能优化是后端工程师的必修课。
数据库性能的本质
性能瓶颈在哪里
应用层 ──10%──→ 网络传输 ──5%──→ 数据库连接 ──85%──→ SQL 执行
↓
┌─────────────┐
│ 解析 SQL │
│ 生成执行计划 │
│ 访问数据 │ ← 瓶颈在这里
│ 返回结果 │
└─────────────┘
80/20 法则:80% 的数据库性能问题来自 20% 的查询。
性能指标
| 指标 | 健康值 | 警告值 | 危险值 |
|---|---|---|---|
| 查询平均耗时 | < 10ms | 10-100ms | > 100ms |
| 慢查询比例 | < 1% | 1-5% | > 5% |
| 索引命中率 | > 95% | 90-95% | < 90% |
| 连接池使用率 | < 70% | 70-90% | > 90% |
| CPU 使用率 | < 50% | 50-80% | > 80% |
索引:数据库的性能加速器
索引的工作原理
类比:索引就像书的目录。
没有目录:你需要翻完整本书(全表扫描)。 有目录:直接跳到对应页(索引查找)。
B-Tree 索引结构(PostgreSQL/MySQL 默认):
[50]
/ \
[25] [75]
/ \ / \
[10] [30] [60] [90]
... ... ... ...
查找 30:从根节点开始,3 次磁盘 I/O 即可定位
索引类型选择
| 索引类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| B-Tree | 等值查询、范围查询 | 通用,平衡 | 大数据量维护成本高 |
| Hash | 精确匹配 | O(1) 查找 | 不支持范围查询 |
| GIN | 数组、JSON、全文搜索 | 多值索引 | 写入慢,占用空间大 |
| GiST | 地理数据、范围类型 | 灵活 | 查询效率取决于数据分布 |
| BRIN | 大数据量时序数据 | 体积小,维护快 | 只适合有序数据 |
索引设计原则
原则 1:选择性高的列优先
选择性 = 不同值数量 / 总行数
-- 高选择性(好)
email: 1,000,000 / 1,000,000 = 1.0
user_id: 1,000,000 / 1,000,000 = 1.0
-- 低选择性(差)
status: 5 / 1,000,000 = 0.000005
gender: 2 / 1,000,000 = 0.000002
反例:在 gender 字段上建索引(只有男/女),查询仍然要扫描一半数据。
原则 2:最左前缀匹配
-- 联合索引 (a, b, c)
CREATE INDEX idx_abc ON orders(a, b, c);
-- ✅ 有效使用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND c = 3 -- 部分使用
-- ❌ 不使用索引
WHERE b = 2
WHERE c = 3
WHERE b = 2 AND c = 3
原则 3:覆盖索引
覆盖索引:查询所需的所有列都在索引中,无需回表。
-- 索引包含 user_id 和 created_at
CREATE INDEX idx_user_created ON orders(user_id, created_at);
-- ✅ 覆盖索引查询
SELECT user_id, created_at FROM orders WHERE user_id = 123;
-- 只需访问索引,不需要访问表数据
-- ❌ 非覆盖索引查询
SELECT * FROM orders WHERE user_id = 123;
-- 找到索引后,还要回表取其他列
覆盖索引的性能提升:
- 普通索引:需要 2 次磁盘 I/O(索引 + 表)
- 覆盖索引:只需 1 次磁盘 I/O(仅索引)
原则 4:避免过多索引
索引的成本:
- 写入变慢(每次 INSERT/UPDATE/DELETE 都要维护索引)
- 占用磁盘空间
- 缓存效率降低
建议:
- 单表索引数量 < 5 个
- 频繁更新的表,索引数量 < 3 个
- 定期清理无用索引
实战:订单表索引设计
-- 订单表结构
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- pending, paid, shipped, delivered, cancelled
amount DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 业务查询分析:
-- Q1: 查询用户的订单列表(高频)
-- Q2: 查询某状态的订单(中频)
-- Q3: 查询某时间段的订单(低频,后台用)
-- 索引设计
-- 1. 主键索引(自动创建)
-- PRIMARY KEY (id)
-- 2. 用户订单查询(高频,覆盖索引)
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC)
INCLUDE (status, amount);
-- 3. 状态查询(中频)
CREATE INDEX idx_orders_status_created
ON orders(status, created_at DESC);
-- 4. 时间范围查询(低频)
CREATE INDEX idx_orders_created
ON orders(created_at);
查询优化:让 SQL 跑得更快
EXPLAIN 分析执行计划
PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
关键指标:
| 指标 | 含义 | 优化目标 |
|---|---|---|
| cost | 预估成本 | 越低越好 |
| actual time | 实际执行时间 | < 10ms 为佳 |
| rows | 扫描行数 | 接近返回行数 |
| Shared Hits | 缓存命中 | 越高越好 |
| Shared Reads | 磁盘读取 | 越低越好 |
常见查询优化技巧
1. 避免 SELECT *
-- ❌ 错误:查询所有列
SELECT * FROM users WHERE id = 123;
-- 需要回表取所有列的数据
-- ✅ 正确:只查询需要的列
SELECT id, name, email FROM users WHERE id = 123;
-- 如果索引覆盖这些列,无需回表
2. 避免在索引列上使用函数
-- ❌ 错误:函数导致索引失效
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- ✅ 正确:范围查询使用索引
SELECT * FROM orders
WHERE created_at >= '2024-01-01'
AND created_at < '2024-01-02';
3. 避免隐式类型转换
-- ❌ 错误:类型不匹配
-- phone 是 VARCHAR,传入 INT
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 正确:类型匹配
SELECT * FROM users WHERE phone = '13800138000';
4. 大分页优化
-- ❌ 错误:深度分页,越往后越慢
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
-- 需要扫描 100020 行,丢弃 100000 行
-- ✅ 正确:基于游标/书签的分页
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:00:00'
ORDER BY created_at DESC
LIMIT 20;
-- 使用索引,只扫描 20 行
-- ✅ 更好的方案:记住上次的位置
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:00:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
5. OR 条件优化
-- ❌ 错误:OR 导致索引失效
SELECT * FROM orders
WHERE user_id = 123 OR status = 'pending';
-- ✅ 正确:拆分为 UNION
SELECT * FROM orders WHERE user_id = 123
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND user_id != 123;
-- 或者使用 IN(如果适用)
SELECT * FROM orders WHERE user_id IN (123, 456);
6. JOIN 优化
-- ❌ 错误:大表驱动小表
SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'active';
-- orders 表很大,users 表较小
-- ✅ 正确:小表驱动大表,先过滤
SELECT * FROM (
SELECT * FROM users WHERE status = 'active'
) u
JOIN orders o ON o.user_id = u.id;
-- 确保 JOIN 列有索引
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_users_status ON users(status);
7. 子查询优化
-- ❌ 错误:相关子查询,每行都执行
SELECT * FROM users u
WHERE (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 10;
-- ✅ 正确:使用 JOIN 替代
SELECT u.* FROM users u
JOIN (
SELECT user_id, COUNT(*) as cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 10
) o ON u.id = o.user_id;
-- 或者使用 EXISTS(有时更高效)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
LIMIT 11
);
批量操作优化
批量插入
-- ❌ 错误:单条插入,N 次网络往返
INSERT INTO orders (user_id, amount) VALUES (1, 100);
INSERT INTO orders (user_id, amount) VALUES (2, 200);
-- ... 1000 次
-- ✅ 正确:批量插入
INSERT INTO orders (user_id, amount) VALUES
(1, 100), (2, 200), (3, 300), ...;
-- 或者用 COPY(PostgreSQL)
COPY orders (user_id, amount) FROM '/path/to/data.csv' CSV;
批量更新
-- ❌ 错误:逐条更新
UPDATE orders SET status = 'shipped' WHERE id = 1;
UPDATE orders SET status = 'shipped' WHERE id = 2;
-- ✅ 正确:批量更新
UPDATE orders
SET status = 'shipped'
WHERE id IN (1, 2, 3, ...);
-- 或者使用 CASE
UPDATE orders
SET status = CASE id
WHEN 1 THEN 'shipped'
WHEN 2 THEN 'delivered'
-- ...
END
WHERE id IN (1, 2, ...);
表设计优化
数据类型选择
| 数据类型 | 推荐选择 | 避免使用 |
|---|---|---|
| 整数 | INT/BIGINT | 根据范围选择,不要过度使用 BIGINT |
| 字符串 | VARCHAR(n) | TEXT(除非真的需要无限长度) |
| 日期时间 | TIMESTAMP | DATETIME(MySQL),使用 TIMESTAMP |
| JSON | JSONB (PG) / JSON (MySQL 8+) | TEXT 存储 JSON |
| 布尔 | BOOLEAN | INT (0/1),TINYINT |
| 枚举 | VARCHAR + CHECK 约束 | ENUM 类型(不利于扩展) |
规范化 vs 反规范化
规范化(3NF):
- 优点:数据一致性,无冗余
- 缺点:JOIN 多,查询复杂
反规范化:
- 优点:查询快,无需 JOIN
- 缺点:数据冗余,维护复杂
实践建议:
- 写入频繁、查询简单:规范化
- 读取频繁、查询复杂:适度反规范化
反规范化示例:
-- 原始设计(规范化)
users: id, name
orders: id, user_id, amount
-- 查询用户总消费需要 JOIN + GROUP BY
-- 反规范化:users 表增加冗余字段
users: id, name, total_spent
-- 查询直接读取,但需要在订单变更时更新
-- 触发器自动更新(PostgreSQL)
CREATE OR REPLACE FUNCTION update_user_total_spent()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE users SET total_spent = total_spent + NEW.amount WHERE id = NEW.user_id;
ELSIF TG_OP = 'UPDATE' THEN
UPDATE users SET total_spent = total_spent - OLD.amount + NEW.amount WHERE id = NEW.user_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE users SET total_spent = total_spent - OLD.amount WHERE id = OLD.user_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_update_total
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION update_user_total_spent();
分区表
适用场景:
- 单表数据量 > 1000 万行
- 数据有明显的时间特征(日志、订单)
- 查询通常只涉及部分数据
PostgreSQL 分区示例:
-- 按时间分区(按月)
CREATE TABLE orders (
id BIGSERIAL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 创建分区
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- 查询优化:PostgreSQL 自动只扫描相关分区
SELECT * FROM orders WHERE created_at >= '2024-01-15';
-- 只扫描 orders_2024_01 分区
架构设计模式
读写分离
┌─────────┐ ┌─────────────┐ ┌─────────┐
│ 应用 │────→│ 主库(写) │ │ 从库(读)│
└─────────┘ └─────────────┘ └─────────┘
│ │
└──────────┬───────────┘
│
主从复制
实现:
- PostgreSQL:流复制
- MySQL:主从复制
- 或使用中间件:ProxySQL、MaxScale
应用层实现(Node.js 示例):
// 读操作走从库
const user = await readPool.query('SELECT * FROM users WHERE id = ?', [id]);
// 写操作走主库
await writePool.query('UPDATE users SET name = ? WHERE id = ?', [name, id]);
// 写后读问题:延迟可能导致读不到最新数据
// 解决方案:强制走主库
const user = await writePool.query('SELECT * FROM users WHERE id = ?', [id]);
缓存策略
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 应用 │────→│ 缓存 │────→│ 数据库 │
└─────────┘ │ (Redis) │ └─────────┘
└─────────┘
缓存模式:
| 模式 | 适用场景 | 风险 |
|---|---|---|
| Cache-Aside | 读多写少 | 缓存击穿、穿透 |
| Write-Through | 强一致性要求 | 写性能下降 |
| Write-Behind | 高写入吞吐量 | 数据丢失风险 |
Cache-Aside 实现:
async function getUser(id) {
// 1. 查缓存
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// 2. 缓存未命中,查数据库
const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
if (!user) return null;
// 3. 写入缓存(TTL 5 分钟)
await redis.setex(`user:${id}`, 300, JSON.stringify(user));
return user;
}
async function updateUser(id, data) {
// 1. 更新数据库
await db.query('UPDATE users SET ? WHERE id = ?', [data, id]);
// 2. 删除缓存(不是更新,避免并发问题)
await redis.del(`user:${id}`);
}
缓存问题处理:
// 缓存穿透:查询不存在的数据
// 解决:缓存空值
const user = await db.query('...');
if (!user) {
await redis.setex(`user:${id}:null`, 60, 'null'); // 短期缓存空值
return null;
}
// 缓存击穿:热点 key 过期,瞬间大量请求
// 解决:互斥锁
async function getHotDataWithLock(key) {
const cached = await redis.get(key);
if (cached) return cached;
// 获取锁
const locked = await redis.set(key + ':lock', '1', 'EX', 10, 'NX');
if (!locked) {
// 没拿到锁,等待后重试
await sleep(100);
return getHotDataWithLock(key);
}
try {
const data = await db.query('...');
await redis.setex(key, 300, JSON.stringify(data));
return data;
} finally {
await redis.del(key + ':lock');
}
}
// 缓存雪崩:大量 key 同时过期
// 解决:随机过期时间
const ttl = 300 + Math.random() * 60; // 300-360 秒
await redis.setex(key, ttl, data);
连接池管理
// PostgreSQL 连接池配置
const pool = new Pool({
host: 'localhost',
port: 5432,
database: 'mydb',
user: 'user',
password: 'pass',
// 连接池配置
max: 20, // 最大连接数
min: 5, // 最小连接数
acquire: 30000, // 获取连接超时
idle: 10000, // 连接空闲超时
evict: 1000 // 检查空闲连接间隔
});
// 使用连接池
const client = await pool.connect();
try {
const result = await client.query('SELECT * FROM users');
return result.rows;
} finally {
client.release(); // 释放回连接池
}
连接池大小建议:
- CPU 核心数 × 2 + 有效磁盘数
- Web 应用:10-50
- 批处理:5-10
- 监控活跃连接数和等待队列
监控与诊断
慢查询日志
PostgreSQL 配置:
# postgresql.conf
log_min_duration_statement = 100 # 记录超过 100ms 的查询
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
log_statement = 'none' # 不记录所有查询,只记录慢查询
MySQL 配置:
# my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1 # 100ms
pg_stat_statements(PostgreSQL)
-- 启用扩展
CREATE EXTENSION pg_stat_statements;
-- 查询最耗时的 SQL
SELECT
query,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 10;
-- 重置统计
SELECT pg_stat_statements_reset();
性能监控工具
| 工具 | 功能 | 推荐场景 |
|---|---|---|
| pgAdmin | PostgreSQL 管理 | 开发调试 |
| MySQL Workbench | MySQL 管理 | 开发调试 |
| pgHero | PostgreSQL 性能分析 | 生产监控 |
| Percona Monitoring | MySQL 监控 | 生产监控 |
| Datadog | 综合监控 | 企业级 |
| Prometheus + Grafana | 开源监控 | 自建监控 |
总结:数据库优化检查清单
设计阶段
- 选择合适的数据类型
- 设计合理的表结构(规范化/反规范化)
- 规划分区策略(大数据量)
- 设计索引策略
- 规划缓存策略
开发阶段
- 使用 EXPLAIN 分析查询
- 避免 SELECT *
- 使用覆盖索引
- 优化深度分页
- 批量操作替代单条
- 使用连接池
部署阶段
- 配置连接池大小
- 启用慢查询日志
- 配置监控告警
- 设置备份策略
运维阶段
- 定期分析慢查询
- 定期清理无用索引
- 监控磁盘空间增长
- 定期分析表(ANALYZE)
- 规划扩容策略
参考资源
书籍:
- 《High Performance MySQL》Baron Schwartz
- 《PostgreSQL 9.0 High Performance》Gregory Smith
- 《SQL Performance Explained》Markus Winand
官方文档:
在线工具:
- EXPLAIN 可视化:explain.depesz.com
- 索引建议:pgmustard.com
你遇到过哪些数据库性能问题?有什么优化技巧?欢迎分享。
本文的优化技巧来自真实生产环境,帮助过多个系统从「卡顿」到「丝滑」。