Also available in: English
Aurora DSQL が re:Invent 2024 で披露されてから 1.5 年程度たちました。DynamoDB 程度の手軽さで SQL を喋る RDBMS が利用できる上に、DynamoDB を含む purpose-built databases にありがちな設計の難しさがない、という点でかなり便利なデータベースサービスになっています。ただ、それだけ経つのにまだあまり世の中に事例が出回っていません。
それもそのはず、SQL は喋れるものの、そのランタイムの手軽さを実現するために vanilla な PostgreSQL とは異なり、より DynamoDB のようなデータベースに近い挙動で提供されていて、それがトレードオフとして存在します。別の言い方をすると PostgreSQL とはあくまでプロトコル互換であって、挙動まで互換ではありません。その関係で既存の PostgreSQL アプリケーションをそのまま移行しづらい点がネックとなっている気がしています。その点においては Neon のような他の PostgreSQL 互換サーバーレス DB の方が、価格モデルが異なるものの有利と言えます。
本稿では実際に Rails で Aurora DSQL をデータベースとして採用し、昨年末から新規にシステムを開発してきた中で、現状の困難なポイントと対処方法を紹介します。
tl;dr
- Aurora DSQL は PostgreSQL とプロトコル互換だが挙動は別物で、OCC・SAVEPOINT なし・DDL の制約がアプリ設計まで波及する
- リトライ可能な transaction ラッパや、細かな OCC への考慮が重要
- DSQL Local がないので開発・CI とも実クラスタを使うことになりレイテンシが最大の敵
- この 1 年で DROP COLUMN・jsonb・expression index・FOR KEY SHARE などかなり改善された。残る大物は DSQL Local、SAVEPOINT と ALTER COLUMN TYPE, SET NOT NULL
Aurora DSQL の特徴
振舞いの大きな特徴としてはロックが存在せず、Optimistic Concurrency Control のもとでクエリが実行される点でしょう。DynamoDB や Redis (Valkey) ではたびたび実装することになる楽観的ロックがトランザクション中で勝手に行われ、競合する書き込みがあった場合は Transaction がまるまるエラーとともに ROLLBACK される挙動をします。
本稿では詳しくは語りませんが、transaction が失敗する可能性が vanilla PostgreSQL に比べて高いことを前提にアプリケーションを開発する必要があります。
Aurora DSQL へ接続する
IAM 認証しかない
Aurora DSQL は IAM 認証しか提供されていません。IAM 認証自体は他の Aurora や RDS ファミリーで提供されているものと大きな差はありません。
DSQL がサポートしてない構文を止める
ORM を通して利用する場合、DSQL がサポートしていない SQL 文の発行をまず止める必要がある。
たとえば Rails では aws-samples/aurora-dsql-samples のモンキーパッチ を適用しておく必要があります。他のフレームワーク向けのものも同じリポジトリにあるので、参照するとよさそうです。
client_min_messages無効化set_standard_conforming_strings無効化supports_ddl_transactions?を false に
なお、Rails ではこれに加えて、下記の Rails へのパッチもモンキーパッチとして適用しておくのが良い:
- Skip ROLLBACK statement following TransactionRollbackError by sorah · Pull Request #56717 · rails/rails
- ROLLBACK された transaction を ROLLBACK しようとして libpq が警告を stderr に出してくるのでそもそも不要な ROLLBACK 発行を止めるパッチ
- Fix PostgreSQL primary key introspection for covering indexes by amaksimo · Pull Request #58555 · rails/rails
- Primary key index に INCLUDE されたカラムがあると
primary_keyの自動検出に失敗して、モデルでself.primary_key = "id"を宣言して回らないといけなくなる問題の修正
- Primary key index に INCLUDE されたカラムがあると
DSQL 向けの connection options
TLS や IAM 認証の設定に加えて、以下を設定しておくのが良い。
advisory_locks: false: サポートされてないのでprepared_statements: false: サポートされていないはずmax_age: 2700: コネクションが 60 分で失効するので、それより早めに再接続させる (default 0.2 のpool_jitterがあるので実際に 2700 秒だと 36~45 分おきにゆらぐ)
production:
advisory_locks: false
prepared_statements: false
max_age: 2700 # 45m
実践 OCC (Rails を添えて)
自動リトライする transaction ブロックを用意する
OCC の影響で競合する書き込みはロック待ちではなく SQL エラーとなるため、基本的にはリトライするのが正しい (もしくは API クライアントにリトライしてもらう、という手がとれるならそれでも良い)。
そのため、自動的にリトライするメソッドを ApplicationRecord に入れておき、基本的には ApplicationRecord.retryable_transaction do … end でトランザクションを作るようにします。以下のコードでは、OCC 競合発生時にジッタを持たせた exponential backoff でリトライされます。
module DsqlTransactionable
extend ActiveSupport::Concern
module ClassMethods
def retryable_transaction(retries: 4, base_sleep_seconds: 0.2, &block)
attempts = 0
begin
transaction(&block)
rescue ActiveRecord::SerializationFailure => e
raise unless e.dsql_retryable? && attempts < retries
attempts += 1
sleep(base_sleep_seconds * (2**(attempts - 1)) * (0.5 + (0.5 * rand)))
retry
end
end
# A marker to indicate that the transaction is non-idempotent
def non_idempotent_transaction(&block) = transaction(&block)
end
def retryable_transaction(...) = self.class.retryable_transaction(...)
def non_idempotent_transaction(&block) = self.class.non_idempotent_transaction(&block)
end
# 追加のモンキーパッチ
class ActiveRecord::SerializationFailure
def dsql_retryable? = message.include?("(OC001)") || message.include?("(OC000)")
end
class PG::TRSerializationFailure
def dsql_retryable? = message.include?("(OC001)") || message.include?("(OC000)")
end
その上で、ApplicationRecord.transaction などは lint (RuboCop) で禁止して、どうしてもリトライをしたくない場合はかわりに同じ concern に用意している non_idempotent_transaction を使うように強制しています。
class TransactionOccAwareness < RuboCop::Cop::Base
MSG = "Use #retryable_transaction or #non_idempotent_transaction for DSQL OCC."
RESTRICT_ON_SEND = %i[transaction with_lock].freeze
def on_send(node)
add_offense(node.loc.selector)
end
end
リトライ時の注意点
retryable_transaction はブロック全体がリトライされるため、ロックを掛けるためにも関連するレコードは reload なり lock! なりして SELECT クエリをトランザクション内で発行しなおす必要があります。
そもそも DSQL でリトライが前提でなくてもそうあるべきですが、OCC の都合でトランザクションは時間的にも短くしたくなります。実際に書き込みトランザクションを実行する前にトランザクション外でクエリして、事前に precondition check などを走らせたりする形です。
その場合、SELECT が漏れると、そもそもロックが取られずに上書きしてしまったり、リトライ時にレコードが巻き戻る可能性があります。そのため、コンフリクトが起きることが稀であれば、そのような書き方は避けるという手もあるでしょう。
現時点でこれを強制するような仕組みは導入できていません (リトライ後に古い ActiveRecord オブジェクトの属性を触ったら例外が飛ぶ、みたいな仕組みがあるとよさそうです)。
# BAD
challenge.verify(totp_response)
challenge.mark_as_used
ApplicationRecord.retryable_transaction do
challenge.save!
end
# GOOD
challenge.verify(totp_response)
ApplicationRecord.retryable_transaction do
challenge.reload
challenge.verify(totp_response)
challenge.mark_as_used
challenge.save!
end
Gap lock は存在しない
OCC ではいわゆる gap lock は存在しないため、存在しないことを確認してから挿入する処理が並行に走ると、両方成功してしまいます。そもそも unique index を張っておこうという話ではあるけれど…。
# BAD
ApplicationRecord.retryable_transaction do
raise Duplicate if user.records.exists?(key: k)
user.records.create!(key: k)
end
# GOOD
ApplicationRecord.retryable_transaction do
user.lock!("FOR KEY SHARE")
raise Duplicate if user.records.exists?(key: k)
user.records.create!(key: k)
end
lock! の引数を省略すると SELECT FOR UPDATE が発行される。最近は FOR KEY SHARE がサポートされて、キー列 以外への書き込みは競合扱いにならない、という事ができるようになったので、単にこのようなケースで競合チェックをしたい場合は FOR KEY SHARE をしておくと良さそう。
(キー列: unique かつ partial でも expression でもない index に含まれるカラム)
競合をそもそも避ける
last_activity_at のようなカラムを持つモデルがあり、特定の行動が発生した場合に現在時刻に更新するような処理をつくっていました。それが並行で多数同時に起こる場合、トランザクション内で SELECT して即座に UPDATE しても競合し、jitter 付き backoff があったとしてもリトライ上限に達してしまう、そもそもリトライによってレスポンスが悪化する、という問題がありました。
先に SQS 経由での非同期ジョブに書き込みを遅延させたりといった手を取っていましたが、それでも OCC 競合のメトリクスやログの上で目立ったため、妥協として値の解像度を 1 分程度まで落とす (現在値とセットしたい値の差が 1 分以内だったら更新しない) みたいな対応を入れています。
また、後述しますが関係ない更新クエリで無駄に競合判定に巻き込まれないためにも、テーブルはある程度分割する方が良いのだろうと感じています。
時間的にも小さいトランザクションにする
OCC の競合判定は実時間をもとに行われます (トランザクション開始時~コミット試行時に競合する書き込みがないか走査される)。そのため、トランザクションは短ければ短いほど競合する可能性が低くなります。また、DSQL の仕様上、1トランザクションでクエリ・更新できる行数には制約があります。
vanilla PostgreSQL でもそうだろ、という感じではあるものの、時間がかかる操作をトランザクション内で実行しない (たとえばパスワードハッシュの一致を確認するとか) ように気をつけておくとよいでしょう。
また、トランザクション内の SELECT も、強い一貫性が求められないのであれば別トランザクションでクエリして一度 COMMIT してしまう、みたいなテクニックもなくはなさそうです。rate limit のような目的のチェックをする SELECT と、実際の処理で利用する DML が同じトランザクションに同居すると当然競合の可能性は高くなります (まったく関係ない処理で同じレコードが更新される場合…)。
nested transaction や SAVEPOINT が未実装
現在の Rails ではモデルやコントローラで必要なタイミングで transaction do … end で囲み、呼び出し元がトランザクションを開いていなくても各所でトランザクションが必要な所で開く、そして場合によっては nested になる、という書き方がよく見られると思います。
Aurora DSQL は SAVEPOINT が実装されていないため、この手が使えない。そのため、基本的にはコントローラかサービスクラスいずれかでトランザクションを開き (それでも基本はコントローラに寄せている)、モデルなどで開く場合は呼び出し元は transaction の中にいないことを前提としてコードを書いています。つらい。
スキーマ定義
後から修正が難しい制約を理解しておく
ここ半年の DSQL 側の機能追加で、かなり手戻りが難しい制約は減ってきました。現状できないのはカラムの型変更と、既存カラムへの NOT NULL 追加くらいでしょう。
初期の頃は DROP COLUMN と DEFAULT 値変更もできませんでしたが、今は NOT NULL にしたいデータだけしっかり固めておけばそんなに気にはならないかなと思います (それがむずかしいんだけど…)。
テーブルを小さくする
ということもあって、前述の制約や OCC の兼ね合いと、ActiveRecord などで全カラムを SELECT しがちなこともあり、基本的に 1 テーブルは小さく収めています (1 cluster あたりの max tables は 1,000)。
json, jsonb 型があり expression index もサポートされたので、スキーマの柔軟性を保ちたい場合や構造化データを持たせたい場合は json 型を使ってしまうのも手かもしれない。
部分インデックスがない
PostgreSQL でたびたび利用される partial index のサポートはありません。複数のカラムに跨り null を含むような unique index を張りたい時にはちょっと不便かも。もしかしたら expression index で workaround できるかも?
-- どうだろう?
create unique index async idx_active on things
((case when discarded_at is null then user_id end));
加えて 2026-08-13 から NULLS NOT DISTINCT がサポートされたので、 逆に NULL をまとめて一意にしたい場合はこちらで表現できるようになった。こちらで事足りるケースもでてきていそう。
スキーマのマイグレーション
以下のような、vanilla PostgreSQL でも存在しうるけど通常目にしない状態になっていることから、migration 方式でも declarative 方式でも、各種ツールとの相性の悪さが見られることがある。
- アクセスメソッドが
using index_btree - primary key が clustered index
- DDL はトランザクション内で 1 つまで
また、index に関しては CREATE INDEX ASYNC 構文を利用せざるを得ず、その対応が回避できない事が多そう。
筆者は psqldef を利用しており、下記のパッチ (リリース済み) で CREATE INDEX ASYNC 構文のサポートと transaction を利用しない変更を入れたんですが、ASYNC については use_create_index_async 的な configuration でも良かったかもなぁと思っています (そういう代案を出された上でマージされてしまった)。create index concurrently に同様のフラグがあったのよね。
あわせて読みたい: できれば知らずに済ませたかったAurora DSQL非互換集 - ArkEdge Space Blog
DDL による OCC エラー
DDL 自体も OCC エラーを発生させます。変更したテーブルや index を作成したテーブルに対して OC001 エラーとして競合が発生するため、psqldef など DDL を実行するツールでも OCC エラーを検知してリトライするようにしておかなければいけません。
async index を待つ
CREATE INDEX ASYNC が示すようにインデックス作成は全て非同期で実行されるため、スキーマ変更時はこれらを待つようなスクリプトを合わせて実行するのがおすすめです。非同期で作成されているの index 作成完了時にも OCC 競合が発生する。sys.wait_for_job() 関数もありますが、psqldef を利用していることと、複数のジョブが走ることもあるため、下記の 2 つのクエリをポーリングして全部完了したのを待つようにしています。
When Aurora DSQL finishes an asynchronous index task, it updates the system catalog to show that the index is active. If other transactions reference the objects in the same namespace at this time, you might see a concurrency error.
https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-create-index-async.html
select job_id, status, details from sys.jobs where status not in ('completed', 'failed');
select c.relname as index_name
from pg_index i
join pg_class c on c.oid = i.indexrelid
join pg_class t on t.oid = i.indrelid
join pg_namespace n on n.oid = t.relnamespace
where
n.nspname = $1
and i.indisunique = true
and i.indisvalid = false
;
特に unique index 作成時はそれを待ってからサーバーなどの更新をした方が安全といえるでしょう。また、筆者が実装したタイミングではコネクションを新規に張らないと最新の情報が取れない (特にカタログの方) という現象を観測したため、ポーリングのたびにコネクションから作成しなおしています (今直ってるかは分からない…)。
ローカル開発
DSQL Local が存在しない
DynamoDB Local 相当のソフトウェアが AWS から配布されていません。OCC エラーにならずロックを取るだけ、という違いはあるものの通常の PostgreSQL を利用しても互換性面は問題ないと思いますが、弊社のチームでは今のところ実際の DSQL クラスタを開発に利用しています。
開発用 DSQL クラスタはある程度使い回し、事前に Terraform で固定数を作成して開発者が自由に利用できるようになっています。DSQL など開発に利用する AWS へのアクセスは sorah/mairu と AWS SSO で最小権限の IAM role がプロジェクト内では自動で利用されるように設定しているため、Claude Code など coding agent にもそのまま渡して利用してもらっています。
各ユーザー固有の schema を準備する
git clone してきて setup スクリプトを実行すると、スキーマの枠があいている DSQL クラスタをディスカバリして、${USER}_${ENV} (例: sorah_development, sorah_test) の名前で create schema と psqldef の実行がされるようになっています。また $USER をもとに role が作成され、search_path が作成したスキーマになるようにしました。
Devin など独立した環境で動く agent を検知した場合はランダムな名前が採用されるようになっています (実体は後述する schema pool から選択する)。利用することになった cluster と schema name は tmp/ 以下のファイルに設定が保存され、明示的に削除するまで同じ schema を利用し続けます。
DSQL は cluster あたりの schema 数が 10 で、public などを含めると実質新規に作成できるのは 7 つ程度になっています。そのことから、一旦クラスタ数を開発者の数に合わせて複数作成して、複数のクラスタから setup 時に選択されるような仕組みが入っています (region あたりの cluster 数は上限緩和申請が可能)。
自動テストをどうにかする
テストも本物の DSQL クラスタに対して実施していると、AWS の外からではレイテンシが比較的大きな問題になります。その上で、DSQL の制約を踏まえた上で工夫をしていく必要があります。
開発も含めてそもそも vanilla PostgreSQL と組み合わせるという手もあるかと思います (CI も両方用意するとか) が、現時点で筆者のチームではそのようにしていません。我々が Aurora DSQL の採用を決定したタイミングではまだ知見が多くなく、DSQL 由来の制限などに後でぶつかると大変だろうなと思っていたためです。
ここでは主に Rails (RSpec) を前提としたハックを紹介します。
テストでも OCC 競合は発生する
実際の DSQL クラスタに対してテストを実行していると、schema に対して同時に実行している他のプロセスがなくとも、同じクラスタで走っている他の作業が原因で OCC 競合が起きることがあります。前述の通り、OCC 競合はスキーマの変更でも発生するためです。
特に CI 環境で他の PR や main branch での ALTER TABLE などで発生することがあり、この場合に CI 実行が flaky になってしまうことを防ぐため、テストでも OCC 競合はリトライさせてあげる必要があります。
module DsqlRetryable
BASE_SLEEP_SECONDS = 0.1
MAX_RETRIES = 10
def self.sleep_duration(attempt)
BASE_SLEEP_SECONDS * (2**(attempt - 1))
end
def self.with_retry(label)
retries = 0
begin
yield
rescue ActiveRecord::SerializationFailure, PG::TRSerializationFailure => e
raise unless e.dsql_retryable? && retries < MAX_RETRIES
retries += 1
warn "retryable SerializationFailure in #{label}, retrying (#{retries}/#{MAX_RETRIES})"
sleep sleep_duration(retries)
retry
end
end
def self.retryable_exception?(exception)
case exception
when ActiveRecord::SerializationFailure, PG::TRSerializationFailure
exception.dsql_retryable?
when RSpec::Core::MultipleExceptionError
exception.all_exceptions.all? { |e| retryable_exception?(e) }
when RSpec::Expectations::ExpectationNotMetError
exception.message.include?("(OC001)") || exception.message.include?("(OC000)")
else
false
end
end
end
RSpec.configure do |config|
config.around do |example|
example.run
10.times do |i|
break if example.exception.nil?
break unless DsqlRetryable.retryable_exception?(example.exception)
warn "retryable SerializationFailure detected in #{example.location} ; retrying the test (#{i + 1}/#{DsqlRetryable::MAX_RETRIES})"
sleep DsqlRetryable.sleep_duration(i + 1)
# Clear memoized let/let! values so they are re-created with fresh DB records on retry.
# The after hook's DatabaseRewinder.clean may have deleted the records that previous let
# values reference, causing "User must exist" or similar validation errors on retry.
example.example.instance_variable_get(:@example_group_instance)&.send(:__init_memoized)
example.example.display_exception = nil
example.run
end
end
end
SAVEPOINT がない
前述の通り SAVEPOINT がないため、Rails でいう transactional fixtures などを活用できません (テストで実行される実装が transaction を使う以上、nested transaction が必要になってしまう)。
config.use_transactional_fixtures = false
そして TRUNCATE もないため、DELETE で削除することになります。昔ながらの amatsuda/database_rewinder gem で INSERT が発生したテーブルに対して DELETE を発行することでユニットテスト間の DB リセットを実現しています。
とはいえ、使えるところではトランザクションを使う
一方でテスト対象の実装がトランザクションを利用しなければ、テスト側でトランザクションを使わない理由はありません。
RSpec Example それぞれを transaction で包む
:batch_transactions tag がついている example については全体が transaction 内で実行されるような before/after hook を仕込んでいます。具体的にはモデルのテストであればユニットテストの実行全体をトランザクションで囲います。use_transactional_fixtures を部分的に適用するイメージです。
Rails が自動で開始 (して即座に COMMIT する) トランザクションがあるため、その COMMIT 待ちはともかく、意外に BEGIN, COMMIT 発行の RTT がチリツモで遅くなるので、明示的にこの example group は transaction で実行して良いということにしています。モデルのテストであれば 1 つのトランザクションでテストしたい内容も完結するので、だいたいこれでなんとかなっている気がします。
# spec/support/transaction.rb
module FactoryTransactionBatcher
def self.finalize_transaction(conn)
conn.commit_transaction
rescue StandardError
conn.rollback_transaction rescue nil
end
end
RSpec.configure do |config|
config.prepend_before do |example|
if example.metadata[:batch_transactions]
# joinable で nested transaction を回避、_lazy で必要になるまで BEGIN 発行を遅延
ActiveRecord::Base.lease_connection.begin_transaction(joinable: true, _lazy: true)
end
end
config.after do |example|
next unless example.metadata[:batch_transactions]
conn = ActiveRecord::Base.lease_connection
FactoryTransactionBatcher.finalize_transaction(conn) if conn.transaction_open?
end
end
# spec/…_spec.rb
RSpec.describe Thing, :batch_transactions do
describe "…" do … end
end
before_all (let_it_be) は独立した transaction で囲っておく
とはいえ、before_all で指定した処理はトランザクション 1 つにまとめておいて支障がないことが多かったため、こちらはデフォルトでまとまるように設定しています。ここでも OCC 競合を前提としたリトライが仕込まれています。
module DsqlBeforeAllRetry
def before_all(setup_fixtures: TestProf::BeforeAll.config.setup_fixtures, &block)
return super(setup_fixtures: setup_fixtures) unless block
retryable_block = proc {
retries = 0
begin
ActiveRecord::Base.transaction { instance_exec(&block) }
rescue ActiveRecord::SerializationFailure, PG::TRSerializationFailure => e
raise unless e.dsql_retryable? && retries < DsqlRetryable::MAX_RETRIES
retries += 1
warn "retryable SerializationFailure in before_all/let_it_be, retrying (#{retries}/#{DsqlRetryable::MAX_RETRIES})"
sleep DsqlRetryable.sleep_duration(retries)
retry
end
}
super(setup_fixtures: setup_fixtures, &retryable_block)
end
end
RSpec::Core::ExampleGroup.singleton_class.prepend(DsqlBeforeAllRetry)
Rails 純正の TimeHelpers が IAM 認証を破壊する
これは Aurora DSQL というか Aurora, RDS でも IAM 認証を使っていたら発生する気がしますが、テストで本物の DSQL を使うという DSQL 特有の事情でもあるのかなと思います。
Rails には時間を操作するためのヘルパ ActiveSupport::Testing::TimeHelpers があり、これを利用していたのですが、DSQL 接続時の IAM 認証の署名に含まれる時刻に影響を及ぼすため、DB 接続に失敗するエラーを発生させることがありました。これまでコミュニティでデファクトだった Timecop と違って、部分的に発生させている時間操作を無効にする方法がなかったため、TimeHelpers から Timecop へ戻し、Timecop が存在する場合は Timecop を無効にした上で auth token を発行する処理を generator に組み込んでいます。
class DsqlAuthTokenGenerator
def call(host:, port:, user:)
# When Timecop is used for time travel in tests, restore real time for token generation
# to avoid "Signature not yet current" errors from AWS
if defined?(Timecop)
Timecop.return { generate_token(host: host, user: user) }
else
generate_token(host: host, user: user)
end
end
private def generate_token(host:, user:)
# e.g. host == "<clusterID>.dsql.us-east-1.on.aws"
region = host.split(".")[2]
raise "Unable to extract AWS region from host '#{host}'" unless region =~ /[\w\d-]+/
token_generator = Aws::DSQL::AuthTokenGenerator.new(
credentials: Aws::CredentialProviderChain.new.resolve,
)
auth_token_params = {
endpoint: host,
region: region,
expires_in: 15 * 60,
}
case user
when "admin"
token_generator.generate_db_connect_admin_auth_token(auth_token_params)
else
token_generator.generate_db_connect_auth_token(auth_token_params)
end
end
end
PG::AWS_RDS_IAM.auth_token_generators.add :dsql do
DsqlAuthTokenGenerator.new
end
CI
手元の開発でも使うということは、(既に書いたけど) GitHub Actions 上の CI でも実際の Aurora DSQL に対してテストを実行します。当然ですが、CI は高速に終わって欲しいものです。実際の DSQL を利用するにあたって生じる速度上の問題も、いくつかの工夫で解決していきます。
まず、GitHub Actions の GitHub-hosted runner は本稿執筆時点で Azure の US リージョンで実行されがちということが分かっています。AWS ではない時点でレイテンシのペナルティがあるところ、Azure は AWS がいないところにもリージョンを構えている、という点が問題です。そして、DSQL の DDL はそんなに速くない、データが空の状態でも CREATE INDEX ASYNC の待ちはそれなりに発生するという点が厄介です。
これらを気合でごまかすハックを紹介します。
DSQL リージョンの選択
開発用の DSQL クラスタが複数あるように、CI 用も複数用意しています (Pull Request や後述の並列化で多数実行されるため)。現在は US にある各 AWS リージョンに 5 つずつ作成しました。割り当てられた GitHub Actions runner から近い AWS リージョンのクラスタの discovery には, Route 53 Latency Based Routing を利用しています。
DSQL クラスタを用意する Terraform state でリージョンごとに SRV record rrset を作成し、スキーマ準備時に取得できるようになっています。
ちなみに、Devin も同じく US のどこかで実行されていそうなので、Devin も CI 用のクラスタを利用するようにしています。
テスト実行の並列化
そもそも SQL 実行にそれなりにレイテンシがある状況なので、テストスイートがそんなに大きくない時点から並列化を実装しました。
ここは定石通り parallel_tests gem を利用して GitHub Actions の matrix で 4 jobs に分割実行しています。
テスト実行時間のブレを取る
parallel_tests はファイル単位の分割実行であるため、分割したジョブそれぞれが同じくらいのタイミングで終わるように分割戦略を組む必要があります。
parallel_tests 標準の手法としてはファイルごとの所要時間のログを取りそれをもとに綺麗に分割する方法があります。GitHub Actions のキャッシュに実行時間ログを保存するところまでは定石であるところ、問題は Aurora DSQL が Actions runner の外にあることです。手元のログを見てみると、下記のような記録になっていました:
| Azure region | n | avg | p50 | spread | AWS regions drawn |
|---|---|---|---|---|---|
| eastus | 103 | 5.78 | 5.67 | 8.55 | us-east-1: 103 |
| eastus2 | 33 | 9.65 | 9.60 | 3.98 | us-east-1: 33 |
| westus2 | 55 | 16.49 | 16.34 | 4.50 | us-west-2: 55 |
| northcentralus | 45 | 22.62 | 21.98 | 22.47 | us-east-2: 33, us-east-1: 12 |
| westus | 71 | 23.91 | 23.85 | 4.87 | us-west-2: 71 |
| canadacentral | 2 | 26.87 | 24.00 | 5.74 | us-east-2: 2 |
| centralus | 59 | 38.16 | 39.26 | 27.94 | us-east-2: 58, us-east-1: 1 |
| westus3 | 61 | 39.13 | 39.22 | 2.64 | us-west-2: 61 |
| westcentralus | 36 | 42.36 | 42.08 | 5.86 | us-west-2: 36 |
| southcentralus | 3 | 54.05 | 59.27 | 21.25 | us-east-2: 2, us-east-1: 1 |
(Azure リージョンは runner からてっとり早く調べるのに azure.archive.ubuntu.com. の CNAME を引いて確認し、SELECT NOW(); RTT の記録を取って Job summary に出力するようにしています)
n>10 ある残った記録で見ると eastus → us-east-1 の 6 ms 程度に対して westcentralus → us-west-2 の 42 ms 程度と、7 倍程度の差が割り当てられた runner によってついてしまっています。最も速いのは Azure・AWS ともにデータセンタが集中している Virginia 近辺同士の組み合わせで、逆に内陸の Azure region (westcentralus は Wyoming, westus3 は Arizona) からは遠い、という当然の結果ではあります。このへんの Azure region を GitHub runner から除外できるなら金を払うので除外させてほしい。
そして、実行時間にこのブレを持ち込んでしまっては正しくジョブ分割することができないため、比率をもとに実行時間を正規化したログを parallel_tests に渡すようにしています。
事前に schema を作成しておく
次は schema 作成についてです。テスト実行前に table や index を schema に作成しておく必要がありますが、DSQL の DDL はそこまで速くありません。32 tables, 56 indices 程度の psqldef 実行でも 1 分半程度かかっています (us-east-1 に近い eastus からでも)。そのうち 50 秒程度は CREATE INDEX ASYNC の非同期ジョブ待ちのようです。
そのため、事前に schema を作成して使い回すのが理想です。実際に筆者の CI では 5 clusters x 3 regions の 15 DSQL clusters それぞれに 5 つ程度の schema を常に作成して main branch の psqldef を予め実行済みの状態にして、PR を含む CI 実行で取り出して使うようにしています。
schema 自体を管理するためのテーブルをそれぞれの DSQL cluster に作成、利用状態が管理されています。各 CI job はランダムに選択して利用中状態に変更、ジョブが終わったらその成否によらず checkin と呼ばれる workflow_dispatch を行って、checkin workflow が schema の廃棄と再作成を行います。PR で実行される場合 main の状態へ schema を戻すのが前述の制約上不可逆なことがあるため、常に削除→再作成という処理を選択しました。
また、何らかの原因で schema が放置されてしまっている場合も、cron で発火する workflow が検知して削除して同様に再作成しています。そして、main branch へ schema の変更がマージされた場合は、順次各 schema に対して psqldef を差分実行する workflow が発火します。
なお、最近は stacked PR や worktree による複数 PR を跨いだ同時作業によって、少ないチームメンバーでも同時に open/draft 状態の PR を多数抱える場合があります。その関係で場合によっては schema が売り切れてしまうことがありますが、そもそも cluster あたりの schema をぎりぎりまで利用しているので、新規には作成できず、とりあえず fail させてしまっているのが現状です。
各種メンテナンス用の workflow は dynamic matrix を利用して、DSQL cluster ごとの job やそこで作成・更新・削除しなければいけない schema ごとの job と並列で走るようになっています。ubuntu-slim ができたことで手軽に並列実行できて嬉しい。
はやくなんとかなってほしい
以上が現状 Aurora DSQL を使う中での開発上の苦しみです。少なくはないですね。実際に production で動かす分には今のトラフィックでは全然コストがかからないし、性能も悪くないし、気に入っています。
大きなところでは下記はどうにかなってほしいですね。
- DSQL Local
- SAVEPOINT, nested transaction
- ALTER COLUMN … TYPE, SET NOT NULL
- Zero ETL や Parquet/Iceberg export がない
- DDL が遅い、SQLが必要、癖がある
- これは本当にもったいないと思っていて、ある程度手軽につかうためには DDL を使わずにスキーマ定義ができたらうれしい
- Aurora にある instant な clone や snapshot ができない
- Neon にある branching ができたら嬉しいよね
一方で改善されたところもある
昨年末から本格的にプロダクト開発で利用していますが、直近でも改善が続いています。
ALTER TABLE … DROP COLUMN(2026-08-03)SET/DROP DEFAULT,DROP NOT NULL,DROP CONSTRAINT,ADD GENERATED AS IDENTITY(2026-07-06)jsonb(2026-06-08)、json(2026-05-04)- sequences and identity columns (2026-02-13),
numericas an index key (2026-02-03) - higher
numericprecision,text/varcharcompression (2026-08-26) - Expression indexes,
INCLUDE,NULLS [NOT] DISTINCT(2026-08-13) SELECT … FOR KEY SHAREandFOR UPDATEwithout full-PK equality predicates (2026-08-24/25)- Database Insights サポート
sequence サポートは来たので、物によっては既存のアプリケーションもサクッと載せられるかも。ただサポートしてないカラム型はあるし、OCC の問題はあるので、どうだろうって感じ。やっぱりわりと OSS ベースの何かを self host するときに PostgreSQL が必要で、でも安く済ませたい…って時は Neon とかを検討してしまうかも。
Outro
意外になんとかなるので、使えるところでは積極的に使っていきましょう。
広告1: DSQL Day で登壇します
ところで、今回このブログで紹介した内容をベースに 2026-09-14 に Amazon HND25 (AWS 麻布台オフィス) で開催される Amazon Aurora DSQL Day Tokyo で登壇します。今回語っていないデータ移行などについても喋る予定です。よければきてくださいね。
広告2: 採用しています
本稿は現職の以下のポジションで触っているシステムの話です。いわゆる開発者体験の Platform から、プロダクトを支える各種システムを指す Platform (具体的には IAM や課金, 現職っぽい領域だと電話の SIP とか) まで一緒にやっていく人を探しています。よろしくね。
All code snippets in this post are licensed under 0BSD or MIT license.