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