MySQL大表优化的生存法则 📊

25 阅读11分钟

一、开篇故事:仓库爆满危机 📦

想象你管理一个快递仓库:

阶段1:小仓库(100万包裹)

仓库员:"找3号货架的包裹。"
机器人:"找到了!"3秒 ✅

特点:
  - 1层仓库,100个货架
  - 找货快,管理方便

阶段2:中型仓库(1000万包裹)

仓库员:"找3号货架的包裹。"
机器人:"让我爬到3楼..."30秒 ⚠️

特点:
  - 3层仓库,1000个货架
  - 找货慢了,但还能接受

阶段3:超大仓库(2000万+包裹)

仓库员:"找3号货架的包裹。"
机器人:"要爬10楼,路还堵车..." → 5分钟 💀

问题暴露:
  1. B+树太高(10层)→ IO次数多
  2. 索引太大 → 缓存放不下
  3. 更新慢 → 锁竞争激烈
  4. 备份慢 → 影响业务

老板:"拆分仓库!分库分表!"

这就是MySQL大表问题的本质!


二、为什么2000万是临界点?🤔

2.1 B+树高度问题

B+树高度计算:

假设:
  - 页大小:16KB
  - 主键:BIGINT,8B
  - 指针:6B
  - 每个非叶子节点存:16KB / (8B + 6B) ≈ 1170个key

树高度与数据量关系:
  2层:1170² ≈ 137万
  3层:1170³ ≈ 1.6亿
  
结论:
  - 2000万数据:刚好3层
  - 超过2000万:可能变4层
  - 4层:多1次IO,性能下降!

图解:

2B+树(百万级):
    根节点(1170个key)
      ↙  ↓  ↘
   叶子节点(数据)
   2次IO ✅

3B+树(千万级):
       根节点
      ↙  ↓  ↘
   中间节点
    ↙  ↓  ↘
   叶子节点
   3次IO ✅

4B+树(亿级):
         根
        ↙ ↘
      中间层1
      ↙   ↘
    中间层2
    ↙   ↘
   叶子节点
   4次IO ❌ 慢了!

2.2 Buffer Pool缓存问题

InnoDB Buffer Pool默认:128MB

索引大小估算(2000万数据):
  - 主键索引:约2GB
  - 辅助索引1:约1GB
  - 辅助索引2:约1GB
  总计:4GB

问题:
  4GB索引 > 128MB缓存
  → 大量索引无法缓存
  → 频繁磁盘IO
  → 性能下降!💀

2.3 锁竞争问题

表越大,锁竞争越激烈:

1000万数据:
  - UPDATE同一行:偶尔冲突
  - 并发度:可接受 ✅

2000万+数据:
  - 热点数据锁竞争激烈
  - 死锁频繁
  - 并发度:严重下降 ❌

2.4 运维问题

备份时间:
  1000万:备份30分钟
  5000万:备份2小时 

ALTER TABLE时间:
  1000万:5分钟
  5000万:30分钟,锁表!💀

主从同步延迟:
  数据越多,同步越慢 

三、大表的危害 ⚠️

3.1 查询性能下降

-- 1000万数据
SELECT * FROM users WHERE age = 25;
-- 耗时:0.1秒 ✅

-- 5000万数据
SELECT * FROM users WHERE age = 25;
-- 耗时:1秒 ❌

-- 1亿数据
SELECT * FROM users WHERE age = 25;
-- 耗时:5秒 💀

原因:

  • B+树变高,IO次数增加
  • 索引缓存命中率下降
  • 扫描数据量增大

3.2 写入性能下降

-- 1000万数据
INSERT INTO users VALUES (...);
-- 耗时:0.01秒 ✅

-- 5000万数据
INSERT INTO users VALUES (...);
-- 耗时:0.1秒 ❌(慢了10倍)

原因:

  • 页分裂频繁
  • 索引更新慢
  • 锁竞争激烈

3.3 DDL操作缓慢

-- 添加索引
ALTER TABLE users ADD INDEX idx_age (age);

