廣告系統需統計每個廣告的點擊數(PV/UV)。高併發下直接寫 MySQL 會導致:
解決方案:Redis 計數器聚合 + 定時/閾值觸發持久化到 MySQL。
pconnect('127.0.0.1', 6379);
$mysql = new PDO('mysql:host=127.0.0.1;dbname=ad_db', 'user', 'pass');
$mysql->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 鍵名規範
const KEY_AD_MAP = 'ad:url_map'; // url_md5 -> ad_id (Hash)
const KEY_AD_EXISTS = 'ad:init_done'; // 初始化標記 (String)
const KEY_AD_CLICK_PREFIX = 'ad:click:'; // ad_id -> 計數器 (String)
const FLUSH_THRESHOLD = 100; // 閾值觸發落庫
const FLUSH_INTERVAL = 300; // 5 分鐘定時任務兜底
function initAdMapping(PDO $mysql, Redis $redis): void {
if ($redis->exists(KEY_AD_EXISTS)) return;
$stmt = $mysql->query('SELECT id, adve_url FROM user_online_adve');
$pipe = $redis->multi();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$urlHash = md5($row['adve_url']);
$pipe->hSet(KEY_AD_MAP, $urlHash, $row['id']);
}
$pipe->set(KEY_AD_EXISTS, 1);
$pipe->exec();
}
function handleClick(Redis $redis, string $referer): void {
$urlHash = md5($referer);
$adId = $redis->hGet(KEY_AD_MAP, $urlHash);
if (!$adId) return; // 非廣告鏈接,直接返回
$clickKey = KEY_AD_CLICK_PREFIX . $adId;
$count = $redis->incr($clickKey); // 原子自增,極高效能
// 閾值觸發異步落庫(生產建議推入隊列由 Worker 處理)
if ($count >= FLUSH_THRESHOLD) {
// Lua 腳本保證「讀取並歸零」原子性
$lua = <<= tonumber(ARGV[1]) then
redis.call('SET', KEYS[1], 0)
return v
end
return 0
LUA;
$flushed = $redis->eval($lua, 1, $clickKey, FLUSH_THRESHOLD);
if ($flushed) {
// 實際生產:推送到 Redis List / Kafka / Swoole Task
// 這裡演示同步寫入
flushToMysql($adId, (int)$flushed);
}
}
}
function cronFlush(PDO $mysql, Redis $redis): void {
$keys = $redis->keys(KEY_AD_CLICK_PREFIX . '*');
if (!$keys) return;
$pipe = $redis->multi();
foreach ($keys as $key) {
$pipe->get($key);
$pipe->set($key, 0); // 歸零
}
$counts = $pipe->exec();
$sql = 'UPDATE user_online_adve SET adve_num = adve_num + :cnt WHERE id = :id';
$stmt = $mysql->prepare($sql);
foreach ($keys as $i => $key) {
$cnt = (int)$counts[$i * 2]; // GET 結果
if ($cnt > 0) {
$adId = (int)str_replace(KEY_AD_CLICK_PREFIX, '', $key);
$stmt->execute([':cnt' => $cnt, ':id' => $adId]);
}
}
}
上述閾值觸發存在競態:incr 後讀取、判斷、歸零非原子。生產環境**必須**用 Lua:
$luaScript = <<<'LUA'
local clickKey = KEYS[1]
local threshold = tonumber(ARGV[1])
local adId = ARGV[2]
local flushKey = 'ad:flush_queue'
local cnt = redis.call('INCR', clickKey)
if cnt >= threshold then
redis.call('SET', clickKey, 0)
redis.call('LPUSH', flushKey, adId .. ':' .. cnt)
end
return cnt
LUA;
$redis->eval($luaScript, 1, $clickKey, FLUSH_THRESHOLD, $adId);
Worker 進程消費 ad:flush_queue 列表批次寫入 MySQL,實現真正的「寫入不阻塞、數據不丟失」。
若需統計唯一訪客(UV),使用 Redis HyperLogLog 或 Set:
function handleUniqueClick(Redis $redis, string $referer, string $userId): void {
$urlHash = md5($referer);
$adId = $redis->hGet(KEY_AD_MAP, $urlHash);
if (!$adId) return;
$uvKey = "ad:uv:{$adId}";
// PFADD 返回 1 表示新增元素(首次訪問)
if ($redis->pfAdd($uvKey, $userId)) {
$redis->incr("ad:uv_count:{$adId}");
}
// 同樣處理 PV 計數
handleClick($redis, $referer);
}
Redis 作為「寫緩衝層」吸收高併發寫入,通過「閾值觸發 + 定時兜底 + Lua 原子操作」實現數據最終一致性落庫,可將 MySQL 寫入壓力降低 **99% 以上**,是廣告、計數、日誌等高寫場景的標準架構模式。