Skip to content

View, trigger, stored procedure ​

Ba tính năng có trong mọi database quan hệ, và đều là cách đặt một phần logic vào trong database:

Là gìChạy khi nào
ViewMột câu SELECT được đặt tên, dùng như một bảngMỗi lần có câu query đọc từ view
TriggerĐoạn code database tự chạy khi bảng bị INSERT, UPDATE hoặc DELETETự động, kèm theo lệnh ghi; app không gọi trực tiếp
Stored procedureMột hàm viết bằng SQL, lưu trong databaseKhi app gọi CALL tên_procedure(...)

Câu hỏi phỏng vấn hay gặp không phải cú pháp mà là có nên đặt logic trong database không. Muốn trả lời được thì phải biết từng thứ làm gì và có bẫy gì, nên trang này đi qua từng thứ trước, rồi mới bàn chuyện nên hay không.

Mọi câu SQL trong trang đã được chạy thật trên MySQL 8.0.46 và PostgreSQL 17 (ngày 2026-10-16). Kết quả in trong trang là kết quả thật.

View ​

View là một câu SELECT được lưu lại với một cái tên. Đọc từ view cũng như đọc từ bảng:

sql
CREATE VIEW active_users AS
  SELECT id, email, name FROM users WHERE status = 'active';

SELECT * FROM active_users;   -- trả An và Bình, không có Chi (bị khoá)

View không lưu dữ liệu. Mỗi lần đọc view, database chạy lại câu SELECT bên trong (PostgreSQL: CREATE VIEW). Vì vậy dữ liệu luôn mới, và đọc view nhanh hay chậm là do câu query bên trong.

Dùng view để làm gì ​

  • Giấu cột nhạy cảm. View active_users không có cột password_hash. Cấp quyền cho tài khoản báo cáo đọc view thay vì đọc bảng gốc, thì người đó không bao giờ thấy được cột này.
  • Đặt tên cho một câu query phức tạp dùng ở nhiều nơi, như báo cáo hay dashboard.
  • Giữ tương thích khi đổi schema. Tách bảng users thành hai bảng nhưng code cũ vẫn đọc users: tạo view tên users ghép lại hai bảng mới, rồi sửa code dần dần.

MySQL chạy view thế nào ​

MySQL có hai cách chạy view (MySQL: View Processing Algorithms):

  • MERGE: ghép câu query bên ngoài với định nghĩa view thành một câu. SELECT * FROM active_users WHERE id = 1 thành SELECT id, email, name FROM users WHERE status = 'active' AND id = 1, và index trên bảng gốc vẫn được dùng như bình thường. Đây là cách MySQL ưu tiên.
  • TEMPTABLE: chạy câu SELECT của view ra một bảng tạm trước, rồi mới lọc trên bảng tạm. MySQL phải dùng cách này khi view có GROUP BY, DISTINCT, LIMIT, hàm gộp…. Hậu quả là điều kiện WHERE bên ngoài không đẩy được vào trong: view tính doanh thu của mọi khách hàng, xong mới lọc lấy một khách.

Vì vậy cẩn thận với view có GROUP BY trên bảng lớn, và với view lồng view nhiều tầng: nhìn câu query ngắn nhưng database có thể phải làm rất nhiều việc.

Bẫy: SELECT * bị đóng băng lúc tạo view ​

sql
CREATE VIEW users_all AS SELECT * FROM users;
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL;
SELECT * FROM users_all WHERE id = 1;
-- id | email   | name | password_hash | status     ← không có cột phone

Cả MySQL lẫn PostgreSQL đều mở rộng * thành danh sách cột ngay lúc tạo view, nên cột thêm sau không xuất hiện (MySQL: CREATE VIEW, PostgreSQL: CREATE VIEW). Ở MySQL, nếu xoá một cột mà view đang dùng thì đọc view sẽ báo lỗi. Trong view nên liệt kê cột rõ ràng.

Cũng theo tài liệu MySQL, ORDER BY trong view bị bỏ qua nếu câu query bên ngoài có ORDER BY riêng.

Sửa dữ liệu qua view ​

View đơn giản (một bảng, không GROUP BY, không DISTINCT, không hàm gộp…) thì sửa được dữ liệu qua nó, và lệnh sửa đi thẳng vào bảng gốc:

sql
UPDATE active_users SET name = 'An Nguyễn' WHERE id = 1;   -- bảng users đổi theo

Có một bẫy: qua view, có thể sửa một dòng sao cho nó rơi ra khỏi view, ví dụ đổi status thành 'banned' qua một view chỉ chứa user active. Thêm WITH CHECK OPTION để database chặn trường hợp này:

sql
CREATE VIEW active_users_chk AS
  SELECT id, email, name, status FROM users WHERE status = 'active'
  WITH CHECK OPTION;

UPDATE active_users_chk SET status = 'banned' WHERE id = 2;
-- MySQL:      ERROR 1369: CHECK OPTION failed 't.active_users_chk'
-- PostgreSQL: ERROR: new row violates check option for view "active_users_chk"

Trên thực tế, ít team sửa dữ liệu qua view; view chủ yếu dùng để đọc.

