返回文章列表

PHP 與 MySQL 資料庫整合實戰:從連線機制到安全防護完整指南

深入探討 PHP 與 MySQL 資料庫整合的核心技術與安全實務,涵蓋 PDO 與 MySQLi 擴充功能的差異比較、預處理陳述式的安全機制、SQL 注入攻擊的防護策略、資料庫連線管理最佳實踐,以及交易處理與錯誤處理技巧,為台灣網站開發者提供完整的資料庫操作安全指南

網站開發 資料庫技術 資訊安全

在現代網站開發的技術堆疊中,PHP 與 MySQL 的組合長期佔據著重要地位,從中小企業的官方網站到大型電商平台,這個經典組合支撐著無數的網路服務。然而,資料庫操作的便利性往往伴隨著安全風險,SQL 注入攻擊(SQL Injection)始終位列 OWASP 十大網站安全威脅的前段班。對於台灣的網站開發從業人員而言,理解 PHP 與 MySQL 整合的正確方式,掌握安全的資料庫存取技術,不僅是技術能力的展現,更是保護使用者資料與企業聲譽的責任。

PHP 提供了兩種主要的 MySQL 資料庫擴充功能:PDO(PHP Data Objects)與 MySQLi(MySQL Improved)。PDO 採用資料庫抽象層的設計理念,支援多種資料庫系統,提供一致的程式介面,讓開發者能夠在不同資料庫之間切換時減少程式碼修改。MySQLi 則專注於 MySQL 資料庫的最佳化支援,提供物件導向與程序式兩種程式風格,讓開發者能夠根據專案需求選擇適合的操作方式。這兩種擴充功能各有優勢,理解它們的差異與適用場景是建構穩健資料庫應用的第一步。

預處理陳述式(Prepared Statements)是防禦 SQL 注入攻擊的核心機制。傳統的字串拼接方式建構 SQL 查詢,容易讓攻擊者透過精心設計的輸入值注入惡意 SQL 程式碼,竊取或破壞資料。預處理陳述式將 SQL 邏輯與資料完全分離,先將查詢結構傳送至資料庫伺服器進行編譯,再將參數值以安全的方式綁定,從根本上消除了 SQL 注入的可能性。在台灣的金融科技、電商平台、醫療資訊系統等高度重視資料安全的領域,預處理陳述式已經成為強制性的開發規範。

資料庫連線管理是影響網站效能與穩定性的關鍵因素。每次建立資料庫連線都需要經過 TCP 握手、身份驗證、資源分配等步驟,在高流量場景下頻繁建立與關閉連線會嚴重影響效能。持久連線(Persistent Connections)與連線池(Connection Pooling)技術能夠重用現有連線,降低連線建立的開銷。然而,這些技術也需要謹慎使用,不當的配置可能導致連線洩漏或資源耗盡。本文將從基礎的 PDO 與 MySQLi 連線方式開始,深入探討預處理陳述式的實作細節、SQL 注入防護的多層次策略、交易處理的 ACID 特性保證,以及錯誤處理與日誌記錄的最佳實踐,為讀者建構完整的 PHP 資料庫操作安全知識體系。

PDO 與 MySQLi 的技術比較與選擇策略

PHP Data Objects(PDO)的設計哲學在於提供資料庫無關的抽象層,讓應用程式能夠在 MySQL、PostgreSQL、SQLite、Oracle 等不同資料庫系統之間切換時,僅需修改連線參數而無需改寫查詢邏輯。這種抽象化設計在大型企業環境中特別有價值,當組織需要從一種資料庫遷移到另一種時,PDO 的統一介面能夠大幅降低遷移成本。PDO 採用完全物件導向的程式風格,所有資料庫操作都透過物件方法完成,這與現代 PHP 開發的主流趨勢高度契合。

