数据库实战指南:从索引优化到分布式架构
前言:数据库是后端的核心
数据库往往是系统最先遇到的瓶颈。从单机到分布式,从索引到事务,每个环节都有坑。
数据库问题的典型演进:
- 慢查询 → 索引优化
- 连接数飙升 → 连接池调优
- 读写争抢 → 主从复制
- 单表数据量大 → 分库分表
- 跨分片事务 → 分布式事务
一、索引优化
1.1 B+Tree 索引原理
MySQL InnoDB 默认使用 B+Tree:
特点:
- 平衡树,所有叶子节点深度相同
- 数据只存储在叶子节点
- 适合范围查询
1.2 索引类型
-- 普通索引
CREATE INDEX idx_users_email ON users(email);
-- 唯一索引
CREATE UNIQUE INDEX idx_users_username ON users(username);
-- 复合索引
CREATE INDEX idx_users_name_age ON users(name, age);
-- 全文索引
CREATE FULLTEXT INDEX idx_posts_content ON posts(content);
1.3 复合索引的最左前缀原则
CREATE INDEX idx_users_name_age_gender ON users(name, age, gender);
-- 能完整使用索引
WHERE name = 'Alice' AND age = 30 AND gender = 'F'
-- 能使用部分索引
WHERE name = 'Alice' AND age = 30
WHERE name = 'Alice'
-- 不能使用索引(不满足最左前缀)
WHERE age = 30
WHERE gender = 'F'
1.4 索引失效的常见场景
-- 1. LIKE 前导通配符
WHERE name LIKE '%Alice%' -- 失效
WHERE name LIKE 'Alice%' -- 有效
-- 2. 索引列上计算
WHERE YEAR(created_at) = 2023 -- 失效
WHERE created_at BETWEEN '2023-01-01' AND '2023-12-31' -- 有效
-- 3. 隐式类型转换
WHERE phone_number = 1234567890 -- 失效(字段是字符串)
WHERE phone_number = '1234567890' -- 有效
-- 4. OR 条件(部分字段无索引)
WHERE name = 'Alice' OR age = 30 -- 全表扫描
1.5 选择性评估
-- 选择性 = COUNT(DISTINCT col) / COUNT(*)
-- 接近 1 适合建索引,接近 0 不适合
SELECT COUNT(DISTINCT email) / COUNT(*) FROM users; -- ~1.0 适合
SELECT COUNT(DISTINCT gender) / COUNT(*) FROM users; -- ~0.0001 不适合
1.6 索引的坑
坑一:索引不是越多越好
为所有字段都建索引会导致写操作变慢。只为必要的字段建索引。
坑二:复合索引顺序不对
-- 查询 1:WHERE name = ? AND age = ?
-- 查询 2:WHERE name = ?
-- 正确:name 在前
CREATE INDEX idx_users_name_age ON users(name, age);
-- 错误:age 在前,查询 2 用不上
CREATE INDEX idx_users_age_name ON users(age, name);
坑三:索引列过多
复合索引列太多导致索引过大。控制索引列数,不超过 3 列。
坑四:索引碎片
# 定期重建索引
0 0 1 * * mysql -u root -p -e "OPTIMIZE TABLE users;"
1.7 索引检查清单
建索引前:
- 查询频率高吗?
- 选择性高吗?
- 写操作频率低吗?
- 数据量大吗?
建索引后:
- 验证索引被使用(
EXPLAIN) - 检查查询性能
- 监控索引大小
- 定期维护索引
二、连接池调优
2.1 为什么需要连接池
数据库连接建立开销大:TCP 三次握手、MySQL 认证、权限校验、会话初始化,几十毫秒起步。
2.2 HikariCP 配置
spring:
datasource:
hikari:
maximum-pool-size: 32 # 8核机器推荐
minimum-idle: 32 # 保持所有连接常驻
connection-timeout: 10000 # 10秒获取超时
idle-timeout: 300000 # 5分钟空闲超时
max-lifetime: 1500000 # 25分钟(< MySQL wait_timeout)
leak-detection-threshold: 60000 # 60秒泄漏检测
validation-timeout: 3000
connection-test-query: SELECT 1
pool-name: BusinessHikariCP
关键参数说明:
| 参数 | 默认值 | 调优建议 |
|---|---|---|
maximum-pool-size | 10 | CPU 核心数 * 2 + 有效磁盘数 |
minimum-idle | 10 | 与最大连接数相同 |
max-lifetime | 30分钟 | 比 wait_timeout 稍短 |
leak-detection-threshold | 0 | 生产开启,60秒 |
2.3 连接池大小公式
PostgreSQL 公式: pool_size = ((核心数 * 2) + 有效磁盘数)
经验值:
- 4 核机器:16-20 连接
- 8 核机器:32 连接
- 16 核机器:50-60 连接
连接数不是越大越好。 超过 40 后性能反而下降(上下文切换开销)。
2.4 连接池对比
| 连接池 | 平均响应 | CPU | 内存 | 特点 |
|---|---|---|---|---|
| HikariCP | 12ms | 低 | 最小 | 轻量快速(推荐) |
| Druid | 15ms | 中 | 较大 | 监控丰富 |
| DBCP2 | 18ms | 中 | 中 | 功能全面 |
| C3P0 | 25ms | 高 | 最大 | 老牌,性能弱 |
2.5 连接池的坑
坑一:连接泄漏
// 错误:return 时连接没关闭
public void processData() {
Connection conn = dataSource.getConnection();
try {
if (someCondition) return; // 泄漏!
} finally {
// 忘了 conn.close()
}
}
// 正确:try-with-resources
try (Connection conn = dataSource.getConnection()) {
// ...
}
坑二:长事务占用连接
// 错误:事务里有 HTTP 调用
@Transactional
public void longTask() {
List<Data> data = repo.findAll();
for (Data item : data) {
externalApiCall(item); // 几秒一个,连接一直占着
}
}
// 正确:拆分事务
public void longTask() {
List<Data> data = repo.findAll();
for (Data item : data) {
externalApiCall(item);
repo.updateStatus(item.getId()); // 每次单独事务
}
}
坑三:连接池与数据库参数不匹配
-- MySQL 默认 wait_timeout = 28800 (8小时)
SHOW VARIABLES LIKE 'wait_timeout';
连接池 max-lifetime 必须比 wait_timeout 小,否则连接失效。
坑四:池级别硬编码隔离级别
不要在连接池全局设隔离级别,只在需要的 @Transactional 上覆盖。
2.6 连接池监控
@Configuration
public class MetricsConfig {
@Bean
public MeterRegistryCustomizer<HikariDataSource> metrics() {
return (dataSource, registry) -> {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
registry.gauge("hikari.active", pool, HikariPoolMXBean::getActiveConnections);
registry.gauge("hikari.idle", pool, HikariPoolMXBean::getIdleConnections);
registry.gauge("hikari.threads.awaiting", pool, HikariPoolMXBean::getThreadsAwaitingConnection);
};
}
}
告警规则:
- alert: HikariPoolNearlyFull
expr: hikari_active / hikari_total > 0.8
for: 5m
- alert: HikariConnectionWait
expr: hikari_threads_awaiting > 5
for: 2m
三、事务隔离级别
3.1 三种读异常
| 异常 | 人话 | 复现场景 |
|---|---|---|
| 脏读 | 读到别人没提交的改动 | MySQL InnoDB 默认就防住 |
| 不可重复读 | 同事务两次读同一行值变了 | 对账脚本两次读结果不同 |
| 幻读 | 同条件两次查行数变了 | 统计订单时新单插入 |
3.2 MySQL vs PostgreSQL 默认
-- MySQL InnoDB 默认 RR
SELECT @@transaction_isolation; -- REPEATABLE-READ
-- PostgreSQL 默认 RC
SHOW transaction_isolation; -- read committed
关键差异: MySQL RR 用 next-key gap lock 实际把幻读也挡掉了(代价是锁范围大);PostgreSQL RC 不保证同事务内多次读一致。
3.3 复现不可重复读
-- 会话 1
SET SESSION transaction_isolation = 'READ-COMMITTED';
START TRANSACTION;
SELECT qty FROM stock WHERE id = 1001; -- 100
-- 会话 2
UPDATE stock SET qty = 97 WHERE id = 1001;
COMMIT;
-- 回到会话 1
SELECT qty FROM stock WHERE id = 1001; -- 97,不一致
COMMIT;
3.4 按场景选隔离级别
| 场景 | 推荐 | 原因 |
|---|---|---|
| 普通 CRUD | 默认 | 不折腾 |
| 扣库存、扣余额 | RC + FOR UPDATE | 行锁语义清晰 |
| 对账、报表 | RR 只读短事务 | 同一快照数字一致 |
| 强一致迁移 | Serializable | 极少用,只在维护窗口 |
3.5 扣库存的正确姿势
@Transactional(isolation = Isolation.READ_COMMITTED)
@Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3)
public void deductStock(Long productId, int qty) {
Stock row = stockRepo.findByIdForUpdate(productId); // SELECT ... FOR UPDATE
if (row.getAvailable() < qty) {
throw new InsufficientStockException();
}
row.setAvailable(row.getAvailable() - qty);
}
关键点:
- RC +
FOR UPDATE比 RR + gap lock 死锁少 - 扣库存必加死锁重试
- 行锁语义比"升 Serializable"清楚
3.6 事务的坑
坑一:RR 不是银弹,死锁会多
热点 SKU 上 FOR UPDATE 死锁明显增加。业务侧必须捕获死锁重试。
坑二:长事务 + RR = 锁很久
-- 错误:事务里做 Excel 生成
START TRANSACTION;
SELECT ...;
-- 生成 Excel(几十秒)
COMMIT;
-- 正确:事务里只做数据库操作
START TRANSACTION;
SELECT ...;
COMMIT;
-- 事务外做 Excel
坑三:隔离级别替代不了业务约束
“先查余额再扣款"如果两次读之间没锁,照样超扣。该上 FOR UPDATE 或乐观锁就上。
坑四:跨库迁移要重测
MySQL RR 和 PostgreSQL RC 的"感觉"差很多。迁移后默认隔离级别变了,第一批报表可能出汇总偏差。
四、主从复制
4.1 为什么要主从
- 读性能:读写分离,写主读从
- 高可用:主挂切从
- 备份:从库做备份不影响主库
4.2 MySQL 主从复制原理
4.3 主库配置
# my.cnf 主库
[mysqld]
server-id = 1
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
gtid_mode = ON
enforce_gtid_consistency = ON
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1
-- 创建复制用户
CREATE USER 'repl'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
-- 查看主库状态
SHOW MASTER STATUS;
4.4 从库配置
# my.cnf 从库
[mysqld]
server-id = 2
relay_log = relay-bin
read_only = ON
super_read_only = ON
relay_log_purge = OFF
-- GTID 方式连接主库(推荐)
CHANGE MASTER TO
MASTER_HOST='master-ip',
MASTER_USER='repl',
MASTER_PASSWORD='password',
MASTER_AUTO_POSITION=1;
START SLAVE;
-- 查看复制状态
SHOW SLAVE STATUS\G
关键指标:
Slave_IO_Running: YesSlave_SQL_Running: YesSeconds_Behind_Master: 0
4.5 主从复制的坑
坑一:主从延迟
-- 从库可能落后主库几秒到几分钟
-- 对实时性要求高的读,强制走主库
坑二:非确定性函数
-- NOW()、UUID()、RAND() 等在主从上结果不同
-- 使用 binlog_format = ROW 避免
坑三:大事务导致延迟
单个大事务会让从库 SQL 线程卡住。拆分大事务。
五、分库分表
5.1 什么时候需要分库分表
- 单表数据 > 1000 万行
- 单库 CPU 持续 > 80%
- 主从延迟分钟级
- 索引文件 > 100GB
重要:先确认问题真的需要分库分表解决。 加索引、优化 SQL、调硬件往往更直接。
5.2 分片策略
def get_db_index(user_id):
return user_id % 4
def get_table_index(user_id):
return (user_id // 4) % 8
常见策略:
- 哈希分片:
user_id % N(数据均匀,扩容麻烦) - 范围分片:按时间或 ID 范围(热点明显)
- 一致性哈希:扩容时数据迁移少
5.3 数据迁移:双写 + 校验 + 灰度
def migrate_batch(start_id, batch_size, source, targets):
"""批量迁移,按 ID 范围分批"""
cursor = source.cursor()
cursor.execute("SELECT * FROM orders WHERE user_id >= %s AND user_id < %s",
(start_id, start_id + batch_size))
for row in cursor.fetchall():
db_idx = get_db_index(row['user_id'])
table_idx = get_table_index(row['user_id'])
target_cursor = targets[db_idx].cursor()
target_cursor.execute(
f"INSERT INTO orders_{table_idx} (...) VALUES (...) "
f"ON DUPLICATE KEY UPDATE ...",
(...)
)
targets[db_idx].commit()
5.4 数据一致性
跨分片事务的两种方案:
方案一:业务补偿(推荐)
def create_order_with_compensation(user_id, items):
# 1. 创建订单(PROCESSING 状态)
order_id = create_order(user_id, items, status='PROCESSING')
try:
# 2. 异步扣库存
deduct_inventory(items)
# 3. 成功,更新状态
update_order_status(order_id, 'COMPLETED')
except Exception:
# 异常时订单保持 PROCESSING,等补偿任务处理
pass
def compensation_task():
"""定时查找处理中超时的订单"""
stale_orders = find_stale_orders(minutes=5)
for order in stale_orders:
compensate_order(order)
方案二:分布式事务框架(Seata 等)
性能差、复杂度高,量大了再上。
5.5 灰度切换与回滚
class MigrationRouter:
def __init__(self):
self.gray_ratio = 0.0
self.gray_user_ids = set()
def should_use_new_system(self, user_id):
if user_id in self.gray_user_ids:
return True
user_hash = int(hashlib.md5(str(user_id).encode()).hexdigest()[:8], 16)
return (user_hash % 100) < (self.gray_ratio * 100)
# 灰度推进
router.gray_ratio = 0.01 # 1%
router.gray_ratio = 0.1 # 10%
router.gray_ratio = 0.5 # 50%
router.gray_ratio = 1.0 # 100%
# 出问题立即回滚
router.gray_ratio = 0.0
5.6 分库分表的坑
坑一:跨分片 JOIN
分片后无法直接 JOIN。解决:
- 应用层组装
- 冗余字段
- 广播表(小表每个分片都存一份)
坑二:分布式 ID
# 雪花算法生成全局唯一 ID
def snowflake_id(worker_id, sequence):
timestamp = int(time.time() * 1000) - EPOCH
return (timestamp << 22) | (worker_id << 12) | sequence
坑三:路由改造成本
原代码 SQL 大多没带分片键。解决:
- 写个轻量 SQL 解析器
- 用户信息本地缓存
坑四:报表查询打到单分片
某个报表没适配分片逻辑,全量查询都打到同一分片导致 CPU 飙升。解决:所有查询都过路由层校验。
六、性能优化实战
6.1 慢查询排查
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
-- 分析慢查询
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- 关键字段
-- type: const > eq_ref > ref > range > index > ALL
-- key: 实际使用的索引
-- rows: 扫描行数
-- Extra: Using index(覆盖索引)、Using temporary(临时表)、Using filesort(文件排序)
6.2 EXPLAIN 解读
| type | 含义 | 性能 |
|---|---|---|
const | 主键或唯一索引 | 最好 |
eq_ref | JOIN 用主键或唯一索引 | 极好 |
ref | 普通索引 | 好 |
range | 范围查询 | 较好 |
index | 扫描整个索引树 | 一般 |
ALL | 全表扫描 | 差 |
6.3 查询优化技巧
-- 1. 只查需要的列(避免 SELECT *)
SELECT name, email FROM users WHERE id = 1;
-- 2. 用 LIMIT 1 优化已知唯一的查询
SELECT 1 FROM users WHERE email = '[email protected]' LIMIT 1;
-- 3. 批量插入
INSERT INTO users (name, email) VALUES
('a', '[email protected]'),
('b', '[email protected]'),
('c', '[email protected]');
-- 4. JOIN 用小表驱动大表
SELECT * FROM small_table s
INNER JOIN big_table b ON s.id = b.sid;
-- 5. 避免子查询,改用 JOIN
-- 慢
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip = 1);
-- 快
SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.vip = 1;
七、实际优化效果
7.1 索引优化
某项目索引优化两周后:
- 查询性能提升 10 倍
- 写入性能下降 20%(可接受)
7.2 连接池调优
某 Spring Boot 应用优化前后:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 平均响应时间 | 85ms | 35ms | -59% |
| P99 响应时间 | 450ms | 120ms | -73% |
| 数据库连接数 | 450-500 | 32-35 | -92% |
| 连接等待超时 | 20+次/小时 | 0 | -100% |
| CPU 使用率 | 65% | 45% | -31% |
7.3 分库分表
某订单系统从单机迁到分布式:
- 订单表 8000 万行 → 32 张分表
- 主从延迟从分钟级恢复到秒级
- CPU 从 80%+ 降到 40%
八、写在最后
数据库问题往往没有银弹。每个决策都是在性能、一致性、复杂度之间权衡。
几条核心原则:
- 先复现问题:两个 SQL 会话比看十篇博客管用
- 默认值不等于合适:MySQL 和 PostgreSQL 默认差异大
- 隔离级别和锁策略一起设计:别指望 Serializable 一把梭
- 索引不是越多越好,是越合适越好
- 连接池不是越大越好:根据硬件和并发量来
- 事务里只做数据库该做的事
- 分库分表是最后手段:加索引、优化 SQL、调硬件往往更直接
- 监控比调优更重要:没有监控的调优是盲人摸象
数据库技术相对稳定,原理几十年来没大变。把基本功练扎实——索引、事务、连接池、主从——大部分生产问题都能从容应对。
本文整合了 10 篇数据库相关文章,涵盖索引优化、连接池调优、事务隔离级别、主从复制、分库分表、性能优化等核心技术。
版权声明: 本文首发于 指尖魔法屋-数据库实战指南:从索引优化到分布式架构(https://blog.thinkmoon.cn/post/database-comprehensive-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。