Aurora DSQLの部分インデックスをオプトインと論理削除で試してみた

Aurora DSQLの部分インデックスをオプトインと論理削除で試してみた

Aurora DSQLの部分インデックスを通常インデックスと比較。オプトインと論理削除では、UPDATEの書き込みDPUは下がった一方、読み取りが軽くなったのは有効会員が全体の2割まで減った場合だけで、述語に合わない行を引くクエリはFull Scanになりました。
2026.10.03

はじめに

2026年10月2日に、Amazon Aurora DSQL が部分インデックスに対応しました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/aurora-dsql-partial-indexes/

構文と基本的な動作は、先行記事で紹介されています。

https://dev.classmethod.jp/articles/aurora-dsql-partial-indexes/

この記事では、ユーザマスタで部分インデックスの利用が想定されるユースケースとして、配信同意を表すオプトインを boolean で管理するケースと、削除日時の有無で論理削除を判定するケースを取り上げ、通常インデックスと部分インデックスの動作を比べた結果を紹介します。

検証シナリオ

東京リージョン(ap-northeast-1)の単一リージョンクラスタを使いました。select version(); は PostgreSQL 16 を返し、クライアントは psql 18.6 です。

テーブルとインデックス

オプトインと論理削除のそれぞれで、同じ2,500行のテーブルを2つ(通常用と部分用)作り、インデックスを1本ずつ張りました。部分インデックスの述語は、オプトインが marketing_opt_in = true、論理削除が deleted_at IS NULL です。通常インデックスは、述語と同じカラム(marketing_opt_in、deleted_at)をキーの先頭に含めます。

-- オプトイン: 通常インデックス
CREATE INDEX ASYNC idx_users_full_optin_created ON users_full (marketing_opt_in, created_at);
-- オプトイン: 部分インデックス
CREATE INDEX ASYNC idx_users_partial_optin_created ON users_partial (created_at) WHERE marketing_opt_in = true;

-- 論理削除: 通常インデックス
CREATE INDEX ASYNC idx_members_full_deleted_created ON members_full (deleted_at, created_at);
-- 論理削除: 部分インデックス
CREATE INDEX ASYNC idx_members_partial_active_created ON members_partial (created_at) WHERE deleted_at IS NULL;

ASYNC で作ったインデックスは、sys.wait_for_job で作成の完了を待ってから測りました。

テーブル定義とデータ投入

オプトイン:

-- 通常インデックス用と部分インデックス用に、同じ構造のテーブルを2つ作る
-- marketing_opt_in が true なら配信に同意済み、false なら未同意
CREATE TABLE users_full (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  member_no        int NOT NULL,
  name             text NOT NULL,
  email            text NOT NULL,
  created_at       timestamptz NOT NULL,
  marketing_opt_in boolean NOT NULL DEFAULT false
);
CREATE TABLE users_partial (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  member_no        int NOT NULL,
  name             text NOT NULL,
  email            text NOT NULL,
  created_at       timestamptz NOT NULL,
  marketing_opt_in boolean NOT NULL DEFAULT false
);

-- 2,500件。member_no が大きいほど入会が新しい。DSQLの1トランザクション上限(3,000行)に収まる
INSERT INTO users_full (member_no, name, email, created_at)
SELECT g,
       'user' || lpad(g::text, 4, '0'),
       'user' || lpad(g::text, 4, '0') || '@example.com',
       timestamptz '2026-01-01 00:00:00+09' + (g * interval '1 hour')
FROM generate_series(1, 2500) g;
-- 同じ内容を部分インデックス用テーブルへコピーする
INSERT INTO users_partial SELECT * FROM users_full;

論理削除:

-- 通常インデックス用と部分インデックス用に、同じ構造のテーブルを2つ作る
-- deleted_at が NULL なら有効会員、値が入っていれば論理削除済み
CREATE TABLE members_full (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  member_no  int NOT NULL,
  name       text NOT NULL,
  email      text NOT NULL,
  created_at timestamptz NOT NULL,
  deleted_at timestamptz
);
CREATE TABLE members_partial (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  member_no  int NOT NULL,
  name       text NOT NULL,
  email      text NOT NULL,
  created_at timestamptz NOT NULL,
  deleted_at timestamptz
);

-- 2,500件。member_no が大きいほど入会が新しい。DSQLの1トランザクション上限(3,000行)に収まる
INSERT INTO members_full (member_no, name, email, created_at)
SELECT g,
       'user' || lpad(g::text, 4, '0'),
       'user' || lpad(g::text, 4, '0') || '@example.com',
       timestamptz '2026-01-01 00:00:00+09' + (g * interval '1 hour')
