表單重複提交是 Web 開發經典問題:用戶雙擊提交按鈕、網絡重發、瀏覽器刷新導致 POST 重發、惡意腳本高頻提交。單一防禦手段均有繞過風險。本文構建**前端攔截 (即時體驗) + 後端 Token (核心業務邏輯) + Redis 分布式鎖 (高併發/集群兜底)** 三層防禦體系,附完整代碼、原理分析、適用場景對比。
| 攻擊向量 | 特徵 | 防禦層級 | 核心手段 |
|---|---|---|---|
| 用戶雙擊/急躁點擊 | 毫秒級間隔,同一會話 | 前端 (L1) | 按鈕禁用、Loading 狀態、防抖 |
| 瀏覽器刷新/後退重發 POST | 同一請求數據重複到達服務端 | 後端 Token (L2) | 一次性 Token (生成->驗證->銷毀) |
| 惡意腳本/爬蟲高頻提交 | 高併發、多 IP/會話、繞過前端 | 後端限流 + 鎖 (L2+L3) | IP/用戶維度限流、Redis 分布式鎖 |
| 分布式集群下同一用戶請求打到不同節點 | 單機鎖失效 | Redis 分布式鎖 (L3) | SETNX + Lua 腳本原子操作 |
<!-- 表單模板 -->
<form id="bizForm" action="/submit" method="post">
<input type="hidden" name="form_token" value="<?= $formToken ?>">
<!-- 業務字段 -->
<button type="submit" id="submitBtn" class="btn-primary">提交</button>
<span id="loadingText" style="display:none;">處理中...</span>
</form>
<script>
// 方案 A:原生 JS 防抖 + 禁用 (無依賴)
document.getElementById('bizForm').addEventListener('submit', function(e) {
const btn = document.getElementById('submitBtn');
const loading = document.getElementById('loadingText');
// 1. 防抖:若已在提交狀態,直接攔截
if (btn.disabled) {
e.preventDefault();
return false;
}
// 2. 進入提交狀態
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = '';
loading.style.display = 'inline';
// 3. 可選:表單驗證 (若失敗需恢復按鈕)
// if (!validateForm()) { resetBtn(); e.preventDefault(); return; }
// 4. 正常提交:表單跳轉或 AJAX
// 若 AJAX 提交,需在 complete/error 中恢復按鈕
});
// 方案 B:jQuery 版 (項目有 jQuery 時)
$('#bizForm').on('submit', function() {
const $btn = $('#submitBtn').prop('disabled', true);
$('#loadingText').show();
// AJAX 示例
// $.post('/api/submit', $(this).serialize(), function() {})
// .always(() => { $btn.prop('disabled', false); $('#loadingText').hide(); });
return true; // 允許默認提交
});
</script>
局限: 用戶可禁用 JS、開發者工具繞過、**無法防禦瀏覽器原生刷新重發 POST**。**必須配合後端方案。**
<?php
/**
* 表單 Token 管理器 (支援 Session/Redis 存儲)
*/
class FormTokenManager {
private $storage; // 'session' | 'redis'
private $redis;
private $ttl = 3600; // Token 有效期 1 小時
private $tokenPrefix = 'form_token:';
public function __construct(string $storage = 'session', \Redis $redis = null) {
$this->storage = $storage;
if ($storage === 'redis') {
if (!$redis) throw new InvalidArgumentException('Redis 模式需注入 Redis 實例');
$this->redis = $redis;
}
if (session_status() === PHP_SESSION_NONE) session_start();
}
/** 生成新 Token 並存儲,返回 Token 字符串 */
public function generate(string $formKey = 'default'): string {
$token = bin2hex(random_bytes(32)); // 256 位隨機 Token
$key = $this->tokenPrefix . $formKey . ':' . $token;
$data = ['used' => false, 'created' => time()];
if ($this->storage === 'redis') {
$this->redis->setex($key, $this->ttl, json_encode($data));
} else {
$_SESSION['form_tokens'][$key] = $data;
$this->gcSessionTokens(); // 清理過期
}
return $token;
}
/** 驗證並消費 Token:成功返回 true 並標記已用,失敗返回 false */
public function validateAndConsume(string $token, string $formKey = 'default'): bool {
$key = $this->tokenPrefix . $formKey . ':' . $token;
if ($this->storage === 'redis') {
// Lua 腳本原子檢查並刪除 (防併發竞態)
$lua = "
local key = KEYS[1]
local data = redis.call('GET', key)
if not data then return 0 end
local obj = cjson.decode(data)
if obj.used then return 0 end
obj.used = true
redis.call('DEL', key) -- 驗證即銷毀,最強防重
return 1
";
$result = $this->redis->eval($lua, 1, $key);
return $result === 1;
} else {
// Session 模式:檢查並標記 (單進程安全,多進程需 Session 共享存儲)
if (empty($_SESSION['form_tokens'][$key]) || $_SESSION['form_tokens'][$key]['used']) {
return false;
}
$_SESSION['form_tokens'][$key]['used'] = true;
unset($_SESSION['form_tokens'][$key]); // 即時銷毀
return true;
}
}
private function gcSessionTokens(): void {
$now = time();
foreach ($_SESSION['form_tokens'] ?? [] as $k => $v) {
if ($now - $v['created'] > $this->ttl) unset($_SESSION['form_tokens'][$k]);
}
}
}
// === 控制器使用示例 ===
// 1. 顯示表單頁:生成 Token 注入模板
$tokenManager = new FormTokenManager('redis', $redis); // 或 'session'
$formToken = $tokenManager->generate('order_create');
// 渲染模板,將 $formToken 填入
// 2. 處理提交:驗證 Token
$submittedToken = $_POST['form_token'] ?? '';
if (!$tokenManager->validateAndConsume($submittedToken, 'order_create')) {
// Token 無效/已用/過期 -> 重複提交或偽造
jsonResponse(['code' => 400, 'msg' => '請勿重複提交,或表單已過期請刷新頁面']);
exit;
}
// 3. Token 通過 -> 執行核心業務邏輯 (下單、扣款、寫入 DB)
// ...
// 4. 成功後:重定向到成功頁 (PRG 模式),徹底防止瀏覽器刷新重發
header('Location: /order/success?id=' . $orderId);
exit;
?>
random_bytes(32) 產生 256 位熵,極難猜測/碰撞。$formKey (如 'order_create', 'user_register') 區分不同業務表單,互不干擾。適用場景:秒殺、搶優惠券、高價值操作,**極端併發下業務邏輯執行時間較長**,Token 驗證通過但業務未完成時,同一用戶其他請求可能已進入業務代碼。需在**業務關鍵段**加鎖。
<?php
/**
* Redis 分布式鎖 (基於 SET NX EX + Lua 釋放)
*/
class RedisDistributedLock {
private $redis;
private $defaultTtl = 10; // 鎖超時時間 (秒),必須 > 業務最大執行時間
public function __construct(\Redis $redis) { $this->redis = $redis; }
/** 獲取鎖
* @param string $key 鎖鍵,如 "biz_lock:order_create:user_123"
* @param int $ttl 鎖過期時間
* @param int $retryTimes 重試次數
* @param int $retryDelay 重試間隔 (微秒)
* @return bool|string 成功返回鎖值 (用於釋放),失敗返回 false
*/
public function acquire(string $key, int $ttl = 0, int $retryTimes = 3, int $retryDelay = 50000): bool|string {
$ttl = $ttl ?: $this->defaultTtl;
$lockVal = bin2hex(random_bytes(16)); // 唯一鎖值,防止誤刷他人鎖
$fullKey = "lock:$key";
for ($i = 0; $i <= $retryTimes; $i++) {
// SET key value NX EX ttl (原子設置)
$acquired = $this->redis->set($fullKey, $lockVal, ['NX', 'EX' => $ttl]);
if ($acquired) return $lockVal; // 返回鎖值
if ($i < $retryTimes) usleep($retryDelay);
}
return false;
}
/** 釋放鎖 (Lua 腳本:僅刪除屬於自己的鎖) */
public function release(string $key, string $lockVal): bool {
$fullKey = "lock:$key";
$lua = "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
";
return $this->redis->eval($lua, 1, $fullKey, $lockVal) === 1;
}
}
// === 業務關鍵段使用示例 (如創建訂單核心邏輯) ===
$lock = new RedisDistributedLock($redis);
$userId = $currentUser['id'];
$lockKey = "order_create:user_$userId"; // 同一用戶串行
$lockVal = $lock->acquire($lockKey, 15, 5, 100000); // 最多重試 5 次,共 ~0.5s
if (!$lockVal) {
jsonResponse(['code' => 429, 'msg' => '系統繁忙,請稍後再試 (併發限制)']);
exit;
}
try {
// 核心業務:檢查庫存、扣減、創建訂單、寫入 DB (事務)
// 此處同一用戶同一時間只能有一個請求執行
$orderId = createOrderInTransaction($userId, $items);
// 成功:釋放鎖,返回結果
$lock->release($lockKey, $lockVal);
jsonResponse(['code' => 200, 'msg' => '下單成功', 'order_id' => $orderId]);
} catch (Exception $e) {
// 失敗:釋放鎖,拋出/記錄錯誤
$lock->release($lockKey, $lockVal);
throw $e; // 或統一錯誤處理
}
?>
用戶提交
│
▼
┌─────────────────────────────────────┐
│ L1 前端攔截 (JS 禁用按鈕/Loading) │ ──攔截 90%+ 普通用戶誤操作
│ - 即時反饋、零服務端開銷 │
└──────────────┬──────────────────────┘
│ (繞過/無 JS/刷新重發)
▼
┌─────────────────────────────────────┐
│ L2 後端 Token 驗證 (一次性消費) │ ──攔截 99%+ 重複提交/刷新重發/偽造
│ - Redis/Session 存儲 │
│ - 驗證即銷毀 (Lua/unset) │
│ - 業務鍵隔離 │
└──────────────┬──────────────────────┘
│ (極端併發/業務執行長/集群競態)
▼
┌─────────────────────────────────────┐
│ L3 Redis 分布式鎖 (關鍵段串行化) │ ──兜底 0.01% 極端併發競態
│ - SET NX EX 原子獲取 │
│ - Lua 安全釋放 │
│ - TTL 防死鎖 │
└──────────────┬──────────────────────┘
│
▼
核心業務邏輯 (DB 事務)
| 業務場景 | 推薦方案組合 | 核心理由 |
|---|---|---|
| 普通表單 (登錄、註冊、聯係我們、評論) | L1 + L2 (Session Token) | 低併發,單機/集群 Session 共享即可,開發最簡單 |
| 重要業務 (下單、支付、提現、改密) | L1 + L2 (Redis Token) + PRG | 集群安全,Token 即時銷毀,PRG 徹底防刷新重發 |
| 高併發搶購 (秒殺、搶券、限量拼團) | L1 + L2 (Redis Token) + L3 (Redis Lock) + Lua 腳本原子業務 | 極限併發下業務邏輯必須串行化,鎖保護關鍵段,配合庫存原子扣減 |
| API 開放平台 (防重放攻擊) | L2 (Redis Token, 短 TTL) + 簽名驗證 + Nonce/Time 窗口 | 無前端,Token 作為請求唯一標識,結合簽名防篡改 |
防止表單重複提交**沒有銀彈,只有分層防禦**。L1 前端攔截解決用戶體驗;L2 後端一次性 Token (Redis + 驗證即銷毀 + PRG)是**核心業務防線**,解決 99%+ 場景;L3 Redis 分布式鎖作為**極限併發兜底**,保護關鍵業務段串行執行。根據業務價值、併發級別、架構模式選型組合,**切記:Token 必須驗證即銷毀,業務成功必須 PRG 重定向,鎖必須帶 TTL 並用 Lua 安全釋放**,才能構建經得起生產環境考驗的防重體系。