数据库代码,是很多系统的核心,但也是最容易被忽视、最容易变烂的地方。

我所在的团队,维护着一个已经跑了五六年的系统,数据库用的是PostgreSQL。这些年,需求不断变化,功能不断增加,数据库里的SQL、存储过程、视图、函数,也越来越多,越来越乱。

最开始的时候,数据库代码还比较整洁,SQL写得也比较规范。但随着时间的推移,开发者换了一拨又一拨,每个人都往里面加自己的代码,没有人愿意去整理和重构。慢慢地,数据库代码就变得越来越烂:SQL写得又长又复杂,嵌套了好几层子查询;索引建了一堆,很多没用,有用的反而没建;存储过程几百行,逻辑混乱,没人敢改;视图套视图,套了三四层,查询慢得要死。

结果就是,系统越来越慢,很多接口响应时间超过几秒,甚至十几秒;数据库CPU经常飙到100%,连接数经常打满;出了问题,排查起来很困难,因为SQL太复杂了,看都看不懂。

我们决定,借着升级到PostgreSQL 17的机会,对数据库代码做一次彻底的重构。把烂SQL重写成优雅SQL,把没用的索引删掉,把该建的索引建上,把混乱的存储过程重写,把嵌套的视图拍平。

重构完成之后,数据库的性能提升了好几倍,慢查询基本消失了,代码也变得清晰、易读、易维护。

这篇文章我想分享一下这次数据库代码重构的经验。从慢SQL分析、索引优化、查询重构到存储过程、事务设计、架构优化,通过实际案例,展示如何把烂数据库代码重构成优雅、高效、可维护的代码。如果你也在维护一个有历史包袱的数据库,或者正在做数据库性能优化,希望这篇文章能给你一些参考。

问题分析:烂数据库代码长什么样

在开始重构之前,我们先对现有的数据库代码做了一次全面的分析,看看问题到底出在哪里。

分析下来,主要有以下几类问题。

第一类问题:SQL写得又长又复杂,可读性差。

很多SQL,写得又长又复杂,一个查询有几百行,嵌套了好几层子查询,还有各种JOIN、UNION、聚合。看这样的SQL,就像看天书一样,要花很长时间才能理解它在做什么。

比如,有一个统计订单的SQL,嵌套了四层子查询,每层都有JOIN和WHERE,整个SQL有两百多行。我们花了半天时间,才弄明白它其实就是在统计每个用户的订单数量和总金额。这么简单的需求,被写成了两百多行的复杂SQL,真是让人哭笑不得。

这样的SQL,不仅可读性差,性能也差。因为太复杂了,优化器很难生成最优的执行计划,经常会选择错误的执行路径,导致查询很慢。

第二类问题:索引混乱,该建的没建,不该建的建了一堆。

索引是数据库性能的关键,但很多人对索引的理解很肤浅,觉得建索引就能提速,于是不管什么字段都建索引。结果就是,一个表上建了十几个甚至几十个索引,很多都是重复的、没用的。

比如,有一个用户表,建了十几个索引,其中有好几个都是单列索引,字段是status、type、is_deleted这种区分度很低的字段。这些索引,不仅没用,还会拖慢INSERT、UPDATE、DELETE的速度,因为每次写操作都要更新所有的索引。

而真正需要的索引,反而没建。比如,很多查询都是按createtime范围查询,再按userid过滤,但没有建(createtime, userid)的联合索引,导致查询的时候全表扫描,很慢。

第三类问题:视图套视图,嵌套太深。

视图本来是为了简化查询,把复杂的查询封装起来,方便复用。但如果用得不好,视图套视图,套了三四层,就会变成性能灾难。

比如,有一个订单统计的需求,最底层是一个订单明细表的视图,上面套了一个订单汇总的视图,再上面套了一个按用户统计的视图,最上面还有一个按时间统计的视图。四层视图套在一起,查询的时候,优化器根本没法优化,只能一层层地执行,性能很差,查一次要十几秒。

而且,视图套视图,可读性也很差。出了问题,要一层层地往下找,才能找到最底层的表,排查起来很费劲。

第四类问题:存储过程冗长,逻辑混乱。

存储过程,本来是为了把复杂的业务逻辑封装在数据库里,提高执行效率。但如果写得不好,存储过程会变成维护的噩梦。

