Cái đèn cảm biến chuyển động rất tiện: bạn không bao giờ phải nhớ bật nó. Nhưng lúc ba giờ sáng nó bỗng sáng lên, bạn chẳng biết vì sao — con mèo, cành cây, cơn gió — vì nguyên nhân nằm cách xa cái công tắc mà bạn sẽ đi tìm. Trigger là đoạn mã cảm biến chuyển động của cơ sở dữ liệu: 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í
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.
Mẫu số chung
Bốn bất ngờ ở trên có chung một gốc, và nó lớn hơn PostgreSQL: một hành vi kích hoạt ngầm mua được "không thể quên" bằng đúng cái giá "không nhìn thấy". Trigger chạy tự động nên nó bắt được cả những thay đổi lọt ngoài ứng dụng — đó là sức mạnh. Nhưng chính sự vô hình ấy khiến mọi ca lỗi của nó gần như không gỡ được: dữ liệu biến mất không báo, thứ tự nằm ẩn trong tên, một đường đi (TRUNCATE) bị bỏ sót lặng lẽ — vì lỗi luôn hiện ra ở thao tác bạn thấy (câu INSERT) còn nguyên nhân sống trong đoạn mã không ai nhìn vào.
Đúng cái "tác động từ xa" này xuất hiện ở mọi nơi có máy móc chạy sau lưng bạn:
- Trong Java/Spring, một
@Transactionalhay@Cacheabletrông như lời gọi phương thức bình thường, nhưng thật ra một proxy chạy mã bao quanh nó — và nó âm thầm không làm gì khi bạn tự gọi phương thức trong cùng lớp, hay khi phương thức làprivate. Đúng kiểu bất ngờ của trigger: hành vi vô hình cộng một ca ngoại lệ không báo lỗi. - Trong Go, cộng đồng cố ý khước từ loại phép màu này — không AOP, không nạp chồng toán tử — nhưng nó vẫn len vào qua
init()chạy trướcmainvới tác dụng phụ không ai thấy, hay mộtSetFinalizerchạy lúc nào không đoán được. Ngay cả ở đây, cái ngầm vẫn là cái khó lần. - Nói chung, mọi lifecycle hook của ORM (
@PrePersist), observer, middleware đều là cùng một đánh đổi: tiện vì tự chạy, nguy vì người đọc mã ở chỗ gọi không hề thấy chúng.
Nên câu hỏi đúng trước khi giao một việc cho máy móc chạy ngầm không phải "nó có tiện không" mà là "khi việc này hỏng, người gỡ lỗi có tìm ra nó ở chỗ họ sẽ nhìn không". Dành cái ngầm cho những mối quan tâm nhỏ, phổ quát, không được phép quên — dấu thời gian, nhật ký kiểm toán — và giữ logic thật ở chỗ tường minh nơi người đọc sẽ gặp. Muốn tự soi, nếu bảng có trigger mà 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.