拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

存储过程为何被弃用?从版本控制到替代方案的工程化思考

存储过程为何被弃用?从版本控制到替代方案的工程化思考

第一次看到团队规范文档里写着“禁止使用存储过程”时,我心里是有些抵触的。那会儿我刚从一家老牌ERP公司出来,在那边写过不下几十个存储过程,月度结账、对账、报表汇总都靠它,自认为把复杂业务逻辑塞进数据库是“高效”的表现。直到半年内我亲手排过几次与存储过程相关的生产事故,才重新审视这条规矩——它不是技术洁癖,而是工程化管理下的理性选择。

这篇文章想聊明白三件事:为什么“禁止使用存储过程”会成为许多团队的硬性规范,存储过程到底犯了哪些“错”,以及真的不写存储过程之后,那些原本靠它完成的活我们该用什么东西替代。无论你正在维护一套满是存储过程的遗留系统,还是刚接手一个把这条写进开发规范的新团队,这篇文章都值得你花几分钟看完。

1. 存储过程曾经是真香:它的黄金时代解决过什么问题

1.1 存储过程的初始定位

存储过程是一段预先编译、存放在数据库内部、可被反复调用的程序单元。Oracle里是PL/SQL,MySQL从5.0开始支持,PostgreSQL里对应PL/pgSQL,openGauss沿袭了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 值得保留三种场景

“禁止使用存储过程”更准确的理解是“新代码不许再写”,而不是“存量全部推翻”。我个人的判断标准是,下面三类场景存储过程仍然有充分的存在理由:

  1. 存量系统中已经运行多年、经过生产验证的复杂批量任务。比如结算、清算这类流程,每次都一样,改动风险极高,而且应用层替代需要相当大的测试投入。这种情况下“熟悉且稳定”本身就是一种巨大的优势。
  2. 实时性要求极高、单次处理逻辑固定的场景。如果实测确认存储过程比应用层调用明显更快,且逻辑几乎不会变化,可以保留。
  3. 短生命周期的一次性脚本。比如数据修复、临时报表、节假日一次性统计,本来就不会进入业务代码体系,用存储过程写反而利落。

3.3 可执行的边界规则

给团队定规范时最怕模糊。单写一句“禁止使用存储过程”等于没写,因为没有边界,执行时全凭个人理解。更落地的做法是给出判断规则:

场景决策说明
新业务、新模块一律禁止业务逻辑必须落在应用层
存量存储过程动到才改有Bug、有需求变更时顺手迁移
复杂批量任务需审批必须给出性能实测和替代方案成本,审批通过才允许
一次性数据脚本允许,但需登记用完即弃,不影响系统架构

规范执行的核心是两个关键词:理由和记录。让所有人知道例外是什么、怎么申请例外、例外的生命期有多久。而不是简单的一句话禁区。

4. 不写存储过程之后,业务逻辑放哪里

4.1 事务边界由应用层管理

最核心的一个变化是:原来封装在存储过程里的“多个操作的原子性”,现在由应用层代码来掌控。Spring里用@Transactional,Python里用上下文管理器,本质上事务的ACID还是由数据库保障,应用层只是负责“开始、提交、回滚”的编排。

我见过不少开发人员不放心应用层事务,担心连接断开、事务没提交。其实事务的原子性是数据库能力,应用层只是多了一次调用距离,连接断开会自动回滚,不存在“应用层事务就不安全”的说法。真正需要担心的是不要把事务范围开得过大,比如在事务里做远程调用、发消息这种操作,这种风格问题在存储过程里反而更隐蔽。

4.2 用查询对象与仓储模式组织复杂SQL

存储过程被禁,不意味着SQL要被禁。恰恰相反,复杂SQL要被组织得更好。我推荐两种主流方式:

  • Repository模式:把SQL收敛到固定的数据访问层,让业务逻辑调用仓库接口而不是直接拼SQL,测试时方便mock。
  • 查询对象(Query Object):用一个对象封装表名、筛选条件、排序字段等参数,避免在业务代码里到处散落SQL片段。

给一个简单的Java示例:

public class OrderRepository { public List<Order> findOrders(OrderQuery query) { String sql = "SELECT * FROM orders WHERE 1=1"; 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(f"SELECT COUNT(*) FROM {table}").scalar() results.append((table, count, now())) # 批量写回统计表 session.bulk_insert_mappings(TableRowStats, results) session.commit()

和存储过程版本对比,应用层版本明显有几个优势:

  • 逻辑可单元测试。把表和统计的调用分离后,可以针对统计函数写断言。
  • 可复用。同样的统计功能可以被计划任务、手工工具、监控系统重复调用。
  • 可人工控制。执行到一半报错时,应用层能保留现场,可以断点续跑;存储过程出错了往往只能整个回滚重来。

4.4 复杂聚合SQL:CTE与窗口函数才是现代解法

很多业务当年写存储过程,就是因为一条SQL搞不定,需要中间表、循环、临时表。但现在主流数据库都支持CTE(Common 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 存量系统怎么一步步“戒掉”存储过程

不要指望一个季度内把几百个存储过程全部消灭。更现实的节奏是这样:

  1. 统计盘点:先弄清楚线上有多少存储过程、各自被谁调用、核心还是边缘。
  2. 新增零容忍:立好规矩,新需求一律不许再新增存储过程。
  3. 随手迁移:老存储过程只要被发现有问题、有需求变更,顺手迁到应用层。
  4. 定期回顾:每季度看一次存储过程数量曲线,确认治理方向没走偏。

按这个节奏,一个几百个存储过程的系统,一年内通常能把活跃存储过程降到三成左右。

5.3 不同数据库生态下的执行力度

“禁止存储过程”这条规则在不同数据库生态下,执行力度其实应该不同。MySQL的存储过程功能相对薄弱,没有包、没有高级调试手段,禁了基本没争议;Oracle的PL/SQL非常完善,禁它的机会成本更高,更需要给业务方留出替代方案的落地时间;openGauss等国产库沿袭PostgreSQL生态,功能介于两者之间。团队如果不结合数据库生态来写规范,很容易被业务方一句话怼回来:“Oracle的存储过程这么好用,你说禁就禁?”

所以更合理的做法是:规则定“新代码默认禁用”,但库选型、团队技术水平、系统生命周期都要纳入考量,给例外留通道。

5.4 我的几则实操体会

最后分享几条我踩过坑之后总结出来的体会:

  1. 别把存储过程禁令变成DBA的对立面。DBA依然要做SQL调优、执行计划分析,禁令针对的是业务逻辑下沉,不是数据库员工的专业能力。
  2. 把“统计各表数据量”这类公共需求整理成公共库。很多团队反复写同样的统计逻辑,不如沉淀成统一工具方法,让所有人复用。
  3. 规范治理要有量化指标。存储过程数量、活跃存储过程占比、迁移完成率,这些数据比任何口号都管用。
  4. 遇到“必须用存储过程”的业务诉求,先问三个问题:它真的不能在应用层拆解吗?它会有版本变更吗?它需要被审计吗?大部分时候问完这三个问题,对方自己也觉得应用层更合适。

说回主题。如果让我重新定义这条规则,我不会在规范里写“禁止使用存储过程”,而是写成“业务逻辑必须出现在能被版本控制、能被测试、能被评审的地方”。这句话比前者准确得多,也更容易让团队达成共识。存储过程本身没有原罪,它只是在一个以协作、交付、治理为核心的工程时代里,成了一个不太好用的工具而已。

返回列表