ラベルによるフィルタリング

導入

GitLab には、イシューやマージリクエスト、エピックに割り当てられるラベルがあります。これらのオブジェクトのラベルは、多相テーブルlabel_links を通して多対多の関係になっています。

これらのオブジェクトを複数のラベルでフィルタリングするために、例えば’~Planというラベルと~backendというラベルを持つすべてのオープンなイシュー’というように、GROUP BY 節を含むクエリを生成します。単純な形式では、次のようになります:

SELECT
    issues.*
FROM
    issues
    INNER JOIN label_links ON label_links.target_id = issues.id
        AND label_links.target_type = 'Issue'
    INNER JOIN labels ON labels.id = label_links.label_id
WHERE
    issues.project_id = 13083
    AND (issues.state IN ('opened'))
    AND labels.title IN ('Plan',
        'backend')
GROUP BY
    issues.id
HAVING (COUNT(DISTINCT labels.title) = 2)
ORDER BY
    issues.updated_at DESC,
    issues.id DESC
LIMIT 20 OFFSET 0

特に

  1. 我々は、GROUP BY issues.id
  2. HAVING (COUNT(DISTINCT labels.title) = 2) 条件を使用して、一致したすべてのイシューが両方のラベルを持つことを確認します。

これは理想的な方法よりも複雑です。クエリ構築にエラーが発生しやすくなります(イシュー#15557など)。

試みAWHERE EXISTS

A1:複数のサブクエリを使用します。WHERE EXISTS

イシュー#37137とそれに関連するマージリクエストではGROUP BYWHERE EXISTS の複数の使用で置き換えることを試みました。 上記の例では、これは次のようになります:

WHERE (EXISTS (
        SELECT
            TRUE
        FROM
            label_links
            INNER JOIN labels ON labels.id = label_links.label_id
        WHERE
            labels.title = 'Plan'
            AND target_type = 'Issue'
            AND target_id = issues.id))
AND (EXISTS (
        SELECT
            TRUE
        FROM
            label_links
            INNER JOIN labels ON labels.id = label_links.label_id
        WHERE
            labels.title = 'backend'
            AND target_type = 'Issue'
            AND target_id = issues.id))

これはスキーマを変更することなく動作し、可読性は多少向上しましたが、クエリのパフォーマンスは向上しませんでした。

試行 A2:WHERE EXISTS 節でラベル ID を使用します。

マージリクエスト#34503では、A1と同様のアプローチをとりました。しかし今回は、EXISTS 節のJOIN を避け、label_links.label_id で直接フィルタリングするために、フィルタで使用されるラベルの ID を取得する別のクエリを実行しました。また、このクエリを高速化するために、target_idlabel_idtarget_type 列に対して、label_links に新しいインデックスを追加しました。

ラベル ID の検索は、1 つのルート名前空間内に同じタイトルのラベルが複数存在する可能性があるため、簡単ではありませんでした。ラベル ID をタイトルでグループ化し、EXISTS 節で ID の配列を使用することで、これを解決しました。

その結果、パフォーマンスが大幅に向上しました。しかし、この最適化はプロジェクトやグループのコンテキストがないダッシュボードページには適用できませんでした。なぜなら、ユーザーがアクセスできるすべてのプロジェクトとグループを検索することになるからです。

試み B:配列カラムを使用した非正規化

GitLab 12.1ではMySQLのサポートがなくなったので、PostgreSQLの配列を使うことにしました。label_links テーブルをクエリ用に非正規化する方法については、イシュー#49651で説明しました。選択肢はラベル ID とタイトルの二つです。

issues,merge_requests,epics の配列カラムとして考えることができます。issues.label_ids はラベルIDの配列カラム、issues.label_titles はラベルタイトルの配列となります。

これらの配列カラムをGINインデックスで補完することで、マッチングを向上させることができます。

試みB1:各オブジェクトのラベルIDを格納

これは、タイトルよりも強力な利点があります:

  1. ラベルが削除されたり、プロジェクトが移動したりしない限り、非正規化されたカラムを一括更新する必要はありません。
  2. タイトルよりも少ないストレージしか使用しません。

残念なことに、私たちのアプリケーションの設計ではこれが難しいのです。もしラベル ID だけで簡単にクエリできたら、冒頭のクエリINNER JOIN labels は必要なかったでしょう。GitLab では、ユーザーはプロジェクトやグループを超えてラベルのタイトルでフィルタをかけることができます。そのため、~Plan というラベルでフィルタをかけると、複数の異なる ID を持つラベルが含まれる可能性があります。

そのため、~Plan というラベルでフィルタをかけると、複数の異なる ID を持つラベルが含まれる可能性があります。私たちはユーザーに異なる ID を知ってほしくないので、このデータセットでは

プロジェクト~プランラベルID~バックエンドラベルID
A1112
B2122
C3132

のようなものが必要でしょう:

WHERE
    label_ids @> ARRAY[11, 12]
    OR label_ids @> ARRAY[21, 22]
    OR label_ids @> ARRAY[31, 32]

場合によっては、同じオブジェクトに異なるIDを持つ2つの〜バックエンドラベルが適用されるかもしれないので、組み合わせの数はさらに増えることになります。

試みB2:各オブジェクトにラベルのタイトルを保存

オブジェクトの更新という観点から見ると、これは最悪の選択肢です。オブジェクトを一括更新しなければなりません:

  1. プロジェクト間でオブジェクトが移動した場合。
  2. プロジェクトがグループから別のグループに移動されます。
  3. ラベルの名前が変更されました。
  4. ラベルが削除されました。

また、より多くのストレージを使用します。クエリは簡単です:

WHERE
    label_titles @> ARRAY['Plan', 'backend']

イシュー#49651で行ったテストでは、これが高速であることがわかりました。

しかし現時点では、デメリットがメリットを上回っています。

結論

非正規化を必要とせず、クエリのパフォーマンスを大幅に改善する方法A2を発見しました。これは全てのケースに適用できるわけではありませんが、残りのケースにA1の方法を適用することで、全てのシナリオでGROUP BYHAVING 節を削除することができました。

これにより、クエリが簡素化され、最も一般的なケースでパフォーマンスが向上しました。