数据量 vs 耗时:
  1000万:5分钟
  5000万:30分钟,锁表!💀
  1亿:1小时,影响业务!💀💀

3.4 备份恢复困难

mysqldump备份时间:
  1000万:30分钟
  5000万:2小时
  1亿:4小时  备份窗口不够!❌

恢复时间更长:
  5000万:恢复需要5小时!
   影响业务连续性 💀

四、大表优化方案 💡

方案1:垂直分表(拆字段)

原理: 把宽表拆成多个窄表

-- ❌ 原表:字段太多
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50),
    phone VARCHAR(20),
    email VARCHAR(100),
    avatar TEXT,          -- 大字段
    description TEXT,     -- 大字段
    settings JSON,        -- 大字段
    ...
);

-- ✅ 拆分后:基础表
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50),
    phone VARCHAR(20),
    email VARCHAR(100),
    created_at DATETIME
);

-- ✅ 扩展表
CREATE TABLE users_ext (
    user_id BIGINT PRIMARY KEY,
    avatar TEXT,
    description TEXT,
    settings JSON,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

优点:

✅ 减少单表大小
✅ 提高热点数据缓存命中率
✅ 减少IO

缺点:

❌ 查询需要JOIN
❌ 应用层复杂度增加

适用场景:

- 有大字段(TEXT、BLOB)
- 有很多不常用字段
- 字段访问频率差异大

方案2:水平分表(拆行)

原理: 把一个表的数据分散到多个表

2.1 按范围分表

-- 按ID范围分表
CREATE TABLE users_0 (...) -- id: 0-1000万
CREATE TABLE users_1 (...) -- id: 1000万-2000万
CREATE TABLE users_2 (...) -- id: 2000万-3000万

优点:

✅ 简单,容易理解
✅ 范围查询方便

缺点:

❌ 数据分布可能不均匀
❌ 热点数据集中在某个表

2.2 按Hash分表

-- 按用户ID Hash分表
CREATE TABLE users_0 (...) -- id % 4 = 0
CREATE TABLE users_1 (...) -- id % 4 = 1
CREATE TABLE users_2 (...) -- id % 4 = 2
CREATE TABLE users_3 (...) -- id % 4 = 3

-- 应用层路由
table_index = user_id % 4
table_name = "users_" + table_index

优点:

✅ 数据分布均匀
✅ 负载均衡

缺点:

❌ 范围查询困难(需要查所有表)
❌ 扩容麻烦(需要重新Hash)

2.3 按时间分表

-- 按月分表(适合订单、日志)
CREATE TABLE orders_202401 (...) -- 2024年1月
CREATE TABLE orders_202402 (...) -- 2024年2月
CREATE TABLE orders_202403 (...) -- 2024年3月

优点:

✅ 适合时间相关业务
✅ 历史数据归档方便
✅ 删除旧数据方便(直接DROP TABLE

缺点:

❌ 跨月查询需要UNION
❌ 需要定期创建新表

方案3:分库分表(终极方案)

原理: 数据分散到多个数据库和表

数据库1:
  users_0
  users_1

数据库2:
  users_2
  users_3

数据库3:
  users_4
  users_5

路由策略:

// 1. 计算库
database_index = user_id / 1000万 % 3
database = "db" + database_index

// 2. 计算表
table_index = user_id / 100万 % 2
table = "users_" + table_index

// 3. 执行SQL
execute("db2.users_1", sql)

优点:

✅ 分散IO压力
✅ 分散存储压力
✅ 可以水平扩展
✅ 单表数据量小,性能好

缺点:

❌ 复杂度极高
❌ 跨库JOIN困难
❌ 分布式事务困难
❌ 运维复杂

方案4:读写分离

原理: 主库写,从库读

        应用
        ↙  ↘
    写操作  读操作
      ↓      ↓
    主库 → 从库1
          ↘ 从库2
           ↘从库3

实现:

@Transactional(readOnly = true)
public User getUser(Long id) {
    // 自动路由到从库
    return userMapper.selectById(id);
}

@Transactional
public void updateUser(User user) {
    // 路由到主库
    userMapper.updateById(user);
}

优点:

✅ 分散读压力
✅ 提高并发能力
✅ 不影响写操作

缺点:

❌ 主从延迟
❌ 需要处理数据一致性

方案5:归档历史数据

原理: 把旧数据移到历史表

-- 主表(只保留近1年数据)
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    ...
    create_time DATETIME
);

-- 历史表(1年前的数据)
CREATE TABLE orders_history (
    id BIGINT PRIMARY KEY,
    ...
    create_time DATETIME
);

-- 定期归档
INSERT INTO orders_history 
SELECT * FROM orders 
WHERE create_time < DATE_SUB(NOW(), INTERVAL 1 YEAR);

DELETE FROM orders 
WHERE create_time < DATE_SUB(NOW(), INTERVAL 1 YEAR);

优点:

✅ 主表数据量小,性能好
✅ 历史数据不丢失
✅ 实现简单

缺点:

❌ 查询历史数据需要查历史表
❌ 需要定期归档(增加运维成本)

方案6:冷热数据分离

原理: 热数据放MySQL,冷数据放HBase/ES

热数据(近3个月):
  → MySQL(高性能)

温数据(3个月-1年):
  → MySQL从库或历史表

冷数据(1年以上):
  → HBase/ES/对象存储(便宜)

实现:

public Order getOrder(Long id, Date createTime) {
    // 3个月内:查MySQL
    if (createTime > threeMonthsAgo) {
        return mysqlMapper.selectById(id);
    }
    // 1年内:查历史表
    else if (createTime > oneYearAgo) {
        return historyMapper.selectById(id);
    }
    // 1年以上:查HBase
    else {
        return hbaseService.get(id);
    }
}

五、分库分表带来的问题 ⚠️

问题1:跨库JOIN

-- ❌ 无法执行
SELECT * FROM db1.users u
JOIN db2.orders o ON u.id = o.user_id;
-- 报错:跨库JOIN不支持

-- ✅ 解决方案1:应用层JOIN
List<User> users = userService.getUsers();
List<Order> orders = orderService.getOrders();
// 在应用层关联

-- ✅ 解决方案2:数据冗余
-- 在orders表冗余user_name等字段

问题2:分布式事务

// ❌ 无法保证一致性
@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
    // from用户在db1
    db1.updateBalance(fromUserId, -amount);
    
    // to用户在db2
    db2.updateBalance(toUserId, +amount);
    
    // 如果db2失败,db1已经扣款!💀
}

