SQL触发器原理与生产级实践:保障数据强一致性
1. 为什么你今天必须真正搞懂 SQL Triggers——不是为了面试,而是为了明天上线的订单系统不崩
SQL Triggers(触发器)这个词,在很多开发者的认知里,大概率还停留在“数据库课上老师提过”“面试被问过但答得模棱两可”“线上出过一次诡异数据不一致,DBA说可能是触发器没关”这种模糊印象里。但现实是:你在写一个电商订单状态流转服务时,订单表更新后要自动同步库存、生成物流单、通知风控系统、记录审计日志——这四个动作,如果全靠应用层代码串行调用,一旦某一步失败,整个事务就卡在中间态;而用 Trigger 就能确保“只要订单状态变了,审计日志一定落库”,哪怕应用服务宕机、网络抖动、甚至你写的 Java 方法抛了空指针,数据库自己会兜底执行。这不是理论优势,是我在三年内经手的7个高并发交易系统里,唯一被 DBA 和架构师共同签字保留的数据库原生能力。它不解决所有问题,但解决的是“强一致性保障”这个最硬的骨头。适合谁?不是只给 DBA 看的——是给所有要写业务逻辑、要对数据准确性负责、要和 MySQL/PostgreSQL/SQL Server 打交道的后端开发者、数据工程师、甚至测试同学看的。它不难,但容易错;它强大,但滥用即灾难。这篇不是语法手册,是我把过去踩过的12个生产级坑、3次半夜被叫醒排查的血泪教训、以及5套已上线系统的触发器设计模式,全部摊开讲透。
2. 触发器不是“自动执行的存储过程”,它的本质是数据库的“事件监听器+原子化钩子”
2.1 核心设计哲学:为什么数据库要内置 Trigger 而不是全靠应用层?
很多人第一反应是:“我用 Spring 的 @Transactional + AOP 不就能做一样的事?”——这是最大的认知偏差。Trigger 的不可替代性,根植于它的执行位置和事务边界。我们来拆解一个真实场景:用户支付成功,订单表orders的status字段从'pending'更新为'paid'。此时需要:
- 向
inventory表扣减商品库存; - 向
logistics_orders表插入一条待发货单; - 向
audit_logs表写入操作记录; - 向
risk_events表发送一条风控信号。
如果全在应用层做,典型流程是:
@Transactional public void updateOrderStatus(Long orderId) { Order order = orderMapper.selectById(orderId); order.setStatus("paid"); orderMapper.updateById(order); // Step 1: 更新订单 inventoryService.deductStock(order.getItemId(), order.getQuantity()); // Step 2: 扣库存 logisticsService.createShipment(order); // Step 3: 创建物流单 auditLogService.record(order, "paid"); // Step 4: 记录审计日志 riskService.sendSignal(order); // Step 5: 发送风控信号 }表面看没问题,但风险点极多:
- Step 2 失败(库存不足),事务回滚,Step 1 也撤销,订单状态不变——看起来安全;
- 但 Step 4 或 Step 5 如果是异步消息(如 Kafka),它们可能已经发出,而事务最终回滚了,导致下游系统收到“已支付”信号,但订单实际未生效;
- 更致命的是:如果 Step 2 成功,Step 3 因网络超时失败,事务回滚,库存已扣,订单没变,形成“幽灵扣减”——这是资损事故的温床。
而 Trigger 的解法是:把 Step 4(审计日志)这个强依赖、弱耦合的动作,直接绑定到orders表的UPDATE事件上,并且和主 UPDATE 语句共享同一个事务上下文。数据库引擎在执行UPDATE orders SET status='paid' WHERE id=123时,内部流程是:
- 获取行锁,读取旧值(old.status = 'pending');
- 写入新值(new.status = 'paid');
- 检查是否有针对
orders表AFTER UPDATE的 Trigger; - 若存在,立即在同一个事务中、同一连接内、同一隔离级别下,执行 Trigger 定义的 SQL(如
INSERT INTO audit_logs (...) VALUES (...)); - 只有 Trigger 执行成功,整个 UPDATE 事务才提交;任一环节失败(包括 Trigger 内部 SQL 报错),整个事务回滚。
提示:这就是 Trigger 的“原子性”核心——它不是“额外启动一个任务”,而是数据库 DML 操作生命周期的天然组成部分。你无法绕过它,也无法在它执行中途中断它。这种确定性,是任何应用层 AOP、消息队列、定时任务都无法提供的。
2.2 三类触发时机的本质差异:BEFORE / AFTER / INSTEAD OF,选错就是埋雷
几乎所有教程都告诉你“BEFORE 用于校验,AFTER 用于记录”,但真实项目里,90% 的误用源于对INSTEAD OF的无知和对BEFORE的滥用。我们逐个击破:
BEFORE INSERT/UPDATE/DELETE:
执行时机:在 DML 语句实际修改数据前,但已获取锁、已解析 SQL、已确定影响行集。
关键能力:可以修改NEW行的字段值(如自动生成 UUID、设置默认时间戳、强制转换大小写)。
经典误用:有人用BEFORE UPDATE去调用外部 HTTP 接口做风控校验——这是灾难。因为 Trigger 执行期间,行锁未释放,HTTP 超时 5 秒,等于整张表该行被锁死 5 秒,QPS 直接归零。注意:
BEFORE中禁止执行SELECT ... FOR UPDATE或任何可能引发死锁的查询;它只应做轻量、确定、无副作用的字段修正。AFTER INSERT/UPDATE/DELETE:
执行时机:在 DML 语句成功写入数据后、事务提交前。
关键能力:可以安全访问OLD和NEW的完整快照(如OLD.amount,NEW.amount),适合做审计、统计、跨表同步。
黄金法则:所有需要“基于新数据做衍生操作”的场景,必须用AFTER。例如:订单金额变更后,更新用户累计消费总额;用户邮箱更新后,向通知中心推送变更事件。提示:
AFTER中可以执行复杂 SQL,但严禁调用存储过程做耗时操作(如文件写入、邮件发送),否则拖慢主事务。INSTEAD OF(仅视图支持):
执行时机:完全替代原本对视图的 DML 操作。
核心价值:让“不可更新的视图”变得可更新。例如,你有一个联合users和profiles表的视图user_full_view,默认UPDATE user_full_view SET name='xxx'会报错,因为数据库不知道该更新哪个基表。此时定义INSTEAD OF UPDATETrigger,明确告诉数据库:“当更新视图的 name 字段时,请更新users表;当更新 bio 字段时,请更新profiles表”。注意:这是高级用法,80% 的项目用不到,但一旦需要,就是救命稻草。切记:
INSTEAD OF只存在于视图上,表不支持。
2.3 触发器的“隐形成本”:性能、调试、可维护性,三座大山怎么翻?
Trigger 最常被诟病的,是它像“黑盒”一样藏在数据库里,应用层看不见、日志里不体现、监控里难追踪。但这不是 Trigger 的原罪,而是使用方式的问题。我们用真实数据说话:
性能损耗实测(MySQL 8.0,SSD,16核32G):
- 单行
INSERT无 Trigger:平均耗时 0.12ms; - 同样
INSERT+ 一个AFTER INSERTTrigger(仅INSERT INTO logs一条语句):平均耗时 0.28ms; - 增加到 3 个 Trigger(审计+统计+通知):平均耗时 0.65ms;
- 关键发现:损耗不是线性增长,而是随 Trigger 数量呈轻微指数增长——第 4 个 Trigger 会让耗时跳到 1.3ms。原因是每个 Trigger 都要重新解析执行计划、申请独立的内存上下文。
- 单行
调试地狱的真实案例:
某次线上订单创建失败,错误日志只显示ERROR 1452: Cannot add or update a child row: a foreign key constraint fails。排查三天,最后发现是orders表的BEFORE INSERTTrigger 里,有一行SET NEW.user_id = (SELECT id FROM users WHERE email = NEW.email),而users表恰好被另一个团队删了一条测试数据,导致子查询返回 NULL,进而触发外键约束失败。Trigger 的错误,会伪装成主 SQL 的错误,且堆栈不包含 Trigger 名称。可维护性陷阱:
我见过最离谱的案例:一个金融系统里,transactions表有 7 个 Trigger,分别叫trg_tx_audit、trg_tx_risk、trg_tx_balance……但没人知道trg_tx_balance是更新accounts表还是ledgers表。更糟的是,其中一个 Trigger 的 SQL 里硬编码了INSERT INTO accounts (balance) VALUES (NEW.amount * 1.05),税率写死,两年后税务政策调整,全库数据错乱。
实操心得:Trigger 必须像代码一样管理——写注释(
COMMENT ON TRIGGER ... IS '记录订单支付成功后的审计日志,关联 order_id, amount, timestamp')、版本化(把 Trigger DDL 放进 Flyway/Liquibase 脚本)、单元测试(用mysqldump --no-data导出结构,用测试数据跑一遍验证逻辑)。把它当成核心业务逻辑,而不是“数据库小技巧”。
3. 从零搭建一个生产级订单审计触发器:参数、语法、避坑全链路
3.1 场景锁定:我们要做什么?为什么必须用 Trigger?
目标:当orders表的status字段从'created'变更为'paid'时,必须向audit_logs表插入一条不可篡改的记录,包含:订单 ID、操作类型('ORDER_PAID')、操作人(来自orders.created_by字段)、操作时间(数据库当前时间)、变更前状态、变更后状态、IP 地址(来自应用层传入的orders.client_ip字段)。
为什么不用应用层?
- 审计合规要求:日志必须与数据变更原子一致,不能有“日志写了但订单没更新”或反之;
- 多入口风险:订单可能通过 Web、App、后台管理、甚至线下导入多种方式创建,应用层无法统一拦截所有入口;
- DBA 强制要求:所有敏感状态变更必须留痕,且日志表由 DBA 管理,应用无写权限。
3.2 语法精解:以 PostgreSQL 为例(兼容性说明附后)
PostgreSQL 的 Trigger 语法最清晰,我们以此为蓝本,再说明 MySQL/SQL Server 差异:
-- Step 1: 创建触发器函数(注意:函数必须返回 trigger 类型) CREATE OR REPLACE FUNCTION log_order_paid() RETURNS TRIGGER AS $$ BEGIN -- 核心逻辑:只在 status 从 'created' 变为 'paid' 时记录 IF TG_OP = 'UPDATE' AND OLD.status = 'created' AND NEW.status = 'paid' THEN INSERT INTO audit_logs ( event_type, target_id, operator_id, ip_address, old_value, new_value, created_at ) VALUES ( 'ORDER_PAID', NEW.id, NEW.created_by, NEW.client_ip, OLD.status, NEW.status, NOW() ); END IF; -- 关键!必须返回 NEW(对 UPDATE/INSERT)或 OLD(对 DELETE) -- 否则 Trigger 会中断主操作 RETURN NEW; END; $$ LANGUAGE plpgsql; -- Step 2: 在 orders 表上绑定触发器 CREATE TRIGGER trg_orders_status_paid AFTER UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION log_order_paid();逐行拆解关键点:
TG_OP是 PostgreSQL 内置变量,值为'INSERT'/'UPDATE'/'DELETE',必须判断,否则INSERT时也会执行OLD.status(会报错);OLD和NEW是行级变量,代表变更前/后的整行数据,可直接点字段(OLD.status,NEW.id);RETURN NEW是强制要求:AFTERTrigger 必须返回NEW(表示继续)或NULL(表示取消,但AFTER不允许NULL,会报错);BEFORETrigger 返回NULL则跳过本次 DML;FOR EACH ROW表示按行触发(而非FOR EACH STATEMENT,后者只触发一次,OLD/NEW不可用);- 函数体用
$$包裹,避免与 SQL 内单引号冲突。
MySQL 8.0 等效写法(语法差异巨大):
DELIMITER $$ CREATE TRIGGER trg_orders_status_paid AFTER UPDATE ON orders FOR EACH ROW BEGIN IF OLD.status = 'created' AND NEW.status = 'paid' THEN INSERT INTO audit_logs ( event_type, target_id, operator_id, ip_address, old_value, new_value, created_at ) VALUES ( 'ORDER_PAID', NEW.id, NEW.created_by, NEW.client_ip, OLD.status, NEW.status, NOW() ); END IF; END$$ DELIMITER ;- 关键区别:MySQL 没有“触发器函数”概念,逻辑直接写在
CREATE TRIGGER里; - 必须用
DELIMITER $$临时修改语句结束符,否则;会被解释为CREATE TRIGGER结束,导致语法错误; BEGIN...END块内不能有SELECT(除非赋值给变量),但INSERT/UPDATE/DELETE可以。
SQL Server 等效写法(T-SQL 风格):
CREATE TRIGGER trg_orders_status_paid ON orders AFTER UPDATE AS BEGIN SET NOCOUNT ON; -- 关键!禁用影响行数消息,避免干扰应用 INSERT INTO audit_logs ( event_type, target_id, operator_id, ip_address, old_value, new_value, created_at ) SELECT 'ORDER_PAID', i.id, i.created_by, i.client_ip, d.status, -- deleted 表存旧值 i.status, -- inserted 表存新值 GETDATE() FROM inserted i INNER JOIN deleted d ON i.id = d.id WHERE d.status = 'created' AND i.status = 'paid'; END;- 核心机制不同:SQL Server 用
inserted和deleted两个内存临时表分别存新/旧值,必须JOIN关联; SET NOCOUNT ON是铁律,否则每条INSERT都返回(1 row affected),应用层可能解析失败。
3.3 参数化与复用:如何让一个 Trigger 适配多张表?
硬编码表名和字段是维护噩梦。解决方案:用通用审计 Trigger + JSON 字段。我们在audit_logs表增加target_table和target_data字段:
ALTER TABLE audit_logs ADD COLUMN target_table VARCHAR(64), ADD COLUMN target_data JSONB; -- PostgreSQL -- MySQL 用 JSON 类型,SQL Server 用 NVARCHAR(MAX) 存 JSON然后改造 Trigger 函数:
CREATE OR REPLACE FUNCTION log_generic_change() RETURNS TRIGGER AS $$ DECLARE v_event_type TEXT; v_old_json JSONB; v_new_json JSONB; BEGIN -- 动态生成 event_type,如 'ORDERS_STATUS_CHANGE' v_event_type := UPPER(TG_TABLE_NAME) || '_STATUS_CHANGE'; -- 将 OLD 和 NEW 行转为 JSON(PostgreSQL 12+ 支持 row_to_json) v_old_json := to_jsonb(OLD); v_new_json := to_jsonb(NEW); INSERT INTO audit_logs ( event_type, target_table, target_id, target_data, created_at ) VALUES ( v_event_type, TG_TABLE_NAME, COALESCE(NEW.id, OLD.id), -- 兼容 INSERT/UPDATE/DELETE JSONB_BUILD_OBJECT( 'old', v_old_json, 'new', v_new_json ), NOW() ); RETURN NEW; END; $$ LANGUAGE plpgsql;这样,一个函数就能服务orders、users、products所有表。应用层查日志时,用target_data->'new'->>'status'提取状态,完全解耦。
实操心得:永远不要在 Trigger 里写
SELECT COUNT(*) FROM huge_table。我曾在线上看到一个 Trigger 因为SELECT MAX(id) FROM orders_history(亿级表)导致主事务卡住 8 秒。正确做法是:把这类聚合计算移到异步任务,Trigger 只负责发一条轻量消息(如INSERT INTO async_tasks (type, payload) VALUES ('RECALC_STATS', '{"table":"orders"}')),由后台 Worker 消费。
4. 生产环境避坑指南:12个血泪教训总结成的速查表
4.1 触发器失效的5种隐性原因(比语法错误更可怕)
| 问题现象 | 根本原因 | 排查命令(PostgreSQL) | 解决方案 |
|---|---|---|---|
| Trigger 完全不执行 | 表被TRUNCATE清空 | SELECT tgname, tgenabled FROM pg_trigger WHERE tgrelid = 'orders'::regclass; | TRUNCATE默认禁用 Trigger,加CONTINUE IDENTITY CASCADE选项或改用DELETE |
OLD字段为空 | 对INSERT操作误用了OLD | SELECT tgname, tgtype FROM pg_trigger WHERE tgrelid = 'orders'::regclass; | 检查tgtype,INSERT的 Trigger 只有NEW,DELETE只有OLD |
| 触发器执行但日志没写入 | audit_logs表空间满或磁盘只读 | SELECT pg_size_pretty(pg_database_size(current_database()));+df -h | 监控表空间,设置自动清理策略 |
NEW.id为 NULL | 主键是SERIAL但 Trigger 在BEFORE INSERT中读取 | SELECT column_default FROM information_schema.columns WHERE table_name='orders' AND column_name='id'; | BEFORE INSERT中NEW.id还未生成,需用nextval()显式获取 |
| 触发器报错但主 SQL 成功 | 错误被EXCEPTION块捕获且未RAISE | SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = 'log_order_paid'; | Trigger 函数内EXCEPTION必须RAISE EXCEPTION,不能静默吞掉 |
4.2 并发与死锁:当两个 Trigger 同时想更新同一张表
经典场景:orders表的AFTER UPDATETrigger 更新user_stats表;同时user_stats表的AFTER UPDATETrigger 又想更新orders表。这就形成了循环依赖,高并发下必死锁。
实测复现步骤(PostgreSQL):
- Session A:
BEGIN; UPDATE orders SET status='paid' WHERE id=1;(持有 orders 行锁) - Session B:
BEGIN; UPDATE user_stats SET total_orders=total_orders+1 WHERE user_id=100;(持有 user_stats 行锁) - Session A: Trigger 执行
UPDATE user_stats ...→ 等待 Session B 的锁 - Session B: Trigger 执行
UPDATE orders ...→ 等待 Session A 的锁
→ 死锁检测器介入,强制回滚一个事务。
破解方案(三选一):
- 物理拆分:把
user_stats.total_orders改为实时计算(SELECT COUNT(*) FROM orders WHERE user_id=100),用物化视图或缓存; - 异步解耦:Trigger 不直接
UPDATE,而是INSERT INTO async_queue (task_type, payload) VALUES ('UPDATE_USER_STATS', '{"user_id":100}'); - 锁顺序约定:所有 Trigger 更新表时,严格按字母序(
audit_logs→user_stats→orders),避免循环。
提示:用
pg_stat_activity查看锁等待:SELECT pid, wait_event_type, wait_event, state FROM pg_stat_activity WHERE wait_event IS NOT NULL;
4.3 版本升级与迁移:MySQL 5.7 升 8.0 后 Trigger 全挂了?
MySQL 8.0 加入了严格的 SQL 模式(STRICT_TRANS_TABLES),导致大量老 Trigger 报错。典型案例如下:
MySQL 5.7 可运行的 Trigger:
INSERT INTO audit_logs (ip_address) VALUES (NEW.client_ip); -- 如果 NEW.client_ip 为 NULL,MySQL 5.7 会静默插入 NULLMySQL 8.0 报错:ERROR 1048: Column 'ip_address' cannot be null
原因:8.0 默认开启严格模式,NULL插入非空字段直接失败。
修复方案(二选一):
- 方案 A(推荐):在 Trigger 中显式处理 NULL
INSERT INTO audit_logs (ip_address) VALUES (COALESCE(NEW.client_ip, '0.0.0.0')); - 方案 B:关闭严格模式(不推荐,治标不治本)
SET sql_mode=(SELECT REPLACE(@@sql_mode,'STRICT_TRANS_TABLES',''));
4.4 安全红线:绝对禁止在 Trigger 中做的3件事
调用外部 HTTP 接口
- 风险:网络超时阻塞事务,数据库连接池耗尽;
- 替代方案:写入消息队列(如
INSERT INTO kafka_outbox (topic, payload) VALUES ('order.paid', '{"id":123}')),由独立服务消费。
执行
SELECT ... FOR UPDATE锁其他表- 风险:极易引发跨表死锁,且锁持有时间不可控;
- 替代方案:用
INSERT ... ON CONFLICT DO UPDATE实现乐观锁,或把锁逻辑移到应用层。
修改 Trigger 自身所依附的表(递归触发)
- 风险:无限递归,耗尽栈空间,数据库崩溃;
- MySQL 有
max_sp_recursion_depth限制,但 PostgreSQL 默认不限制,极其危险; - 防御:在 Trigger 开头加标志位检查(如
IF NOT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='trigger' AND relation='orders') THEN ...),但最稳妥的是设计上杜绝。
5. 高阶实战:用 Trigger 构建实时数据管道,替代 70% 的 ETL 任务
5.1 场景升级:从“单表审计”到“跨库同步”,Trigger 如何成为 CDC(变更数据捕获)基石?
传统 ETL 流程:定时(如每5分钟)SELECT * FROM orders WHERE updated_at > last_run_time→ 写入 Kafka → 消费入库。问题:延迟高、漏数据(updated_at被人工修改)、全量扫描压力大。
Trigger 方案:在源库orders表上建AFTER INSERT/UPDATE/DELETETrigger,将变更事件实时写入一张专用的cdc_events表:
CREATE TABLE cdc_events ( id SERIAL PRIMARY KEY, table_name VARCHAR(64) NOT NULL, operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), record_id BIGINT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), processed BOOLEAN DEFAULT FALSE ); -- Trigger 函数示例(PostgreSQL) CREATE OR REPLACE FUNCTION capture_cdc_event() RETURNS TRIGGER AS $$ DECLARE v_payload JSONB; BEGIN IF TG_OP = 'INSERT' THEN v_payload := to_jsonb(NEW); ELSIF TG_OP = 'UPDATE' THEN v_payload := JSONB_BUILD_OBJECT('old', to_jsonb(OLD), 'new', to_jsonb(NEW)); ELSIF TG_OP = 'DELETE' THEN v_payload := to_jsonb(OLD); END IF; INSERT INTO cdc_events (table_name, operation, record_id, payload) VALUES (TG_TABLE_NAME, SUBSTR(TG_OP,1,1), COALESCE(NEW.id, OLD.id), v_payload); RETURN NEW; END; $$ LANGUAGE plpgsql;优势对比:
| 维度 | 传统定时 ETL | Trigger CDC |
|---|---|---|
| 延迟 | 分钟级 | 毫秒级(事务提交即写入) |
| 准确性 | 依赖updated_at,易漏 | 100% 捕获所有 DML |
| 资源消耗 | 全表扫描,IO 压力大 | 单行插入,负载恒定 |
| 实现复杂度 | 应用层需维护调度、断点续传 | 数据库内完成,应用无感 |
5.2 实时风控联动:当 Trigger 遇上规则引擎
某支付系统要求:单笔订单金额 > 5000 元,且用户 24 小时内累计支付 > 20000 元,需触发人工审核。应用层实现需:
- 每次支付后查
orders表求和 → 网络 IO; - 缓存
user_daily_total→ 缓存穿透、一致性难题; - 规则变更要改代码 → 发布周期长。
Trigger + Redis 方案:
AFTER INSERTTrigger 检查NEW.amount > 5000;- 若满足,执行
SELECT redis_call('INCRBY', 'user_daily_total:'||NEW.user_id, NEW.amount)(PostgreSQL 的redis_fdw外部数据包装); - 同时
SELECT redis_call('GET', 'user_daily_total:'||NEW.user_id),若结果 > 20000,则INSERT INTO review_queue (order_id, reason) VALUES (NEW.id, 'high_risk_payment')。
效果:规则写在 Trigger SQL 里,DBA 可随时CREATE OR REPLACE FUNCTION更新,无需重启应用,毫秒级生效。
5.3 监控与可观测性:让 Trigger 从“黑盒”变成“仪表盘”
没有监控的 Trigger 就是定时炸弹。我们给它装上“行车记录仪”:
- 执行耗时监控:在 Trigger 函数开头
v_start := clock_timestamp();,结尾INSERT INTO trigger_metrics (name, duration_ms) VALUES (TG_NAME, EXTRACT(EPOCH FROM (clock_timestamp() - v_start)) * 1000);; - 错误率告警:建视图
SELECT trigger_name, COUNT(*) FILTER (WHERE error IS NOT NULL) * 100.0 / COUNT(*) AS error_rate FROM trigger_logs GROUP BY trigger_name;,接入 Prometheus; - 调用量大盘:
SELECT tgname, n_tup_ins+n_tup_upd+n_tup_del AS total_calls FROM pg_stat_user_tables JOIN pg_trigger ON pg_stat_user_tables.relid = pg_trigger.tgrelid;
最后分享一个小技巧:在所有 Trigger 的
INSERT INTO audit_logs语句后,加一行PERFORM pg_notify('trigger_event', json_build_object('trigger', TG_NAME, 'table', TG_TABLE_NAME)::text);。应用层用LISTEN trigger_event就能实时收到数据库事件,实现真正的双向通信。这是我目前用得最顺手的“数据库主动通知”方案,比轮询高效十倍。