我们有一个存储过程,有五百多行,里面什么逻辑都有:数据校验、业务处理、异常捕获、日志记录,甚至还有一些和业务无关的临时调试代码。变量命名很随意,有的用a、b、c,有的用拼音,有的用英文缩写。逻辑也很混乱,到处都是GOTO和嵌套的IF-ELSE,看了前面忘了后面。

这样的存储过程,没人敢改,因为改了不知道会出什么问题。但需求又在变,只能在原来的基础上继续加IF-ELSE,继续堆代码,越堆越乱,形成恶性循环。

第五类问题:事务设计不合理,锁冲突严重。

事务是数据库的重要特性,但如果设计不好,会导致严重的锁冲突,影响并发性能。

比如,有一个批量更新的操作,放在一个大事务里,一次更新几万条数据,事务要执行几十秒。在这几十秒里,这些数据都被锁住了,其他的更新操作都要等待,导致大量的锁等待,系统并发上不去。

还有一些事务,里面包含了很多不必要的操作,比如查询、计算、甚至调用外部接口,把事务拉得很长。事务越长,锁的持有时间就越长,锁冲突就越严重。

第六类问题:缺乏规范,代码风格不统一。

整个数据库的代码,没有统一的规范。SQL的写法,每个人都不一样:有的关键字大写,有的小写;有的缩进,有的不缩进;有的表名用单数,有的用复数;有的字段名用下划线,有的用驼峰。存储过程的写法,也不统一,有的有注释,有的没有;有的有异常处理,有的没有。

缺乏规范,导致代码的可读性和可维护性都很差。新人接手,要花很长时间才能适应不同的代码风格。出了问题,排查起来也很费劲。

这些问题,不是一天两天形成的,而是几年累积下来的。要解决这些问题,不是改几个SQL就行的,需要一次系统性的重构。

重构的原则和步骤

在开始重构之前,我们先确立了重构的原则和步骤,避免盲目重构,导致问题更多。

重构的原则,有以下几条。

第一,性能优先,同时保证可读性和可维护性。

重构的主要目的,是提升性能,但也不能为了性能,把代码写得晦涩难懂。要在性能和可读性之间找到平衡,写出既高效又优雅的代码。

第二,小步快跑,逐步重构。

数据库代码的重构,风险很大,不能一下子全部推倒重写。要小步快跑,一个模块一个模块地重构,每重构完一个模块,就测试验证,确保没问题了,再重构下一个模块。这样,风险可控,出了问题也容易回滚。

第三,先补测试,再重构。

和应用代码的重构一样,数据库代码的重构,也需要测试来保障。重构之前,先给要重构的SQL、存储过程、视图补上测试,确保重构之后,输出的结果和重构之前一致。有了测试,重构就有了安全网,不用担心改出问题。

第四,不改变业务逻辑,只优化实现方式。

重构的过程中,不改变业务逻辑,只优化实现方式。也就是说,输入相同的情况下,输出必须和原来一致。这样,重构才是安全的,不会影响业务。如果业务逻辑需要变,那是需求变更,不是重构,应该分开做。

第五,做好备份,随时可以回滚。

重构之前,做好数据库的备份,包括表结构、数据、存储过程、视图、函数等。重构的过程中,如果出了问题,可以随时回滚到之前的状态,保证业务不受影响。

重构的步骤,我们是这样安排的。

第一步,全面摸底,建立清单。

先对整个数据库的代码做一次全面的摸底,把所有的SQL、存储过程、视图、函数、索引都列出来,建立一个清单。然后,对每个项进行评估,看看有没有问题,问题有多严重,优先级有多高。

第二步,制定计划,按优先级排序。

根据摸底的结果,制定重构计划,按优先级排序。优先级高的,比如严重影响性能的慢SQL、导致锁冲突的事务,先重构;优先级低的,比如代码风格不统一、注释不全,后重构。

第三步,逐个重构,测试验证。

按照计划,逐个重构。每个项重构之前,先补测试;重构之后,跑测试,确保结果一致;同时,做性能测试,确保性能有提升。测试通过了,再上线;上线之后,监控一段时间,确保没问题。

第四步,建立规范,防止再次变烂。

重构完成之后,建立数据库代码的规范,包括SQL的写法、索引的设计、存储过程的写法、命名规范等。同时,建立Code Review机制,所有的数据库代码变更,都要经过Review,确保符合规范,防止代码再次变烂。

按照这些原则和步骤,我们开始了数据库代码的重构。

慢SQL分析和优化

慢SQL,是最影响系统性能的问题,也是我们重构的重点。我们先从慢SQL开始。

第一步,是找出所有的慢SQL。

PostgreSQL有一个很强大的工具,叫pgstatstatements,可以记录所有SQL的执行情况,包括执行次数、总耗时、平均耗时、最大耗时等。我们开启了pgstatstatements,跑了一周,收集了所有SQL的执行数据。

然后,按总耗时排序,找出最耗时的那些SQL。总耗时,比单次耗时更重要,因为一个SQL单次只需要100毫秒,但一天执行一百万次,总耗时就是10万秒,比一个单次10秒但一天只执行一次的SQL,影响大得多。

找出慢SQL之后,我们用EXPLAIN ANALYZE来分析每个慢SQL的执行计划,看看瓶颈在哪里。

常见的慢SQL原因,有以下几种。

第一种:全表扫描,没有走索引。

这是最常见的慢SQL原因。查询的时候,没有合适的索引,只能全表扫描,表越大,扫描越慢。

比如,有一个查询:

SELECT * FROM orders WHERE user_id = 123 AND status = 'paid' ORDER BY create_time DESC LIMIT 20;

orders表有几百万条数据,但没有建(userid, status, createtime)的联合索引,导致查询的时候全表扫描,要好几秒。

优化的方法,就是建合适的索引。给这个查询建一个联合索引:

CREATE INDEX idx_orders_user_status_time ON orders (user_id, status, create_time DESC);

建了索引之后,查询就走索引了,只要几毫秒,性能提升了几百倍。

建索引的时候,要注意联合索引的字段顺序。一般来说,等值查询的字段放在前面,范围查询的字段放在后面,排序的字段放在最后。这样,索引的效率最高。

第二种:索引选错了,或者索引没用上。

有时候,虽然建了索引,但优化器选错了索引,或者因为某些原因,索引没用上,还是全表扫描。

比如,索引字段上用了函数或者运算,导致索引失效:

SELECT * FROM orders WHERE DATE(create_time) = '2025-11-01';

虽然create_time上有索引,但因为用了DATE()函数,索引就失效了,只能全表扫描。

优化的方法,是避免在索引字段上用函数,改成范围查询:

SELECT * FROM orders WHERE create_time >= '2025-11-01' AND create_time < '2025-11-02';

这样,就能用上索引了。

还有一种情况,是优化器选错了索引。比如,有两个索引,一个区分度高,一个区分度低,优化器选了区分度低的那个,导致性能差。这时候,可以用ANALYZE更新统计信息,让优化器做出更准确的选择;或者,用pghintplan指定用哪个索引。

第三种:不必要的排序,或者排序没法用索引。

ORDER BY是很常见的操作,但如果排序没法用索引,就需要在内存里或者磁盘上排序,数据量大的时候,会很慢。

比如:

SELECT * FROM orders WHERE user_id = 123 ORDER BY amount DESC LIMIT 20;

如果没有建(userid, amount DESC)的联合索引,就需要先查出userid=123的所有订单,然后按amount排序,再取前20条。如果这个用户的订单很多,排序就会很慢。

优化的方法,是建包含排序字段的联合索引,让排序可以用索引完成,不需要额外排序:

CREATE INDEX idx_orders_user_amount ON orders (user_id, amount DESC);

这样,查询的时候,按索引顺序取前20条就行,不需要排序,性能很好。

第四种:JOIN太多,或者JOIN的顺序不对。

有些SQL,JOIN了很多张表,五六张甚至七八张表JOIN在一起。JOIN的表越多,优化器越难选择最优的执行计划,性能也越差。

比如,有一个查询,JOIN了订单表、用户表、商品表、分类表、店铺表、地址表,一共六张表,查询要十几秒。

优化的方法,一是减少JOIN的表,把不需要的表去掉,把可以在应用层做的JOIN放到应用层;二是确保JOIN的字段都有索引;三是如果JOIN的表确实很多,可以考虑用子查询或者CTE,把查询拆分成几步,让优化器更容易优化。

