搜索全站
性能优化 20 分钟阅读

WordPress 文章点赞与收藏功能:数据存储方案与缓存冲突处理

点赞数被缓存,用户点了半天数字不变。本文对比 post meta / 自定义表 / 独立计数服务三种存储方案的性能差异,给出 WTI Like Post 与自研 AJAX 点赞的完整实现,重点解决全站缓存下的计数同步问题。

U
UFO_PF 2024-06-13 · 阅读 20 分钟
Wo 性能优化
阅读提示
本文约 7,672 字,预计阅读 20 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:点一次赞,数字闪一下又变回去

这是 WordPress 上做UGC互动最典型的翻车现场:用户在文章底部点了赞,数字从 12 变成 13,然后刷新页面又回到 12;或者在另一个用户的浏览器上永远是 12。运营不知道到底有多少人点了,用户觉得这个站”坏了”。

根因是三层東西叠在一起:

第一层:全站页面缓存。 页面 HTML 被 WP Rocket / Nginx FastCGI / Cloudflare APO 缓存时,那个”12″是缓存生成那一刻的快照,后续所有点赞都不会改变它,除非清缓存——而清缓存显然不现实。

第二层:CDN/浏览器缓存 AJAX 请求。 即使你聪明地用 AJAX 更新计数,某些 CDN 会把 POST /wp-admin/admin-ajax.php 也缓存(因为路径相同),不同用户拿到同一个结果。

第三层:登录用户与访客的页面不一致。 不同用户的登录态不同,如果改用”按用户缓存”,缓存碎片率会直接爆炸,TTFB 反而上升。

所以要做一个能用的点赞/收藏功能,必须同时解决三件事:数据存哪、如何写入、前端怎么读。

原理分析:三种存储方案的取舍

方案 写入成本 查询性能 能否避免重复点赞 适合规模
post meta(update_post_meta) 中,行锁竞争 中,需 join postmeta 表 靠 cookie/IP/用户 ID 辅助 小站(<1 万 PV/日)
自定义表 + 唯一索引 低 高,可建复合索引 数据库层面唯一约束,最可靠 中大型站
Redis / 独立计数服务 极低 极高 需自行去重 高并发

为什么自定义表最可靠:点赞本质是”多对多关系(用户对文章的一次行为)”,而 post meta 只能存”文章上的一个标量计数”。你無法用 post meta 干净地回答”这个用户给这篇文章点过赞吗”——除非再存一份 user meta,那两份数据就会不一致。

正确的表设计应当同时对 (post_id, user_key) 建唯一索引,让数据库替你去重:

CREATE TABLE wp_post_likes (
    id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    post_id bigint(20) unsigned NOT NULL,
    user_key char(64) NOT NULL,        -- 登录用户: 'u_'.ID ;未登录: 'ip_'.hash
    created_at datetime NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uniq_post_user (post_id, user_key),
    KEY idx_post (post_id)
);

第二条关键认知:计数可以不同步,状态必须同步。 用户点完赞之后,最影响体验的是”按钮有没有变成已点赞状态”,这个必须立刻生效(前端 local state 就能做);至于”总数是多少”,晚几秒更新、甚至只在特定时机刷新都没人在意。

保姆级实操

步骤 1:插件方案快速上线——WTI Like Post

WTI Like Post(”WTI Like Post – Post Like Dislike System”)是 free 插件里比较稳的一个,支持点赞/点踩、支持 IP + 用户 ID 去重、自带 widget。安装后在「WTI Like Post → Settings」配置以下几项:

  • Login Option:选 “Allow visitors + logged in users”
  • Voting Type:Thumb Up / Down(或只有 Like)
  • Post Types:只勾 post
  • 也可以用它的 wti_like_post() 模板标签手工插入。

它底层用的是自定义表(前缀 wti_like_post),比 post meta 方案健壮。缺点是它会往 wp_head 里插入自己的 JS,且点赞次数写在 HTML 里同样会被页面缓存。

步骤 2:自己实现——建表与写入

function mf_install_like_table() {
    global $wpdb;
    $table   = $wpdb->prefix . 'post_likes';
    $charset = $wpdb->get_charset_collate();
    $sql = "CREATE TABLE {$table} (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        post_id bigint(20) unsigned NOT NULL,
        user_key char(64) NOT NULL,
        created_at datetime NOT NULL,
        PRIMARY KEY (id),
        UNIQUE KEY uniq_post_user (post_id, user_key),
        KEY idx_post (post_id)
    ) {$charset};";
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
}
register_activation_hook( __FILE__, 'mf_install_like_table' );

步骤 3:AJAX 写入(幂等、不可枚举)

add_action( 'wp_ajax_nopriv_mf_toggle_like', 'mf_toggle_like' );
add_action( 'wp_ajax_mf_toggle_like', 'mf_toggle_like' );
function mf_toggle_like() {
    check_ajax_referer( 'mf_like', 'nonce' );

    $post_id = absint( $_POST['post_id'] ?? 0 );
    if ( ! $post_id || 'post' !== get_post_type( $post_id ) ) {
        wp_send_json_error( 'bad post', 400 );
    }

    global $wpdb;
    $table = $wpdb->prefix . 'post_likes';
    $key   = is_user_logged_in()
        ? 'u_' . get_current_user_id()
        : 'ip_' . wp_hash( $_SERVER['REMOTE_ADDR'] ); // 不存明文 IP

    // 利用唯一索引实现幂等 toggle
    $existing = $wpdb->get_var( $wpdb->prepare(
        "SELECT id FROM {$table} WHERE post_id = %d AND user_key = %s", $post_id, $key
    ) );

    if ( $existing ) {
        $wpdb->delete( $table, array( 'id' => $existing ), array( '%d' ) );
        $liked = false;
    } else {
        $wpdb->insert( $table, array(
            'post_id'    => $post_id,
            'user_key'   => $key,
            'created_at' => current_time( 'mysql', 1 ),
        ), array( '%d', '%s', '%s' ) );
        $liked = true;
    }

    $count = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$table} WHERE post_id = %d", $post_id
    ) );

    // 同步一份到 post meta,方便 WP_Query 的 meta 排序/列表展示使用
    update_post_meta( $post_id, '_mf_like_count', $count );

    wp_send_json_success( array( 'count' => $count, 'liked' => $liked ) );
}

