Laravel 进阶内容分享三|线上真实性能优化实战案例
itamour 发布于 阅读:288
背景:某内部B端订单管理系统,Laravel8 单体项目。业务场景:订单列表查询接口。
现象:订单量约80万条,前端分页查询接口,首页加载耗时 2.8‑4s,偶尔超时504;数据库CPU经常冲到80%,高峰期多个用户同时打开列表,接口大面积慢。
原始接口:获取订单列表,附带用户信息、支付记录、订单商品明细,支持多条件筛选、时间范围、状态筛选,返回分页数据。
原始问题代码(线上最初版本)
控制器直接写查询,循环加载关联,大量 if 判断拼接条件。
public function index(Request $request)
{
$status = $request->status;
$startTime = $request->start_time;
$endTime = $request->end_time;
$keyword = $request->keyword;
$query = Order::query();
if (!empty($status)) {
$query->where('status', $status);
}
if (!empty($startTime)) {
$query->where('created_at', '>=', $startTime);
}
if (!empty($endTime)) {
$query->where('created_at', '<=', $endTime);
}
if (!empty($keyword)) {
$query->where('order_no', 'like', "%{$keyword}%");
}
$list = $query->paginate(15);
// 循环内再查关联,典型 N+1 问题
foreach ($list as $item) {
$item->user;
$item->payRecord;
$item->orderGoods;
}
return response()->json($list);
}
原始代码存在的性能问题
- N+1 查询:分页15条,循环里每次去查用户、支付、商品;一次接口额外产生 15*3=45 条SQL。
- 没有索引:
status、created_at、order_no筛选字段缺少联合索引,全表扫描。 like %xxx%前置通配符,索引失效。- 没有缓存,每次请求都直查数据库。
- 控制器厚重,查询逻辑散落在Controller,无法复用。
- 时间范围查询没有做边界校验,传非法时间导致数据库异常。
数据库监控:一次接口总共执行 48条SQL,执行时间大部分消耗在全表扫描。
第一轮优化:代码层面优化(不改动数据库)
1. 使用 when() 动态条件 + with() 预加载消除N+1
public function index(Request $request)
{
$list = Order::query()
->when($request->status, fn($q) => $q->where('status', $request->status))
->when($request->start_time, fn($q) => $q->where('created_at', '>=', $request->start_time))
->when($request->end_time, fn($q) => $q->where('created_at', '<=', $request->end_time))
->when($request->keyword, function ($q) use ($request) {
// 去掉前置%,改为后缀匹配,让索引生效
$q->where('order_no', 'like', "{$request->keyword}%");
})
// 预加载关联,一次性3条SQL取出全部关联数据
->with(['user', 'payRecord', 'orderGoods'])
->paginate(15);
return response()->json($list);
}
优化后效果:SQL数量从48条降到 4条SQL,接口耗时从3s下降到1.2~1.6s。
但高峰期并发上来,依然会到1s以上,数据库CPU压力依旧不小。
第二轮优化:数据库索引优化
分析慢查询日志,筛选条件经常组合:status + created_at。
创建联合索引迁移文件:
// create_index_orders_status_created_at.php
public function up()
{
Schema::table('orders', function ($table) {
// 常用筛选联合索引
$table->index(['status', 'created_at']);
// 订单号单独索引,用于订单号搜索
$table->index('order_no');
});
}
执行迁移:
php artisan migrate
注意:不要盲目建索引,索引会降低写入性能;只给查询高频字段建立索引。
建立索引之后,大部分条件查询走索引,耗时降到 400‑700ms。
但是:无筛选条件的“全部订单”首页,仍然扫描大量数据,700ms左右,并发高时压力依旧。
第三轮优化:增加Redis缓存,热点页面缓存
业务特点:订单列表数据允许短时间延迟(5秒缓存窗口,B端业务可接受)。
使用 Cache::remember 对分页结果做缓存;缓存key需要包含所有筛选条件、页码、每页条数。
public function index(Request $request)
{
$params = $request->only(['status','start_time','end_time','keyword','page','per_page']);
// 生成唯一缓存key
$cacheKey = 'order:list:' . md5(json_encode($params));
$list = Cache::remember($cacheKey, 5, function () use ($request) {
return Order::query()
->when($request->status, fn($q) => $q->where('status', $request->status))
->when($request->start_time, fn($q) => $q->where('created_at', '>=', $request->start_time))
->when($request->end_time, fn($q) => $q->where('created_at', '<=', $request->end_time))
->when($request->keyword, function ($q) use ($request) {
$q->where('order_no', 'like', "{$request->keyword}%");
})
->with(['user', 'payRecord', 'orderGoods'])
->paginate(15);
});
return response()->json($list);
}
缓存失效策略:订单新增/修改/关闭时,清除相关缓存
// 订单更新后清除订单列表缓存 Cache::forget('order:list:*');这里生产环境不建议直接
forget通配符,redis需要开启key扫描;更好的做法:业务更新时,只清除用户相关的业务缓存key。
优化后效果:
- 缓存命中:接口 30‑80ms,几乎不打数据库;
- 缓存未命中:400‑700ms;
- 数据库CPU高峰期从80%降到20‑30%,不再出现接口超时504。
第四轮进阶优化:大表深度分页问题暴露
当用户翻到第100页之后,paginate()底层 limit offset 1500,15,offset越大,数据库扫描数据越多,翻页越慢。
问题:offset分页,深度分页性能断崖下跌。
方案:改用游标分页(Keyset分页),适合列表场景
// 前端传最后一条记录id last_id
$lastId = $request->last_id;
$query = Order::query()
->when($lastId, fn($q) => $q->where('id','<',$lastId))
->when($request->status, fn($q) => $q->where('status', $request->status))
->when($request->start_time, fn($q) => $q->where('created_at', '>=', $request->start_time))
->when($request->end_time, fn($q) => $q->where('created_at', '<=', $request->end_time))
->with(['user','payRecord','orderGoods'])
->orderBy('id','desc')
->limit(15)
->get();
不再使用offset,利用主键索引,无论翻多少页性能稳定。
缺点:传统页码(第N页)无法使用,只适合“上拉加载更多”。
第五轮:业务架构优化,把查询逻辑迁移到Repository
把查询逻辑抽离,控制器只负责入参接收、返回响应。
// app/Repositories/OrderRepository.php
class OrderRepository
{
public function getList(array $params)
{
return Order::query()
->when($params['status']??null, fn($q) => $q->where('status', $params['status']))
->when($params['start_time']??null, fn($q) => $q->where('created_at', '>=', $params['start_time']))
->when($params['end_time']??null, fn($q) => $q->where('created_at', '<=', $params['end_time']))
->when($params['keyword']??null, function ($q) use ($params) {
$q->where('order_no', 'like', "{$params['keyword']}%");
})
->with(['user','payRecord','orderGoods']);
}
}
控制器变得很轻薄:
public function index(Request $request, OrderRepository $orderRepo)
{
$params = $request->only(['status','start_time','end_time','keyword','page','per_page']);
$cacheKey = 'order:list:' . md5(json_encode($params));
$list = Cache::remember($cacheKey,5,function() use($params,$orderRepo){
return $orderRepo->getList($params)->paginate(15);
});
return response()->json($list);
}
优化前后对比汇总表
| 优化阶段 | 单次接口SQL数量 | 平均耗时 | 数据库CPU | 备注 |
|---|---|---|---|---|
| 原始版本 | 48条 | 2.8‑4s | 70‑85% | 高峰期504超时 |
| 代码优化(with+when) | 4条 | 1.2‑1.6s | 50‑65% | 消除N+1 |
| 增加索引 | 4条 | 400‑700ms | 35‑50% | 索引生效 |
| 增加Redis缓存 | 命中0‑4条;未命中4条 | 命中30‑80ms;未命中400‑700ms | 20‑30% | 解决并发压力 |
| keyset游标分页 | 4条 | 稳定300‑600ms | 20‑30% | 解决深度翻页慢 |
踩过的坑(实战总结)
- 不要滥用缓存:实时性极高的业务不能用长缓存;B端5‑10秒窗口是比较稳妥的选择。
- 索引不是越多越好,索引会降低insert/update性能。
like "%关键词%"前置通配符索引失效,尽量改成后缀匹配;如果必须全文检索,考虑引入Elasticsearch。- 大表尽量避免 offset深度分页,优先keyset游标分页。
- N+1问题肉眼很难发现,建议开启
debugbar开发环境查看SQL数量。 - Cache::remember 内部发生异常不会自动清除缓存,要做好异常捕获。
后续可继续演进方向
- 数据量继续上涨,可做读写分离,查询走从库;
- 高频复杂筛选列表,迁移ES搜索引擎;
- 大表做分表,按时间水平分表;
- 使用Horizon队列做预计算统计,把统计计算从接口移到异步。