Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP

PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP

Quản lý giao dịch cơ sở dữ liệu bằng beginTransaction, commit và rollBack trong PHP PDO, thực hành bật ràng buộc khóa ngoại và đóng gói truy vấn vào Repository.

Nhiều lần ghi cần cùng thành công hoặc hoàn tác

Trong các nghiệp vụ thực tế như tạo đơn hàng kèm chi tiết mặt hàng, chuyển khoản giữa hai ví hoặc khởi tạo phòng ban kèm phiếu hỗ trợ đầu tiên, một hành động của người dùng đòi hỏi phải ghi xuống 2 đến 3 bảng cơ sở dữ liệu liên tiếp. Ở chế độ mặc định (autocommit), mỗi câu lệnh $stmt->execute() được chốt cứng xuống đĩa ngay lập tức. Nếu câu lệnh thứ nhất ghi thành công nhưng câu lệnh thứ hai gặp lỗi vi phạm khóa ngoại hoặc mất kết nối, cơ sở dữ liệu sẽ rơi vào trạng thái "nửa vời": tiền đã trừ ở ví A nhưng chưa cộng vào ví B, hoặc bản ghi cha đã tạo nhưng không có bản ghi con đi kèm.

Bài học này hướng dẫn bạn quản lý giao dịch cơ sở dữ liệu (Database Transactions) theo nguyên tắc nguyên tử (Atomicity: thành công toàn bộ hoặc hủy bỏ toàn bộ) với beginTransaction(), commit() và rollBack() trong PHP PDO, kích hoạt kiểm tra ràng buộc khóa ngoại (FOREIGN KEY), và đóng gói toàn bộ logic SQL vào lớp Repository tách biệt hoàn toàn với thực thể miền (Domain Entity). Bài viết nối tiếp PHP EP 19: PDO và truy vấn tham số hóa trong PHP và chuẩn bị cho PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP. Tài liệu tham chiếu gồm PHP Manual: Transactions and auto-commit và SQLite Foreign Key Support.

Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) và SQLite 3.53.4 (pdo_sqlite) trên Alpine Linux 3.21 (aarch64).
  • Giới hạn kiểm chứng: Kiểm chứng giao dịch ACID, hoàn tác rollBack(), ràng buộc khóa ngoại trên SQLite và mẫu thiết kế Repository; không tuyên bố kiểm chứng giao dịch phân tán hai pha (2PC / XA) giữa nhiều máy chủ CSDL.

Hợp đồng của Transaction và Repository

Bảng dưới đây tổng hợp hợp đồng kỹ thuật của các phương thức điều khiển giao dịch PDO và kiến trúc Repository:

Phương thức / Thành phần Cú pháp chuẩn Hành vi thực tế trong PHP 8.4 & PDO Điều kiện / Không nên giả định
$pdo->beginTransaction() Gọi ngay trước chuỗi câu lệnh ghi dữ liệu liên kết Tắt chế độ tự động chốt (`autocommit`); mọi thay đổi chỉ nằm trong vùng đệm giao dịch cho tới khi gọi commit() hoặc rollBack(). PDO không hỗ trợ lồng trực tiếp hai lệnh beginTransaction() liên tiếp (sẽ ném PDOException: There is already an active transaction).
$pdo->commit() Gọi ở dòng cuối của khối try khi mọi câu lệnh thành công Ghi nhận vĩnh viễn toàn bộ thay đổi trong giao dịch xuống cơ sở dữ liệu và trả kết nối về chế độ autocommit. Trong MySQL/MariaDB, các câu lệnh DDL (như CREATE TABLE, ALTER TABLE) sẽ gây **tự động commit ngầm (implicit commit)**!
$pdo->inTransaction() + $pdo->rollBack() if ($pdo->inTransaction()) { $pdo->rollBack(); } trong khối catch Hủy bỏ sạch sẽ toàn bộ các thao tác INSERT/UPDATE/DELETE đã chạy từ lúc mở beginTransaction(). Luôn kiểm tra inTransaction() trước khi gọi rollBack() để tránh lỗi nếu giao dịch đã bị đóng tự động.
Ràng buộc Khóa ngoại trên SQLite $pdo->exec('PRAGMA foreign_keys = ON'); Bắt buộc SQLite kiểm tra sự tồn tại của khóa chính ở bảng cha khi chèn/sửa bảng con hoặc xóa bảng cha. Mặc định SQLite tắt kiểm tra khóa ngoại trên mỗi kết nối mới; **bắt buộc phải chạy lệnh PRAGMA này ngay sau khi mở kết nối**.
Lớp Repository final readonly class TicketRepository Đóng gói đối tượng PDO, chuyển đổi giữa bản ghi bảng SQL và đối tượng thực thể miền (`Entity`). Không trả về đối tượng PDOStatement thô ra ngoài Repository; luôn trả về thực thể miền có kiểu.

