高並發下 MySQL 性能瓶頸導致查詢超時/阻塞,PHP 作為弱類型語言,若不檢查返回值,極易引發邏輯錯誤、數據不一致甚至服務雪崩。本文通過實測複現線程阻塞與鎖阻塞兩大場景,給出防禦編程模式。
觸發條件: 連線數耗盡
-- 設置最大連線數為 1
SET GLOBAL max_connections=1;
-- 終端 A 佔用唯一連線
mysql -u root -p
SELECT SLEEP(100); -- 長佔用
-- 終端 B 執行 PHP 腳本
<?php
$con = mysql_connect('127.0.0.1', 'root', '');
var_dump($con); // bool(false)
mysql_select_db('test', $con);
$cursor = mysql_query("SELECT * FROM timeout_test WHERE id=2 FOR UPDATE");
var_dump($cursor); // bool(false)
var_dump(mysql_fetch_assoc($cursor)); // NULL + Warning
echo "done!";
?>
結果: mysql_connect 返回 false,mysql_query 返回 false,均拋出 E_WARNING,**不終止腳本執行**。
觸發條件: 行鎖等待超時
-- 終端 A 開啟事務鎖定行
SET autocommit=0;
SELECT * FROM timeout_test WHERE id=2 FOR UPDATE;
-- 不提交,持有鎖
-- 終端 B 執行同樣 PHP 腳本
結果: mysql_connect 成功返回 resource,mysql_query 返回 false(**不拋出 Warning**),mysql_fetch_assoc 參數為 bool 觸發 E_WARNING 並返回 NULL。
| 函數 | 線程阻塞 (連線耗盡) | 鎖阻塞 (等待超時) |
|---|---|---|
| mysql_connect | false + Warning | resource (正常) |
| mysql_query | false + Warning | false (無 Warning) |
| mysql_fetch_* | 未執行 | NULL + Warning (參數類型錯誤) |
| 腳本執行 | 繼續 | 繼續 |
false == 0 == '' == null 在鬆散比較下皆為真,邏輯判斷極易出錯mysql_* 系列函數不拋出異常,需手動檢查mysql_connect 失敗後,即使資源釋放,同連線變數後續 mysql_query 不會重連<?php
// 連線
$con = @mysql_connect('127.0.0.1', 'root', 'pass');
if ($con === false) {
error_log('MySQL connect failed: ' . mysql_error());
die('Service unavailable');
}
// 查詢
$cursor = @mysql_query("SELECT * FROM t WHERE id=1", $con);
if ($cursor === false) {
error_log('Query failed: ' . mysql_error($con));
// 關鍵:根據業務決定是重試、降級還是拋出異常
throw new RuntimeException('Database query failed');
}
// 獲取結果
$row = mysql_fetch_assoc($cursor);
if ($row === false) {
// 無數據或錯誤,需區分
if (mysql_errno($con) !== 0) {
throw new RuntimeException('Fetch error: ' . mysql_error($con));
}
// 正常無數據
$row = null;
}
<?php
// mysqli 面向對象 + 異常模式
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
try {
$mysqli = new mysqli('127.0.0.1', 'root', 'pass', 'test');
$mysqli->set_charset('utf8mb4');
$stmt = $mysqli->prepare("SELECT * FROM t WHERE id=? FOR UPDATE");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
} catch (mysqli_sql_exception $e) {
error_log('MySQL error: ' . $e->getMessage());
// 統一錯誤處理:重試/熔斷/降級
throw new ServiceUnavailableException('Database error');
}
<?php
// PDO + 異常模式
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test;charset=utf8mb4', 'root', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // 真正的預處理
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
try {
$stmt = $pdo->prepare("SELECT * FROM t WHERE id=? FOR UPDATE");
$stmt->execute([$id]);
$row = $stmt->fetch();
} catch (PDOException $e) {
error_log('PDO error: ' . $e->getMessage());
throw new ServiceUnavailableException('Database error');
}
<?php
// mysqli
$mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 3); // 連線超時 3s
$mysqli->options(MYSQLI_OPT_READ_TIMEOUT, 10); // 讀取超時 10s (需 mysqlnd)
// PDO
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_TIMEOUT => 3, // 連線超時
// PDO 無原生查詢超時,需配合 signal/pcntl 或異步查詢
]);
-- MySQL 服務端設置
SET SESSION MAX_EXECUTION_TIME=5000; -- 毫秒,5 秒查詢超時 (5.7+)
-- 或全局
SET GLOBAL MAX_EXECUTION_TIME=5000;
<?php
function queryWithRetry($pdo, $sql, $params, $maxRetries = 3, $baseDelay = 100) {
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
} catch (PDOException $e) {
$code = $e->getCode();
// 僅重試暫時性錯誤:鎖超時(1205)、死鎖(1213)、連線斷開(2006/2013)
$retryCodes = ['HY000' => [1205, 1213, 2006, 2013]];
if ($attempt < $maxRetries && in_array($code, $retryCodes[$e->getCode()] ?? [])) {
usleep($baseDelay * $attempt * 1000); // 指數退避
continue;
}
throw $e;
}
}
}
Threads_connected / Max_used_connections 接近 max_connections 告警Innodb_row_lock_waits、Innodb_row_lock_time_avg 異常升高告警pt-query-digest