去年双11大促,我们一个订单查询接口突然超时,MySQL CPU直接飙到100%。查慢查询日志,发现一个订单列表页执行了3000+条SQL——典型的N+1问题。业务逻辑很简单:查订单列表,然后循环获取每个订单的商品和用户信息。代码就几行,但数据量一上来直接崩。
这文章不讲虚的。直接上实战:从发现问题到优化完成,每一步都有代码和压测数据。环境:PHP 8.3.6 + Laravel 11.9 + MySQL 8.0.35 + 8核16G服务器。
先看原始代码:
// app/Http/Controllers/OrderController.php
public function index()
{
$orders = Order::where('status', 1)->get(); // 1条SQL
foreach ($orders as $order) {
$order->user->name; // 每次循环都查user表
foreach ($order->items as $item) { // 每次循环都查items表
$item->product->title; // 每次循环都查product表
}
}
return view('orders.index', compact('orders'));
}假设订单表1000条记录,每个订单平均3个商品。这条代码实际执行的SQL数量:1(查订单) + 1000(查user) + 1000(查items) + 3000(查product) = 5001条SQL。数据库不崩才怪。
用Laravel Debugbar看一下:
# 安装Debugbar
composer require barryvdh/laravel-debugbar --dev
# 访问页面,查看底部Queries数数据:1000个订单,执行了5001条查询,耗时12.3秒,内存占用45MB。
最简单的优化:用with()一次性加载关联关系。
// 优化后
$orders = Order::with(['user', 'items.product'])
->where('status', 1)
->get();
// 只执行4条SQL:orders, users, items, products压测结果(使用ab工具,并发10,请求1000次):
| 方案 | SQL数量 | 平均响应时间 | 内存占用 | QPS |
|---|---|---|---|---|
| 原始N+1 | 5001 | 12.3s | 45MB | 8 |
| 预加载 | 4 | 0.15s | 12MB | 666 |
QPS从8提升到666,提升83倍。但注意:预加载不是银弹。如果关联数据量巨大(比如一个订单有1000个商品),预加载会把所有数据拉到内存,反而更慢。
有时候你不能一开始就决定加载什么关联。比如根据条件动态加载:
$orders = Order::where('status', 1)->get();
if (request('include_user')) {
$orders->load('user'); // 延迟预加载
}
if (request('include_items')) {
$orders->load('items.product');
}这比循环里用$order->user好100倍,但注意:load()也是发SQL,只是合并成一条IN查询。如果$orders有1000条,load('user')会执行:SELECT * FROM users WHERE id IN (1,2,3,...1000)。这条SQL可能很长,MySQL有max_allowed_packet限制(默认64MB),但1000个ID一般没问题。
如果数据量超过10万条,用get()一次性加载会导致内存溢出。用chunk分块:
// 每次处理1000条
Order::where('status', 1)->chunk(1000, function ($orders) {
foreach ($orders as $order) {
// 处理逻辑
Log::info('Order: ' . $order->id);
}
});chunk内部用limit和offset实现分页。但有个坑:如果数据在分块过程中被修改(比如删除),offset会偏移导致重复或遗漏。Laravel 8+提供了chunkById,用主键ID做游标:
Order::where('status', 1)->chunkById(1000, function ($orders) {
foreach ($orders as $order) {
// 安全处理
}
});压测对比(处理50万条订单数据):
| 方案 | 内存峰值 | 执行时间 | 是否稳定 |
|---|---|---|---|
| get()一次性加载 | 1.2GB | 8.5s | OOM风险 |
| chunk(1000) | 35MB | 9.2s | 可能重复 |
| chunkById(1000) | 35MB | 9.1s | 稳定 |
chunkById是处理大数据集的正确姿势。但注意:chunkById要求表有自增主键,且不能有复合主键。
Laravel的cursor()使用生成器,一次只从数据库取一条记录,内存占用极低:
foreach (Order::where('status', 1)->cursor() as $order) {
// 处理逻辑
// 不会一次性加载所有数据
}但cursor()有坑:它保持MySQL连接打开,如果处理时间过长,会触发wait_timeout。而且cursor()不支持预加载关联模型,因为关联查询需要额外的连接。实测:处理50万条数据,内存占用仅2MB,但执行时间比chunkById长20%(因为每次取一条,网络开销大)。
适用场景:内存敏感、处理逻辑简单、不需要关联数据。
有些场景ORM的开销太大。比如只需要统计某个字段:
// ORM方式
$count = Order::where('status', 1)->count(); // 生成SELECT count(*) FROM orders WHERE status = ?
// 但如果你只需要一个数字,ORM会实例化Builder对象,有额外开销
// 原生查询
$count = DB::select('SELECT COUNT(*) as total FROM orders WHERE status = ?', [1]);
// 直接返回stdClass,没有Eloquent模型开销压测对比(100万次count查询):
| 方式 | 平均耗时 | 内存/次 |
|---|---|---|
| Eloquent count() | 0.12ms | 0.5KB |
| DB::select() | 0.08ms | 0.1KB |
差距不大,但如果你在循环里调用100万次,差距就出来了。不过一般业务不会这么干。原生查询适合复杂SQL(比如子查询、联合查询),ORM搞不定的时候。
有时候一个关联查询可以用子查询替代,减少JOIN的数据量。比如查每个用户的最新订单:
// 原始:先查用户,再循环查订单(N+1)
$users = User::all();
foreach ($users as $user) {
$latestOrder = $user->orders()->latest()->first();
}
// 优化:子查询
$users = User::addSelect(['latest_order_id' => Order::select('id')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])->get();
// 然后可以用with('latestOrder')预加载或者用Laravel的withExists:
$users = User::withExists(['orders' => function ($query) {
$query->where('created_at', '>', now()->subDay());
}])->get();
// 每个用户多一个orders_exists字段,true/false子查询比JOIN高效,因为子查询只返回需要的字段,而JOIN会返回所有字段。但MySQL优化器有时候会把子查询转成JOIN,所以需要EXPLAIN确认。
ORM优化了代码,但SQL执行慢,很可能是索引没建对。用EXPLAIN分析:
EXPLAIN SELECT * FROM orders WHERE status = 1 AND created_at > '2024-01-01';如果type是ALL(全表扫描),加复合索引:
ALTER TABLE orders ADD INDEX idx_status_created_at (status, created_at);注意:索引不是越多越好。写频繁的表,索引会降低写入性能。一般建议:查询频率高的字段建索引,区分度低的字段(如status只有0/1)不适合单独建索引,但可以放在复合索引最左边。
压测对比(100万条数据,查询status=1且created_at范围):
| 索引 | 扫描行数 | 查询耗时 |
|---|---|---|
| 无索引 | 100万 | 1.2s |
| 单列status索引 | 50万 | 0.6s |
| 复合索引(status, created_at) | 1000 | 0.002s |
复合索引直接把查询从1.2秒降到2毫秒,600倍提升。
Laravel的Model有全局作用域、事件监听、访问器,这些都会占用内存。如果你在循环里创建模型实例,一定要手动释放:
// 错误做法
$orders = Order::all();
foreach ($orders as $order) {
$order->load('items');
// 处理...
// $order对象一直存在内存里
}
// 正确做法:用chunk,处理完unset
Order::chunk(100, function ($orders) {
foreach ($orders as $order) {
// 处理
unset($order); // 显式释放
}
});实测:循环处理10万条订单,不unset内存峰值200MB,unset后降到30MB。
坑1:with()预加载导致内存爆炸
有一次我预加载了一个一对多关联,一个订单有5000个商品,with('items')直接加载了5000*1000=500万条记录到内存,服务器直接OOM。解决方案:用withCount('items')只统计数量,或者用lazy加载。
坑2:chunkById在事务中死锁
chunkById使用ORDER BY id ASC,如果同时有其他事务在插入数据,会导致间隙锁,引发死锁。解决方案:在事务外使用chunkById,或者用SERIALIZABLE隔离级别(不推荐)。
坑3:cursor()导致MySQL连接超时
cursor()保持连接打开,如果处理逻辑耗时很长(比如调用外部API),MySQL的wait_timeout(默认28800秒)可能不够。解决方案:设置更长的wait_timeout,或者改用chunkById。
坑4:子查询在MySQL 5.7中性能差
MySQL 5.7对子查询优化不好,很多子查询会全表扫描。升级到MySQL 8.0后,子查询性能大幅提升。如果还在用5.7,尽量用JOIN替代子查询。
坑5:索引失效的常见原因
在WHERE条件中对索引列使用函数(如DATE(created_at) = '2024-01-01')会导致索引失效。应该用范围查询:created_at >= '2024-01-01' AND created_at < '2024-01-02'。
ORM优化不是玄学。记住几个原则:
最后给个检查清单:每次上线前,用Debugbar看SQL数量和耗时,用EXPLAIN看索引使用情况,用压力测试看QPS和内存。别等到线上崩了再优化。
专注技术分享与实战