还有一种情况,是JOIN的顺序不对。优化器会根据统计信息选择JOIN的顺序,但如果统计信息不准确,可能会选错。这时候,可以用ANALYZE更新统计信息,或者手动调整JOIN的顺序。

第五种:子查询嵌套太深,或者用了不合适的子查询。

有些SQL,嵌套了好几层子查询,每层都有JOIN和聚合,性能很差。

比如,有一个查询:

SELECT * FROM users WHERE id IN (
    SELECT user_id FROM orders WHERE amount > 1000 AND id IN (
        SELECT order_id FROM order_items WHERE product_id IN (
            SELECT id FROM products WHERE category_id = 5
        )
    )
);

四层子查询嵌套,性能很差。

优化的方法,是把子查询改成JOIN:

SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.amount > 1000 AND p.category_id = 5;

改成JOIN之后,优化器可以更好地优化,性能提升很多。

还有一种情况,是用了IN子查询,但子查询的结果集很大,导致性能差。这时候,可以改成EXISTS,或者改成JOIN,性能会更好。

第六种:聚合查询没有优化。

GROUP BY、COUNT、SUM等聚合查询,如果数据量大,也会很慢。

比如,统计每个用户的订单数量和总金额:

SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount
FROM orders
WHERE create_time >= '2025-01-01'
GROUP BY user_id;

如果orders表有几百万条数据,这个聚合查询会很慢,因为要扫描大量的数据,还要做分组聚合。

优化的方法,一是建合适的索引,比如(createtime, userid, amount)的覆盖索引,这样查询的时候只需要扫描索引,不需要回表,性能会好很多;二是如果数据量特别大,可以考虑用预聚合,比如把统计结果提前算好,存在汇总表里,查询的时候直接查汇总表,不需要实时聚合。

通过这些优化,我们把大部分慢SQL的性能,都提升了几倍甚至几十倍。系统的响应时间,从平均几秒,降到了几百毫秒,用户体验有了质的提升。

索引优化:该建的建,该删的删

索引优化,是数据库性能优化的关键。我们对所有表的索引,做了一次全面的梳理和优化。

第一步,是找出没用的索引,删掉。

PostgreSQL有一个视图,叫pgstatuser_indexes,可以查看每个索引的使用情况,包括索引扫描的次数。如果一个索引,扫描次数是0,或者很少,说明这个索引可能没用,可以考虑删掉。

我们统计了所有索引的使用情况,发现有将近一半的索引,几乎从来没被用过。这些索引,不仅没用,还占用了大量的磁盘空间,拖慢了写操作的速度。

比如,有一个表,有十几个索引,其中有好几个都是单列索引,字段是status、type、is_deleted这种区分度很低的字段。这些索引,几乎从来没被用过,因为优化器知道,用这些索引还不如全表扫描快。

我们把这些没用的索引都删掉了,一共删了几百个索引。删了之后,表的体积减小了很多,写操作的速度也提升了不少。

第二步,是找出重复的索引,合并或者删掉。

有些索引,是重复的,或者是包含关系。比如,有一个(a, b)的联合索引,又有一个(a)的单列索引,那么单列索引就是多余的,因为联合索引已经可以满足a的查询了。

我们找出了所有重复的、包含的索引,把多余的删掉,或者合并成一个更合理的联合索引。

第三步,是为慢查询建合适的索引。

删掉没用的索引之后,我们为之前分析出来的慢查询,建了合适的索引。建索引的时候,遵循以下几个原则。

原则一:优先建联合索引,少建单列索引。

联合索引的效率,比单列索引高,因为它可以同时过滤多个字段,还可以覆盖排序和聚合。而且,一个联合索引,可以满足多个查询的需求,减少索引的数量。

比如,有一个查询是WHERE userid = ? AND status = ? ORDER BY createtime DESC,我们就建一个(userid, status, createtime DESC)的联合索引,而不是分别建三个单列索引。

原则二:索引字段的顺序,要考虑查询的模式。

联合索引的字段顺序,很重要。一般来说,等值查询的字段放在前面,范围查询的字段放在后面,排序的字段放在最后。这样,索引的效率最高。

