缓存站的阅读量统计:PHP 不执行了,计数该怎么做-第三维度MokIII

一个自相矛盾的需求

站点上了静态缓存(缓存命中时 nginx 直接返回 HTML,根本不执行 PHP),但文章页又需要显示真实的阅读量。

这两件事天然冲突:计数逻辑写在 PHP 里,而缓存命中的请求不进 PHP。

一、症状:阅读量退化成"每小时 +1"

主题自带的计数挂在 wp_head:

function pandapro_record_visitors() {
    if ( is_singular() ) {
        // 把当前文章的 views +1
    }
}
add_action( 'wp_head', 'pandapro_record_visitors' );

它只在生成缓存页的那一次请求里执行过。之后所有访客拿到的都是那份静态 HTML,计数不再发生。

我实测过一个 URL:

缓存未命中 → views +1
缓存命中(0.039 秒直出)→ views +0

而缓存的有效期是 1 小时。于是阅读量实际变成了 "每个 URL 每小时最多 +1"——和真实访问量完全脱钩。

二、思路:把"服务端执行"换成"客户端上报"

既然服务端不执行了,那就让浏览器来上报。三件事:

  1. 摘掉主题的 wp_head 计数(否则缓存未命中时会 +2);
  2. 在页面底部输出一段极小的埋点脚本——它会随页面一起被写进静态缓存文件,所以缓存命中时也会执行;
  3. 上报走 admin-ajax.php(这个地址不会被缓存),服务端把 views +1。

关键点:埋点必须"能进缓存"

这是整个方案的巧妙处——我们不缓存计数结果,我们缓存"触发计数的动作"。埋点是一段静态的 JS,它对所有访客都一样,因此和页面缓存完全兼容。

三、代码

<?php
/** ① 摘掉主题的 wp_head 计数(不改主题文件) */
add_action( 'wp_head', function () {
    remove_action( 'wp_head', 'pandapro_record_visitors' );
}, 0 );

/** ② 单篇页输出埋点 */
add_action( 'wp_footer', function () {
    if ( is_admin() || is_preview() || is_feed() ) { return; }
    if ( ! is_singular( array( 'post', 'news' ) ) ) { return; }
    $post_id = (int) get_queried_object_id();
    if ( $post_id <= 0 ) { return; }
    ?>
<script id="view-beacon">
( function () {
  try {
    var fd = new FormData();
    fd.append( 'action', 'my_view' );
    fd.append( 'post_id', <?php echo (int) $post_id; ?> );
    fd.append( '_nonce', <?php echo wp_json_encode( wp_create_nonce( 'my_view' ) ); ?> );
    var url = <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>;
    if ( navigator.sendBeacon ) {
      navigator.sendBeacon( url, fd );          // 不阻塞页面卸载
    } else {
      var xhr = new XMLHttpRequest();
      xhr.open( 'POST', url, true );
      xhr.send( fd );
    }
  } catch ( e ) {}
} )();
</script>
    <?php
}, 999 );

/** ③ 计数入口 */
add_action( 'wp_ajax_my_view', 'my_view_increment' );
add_action( 'wp_ajax_nopriv_my_view', 'my_view_increment' );

function my_view_increment() {
    check_ajax_referer( 'my_view', '_nonce' );

    $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
    if ( $post_id <= 0 ) { wp_die( '0' ); }

    $post = get_post( $post_id );
    if ( ! $post instanceof WP_Post
        || wp_is_post_revision( $post_id )
        || ! in_array( $post->post_type, array( 'post', 'news' ), true )
        || ! is_post_publicly_viewable( $post ) ) {
        wp_die( '0' );
    }

    update_post_meta( $post_id, 'views', (int) get_post_meta( $post_id, 'views', true ) + 1 );
    wp_die( '1' );
}

四、三个必须做的校验

这个接口是 未登录访客也能调用 的(wp_ajax_nopriv_),不设防会被滥用:

  1. check_ajax_referer():必须带 nonce,挡住跨站伪造的批量请求;
  2. 校验文章真实存在 + 类型在白名单内:update_post_meta() 对不存在的 ID 也会建行,不校验的话,别人可以遍历 ID 空间把你的 wp_postmeta 表撑大;
  3. is_post_publicly_viewable():草稿、私密文章不计数。

五、两个附带的好处

  • 不执行 JS 的爬虫天然不计数——比"按 IP 去重"简单得多,也不会误伤;
  • navigator.sendBeacon():页面卸载时也能发出请求,不会拖慢跳转,也不会被浏览器取消。

六、怎么验证

# 1) 缓存命中的页面里,应该能看到埋点(说明它被写进静态文件了)
curl -s https://www.example.com/123.html | grep -c 'view-beacon'

# 2) 连续访问几次,看 views 是否真的在涨
#    (在后台或数据库里看 wp_postmeta 的 views 值)

再顺便确认一件事:缓存未命中时不要 +2——把埋点输出和 wp_head 计数同时留着就会双计,所以第 ① 步的 remove_action 不能省。


一句话总结:静态缓存和"服务端计数"天生冲突,解法不是绕过缓存,而是把计数从服务端搬到客户端——缓存里存"触发计数的动作"(一段静态 JS),请求再走不会被缓存的 admin-ajax.php。前提是接口要做 nonce + 存在性 + 可见性三重校验。