各位好,我是你们的PHP架构师朋友。
今晚咱们不聊那些虚头巴脑的设计模式,也不纠结哪个框架更好用。咱们来聊聊一个让无数PHP程序员在半夜三点惊醒的噩梦——数据库表太大了。
想象一下,你的系统上线了三年。刚开始,你的订单表只有几百行数据,查询快得像在高速公路上开车,随随便便一秒钟就是几百个请求。但是,三年过去了,订单表有了五千万行,用户表有了一亿行。现在,你的网站慢得像是在便秘。稍微来个双十一活动,或者老板发个促销邮件,数据库CPU直接飙升到100%,CPU风扇转得比直升机螺旋桨还响,然后——啪叽,宕机了。
这就是典型的“数据库肥胖症”。你的表变胖了,你的索引变胖了,你的查询变得步履蹒跚。
今天,咱们就来谈谈怎么给这个肥胖症患者做“抽脂手术”。我们的目标是:主表瘦身,查询如飞。
一、 痛点分析:为什么你的数据库越来越“重”?
在动手之前,我们得先搞清楚,为什么数据多了性能就崩了?这可不是魔法,是物理规律。
1. 索引树的深度加深
MySQL的索引用的是B+树。以前你只有几万条数据,B+树可能只有两层。现在你有五千万数据,B+树可能得长到三层、四层甚至五层。当你执行一个查询时,MySQL得像剥洋葱一样,一层一层往下钻,直到找到叶子节点。树高了,IO操作就多了。每一次IO都是对磁盘的“拳打脚踢”,CPU再快也挡不住磁盘读取的延迟。
2. 全表扫描的噩梦
有些时候,你的查询条件写得烂,或者索引失效了。MySQL就傻乎乎地把五千万行数据全部扫一遍,然后过滤出你要的结果。这就好比你在一个只有一张桌子的大房间里找一张藏起来的纸,你得把桌子上的所有东西都翻一遍。这种查询,哪怕你只有100个并发,数据库也给你干趴下了。
3. 磁盘空间与IO瓶颈
数据多了,磁盘IO就满了。主从同步也会因为数据量太大而变得极其缓慢。
所以,解决思路是什么?分而治之。不要把所有的鸡蛋放在一个篮子里,更不要把所有的数据堆在一个大坑里。
二、 核心方案一:分区表——给数据库装上“隔断墙”
MySQL原生有一个很强大的功能,叫分区表(Partitioning)。这就像是把一个巨大的仓库隔成了很多个小仓库。
你可以按时间分区,按地区分区,或者按ID哈希分区。如果按时间分区,那么“2020年的数据”就在“2021年的数据”隔壁。当你查“2023年的数据”时,MySQL一看:哦,不用查2020年的那个隔间,只需要查我屁股底下的这个隔间就行了。
这就是分区裁剪(Partition Pruning),性能提升的杀手锏。
1. 怎么设计分区表?
假设我们有一个超级巨大的orders表。
CREATE TABLE `orders` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`order_no` varchar(64) NOT NULL,
`amount` decimal(10,2) NOT NULL,
`create_time` datetime NOT NULL COMMENT '订单创建时间',
PRIMARY KEY (`id`, `create_time`)
) ENGINE=InnoDB
-- 关键点来了:按时间范围分区
PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p_202001 VALUES LESS THAN (TO_DAYS('2020-02-01')),
PARTITION p_202002 VALUES LESS THAN (TO_DAYS('2020-03-01')),
PARTITION p_202003 VALUES LESS THAN (TO_DAYS('2020-04-01')),
PARTITION p_202004 VALUES LESS THAN (TO_DAYS('2020-05-01')),
-- ...以此类推,每个月一个分区
PARTITION p_max VALUES LESS THAN MAXVALUE
);
看到了吗?create_time被设为了联合主键的一部分(或者至少是索引的一部分),并且作为了分区键。
2. PHP怎么查?
PHP程序员最怕写SQL,但这里必须强调:写SQL的时候,尽量带上分区键!
如果你只写 SELECT * FROM orders WHERE user_id = 1001,MySQL傻眼了,它不知道你要查哪个分区,于是它把所有的分区都扫了一遍。这就失去了分区的意义。
但是,如果你写 SELECT * FROM orders WHERE create_time > '2023-01-01',MySQL会极其聪明地只去查2023年的那些分区文件。性能瞬间起飞!
// PHP 代码示例
$stmt = $pdo->prepare("SELECT * FROM orders WHERE create_time > :date");
$stmt->execute(['date' => '2023-01-01 00:00:00']);
$orders = $stmt->fetchAll(PDO::FETCH_ASSOC);
三、 核心方案二:冷热数据分离——把垃圾扔出去
虽然分区表能提高查询速度,但如果分区表也太多了,管理起来很麻烦。而且,有时候你想查去年的数据,虽然只查了去年的分区,但MySQL还是得去磁盘上找文件。
这时候,我们需要一个更彻底的方案:归档(Archive)。
把旧数据(比如超过1年或2年的)从生产数据库中移走,存到一个专门的历史数据库里,或者存成文件。生产库里只保留最近半年或一年的数据。这就是“热数据”和“冷数据”的分离。
1. 归档策略
我们不能把数据删了,万一用户要查三年前的发票呢?所以,我们得把它复制到另一个表或库。
步骤如下:
- 备份:先对原表做个备份,这是程序员的保命底线。
- 查询:写SQL把旧数据查出来。
- 插入:把查出来的数据插入到归档表里。
- 删除:从原表删除这些数据。
- 优化:执行
OPTIMIZE TABLE,把表结构碎片整理一下。
2. PHP 归档脚本实战
这可是个技术活。千万不能在一个脚本里把所有旧数据都归档。如果数据有几千万,你的脚本运行个30秒(PHP默认超时时间),或者锁表时间太长,网站就会卡死。
我们要用 “分而治之,流水线作业” 的思路。
这里给你写一个“归档卫士”类的雏形:
<?php
class DataArchiver {
private $pdo;
private $batchSize = 1000; // 每次处理的数量,不要太大,也不要太小
private $chunkSize = 10000; // 每次从数据库取多少条
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
/**
* 执行归档任务
*/
public function archiveOldData($olderThanDate) {
// 1. 开启长连接,减少握手开销
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 无限循环,直到没有数据了
while (true) {
try {
// 设置脚本永不超时(在CLI模式下)
set_time_limit(0);
// 2. 从主表读取一批旧数据
// 重点:一定要加索引!如果create_time没有索引,这步就是灾难
$sqlSelect = "SELECT * FROM orders
WHERE create_time < :date
LIMIT :limit OFFSET :offset";
$stmt = $this->pdo->prepare($sqlSelect);
$stmt->bindValue(':date', $olderThanDate);
$stmt->bindValue(':limit', $this->batchSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $this->offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($rows)) {
echo "归档完成,没有更多数据了。n";
break;
}
echo "本批次归档 {$rows->rowCount()} 条数据...n";
// 3. 插入到归档表
$sqlInsert = "INSERT INTO orders_archive (id, user_id, order_no, amount, create_time, create_time)
VALUES (:id, :user_id, :order_no, :amount, :create_time, :create_time)";
$stmtInsert = $this->pdo->prepare($sqlInsert);
foreach ($rows as $row) {
$stmtInsert->execute([
':id' => $row['id'],
':user_id' => $row['user_id'],
':order_no' => $row['order_no'],
':amount' => $row['amount'],
':create_time' => $row['create_time']
]);
}
// 4. 从主表删除(这是最危险的一步,建议加事务)
$this->pdo->beginTransaction();
$sqlDelete = "DELETE FROM orders WHERE id IN (";
$ids = array_column($rows, 'id');
$sqlDelete .= implode(',', array_fill(0, count($ids), '?')) . ")";
$stmtDelete = $this->pdo->prepare($sqlDelete);
$stmtDelete->execute($ids);
$this->pdo->commit();
echo "删除成功,当前偏移量: " . $this->offset . "n";
// 更新偏移量
$this->offset += $this->batchSize;
} catch (Exception $e) {
$this->pdo->rollBack();
echo "归档出错: " . $e->getMessage() . "n";
// 出错可以休眠一下,或者直接退出,视情况而定
sleep(5);
break;
}
}
}
}
// 使用示例
// $pdo = new PDO('mysql:host=localhost;dbname=prod', 'user', 'pass');
// $archiver = new DataArchiver($pdo);
// $archiver->archiveOldData('2022-01-01 00:00:00');
注意了,代码里的几个坑:
- 事务:一定要用事务。因为删除和插入是两条SQL,如果中间断电,你的数据就丢失了。
- IN查询限制:
DELETE FROM table WHERE id IN (1,2,3...10000)这个SQL语句长度有限制。如果ID很多,要拆分SQL。上面的代码用了implode,假设你有5000个ID,那就没问题。 - sleep:如果脚本跑得太快,数据库压力太大,可以适当在循环里加
sleep(1)。
四、 进阶技巧:读写分离与缓存加速
归档只是第一步,保住主库的性能是第二步。
1. 读写分离
如果数据量到了TB级,单机MySQL已经扛不住了。这时候,你需要搭建主从架构。
主库(Master): 负责写操作(INSERT, UPDATE, DELETE)。所有的业务逻辑代码(PHP)都连主库写数据。
从库(Slave): 负责读操作(SELECT)。归档后的旧数据可以放在从库里,或者归档查询专门走从库。
PHP怎么连接?简单点可以用PDO直接连,但性能不好。业界标准是 ProxySQL 或 Mycat。它们中间有个代理层,PHP只管连代理,代理自动把写请求转发给主库,读请求转发给从库。从库甚至可以多搞几台,做负载均衡。
2. Redis 缓存层
对于核心的查询,比如“查询某用户的最近订单”,如果数据还在主表里,并且数据量巨大,查询依然会慢。
我们可以加一层 Redis。
- 用户请求查询订单。
- PHP先去Redis查。如果Redis里有,直接返回。
- 如果Redis没有(缓存击穿),PHP再去MySQL查。
- 关键点:查完MySQL后,把结果写入Redis。设置一个合理的过期时间(比如5分钟)。
这样一来,绝大多数热数据的查询都在内存里完成了,MySQL根本不用动。这叫“以空间换时间”。
五、 终极兵器:分库分表
如果你们公司已经不是初创公司了,数据量是 亿级 甚至 十亿级,那么分区表和归档都不够看了。你需要 分库分表。
把一个大表拆成100个小表。
orders_0(用户ID尾数0的用户数据)
orders_1(用户ID尾数1的用户数据)
orders_2(用户ID尾数2的用户数据)
ShardingSphere 是目前Java界最火的分库分表中间件,PHP也能用(通过JDBC桥接或者ProxySQL)。
虽然分库分表实现起来很复杂(比如跨库Join怎么办?全局ID怎么办?),但它能把单表性能提升100倍,甚至1000倍。
六、 避坑指南:千万别这么做
作为资深专家,我得给你们提几个醒,否则你们半夜报警的时候别来找我。
1. 千万不要直接 TRUNCATE 历史表
虽然 TRUNCATE 比 DELETE 快(因为它不写Binlog,不逐行删),但在高并发下,它会锁表!直接把数据库锁死,所有请求排队,流量洪水一样打进来,服务器瞬间宕机。归档的时候,一定要用 INSERT + DELETE,别用 TRUNCATE。
2. 索引要随表瘦身
归档完数据后,别忘了给归档后的表重建索引。OPTIMIZE TABLE 命令就是干这个的。索引碎片化了,查询也会变慢。
3. 避免全表扫描
这是老生常谈,但也是最容易犯的错。写查询的时候,WHERE 后面尽量带上 create_time 或者 id。如果你的查询没有用到索引,或者索引失效了,哪怕你只删了一行数据,MySQL也可能去全表扫描。
4. 警惕“幽灵”表
有时候,你用 DELETE FROM orders 删除了数据,但表空间并没有减小。这是因为MySQL为了性能保留了空间。必须跑 OPTIMIZE TABLE 或者重建表。这一步很耗资源,最好在凌晨低峰期做。
七、 总结
好了,咱们来回顾一下今天的干货。
- 分区表是MySQL原生的“瘦身”工具,通过按时间分区,让查询只扫相关区域,立竿见影。
- 归档脚本是执行者,要用PHP写好定时任务,分批处理,配合事务,避免锁表和超时。
- 读写分离和缓存是保命手段,把压力分散出去。
- 最后,分库分表是核武器,不到万不得已别轻易用。
各位,数据是公司的资产,也是你们代码的负担。一个好的架构师,不仅会写能跑的代码,更要会“管理”数据。当你看到你的主表依然轻盈如燕,索引依然清晰锐利,哪怕面对百万级并发,你的数据库依然稳如泰山时,你才会发现自己真正的价值。
别让你的数据库像你的发际线一样,越来越高,越来越危险。
现在,拿起你们的键盘,去优化你们的SQL吧!记得,归档前先备份!
谢谢大家!