DuckDB 支持 AsOf Joins——一种匹配附近值的方法。 它们对于搜索事件表以进行时间分析特别有用。
有想要连接的时间序列数据但时间戳不太匹配? 或者想使用另一个表中的时间查找随时间变化的值? 最终是否编写了复杂(且缓慢)的不等值连接来获得结果? 那么这篇文章适合你!
时间序列数据并不总是完全一致。 时钟可能略有偏差,或者原因和结果之间可能存在延迟。 这可能会给连接两组有序数据带来挑战。 AsOf Joins 是解决此问题和其他类似问题的工具。
AsOf Joins 用于解决的问题之一是查找特定时间点的变化属性的值。 这个用例非常常见,因此它的名字就来自于:
请告诉我目前property的值
然而,更一般地说,AsOf 连接体现了一些常见的时间分析语义,这些语义在标准 SQL 中实现起来可能很麻烦且缓慢。
让我们从一个具体的例子开始。 假设我们有一个带有时间戳的股票价格表:
ticker | when | price |
APPL | 2001-01-01 00:00:00 | 1 |
APPL | 2001-01-01 00:01:00 | 2 |
APPL | 2001-01-01 00:02:00 | 3 |
MSFT | 2001-01-01 00:00:00 | 1 |
MSFT | 2001-01-01 00:01:00 | 2 |
MSFT | 2001-01-01 00:02:00 | 3 |
GOOG | 2001-01-01 00:00:00 | 1 |
GOOG | 2001-01-01 00:01:00 | 2 |
GOOG | 2001-01-01 00:02:00 | 3 |
我们还有另一个表,其中包含不同时间点的投资组合持有量:
ticker | when | shares |
APPL | 2000-12-31 23:59:30 | 5.16 |
APPL | 2001-01-01 00:00:30 | 2.94 |
APPL | 2001-01-01 00:01:30 | 24.13 |
GOOG | 2000-12-31 23:59:30 | 9.33 |
GOOG | 2001-01-01 00:00:30 | 23.45 |
GOOG | 2001-01-01 00:01:30 | 10.58 |
DATA | 2000-12-31 23:59:30 | 6.65 |
DATA | 2001-01-01 00:00:30 | 17.95 |
DATA | 2001-01-01 00:01:30 | 18.37 |
我们可以通过使用 AsOf Join 查找持有时间戳之前的最新价格来计算该时间点每个持有的价值:
SELECT h.ticker, h.when, price * shares AS value
FROM holdings h ASOF JOIN prices p
ON h.ticker = p.ticker
AND h.when >= p.when
这会将当时持有的价值附加到每一行:
ticker | when | value |
APPL | 2001-01-01 00:00:30 | 2.94 |
APPL | 2001-01-01 00:01:30 | 48.26 |
GOOG | 2001-01-01 00:00:30 | 23.45 |
GOOG | 2001-01-01 00:01:30 | 21.16 |
它本质上执行一个通过在价格表中查找附近值来定义的函数。 另请注意,缺失的股票代码值没有匹配项,并且不会出现在输出中。
由于 AsOf 最多从右侧生成一个匹配项,因此左侧表不会因连接而增长,但如果右侧有缺失时间,左侧表可能会缩小。 为了处理这种情况可以使用外部 AsOf 连接:
SELECT h.ticker, h.when, price * shares AS value
FROM holdings h ASOF LEFT JOIN prices p
ON h.ticker = p.ticker
AND h.when >= p.when
ORDER BY ALL
正如所期望的,当没有股票行情或时间在价格开始之前时,这将产生 NULL 价格和值,而不是删除左侧行。
ticker | when | value |
APPL | 2000-12-31 23:59:30 | |
APPL | 2001-01-01 00:00:30 | 2.94 |
APPL | 2001-01-01 00:01:30 | 48.26 |
GOOG | 2000-12-31 23:59:30 | |
GOOG | 2001-01-01 00:00:30 | 23.45 |
GOOG | 2001-01-01 00:01:30 | 21.16 |
DATA | 2000-12-31 23:59:30 | |
DATA | 2001-01-01 00:00:30 | |
DATA | 2001-01-01 00:01:30 |
标准 SQL 可以实现这种连接,但需要使用窗口函数和不等式连接。 这些操作都可能相当昂贵,但查询如下所示:
WITH state AS (
SELECT ticker, when, price,
LEAD(when, 1, 'infinity') OVER (PARTITION BY ticker ORDER BY when) AS end
),
SELECT ticker, h.when, price * shares AS value
FROM holdings h INNER JOIN state s
ON h.ticker = s.ticker
AND h.when >= s.when
AND h.when < s.end
默认值无穷大用于确保最后一行有一个可以比较的结束值。 对于我们的示例,状态 CTE 如下所示:
ticker | price | when | end |
APPL | 1 | 2001-01-01 00:00:00 | 2001-01-01 00:01:00 |
APPL | 2 | 2001-01-01 00:01:00 | 2001-01-01 00:02:00 |
APPL | 3 | 2001-01-01 00:02:00 | infinity |
GOOG | 1 | 2001-01-01 00:00:00 | 2001-01-01 00:01:00 |
GOOG | 2 | 2001-01-01 00:01:00 | 2001-01-01 00:02:00 |
GOOG | 3 | 2001-01-01 00:02:00 | infinity |
MSFT | 1 | 2001-01-01 00:00:00 | 2001-01-01 00:01:00 |
MSFT | 2 | 2001-01-01 00:01:00 | 2001-01-01 00:02:00 |
MSFT | 3 | 2001-01-01 00:02:00 | infinity |
在没有相等条件的情况下,规划器将不得不使用不等式连接,这可能非常昂贵。 即使在相等条件的情况下,生成的散列连接也可能最终得到相同的代码键的长链,这些键都将匹配并需要裁剪。
如果 SQL 已经可以计算 AsOf 连接,为什么我们需要新的连接类型? 有两个重要原因:可表达性和性能。 窗口替代方案比 AsOf 语法更冗长且更难理解,因此更容易说出您正在做的事情可以帮助其他人(甚至您!)理解正在发生的事情。
该语法还使 DuckDB 更容易理解您想要的内容并更快地生成结果。 窗口和不等式连接版本丢失了间隔不重叠的有价值的信息。 它还可以防止查询优化器移动连接,因为 SQL 坚持在连接之后进行窗口化。 通过将操作视为具有已知数据约束的联接,DuckDB 可以移动联接以提高性能并使用定制的联接算法。 我们使用的算法是对右侧表进行排序,然后与左侧值进行某种合并连接。 但与标准合并联接不同,AsOf 可以在找到第一个匹配项时停止搜索,因为最多有一个匹配项。
可能想知道为什么WITH 子句中的公共表表达式被称为状态。 这是因为价格表实际上是时间分析中所谓的事件表的一个示例。 事件表的行包含时间戳和当时发生的事情(即事件)。 价格表中的事件是股票价格的变化。 事件表的另一个常见示例是结构化日志文件:日志的每一行记录“发生”某事的时间——通常是对系统一部分的更改。
事件表很难使用,因为每个事实只有开始时间。 为了知道事实是否仍然正确(或在特定时间正确),还需要结束时间。 具有开始时间和结束时间的表称为状态表。 将事件表转换为状态表是一项常见的时态数据准备任务,上面的窗口 CTE 显示了如何使用 SQL 来完成此任务。
窗口方法的一个限制是排序类型需要具有在不支持无穷大时可以使用的哨兵值(未使用的值或 NULL)。
这两种选择都可能存在问题。 在第一种情况下,确定上哨兵值可能并不容易(假设排序是字符串列?)在第二种情况下,您需要将条件编写为 h.when < s.end OR s.end IS NULL
并在连接条件中使用这样的 OR 会使比较变慢并且难以优化。 此外,如果排序列已使用 NULL 来指示缺失值,则此选项不可用。
对于大多数状态表,都有合适的选择(例如大日期),但 AsOf 的优点之一是,如果分析任务不需要状态表,它可以避免设计状态表。
到目前为止,我们一直在使用标准类型的事件表,其中时间戳被假定为状态转换的开始。 但 AsOf 现在可以使用任何不等式,这使其能够处理其他类型的事件表。
为了探索这一点,让我们使用两个非常简单的表,没有相等条件。 构建端只有四个带有字母值的整数“时间戳”:
Time | Value |
1 | a |
2 | b |
3 | c |
4 | d |
探测表只是时间值加上中点,我们可以制作一个表来显示每个探测时间匹配的值大于或等于:
Probe | >= |
0.5 | |
1.0 | a |
1.5 | a |
2.0 | b |
2.5 | b |
3.0 | c |
3.5 | c |
4.0 | d |
4.5 | d |
这表明探测值匹配的区间处于半开区间[Tn,Tn+1)内。
现在让我们看看如果使用严格大于作为不等式会发生什么:
Probe | > |
0.5 | |
1.0 | |
1.5 | a |
2.0 | a |
2.5 | b |
3.0 | b |
3.5 | c |
4.0 | c |
4.5 | d |
现在我们可以看到探针值匹配的区间处于半开区间(Tn,Tn+1]。唯一的区别是该区间在末尾而不是在开头闭合。这意味着对于这种不等式类型 ,时间不是间隔的一部分。
如果不等式向另一个方向发展(例如小于或等于)怎么办?
Probe | <= |
0.5 | a |
1.0 | a |
1.5 | b |
2.0 | b |
2.5 | c |
3.0 | c |
3.5 | d |
4.0 | d |
4.5 |
同样,我们有半开间隔,但这次我们匹配前一个间隔 (Tn-1, Tn]。解释这一点的一种方法是构建表中的时间是间隔的结束时间,而不是开始时间 .此外,与大于或等于不同,间隔在末尾而不是在开始处闭合。将其添加到我们发现的严格大于的内容中,我们可以将其解释为意味着当非时查找时间是间隔的一部分 - 使用严格的不等式。
我们可以通过查看最后一个不等式来检查这一点:严格小于:
Probe | < |
0.5 | a |
1.0 | b |
1.5 | b |
2.0 | c |
2.5 | c |
3.0 | d |
3.5 | d |
4.0 | |
4.5 |
在这种情况下,匹配间隔是[Tn-1, Tn)。 这是一个严格的不等式,所以表时间不在区间内,而且是一个小于,所以时间是区间结束的时间。
总而言之,以下是完整列表:
Inequality | Interval |
> | (Tn, Tn+1] |
>= | [Tn, Tn+1) |
<= | (Tn-1, Tn] |
< | [Tn-1, Tn) |
现在我们对不平等的含义有两种自然的解释:
• 大于(或小于)不等式意味着该时间是间隔的开始(或结束)。
• 严格(或非严格)不等式意味着时间被排除在(或包含在)间隔之外。
因此,如果我们知道时间是事件的开始还是结束,以及时间是包含还是排除,我们就可以选择适当的 AsOf 不等式。
到目前为止,我们已经明确指定 AsOf 的条件,但 SQL 还针对两个表中列名相同的常见情况提供了简化的连接条件语法。 此语法使用 USING 关键字列出应比较相等性的字段。 AsOf 也支持此语法,但有两个限制:
• 最后一个字段是不等式
• 不等式为 >= (最常见的情况)
我们的第一个查询可以写成:
SELECT ticker, h.when, price * shares AS value
FROM holdings h ASOF JOIN prices p USING(ticker, when)
请注意,如果您没有在 SELECT 中显式列出列,则排序字段值将是探测值,而不是构建值。 对于自然连接,这不是问题,因为所有条件都是相等的,但对于 AsOf,必须选择一侧。 由于 AsOf 可以被视为查找函数,因此返回“函数参数”比函数内部更自然。
AsOf 连接真正做的事情是允许将事件表视为连接操作的状态表。 通过了解连接的语义,它可以避免创建完整的状态表,并且比一般的不等式连接更有效。
让我们首先看看窗口版本是如何工作的。 请记住,我们使用此查询将事件表转换为状态表:
WITH state AS (
SELECT ticker, when, price,
LEAD(when, 1, 'infinity') OVER(PARTITION BY ticker ORDER BY when) AS end
),
状态表 CTE 是通过在代码上对表进行哈希分区、按时间排序,然后计算恰好向下移动 1 的另一列来创建的。 然后通过股票上的散列连接和时间上的两次比较来实现连接。
如果没有股票行情列(例如,单个商品的价格),那么将使用我们的不等式连接运算符来实现连接,该运算符将实现并对两侧进行排序,因为它不知道范围是不相交的。
AsOf 运算符使用所有三个运算符管道 API 来合并和收集行。 在接收阶段,AsOf 哈希分区并对右侧进行排序以生成临时状态表。 (事实上,它使用与 Window 相同的代码,但没有不必要地具体化结束列。)在运算符阶段,它会过滤掉(或返回)由于谓词表达式中的 NULL 值而无法匹配的行,然后进行哈希分区和 将剩余的行排序到缓存中。 最后,在源阶段,它匹配哈希分区,然后合并连接每个哈希分区内的排序值。
由于 AsOf 连接可以使用标准 SQL 查询以多种方式实现,因此基准测试实际上是比较各种替代方案。
一种替代方法是名为 debug_asof_iejoin 的 AsOf 调试 PRAGMA,它使用 Window 和 IEJoin 实现连接。 这使我们能够轻松地在实现之间切换并比较运行时间。
其他替代方案结合了等连接和窗口函数。 等值连接用于实现等式匹配条件,窗口用于选择最接近的不等式。 我们现在将研究两种不同的窗口技术并比较它们的性能。 最重要的是,虽然 AsOf 连接有时会更快一些,但 AsOf 连接具有所有算法中最一致的行为。
第一个基准测试将哈希连接与状态表进行比较。 它使用自连接探测由 100K 时间戳和 50 个分区键构建的 5M 行值表,其中仅存在 50% 的键,并且时间戳已移动到原始时间戳的中间位置:
CREATE TABLE build AS (
SELECT k, '2001-01-01 00:00:00'::TIMESTAMP + INTERVAL (v) MINUTE AS t, v
FROM range(0,100000) vals(v), range(0,50) keys(k)
);
CREATE TABLE probe AS (
SELECT k * 2 AS k, t - INTERVAL (30) SECOND AS t
FROM build
);
构建表如下所示:
k | t | v |
0 | 2001-01-01 00:00:00 | 0 |
0 | 2001-01-01 00:01:00 | 1 |
0 | 2001-01-01 00:02:00 | 2 |
0 | 2001-01-01 00:03:00 | 3 |
… | … | … |
探测表如下所示(k 只有偶数值):
k | t |
0 | 2000-12-31 23:59:30 |
0 | 2001-01-01 00:00:30 |
0 | 2001-01-01 00:01:30 |
0 | 2001-01-01 00:02:30 |
0 | 2001-01-01 00:03:30 |
… | … |
基准测试只是进行连接并对 v 列求和:
SELECT SUM(v)
FROM probe ASOF JOIN build USING(k, t);
调试PRAGMA不允许我们使用散列连接,但我们可以再次在CTE中创建状态表并使用内连接:
-- Hash Join implementation
WITH state AS (
SELECT k,
t AS begin,
v,
LEAD(t, 1, 'infinity'::TIMESTAMP) OVER (PARTITION BY k ORDER BY t) AS end
FROM build
)
SELECT SUM(v)
FROM probe p INNER JOIN state s
ON p.t >= s.begin AND p.t < s.end AND p.k = s.k
这是有效的,因为规划器假设相等条件比不等式更具选择性,并使用过滤器生成哈希连接。
运行基准测试,我们得到如下结果:
Algorithm | Median of 5 |
AsOf | 0.425 |
IEJoin | 3.522 |
State Join | 192.460 |
AsOf 相对于 IEJoin 的运行时改进约为 9 倍。 Hash Join 糟糕的性能是由哈希表中的长(100K)桶链造成的。
第二个基准测试测试探针侧比构建侧小约 10 倍的情况:
CREATE TABLE probe AS
SELECT k,
'2021-01-01T00:00:00'::TIMESTAMP + INTERVAL (random() * 60 * 60 * 24 * 365) SECOND AS t,
FROM range(0, 100000) tbl(k);
CREATE TABLE build AS
SELECT r % 100000 AS k,
'2021-01-01T00:00:00'::TIMESTAMP + INTERVAL (random() * 60 * 60 * 24 * 365) SECOND AS t,
(random() * 100000)::INTEGER AS v
FROM range(0, 1000000) tbl(r);
SELECT SUM(v)
FROM probe p
ASOF JOIN build b
ON p.k = b.k
AND p.t >= b.t
-- Hash Join Version
WITH state AS (
SELECT k,
t AS begin,
v,
LEAD(t, 1, 'infinity'::TIMESTAMP) OVER (PARTITION BY k ORDER BY t) AS end
FROM build
)
SELECT SUM(v)
FROM probe p INNER JOIN state s
ON p.t >= s.begin AND p.t < s.end AND p.k = s.k
Algorithm | Median of 5 |
State Join | 0.065 |
AsOf | 0.077 |
IEJoin | 49.508 |
现在,AsOf 相对 IEJoin 的运行时改进是巨大的(约 500 倍),因为它可以利用分区来消除几乎所有的等式不匹配。
哈希连接实现在这里做得更好,因为优化器注意到探测端较小,并在“探测”表上构建哈希表。 此外,这里的探测值是唯一的,因此哈希表链是最小的。
使用窗口运算符的另一种方法是:
• 根据相等谓词连接表
• 过滤到构建时间早于探测时间的对
• 根据等式键和探测时间戳对结果进行分区
• 按构建时间戳降序对分区进行排序
• 过滤掉除排名 1 之外的所有值(即最大构建时间 <= 探测时间)
查询如下所示:
WITH win AS (
SELECT p.k, p.t, v,
rank() OVER (PARTITION BY p.k, p.t ORDER BY b.t DESC) AS r
FROM probe p INNER JOIN build b
ON p.k = b.k
AND p.t >= b.t
QUALIFY r = 1
)
SELECT k, t, v
FROM win
此窗口查询的优点是它不需要哨兵值,因此它可以处理任何数据类型。 缺点是它会创建更多分区,因为它包含两个时间戳,这需要更复杂的排序。 此外,由于它在连接后应用窗口,因此可能会产生巨大的中间产物,从而导致外部排序和昂贵的内存不足操作。
对于此基准测试,我们将使用三个构建表和两个探测表,全部包含 10K 整数相等键。 探测表的每个键有 1 或 15 个时间戳:
CREATE TABLE probe15 AS
SELECT k, purchase_timestamp
FROM range(10000) cs(k),
range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 26 DAY) ts(t);
CREATE TABLE probe1 AS
SELECT k, '2022-01-01'::TIMESTAMP + INTERVAL (customer_id) HOUR purchase_timestamp
FROM range(10000) cs(k);
构建表要大得多,条目数大约是 15 个元素表的 10/100/1000 倍:
-- 10:1
CREATE TABLE build10 AS
SELECT k, t, (RANDOM() * 1000)::DECIMAL(7,2) AS v
FROM range(10000) ks(k),
range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 59 HOUR) ts(t);
-- 100:1
CREATE TABLE build100 AS
SELECT k, t, (RANDOM() * 1000)::DECIMAL(7,2) AS v
FROM range(10000) ks(k),
range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 350 MINUTE) ts(t);
-- 1000:1
CREATE TABLE build1000 AS
SELECT k, t, (RANDOM() * 1000)::DECIMAL(7,2) AS v
FROM range(10000) ks(k),
range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 35 MINUTE) ts(t);
AsOf 连接查询是:
-- AsOf/IEJoin
SELECT p.k, p.t, v
FROM probe p ASOF JOIN build b
ON p.k = b.k
AND p.t >= b.t
ORDER BY 1, 2
-- Rank
WITH win AS (
SELECT p.k, p.t, v,
rank() OVER (PARTITION BY p.k, p.t ORDER BY b.t DESC) AS r
FROM probe p INNER JOIN build b
ON p.k = b.k
AND p.t >= b.t
QUALIFY r = 1
)
SELECT k, t, v
FROM win
ORDER BY 1, 2
结果如下所示:
(中位数为 5,排名/15/1000 除外)。
• 对于具有 15 个探针的所有比率,AsOf 是性能最好的。
• 对于具有 15 个探针的小比例,Rank 击败了 IEJoin(均带有窗口),但到了 100:1,它开始爆炸。
• 对于单元素探针,Rank 是最有效的,但即使如此,它在规模上相对于 AsOf 的优势也只有 50% 左右。
这表明 AsOf 可能会得到改进,但预测发生这种情况的位置会很棘手,而且出错会带来巨大的成本。
DuckDB 现在可以以合理的性能对所有不等式类型执行 AsOf 连接。 在某些情况下,即使使用我们的快速不等式连接运算符,性能增益也比标准 SQL 版本高出几个数量级。
虽然当前的 AsOf 运算符完全通用,但这里可以应用一些规划优化。
当存在选择性相等条件时,针对物化状态表进行过滤的哈希连接可能会明显更快。 如果我们能够检测到这一点并且有合适的哨兵值可用,则规划器可以选择使用散列连接而不是默认的 AsOf 实现。 还有一些用例,其中探测表比构建表小得多,并且具有相等条件,并且针对探测表执行哈希联接可以显着提高性能。 尽管如此,请记住 SQL 的优点之一是它是一种声明性语言: 指定想要的内容,然后将其留给数据库来确定如何进行。 现在我们已经定义了 AsOf 连接的语义,用户可以编写查询来说明这就是想要的 - 并且我们可以自由地不断改进方法!
DuckDB 工作中最有趣的部分之一是它扩展了无序数据的传统 SQL 模型。 DuckDB 可以轻松查询有序数据集(例如数据框和 parquet 文件),当拥有此类数据时希望能够进行有序分析! 实现快速排序、快速窗口化和快速 AsOf 连接是我们实现这一期望的方式。