PostgreSQL学徒

修改列类型又掉坑了!

前言

今天一位同事火急火燎地找过来,说线上有个 SQL 将 CPU 打爆了,紧急 kill 了之后,手动收集了一下统计信息才慢慢恢复,后面经过复盘,发现昨晚开发修改了字段的长度,扩长,而正是这么一个不太起眼的操作导致了今天的血案,让我们一起看看这个有趣的案例!

现象

如前面所述,开发修改了字段类型的长度之后,导致SQL的执行计划发生了改变。让我们复现一下 👇🏻

postgres=# create table t(id int,info varchar(10));
CREATE TABLE
postgres=# create index on t(info);
CREATE INDEX
postgres=# insert into t select n,left(md5(random()::text),10) from generate_series(1,100000) as n;
INSERT 0 100000
postgres=# analyze t;
ANALYZE
postgres=# explain select id,info from t where info = 'hello';
                             QUERY PLAN                              
---------------------------------------------------------------------
 Index Scan using t_info_idx on t  (cost=0.42..8.44 rows=1 width=15)
   Index Cond: ((info)::text = 'hello'::text)
(2 rows)

postgres=# select pg_relation_filepath('t');
 pg_relation_filepath 
----------------------
 base/13892/2284498
(1 row)

postgres=# select pg_relation_filepath('t_info_idx');
 pg_relation_filepath 
----------------------
 base/13892/2284501
(1 row)

至此还没有任何问题,优化器选择了索引扫描。现在让我们模拟一下昨晚开发做的事情,修改了列的长度(关于修改列长度的注意事项在之前的文章有介绍过,这里就不再重复了)

那么同样操作一下:

postgres=# alter table t alter COLUMN info type varchar(20);
ALTER TABLE
postgres=# explain select id,info from t where info = 'hello';
                                 QUERY PLAN                                 
----------------------------------------------------------------------------
 Bitmap Heap Scan on t  (cost=16.29..574.78 rows=500 width=62)
   Recheck Cond: ((info)::text = 'hello'::text)
   ->  Bitmap Index Scan on t_info_idx  (cost=0.00..16.17 rows=500 width=0)
         Index Cond: ((info)::text = 'hello'::text)
(4 rows)

postgres=# select pg_relation_filepath('t');    ---表未重写
 pg_relation_filepath 
----------------------
 base/13892/2284498
(1 row)

postgres=# select pg_relation_filepath('t_info_idx');  ---索引也未重写
 pg_relation_filepath 
----------------------
 base/13892/2284501
(1 row)

可以看到索引和表没有发生重写,但是执行计划发生了改变!并且预估的行数由最开始的 1 行变成了 500 行!根据过往经验,这个 500 很像是默认选择率的结果,reltuples * DEFAULT_EQ_SEL = 100000 * 0.005 = 500

/* default selectivity estimate for equalities such as "A = b" */
#define DEFAULT_EQ_SEL 0.005

/* default selectivity estimate for inequalities such as "A < b" */
#define DEFAULT_INEQ_SEL  0.3333333333333333

那让我们检查一下统计信息

postgres=# select relpages,reltuples from pg_class where relname = 't_info_idx';
 relpages | reltuples 
----------+-----------
        0 |         0
(1 row)

postgres=# select * from pg_stats where tablename = 't' and attname = 'info';
 schemaname | tablename | attname | inherited | null_frac | avg_width | n_distinct | most_common_vals | most_common_freqs | histogram_bounds | correlation | most_common_elems | most_common_elem_freqs | elem_count_histogram 
------------+-----------+---------+-----------+-----------+-----------+------------+------------------+-------------------+------------------+-------------+-------------------+------------------------+----------------------
(0 rows)

果然,又双叒叕是统计信息出问题了,什么都没有了,因此优化器选择了一个默认的选择率,重新收集一下统计信息即可。

postgres=# analyze t;
ANALYZE
postgres=# explain select id,info from t where info = 'hello';
                             QUERY PLAN                              
---------------------------------------------------------------------
 Index Scan using t_info_idx on t  (cost=0.42..8.44 rows=1 width=15)
   Index Cond: ((info)::text = 'hello'::text)
(2 rows)

深入分析

至此此次案例的原因分析清除了。那让我们再深入一下,为何会有这样的操作?老样子当我们不知道代码跑了哪些流程,用原始办法全部抓下来即可