MySQLi 則是針對 MySQL 資料庫的專用擴充功能,提供了對 MySQL 特定功能的深度支援。MySQLi 同時提供物件導向與程序式兩種程式介面,讓開發者能夠根據專案的程式風格選擇適合的方式。在物件導向模式下,MySQLi 的操作方式與 PDO 相似,但在某些進階功能上提供了更細緻的控制能力。程序式介面則保持了與傳統 mysql 擴充功能相似的函式呼叫方式,方便從舊系統遷移的專案逐步升級。

在台灣的網站開發實務中,選擇 PDO 或 MySQLi 往往取決於專案的具體需求與未來規劃。若專案明確使用 MySQL 且無資料庫切換需求,MySQLi 能夠提供更直接的 MySQL 特定功能支援。若專案需要在不同環境使用不同資料庫,或是希望保留未來遷移的彈性,PDO 的資料庫抽象層優勢就更加明顯。無論選擇哪種擴充功能,正確使用預處理陳述式進行參數化查詢都是確保安全性的核心要求。

<?php
/**
 * PHP 與 MySQL 資料庫整合範例
 * 展示 PDO 與 MySQLi 的基本連線方式、預處理陳述式使用,以及安全的資料查詢操作
 */

// ============================================================================
// 範例一:PDO 資料庫連線與基本查詢
// ============================================================================

/**
 * 使用 PDO 建立資料庫連線
 * PDO 提供資料庫抽象層,支援多種資料庫系統
 */
class DatabasePDO {
    private $pdo;
    private $host = 'localhost';
    private $database = 'company_db';
    private $username = 'web_user';
    private $password = 'secure_password';
    private $charset = 'utf8mb4';
    
    /**
     * 建立 PDO 連線
     * 使用 try-catch 處理連線錯誤
     */
    public function connect() {
        try {
            // 建立 DSN (Data Source Name) 字串
            // charset=utf8mb4 確保完整的 Unicode 支援(包含表情符號)
            $dsn = "mysql:host={$this->host};dbname={$this->database};charset={$this->charset}";
            
            // PDO 連線選項設定
            $options = [
                // 設定錯誤模式為例外處理,方便捕捉錯誤
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                
                // 設定預設的 fetch 模式為關聯陣列
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                
                // 禁用模擬預處理陳述式,使用真正的預處理功能
                PDO::ATTR_EMULATE_PREPARES => false,
                
                // 設定持久連線(需謹慎使用,可能導致連線洩漏)
                // PDO::ATTR_PERSISTENT => true,
            ];
            
            // 建立 PDO 實例
            $this->pdo = new PDO($dsn, $this->username, $this->password, $options);
            
            return $this->pdo;
            
        } catch (PDOException $e) {
            // 記錄錯誤訊息到日誌檔案
            error_log("資料庫連線失敗:" . $e->getMessage());
            
            // 不要將詳細錯誤訊息顯示給使用者,避免洩漏系統資訊
            throw new Exception("資料庫連線失敗,請稍後再試");
        }
    }
    
    /**
     * 使用預處理陳述式進行安全查詢
     * 
     * @param int $userId 使用者 ID
     * @return array|false 查詢結果或 false
     */
    public function getUserById($userId) {
        try {
            // 準備 SQL 陳述式,使用 ? 作為參數佔位符
            // 這種方式稱為位置參數(Positional Parameters)
            $sql = "SELECT id, username, email, created_at 
                    FROM users 
                    WHERE id = ? AND status = ?";
            
            // 建立預處理陳述式
            $stmt = $this->pdo->prepare($sql);
            
            // 執行查詢並綁定參數
            // 參數按順序對應 SQL 中的 ? 佔位符
            $stmt->execute([$userId, 'active']);
            
            // 取得單筆記錄
            $user = $stmt->fetch();
            
            return $user;
            
        } catch (PDOException $e) {
            error_log("查詢錯誤:" . $e->getMessage());
            return false;
        }
    }
    
