一、问题引入:好好的分区表为啥突然变慢了

做数据库开发的朋友大概率都踩过这样的坑:一开始为了处理海量数据,特意给表做了分区,比如按月份把数据拆成12个小分区,平时查当月数据,快得飞起;结果某天换了个查询方式,速度直接掉成渣,查一次要好几分钟,跟没分区似的。

我之前帮一家做用户行为分析的公司排查过类似问题:他们存用户访问日志的表按天分区,每天生成一个新分区,数据量最大的分区有200万条,总数据量超过1亿条。一开始用固定日期查询的时候,比如查2024-05-01的数据,查询时间不到1秒;后来业务需要动态传参,比如前端选日期范围、或者用变量传日期,结果查询时间直接飙升到30秒以上,甚至有时候会扫全部分区。

当时他们的开发第一反应是索引坏了,重建索引、调整索引都没用,后来查执行计划才发现:原来查询的时候根本没用到分区裁剪,相当于把1亿条数据全扫了一遍,慢是必然的。

二、问题拆解:分区裁剪为啥会失效

要解决这个问题,得先搞懂两个基础概念:分区裁剪、约束排除。

2.1 什么是分区裁剪

分区裁剪说白了就是数据库的“聪明程度”:它能根据查询条件,判断哪些分区根本没有符合条件的数据,直接跳过不查,只扫有用的分区。比如按天分区的表,查2024-05-01的数据,数据库就只扫2024-05-01对应的那个分区,其他分区全跳过,速度自然快。

2.2 分区裁剪失效的核心原因

之前那家公司的问题,本质是动态SQL参数导致分区裁剪失效。我们先看他们当时用的代码(先明确技术栈:PostgreSQL 14):

-- 技术栈:PostgreSQL 14
-- 定义动态参数:前端传的日期范围
DO $$
DECLARE
    v_start_date DATE := '2024-05-01'; -- 模拟前端传参的变量
    v_end_date DATE := '2024-05-07';
    v_query_sql TEXT;
BEGIN
    -- 拼接动态SQL,查询指定日期范围的访问日志
    v_query_sql := 'SELECT COUNT(*) FROM user_access_log WHERE access_time BETWEEN $1 AND $2';
    -- 执行动态SQL,传参数
    EXECUTE v_query_sql USING v_start_date, v_end_date;
END $$;

这段代码看起来没毛病,参数传的也对,但查执行计划就会发现:数据库把所有分区都扫了。原因是PostgreSQL的优化器在执行动态SQL的时候,会把参数当成“未知值”处理——它不知道参数具体是多少,自然不敢随便跳过分区,怕漏了数据。

这就好比你要找“身高170cm的人”,如果明确知道范围是169-171,你会直接翻这几页;但如果范围是“某个变量X到Y”,你不知道X和Y具体是多少,只能把整本书都翻一遍。

三、解决方案:两步让分区裁剪重新生效

针对这个问题,有两个核心解决办法:调整约束排除、把参数直接拼到SQL里(也就是“参数绑定”),我们一步步来。

3.1 第一步:调整约束排除,让优化器敢跳分区

PostgreSQL里有个叫“约束排除”的配置,默认是打开的,但动态SQL的时候优化器还是不敢用,我们可以调整它的配置,强制优化器在执行前检查约束。

首先,先给分区表加约束(如果之前没加的话)。比如按天分区的表,每个分区都应该有一个约束,明确这个分区的日期范围:

-- 技术栈:PostgreSQL 14
-- 给2024-05-01的分区加约束:access_time必须在2024-05-01当天
ALTER TABLE user_access_log_20240501 ADD CONSTRAINT ck_access_time_20240501 CHECK (access_time >= '2024-05-01' AND access_time < '2024-05-02');
-- 给2024-05-02的分区加约束,以此类推
ALTER TABLE user_access_log_20240502 ADD CONSTRAINT ck_access_time_20240502 CHECK (access_time >= '2024-05-02' AND access_time < '2024-05-03');

