原文範例存在嚴重 SQL 注入、無 CSRF 保護、同步阻塞請求、錯誤處理缺失、使用棄用 mysql_* 函式等生產環境不可接受的問題。
// migration
CREATE TABLE `page_views` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`page_key` VARCHAR(128) NOT NULL COMMENT '頁面唯一標識',
`view_count` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_page_key` (`page_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
class ViewCounterService {
private \PDO $pdo;
public function __construct(\PDO $pdo) { $this->pdo = $pdo; }
public function increment(string $pageKey): int {
$sql = "INSERT INTO `page_views` (`page_key`, `view_count`) VALUES (?, 1)
ON DUPLICATE KEY UPDATE `view_count` = `view_count` + 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$pageKey]);
// 取得更新後的計數
$stmt = $this->pdo->prepare("SELECT `view_count` FROM `page_views` WHERE `page_key` = ?");
$stmt->execute([$pageKey]);
return (int)$stmt->fetchColumn();
}
public function batchIncrement(array $pageKeys): array {
// 事務批次處理,減少往返
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare("INSERT INTO `page_views` (`page_key`, `view_count`) VALUES (?, 1)
ON DUPLICATE KEY UPDATE `view_count` = `view_count` + 1");
foreach ($pageKeys as $key) { $stmt->execute([$key]); }
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $this->getCounts($pageKeys);
}
}
class ViewCounterController {
private ViewCounterService $service;
private RateLimiter $limiter;
public function increment(Request $request): JsonResponse {
// 1. CSRF 驗證(API 通常用 Token,此處示範同源策略 + SameSite Cookie)
if (!$this->validateCsrf($request)) {
return $this->jsonError('Invalid CSRF token', 403);
}
// 2. 參數驗證
$pageKey = $request->input('page_key');
if (empty($pageKey) || !preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $pageKey)) {
return $this->jsonError('Invalid page_key', 400);
}
// 3. 速率限制:每 IP 每分鐘最多 60 次
$ip = $request->ip();
if (!$this->limiter->attempt("view:increment:$ip", 60, 60)) {
return $this->jsonError('Rate limit exceeded', 429);
}
// 4. 業務邏輯
try {
$count = $this->service->increment($pageKey);
return response()->json(['count' => $count, 'page_key' => $pageKey]);
} catch (\Throwable $e) {
Log::error('View increment failed', ['error' => $e->getMessage(), 'page_key' => $pageKey]);
return $this->jsonError('Internal server error', 500);
}
}
}
async function trackView(pageKey) {
const endpoint = '/api/view/increment';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin', // 攜帶 Cookie
body: JSON.stringify({ page_key: pageKey }),
// keepalive: true // 頁面卸載時仍發送
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
updateViewDisplay(pageKey, data.count);
} catch (error) {
console.warn('View tracking failed:', error);
// 靜默失效,不影響用戶體驗
}
}
// 頁面可見時觸發(避免預渲染/後台標籤計數)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const pageKey = document.body.dataset.pageKey;
if (pageKey) trackView(pageKey);
}
});
// composables/useViewCounter.js
import { ref } from 'vue';
export function useViewCounter(pageKey) {
const count = ref(0);
const loading = ref(false);
const fetch = async () => {
loading.value = true;
try {
const res = await $api.post('/view/increment', { page_key: pageKey });
count.value = res.data.count;
} finally {
loading.value = false;
}
};
return { count, loading, fetch };
}
| 層級 | 方案 | 適用場景 |
|---|---|---|
| 應用層 | Redis INCR + 定時持久化到 MySQL | 高流量頁面(>1000 QPS) |
| 應用層 | 本地記憶體計數 + 批次寫入 | 中等流量,單機部署 |
| 資料庫層 | MySQL 原子更新 + 覆蓋索引 | 低中流量,強一致性要求 |
| 架構層 | 寫入 Kafka/Redis Stream,消費者聚合入庫 | 超大流量,最終一致性可接受 |
class RedisViewCounter {
private \Redis $redis;
private \PDO $pdo;
private string $luaScript = "
local key = KEYS[1]
local ttl = ARGV[1]
local count = redis.call('INCR', key)
if count == 1 then redis.call('EXPIRE', key, ttl) end
return count
";
public function increment(string $pageKey): int {
$key = "views:{$pageKey}";
$count = $this->redis->eval($this->luaScript, 1, $key, 86400); // 24h TTL
// 異步持久化(佇列任務)
if ($count % 100 === 0) { // 每 100 次觸發一次持久化
dispatch(new SyncViewCountToDb($pageKey, $count));
}
return $count;
}
}
點擊量統計看似簡單,實則涉及資料一致性、高併發寫入、防刷控制、前端體驗、資料持久化策略等多維工程課題。摒棄同步阻塞、裸 SQL、無防護的原始寫法,採用「非同步前端 + 速率限制 API + Redis 熱點計數 + 異步落庫」的分層架構,才能支撐百萬級 PV 的穩定運行。