Kinh nghiệm dùng Trigger trong MySQL: Validate, ghi Audit Log và tránh bẫy hiệu năng

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

Cách đây 6 tháng, team mình quyết định chuyển toàn bộ logic kiểm tra số dư và theo dõi biến động tài khoản xuống thẳng MySQL. Quyết định này giúp siết chặt dữ liệu. Đổi lại, hệ thống từng suýt sập khi pool kết nối chạm ngưỡng 500 connections chỉ sau một đợt flash sale. Dưới đây là những kinh nghiệm thực chiến giúp bạn tận dụng Trigger mà không làm sập production.

Ba hướng tiếp cận khi cần tự động hóa dữ liệu

Giả sử bạn cần cấm nhập số âm, tự sinh timestamp hoặc theo dõi lịch sử cập nhật bảng. Các backend engineer thường cân nhắc 3 giải pháp sau:

  • Xử lý tại tầng ứng dụng (Backend Code): Toàn bộ logic validate và ghi log nằm trong source code (NestJS, Laravel, Go, Spring Boot).
  • Dùng MySQL Trigger: Đẩy thẳng logic vào MySQL Engine. Khi câu lệnh INSERT, UPDATE hoặc DELETE chạy, MySQL sẽ tự kích hoạt script SQL tương ứng.
  • Dùng Stored Procedure hoặc Scheduled Event: Buộc mọi thao tác ghi phải đi qua procedure, hoặc dựng cron job định kỳ quét bảng để đối soát.

Bảng so sánh thực tế sau 6 tháng vận hành

Mỗi giải pháp đều có chi phí đánh đổi riêng. Bạn cần chọn đúng công cụ cho bài toán của mình:

1. Xử lý ở tầng ứng dụng

  • Ưu điểm: Dễ scale ngang bằng cách cắm thêm pod hay server. Code có version control rõ ràng, dễ viết unit test và mock dữ liệu.
  • Hạn chế: Dễ sót dữ liệu nếu nhiều microservice cùng ghi vào một DB. Chưa kể, nếu ai đó mở DBeaver gõ query tay, rule nghiệp vụ coi như bị bỏ qua. Việc gọi thêm câu lệnh ghi log cũng tốn thêm 1 round-trip network (khoảng 2–5ms mỗi query).

2. Dùng MySQL Trigger

  • Ưu điểm: Bất kể câu lệnh ghi đến từ API, worker hay thao tác tay của DBA, Trigger đều can thiệp được. Tốc độ thực thi cực nhanh vì mọi thứ chạy ngay trong bộ nhớ của InnoDB engine, không tốn network latency.
  • Hạn chế: Rất khó viết automated test. Logic nằm ngầm dưới DB nên nhân sự mới thường sót nghiệp vụ khi đọc code backend. Nguy hiểm nhất: Trigger chạy chung transaction với câu lệnh cha. Nếu Trigger xử lý chậm, thời gian giữ lock sẽ kéo dài và kéo tụt throughput toàn hệ thống.

3. Stored Procedure và Scheduled Event

  • Ưu điểm: Gom cụm logic ghi thành một luồng chuẩn, job chạy nền không cản trở luồng request chính của người dùng.
  • Hạn chế: Scheduled Event luôn có độ trễ (delay từ vài giây đến vài phút). Ngược lại, Stored Procedure làm việc rất gượng gạo với các ORM phổ biến như Prisma, TypeORM hay Hibernate.

Khi nào Trigger là lựa chọn hợp lý?

Trigger không phải giải pháp toàn năng. Bạn chỉ nên dùng nó trong hai tình huống cụ thể:

  1. Ghi Audit Log bắt buộc: Lưu vết các bảng nhạy cảm như số dư ví, trạng thái đơn hàng hoặc quyền admin. Dữ liệu cần minh bạch tuyệt đối, không thể bị sửa lén.
  2. Validate ràng buộc phức tạp: Áp dụng khi các constraint mặc định như CHECK hay FOREIGN KEY không đủ linh hoạt để xử lý logic chéo.

Nếu hệ thống phải chịu tải từ 5.000 write request/giây trở lên, hãy đưa việc ghi log sang Message Queue (Kafka, RabbitMQ) rồi xử lý bất đồng bộ. Với các hệ thống vừa và nhỏ (dưới 500 writes/giây), Trigger là công cụ đơn giản và đáng tin cậy.

Hai kịch bản triển khai Trigger phổ biến

1. Cú pháp và các biến ngữ cảnh