Transaction và inTransaction kiểm soát hoàn tác

Một giao dịch (Transaction) đảm bảo 4 tính chất ACID (Atomicity, Consistency, Isolation, Durability). Khi bọc chuỗi thao tác ghi vào khuôn mẫu chuẩn (beginTransaction() -> try -> commit() -> catch (\Throwable $e) -> rollBack()), có 3 quy tắc sống còn cần ghi nhớ:

  • Thứ nhất, bắt \Throwable $e (thay vì chỉ PDOException) để nếu giữa hai câu lệnh SQL có một bước tính toán bằng PHP bị lỗi TypeError hoặc InvalidArgumentException, giao dịch vẫn được hoàn tác sạch sẽ.
  • Thứ hai, luôn kiểm tra if ($pdo->inTransaction()) trước khi gọi $pdo->rollBack(), tránh việc ném chồng thêm ngoại lệ There is no active transaction nếu giao dịch đã bị đóng ngầm trước đó.
  • Thứ ba, sau khi đã hoàn tác rollBack(), phải ném lại ngoại lệ throw $e; để tầng dịch vụ bên trên biết tác vụ đã thất bại.
Luồng điều khiển giao dịch PDO từ lúc mở Transaction đến khi Commit hoặc Rollback.
Hình 1. Luồng điều khiển giao dịch PDO từ lúc mở Transaction đến khi Commit hoặc Rollback. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Kiểm tra rollback khi vi phạm khóa ngoại

Chúng ta sẽ tạo cơ sở dữ liệu SQLite có bật PRAGMA foreign_keys = ON với hai bảng departments và tickets, thực hiện một giao dịch hợp lệ (commit) và một giao dịch cố tình chèn dept_id = 999 không tồn tại để chứng minh lệnh chèn phòng ban trước đó được rollBack() sạch sẽ.

Bước 1: Tạo vùng làm việc tạm an toàn

Mục đích: Khởi tạo thư mục thực hành riêng trong /tmp.

WORKDIR="$(mktemp -d /tmp/php-ep20-XXXXXX)"
cd "$WORKDIR"
pwd

Bước 2: Tạo kịch bản pdo_tx.php kiểm chứng Commit và Rollback khi lỗi khóa ngoại

Mục đích: Xác minh giao dịch thứ hai khi thất bại ở bước chèn tickets sẽ hoàn tác luôn cả lệnh chèn phòng ban 'Billing' đã chạy ngay trước đó.

cat <<'EOF' > "$WORKDIR/pdo_tx.php"
<?php
declare(strict_types=1);

$pdo = new PDO('sqlite:' . __DIR__ . '/tx.sqlite', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)');
$pdo->exec('CREATE TABLE tickets (id INTEGER PRIMARY KEY, dept_id INTEGER NOT NULL, title TEXT NOT NULL, FOREIGN KEY(dept_id) REFERENCES departments(id))');