[root@xiongcc postgres]# cat stap.out | sort | uniq  | grep -i statistic
postgres: RemoveStatistics
postgres: transformExtendedStatistics

代码在tablecmd.c里面,RemoveStatistics,很清晰根据名字就可知晓其作用:移除统计信息。

 /*
  * Drop any pg_statistic entry for the column, since it's now wrong type
    因为已经是一个错误的类型了,所以删除pg_statistic中关于此列的信息
  */

 RemoveStatistics(RelationGetRelid(rel), attnum);
/*
 * RemoveStatistics --- remove entries in pg_statistic for a rel or column
 *
 * If attnum is zero, remove all entries for rel; else remove only the one(s)
 * for that column.
 */

void
RemoveStatistics(Oid relid, AttrNumber attnum)
{
 Relation pgstatistic;
 SysScanDesc scan;
 ScanKeyData key[2];
 int   nkeys;
 HeapTuple tuple;

  ...
  /* we must loop even when attnum != 0, in case of inherited stats */
 while (HeapTupleIsValid(tuple = systable_getnext(scan)))  ---删除系统表元组
  CatalogTupleDelete(pgstatistic, &tuple->t_self);

    ...

因此按照我们的分析,不管表上有没有索引,都会移除统计信息,此次案例只是刚好有了一个索引。让我们验证一下:

postgres=# create table t2(info varchar(10));
CREATE TABLE
postgres=# insert into t2 select left(md5(random()::text),10) from generate_series(1,10000) as b;
INSERT 0 10000
postgres=# analyze t2;
ANALYZE
postgres=# select count(*) from pg_stats where tablename = 't2' and attname = 'info';
 count 
-------
     1
(1 row)

postgres=# alter table t2 alter COLUMN info type varchar(30);
ALTER TABLE
postgres=# select count(*) from pg_stats where tablename = 't2' and attname = 'info';
 count 
-------
     0
(1 row)

postgres=# select relpages,reltuples from pg_class where relname = 't2';
 relpages | reltuples 
----------+-----------
       55 |     10000
(1 row)

果然 pg_stats 已经没有了关于此列的统计信息。GDB也证实了这一点 👇🏻

(gdb) c
Continuing.

Breakpoint 1, RemoveStatistics (relid=2284514, attnum=1) at heap.c:3246
3246            pgstatistic = table_open(StatisticRelationId, RowExclusiveLock);
(gdb) bt
#0  RemoveStatistics (relid=2284514, attnum=1) at heap.c:3246
#1  0x00000000006a0a90 in ATExecAlterColumnType (tab=0x24ea638, rel=0x248dab8, cmd=0x24e9f58, lockmode=8) at tablecmds.c:12346
#2  0x0000000000691021 in ATExecCmd (wqueue=0x7ffc43a76918, tab=0x24ea638, cmd=0x24e9f58, lockmode=8, cur_pass=1, context=0x7ffc43a76ab0) at tablecmds.c:4996
#3  0x00000000006905e6 in ATRewriteCatalogs (wqueue=0x7ffc43a76918, lockmode=8, context=0x7ffc43a76ab0) at tablecmds.c:4792
#4  0x000000000068fb30 in ATController (parsetree=0x234ed20, rel=0x248dab8, cmds=0x234ecd0, recurse=true, lockmode=8, context=0x7ffc43a76ab0) at tablecmds.c:4388
#5  0x000000000068f78c in AlterTable (stmt=0x234ed20, lockmode=8, context=0x7ffc43a76ab0) at tablecmds.c:4035
#6  0x00000000008ffb09 in ProcessUtilitySlow (pstate=0x24e9ce8, pstmt=0x234f040, queryString=0x234e028 "alter table t2 alter COLUMN info type varchar(30);", context=PROCESS_UTILITY_TOPLEVEL, params=0x0, queryEnv=0x0, dest=0x234f120, qc=0x7ffc43a770b0)
    at utility.c:1317
#7  0x00000000008ff514 in standard_ProcessUtility (pstmt=0x234f040, queryString=0x234e028 "alter table t2 alter COLUMN info type varchar(30);", readOnlyTree=false, context=PROCESS_UTILITY_TOPLEVEL, params=0x0, queryEnv=0x0, dest=0x234f120, 
    qc=0x7ffc43a770b0)
 at utility.c:1066
