数据库实战指南:从索引优化到分布式架构

前言:数据库是后端的核心

数据库往往是系统最先遇到的瓶颈。从单机到分布式,从索引到事务,每个环节都有坑。

数据库问题的典型演进:

  1. 慢查询 → 索引优化
  2. 连接数飙升 → 连接池调优
  3. 读写争抢 → 主从复制
  4. 单表数据量大 → 分库分表
  5. 跨分片事务 → 分布式事务

一、索引优化

1.1 B+Tree 索引原理

MySQL InnoDB 默认使用 B+Tree:

graph TB A[根节点] --> B[中间节点1] A --> C[中间节点2] B --> D[叶子节点1] B --> E[叶子节点2] C --> F[叶子节点3] C --> G[叶子节点4]

特点:

  • 平衡树,所有叶子节点深度相同
  • 数据只存储在叶子节点
  • 适合范围查询

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 认证、权限校验、会话初始化,几十毫秒起步。

graph LR A[应用请求] --> B{池中有空闲连接?} B -->|有| C[分配连接] B -->|没有| D{池已满?} D -->|未满| E[创建新连接] D -->|已满| F[等待或超时] C --> G[执行 SQL] G --> H[归还连接]

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-size10CPU 核心数 * 2 + 有效磁盘数
minimum-idle10与最大连接数相同
max-lifetime30分钟wait_timeout 稍短
leak-detection-threshold0生产开启,60秒

2.3 连接池大小公式

PostgreSQL 公式: pool_size = ((核心数 * 2) + 有效磁盘数)

经验值:

  • 4 核机器:16-20 连接
  • 8 核机器:32 连接
  • 16 核机器:50-60 连接

连接数不是越大越好。 超过 40 后性能反而下降(上下文切换开销)。

2.4 连接池对比

连接池平均响应CPU内存特点
HikariCP12ms最小轻量快速(推荐)
Druid15ms较大监控丰富
DBCP218ms功能全面
C3P025ms最大老牌,性能弱

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 主从复制原理

graph LR A[主库] -->|binlog| B[从库 IO 线程] B -->|中继日志| C[从库 SQL 线程] C --> D[从库数据]

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: Yes
  • Slave_SQL_Running: Yes
  • Seconds_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_refJOIN 用主键或唯一索引极好
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 应用优化前后:

指标优化前优化后改善
平均响应时间85ms35ms-59%
P99 响应时间450ms120ms-73%
数据库连接数450-50032-35-92%
连接等待超时20+次/小时0-100%
CPU 使用率65%45%-31%

7.3 分库分表

某订单系统从单机迁到分布式:

  • 订单表 8000 万行 → 32 张分表
  • 主从延迟从分钟级恢复到秒级
  • CPU 从 80%+ 降到 40%

八、写在最后

数据库问题往往没有银弹。每个决策都是在性能、一致性、复杂度之间权衡。

几条核心原则:

  1. 先复现问题:两个 SQL 会话比看十篇博客管用
  2. 默认值不等于合适:MySQL 和 PostgreSQL 默认差异大
  3. 隔离级别和锁策略一起设计:别指望 Serializable 一把梭
  4. 索引不是越多越好,是越合适越好
  5. 连接池不是越大越好:根据硬件和并发量来
  6. 事务里只做数据库该做的事
  7. 分库分表是最后手段:加索引、优化 SQL、调硬件往往更直接
  8. 监控比调优更重要:没有监控的调优是盲人摸象

数据库技术相对稳定,原理几十年来没大变。把基本功练扎实——索引、事务、连接池、主从——大部分生产问题都能从容应对。


本文整合了 10 篇数据库相关文章,涵盖索引优化、连接池调优、事务隔离级别、主从复制、分库分表、性能优化等核心技术。

版权声明: 本文首发于 指尖魔法屋-数据库实战指南:从索引优化到分布式架构https://blog.thinkmoon.cn/post/database-comprehensive-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!