Materialized view ​

Materialized view lưu kết quả lại như một bảng thật. Đọc rất nhanh, nhưng dữ liệu chỉ mới tới lần làm mới gần nhất. PostgreSQL có sẵn tính năng này, MySQL thì không.

sql
-- PostgreSQL
CREATE MATERIALIZED VIEW daily_revenue AS
  SELECT created_at AS d, SUM(amount) AS revenue FROM orders GROUP BY created_at;

INSERT INTO orders VALUES (4, 2, 30, '2026-10-02');
SELECT * FROM daily_revenue;   -- ngày 2/10 vẫn là 70: đơn mới chưa được tính

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
-- ERROR: cannot refresh materialized view "public.daily_revenue" concurrently
-- HINT: Create a unique index with no WHERE clause on one or more columns …

CREATE UNIQUE INDEX ON daily_revenue (d);
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;   -- ngày 2/10 thành 100

REFRESH thường thì nhanh hơn, nhưng có thể chặn các câu đang đọc materialized view. REFRESH … CONCURRENTLY cho đọc trong lúc làm mới, đổi lại cần một unique index trên các cột và chỉ chạy được khi view đã có dữ liệu (PostgreSQL: REFRESH MATERIALIZED VIEW). Làm mới bao lâu một lần thì app tự lo, ví dụ một job chạy mỗi 5 phút.

Trên MySQL, làm tương đương bằng một bảng tổng hợp mà job định kỳ cập nhật bằng upsert (Bộ đếm & bảng tổng hợp).

Trigger ​

Trigger là đoạn code database tự chạy mỗi khi một bảng bị INSERT, UPDATE hoặc DELETE, chạy trước (BEFORE) hoặc sau (AFTER) lệnh đó. App không gọi trigger và thường không biết trigger tồn tại.

  • Row-level trigger chạy một lần cho mỗi dòng bị đổi, và đọc được giá trị cũ (OLD) lẫn giá trị mới (NEW) của dòng. MySQL chỉ có loại này (MySQL: CREATE TRIGGER).
  • Statement-level trigger chạy một lần cho cả câu lệnh. PostgreSQL có cả hai loại.

Ba cách dùng hay gặp ​

Ghi audit log: mỗi lần xoá comment thì ghi một dòng vào audit_log.

sql
-- MySQL
CREATE TRIGGER comments_audit AFTER DELETE ON comments FOR EACH ROW
  INSERT INTO audit_log (tbl, row_id, action) VALUES ('comments', OLD.id, 'DELETE');

-- PostgreSQL: viết một trigger function, rồi gắn nó vào bảng
CREATE FUNCTION log_comment_delete() RETURNS trigger AS $$
BEGIN
  INSERT INTO audit_log (tbl, row_id, action) VALUES ('comments', OLD.id, 'DELETE');
  RETURN OLD;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER comments_audit AFTER DELETE ON comments
  FOR EACH ROW EXECUTE FUNCTION log_comment_delete();

Chặn lệnh ghi vi phạm quy tắc: trigger BEFORE báo lỗi thì cả câu lệnh bị huỷ.

sql
-- MySQL
CREATE TRIGGER accounts_check BEFORE UPDATE ON accounts FOR EACH ROW
BEGIN
  IF NEW.balance < 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'balance cannot be negative';
  END IF;
END;
-- PostgreSQL: IF NEW.balance < 0 THEN RAISE EXCEPTION 'balance cannot be negative: %', NEW.balance; END IF;

UPDATE accounts SET balance = balance - 500 WHERE id = 1;
-- MySQL:      ERROR 1644 (45000): balance cannot be negative
-- PostgreSQL: ERROR: balance cannot be negative: -400

Quy tắc đơn giản như thế này viết bằng CHECK (balance >= 0) thì gọn hơn và dễ thấy hơn (Chống trùng & race condition). Trigger chỉ đáng dùng khi quy tắc cần đọc bảng khác.

Tự cập nhật updated_at: trên MySQL thì không cần trigger, vì có sẵn ON UPDATE CURRENT_TIMESTAMP:

sql
-- MySQL
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

-- PostgreSQL không có cú pháp này, phải dùng trigger BEFORE UPDATE
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at := now(); RETURN NEW; END $$ LANGUAGE plpgsql;
CREATE TRIGGER notes_touch BEFORE UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();

Những chỗ trigger không chạy, hoặc chạy khác nhau giữa MySQL và PostgreSQL ​

Thử: lệnh nào làm trigger chạy
Chạy trên:
DELETE FROM posts WHERE id = 1;
  1. 1Xoá dòng posts id = 1
  2. 2InnoDB tự xoá comments 1, 2 (cascade)
  3. 3Trigger comments_audit KHÔNG chạy
audit_logtrống
Hai comment bị xoá nhưng audit_log trống. MySQL không chạy trigger cho thay đổi do cascade của khoá ngoại.