    /**
     * 使用命名參數的預處理陳述式
     * 命名參數提供更好的可讀性,特別是在複雜查詢中
     * 
     * @param string $email 電子郵件
     * @param string $username 使用者名稱
     * @return array|false 查詢結果
     */
    public function getUserByEmailOrUsername($email, $username) {
        try {
            // 使用命名參數(Named Parameters),以 :name 格式標記
            $sql = "SELECT id, username, email 
                    FROM users 
                    WHERE email = :email OR username = :username 
                    LIMIT 1";
            
            $stmt = $this->pdo->prepare($sql);
            
            // 綁定命名參數
            // 參數名稱必須與 SQL 中的佔位符一致(包含冒號)
            $stmt->execute([
                ':email' => $email,
                ':username' => $username
            ]);
            
            return $stmt->fetch();
            
        } catch (PDOException $e) {
            error_log("查詢錯誤:" . $e->getMessage());
            return false;
        }
    }
    
    /**
     * 新增使用者記錄
     * 展示 INSERT 操作與取得最後插入的 ID
     * 
     * @param array $userData 使用者資料
     * @return int|false 新增記錄的 ID 或 false
     */
    public function createUser($userData) {
        try {
            $sql = "INSERT INTO users (username, email, password_hash, created_at) 
                    VALUES (:username, :email, :password_hash, NOW())";
            
            $stmt = $this->pdo->prepare($sql);
            
            // 密碼應該使用 password_hash() 進行雜湊處理
            $passwordHash = password_hash($userData['password'], PASSWORD_BCRYPT);
            
            $result = $stmt->execute([
                ':username' => $userData['username'],
                ':email' => $userData['email'],
                ':password_hash' => $passwordHash
            ]);
            
            if ($result) {
                // 回傳最後插入的 ID
                return $this->pdo->lastInsertId();
            }
            
            return false;
            
        } catch (PDOException $e) {
            error_log("新增使用者失敗:" . $e->getMessage());
            return false;
        }
    }
    
    /**
     * 交易處理範例
     * 確保多個資料庫操作的原子性(ACID 特性)
     * 
     * @param int $fromUserId 轉出帳戶 ID
     * @param int $toUserId 轉入帳戶 ID
     * @param float $amount 轉帳金額
     * @return bool 交易是否成功
     */
    public function transferFunds($fromUserId, $toUserId, $amount) {
        try {
            // 開始交易
            $this->pdo->beginTransaction();
            
            // 從轉出帳戶扣款
            $sql1 = "UPDATE accounts 
                     SET balance = balance - :amount 
                     WHERE user_id = :user_id AND balance >= :amount";
            
            $stmt1 = $this->pdo->prepare($sql1);
            $stmt1->execute([
                ':amount' => $amount,
                ':user_id' => $fromUserId
            ]);
            
            // 檢查是否有記錄被更新(餘額是否足夠)
            if ($stmt1->rowCount() === 0) {
                throw new Exception("餘額不足或帳戶不存在");
            }
            
            // 向轉入帳戶加款
            $sql2 = "UPDATE accounts 
                     SET balance = balance + :amount 
                     WHERE user_id = :user_id";
            
            $stmt2 = $this->pdo->prepare($sql2);
            $stmt2->execute([
                ':amount' => $amount,
                ':user_id' => $toUserId
            ]);
            
            // 記錄交易歷史
            $sql3 = "INSERT INTO transactions 
                     (from_user_id, to_user_id, amount, created_at) 
                     VALUES (:from_user, :to_user, :amount, NOW())";
            
            $stmt3 = $this->pdo->prepare($sql3);
            $stmt3->execute([
                ':from_user' => $fromUserId,
                ':to_user' => $toUserId,
                ':amount' => $amount
            ]);
            
            // 提交交易
            $this->pdo->commit();
            
            return true;
            
        } catch (Exception $e) {
            // 發生錯誤時回滾所有變更
            $this->pdo->rollBack();
            
            error_log("交易失敗:" . $e->getMessage());
            return false;
        }
    }
}

