読者です 読者をやめる 読者になる 読者になる

UUUM攻殻機動隊

UUUMのエンジニアによる技術ブログです

不具合から見る go の database/sql の挙動

nazoです。

先日、とある go で書かれたWebサーバーで、too many open files が出て死ぬ、という問題が発生しました。今回はその点に関して調査した内容を紹介したいと思います。

調査内容と結果

too many open files は、言うまでもなく、ファイルの開きすぎで出るエラーですが、 lsof してみたところ、 can't identify protocol 状態のものが大量に存在していました。ソケットが正常に閉じられずに放置されていたようです。

さらに調査をしていると、トランザクションの BeginCommit をコメントアウトすると発生しなくなることが判明し、そこから以下のバグが発見できました。(gorm を使用しています。また実際のコードとは違います)

tx := db.Begin()

db.Model(&user).Where("id = ?", userID).UpdateColumns(〜)

tx.Commit()

このコード自体はそこまで重大なバグはないのですが、トランザクション開始*1後のクエリに tx を使っていません。この時、 gorm はトランザクション中でないコネクションを使用しようとした時、新規にトランザクションを張ります。この時、 SetMaxOpenConns を(少なめの値で)指定していると、コネクションが上限に達した時に、全ての接続に対して応答がなくなるということが判明しました。

これは、 database/sql の現在の仕様で、空きコネクションがない状態でコネクションを取ろうとすると、コネクションを取れるまで時間無制限で待つ という仕様によるものです。これと、先ほどのトランザクション制御ミスにより、1セッションでトランザクションが2回張られたタイミングの2回目でデッドロックを起こし、最終的に全体が止まるという現象を確認できました。

さて、これ自体は too many open files とは直接関係ないのですが、これと同様のものを探していたところ、以下のようなコードがありました。

    tx := db.Begin()

    if err := db.Model(&user).Where("id = ?", userID).UpdateColumns(~).Error; err != nil {
        tx.Rollback()
    }
    
    // end
}

見ての通り、Commitしていません。しかし、 UpdateColumnstx を使っていないので、自前のトランザクション制御により正常(?)に動作します。発見が難しい挙動です。

database/sql は、トランザクションを張りっぱなしにしていると、いつまでも解放しませんdb.putConn(conn, nil) が呼ばれると、そのコネクションが空きプールに入りますが、これは Commit() または Rollback() 実行時にしか呼ばれないようになっています。そもそも go にはデストラクタはありませんし、database/sql が Web のリクエストに対応していない(当然)ためです。これにより、無限にコネクションが増え続け、最終的に too many open files となりました。他言語に慣れているとハマるところかと思います。DB接続までサポートしているフレームワークであれば、このあたりも考慮されているのかもしれませんが、今のところ調査してません。

手軽に回避する方法としては、素の database/sql であれば、defer tx.Rollback() してしまう手があります。現状は Rollback() は二重に実行してもエラーを返すだけなので、特に問題がありませんが、将来的に同じ挙動かどうか保証されないのが難点です。なお、gorm の場合はエラーが蓄積されてしまうので、この手法は使えません。

今回の内容では、shogo82148/txmanager に似たようなトランザクション制御を用意し、全コードをそれで書き直すことで対応しました。

まとめ

database/sql を使っている場合、トランザクション中にトランザクションでない接続を使用すると、別途トランザクションを張ろうとします。

また、トランザクションを放置するとそのコネクションは永遠に開きっぱなしになります。この時、 SetMaxOpenConns() を指定していると、コネクションが頭打ちになり、新しいコネクションを取得しようとした時に永遠に停止します。 SetMaxOpenConns() が指定されていない場合、無制限にコネクションを張るようになり、ulimit 上限に達してアプリケーションが動かなくなります。

database/sql あるいはそれを使ったO/Rマッパーなどを利用する場合、ライブラリ側でトランザクション制御が用意されているか(閉じ忘れ防止機能などがあるか)、なければ必ず閉じるようなコードにしましょう。shogo82148/txmanager のようなものを使うのも一つの手です。gocraft/dbr だと、 RollbackUnlessCommitted() という defer 向けメソッドが用意されているので、比較的安全に利用することができます。

また、常駐するアプリケーションで go の channel を使う場合は、そこでの停止から脱出する方法があるか、デッドロックにならないか、などを注意するようにしましょう。

UUUMでは、go でアプリケーションを書きたいエンジニアも募集しています。詳しくは以下をご覧下さい。

*1:2016/07/14修正