«

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);
}

原始代码存在的性能问题

  1. N+1 查询:分页15条,循环里每次去查用户、支付、商品;一次接口额外产生 15*3=45 条SQL。
  2. 没有索引:status、created_at、order_no 筛选字段缺少联合索引,全表扫描。
  3. like %xxx% 前置通配符,索引失效。
  4. 没有缓存,每次请求都直查数据库。
  5. 控制器厚重,查询逻辑散落在Controller,无法复用。
  6. 时间范围查询没有做边界校验,传非法时间导致数据库异常。

数据库监控:一次接口总共执行 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。

优化后效果:

第四轮进阶优化:大表深度分页问题暴露

当用户翻到第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% 解决深度翻页慢

踩过的坑(实战总结)

  1. 不要滥用缓存:实时性极高的业务不能用长缓存;B端5‑10秒窗口是比较稳妥的选择。
  2. 索引不是越多越好,索引会降低insert/update性能。
  3. like "%关键词%" 前置通配符索引失效,尽量改成后缀匹配;如果必须全文检索,考虑引入Elasticsearch。
  4. 大表尽量避免 offset深度分页,优先keyset游标分页。
  5. N+1问题肉眼很难发现,建议开启 debugbar 开发环境查看SQL数量。
  6. Cache::remember 内部发生异常不会自动清除缓存,要做好异常捕获。

后续可继续演进方向

  1. 数据量继续上涨,可做读写分离,查询走从库;
  2. 高频复杂筛选列表,迁移ES搜索引擎;
  3. 大表做分表,按时间水平分表;
  4. 使用Horizon队列做预计算统计,把统计计算从接口移到异步。

收到6条评论
avatar
test 2026-10-31 05:43
*
回复
avatar
test 2026-10-31 05:43
test
回复
avatar
test 2026-10-31 05:43
test'
回复
avatar
test' 2026-10-31 05:43
test
回复
avatar
* 2026-10-31 05:43
test
回复
avatar
test 2026-10-31 05:43
test
回复