// ============================================================================
// 範例二:MySQLi 物件導向方式的資料庫操作
// ============================================================================

/**
 * 使用 MySQLi 物件導向方式建立資料庫連線
 * MySQLi 專注於 MySQL 資料庫的最佳化支援
 */
class DatabaseMySQLi {
    private $mysqli;
    private $host = 'localhost';
    private $username = 'web_user';
    private $password = 'secure_password';
    private $database = 'company_db';
    
    /**
     * 建立 MySQLi 連線
     */
    public function connect() {
        // 啟用錯誤報告
        mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
        
        try {
            // 建立 MySQLi 物件
            $this->mysqli = new mysqli(
                $this->host,
                $this->username,
                $this->password,
                $this->database
            );
            
            // 設定字元集為 utf8mb4(完整 Unicode 支援)
            $this->mysqli->set_charset('utf8mb4');
            
            return $this->mysqli;
            
        } catch (mysqli_sql_exception $e) {
            error_log("MySQLi 連線失敗:" . $e->getMessage());
            throw new Exception("資料庫連線失敗");
        }
    }
    
    /**
     * 使用 MySQLi 預處理陳述式進行查詢
     * 
     * @param string $email 電子郵件
     * @return array|null 查詢結果
     */
    public function getUserByEmail($email) {
        try {
            // 準備 SQL 陳述式,使用 ? 作為參數佔位符
            $sql = "SELECT id, username, email, created_at 
                    FROM users 
                    WHERE email = ?";
            
            // 建立預處理陳述式
            $stmt = $this->mysqli->prepare($sql);
            
            // 綁定參數
            // 第一個參數 "s" 表示參數類型為字串(string)
            // 其他類型:i=integer, d=double, b=blob
            $stmt->bind_param("s", $email);
            
            // 執行查詢
            $stmt->execute();
            
            // 取得結果
            $result = $stmt->get_result();
            
            // 取得一筆記錄作為關聯陣列
            $user = $result->fetch_assoc();
            
            // 關閉陳述式
            $stmt->close();
            
            return $user;
            
        } catch (mysqli_sql_exception $e) {
            error_log("查詢錯誤:" . $e->getMessage());
            return null;
        }
    }
    
    /**
     * 使用 MySQLi 進行多參數查詢
     * 展示不同資料型態的參數綁定
     * 
     * @param int $minAge 最小年齡
     * @param int $maxAge 最大年齡
     * @param string $city 城市
     * @return array 查詢結果陣列
     */
    public function getUsersByAgeAndCity($minAge, $maxAge, $city) {
        try {
            $sql = "SELECT id, username, email, age, city 
                    FROM users 
                    WHERE age BETWEEN ? AND ? AND city = ? 
                    ORDER BY age ASC";
            
            $stmt = $this->mysqli->prepare($sql);
            
            // 綁定多個參數
            // "iis" 表示:integer, integer, string
            $stmt->bind_param("iis", $minAge, $maxAge, $city);
            
            $stmt->execute();
            
            $result = $stmt->get_result();
            
            // 取得所有記錄
            $users = $result->fetch_all(MYSQLI_ASSOC);
            
            $stmt->close();
            
            return $users;
            
        } catch (mysqli_sql_exception $e) {
            error_log("查詢錯誤:" . $e->getMessage());
            return [];
        }
    }
    
