はじめに
以前、tcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話という記事をかいたのですが、そのきっかけは
以前、アプリからDBにSQLを投げたところ、コネクションがinvalidだというエラーが起きました。この原因自体はとても簡単でサーバ側(DB側)のコネクションを保持するタイムアウト設定がクライアントよりも短かったというだけなのですが、「これってクライアントライブラリ側でソケットにwriteした時点でエラーになるんだからハンドリングしてコネクションプールに保持している他のコネクションをよしなに使ってよ!!」と思ったのでした。
でした。
これはgoのmysqlドライバーを利用した場合に発生していたのですが、まさにこの問題をGitHubの中の人が去年に修正しており、それをテーマにブログを書かれていました。
それが非常に勉強になったので、わかりずらい部分を補足しつつ紹介したいと思います。
ブログを読む
背景
Three bugs in the Go MySQL Driverです。
背景とかの話も非常に興味深かったので若干主旨とずれる部分も紹介します。
GitHubのサービスはRailsのモノリスだったのを、ここ数年で少しずつ、速度や信頼性が必要な部分を中心に切り出していってGolangで書き換えていっているようです。
その中の一つのサービスが2019年に稼働したauthzdというサービスで、GitHub社のGoで書かれたWebアプリでMySQLに接続する初めてのサービスだったようです。
そのブログはその際に経験したバグに対してGitHub社が修正した3つのPRをもとに修正が紹介されています。今回は最初に紹介されているThe crashの部分を紹介します。
ちなみにresulting in our first “9” of availability for the serviceと書いてあったのでThe crashの修正によってサービス可用性90%突破したようです。
業務でサービス可用性目標をあげるところもあると思いますが、OSSがボトルネックになっていたのでOSSを修正するというのは素晴らしいですね!!
ちなみにブログに添付されているスクリーンショットはDatadogのmonitorのようなので、GitHub社もDatadogを利用しているようですね(どうでもいい)
The crash
どんな話なのかを先にざっくり書いてしまうと、MySQLのサーバ側のidle timeoutがクライアントのそれよりも短い場合、クライアントからクエリを送ろうとしたとき実はそのコネクションはサーバ側がcloseしていたということがおこりえます。その場合にクライアントとしては強制的にエラーになることを経験します。
この問題への対応としては簡単で(*DB).SetConnMaxLifetimeをサーバのidle timeoutより小さくすればいいだけです。
ただ、SetConnMaxLifetimeであってSetIdleConnMaxLifetimeではないので、idleではなくactiveなコネクションも不必要にcloseされてしまい、イケていないです。これは全てのDBサーバのコネクションにidleという概念があるわけではないため、database/sql側が用意していないという背景があるようです。
自分はまさに上記の対応を行って(参考までにDBのidle timeoutはAWSのAuroraの場合、デフォルトで8hに設定されているようです。GitHub社では30sに設定しているようです。短い!!)おり、そのときにmysqlドライバー側でよしなにできないの?と思い、調査したことを以前記事にしたわけですが、修正してくれたようです。
さて、詳細に入っていきます。
記事の序盤はtcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話とほぼ同じことがTCPの遷移図とともに書かれています。
TCPの仕様上、サーバがFINパケットを送ってもそれはあくまでサーバ側がwriteしないことのみを意味し、クライアントからはサーバへwriteし、サーバがreadし処理をするのはありえます。そして、サーバがwriteもreadも全くなにもしない(例えばソケットをcloseするなど)ことを安全にクライアントへ伝える方法はtcpのプロトコルに存在しません。
わかりやすいので以下引用しますが、TCPの上記のような特性はほとんどのプロトコルの場合は問題にならないようですが、MySQLのプロトコルは「クライアントが送りサーバがそれに返答する」という流れが決まっており、クライアントはwriteするまでreadすることがないようです。
In most network protocols on top of TCP, this isn’t an issue. The client is performing reads from the server, and as soon as it receives a [SYN, ACK], the next read returns an EOF error, because the Kernel knows that the server won’t write more data to this connection. However, as discussed earlier, once a MySQL connection is in its Command Phase, the MySQL protocol is client-managed. The client only reads from the server after it sends a request, because the server only sends data in response to requests from the client.
そういえば、この特性はHTTP/1.x(pipeliningは除く)も同様かと思いますが、以前、Goのhttp.Requestのキャンセルの仕組みを理解するという記事で書いたようにGoのhttpサーバの実装ではリクエストボディを読み切ったタイミングでソケットをReadするgoルーチンが作成され、サーバ処理中にクライアント側のcloseに気づけるようになっています。こちらはサーバ側の話ですが。
ここまで話を聞いて、エラーだったらretryしてくれよと思う方もいるかもしれません。
実は、retryの仕組みはdatabase/sqlに用意されており、ErrBadConnを返却するようにすれば、maxBadConnRetries(2回)リトライし、それでもエラーになればコネクションプールを利用せずに新規のコネクションを作成する実装になっています。
以下はQueryContextの例ですが、database/sqlのあらゆる処理に同じようなリトライの処理があり、また、driver側(goのmysqlドライバーも)でもdatabase/sql/driverをimportして、driver.ErrBadConnを返却しているケースがあるようです。
// ErrBadConn should be returned by a driver to signal to the sql
// package that a driver.Conn is in a bad state (such as the server
// having earlier closed the connection) and the sql package should
// retry on a new connection.
//
// To prevent duplicate operations, ErrBadConn should NOT be returned
// if there's a possibility that the database server might have
// performed the operation. Even if the server sends back an error,
// you shouldn't return ErrBadConn.
var ErrBadConn = errors.New("driver: bad connection")
// QueryContext executes a query that returns rows, typically a SELECT.
// The args are for any placeholder parameters in the query.
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error) {
	var rows *Rows
	var err error
	for i := 0; i < maxBadConnRetries; i++ {
		rows, err = db.query(ctx, query, args, cachedOrNewConn)
		if err != driver.ErrBadConn {
			break
		}
	}
	if err == driver.ErrBadConn {
		return db.query(ctx, query, args, alwaysNewConn)
	}
	return rows, err
}
今回のも同じようにErrBadConnを返却するようにしていればそもそも問題にならない(リトライに失敗しても最後にはコネクションプールを必ず使わないから)のですが、エラーが発覚する箇所がwriteである(Goのhttpserver実装のようになんらかの仕組みを用意しない限りwriteで初めてサーバのcloseに気付く)ので、常に安全にリトライできないという事情があるようです。
ブログに紹介されている以下のケースは、まさにErrBadConnのコメントにあるTo prevent duplicate operations, ErrBadConn should NOT be returned if there's a possibility that the database server might have performed the operationのケースなので、ErrBadConnは返却してはいけないということになります。
What would happen if we performed an UPDATE in a perfectly healthy connection, MySQL executed it, and then our network went down before it could reply to us? The Go MySQL driver would also receive an EOF after a valid write. But if it were to return driver.ErrBadConn, database/sql would
では、writeするまえにnon-blockingでreadしてEOFであればErrBadConnをなげればいいのでは?
と思うかもしれませんが、まさにそれがPRで対応されていることです!
いやー、事情が複雑ですね。。
PRを読む
packets: Check connection liveness before writing queryを実際によんでいきましょう。
修正方針を前章で把握するのだけでもお腹いっぱいですが、PRも100行ほどの小さいPRにもかかわらず、なかなか勉強になりました。
勉強になった点を3つ紹介します。
チェックを行うときには生のファイルディスクリプタを参照する
やることは前章で整理したようにwriteする直前にソケットをnon-blockingでreadしてすでにサーバがclose済みであればErrBadConnを返すだけです。
が、Goのネットワーク処理はAPIとしては同期的なAPIを提供しているが、実は内部ではnon-blockingな処理がされています。
簡単に説明すると、netpollerと呼ばれる仕組みでネットワークの待ちになった際に、goroutineが元処理から切り離されepollなどのシステムコールで非同期にソケットに対するイベントを把握し、処理可能になったら再度goroutineを割り当てる仕組みがgoのランタイムには備わっています(といっても自分は該当部分のソースを読んだことがないです)
これは本当に素晴らしい仕組みだと思うのですが、今回のようにブロックされることがないことが確定している場合には生のファイルディスクリタを利用したシステムコールを使った方が好ましいです。というわけで以下のような実装がされています。
明示的にnon-blockingにしていないのは、生のファイルディスクリタはGoのランタイム側ですでにO_NONBLOCK指定されているためだと思います。
	sconn, ok := c.(syscall.Conn)
	if !ok {
		return nil
	}
	rc, err := sconn.SyscallConn()
	if err != nil {
		return err
	}
	rerr := rc.Read(func(fd uintptr) bool {
		n, err = syscall.Read(int(fd), buff[:])
		return true
	})
	switch {
	case rerr != nil:
		return rerr
	case n == 0 && err == nil:
		return io.EOF
	case n > 0:
		return errUnexpectedRead
	case err == syscall.EAGAIN || err == syscall.EWOULDBLOCK:
		return nil
	default:
		return err
	}