比如,查询是WHERE userid = ? AND createtime >= ? ORDER BY status,那么索引应该是(userid, createtime, status),而不是其他顺序。

原则三:考虑覆盖索引,减少回表。

如果一个查询,需要的字段都在索引里,那么就不需要回表查询数据,只需要扫描索引就行,性能会好很多。这样的索引,叫覆盖索引。

比如,查询是SELECT userid, SUM(amount) FROM orders WHERE createtime >= ? GROUP BY userid,我们可以建一个(createtime, user_id, amount)的联合索引,这样查询需要的字段都在索引里,不需要回表,性能很好。

原则四:区分度低的字段,不要单独建索引。

区分度低的字段,比如status、type、is_deleted,只有几个取值,单独建索引的效果很差,因为优化器会觉得,用索引还不如全表扫描快。这些字段,可以和其他字段一起建联合索引,放在联合索引的后面。

原则五:不要过度索引。

索引不是越多越好,每个索引都会占用磁盘空间,都会拖慢写操作的速度。所以,只建真正需要的索引,不要为了"可能会用到"而建索引。建了索引之后,要监控它的使用情况,如果长期没用,就删掉。

通过这些优化,我们的索引数量减少了一半,但查询性能反而提升了很多。这说明,索引不在多,而在精。

查询重构:从烂SQL到优雅SQL

优化了索引之后,我们开始重构SQL本身,把烂SQL重写成优雅SQL。

重构SQL的时候,遵循以下几个原则。

原则一:简洁明了,逻辑清晰。

好的SQL,应该是简洁明了的,看一眼就知道它在做什么。不要为了"炫技",把SQL写得很复杂,嵌套很多层,用很多生僻的语法。这样的SQL,虽然可能性能不错,但可读性很差,维护起来很痛苦。

比如,原来的一个统计SQL,嵌套了四层子查询,两百多行,我们把它重写成了一个带CTE的查询,只有三十多行,逻辑清晰,一看就懂。

原则二:用CTE代替嵌套子查询。

CTE(Common Table Expression),也就是WITH子句,可以把复杂的查询拆分成几个有名字的临时结果集,然后在主查询里引用。用CTE代替嵌套子查询,可以让SQL的逻辑更清晰,可读性更好。

比如,原来的嵌套子查询:

SELECT * FROM (
    SELECT user_id, COUNT(*) as cnt FROM orders WHERE status = 'paid' GROUP BY user_id
) t WHERE t.cnt > 10;

用CTE重写:

WITH user_order_count AS (
    SELECT user_id, COUNT(*) as cnt FROM orders WHERE status = 'paid' GROUP BY user_id
)
SELECT * FROM user_order_count WHERE cnt > 10;

用CTE之后,每一步的逻辑都很清晰,先算什么,后算什么,一目了然。

原则三:用JOIN代替IN/EXISTS子查询(或者反过来,看哪种性能好)。

IN和EXISTS子查询,有时候性能不如JOIN,特别是子查询的结果集很大的时候。这时候,可以改成JOIN,性能会更好。

但也不是所有情况都要改成JOIN,有时候EXISTS的性能更好,特别是子查询的结果集很小的时候。所以,要根据实际情况,测试哪种性能更好,就用哪种。

原则四:避免SELECT *,只查需要的字段。

SELECT *会查询所有的字段,增加了数据传输的量,也可能导致无法使用覆盖索引。好的习惯是,只查需要的字段,明确列出来。

比如,不要写SELECT * FROM users,而是写SELECT id, name, email FROM users。

原则五:合理使用LIMIT,避免查询大量数据。

如果不需要所有的数据,就用LIMIT限制返回的行数。比如,分页查询,用LIMIT和OFFSET,每次只查一页的数据。不要一次性查出所有的数据,然后在应用层分页,这样性能很差。

但要注意,OFFSET很大的时候,性能也会很差,因为要扫描前面的所有数据。这时候,可以用游标分页,也就是用上一页的最后一条记录的ID作为条件,查询下一页,避免大OFFSET。

原则六:用UNION ALL代替UNION(如果不需要去重)。

UNION会对结果集去重,需要排序和去重的操作,性能较差。如果确定结果集没有重复,或者不需要去重,就用UNION ALL,性能会好很多。

原则七:注释清晰,说明业务逻辑。

复杂的SQL,要加上注释,说明这个SQL是做什么的,业务逻辑是什么,有什么注意事项。这样,以后维护的人,看注释就能理解,不需要花很长时间去猜。

按照这些原则,我们把几百个烂SQL,重写成了优雅SQL。重写之后,SQL的可读性和可维护性,都有了很大的提升,性能也更好了。

存储过程和视图的重构

除了SQL,我们还重构了存储过程和视图。

先说存储过程的重构。

存储过程的重构,比SQL的重构更复杂,因为存储过程里包含了业务逻辑,还有流程控制、异常处理等。我们重构存储过程的时候,遵循以下几个原则。

原则一:拆分大存储过程,每个存储过程只做一件事。

原来的存储过程,很多都是几百行,什么都做。我们把它们拆分成多个小的存储过程,每个存储过程只做一件事,职责单一。比如,原来的一个"处理订单"的存储过程,包含了订单校验、库存扣减、支付处理、日志记录等,我们把它拆成了validateorder、deductinventory、processpayment、recordlog四个小存储过程,然后在主存储过程里调用它们。

拆分之后,每个存储过程都很短小,逻辑清晰,容易理解和维护,也更容易测试。

原则二:变量命名清晰,有意义。

原来的存储过程,变量命名很随意,有的用a、b、c,有的用拼音,有的用英文缩写。我们把所有的变量,都改成了清晰、有意义的名字,比如vuserid、vorderamount、vtotalcount。看名字就知道变量是做什么的,不需要猜。

原则三:减少全局变量,用参数传递。

原来的存储过程,很多用全局变量来传递数据,导致存储过程之间的耦合度很高,改一个存储过程,可能会影响其他存储过程。我们把全局变量都去掉了,改成用参数传递数据,存储过程之间通过参数通信,耦合度降低了,也更容易测试。

原则四:异常处理统一,有明确的错误信息。

原来的存储过程,有的有异常处理,有的没有;有的异常处理只是简单地捕获,然后什么都不做,导致出了问题都不知道。我们给所有的存储过程,都加上了统一的异常处理,捕获异常之后,记录详细的错误日志,然后抛出有意义的错误信息,方便排查问题。

原则五:加上注释,说明业务逻辑。

存储过程的开头,加上注释,说明这个存储过程是做什么的,参数是什么,返回值是什么,有什么注意事项。关键的业务逻辑,也加上行内注释,说明为什么这么做。

通过这些重构,存储过程的质量有了很大的提升,从没人敢碰的"定时炸弹",变成了清晰、易维护的代码。

再说视图的重构。

视图的重构,主要是解决视图套视图的问题。我们的原则是:尽量不要视图套视图,如果一定要套,最多套一层。

对于套了三四层的视图,我们把它们拍平,重写成一个直接查询基础表的视图,或者直接改成SQL,不再用视图。

比如,原来的一个四层嵌套的视图,我们把它重写成了一个带CTE的查询,直接查询基础表,不再用视图。重写之后,查询性能提升了十几倍,可读性也更好了。

另外,我们也清理了一些没用的视图。有些视图,创建之后就再也没被用过,或者已经被其他方式替代了,我们就把它们删掉了,减少维护的负担。

事务和锁的优化

事务和锁的问题,也是影响数据库并发性能的重要因素。我们对事务和锁,也做了优化。

第一,缩小事务的范围,减少锁的持有时间。

原来的很多事务,范围很大,里面包含了很多不必要的操作,比如查询、计算、甚至调用外部接口,导致事务很长,锁的持有时间很长,锁冲突严重。

我们把事务的范围缩小了,只把真正需要原子性的写操作,放在事务里。查询、计算、外部接口调用,都放到事务外面。这样,事务变短了,锁的持有时间变短了,锁冲突减少了,并发性能提升了。

比如,原来的一个"创建订单"的事务,里面包含了查询用户信息、查询商品信息、计算价格、调用库存接口、调用支付接口、写入订单表、写入订单明细表,整个事务要执行好几秒。我们把它改成了:先在事务外面查询用户信息、商品信息、计算价格,然后在事务里只做写入订单表、写入订单明细表、扣减库存,事务只需要几十毫秒。锁的持有时间大大缩短,并发性能提升了很多。

第二,避免大事务,批量操作分批提交。

