为什么要记录统计信息(why)
这里提的统计信息主要是用于选择执行计划的统计信息,不是对系统的监控。
一条SQL在PG中的执行过程是:
----> SQL输入
----> 解析SQL,获取解析后的语法树
----> 分析、重写语法树,获取查询树
----> 根据重写、分析后的查询树计算各路径代价,从而选择一条成本最优的执行树
----> 根据执行树进行执行
----> 获取结果并返回
上图中,生成查询树后,就需要根据统计信息预判生成的各个执行计划的执行代价,这里的代价主要是计算IO代价。即读页面的开销,这可以认为读取的页面数和行数是正相关的。
统计信息主要记录的就是表的行数页面以及不同列不同值的分布关系。
看一个例子
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 1000;
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1 (cost=24.06..394.64 rows=1007 width=244)
Recheck Cond: (unique1 < 1000)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..23.80 rows=1007 width=0)
Index Cond: (unique1 < 1000)
可以看到这个例子会列出不同的算子的预估代价。
记录什么统计信息(what)
根据上面的例子,我们可以推断,统计信息至少要记录行数和页数
SELECT relname, relkind, reltuples, relpages
FROM pg_class
WHERE relname LIKE 'tenk1%';
relname | relkind | reltuples | relpages
----------------------+---------+-----------+----------
tenk1 | r | 10000 | 358
tenk1_hundred | i | 10000 | 30
tenk1_thous_tenthous | i | 10000 | 30
tenk1_unique1 | i | 10000 | 30
tenk1_unique2 | i | 10000 | 30
(5 rows)
如果查询语句包括where这类条件语句,就像上文的例子WHERE unique1 < 1000
,那我们还需要记录数据的分布律,来预计小于1000到底存在多少行的数据,如果大部分数据都小于1000,那么顺序扫描的开销要低于索引扫描。因此pg还统计了以下信息。
Name | Type | References | Description |
---|---|---|---|
starelid | oid | pg_class.oid | The table or index that the described column belongs to |
staattnum | int2 | pg_attribute.attnum | The number of the described column |
stainherit | bool | If true, the stats include inheritance child columns, not just the values in the specified relation | |
stanullfrac | float4 | The fraction of the column’s entries that are null(空值的比例) | |
stawidth | int4 | The average stored width, in bytes, of nonnull entries(行平均长度) | |
stadistinct | float4 | The number of distinct nonnull data values in the column. A value greater than zero is the actual number of distinct values. A value less than zero is the negative of a multiplier for the number of rows in the table; for example, a column in which about 80% of the values are nonnull and each nonnull value appears about twice on average could be represented by stadistinct = -0.4. A zero value means the number of distinct values is unknown.(重复率) | |
stakindN | int2 | A code number indicating the kind of statistics stored in the N th “slot” of the pg_statistic row. (统计信息的类型) | |
staopN | oid | pg_operator.oid | An operator used to derive the statistics stored in the N th “slot”. For example, a histogram slot would show the < operator that defines the sort order of the data.(用于表示该统计值支持的操作,如’=’或’<’等。) |
stanumbersN | float4[] | Numerical statistics of the appropriate kind for the N th “slot”, or null if the slot kind does not involve numerical values(如果是MCV类型(即kind=1),那么这里即是下面对应的stavaluesN出现的概率值,即MC) | |
stavaluesN | anyarray | Column data values of the appropriate kind for the N th “slot”, or null if the slot kind does not store any data values. Each array’s element values are actually of the specific column’s data type, or a related type such as an array’s element type, so there is no way to define these columns’ type more specifically thananyarray .(统计信息的值,根据类型不同,值的内容也不同) |
PG为每列统计了五类信息,根据不同的数据类型会选择统计不同的信息,具体的内容很复杂,有时间我会放在另一篇文章讨论。
统计信息记录在哪里(where)
用于进行查询计划选择的统计信息记录在系统表内
- pg_statistic 记录值的分布率
- pg_class 记录行数和页面数
谁来更新统计信息(who)
统计信息是有autovacuum线程来更新,PG使用MVCC机制进行数据库的并发控制,因此同样需要一组后台进程进行过期版本的清理。
autovacuum.png
autovacuum线程不止负责对过期元组进行清理,同时也负责定期更新表的统计信息。
为什么要把这两个操作放在一起?
- PG的MVCC机制数据和数据的旧版本是统一存放在表文件上的,在清理时要进行全表扫描的操作,而统计信息的收集也是需要读取表文件的,这两个操作放在一起做可以在一定程度上节省IO;
- 清理废旧元组和更新统计信息都是通过收集表的元组变更数据来触发的,共享一套机制,因此放在一起处理也比较方便;
何时触发统计信息(when)
统计信息有两种触发方式:
- 用户使用analyze命令手动触发(analyze/vacuum analyze)
- daemon进程触发
讲讲daemon进程触发的机制:
在PG中,事务提交/回滚时会发消息给进程pgstat,pgstat会汇总这份信息并记录到文件中,autovacuum launcher会定期读取文件,获得,当某个表的改动超过阈值时便会触发一次统计信息的更新操作。
pgstat.png
需要注意的是autovacuum worker也会给pgstat发消息,但实际上这个消息是通知pgstat统计更新/清理已经完成,可以清理统计信息了。因此stat file只有pgstat更新,launcher只是读取,不涉及并发写冲突。
当变化元组数超过多少时启动一次统计信息更新?PG采用如下机制:
anlthresh = (float4) anl_base_thresh + anl_scale_factor * reltuples;
anl_base_thresh默认值时50,anl_scale_factor默认值时0.1,这都是可配置的,而且是每个表可以独立配置的。anl_base_thresh的作用是如果表文件太小,比如只有5行,如果不设置anl_base_thresh的话,1行变化就会启动统计信息更新,这是没有必要的。当然,只有一个页面的表默认就会使用顺序扫描。
如何记录统计信息(how)
抽样算法
关于抽样算法,这里有一个很好的说明,如下:摘自阿里月报
确定这个字段是否可以分析,如果不可以,则返回NULL。
一般有两种情况致使这个字段不进行分析:字段已被删除(已删除的字段还存在于系统表中,只是作了标记);用户指定了字段。获取数据类型,并决定针对该类型的采样数据量和统计函数
不同的类型,其分析函数也不同,比如array_typanalyze。如果该类型没有对应的分析函数,则采用标准的分析函数std_typanalyze。
以标准分析函数为例,其确定了两个地方:采样后用于统计的函数(compute_scalar_stats或compute_minimal_stats,和采样的记录数(现在默认是300 * 100)。索引
索引在PG中,是以与表类似的方式存在的。当analyze没有指定字段,或者是继承表的时候,也会对索引进行统计信息的计算。以AccessShareLock打开该表上所有的锁,同样的检查索引的每个字段是否需要统计、如何统计等。采样
选择表所有字段所需采样数据量的最大值作为最终采样的数据量。当前PG采取的两阶段采样的算法:- 先获取所需数据量的文件块
- 遍历这些块,根据Vitter算法,选择出所需数据量的记录时以页为单位,尽量读取该页中所有的完整记录,以减少IO;按照物理存储的位置排序,后续会用于计算相关性(correlation)。
这里的采样并不会处理事务中的记录,如正在插入或删除的记录。但如果删除或插入操作是在当前analyze所在的事务执行的,那么插入的是被记为live_tuples并且加入统计的;删除的会被记为dead_tuples而不加入统计。
由此会可能产生两个问题:
- 当有另外一个连接正好也在进行统计的时候,自然会产生不同的统计值,且后来者会直接覆盖前者。当统计期间有较多的事务在执行,且很快结束,那么结果与实际情况可能有点差别。
- 当有超长的事务出现,当事务结束时,统计信息与实际情况可能有较大的差距。
以上两种情况,重复执行analyze即可。但有可能因统计信息不准确导致的执行计划异常而造成短时间的性能波动,需要注意!这里也说明了长事务的部分危害。
统计、计算
在获取到相应样本数据后,针对每个字段分别进行分析。
首先会依据当前字段的值,对记录进行排序。因在取出样本数据的时候,按照tuple在磁盘中的位置顺序取出的,因此对值进行排序后即可计算得出相关性。另外,在排序后,也更容易计算统计值的频率,从而得出MCV和MCF。这里采用的快速排序!
之后,会根据每个值进行分析:如果是NULL,则计数
NULL概率的计算公式是:stanullfrac = null_number / sample_row_number。如果是变长字段,如text等,则需要计算平均宽度
计算出现最多的值,和相应频率
基数的计算
部分计算稍微复杂一些,分为以下三种情况:
当采样中的值没有重复的时候,则认为所有的值唯一,stadistinct = -1。
当采样中的每个值都出现重复的时候,则认为基数有限,则stadistinct = distinct_value_number
当采样中的值中,存在有唯一值并且存在不唯一值的时候,则依据以下的公式(by Haas and Stokes in IBM Research Report RJ 10025):
n * d / (n - f1 + f1 * n/N)
其中,N是指所有的记录数,即pg_class.reltuples;n是指sample_row_number,即采样的记录数;f1则是只出现一次的值的数据;d则是采样中所有的值的数量。
MCV / MCF
并不是所有采样的值都会被列入MCV/MCF。首先是如果可以,则将所有采样的记录放到MCV中,如表所有的记录都已经取作采样的时候;其次,则是选取那些出现频率超过平均值的值,事实上是超过平均值的25%;那些出现频率大于直方图的个数的倒数的时候等。直方图
计算直方图,会首先排除掉MCV中的值。
意思是直方图中的数据不包含MCV/MCF的部分,两者的值是补充关系而且不会重合,但不一定互补(两种加起来未必是全部数据)。这个也与成本的计算方式有关系,请参考row-estimation-examples 。
其计算公式相对比较简单,如下:values[(i * (nvals – 1)) / (num_hist – 1)]
i指直方图中的第几列;nvals指当前还有多少个值;num_hist则指直方图中还有多少列。计算完成后,kind的值会被置为2。
到此,采样的统计基本结束。
样本大小
PG采样大小来自论文《Random sampling for histogram construction: how much is enough?》
论文内提出里一个公式
在表大小为n,矩形图大小为k,分组内相关最大相关性错误为f,错误可能为gamma的情况下,最小的样本大小如下:
r = 4 * k * ln(2*n/gamma) / f^2
取f=0.5,gamma=0.01,n=10^6,我们可以得到
r = 305.82 * k
可以看到,元组数对样本大小的影响很小,即使元组数为10^12,采样出错的概率也不高,因此PG统一使用300作为采样的权值,简化问题,在确定样本大小时无需获取表大小。
注释如下
/*--------------------
* The following choice of minrows is based on the paper
* "Random sampling for histogram construction: how much is enough?"
* by Surajit Chaudhuri, Rajeev Motwani and Vivek Narasayya, in
* Proceedings of ACM SIGMOD International Conference on Management
* of Data, 1998, Pages 436-447. Their Corollary 1 to Theorem 5
* says that for table size n, histogram size k, maximum relative
* error in bin size f, and error probability gamma, the minimum
* random sample size is
* r = 4 * k * ln(2*n/gamma) / f^2
* Taking f = 0.5, gamma = 0.01, n = 10^6 rows, we obtain
* r = 305.82 * k
* Note that because of the log function, the dependence on n is
* quite weak; even at n = 10^12, a 300*k sample gives <= 0.66
* bin size error with probability 0.99. So there's no real need to
* scale for n, which is a good thing because we don't necessarily
* know it at this point.
*--------------------
*/
随机数算法
随机数算法使用的是Knuth’s Algorithm S,就不细说了,具体流程如下:
设k为样本大小, K为抽样对象大小, i为当前对象,V生成的0-1之间的随机数
while i < k
p = k/(K - i);
if V < p
pick it
k--;
i++
alter table的冲突处理
analyze是需要加锁的,见下
#define NoLock 0
#define AccessShareLock 1 /* SELECT */
#define RowShareLock 2 /* SELECT FOR UPDATE/FOR SHARE */
#define RowExclusiveLock 3 /* INSERT, UPDATE, DELETE */
#define ShareUpdateExclusiveLock 4 /* VACUUM (non-FULL),ANALYZE, CREATE INDEX
* CONCURRENTLY */
#define ShareLock 5 /* CREATE INDEX (WITHOUT CONCURRENTLY) */
#define ShareRowExclusiveLock 6 /* like EXCLUSIVE MODE, but allows ROW
* SHARE */
#define ExclusiveLock 7 /* blocks ROW SHARE/SELECT...FOR UPDATE */
#define AccessExclusiveLock 8 /* ALTER TABLE, DROP TABLE, VACUUM FULL,
* and unqualified LOCK TABLE */
analyze需要加ShareUpdateExclusiveLock
这个锁和alter table是有冲突的,禁止了冲突的发生。
和MVCC机制的冲突处理
统计时会过滤掉部分元组,代码如下。
switch (HeapTupleSatisfiesVacuum(&targtuple, OldestXmin, targbuffer))
{
case HEAPTUPLE_LIVE:
pick it
case HEAPTUPLE_DEAD:
case HEAPTUPLE_RECENTLY_DEAD:
give up
case HEAPTUPLE_INSERT_IN_PROGRESS:
pick it only it is inserted by me;
case HEAPTUPLE_DELETE_IN_PROGRESS:
give up
default:
elog(ERROR, "unexpected HeapTupleSatisfiesVacuum result");
break;
}
- 统计已提交事务插入的元组;
- 统计本事务插入的元组;
关于第二点是为了处理某个事务插入了很多元组之后,立刻在本事务内进行分析的情况,比如很多包含临时表的操作。
资源控制
vacuum worker在每次touch页面时,会自动计数,统计为cost,当cost超过一定值时,会自动sleep几秒钟,降低vacuum和analyze对正常流程的影响。
这些参数是可以配置的,这里我们只谈谈autovacuum情况下的默认值。
- 每次cost超过200时,就会启动一次sleep,默认80ms;
- page hit的cost是1,page miss的cost是10,page dirty的cost是20;
- 睡眠时间是按照如下公式动态计算的
msec = VacuumCostDelay(20ms) * VacuumCostBalance / VacuumCostLimit(200);
if (msec > VacuumCostDelay * 4)
msec = VacuumCostDelay * 4;
例外
- 系统表不需要统计信息
- 系统列不需要统计信息
参考文档
以上例子来自PG官方文档
postgres源码
https://www.postgresql.org/docs/9.6
http://mysql.taobao.org/monthly/2016/05/09/
https://www.postgresql.org/docs/9.6/static/row-estimation-examples.html
https://www.postgresql.org/docs/9.6/static/planner-stats-security.html
广告
最后,打个广告,如果对创业,分布式数据库和开源社区感兴趣,欢迎加入我们,实习和工作都很欢迎!
pingcap是国内为数不多的newsql方向的分布式数据库,维护国内最顶级的开源社区,关注度近万,做类f1+spanner架构,和多家公司有合作关系。
TiDB: https://github.com/pingcap/tidb
TiKV: https://github.com/pingcap/tikv
Email: xuwentao@pingcap.com
微信: fbisland