Kết quả các kịch bản là kết quả chạy thật trên MySQL 8.0.46 và PostgreSQL 17; riêng trigger ON TRUNCATE của PostgreSQL lấy theo kiến thức chung, chưa chạy thử. Bảng mẫu: posts, comments (khoá ngoại ON DELETE CASCADE), accounts, notes; trigger AFTER DELETE ON comments ghi vào audit_log.

Tình huốngMySQL 8.0PostgreSQL 17
Dòng bị xoá do ON DELETE CASCADETrigger không chạy. Xoá post làm mất 2 comment, nhưng audit_log trốngTrigger chạy cho từng comment bị xoá theo
TRUNCATETrigger DELETE không chạyTrigger ON DELETE không chạy; có loại trigger riêng ON TRUNCATE
Trigger sửa chính bảng đang gọi nóLỗi 1442, cả câu lệnh bị huỷĐược phép

Hai dòng MySQL trong bảng trên đều có trong tài liệu: “Cascaded foreign key actions do not activate triggers”, và TRUNCATE, DROP TABLE không kích hoạt trigger DELETE (MySQL: CREATE TRIGGER). Giới hạn về lỗi 1442 nằm ở Restrictions on Stored Programs.

Dòng đầu tiên là cái bẫy lớn nhất khi dùng trigger cho audit trên MySQL: audit log thiếu đúng những lần xoá hàng loạt do cascade. Đổi sang CDC cũng không cứu được. Trên MySQL 8.0.46, binlog chỉ ghi lệnh xoá dòng posts, còn các dòng comments bị xoá theo không có trong binlog (đã kiểm bằng SHOW BINLOG EVENTS). Debezium và các công cụ CDC đọc binlog cũng không thấy chúng (MySQL chương 9). Muốn audit đầy đủ thì app phải tự xoá bảng con rồi mới xoá bảng cha, thay vì dựa vào cascade.

Trigger làm chậm mọi lệnh ghi ​

Row-level trigger chạy cho từng dòng, nên một câu UPDATE 100.000 dòng là 100.000 lần chạy trigger. Đo trên laptop, MySQL và PostgreSQL chạy trong Docker, với một trigger AFTER UPDATE ghi giá cũ và giá mới vào bảng log. Mỗi ô là khoảng thời gian qua nhiều lượt đo trong hai ngày (16/10 và 19/10), mỗi lượt 3 lần:

Không có triggerCó trigger ghi logChậm đi
MySQL 8.0196 – 374 ms689 – 869 mskhoảng 2 – 4 lần
PostgreSQL 17184 – 338 ms338 – 565 mskhoảng 1,3 – 2 lần

Con số tuyệt đối phụ thuộc máy và dao động khá nhiều giữa các lượt. Điều đáng nhớ là lệnh ghi chậm đi rõ rệt mà người viết câu UPDATE không hề biết, và mức chậm khác nhau giữa hai database. Trên bảng ghi nhiều, trigger còn kéo dài thời gian giữ lock.

Trigger và replication ​

Với binlog dạng ROW (mặc định), MySQL ghi các dòng do trigger thay đổi vào binlog. Replica áp dụng thẳng các dòng đó và không chạy lại trigger (MySQL: Stored Program Binary Logging).

Tìm xem bảng đang có trigger nào ​

Trigger nằm ngoài code ứng dụng nên dễ bị quên. Khi một bảng ghi chậm bất thường hay dữ liệu tự đổi, hãy kiểm tra:

sql
-- MySQL
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_TIMING
FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE();

-- PostgreSQL (psql): \d tên_bảng   liệt kê trigger ở cuối

Stored procedure ​

Stored procedure là một hàm viết bằng SQL (và ngôn ngữ thủ tục của database: biến, IF, vòng lặp), lưu trong database, app gọi bằng CALL.

sql
-- MySQL: chuyển tiền giữa hai ví trong một transaction
CREATE PROCEDURE transfer(IN p_from INT, IN p_to INT, IN p_amount INT)
BEGIN
  DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
  START TRANSACTION;
  UPDATE wallets SET balance = balance - p_amount WHERE id = p_from AND balance >= p_amount;
  IF ROW_COUNT() = 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'insufficient balance';
  END IF;
  UPDATE wallets SET balance = balance + p_amount WHERE id = p_to;
  COMMIT;
END;

CALL transfer(1, 2, 30);    -- ví 1: 100 → 70, ví 2: 0 → 30
CALL transfer(1, 2, 500);   -- ERROR 1644 (45000): insufficient balance; số dư giữ nguyên

Gọi từ Node.js bằng mysql2 cũng như chạy một câu SQL thường. Kết quả trả về là một mảng gồm các result set của procedure:

js
await pool.query('CALL transfer(?, ?, ?)', [fromId, toId, amount])

PostgreSQL: function và procedure ​

PostgreSQL có cả function (gọi trong SELECT, trả về giá trị) và procedure (gọi bằng CALL, có từ bản 11). Khác biệt quan trọng là chỉ procedure mới được COMMIT bên trong (PostgreSQL: Transaction Management). Nhờ vậy, procedure xử lý được dữ liệu lớn theo lô và commit từng lô:

