Timee Product Team Blog

タイミー開発者ブログ

DuckDB を使ったデータ品質保証の実践

この記事は Timee Advent Calendar 2024 シリーズ 1 の5日目の記事です。

はじめに

こんにちは。タイミーの DRE チームの chanyou です。2024年の3月に DRE チームにジョインして、社内のデータ基盤を作って運用しています。

DuckDB を使ってデータ基盤で扱うデータの品質を保証し始めたので、その内容をご紹介します。

データ品質と完全性

タイミーのデータ基盤で重視しているデータ品質

タイミーでは、DMBOK を参考に以下のデータ品質を重視して設計や日々の運用を行っています。

 特性 意味
完全性 データが欠損していないか
適時性 必要なときにすぐにデータを参照できるか
一意性 データが重複していないか
一貫性 型・タイムゾーン・表記揺れなど、値の書式や意味が統一されているか

今回は完全性にフォーカスします。

完全性が損なわれるタイミング

上記の通り、完全性とは「どの程度データに欠損があるか」を意味します。

データの欠損は、主にデータ転送時に生じる場合が多いです。例えば、以下のようなケースが考えられます。

  • あるテーブルが転送対象から外れてしまっていた
  • 転送元のシステムに追加されたカラムが対象に含まれていなかった
  • パーティション分割された Parquet ファイルのうち、一部のファイルしか転送できていなかった

従来の完全性テストの実施方法

完全性を保証するということは、欠損がないことを保証することと同義です。欠損が生じやすい転送前後のデータを比較することで、欠損の有無を検知できます。

欠損を検知する仕組みのことを、タイミーでは「完全性テスト」と呼んでいます。

表形式のデータに対して厳密に完全性テストを実施するには、セル単位で比較を行う必要があります。

これまでは計算コストがかかるため、統計量を比較する手法を取っていました。

近似的に比較していたため、全レコード全カラムに対して欠損が全くないことを厳密に保証できない課題がありました。

詳細は昨年のアドベントカレンダー記事をご覧いただければ幸いです。

刷新した完全性テストの実施方法

今回のケース

刷新のきっかけとなったケースについて説明します。

S3 にある Parquet ファイルを BigQuery にロードする、非常にシンプルなケースでした。

S3 の Parquet ファイルと転送後の BigQuery テーブルのデータが完全に一致することを保証する必要がありました。

Parquet ファイルと BigQuery のデータ比較のためにスクリプトを実装しました。その内部のクエリエンジンとして DuckDB を採用して、セル単位の厳密なデータの比較に対応しました。

DuckDB を採用した理由

BigQuery 内のテーブル同士であれば BigQuery のクエリで完結しますが、データベースをまたいだ完全性テストは BigQuery の外側でデータの比較をする必要があります。

DuckDB は高いパフォーマンスを維持しながら、複数のデータソースに対して同様にクエリをかけることが可能で、今回のケースに非常にマッチしていました。

データの比較も EXCEPT 句が利用可能で、簡潔なクエリで表現可能でした。

他の選択肢として Pandas や Polers などの DataFrame インターフェイスのツールも候補に挙がりますが、依存モジュールのメンテナンスコストが一定かかるため、今回のケースではシングルバイナリでより手軽に実行環境を整備しやすい DuckDB に軍配が上がりました。

以上の理由で DuckDB を採用しました。

具体的な実装方針

S3 の Parquet ファイルの読み込みについては DuckDB が標準で対応しているため、DuckDB の read_parquet() 関数で簡単に読み込むことができます。

BigQuery に対するクエリは、後述の理由により BigQuery から GCS に Parquet ファイルとして出力を行い、 GCS の Parquet ファイルを DuckDB から読み込むことで対応しました。

DuckDB の EXCEPT 句を使って、片方のテーブルにしか存在しないレコードを抽出するクエリを実行します。以下がクエリの例です。

WITH source AS (
    SELECT * FROM read_parquet(getenv('source_path'))
    ORDER BY id
),
destination AS (
    SELECT * FROM read_parquet(getenv('destination_path'))
    ORDER BY id
)
SELECT
    'source' AS _location,
    *,
FROM (
    SELECT * FROM source
    EXCEPT
    SELECT * FROM destination
)
UNION ALL
SELECT
    'destination' AS _location,
    *,
FROM (
    SELECT * FROM destination
    EXCEPT
    SELECT * FROM source
);

source だけあるレコードと destination だけにあるレコードを抽出して、連結して出力しています。

事前に DuckDB の Secret Manager で各クラウドリソースへの認証情報を設定する必要がありますが、それだけで上記のようなクエリでセル単位の厳密な完全性テストが可能となりました。

これらを実行するシェルスクリプトを実装して、Docker コンテナにまとめて実行環境に展開しました。

よかったところ

複数のデータソースに対するクエリが、非常に簡単に実行できた

ローカル、S3、 GCS のどこにデータがあっても、 read_parquet() で読み込めるのは非常に体験がよかったです。

パフォーマンスが高く安定して実行できた

従来の完全性テストから実行環境が変わったため実行時間の比較ができないのですが、刷新後は 100GB 程度の Parquet ファイルの完全性テストが IO 含めて10分以内に実行できています。

デイリー程度の転送頻度であれば毎回実行しても差し支えない実行時間で、全く問題ありませんでした。

詰まったところや工夫したところ

BigQuery Community Extension で読み取れないカラムがあった

当初 DuckDB の BigQuery Community Extension を使って、BigQuery に直接クエリを実行しようとしていました。

大半のデータには問題なく使えたのですが、一部の文字列型のフィールドで読み取れないカラムがありました。

エラーメッセージがなく、読み取れなかったカラムが ORDER BY で結果の順序を変えると読み取れることがあるなど、原因特定から難航しそうなので今回のケースでは Community Extension の使用は見送りました。

BigQuery 側のログではちゃんとクエリが走っていたので、 DuckDB での処理のどこかでコケてしまっていたようでした。時間があるときに内部実装を追って、修正できそうであれば PR を送りたいと思います。

jsonlines モードと jq の組み合わせが楽だった

DuckDB には csv, json, html などの出力形式が多数あります。

今回はシェルスクリプトで DuckDB の結果を扱いたかったため、 jsonlines で出力したうえで jq で結果を処理するのが簡単でした。

柔軟に出力を切り替えられるので、あらゆるスクリプトで利用しやすいと思います。

まとめ

完全性テストを DuckDB を使って実施する内容をご紹介しました。DuckDB を使うことで、手軽にマルチクラウドな環境においても厳密な完全性テストを行えました。

DuckDB は非常に魅力的ですが、分析用途での DuckDB はガバナンスを効かせながら運用することが難しく、現状は社内で広く使ってもらうには様々なハードルがあるように思います。

一方で今回のデータテストのように、スクリプトの内部で利用するには統制を取りやすく、非常に相性がよいように感じました。分析用途の場合は DuckDB のステートを同期し続ける必要がありますが、テストの場合は同期が不要で揮発しても問題なく、カジュアルに DuckDB を使いやすかったです。

またシングルバイナリで環境整備も非常に簡単な点も運用しやすく、他のデータテストでも機会があれば利用を検討したいと思いました。

他にも dbt で CI 実行するときに、 DuckDB アダプタに切り替えることでコストを圧縮できそうです。 CI やスクリプト用途における DuckDB の活用の余地がまだまだありそうで、今後も模索したいと思いました。