SQL查询基础教程
前言
本文为作者学习SQL
的个人笔记,旨在记录自己学习的常用语法,整体学习顺序为先学基本框架语法再慢慢补充零碎知识点。运行环境为MySQL
,故使用所有语法均使用MySQL
版本。
基础结构
SQL
查询语句实现的基本功能,从一张表中,取出一或多个列,这些列需要符合一些条件,最终取出的数据要根据一或多个列的大小进行排序。上面加粗的便是我们需要提供给SQL
的参数,具体语句如下(所有SQL语句都需要以分号结束):
1 | select 列名 |
select
select
语句可以查询一个或多个列,或者查询所有列,具体的输入如下:
1 | select 列名 # 查询一个列 |
where
where
子句类似于一般编程语言中的if-else
语句,即给定筛选条件,一般会包含运算符,常见的运算符如下表
大部分运算符与常见编程语言相同,需要额外注意的为null
的运算。
当然,where
子句也支持条件的组合,使用and
与or
来连接多个条件语句(需要注意的是and
的优先级更高,必要时需要使用括号来改变优先级)。还有in
操作符,用于表示筛选条件为数据位于一个集合中。还有not
操作符,用于取反。还是用上文的表studentscore
来举例说明
1 | where id = 1 # 筛选id为1的记录 |
where
子句还可以配合很多函数使用,这些我们后文再说。
order by
order by
子句非常简单,就是对最终输出的结果按照指定列进行排序,默认为升序,即字符串从A到Z,数值从小到大,时间从早到晚,使用desc
关键字可改为降序,常见输入如下
1 | order by 列名 # 按给定列排序 |
还需要注意的是,order by
子句必须写在SQL
语句的最后。
示例
现在,让我们结合上述的基本结构,使用表studentscore
来举例。表studentscore
(id为学生学号,lesson为课程编号,score为成绩)
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 50 |
2 | 1 | 70 |
现在需要取出课程编号为1,2的所有及格的成绩,同时score可能存在空值,需要剔除score为空的数据,最后按学号升序排序。
1 | select * |
补充
distinct关键字
在实际使用过程中,可能会遇到需要查看一个列所有取值的情况,这时候就可以使用distinct
关键字,这可以返回给定列中不同的值。我们来看一些例子,使用上文的表studentscore
来举例
进行查询
1 | select id |
其输出为
1 | id |
若使用distinct
关键字
1 | select distinct id |
则输出为
1 | id |
需要注意的是,distinct
关键字会作用于所有输入的列,这会输出所有不同的多个列的组合,例如
1 | select distinct id, lesson |
则输出为
id | lesson |
---|---|
1 | 1 |
1 | 2 |
2 | 1 |
该表中存在两条id为2,lesson为1的记录,故只会输出一次。
从上述例子也可以看出,只要使用distinct
关键字,则所有列都会被去重,故不存在对一部分列使用distinct
关键字,对另一部分不使用的用法。
模糊匹配
模糊匹配多用于字符数据,如筛选以大学结尾的学校这类需求,就可以使用模糊匹配来进行筛选,一般配合where
子句使用,这里介绍两种模糊匹配的关键字like
与rlike
,其语法为
1 | 列名 like 通配符表达式 |
首先是like
,like
有三种支持的通配符,分别是%
,_
与[]
,但是MySQL
不支持[]
通配符(rlike
中可以用),故这里只讲前两种。%
用于代表任意多个字符,_
通配符用于代表一个字符,写模糊筛选语句的过程就是将需求转换为通配符表达式的过程,下面来看例子。
假如,现在有一个列name
,我要筛选出所有姓李的人,那就是第一个字符为李,而后面可以跟上若干个字符,所以通配符表达式为'李%'
,写为where
子句为
1 | where name like '李%' |
再比如,我们要筛选出姓李且姓名为两个字的人,则就是第一个字符为李,而后面只能跟上一个字符,所以通配符表达式为'李_'
,写为where
子句为
1 | where name like '李_' |
当然like
也支持not
关键字,也就是反向筛选,例如筛选出不姓李的人,就是
1 | where name not like '李%' |
然后是rlike
,可以看到like
只能解决一下比较简单的模糊匹配问题,复杂的模糊匹配问题就要使用到rlike
。
rlike
使用的是正则表达式,这与like
的逻辑很不一样,例如like '张伟'
筛选出来的结果只有“张伟”,而rlike '张伟'
会筛选出所有包含“张伟”的结果,如“张伟伟”等,而如果只要筛选出“张伟”,则是在现有表达式上用通配符做限制,如rlike '^张伟$'
,表示以张伟开头,以张伟结尾,这样就能只筛选出“张伟”了。
下面来看一些正则表达式的常用通配符
^: 匹配开头,如^s匹配以s开头的字符串。
$:匹配结尾,如s$匹配以s结尾的字符串。
[]:匹配任意一个[]内的字符,如[abc],[012],也可以使用-字符来指定范围,如[a-c],[0-9],还可以使用^,表示非,如[^abc]表示不为abc。
*:匹配前面的子表达式零次或多次,如,zo* 能匹配 “z” 以及 “zoo”。* 等价于{0,}。
+:匹配前面的子表达式一次或多次。例如,‘zo+’ 能匹配 “zo” 以及 “zoo”,但不能匹配 “z”。+ 等价于 {1,}。
{n}:n 是一个非负整数。匹配确定的 n 次。例如,‘o{2}’ 不能匹配 “Bob” 中的 ‘o’,但是能匹配 “food” 中的两个 o。
{n, m}:m 和 n 均为非负整数,其中n <= m。最少匹配 n 次且最多匹配 m 次。m可以不填,表示无上限。
用上面的通配符,就可以做到很多,例如筛选QQ号为10位的数据,那就是任意数字重复10次,且不能有任何其他字符,正则表达式为'^[0-9]{10}$'
。
limlt
limlt
写于SQL
语句的最末端,用于限制输出数量,也可与order by
子句配合输出指定排名数据,其语法如下
1 | limit n # 输出前n条记录 |
用表studentscore
举例,例如输出前2条数据
1 | select * |
例如输出分数前三高的三条记录
1 | select * |
有时候还会遇到一些需求,例如以3条记录为一页,输出第三页,这就可以转化为,跳过前两页,也就是6条记录,输出一页数量的记录,也就是3条记录,因此可以写为limit 6, 3
聚集函数
聚集函数就是常见的统计函数,如sum(), avg(), count(), max(), min()
等,大多数情况下,聚集函数还需要配合group by
子句使用,还是使用上文的表studentscore
来举例
1 | select |
输出为
sum_score | avg_score | cc |
---|---|---|
290 | 72.5 | 4 |
sum(score)
会输出整个表score的和,avg(score)
会输出整个表score的平均值,count(*)
会输出整个表的记录条数
注:
as
为重命名语法,可以重命名列名,还有表名(后文会讲)- 由于
count()
函数起计数功能,在没有null的情况下,填任何列结果都是一样的,所以一般写count(*)
可以注意到,使用聚集函数后整个表将会改变形状,这时候在select
中写入无关列名会报错,如select id
group by
group by
子句的作用是分组,比如现在我们要统计各课程的平均成绩,那就可以使用group by
子句,按照课程编号分组,再使用聚集函数进行统计计算,代码如下
1 | select lesson, avg(score) as avg_score |
输出为
lesson | avg_score |
---|---|
1 | 70 |
2 | 80 |
与上一个例子相同,这时候在select
中写入无关列名会报错(只有用于分组的列是相关的)
补充
count()与null
将表studentscore
的数据改为
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 50 |
2 | 1 | null |
这时,count(*)
,count(lesson)
,count(score)
分别会输出4,4,3。也就是说,count()
会不统计空值。
注:如果有全空的列,则
count()
会输出0,而不是null
count()与distinct
distinct
关键字的作用是去重,这也可以与count()
配合使用,就拿表studentscore
来说,假设现在我们想要统计学生数量,就可以这么写
1 | select count(distinct id) |
这会计数所有不重复的id。
当然,还有更复杂的用法,比如现在我们要统计所有学生说选修的课程总数,可以看到在上述表格中,是存在两条id与lesson均相同的记录的,这可能是补考或者重修之类的原因,所以,我们就可以这么写
1 | select count(distinct id, lesson) |
这会计数所有不重复的id与lesson的组合,这跟在select
中使用是相同的。
在更加现实的场景中,类似的需求并不少见,比如用户活跃量。假设现在每次用户访问,都会在数据库中留下一条记录,一个用户每天可能会访问多次,留下多条记录,但是在计数时我们只能统计为一个活跃日,现在我们要这个月所有用户的活跃天数之和,那就可以写为count(distinct id, day)
。(id为用户账号,day为访问时的日期,这里默认该数据表是一个月的数据,没有做月份的筛选)
concat()家族
concat()
家族包括concat()
,concat_ws()
与group_concat()
,用于连接数值,字符串。首先是concat()
函数,其语法如下
1 | concat(字符串1, 字符串2, ...) |
用表表studentscore
举例,现在我们要给分数写上单位分,就可以这么写
1 | select id, lesson, concat(score, '分') as score |
输出如下
id | lesson | score |
---|---|---|
1 | 1 | 90分 |
1 | 2 | 80分 |
2 | 1 | 50分 |
2 | 1 | 70分 |
concat_ws()
函数在此基础上增加了选择分隔符的功能,其语法如下
1 | concat_ws(分隔符, 字符串1, 字符串2, ...) # 默认分隔符为逗号 |
例如,我们要在一列中包含课程编号与分数,用-做分隔符,代码如下
1 | select id, concat_ws('-', lesson, score) as lesson_score |
输出如下
id | lesson_score |
---|---|
1 | 1-90 |
1 | 2-80 |
2 | 1-50 |
2 | 1-70 |
group_concat()
函数用于配合group by
子句做输出,用于将同一个分组的数据整合到一起。其语法如下
1 | group_concat([distinct] 要连接的字段 [order by 排序字段 asc/desc ] [separator '分隔符']) # []内为可选参数 |
例如,现在我们要输出参加了每个课程考试的学生id,并用逗号分隔,则可以这么写
1 | select lesson, group_concat(distinct id separator ',') as lesson_id |
输出如下
lesson | lesson_id |
---|---|
1 | 1,2 |
2 | 1 |
连接表
连接表用于需要查询或用于判断分组的数据分别存在不同的表中,这时候可以使用连接表,将多个表连接起来,再进行查询。在实际应用场景中,由于数据库设计的需要,经常会出现数据分散在多个表中的情况,故这也是一个很常用的语法。具体的语法
1 | from 表1 |
连接语句常用的有3个,分别为
1 | inner join # 对应连接,只保留两个表中都有的部分,不会出现null |
接下来我们通过例子来具体理解这4种连接方式,假设现在有三个表,表studentscore
,这里我们把该表的数据再放上来,便于观看
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 50 |
2 | 1 | 70 |
再增加一个表lessonname
,用于储存课程名称(lesson为课程编号,name为课程名称)
lesson | name |
---|---|
1 | 语文 |
2 | 数学 |
3 | 物理 |
4 | 化学 |
表student
,用于储存学生信息(id为学生学号,age为学生年龄)
id | age |
---|---|
1 | 17 |
2 | 18 |
3 | 18 |
4 | 19 |
inner join
现在我们来看具体需求,假设现在我要查询所有的成绩记录,要包括学号,课程名,成绩,那么这就要涉及表studentscore
与表lessonname
,具体查询语句如下
1 | select ss.id, ln.name, ss.score |
这里,表名后面的ss
与ln
为重命名,中间可以写一个as
,也可以省略(同列的重命名),重命名是为了方便写下面的连接条件以及列名。还有一个需要注意的点,因为在两个表连接起来之后,会有一些同名的列,比如上述语句就有两个lesson
,所以,我们需要在列名前加上表名,以准确的告诉SQL
具体的列。(这里建议,在涉及连接的语句中,将用到的列名前全部加上表名,不管其是否真的需要,这会减少很多奇怪的bug)。最后inner join
也可以写为join
。
现在,让我们来看看上述语句的输出,其输出如下
id | name | score |
---|---|---|
1 | 语文 | 90 |
1 | 数学 | 80 |
2 | 语文 | 50 |
2 | 语文 | 70 |
可以看到,只有两个表的lesson
能对上的记录才会被取出
接下来,如果我们要在上述基础上补充各学生的年龄,那么我们就需要连接三个表,其查询语句如下
1 | select ss.id, ln.name, ss.score, s.age |
其输出为
id | name | score | age |
---|---|---|---|
1 | 语文 | 90 | 17 |
1 | 数学 | 80 | 17 |
2 | 语文 | 50 | 18 |
2 | 语文 | 70 | 18 |
left/right join
left join
与right join
是相同的,就是调换了一下表的位置而已,故这里只讲left join
。
假设现在有这样的需求,我们要查询所有学生的考试信息,包括学号,课程编号与成绩,但是要求保留所有学生的信息,也就是,就算这个学生一条考试成绩的记录都没有,你也要给我报空,告诉我这个学生啥都没考。这就需要用到left join
来保留左表的所有信息(先写的那个是左表),具体查询语句如下
1 | select s.id, ss.lesson, ss.score |
其输出为
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 50 |
2 | 1 | 70 |
3 | null | null |
4 | null | null |
可以看到,左表中的id取3,4的记录,在右表中是没有对应记录的,但是由于这是左连接,所以左表的所有信息都会被保留,就算右表没有对应记录,也需要返回null
补充
多个连接键
在连接表的过程中,可能会遇到需要指定多个连接键的情况,这时候可以用and
连接多个连接键,例如,为了防止分数出错,一次考试中分别有两个老师打分,其分数分别记录在表studentscore1
与表studentscore2
中,数据如下
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 50 |
2 | 1 | 70 |
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 55 |
2 | 1 | 70 |
现在我们需要对比两个老师的打分,那么就需要将两个表连接起来,并且连接键为id与lesson均相等,代码为
1 | select s1.id, s1.lesson, s1.score as score1, s2.score as score2 |
输出为
id | lesson | score1 | score2 |
---|---|---|---|
1 | 1 | 90 | 90 |
1 | 2 | 80 | 80 |
2 | 1 | 50 | 55 |
2 | 1 | 70 | 70 |
组合查询
如果说连接表是在列方向上的连接,那么组合查询就是行方向上的连接,用关键字union
实现。用表studentscore
与表student
举例,例如,现在我们要查找有一门考试分数超过70分,或者年龄为18岁学生,那么我们可以对这两个条件分别查找,再组合成一个表,代码如下
1 | select distinct id |
输出为
id |
---|
1 |
2 |
3 |
很容易想到,组合的两个表的列需要完全一样。再者,如果需要排序,那么order by
子句需要写在整个语句的最后,并且只能有一个order by
子句。最后,union
关键字会对结果进行去重,例如上述代码,两个查询的结果分别为1, 2
与2, 3
,但是最后的输出仅有一个2
。
如果想要不去重的结果,则可以使用union all
关键字,例如将上述代码改为
1 | select distinct id |
则输出为
id |
---|
1 |
2 |
2 |
3 |
子查询
子查询本质就是查询的嵌套,其语法就是用括号括住一个查询,将其看做一个新的表。用表studentscore
与表student
举例,例如,现在我们要查看成年学生的考试信息,就可以用子查询与in
关键字配合
1 | select * |
需要注意的是,与in
关键字配合时子查询只能有一个查询列名。
再比如,我们想要输出最高的学生总分,那我们需要先用一个表计算出总分,再在这个表里求最大值,可以这么写
1 | select max(sum_score) as max_sum_score |
上述代码在子查询的括号后可以加上as 表名
用于重命名子查询,在需要多表连接的时候需要用到
这个需求也可以这么写
1 | with sc as ( |
就是在最前面定义一个表,其语法如下
1 | with 表名1 as ( |
条件判断函数
条件判断语句也就类似于一般编程语言的if-else
结构,用起来很方便,接下来就来看看SQL
里的条件语句
case when
case when
语句是最类似与if-else
的,接下来我们来看看case when
语句的具体语法,其有两种语法
1 | # 用法1 |
可以看到,这其实就是if-else
的翻版,这可以用于各种子句中,例如select
子句,以表studentscore
举例,假设现在我们要输出学号,课程名字,成绩,并且我们知道课程编号对应的课程名字,就可以这么写
1 | select id, (case lesson when 1 then "语文" else "数学" end) as name, score |
其输出如下
id | name | score |
---|---|---|
1 | 语文 | 90 |
1 | 数学 | 80 |
2 | 语文 | 50 |
2 | 语文 | 70 |
再比如,现在我们给学生的分数划分等级,大于等于90为优秀,80-90为良好,60-80为及格,小于60为不及格,则可以这么写
1 | select |
其输出如下
id | lesson | level |
---|---|---|
1 | 1 | 优秀 |
1 | 2 | 良好 |
2 | 1 | 不及格 |
2 | 1 | 及格 |
还可以配合聚合函数使用,用表student
,例如,现在我们要计算大于等于18岁的学生数量,可以这么写
1 | # 写法1 |
则输出为
1 | cnt |
if
if
语句的作用与case when
语句基本相同,但是其只能填入一个条件,语法为
1 | if(条件, 条件为真对应结果, 条件不为真对应结果) |
使用if
语句重写一下上述例子
1 | # 输出课程名字 |
ifnull
ifnull
语句是用于处理空值的,其语法为
1 | ifnull(列名, 若该列数值为空代替使用的值) |
比如这个带空值的表studentscore
id | lesson | score |
---|---|---|
1 | 1 | 90 |
1 | 2 | 80 |
2 | 1 | 50 |
2 | 1 | null |
假设现在要计算各学生所有分数的和,空值记为0分则可以这么写
1 | select id, sum(ifnull(score, 0)) as sum_score |
窗口函数
在使用group by
子句时,会改变数据表的形状,而窗口函数就可以在不改变数据表形状下,使用统计函数,计算统计指标,并将其作为新的一列加入数据表,其具体语法如下
1 | 窗口函数 over(partition by 分组列名 order by 排序列名 框架) # over中的参数均可选 |
用求各课程平均分为例,之前使用group by
子句的写法如下
1 | select lesson, avg(score) as avg_score |
用窗口函数的写法如下
1 | select *, avg(score) over(partition by lesson) as avg_score |
最终输出如下
id | lesson | score | avg_score |
---|---|---|---|
1 | 1 | 90 | 70 |
1 | 2 | 80 | 80 |
2 | 1 | 50 | 70 |
2 | 1 | 70 | 70 |
可以看到,课程的平均分以新列的形式出现在了表的最后,并且表原本的信息均可以得到保留。
聚合函数
接下来我们来看看聚合函数作为窗口函数的用法,这与group by
子句类似,其效果上述例子已经看过了。具体的来说,将原本写在group by
子句后的列名写在partition by
后即可,同理partition by
可以为空,例如
1 | select *, |
当然,聚合函数也可以配合order by
使用,其主要在带时间的数据中会被用到,我们用一个例子来理解一下,现在修改表studentscore
,给条记录加上一个时间,修改后如下
id | lesson | time | score |
---|---|---|---|
1 | 1 | 2021-07-02 09:05:01 | 90 |
1 | 2 | 2021-07-03 09:05:01 | 80 |
2 | 1 | 2021-07-05 09:05:01 | 50 |
2 | 1 | 2021-09-10 09:05:01 | 70 |
现在我们的需求是输出全表及该学生在当前时间下所考到的最高分,代码如下
1 | select *, |
输出如下
id | lesson | time | score | mcnt |
---|---|---|---|---|
1 | 1 | 2021-07-02 09:05:01 | 90 | 90 |
1 | 2 | 2021-07-03 09:05:01 | 80 | 90 |
2 | 1 | 2021-07-05 09:05:01 | 50 | 50 |
2 | 1 | 2021-09-10 09:05:01 | 70 | 70 |
排序函数
语法
然后是排序函数,常见的有rank(), dense_rank(), row_number()
,这些函数都用于排序,一般配合order by
使用,例如,现在要对表studentscore
中的所有分数进行排序,写法如下
1 | select *, rank() over(order by score desc) as score_rank |
输出为
id | lesson | score | score_rank |
---|---|---|---|
1 | 1 | 90 | 1 |
1 | 2 | 80 | 2 |
2 | 1 | 70 | 3 |
2 | 1 | 50 | 4 |
这个写法就表名,根据列score降序(从大到小)进行排名。
当然,还可以配合partition by
一起使用,比如说,现在我们要输出每一个科目的成绩排名,那就需要按照lesson进行分组,再按照score降序进行排名,写出来就是
1 | select *, rank() over(partition by lesson order by score desc) as score_rank |
输出为
id | lesson | score | score_rank |
---|---|---|---|
1 | 1 | 90 | 1 |
2 | 1 | 70 | 2 |
2 | 1 | 50 | 3 |
1 | 2 | 80 | 1 |
当然,还可以按照多个列进行排序,比如,对于相同的得分就按照id大小排序,那上述代码可以改为
1 | select *, rank() over(partition by lesson order by score desc, id) as score_rank |
排序函数的区别
那么排序函数rank(), dense_rank(), row_number()
之间有什么区别呢,主要区别在出现相同的排名时的处理函数,我们通过一个例子来看看
score | rank() | dense_rank() | row_number() |
---|---|---|---|
100 | 1 | 1 | 1 |
90 | 2 | 2 | 2 |
90 | 2 | 2 | 3 |
80 | 4 | 3 | 4 |
80 | 4 | 3 | 5 |
可以看到,在出现相同的排名时,rank(), dense_rank()
都会输出同样的排名,不同的是rank()
输出的排名是间断的,而dense_rank()
输出的排名是连续的。而row_number()
则不会输出不同的排名,而是自己分配一个先后顺序。
补充
使用排序函数求中位数
在不支持使用聚合函数直接求中位数的SQL
环境中,可以使用排序函数求中位数,以表studentscore
为例,求出每个学生的分数中位数,代码如下
1 | with rr as ( |
具体思路为,先计算出每条记录的排名以及总数,即上述定义的表rr
,此处排名使用row_number()
函数或rank()
函数均可,然后将总数除以2,若为奇数,则会得到一个整数,该整数对应排名的记录即为中位数,若为偶数,则会得到一个小数,此时需要求最近的两个整数(如4.5就取4,5,用floor()
函数与ceiling()
函数就可以实现),取出两个整数对应排名的记录,再进行平均则得到中位数。
前后函数
前后函数包括lead()
函数与lag()
函数,其作用是将数据向前后位移,这样说有点抽象,我们用具体了例子来说明。用带时间的表studentscore
来举例,数据如下
id | lesson | time | score |
---|---|---|---|
1 | 1 | 2021-07-02 09:05:01 | 90 |
1 | 2 | 2021-07-03 09:05:01 | 80 |
2 | 1 | 2021-07-05 09:05:01 | 50 |
2 | 1 | 2021-09-10 09:05:01 | 70 |
首先我们来看看语法,lead()
函数与lag()
函数的语法都很简单
1 | lead(列名, 位移长度, null值处理) # 向上位移 |
其中null值处理可以不填。
现在,我们的需求是求出每个学生相近的两次考试时间的时间差,也被称为时间窗,具体写法如下
1 | select *, lead(time, 1) over(partition by uid order by time) as tt, timestampdiff(day, time, lead(time, 1) over(partition by uid order by time)) as time_window |
上述代码中,timestampdiff()
函数用于计算时间差,具体可见下文时间数据相关函数部分,代码输出如下
id | lesson | time | tt | time_window |
---|---|---|---|---|
1 | 1 | 2021-07-02 09:05:01 | 2021-07-03 09:05:01 | 1 |
1 | 2 | 2021-07-03 09:05:01 | null | null |
2 | 1 | 2021-07-05 09:05:01 | 2021-09-10 09:05:01 | 67 |
2 | 1 | 2021-09-10 09:05:01 | null | null |
可以看到,time列被整体向上位移了一个单位,并且由于没有填写null值的处理方式,所以缺失的值会直接为null(如果写的是lead(time, 1, 0)
则缺失值为0)。还需要注意的是,窗口函数中根据time
排序,这样才能确保是相邻的两次考试时间。
框架
最后我们来讲前面一直没有用到的一个参数,框架。框架用于进一步限制窗口大小,其分为rows
与range
两种模式
rows模式
rows
模式类似于滑动窗口,我们先从一个例子入手,现在有一份关于销售量的表sale
,数据如下
sale_date | sale_cnt |
---|---|
2021-08-30 09:05:01 | 40 |
2021-08-31 09:05:01 | 60 |
2021-09-01 09:05:01 | 70 |
2021-09-02 09:05:01 | 30 |
2021-09-03 09:05:01 | 50 |
我们要计算出从九月一号起,每一天的累计3天销量。这时候,我们就需要一个大小为3的滑动窗口,先计算从8-30到9-1的平均销量,然后往下滑动一格,再计算从8-31到9-2的平均销量,以此类推。这样的滑动窗口就可以用rows
模式来实现。
rows
模式是根据写好的规格,以当前行为参考,计算出限制窗口,其有两种写法
1 | rows 指定行 # 从当前行开始到指定行或从指定行开始到当前行 |
可填参数有以下几个
current row:当前行
unbounded preceding:当前行上侧所有行,也就是第一行
unbounded following:当前行下侧所有行,也就是最后一行
expr preceding:当前行上侧expr行(expr可以是数字,也可以是表达式)
expr following:当前行下侧expr行(expr可以是数字,也可以是表达式)
上述例子的需求,可以转化为计算从当前行上2行开始到当前行的平均销量,代码如下
1 | select *, avg(sale_cnt) over(rows 2 preceding) as avg_sale_3 |
上述代码的输出如下
sale_date | sale_cnt | avg_sale_3 |
---|---|---|
2021-08-30 09:05:01 | 40 | 40 |
2021-08-31 09:05:01 | 60 | 100 |
2021-09-01 09:05:01 | 70 | 170 |
2021-09-02 09:05:01 | 30 | 160 |
2021-09-03 09:05:01 | 50 | 150 |
可以看到,由于窗口函数计算时还是从第一行开始计算的,所以也会以8-30与8-31为中心计算,而其前两行中有空行,所以会被忽视。所以,上述需求的完整代码其实要这么写
1 | with avg_sale as ( |
range模式
range
模式是根据当前行的order by
列值,来筛选符合所写规则的行。其语法为
1 | range 指定值 # 筛选列值属于从当前值到指定值或从指定值到当前值的行 |
其可填参数不变,但意义不同
current row:当前值
unbounded preceding:无下限,也就是小多少都可以
unbounded following:无上限,也就是大多少都可以
expr preceding:当前行的列值小expr(expr可以是数字,也可以是表达式)
expr following:当前行的列值大expr(expr可以是数字,也可以是表达式)
例如,当前值为,我要筛选出列值在之间的行,那就是写为range between 3 preceding and 4 following
时间数据相关函数
时间类型的数据在实际的数据库中很常见,其一般格式为2021-07-02 09:05:01
,下面介绍一些操作与处理时间数据的函数
date_format()
date_format()
函数用于取出时间数据的各个部件,也就是可以根据实际要求取出年,月,日,小时,分钟,秒钟这些部分的一个或多个。以上述给的2021-07-02 09:05:01
为例,介绍一下date_format()
函数的语法
1 | date_format(时间列名, '通配符与字符的组合') |
假设时间数据所在的列名为start_time,其中的数据为2021-07-02 09:05:01
,常用的通配符有
1 | date_format(start_time, '%Y') # 函数值为2021 |
通配符也可以同时使用,以及与字符配合使用,比如
1 | date_format(start_time, '%Y%m%d') # 函数值为20210702 |
想要得到周几也可以通过date_format()
函数取出,使用通配符"%w"
,输出为数字0-7(0为星期天),如
1 | date_format(start_time, '%w') |
date_format()
函数可以配合多个子句使用,比如
1 | select date_format(start_time, '%Y%m%d') # 打印日期 |
date()
date()
函数用于提取时间数据的年月日部分,输出格式为%Y-%m-%d
,例如
1 | date(2021-07-02 09:05:01) # 输出为2021-07-02 |
timestampdiff()
timestampdiff()
函数用于计算两个时间数据之间的时间差,并且可以输出为各种单位,具体语法如下
1 | timestampdiff(单位, 时间数据1, 时间数据2) # 最终输出时间数据2-时间数据1 |
其中,单位可以输入
frac_second:毫秒
second:秒
minute:分钟
hour:小时
day:天
week:星期
month:月
quarter:季度
year:年
例如
1 | timestampdiff(day, 2021-07-02 09:05:01, 2021-07-03 09:05:01) # 输出 1 |
datediff()
datediff()
函数用于计算两个时间数据的日期差,语法如下
1 | datediff(时间数据1, 时间数据2) # 最终输出时间数据1-时间数据2 |
例如
1 | datediff(2021-07-03 09:05:01, 2021-07-02 09:05:01) # 输出1 |
timestampdiff()与datediff()的区别
timestampdiff()
函数计算时会考虑时间数据的全部信息,再转换为具体单位,且转换时会直接丢弃小数。datediff()
函数由于只用于计算日期差,所以是用时间数据的日期直接相减。例如
1 | timestampdiff(day, 2021-09-04 12:00:00, 2021-09-05 11:00:00) # 输出0 |
这两个时间数据相差23小时,timestampdiff()
函数转化为day时发现不满一天,则直接返回0,datediff()
函数用日期直接相减则返回1。
date_add()与date_sub()
date_add()
与date_sub()
函数用于加减时间,其语法如下
1 | date_add(时间, interval 增加时间数 单位) |
其中,单位可填的有
microsecond:毫秒
second:秒
minute:分钟
hour:小时
day:天
week:星期
month:月
quarter:季度
year:年
例如要给时间增加5天,就是
1 | date_add(2021-07-10 09:05:01, interval 5 day) # 输出2021-07-15 09:05:01 |
还可以使用复合型的写法,例如要给时间增加一个月3天,可以这么写
1 | date_add(2021-07-10 09:05:01, interval '1, 3' month_day) # 输出2021-08-13 09:05:01 |
常用指标与场景
留存率
在互联网行业中,留存率是一个重要且常用的指标。其百科定义如下:留存率是用于反映网站、互联网应用或网络游戏的运营情况的统计指标,其具体含义为在统计周期(周/月)内,每日活跃用户数在第N日仍启动该App的用户数占比的平均值。其中N通常取2、4、8、15、31,分别对应次日留存率、三日留存率、周留存率、半月留存率和月留存率。
次日留存率
以次日留存率为例,示例表user_log
,数据如下(uid为用户id,dt为登录日期):
uid | dt |
---|---|
101 | 2021-07-10 |
101 | 2021-07-11 |
101 | 2021-07-12 |
102 | 2021-07-11 |
102 | 2021-07-12 |
102 | 2021-07-14 |
103 | 2021-07-11 |
103 | 2021-07-13 |
103 | 2021-07-14 |
104 | 2021-07-12 |
104 | 2021-07-13 |
104 | 2021-07-15 |
这里使用的是经过去重的数据,实际场景中数据通常还需要经过distinct
或union
等处理
现在的需求是,求每天的新用户的次日留存率。首先是判断新用户,只需要计算其第一次登录的日期即可,这里我们使用窗口函数在原表最后添加一列第一次登录日期
1 | select uid, dt, min(dt) over(partition by uid) as f_dt |
然后我们就可以根据f_dt
进行分组,count(distinct uid)
计算当天新用户人数,判断次日登录只需要判断dt
与f_dt
加一天是否相等即可,完整代码如下
1 | with ul as ( |
如果现在的需求是求每天的次日留存率,注意需要计算所有用户,那就是要筛选出每天登录的人,再计算这些人中第二天还登录的有多少人。这里可以通过前后函数来简单实现
1 | select uid, dt, lead(dt, 1) over(partition by uid order by dt) as dt2 |
首先用lead()
函数,根据用户id分组,登录日期排序,由于数据经过去重,故这可以得到每次登录记录的下一次登录日期。输出如下
uid | dt | dt2 |
---|---|---|
101 | 2021-07-10 | 2021-07-11 |
101 | 2021-07-11 | 2021-07-12 |
101 | 2021-07-12 | null |
102 | 2021-07-11 | 2021-07-12 |
102 | 2021-07-12 | 2021-07-14 |
102 | 2021-07-14 | null |
103 | 2021-07-11 | 2021-07-13 |
103 | 2021-07-13 | 2021-07-14 |
103 | 2021-07-14 | null |
104 | 2021-07-12 | 2021-07-13 |
104 | 2021-07-13 | 2021-07-15 |
104 | 2021-07-15 | null |
我们要判断是否次日登录,只需要判断dt
加一天是否等于dt2
。完整代码如下
1 | with ul as ( |
实例
最后我们来看一个比较现实的例子,用户行为日志表tb_user_log
(uid-用户ID, artical_id-文章ID, in_time-进入时间, out_time-离开时间, sign_in-是否签到)。计算2021年11月每天新用户的次日留存率,结果按日期升序(如果in_time-进入时间和out_time-离开时间跨天了,在两天里都记为该用户活跃过),数据如下
id | uid | artical_id | in_time | out_time | sign_cin |
---|---|---|---|---|---|
1 | 101 | 0 | 2021-11-01 10:00:00 | 2021-11-01 10:00:42 | 1 |
2 | 102 | 9001 | 2021-11-01 10:00:00 | 2021-11-01 10:00:09 | 0 |
3 | 103 | 9001 | 2021-11-01 10:00:01 | 2021-11-01 10:01:50 | 0 |
4 | 101 | 9002 | 2021-11-02 10:00:09 | 2021-11-02 10:00:28 | 0 |
5 | 103 | 9002 | 2021-11-02 10:00:51 | 2021-11-02 10:00:59 | 0 |
6 | 104 | 9001 | 2021-11-02 11:00:28 | 2021-11-02 11:01:24 | 0 |
7 | 101 | 9003 | 2021-11-03 11:00:55 | 2021-11-03 11:01:24 | 0 |
8 | 104 | 9003 | 2021-11-03 11:00:45 | 2021-11-03 11:00:55 | 0 |
9 | 105 | 9003 | 2021-11-03 11:00:53 | 2021-11-03 11:00:59 | 0 |
10 | 101 | 9002 | 2021-11-04 11:00:55 | 2021-11-04 11:00:59 | 0 |
首先,我们要将数据往上述的示例数据那边靠,根据题意,进入时间与离开时间都算活跃,那就把这两列合并,变成一个uid与活跃时间的表,代码如下
1 | select uid, date(in_time) as dt |
用date()
提取出我们关注的日期,用union
做去重。
然后,就是求第一次登录时间,同样的使用窗口函数
1 | with tul as ( |
最后计算次日留存率
1 | with tul as ( |
注意,2021年11月这个限定条件要在最后进行限定,如果在表ut
中提前限定的话,会导致错判部分用户的首次登录日期。
连续签到天数
在实际场景中,经常会有计算连续签到天数的需求,其难点在于如何判断日期的连续。示例表user_log
,数据如下(uid为用户id,dt为登录日期):
uid | dt |
---|---|
101 | 2021-07-10 |
102 | 2021-07-10 |
101 | 2021-07-11 |
102 | 2021-07-12 |
102 | 2021-07-13 |
101 | 2021-07-13 |
这里用的是最方便的数据,实际情况下数据会比较复杂,需要计算连续签到时,可以将数据处理成类似于上述数据的结构,例如用date()
函数提取时间的日期部分,有多个时间列的情况下,用union
将所有时间列合并为一个等。
现在我们来看看判断连续签到的具体思路,一般的思路是,先对时间进行排序,再使用时间与排序相减,只要时间是连续的,那么时间与排序相减的值就是不变的,代码为
1 | select |
输出为
uid | dt | r1 | dt2 |
---|---|---|---|
101 | 2021-07-10 | 1 | 20210709 |
101 | 2021-07-11 | 2 | 20210709 |
101 | 2021-07-13 | 3 | 20210710 |
102 | 2021-07-10 | 1 | 20210709 |
102 | 2021-07-12 | 2 | 20210710 |
102 | 2021-07-13 | 3 | 20210710 |
可以看到,只需要在上述表的基础上,根据uid, dt2
进行分组,那就可以保证每一个分组中都是连续的日期,只需要根据这个分组进行排序,就可以计算得到连续签到的天数了,代码如下
1 | with r1 as ( |
输出为
uid | dt | r1 | dt2 | cnt_day |
---|---|---|---|---|
101 | 2021-07-10 | 1 | 20210709 | 1 |
101 | 2021-07-11 | 2 | 20210709 | 2 |
101 | 2021-07-13 | 3 | 20210710 | 1 |
102 | 2021-07-10 | 1 | 20210709 | 1 |
102 | 2021-07-12 | 2 | 20210710 | 1 |
102 | 2021-07-13 | 3 | 20210710 | 2 |
可以看到cnt_day
记录的就是当前时间下用户的连续签到天数。
补充
数据类型转化
在实际情况中,有时候会出现数据类型与我们的要求不符导致bug的情况(比如两列相减,但是列的类型为无符号整数,相减结果有负数机会溢出报错),这时候就要用到数据类型转化。数据类型转化有两种方法,分别是cast()
与convert()
,其语法分别为
1 | cast(列名 as 类型) |
类型可填参数如下
二进制: binary
字符型: char
日期: date
时间: time
日期时间型: datetime
浮点数: decimal
整数: signed
无符号整数: unsigned