概要
DuckDB は、近くの値を照合する方法である AsOf Joins をサポートしています。これらは、一時的な分析のためにイベント テーブルを検索する場合に特に役立ちます。
結合したい時系列データがあるが、タイムスタンプが完全に一致しないことがありますか? または、別のテーブルの時間を使用して、時間の経過とともに変化する値を見つけたいですか? 結果を得るために複雑な (そして遅い) 不等結合を作成することになりましたか? それなら、この記事はあなたのためのものです!
AsOf接続とは何ですか?
時系列データは必ずしも完全に一貫しているわけではありません。時計がわずかにずれているか、原因と結果の間に遅れがある可能性があります。これにより、順序付けされた 2 セットのデータを結合する際に課題が生じる可能性があります。AsOf Joins は、この問題および他の同様の問題を解決するツールです。
AsOf Joins を使用して解決する問題の 1 つは、特定の時点で変化する属性の値を見つけることです。この使用例は非常に一般的であるため、その名前の由来は次のとおりです。
現在の不動産価値を教えてください
ただし、より一般的には、AsOf 結合は、標準 SQL で実装するには煩雑で時間がかかる可能性がある、いくつかの一般的な時間分析セマンティクスを具体化しています。
ポートフォリオの例
具体的な例から始めましょう。タイムスタンプ付きの株価リストがあるとします。
ティッカー | いつ | 価格 |
アプリ | 2001-01-01 00:00:00 | 1 |
アプリ | 2001-01-01 00:01:00 | 2 |
アプリ | 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 |
グーグ | 2001-01-01 00:00:00 | 1 |
グーグ | 2001-01-01 00:01:00 | 2 |
グーグ | 2001-01-01 00:02:00 | 3 |
さまざまな時点でのポートフォリオの保有状況を示す別のテーブルがあります。
ティッカー | いつ | 株式 |
アプリ | 2000-12-31 23:59:30 | 5.16 |
アプリ | 2001-01-01 00:00:30 | 2.94 |
アプリ | 2001-01-01 00:01:30 | 24.13 |
グーグ | 2000-12-31 23:59:30 | 9.33 |
グーグ | 2001-01-01 00:00:30 | 23.45 |
グーグ | 2001-01-01 00:01:30 | 10.58 |
データ | 2000-12-31 23:59:30 | 6.65 |
データ | 2001-01-01 00:00:30 | 17.95 |
データ | 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
これにより、その時点で保持されていた値が各行に追加されます。
ティッカー | いつ | 価値 |
アプリ | 2001-01-01 00:00:30 | 2.94 |
アプリ | 2001-01-01 00:01:30 | 48.26 |
グーグ | 2001-01-01 00:00:30 | 23.45 |
グーグ | 2001-01-01 00:01:30 | 21.16 |
これは基本的に、価格リスト内の近くの値を見つけることによって定義された機能を実行します。また、欠落しているティッカー値には一致がなく、出力に表示されないことにも注意してください。
外部 AsOf 接続
AsOf は右側から最大 1 つの一致を生成するため、左側のテーブルは結合によって拡大しませんが、右側に欠落時間があれば縮小する可能性があります。この状況に対処するには、外部 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 の価格と値が生成されます。
ティッカー | いつ | 価値 |
アプリ | 2000-12-31 23:59:30 | |
アプリ | 2001-01-01 00:00:30 | 2.94 |
アプリ | 2001-01-01 00:01:30 | 48.26 |
グーグ | 2000-12-31 23:59:30 | |
グーグ | 2001-01-01 00:00:30 | 23.45 |
グーグ | 2001-01-01 00:01:30 | 21.16 |
データ | 2000-12-31 23:59:30 | |
データ | 2001-01-01 00:00:30 | |
データ | 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 は次のようになります。
ティッカー | 価格 | いつ | 終わり |
アプリ | 1 | 2001-01-01 00:00:00 | 2001-01-01 00:01:00 |
アプリ | 2 | 2001-01-01 00:01:00 | 2001-01-01 00:02:00 |
アプリ | 3 | 2001-01-01 00:02:00 | 無限大 |
グーグ | 1 | 2001-01-01 00:00:00 | 2001-01-01 00:01:00 |
グーグ | 2 | 2001-01-01 00:01:00 | 2001-01-01 00:02:00 |
グーグ | 3 | 2001-01-01 00:02:00 | 無限大 |
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 | 無限大 |
等価条件がない場合、プランナは不等結合を使用する必要がありますが、これは非常にコストがかかる可能性があります。等価条件の場合でも、結果として得られるハッシュ結合は、すべて一致する同一のコード キーの長いチェーンになる可能性があるため、削除する必要があります。
なぜ今なのか?
SQL がすでに AsOf 結合を計算できるのに、なぜ新しい結合タイプが必要なのでしょうか? 重要な理由は 2 つあります: 表現力とパフォーマンスです。ウィンドウによる代替は AsOf 構文よりも冗長で理解しにくいため、自分が何をしているのかを言いやすくすることで、他の人 (あなた自身も!) が何が起こっているのかを理解するのに役立ちます。
また、この構文により、DuckDB は必要なものを理解しやすくなり、結果がより速く生成されます。ウィンドウ処理バージョンと不等結合バージョンでは、間隔が重複していないという貴重な情報が失われます。また、SQL は結合後のウィンドウ処理を要求するため、クエリ オプティマイザーが結合を移動できなくなります。操作を既知のデータ制約を持つ結合として扱うことにより、DuckDB は結合を移動してパフォーマンスを向上させ、カスタム結合アルゴリズムを使用できます。私たちが使用するアルゴリズムは、右側でテーブルをソートし、左側の値を使用してある種のマージ結合を実行することです。ただし、標準のマージ結合とは異なり、AsOf では一致が最大 1 つであるため、最初の一致が見つかったときに検索を停止できます。
ステータステーブル
可能想知道为什么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 连接是我们实现这一期望的方式。