Kết nối MySQL bằng PHP PDO: Bảo mật tuyệt đối với Prepared Statements và Transaction

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

Từ bỏ mysqli: Bài học đắt giá từ một lỗ hổng bảo mật

Hồi mới tập tành làm web, mình cứ nghĩ mysqli là chân ái vì nó dễ dùng. Cứ nối chuỗi, ném query vào hàm là xong. Sai lầm này đã trả giá đắt khi một dự án bị dính SQL Injection chỉ vì mình quên thoát (escape) input từ người dùng. Sau đó, mình chuyển hẳn sang PDO (PHP Data Objects) để quản lý hệ thống kho hơn 2 triệu bản ghi. Đó là quyết định sáng suốt nhất sự nghiệp của mình.

PDO không chỉ hỗ trợ MySQL mà còn chơi tốt với PostgreSQL lẫn SQLite. Ưu điểm vượt trội nhất chính là Prepared Statements. Cơ chế này tách biệt hoàn toàn lệnh SQL và dữ liệu đầu vào. Nhờ vậy, bạn loại bỏ được 99% nguy cơ bị tấn công mà không cần dùng những hàm real_escape_string rườm rà.

Khởi tạo kết nối PDO chuẩn để chạy Production

Đừng chỉ khởi tạo PDO theo kiểu “cho chạy được”. Để hệ thống ổn định, bạn cần cấu hình các thuộc tính (attributes) để kiểm soát lỗi chặt chẽ. Dưới đây là cách mình triển khai class Database thực tế.

<?php
$host = '127.0.0.1';
$db   = 'ecommerce_db';
$user = 'sys_admin';
$pass = 'p@ssw0rd_secure';
$charset = 'utf8mb4';

$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
     $pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
     // Ghi log lỗi nội bộ, tuyệt đối không show ra ngoài
     error_log("Lỗi kết nối DB: " . $e->getMessage());
     die("Hệ thống đang bảo trì. Vui lòng quay lại sau.");
}
?>

Nhìn vào đoạn code trên, mình set ERRMODE_EXCEPTION để mọi lỗi phát sinh đều ném ra Exception. Điều này giúp việc bắt lỗi bằng try-catch trở nên cực kỳ gọn gàng. Đặc biệt, việc tắt ATTR_EMULATE_PREPARES buộc PDO dùng Prepared Statements thực thụ từ MySQL server thay vì giả lập, giúp bảo mật tầng sâu hơn.

Chặn đứng SQL Injection bằng Prepared Statements

Nhiều bạn vẫn giữ thói quen nối chuỗi trực tiếp vào query. Khi bảng users vượt ngưỡng 10 triệu dòng, việc này không chỉ gây mất an toàn mà còn kéo sụt hiệu năng. MySQL sẽ phải biên dịch lại câu lệnh cho mỗi lần thực thi.

Với Prepared Statements, MySQL biên dịch khung câu lệnh trước, dữ liệu đổ vào sau. Bạn có hai lựa chọn:

1. Sử dụng Named Placeholders (Khuyên dùng)

Cách này giúp code cực kỳ trong sáng, đặc biệt khi query có nhiều tham số phức tạp.

$params = [
    'email' => '[email protected]',
    'status' => 1
];

$sql = "SELECT id, username FROM users WHERE email = :email AND status = :status";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$user = $stmt->fetch();

2. Sử dụng Positional Placeholders

Sử dụng dấu hỏi chấm (?), ngắn gọn nhưng dễ gây nhầm lẫn thứ tự nếu bạn có hơn 5 tham số.

$sql = "INSERT INTO activity_logs (user_id, action) VALUES (?, ?)";
$pdo->prepare($sql)->execute([$userId, 'update_profile']);

Transaction: Bảo hiểm cho dữ liệu của bạn

Thử nghĩ xem: Bạn đang viết code thanh toán. Hệ thống vừa trừ tiền khách xong thì server sập, dẫn đến đơn hàng chưa kịp tạo. Khách mất tiền, bạn mất uy tín. Thảm họa!

Kinh nghiệm xương máu của mình: Cứ thao tác nào động chạm từ 2 bảng trở lên, hãy bọc chúng vào Transaction.

try {
    $pdo->beginTransaction();

    // Bước 1: Trừ 500k trong ví
    $pdo->prepare("UPDATE wallets SET balance = balance - 500000 WHERE user_id = ?")->execute([$userId]);

    // Bước 2: Tạo đơn hàng mới
    $pdo->prepare("INSERT INTO orders (user_id, amount) VALUES (?, ?)")->execute([$userId, 500000]);

    $pdo->commit();
    echo "Giao dịch hoàn tất!";
} catch (Exception $e) {
    $pdo->rollBack();
    echo "Lỗi hệ thống: Giao dịch đã được hoàn tác.";
}

Vấn đề Connection Pool trong PHP

Dân Java hay Node.js thường thắc mắc tại sao PHP không có Connection Pool mặc định. Thực tế, PHP chạy theo mô hình “shared-nothing”. Mỗi request là một tiến trình riêng và kết thúc hoàn toàn sau khi trả kết quả. Nó không chạy nền liên tục (long-running) như các ngôn ngữ khác.

Để tối ưu khi traffic tăng cao, bạn có hai hướng đi:

  • Persistent Connections: Thêm PDO::ATTR_PERSISTENT => true. PHP sẽ giữ lại kết nối để dùng cho request sau. Tuy nhiên, hãy cẩn thận kẻo làm cạn kiệt max_connections của MySQL.
  • ProxySQL: Giải pháp cho dự án lớn. ProxySQL đóng vai trò trung gian quản lý pool kết nối, giúp PHP không phải tốn công khởi tạo lại từ đầu mỗi lần có user truy cập.

Mẹo xử lý khi dữ liệu phình to

Khi bảng dữ liệu chạm mốc vài chục triệu dòng, dù dùng PDO chuẩn đến mấy mà query thiếu Index thì vẫn “tạch” như thường. Mình từng gặp case query mất 8 giây chỉ vì quên đánh Index cho cột created_at. Nhớ kỹ 2 nguyên tắc:

  1. Tuyệt đối tránh SELECT *. Chỉ lấy đúng những cột cần dùng.
  2. Đừng lạm dụng fetchAll(). Nếu kết quả trả về 100.000 dòng, nó sẽ ngốn sạch 128MB RAM mặc định của PHP ngay lập tức. Hãy dùng fetch() trong vòng lặp while để xử lý từng dòng một.
$stmt = $pdo->prepare("SELECT product_name FROM big_data_table");
$stmt->execute();

// Tiết kiệm RAM tối đa
while ($row = $stmt->fetch()) {
    // Xử lý dữ liệu tại đây
}

Lời kết sau 6 tháng thực chiến

Chuyển sang PDO không chỉ là đổi cách viết code, đó là cách bạn bảo vệ sản phẩm của mình. Prepared Statements giúp mình ngủ ngon hơn vì không lo hacker “ghé thăm”. Transaction đảm bảo dữ liệu tài chính luôn chính xác đến từng đồng. Nếu bạn đang bắt đầu dự án PHP mới, hãy chọn PDO ngay từ ngày đầu tiên. Chút thời gian setup ban đầu sẽ đổi lại sự an tâm và khả năng mở rộng cực tốt về sau.

Share: