跳到主要内容
跳到主要内容

IN 运算符

INNOT INGLOBAL INGLOBAL NOT IN 运算符单独介绍,因为它们的功能较为丰富。

运算符左侧可以是单个列或元组。

示例:

SELECT UserID IN (123, 456) FROM ...
SELECT (CounterID, UserID) IN ((34, 123), (101500, 456)) FROM ...

如果左侧是一个被索引的单个列,而右侧是一个常量集合,系统会使用该索引来处理查询。

不要显式列出过多的值(例如数百万个)。如果数据集很大,请将其放入一个临时表中(例如,参见 External data for query processing 一节),然后再使用子查询。

运算符的右侧可以是常量表达式的集合、由常量表达式构成的元组的集合(如上面的示例所示),或者是一个数据库表名,或用括号括起来的 SELECT 子查询。

ClickHouse 允许 IN 子查询左右两侧的类型不同。 在这种情况下,它会将右侧的值转换为左侧的类型, 就像在右侧应用了 accurateCastOrNull 函数一样。

这意味着数据类型会变为 Nullable,并且如果无法完成转换,则返回 NULL

示例

查询:

SELECT '1' IN (SELECT 1);

结果:

┌─in('1', _subquery49)─┐
│                    1 │
└──────────────────────┘

如果运算符右侧是表名(例如 UserID IN users),这等价于子查询 UserID IN (SELECT * FROM users)。在处理随查询一同发送的外部数据时,可以使用这种方式。例如,可以将查询与一组用户 ID 一起发送,这些 ID 被加载到临时表 'users' 中,并需要对其进行过滤。

如果运算符右侧是使用 Set 引擎(始终驻留在 RAM 中的预先准备的数据集)的表名,则不会在每次查询时重复创建该数据集。

子查询可以指定多列来过滤元组。

示例:

SELECT (CounterID, UserID) IN (SELECT CounterID, UserID FROM ...) FROM ...

IN 运算符左侧和右侧的列必须具有相同的数据类型。

IN 运算符和子查询可以出现在查询语句的任何部分,包括聚合函数和 lambda 函数中。 示例:

SELECT
    EventDate,
    avg(UserID IN
    (
        SELECT UserID
        FROM test.hits
        WHERE EventDate = toDate('2014-03-17')
    )) AS ratio
FROM test.hits
GROUP BY EventDate
ORDER BY EventDate ASC
┌──EventDate─┬────ratio─┐
│ 2014-03-17 │        1 │
│ 2014-03-18 │ 0.807696 │
│ 2014-03-19 │ 0.755406 │
│ 2014-03-20 │ 0.723218 │
│ 2014-03-21 │ 0.697021 │
│ 2014-03-22 │ 0.647851 │
│ 2014-03-23 │ 0.648416 │
└────────────┴──────────┘

对于 3 月 17 日之后的每一天,统计页面浏览量中来自在 3 月 17 日访问过该站点的用户的占比。 IN 子句中的子查询始终只会在单个服务器上执行一次。不存在相关子查询。

NULL 处理

在请求处理过程中,IN 运算符假定与 NULL 的运算结果始终等于 0,无论 NULL 位于运算符的右侧还是左侧。如果 transform_null_in = 0,则 NULL 值不会包含在任何数据集中,彼此之间也不相等,且无法进行比较。

下面是使用 t_null 表的一个示例:

┌─x─┬────y─┐
│ 1 │ ᴺᵁᴸᴸ │
│ 2 │    3 │
└───┴──────┘

运行查询 SELECT x FROM t_null WHERE y IN (NULL,3) 将得到以下结果:

┌─x─┐
│ 2 │
└───┘

你可以看到,y = NULL 所在的行被从查询结果中剔除了。这是因为 ClickHouse 无法判断 (NULL,3) 这个 set 中是否包含 NULL,因此将该运算的结果返回为 0,而 SELECT 会将这一行从最终输出中排除。

SELECT y IN (NULL, 3)
FROM t_null
┌─in(y, tuple(NULL, 3))─┐
│                     0 │
│                     1 │
└───────────────────────┘

分布式子查询

对于带有子查询的 IN 运算符(类似于 JOIN 运算符),有两种用法:普通的 IN / JOINGLOBAL IN / GLOBAL JOIN。它们在分布式查询处理中的执行方式不同。

注意

请注意,下文描述的算法可能会因 settings 中的 distributed_product_mode 设置而表现不同。

当使用常规的 IN 时,查询会被发送到远程服务器,每个远程服务器都会在其上执行 INJOIN 子句中的子查询。

当使用 GLOBAL IN / GLOBAL JOIN 时,首先会执行所有 GLOBAL IN / GLOBAL JOIN 的子查询,并将结果收集到临时表中。然后,这些临时表被发送到每个远程服务器,在远程服务器上使用这些临时数据来执行查询。

对于非分布式查询,请使用普通的 IN / JOIN

在分布式查询处理时,在 IN / JOIN 子句中使用子查询要格外小心。

来看一些例子。假设集群中的每台服务器都有一个普通的 local_table。每台服务器还有一个 Distributed 类型的 distributed_table 表,该表指向集群中的所有服务器。

对于发往 distributed_table 的查询,该查询会被发送到所有远程服务器,并在这些服务器上基于 local_table 执行。

例如,查询

SELECT uniq(UserID) FROM distributed_table

将以如下形式发送到所有远程服务器

SELECT uniq(UserID) FROM local_table

并在每个节点上并行运行,直到达到可以合并中间结果的阶段。然后中间结果会被返回到发起请求的服务器并在其上合并,最终结果将被发送给客户端。

现在让我们来看一个带有 IN 的查询:

SELECT uniq(UserID) FROM distributed_table WHERE CounterID = 101500 AND UserID IN (SELECT UserID FROM local_table WHERE CounterID = 34)
  • 计算两个网站受众的交集。

此查询将以如下形式发送到所有远程服务器:

SELECT uniq(UserID) FROM local_table WHERE CounterID = 101500 AND UserID IN (SELECT UserID FROM local_table WHERE CounterID = 34)

换句话说,IN 子句中的数据集会在每台服务器上独立收集,只会基于该服务器本地存储的数据进行处理。

如果你事先为这种情况做好准备,并且在集群服务器之间分布数据,使得单个 UserID 的数据完全存放在同一台服务器上,那么该机制会正确且高效地工作。在这种情况下,每台服务器上所需的数据都可以在本地获取。否则,结果将会不准确。我们将这种形式的查询称为 “local IN”。

当数据随机分布在集群服务器上时,为了修正查询的执行方式,你可以在子查询中指定 distributed_table。查询将如下所示:

SELECT uniq(UserID) FROM distributed_table WHERE CounterID = 101500 AND UserID IN (SELECT UserID FROM distributed_table WHERE CounterID = 34)

该查询将以如下形式发送到所有远程服务器:

SELECT uniq(UserID) FROM local_table WHERE CounterID = 101500 AND UserID IN (SELECT UserID FROM distributed_table WHERE CounterID = 34)

子查询会在每台远程服务器上开始执行。由于子查询使用了分布式表,每台远程服务器上的该子查询会被重新发送到所有远程服务器,如下所示:

SELECT UserID FROM local_table WHERE CounterID = 34

例如,如果你有一个包含 100 台服务器的集群,执行整个查询将需要 10,000 个基础请求,这样的开销通常是不可接受的。

在这种情况下,你应始终使用 GLOBAL IN 而不是 IN。让我们看看它在该查询中的工作方式:

SELECT uniq(UserID) FROM distributed_table WHERE CounterID = 101500 AND UserID GLOBAL IN (SELECT UserID FROM distributed_table WHERE CounterID = 34)

发起请求的服务器会执行该子查询:

SELECT UserID FROM distributed_table WHERE CounterID = 34

其结果会被放入 RAM 中的临时表。然后将请求按如下方式发送到每个远程服务器:

SELECT uniq(UserID) FROM local_table WHERE CounterID = 101500 AND UserID GLOBAL IN _data1

临时表 _data1 将随查询一起发送到每个远程服务器(临时表的名称由具体实现决定)。

这比使用普通的 IN 更高效,但请注意以下几点:

  1. 创建临时表时,数据不会自动去重。为了减少通过网络传输的数据量,请在子查询中使用 DISTINCT。(对于普通的 IN,不需要这样做。)
  2. 临时表会被发送到所有远程服务器。传输时不会考虑网络拓扑结构。例如,如果有 10 台远程服务器位于距离发起请求的服务器很远的数据中心,则数据会通过与该远程数据中心的网络链路被发送 10 次。在使用 GLOBAL IN 时,尽量避免使用大型数据集。
  3. 向远程服务器传输数据时,无法对网络带宽限制进行配置,这可能会导致网络过载。
  4. 尽量将数据合理分布到各个服务器上,以避免经常需要使用 GLOBAL IN
  5. 如果你需要频繁使用 GLOBAL IN,请规划 ClickHouse 集群的部署位置,使得同一组副本不跨越多个数据中心,并在它们之间使用高速网络,以便一个查询可以完全在单个数据中心内完成处理。

GLOBAL IN 子句中指定一个本地表也是有意义的,特别是在该本地表仅在发起请求的服务器上可用,而你又希望在远程服务器上使用其中数据的情况下。

分布式子查询和 max_rows_in_set

你可以使用 max_rows_in_setmax_bytes_in_set 来控制在分布式查询期间传输的数据量。

如果 GLOBAL IN 查询返回的数据量很大,这一点尤为重要。请考虑下面的 SQL:

SELECT * FROM table1 WHERE col1 GLOBAL IN (SELECT col1 FROM table2 WHERE <some_predicate>)

如果 some_predicate 的选择性不够高,它会返回大量数据并导致性能问题。在这种情况下,限制通过网络传输的数据量是明智的做法。另请注意,set_overflow_mode 默认设置为 throw,这意味着当达到这些阈值时会抛出异常。

分布式子查询与 max_parallel_replicas

max_parallel_replicas 大于 1 时,分布式查询会被进一步转换。

例如,如下所示:

SELECT CounterID, count() FROM distributed_table_1 WHERE UserID IN (SELECT UserID FROM local_table_2 WHERE CounterID < 100)
SETTINGS max_parallel_replicas=3

在每台服务器上被转换为:

SELECT CounterID, count() FROM local_table_1 WHERE UserID IN (SELECT UserID FROM local_table_2 WHERE CounterID < 100)
SETTINGS parallel_replicas_count=3, parallel_replicas_offset=M

其中 M 的取值范围为 13,取决于本地查询正在第几个副本上执行。

这些设置会影响该查询中所有 MergeTree 系列表,并且其效果等同于在每个表上应用 SAMPLE 1/3 OFFSET (M-1)/3

因此,只有在两个表具有相同的复制方案,并且都按 UserID 或其子键进行采样时,添加 max_parallel_replicas 设置才会产生正确的结果。尤其是,如果 local_table_2 没有采样键,则会产生不正确的结果。同样的规则也适用于 JOIN

如果 local_table_2 不满足这些要求,一种变通方法是使用 GLOBAL INGLOBAL JOIN

如果某个表没有采样键,则可以使用更灵活的 parallel_replicas_custom_key 选项,从而实现不同且更优的行为。