    /**
     * MySQLi 交易處理範例
     * 
     * @param array $orderData 訂單資料
     * @return bool 交易是否成功
     */
    public function createOrder($orderData) {
        try {
            // 開始交易
            $this->mysqli->begin_transaction();
            
            // 插入訂單主檔
            $sql1 = "INSERT INTO orders (user_id, total_amount, created_at) 
                     VALUES (?, ?, NOW())";
            
            $stmt1 = $this->mysqli->prepare($sql1);
            $stmt1->bind_param("id", $orderData['user_id'], $orderData['total_amount']);
            $stmt1->execute();
            
            // 取得訂單 ID
            $orderId = $this->mysqli->insert_id;
            
            // 插入訂單明細
            $sql2 = "INSERT INTO order_items (order_id, product_id, quantity, price) 
                     VALUES (?, ?, ?, ?)";
            
            $stmt2 = $this->mysqli->prepare($sql2);
            
            foreach ($orderData['items'] as $item) {
                $stmt2->bind_param(
                    "iiid",
                    $orderId,
                    $item['product_id'],
                    $item['quantity'],
                    $item['price']
                );
                $stmt2->execute();
            }
            
            // 更新庫存
            $sql3 = "UPDATE products 
                     SET stock = stock - ? 
                     WHERE id = ? AND stock >= ?";
            
            $stmt3 = $this->mysqli->prepare($sql3);
            
            foreach ($orderData['items'] as $item) {
                $stmt3->bind_param(
                    "iii",
                    $item['quantity'],
                    $item['product_id'],
                    $item['quantity']
                );
                $stmt3->execute();
                
                // 檢查是否成功更新(庫存是否足夠)
                if ($stmt3->affected_rows === 0) {
                    throw new Exception("庫存不足:產品 ID {$item['product_id']}");
                }
            }
            
            // 提交交易
            $this->mysqli->commit();
            
            return true;
            
        } catch (Exception $e) {
            // 回滾交易
            $this->mysqli->rollback();
            
            error_log("訂單建立失敗:" . $e->getMessage());
            return false;
        }
    }
}

// ============================================================================
// 範例三:防範 SQL 注入攻擊的實務案例
// ============================================================================

/**
 * 展示不安全的查詢方式(絕對不要這樣做!)
 * 這段程式碼僅供教學目的,說明 SQL 注入的危險性
 */
class UnsafeDatabaseExample {
    private $pdo;
    
    /**
     * 不安全的查詢方式 - 容易受到 SQL 注入攻擊
     * 
     * 假設攻擊者輸入:admin' OR '1'='1
     * 最終 SQL 變成:SELECT * FROM users WHERE username = 'admin' OR '1'='1'
     * 這會回傳所有使用者記錄!
     */
    public function unsafeLogin($username, $password) {
        // ❌ 危險!直接將使用者輸入拼接到 SQL 字串中
        $sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
        
        // 執行查詢(這會讓攻擊者有機會注入惡意 SQL)
        $result = $this->pdo->query($sql);
        
        return $result->fetch();
    }
    
    /**
     * 安全的查詢方式 - 使用預處理陳述式
     */
    public function safeLogin($username, $password) {
        // ✅ 安全!使用預處理陳述式與參數綁定
        $sql = "SELECT id, username, email FROM users WHERE username = ? LIMIT 1";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$username]);
        
        $user = $stmt->fetch();
        
        // 使用 password_verify() 驗證密碼雜湊
        if ($user && password_verify($password, $user['password_hash'])) {
            return $user;
        }
        
        return false;
    }
}

// ============================================================================
// 使用範例
// ============================================================================

// PDO 範例
$db = new DatabasePDO();
$db->connect();

// 安全地查詢使用者
$user = $db->getUserById(123);

if ($user) {
    echo "使用者名稱:" . htmlspecialchars($user['username']) . "\n";
    echo "電子郵件:" . htmlspecialchars($user['email']) . "\n";
}

// MySQLi 範例
$mysqli = new DatabaseMySQLi();
$mysqli->connect();

// 查詢特定城市的使用者
$users = $mysqli->getUsersByAgeAndCity(25, 35, '台北市');

foreach ($users as $user) {
    echo "姓名:" . htmlspecialchars($user['username']) . "\n";
}

