RawBytesは使い捨てよう

go-mysql-driverに来たバグ報告を調べていたら、 database/sql.RawBytes の利用方法にハマるとデバッグの難しい落とし穴があったのを見つけたので、Go側のバグとは断言できないもののGo側で直すべきだと報告しました。他の人がハマらないように簡単に解説しておきます。

github.com

RawBytestype RawBytes []byteのように宣言されていて、[]byteのように扱える。[]byteとの違いは、利用可能期間が短くてrows.Scan(&in)からrows.Next()rows.Close()までの間にしか使えないという制約があることだ。

rows.Next()が呼ばれた時、ドライバーは受信バッファの中の文字列等のデータを浅い(shallow)コピーで返すことができる。そのデータは次にrows.Scan()が呼ばれた時に使われるのだが、Scanの引数が*[]byteなら新しいメモリをアロケートしてデータのコピーを行い、*RawBytesならドライバが返した[]byteをそのまま渡す。このデータはドライバの受信バッファ内に本体があるので、ドライバが次に何か動作をするときに消される可能性がある。そのため利用可能期間が短いのだ。

例えば、データベースに保存した大きいバイナリデータを取得してすぐにbase64エンコードする場合など、エンコード前のデータを一時的に持つためだけに大きいアロケートとコピーをしなくてよくなる。

この時、RawBytes型の変数を次のように宣言していれば、生存期間が次のrows.Next()後にまで引き継がれることがないので、他の場所に浅いコピーをしていない限り安全だ。

for rows.Next() {
    var data sql.RawBytes
    err = rows.Scan(&data)
    // data を使う処理
}

しかし、RawBytesのスコープを広くしてしまって、1つの変数を複数回rows.Scan()に渡してしまうと問題が起こるケースがある。上のバグ報告で書いたサンプルがこれだ。

var buf sql.RawBytes

rows := tx.Query(tx, "SELECT 'hoge'")
rows.Next()      // driver returns []byte("hoge") that references internal buffer.
rows.Scan(&buf)  // buf now points to driver's internal buffer.
rows.Close()

rows = tx.Query(tx, "SELECT 42")
rows.Next()      // driver returns int64(42).
rows.Scan(&buf)  // sql does `buf = strconv.AppendInt([]byte(buf)[:0], 42)`. That write "42" to the internal buffer.

1回目のScan(&buf)は結果が文字列なので、bufにはドライバの内部バッファの一部を指すような浅いコピーとして文字列が入っている。

しかし、2回目のScan(&buf)は結果が整数なので、ドライバはint64型で42を返している。そしてScan(&buf)はint64からRawBytesに変換する時に、buf = strconv.AppendInt(buf[:0], 42, 10)をしてしまう。なので前のScan時には"hoge"の"ho"の部分が格納されていた領域に、今は何が入っているか分からないのに、"42"を書き込んでしまうのだ。もしそれが受信バッファの中の次の行のデータだったら次の行が破壊されてしまう。

実際にはドライバの動作はドライバごとに異なるので上のはシンプル化したシナリオでしかない。実際にgo-mysql-driverに報告されていたIssueでは、たまたま次のパケットの長さを示す場所を別の数字で上書きしてしまい、来るはずのない大きいパケットを待ち続けていた。

RawBytesScan()に渡す時、ユーザーは *buf = 何かデータ のような代入先としての使い方しか想定しておらず、 *buf = append何か(*buf[:0], 何か) のような追記先としての使われ方は想定外だろう。Appendを使うことでアロケートを避けられるケースもあるかもしれないが、それはごく稀だ。今回のように止まるとか、エラーが返るならまだマシで、サイレントにデータを壊す可能性もあるので、Scan()はRawBytesへ追記すべきではないと思う。

しかし、ずっと前から現バージョンまでずっとこの動作になっていた以上、ユーザーはなるべくRawBytesを避け、使う場合もスコープに気をつけて使わなければいけない。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。