#8  0x00000000008fe715 in ProcessUtility (pstmt=0x234f040, queryString=0x234e028 "alter table t2 alter COLUMN info type varchar(30);", readOnlyTree=false, context=PROCESS_UTILITY_TOPLEVEL, params=0x0, queryEnv=0x0, dest=0x234f120, qc=0x7ffc43a770b0)
    at utility.c:527
#9  0x00000000008fd65b in PortalRunUtility (portal=0x23b0648, pstmt=0x234f040, isTopLevel=true, setHoldSnapshot=false, dest=0x234f120, qc=0x7ffc43a770b0) at pquery.c:1155
#10 0x00000000008fd854 in PortalRunMulti (portal=0x23b0648, isTopLevel=true, setHoldSnapshot=false, dest=0x234f120, altdest=0x234f120, qc=0x7ffc43a770b0) at pquery.c:1312
#11 0x00000000008fce39 in PortalRun (portal=0x23b0648, count=9223372036854775807, isTopLevel=true, run_once=true, dest=0x234f120, altdest=0x234f120, qc=0x7ffc43a770b0) at pquery.c:788
#12 0x00000000008f6f63 in exec_simple_query (query_string=0x234e028 "alter table t2 alter COLUMN info type varchar(30);") at postgres.c:1214
#13 0x00000000008fb20b in PostgresMain (argc=1, argv=0x7ffc43a77340, dbname=0x2378658 "postgres", username=0x2378638 "postgres") at postgres.c:4486
#14 0x000000000084c992 in BackendRun (port=0x23700a0) at postmaster.c:4530
#15 0x000000000084c318 in BackendStartup (port=0x23700a0) at postmaster.c:4252
#16 0x0000000000848a34 in ServerLoop () at postmaster.c:1745
#17 0x0000000000848315 in PostmasterMain (argc=3, argv=0x2348bf0) at postmaster.c:1417
#18 0x000000000075936d in main (argc=3, argv=0x2348bf0) at main.c:209

还有一些其他 case 各位同学就各自测试一下吧

postgres=# create table t2(info varchar(10));         ---修改字段类型会
CREATE TABLE
postgres=# insert into t2 select left(md5(random()::text),10) from generate_series(1,10000) as b;
INSERT 0 10000
postgres=# analyze t2;
ANALYZE
postgres=# select count(*) from pg_stats where tablename = 't2' and attname = 'info';
 count 
-------
     1
(1 row)

postgres=# alter table t2 alter COLUMN info type text;
ALTER TABLE
postgres=# select count(*) from pg_stats where tablename = 't2' and attname = 'info';
 count 
-------
     0
(1 row)

postgres=# create table t2(info varchar(10) default null);   ---删除默认值不会
CREATE TABLE
postgres=# insert into t2 select left(md5(random()::text),10) from generate_series(1,10000) as b;
INSERT 0 10000
postgres=# analyze t2;
ANALYZE
postgres=# select count(*) from pg_stats where tablename = 't2' and attname = 'info';
 count 
-------
     1
(1 row)

postgres=# alter table t2 alter COLUMN info drop default ;
ALTER TABLE
postgres=# select count(*) from pg_stats where tablename = 't2' and attname = 'info';
 count 
-------
     1
(1 row)

根据此函数的调用链,我大致看了一下哪些场景会删除统计信息

Image

  • index_drop,删除索引
  • ALTER COLUMN .. SET DATA TYPE,修改列的类型
  • ALTER TABLE DROP COLUMN ,删除列
  • drop index/constraints,删除索引和约束等

小结

可以看到,修改一个列的类型里面隐藏了太多太多的学问,总结一下:

  • 字段长度或者是精度标度由小变大,可以不需要重写。但是注意对于int4到int8这种转化,还是需要重写的,因为底层存储不一样。
  • 修改非索引列,字段长度由小改大,不会发生重写,假如由大改小,整个分区表所有子表和索引都会发生重写,包括普通表
  • 修改索引列,对于分区表有所出入,字段长度由小改大,虽然所有子表不会重写,但是所有索引会重写!由大改小规则不变,整个分区表和索引都会发生重写
  • 修改列的类型,包括长度、类型,需要重新收集统计信息

真是个有趣的案例。另外 PostgreSQL Architecture 高清无水印大图已经印刷好了,在下一期的官方 PostgreSQL weekly 相信各位就能看到这封大图的消息了,没错我捐献给了社区 ~ 今天疫情解封了,后面安排邮寄

Image