預處理陳述式的安全性優勢源自於其執行機制的根本設計。當我們呼叫 prepare 方法時,SQL 陳述式的結構(包含佔位符)會先被傳送至資料庫伺服器進行解析與編譯。此時資料庫已經確定了查詢的邏輯結構,後續透過 execute 方法傳入的參數值無論內容為何,都只會被視為純粹的資料值,不會被解釋為 SQL 語法的一部分。這種機制從根本上阻斷了 SQL 注入攻擊的可能性。

參數綁定(Parameter Binding)提供了兩種方式:位置參數與命名參數。位置參數使用問號作為佔位符,參數值按順序對應。命名參數使用冒號加名稱作為佔位符,參數值透過關聯陣列指定。在簡單查詢中,位置參數較為簡潔。在複雜查詢或同一參數需要多次使用時,命名參數提供更好的可讀性與維護性。選擇哪種方式主要取決於查詢的複雜度與團隊的程式風格偏好。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 14
skinparam minClassWidth 150

package "PHP MySQL 資料庫存取架構" {
  
  frame "應用程式層" as AppLayer {
    rectangle "Web 應用程式" as WebApp
    rectangle "業務邏輯層" as BusinessLogic
  }
  
  frame "資料庫存取層" as DatabaseLayer {
    card "PDO 擴充功能" as PDO {
      component "資料庫抽象層" as PDOAbstract
      component "預處理陳述式" as PDOPrepared
      component "參數綁定機制" as PDOBinding
    }
    
    card "MySQLi 擴充功能" as MySQLi {
      component "物件導向介面" as MySQLiOOP
      component "程序式介面" as MySQLiProc
      component "預處理陳述式" as MySQLiPrepared
    }
  }
  
  frame "安全防護層" as SecurityLayer {
    rectangle "SQL 注入防護" as SQLInjection
    rectangle "參數驗證" as ParamValidation
    rectangle "錯誤處理" as ErrorHandling
  }
  
  database "MySQL 資料庫伺服器" as MySQL {
    [查詢解析器]
    [查詢最佳化器]
    [執行引擎]
    [儲存引擎]
  }
}

WebApp --> BusinessLogic
BusinessLogic --> PDO
BusinessLogic --> MySQLi

PDO --> PDOAbstract
PDOAbstract --> PDOPrepared
PDOPrepared --> PDOBinding

MySQLi --> MySQLiOOP
MySQLi --> MySQLiProc
MySQLiOOP --> MySQLiPrepared
MySQLiProc --> MySQLiPrepared

PDOBinding --> SQLInjection
MySQLiPrepared --> SQLInjection

SQLInjection --> ParamValidation
ParamValidation --> ErrorHandling

PDO --> MySQL
MySQLi --> MySQL

ErrorHandling --> MySQL

note right of PDO
  PDO 優勢:
  - 支援多種資料庫系統
  - 統一的程式介面
  - 命名參數支援
  - 例外處理機制
end note

note right of MySQLi
  MySQLi 優勢:
  - MySQL 專用最佳化
  - 支援非同步查詢
  - 物件導向與程序式雙介面
  - MySQL 特定功能存取
end note

note bottom of SecurityLayer
  安全防護三層機制:
  1. 預處理陳述式防止 SQL 注入
  2. 參數驗證確保資料正確性
  3. 錯誤處理避免資訊洩漏
end note

@enduml

SQL 注入攻擊的防護策略與多層次安全機制

SQL 注入攻擊的本質在於攻擊者透過精心設計的輸入值,改變原本 SQL 陳述式的邏輯結構,進而執行未經授權的資料庫操作。最常見的攻擊場景是登入表單,攻擊者在使用者名稱或密碼欄位輸入特殊字元組合,試圖繞過身份驗證邏輯。在台灣曾發生多起因 SQL 注入導致的資料外洩事件,包含會員個資、信用卡資訊、交易記錄等敏感資料被竊取,不僅造成企業鉅額損失,更嚴重損害品牌信譽。

