Trigger là đoạn mã chạy tự động mỗi khi dữ liệu thay đổi. Tiện vì không thể quên, khó vì nó chạy vô hình. Phần này đo chi phí của nó và dựng lại bốn tình huống nó cho ra kết quả ngoài dự đoán.

Chi phí theo loại trigger và bốn chỗ gây bất ngờ

Chi phí

Chèn 1.000.000 dòng, mỗi lần đúng một loại trigger:

Cấu hình Thời gian WAL So với không có
Không có trigger 0,46 s 69 MB
1 trigger STATEMENT 0,40 s 69 MB miễn phí
1 trigger BEFORE, thân hàm rỗng 0,62 s 69 MB +35%
1 trigger BEFORE, gán một cột 0,77 s 77 MB +67%
1 trigger AFTER, ghi sang bảng khác 1,72 s 145 MB +274%

Con số đáng nhớ nhất là dòng thứ ba: một trigger không làm gì cả đã lấy 35% thời gian. Đó là chi phí gọi hàm một triệu lần — dựng ngữ cảnh plpgsql, cấp phát biến NEW, gọi, dọn. Chưa có một dòng logic nào.

Trigger STATEMENT thì miễn phí vì nó chạy một lần cho cả câu lệnh, không phải một lần cho mỗi dòng. Nếu việc bạn cần làm có thể gộp lại — ghi một dòng nhật ký "đã nhập 1 triệu dòng lúc 10h" thay vì một triệu dòng nhật ký — thì STATEMENT là lựa chọn rẻ hơn ba bậc.

Nhiều trigger thì cộng dồn tuyến tính, không có tối ưu nào:

0 trigger  0,57 s      3 trigger  0,83 s      6 trigger  1,27 s     (500.000 dòng)

Bất ngờ 1: BEFORE trả về NULL thì dòng biến mất

create function f_null() returns trigger as $$
begin
  if new.v % 2 = 0 then return null; end if;
  return new;
end $$ language plpgsql;

create trigger t1 before insert on tg for each row execute function f_null();
insert into tg select i, i, null from generate_series(1, 1000) i;

Kết quả: bảng có 500 dòng.

Lệnh báo thành công. Không lỗi, không cảnh báo, không gì cả. Một nửa dữ liệu biến mất.

Đây là hành vi có chủ đích — trả NULL từ trigger BEFORE là cách chính thức để huỷ thao tác. Nhưng nó cũng có nghĩa là một câu return thiếu trong nhánh nào đó của hàm sẽ âm thầm nuốt dữ liệu, vì plpgsql trả NULL mặc định khi hàm kết thúc mà không return.

Cách phòng: mọi trigger BEFORE nên kết thúc bằng return new; ở dòng cuối cùng, kể cả khi bạn nghĩ mọi nhánh đã có return.

Bất ngờ 2: thứ tự chạy theo tên, không theo lúc tạo

create trigger zz_nhan before insert on tg for each row execute function f_a();  -- v = v * 10
create trigger aa_cong before insert on tg for each row execute function f_b();  -- v = v + 1
insert into tg values (1, 5, null);

Với v = 5:

nếu chạy theo thứ tự tạo  ->  (5 * 10) + 1 = 51
nếu chạy theo tên         ->  (5 + 1) * 10 = 60
kết quả thật              ->  60

aa_cong chạy trước dù được tạo sau, vì a đứng trước z trong bảng chữ cái.

Với một trigger thì không sao. Với hai trigger cùng sửa một cột thì thứ tự quyết định kết quả — và không có gì trong mã nguồn của chúng nói ra thứ tự đó. Người đọc phải biết quy tắc này và phải so tên.

Quy ước thực dụng: đặt tên có tiền tố số khi thứ tự quan trọng — 10_chuan_hoa, 20_tinh_toan, 30_ghi_log.

Bất ngờ 3: COPY vẫn kích hoạt trigger theo dòng

COPY 100 dòng vào bảng có trigger AFTER
   bảng tg   100 dòng
   bảng log  100 dòng

Trigger chạy đủ 100 lần.

Điều này quan trọng vì phần 37 đã đo COPY đạt 813.008 dòng/giây — con số đó chỉ đúng với bảng không có trigger. Trên bảng có trigger AFTER ghi sang bảng khác, tốc độ rơi về khoảng phần ba, và mỗi dòng nạp vào sinh thêm một dòng ở bảng nhật ký.

Nếu bạn định nạp mười triệu dòng vào bảng có trigger kiểm toán, hãy tắt trigger trước rồi bật lại:

