Rails: SQLよりもデータベースプロトコルの方が問題だ(翻訳)
この業界でのキャリアが長い方なら、リレーショナルデータベースのクエリ言語としてのSQLのメリットや問題点に関する議論を随分目にしてきたことでしょう。私もORMのメンテナーとして、SQLにはいくつか不満な点があるものの、おおむね実用的に使えています。とにかくSQLの勢いはとても大きいので、SQLに代わる代替言語を空想したところで意味はありません。
しかし、これまでデータベース方面でろくに議論されていない、改善の余地があると思われるトピックが1つあります。それは、データベースがクエリを実行するために公開しているプロトコルです。
リレーショナルデータベースは非常に優秀なテクノロジーですが、データベースクライアントのプロトコルを見ていると、人間がCLIインターフェイスでコマンドを手入力する場合以外の用途を本気で考えたことが一度でもあるのかと疑いたくなります。
私はたまたまRuby用のRedisクライアントもメンテしているのですが、Redisのプロトコルは完璧にはほど遠いとはいえ、私が多少なりとも精通しているPostgreSQLやMySQLのプロトコルに比べればいくぶんマシな方だと思えます。
🔗 ミュータブルなステートがあまりに多い
Active Recordがデータベースに接続すると、私が「プレリュード(prelude: 前奏曲)」と呼んでいるさまざまなデータベース固有のクエリを実行します。これらはデフォルトではログに出力されないので、おそらく皆さんは一度も見たことはないでしょう。
プレリュードでどんなクエリが送信されるかはActive Recordの設定によって異なりますが、ほとんどの人にとってこれがデフォルトになります。
MySQLの場合は以下のようなクエリになります。
SET @@SESSION.sql_mode = CONCAT(@@sql_mode, ',STRICT_ALL_TABLES,NO_AUTO_VALUE_ON_ZERO'),
@@SESSION.wait_timeout = 2147483
PostgreSQLの場合はもう少し増えます。
SET client_min_messages TO 'warning';
SET standard_conforming_strings = on;
SET intervalstyle = iso_8601;
SET SESSION timezone TO 'UTC'
どちらの場合も、コネクションを設定して振る舞いを変えるという考え方は同じです。データベースが年を取るに連れて、新しいモードや機能が導入されるので、下位互換性のためにそれらを有効にする必要があるという考え方自体は、一般的に問題ありません。
しかし私が問題にしたいのは、この設定を「任意のタイミングで変更できてしまう」ことです。
これらの設定は、初期の認証や設定以外のステップでも行えるので、フレームワークやライブラリがコネクションをユーザーコードに渡して、後でコネクションが返されたときに、その間に設定がこっそり変更されていたかどうかを確実に知る方法がありません。
同様に、コネクションには「設定済み」のものと「設定未完了」のものが両方存在することになるため、誤って設定未完了のコネクションを使わないよう厳重に注意しなければなりません。これは世界の終わりというほどではないものの、コネクションを管理するコードが著しく複雑になります。
このステートフル性によって、エラー復旧が難しくなります(不可能ではないにしても)。クエリが何らかの理由で失敗した場合に、コネクションがどのステートになっていたのかを判定するのは困難です。唯一合理的な対策は、コネクションをクローズして新しいコネクションで最初からやり直すことです。
これらのプロトコルで仮に「初期設定フェーズ」が明確に設定されていたとすれば、エラーの後(もしくはユーザーコードで不明なクエリを実行させた後)に「reset state」メッセージを送信してコネクションを既知のクリーンな状態に復旧する作業はもっと楽にできたことでしょう。
Rubyクライアントの立場からは、以下のようなコードになるでしょう。
connection = MyDB.new_connection
connection.authenticate(user, password)
connection.configure("SET ...")
connection.query("INSERT INTO ...")
connection.reset
コネクションがコネクションプールに戻されるたびにステートをリセットする作業も、ずっと楽になるでしょう。
私はデータベースサーバーが直面する制約をすべて熟知しているわけではありませんが、そうしたプロトコル機能を実装するのが難しい理由を特に思いつきません。
🔗 安全なリトライ
データベースクライアント(つまり一般のネットワーククライアント)の大事な仕事の1つは、ネットワークエラーを適切に処理することです。
ほとんどのデータベースクライアントでは、内部で以下のようなことが行われています。
def query(command)
packet = serialize(command)
@socket.write(packet)
response = @socket.read
deserialize(response)
end
クエリをサーバーに送信して、サーバーのレスポンスを読み取る、他愛もないコードです。ただし難しいのはwrite
とread
の操作がさまざまな形で失敗する可能性があることです。
サーバーが一時的にアクセス不能になれば、おそらく1〜2秒で復旧するでしょう。あるいは、アクセスは可能でも一時的に過負荷になったためにレスポンスが間に合わず、クライアントがタイムアウトすることもあるでしょう。
このようなエラーはめったに発生しないはずですが、完全に回避するのは無理です。ネットワーク越しに何かを送信する場合、うまくいかない可能性は常につきまとうので、これはもう仕方がありません。
すなわち、そうしたエラーを可能な限り適切に処理する必要がありますが、そのための方法は多くありません。
エラーを適切に処理するための最も明白な方法は、クエリをリトライすることですが、問題は、データベースクライアントの立場からは、リトライしても安全かどうかがはっきりしないことです。
私の見解では、現時点のHTTP
プロトコルの最も優秀な機能は、HTTP verbの仕様が「明示化されている」ことです。GET
やDELETE
などの特定のHTTP verbは冪等なので、HTTPの仕様では、クライアントではもちろんプロキシでもリトライを許可しています。
これが重要な理由は、write
やread
が失敗すると、ほどんどの場合、クエリが実際にサーバー上で実行された後なのかどうかがわからないためです。だからこそ冪等性という性質が重要なのです。定義上、冪等な操作は2回実行してもよいので、実行が完了したかどうか疑わしい場合はリトライできます。
しかしSQLの場合は、クエリが冪等かどうかを知るのは簡単ではありません。たとえば、以下のDELETE
クエリは冪等です。
DELETE
FROM articles
WHERE id = 42;
しかし以下のように、冪等でないクエリを完璧に記述することも可能なのです。
DELETE
FROM articles
WHERE id IN (
SELECT id
FROM articles
LIMIT 10
);
したがって、現実には、呼び出し側が「これは安全に実行可能である」と指示しない限り、エラー時にデータベースクライアントが安全にリトライできません。クエリを分析して冪等かどうかを判定するクライアントを作成することもできなくはありませんが、どうしても危険が伴うため、「リトライが安全かどうか」の判断は呼び出し側の指示に頼るのが一般に望ましいと言えます。
最近の私は、Active Recordを少しずつリファクタリングして、ネットワークエラー時にリトライ可能なクエリを増やす作業を行っているのですが、その理由の1つがこれです。しかしこのリファクタリングが完了したとしても、Active recordには冪等でないクエリがまだまだたくさんあるので、それらが失敗した場合についてはまだ対応できません。
🔗 冪等性キー
しかし、冪等でない操作を冪等な操作に変えてくれるソリューションが存在します。これは「冪等性キー(Idempotency Keys)」と呼ばれることもあります。
StripeのAPIを使ったことがあれば既にご存知かもしれません。Stripeがこのソリューションを最初に発見したわけではないと思いますが、私が冪等性キーを知ったのはStripeのAPIでした。
冪等性キーの概念はかなりシンプルで、冪等でない操作を実行する場合(例: 新規customerレコードを作成する場合)、ランダムに生成された文字列を含むIdempotency-Key
というHTTP
ヘッダーを追加できます。何らかの理由でリクエストをリトライする必要が生じたら、同じ値のIdempotency-Key
を渡してリトライできます。これによって、Stripe APIは最初のリクエストが成功したかどうかをチェックして、リトライを実行するか破棄するかを決定できるようになります。
さらに、Idempotency-Key
を持つリクエストが成功した場合はレスポンスを記録し、リトライが必要になったら最初のレスポンスをそのまま返します。この機能のおかげで、APIが冪等かどうかにかかわらず、APIへのあらゆるAPI呼び出しを安全にリトライ可能になります。
この機能が実によくできているので、昨年のRails World 2024カンファレンスでKyle Davisが主催しているValKey(Redisのfork)のブースを見かけたとき、ValKeyではこのかなり一般的な問題に取り組むことについて興味がありそうかどうかを尋ねてみることにしました。
SQLと冪等性について私が話したことはすべて、Redisにも(そしてValKeyにも)当てはまります。クエリが安全にリトライ可能かどうかをRedisクライアント側で判定するのは困難です。しかも、私がRedisクライアントのメンテナーになる前、Redisクライアントはすべてのクエリをデフォルトでリトライしていたのです。
当初はECONNRESET
エラーの場合にのみリトライしていましたが、やがて時が経つに連れて他のエラーもリトライリストに追加されていきました。私はTCP
について最も詳しい人物ではないことを認めなければなりません。したがって、こういうエラーが返された場合は、サーバーがクエリを受信していなかったと仮定するのはたしかに安全ではあるものの、時間とともにリトライリストに追加されたエラーが増えていけば、本当にリトライしても安全なのか甚だ疑わしくなります。
だからこそ、後に私がredis-client
(Redis用のずっとシンプルで低レベルなクライアント)を書いたときは、デフォルトではリトライしないようにするとともに、call
メソッドとcall_once
メソッドの両方を用意することで冪等なクエリを区別するようにしたのです。
しかし、Mike PerhamがSidekiqでredis
gemをredis-client
に置き換えたときのフィードバックによると、従来起きたことのないエラーがレポートされるようになったことに多くのユーザーが気づき始め、特にクラウド環境ではリモートデータストアが実際にはいかに信頼できないかが示されました。
つまり、これらのリトライは潜在的に安全でなく、データ喪失を引き起こす可能性もあったにもかかわらず、ユーザーはリトライを望んでいたのです。
そういうわけで、私は冪等性キー的な機能をKyleに提案しようと試みたのです。KyleはValKeyリポジトリでフィーチャーリクエストを開くよう促しました(#1087)。何度か議論を重ねた後、ValKeyのコアチームはこの機能を受け入れました。私の知る限り、これはまだ実装されていませんが、おそらくValKeyの次期バージョンに反映されるでしょう。
これも概念上は非常にシンプルです。
MULTISTORE 699accd1-c7fa-4c40-bc85-5cfcd4d3d344 EX 10
INC counter
LPOP queue
EXEC
Stripe APIの場合と同様に、最初はランダムな生成キー(ここではUUID)と有効期限を指定してトランザクションを開始します。
上の例では、トランザクションを次の10秒間保持するようValKeyに要求しています。この期間は安全にリトライ可能であり、これを過ぎたらValKeyはレスポンスを破棄できます。
ValKeyの次期バージョンにこの機能が搭載されれば、可能なすべてのクエリを最終的に安全にリトライするソリューションがValKeyに提供されるはずです。
リレーショナルデータベースのサイズは、(Redisのような)インメモリのキーバリューストアよりもはるかに大きく、実装が難しいことについては私も十分理解しています。しかし私がMySQLやPostgreSQLにどんな機能を追加したら使いやすくなるかと質問されたら、間違いなく「この機能だ」と答えるでしょう。
ValKeyの場合はテキスト形式のプロトコルなので、新しいコマンドを導入することになりますが、MySQLやPostgreSQLはどちらもバイナリ形式のプロトコルで、パケットのタイプもそれぞれ異なっているので、それぞれのSQL構文を変更することなく、かつ後方互換性の問題が生じることもなく、プロトコルレベルでこの機能を導入可能だと思います。
🔗 プリペアドステートメント
データベースプロトコルでもう1つ重要な部分であり、かつ扱いが面倒だと思うのは、プリペアドステートメント(prepared statement)です。
プリペアドステートメントは主に2つの機能を提供しますが、最も重要な機能は、クエリとそのパラメータを別々に提供することで、SQLインジェクションのリスクを排除することです。さらに、プリペアドステートメントはクエリを毎回解析したりネットワークに送信したりする手間を省けるので、用途によってはパフォーマンスの向上にも役立ちます。データベースによっては、関連するクエリプランもキャッシュされます。
以下は、MySQLプロトコルでプリペアドステートメントを利用する方法です。
- 最初に、パラメータ化クエリ(
SELECT * FROM users WHERE id = ?
)を含むCOM_STMT_PREPARE
パケットを送信する。 - 返された
COM_STMT_PREPARE_OK
パケットを読み取ってstatement_id
を抽出する。 - 次に、
COM_STMT_EXECUTE
パケットにstatement_id
とパラメータを含めて送信する。 - 受け取った
OK_Packet
レスポンスを読み取る。 - プリペアドステートメントが不要になった場合は、
COM_STMT_CLOSE
パケットに同じstatement_id
を含めて送信する。
理想的な場合は、同じステートメントが比較的頻繁に実行されてトラッキングされるようになり、最善のケースでは既知のstatement_id
を含むCOM_STMT_EXECUTE
を直接送信することで、1回の往復でパラメーター化クエリを実行できます。
しかし、ここで1つ大きな問題があります。statement_id
はセッションスコープで動作するので、作成に用いたコネクション内でしか有効になりません。現代のWebアプリケーションではコネクションが1個に限定されずにプールされるので、同じものを何度もトラッキングする必要があります。
さらに困ったことに、上述したように、エラーを安全に回復するにはコネクションをクローズして再度オープンするしかない場合が多いため、エラーが発生するとプリペアドステートメントもすべて失われてしまいます。
こうしたステートメントはサーバー側でもコストを強いられます。データベースサーバー内ではステートメントごとにある程度のメモリが要求されるため、ステートメントを無制限に作成しないよう注意が必要ですが、ORMでこれを強制するのは簡単ではありません。
アプリケーションがユーザー入力に応じてさまざまなクエリを動的に生成することは珍しくありません(高度な検索フォームやフィルタリングフォームなど)。
しかも、Active RecordではSQLフラグメント(=生SQL)の提供も可能ですが、Active Recordは、そのSQLフラグメントが静的な文字列なのか動的に生成された文字列なのかを区別できません。たとえば、以下のような書き方は本来よくないのですが、書くことは完全に可能です。
Article.where("published_at > '#{Time.now.to_s(db)}'")
さらに、Active Recordのクエリログを有効にしている場合、ほとんどのクエリは一意になります。
これらはどれも、Active Recordのようなライブラリはプリペアドステートメントとその有効期間をトラッキングするためのロジックを大量に必要とするということを意味しています。さらに、LRU(Least Recently Used: 直近で最も使われていない)ロジックを用いて、使われなかったステートメントを掃除してサーバーのリソースを解放する必要もあるでしょう。
特定のクエリを近いうちに再実行する理由が特にないのであれば、多くの場合、プリペアドステートメントを使わない方が現実には有利です。パラメータ化クエリについては使うのが理想的ですが、その場合やりとりが2〜3往復することになります1。
つまり、少なくともMySQLの場合、Active RecordでSQLフラグメントを文字列として提供すると、Active Recordはプリペアドステートメントを使わない方にフォールバックし、代わりにパラメータを式展開でクエリ内に挿入します。
「パラメータ化クエリは引き続き利用するが、プリペアドステートメントは利用しない」のが理想的ではありますが、MySQLのプロトコルはそのような機能を提供していません。パラメータクエリを使うのであれば、プリペアドステートメントを使わないわけにはいきません。しかしプリペアドステートメントを使うと、多くの場合往復回数が増えることになります。
私はPostgreSQLのプロトコルについて詳しくありませんが、仕様をざっと眺めてみた限りでは、PostgreSQLの動作もMySQLと同じだと思われます。
では、これを改善するにはどうすればよいでしょうか?
最初に思いつくのは、プリペアドステートメント抜きでパラメータ化クエリを実行することは可能なはずだということです。これが不可能な理由は今のところ思いつきません。
ここでも、Redisから何らかのヒントを得られそうです。
🔗 EVALSHA
Redisにはプリペアドステートメントはありません(あったとしてもあまり意味はありませんが)。しかし、Luaスクリプトの形で似たようなものがあります。
> EVAL "return ARGV[1] .. ARGV[2]" 0 "hello" "world!"
"helloworld!"
しかしLuaコードも、SQLクエリと同様に解析が必要であり、比較的量が増える可能性もあるので、その操作をキャッシュする方がパフォーマンス上好ましいと言えます。しかしRedisは、PREPARE
コマンド(指定のスクリプトに対するコネクション固有の識別子を返す)ではなく、SHA1ダイジェストを利用します。
最初に、SCRIPT LOAD
コマンドでスクリプトを読み込みます。
> SCRIPT LOAD "return ARGV[1] .. ARGV[2]"
"702b19e4aa19aaa9858b9343630276d13af5822e"
これで、返されたダイジェストを参照するだけでスクリプトを何度でも繰り返し実行できるようになります。
> EVALSHA "702b19e4aa19aaa9858b9343630276d13af5822e" 0 "hello" "world!"
"helloworld!"
このスクリプトのレジストリはグローバルなので、仮にコネクションが5000個あったとしても、それらのコネクションはすべて同じスクリプトを共有できます。しかも、スクリプトは既に読み込み済みであることが前提なので、読み込まれていない場合はリトライ時に読み込まれます。
require "redis-client"
require "digest/sha1"
class RedisScript
def initialize(src)
@src = src
@digest = Digest::SHA1.hexdigest(src)
end
def execute(connection, *args)
connection.call("EVALSHA", @digest, *args)
rescue RedisClient::CommandError
connection.call("SCRIPT", "LOAD", @src)
connection.call("EVALSHA", @digest, *args)
end
end
CONCAT_SCRIPT = RedisScript.new(<<~LUA)
return ARGV[1] .. " " .. ARGV[2]
LUA
redis = RedisClient.new
p CONCAT_SCRIPT.execute(redis, 0, "Hello", "World!")
私はデータベースエンジニアではないので、何か大きな制約を見落としているかもしれませんが、何らかの予測可能なダイジェストをプリペアドステートメントの識別子にする方がずっと合理的だと思います。そうすれば、コネクション同士で簡単に共有できるようになりますし、長時間使われていないプリペアドステートメントをサーバーがガベージコレクションしたり、何らかの参照カウント戦略を利用したりすることも可能になります。
🔗 結論
MySQLやPostgreSQLのプロトコルに関する非現実的な例は、探せば他にもいくつか見つかるかもしれませんが、それらのプロトコルに関する私の思いを伝えるには、ここまでの例で十分でしょう。
リレーショナルデータベースは極めて優秀なプロジェクトであり、非常に優れた人々によって構築されたことは明らかですが、開発エクスペリエンスに関する優先順位は(たとえ考慮されていたとしても)あまり高くないように感じられます。おそらく2010年代初頭におけるNoSQLの魅力の一部も、そのことで説明がつくでしょう。しかし、クエリ言語を変更しなくても、クエリプロトコルを改善するだけで、使い勝手を大幅に向上させることは可能だと私は考えます。
関連記事
-
合計で3往復ですが、理論上は
COM_STMT_CLOSE
を非同期に実行可能です。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。