チェックを行う回数をできるだけ少なくする
ResetSessionがsql/driver側でinterfaceで定義されており、この処理は処理済みのコネクションをコネクションプールに戻す時にsql/driverが呼びます。これにより実装するdriver側が処理を行う機会を得ます。
今回のPRではこのinterface実装でコネクションに設けたフラグをonにし、write時にこのフラグがあるときのみチェックを行い、チェック後フラグをoffにするという工夫がされています。
これにより、コネクションプールから取得したコネクションが最初に通信する時のみにチェックがされることになります。すごい!!
// SessionResetter may be implemented by Conn to allow drivers to reset the
// session state associated with the connection and to signal a bad connection.
type SessionResetter interface {
	// ResetSession is called while a connection is in the connection
	// pool. No queries will run on this connection until this method returns.
	//
	// If the connection is bad this should return driver.ErrBadConn to prevent
	// the connection from being returned to the connection pool. Any other
	// error will be discarded.
	ResetSession(ctx context.Context) error
}
windowsでは何もしない
PRではwindowsで動作確認がとれておらず、CIも存在しない。どうやって動作確認しようか
と議論がつまりかけたのですが、// +build !windowsが指定されているconncheck.goとconncheck_windows.goの両方のファイルでconnCheck関数を実装し、conncheck_windows.go側ではnilを返すというだけという技を使って議論を進めていました。これによってwindows側には何の変更もなしに修正したことになります。
すごい!!
おわりに
PRを確認すると最初にあげる時点でかなり詳しく説明し、パフォーマンス遅延などへの影響等も検証されていてすごいと思いました。OSSでPRあげる時はどうしても低姿勢になりがちな気がしますが、自分の修正内容に自信をもっていて、フローが遅いぞと圧をかけたり、こんな大変なissueも残っているんだと言われた場合もokそっちは来週PR作るねといって実際にmergeされているのでやばい
内容が素晴らしく、自分も努力せねばと思ったので紹介させていただきました。