sql
CREATE PROCEDURE mark_done_in_batches(batch INT) LANGUAGE plpgsql AS $$
DECLARE n INT;
BEGIN
  LOOP
    UPDATE big SET done = true WHERE id IN (SELECT id FROM big WHERE NOT done LIMIT batch);
    GET DIAGNOSTICS n = ROW_COUNT;
    EXIT WHEN n = 0;
    COMMIT;   -- commit từng lô
  END LOOP;
END $$;
CALL mark_done_in_batches(3);

-- Function thì không được COMMIT:
-- ERROR: invalid transaction termination

MySQL cũng có stored function, nhưng không được COMMIT hay START TRANSACTION bên trong, giống trigger (Restrictions on Stored Programs). Khi binlog bật, MySQL còn bắt khai báo function là DETERMINISTIC, NO SQL hoặc READS SQL DATA, nếu không thì báo lỗi 1418 (Stored Program Binary Logging).

Có nên đặt logic trong database? ​

LợiHại
ViewGiấu cột, đặt tên query, giữ tương thích khi đổi schemaDễ chậm khi lồng nhiều tầng hoặc có GROUP BY; SELECT * bị đóng băng
TriggerChạy với mọi nguồn ghi (app, script, người sửa tay)Logic ẩn, khó debug; làm chậm mọi lệnh ghi; MySQL bỏ qua cascade và TRUNCATE
Stored procedureÍt round-trip; xử lý dữ liệu ngay cạnh dữ liệuLogic nghiệp vụ nằm ngoài codebase; khó test, khó review, khó deploy

Vì sao nhiều team cấm trigger và stored procedure trong code nghiệp vụ:

  • Logic ẩn. Đọc code app không biết có trigger. Dữ liệu “tự đổi” làm mất nhiều giờ debug.
  • Khó quản lý phiên bản và deploy. Code app deploy theo phiên bản và rollback được. Procedure và trigger phải đi qua migration, và đổi chúng trên bảng đang chạy là việc rủi ro (Migration trong team).
  • Khó test. Unit test của app không chạy được logic trong database; phải test với database thật (Test với database).
  • Dồn tải vào chỗ khó scale nhất. App server thêm máy dễ dàng, còn primary database thì không (MySQL chương 10).
  • Gắn chặt với một database. Cú pháp trigger và procedure của MySQL và PostgreSQL khác nhau hoàn toàn, như các ví dụ trên.
  • ORM và code app đi vòng qua. Logic một nửa ở app, một nửa ở database thì không ai nắm hết.

Khi nào vẫn hợp lý:

  • View để giấu cột hay đặt tên cho báo cáo: rủi ro thấp, nhiều team vẫn dùng.
  • Materialized view cho báo cáo chấp nhận dữ liệu cũ vài phút (PostgreSQL).
  • Trigger cho audit khi nhiều nguồn cùng ghi vào bảng (app, script, công cụ admin) và cần ghi lại mọi thay đổi. Trên MySQL thì nhớ bẫy cascade ở trên.
  • Procedure cho việc xử lý dữ liệu lớn chạy một lần hoặc định kỳ, cần chạy gần dữ liệu (PostgreSQL, có commit từng lô).
  • Hệ thống cũ đã dựa nhiều vào procedure: hiểu nó và đừng thêm logic mới vào đó.

Thay thế thường dùng:

Thay vìDùng
Trigger kiểm tra quy tắcCHECK, UNIQUE, FOREIGN KEY, generated column
Trigger cập nhật updated_at (MySQL)ON UPDATE CURRENT_TIMESTAMP
Trigger gửi sự kiện sang hệ thống khácBảng outbox ghi cùng transaction (Hàng đợi bằng database)
Trigger ghi auditGhi audit trong service layer, hoặc CDC (trên MySQL, cả hai cách đều phải tránh xoá bằng cascade)
Stored procedure nghiệp vụService layer trong app, gói các câu SQL trong một transaction

Ghi nhớ

  1. View là câu SELECT được đặt tên, không lưu dữ liệu; materialized view (PostgreSQL) lưu kết quả và phải REFRESH.
  2. View có GROUP BY, DISTINCT, LIMIT thì MySQL phải tính ra bảng tạm trước, nên điều kiện lọc bên ngoài không dùng được index. SELECT * trong view bị đóng băng lúc tạo.
  3. Trigger tự chạy theo từng dòng khi có lệnh ghi. Nó làm lệnh ghi chậm đi vài lần mà người viết lệnh không biết.
  4. MySQL không chạy trigger cho thay đổi do ON DELETE CASCADE và TRUNCATE; binlog 8.0 cũng không ghi dòng bị xoá do cascade. PostgreSQL thì chạy trigger cho cascade.
  5. Stored procedure gọi bằng CALL. PostgreSQL procedure được COMMIT bên trong, function thì không.
  6. Nhiều team cấm trigger và procedure cho logic nghiệp vụ vì logic ẩn, khó test, khó deploy, dồn tải vào database. Ưu tiên ràng buộc, service layer và outbox.

Nguồn tham khảo ​

