
第一次看到团队规范文档里写着“禁止使用存储过程”时我心里是有些抵触的。那会儿我刚从一家老牌ERP公司出来在那边写过不下几十个存储过程月度结账、对账、报表汇总都靠它自认为把复杂业务逻辑塞进数据库是“高效”的表现。直到半年内我亲手排过几次与存储过程相关的生产事故才重新审视这条规矩——它不是技术洁癖而是工程化管理下的理性选择。这篇文章想聊明白三件事为什么“禁止使用存储过程”会成为许多团队的硬性规范存储过程到底犯了哪些“错”以及真的不写存储过程之后那些原本靠它完成的活我们该用什么东西替代。无论你正在维护一套满是存储过程的遗留系统还是刚接手一个把这条写进开发规范的新团队这篇文章都值得你花几分钟看完。1. 存储过程曾经是真香它的黄金时代解决过什么问题1.1 存储过程的初始定位存储过程是一段预先编译、存放在数据库内部、可被反复调用的程序单元。Oracle里是PL/SQLMySQL从5.0开始支持PostgreSQL里对应PL/pgSQLopenGauss沿袭了PostgreSQL的生态。它的黄金时期大致在2000年到2010年那时应用服务器和数据库服务器的分工非常明确业务被分成“前台展示逻辑”和“后台数据逻辑”。那个年代存储过程有几大天然优势减少网络往返。早年数据库连接是昂贵的资源一次存储过程调用可以完成多条SQL的工作能省下大量网络IO。事务封装在数据库内部。跨表的数据一致性由数据库统一保证应用层代码极其简洁。数据计算靠近数据源。复杂统计在数据库内完成不需要把大量原始数据拉到应用层再算。我在前公司的ERP系统里几乎所有核心模块都依赖存储过程。月度结账过程由50多个存储过程首尾相接十几小时跑完。当时的评价就是“稳定、高效”。大家习惯性把所有跟“复杂数据逻辑”沾边的东西往里塞一个存储过程几百行是常态。1.2 典型的统计存储过程长什么样你看这几天的热门词里有“建一个统计当前库下各表数据总量的存储过程”这恰好是当年最常见的需求。在不允许写存储过程的团队里新同学看到这种需求往往会问这种活不用存储过程怎么做我们先看看当年典型的写法。Oracle版本大致是这样CREATE OR REPLACE PROCEDURE stats_table_rowcount AS v_table_name VARCHAR2(128); v_count NUMBER; BEGIN FOR rec IN (SELECT table_name FROM user_tables) LOOP v_table_name : rec.table_name; EXECUTE IMMEDIATE SELECT COUNT(*) FROM || v_table_name INTO v_count; INSERT INTO table_row_stats(table_name, row_count, stat_time) VALUES (v_table_name, v_count, SYSDATE); END LOOP; COMMIT; END;MySQL版本则需要游标加动态SQL看起来更繁琐DELIMITER // CREATE PROCEDURE stats_row_count() BEGIN DECLARE done INT DEFAULT 0; DECLARE tbl VARCHAR(255); DECLARE cur CURSOR FOR SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA DATABASE(); DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; OPEN cur; read_loop: LOOP FETCH cur INTO tbl; IF done THEN LEAVE read_loop; END IF; SET sql CONCAT(SELECT COUNT(*) FROM , tbl, INTO cnt); PREPARE stmt FROM sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; INSERT INTO table_row_stats(table_name, row_count, stat_time) VALUES (tbl, cnt, NOW()); END LOOP; CLOSE cur; END// DELIMITER ;这个需求本身并不复杂但用存储过程写完之后后续的维护问题就开始了表结构变化了、统计口径变化了、要排除临时表了、要增量统计了……每一次改动都需要DBA去库里改版本管理靠导出SQL文件手工比对时间一长没人知道线上跑的那一版到底是什么样。1.3 转折点架构和协作方式变了后来大家开始反感存储过程并不是因为它本身难用了而是周围的环境变了。微服务把数据库拆开了一个业务域一个库甚至一个表组CI/CD要求每一次变更可追溯、可回滚敏捷开发要求业务逻辑可以被代码评审看清楚。而存储过程依然待在数据库里没有进入这套工程体系。举个例子。一个微服务团队里应用代码走Git有合并请求、有代码评审、有流水线自动构建。存储过程呢它可能只是数据库客户端里一个小窗口谁改的、改成什么样、为什么改全靠口头约定。应用代码可以测试、可以灰度、可以回滚存储过程一改就是全局生效根本没有“灰度”这个概念。这种反差才是“禁止使用存储过程”这种极端表述出现的原因。不是存储过程技术落后而是它没有跟上软件工程体系。2. 掰开揉碎存储过程的五大软肋也是“禁止”的真正理由2.1 版本控制与变更追踪代码库看不见它存储过程最大的原罪是它不在代码仓库里。虽然可以把建库脚本导出成.sql文件托管到Git但真实开发中很少有人每次改动都同步导出。于是出现了一个经典场景开发环境和生产环境的存储过程版本不一致等到发版时应用代码更新了、存储过程没更新或者反过来。我说个真实事故。有一回需求是订单表的status字段从0/1调整成0/1/2三类应用代码改完上线订单列表接口立刻报错。排查了半天才发现是一个老存储过程里还在用CASE WHEN status 1做统计这个存储过程跑了好几年文档里没人记得它。这种问题并非不能解决比如把存储过程纳入数据库迁移脚本体系用Flyway、Liquibase之类的工具管理起来可以在一定程度上弥补。但现实是存储过程的编写和评审远没有应用代码那么严格。更麻烦的是线上救火时DBA一着急就直接在正式库里改过程改完也没人记录代码库又一次和线上失去同步。注意如果存量存储过程实在没法立刻迁走至少要做一件事——把线上所有存储过程导出到代码仓库并建立“改存储过程必须同步提交脚本”的纪律不然后面的任何治理都是空谈。2.2 测试与质量保障业务逻辑进了死胡同软件工程讲究三层防线单元测试、集成测试、端到端测试。应用层代码可以用JUnit、pytest写大量测试可存储过程呢你很难为一个存储过程做单元测试因为它依赖真实数据、具体表结构、特定数据库版本。假设你愿意写测试测试环境也需要接近生产的数据库实例和造数脚本。很多团队连开发库都只有一台共享实例一个存储过程被多个系统共用改一个过程影响的是别人的报表、对账、批量任务。改之前不敢动改完之后不知道验证什么。存储过程内部的逻辑也远不如应用代码好调试。应用代码打日志、断点、单步调试都很成熟存储过程出问题Oracle里用DBMS_OUTPUT输出调试信息MySQL里用SELECT变量看中间值体验差距很大尤其是面对几百行、层层嵌套的存储过程你甚至不知道从哪下手。自动化测试更是无从谈起。应用层代码可以在流水线里跑单元测试、静态扫描、覆盖率统计存储过程全都做不了。业务逻辑藏在数据库里就等于从质量保障体系里逃逸了。2.3 数据库迁移存储过程是最大的迁移工作量数据库国产化是这几年绕不开的话题Oracle迁openGauss、MySQL迁PostgreSQL……业务表结构迁移其实好办用工具转一遍就行。真正痛苦的是把几千个存储过程从PL/SQL改成PL/pgSQL两个方言差异之大几乎等同于重写。举几个常见的差异Oracle的SQL%ROWCOUNT、自治事务PRAGMA AUTONOMOUS_TRANSACTION在openGauss里没有直接等价物。MySQL存储过程的SIGNAL、HANDLER、游标循环到PostgreSQL里语法完全不同。存储过程里调用DBMS_JOB、DBMS_SCHEDULER做定时调度时迁移连调度机制都得换。我参与过一次从Oracle到openGauss的迁移评估数据迁移预计两周存储过程改写排了一个半月还打不住。如果业务逻辑都在应用层迁移工作量至少能砍掉一半。这是很多团队把“禁止存储过程”写进规范时没有明说、但内心非常看重的一个理由——架构要想不被数据库厂商绑架就不能把核心逻辑紧紧绑死在数据库方言上。2.4 性能误区存储过程不等于快但它一定很难优化“存储过程性能好”是最大的认知误区。它只是减少了网络往返SQL执行计划的好坏与它在不在存储过程里其实没有必然关系。相反存储过程往往是慢SQL的重灾区。最典型的反模式是逐行处理row-by-row。比如一个存储过程对100万行数据做逐条UPDATE每处理一行就提交一次性能极差。这种写法在应用层也快不到哪去但存储过程给人一个错觉“它已经在数据库里了为什么不能直接快”——可实际瓶颈就出在循环和逐条DML上。另外存储过程的执行计划缓存在不同数据库下表现也不一样。Oracle的共享游标能很大程度缓解SQL解析开销MySQL 8.0之前没有查询计划缓存存储过程里动态拼SQL极易引发解析风暴。相比之下应用层用参数化SQL配合连接池不仅性能稳定出了问题还容易定位。问题面具体表现典型影响版本控制存储过程不在代码库改动难追踪生产与开发环境行为不一致测试与质量难以单元测试、测试环境隔离困难回归风险失控数据库迁移PL/SQL与PL/pgSQL方言严重不兼容跨库改造代价数倍放大性能优化循环逐行处理、执行计划难控制慢SQL定位难、优化难安全与审计黑盒访问、动态SQL拼接受控难注入风险、审计不透2.5 权限、安全与可审计性黑盒带来的麻烦存储过程还有一个隐含问题业务数据访问被“藏”了起来。为了安全有些团队会把基表的直接访问权限收掉只给业务账号执行存储过程的权限。这在隔离性上是合理的但它让数据访问审计变得困难——所有访问都从一个黑盒走谁也说不清某个数据到底被谁动过、怎么动的。更麻烦的是动态SQL。存储过程里拼动态SQL尤其是需要传表名、列名的场景很容易引入SQL注入。这和使用参数化查询的应用层代码相比风险高得多。遇到合规审计检查“数据访问透明、可解释”时存储过程往往是最难交代的一环。我一个做审计的朋友说过一句很扎心的话“我最怕看到的不是裸奔的账号而是那种所有人都通过一个存储过程操作数据的系统——看起来有控制其实等于没控制。”因为存储过程内部的逻辑对审计人员来说就是黑盒。3. 别矫枉过正哪些场景存储过程依然有理由存在3.1 存量的现实约束如果你接手的是一个已经稳定运行十几年、几百个存储过程支撑着核心业务命脉的老系统上来就喊“全部迁移”是很不负责任的。现实一点的做法是分优先级、分批次地治理而不是热血沸腾地做技术大扫除。存量存储过程我建议分三类处理第一优先级被新业务依赖的、经常修改的、报错率高的优先迁移到应用层。第二优先级核心批量任务稳定运行多年改动风险极大暂时保持原样但必须补文档、补监控。第三优先级已经找不到调用方的一次性脚本审计后直接删除。3.2 值得保留三种场景“禁止使用存储过程”更准确的理解是“新代码不许再写”而不是“存量全部推翻”。我个人的判断标准是下面三类场景存储过程仍然有充分的存在理由存量系统中已经运行多年、经过生产验证的复杂批量任务。比如结算、清算这类流程每次都一样改动风险极高而且应用层替代需要相当大的测试投入。这种情况下“熟悉且稳定”本身就是一种巨大的优势。实时性要求极高、单次处理逻辑固定的场景。如果实测确认存储过程比应用层调用明显更快且逻辑几乎不会变化可以保留。短生命周期的一次性脚本。比如数据修复、临时报表、节假日一次性统计本来就不会进入业务代码体系用存储过程写反而利落。3.3 可执行的边界规则给团队定规范时最怕模糊。单写一句“禁止使用存储过程”等于没写因为没有边界执行时全凭个人理解。更落地的做法是给出判断规则场景决策说明新业务、新模块一律禁止业务逻辑必须落在应用层存量存储过程动到才改有Bug、有需求变更时顺手迁移复杂批量任务需审批必须给出性能实测和替代方案成本审批通过才允许一次性数据脚本允许但需登记用完即弃不影响系统架构规范执行的核心是两个关键词理由和记录。让所有人知道例外是什么、怎么申请例外、例外的生命期有多久。而不是简单的一句话禁区。4. 不写存储过程之后业务逻辑放哪里4.1 事务边界由应用层管理最核心的一个变化是原来封装在存储过程里的“多个操作的原子性”现在由应用层代码来掌控。Spring里用TransactionalPython里用上下文管理器本质上事务的ACID还是由数据库保障应用层只是负责“开始、提交、回滚”的编排。我见过不少开发人员不放心应用层事务担心连接断开、事务没提交。其实事务的原子性是数据库能力应用层只是多了一次调用距离连接断开会自动回滚不存在“应用层事务就不安全”的说法。真正需要担心的是不要把事务范围开得过大比如在事务里做远程调用、发消息这种操作这种风格问题在存储过程里反而更隐蔽。4.2 用查询对象与仓储模式组织复杂SQL存储过程被禁不意味着SQL要被禁。恰恰相反复杂SQL要被组织得更好。我推荐两种主流方式Repository模式把SQL收敛到固定的数据访问层让业务逻辑调用仓库接口而不是直接拼SQL测试时方便mock。查询对象Query Object用一个对象封装表名、筛选条件、排序字段等参数避免在业务代码里到处散落SQL片段。给一个简单的Java示例public class OrderRepository { public ListOrder findOrders(OrderQuery query) { String sql SELECT * FROM orders WHERE 11; if (query.getStatus() ! null) { sql AND status ?; } if (query.getStartTime() ! null) { sql AND created_at ?; } sql ORDER BY query.getSortField() query.getSortOrder(); // 使用预编译参数执行返回结果 } }这样做的价值在于SQL保留了但它有版本、有评审、能测试也容易做代码走查。存储过程时代那种“业务逻辑散落在几百个过程里”的状态从根本上被结构化的数据访问层取代了。4.3 批量任务的替代实现前面那个“统计各表数据总量”的例子用应用层实现其实很清爽。Python伪码大概这个样子def collect_row_counts(): tables list_tables() # 从 information_schema 读取表名 results [] for table in tables: count session.execute(fSELECT COUNT(*) FROM {table}).scalar() results.append((table, count, now())) # 批量写回统计表 session.bulk_insert_mappings(TableRowStats, results) session.commit()和存储过程版本对比应用层版本明显有几个优势逻辑可单元测试。把表和统计的调用分离后可以针对统计函数写断言。可复用。同样的统计功能可以被计划任务、手工工具、监控系统重复调用。可人工控制。执行到一半报错时应用层能保留现场可以断点续跑存储过程出错了往往只能整个回滚重来。4.4 复杂聚合SQLCTE与窗口函数才是现代解法很多业务当年写存储过程就是因为一条SQL搞不定需要中间表、循环、临时表。但现在主流数据库都支持CTECommon Table Expression和窗口函数替代能力远超十年前。举一个例子计算“每个月的累计订单金额”。存储过程时代你可能要写循环按月累加而一条窗口函数SQL就能搞定SELECT order_month, SUM(month_amount) OVER (ORDER BY order_month) AS cumulative_amount FROM ( SELECT DATE_FORMAT(created_at, %Y-%m) AS order_month, SUM(amount) AS month_amount FROM orders WHERE created_at 2024-01-01 GROUP BY DATE_FORMAT(created_at, %Y-%m) ) t;这在逻辑清晰度、执行效率、维护成本上都优于老式存储过程。我见过不少从存储过程迁移到CTE的改造性能不但没降反而因为优化器拿到的是更明确的集合语义执行计划变得更好。5. 从“禁止”到“共识”落地这条规范的真实心得5.1 规则必须写清楚“为什么”我在很多团队的规范文档里见过一句话式的规则执行不下去的根本原因是没解释为什么。你写“禁止使用存储过程”新人不理解老人不服气最终变成一纸空文。正确的写法是先讲背景比如我们踩过存储过程导致线上事故的案例再讲原则比如“所有业务逻辑必须可版本化、可测试、可评审”最后才是具体规则和例外流程。规则一旦有了上下文协作时的摩擦会小很多。5.2 存量系统怎么一步步“戒掉”存储过程不要指望一个季度内把几百个存储过程全部消灭。更现实的节奏是这样统计盘点先弄清楚线上有多少存储过程、各自被谁调用、核心还是边缘。新增零容忍立好规矩新需求一律不许再新增存储过程。随手迁移老存储过程只要被发现有问题、有需求变更顺手迁到应用层。定期回顾每季度看一次存储过程数量曲线确认治理方向没走偏。按这个节奏一个几百个存储过程的系统一年内通常能把活跃存储过程降到三成左右。5.3 不同数据库生态下的执行力度“禁止存储过程”这条规则在不同数据库生态下执行力度其实应该不同。MySQL的存储过程功能相对薄弱没有包、没有高级调试手段禁了基本没争议Oracle的PL/SQL非常完善禁它的机会成本更高更需要给业务方留出替代方案的落地时间openGauss等国产库沿袭PostgreSQL生态功能介于两者之间。团队如果不结合数据库生态来写规范很容易被业务方一句话怼回来“Oracle的存储过程这么好用你说禁就禁”所以更合理的做法是规则定“新代码默认禁用”但库选型、团队技术水平、系统生命周期都要纳入考量给例外留通道。5.4 我的几则实操体会最后分享几条我踩过坑之后总结出来的体会别把存储过程禁令变成DBA的对立面。DBA依然要做SQL调优、执行计划分析禁令针对的是业务逻辑下沉不是数据库员工的专业能力。把“统计各表数据量”这类公共需求整理成公共库。很多团队反复写同样的统计逻辑不如沉淀成统一工具方法让所有人复用。规范治理要有量化指标。存储过程数量、活跃存储过程占比、迁移完成率这些数据比任何口号都管用。遇到“必须用存储过程”的业务诉求先问三个问题它真的不能在应用层拆解吗它会有版本变更吗它需要被审计吗大部分时候问完这三个问题对方自己也觉得应用层更合适。说回主题。如果让我重新定义这条规则我不会在规范里写“禁止使用存储过程”而是写成“业务逻辑必须出现在能被版本控制、能被测试、能被评审的地方”。这句话比前者准确得多也更容易让团队达成共识。存储过程本身没有原罪它只是在一个以协作、交付、治理为核心的工程时代里成了一个不太好用的工具而已。