• システム開発に関わる内容をざっくりと書いていく

RDS Proxy + Dapper + MySqlConnector で FirstOrDefault 実行時にピン留めが起きるとき

RDS Proxy を MySqlConnector の前段に置き、Dapper でいつも通り QueryFirstOrDefault などを使っている。CloudWatch の DatabaseConnectionsCurrentlySessionPinned が立ち、Proxy ログに pinning 警告が出る——そんなときの整理。

この記事は Dapper(従来どおりの書き方)+ MySqlConnector + RDS Proxy(MySQL / Aurora) を前提に、FirstOrDefault で実際にどんなクエリが飛ぶかと、どう直すかをざっくり書く。


前提:この3つがつながっている

  • Dapper: SQL 文字列 + 匿名オブジェクトでパラメータを渡す(いつもの書き方)
  • MySqlConnector: .NET 用 MySQL ドライバ
  • RDS Proxy: 接続の多重化(multiplexing)で DB 実接続数を抑える
アプリ
  └ Dapper(QueryFirstOrDefault など)
       └ MySqlConnector
            └ RDS Proxy
                 └ Aurora / RDS MySQL

先に結論

  • ピン留めの主因は FirstOrDefault そのものではなく、Proxy が「セッション状態が残る」と判断したとき
  • Dapper の QueryFirstOrDefault は SQL を自動で LIMIT 1 付きに書き換えない
  • まず SQL に LIMIT 1 を付け、Proxy ログの Reason で pinning の正体を確認する
  • Dapper をやめる必要はない

ピン留め(pinning)って何?

Proxy は DB 接続をトランザクション単位で使い回す。ただしセッション固有の状態(prepared statement、SET、一時テーブルなど)が残ると、他クライアントへ貸せなくなる。このとき接続を特定クライアントにピン留めする。

ログ例:

Reason: A protocol-level prepared statement was detected.

「FirstOrDefault だから pin する」わけではない。Reason に書いてある操作を潰すのが近道。


FirstOrDefault でどんなクエリが飛ぶか

「FirstOrDefault だから DB も1件だけ読む」と思いがちだが、Dapper + MySqlConnector でも SQL 文そのものは書き換わらない

1. Dapper 側(従来どおりの書き方)

var user = connection.QueryFirstOrDefault<User>(
    sqlWithoutLimit,
    new { Id = id });
  • 渡した SQL がそのまま MySqlConnector へ渡る(自動で LIMIT 1 は付かない)
  • @Id 等はパラメータとしてバインド(文字列結合はしない)
  • 結果の先頭1行だけを Dapper がオブジェクト化して返す
  • 通常は Prepare() を呼ばない
  • QueryFirstOrDefaultCommandBehavior.SingleRow を付ける(MySqlConnector 0.57 以降は sql_select_limit 最適化あり)。ただし SQL 文に LIMIT 1 が付くわけではない

「First」はコード側の取り方であって、クエリ文の書き換えではない。

2. MySqlConnector / Proxy 側から見えるもの

  • SQL に件数制限がなければ、DB は複数行返せる計画のまま動き得る
  • Proxy は Dapper かどうかではなく、MySqlConnector が送るプロトコルとセッション状態を見る
  • pinning 自体は FirstOrDefault 固有ではなく、Reason に書かれたセッション操作がトリガー

3. QueryFirstOrDefault と Query + FirstOrDefault

  • QueryFirstOrDefault … 先頭行だけ読む。SQL は書き換えない
  • Query(...).FirstOrDefault() … 既定でバッファする。LIMIT 1 なしだと無駄が大きい
  • どちらも MySqlConnector 経由の実行なので、pinning の切り分けは同じ(Proxy ログの Reason)

置き換え・対策(Dapper はそのまま)

A. SQL に LIMIT 1 を付ける(本命)

// Before
var user = connection.QueryFirstOrDefault<User>(
    sqlWithoutLimit,
    new { Id = id });

// After: SQL だけ LIMIT 1 付きにする
var user = connection.QueryFirstOrDefault<User>(
    sqlWithLimit1,
    new { Id = id });

DB / Proxy に飛ぶクエリ自体が「最大1行」になる。FirstOrDefault 利用ではほぼ必須寄り。pinning だけの対策にはならないが、まずここから直す。

B. Query してから FirstOrDefault(LIMIT 1 とセット)

var user = connection.Query<User>(
    sqlWithLimit1,
    new { Id = id })
    .FirstOrDefault();

LIMIT 1 なしだと「たくさん取って先頭1件」になりやすい。API の好みで選べばよい。

C. QuerySingleOrDefault の使い分け

  • QueryFirstOrDefault … 0件 or 1件想定
  • QuerySingleOrDefault … 0件 or 1件。2件以上なら例外
  • どちらも SQL 側の LIMIT 1 は付ける

D. pinning が残るとき

SQL を直しても SessionPinned が下がらない場合は、Proxy ログの Reason を見て MySqlConnector / Proxy 側を合わせる。

  • Reason が prepared statement なら、コード内の Prepare( を探す。必要なら接続文字列に IgnorePrepare=true も試せる(補助的な手段)
  • Aurora 系で問題が出るなら Pipelining=false
  • SET 起因なら Proxy の Initialization query で全接続を同一初期化にする

調査の進め方

  1. CloudWatch で DatabaseConnectionsCurrentlySessionPinned を確認
  2. Proxy ログ(Debug 有効)で Reason を1行特定する
  3. FirstOrDefault 系 SQL に LIMIT 1 があるか確認
  4. 修正後に SessionPinned と DB 接続数が下がるか見る

ざっくりまとめ

  • 構成は Dapper → MySqlConnector → RDS Proxy → MySQL
  • FirstOrDefault で飛ぶ SQL は開発者が書いた文そのもの。LIMIT 1 は自動付与されない
  • まず SQL に LIMIT 1、並行して Proxy ログの Reason を確認
  • pinning が残るときだけ Reason に沿って MySqlConnector / Proxy 設定を触る
  • IgnorePrepare=true は prepared statement が Reason のときの補足オプション
  • Dapper をやめる必要はない