// ✅ 解决方案:分布式事务(Seata、TCC等)

问题3:全局唯一ID

// ❌ 自增ID不能用
// db1的user_1: id=1
// db2的user_1: id=1 → 冲突!

// ✅ 解决方案1:雪花算法
long id = snowflakeIdGenerator.next(); // 全局唯一

// ✅ 解决方案2:号段模式
long id = idGenerator.next(); // 从号段服务获取

问题4:分页查询

-- ❌ 无法直接执行
SELECT * FROM users ORDER BY create_time LIMIT 100, 10;
-- 需要查所有分表,然后合并排序

-- ✅ 解决方案:
-- 1. 查询所有分表
SELECT * FROM db1.users_0 ORDER BY create_time LIMIT 110;
SELECT * FROM db1.users_1 ORDER BY create_time LIMIT 110;
SELECT * FROM db2.users_2 ORDER BY create_time LIMIT 110;
SELECT * FROM db2.users_3 ORDER BY create_time LIMIT 110;

-- 2. 应用层合并排序
List<User> merged = mergeAndSort(results);

-- 3. 取第100-110条
return merged.subList(100, 110);

六、大表优化实战案例 💼

案例:电商订单表优化

背景:

订单表:5000万数据
问题:
  - 查询慢(5秒)
  - 写入慢(0.5秒/条)
  - 备份慢(3小时)

优化方案:

第1步:垂直分表

-- 主表(高频字段)
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(50),
    user_id BIGINT,
    amount DECIMAL(10,2),
    status TINYINT,
    create_time DATETIME,
    INDEX idx_user_id (user_id),
    INDEX idx_status_time (status, create_time)
);