alter table tg disable trigger t1;
copy tg from '...' with (format csv);
alter table tg enable trigger t1;

Với điều kiện bạn chấp nhận rằng lần nạp đó không có trong nhật ký.

Bất ngờ 4: TRUNCATE không kích hoạt trigger DELETE

Một trigger kiểm toán gắn vào DELETE sẽ bỏ sót hoàn toàn một câu TRUNCATE. Bảng trống trơn mà nhật ký không có dòng nào.

TRUNCATE không xoá từng dòng — nó bỏ cả tệp dữ liệu và tạo tệp mới (đó cũng là lý do nó nhanh). Không có dòng nào bị xoá theo nghĩa của trigger, nên trigger DELETE không có gì để chạy.

Phải khai riêng:

create trigger t2 before truncate on tg
for each statement execute function f_tr();

Tôi kiểm chứng: có khai thì nó chạy, dòng nhật ký được ghi.

Đây là lỗ hổng kiểm toán thật. Nếu yêu cầu của bạn là "mọi lần dữ liệu bị xoá đều phải có vết", trigger DELETE một mình không đáp ứng được.

Khi nào nên và không nên

Nên dùng trigger khi:

Cập nhật updated_at. Logic một dòng, và điểm mạnh chính là không thể quên — kể cả khi ai đó chạy UPDATE bằng tay trong psql.

Ghi nhật ký kiểm toán. Cùng lý do: nó phải bắt được cả những thay đổi không đi qua ứng dụng.

Giữ đồng bộ dữ liệu dẫn xuất trong cùng một dòng, ví dụ cột tsvector cho tìm kiếm toàn văn (phần 16).

Không nên dùng trigger khi:

Kiểm tra giá trị đơn giản. Dùng CHECK — phần 39 đã đo nó gần như miễn phí, còn trigger rỗng đã tốn 35%. CHECK cũng hiện ra trong \d của bảng, còn trigger thì phải đi tìm hàm.

Logic nghiệp vụ nhiều bước. Trigger chạy vô hình: không xuất hiện trong mã ứng dụng, khó đặt điểm dừng, khó viết kiểm thử, và khi có lỗi thì dấu vết chỉ ra ở câu INSERT chứ không ở chỗ thật sự sai.

Gọi hệ thống ngoài. Trigger chạy bên trong giao dịch. Một lệnh gọi HTTP trong trigger nghĩa là giao dịch của bạn giữ khoá cho tới khi hệ thống kia trả lời — và nếu nó treo, phần 26 đã đo hậu quả. Cách đúng là ghi vào bảng hàng đợi rồi để tiến trình riêng xử lý (phần 27).

Thay cho cột sinh. Từ PostgreSQL 12 có cột sinh:

alter table tg add column tong numeric generated always as (gia * so_luong) stored;

Nó không tốn một lần gọi hàm nào, không bị vấn đề thứ tự, và hiện rõ trong định nghĩa bảng.

Chẩn đoán

Xem bảng nào đang có trigger gì:

select c.relname as bang,
       t.tgname as trigger,
       p.proname as ham,
       case t.tgtype::int & 1 when 1 then 'ROW' else 'STATEMENT' end as pham_vi,
       case t.tgtype::int & 2 when 2 then 'BEFORE' else 'AFTER' end as thoi_diem,
       t.tgenabled
from pg_trigger t
join pg_class c on c.oid = t.tgrelid
join pg_proc p on p.oid = t.tgfoid
where not t.tgisinternal
order by c.relname, t.tgname;

Cột tgname được sắp xếp theo đúng thứ tự chúng sẽ chạy — đó là cách kiểm tra bất ngờ số 2.

Cột tgenabled bằng D nghĩa là trigger đang bị tắt. Nếu ai đó tắt để nạp dữ liệu rồi quên bật lại, nó nằm im ở đó mà không có gì nhắc.

Thử ba mươi giây

Nếu bảng của bạn có trigger và bạn thấy ghi chậm:

explain (analyze) insert into <bảng> values (...);

EXPLAIN ANALYZE có dòng riêng cho trigger:

Trigger t1: time=12.345 calls=1000

Con số đó tách bạch với thời gian của chính câu INSERT. Nếu nó chiếm phần lớn tổng thời gian, bạn biết chính xác nên nhìn vào đâu — và câu hỏi tiếp theo là liệu việc đó có làm bằng CHECK, cột sinh, hay một hàng đợi được không.

Phần sau đo hàm trong cơ sở dữ liệu: plpgsql so với SQL thuần, và chi phí của việc gọi hàm một triệu lần.