Chào mọi người, mình quay lại với Phần 2 trong mini-series "Trigger trong SQL Server – Từ góc nhìn thực chiến". Ở phần trước, mình nói về việc biết & hiểu trigger theo 5W1H. Hôm nay là lúc mình kể tiếp: mình đã thật sự dùng trigger như thế nào?
📦 Các bảng mẫu sử dụng trong bài viết
Vì mình đang làm việc tại một công ty Product nên không thể chia sẻ code thực tế trong dự án. Mình đã ký cam kết bảo mật thông tin, và mình luôn cố gắng tôn trọng điều đó 💼
Vậy nên trong chuỗi bài viết này, mình sẽ dùng các bảng dữ liệu giả lập để minh họa, gần giống với mô hình thực tế mà mình đã làm việc.
Dưới đây là 3 bảng chính mình sẽ sử dụng xuyên suốt ví dụ:
-- Bảng orders – Thông tin đơn hàng
CREATE TABLE [dbo].[orders]
( [order_id] [int] NOT NULL IDENTITY(1, 1), [customer_id] [int] NULL, [order_date] [datetime] NULL DEFAULT (getdate()), [status] [nvarchar] (20) DEFAULT ('Pending'), [total_amount] [decimal] (18, 2) NULL DEFAULT ((0))
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[orders] ADD PRIMARY KEY CLUSTERED ([order_id]) ON [PRIMARY]
GO -- Bảng order_detail – Chi tiết từng dòng sản phẩm trong đơn
CREATE TABLE [dbo].[order_detail]
( [detail_id] [int] NOT NULL IDENTITY(1, 1), [order_id] [int] NULL, [product_id] [int] NULL, [product_name] [nvarchar] (100) NULL, [quantity] [int] NULL, [unit_price] [decimal] (18, 2) NULL, [total_price] AS ([quantity]*[unit_price]) PERSISTED
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[order_detail] ADD PRIMARY KEY CLUSTERED ([detail_id]) ON [PRIMARY]
GO -- Bảng order_log – Nhật ký thay đổi đơn hàng
CREATE TABLE [dbo].[order_log]
( [log_id] [int] NOT NULL IDENTITY(1, 1), [order_id] [int] NULL, [action_type] [nvarchar] (10) NULL, -- INSERT, UPDATE, DELETE [table_name] [nvarchar] (100) NULL, -- Ghi rõ bảng nào bị ảnh hưởng [column_changed] [nvarchar] (100) NULL, -- Cột nào bị thay đổi [old_value] [nvarchar] (255) NULL, -- Giá trị cũ [new_value] [nvarchar] (255) NULL, -- Giá trị mới [changed_by] [nvarchar] (100) NULL, -- Ai thay đổi (nếu có) [created_at] [datetime] NULL DEFAULT (getdate())
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[order_log] ADD PRIMARY KEY CLUSTERED ([log_id]) ON [PRIMARY]
GO -- Tạo 1 ít data mẫu nhé:
-- Bảng orders
INSERT INTO orders (customer_id, order_date, status, total_amount)
VALUES (101, '2025-04-01', 'Pending', 200000), (102, '2025-04-02', 'Processing', 350000); -- Bảng order_detail
INSERT INTO order_detail (order_id, product_id, product_name, quantity, unit_price)
VALUES (1, 1, 'Laptop', 1, 200000), (2, 2,'Phone', 1, 250000), (2, 3,'Headphones', 2, 50000);
🛠 Một vài use-case mà mình từng áp dụng Trigger:
1. 📝 Tương tác với AFTER UPDATE
Mình sẽ tập trung vào 2 bảng chính là orders và order_detail. Trong các nghiệp vụ thực tế hoặc task mình từng được giao, có một yêu cầu khá phổ biến như sau:
🔁 Khi người dùng thực hiện cập nhật dữ liệu trên 2 bảng này, hệ thống cần tự động ghi lại log thay đổi – cụ thể là chèn một bản ghi vào bảng order_log.
Với yêu cầu rõ ràng như vậy, mình xác định sẽ cần 2 trigger riêng biệt:
-
Một trigger cho bảng orders
CREATE TRIGGER [dbo].[trg_update_orders]
ON [dbo].[orders]
AFTER UPDATE
AS
BEGIN -- status INSERT INTO order_log (order_id, action_type, table_name, column_changed, old_value, new_value) SELECT i.order_id, 'UPDATE', 'orders', 'status', d.status, i.status FROM inserted i JOIN deleted d ON i.order_id = d.order_id WHERE i.status <> d.status OR (i.status IS NULL AND d.status IS NOT NULL) OR (i.status IS NOT NULL AND d.status IS NULL); -- total_amount INSERT INTO order_log (order_id, action_type, table_name, column_changed, old_value, new_value) SELECT i.order_id, 'UPDATE', 'orders', 'total_amount', CAST(d.total_amount AS VARCHAR(50)), CAST(i.total_amount AS VARCHAR(50)) FROM inserted i JOIN deleted d ON i.order_id = d.order_id WHERE i.total_amount <> d.total_amount OR (i.total_amount IS NULL AND d.total_amount IS NOT NULL) OR (i.total_amount IS NOT NULL AND d.total_amount IS NULL);
END;
-
Một trigger cho bảng order_detail
CREATE TRIGGER [dbo].[trg_update_order_detail]
ON [dbo].[order_detail]
AFTER UPDATE
AS
BEGIN -- quantity INSERT INTO order_log (order_id, action_type, table_name, column_changed, old_value, new_value) SELECT i.order_id, 'UPDATE', 'order_detail', 'quantity', CAST(d.quantity AS NVARCHAR), CAST(i.quantity AS NVARCHAR) FROM inserted i JOIN deleted d ON i.detail_id = d.detail_id WHERE i.quantity <> d.quantity; -- unit_price INSERT INTO order_log (order_id, action_type, table_name, column_changed, old_value, new_value) SELECT i.order_id, 'UPDATE', 'order_detail', 'unit_price', CAST(d.unit_price AS NVARCHAR), CAST(i.unit_price AS NVARCHAR) FROM inserted i JOIN deleted d ON i.detail_id = d.detail_id WHERE i.unit_price <> d.unit_price; -- Cập nhật lại total_amount cho các đơn hàng liên quan UPDATE o SET total_amount = ( SELECT SUM(od.quantity * od.unit_price) FROM order_detail od WHERE od.order_id = o.order_id ) FROM orders o WHERE o.order_id IN ( SELECT order_id FROM inserted );
END;
-
Bây giờ thử update và xem kết quả nhé.
Data lúc ban đầu khi vừa insert data mẫu vào:
-- Cập nhật quantity trong chi tiết đơn hàng id = 3
UPDATE order_detail
SET quantity = 3
WHERE detail_id = 3;
Sau khi chạy câu lệnh cập nhật, mình thấy bảng order_log đã ghi lại 2 dòng log tương ứng với thay đổi. Đồng thời, giá trị total_amount ở bảng orders cũng được tự động cập nhật lại đúng theo thay đổi từ bảng chi tiết đơn hàng. Tương tự như khi mình thao tác UPDATE ở bảng order_detail, bảng orders cũng sẽ được cập nhật.
⚠️ Tuy nhiên, có một điểm cần lưu ý: Cột total_amount trong bảng orders không nên được cập nhật một cách tùy tiện, vì nếu người dùng sửa tay hoặc ghi đè không thông qua logic tổng hợp từ order_detail, dữ liệu sẽ trở nên không nhất quán và rất khó kiểm soát.
2. 📝 Tương tác với INSTEAD OF UPDATE
Để ngăn chặn các tình huống người dùng cập nhật dữ liệu không hợp lý, mình sử dụng INSTEAD OF UPDATE. Cách này giúp kiểm soát và điều chỉnh hành vi cập nhật trực tiếp vào bảng orders, tránh những thay đổi không mong muốn.
💡 Một số tình huống mình cần ngăn chặn:
-
Trạng thái đơn hàng đã là "Done": Không cho phép người dùng thay đổi bất kỳ thông tin nào của đơn hàng khi đã hoàn tất.
-
total_amount không khớp với tổng tiền từ các dòng order_detail: Nếu tổng tiền bị thay đổi nhưng không khớp với tổng số tiền tính từ chi tiết đơn hàng, mình sẽ ngăn chặn cập nhật để đảm bảo tính nhất quán của dữ liệu.
CREATE TRIGGER [dbo].[trg_instead_of_update_orders]
ON [dbo].[orders]
INSTEAD OF UPDATE
AS
BEGIN SET NOCOUNT ON; -- Chặn nếu đơn hàng đã hoàn tất (status = 'done') IF EXISTS ( SELECT 1 FROM inserted i JOIN orders o ON i.order_id = o.order_id WHERE o.status = 'done' ) BEGIN RAISERROR('Không thể cập nhật đơn hàng đã hoàn tất (done).', 16, 1); RETURN; END -- Chặn nếu total_amount không khớp với tổng tiền của order_detail DECLARE @calculated_total_amount DECIMAL(18, 2); SELECT @calculated_total_amount = SUM(od.quantity * od.unit_price) FROM order_detail od WHERE od.order_id IN (SELECT order_id FROM inserted); IF EXISTS ( SELECT 1 FROM inserted i WHERE i.total_amount <> @calculated_total_amount ) BEGIN RAISERROR('Số tiền (total_amount) không khớp với tổng tiền trong order_detail.', 16, 1); RETURN; END -- Thực hiện update nếu không gặp lỗi UPDATE o SET o.status = i.status, o.total_amount = i.total_amount FROM orders o, inserted i WHERE o.order_id = i.order_id;
END;
-
Bây giờ thử update và xem kết quả nhé.
🧐 Tại sao sử dụng INSTEAD OF UPDATE:
- Kiểm soát chính xác hành vi cập nhật trên bảng orders.
- Đảm bảo dữ liệu nhất quán: Chỉ cho phép cập nhật khi dữ liệu hợp lệ và không vi phạm các ràng buộc mà bạn đã định trước (trạng thái "Done" hoặc tổng tiền không khớp).
- Giảm lỗi người dùng: Ngăn người dùng cập nhật dữ liệu sai thông qua hệ thống tự động kiểm tra trước khi thực thi.
💥 Những điều mình học được sau khi áp dụng:
-
Trigger cực kỳ tiện khi mình cần xử lý logic phụ, audit, hoặc ràng buộc đơn giản – mà không muốn phụ thuộc backend.
-
Tuy nhiên, phải viết trigger rõ ràng, dễ đọc, tránh lồng ghép nhiều logic khiến người sau không hiểu.
-
Quan trọng hơn cả: phải biết khi nào nên dùng, khi nào không – và phần 3 mình sẽ chia sẻ rõ điều đó.