Chỉ cần 10 phút, bạn có thể nắm vững cách Trigger SQL hoạt động, khi nào nên dùng và những bẫy thường gặp. Dưới đây là hướng dẫn cô đọng nhưng đủ sâu, kèm ví dụ thực chiến để bạn vừa hiểu nguyên lý vừa áp dụng an toàn trong hệ thống sản xuất.
Trigger là đoạn mã được cơ sở dữ liệu tự động kích hoạt khi có sự kiện DML hoặc DDL xảy ra, thường là INSERT, UPDATE, DELETE trên một bảng. Bạn có thể hình dung trigger như một event listener ở tầng dữ liệu: mỗi khi bản ghi thay đổi, logic trong trigger sẽ chạy trước hoặc sau sự kiện đó.
Tại sao nên dùng?
Khác gì so với stored procedure và constraint?
Điểm cần nhớ trong 10 giây:
Cùng một ý tưởng, nhưng cú pháp và khả năng khác nhau theo hệ quản trị. Dưới đây là tóm lược thực dụng.
PostgreSQL (dùng hàm trigger):
-- Bảng ví dụ
create table account (
id bigserial primary key,
email text not null unique,
updated_at timestamptz not null default now()
);
-- Hàm trigger (PL/pgSQL)
create or replace function trg_set_updated_at()
returns trigger as $$
begin
new.updated_at := now();
return new;
end;
$$ language plpgsql;
-- Trigger BEFORE UPDATE trên mỗi dòng
create trigger account_set_updated_at
before update on account
for each row
execute function trg_set_updated_at();
MySQL (trigger inline, không cần hàm riêng):
create table account (
id bigint primary key auto_increment,
email varchar(255) not null unique,
updated_at timestamp not null default current_timestamp
on update current_timestamp
);
-- Ví dụ BEFORE INSERT chuẩn hóa email
delimiter $$
create trigger account_norm_email
before insert on account
for each row
begin
set new.email = lower(new.email);
end$$
delimiter ;
SQL Server (T-SQL):
create table dbo.Account (
Id bigint identity primary key,
Email nvarchar(255) not null unique,
UpdatedAt datetime2 not null constraint df_account_updated default sysutcdatetime()
);
go
create trigger dbo.Account_SetUpdatedAt
on dbo.Account
after update
as
begin
set nocount on;
update a
set UpdatedAt = sysutcdatetime()
from dbo.Account a
inner join inserted i on a.Id = i.Id;
end;
go
Oracle (PL/SQL khái quát):
create table account (
id number generated always as identity primary key,
email varchar2(255) not null unique,
updated_at timestamp default current_timestamp
);
create or replace trigger account_set_updated_at
before insert or update on account
for each row
begin
:new.updated_at := current_timestamp;
end;
/
Lưu ý khác biệt đáng kể:
Tình huống phù hợp:
Tình huống không nên dùng (hoặc cân nhắc kỹ):
Nguyên tắc vàng: nếu constraint có thể giải quyết, hãy dùng constraint. Nếu cần trigger, giữ logic tối thiểu, rõ ràng và dễ kiểm thử.
Mục tiêu: lưu lại lịch sử thay đổi cho bảng orders, gồm thao tác, ai thực hiện và giá trị trước sau.
PostgreSQL với JSONB để tiện phân tích:
create table orders (
id bigserial primary key,
customer_id bigint not null,
status text not null,
total numeric(12,2) not null check (total >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table orders_audit (
audit_id bigserial primary key,
order_id bigint not null,
action text not null,
changed_by text,
changed_at timestamptz not null default now(),
old_row jsonb,
new_row jsonb
);
create or replace function trg_orders_audit()
returns trigger as $$
declare
v_user text := current_user;
begin
if tg_op = 'INSERT' then
insert into orders_audit(order_id, action, changed_by, old_row, new_row)
values (new.id, 'INSERT', v_user, null, to_jsonb(new));
return new;
elsif tg_op = 'UPDATE' then
-- chỉ ghi khi có thay đổi thực sự
if row_to_json(old) is distinct from row_to_json(new) then
insert into orders_audit(order_id, action, changed_by, old_row, new_row)
values (new.id, 'UPDATE', v_user, to_jsonb(old), to_jsonb(new));
end if;
return new;
elsif tg_op = 'DELETE' then
insert into orders_audit(order_id, action, changed_by, old_row, new_row)
values (old.id, 'DELETE', v_user, to_jsonb(old), null);
return old;
end if;
return null;
end;
$$ language plpgsql;
create trigger orders_audit_trg
after insert or update or delete on orders
for each row execute function trg_orders_audit();
Điểm hay:
MySQL tương tự, dùng JSON và user():
create table orders (
id bigint primary key auto_increment,
customer_id bigint not null,
status varchar(30) not null,
total decimal(12,2) not null,
created_at timestamp not null default current_timestamp,
updated_at timestamp not null default current_timestamp on update current_timestamp
);
create table orders_audit (
audit_id bigint primary key auto_increment,
order_id bigint not null,
action varchar(10) not null,
changed_by varchar(255),
changed_at timestamp not null default current_timestamp,
old_row json,
new_row json
);
delimiter $$
create trigger orders_audit_ai
after insert on orders
for each row
begin
insert into orders_audit(order_id, action, changed_by, old_row, new_row)
values (new.id, 'INSERT', user(), null, json_object('id', new.id, 'customer_id', new.customer_id, 'status', new.status, 'total', new.total));
end$$
delimiter $$
create trigger orders_audit_au
after update on orders
for each row
begin
if not (old.status <=> new.status and old.total <=> new.total and old.customer_id <=> new.customer_id) then
insert into orders_audit(order_id, action, changed_by, old_row, new_row)
values (
new.id, 'UPDATE', user(),
json_object('id', old.id, 'customer_id', old.customer_id, 'status', old.status, 'total', old.total),
json_object('id', new.id, 'customer_id', new.customer_id, 'status', new.status, 'total', new.total)
);
end if;
end$$
delimiter ;
Mẹo:
Bài toán: khi tạo order_item, cần trừ tồn kho và không cho vượt quá tồn hiện tại, an toàn trong môi trường đồng thời.
Phác thảo với PostgreSQL, dùng khóa hàng với select for update:
create table products (
id bigserial primary key,
sku text unique not null,
stock int not null check (stock >= 0)
);
create table order_items (
id bigserial primary key,
product_id bigint not null references products(id),
qty int not null check (qty > 0)
);
create or replace function trg_reserve_stock()
returns trigger as $$
declare
v_stock int;
begin
-- khóa dòng sản phẩm để tránh race condition
select stock into v_stock from products where id = new.product_id for update;
if v_stock is null then
raise exception 'Product not found: %', new.product_id;
end if;
if v_stock < new.qty then
raise exception 'Insufficient stock. need: %, available: %', new.qty, v_stock;
end if;
update products set stock = stock - new.qty where id = new.product_id;
return new;
end;
$$ language plpgsql;
create trigger order_items_reserve
before insert on order_items
for each row execute function trg_reserve_stock();
Tại sao BEFORE? Vì ta muốn chặn ngay việc chèn khi không đủ tồn.
Lưu ý quan trọng:
SQL Server cùng ý tưởng, dùng update set-based trong trigger để xử lý batch:
create trigger dbo.OrderItems_Reserve
on dbo.OrderItems
instead of insert
as
begin
set nocount on;
-- khóa và kiểm tra đủ tồn
if exists (
select 1
from inserted i
join dbo.Products p with (updlock, rowlock)
on p.Id = i.ProductId
where p.Stock < i.Qty
)
begin
raiserror('Insufficient stock for one or more items', 16, 1);
rollback transaction;
return;
end
update p
set p.Stock = p.Stock - i.Qty
from dbo.Products p
join inserted i on p.Id = i.ProductId;
insert into dbo.OrderItems(ProductId, Qty)
select ProductId, Qty from inserted;
end;
Điểm rút ra: với batch insert, dùng statement-level hoặc instead of để chặn đồng loạt thay vì lặp từng dòng.
Bài toán: đảm bảo email luôn ở dạng chữ thường và loại bỏ khoảng trắng, đồng thời tạo slug từ tên.
PostgreSQL:
create extension if not exists unaccent;
create table authors (
id bigserial primary key,
full_name text not null,
slug text unique,
email text not null unique
);
create or replace function f_to_slug(txt text) returns text as $$
select regexp_replace(lower(unaccent($1)), '[^a-z0-9]+', '-', 'g');
$$ language sql immutable;
create or replace function trg_authors_normalize()
returns trigger as $$
begin
new.email := lower(trim(new.email));
if new.slug is null or new.slug = '' then
new.slug := trim(both '-' from f_to_slug(new.full_name));
end if;
return new;
end; $$ language plpgsql;
create trigger authors_norm
before insert or update on authors
for each row execute function trg_authors_normalize();
MySQL có thể dùng generated column thay vì trigger nếu quy tắc đơn giản:
create table authors (
id bigint primary key auto_increment,
full_name varchar(255) not null,
slug varchar(255) as (regexp_replace(lower(full_name), '[^a-z0-9]+', '-')) persisted unique,
email varchar(255) not null unique
);
Khi nào chọn trigger, khi nào chọn generated/check?
Kết luận thực dụng: dùng trigger cho ràng buộc dữ liệu và tự động hóa nhẹ trong cùng giao dịch; dùng outbox/CDC cho tích hợp với hệ thống ngoài và tác vụ tốn thời gian.
Ví dụ cờ canh gác trong PostgreSQL:
create or replace function trg_safe_update()
returns trigger as $$
declare
v_flag text;
begin
begin
v_flag := current_setting('app.trg_guard', true);
exception when others then
v_flag := null;
end;
if v_flag = 'on' then
return new; -- bỏ qua nếu đã chạy
end if;
perform set_config('app.trg_guard', 'on', true);
-- logic chính ở đây
perform set_config('app.trg_guard', 'off', true);
return new;
end; $$ language plpgsql;
Mẹo nhớ 5 chữ: Sự kiện, Thời điểm, Phạm vi, An toàn, Đo đạc.
Trigger không chỉ là kỹ thuật; đó là cách bạn định nghĩa hợp đồng dữ liệu: mọi thay đổi phải tôn trọng quy tắc cốt lõi, bất kể đến từ dịch vụ nào. Khi thiết kế với tinh thần tối giản và có đo đạc, trigger giúp hệ thống bền bỉ hơn trước lỗi con người và drift của ứng dụng. Nhưng giống mọi công cụ mạnh, lạm dụng sẽ phản tác dụng: logic rối rắm, hiệu năng bất ổn, khó debug. Bí quyết nằm ở ranh giới hợp lý: để những quy tắc bất biến ở DB, đẩy quy trình linh hoạt về tầng ứng dụng, và kết nối hai thế giới bằng các mẫu như outbox, CDC, materialized view.
Nếu bạn chỉ có 10 phút, hãy ghi nhớ: dùng trigger cho điều bắt buộc, ngắn gọn, quyết định; kiểm thử kỹ; giám sát chặt. Phần còn lại, để ứng dụng và pipeline dữ liệu gánh vác. Khi đó, trigger không còn là bí ẩn khó kiểm soát, mà trở thành lớp bảo vệ kín đáo nhưng hiệu quả cho dữ liệu của bạn.