建立高併發、防刷單、體驗流暢的「讚」系統:前端 jQuery 動畫回饋、Ajax 非同步請求、後端 PHP + Redis 原子計數、IP+Cookie 雙重去重、速率限制、CSRF 保護、數據一致性最終一致同步至 MySQL。
-- MySQL 永久儲存
CREATE TABLE posts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
likes_count INT UNSIGNED DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE post_likes (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
ip_hash CHAR(64) NOT NULL, -- SHA256(ip + secret)
cookie_id CHAR(32) NOT NULL, -- 隨機 16 bytes hex
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_like (post_id, ip_hash, cookie_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
// Redis 結構 (熱資料)
/*
like:count:{post_id} -> STRING (計數器)
like:ips:{post_id} -> SET (IP hash 集合)
like:cookies:{post_id} -> SET (Cookie ID 集合)
like:lock:{post_id} -> 鎖 (防止並發競態)
*/
<article class="post" data-post-id="123">
<h2>文章標題</h2>
<div class="like-btn-wrap">
<button class="like-btn" type="button" aria-pressed="false" aria-label="喜歡">
<svg class="heart-icon" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<span class="count">0</span>
&button>
</div>
</article>
<script>
$(function() {
$('.like-btn').on('click', function(e) {
const $btn = $(this);
const postId = $btn.closest('.post').data('post-id');
if ($btn.hasClass('loading') || $btn.attr('aria-pressed') === 'true') return;
$btn.addClass('loading').prop('disabled', true);
$.ajax({
url: '/api/like',
method: 'POST',
data: { post_id: postId, csrf_token: $('meta[name="csrf-token"]').attr('content') },
dataType: 'json',
success: function(res) {
if (res.success) {
$btn.attr('aria-pressed', 'true').addClass('liked');
$btn.find('.count').text(res.count);
// 心跳動畫
$btn.find('.heart-icon').addClass('beat');
setTimeout(() => $btn.find('.heart-icon').removeClass('beat'), 600);
} else {
alert(res.message || '操作失敗');
}
},
error: function() { alert('網路錯誤,請重試'); },
complete: function() { $btn.removeClass('loading').prop('disabled', false); }
});
});
});
</script>
<style>
.like-btn { display:inline-flex; align-items:center; gap:4px; background:#fff; border:1px solid #dee2e6; border-radius:999px; padding:6px 12px; cursor:pointer; transition:all .2s; }
.like-btn:hover { border-color:#0d6efd; color:#0d6efd; }
.like-btn.liked { background:#fff0f0; border-color:#dc3545; color:#dc3545; }
.like-btn .heart-icon { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:2; transition:transform .2s; }
.like-btn .heart-icon.beat { animation: beat .6s ease; }
@keyframes beat { 0%,100% { transform:scale(1); } 50% { transform:scale(1.3); } }
.like-btn.loading { opacity:.6; cursor:wait; }
</style>
<?php
declare(strict_types=1);
// api/like.php
require __DIR__.'/../vendor/autoload.php';
use Predis\Client as Redis;
session_start();
header('Content-Type: application/json; charset=utf-8');
$redis = new Redis(['host'=>'127.0.0.1','port'=>6379]);
$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8mb4', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$postId = (int)($_POST['post_id'] ?? 0);
$csrf = $_POST['csrf_token'] ?? '';
if ($postId <= 0 || !hash_equals($_SESSION['csrf_token'] ?? '', $csrf)) {
echo json_encode(['success'=>false, 'message'=>'無效請求']);
exit;
}
// 速率限制:每 IP 每分鐘最多 30 次
$ipHash = hash('sha256', $_SERVER['REMOTE_ADDR'] . 'SALT_KEY');
$rateKey = "ratelike:ip:{$ipHash}";
$rate = $redis->incr($rateKey);
if ($rate === 1) $redis->expire($rateKey, 60);
if ($rate > 30) {
echo json_encode(['success'=>false, 'message'=>'操作太頻繁,請稍後']);
exit;
}
// Cookie ID (若無則發新)
$cookieId = $_COOKIE['like_id'] ?? bin2hex(random_bytes(16));
setcookie('like_id', $cookieId, time()+86400*365, '/', '', true, true); // Secure, HttpOnly
// Redis 分散式鎖 (防止同一用戶並發請求重複計數)
$lockKey = "like:lock:{$postId}:{$ipHash}:{$cookieId}";
$lock = $redis->set($lockKey, '1', 'NX', 'EX', 10);
if (!$lock) {
echo json_encode(['success'=>false, 'message'=>'請勿重複點擊']);
exit;
}
try {
// 檢查是否已讚 (IP + Cookie 雙重)
$ipKey = "like:ips:{$postId}";
$cookieKey = "like:cookies:{$postId}";
$already = $redis->sismember($ipKey, $ipHash) && $redis->sismember($cookieKey, $cookieId);
if ($already) {
// 已讚,返回當前計數
$count = (int)$redis->get("like:count:{$postId}");
echo json_encode(['success'=>true, 'count'=>$count, 'message'=>'已讚過']);
} else {
// 原子遞增
$count = $redis->incr("like:count:{$postId}");
$redis->sadd($ipKey, $ipHash);
$redis->sadd($cookieKey, $cookieId);
$redis->expire($ipKey, 86400*30);
$redis->expire($cookieKey, 86400*30);
// 非同步同步至 MySQL (可用佇列)
// 這裡簡單演示同步寫入
$pdo->prepare('UPDATE posts SET likes_count = ? WHERE id = ?')->execute([$count, $postId]);
$pdo->prepare('INSERT IGNORE INTO post_likes (post_id, ip_hash, cookie_id) VALUES (?, ?, ?)')
->execute([$postId, $ipHash, $cookieId]);
echo json_encode(['success'=>true, 'count'=>$count]);
}
} finally {
$redis->del($lockKey);
}
?>
<?php
// cron/sync_likes.php (每分鐘跑一次)
$redis = new Redis(['host'=>'127.0.0.1']);
$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8mb4', 'user', 'pass');
$keys = $redis->keys('like:count:*');
foreach ($keys as $key) {
$postId = (int)str_replace('like:count:', '', $key);
$count = (int)$redis->get($key);
$pdo->prepare('UPDATE posts SET likes_count = ? WHERE id = ?')->execute([$count, $postId]);
}
// 也可用 Redis Keyspace Notifications 觸發即時同步
?>