防範 SQL 注入攻擊需要建立多層次的安全防護機制。預處理陳述式是第一道也是最關鍵的防線,透過將 SQL 結構與資料分離,從根本上消除注入的可能性。輸入驗證是第二道防線,在資料進入資料庫之前先進行格式檢查與內容過濾。例如電子郵件欄位應該驗證格式是否符合標準,電話號碼應該確認是否為數字,日期欄位應該檢查合理性。這種驗證不僅能夠防止惡意輸入,也能確保資料品質。

最小權限原則(Principle of Least Privilege)是資料庫安全的重要概念。應用程式連線資料庫時使用的帳號,應該只擁有完成任務所需的最小權限。例如一般的網站應用只需要 SELECT、INSERT、UPDATE、DELETE 權限,不應該擁有 DROP、CREATE、GRANT 等管理權限。即使應用程式被入侵,攻擊者也無法透過資料庫帳號執行結構變更或權限提升操作。在台灣的金融科技應用中,通常會為不同的應用功能建立不同的資料庫帳號,嚴格控制每個帳號的存取範圍。

錯誤訊息的處理同樣關係到安全性。詳細的資料庫錯誤訊息可能洩漏資料表結構、欄位名稱、SQL 語法等敏感資訊,為攻擊者提供有價值的情報。在生產環境中,應該將詳細錯誤訊息記錄到日誌檔案供開發者除錯,而向使用者顯示的錯誤訊息應該是通用且不含敏感資訊的提示。例如「查詢失敗,請稍後再試」比「Table ‘users’ doesn’t exist in database ‘company_db’」安全得多。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 14
skinparam minClassWidth 150

participant "使用者瀏覽器" as Browser
participant "Web 應用程式" as WebApp
participant "PDO/MySQLi" as DatabaseAPI
participant "MySQL 伺服器" as MySQL

== 預處理陳述式安全查詢流程 ==

Browser -> WebApp: 提交登入請求\nusername: "admin"\npassword: "secret123"
activate WebApp

WebApp -> WebApp: 輸入驗證\n檢查格式與長度

WebApp -> DatabaseAPI: prepare()\n"SELECT * FROM users\nWHERE username = ?"
activate DatabaseAPI

DatabaseAPI -> MySQL: 傳送 SQL 結構\n進行編譯與最佳化
activate MySQL

MySQL -> MySQL: 解析 SQL 語法\n建立查詢計畫

MySQL --> DatabaseAPI: 回傳預處理\n陳述式物件
deactivate MySQL

DatabaseAPI --> WebApp: 預處理陳述式就緒
deactivate DatabaseAPI

WebApp -> DatabaseAPI: execute(['admin'])
activate DatabaseAPI

DatabaseAPI -> MySQL: 傳送參數值\n'admin' 僅作為資料
activate MySQL

MySQL -> MySQL: 使用預編譯的\n查詢計畫執行

MySQL -> MySQL: 參數值不會被\n解釋為 SQL 語法

MySQL --> DatabaseAPI: 回傳查詢結果
deactivate MySQL

DatabaseAPI --> WebApp: 回傳結果集
deactivate DatabaseAPI

WebApp -> WebApp: 驗證密碼雜湊\npassword_verify()

WebApp --> Browser: 登入成功/失敗回應
deactivate WebApp

== SQL 注入攻擊嘗試(被阻擋)==

Browser -> WebApp: 惡意登入請求\nusername: "admin' OR '1'='1"\npassword: "any"
activate WebApp

WebApp -> WebApp: 輸入驗證\n檢測到異常字元

note over WebApp
  即使繞過前端驗證
  預處理陳述式仍能防護
end note

WebApp -> DatabaseAPI: prepare()\n"SELECT * FROM users\nWHERE username = ?"
activate DatabaseAPI

