今天看到了这个图片,觉得挺有意思的,可以写一篇博文探讨一下。
本文的部分灵感和构思来自以下链接:
www.eversql.com/how-to-spee...
Count和聚合函数
这里使用的Count函数意为"计数"。实际上,它是一个聚合函数,它用于对记录集按照字段的数值进行分组,然后计算每个分组中,记录的数量。这里面,聚合的意思就是将记录集按照某个字段的数值进行分组,然后计算一些统计信息或者分组中的数组特征。计数,当然是其中的一个,除了计数之外,常用的聚合函数还包括:
- 求和: Sum
对于聚合组中的记录字段的数值进行求和,只支持数值类型的字段,或者需要转换为数值。
- 求平均值: Avg
对于聚合组中记录的字段值,求平均值。
- 求最大值或最小值: Max/Min
找出聚合组中数值的最大值和最小值。
这些函数的基本形式和操作方式都是类似的,我们可以将这一类函数都称为聚合函数。在PG和标准SQL中,聚合函数使用Group By子句实现,它其实是Select语句的一个可选项,其标准形式是:
sql
SELECT
column_1,
column_2,
...,
aggregate_function(column_3)
FROM
table_name
GROUP BY
column_1,
column_2,
...;
这里:
- 基本形式是一个SELECT..FROM..GROUP BY语句
- GROUP BY: 聚合操作关键字
- 指明需要进行聚合计算使用的字段,可以使用多个字段参与分组,分组依据就是这些字段值的组合
- 如果不使用GROUP BY,则在整个记录集中使用聚合操作,其实就是将整个记录集看成一组
- SELECT内容可以也可以不包含参与聚合的字段,但不能包含不参与分组的字段,就是不能有其他字段
好了,了解了聚合函数的基本概念和形式后,我们回到文章开头的主题,就是Count这个聚合函数的几种使用方式,和它们之间的差异。为了简化讨论,文初的场景,没有用到分组方式,范围是整个记录集。它们都是使用Count函数,差异主要是其中的参数,有这么几种情况:
- count(*)
这个可以用来计数所有满足条件的记录行的数量,无论它的值是多少。这个经常用到统计表中的记录数量,而不太关心具体的记录字段中的数值。还有一种count(1)的写法,其实结果是一样的。因为虽然逻辑上,count* 会检查所有的列中的行,count1只检查记录存在,概念上还是略有区别,但实际上SQL执行优化程序会忽略这一点。
- count(name)
如果count的参数是某一个字段,那么它的隐藏的语义是统计这个字段中,非null的数值的记录的数量。
- count(distinct name)
先对name字段进行去重字段,然后再检查非重复的名称的数量。
下面我们通过一些具体的SQL示例,来具体感受和说明一下这个函数的应用方式。
SQL示例
示例数据
在正式开始之前,我们先来构造一些示例数据,我们不用创建真实的数据表,可以通过以下方式(CTE)构建一个记录集:
js
with T( id,name, nation, dep ) as (values
(1, 'John', 'England', 1),
(2, 'Tom', 'France',1),
(3, 'Alexda', 'England',3),
(4, 'Smith', null, 3),
(5, 'Bob', 'USA', 4 ))
--select count(*), count(1), count(nation), count(distinct(nation)) from T;
select gp, count(*) star, count(1) one, count(nation) nation, count(distinct(nation)) nation2 from T group by 1;
当然,按照SQL执行的原理,我们必须要把这个代码,放在一个真实的SQL连接环境里面,才能运行,所以还是需要一个真正的数据库,只不过可以不影响和操作那个数据库里面的数据而已。
聚合查询实现
基于上面的数据,我们可以使用下面的SQL语句,来实现前面几种Count聚合查询方法如下:
sql
-- 记录集
select count(*), count(1), count(nation), count(distinct(nation)) nation2
from T;
star one nation nation2
5 5 4 3
-- 分组
select gp, count(*) star, count(1) one, count(nation) nation, count(distinct(nation)) nation2 from T group by 1;
gp star one nation nation2
1 2 2 2 2
3 2 2 1 1
4 1 1 1 1
示例代码中可以看到,为了方便讨论和比较,我们将这几种Count方法,都放在了同一个语句当中。完全的验证了前面的表述和内容。
此外,Postgres的GroupBy子句,还支持"逻辑字段",就是使用字段序号,来替代字段的名称(这里就是GP),可以方便代码的编写和迁移。
Count Distinct
除了Count的基本使用之外,文首引用的文章,其核心,其实是在讨论一个Distinct性能和优化的问题。就是Count(distinct(name))方法,如果遇到比较大的数据规模,可能会有潜在的性能问题。就需要对这个操作进行优化,来提高操作速度。我们将在下一章节深入探讨。
Distinct优化
本章节,我们利用原文提供的方法,具体的来探讨如何在比较大的数据集中,加速和优化统计计算的方法。
数据准备
为了更方便讨论和比较,这里还是使用原文中的示例数据和方法,并且在一个真实的postgres数据库中,创建相关的数据库和示例数据,如下所示:
sql
create table EXAMPLE_TBL (
Id serial primary key,
Username text,
Purchase_Date Date,
Product text,
Qty int
);
INSERT INTO EXAMPLE_TBL (Username, Purchase_Date, Product, Qty)
SELECT 'User' || floor(random() * 200 + 1)::int,
CURRENT_DATE - floor(random() * 5 + 1)::int,
CASE floor(random() * 3 + 1)::int
WHEN 1 then '👞'
WHEN 2 then '👟'
WHEN 3 then '🎧'
END,
floor(random() * 10 + 1)::int
FROM generate_series(1,1000000);
上述代码会在数据库创建一个示例数据表,并随机填充1000000条数据记录。
原生操作和基本过程
数据准备好之后,我们下面将实施原生的count-distinct操作:
js
select count(distinct(username) ) from example_tbl;
在笔者的系统中,此标准查询大约耗时1987ms。
从原理上而言,与经典计数相比,distinct去重并统计的操作通常较慢。因为它需要执行两个操作:
- 它需要扫描整个记录集,列出不重复的字段值,这个操作本身就比较复杂和低效
- 对处理后的字段值进行计数
为了改善这一操作的效率,在原文中,提出了下列可以考虑的加速和优化方案。
数据库估算
这是一个典型的工程化处理方式。对于很大的数据,在业务上,特别是统计意义上,其实并不需要一个完整精确的数字。比如一个百万级的用户分类统计,1568121和1568120,在业务需求上基本上是没有区别的。如果能够接收这一点,我们可以利用数据库的估算功能,而非完全精确的统计。
很多关系型数据库系统,比如postgres在一些系统的基础信息表中,已经包括了一些近似基数的统计数据,可以用来进行估算,而不需要真正的去扫描真实的数据库表。
相关的操作如下:
js
// 分析数据表,更新系统分析和估算数据
ANALYZE EXAMPLE_TBL;
// 查询估算数据
SELECT reltuples AS estimate FROM pg_class WHERE relname = 'example_tbl';
// 创建估算解释计数程序
CREATE OR REPLACE FUNCTION count_estimate(
query text
) RETURNS integer LANGUAGE plpgsql AS $$
DECLARE
plan jsonb;
BEGIN
EXECUTE 'EXPLAIN (FORMAT JSON)' || query INTO plan;
RETURN plan->0->'Plan'->'Plan Rows';
END;
$$;
// 运行估算解释计数程序
SELECT count_estimate('SELECT DISTINCT username FROM example_tbl');
在笔者的系统中,此操作耗时372ms,但结果确实不是准确的(实际应该是203,得到200)。但耗时约为原始查询的四分之一。
简要说明如下:
- pg_class作为系统表,保存了很多当前数据表的统计信息
- 运行analyze可以对数据库表进行分析,并更新这些统计信息
- 可以使用relname,来过滤和查找特点的数据库对象,这里是示例数据表
- 分析之后,在执行查找,可以得到数据库表行的数量
- 结合估算信息,结合计划任务,可以快速的估计查询语句要涉及到的行记录的数量
- 估算计数操作,在本例中被封装为一个字定义函数
- 使用数据库估算,可以快速获得一个大致而非精确的结果
物化视图
如果确实需要精确的统计结果,但又想要一个比较高的查询性能,可以考虑使用物化视图,作为一种缓存机制。
sql
// 创建物化视图
CREATE MATERIALIZED VIEW EXAMPLE_MW AS
SELECT PRODUCT, COUNT(DISTINCT USERNAME)
FROM EXAMPLE_TBL GROUP BY PRODUCT;
// 刷新物化视图
REFRESH MATERIALIZED VIEW example_mw;
// 查询物化视图
SELECT * FROM example_mw;
在笔者的系统中,原生查询需要3970ms,使用物化视图查询需要359ms,更新物化视图需要3200ms。
物化视图就是一种持久化的视图,它经常用于将查询结果保存在数据库中,并使用简单而相对高效的查询结果数据更新机制。本质上,它就是一种数据缓存机制,查询时,可以直接从缓存的记录表中返回结果,达到提高查询性能的目的。它并没有改变原始查询的性能,但是它是精确的,在某些场景下是可以重复使用的,代价是耗费一些存储空间。使用物化视图需要注意视图更新的时机,所以它比较适合于写少读多的应用场景。
专用数据结构聚合: HyperLogLog(HLL)
HyperLogLog(HLL,无正式译名,笔者译为混合对数日志)是一种概率性的数据结构,用于估计一个集合中不同元素的基数(cardinality),即集合中不同元素的数量。HLL基于哈希函数和位运算来进行估计,通过将输入元素哈希成固定长度的二进制串,然后利用位运算进行统计。在进行估算时,HLL会使用一些特定的技巧来处理哈希值,以达到减小估计误差的目的。相比于精确计数方法,HLL能在牺牲一定的准确性的情况下,显著减少内存使用。实际上,HLL的内存占用是固定的,与处理的元素数量无关。并且, HLL的计算是可以并行进行的,可以分别计算多个部分,然后将结果合并。而且,相对而言,HLL对更大规模大数据集的基数估计效果相对更好,能够提供相对精确的结果。这些特性都使得它适用于大规模的数据集合。
HLL不是Postres原生的功能,需要通过扩展模块来使用。下面的示例可以让我们对它的应用方式有一个初步理解:
sql
// 创建扩展模块
CREATE EXTENSION hll;
// 统计专用数据表
CREATE TABLE daily_users (
Purchase_Date date UNIQUE,
users hll
);
// 统计数据录入和更新
INSERT INTO daily_users
SELECT Purchase_Date, hll_add_agg(hll_hash_text(Username))
FROM EXAMPLE_TBL GROUP BY 1;
// 基数查询
SELECT Purchase_Date, hll_cardinality(users)
FROM daily_users;
//
SELECT hll_cardinality(hll_union_agg(users))
FROM daily_users WHERE Purchase_Date >= CURRENT_DATE -2;
在PG中,HLL字段是二进制编码的,无法直接查询和使用,需要配合相关函数工具。所以HLL的应用有一定的学习和使用门槛,需要熟悉和了解相关的概念和方法。要满足不同的统计查询需求,可能需要组合使用不同的HLL方法。关于这些内容,由于笔者的理解有限还有篇幅的限制,本文中就不做深入讨论了。有兴趣的读者可以认真阅读原文,获取更多的信息。
作为一个性能的比较,在笔者的测试环境中,HLL查询需要2295ms;插入需要584ms;查询基数643ms;
使用覆盖索引 Covering Index
前面提到的物化视图和HLL的优化方案,都有一个问题是无法提供实时的结果,并且要尽量接近真实状态,需要阶段性的数据刷新操作。为此,这里有一个替代方案: 覆盖索引(Convering Index)。
覆盖索引是一种优化数据库查询性能的技术。当一个查询可以完全通过索引满足时,即不需要去查找表中的实际数据行,就称之为覆盖索引。这种索引包含了查询所需的所有列,因此可以直接提供查询的结果,而无需额外地访问表。显然,这种工作方式可以显著减少查询的IO操作,数据库引擎可以直接从索引中获取所需的数据,而不必去查找底层的真实数据表。这对于大型数据库和频繁执行读取操作的场景非常有用,能够提升查询性能。
下面的示例,可以帮助我们理解这一技术:
sql
// 基本查询
SELECT PURCHASE_DATE, COUNT(DISTINCT USERNAME)
FROM EXAMPLE_TBL GROUP BY 1;
// 定义覆盖索引
CREATE INDEX EXAMPLE_COVERING_IDX ON EXAMPLE_TBL(Purchase_date) INCLUDE(Username);
// 解释查询计划
EXPLAIN select Purchase_date, count(distinct Username) from EXAMPLE_TBL group by Purchase_date;
GroupAggregate (cost=0.42..23853.58 rows=5 width=12)
Group Key: purchase_date
-> Index Only Scan using example_covering_idx on example_tbl (cost=0.42..18853.50 rows=1000005 width=11)
在笔者的测试系统中,基本查询耗时7586ms;创建索引5978ms; 解释计划311ms。可以看到覆盖索引创建后,只使用索引来执行查询。优化后的基本查询耗时4507ms,有改善,但好像不是很大,优点是实时又准确。
这个技术还有一些限制。添加索引可能会减慢表上的任何写入速度,因为它还需要更新索引。而且,索引是数据库中的物理数据结构,它们占用空间,并且需要定期维护(数据库自动维护)以保持其性能。
查询分析和优化产品
PG内置了Explain查询计划解释语句和工具,可以告知当前查询语言的工作方式。其实对于一般查询类操作的优化,只有一个主要和简单的原则:"尽可能多的使用索引并避免进行全表扫描"。查询分析工具可以帮助分析查询执行计划、涉及的步骤和记录集合、是否使用索引等相关信息,帮助开发者来编写正确的查询语言,建立正确合适的索引,来提高查询性能。
对查询计划解释语句的结果进行解读和分析对专业性有相当高的要求,使用起来并不是非常简单方便。所以市场也有一些产品化的软件,如各种查询优化器等等,可以提供更详细和直观的信息和建议。比如直接告知,针对当前的查询操作,应当在哪些字段上建立索引等等。开发者也可以配合查询计划解释程序使用,来评估优化方案。
优化数据库结构
如果认真思考前面示例数据的涉及,结合具体的业务需求,就会发现,针对于这个统计业务需求,这样的类似于Excel表格,将所有数据和模型都放在一起的数据库结构的设计并不是特别合理。
正确的方式是,可以考虑设计多个数据表,来承载不同的数据模型,每个数据表中的数据记录(模型)是可以用数据标识来达成唯一性;然后使用数据关联关系(一对多、多对多)的将数据记录(模型)关联起来。这样,其实就不存在需要去重的操作了。
从这里也可以看到,很多的disctinct问题,其实也不是真的重复记录的问题了。
限制记录集
在某些应用场景和数据集当中,可能不需要处理整个记录集,而只需要处理一部分数据和记录,就可以得到令人可以接受的统计结果。而合理的根据实际情况,选取部分数据集来进行处理,可以大幅度降低输入处理的数量,从而减少查询处理所需要的时间。
下面是一个简单的例子:
sql
SELECT COUNT(DISTINCT Username)
FROM (SELECT Username from EXAMPLE_TBL ORDER BY ID LIMIT 10000) SUB;
这里假设我们虽然有100万条数据记录,但当前的业务需求是统计不同名的用户数量,就可以使用这个"妥协"的技术方案来处理,并达到一个可以接受的结果。Postgres提供了limit指令,来限制处理数据集的规模,可以方便的使用来构造查询分析子记录集。
这种方法,可能可以适用于以下一些情景:
- 数据均匀分布
理想情况下,一个普通的用户业务系统,用户在业务中的分布是比较均匀的,特别是在靠前的业务数据当中。这样,其实我们可以只统计前一万条记录,就可以得到一个比较接近实际的情况。或者,基于用户体验的要求,只是需要提供一个有限的不重复的数据集合,也可以考虑这个技术方案。
- 用户感知
很多时候,特别是对于最终的人类用户,向其展示大量精确的信息是不必要的,它超出了大多少用户的感知和信息处理能力的范围,比如,如果用户正在处理显示前50个结果的列表,那么向最终用户提供500个结果并不能真正提供太多附加价值。
- 业务限制
同样的道理,在很多业务场景中,只是需要提供一个不包括重复记录的列表(比如一个可用的名单),也可以使用这些技术方案。
重新评估业务需求
上面的讨论,提供了很多技术方案和选项。但如果我们回到问题的源头。就会发现更深层次的问题是是否必须要用这种方式来满足业务需求,或者说,这个需求是否只能使用这种方式。在很多时候,可能需要使用不同的方式,才能找到更好更合适的解决方式。
根据原文的启发,笔者也将前面的讨论内容进行了总结、梳理和抽象,并绘制了相关的思维和决策流程框图,来帮助分析真实的业务需求和解决方案,以及选择合适的技术解决方案。
扩展信息
基于基本聚合函数,在实际应用中,可能有一些扩展的使用场景,这里简单的总结一下。限于篇幅,这里只是概念性的说明,不会深入讨论。
- 查询条件: where
在实际应用场景中,通常不会直接使用整个记录表和所有的记录,而是经常需要先对记录集进行过滤,筛选符合条件的记录,然后再来进行聚合统计。这时可以使用Where子句,和Select语句中的方式是一样的。
- 聚合查询条件: having
一般的Where子句,用于构造符合条件的记录集。但我们还会遇到一种情况,就是需要对聚合的结果进行过滤,就是需要将聚合结果作为数据过滤的条件。这时就不能再使用标准Where条件查询,而是需要使用Having子句。下面是一个简单的用例:
sql
SELECT city, count(*), max(temp_lo) FROM weather GROUP BY city
HAVING max(temp_lo) < 40;
- 字段值过滤器: filter
在Postgres中,聚合函数,可以和字段值过滤器filter结合使用,构造更灵活更强大的查询操作。
js
SELECT city, count(*) FILTER (WHERE temp_lo < 45), max(temp_lo)
FROM weather GROUP BY city;
可以看到,filter可以针对每个字段都设置预过滤条件,从而可以在一个语句中,表达更复杂的查询操作,或者在同一个查询结果中,获得不同条件组合的聚合计算统计结果。
- 窗口函数
从上面的分析和讨论,我们也可以发现,聚合函数,虽然可以解决一些分组统计分析的问题,但它只能将。这时候,就需要引入窗口函数。
当然,窗口函数的操作,也会带来更大的处理数据量和计算量,对于简单的操作,性能肯定无法直接跟简单的聚合函数相比。所以,它们并不是简单的替代关系,而是不同需求和场景下选择的技术和实现方式。
小结
本文从一个简单的Count函数使用示例出发,讨论了聚合函数的相关概念。并展开讨论了Count Distinct操作相关的各种技术优化和操作,以及如何选择合适的处理方案,帮助读者了解相关的数据库应用技术和工程化思维框架。