加完约束后,调整PostgreSQL的配置,让优化器在执行动态SQL的时候也检查约束。这里要注意,配置分全局和会话级,全局改的话所有查询都会生效,会话级只改当前会话的配置,更安全:

-- 技术栈:PostgreSQL 14
-- 会话级调整约束排除配置:让优化器检查动态SQL的约束
SET constraint_exclusion = 'on'; -- 强制打开约束排除
SET enable_partition_pruning = 'on'; -- 强制打开分区裁剪(PostgreSQL 12+默认开,但动态SQL可能失效)

改完配置后,再执行之前的动态SQL,查执行计划就会发现:数据库只会扫2024-05-01到2024-05-07对应的7个分区,其他分区全跳过,速度直接回到1秒以内。

3.2 第二步:参数绑定,把参数直接拼到SQL里

调整约束排除是一个办法,但有时候会有副作用:比如约束太复杂,优化器检查的时候会耗时,反而影响速度。这时候可以用“参数绑定”的办法,也就是把动态参数直接拼到SQL字符串里,让优化器在执行前就知道参数的具体值。

我们改一下之前的代码:

-- 技术栈:PostgreSQL 14
-- 定义动态参数
DO $$
DECLARE
    v_start_date DATE := '2024-05-01';
    v_end_date DATE := '2024-05-07';
    v_query_sql TEXT;
BEGIN
    -- 把参数直接拼到SQL里,而不是用占位符$1、$2
    v_query_sql := 'SELECT COUNT(*) FROM user_access_log WHERE access_time BETWEEN ''' || v_start_date || ''' AND ''' || v_end_date || '''';
    -- 执行动态SQL,不需要再传参数
    EXECUTE v_query_sql;
END $$;

这段代码的核心是把参数直接拼到SQL字符串里,比如v_start_date是'2024-05-01',拼完的SQL就是:

SELECT COUNT(*) FROM user_access_log WHERE access_time BETWEEN '2024-05-01' AND '2024-05-07'

这时候优化器拿到的是一个固定参数的SQL,它能明确知道查询的日期范围,自然就会触发分区裁剪。

这里要注意一个安全问题:如果参数是用户直接传的(比如前端传的日期范围),直接拼接可能会有SQL注入的风险。比如用户传的参数是'2024-05-01' AND 1=1; DROP TABLE user_access_log;--,拼完的SQL就会有问题。所以我们需要对参数做校验,比如用to_date函数把参数转成合法的日期,或者用quote_literal函数转义参数:

-- 技术栈:PostgreSQL 14
-- 用quote_literal转义参数,防止SQL注入
DO $$
DECLARE
    v_start_date DATE := '2024-05-01';
    v_end_date DATE := '2024-05-07';
    v_query_sql TEXT;
BEGIN
    v_query_sql := 'SELECT COUNT(*) FROM user_access_log WHERE access_time BETWEEN ' || quote_literal(v_start_date) || ' AND ' || quote_literal(v_end_date);
    EXECUTE v_query_sql;
END $$;

quote_literal函数会把参数转成合法的字符串,比如如果参数里有单引号,它会自动转义,避免SQL注入。

四、两种方案的对比与适用场景

我们把两种方案的优缺点和适用场景整理一下,方便大家选择:

4.1 约束排除方案

  • 优点:不需要改代码逻辑,只需要调整配置和加约束,适合代码已经写死、不好改的情况;没有SQL注入的风险;
  • 缺点:如果约束太复杂,优化器检查约束会耗时;分区多的时候,检查所有约束也会增加额外开销;
  • 适用场景:分区数量不多(比如按年分区,每年1个分区);约束简单(比如按日期、按数字范围分区);代码不好修改的情况。

4.2 参数绑定方案

  • 优点:优化器能拿到明确的参数值,分区裁剪更彻底;没有额外的约束检查开销;
  • 缺点:需要改代码,把参数拼到SQL里;如果参数处理不好,会有SQL注入的风险;
  • 适用场景:分区数量多(比如按天分区,一年365个分区);约束复杂(比如按多个字段分区);代码可以修改的情况。

五、实际案例:电商订单表的优化

我们再举一个电商的例子,更贴近业务场景:电商的订单表按月份分区,每个月一个分区,总数据量超过5亿条,之前用动态参数查询的时候,速度很慢,我们用两种方案优化。

5.1 问题场景

电商的后台要查询某个用户在2024年5月的订单数量,用动态SQL传用户ID和月份:

-- 技术栈:PostgreSQL 14
DO $$
DECLARE
    v_user_id INT := 12345; -- 动态传的用户ID
    v_month DATE := '2024-05-01'; -- 动态传的月份
    v_query_sql TEXT;
BEGIN
    v_query_sql := 'SELECT COUNT(*) FROM orders WHERE user_id = $1 AND order_time BETWEEN $2 AND $2 + INTERVAL ''1 month''';
    EXECUTE v_query_sql USING v_user_id, v_month;