Đã chạy thử ngày 2026-10-16 trên MySQL 8.0.46 (Docker image mysql:8.0, binlog bật dạng ROW) và PostgreSQL 17 (Docker image postgres:17-alpine). Mọi đoạn có kết quả hay thông báo lỗi trong trang là kết quả thật, gồm cả:

  • bảng đo thời gian chạy trigger;
  • phép kiểm SHOW BINLOG EVENTS cho thấy dòng bị xoá do cascade không có trong binlog.

Mô phỏng TriggerFlowSim dùng kết quả của các lần chạy này.

Tài liệu chính thức đã đối chiếu (ngày 2026-10-16):

NguồnDùng cho phần
MySQL 8.4, CREATE VIEWĐịnh nghĩa view bị đóng băng lúc tạo, SELECT * không thêm cột mới; ORDER BY trong view bị bỏ qua
MySQL 8.4, View Processing AlgorithmsMERGE và TEMPTABLE; view dùng bảng tạm thì không sửa dữ liệu qua được
PostgreSQL, CREATE VIEWView không lưu dữ liệu; điều kiện view sửa được; WITH CHECK OPTION; * mở rộng lúc tạo; security_barrier, security_invoker
PostgreSQL, REFRESH MATERIALIZED VIEWCONCURRENTLY cần unique index, cho đọc trong lúc làm mới; refresh thường có thể chặn đọc
MySQL 8.4, CREATE TRIGGERCascade không kích hoạt trigger; TRUNCATE, DROP TABLE không kích hoạt trigger DELETE; thứ tự trigger FOLLOWS/PRECEDES
MySQL 8.4, Restrictions on Stored ProgramsFunction và trigger không được sửa bảng mà câu lệnh gọi nó đang dùng; không được commit, rollback
MySQL 8.4, Stored Program Binary LoggingBinlog dạng ROW ghi dòng do trigger đổi, replica không chạy lại trigger; function cần DETERMINISTIC… khi bật binlog
PostgreSQL, Transaction ManagementCOMMIT, ROLLBACK trong procedure gọi bằng CALL và khối DO

Chưa đối chiếu, từ kiến thức chung:

  • PostgreSQL có trigger mức statement và trigger ON TRUNCATE; PostgreSQL có procedure từ bản 11.
  • Kết quả trả về khi gọi CALL qua mysql2.
  • CDC trên PostgreSQL (logical decoding) có thấy dòng bị xoá do cascade hay không: chưa chạy thử.
  • MySQL 9.x có thể đã đổi cách xử lý khoá ngoại; các kết luận về cascade ở đây chỉ đúng cho MySQL 8.0.

Code đã dùng để chạy thử ​

Script nằm trong thư mục lab/view-trigger/ của repo. Tự chạy lại bằng npm run lab:up rồi npm run lab -- view-trigger (cách chạy). Lần chạy lại ngày 2026-10-19 cho cùng kết quả ở mọi phép thử, trừ thời gian chạy trigger (bảng đo đã gộp cả hai ngày).

views.sql: view, SELECT * đóng băng, sửa qua view, WITH CHECK OPTION
sql
-- View: chạy trên cả MySQL và PostgreSQL.
DROP VIEW IF EXISTS active_users;
DROP VIEW IF EXISTS users_all;
DROP VIEW IF EXISTS active_users_chk;
DROP TABLE IF EXISTS users;

CREATE TABLE users (id INT PRIMARY KEY, email VARCHAR(50) NOT NULL, name VARCHAR(50) NOT NULL, password_hash VARCHAR(100) NOT NULL, status VARCHAR(10) NOT NULL);
INSERT INTO users VALUES (1,'an@x.vn','An','h1','active'),(2,'binh@x.vn','Bình','h2','active'),(3,'chi@x.vn','Chi','h3','banned');

SELECT '--- View thường' AS step;
CREATE VIEW active_users AS SELECT id, email, name FROM users WHERE status = 'active';
SELECT * FROM active_users ORDER BY id;

SELECT '--- SELECT * bị đóng băng lúc tạo view: thêm cột phone, view không có' AS step;
CREATE VIEW users_all AS SELECT * FROM users;
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL;
SELECT * FROM users_all WHERE id = 1;

SELECT '--- Sửa dữ liệu qua view' AS step;
UPDATE active_users SET name = 'An Nguyễn' WHERE id = 1;
SELECT id, name FROM users WHERE id = 1;

SELECT '--- WITH CHECK OPTION chặn lệnh sửa làm dòng rơi khỏi view' AS step;
CREATE VIEW active_users_chk AS SELECT id, email, name, status FROM users WHERE status = 'active' WITH CHECK OPTION;
UPDATE active_users_chk SET status = 'banned' WHERE id = 2;
SELECT id, status FROM users ORDER BY id;
mysql.sql: trigger (cascade, TRUNCATE, SIGNAL, lỗi 1442) và stored procedure trên MySQL
sql
-- Trigger và stored procedure trên MySQL.
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS wallets;
DROP PROCEDURE IF EXISTS transfer;