原来的一些批量操作,比如批量更新、批量删除,放在一个大事务里,一次更新几万条甚至几十万条数据,事务要执行几十秒甚至几分钟。在这期间,大量的数据被锁住,其他操作都要等待。

我们把大事务拆分成小事务,批量操作分批提交,每批处理几百条或者几千条,提交一次,再处理下一批。这样,每个事务都很小,锁的持有时间很短,不会影响其他操作。

比如,批量更新一百万条数据,我们改成每批更新一千条,分一千批处理,每批一个事务。这样,虽然总时间可能稍微长一点,但不会锁住大量数据,不会影响系统的正常运行。

第三,合理使用隔离级别,避免不必要的锁。

PostgreSQL默认的隔离级别是READ COMMITTED,这个级别对于大多数应用来说,已经足够了。但有些应用,为了"安全",把隔离级别设置成了REPEATABLE READ甚至SERIALIZABLE,导致锁的范围更大,并发性能更差。

我们评估了每个事务的需求,把不必要的高隔离级别,改成了READ COMMITTED。只有真正需要的地方,才用高隔离级别。这样,减少了不必要的锁,提升了并发性能。

第四,优化锁的粒度,避免表锁。

有些操作,会导致表锁,比如ALTER TABLE、DROP TABLE等DDL操作,或者没有索引的UPDATE、DELETE,会全表扫描,锁住整个表。表锁会严重影响并发,因为所有的操作都要等待。

我们优化了这些操作,确保UPDATE、DELETE都有索引,不会全表扫描,不会锁表。DDL操作,尽量用在线DDL(比如CREATE INDEX CONCURRENTLY、pg_repack等),避免长时间锁表。

通过这些优化,数据库的锁冲突大大减少,并发性能提升了很多。数据库的CPU和连接数,也都降下来了,系统运行得更稳定了。

建立规范,防止再次变烂

重构完成之后,最重要的事情,是建立规范,防止代码再次变烂。否则,过不了多久,代码又会回到以前的样子。

我们建立了以下几个规范。

第一,SQL编写规范。

包括:关键字大写,表名和字段名小写,用下划线分隔;缩进统一,用四个空格;SELECT明确列出字段,不用SELECT *;WHERE条件的顺序,和索引的顺序一致;复杂的查询用CTE,不用嵌套子查询;注释清晰,说明业务逻辑等。

第二,索引设计规范。

包括:优先建联合索引,少建单列索引;联合索引的字段顺序,等值查询在前,范围查询在后,排序字段在最后;区分度低的字段,不单独建索引;每个表的索引数量,尽量不超过5个;新建索引,必须说明是为哪个查询服务的;定期清理没用的索引等。

第三,存储过程编写规范。

包括:每个存储过程只做一件事,职责单一;存储过程的长度,尽量不超过100行;变量命名清晰,有意义;参数传递,不用全局变量;有统一的异常处理;有清晰的注释等。

第四,视图使用规范。

包括:尽量不用视图套视图,如果一定要套,最多一层;视图只用于简化查询,不用于封装复杂的业务逻辑;定期清理没用的视图等。

第五,事务设计规范。

包括:事务尽量小,只包含必要的写操作;避免大事务,批量操作分批提交;隔离级别,默认用READ COMMITTED,只有需要的时候才用更高的级别;避免在事务里做耗时的操作,比如外部接口调用等。

除了规范,我们还建立了Code Review机制。所有的数据库代码变更,包括SQL、存储过程、视图、索引、表结构的变更,都要提交Merge Request,经过至少一个人的Review,才能合并。Review的时候,检查是否符合规范,性能是否有问题,逻辑是否正确。

另外,我们还建立了慢查询监控机制。开启pgstatstatements,每天监控慢查询,如果发现新的慢查询,及时分析和优化。这样,就能在问题变得严重之前,就解决掉。

通过这些规范和机制,我们的数据库代码,保持了健康的状态,没有再次变烂。

重构的成果和经验

经过几个月的努力,数据库代码的重构终于完成了。我们来看看成果。

第一,性能大幅提升。