Trigger trong MySQL xoay quanh ba yếu tố: thời điểm (BEFORE/AFTER), hành động (INSERT/UPDATE/DELETE) và ngữ cảnh. Trong đó, OLD đại diện cho bản ghi cũ trước khi sửa/xóa, còn NEW là bản ghi mới chuẩn bị được nạp vào.

2. Kịch bản 1: Validate và chuẩn hóa dữ liệu với BEFORE INSERT

Bài toán: Bảng người dùng cần đảm bảo email luôn lưu dưới dạng chữ thường (lowercase) và số dư ban đầu không được nhỏ hơn 0.

DELIMITER $$

CREATE TRIGGER trg_users_before_insert
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
    -- Tự động chuẩn hóa email
    SET NEW.email = LOWER(TRIM(NEW.email));

    -- Chặn dữ liệu sai logic nghiệp vụ
    IF NEW.balance < 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Lỗi nghiệp vụ: Số dư tài khoản không được nhỏ hơn 0.';
    END IF;
END$$

DELIMITER ;

Nếu câu lệnh cố chèn số dư âm, MySQL sẽ lập tức rollback transaction và trả về lỗi SQLSTATE 45000. Dữ liệu sai không thể lọt vào database.

3. Kịch bản 2: Ghi Audit Log tự động với AFTER UPDATE

Đây là kịch bản rất thực tế: lưu lại trạng thái đơn hàng thay đổi ra sao và vào thời điểm nào.

-- Bảng lưu lịch sử thay đổi
CREATE TABLE order_audit_logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    old_status VARCHAR(50),
    new_status VARCHAR(50),
    changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_order_id (order_id)
) ENGINE=InnoDB;

-- Trigger theo dõi thay đổi trạng thái
DELIMITER $$

CREATE TRIGGER trg_orders_after_update
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
    -- Chỉ ghi nhận khi trạng thái thực sự thay đổi
    IF OLD.status <> NEW.status THEN
        INSERT INTO order_audit_logs (order_id, old_status, new_status, changed_at)
        VALUES (OLD.id, OLD.status, NEW.status, NOW());
    END IF;
END$$

DELIMITER ;

Bốn quy tắc sống còn khi chạy Trigger trên Production

Hệ thống của mình chạy MySQL 8.0 với dung lượng database hơn 60GB, xử lý khoảng 300 giao dịch/giây. Dưới đây là những kinh nghiệm giúp hệ thống chạy ổn định suốt đợt cao điểm:

1. Giữ Trigger cực kỳ gọn nhẹ

Tuyệt đối không chạy subquery phức tạp hoặc join nhiều bảng bên trong Trigger. Mỗi 1ms Trigger kéo dài sẽ cộng trực tiếp vào thời gian giữ row lock. Hậu quả là các transaction đến sau phải xếp hàng chờ, dễ dẫn tới lỗi Lock wait timeout exceeded.

2. Tránh bẫy Deadlock khi cập nhật chéo bảng

Giả sử Trigger trên bảng orders lại chạy lệnh update sang bảng wallets. Cùng lúc đó, một API khác đang update bảng wallets rồi gọi tới orders. Hai tiến trình này sẽ khoá lẫn nhau và tạo ra Deadlock. Giải pháp an toàn nhất: Trigger chỉ nên gán giá trị trên dòng hiện tại (dùng NEW) hoặc chạy lệnh INSERT sang bảng audit log.

3. Cắt giảm Index thừa trên bảng Audit Log

Bảng ghi log thường phình to rất nhanh, có thể tăng hàng triệu bản ghi mỗi tuần. Nếu bảng này cõng quá nhiều index, mỗi lần trigger chạy lệnh insert sẽ làm chậm toàn bộ câu lệnh gốc. Bạn chỉ nên giữ lại Primary Key và index trên cột cần tra cứu (như order_id).

4. Quản lý Trigger bằng migration file

Không bao giờ tạo hay sửa Trigger trực tiếp trên production qua GUI. Hãy đưa định nghĩa Trigger vào file migration của dự án để theo dõi qua Git. Khi cần kiểm tra hoặc cập nhật trên server, bạn dùng hai lệnh sau:

-- Xem danh sách trigger trên bảng orders
SHOW TRIGGERS LIKE 'orders';

-- Xóa trigger cũ trước khi cập nhật bản mới
DROP TRIGGER IF EXISTS trg_orders_after_update;

Trigger giống như chốt chặn cuối cùng bảo vệ dữ liệu. Hãy dùng nó đúng chỗ, giữ logic thật đơn giản và luôn tính toán chi phí row lock trước khi đưa lên production.

Share: