十五年数据库相关经验,做过 DBA、架构师、技术顾问。不求"颠覆",只求"靠谱"。
数据库里最危险的东西不是慢 SQL,是藏在存储过程和触发器里的业务逻辑。
慢 SQL 出问题的时候你能看到------执行计划不对、CPU 飙高、日志里有记录。但存储过程和触发器出问题的时候,业务逻辑静默错误:该扣的钱没扣、该生成的记录没生成、该校验的条件没校验。查了半天应用代码没问题,最后发现是数据库里的触发器漏了一条规则。
存储过程和触发器本身不是坏东西。用对了,它们是数据库的利器。用错了,它们是定时炸弹。
今天把存储过程和触发器的使用边界讲清楚。什么时候该用、什么时候绝对不该用、踩了什么坑、怎么治理历史遗留的烂代码。
01 存储过程:把复杂逻辑放在数据库里
存储过程是什么:一段预编译的 SQL 代码块,存在数据库里,可以被应用调用。
适合用存储过程的场景
场景一:批量数据处理。
比如月底出账单,涉及几十万用户的计费数据汇总。用应用层逐条查、逐条算、逐条插,效率极低。用存储过程,数据库内部完成计算和写入,减少了应用和数据库之间的网络往返。
sql
-- 示例:月度账单汇总
CREATE OR REPLACE PROCEDURE monthly_billing(target_month VARCHAR)
AS $$
DECLARE
r RECORD;
BEGIN
-- 汇总用量
FOR r IN SELECT user_id, SUM(amount) AS total
FROM usage_records
WHERE billing_month = target_month
GROUP BY user_id
LOOP
-- 计算账单
INSERT INTO bills (user_id, month, amount, status)
VALUES (r.user_id, target_month, r.total * 0.8, 'pending');
END LOOP;
COMMIT;
END;
$$ LANGUAGE plpgsql;
场景二:强一致性要求的复合操作。
比如转账:A 账户扣钱、B 账户加钱、记录流水。这三个操作必须在一个事务里完成,要么全成功,要么全失败。放在存储过程里,事务边界清晰,比应用层拼 SQL 更可靠。
sql
CREATE OR REPLACE PROCEDURE transfer(
from_account INT,
to_account INT,
amount DECIMAL
)
AS $$
BEGIN
-- A 扣钱
UPDATE accounts SET balance = balance - amount
WHERE id = from_account AND balance >= amount;
IF NOT FOUND THEN
RAISE EXCEPTION '余额不足';
END IF;
-- B 加钱
UPDATE accounts SET balance = balance + amount
WHERE id = to_account;
-- 记录流水
INSERT INTO transactions (from_id, to_id, amount, created_at)
VALUES (from_account, to_account, amount, NOW());
END;
$$ LANGUAGE plpgsql;
场景三:数据迁移/清洗脚本。
数据迁移的时候,临时写一段存储过程做数据转换和校验,跑完就删。这种场景不需要改应用代码,直接在数据库里操作,效率最高。
存储过程的实际生态
PL/pgSQL(PostgreSQL/KES)和 PL/SQL(Oracle)的存储过程语法类似,但和 MySQL 的存储过程语法差异不小。如果团队从 Oracle 迁移过来,金仓 KES 的 Oracle 兼容模式能覆盖大部分 PL/SQL 语法,存储过程不需要全部重写,改几个关键字就能跑。这在 Oracle 迁移项目里是个实打实的优势------存量代码的改造量直接决定了迁移工期。
02 触发器:隐藏的业务逻辑炸弹
触发器是什么:当某个表发生 INSERT/UPDATE/DELETE 时,自动执行一段代码。
适合用触发器的场景
场景一:审计日志自动记录。
每次修改数据时,自动把旧值、新值、修改人、修改时间记录到审计表里。这种逻辑写在触发器里,应用层完全不用管,保证不会漏记。
sql
-- 审计触发器函数
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, old_data, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, row_to_json(OLD), row_to_json(NEW), current_user, NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 绑定到表
CREATE TRIGGER employees_audit_trigger
AFTER UPDATE ON employees
FOR EACH ROW
EXECUTE FUNCTION audit_trigger_func();
场景二:数据冗余自动维护。
比如订单表有 order_count 字段,每次新增订单时自动 +1。这种冗余字段的维护放在触发器里,不会忘记更新。
场景三:数据校验。
比如工资字段不能低于最低工资标准,不能高于上限。在触发器里做校验,任何写入路径(应用、手工 SQL、批量导入)都不会绕过。
触发器的隐患
触发器最大的问题是不可见性。
一个开发者来查"为什么这个用户的订单状态不对",翻了应用代码没找到问题,翻了配置没找到问题,最后才发现是表上有个触发器在偷偷改数据。而这个触发器是三年前某个离职的人写的,没人知道它的存在。
所以触发器的第一条铁律:能不用就不用,用了必须文档化。
03 定时任务:数据库里的"cron"
数据库也可以跑定时任务(PostgreSQL 叫 pg_cron / pgAgent,MySQL 叫 Event Scheduler,Oracle 叫 DBMS_SCHEDULER)。
适合用数据库定时任务的场景
场景一:定期清理过期数据。
sql
-- 每天凌晨 2 点清理 90 天前的日志
SELECT cron.schedule('cleanup_old_logs',
'0 2 * * *',
$$DELETE FROM access_logs WHERE created_at < NOW() - INTERVAL '90 days'$$
);
场景二:定期汇总统计。
比如每天凌晨把当天的明细数据汇总到统计表里,第二天查报表的时候直接读汇总表,不用每次实时算。
场景三:定期刷新物化视图。
物化视图存的是预计算结果,需要定期刷新保持数据新鲜。
不该用数据库定时任务的场景
- 需要复杂调度逻辑的任务。 比如有依赖关系的多个任务、失败重试策略、并发控制------这些是 Airflow、Celery 等应用层调度工具的强项,不是数据库该干的。
- 需要和外部系统交互的任务。 比如调 API、发消息、写文件------数据库不应该干这些事。
- 业务逻辑相关的定时任务。 比如"每天给过期用户发提醒邮件"------这是应用层的事,不应该让数据库去发邮件。
04 反模式:什么时候绝对不该用
反模式一:业务逻辑全部写存储过程
有些团队的架构是"应用层只做薄薄一层,所有业务逻辑都在存储过程里"。这种架构的问题:
- 版本管理困难。 存储过程存在数据库里,不像应用代码有 Git 版本控制。谁改的、改了什么、什么时候改的,很难追溯。
- 测试困难。 存储过程的单元测试比应用代码难得多,CI/CD 流水线不好集成。
- 调试困难。 出了 bug,要查应用代码 → 存储过程 → 触发器 → 另一个触发器,链路极长。
- 人才瓶颈。 能写好应用代码的人多,能写好存储过程的人少。团队里如果只有一个人懂存储过程,他就是单点故障。
正确做法:存储过程只保留数据处理、事务管理、批量操作这类"数据库擅长的事情"。业务规则、流程控制、状态机这些放在应用层。
反模式二:触发器里写复杂业务逻辑
触发器适合做简单的数据操作(记日志、维护冗余字段、基础校验)。但有些团队把完整的业务流程写在触发器里:
sql
-- 反模式:触发器里做完整业务流程
CREATE TRIGGER order_after_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
-- 扣库存
UPDATE inventory SET stock = stock - NEW.quantity WHERE product_id = NEW.product_id;
-- 计算积分
UPDATE users SET points = points + NEW.quantity * 10 WHERE id = NEW.user_id;
-- 发短信通知
-- (别真在触发器里调外部 API,但有人这么想过)
-- 生成财务报表记录
INSERT INTO finance_records ...
-- 更新用户等级
IF ... THEN UPDATE users SET level = ...
END;
这种触发器是定时炸弹。任何一环出问题,整个订单写入就失败。而且触发器里做的事对应用层是透明的,应用开发者根本不知道一条 INSERT 背后触了多少事。
正确做法:触发器只做一件事------记录审计日志。其他的业务流程放在应用层,或者用存储过程显式调用,而不是隐式触发。
反模式三:触发器嵌套触发器
表 A 的触发器更新表 B,表 B 的触发器更新表 C,表 C 的触发器又更新表 A......
这种嵌套触发器一旦出问题,排查难度是指数级的。而且不同数据库对触发器嵌套层数的限制不同,换数据库可能直接报错。
对比:该用 vs 不该用
| 场景 | 建议 | 原因 |
|---|---|---|
| 批量数据汇总计算 | 存储过程 ✅ | 数据库内部计算,减少网络往返 |
| 转账等强一致复合操作 | 存储过程 ✅ | 事务边界清晰 |
| 审计日志自动记录 | 触发器 ✅ | 任何写入路径都不会漏 |
| 基础数据校验 | 触发器 ✅ | 所有写入路径都生效 |
| 定时清理过期数据 | 定时任务 ✅ | 简单、可靠、不依赖外部系统 |
| 完整业务流程 | 存储过程 ❌ | 版本管理、测试、调试都困难 |
| 调外部 API | 触发器 ❌ | 数据库不该干这种事 |
| 业务规则判断 | 存储过程 ❌ | 放应用层更灵活、更好测 |
| 复杂调度任务 | 定时任务 ❌ | 用专业调度工具 |
| 触发器里触发触发器 | 触发器 ❌ | 排查难度指数级增长 |
决策框架:存储过程和触发器的使用边界
| 维度 | 用存储过程 | 用触发器 | 用应用层 |
|---|---|---|---|
| 批量数据处理 | ✅ | ❌ | 效率低 |
| 事务一致性 | ✅ | ⚠️ 可以但不够灵活 | ✅ 需要小心处理 |
| 审计日志 | ❌ 可以但没必要 | ✅ | 容易漏 |
| 数据校验 | ⚠️ 不如约束 | ✅ | ✅ 但只覆盖应用路径 |
| 业务流程 | ❌ | ❌ | ✅ |
| 外部交互 | ❌ | ❌ | ✅ |
| 复杂调度 | ❌ | ❌ | ✅ |
深度分析:为什么存储过程和触发器容易被滥用
根因是便利性诱惑。
写存储过程比写应用代码快:不需要建项目、配环境、写接口。打开数据库客户端,几行 SQL 就搞定逻辑。触发器更方便:不用改应用代码,数据库里加一个触发器,业务逻辑就生效了。
短期看确实方便。但长期的代价是:
技术债积累。 存储过程和触发器不像应用代码那样有完善的版本管理、代码审查、自动化测试。它们悄无声息地积累在数据库里,几年下来,数据库成了一个"黑盒"------外面的人不知道里面藏了多少逻辑。
迁移成本爆炸。 当有一天需要换数据库的时候,这些存储过程和触发器就是最大的障碍。语法不兼容、功能不支持、逻辑需要重写。如果当初把业务逻辑放在应用层,换数据库只需要改连接字符串。
团队知识孤岛。 能写好存储过程的人越来越少。很多团队里,存储过程的原始作者已经离职,后来的人看不懂、不敢改、不敢删。这些代码就永远留在那里。
所以存储过程和触发器的核心原则是:用得越少,用得越好。 能用应用层解决的不用存储过程,能用约束解决的不用触发器。把它们留给真正需要它们的场景------批量处理、事务一致性、审计日志。
历史代码治理建议
如果你的数据库里已经有一堆存储过程和触发器,怎么办?
- 先盘点。 列出所有存储过程和触发器,标记功能、调用方、重要性。不知道有什么,就没法管。
- 分类处理。 审计类触发器保留,业务流程类标记为待迁移,批量处理类保留但补充文档。
- 逐步迁移。 不要一次性全删。按业务模块逐个迁移到应用层,每迁一个验证一个。
- 建立规范。 新代码禁止新增存储过程和触发器(除审计类触发器外),旧的逐步清理。
总结
存储过程和触发器不是洪水猛兽,是双刃剑。
存储过程适合批量处理、事务一致性、数据迁移这些场景。触发器适合审计日志、基础校验、冗余字段维护这些场景。但它们不适合完整业务流程、外部交互、复杂调度。
用得越少,用得越好。能用应用层解决的不用存储过程,能用约束解决的不用触发器。把存储过程和触发器留给数据库真正擅长的事------数据处理和事务管理。
不要在数据库里藏业务逻辑。数据库是存数据的地方,不是跑业务的地方。
后续我会继续分享数据库字符集陷阱、分区表实战这些话题,跟着我一篇篇学,数据库这块就没问题了。
有问题评论区见。
十五年数据库领域老炮。关注我,一起把数据库这件事搞明白。