CREATE TABLE posts (id INT PRIMARY KEY, title VARCHAR(50) NOT NULL);
CREATE TABLE comments (id INT PRIMARY KEY, post_id INT NOT NULL, body VARCHAR(50) NOT NULL, FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE);
CREATE TABLE audit_log (id INT AUTO_INCREMENT PRIMARY KEY, tbl VARCHAR(20), row_id INT, action VARCHAR(10), at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TRIGGER comments_audit AFTER DELETE ON comments FOR EACH ROW
  INSERT INTO audit_log (tbl, row_id, action) VALUES ('comments', OLD.id, 'DELETE');
INSERT INTO posts VALUES (1,'p1'),(2,'p2');
INSERT INTO comments VALUES (1,1,'c1'),(2,1,'c2'),(3,2,'c3');

SELECT '--- Xoá post 1: ON DELETE CASCADE xoá comment 1, 2 nhưng trigger không chạy' AS step;
DELETE FROM posts WHERE id = 1;
SELECT COUNT(*) AS comments_left FROM comments;
SELECT tbl, row_id, action FROM audit_log ORDER BY id;

SELECT '--- Xoá comment 3 trực tiếp: trigger chạy' AS step;
DELETE FROM comments WHERE id = 3;
SELECT tbl, row_id, action FROM audit_log ORDER BY id;

SELECT '--- TRUNCATE không chạy trigger DELETE' AS step;
DELETE FROM audit_log;
INSERT INTO comments VALUES (12, 2, 'c12'), (13, 2, 'c13');
SELECT COUNT(*) AS comments_before FROM comments;
TRUNCATE comments;
SELECT COUNT(*) AS audit_rows FROM audit_log;

SELECT '--- Trigger BEFORE UPDATE chặn số dư âm (SIGNAL)' AS step;
CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL);
INSERT INTO accounts VALUES (1, 100);
DELIMITER //
CREATE TRIGGER accounts_check BEFORE UPDATE ON accounts FOR EACH ROW
BEGIN
  IF NEW.balance < 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'balance cannot be negative';
  END IF;
END//
DELIMITER ;
UPDATE accounts SET balance = balance - 500 WHERE id = 1;
SELECT * FROM accounts;

SELECT '--- updated_at không cần trigger: ON UPDATE CURRENT_TIMESTAMP' AS step;
CREATE TABLE notes (id INT PRIMARY KEY, body VARCHAR(20), updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP);
INSERT INTO notes (id, body, updated_at) VALUES (1, 'a', '2020-01-01 00:00:00');
UPDATE notes SET body = 'b' WHERE id = 1;
SELECT id, body, updated_at > '2025-01-01' AS touched FROM notes;

SELECT '--- Trigger sửa chính bảng của nó: lỗi 1442, cả lệnh INSERT bị huỷ' AS step;
INSERT INTO notes (id, body) VALUES (2, 'x');
CREATE TRIGGER notes_self AFTER INSERT ON notes FOR EACH ROW UPDATE notes SET body = CONCAT(body, '!') WHERE id <> NEW.id;
INSERT INTO notes (id, body) VALUES (3, 'y');
SELECT id, body FROM notes ORDER BY id;

SELECT '--- Các trigger đang có' AS step;
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_TIMING
FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY TRIGGER_NAME;

SELECT '--- Stored procedure chuyển tiền' AS step;
CREATE TABLE wallets (id INT PRIMARY KEY, balance INT NOT NULL);
INSERT INTO wallets VALUES (1, 100), (2, 0);
DELIMITER //
CREATE PROCEDURE transfer(IN p_from INT, IN p_to INT, IN p_amount INT)
BEGIN
  DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
  START TRANSACTION;
  UPDATE wallets SET balance = balance - p_amount WHERE id = p_from AND balance >= p_amount;
  IF ROW_COUNT() = 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'insufficient balance';
  END IF;
  UPDATE wallets SET balance = balance + p_amount WHERE id = p_to;
  COMMIT;
END//
DELIMITER ;
CALL transfer(1, 2, 30);
SELECT * FROM wallets ORDER BY id;
CALL transfer(1, 2, 500);
SELECT * FROM wallets ORDER BY id;
pg.sql: materialized view, trigger, function và procedure trên PostgreSQL
sql
-- Materialized view, trigger, function và procedure trên PostgreSQL.
DROP TABLE IF EXISTS orders, comments, posts, audit_log, accounts, notes, wallets, big CASCADE;
DROP MATERIALIZED VIEW IF EXISTS daily_revenue;

\echo '--- Materialized view: dữ liệu chỉ mới tới lần REFRESH gần nhất'
CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL, amount INT NOT NULL, created_at DATE NOT NULL);
INSERT INTO orders VALUES (1,1,100,'2026-10-01'),(2,2,50,'2026-10-01'),(3,1,70,'2026-10-02');
CREATE MATERIALIZED VIEW daily_revenue AS SELECT created_at AS d, SUM(amount) AS revenue FROM orders GROUP BY created_at;
INSERT INTO orders VALUES (4,2,30,'2026-10-02');
SELECT * FROM daily_revenue ORDER BY d;
\echo '--- REFRESH CONCURRENTLY khi chưa có unique index: lỗi'
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
CREATE UNIQUE INDEX ON daily_revenue (d);
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
SELECT * FROM daily_revenue ORDER BY d;