END $$;

这段代码执行时间超过20秒,查执行计划发现扫了所有分区。

5.2 用约束排除方案优化

首先给每个月份的分区加约束:

-- 技术栈:PostgreSQL 14
ALTER TABLE orders_202405 ADD CONSTRAINT ck_order_time_202405 CHECK (order_time >= '2024-05-01' AND order_time < '2024-06-01');
ALTER TABLE orders_202406 ADD CONSTRAINT ck_order_time_202406 CHECK (order_time >= '2024-06-01' AND order_time < '2024-07-01');

然后调整会话配置:

-- 技术栈:PostgreSQL 14
SET constraint_exclusion = 'on';
SET enable_partition_pruning = 'on';

改完后执行代码,速度降到0.5秒,执行计划显示只扫了2024年5月的分区。

5.3 用参数绑定方案优化

改代码把参数拼到SQL里,同时用quote_literal转义:

-- 技术栈:PostgreSQL 14
DO $$
DECLARE
    v_user_id INT := 12345;
    v_month DATE := '2024-05-01';
    v_query_sql TEXT;
BEGIN
    v_query_sql := 'SELECT COUNT(*) FROM orders WHERE user_id = ' || v_user_id || ' AND order_time BETWEEN ' || quote_literal(v_month) || ' AND ' || quote_literal(v_month + INTERVAL '1 month');
    EXECUTE v_query_sql;
END $$;

改完后执行速度降到0.3秒,比约束排除方案还快,因为没有额外的约束检查开销。

六、注意事项

  1. 分区键的选择:分区键一定要是查询条件里经常用到的字段,比如按时间分区,查询条件经常用到时间范围,这样才能触发分区裁剪;如果分区键是很少用到的字段,分区就没意义了;
  2. 约束的准确性:加约束的时候一定要准确,不能把范围写错,比如把2024-05的约束写成2024-06,会导致查询漏数据;
  3. SQL注入的防范:用参数绑定方案的时候,一定要对参数做校验和转义,比如用quote_literal函数,不能直接拼接用户传的参数;
  4. 配置的影响:全局调整constraint_exclusion的时候,要注意对其他查询的影响,比如有些查询不需要分区裁剪,可能会增加额外开销;
  5. 动态SQL的使用场景:动态SQL适合复杂的查询逻辑,比如拼接不同的查询条件、不同的表名,但如果只是传参数,尽量用普通的SQL,不要用动态SQL,避免分区裁剪失效。

七、总结

分区表的核心优势就是分区裁剪,能大幅提升查询速度,但动态SQL参数会导致优化器无法判断参数的具体值,进而导致分区裁剪失效。解决这个问题有两个核心方案:调整约束排除,让优化器敢跳过分区;参数绑定,把参数直接拼到SQL里,让优化器拿到明确的参数值。

在实际开发中,要根据自己的业务场景选择合适的方案:如果代码不好改,选约束排除;如果代码可以改,选参数绑定,同时注意防范SQL注入。另外,分区键的选择、约束的准确性、配置的影响都是需要注意的细节,只有把这些细节都做好,才能让分区表发挥最大的优势。