重构之后,数据库的平均查询响应时间,从原来的1.2秒,降到了80毫秒,提升了15倍。慢查询(超过1秒的查询),从每天几千个,降到了每天几个,基本消失了。数据库的CPU使用率,从平均70%,降到了20%;连接数,从平均几百,降到了几十。系统运行得非常流畅,再也没有出现过因为数据库慢导致的系统卡顿。

第二,代码质量大幅提升。

重构之后,数据库代码的结构清晰了,SQL简洁了,存储过程短小了,索引合理了。代码的可读性和可维护性,都有了很大的提升。新人接手,看规范和代码,很快就能上手,不需要像以前那样,花好几天去理清混乱的代码。

第三,系统稳定性提升了。

因为性能好了,锁冲突少了,系统的稳定性也提升了。以前,经常出现因为数据库慢、锁冲突导致的接口超时、系统不可用;重构之后,这些问题基本消失了,系统运行得很稳定,故障率大大降低。

第四,开发效率提升了。

因为代码清晰了,规范建立了,开发新功能的时候,写SQL、建索引、写存储过程,都有章可循,不需要花很多时间去研究旧代码,也不用担心改出问题。开发效率提升了很多,需求的交付速度也更快了。

这次重构,也让我们总结了一些经验。

第一,数据库代码和应用代码一样,需要持续维护和重构。

很多人觉得,数据库代码,只要能跑就行,不需要像应用代码那样注重质量和规范。但实际上,数据库代码是系统的核心,它的质量,直接影响系统的性能和稳定性。烂的数据库代码,短期看是快了,但长期看,会成为系统的瓶颈,维护成本很高。所以,数据库代码,也要像应用代码一样,注重质量,持续维护和重构。

第二,性能优化,要先测量,再优化。

和应用代码的性能优化一样,数据库的性能优化,也要先测量,找到真正的瓶颈,再针对性地优化。不要凭感觉优化,觉得哪里慢就改哪里,那样往往效果不好,还可能引入新的问题。用pgstatstatements、EXPLAIN ANALYZE等工具,找到真正的慢SQL和瓶颈,然后优化,效果才会好。

第三,索引不在多,而在精。

很多人觉得,索引越多,查询越快。但实际上,索引太多,不仅没用,还会拖慢写操作,占用大量磁盘空间。索引的关键,是合理,是为查询服务。该建的建,不该建的不建,没用的删掉。索引数量少了,但查询性能反而更好了。

第四,重构要有计划、有步骤,不能盲目。

数据库代码的重构,风险很大,不能盲目地全部推倒重写。要有计划、有步骤地进行,先摸底,再制定计划,然后逐个重构,每个都测试验证。小步快跑,风险可控,才能保证重构的成功。

第五,建立规范和机制,防止再次变烂。

重构完成之后,最重要的是建立规范和机制,防止代码再次变烂。否则,过不了多久,又会回到以前的样子。规范、Code Review、慢查询监控,这些机制,能让代码保持健康,持续产生价值。

写在最后

PostgreSQL 17+的代码重构,是我们做过的最有价值的事情之一。

它不仅提升了数据库的性能,让系统运行得更流畅、更稳定,也提升了代码的质量,让开发更高效、更愉快。更重要的是,它让我们认识到了数据库代码质量的重要性,建立了规范和机制,让系统能够长期健康地发展。

数据库,是很多系统的核心。数据库代码的质量,直接决定了系统的上限。烂的数据库代码,会成为系统的瓶颈,限制系统的发展;优雅的数据库代码,会成为系统的基石,支撑系统的发展。

所以,不要忽视数据库代码的质量。当你发现数据库里的SQL越来越乱,性能越来越差的时候,不要只是加机器、加缓存,要停下来,好好重构一下数据库代码。从慢SQL分析,到索引优化,到查询重构,到存储过程和视图的重构,到事务和锁的优化,一步步来,你会发现,系统的性能和可维护性,都会有质的提升。

当然,数据库代码的重构,不是一次就能完成的,也不是一劳永逸的。需求在变,数据在涨,代码也会不断地腐化。所以,要把数据库代码的维护和优化,当成日常工作的一部分,持续进行,让数据库代码始终保持优雅和高效。

最后用一句话来结束这篇文章:"数据库是系统的心脏,优雅的数据库代码,是系统健康跳动的保障。"

愿每一个开发者,都能写出优雅、高效、可维护的数据库代码,让系统跑得更快、更稳、更远。