去年年底做年度复盘,技术指标那一栏里有个数字很难看:商品列表接口 P99 是 1820 毫秒。这个接口日均调用量三十多万次,前端用户平均要等将近一秒半才能看到首屏。产品那边一直以为是前端渲染慢,直到有天自己用 4G 网络点了一下,回来说”白屏好久”。
接下来两周我就泡在这个接口里。从监控报警到慢查询日志,从 ORM 代码到 SQL 执行计划,一路查下来改了五个地方。最终 P99 落到 90 毫秒,数据库 CPU 从 45% 降到 12%。下面把这个过程完整写下来。
先看看原来在跑什么
接口的职责很简单:给商品列表页返回一页商品数据,每条数据要包含商品基础信息、所属分类、所属品牌、店铺名称,以及该商品所有 SKU 里的最低价格。前端拿到之后就直接渲染成卡片。
原代码大致长这样,我抄了一段核心部分:
public function index(Request $request)
{
$page = (int) $request->param('page', 1);
$size = (int) $request->param('size', 20);
$catId = (int) $request->param('cat_id', 0);
$query = ProductModel::where('status', 1);
if ($catId > 0) {
$query->where('cat_id', $catId);
}
$total = $query->count();
$list = $query->order('id', 'desc')
->page($page, $size)
->select();
$result = [];
foreach ($list as $product) {
$category = CategoryModel::find($product['cat_id']);
$brand = BrandModel::find($product['brand_id']);
$shop = ShopModel::find($product['shop_id']);
$minPrice = SkuModel::where('product_id', $product['id'])
->min('price');
$result[] = [
'id' => $product['id'],
'title' => $product['title'],
'cover' => $product['cover'],
'price' => $minPrice,
'category' => $category['name'] ?? '',
'brand' => $brand['name'] ?? '',
'shop' => $shop['name'] ?? '',
];
}
return json(['total' => $total, 'list' => $result]);
}
不用往下看也知道,这就是教科书级的 N+1。一页 20 条数据,主查询 1 次,循环里每条 4 次查询,一共 1 + 20 × 4 = 81 次 SQL。这还只是首页,翻到后面数据量大的时候加上深分页的 LIMIT 100000, 20,不慢才怪。
但光知道慢没用,要有数据。我打开了慢查询日志。
第一步:让数据库自己说话
MySQL 的慢查询日志默认是关的。生产环境一般由 DBA 统一管理,我们这边是自己运维,直接改了配置:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
阈值设 0.5 秒,跑了一天。第二天捞文件,mysqldumpslow -s t -t 20 /var/log/mysql/slow.log 按总耗时排序,前二十条里我们的 SQL 占了七条。
最典型的一条:
SELECT * FROM tp_product WHERE status = 1 ORDER BY id DESC LIMIT 20 OFFSET 1800;
这条查询 Rows_examined 是 1820 行,但 Rows_sent 只有 20 行。意思是为了拿到 20 条数据,MySQL 扫了 1820 行,前面 1800 行全部被丢弃。这是经典的深分页问题。
再往下翻,还有一条:
SELECT MIN(price) FROM tp_sku WHERE product_id = 38421;
单条耗时只有 0.8 毫秒,但因为它被循环调用了 20 次,累计就是 16 毫秒。加上分类、品牌、店铺三次查询,光循环里就能吃掉 50 毫秒以上。
到这里,目标是清晰的:干掉循环里的查询、干掉深分页的 OFFSET 扫描。
第二步:用 with 预载入消掉循环查询
ThinkPHP 8 的关联预载入是 with(),用法很直接。前提是模型里要定义好关联关系。我们项目里字段命名还算规范,ProductModel 上先补上关联:
class ProductModel extends Model
{
protected $name = 'product';
public function category()
{
return $this->belongsTo(CategoryModel::class, 'cat_id', 'id');
}
public function brand()
{
return $this->belongsTo(BrandModel::class, 'brand_id', 'id');
}
public function shop()
{
return $this->belongsTo(ShopModel::class, 'shop_id', 'id');
}
public function skus()
{
return $this->hasMany(SkuModel::class, 'product_id', 'id');
}
}
然后查询改成:
$list = ProductModel::with(['category', 'brand', 'shop', 'skus'])
->where('status', 1)
->order('id', 'desc')
->page($page, $size)
->select();
这时 SQL 从 81 条降到 5 条:主查询 1 次,加上 cat、brand、shop 三次 IN 查询,再加 skus 一次 IN 查询。
但跑完第一次测试我就发现,skus 这个关联不能这么用。因为一个商品可能有几十个 SKU,一次带出来打底几百行数据。而我们真正需要的只是”最低价”这一个数字。预载入之后我还是要 foreach 遍历 skus 求 min。数据量大一点(比如一页 50 个商品,每个 20 个 SKU),就多拉了一千行冗余数据。
所以 skus 这个关联先从 with 里拿掉,我们用另一种方式算最低价。先记着这块。
第三步:延迟关联干掉深分页
深分页问题不是 ThinkPHP 独有的,是 MySQL 层面的。核心矛盾在于 OFFSET 之后的数据必须先扫过才能丢,扫描成本是 O(offset) 的。
延迟关联的思路是:先在小范围(只查主键)里完成分页,再由主键回表拿完整字段。因为主键索引是最窄的,扫 1800 行主键比扫 1800 行整行数据便宜太多。
改法是这样:
$ids = ProductModel::where('status', 1)
->order('id', 'desc')
->page($page, $size)
->column('id');
if (empty($ids)) {
return json(['total' => 0, 'list' => []]);
}
$list = ProductModel::with(['category', 'brand', 'shop'])
->whereIn('id', $ids)
->order('id', 'desc')
->select();
这样做有一个关键前提:id 必须是聚簇索引或者有独立的二级索引。我们的表主键就是自增 id,第一个查询完全可以走覆盖索引,扫 1800 行主键的成本大概在 3-5 毫秒。第二个查询是 WHERE id IN (...) 加 ORDER BY id DESC,20 个 id 走主键定位,直接随机读取,也是 1 毫秒以内的事。
不过这个改法有个副作用:因为分成了两次查询,中间是不加锁的,如果在这两次查询之间发生了数据变更,可能出现少量数据不一致(比如某个商品被下架了,主查询拿到了 id 但回表时查不到)。我们的业务场景能接受这一点。如果换成订单相关接口,就必须包在一个事务里,或者换用基于游标的分页。
第四步:用子查询聚合 SKU 最低价
回到刚才 SKU 最低价的问题。这里有几个方案,我们对比过。
方案一:with 预载入后 PHP 求 min。缺点已经说了,拉冗余数据。除非你同时需要展示 SKU 列表,否则不推荐。
方案二:主查询里 join 一个聚合子查询。
SELECT p.*, s.min_price
FROM tp_product p
LEFT JOIN (
SELECT product_id, MIN(price) AS min_price
FROM tp_sku
WHERE status = 1
GROUP BY product_id
) s ON s.product_id = p.id
WHERE p.status = 1
ORDER BY p.id DESC
LIMIT 20;
这个写法在 MySQL 8 里性能不错,因为子查询只跑一次,join 用的是派生表,MySQL 可以自动加索引。缺点是这条 SQL 不能直接用 ORM 链式写出来,得手写 SQL 或者用 Db::query。
方案三:用 ThinkPHP 的闭包 with + 关联统计。ThinkPHP 8 里可以这样写:
ProductModel::with(['category', 'brand', 'shop'])
->withSum('skus', 'price')
->where('status', 1)
...
但 withSum 是求和,不是求最小。要求最小,得自己写扩展或者用 withAttr 配合。这条路其实不太顺。
我们最后用的是方案二,直接手写 SQL 段通过 field 塞进去。虽然不太优雅,但性能最好,而且这种 SQL 写一次就固化,几乎不用改:
$list = Db::name('product')
->alias('p')
->leftJoin(
'(SELECT product_id, MIN(price) AS min_price FROM tp_sku '
. 'WHERE status = 1 GROUP BY product_id) s',
's.product_id = p.id'
)
->where('p.status', 1)
->whereIn('p.id', $ids)
->field('p.id, p.title, p.cover, p.cat_id, p.brand_id, p.shop_id, s.min_price')
->select();
注意这里我加了 field。原来用 select() 不带 field 是查 *,会多带一堆我们的列表页根本用不到的字段,比如商品详情、规格描述、搜索关键词等大字段(我们商品表里有几个 TEXT 字段)。这些字段光是从磁盘读出来就费劲,更别说网络传输。
改成指定字段之后,单次查询返回的数据量下降了 70% 左右,整体扫描时间少了大概 30 毫秒。
第五步:count 也要优化
这一块容易被忽略。商品列表接口返回 total,用来给前端做分页控件。原来的写法是 $query->count(),跑出来的 SQL 是 SELECT COUNT(*) FROM tp_product WHERE status = 1。
数据量小的时候没感觉,我们产品表有 600 多万行,status = 1 的占 85%,这个 count 每次都要扫全表。mysqldumpslow 里显示这条 SQL 平均耗时 320 毫秒,比主查询还慢。
ThinkPHP 8 里有个配置项叫 paginate 的 simple 模式,但那是 paginate 方法,不是我们手写的 count。要优化得自己动手。
改进方法有几种:
一是给 count 用的查询加索引。比如建一个联合索引 (status, id),count 就可以走索引覆盖扫描。600 万行数据里 85% 满足条件,扫描的还是 500 万行,改善有限。
二是改成”估算值”。用 SHOW TABLE STATUS 或者 information_schema.tables 里的 TABLE_ROWS 字段取近似值。缺点是精度差,可能差几个百分点。适用于”共 xxx 条数据”这种模糊展示。
三是缓存。列表页的 count 值对时效性要求不高,缓存 60 秒完全可接受。我们在 Redis 里用一个 key 存 product:list:count:{cat_id},TTL 60 秒。同一个分类一分钟内所有用户共享缓存,DB 压力直接降为零。
我们最后用的是缓存 + 索引结合的方式。复杂查询(比如带多条件筛选的)用缓存,简单查询(只按分类筛)走联合索引直接 count。这样 P99 从 320 毫秒降到了 8 毫秒。
顺手处理 EXPLAIN 里的隐性开销
把上面几个改完之后跑测试,P99 已经降到 180 毫秒。还剩一步。
把改后的主查询拿去做 EXPLAIN,看到几个字段:
+----+-------------+-------+------------+-------+---------------+
| id | select_type | table | type | key | rows |
+----+-------------+-------+------------+-------+---------------+
| 1 | PRIMARY | p | ref | idx_st| 2851 |
| 1 | PRIMARY | s | ref | auto | 15 |
+----+-------------+-------+------------+-------+---------------+
注意主查询的 rows 是 2851。它表示 MySQL 预估要扫的行数。我们明明只用 20 条数据,为什么还要扫 2851 行?
因为第一个查询是按 status = 1 和 id IN (...) 条件筛的,status 上有索引,id IN 是 20 个值。理论上应该只扫 20 行才对。EXPLAIN 显示 2851 是因为 MySQL 的优化器选错了索引——它优先用了 status 索引,然后在这个索引里过滤 id,等于把 status = 1 的所有行都过了一遍。
强制用 id 索引:
->whereIn('p.id', $ids)
->where('p.status', 1)
->force('PRIMARY')
这时候 rows 就降到 20 了。但这是一种妥协——生产环境里强制索引很容易在未来索引变化时翻车。更稳妥的做法是让优化器自己选对,比如调整查询条件顺序、或者把 status = 1 改成 status IN (1) 试试。我们的实际情况是加了 force 之后稳定了三个月,暂时保留,但代码里写了一条注释提醒。
不过这一段我要强调:除非你确定优化器选错了,否则不要随便 force 索引。MySQL 的优化器有好几种,我们当时是在 MySQL 8.0.32 上验证过,换成 8.0.35 或者 MariaDB,行为可能就变了。强制索引是个带未来风险的操作,不是万能药。
再加一层 Redis 缓存
180 毫秒虽然能接受,但还能优化。因为商品列表这个接口的缓存命中率天然很高:同一个分类的首页,三十万用户看到的是一模一样的数据,只要不新增商品或者改价,缓存就一直有效。
我们在接口最前面加了一层缓存:
$cacheKey = 'product:list:' . md5(json_encode([
'cat' => $catId,
'page' => $page,
'size' => $size,
]));
$cached = Cache::store('redis')->get($cacheKey);
if ($cached !== null) {
return json($cached);
}
// ... 执行查询 ...
Cache::store('redis')->set($cacheKey, $result, 30);
有几个细节必须说清楚,不然缓存会出事故。
缓存 key 一定要包含所有影响结果的参数。我们一开始只把 catId 拼进了 key,忘了 page 和 size。结果用户翻到第二页,看到的是第一页的数据。这种 bug 上线第一天就被用户投诉了。教训是:所有会影响查询结果的参数,都要参与 key 计算。
缓存时长给 30 秒是个折中。太长了后台运营改价用户看不到,太短了缓存命中率低。30 秒是我们根据业务节奏算的,如果你们的商品改价很频繁,就得改成 10 秒或者用主动删除的方式。
缓存穿透要防。有人可能会构造一个 cat_id = 99999999 这种不存在的分类刷接口,每次都穿透缓存打数据库。我们在 key 里加了 catId 存在性检查:如果分类 id 不在白名单里,直接返回空,不查数据库也不写缓存。
坑一:with 里的字段选择
用 with 预载入的时候,belongsTo 关联的模型默认会查所有字段。如果分类表、品牌表里有很大的字段(比如描述、图标 SVG 之类),每个关联都拉一大堆,白捞没用的。
ThinkPHP 8 里可以给 with 的关联指定字段:
ProductModel::with([
'category' => function ($query) {
$query->field('id, name');
},
'brand' => function ($query) {
$query->field('id, name');
},
'shop' => function ($query) {
$query->field('id, name');
},
])
注意:belongsTo 的关联字段必须包含外键(这里是 id),否则 ThinkPHP 没法对齐。如果不带 id,主表里的 cat_id 就匹配不上任何一条数据。
坑就在这。ThinkPHP 8 早期版本里,如果你 field 里漏掉了关联键字段,它不会报错,而是静默地返回空关联。我一度以为 with 没生效,排查了半天才发现是字段漏了 id。
坑二:字段顺序和 JSON 序列化
用 Db 门面查询返回的是数组,而用 Model 查询返回的是模型对象。这两者在 JSON 序列化的时候行为略有不同。比如模型上如果有 $hidden 属性,字段会被自动隐藏;而数组不会。
我们原来那段代码混用了 Model 和 Db,导致返回的字段名和前端约定的对不上:min_price 有时候有、有时候没有。最后统一改成 Db + 手动组装数组的方式,问题消失。统一写法有时比统一风格更重要。
坑三:page 参数没做下限
这是另一个隐蔽的性能坑。有人构造 ?page=999999&size=20,会生成一个 OFFSET 19999980 的查询。虽然延迟关联能挡住一部分成本,第一个查询(只查主键)还是要扫两千万行。这种请求偶尔几个没事,被刷起来就够呛。
处理方式很简单:$page = max(1, min($page, 500)),翻页最多到 500。再往下告诉前端”结果太多,请用筛选条件缩小范围”。大多数电商列表页都会做这个限制,用户也很少翻到 500 页之后。
最终的调用链
改完之后的完整流程大概是:
接口进来先校验参数范围,cat_id 在白名单内、page 在 1 到 500 之间、size 在 20 到 100 之间。然后查 Redis 缓存,命中直接返回。没命中就先执行 count,count 也走 Redis 60 秒缓存。然后第一个查询只拿 id 列表,第二个查询按 id 列表拉全量数据,SKU 最低价通过子查询 join 一次拿齐。最后组装返回,并异步写回缓存。
整个流程下来,数据库这边最多 3 次查询,缓存命中时 0 次。单次请求耗时从 1800 毫秒降到了 90 毫秒。我们统计了一下,命中缓存的时候大概是 8 毫秒。
总结一下思路
这套改法没有用任何黑科技。核心就三句话:
不要在循环里查数据,用预载入或者一次批量查询把 N+1 干掉。大偏移量分页用延迟关联,先拿主键再回表。给所有查询加上合理的字段筛选,别用 SELECT *。
剩下的都是围绕着这三句话的工程细节:count 缓存、Redis 前置缓存、参数范围限制、EXPLAIN 分析、索引梳理。
最后一点经验:不要一上来就怀疑是框架慢。ThinkPHP 8 的 ORM 本身没有明显性能问题,我们遇到的慢全是因为写法。一个循环里查 20 次,换成任何框架都会慢。先看自己写了什么 SQL,再看框架本身。这个顺序反了,永远优化不到点子上。

