最近接手了一个老项目,里面的PostgreSQL代码简直惨不忍睹,SQL语句写了几百行,存储过程嵌套了好几层,重复代码到处都是,性能也很差。我花了一个月的时间,对这些代码进行了重构,从烂代码变成了优雅代码,性能也提升了好几倍。今天,分享一下重构的过程、方法和经验。
先说说项目背景。这个项目是一个业务系统,已经运行了五六年了,数据库用的是PostgreSQL,从最开始的9.6版本,一路升级到了现在的13版本。项目一开始,是几个开发人员一起写的,后来人员换了好几拨,代码也越积越多,越写越乱。
我接手的时候,数据库里有几百个表,几百个视图,几十个存储过程和函数,还有一大堆触发器。代码质量很差,主要有这些问题:
- SQL语句又长又复杂:一个查询语句,写了几百行,嵌套了好几层子查询,可读性很差,维护起来很困难。
- 重复代码很多:同样的逻辑,在不同的地方重复写了很多遍,比如计算某个指标的逻辑,在好几个存储过程里都有,改一个地方,其他地方也要改,很容易漏。
- 存储过程嵌套太深:一个存储过程,调用另一个存储过程,再调用另一个,嵌套了四五层,调用关系很复杂,出了问题很难排查。
- 命名不规范:表名、字段名、视图名、存储过程名,命名很随意,有的用拼音,有的用英文,有的大小写混用,看起来很混乱。
- 没有注释:大部分代码都没有注释,或者注释很少,看代码的时候,不知道这段代码是干什么的,为什么这么写。
- 性能很差:很多查询都没有走索引,全表扫描,数据量大了之后,查询很慢,有的查询要跑好几分钟,甚至几十分钟。
- 硬编码很多:很多常量,比如状态值、类型值,都是硬编码在SQL里的,没有用常量或者枚举,改起来很麻烦,也容易出错。
面对这样一堆烂代码,我一开始也很头疼,不知道从哪里下手。但项目还在运行,业务还在迭代,不能推倒重来,只能在现有基础上重构。于是,我花了一个月的时间,对这些代码进行了系统性的重构。
今天,就把重构的过程、方法和经验,分享出来,希望对大家有所帮助。
一、重构前的准备:先理解,再动手
重构之前,最重要的不是马上动手改代码,而是先理解现有代码,理解业务逻辑,理解数据结构。如果不理解就动手改,很容易改出问题,甚至把系统改崩。
我做了以下几件准备工作:
1. 梳理数据库结构
首先,我把数据库里的所有表、视图、存储过程、函数、触发器,都列了出来,整理成一个清单。然后,逐个理解每个表是干什么的,每个字段是什么意思,表和表之间是什么关系。
我用了数据库建模工具,比如PowerDesigner、dbdiagram.io,把表结构导出来,生成ER图,直观地看到表之间的关系。这样,对整个数据库的结构,就有了一个整体的认识。
2. 梳理业务逻辑
然后,我梳理了业务逻辑,理解每个存储过程、每个复杂查询,是干什么的,处理的是什么业务,输入是什么,输出是什么,中间的处理逻辑是什么。
这个过程,花了我很多时间,因为代码没有注释,逻辑又复杂,只能一行一行地读,一点一点地理解。有的存储过程,写了几百行,我读了好几遍,才弄明白它是干什么的。
在梳理的过程中,我做了详细的笔记,把每个存储过程的功能、输入、输出、逻辑,都记录下来,形成了一份文档。这份文档,后来在重构的时候,帮了我很大的忙。
3. 找出性能瓶颈
接下来,我找出了性能瓶颈,看看哪些查询最慢,哪些存储过程执行时间最长,哪些表的数据量最大。
我用了PostgreSQL的统计信息,比如pgstatstatements扩展,记录了所有SQL语句的执行次数、总时间、平均时间等,找出了最慢的那些查询。还看了执行计划(EXPLAIN ANALYZE),看看这些查询为什么慢,是没有走索引,还是全表扫描,还是排序太多。
找出性能瓶颈之后,我就知道了,哪些地方是重构的重点,哪些地方最需要优化。
4. 制定重构计划
最后,我制定了一个详细的重构计划,分阶段、分模块地进行重构,而不是一下子全改。
我的计划是:
- 第一阶段:基础优化,比如加索引、优化慢查询、清理无用代码。
- 第二阶段:结构优化,比如表结构优化、视图优化、命名规范化。
- 第三阶段:代码重构,比如存储过程重构、重复代码抽取、公共函数封装。
- 第四阶段:性能优化和测试,全面性能测试,确保重构后的代码性能更好,功能正确。
分阶段重构的好处是,每个阶段都有明确的目标和产出,风险可控,出了问题也容易回滚。而且,可以在重构的同时,不影响业务的正常迭代。
二、第一阶段:基础优化,先解决最痛的问题
第一阶段,我先做基础优化,解决最痛的问题,也就是性能问题。因为性能问题是最直观的,用户感受最明显,而且优化起来见效快,能快速看到成果,增强信心。
1. 加索引
加索引,是最简单也最有效的性能优化手段。我通过执行计划,找出了那些全表扫描的查询,然后给对应的字段加上索引。
加索引的时候,我遵循了几个原则:
- 选择性高的字段加索引:选择性高的字段(比如ID、手机号、邮箱等),索引效果好;选择性低的字段(比如性别、状态等),索引效果差,不建议加。
- 联合索引的顺序很重要:联合索引,要把选择性高的字段放在前面,把等值查询的字段放在前面,把范围查询的字段放在后面。
- 不要加太多索引:索引不是越多越好,每个索引都会增加写入的开销,也会占用存储空间。一般来说,一个表的索引数量,不要超过5-6个。
- 定期清理无用索引:有些索引,可能从来没有被使用过,或者已经被其他索引覆盖了,这些索引要及时清理,减少写入开销。
加了索引之后,很多查询的速度,从几分钟降到了几秒钟,效果非常明显。
2. 优化慢查询
除了加索引,我还对那些特别慢的查询,进行了针对性的优化。
优化的方法包括:
- 避免SELECT :只查询需要的字段,不要用SELECT ,减少数据传输和内存使用。
- 避免在WHERE子句中对字段做运算或函数:比如
WHERE YEAR(createtime) = 2020,这样会导致索引失效,改成WHERE createtime >= '2020-01-01' AND create_time < '2021-01-01'。 - 用JOIN代替子查询:很多子查询,可以改成JOIN,性能更好,而且PostgreSQL的优化器对JOIN的优化更成熟。
- 用EXISTS代替IN:对于子查询,用EXISTS比IN性能更好,尤其是子查询结果集很大的时候。
- 分页优化:深分页(比如LIMIT 100000, 10)很慢,可以用游标分页,或者先查出ID,再关联查询。
- 避免排序:如果不需要排序,就不要用ORDER BY;如果需要排序,确保排序字段有索引。
通过这些优化,很多慢查询的性能,都提升了好几倍,甚至几十倍。
3. 清理无用代码
在梳理代码的过程中,我发现有很多无用的代码,比如已经不用的存储过程、视图、表、字段,还有一些注释掉的代码。这些无用的代码,不仅占用空间,还会干扰阅读,增加维护成本。
我把这些无用的代码,都清理掉了。清理之前,我先确认这些代码确实没有被使用,通过搜索代码、查看日志、询问业务人员等方式,确认无误后,才删除。删除的时候,我也做了备份,万一删错了,可以恢复。
清理了无用代码之后,数据库里的代码量减少了三分之一,看起来清爽了很多,维护起来也容易了。
三、第二阶段:结构优化,打好基础
基础优化做完之后,第二阶段,我做结构优化,也就是对表结构、视图、命名等,进行优化,打好基础,为后面的代码重构做准备。
1. 表结构优化
表结构,是数据库的基础。好的表结构,能让代码更简洁,性能更好,维护更容易。
我对表结构做了以下优化:
- 消除冗余字段:有些字段,在多个表中重复存储,而且经常不一致,我把这些冗余字段去掉了,通过关联查询来获取。
- 统一字段类型:同样含义的字段,在不同的表中,类型不一样,比如有的用INT,有的用BIGINT,有的用VARCHAR,我统一成了相同的类型。
- 添加约束:很多表都没有主键、外键、唯一约束、非空约束等,我根据业务逻辑,添加了合适的约束,保证数据的完整性和一致性。
- 大表拆分:对于数据量特别大的表(比如几千万条),进行了分区或者分表,提升查询性能。
- 字段命名规范化:字段名,统一用小写字母加下划线,比如
userid、createtime,不用拼音,不用驼峰,不用大小写混用。
表结构优化,是一个比较大的改动,需要修改表结构,还要修改对应的代码,所以我做得很谨慎,一个表一个表地改,改完之后,做了充分的测试,确保数据正确,功能正常。
2. 视图优化
视图,是虚拟的表,可以简化复杂的查询,提高代码的复用性。但项目里的视图,写得也很乱,有的视图嵌套了好几层,有的视图定义了但从来没用过,有的视图性能很差。
我对视图做了以下优化:
- 清理无用视图:删除了那些从来没有被使用过的视图。
- 简化视图定义:把嵌套太深的视图,展开或者简化,提升性能。
- 复用公共逻辑:把常用的查询逻辑,封装成视图,在多个地方复用,减少重复代码。
- 物化视图:对于那些查询频繁但数据不经常变化的视图,改成物化视图,提前计算好结果,查询的时候直接用,性能更好。
视图优化之后,代码的复用性提高了,很多复杂的查询,都可以通过简单的视图查询来完成,代码更简洁,也更容易维护。
3. 命名规范化
前面提到了,项目里的命名很混乱,有的用拼音,有的用英文,有的大小写混用。我对所有的表、字段、视图、存储过程、函数、触发器,都进行了命名规范化。
命名规范:
- 表名:小写字母加下划线,用复数形式,比如
users、orders、order_items。 - 字段名:小写字母加下划线,比如
userid、username、createtime、updatetime。 - 视图名:以
v开头,比如vuserorder、vproduct_sales。 - 存储过程名:以
sp开头,用动词加名词的形式,比如spcreateorder、spupdate_user。 - 函数名:以
fn开头,用动词加名词的形式,比如fncalculatetotal、fngetusername。 - 触发器名:以
tr开头,比如trorderafterinsert。
命名规范化之后,代码看起来整齐了很多,一看名字就知道这个对象是干什么的,是什么类型的,维护起来容易多了。
当然,命名规范化,是一个比较大的改动,需要修改所有引用这些对象的代码,所以我做得很谨慎,一个对象一个对象地改,改完之后,做了充分的测试,确保所有引用都正确。
四、第三阶段:代码重构,从烂代码到优雅代码
结构优化做完之后,第三阶段,就是核心的代码重构了,也就是对存储过程、函数、复杂SQL等,进行重构,从烂代码变成优雅代码。
1. 拆分大的存储过程
项目里有很多很大的存储过程,一个存储过程写了几百行,甚至上千行,做了很多事情,可读性很差,维护起来很困难。
我把这些大的存储过程,拆分成了多个小的存储过程或者函数,每个存储过程只做一件事情,遵循单一职责原则。
比如,有一个spprocessorder存储过程,做了很多事情:验证订单、扣减库存、创建订单、计算价格、发送通知、记录日志,写了五百多行。我把它拆分成了:
spvalidateorder:验证订单spdeductinventory:扣减库存spcreateorder:创建订单spcalculateprice:计算价格spsendnotification:发送通知splogoperation:记录日志
然后,spprocessorder只负责调用这些小的存储过程,编排流程。这样,每个存储过程都很小,很清晰,可读性好,维护起来也容易,而且可以单独测试,单独复用。
2. 抽取公共函数,消除重复代码
前面提到了,项目里有很多重复代码,同样的逻辑,在不同的地方重复写了很多遍。我把这些重复的逻辑,抽取成了公共函数,在多个地方复用。
比如,计算订单总金额的逻辑,在好几个存储过程里都有,我把它抽取成了fncalculateorder_total函数,在需要的地方调用。这样,修改的时候,只需要改一个地方,不会漏改,也减少了代码量。
再比如,格式化日期的逻辑、生成订单号的逻辑、验证手机号的逻辑等,我都抽取成了公共函数,在多个地方复用。
抽取公共函数之后,代码量减少了很多,重复代码基本消除了,维护起来也容易多了。
3. 用CTE代替嵌套子查询
项目里有很多复杂的查询,用了很多嵌套子查询,一层套一层,可读性很差,也不容易优化。
我把这些嵌套子查询,改成了CTE(Common Table Expression,公共表表达式),也就是WITH子句。CTE可以把复杂的查询,拆分成多个简单的部分,每个部分有一个名字,可读性很好,而且PostgreSQL的优化器对CTE的优化也很好。
比如,一个嵌套了三层子查询的查询:
SELECT * FROM (
SELECT * FROM (
SELECT * FROM orders WHERE status = 'paid'
) t1 WHERE create_time >= '2020-01-01'
) t2 WHERE total > 100;改成CTE之后:
WITH paid_orders AS (
SELECT * FROM orders WHERE status = 'paid'
),
recent_orders AS (
SELECT * FROM paid_orders WHERE create_time >= '2020-01-01'
)
SELECT * FROM recent_orders WHERE total > 100;这样,每一步都很清晰,一看就知道查询的逻辑是什么,可读性好了很多。
而且,CTE还可以递归,用来处理树形结构、层级关系等,非常方便。
4. 用窗口函数代替复杂的子查询
项目里有很多查询,需要做排名、分组取Top N、累计求和等,以前都是用很复杂的子查询或者存储过程来实现,代码很长,性能也不好。
我把这些查询,改成了用窗口函数(Window Function)来实现,代码简洁了很多,性能也提升了。
比如,查询每个用户金额最大的三个订单,以前要用子查询:
SELECT * FROM orders o1
WHERE (
SELECT COUNT(*) FROM orders o2
WHERE o2.user_id = o1.user_id AND o2.total > o1.total
) < 3
ORDER BY user_id, total DESC;用窗口函数之后:
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) as rn
FROM orders
) t
WHERE rn <= 3
ORDER BY user_id, total DESC;代码简洁了很多,而且性能更好,因为窗口函数只需要扫描一次表,而子查询需要相关子查询,扫描多次。
PostgreSQL支持很多窗口函数,比如ROWNUMBER、RANK、DENSERANK、SUM、AVG、LAG、LEAD等,能满足大部分的分析需求。
5. 用参数化查询代替动态SQL
项目里有很多存储过程,用了动态SQL,也就是字符串拼接SQL,然后用EXECUTE执行。动态SQL很灵活,但也有很多问题:可读性差、容易有SQL注入风险、性能不好(每次都要重新解析)、调试困难。
我把大部分动态SQL,都改成了参数化查询,用EXECUTE ... USING来传递参数,而不是字符串拼接。这样,既避免了SQL注入,又提升了性能(可以缓存执行计划),可读性也更好。
比如,以前的动态SQL:
EXECUTE 'SELECT * FROM users WHERE name = ''' || p_name || '''';改成参数化查询:
EXECUTE 'SELECT * FROM users WHERE name = $1' USING p_name;这样,更安全,更高效,也更易读。
当然,有些场景确实需要动态SQL,比如表名、字段名是动态的,这时候还是要用动态SQL,但要注意安全,验证输入,避免SQL注入。
6. 添加注释
前面提到了,项目里的代码,大部分都没有注释。我在重构的过程中,给所有的存储过程、函数、视图、复杂查询,都加上了详细的注释。
注释包括:
- 对象的功能说明:这个存储过程/函数/视图是干什么的。
- 参数说明:每个参数是什么意思,类型是什么,取值范围是什么。
- 返回值说明:返回什么,格式是什么。
- 业务逻辑说明:关键的业务逻辑,为什么这么写,有什么注意事项。
- 修改记录:什么时候修改的,修改了什么,为什么修改。
加了注释之后,代码的可读性大大提升,后来的维护者,一看注释就知道代码是干什么的,不需要再一行一行地读代码去理解了。
我一直觉得,注释是代码的一部分,好的注释,能让代码的价值翻倍。写注释,不是浪费时间,而是节省时间,节省后来维护者的时间,也节省自己以后的时间。
五、第四阶段:性能优化和测试
代码重构完之后,第四阶段,就是全面的性能优化和测试,确保重构后的代码,功能正确,性能更好,没有引入新的问题。
1. 全面性能测试
我对重构后的所有存储过程、函数、复杂查询,都做了全面的性能测试,对比重构前后的执行时间,确保性能没有下降,而且大部分都有提升。
测试的方法:
- 用
EXPLAIN ANALYZE查看执行计划,对比重构前后的成本和执行时间。 - 用
pgstatstatements统计所有SQL的执行时间,对比重构前后的总时间和平均时间。 - 模拟真实的业务场景,做压力测试,看看在高并发下的表现。
测试结果,重构后的代码,整体性能提升了3-5倍,有的查询甚至提升了几十倍。而且,代码的可读性和可维护性,也大大提升了。
2. 功能测试
除了性能测试,我还做了全面的功能测试,确保重构后的代码,功能和重构前一致,没有引入新的bug。
测试的方法:
- 单元测试:对每个小的存储过程和函数,写单元测试,验证输入输出是否正确。
- 集成测试:对整个业务流程,做集成测试,验证各个存储过程之间的调用是否正确。
- 对比测试:用重构前的代码和重构后的代码,处理同样的输入,对比输出是否一致。
- 灰度发布:先在测试环境验证,然后在生产环境灰度发布,让一小部分用户先用,观察有没有问题,没问题再全量发布。
通过全面的测试,确保了重构后的代码,功能正确,没有引入新的问题。
3. 文档和培训
重构完成之后,我还整理了详细的文档,包括数据库结构文档、存储过程文档、视图文档、命名规范、编码规范等,方便后来的维护者参考。
同时,我还给团队做了培训,讲解重构后的代码结构、命名规范、编码规范,以及如何维护和扩展这些代码。确保团队的每个人,都能理解和接受新的代码结构,按照新的规范来写代码。
如果只有我一个人按照新规范写,其他人还是按照老规范写,那过不了多久,代码又会变回老样子。所以,文档和培训很重要,要让整个团队都达成共识,一起维护好代码质量。
六、重构的经验和感悟
经过这一个月的重构,我有很多经验和感悟,分享给大家。
1. 重构不是推倒重来,而是循序渐进
很多人一提到重构,就觉得要推倒重来,把所有代码都重写一遍。其实不是这样的,重构应该是循序渐进的,在现有代码的基础上,一步一步地优化,小步快跑,每次只改一部分,每次改完都能运行,都能测试。
推倒重来的风险很大,很容易改出问题,而且业务还在迭代,不可能停下来等你重写。循序渐进的重构,风险可控,而且可以在重构的同时,不影响业务的正常迭代。
2. 先理解,再动手
重构之前,一定要先理解现有代码,理解业务逻辑,理解数据结构。如果不理解就动手改,很容易改出问题,甚至把系统改崩。
理解代码的过程,可能很枯燥,很费时间,但这是必要的,是重构的基础。只有理解了,才能知道哪些可以改,哪些不能改,怎么改才是对的。
3. 有测试保障,才能放心重构
重构,最怕的就是改出问题,把原来正常的功能改坏了。所以,一定要有测试保障,有了测试,才能放心地重构,改完之后跑一遍测试,就知道有没有改坏。
如果项目里没有测试,那重构之前,最好先补一些测试,至少对核心的业务逻辑,要有测试。有了测试保障,重构的风险就小很多。
4. 命名和注释,是最便宜也最有效的优化
很多人觉得,重构就是要改逻辑,改算法,提升性能。其实,命名和注释,是最便宜也最有效的优化。好的命名,能让代码一看就懂;好的注释,能让代码的意图一目了然。
命名和注释,不需要改逻辑,不需要改算法,只需要花一点时间,就能让代码的可读性大大提升,维护成本大大降低。这是性价比最高的重构,建议大家从这里开始。
5. 重复代码是万恶之源
重复代码,是很多问题的根源。同样的逻辑,在多个地方重复,改一个地方,其他地方也要改,很容易漏改,导致bug。而且,重复代码会让代码量变大,可读性变差,维护成本变高。
所以,重构的时候,一定要消除重复代码,把公共的逻辑抽取成函数或者视图,在多个地方复用。消除了重复代码,代码质量会提升一大截。
6. 性能优化要有数据支撑,不要凭感觉
性能优化,不要凭感觉,觉得哪里慢就改哪里。一定要有数据支撑,用工具找出真正的性能瓶颈,然后针对性地优化。
很多时候,你觉得慢的地方,可能并不是瓶颈;而你觉得没问题的地方,可能才是真正的瓶颈。用数据说话,才能找到真正的问题,优化才能见效。
7. 重构是持续的,不是一次性的
重构,不是一次性的工作,不是重构完了就完事了。重构是持续的,在后续的开发中,要持续地维护代码质量,看到不好的代码,就顺手改一下,不要让烂代码继续积累。
如果重构完了,后续开发还是按照老习惯写,那过不了多久,代码又会变回老样子。所以,要建立代码规范,建立代码评审机制,在日常开发中,持续地维护代码质量。
七、写在最后
经过一个月的努力,这个老项目的PostgreSQL代码,终于从烂代码变成了优雅代码。代码量减少了三分之一,可读性大大提升,维护成本大大降低,性能也提升了3-5倍。
这次重构,让我深刻地体会到,代码质量的重要性。烂代码,短期来看,好像节省了时间,但长期来看,维护成本很高,bug很多,性能很差,最终会拖慢整个项目的进度。而优雅的代码,虽然写的时候多花了一点时间,但长期来看,维护成本低,bug少,性能好,最终会节省很多时间。
所以,我们在写代码的时候,不要只图快,还要注重质量。写优雅的代码,写可维护的代码,写有注释的代码,这是对自己负责,也是对团队负责,对项目负责。
希望这次重构的经验和方法,能对大家有所帮助。如果你也在维护一个老项目,也在面对一堆烂代码,不要气馁,循序渐进地重构,一步一步地优化,相信你也能把烂代码变成优雅代码。
如果有什么问题或者不同的看法,欢迎在评论区交流。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录