FROM generate_series(1, 2500) g;
-- 同じ内容を部分インデックス用テーブルへコピーする
INSERT INTO members_partial SELECT * FROM members_full;

同意済み・削除済みを増やして測る

同意済み(オプトイン)または削除済み(論理削除)の会員数を、入会の古い順に累計で 0 / 1 / 10 / 100 / 1,000 / 2,000 と増やしました。テーブルは2,500件のままで、UPDATE だけで状態を変えています。論理削除では、削除済みが増えるほど、部分インデックスに載る有効会員は減ります。

各段階で UPDATE と ANALYZE を実行し、抽出クエリを測りました。:t は対象テーブル、:n はその段階の累計です。Q1 は述語に合う行(同意済み、有効会員)、Q2 は述語に合わない行(未同意、削除済み)の最新20件を引きます。

-- オプトイン
-- EXPLAIN ANALYZE 付きの UPDATE は実際に更新を実行し、書き込み DPU も表示する
EXPLAIN (ANALYZE, VERBOSE)
UPDATE :t SET marketing_opt_in = true
 WHERE member_no <= :n AND marketing_opt_in = false;
ANALYZE :t;
-- Q1 同意済み会員の最新20件
EXPLAIN (ANALYZE, VERBOSE)
SELECT id, created_at FROM :t
 WHERE marketing_opt_in = true ORDER BY created_at DESC LIMIT 20;
-- Q2 未同意会員の最新20件
EXPLAIN (ANALYZE, VERBOSE)
SELECT id, created_at FROM :t
 WHERE marketing_opt_in = false ORDER BY created_at DESC LIMIT 20;

-- 論理削除
EXPLAIN (ANALYZE, VERBOSE)
UPDATE :t SET deleted_at = timestamptz '2026-10-03 17:00:00+09'
 WHERE member_no <= :n AND deleted_at IS NULL;
ANALYZE :t;
-- Q1 有効会員の最新20件
EXPLAIN (ANALYZE, VERBOSE)
SELECT id, created_at FROM :t
 WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 20;
-- Q2 削除済み会員の最新20件
EXPLAIN (ANALYZE, VERBOSE)
SELECT id, created_at FROM :t
 WHERE deleted_at IS NOT NULL ORDER BY created_at DESC LIMIT 20;

エントリ数は、各段階の ANALYZE 直後の pg_class.reltuples(インデックスに載る行数の統計値)です。DPU は EXPLAIN (ANALYZE, VERBOSE) の Statement DPU Estimate で、請求額や実消費量ではなく見積り値です。抽出クエリの DPU は3回実行の中央値、エントリ数と Write DPU は各段階1回の値です。

検証結果

オプトインの更新コストとエントリ数

エントリ数の括弧内は relpages、Write DPU は同意フラグを false から true に変える UPDATE 1文全体の値です。

同意済み(累計) 更新した行数 通常のエントリ数 (relpages) 部分のエントリ数 (relpages) 通常の Write DPU 部分の Write DPU
0 0 2,500 (10) 0 (0) 0.00000 0.00000
1 1 2,500 (10) 1 (1) 0.01875 0.01250
10 9 2,500 (10) 10 (1) 0.16875 0.11250
100 90 2,500 (10) 100 (1) 1.68750 1.12500
1,000 900 2,500 (10) 1,000 (2) 16.87500 11.25000
2,000 1,000 2,500 (10) 2,000 (4) 18.75000 12.50000

部分インデックスのエントリ数は同意済みの数に追従し、通常インデックスは2,500のままです。Write DPU は更新した行数に比例し、1行あたり通常0.01875、部分0.0125でした。この1行あたりの値は、同意済みが80%(2,000件)の段階でも変わりませんでした。

オプトインの抽出

各セルは、スキャン方式 / DPU(3回の中央値)です。

同意済み(累計) Q1 通常 Q1 部分 Q2 通常 Q2 部分
0 Index Scan Backward / 0.01011 Index Scan Backward / 0.00623 Full Scan + Sort / 0.37613 Full Scan + Sort / 0.37662
1 Index Scan Backward / 0.00181 Index Scan Backward / 0.00187 Full Scan + Sort / 0.37615 Full Scan + Sort / 0.37619
10 Index Scan Backward / 0.00493 Index Scan Backward / 0.00938 Full Scan + Sort / 0.37615 Full Scan + Sort / 0.37611
100 Index Scan Backward / 0.01802 Index Scan Backward / 0.01769 Full Scan + Sort / 0.37601 Full Scan + Sort / 0.37595
1,000 Index Scan Backward / 0.01796 Index Scan Backward / 0.02161 Full Scan + Sort / 0.37464 Full Scan + Sort / 0.37517
2,000 Full Scan + Sort / 0.37616 Full Scan + Sort / 0.37842 Index Scan Backward / 0.01811 Full Scan + Sort / 0.37411

