本文约 5,364 字,预计阅读 14 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:加了 ACF 之后,列表页从 300ms 变成 3 秒
做过 ACF 项目的人几乎都遇到过这个曲线:刚上线时页面 300ms,字段加到二十几个、文章涨到几千篇之后,列表页首字节时间稳定在 2–3 秒,数据库 CPU 常年 60% 以上。慢查询日志里全是 SELECT ... FROM wp_postmeta WHERE post_id = ...。
直觉反应是”ACF 太重了”,于是有人换 Meta Box,有人干脆手写 meta。但换插件之后往往只快一点点,因为瓶颈不在 ACF 的函数开销,而在查询次数和查询形态这两件事上。前者可以通过缓存预热解决,后者(尤其是 meta_query 和 LIKE '%...%')跟用哪个插件关系不大,是 postmeta 表结构的固有代价。
这篇文章假定你已经会用 get_field()(若还不熟,先看 Advanced Custom Fields 入门教程),重点解决”怎么让它跑得快”。
原理分析:一次 get_field 到底查了几次库
ACF 5/6 的取值链路大致是:get_field() → acf_get_value() → acf_get_metadata() → WordPress 的 get_metadata()。关键在于 WordPress 的元数据缓存机制:
get_post_meta( $id, 'key', true )第一次调用时,WordPress 其实会把这篇文章的所有 meta 一次性查出来放进缓存(update_meta_cache),后续同 ID 的调用都走内存,不再查库。- 也就是说,同一篇文章读 20 个字段,理想情况下只有 1 次查询。
- 但如果你在列表页循环里读 20 篇文章 × 20 个字段,且没有预热,就可能变成 20 次查询(每篇文章一次全量拉取);如果中间还混了
acf_get_meta()之外的路径或缓存被清掉,数字会更难看。
真正致命的往往是另外三类查询:
| 慢查询形态 | 成因 | 代价 |
|---|---|---|
| N+1 meta 查询 | 循环内未预热 meta 缓存 | 数据库连接数暴涨 |
meta_query 排序/筛选 |
postmeta 无有效索引(只有 post_id + meta_key 前缀索引) |
全表扫描,Using filesort |
LIKE '%值%' |
需要匹配序列化数据(Repeater 子字段) | 索引完全失效 |
SELECT * FROM postmeta |
get_fields() 在超大 meta 行上调用 |
单行几 MB 也会拖慢 |
Repeater 的存储形态尤其要记住:一个 20 行、3 个子字段的 Repeater,在 wp_postmeta 里是 1 条行数记录 + 60 条子字段记录,meta_key 形如 specs_0_label、specs_1_label。想按 Repeater 里某个值筛选文章,只能写出 meta_key LIKE 'specs_%_label' AND meta_value = 'xxx' 这种查询,索引必然失效。
保姆级实操
第一步:先量化,别猜
在 wp-config.php 里打开查询记录:
define( 'SAVEQUERIES', true );
然后在页脚把数字打出来(只对管理员可见):
add_action( 'wp_footer', function () {
if ( ! current_user_can( 'manage_options' ) || ! defined( 'SAVEQUERIES' ) ) {
return;
}
global $wpdb;
printf(
'<div style="padding:8px;background:#222;color:#0f0;font:12px monospace">查询 %d 次,页面耗时 %.3fs</div>',
get_num_queries(),
timer_stop( 0, 3 )
);
// 打印最慢的 5 条
$slow = $wpdb->queries;
usort( $slow, fn( $a, $b ) => $b[1] <=> $a[1] );
foreach ( array_slice( $slow, 0, 5 ) as $q ) {
echo '<pre style="color:#0f0">' . esc_html( $q[0] ) . ' — ' . esc_html( number_format( $q[1] * 1000, 1 ) ) . 'ms</pre>';
}
}, 999 );
SAVEQUERIES 本身有开销,只在排查时开,生产环境务必关掉。
第二步:循环前预热 meta 缓存
这是投入产出比最高的一招。WP_Query 默认会自动预热主循环文章的 meta 缓存,但如果你手动拼 ID 数组查询,就得自己做:
$query = new WP_Query( array(
'post_type' => 'product',
'posts_per_page' => 20,
'no_found_rows' => true, // 不需要分页时省掉一次 COUNT
'fields' => '', // 保持默认,才能触发 meta 预热
) );
// 手动预热:一次查询拉走 20 篇文章的全部 meta
update_meta_cache( 'post', wp_list_pluck( $query->posts, 'ID' ) );
foreach ( $query->posts as $p ) {
echo esc_html( get_field( 'model', $p->ID ) ); // 不再查库
}
注意 fields => 'ids' 会让查询变快,但也不会预热 meta,后续 get_field() 会退化成 N+1,取舍要看后面用不用字段。
第三步:简单字段直连 get_post_meta
acf_get_value() 会经过一系列过滤器(acf/load_value、acf/format_value)和字段查找逻辑。对于纯文本、数字这类不需要格式化的字段,直接读 meta 更快也更可控:
// 需要被 ACF 格式化处理(图片数组、选项 label、WYSIWYG)时用 get_field
$gallery = get_field( 'photos', $post_id );
// 纯文本/数字/布尔,直接读,少走一层
$model = get_post_meta( $post_id, 'model', true );
$is_new = ( '1' === get_post_meta( $post_id, 'is_new', true ) );
第四步:一页要用很多字段时,先 get_fields 一次
$fields = get_fields( $post_id ); // 内部走 acf_get_meta() → get_post_meta( $id ),一条查询拿全量
echo esc_html( $fields['model'] ?? '' );
echo esc_html( $fields['summary'] ?? '' );
ACF 会把这份元数据存在自己的内存 store 里,之后的 get_field() 不再重复查库。反过来,如果一页只用一两个字段,就别调 get_fields(),它会把几 MB 的 Repeater 数据也一起拉出来。
第五步:装持久化对象缓存
默认的 WP 对象缓存是单次请求内有效的,请求结束即失效。装 Redis 持久化缓存(如 Redis Object Cache 之类插件,本质是放一个 object-cache.php drop-in 到 wp-content/)之后,meta 缓存跨请求复用,列表页的第二三次访问几乎零查询:
// wp-config.php
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_CACHE_KEY_SALT', 'mimangfei_' ); // 多站共用 Redis 时必须区分
define( 'WP_REDIS_MAXTTL', 3600 );
装完在后台确认状态是 Connected,并 wp cache flush 一次清掉旧缓存。
第六步:把需要筛选的字段换成 taxonomy
如果业务上确实要”按品牌筛选产品””按地区筛选门店”,不要用 meta_query,用自定义分类法:
$query = new WP_Query( array(
'post_type' => 'product',
'tax_query' => array(
array(
'taxonomy' => 'brand',
'field' => 'slug',
'terms' => 'siemens',
),
),
) );
wp_term_relationships 表有 (object_id, term_taxonomy_id) 联合索引,筛选效率和 postmeta 完全不是一个量级。品牌这类有限枚举值,就应该建模成分类而不是字段。
常见踩坑
- 开了
SAVEQUERIES忘了关,生产环境多出 10%–20% 的 PHP 开销,还会占内存。 fields => 'ids'和get_field()混用,以为省了一次查询,结果后面补了 N 次。meta_query里对数字字段用type => 'CHAR',导致ORDER BY按字符串排序,10排在9前面。数值字段务必写'type' => 'NUMERIC'。- 缓存插件清了对象缓存但没清页面缓存,页面缓存里的旧 HTML 还在,看起来像”优化没生效”。
- Repeater 存了上千行,单篇文章 meta 总量几 MB,
get_fields()一次拉全量反而更慢,应按需\d+行读取或直接改数据表。 - 对象缓存没设
WP_CACHE_KEY_SALT,同一台机器两个站点共用一个 Redis 库,数据串了,A 站读到 B 站的字段值。 - 案例: 一个工业品站的产品列表页 TTFB 长年 2.8 秒。慢查询日志显示每条
SELECT meta_value FROM wp_postmeta WHERE post_id = X只要 0.4ms,但一页 20 篇 × 12 个字段触发了 240 次。加上update_meta_cache( 'post', $ids )预热并把纯文本字段换成get_post_meta()直读后,查询从 260 次降到 34 次,TTFB 降到 420ms;再装 Redis 持久化对象缓存,二次访问降到 90ms。整个过程没有换插件、没有加服务器,只是把查询次数压下去。
总结
优化顺序不要乱,按这个清单走:
- 用
SAVEQUERIES+get_num_queries()先量化,确认慢在查询次数还是单条查询; - 循环前
update_meta_cache()预热,消灭 N+1; - 能直读的纯文本字段用
get_post_meta(),需要格式化的才用get_field(); - 装持久化对象缓存,让缓存跨请求复用;
- 需要筛选排序的字段改建成分类法,绕开
meta_query与LIKE; - 数据量大到 postmeta 扛不住时,考虑自建数据表。
判断标准一句话:列表页的查询次数应该接近常数,而不是随文章数线性增长。 如果你的列表页文章从 10 篇涨到 50 篇,查询数也跟着翻五倍,那一定有 N+1 没解决。后续可以配合 WordPress 数据库清理与优化 把 postmeta 里的历史垃圾一起清掉;如果字段与文章类型还要做更复杂的建模,Meta Box 与 Pods 插件对比 里讲了什么时候该换存储方案。
