一道题用明白 SQL 滑动窗口
题目描述
写一个 SQL 查询语句,检索连续 3 天都做了核酸而且是阴性的用户
例如对于表格 T
1
2
3
4
5
6
7
8
9
| | uid | tdate | result |
| --- | -------- | ------ |
| 001 | 2023/1/1 | neg |
| 002 | 2023/1/1 | neg |
| 001 | 2023/1/2 | neg |
| 002 | 2023/1/2 | neg |
| 001 | 2023/1/3 | neg |
| 001 | 2023/1/4 | neg |
| 002 | 2023/1/4 | neg |
|
我们期望查询到用户 001 连续 3 天做了核酸,而 002 用户只连续做了两天,漏掉了 2023/1/3 的日期,不满足我们的要求。所以我们的查询结果是
1
2
3
| | uid |
| --- |
| 001 |
|
解法 1: 多表连接
我们可以使用三个表格的 self join 方法,每个表格代表同一用户在不同日期的核酸测试结果,我们可以使用 t.result = 'neg'
来筛选出阴性记录,并通过 DISTINCT 关键字来去除重复。
1
2
3
4
5
6
7
8
9
10
11
12
| SELECT
DISTINCT t1.uid
FROM
T t1
JOIN T t2 ON t1.uid = t2.uid
JOIN T t3 ON t1.uid = t3.uid
WHERE
t1.result = 'neg'
AND t2.result = 'neg'
AND t3.result = 'neg'
AND t1.tdate = DATE_SUB(t2.tdate, INTERVAL 1 DAY)
AND t2.tdate = DATE_SUB(t3.tdate, INTERVAL 1 DAY);
|
当然,上面的 SQL 还有可以优化的空间,因为这样的多表链接存在大量的日期交错问题,表格的 JOIN
操作会有如下的很多冗余无意义结果
1
2
3
4
5
6
| | t1.tdate | t2.tdate | t3.tdate |
| -------- | -------- | -------- |
| 2023/1/1 | 2023/1/2 | 2023/1/3 |
| 2023/1/2 | 2023/1/1 | 2023/1/3 |
| 2023/1/1 | 2023/1/1 | 2023/1/1 |
……
|
我们可以使用日期条件进行 JOIN
操作,可以很大程度上避免重复,让查询更加简单且直观。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| SELECT
DISTINCT t1.uid
FROM
T t1
JOIN T t2
ON t1.uid = t2.uid
AND t1.tdate = DATE_SUB(t2.tdate, INTERVAL 1 DAY)
JOIN T t3
ON t2.uid = t3.uid
AND t2.tdate = DATE_SUB(t3.tdate, INTERVAL 1 DAY)
WHERE
t1.result = 'neg'
AND t2.result = 'neg'
AND t3.result = 'neg';
|
在上面的 SQL 语句中,我们在 JOIN 操作的 ON 子句中加入了日期条件,即 t1.tdate = DATE_SUB(t2.tdate, INTERVAL 1 DAY
这样我们表连接出来的结果就是连续且自增的三天,相比于上面的判断语句减少了开销。
解法 2: 日期与天数的差
上面的例子缺乏扩展性,如果我们更新了要求,需要你检查连续 7 天核酸或 15 天核酸,那么就需要写非常非常多的 join 操作,检索的成本和开销都会大幅提升。有没有什么更好的办法呢?
我们可以观察到,如果是连续做核酸,那么用户做核酸的日期和一个递增的数字之差应该是相等的。我们可以看下面的 SQL 作为例子
1
2
3
4
5
6
7
8
| SELECT
uid,
tdate,
row_number() over(partition by uid order by tdate) date_rank
FROM
T
WHERE
T.result = 'neg';
|
我们可以得到如下的表格
1
2
3
4
5
6
7
8
9
| | uid | tdate | date_rank |
| --- | -------- | --------- |
| 001 | 2023/1/1 | 1 |
| 001 | 2023/1/2 | 2 |
| 001 | 2023/1/3 | 3 |
| 001 | 2023/1/4 | 4 |
| 002 | 2023/1/1 | 1 |
| 002 | 2023/1/2 | 2 |
| 002 | 2023/1/4 | 3 |
|
之后,我们只需要获取每个核酸日期 (tdate) 中,日期与核酸日期 rank(date_rank) 之间的差值,例如对于 uid 001 的用户,他在 2023/1/3 的核酸日期中 3 号,与它的每日核酸次数排名 date_rank 3 做差值,得到 1
对应的 SQL 如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| SELECT
uid,
tdate,
date_rank,
(date_format(tdate, "%e") - date_rank) as diff
FROM(
SELECT
uid,
tdate,
row_number() over (
partition by uid order by tdate
) date_rank
FROM
T
WHERE
T.result = 'neg'
) T1;
|
我们可以得到如下的结果
1
2
3
4
5
6
7
8
9
| | uid | tdate | date_rank | diff |
| --- | -------- | --------- | ---- |
| 001 | 2023/1/1 | 1 | 0 |
| 001 | 2023/1/2 | 2 | 0 |
| 001 | 2023/1/3 | 3 | 0 |
| 001 | 2023/1/4 | 4 | 0 |
| 002 | 2023/1/1 | 1 | 0 |
| 002 | 2023/1/2 | 2 | 0 |
| 002 | 2023/1/4 | 3 | 1 |
|
从上表我们可以发现,连续核酸日期的 diff 结果上相等的,例如可以是一系列的 0,一系列的 1 或 2 等。那么我们就可以非常简单的获取用户的连续核酸天数,即只需要对这个表格按照 uid 和 diff 进行 count 聚合就可以。
对应的 SQL 如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| SELECT
uid,
diff,
count(tdate) as cont_days
FROM(
SELECT
uid,
tdate,
date_rank,
(date_format(tdate, "%e") - date_rank) as diff
FROM(
SELECT
uid,
tdate,
row_number() over (
partition by uid order by tdate
) date_rank
FROM
T
WHERE
T.result = 'neg'
) T1
) T2
GROUP BY
uid,
diff;
|
我们可以查到形如下面的表格
1
2
3
4
5
| | uid | diff | cont_days |
| --- | ---- | --------- |
| 001 | 0 | 4 |
| 002 | 0 | 2 |
| 002 | 1 | 1 |
|
这个表格表示,在这个时间段,用户 001 连续做了 4 天核酸,而用户 002 首先连续做了 2 天核酸,然后漏做了 1 天,之后又做了 1 次核酸。
那么最后,我们只需要按照用户的连续核酸天数进行筛选,就可以得到我们的需求目标,SQL 如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| SELECT
DISTINCT uid
FROM(
SELECT
uid,
diff,
count(tdate) as cont_days
FROM(
SELECT
uid,
tdate,
date_rank,
(date_format(tdate, "%e") - date_rank) as diff
FROM(
SELECT
uid,
tdate,
row_number() over (
partition by uid order by tdate
) date_rank
FROM
T
WHERE
T.result = 'neg'
) T1
) T2
GROUP BY
uid,
diff
)
WHERE
cont_days >= 3;
|
通过这样的方式,我们就获得了连续 3 天做了核酸的用户,而且也可以非常灵活的扩展到连续 N 天做了核酸的用户。
扩展:连续出现 N 次的数字
上面通过日期和序列做差的方法是利用了日期的自增特性,那么对于更加广泛的场景,只要存在序列化信息,我们都能用上述的方法实现连续出现内容的统计。
在这个问题场景中,我们需要使用 SQL 来检索连续出现 N 次的数字,例如连续出现 3 次,如下表 NUM 所示:
1
2
3
4
5
6
7
8
9
10
11
12
| | id | num |
| --- | --- |
| 1 | 1 |
| 2 | 1 |
| 3 | 1 |
| 4 | 2 |
| 5 | 2 |
| 6 | 1 |
| 7 | 7 |
| 8 | 9 |
| 9 | 9 |
| 10 | 9 |
|
我们期望检索出连续出现 3 次的数字,结果为
1
2
3
4
| | num |
| --- |
| 1 |
| 9 |
|
可以仿照上述的方法,构造一个分组自增的 num_rank,与 id 做差,进行统计
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| WITH V AS (
SELECT
num,
id - num_rank as diff
FROM(
SELECT
num,
row_number() over (
partition by num order by id
) as num_rank,
FROM
NUM
) T
)
SELECT
DISTINCT num
FROM
V
GROUP BY
num,
diff
HAVING
count(*) >= 3;
|
在这里,我们把自增的信息从日期变为了 id,本质上还是将一个连续出现的数字组构造一系列组内自增信息,即 num_rank,与这里的全局 id 进行做差,找到不变的序列量。
Conclusion
本文中我们探讨了如何从数据库中检索一个连续出现 N 次的内容。在这里,我们由浅入深,从最直观的多表连接入手,然后使用窗口函数扩展到连续 N 天打卡,最后将这个连续自增信息从日期扩展到了 id 等一系列代表序列的内容,实现了检索连续出现 N 次的数字。