2011年11月5日土曜日

CakePHPでデータベースセッションを使った時の怪現象

CakePHP1.3.5でのお話。

CakePHPを配置するWebサーバーを複数台でロードバランスしている環境でセッションを使おうと思ったら、ファイル以外にセッションを保存しないといけません。そういった理由で、セッションをデータベースに保存しているシステムがあります。このシステムでデータベースに保存されているセッションが突然全消しされる怪現象が何度か起こってて、なんじゃこりゃ(´・ω・`)ショボーンな感じで、原因究明に3日ぐらいかかった。忘れないうちにその全貌を書き残しておこう。

1. 問題部分

CakePHPでデータベースにセッションを保存している場合、有効期限が切れた時、PHPのsession_destroy関数が呼ばれて、それをトリガーにcake/libs/cake_session.phpの__destroyメソッドが呼ばれます。
function __destroy($id) {
    $model =& ClassRegistry::getObject('Session');
    $return = $model->delete($id);

    return $return;
}

ここで見てわかるようにセッションIDをキーとしてデータベースからセッション情報を削除する処理が流れます。さて、このモデルのdeleteメソッドを詳しく見てみると

X. cake/libs/model/model.php deleteメソッド
function delete($id = null, $cascade = true) {
    if (!empty($id)) {
        $this->id = $id;
    }
    $id = $this->id;

    if ($this->beforeDelete($cascade)) {
        $filters = $this->Behaviors->trigger($this, 'beforeDelete', array($cascade), array(
            'break' => true, 'breakOn' => false
        ));
        if (!$filters || !$this->exists()) {
            return false;
        }
        $db =& ConnectionManager::getDataSource($this->useDbConfig);

        $this->_deleteDependent($id, $cascade);
        $this->_deleteLinks($id);
        $this->id = $id;

        if (!empty($this->belongsTo)) {
            $keys = $this->find('first', array(
                'fields' => $this->__collectForeignKeys(),
                'conditions' => array($this->alias . '.' . $this->primaryKey => $id)
            ));
        }

        if ($db->delete($this)) {
            if (!empty($this->belongsTo)) {
                $this->updateCounterCache($keys[$this->alias]);
            }
            $this->Behaviors->trigger($this, 'afterDelete');
            $this->afterDelete();
            $this->_clearCache();
            $this->id = false;
            return true;
        }
    }
    return false;
}

Y. cake/libs/model/datasources/dbo_source.php deleteメソッド
function delete(&$model, $conditions = null) {
    $alias = $joins = null;
    $table = $this->fullTableName($model);
    $conditions = $this->_matchRecords($model, $conditions);

    if ($conditions === false) {
        return false;
    }

    if ($this->execute($this->renderStatement('delete', compact('alias', 'table', 'joins', 'conditions'))) === false) {
        $model->onError();
        return false;
    }
    return true;
}

処理の流れのポイントとしてX11行目でIDが存在するかどうかをチェックして、存在しなければ処理を終了しています。IDが存在すればX27行目でデータソースのdeleteメソッドが呼ばれます。

Y4行目の_matchRecordsメソッドはWHERE句を文字列で返してくれる関数なのですが、中身を辿っていくともう一度モデルのexistsメソッドが呼ばれています。ここでIDが存在すれば

A. WHERE Session.id = 'xxxxx' AND 1 = 1

という文字列が返されます。もしIDが存在しなければ

B. WHERE 1 = 1

という文字列が返されます。

BのWHERE句はなんとも危険な香り(´∀`;) ま、通常はX11行目でIDの存在をチェックして、存在する場合しかYには入ってこないんだけど・・・さて、ここまで来たらなんとなくわかってきた気がします。


2. 非同期通信の怖さ

まず今回のシステムではUIにExtJSというJavaScriptのフレームワークを使っていました。ExtJSからバックエンドのCakePHPにいろんなデータを投げて処理するという基本的な流れです。さてこのExtJS、これを使って開発していると、どうしてもAjax通信を多用することになります。

今回は画面を開いた時に、連続してAjax通信するようにコーディングされている部分がありました。Ajaxは非同期通信なので、連続でAjax通信されている部分があるとすると、前の通信の完了をまたずに次の通信を開始します。これが諸悪の根源でした。

さて、今ここにセッションの有効期限が切れたユーザーがいるとします。このユーザーが画面を開いたとするとAjax通信が非同期に2つ同時に流れます。その2つの通信それぞれについて、セッションの有効期限が切れているためcake/libs/cake_session.phpの__destroyメソッドからモデルのdeleteメソッドが呼ばれます。

それぞれ通信Aと通信Bとしましょう。AとBはそれぞれ__destroyメソッドからdeleteメソッドを呼ばれ、X11行目のID存在チェックを通りぬけYに入ります。たまたまタイミングの問題でAが先にY4行目にさしかかり、WEHERE句を取得してそのままY10行目のSQL実行まで行ったとします。次にBがY4行目にさしかかり、WHERE句を取得して・・・・と、ちょっとここでストップ。そう、Aが先にデータベースからIDを削除しています。すると、BがY4行目を実行してる時点では既にIDは消えてなくなっていることに!

ここまで来るともうわかります。IDが無いのでWHERE 1 = 1という危険なSQLが返ってきて、それを実行しちゃうのでセッションが全消しされます。


3. コーディングルール

そのシステムではAjax通信をする際は、前の通信が終わってから(コールバックなどを利用して)次の通信を始めるというような基本的なルールがあったのですが、今回問題が起こった部分はそれに沿っていない箇所でした。もし、そのルールにのっとっていれば非同期の同時アクセスもなくこんな問題は起きなかった。開発者がJavaScriptについてあまり知らないらしいので、コールバックを使った同期通信の手法を知らなかったのかもしれません。



と、まぁ、今回はタイミングの問題もあるので、ほとんど起こらないような事象だけど、こういうこともあるんだよ、っていうことで誰かが読んでくれればいいな。

ところで、根本的にこれを解決するにはコアをいじらなきゃいけないよな。たぶん。どこをどう変えたらいいんだろう。


2011-11-07追記
CakePHP1.3.6からはモデルのdeleteメソッドが微修正されているようです。たぶんもう起きません。

0 件のコメント:

コメントを投稿