Buổi 9: Stored Procedure & Trigger (cơ bản)
Đến giờ chúng ta mới chỉ gõ từng câu lệnh rồi bấm chạy. Ở công ty chứng khoán, rất nhiều thao tác lặp đi lặp lại mỗi ngày: tính tổng tài sản khách hàng, khóa tài khoản ngủ đông, cập nhật số liệu thống kê sau mỗi phiên khớp lệnh, ghi vết mỗi lần đổi trạng thái khách hàng để phục vụ kiểm toán... Buổi này học cách đóng gói các câu lệnh thành Stored Procedure (gọi khi cần) và Trigger (tự chạy khi có sự kiện), để mọi người trong phòng dùng chung một quy trình chuẩn thay vì mỗi người tự viết một kiểu.
Lưu ý: Buổi này dùng bộ dữ liệu hnx_securities đã giới thiệu ở Buổi 1 (xem lại sơ đồ bảng ở mục "Bộ dữ liệu mẫu của khóa học"). Hai bảng CustomerStats (thống kê giao dịch theo khách hàng) và AuditLog (nhật ký đổi trạng thái khách hàng) được thiết kế riêng cho buổi hôm nay. Ngày "hôm nay" của bộ dữ liệu là cố định: 2026-09-25.
Mục tiêu buổi học
Sau buổi này bạn có thể:
- Giải thích Stored Procedure là gì và nêu 4 lợi ích: tái sử dụng, bảo mật, hiệu năng, kiểm soát.
- Viết procedure có tham số INPUT/OUTPUT, biến cục bộ (
DECLARE), điều khiển luồngIF/CASE/WHILE, trả kết quả bằngSELECT,OUTPUTvàRETURNtrên SQL Server và MySQL. - Gọi procedure (
EXEC/CALL), xử lý kết quả trả về và viết procedure thay đổi dữ liệu (UPDATE) an toàn với@@ROWCOUNT/ROW_COUNT(). - Phân biệt trigger
BEFORE/AFTER/INSTEAD OF, dùngNEW/OLD(MySQL) vàinserted/deleted(SQL Server, xử lý theo tập dòng). - Xây dựng trigger cập nhật số liệu thống kê (
CustomerStats) và ghi nhật ký kiểm toán (AuditLog). - Quyết định khi nào nên đặt logic trong Stored Procedure/Trigger, khi nào để ở code ứng dụng; biết cách quản lý version, kiểm thử, tài liệu hóa.
Bản đồ buổi học
Buổi học 180 phút, khoảng 34% lý thuyết + demo và 66% thực hành. Mỗi phần lý thuyết đều kết thúc bằng một khung Thực hành ngay tại chỗ: học đến đâu làm đến đó.
| Phần | Nội dung | Hình thức | Thời lượng gợi ý |
|---|---|---|---|
| 1 | Stored Procedure là gì? Lợi ích, cú pháp cơ bản | Lý thuyết + demo | 8 phút |
| 2 | Viết Stored Procedure: tham số, biến, IF/CASE/WHILE, RETURN và SELECT |
Lý thuyết + demo | 16 phút |
| Thực hành bài 9.1 (5 bài, gồm chữa bài) | Thực hành | 43 phút | |
| 3 | Gọi procedure, xử lý kết quả trả về, procedure có UPDATE |
Lý thuyết + demo | 6 phút |
| Thực hành bài 9.2 (3 bài, gồm chữa bài) | Thực hành | 31 phút | |
| 4 | Trigger là gì? Sự kiện, thời điểm, NEW/OLD và inserted/deleted |
Lý thuyết + demo | 8 phút |
| 5 | Viết Trigger: AFTER INSERT / UPDATE / DELETE, BEFORE, INSTEAD OF | Lý thuyết + demo | 12 phút |
| Thực hành bài 9.3 (4 bài, gồm chữa bài) | Thực hành | 45 phút | |
| 6 | Best Practices: khi nào dùng SP/Trigger, version, test, tài liệu | Thảo luận | 11 phút |
| Tổng | Lý thuyết + demo ~61 phút, thực hành ~119 phút (66%) | 180 phút |
1. Stored Procedure là gì?
Stored Procedure (SP, thủ tục lưu trữ) là một tập hợp các câu lệnh SQL được đặt tên và lưu ngay trong cơ sở dữ liệu. Muốn chạy, ta chỉ cần gọi tên (kèm tham số nếu có), thay vì gõ lại toàn bộ câu lệnh.
Ví dụ nghiệp vụ: thay vì mỗi cuối tháng bạn tự viết 15 dòng SQL để "tăng lãi cho khách VIP", bạn viết một lần, đặt tên
sp_BoostVipBalance, rồi mọi người trong phòng chỉ cầnEXEC sp_BoostVipBalance 2.5.
1.1. Lợi ích
| Lợi ích | Giải thích | Ví dụ trong công ty chứng khoán |
|---|---|---|
| Tái sử dụng | Viết một lần, gọi ở nhiều nơi (báo cáo, ứng dụng, job đêm) | Cùng một SP tính tổng tài sản cho báo cáo tuần và màn hình tra cứu |
| Bảo mật | Cấp quyền EXECUTE trên SP mà không cấp quyền đọc/ghi trực tiếp lên bảng |
Nhân viên chăm sóc khách hàng gọi được SP đổi trạng thái nhưng không UPDATE tự do bảng Customers |
| Hiệu năng | Server phân tích (biên dịch) sẵn, dùng lại kế hoạch thực thi; giảm lưu lượng mạng vì chỉ gửi tên SP + tham số | Ứng dụng gửi EXEC sp_TopCustomers 10 thay vì một câu SQL dài |
| Kiểm soát / nhất quán | Quy tắc nghiệp vụ nằm một chỗ; sửa một nơi là mọi nơi cùng đổi | Quy tắc "VIP = giao dịch từ 200 triệu" nằm trong SP, không rải rác trong nhiều ứng dụng |
1.2. Cú pháp cơ bản
Hai hệ quản trị của khóa học dùng cú pháp khác nhau. SQL Server (T-SQL) là công cụ chính, MySQL là phương án thứ hai.
SQL Server
CREATE PROCEDURE ten_procedure
@tham_so1 KIEU_DU_LIEU,
@tham_so2 KIEU_DU_LIEU = gia_tri_mac_dinh,
@ket_qua KIEU_DU_LIEU OUTPUT
AS
BEGIN
SET NOCOUNT ON; -- không in "n rows affected", gọn kết quả trả về
-- các câu lệnh SQL
ENDMySQL
DELIMITER //
CREATE PROCEDURE ten_procedure(
IN p_tham_so1 KIEU_DU_LIEU,
OUT p_ket_qua KIEU_DU_LIEU
)
BEGIN
-- các câu lệnh SQL, mỗi câu kết thúc bằng dấu ;
END //
DELIMITER ;Vì sao MySQL cần DELIMITER? Trong thân procedure có nhiều dấu ;, nhưng client hiểu ; là "kết thúc câu lệnh, gửi đi ngay". Lệnh DELIMITER // báo cho client tạm coi // mới là dấu kết thúc, để cả khối CREATE PROCEDURE ... END được gửi đi nguyên vẹn; cuối cùng trả lại DELIMITER ;.
Cảnh báo: với SQL Server trên Codelab, script có
CREATE PROCEDURE/CREATE TRIGGER/CREATE FUNCTION/CREATE VIEWphải bắt đầu bằng một dòngGO, và các khối cách nhau bằngGO. Lý do:CREATE ...bắt buộc là câu đầu tiên của batch, mà hệ thống chấm tự chènSET NOCOUNT ON;vào trước script của bạn.GOkhông phải T-SQL mà là lệnh của công cụ (SSMS, sqlcmd) báo "kết thúc một batch, gửi đi". QuênGOlà lỗi số một khi làm bài của buổi này.
SQL Server
GO
CREATE PROCEDURE sp_Hello AS
BEGIN
SELECT 'Xin chao' AS loi_chao;
END
GO
EXEC sp_Hello;MySQL
DELIMITER //
CREATE PROCEDURE sp_Hello()
BEGIN
SELECT 'Xin chao' AS loi_chao;
END //
DELIMITER ;
CALL sp_Hello();Cả hai cho một dòng kết quả Xin chao. Bấm Chạy thử để tự kiểm tra.
1.3. Sửa, xóa và liệt kê procedure
| Việc | SQL Server | MySQL |
|---|---|---|
| Tạo mới hoặc thay thế | CREATE OR ALTER PROCEDURE ... (SQL Server 2016 SP1+) hoặc ALTER PROCEDURE ... |
DROP PROCEDURE IF EXISTS p; rồi CREATE PROCEDURE p ... |
| Xóa | DROP PROCEDURE sp_Hello; |
DROP PROCEDURE sp_Hello; |
| Liệt kê | SELECT name FROM sys.procedures; |
SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE(); |
Mẹo: tránh tiền tố
sp_trong dự án thật trên SQL Server (SQL Server tìm SPsp_...trong databasemastertrước nên hơi chậm và dễ trùng với SP hệ thống); nhiều nhóm dùngusp_(user stored procedure) hoặcproc_. Trong khóa học ta vẫn dùngsp_cho dễ nhận biết và đúng với tên bài tập.
2. Viết Stored Procedure
2.1. Procedure đầu tiên: một tham số INPUT
Bài toán: lấy khách hàng theo thành phố.
SQL Server
GO
CREATE PROCEDURE sp_CustomersByCity @city NVARCHAR(30)
AS
BEGIN
SET NOCOUNT ON;
SELECT customer_id, full_name, status
FROM Customers
WHERE city = @city
ORDER BY customer_id;
END
GO
EXEC sp_CustomersByCity 'Da Nang';MySQL
DELIMITER //
CREATE PROCEDURE sp_CustomersByCity(IN p_city VARCHAR(30))
BEGIN
SELECT customer_id, full_name, status
FROM Customers
WHERE city = p_city
ORDER BY customer_id;
END //
DELIMITER ;
CALL sp_CustomersByCity('Da Nang');Kết quả trên bộ dữ liệu mẫu:
| customer_id | full_name | status |
|---|---|---|
| 2 | Tran Thi Binh | ACTIVE |
| 6 | Vu Thi Hoa | ACTIVE |
| 15 | Vo Van Tuan | ACTIVE |
| 21 | Truong Van Bao | ACTIVE |
| 27 | Vu Van Kien | ACTIVE |
SQL Server vs MySQL: tên tham số T-SQL luôn có
@đứng trước; MySQL không có@và mọi người quen thêm tiền tốp_(hoặcin_) để không trùng tên cột. Nếu tham số têncitytrùng cộtcity, MySQL sẽ hiểu là cột và câuWHERE city = cityđúng với mọi dòng!
2.2. Tham số INPUT và OUTPUT
- INPUT (mặc định): giá trị truyền vào procedure.
- OUTPUT: procedure trả giá trị ra qua tham số để nơi gọi dùng tiếp (chỉ trả được 1 giá trị/tham số, khác với
SELECTtrả cả một bảng).
SQL Server (thêm từ khóa OUTPUT ở cả lúc khai báo lẫn lúc gọi):
GO
CREATE PROCEDURE sp_CountActiveAccounts @customer_id INT, @n INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SELECT @n = COUNT(*) FROM Accounts
WHERE customer_id = @customer_id AND status = 'ACTIVE';
END
GO
DECLARE @n INT;
EXEC sp_CountActiveAccounts 2, @n OUTPUT;
SELECT @n AS active_accounts;MySQL (IN / OUT / INOUT viết ngay trước tên tham số; gọi bằng biến phiên @n):
DELIMITER //
CREATE PROCEDURE sp_CountActiveAccounts(IN p_customer_id INT, OUT p_n INT)
BEGIN
SELECT COUNT(*) INTO p_n FROM Accounts
WHERE customer_id = p_customer_id AND status = 'ACTIVE';
END //
DELIMITER ;
CALL sp_CountActiveAccounts(2, @n);
SELECT @n AS active_accounts;Khách hàng số 2 có 3 tài khoản ACTIVE nên kết quả là 3.
| Tham số | Vai trò | T-SQL | MySQL |
|---|---|---|---|
| Đầu vào | Nơi gọi truyền giá trị vào | @x INT |
IN p_x INT |
| Đầu ra | Procedure gán, nơi gọi đọc | @x INT OUTPUT |
OUT p_x INT |
| Vào rồi ra | Truyền vào, procedure sửa, đọc lại | @x INT OUTPUT |
INOUT p_x INT |
Cảnh báo: quên
OUTPUTkhi gọi trong T-SQL thì biến nhận về vẫnNULL. Ngoài ra tính "tổng" bằngSUM()mà khách hàng không có dòng nào cho kết quảNULL, phải bọcCOALESCE(SUM(...), 0). Bài 9.1b kiểm tra đúng bẫy này (khách hàng 4 chỉ có tài khoản đóng, khách hàng 10 không có tài khoản).
2.3. Biến cục bộ (DECLARE)
Biến giữ giá trị trung gian trong procedure.
| SQL Server | MySQL | |
|---|---|---|
| Khai báo | DECLARE @total DECIMAL(18,2); (đặt ở bất cứ đâu, có thể gán ngay = 0) |
DECLARE v_total DECIMAL(18,2) DEFAULT 0; (phải đặt ngay đầu khối BEGIN, trước các câu lệnh khác) |
| Gán giá trị | SET @total = 10; hoặc SELECT @total = SUM(...) FROM ...; |
SET v_total = 10; hoặc SELECT SUM(...) INTO v_total FROM ...; |
Lưu ý: bẫy quen thuộc của MySQL là nhầm biến cục bộ (
DECLARE, không có@, chỉ sống trong procedure) với biến phiên (@ten, sống suốt kết nối). Trong thân procedure hãy dùng biến cục bộ; biến phiên@xchỉ dùng ở nơi gọi để nhận tham sốOUT.
2.4. Điều khiển luồng: IF, CASE, WHILE, "FOR"
IF ... ELSE — ví dụ phân mức theo số tài khoản đang hoạt động của khách hàng:
SQL Server
GO
CREATE PROCEDURE sp_AccountLevel @customer_id INT, @level NVARCHAR(20) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @n INT = (SELECT COUNT(*) FROM Accounts WHERE customer_id = @customer_id AND status = 'ACTIVE');
IF @n >= 3
SET @level = 'NHIEU TAI KHOAN';
ELSE IF @n >= 1
SET @level = 'CO TAI KHOAN';
ELSE
SET @level = 'CHUA CO TAI KHOAN';
END
GO
DECLARE @lv NVARCHAR(20);
EXEC sp_AccountLevel 2, @lv OUTPUT;
SELECT @lv AS muc;MySQL (dùng IF ... THEN ... ELSEIF ... ELSE ... END IF;, chỉ dùng được bên trong procedure/trigger/function)
DELIMITER //
CREATE PROCEDURE sp_AccountLevel(IN p_customer_id INT, OUT p_level VARCHAR(20))
BEGIN
DECLARE v_n INT DEFAULT 0;
SELECT COUNT(*) INTO v_n FROM Accounts WHERE customer_id = p_customer_id AND status = 'ACTIVE';
IF v_n >= 3 THEN
SET p_level = 'NHIEU TAI KHOAN';
ELSEIF v_n >= 1 THEN
SET p_level = 'CO TAI KHOAN';
ELSE
SET p_level = 'CHUA CO TAI KHOAN';
END IF;
END //
DELIMITER ;
CALL sp_AccountLevel(2, @lv);
SELECT @lv AS muc;Đổi số 2 thành 10 (khách hàng chưa có tài khoản) để thấy nhánh ELSE. Khối nhiều lệnh trong T-SQL dùng cặp BEGIN ... END.
CASE có hai dạng: biểu thức CASE trong SELECT (đã học ở Buổi 4, dùng được ở cả hai hệ) và câu lệnh CASE trong procedure (chỉ MySQL):
-- MySQL: câu lệnh CASE trong procedure
CASE
WHEN v_total >= 1000000000 THEN SET p_tier = 'VIP';
WHEN v_total >= 100000000 THEN SET p_tier = 'STANDARD';
ELSE SET p_tier = 'BASIC';
END CASE;Với T-SQL, trong procedure hãy dùng IF...ELSE hoặc gán bằng biểu thức: SET @tier = CASE WHEN @total >= 1000000000 THEN 'VIP' WHEN @total >= 100000000 THEN 'STANDARD' ELSE 'BASIC' END;
WHILE — lặp khi điều kiện còn đúng. Ví dụ cộng các số từ 1 đến n:
SQL Server
DECLARE @i INT = 1, @sum INT = 0, @n INT = 5;
WHILE @i <= @n
BEGIN
SET @sum = @sum + @i;
SET @i = @i + 1;
END
SELECT @sum AS tong;MySQL (vòng lặp chỉ chạy được bên trong procedure)
DELIMITER //
CREATE PROCEDURE sp_SumTo(IN p_n INT, OUT p_sum INT)
BEGIN
DECLARE i INT DEFAULT 1;
SET p_sum = 0;
WHILE i <= p_n DO
SET p_sum = p_sum + i;
SET i = i + 1;
END WHILE;
END //
DELIMITER ;
CALL sp_SumTo(5, @s);
SELECT @s AS tong;Cả hai cho 15 (tổng 1 + 2 + 3 + 4 + 5).
"FOR": cả T-SQL lẫn MySQL không có vòng FOR i = 1 TO n. Ta mô phỏng bằng WHILE (tăng biến đếm thủ công), hoặc trong MySQL dùng LOOP ... LEAVE nhãn / REPEAT ... UNTIL:
-- MySQL: LOOP với nhãn, thoát bằng LEAVE (thay cho WHILE ở trên)
loop_label: LOOP
IF i > p_n THEN LEAVE loop_label; END IF;
SET p_sum = p_sum + i;
SET i = i + 1;
END LOOP;Cảnh báo: SQL là ngôn ngữ thao tác tập hợp. Nếu bạn viết
WHILEđể duyệt từng khách hàng rồiUPDATEtừng dòng, thường chậm hơn nhiều lần một câuUPDATE ... WHEREduy nhất. Chỉ dùng vòng lặp khi thật sự cần xử lý tuần tự (bài 9.1e là ví dụ mang tính luyện cú pháp).
2.5. RETURN và SELECT trong Procedure: ba cách "trả kết quả"
| Cách | Trả gì | SQL Server | MySQL |
|---|---|---|---|
Result set (SELECT cuối procedure) |
Cả một bảng dữ liệu | Có | Có |
| Tham số OUTPUT/OUT | Vài giá trị đơn | Có | Có |
| RETURN giá trị | Mã trạng thái nguyên (thành công = 0) | RETURN 0; — nhận bằng EXEC @rc = sp_x ... |
RETURN không mang giá trị trong procedure (chỉ dùng trong FUNCTION); thoát sớm bằng LEAVE nhan |
Ví dụ T-SQL dùng RETURN để thoát sớm và trả mã: 0 nếu khách hàng tồn tại, 1 nếu không.
GO
CREATE PROCEDURE sp_CheckCustomer @customer_id INT
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM Customers WHERE customer_id = @customer_id)
RETURN 1; -- thoát sớm: không tìm thấy
RETURN 0; -- thành công
END
GO
DECLARE @rc INT;
EXEC @rc = sp_CheckCustomer 9999;
SELECT @rc AS return_code;Kết quả return_code = 1 vì khách hàng 9999 không tồn tại; đổi thành EXEC @rc = sp_CheckCustomer 2; sẽ được 0.
MySQL thoát sớm khỏi procedure bằng cách gắn nhãn cho khối BEGIN...END rồi LEAVE:
DELIMITER //
CREATE PROCEDURE sp_CheckCustomer(IN p_customer_id INT, OUT p_rc INT)
main_block: BEGIN
SET p_rc = 0;
IF NOT EXISTS (SELECT 1 FROM Customers WHERE customer_id = p_customer_id) THEN
SET p_rc = 1;
LEAVE main_block; -- thoát sớm, giống RETURN của T-SQL
END IF;
END //
DELIMITER ;
CALL sp_CheckCustomer(9999, @rc);
SELECT @rc AS return_code;Lưu ý: mã
RETURNcủa T-SQL chỉ là số nguyên để báo trạng thái (quy ước: 0 = thành công). Muốn trả dữ liệu thì dùngOUTPUThoặcSELECT, đừng lạm dụngRETURNđể trả số liệu nghiệp vụ.
Thực hành: Vừa học xong tham số INPUT/OUTPUT, biến cục bộ,
IF/CASE/WHILEvà cách trả kết quả, hãy làm bài 9.1 để tự viết 5 procedure: liệt kê khách hàng theo loại, tính tổng tài sản qua tham số OUTPUT, lấy TOP N, phân hạng bằng biến +IF, và khởi tạoCustomerStatsbằng vòng lặpWHILE. Nhớ dòngGO(SQL Server) vàDELIMITER //(MySQL).
3. Gọi Stored Procedure
Viết xong procedure là mới một nửa; nơi gọi phải truyền đúng tham số và đọc đúng loại kết quả.
| SQL Server | MySQL | |
|---|---|---|
| Không tham số | EXEC sp_InitCustomerStats; |
CALL sp_InitCustomerStats(); |
| Có tham số (theo thứ tự) | EXEC sp_CustomersByCity 'Da Nang'; |
CALL sp_CustomersByCity('Da Nang'); |
| Theo tên tham số | EXEC sp_CustomersByCity @city = 'Da Nang'; |
(không hỗ trợ — truyền theo thứ tự) |
| Tham số OUTPUT | EXEC sp 2, @n OUTPUT; |
CALL sp(2, @n); |
| Tham số có giá trị mặc định | CREATE PROCEDURE sp_Top @n INT = 3 ... rồi EXEC sp_Top; |
(không hỗ trợ giá trị mặc định cho tham số) |
Ví dụ giá trị mặc định (chỉ T-SQL): gọi không truyền tham số thì @n lấy giá trị 3.
GO
CREATE PROCEDURE sp_RecentCustomers @n INT = 3
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (@n) customer_id, full_name, created_date
FROM Customers
ORDER BY created_date DESC, customer_id;
END
GO
EXEC sp_RecentCustomers;3.1. Xử lý kết quả trả về
- Result set đến trực tiếp ứng dụng/SSMS. Nếu cần xử lý tiếp ngay trong SQL Server, hứng vào bảng tạm bằng
INSERT ... EXEC:
GO
CREATE PROCEDURE sp_RecentCustomers @n INT = 3
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (@n) customer_id, full_name, created_date
FROM Customers
ORDER BY created_date DESC, customer_id;
END
GO
CREATE TABLE #r (customer_id INT, full_name NVARCHAR(120), created_date DATE);
INSERT INTO #r EXEC sp_RecentCustomers @n = 2; -- hứng kết quả của SP vào bảng tạm
SELECT COUNT(*) AS so_dong_hung_duoc FROM #r;- OUTPUT / OUT đọc từ biến (
@n). - Mã RETURN (T-SQL) đọc bằng
EXEC @rc = ....
SQL Server vs MySQL: MySQL không có
INSERT ... CALL. Nếu cần dùng kết quả trong SQL, hãy để procedure ghi vào bảng tạm/bảng thật, hoặc dùngFUNCTION/VIEWthay cho procedure.
Xem lại mã nguồn procedure: SQL Server EXEC sp_helptext 'sp_CustomersByCity'; (hoặc SSMS: Programmability → Stored Procedures → Modify). MySQL SHOW CREATE PROCEDURE sp_CustomersByCity;.
3.2. Procedure có ghi dữ liệu (UPDATE)
Procedure thường dùng để ghi dữ liệu theo một quy trình nghiệp vụ nhiều bước. Ví dụ đơn giản: đóng băng (chuyển CLOSED) các tài khoản ACTIVE của một khách hàng và trả về số tài khoản đã đổi. Số dòng bị ảnh hưởng: SQL Server @@ROWCOUNT (đọc ngay sau câu lệnh, câu lệnh kế tiếp sẽ ghi đè); MySQL ROW_COUNT().
SQL Server
GO
CREATE PROCEDURE sp_FreezeCustomerAccounts @customer_id INT, @affected INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
UPDATE Accounts SET status = 'CLOSED'
WHERE customer_id = @customer_id AND status = 'ACTIVE';
SET @affected = @@ROWCOUNT; -- số dòng vừa bị UPDATE
END
GO
DECLARE @a INT;
EXEC sp_FreezeCustomerAccounts 2, @a OUTPUT;
SELECT @a AS so_tai_khoan_da_dong;MySQL
DELIMITER //
CREATE PROCEDURE sp_FreezeCustomerAccounts(IN p_customer_id INT, OUT p_affected INT)
BEGIN
UPDATE Accounts SET status = 'CLOSED'
WHERE customer_id = p_customer_id AND status = 'ACTIVE';
SET p_affected = ROW_COUNT(); -- số dòng vừa bị UPDATE
END //
DELIMITER ;
CALL sp_FreezeCustomerAccounts(2, @a);
SELECT @a AS so_tai_khoan_da_dong;Khách hàng 2 có 3 tài khoản ACTIVE nên so_tai_khoan_da_dong = 3. Gọi lần thứ hai sẽ được 0 (không còn tài khoản nào ACTIVE để đổi): đây chính là cách procedure báo cho nơi gọi biết "đã làm được gì".
Cảnh báo: vì thay đổi dữ liệu, hãy luôn
WHEREchặt và thử trên dữ liệu test trước. MộtUPDATEthiếuWHEREtrong procedure sẽ sửa cả bảng mỗi lần ai đó gọi. Hãy kiểm tra tham số hợp lệ (trạng thái thuộc danh sách cho phép, khách hàng có tồn tại) trước khi ghi.
Thực hành: Vừa học xong cách viết procedure có
UPDATEvà lấy số dòng bị ảnh hưởng, hãy làm bài 9.2: đổi trạng thái khách hàng có kiểm tra hợp lệ, tăng số dư cho khách VIP theo tỷ lệ, và khóa khách hàng "ngủ đông" kèm tham số OUTPUT báo số dòng đã đổi.
4. Trigger là gì?
Trigger (bộ kích hoạt) là một khối lệnh SQL tự động chạy khi có sự kiện INSERT, UPDATE hoặc DELETE xảy ra trên một bảng. Bạn không gọi nó bằng tay và nó gắn với một bảng cụ thể. Khác biệt cốt lõi với procedure: procedure "gọi thì chạy", trigger "có sự kiện thì chạy".
Hình dưới minh họa luồng của một trigger ghi nhật ký kiểm toán: người dùng chỉ thực hiện một câu INSERT/UPDATE/DELETE, còn việc ghi AuditLog được database tự động làm ngay sau đó.
Người dùng chỉ chạy lệnh UPDATE; trigger AFTER UPDATE tự ghi lại giá trị cũ/mới vào AuditLog. Trong trigger: MySQL dùng OLD/NEW, SQL Server dùng bảng inserted/deleted.
Cách đọc hình: bắt đầu từ câu lệnh của người dùng ở bên trái, sự kiện xảy ra trên bảng gốc kích hoạt trigger, trigger chạy cùng transaction với câu lệnh gốc và ghi thêm dòng vào bảng nhật ký. Nếu câu lệnh gốc bị hoàn nguyên (ROLLBACK) thì phần trigger đã làm cũng bị hoàn nguyên theo.
4.1. Ứng dụng điển hình
- Cập nhật tự động dữ liệu phái sinh: mỗi khi thêm giao dịch khớp lệnh thì cộng dồn vào
CustomerStats. - Audit log (nhật ký kiểm toán): ai đổi trạng thái khách hàng, từ gì sang gì. Quy định của công ty chứng khoán về truy vết bắt buộc phải có.
- Kiểm tra dữ liệu: chặn số dư âm, chuẩn hóa email trước khi lưu.
4.2. Thời điểm kích hoạt: BEFORE / AFTER / INSTEAD OF
| Loại | Chạy khi nào | MySQL | SQL Server |
|---|---|---|---|
| BEFORE | Trước khi ghi dòng vào bảng; có thể sửa giá trị sắp ghi hoặc từ chối | Có (BEFORE INSERT/UPDATE/DELETE) |
Không có |
| AFTER | Sau khi dòng đã được ghi | Có | Có (AFTER hoặc FOR) |
| INSTEAD OF | Thay thế hoàn toàn thao tác gốc | Không có | Có (thường dùng trên VIEW hoặc để "xóa mềm") |
SQL Server vs MySQL: điểm khác biệt lớn nhất là T-SQL không có BEFORE trigger. Muốn "kiểm tra trước khi ghi" trong SQL Server bạn dùng
INSTEAD OF(tự viết lệnh ghi) hoặcAFTERrồiROLLBACK/THROWđể hủy. Vì vậy các bài tập của buổi này dùng AFTER cho cả hai hệ để kết quả giống nhau.
4.3. Truy cập "dòng mới / dòng cũ": NEW/OLD và inserted/deleted
| MySQL | SQL Server | |
|---|---|---|
| Đơn vị chạy | Mỗi dòng (FOR EACH ROW): trigger chạy N lần nếu câu lệnh chạm N dòng |
Mỗi câu lệnh: trigger chạy một lần cho cả tập dòng bị ảnh hưởng |
| Dòng mới (INSERT/UPDATE) | NEW.cột |
Bảng ảo inserted (có thể nhiều dòng) |
| Dòng cũ (UPDATE/DELETE) | OLD.cột |
Bảng ảo deleted (có thể nhiều dòng) |
| Sự kiện | MySQL | SQL Server |
|---|---|---|
| INSERT | chỉ có NEW |
chỉ có inserted |
| DELETE | chỉ có OLD |
chỉ có deleted |
| UPDATE | OLD (cũ) và NEW (mới) |
deleted (cũ) và inserted (mới), ghép bằng khóa chính |
Cảnh báo: bẫy lớn nhất khi chuyển từ MySQL sang T-SQL là trigger T-SQL chạy một lần cho cả tập dòng. Đừng viết như thể
insertedluôn có đúng 1 dòng (DECLARE @id = (SELECT customer_id FROM inserted)chỉ lấy một dòng ngẫu nhiên, bỏ sót các dòng còn lại). Hãy luôn viết theo kiểu tập hợp:UPDATE ... FROM ... JOIN inserted,INSERT ... SELECT ... FROM inserted.
5. Viết Trigger
5.1. AFTER INSERT: tự tạo dòng thống kê cho khách hàng mới
Mỗi khách hàng cần đúng một dòng trong CustomerStats. Thay vì nhắc người nhập liệu, để trigger tự làm.
SQL Server
GO
CREATE TRIGGER trg_Customers_AfterInsert ON Customers
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO CustomerStats (customer_id, total_trade_value, trade_count)
SELECT customer_id, 0, 0 FROM inserted; -- xử lý được nhiều khách hàng cùng lúc
END
GO
INSERT INTO Customers (customer_id, customer_code, full_name, customer_type, city, email, status, created_date)
VALUES (101, 'KH900001', 'Nguyen Test Mot', 'INDIVIDUAL', 'Ha Noi', '[email protected]', 'NEW', '2026-09-25'),
(102, 'KH900002', 'Nguyen Test Hai', 'INDIVIDUAL', 'Ha Noi', '[email protected]', 'NEW', '2026-09-25');
SELECT customer_id, total_trade_value, trade_count FROM CustomerStats WHERE customer_id > 100 ORDER BY customer_id;MySQL
DELIMITER //
CREATE TRIGGER trg_Customers_AfterInsert AFTER INSERT ON Customers
FOR EACH ROW
BEGIN
INSERT INTO CustomerStats (customer_id, total_trade_value, trade_count)
VALUES (NEW.customer_id, 0, 0);
END //
DELIMITER ;
INSERT INTO Customers (customer_id, customer_code, full_name, customer_type, city, email, status, created_date)
VALUES (101, 'KH900001', 'Nguyen Test Mot', 'INDIVIDUAL', 'Ha Noi', '[email protected]', 'NEW', '2026-09-25'),
(102, 'KH900002', 'Nguyen Test Hai', 'INDIVIDUAL', 'Ha Noi', '[email protected]', 'NEW', '2026-09-25');
SELECT customer_id, total_trade_value, trade_count FROM CustomerStats WHERE customer_id > 100 ORDER BY customer_id;Kết quả: hai dòng thống kê 101 và 102 (giá trị 0.00 và 0) xuất hiện tự động dù ta chưa hề INSERT vào CustomerStats. Chú ý: một câu INSERT thêm hai khách hàng; trigger MySQL chạy hai lần (mỗi dòng một lần), trigger SQL Server chạy một lần và inserted chứa cả hai dòng.
5.2. AFTER INSERT / DELETE / UPDATE: giữ số liệu thống kê đồng bộ
Đây là nội dung các bài 9.3a, 9.3b, 9.3d. Dùng chung một ý tưởng: khách hàng sở hữu giao dịch được xác định qua chuỗi Trades.order_id → Orders.account_id → Accounts.customer_id.
Ví dụ ý tưởng khi thêm giao dịch (MySQL, mỗi dòng một lần):
UPDATE CustomerStats
SET total_trade_value = total_trade_value + NEW.quantity * NEW.price,
trade_count = trade_count + 1
WHERE customer_id = (SELECT a.customer_id
FROM Orders o JOIN Accounts a ON a.account_id = o.account_id
WHERE o.order_id = NEW.order_id);Với T-SQL, trigger nhận cả tập inserted; nếu một câu INSERT thêm 2 giao dịch của cùng một khách hàng, phải gom nhóm (GROUP BY customer_id) trước khi cộng, nếu không chỉ một trong hai dòng được cộng (vì UPDATE ... FROM ... JOIN với nhiều dòng khớp chỉ áp dụng một dòng).
Kết quả mong đợi khi chèn 3 giao dịch (2 của khách hàng 22: 9.000.000 + 1.200.000; 1 của khách hàng 40: 27.000.000):
| customer_id | total_trade_value | trade_count | Ghi chú |
|---|---|---|---|
| 22 | 430725000.00 | 9 | trước đó 420.525.000 và 7 giao dịch |
| 40 | 27000000.00 | 1 | trước đó 0 và 0 giao dịch |
Với DELETE và UPDATE làm tương tự: khi xóa, trừ đi theo deleted/OLD; khi sửa khối lượng hoặc giá, cộng phần chênh lệch (mới − cũ).
5.3. AFTER UPDATE: ghi AuditLog chỉ khi trạng thái thật sự đổi
Trigger AFTER UPDATE kích hoạt cho mọi câu UPDATE trên bảng, kể cả khi bạn chỉ sửa cột city hay gán lại đúng giá trị cũ. Vì vậy phải so sánh giá trị mới với cũ:
-- SQL Server: chỉ những dòng có status đổi
INSERT INTO AuditLog (customer_id, old_status, new_status)
SELECT i.customer_id, d.status, i.status
FROM inserted i JOIN deleted d ON d.customer_id = i.customer_id
WHERE d.status <> i.status;-- MySQL
IF OLD.status <> NEW.status THEN
INSERT INTO AuditLog (customer_id, old_status, new_status)
VALUES (OLD.customer_id, OLD.status, NEW.status);
END IF;Lưu ý: nếu cột có thể
NULL,<>cho kết quảNULL(không đúng, không sai) và bỏ sót thay đổi từ/đếnNULL; khi đó dùngNOT (OLD.x <=> NEW.x)trong MySQL hoặcISNULL(d.x, '') <> ISNULL(i.x, '')trong T-SQL. Cộtstatuscủa Customers làNOT NULLnên dùng<>là đủ. Cộtchanged_atcủa AuditLog có giá trị mặc định (thời điểm hiện tại) nên không cần điền; vì "thời điểm hiện tại" thay đổi mỗi lần chạy, các bài chấm tự động không in cột này.
5.4. BEFORE trigger (MySQL) và INSTEAD OF trigger (SQL Server)
Tổng hợp bốn cơ chế thường gặp trong outline và ví dụ nghiệp vụ tương ứng:
| Cơ chế | Dùng để | Ví dụ |
|---|---|---|
BEFORE INSERT (MySQL) |
Chuẩn hóa dữ liệu sắp ghi | Đổi email về chữ thường, mã khách hàng về chữ hoa |
AFTER INSERT |
Cập nhật dữ liệu phái sinh | Tạo dòng CustomerStats, cộng dồn giá trị giao dịch |
BEFORE UPDATE (MySQL) |
Từ chối dữ liệu sai | Chặn số dư tài khoản âm |
AFTER UPDATE |
Ghi vết thay đổi | Ghi AuditLog khi đổi trạng thái |
AFTER DELETE |
Trừ số liệu, lưu bản sao dòng bị xóa | Trừ giá trị giao dịch bị hủy khỏi CustomerStats |
MySQL — BEFORE INSERT chuẩn hóa dữ liệu trước khi ghi (gán vào NEW.cột):
DELIMITER //
CREATE TRIGGER trg_Customers_BI BEFORE INSERT ON Customers FOR EACH ROW
BEGIN
SET NEW.email = LOWER(TRIM(NEW.email));
SET NEW.customer_code = UPPER(NEW.customer_code);
END //
DELIMITER ;
INSERT INTO Customers (customer_id, customer_code, full_name, customer_type, city, email, status, created_date)
VALUES (101, 'kh900001', 'Nguyen Test Mot', 'INDIVIDUAL', 'Ha Noi', ' [email protected] ', 'NEW', '2026-09-25');
SELECT customer_id, customer_code, email FROM Customers WHERE customer_id = 101;Email ' [email protected] ' được lưu thành [email protected] và mã kh900001 thành KH900001, dù câu INSERT ghi khác.
MySQL — BEFORE UPDATE từ chối dữ liệu sai bằng SIGNAL (gây lỗi, câu lệnh bị hủy):
CREATE TRIGGER trg_Accounts_NoNegative BEFORE UPDATE ON Accounts FOR EACH ROW
BEGIN
IF NEW.balance < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'So du khong duoc am';
END IF;
ENDCâu UPDATE Accounts SET balance = -1 ... sẽ báo lỗi So du khong duoc am và không dòng nào bị đổi.
SQL Server — INSTEAD OF DELETE: "xóa mềm" khách hàng (không cho xóa thật, chỉ đánh dấu INACTIVE):
GO
CREATE TRIGGER trg_Customers_SoftDelete ON Customers
INSTEAD OF DELETE
AS
BEGIN
SET NOCOUNT ON;
UPDATE c SET status = 'INACTIVE'
FROM Customers c JOIN deleted d ON d.customer_id = c.customer_id;
END
GO
DELETE FROM Customers WHERE customer_id = 10;
SELECT customer_id, status FROM Customers WHERE customer_id = 10;Dòng 10 INACTIVE vẫn còn trong bảng: lệnh DELETE đã bị trigger thay thế bằng UPDATE.
SQL Server — AFTER + ROLLBACK/THROW thay cho BEFORE kiểm tra dữ liệu:
GO
CREATE TRIGGER trg_Accounts_NoNegative ON Accounts
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM inserted WHERE balance < 0)
BEGIN
ROLLBACK TRANSACTION; -- hủy cả câu UPDATE vừa chạy
THROW 50001, 'So du khong duoc am', 1;
END
END
GO5.5. Quản lý trigger
| Việc | SQL Server | MySQL |
|---|---|---|
| Liệt kê | SELECT name FROM sys.triggers; |
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_TIMING FROM INFORMATION_SCHEMA.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE(); |
| Xóa | DROP TRIGGER trg_Customers_AfterInsert; |
DROP TRIGGER trg_Customers_AfterInsert; |
| Tạm tắt | DISABLE TRIGGER trg_x ON Customers; |
(không có — phải xóa và tạo lại) |
5.6. Sai lầm thường gặp với trigger
- Đệ quy / chuỗi trigger: trigger A ghi bảng B, trigger của B lại ghi bảng A... khó lường. MySQL cấm trigger sửa chính bảng đang kích hoạt nó.
- Trigger "ngầm": người viết câu
INSERTkhông biết còn có trigger chạy nên khó gỡ lỗi. Luôn tài liệu hóa. - Coi trigger chạy 1 dòng (T-SQL): như đã cảnh báo ở mục 4.3.
- Việc nặng trong trigger: trigger chạy trong cùng transaction với thao tác gốc; xử lý chậm sẽ làm chậm mọi lệnh đặt/khớp lệnh và giữ khóa lâu.
- Import hàng loạt: nạp 1 triệu dòng kích hoạt trigger 1 triệu lần (MySQL). Cân nhắc tắt trigger tạm thời (
DISABLE TRIGGER) khi migrate, rồi tính lại số liệu.
Thực hành: Vừa học xong cả trigger
AFTER INSERT/DELETE/UPDATEvớiNEW/OLDvàinserted/deleted, hãy làm bài 9.3: giữCustomerStatsđồng bộ khi thêm, xóa, sửa giao dịch và ghiAuditLogkhi khách hàng đổi trạng thái. Hệ thống chấm sẽ tự kích hoạt trigger của bạn bằng các lệnh DML rồi so sánh dữ liệu, kể cả trường hợp nhiều dòng trong một câu lệnh.
6. Best Practices
6.1. Khi nào dùng Stored Procedure, khi nào dùng Application Code?
| Nên đặt trong Stored Procedure / Trigger | Nên đặt trong code ứng dụng |
|---|---|
| Thao tác thuần dữ liệu, nhiều bước, cần chạy sát CSDL (chuyển tiền, đóng phiên, đối soát) | Logic nghiệp vụ phức tạp, nhiều rẽ nhánh, cần thư viện ngoài (gọi API, gửi mail, mã hóa) |
| Quy tắc bắt buộc mọi hệ thống phải tuân thủ dù ai truy cập (audit log) | Logic hay thay đổi, cần triển khai/rollback nhanh cùng với bản phát hành phần mềm |
| Cần bảo mật theo quyền EXECUTE (không cho truy cập bảng trực tiếp) | Cần unit test tự động, CI/CD, debug dễ bằng IDE |
| Xử lý khối lượng lớn mà chuyển dữ liệu ra ứng dụng sẽ tốn mạng | Xử lý phụ thuộc trạng thái phiên người dùng, giao diện |
Mẹo: nguyên tắc thực tế là "thao tác dữ liệu ở database, quyết định nghiệp vụ phức tạp ở ứng dụng". Đừng nhồi toàn bộ nghiệp vụ vào procedure (khó test, khó dùng chung hệ quản trị khác, khó mở rộng ngang); nhưng cũng đừng để ứng dụng gửi hàng nghìn câu SQL nhỏ mỗi thao tác.
6.2. Quản lý version, testing, documentation
- Version: lưu script
CREATE PROCEDURE/TRIGGERtrong Git (một file/đối tượng), đánh số migration; không sửa trực tiếp trên production bằng tay. DùngCREATE OR ALTER(T-SQL) hoặcDROP ... IF EXISTS(MySQL) để script chạy lại nhiều lần được. - Testing: chạy procedure trên dữ liệu thử, kiểm cả trường hợp biên như buổi này: khách hàng không có tài khoản (
NULL), đồng hạng, tham số không hợp lệ, số dòng bằng 0. Viết script kiểm thử: chuẩn bị dữ liệu, gọi,SELECTtrạng thái, so với kỳ vọng. Đó chính là cách các bài chấm "verify" của Codelab hoạt động. - Documentation: ghi chú đầu procedure (mục đích, tham số, giá trị trả về, ví dụ gọi, tác giả/ngày sửa). Đặt tên nhất quán:
sp_<Động từ><Đối tượng>,trg_<Bảng>_<Thời điểm><Sự kiện>. - Xử lý lỗi: T-SQL dùng
TRY ... CATCH, MySQL dùngDECLARE ... HANDLER; procedure ghi dữ liệu nên bọc trong transaction (sẽ học ở Buổi 10). - Hiệu năng: đặt
SET NOCOUNT ON(T-SQL); không viếtSELECT *; tránh con trỏ/vòng lặp khi câu lệnh tập hợp làm được.
Sai lầm thường gặp
| Sai lầm | Hậu quả | Cách đúng |
|---|---|---|
SQL Server: quên dòng GO trước CREATE PROCEDURE/TRIGGER |
Lỗi "CREATE PROCEDURE must be the only statement in the batch" | Bắt đầu script bằng GO, ngăn các khối bằng GO |
MySQL: quên DELIMITER // (hoặc quên trả lại DELIMITER ;) |
Lỗi cú pháp ngay dòng ; đầu tiên trong thân |
Bao khối bằng DELIMITER // ... END // DELIMITER ; |
Quên OUTPUT khi gọi (T-SQL) |
Biến nhận về luôn NULL |
EXEC sp 2, @n OUTPUT; |
| Đặt tên tham số MySQL trùng tên cột | WHERE city = city đúng với mọi dòng |
Thêm tiền tố p_ cho tham số, v_ cho biến |
Tính SUM() mà không có dòng nào |
Trả NULL thay vì 0 |
COALESCE(SUM(...), 0) |
Đọc @@ROWCOUNT sau khi đã chạy thêm lệnh khác |
Số dòng sai (bị ghi đè) | Gán ngay sau câu UPDATE: SET @affected = @@ROWCOUNT; |
UPDATE trong procedure thiếu WHERE hoặc không kiểm tra tham số |
Sửa cả bảng mỗi lần gọi | WHERE chặt, kiểm tra tham số hợp lệ trước khi ghi |
Dùng WHILE duyệt từng dòng thay cho một câu tập hợp |
Chậm hơn hàng chục đến hàng trăm lần | Viết một câu UPDATE/INSERT ... SELECT |
Trigger T-SQL giả định inserted chỉ có 1 dòng |
Bỏ sót các dòng còn lại khi INSERT nhiều dòng |
Viết theo tập hợp: JOIN inserted, GROUP BY trước khi cộng |
Trigger AFTER UPDATE không so sánh giá trị cũ/mới |
Ghi log cả khi không có thay đổi | So sánh OLD.x <> NEW.x / d.x <> i.x |
| Trigger ghi ngược lên chính bảng hoặc gọi vòng tròn | Lỗi (MySQL) hoặc đệ quy khó kiểm soát | Thiết kế một chiều, tài liệu hóa mọi trigger |
Tóm tắt
| Chủ đề | Ghi nhớ |
|---|---|
| Stored Procedure | Khối lệnh đặt tên, lưu trong CSDL; lợi ích: tái sử dụng, bảo mật, hiệu năng, kiểm soát |
| Tạo / gọi | T-SQL: GO + CREATE PROCEDURE ... AS BEGIN ... END / EXEC; MySQL: DELIMITER // ... / CALL |
| Tham số | INPUT mặc định; OUTPUT (T-SQL: @x INT OUTPUT; MySQL: OUT p_x INT) |
| Biến | DECLARE; T-SQL gán bằng SET/SELECT @x = ..., MySQL SET/SELECT ... INTO |
| Điều khiển | IF, CASE, WHILE; không có FOR (dùng WHILE/LOOP/REPEAT) |
| Trả kết quả | Result set (SELECT), tham số OUTPUT, mã RETURN (chỉ T-SQL) |
| Số dòng bị ảnh hưởng | T-SQL @@ROWCOUNT; MySQL ROW_COUNT() |
| Trigger | Tự chạy khi INSERT/UPDATE/DELETE; MySQL FOR EACH ROW + NEW/OLD; SQL Server theo câu lệnh + inserted/deleted |
| Thời điểm | MySQL: BEFORE/AFTER; SQL Server: AFTER và INSTEAD OF (không có BEFORE) |
| Best practice | Thao tác dữ liệu ở CSDL, nghiệp vụ phức tạp ở ứng dụng; quản lý version, test cả trường hợp biên, tài liệu hóa |
Gợi ý mở rộng
- Viết procedure
sp_RecalcCustomerStatstính lại toàn bộCustomerStatstừ bảngTradesbằng một câu lệnh tập hợp, dùng để đối chiếu với số liệu do trigger duy trì (phát hiện lệch). - Thêm cột
updated_byvào AuditLog và ghi người thay đổi (T-SQLSUSER_SNAME(), MySQLCURRENT_USER()). - Tìm hiểu User-Defined Function (hàm trả về giá trị/bảng dùng ngay trong
SELECT) và khác biệt với Stored Procedure. - Tìm hiểu xử lý lỗi
TRY ... CATCH(T-SQL) /DECLARE ... HANDLER(MySQL), chuẩn bị cho phần Transaction ở Buổi 10.