我给 19 篇文章补上了特色图片。WordPress 后台显示得好好的,缩略图也生成了,但页面源码里没有 og:image。
更奇怪的是:同一个页面的 JSON-LD 结构化数据里,thumbnailUrl 已经指向了新封面。
这个组合——schema 有、og 没有——就是诊断的关键。它把问题范围一下子缩到了一处。
为什么这个组合能定位问题
Yoast 输出的这两样东西走的是不同的数据通路。
JSON-LD 里的图片是在渲染那一刻现算的:读文章的 _thumbnail_id,取附件 URL,拼进 schema。所以只要特色图片真的设上了,它立刻就有。
而 og:image 这类社交元标签,Yoast 是从一张预计算的索引表里读的——wp_yoast_indexable。这张表为每篇文章存了一行,里面有 open_graph_image、twitter_image、标题、描述等等,目的是避免每次请求都重算。
问题就在这里:这行数据是文章保存时写的。我是用 wp media import --featured_image 挂的封面,这条路径没有触发 Yoast 的索引更新钩子,于是表里那行还留着”没有图片”的旧值。
所以这个组合是个可靠的判据:
- schema 有图、og 没有 → 特色图片是好的,索引表是旧的
- 两个都没有 → 特色图片压根没设上,先去查
_thumbnail_id
如果不看 schema,只看到 og:image 缺失,很容易怀疑是特色图片没设成功,然后去反复重设——而那一步永远不会改变结果。
这张表里还存了什么
值得知道 wp_yoast_indexable 的范围,因为陈旧的不会只有图片一项。这张表每行大致包含:
SEO 标题和描述、canonical、robots 指令(noindex/nofollow/noarchive)、Open Graph 的标题、描述、图片及其宽高、Twitter 卡片的对应字段、面包屑用的标题、以及内容分析的评分。
也就是说,凡是走这张表的字段,都可能一起陈旧。我这次只注意到图片,是因为图片是我刚改的;如果同一批脚本还改了标题或描述,那些同样不会反映到页面上。
所以定位之后,检查范围应该扩大一圈:
wp db query "SELECT object_id, title, description, open_graph_image, is_robots_noindex
FROM ${PREFIX}yoast_indexable
WHERE object_type='post' AND object_id IN (1031,1032);"
把这几列和文章实际该有的值对一遍,比只盯着一个字段可靠。
怎么确认是不是这个原因
直接查那张表。先拿到表前缀,再看这些文章的那一列:
PREFIX=$(wp db prefix)
wp db query "SELECT object_id, open_graph_image
FROM ${PREFIX}yoast_indexable
WHERE object_type='post' AND object_id IN (1031,1032,1033);"
如果 open_graph_image 是 NULL 而文章确实有特色图片,就确诊了。
修法
最干净的做法是删掉这些行。Yoast 在下次访问该文章时发现没有索引行,会重新构建一条:
wp db query "DELETE FROM ${PREFIX}yoast_indexable
WHERE object_type='post' AND object_id IN (1031,1032,1033);"
听起来粗暴,实际上是安全的——这张表是缓存,不是数据源。里面每一个字段都能从文章本体、附件和 Yoast 设置重新推导出来。删掉只会让下一次请求慢一点点。
删完之后要先访问一次页面触发重建,再去检查结果。我第一次删完立刻检查,看到的还是旧的,差点以为方法不对。
# 第一次请求触发重建,第二次才是真实结果
curl -s -o /dev/null "$URL"
curl -s "$URL" | grep -o 'og:image[^>]*'
另外别忘了 CDN。如果站点前面有边缘缓存,你拿到的可能是它缓存的旧 HTML——加个随机查询参数绕过去,确认无误后再 purge。
更省事的做法
Yoast 提供了重建命令,不用直接碰数据库:
wp yoast index --reindex
缺点是它会重建全站索引。文章多的话要跑很久,而且期间数据库压力不小。只有十几篇要修的时候,定点删除那几行更划算。
哪些操作会绕过写入路径
知道了原因,更有用的是知道什么时候会踩到。后台点”更新”会走完整的 save_post 生命周期,Yoast 挂在上面的钩子随之刷新索引。真正危险的是那些不经过这条路的操作:
WP-CLI 的媒体与元数据命令。wp media import --featured_image 本质上是设 _thumbnail_id 这个 postmeta,不触发文章保存。wp post meta update 同理。
直接写数据库。用 SQL 改 wp_posts 或 wp_postmeta,什么钩子都不会跑。
批量迁移脚本。很多导入工具为了速度会用 wp_insert_post 配合 $wpdb 直写,或者显式挂起钩子。
某些插件的批量编辑。取决于实现,有的走标准 API,有的为了性能绕过去。
反过来,wp post update 会触发保存钩子,所以如果只是想让索引刷新,最省事的其实是把文章原样保存一次:
wp post update 1031 --post_status=publish
这条命令什么内容都没改,但走完了保存流程,索引会跟着重建。批量的话配合 wp post list --format=ids 即可。
为什么不能只在后台确认
这次最值得记的一点:后台看到的和访客看到的,读的可能不是同一份数据。
WordPress 后台的文章编辑界面读的是 wp_posts 和 wp_postmeta——也就是我刚刚写进去的那份源数据。它当然显示正确,因为它读的就是我写的东西。而访客拿到的 HTML 里,有一部分字段来自那张派生出来的索引表。
这两者之间隔着一次同步。同步没发生时,后台是绿的,前端是错的,而且没有任何地方会报错——不是 500,不是警告,只是一个本该存在的标签没有出现。
所以脚本批量改完内容之后,那一步”挑一条改动,从前端确认”不是多余的谨慎。它是唯一能发现这类静默失配的方法。
我这次是在做全站体检时顺手查 og:image 才发现的。如果没做那次体检,19 篇文章会带着空的社交卡片一直挂着——分享出去没有配图,而后台一切正常。
清缓存的顺序错了,等于没清
这个站点上从一次改动到用户看到,中间隔着的不止 Yoast 那张表。至少有四层,每一层都可能存着上一层的旧结果:
数据库(源数据)
→ Yoast indexable 表(派生的 SEO 元数据)
→ WordPress 对象缓存 / transient(派生的页面片段)
→ 页面缓存(整页 HTML)
→ CDN 边缘缓存(离用户最近的一份)
关键在于必须从源头往外清。如果先清 CDN 再清 Yoast 表,那么在清 Yoast 之前的那段时间里,任何一个请求都会让 CDN 重新从源站取一份——而源站此时给出的仍然是旧的 og:image。结果是缓存被”清”了,里面装的还是旧内容,而且你已经用掉了这次清缓存的机会。
正确顺序就是上面那个列表从上到下:先改数据库,再删 indexable 行,再清对象缓存和页面缓存,最后才 purge CDN。每一步都要等上一步真正生效——否则下游会把还没更新的上游内容重新缓存进来。
验证也要按相反的方向做:先用带随机参数的 URL 确认源站已经对了,再用普通 URL 确认边缘也跟上了。只测其中一个都会得到误导性的结论——只测随机参数会以为全好了(边缘可能还在发旧的),只测普通 URL 会以为没修好(源站可能早就对了)。
怎么系统性地找出所有派生缓存
上面那四层是这个站点的情况,别的系统不一样。但找出它们的方法是通用的:改一个字段,然后从最终用户看到的地方倒着往回查,看这个改动在哪一层停住了。
具体做法是准备一个探针值——一个不会在别处出现的字符串,比如把标题临时改成 ZZTEST-20260808,然后逐层查它是否出现:
# 1. 源数据
wp post get $ID --field=post_title
# 2. 派生表
wp db query "SELECT title FROM wp_yoast_indexable WHERE object_id=$ID"
# 3. 源站渲染(带随机参数绕开所有缓存)
curl -s "https://example.com/slug/?nc=$RANDOM" | grep -o 'ZZTEST-[0-9]*'
# 4. 边缘(普通地址)
curl -sI "https://example.com/slug/" | grep -i cf-cache-status
探针值消失在哪一层,哪一层就有一份需要单独失效的缓存。这个方法的好处是不依赖你事先知道系统里有几层缓存——它是从现象反推出来的,所以连你不知道存在的那些层也会被发现。
做完之后把结果写下来。这类知识很难靠读代码得到(缓存往往分散在插件、主题、服务器配置和 CDN 面板里),但一旦记录下来,下次改任何东西都能直接照着清,不用重新排查一遍。
这类问题的一般形状
这件事本身是个 Yoast 的实现细节,但它的形状很常见:一份数据有两条通路,一条实时算、一条走缓存,而写入操作只更新了其中一条。
触发条件通常是绕过常规写入路径。后台点”更新”会走完整的保存钩子,缓存跟着刷新;而 WP-CLI、REST API、直接改数据库、或者某个插件的批量操作,都可能只改了源数据。
所以凡是用脚本批量改内容之后,值得多做一步:挑一条改动,从前端确认它真的生效了——不是在后台看,是看访客拿到的 HTML。后台读的往往就是你刚写的那份源数据,它当然是对的。
这次我如果只在后台确认”封面设上了”就收工,19 篇文章会带着空的 og:image 一直挂着,而且不会有任何报错。