-- 扩展表(低频字段)
CREATE TABLE orders_ext (
    order_id BIGINT PRIMARY KEY,
    address TEXT,
    remark TEXT,
    invoice_info JSON,
    FOREIGN KEY (order_id) REFERENCES orders(id)
);

效果:

主表大小:2GB → 500MB
查询速度:5秒 → 2秒

第2步:按时间分表

-- 按月分表
CREATE TABLE orders_202401 LIKE orders;
CREATE TABLE orders_202402 LIKE orders;
CREATE TABLE orders_202403 LIKE orders;
...

-- 路由逻辑
String tableName = "orders_" + orderDate.format("yyyyMM");

效果:

单表数据:200万
查询速度:2秒 → 0.2秒

第3步:归档历史数据

-- 3个月前的数据归档到历史表
INSERT INTO orders_history 
SELECT * FROM orders_202310 
WHERE create_time < '2024-01-01';

-- 删除主表旧数据
DROP TABLE orders_202310;

效果:

主表数据:600万(只保留3个月)
备份时间:3小时 → 30分钟

第4步:读写分离

主库:处理写操作
从库1-3:处理查询操作

效果:

并发能力:1000 QPS  5000 QPS

最终效果:

查询速度:5秒  0.2秒(提升25倍)✅
写入速度:0.5秒  0.05秒(提升10倍)✅
备份时间:3小时  30分钟(提升6倍)✅
并发能力:1000 QPS  5000 QPS(提升5倍)✅

七、何时需要分库分表?🤔

判断标准

单表数据量:
  < 1000万:不需要分表 
  1000万-2000万:可以考虑 ⚠️
  > 2000万:建议分表 
  > 5000万:必须分表 💀

单库大小:
  < 100GB:不需要分库 
  100GB-500GB:可以考虑 ⚠️
  > 500GB:建议分库 
  > 1TB:必须分库 💀

QPS:
  < 1000:不需要 
  1000-5000:考虑读写分离 ⚠️
  > 5000:考虑分库分表 

八、面试高频问题 🎤

Q1: 为什么MySQL单表建议不超过2000万?

答:

  1. B+树高度:超过2000万可能变成4层,多1次IO
  2. 索引缓存:索引太大,Buffer Pool放不下,命中率低
  3. 锁竞争:数据越多,锁冲突越严重
  4. 运维成本:备份、恢复、DDL操作都变慢

Q2: 分库分表有哪些方案?

答:

  1. 垂直分表:按字段拆分(大字段、冷字段分离)
  2. 水平分表:按行拆分(范围、Hash、时间)
  3. 分库:数据分散到多个数据库
  4. 读写分离:主库写,从库读

Q3: 分库分表后如何解决跨库JOIN?

答:

  1. 应用层JOIN:分别查询,应用层关联
  2. 数据冗余:在表中冗余关联字段
  3. 全局表:小表在每个库都复制一份
  4. ES等:使用搜索引擎解决复杂查询

Q4: 分库分表后如何生成全局唯一ID?

答:

  1. 雪花算法:64位Long,包含时间戳+机器ID+序列号
  2. 号段模式:从ID生成服务获取号段
  3. UUID:全局唯一,但无序,影响B+树性能
  4. Redis:INCR生成ID

Q5: 何时应该分库分表?

答:

  • 单表 > 2000万
  • 单库 > 500GB
  • QPS > 5000
  • 查询/写入明显变慢
  • 备份/恢复时间过长

九、总结口诀 📝

MySQL大表有瓶颈,
两千万是临界点。
B+树高度是关键,
四层IO慢一倍。

垂直分表拆字段,
水平分表拆数据。
范围Hash按时间,
选择方案看场景。

分库分表能力强,
但也带来新麻烦。
跨库JOIN要避免,
分布式事务难搞。

读写分离提性能,
归档历史减负担。
冷热分离是良方,
优化方案组合用!

参考资料 📚


下期预告: 143-MySQL的主从复制原理和延迟问题 🔄


编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0

愿你的表永远精简高效! 📊✨