同意済みの抽出(Q1)は、どの段階でも通常と部分でスキャン方式が同じで、DPU も同水準でした。この検証のオプトインでは、置き換えても読み取りの DPU は下がりませんでした。

未同意の抽出(Q2)では、部分インデックスは全段階で Full Scan です。通常インデックスは、未同意が多い間は Full Scan で、同意済みが2,000件に進み未同意が500件(全体の20%)に減った段階だけ Index Scan Backward になりました。

未同意が少数になるテーブルで未同意の会員を引くクエリがある場合、通常インデックスを部分インデックスに置き換えると、Index Scan が Full Scan に変わります。

論理削除の更新コストと抽出

論理削除で、有効会員の抽出(Q1)に差が出た段階は1つだけでした。通常インデックスのエントリ数は全段階で2,500で、部分インデックスは有効会員の数に追従します。削除日時を入れる UPDATE の Write DPU は、全段階で1行あたりの値がオプトインと同じ(通常0.01875、部分0.0125)でした。削除が2,000件の段階(1,000行を更新)では、通常18.75000、部分12.50000です。

1件と10件の段階は0件と同じ傾向のため、表から省いています。

削除済み(累計) 部分のエントリ数 (有効会員数) Q1 通常 Q1 部分 Q2 通常 Q2 部分
0 2,500 Full Scan + Sort / 0.37327 Full Scan + Sort / 0.37166 Index Scan + Sort / 0.00093 Full Scan + Sort / 0.36839
100 2,400 Full Scan + Sort / 0.37285 Full Scan + Sort / 0.37590 Index Scan + Sort / 0.03978 Full Scan + Sort / 0.37002
1,000 1,500 Full Scan + Sort / 0.38462 Full Scan + Sort / 0.38725 Full Scan + Sort / 0.38403 Full Scan + Sort / 0.38405
2,000 500 Index Scan + Sort / 0.17980 Index Scan Backward / 0.02160 Full Scan + Sort / 0.39991 Full Scan + Sort / 0.39990

有効会員が大半を占める間は、部分インデックスのエントリ数が通常とほぼ同じで、有効会員の抽出(Q1)は両方 Full Scan + Sort となり差がありません。差が出たのは、削除が2,000件に進み有効会員が500件(20%)になった段階です。この段階の Q1 の実行プランを示します。

通常インデックス:

 Limit  (cost=357.43..357.48 rows=20 width=24) (actual time=5.468..5.472 rows=20 loops=1)
   ->  Sort  (cost=357.43..358.68 rows=500 width=24) (actual time=5.467..5.469 rows=20 loops=1)
         ->  Index Scan using idx_members_full_deleted_created on public.members_full  (cost=325.03..344.13 rows=500 width=24) (actual time=4.757..5.373 rows=500 loops=1)
               Index Cond: (members_full.deleted_at IS NULL)
 Statement DPU Estimate:
   Compute: 0.00585 DPU
   Read: 0.17395 DPU
   Write: 0.00000 DPU
   Total: 0.17980 DPU

部分インデックス:

 Limit  (cost=325.02..325.47 rows=20 width=24) (actual time=1.076..1.100 rows=20 loops=1)
   ->  Index Scan Backward using idx_members_partial_active_created on public.members_partial  (cost=325.02..336.12 rows=500 width=24) (actual time=1.075..1.097 rows=20 loops=1)
 Statement DPU Estimate:
   Compute: 0.00307 DPU
   Read: 0.01852 DPU
   Write: 0.00000 DPU
   Total: 0.02160 DPU

通常インデックスは Index Scan が500行を返して Sort に渡し、部分インデックスは Sort なしで Index Scan が20行を返しました。

削除済みの抽出(Q2)は、部分インデックスでは全段階で Full Scan です。通常インデックスは、削除が100件までなら Index Scan でした。

まとめ

Aurora DSQL の部分インデックスを、オプトインと論理削除で通常インデックスと比べました。述語に合う行だけをインデックスに載せるため、同意フラグや削除日時を更新する UPDATE の書き込み DPU は、どの割合でも下がることが確認できました。

一方、読み取りが軽くなったのは、論理削除で有効会員が全体の2割まで減った場合だけでした。また、述語に合わない側の行(未同意、削除済み)を引くクエリは、通常インデックスなら Index Scan になる場合でも、部分インデックスでは Full Scan になるという副作用もありました。

部分インデックスを採用する場合は、実際のワークロードのクエリで実行計画と EXPLAIN (ANALYZE, VERBOSE) の DPU で事前に確認し、運用後も意図どおりに使われているかを見ながら利用することをおすすめします。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事