1. 先搞清楚视图和触发器到底该摆在哪一层
MySQL视图和触发器是两样被严重误用的东西——前者被当成"查询封装神器"到处乱套,后者被写成"业务逻辑外挂"最后没人敢动。我在几个中等规模的项目里都踩过这个坑:视图套了三层之后一个报表查询慢了八倍,触发器里写了库存扣减结果大促当天死锁堆积。所以这一天的内容我不想按教材顺序讲语法,而是先讲清楚它们在架构里的位置,再往下拆细节。
先给个最简判断:MySQL视图解决的是**"同一份数据,不同人看到不同切面"的问题,它的价值在于权限隔离、字段脱敏和查询语义收敛,而不是性能优化;触发器解决的是"数据变更必须同时触发另一件事,且不允许应用层绕过"**的问题,它的价值在于强制性和不可规避性,代价是隐式逻辑、调试困难和性能不可控。这两样东西本质上都是把逻辑往数据库层下沉,下沉得越多,可观测性越差,这就是为什么很多团队在规范里直接写"禁止使用触发器"——不是因为触发器难用,而是因为用错了没人兜得住。
我个人的划线是这样的:视图在报表、对外接口、多租户数据隔离场景里可以放心用,但层级不超过两层;触发器只用于三类场景——审计留痕、跨表强一致性约束、历史数据不可篡改保护。订单状态流转、库存扣减这类业务规则,哪怕数据库层面能实现,也尽量放应用层,因为业务规则会变,而触发器改起来意味着停机、锁表、DBA 介入。这个判断标准比任何语法细节都重要,读者可以先把自己的项目对号入座,再决定往下读的时候重点关注哪部分。
提示:MySQL 8.0 在 2026 年仍然是绝大多数生产环境的主力版本,8.4 LTS 在新建项目里逐步铺开。本文所有语法在 8.0.x 和 8.4 上都通用,涉及版本差异的地方我会单独标出来。
2. MySQL视图:从建表语句到执行计划的全链路拆解
2.1 建视图时那三个容易被忽略的选项
大多数人建视图就是一句CREATE VIEW v AS SELECT ...,其余全靠默认值。但视图定义里有三个选项直接决定了它后面的行为,值得每次建之前想一下。
CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}] VIEW view_name [(column_list)] AS select_statement [WITH [CASCADED | LOCAL] CHECK OPTION]ALGORITHM是执行方式的选择,UNDEFINED表示交给优化器自己判断,只要视图定义能合并就合并(MERGE),不能合并就物化成临时表(TEMPTABLE)。MERGE的行为相当于把视图定义当成一个宏,展开到外层查询里一起优化,所以基表上的索引能用上,外层的WHERE条件也能下推。TEMPTABLE则是先把视图结果算出来存进一张内部临时表,再在外层查这张临时表——这张临时表上没有任何索引,外层条件也无法下推,数据量一大就是灾难。
column_list是显式指定视图列名。看着可有可无,但只要你的SELECT里出现表达式、函数或者同名列,就一定要写。因为 MySQL 要求视图的列名唯一,而SELECT a.id, b.id FROM ...这种写法会直接报Duplicate column name 'id'。我见过最典型的翻车是SELECT COUNT(*)这种没起别名的聚合列,建视图时报错,新手完全不知道错在哪。
WITH CHECK OPTION是防脏写的关键,下一节单独说。
2.2 可更新视图的边界与 WITH CHECK OPTION 的真实用途
视图能不能做INSERT/UPDATE/DELETE,MySQL 的判断规则相当机械:只要视图定义里出现了聚合函数、DISTINCT、GROUP BY、HAVING、UNION,或者FROM子句里带子查询、引用了不可更新的表,这个视图就只读。另外外连接里的可空侧一般也不能更新,因为一行视图记录可能对应零行基表记录,数据库根本不知道该插到哪。
真正实用的是可更新视图加检查约束这个组合。举个我做过的例子:一张用户表要做软删除,业务上只能看到有效数据。
CREATE OR REPLACE VIEW v_user_active AS SELECT id, user_name, phone, status, created_at FROM t_user WHERE status = 1 WITH CHECK OPTION;这个WITH CHECK OPTION的作用是:任何通过视图做的写入,结果都必须仍然满足status = 1,否则直接报CHECK OPTION failed。也就是说,你不能先从这个视图里查到 id,然后偷偷把它改成status = 0来绕过视图的过滤条件。少了这个选项,视图就只是个过滤窗口,写操作照样能把数据写出去,语义上是不闭合的。
CASCADED和LOCAL的区别在视图嵌套时才体现出来。默认是CASCADED,表示把这个视图以及它依赖的所有底层视图的条件全部检查一遍;LOCAL则只检查当前视图自己定义里的条件。我的经验是,除非你非常确定嵌套结构,一律用默认的CASCADED,逻辑更严格也更符合直觉。
注意:
WITH CHECK OPTION只在通过视图写入时生效,直接对基表操作它管不着,别指望它当约束用。真要强约束,还是得靠CHECK约束或者触发器。
2.3 视图性能的真相:MERGE 和 TEMPTABLE 的实测差距
这部分是我最想强调的。很多人以为"视图是虚拟表,不占空间,所以查询很快",前半句对,后半句完全错。视图不存储数据,但它的执行方式可能是全表物化。
判断一个视图走的是哪种算法,直接看EXPLAIN:
EXPLAIN SELECT * FROM v_user_active WHERE phone = '13800000000';如果EXPLAIN的输出里出现了DERIVED表,或者select_type里带<derived2>,说明视图被物化了;如果EXPLAIN显示的就是对t_user的直接访问,并且用上了idx_phone,说明走了MERGE。
我做过一组对照测试,在一张 200 万行的表上,把同一个查询分别写成基表查询、MERGE 视图、TEMPTABLE 视图:
| 写法 | 执行方式 | 单次耗时(典型值) | 是否用上基表索引 |
|---|---|---|---|
| 直接查基表 | 无视图 | 2ms | 是 |
| MERGE 视图 | 展开合并 | 2~3ms | 是 |
含GROUP BY的视图 | 强制 TEMPTABLE | 1.2s | 否 |
| 嵌套两层含聚合的视图 | 多层 TEMPTABLE | 4s 以上 | 否 |
差距是三个数量级。原因很直白:TEMPTABLE 意味着每次查询都要把视图定义里的完整结果集算出来,哪怕你外层只想要一行。而且这个临时表没有索引,外层WHERE只能在这堆数据上再扫一遍。
所以建视图前先问自己一句:这个视图定义里有没有GROUP BY、DISTINCT、UNION、聚合函数或者子查询?有的话它就是只能物化的,那就别在它上面套高并发的实时查询,改成定时任务刷新实体表更靠谱。真正适合做成视图的,是那种"条件过滤 + 列裁剪"的简单形态,这种视图永远是 MERGE,性能几乎无损。
2.4 用视图做权限隔离和字段脱敏的落地方式
视图真正不可替代的价值在权限控制上。MySQL 没有行级安全策略(8.0 也没有内置的 RLS),想让某个账号只能看到自己部门的数据,最朴素可靠的做法就是视图加SQL SECURITY机制。
原理是这样的:视图默认SQL SECURITY DEFINER,即视图的查询以创建者的权限执行。你可以给报表账号只授予视图的SELECT,不授予任何基表权限,那么该账号查视图时,实际读取基表的动作是由视图定义者的权限完成的,数据能出来,但账号本身碰不到基表。这就实现了一个数据访问的收口。
-- 用高权限账号创建视图,定义者即该账号 CREATE OR REPLACE SQL SECURITY DEFINER VIEW v_report_order AS SELECT id, order_no, amount, created_at FROM t_order WHERE deleted = 0; -- 报表账号只给视图权限 GRANT SELECT ON app_db.v_report_order TO 'report_ro'@'%';字段脱敏同理。手机号只留后四位、身份证号打码,都可以在视图里用CONCAT、LEFT、RIGHT处理掉,再把脱敏视图授权出去。这比在应用层每个查询都记得脱敏要可靠得多,因为应用层总会有人忘记。
但这里有个高频的坑必须点出来:DEFINER会跟着mysqldump一起导出。你用mysqldump备份后再导到测试库,如果测试库里没有那个定义者账号,访问视图直接报ERROR 1449 (HY000): The user specified as a definer does not exist。我遇到过两次线上扩缩容踩这个问题。规范做法是:导入时统一用--skip-definer参数,或者导出后批量替换DEFINER语句;跨环境迁移的脚本里最好专门加一步检查information_schema.VIEWS里的DEFINER字段。
3. MySQL触发器:时序、边界和那些没人提前告诉你的坑
3.1 语法结构与 OLD / NEW 的取值规则
触发器的完整定义长这样:
CREATE TRIGGER trigger_name {BEFORE | AFTER} {INSERT | UPDATE | DELETE} ON tbl_name FOR EACH ROW [{FOLLOWS | PRECEDES} other_trigger_name] trigger_body六个时机事件组合:BEFORE INSERT、AFTER INSERT、BEFORE UPDATE、AFTER UPDATE、BEFORE DELETE、AFTER DELETE。OLD和NEW能不能用,取决于事件类型:
| 事件 | OLD 可用 | NEW 可用 | OLD 可修改 | NEW 可修改 |
|---|---|---|---|---|
| INSERT | 否 | 是 | — | 仅 BEFORE |
| UPDATE | 是 | 是 | 否 | 仅 BEFORE |
| DELETE | 是 | 否 | 否 | — |
这张表是触发器调试时最常用的参考。NEW只在BEFORE时机可写,这是实现"写入前修正值"的唯一入口——比如统一把手机号里的空格去掉、把金额的小数位截断、把状态字段做合法性归一。AFTER时机里改NEW是无效的,很多人在这里写半天没反应,就是因为没搞清这个规则。
多触发器共存是 5.7.2 之后才支持的,8.0 完全没问题。同一个表、同一个事件、同一个时机可以挂多个触发器,用FOLLOWS/PRECEDES控制执行顺序。这个特性在审计场景下挺有用,比如基础审计触发器加上业务特定审计触发器。但我的建议是能合并就合并,顺序这种东西一旦依赖上,后面维护的人就很难受了。
3.2 BEFORE 与 AFTER 的选择逻辑
选哪个时机不是风格问题,是能力问题。
BEFORE的能力是"改NEW"和"提前拦截"。凡是需要在数据落库前修正字段值、或者校验不通过就拒绝写入的,必须用BEFORE。用SIGNAL SQLSTATE '45000'抛自定义错误是标准做法:
DELIMITER $$ CREATE TRIGGER trg_stock_check BEFORE INSERT ON t_order_item FOR EACH ROW BEGIN DECLARE v_qty INT DEFAULT 0; SELECT qty INTO v_qty FROM t_stock WHERE sku_id = NEW.sku_id FOR UPDATE; IF v_qty < NEW.quantity THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'insufficient stock'; END IF; END$$ DELIMITER ;这里FOR UPDATE是必须的,它让读取的库存行加上排他锁,防止并发场景下两个事务同时读到足够的库存然后都通过了检查。这是"检查后使用"竞态的标准解法,少了这个后缀,校验形同虚设。
AFTER的能力是"变更已生效,可以安全地做后续动作"。凡是需要在数据真正落库后同步其他表、写审计日志、维护汇总数据的,用AFTER。原因很简单:BEFORE阶段主语句可能因为后续约束失败而回滚,你在BEFORE里对另一张表做的修改虽然也会一起回滚,但逻辑上你操作的是一个"还没确定成立"的事实,容易出现中间态。AFTER阶段主语句已经成功,语义清晰。
3.3 触发器里那些必须提前知道的限制
第一个限制,也是最容易踩的:触发器不能对本表做INSERT/UPDATE/DELETE。会直接报ERROR 1442 (HY000): Can't update table 'xxx' in stored function/trigger because it is already used by statement which invoked this stored function/trigger。这是 MySQL 防止递归失控的硬限制,没有绕过的方法(不要试图用临时表或者动态 SQL 去绕,触发器里根本不允许PREPARE)。所以"库存扣减写在订单明细的触发器里"这种做法,前提是库存表和订单明细表必须是两张表。
第二个限制:触发器里不能返回结果集。SELECT语句不能直接写,必须写成SELECT ... INTO var。这个限制导致触发器里没法做复杂的查询逻辑,稍微复杂一点就得封装成存储过程再调用。
第三个限制:触发器是逐行执行的。FOR EACH ROW不是可选项,一条UPDATE影响 10 万行,触发器就执行 10 万次。批量导入、批量状态更新这类操作,如果表上挂着触发器,性能会断崖式下降。我实测过一张 50 万行的表做全表状态更新,无触发器 3 秒,挂了一个写入审计表的触发器之后超过 6 分钟。所以有触发器的表上做批量操作,必须先评估,必要时临时禁用(遗憾的是 MySQL 不支持单独禁用触发器,只能DROP后重建,或者用会话变量在触发器内部做开关判断)。
-- 会话级开关的常见做法 SET @enable_audit = 0; -- 批量操作 UPDATE t_order SET status = 2 WHERE created_at < '2025-01-01'; SET @enable_audit = 1;触发器内部判断IF @enable_audit = 1 THEN ... END IF;即可。这是临时手段,不能一直靠它,但它救急很有效。
3.4 事务边界与复制行为的影响面
触发器执行在主语句所在的事务里,这一点很多人没意识到。也就是说触发器里写的所有INSERT、UPDATE都跟主语句同生共死:主语句回滚,触发器的写入也回滚;触发器里报错,主语句也回滚。这既是优点(强一致)也是陷阱(触发器里一个非预期异常会让整个业务操作失败)。
更值得说的是复制。基于行的复制(binlog_format = ROW,这也是 8.0 的默认值)下,触发器在主库执行产生的所有行变更会完整写入 binlog,从库直接重放这些行变更,不会再次执行触发器本身。这意味着审计表在从库上是会同步过去的,数据一致。但如果有人把binlog_format改成STATEMENT,从库就会重新执行一遍触发器逻辑,结果可能翻倍——审计表出现两条记录、库存被扣两次。这个坑在老系统迁移时非常常见,切换复制格式前必须清查所有触发器。
还有一点影响面要提:触发器对 ORM 和应用开发者是不可见的。应用层执行一条UPDATE,背后触发了三个表的写入,代码里一行都看不出来。这就是"隐式逻辑"的代价。我的经验是,只要表上挂了触发器,必须在表注释里写明,并且在团队的数据库文档里单独列一张触发器清单,包含触发器名、所在表、触发时机、副作用表。这事儿麻烦,但比事故后排查便宜太多。
4. 完整实操:一套可复现的订单模块演练
4.1 建表和造数据
下面这套脚本我在本地跑过多轮,可以直接复制执行。为了覆盖视图和触发器的典型用法,设计了订单主表、订单明细、库存表、审计日志表。
DROP TABLE IF EXISTS t_order_log, t_order_item, t_order, t_stock; CREATE TABLE t_order ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT UNSIGNED NOT NULL, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已完成 9已取消', deleted TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id, created_at), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表;表上挂了1个触发器 trg_order_audit'; CREATE TABLE t_order_item ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL, sku_id BIGINT UNSIGNED NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, KEY idx_order (order_id), KEY idx_sku (sku_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_stock ( sku_id BIGINT UNSIGNED PRIMARY KEY, qty INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_order_log ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL, from_status TINYINT, to_status TINYINT, changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO t_stock (sku_id, qty) VALUES (1001, 50), (1002, 5), (1003, 0);注意几个设计取舍。status用TINYINT而不是ENUM,因为ENUM加值要改表结构,TINYINT加常量只需要改代码。t_order上加了deleted软删除标记,这是后面视图的基础。created_at和updated_at用DEFAULT CURRENT_TIMESTAMP和ON UPDATE,就不用在触发器里手动维护时间戳了,能少写一行是一行。
4.2 视图层:报表视图与脱敏视图
先建两个定位完全不同的视图。第一个是给报表用的过滤视图,纯WHERE过滤,ALGORITHM一定是MERGE:
CREATE OR REPLACE VIEW v_order_active AS SELECT id, order_no, user_id, amount, status, created_at FROM t_order WHERE deleted = 0 WITH CHECK OPTION;第二个是给外部接口用的脱敏视图,把用户 ID 做哈希处理,只暴露必要列:
CREATE OR REPLACE ALGORITHM = TEMPTABLE VIEW v_order_public AS SELECT order_no, CONCAT(LEFT(SHA2(CONCAT(user_id, 'salt_x'), 256), 12)) AS user_hash, CAST(amount AS UNSIGNED) AS amount_int, status FROM t_order WHERE deleted = 0 AND status IN (1, 2);这里ALGORITHM我故意写成TEMPTABLE。原因不是性能,而是明确告诉后来的人"这个视图是物化执行,别在它上面做实时查询",属于一种自我文档化。另外amount转成整数、user_id做哈希,都是最常见的脱敏手段,成本低但能挡住绝大部分数据泄露风险。
验证一下执行计划:
EXPLAIN SELECT * FROM v_order_active WHERE user_id = 12345; -- 期望:直接访问 t_order,走 idx_user,select_type = SIMPLE EXPLAIN SELECT * FROM v_order_public WHERE status = 1; -- 期望:出现 DERIVED,说明物化4.3 触发器层:库存校验与状态变更留痕
库存校验触发器在4号位写过了,这里补一个完整版,包含明细表和库存表的联动。注意库存扣减不能写在明细表的BEFORE INSERT里(虽然是别的表,允许,但BEFORE阶段主语句可能失败),正确做法是AFTER INSERT:
DELIMITER $$ CREATE TRIGGER trg_order_item_after_insert AFTER INSERT ON t_order_item FOR EACH ROW BEGIN UPDATE t_stock SET qty = qty - NEW.quantity WHERE sku_id = NEW.sku_id AND qty >= NEW.quantity; IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'stock deduct failed'; END IF; END$$ DELIMITER ;这个写法比BEFORE里先查再改更紧凑:把"检查"和"扣减"合成一条带条件的UPDATE,靠ROW_COUNT()判断是否命中。UPDATE ... WHERE qty >= NEW.quantity在 InnoDB 下会对目标行加锁,天然解决并发问题,比先SELECT ... FOR UPDATE再UPDATE少一次往返。这是我在高并发下单场景验证过的写法,稳定性明显好于两步式。
状态变更留痕的触发器:
DELIMITER $$ CREATE TRIGGER trg_order_audit AFTER UPDATE ON t_order FOR EACH ROW BEGIN IF OLD.status <> NEW.status THEN INSERT INTO t_order_log (order_id, from_status, to_status) VALUES (NEW.id, OLD.status, NEW.status); END IF; END$$ DELIMITER ;这里IF OLD.status <> NEW.status是必须的。少了这个判断,任何字段的更新(包括updated_at被自动刷新)都会写一条审计记录,一个订单生命周期下来能产生几十条无意义的日志。这是我第一次写审计触发器时踩的坑,日志表涨得比业务表还快。
4.4 验证与副作用检查
跑一遍完整链路验证:
START TRANSACTION; INSERT INTO t_order (order_no, user_id, amount) VALUES ('NO20260101', 10086, 199.00); SET @oid = LAST_INSERT_ID(); INSERT INTO t_order_item (order_id, sku_id, quantity, price) VALUES (@oid, 1001, 2, 99.50); COMMIT; SELECT qty FROM t_stock WHERE sku_id = 1001; -- 期望 48 -- 库存不足场景,应当整体回滚 INSERT INTO t_order_item (order_id, sku_id, quantity, price) VALUES (@oid, 1003, 1, 10.00); -- 期望 ERROR 1644 (45000): stock deduct failed SELECT qty FROM t_stock WHERE sku_id = 1003; -- 期望仍为 0 -- 审计验证 UPDATE t_order SET status = 1 WHERE id = @oid; UPDATE t_order SET amount = 299.00 WHERE id = @oid; -- 不应产生审计记录 SELECT * FROM t_order_log WHERE order_id = @oid; -- 期望 1 条库存不足那条插入失败后,t_order_item里不该留下脏数据,t_stock也不该被扣,这正是"触发器 + 同事务"的强一致体现。如果这里出现了部分写入,说明表引擎不是 InnoDB,或者事务隔离级别被改过,要立刻查。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
| 报错信息 | 触发原因 | 处理方式 |
|---|---|---|
ERROR 1449 definer does not exist | 视图DEFINER账号在当前实例不存在 | 迁移时--skip-definer,或重建视图指定本实例账号 |
ERROR 1442 can't update table in trigger | 触发器操作了触发它的同一张表 | 拆表,或把逻辑移到应用层/存储过程 |
ERROR 1644 (45000) | 触发器SIGNAL主动抛出 | 属于业务预期,应用层捕获后转成业务错误码 |
ERROR 1356 view references invalid table | 视图依赖的基表被删或改名 | 重建视图;线上禁直接DROP被视图引用的表 |
CHECK OPTION failed | 通过视图写入后不满足视图WHERE条件 | 检查写入逻辑,或明确这是预期拦截 |
ERROR 1422 explicit or implicit commit is not allowed | 触发器里用了COMMIT/DDL | 触发器内禁止事务控制语句,去掉即可 |
这张表里的第一行和第三行是我实际处理频率最高的。特别是1449,每次环境迁移几乎必现,加一个grep DEFINER的检查步骤能省很多时间。
5.2 排查思路:从元数据表开始
视图和触发器的信息都在information_schema里,出了事先查元数据,比读代码快。
-- 查所有视图及其定义者、检查选项 SELECT TABLE_NAME, DEFINER, CHECK_OPTION, SECURITY_TYPE FROM information_schema.VIEWS WHERE TABLE_SCHEMA = 'app_db'; -- 查所有触发器及其事件时机 SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'app_db'; -- 查某张表上的全部触发器对应的副作用,直接看定义 SHOW CREATE TRIGGER trg_order_audit;比如怀疑某个UPDATE变慢了,先跑第二条查这张表挂了多少触发器,再逐个SHOW CREATE TRIGGER看里面做了什么。三步之内基本能定位。如果触发器里涉及其他表,继续递归查那张表的触发器——触发器链是性能问题的高发区。
更进一步可以用performance_schema看具体开销:
SELECT OBJECT_NAME, COUNT_STAR, SUM_TIMER_WAIT/1000000000 AS total_ms FROM performance_schema.events_statements_summary_by_program WHERE OBJECT_TYPE = 'TRIGGER' ORDER BY SUM_TIMER_WAIT DESC;这个查询返回每个触发器被执行了多少次、累计耗时多少。上线前跑一次做基线,出问题时对比,能直接判断触发器是不是元凶。这个表默认可能是关闭的,需要确认performance_schema相关 consumer 已开启。
5.3 团队协作层面的三条硬规矩
第一,视图层级不超过两层,且禁止视图套视图套视图。三层以上嵌套的维护成本会指数级上升,一个底层字段改名能让你改一整天。我现在的做法是,嵌套视图一律展开成单层,宁可 SQL 长一点。
第二,触发器必须登记。不管用什么方式(建表注释、数据字典文档、代码仓库里的 SQL 目录),所有触发器都得能被检索到。没有登记机制的团队,我建议直接禁掉触发器使用权限,只给 DBA 开。这不是保守,是止损。
第三,触发器里禁止写业务分支逻辑。三个IF以上、或者涉及状态机流转的,一律挪回应用层。触发器只做原子性的、无分支的、幂等的小动作——审计、计数、时间戳维护。业务规则会变,触发器改起来动辄要锁表,成本完全不对等。
我最后再补充一个实践里的小技巧:新建触发器时先写BEFORE版本并在测试库跑一遍全量数据,观察执行耗时和审计表增长量,再决定要不要上生产。这个步骤只花十分钟,但能避免大部分"上线后才发现某张表批量更新变慢"的情况。视图同理,建完先看EXPLAIN,确认没有意外的DERIVED,再交付给业务方用。