步骤 4:前端——先改状态,再要数字

<button type="button" class="mf-like" data-post="123" aria-pressed="false">
    <span class="mf-like-icon">♥</span>
    <span class="mf-like-count">12</span>
</button>
document.querySelectorAll('.mf-like').forEach(function (btn) {
    btn.addEventListener('click', function () {
        var cur = 'true' === btn.getAttribute('aria-pressed');
        // 乐观更新:先把状态与数字改掉,网络失败再回滚
        btn.setAttribute('aria-pressed', String(!cur));
        var countEl = btn.querySelector('.mf-like-count');
        var old = parseInt(countEl.textContent, 10) || 0;
        countEl.textContent = cur ? Math.max(0, old - 1) : old + 1;

        fetch('/wp-admin/admin-ajax.php', {
            method: 'POST',
            credentials: 'same-origin',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
                action: 'mf_toggle_like',
                post_id: btn.dataset.post,
                nonce: window.mfLikeNonce
            })
        }).then(function (r) { return r.json(); })
          .then(function (res) {
              if (!res.success) { throw new Error(res.data || 'fail'); }
              countEl.textContent = res.data.count;
              btn.setAttribute('aria-pressed', String(res.data.liked));
          }).catch(function () {
              // 回滚
              btn.setAttribute('aria-pressed', String(cur));
              countEl.textContent = old;
          });
    });
});

先把 nonce 放到页面:

wp_localize_script( 'mf-like', 'mfLikeNonce', wp_create_nonce( 'mf_like' ) );

步骤 5:解决缓存冲突(最关键)

三条必须同时做:

// 1) 排除 admin-ajax.php 被缓存
add_filter( 'rocket_never_cache_wp_admin_content', '__return_true' ); // WP Rocket
# 2) Nginx FastCGI Cache:别缓存 AJAX 请求
location = /wp-admin/admin-ajax.php {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    fastcgi_cache off;
}

Cloudflare 规则:Page Rule 或 Cache Rules:若路径为 /wp-admin/admin-ajax.php → Cache Level = Bypass。

步骤 6:列表页显示”最赞”文章

因为计数已同步到 post meta,可以用常规查询:

$top = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 10,
    'meta_key'       => '_mf_like_count',
    'orderby'        => 'meta_value_num',
    'order'          => 'DESC',
    'date_query'     => array( array( 'after' => '90 days ago' ) ),
) );

常见踩坑

  1. admin-ajax.php 被 Cloudflare 缓存:这是最经典的故障,所有人拿到同一份 JSON,务必设为 Bypass。
  2. 用 update_post_meta 做自增导致计数漂移:get_post_meta → +1 → update 是非原子操作,并发时会丢计数。要么用上面的”按行计数”,要么在事儿极小规模时用 UPDATE ... SET meta_value = meta_value + 1 原子 SQL。
  3. 案例:某社区站点用某免费点赞插件,插件把计数 post meta 直接写进文章 HTML,同时站点开了 Cloudflare APO 全页缓存。结果用户点赞后刷新,数字回到缓存中的旧值,但 AJAX 写入其实成功——于是用户反复点击,唯一索引虽然在数据库层面去重了,运营却看到”点赞数增长极慢”,判断为插件故障,连换了三个插件。真正的解法只有一条:把计数从被缓存的 HTML 中剥离出来,让前端每次用 AJAX 重新读取。教训:页面缓存场景下,动态数字绝不写在 HTML 里。
  4. 未登录用户用明文 IP 做 key:涉及 GDPR/《个保法》,必须哈希后再存(上面 wp_hash() 已处理)。
  5. nonce 过期:WordPress 的 nonce 默认 24 小时有效期,页面被缓存超过这个时长会让 AJAX 报 -1。处理:把 nonce 有效期延长(nonce_life 过滤器)或定时刷新 nonce。
  6. 忘了 die() / wp_send_json_*:admin-ajax 的回调如果不结束请求,会返回多余的 0。
  7. REST_ROUTE 比 admin-ajax 更适合:新项目建议用 register_rest_route() 而不是 admin-ajax.php,这样路径 /wp-json/mf/v1/like 天然不会被页面缓存规则命中。
  8. 恶意刷赞:唯一索引只能保证每人对每篇文章一次;对批量脚本仍需限速,可用 transient 做简单节流(同一 IP 60 秒内不超过 10 次),或在 CDN 层开启 Bot 防护。

延伸阅读:WordPress REST API 鉴权

总结

选型口径:小站点、追求快速上线 → WTI Like Post 直接上手;有开发能力、数据要留痕 → 自建自定义表((post_id, user_key) 唯一索引是去重的关键)。无论哪种,落地必须做到三条:①计数不写在被缓存的 HTML 里,前端用 AJAX 读;②admin-ajax.php(或 REST 路由)必须在 CDN / 页面缓存里 Bypass;③前端做乐观更新,网络失败要能回滚。做到这三条,点赞功能才算”能用”,否则它只是一个会让人怀疑站点坏了的装饰品。

延伸:WordPress 缓存与动态内容冲突排查

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。