\echo '--- Trigger audit: ON DELETE CASCADE có chạy trigger trên bảng con'
CREATE TABLE posts (id INT PRIMARY KEY, title TEXT NOT NULL);
CREATE TABLE comments (id INT PRIMARY KEY, post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, body TEXT NOT NULL);
CREATE TABLE audit_log (id SERIAL PRIMARY KEY, tbl TEXT, row_id INT, action TEXT, at TIMESTAMPTZ DEFAULT now());
CREATE OR REPLACE FUNCTION log_comment_delete() RETURNS trigger AS $$
BEGIN
  INSERT INTO audit_log (tbl, row_id, action) VALUES ('comments', OLD.id, 'DELETE');
  RETURN OLD;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER comments_audit AFTER DELETE ON comments FOR EACH ROW EXECUTE FUNCTION log_comment_delete();
INSERT INTO posts VALUES (1,'p1'), (2, 'p2');
INSERT INTO comments VALUES (1,1,'c1'),(2,1,'c2');
DELETE FROM posts WHERE id = 1;
SELECT tbl, row_id, action FROM audit_log ORDER BY id;

\echo '--- TRUNCATE không chạy trigger ON DELETE'
DELETE FROM audit_log;
INSERT INTO comments VALUES (10, 2, 'c10'), (11, 2, 'c11');
SELECT COUNT(*) AS comments_before FROM comments;
TRUNCATE comments;
SELECT COUNT(*) AS audit_rows FROM audit_log;

\echo '--- Trigger BEFORE UPDATE chặn số dư âm (RAISE EXCEPTION)'
CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL);
INSERT INTO accounts VALUES (1, 100);
CREATE OR REPLACE FUNCTION no_negative() RETURNS trigger AS $$
BEGIN
  IF NEW.balance < 0 THEN RAISE EXCEPTION 'balance cannot be negative: %', NEW.balance; END IF;
  RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER accounts_check BEFORE UPDATE ON accounts FOR EACH ROW EXECUTE FUNCTION no_negative();
UPDATE accounts SET balance = balance - 500 WHERE id = 1;
SELECT * FROM accounts;

\echo '--- updated_at bằng trigger'
CREATE TABLE notes (id INT PRIMARY KEY, body TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$ LANGUAGE plpgsql;
CREATE TRIGGER notes_touch BEFORE UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
INSERT INTO notes (id, body, updated_at) VALUES (1, 'a', '2020-01-01');
UPDATE notes SET body = 'b' WHERE id = 1;
SELECT id, body, updated_at > '2025-01-01' AS touched FROM notes;

\echo '--- Trigger sửa chính bảng của nó: được phép'
CREATE OR REPLACE FUNCTION bump_self() RETURNS trigger AS $$ BEGIN UPDATE notes SET body = body || '!' WHERE id <> NEW.id; RETURN NEW; END $$ LANGUAGE plpgsql;
INSERT INTO notes (id, body) VALUES (2, 'x');
CREATE TRIGGER notes_self AFTER INSERT ON notes FOR EACH ROW EXECUTE FUNCTION bump_self();
INSERT INTO notes (id, body) VALUES (3, 'y');
SELECT id, body FROM notes ORDER BY id;

\echo '--- Function chuyển tiền'
CREATE TABLE wallets (id INT PRIMARY KEY, balance INT NOT NULL);
INSERT INTO wallets VALUES (1, 100), (2, 0);
CREATE OR REPLACE FUNCTION transfer(p_from INT, p_to INT, p_amount INT) RETURNS void AS $$
BEGIN
  UPDATE wallets SET balance = balance - p_amount WHERE id = p_from AND balance >= p_amount;
  IF NOT FOUND THEN RAISE EXCEPTION 'insufficient balance'; END IF;
  UPDATE wallets SET balance = balance + p_amount WHERE id = p_to;
END $$ LANGUAGE plpgsql;
SELECT transfer(1, 2, 30);
SELECT * FROM wallets ORDER BY id;
SELECT transfer(1, 2, 500);
SELECT * FROM wallets ORDER BY id;

\echo '--- Procedure được COMMIT bên trong (xử lý theo lô)'
CREATE TABLE big (id INT PRIMARY KEY, done BOOLEAN NOT NULL DEFAULT false);
INSERT INTO big SELECT g FROM generate_series(1, 10) g;
CREATE OR REPLACE PROCEDURE mark_done_in_batches(batch INT) LANGUAGE plpgsql AS $$
DECLARE n INT;
BEGIN
  LOOP
    UPDATE big SET done = true WHERE id IN (SELECT id FROM big WHERE NOT done LIMIT batch);
    GET DIAGNOSTICS n = ROW_COUNT;
    EXIT WHEN n = 0;
    COMMIT;
  END LOOP;
END $$;
CALL mark_done_in_batches(3);
SELECT COUNT(*) FILTER (WHERE done) AS done FROM big;

\echo '--- Function không được COMMIT'
CREATE OR REPLACE FUNCTION f_commit() RETURNS void LANGUAGE plpgsql AS $$ BEGIN COMMIT; END $$;
SELECT f_commit();
binlog-cascade.mjs: cascade có được ghi vào binlog không
js
// MySQL: dòng bị xoá do ON DELETE CASCADE có được ghi vào binlog không?
// Xoay sang file binlog mới, xoá một post có 2 comment, rồi liệt kê event trong file đó.
// Nếu cascade được ghi thì phải có Table_map cho bảng comments.
import mysql from 'mysql2/promise'
import { MYSQL } from '../common/config.mjs'

const c = await mysql.createConnection({ ...MYSQL, database: 'view_trigger' })
const [[fmt]] = await c.query('SELECT @@log_bin AS log_bin, @@binlog_format AS binlog_format')
console.log('Binlog:', fmt)
await c.query('DROP TABLE IF EXISTS bl_comments')
await c.query('DROP TABLE IF EXISTS bl_posts')
await c.query('CREATE TABLE bl_posts (id INT PRIMARY KEY)')
await c.query('CREATE TABLE bl_comments (id INT PRIMARY KEY, post_id INT NOT NULL, FOREIGN KEY (post_id) REFERENCES bl_posts(id) ON DELETE CASCADE)')
await c.query('INSERT INTO bl_posts VALUES (5)')
await c.query('INSERT INTO bl_comments VALUES (50, 5), (51, 5)')
await c.query('FLUSH BINARY LOGS')
await c.query('DELETE FROM bl_posts WHERE id = 5')
const [[{ n }]] = await c.query('SELECT COUNT(*) AS n FROM bl_comments')
console.log(`Comment còn lại sau khi xoá post: ${n}`)
const [logs] = await c.query('SHOW BINARY LOGS')
const last = logs[logs.length - 1].Log_name
const [events] = await c.query(`SHOW BINLOG EVENTS IN '${last}'`)
for (const e of events) console.log(`${e.Event_type.padEnd(16)} ${e.Info}`)
await c.end()
timing-mysql.sql, timing-pg.sql: đo chi phí row trigger
sql
-- Đo chi phí row trigger trên MySQL: UPDATE 100.000 dòng, 3 lần không trigger, 3 lần có trigger ghi log.
DROP TABLE IF EXISTS items;
DROP TABLE IF EXISTS price_log;
CREATE TABLE items (id INT PRIMARY KEY, price INT NOT NULL);
CREATE TABLE price_log (item_id INT, old_price INT, new_price INT);
SET SESSION cte_max_recursion_depth = 200000;
INSERT INTO items WITH RECURSIVE s AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM s WHERE n < 100000) SELECT n, 100 FROM s;

