メインコンテンツへスキップ

Go Proposal Weekly Digest

Go言語のproposal更新を毎週お届け

#67546likely_accept

database/sql/driver: allow driver to entirely override Scan

ステータス変更: active likely_accept

要約

AIによる要約であり、誤りを含む場合があります。

概要

database/sql/driver パッケージに RowsColumnScanner インターフェースと sql.ConvertAssign 関数を追加し、SQLドライバーが行スキャン処理を完全に制御できるようにするProposalです。これにより、ドライバーは driver.Value を経由せずにユーザー提供の任意の型へ直接値をスキャンできるようになります。

ステータス変更

activelikely_accept
2026年4月22日のProposal Review MeetingでAustin Clements(@aclements)が likely_accept と判断しました。2024年7月に一度acceptedとなりGo 1.26へ向けて実装が進みましたが、APIの後方互換性の問題が発覚して差し戻し(roll-back)となりました。その後Neil Brownell(@neild)が NextRow() メソッドを追加した改訂版APIを提案・実装(CL 766701)し、実ドライバー(go-sqlite3)での有意なアロケーション削減が確認されたため今回の承認に至りました。

技術的背景

現状の問題点

database/sqlRows.Scan は内部的に以下の2段階の処理を行います。

  1. ドライバーの Next(dest []driver.Value) を呼び出し、全カラムの値を driver.Valueinterface{})にボックス化して取得する
  2. convertAssigndriver.Value からユーザー指定の型へ変換する
    この設計には2つの根本的な制約があります。
    制約1: 中間アロケーション
    int64float64 などのプリミティブ型も毎回ヒープにボックス化されます。100万行・10カラムのクエリでは最低1,000万回の不要なアロケーションが発生し、GCへの圧力が高まります。
    制約2: 型サポートの限界
    driver.Value として表現できない型(PostgreSQLの配列 []int64、UUID、レンジ型など)をスキャンターゲットとして使えません。map[string]string[]int64 へ直接スキャンする場合、ドライバーはいったん文字列にエンコードし、ユーザー側でデコードするワークアラウンドが必要でした。
    pgx(PostgreSQL用Goドライバー)の場合、クエリ引数については NamedValueChecker インターフェースで独自処理を実装できますが、スキャン側には同等の仕組みがありませんでした。
// Before: ワークアラウンドが必要
m := pgtype.NewMap()
var a []int64
err := db.QueryRow("select '{1,2,3}'::bigint[]").Scan(m.SQLScanner(&a))
// After: 直接スキャン可能に
var a []int64
err := db.QueryRow("select '{1,2,3}'::bigint[]").Scan(&a)

提案された解決策

database/sql/driver パッケージに新しいオプションインターフェース RowsColumnScanner を追加します。また、ドライバーが標準変換処理を再利用できるよう sql.ConvertAssign をエクスポートします。

package driver
// RowsColumnScanner は [Rows] インターフェースを拡張し、ドライバーが
// スキャン先へ直接値をコピーできるようにします。
// RowsColumnScanner は [Rows.Next] メソッドを置き換えます。
type RowsColumnScanner interface {
    Rows
    // NextRow は次の行に進みます。行がない場合は io.EOF を返します。
    NextRow() error
    // ScanColumn は現在の行の index 番目のカラムを dest にコピーします。
    // ドライバーは sql.ConvertAssign を使って driver.Value を dest に代入できます。
    ScanColumn(index int, dest any) error
}
package sql
// ConvertAssign は src の値を dest が指す先にコピーします。
// ドライバー実装での使用を意図しています。
func ConvertAssign(dest any, src driver.Value) error

重要な設計変更点(差し戻し後の改訂): 当初の実装では RowsColumnScannerNext メソッドを「使わなくてよい」ものとして扱っていましたが、これは Go 1.25以前との後方互換性を破壊します。改訂版では NextRow() を別メソッドとして導入することで、Next の契約を維持しつつ新機能を提供します。

これによって何ができるようになるか

コード例

// Before: driver.Value経由の2段階変換(アロケーションが発生)
// ドライバーのNext()実装
func (r *pgRows) Next(dest []driver.Value) error {
    // 全カラムをdriver.Valueにボックス化(アロケーション発生)
    dest[0] = int64(42)         // ヒープにエスケープ
    dest[1] = "some text"       // ヒープにエスケープ
    // []int64型の配列は直接表現できないため文字列にエンコード
    dest[2] = "{1,2,3}"         // 後でパースが必要
    return nil
}
// After: RowsColumnScannerを実装したドライバー
func (r *pgRows) NextRow() error {
    return r.rowReader.Next()    // 行を進めるだけ(アロケーションなし)
}
func (r *pgRows) ScanColumn(index int, dest any) error {
    switch d := dest.(type) {
    case *int64:
        *d = r.rowReader.Int64(index)    // ボックス化なしで直接代入
        return nil
    case *[]int64:
        // PostgreSQLバイナリ形式から直接デコード
        return r.rowReader.DecodeArray(index, d)
    default:
        // 標準変換にフォールバック
        return sql.ConvertAssign(dest, r.rowReader.Value(index))
    }
}

主なユースケース

  1. PostgreSQL配列型の直接スキャン: []int64[]string[]uuid.UUID などをworkaroundなしでスキャン可能
  2. 時系列データベースのパフォーマンス改善: 数百万行のクエリでGCプレッシャーを大幅削減(go-sqlite3での実測でDB_ReadPostAndMaybeWriteCommentが11.5%高速化)
  3. バイナリプロトコルの活用: PostgreSQLのバイナリフォーマットを使えるため、テキストパース(特にUUID、配列、レンジ型)を回避してパフォーマンスが向上
  4. カスタム型の透過的サポート: sql.Scanner を実装していない標準Goの型(map[string]string など)へのスキャンをドライバー側で実現可能

議論のハイライト

  • Go 1.26での差し戻し(2025年12月): 当初の RowsColumnScannerNext メソッドが dest を埋めなくてよい前提で設計されていたが、これは Go 1.25以前との後方互換性を破壊するため @aclements の判断でroll-backされた。ドライバーが go1.26でのみ動作し古いGoと非互換になるリスクがあった
  • NextRow() メソッドの追加が解決策: @neild が「行の前進」と「値のスキャン」を分離する NextRow() を提案。これにより Next の契約を壊さずにドライバーが Next をstubにできる設計を実現した
  • sql.ConvertAssign のエクスポート: ドライバーが標準の型変換ロジックをフォールバックとして利用できるよう、内部関数 convertAssign を公開APIとして提供する方針が決定。ただし driver.Value を経由するためアロケーションは発生する
  • アロケーション最適化の限界: ConvertAssign(dest any, src driver.Value) では src がヒープにエスケープするため、完全なゼロアロケーションは実現できない。ドライバーが型スイッチで特殊化することで回避可能
  • *sql.RawBytes*sql.Rows の特殊ケース: これらの型はドライバー側から「不透明」なため、dest の型として判別できない。@aclements と @neild が設計を検討中であり、この点が最後の技術的課題として残っている

関連リンク