$pdo->beginTransaction();
$pdo->prepare('INSERT INTO departments (id, name) VALUES (1, :name)')->execute([':name' => 'HelpDesk']);
$pdo->prepare('INSERT INTO tickets (dept_id, title) VALUES (1, :title)')->execute([':title' => 'VPN Error']);
$pdo->commit();

$rolledBack = false;
try {
    $pdo->beginTransaction();
    $pdo->prepare('INSERT INTO departments (id, name) VALUES (2, :name)')->execute([':name' => 'Billing']);
    $pdo->prepare('INSERT INTO tickets (dept_id, title) VALUES (999, :title)')->execute([':title' => 'Invalid FK']);
    $pdo->commit();
} catch (PDOException $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
        $rolledBack = true;
    }
}

$deptCount = (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn();
echo "Depts={$deptCount} | RolledBack=" . ($rolledBack ? 'true' : 'false') . "\n";
EOF
php "$WORKDIR/pdo_tx.php"

Kết quả quan sát được:

  • Đầu ra hiển thị: Depts=1 | RolledBack=true.
  • Mặc dù câu lệnh chèn phòng ban 'Billing' (id = 2) đã chạy xong, nhưng vì câu lệnh kế tiếp chèn dept_id = 999 vi phạm FOREIGN KEY, khối catch đã gọi $pdo->rollBack(). Kết quả là phòng ban 'Billing' bị hủy bỏ hoàn toàn, tổng số phòng ban vẫn giữ nguyên là 1.

Cảnh báo an toàn: Nếu quên dòng $pdo->exec('PRAGMA foreign_keys = ON') khi dùng SQLite, câu lệnh chèn dept_id = 999 sẽ chạy thành công mà không báo lỗi, tạo ra bản ghi mồ côi!

Tách truy vấn SQL vào Repository

Để tầng nghiệp vụ không bị trộn lẫn với câu lệnh SQL, chúng ta áp dụng Repository Pattern: định nghĩa thực thể Department thuần PHP và lớp DepartmentRepository chịu trách nhiệm chuyển đổi giữa dòng dữ liệu SQL và đối tượng Department.

Bước 3: Tạo kịch bản repository_demo.php tách biệt Entity và Repository

Mục đích: Đóng gói thao tác tìm kiếm phòng ban vào lớp DepartmentRepository trả về thực thể Department có kiểu chặt chẽ.

cat <<'EOF' > "$WORKDIR/repository_demo.php"
<?php
declare(strict_types=1);

readonly class Department
{
    public function __construct(
        public ?int $id,
        public string $name,
    ) {}
}

final readonly class DepartmentRepository
{
    public function __construct(private PDO $pdo) {}

    public function findById(int $id): ?Department
    {
        $stmt = $this->pdo->prepare('SELECT id, name FROM departments WHERE id = :id');
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch();
        if ($row === false) {
            return null;
        }
        return new Department((int) $row['id'], (string) $row['name']);
    }
}

$pdo = new PDO('sqlite:' . __DIR__ . '/tx.sqlite', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$repo = new DepartmentRepository($pdo);
$dept = $repo->findById(1);
$missing = $repo->findById(99);

echo "Dept1=" . ($dept ? $dept->name : 'none') . " | Dept99=" . ($missing === null ? 'null' : 'found') . "\n";
EOF
php "$WORKDIR/repository_demo.php"

Kết quả quan sát được:

  • Màn hình in ra: Dept1=HelpDesk | Dept99=null. Phương thức findById(int $id): ?Department trả về đúng kiểu đối tượng Department hoặc null.

Cảnh báo an toàn: Tuyệt đối không thực hiện các cuộc gọi HTTP API ra bên ngoài hay gửi email ở bên trong cặp beginTransaction() ... commit(), vì thời gian chờ mạng sẽ làm treo khóa bảng trong DB.

Kiểm tra lỗi mở giao dịch lồng nhau và dọn dẹp

Khi phương thức A đã mở $pdo->beginTransaction() rồi gọi phương thức B cũng gọi $pdo->beginTransaction(), PDO sẽ ném lỗi vì không hỗ trợ giao dịch lồng nhau tự động.

Bước 4: Gọi beginTransaction() hai lần liên tiếp và kiểm tra mã thoát

Mục đích: Xác nhận PDO ném ngoại lệ PDOException khi mở chồng giao dịch.

set +e
php -r '
$pdo = new PDO("sqlite::memory:");
$pdo->beginTransaction();
$pdo->beginTransaction();
'
RC=$?
set -e
echo "RC=$RC"

Kết quả quan sát được:

  • Tiến trình kết thúc với RC=255 kèm thông báo: Fatal error: Uncaught PDOException: There is already an active transaction.
Vai trò tách biệt giữa đối tượng thực thể (Entity) và lớp truy cập dữ liệu Repository.
Hình 2. Vai trò tách biệt giữa đối tượng thực thể (Entity) và lớp truy cập dữ liệu Repository. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Bước 5: Dọn dẹp vùng làm việc tạm

Mục đích: Xóa thư mục thực hành trong /tmp.

rm -rf "$WORKDIR"

Phân biệt Savepoint và khóa SELECT FOR UPDATE

Khi xây dựng hệ thống tài chính có độ tranh chấp cao trên Production, cần nắm hai kỹ thuật nâng cao:

  1. Xử lý giao dịch lồng nhau bằng SAVEPOINT: Kiểm tra if ($pdo->inTransaction()) để phát lệnh SQL SAVEPOINT sp_1 (và ROLLBACK TO SAVEPOINT sp_1 khi lỗi) thay vì gọi beginTransaction() lần hai.
  2. Chống lỗi Race Condition bằng SELECT ... FOR UPDATE: Khi kiểm tra số dư ví rồi mới trừ tiền trên MySQL/PostgreSQL, dùng SELECT balance FROM wallets WHERE id = :id FOR UPDATE bên trong Transaction để khóa dòng đó lại cho tới khi commit().

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:

  • Câu hỏi 1: Điều gì xảy ra nếu đã gọi $pdo->beginTransaction() và chạy 2 lệnh INSERT, nhưng kịch bản PHP kết thúc mà quên gọi $pdo->commit()? -> PDO tự động thực hiện rollBack() ngầm khi kết nối đóng lại, hủy bỏ toàn bộ 2 lệnh INSERT đó.
  • Câu hỏi 2: Vì sao trên SQLite qua PDO, mặc định các ràng buộc FOREIGN KEY không bắt lỗi khi chèn khóa ngoại sai? -> Vì SQLite mặc định tắt kiểm tra khóa ngoại. Bắt buộc phải chạy $pdo->exec('PRAGMA foreign_keys = ON') ngay sau khi mở kết nối.
  • Câu hỏi 3: Tại sao không nên gọi API bên thứ ba hoặc gửi email bên trong khối beginTransaction() ... commit()? -> Vì cuộc gọi mạng kéo dài thời gian giữ khóa (lock) trong CSDL, gây tắc nghẽn cho các kết nối khác.
  • Câu hỏi 4: Lợi ích lớn nhất của việc bọc PDO bên trong lớp Repository trả về Entity là gì? -> Tách biệt tầng nghiệp vụ khỏi cú pháp SQL và đảm bảo an toàn kiểu tĩnh (Type Safety) trên toàn ứng dụng.

Bạn đã nắm vững cách quản lý giao dịch và thiết kế lớp Repository. Hãy tiếp tục sang PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP để viết kịch bản kiểm thử tự động cho dự án.

Ôn bài trước: PHP EP 19: PDO và truy vấn tham số hóa trong PHP. Học tiếp: PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP.

Bài liên quan trong bộ PHP

Tìm hiểu thêm