DatabaseAPI -> MySQL: 傳送 SQL 結構
activate MySQL

MySQL --> DatabaseAPI: 預處理陳述式就緒
deactivate MySQL
deactivate DatabaseAPI

WebApp -> DatabaseAPI: execute(["admin' OR '1'='1"])
activate DatabaseAPI

DatabaseAPI -> MySQL: 參數值作為\n純粹字串處理
activate MySQL

MySQL -> MySQL: 查詢條件變成:\nusername = "admin' OR '1'='1"\n(整串作為字串比對)

MySQL -> MySQL: 沒有使用者名稱\n等於這個奇怪的字串

MySQL --> DatabaseAPI: 回傳空結果集
deactivate MySQL

DatabaseAPI --> WebApp: 無符合記錄
deactivate DatabaseAPI

WebApp --> Browser: 登入失敗\n(攻擊被阻擋)
deactivate WebApp

note over Browser, MySQL
  關鍵安全機制:
  1. SQL 結構與資料完全分離
  2. 參數值永遠不會被當作 SQL 語法解析
  3. 攻擊者無法改變查詢邏輯
  4. 多層驗證確保資料安全
end note

@enduml

交易處理與 ACID 特性保證

資料庫交易(Transaction)是確保資料一致性的核心機制,在需要多個資料庫操作必須全部成功或全部失敗的場景中不可或缺。最典型的例子是金融轉帳,從帳戶 A 扣款與向帳戶 B 入款必須作為一個不可分割的整體,若其中任一操作失敗,整個轉帳都應該被取消。ACID 特性(Atomicity、Consistency、Isolation、Durability)定義了交易處理的品質標準,確保資料庫在並行存取與系統故障情況下仍能維持資料完整性。

原子性(Atomicity)保證交易中的所有操作要麼全部執行成功,要麼全部不執行,不存在部分執行的中間狀態。一致性(Consistency)確保交易執行前後,資料庫都處於有效的狀態,不會違反任何約束條件。隔離性(Isolation)讓並行執行的多個交易之間互不干擾,每個交易都像是在獨立環境中執行。持久性(Durability)保證交易一旦提交,其結果就會永久保存,即使系統當機也不會遺失。

在 PHP 中使用 PDO 或 MySQLi 進行交易處理時,基本流程包含三個步驟:開始交易(beginTransaction)、執行多個資料庫操作、提交交易(commit)或回滾(rollback)。若所有操作都成功執行,呼叫 commit 方法將變更永久保存到資料庫。若任何操作失敗或偵測到錯誤,呼叫 rollback 方法撤銷所有已執行的變更,回到交易開始前的狀態。這種機制確保了資料的完整性,避免了部分更新導致的資料不一致問題。

台灣的電商平台在處理訂單時廣泛使用交易機制。當使用者完成結帳時,系統需要同時執行多個操作:建立訂單記錄、扣減庫存數量、記錄付款資訊、發送確認郵件。這些操作必須作為一個交易處理,若庫存不足導致扣減失敗,整個訂單建立流程都應該回滾,避免產生無法出貨的訂單。交易機制讓這種複雜的業務邏輯能夠以簡潔且可靠的方式實作。

PHP 與 MySQL 資料庫整合的技術看似基礎,卻是建構安全可靠網站應用的關鍵基石。預處理陳述式不僅是防禦 SQL 注入的最佳實踐,更代表了一種對安全性的根本性思考方式。交易處理機制確保了資料的完整性與一致性,讓複雜的業務邏輯能夠以可靠的方式實作。對於台灣的網站開發從業人員而言,精通這些技術不僅是職業技能的要求,更是保護使用者資料、維護企業信譽的專業責任。隨著資料安全法規日益嚴格、使用者隱私意識不斷提升,資料庫安全的重要性只會持續增加,唯有建立正確的安全意識與紮實的技術能力,才能在數位時代中提供值得信賴的網路服務。