金融轉帳、扣款入帳等涉及多筆資料庫寫入的操作,必須使用資料庫事務 確保原子性:要么全部成功,要么全部回滾。本文提供 Mysqli(程序式/物件導向)與 PDO 兩套完整實作範例,涵蓋關閉自動提交、錯誤處理、回滾機制、例外捕獲,並說明常見陷阱與最佳實務。
sequenceDiagram
participant App
participant DB
App->>DB: BEGIN / autocommit=0
App->>DB: UPDATE account SET balance=balance-1000 WHERE id=3
alt 扣款失敗
App->>DB: ROLLBACK
App-->>User: 轉帳失敗
else 扣款成功
App->>DB: UPDATE account SET balance=balance+1000 WHERE id=2
alt 入帳失敗
App->>DB: ROLLBACK
App-->>User: 轉帳失敗
else 入帳成功
App->>DB: COMMIT
App-->>User: 轉帳成功
end
end
<?php
$mysqli = new mysqli('localhost', 'root', 'password', 'bank_db');
$mysqli->set_charset('utf8mb4');
if ($mysqli->connect_errno) {
die('DB 連線失敗: ' . $mysqli->connect_error);
}
try {
$mysqli->begin_transaction(MYSQLI_TRANS_START_READ_WRITE); // 關閉自動提交
$flag = true;
// 扣款
$res = $mysqli->query("UPDATE account SET balance = balance - 1000 WHERE id = 3");
if (!$res || $mysqli->affected_rows === 0) {
throw new Exception('扣款失敗:帳戶不存在或餘額不足');
}
// 入帳
$res = $mysqli->query("UPDATE account SET balance = balance + 1000 WHERE id = 2");
if (!$res || $mysqli->affected_rows === 0) {
throw new Exception('入帳失敗:目標帳戶不存在');
}
$mysqli->commit();
echo '轉帳成功';
} catch (Throwable $e) {
$mysqli->rollback();
error_log('轉帳異常: ' . $e->getMessage());
echo '轉帳失敗: ' . $e->getMessage();
} finally {
$mysqli->close();
}
<?php
$dsn = 'mysql:host=localhost;dbname=bank_db;charset=utf8mb4';
$pdo = new PDO($dsn, 'root', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 拋出例外
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 使用真實預處理
]);
try {
$pdo->beginTransaction();
// 使用預處理語句防 SQL 注入
$stmt = $pdo->prepare('UPDATE account SET balance = balance - :amt WHERE id = :id');
$stmt->execute(['amt' => 1000, 'id' => 3]);
if ($stmt->rowCount() === 0) throw new Exception('扣款失敗');
$stmt = $pdo->prepare('UPDATE account SET balance = balance + :amt WHERE id = :id');
$stmt->execute(['amt' => 1000, 'id' => 2]);
if ($stmt->rowCount() === 0) throw new Exception('入帳失敗');
$pdo->commit();
echo '轉帳成功';
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
error_log('轉帳異常: ' . $e->getMessage());
echo '轉帳失敗: ' . $e->getMessage();
}
<?php
// 原版有語法錯誤:if(!result ...) 應為 if(!$result ...)
// 以下為修正後可執行版本
$mysqli = new mysqli("localhost", "root", "064319", "php");
$mysqli->set_charset("utf8");
if ($mysqli->connect_errno) die('連線失敗: ' . $mysqli->connect_error);
$mysqli->autocommit(false);
$flag = true;
$res = $mysqli->query("UPDATE account SET balance=balance-1000 WHERE id=3");
if (!$res || $mysqli->affected_rows === 0) $flag = false;
$res = $mysqli->query("UPDATE account SET balance=balance+1000 WHERE id=2");
if (!$res || $mysqli->affected_rows === 0) $flag = false;
if ($flag) {
$mysqli->commit();
echo '轉帳成功';
} else {
$mysqli->rollback();
echo '轉帳失敗';
}
$mysqli->autocommit(true);
$mysqli->close();
| 陷阱 | 後果 | 解決方案 |
|---|---|---|
| 未關閉 autocommit | 每句自動 commit,無法回滾 | `$mysqli->autocommit(false)` 或 `$pdo->beginTransaction()` |
| 只檢查 `$res` 不檢查 `affected_rows` | WHERE 不匹配仍回傳 true,資料實際未更新 | 必須雙重檢查:`!$res || $affected_rows === 0` |
| 使用 `mysql_*` 已棄用函式 | PHP 7+ 移除,安全性差 | 改用 mysqli / PDO |
| catch 只接 `Exception` 不接 `Throwable` | PHP 7+ Error 類錯誤無法捕獲 | `catch (Throwable $e)` |
| 事務中執行 DDL(CREATE/ALTER/DROP) | MySQL 隱性 commit,破壞事務 | 事務中僅執行 DML(INSERT/UPDATE/DELETE) |
| 長事務鎖定資源 | 阻塞其他連線,導致死鎖或超時 | 事務內盡量少查詢、快進快出、避免網路呼叫 |
高併發下,單純 `balance - 1000` 可能導致負餘額。加上版本號或條件更新:
UPDATE account SET balance = balance - 1000, version = version + 1 WHERE id = 3 AND balance >= 1000 AND version = :current_version
若 `affected_rows === 0` 則代表餘額不足或版本衝突,觸發重試或回滾。
掌握事務正確用法,是開發可靠金融、電商、庫存系統的基石。建議專案統一封裝 `TransactionManager` 或使用框架內建的 `DB::transaction()` 以減少樣板碼與人為錯誤。