SET @t = NOW(6); UPDATE items SET price = price + 1; SELECT 'không trigger' AS k, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) DIV 1000 AS ms;
SET @t = NOW(6); UPDATE items SET price = price + 1; SELECT 'không trigger' AS k, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) DIV 1000 AS ms;
SET @t = NOW(6); UPDATE items SET price = price + 1; SELECT 'không trigger' AS k, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) DIV 1000 AS ms;
CREATE TRIGGER items_log AFTER UPDATE ON items FOR EACH ROW INSERT INTO price_log VALUES (OLD.id, OLD.price, NEW.price);
SET @t = NOW(6); UPDATE items SET price = price + 1; SELECT 'có trigger' AS k, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) DIV 1000 AS ms;
SET @t = NOW(6); UPDATE items SET price = price + 1; SELECT 'có trigger' AS k, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) DIV 1000 AS ms;
SET @t = NOW(6); UPDATE items SET price = price + 1; SELECT 'có trigger' AS k, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) DIV 1000 AS ms;
SELECT COUNT(*) AS log_rows FROM price_log;
sql
-- Đo chi phí row trigger trên PostgreSQL: UPDATE 100.000 dòng, 3 lần không trigger, 3 lần có trigger ghi log.
DROP TABLE IF EXISTS items, price_log;
CREATE TABLE items (id INT PRIMARY KEY, price INT NOT NULL);
CREATE TABLE price_log (item_id INT, old_price INT, new_price INT);
INSERT INTO items SELECT g, 100 FROM generate_series(1, 100000) g;
VACUUM ANALYZE items;
\echo '--- không trigger'
\timing on
UPDATE items SET price = price + 1;
UPDATE items SET price = price + 1;
UPDATE items SET price = price + 1;
\timing off
CREATE OR REPLACE FUNCTION log_price() RETURNS trigger AS $$ BEGIN INSERT INTO price_log VALUES (OLD.id, OLD.price, NEW.price); RETURN NEW; END $$ LANGUAGE plpgsql;
CREATE TRIGGER items_log AFTER UPDATE ON items FOR EACH ROW EXECUTE FUNCTION log_price();
VACUUM ANALYZE items;
\echo '--- có trigger'
\timing on
UPDATE items SET price = price + 1;
UPDATE items SET price = price + 1;
UPDATE items SET price = price + 1;
\timing off
SELECT COUNT(*) AS log_rows FROM price_log;