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

HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

DBMSへのクエリの結果をキャッシュすることについて考えてみる

memcached MySQL

MySQL(実はPostgreSQLもだが)を使った開発(何)で、memcachedにクエリの結果をキャッシュするという要件があるのだが(謎)、ちょっと考えてみても、いろいろと考察しないといけないことがあるよなぁ、と思ったので、ちょっと考えてみる。
一口にキャッシュすると言っても、いろいろな条件が考えられると思うが、とりあえず今回は次のようなシンプルケースを前提とする。

  • 検索、追加、更新、削除はすべて同じ人が責任を持つ。つまり、他プログラムが介入してDB上のデータを更新することはない。
  • DB上のデータが更新されない限りは、キャッシュを残しっぱなしにしておいてもよい。つまり、タイムアウト時間は任意でよい。
  • 逆に、DB上のデータが更新されたら、該当するキャッシュの内容は破棄する。

単純に考え付くのが、検索用のSQL文をキーとしてmemcachedに検索結果を放り込んでおけばよいというやり方。
確かに、

SELECT name, address, phone
    FROM members
    WHERE id = 'aaa'

のようなクエリに関して言えば、「id = 'aaa'」である行の増減および変更がなければ、毎回同じ結果が返ってくるのだからそれでよい。
更新時の処理に関しても、

UPDATE members
    SET name = 'xxx'
      , address = 'yyy'
      , phone = 'zzz'
    WHERE id = 'aaa'

ということなら、上記のSELECT文をキーとするキャッシュデータを削除すればよい。
しかし、そうは行かないケースがある。「時間の経過とともに検索結果が変わる」ケースである。
たとえば、次のようなクエリ。

SELECT title, contents
    FROM info
    WHERE startDate <= CURRENT_TIMESTAMP
      AND CURRENT_TIMESTAMP <= endDate

SQL文自体は毎回同じであっても、実行する時刻によってCURRENT_TIMESTAMPの値は異なるのだから、同じクエリであるとは言えない。
ならば、と、次のようにしてみる。

$curDate = date("Y-m-d H:i:s", time());
$query =
    "SELECT title, contents"
    . "    FROM info"
    . "    WHERE startDate <= '${curDate}'"
    . "      AND '${curDate}' <= endDate";
$res = mysql_query($query, $dbRes);

SQLのCURRENT_TIMESTAMPを使う代わりに、プログラムのほうで生成した時刻をクエリ文字列に埋め込む。こうすることで、クエリ文字列が同じであれば同じ検索結果が得られるということが保障される。
しかし、めでたしめでたし、なわけがない。クエリ文字列自体が1秒経過するたびに変わるのだから、キャッシュしたデータを再利用するなんて、ほぼありえないということになり、キャッシュしている意味がない。


この問題に対して、思いつく解決策は2つ。

  • いっそ、キャッシュするのをやめる。
  • 大雑把にキャッシュする。

前者‥では話が終わってしまうし何の工夫もないので却下。
というわけで後者になるわけだが、ざっくりとしたイメージは次のような感じ。

  • DBMSに対して、ざっくりとした範囲(例:1時間分、1日分、等)でデータを要求
    • このSQL文をキーとしてキャッシュに放り込む
    • 単位時間(1時間、1日、等)が過ぎたらキャッシュが破棄されるように期限を設定しておく
  • 最終的な絞込みはプログラム側で行う

これをPHPで実装してみると次のような感じ。

// ざっくり取得するときの検索条件とする日時(「○時台」という条件)
$from = date("Y-m-d H", $curTS);
$to = date("Y-m-d H", $curTS + 3600);

// SQL文。最後の絞込みをするために、日時も取得しておく必要がある。
$query =
    "select title, contents, startDate, endDate"
    . "    from hoge"
    . "    where startDate < '${to}'"
    . "      and '${from}' <= endDate";

// SQL文をキーとして、キャッシュをチェック
$key = $query;
$tmp = $memcache->get($key);
if ($tmp) {
    print('    cached: ' . $key . "\n");
} else {
    // キャッシュになければDBから取得してキャッシュに放り込む
    print('not cached: ' . $key . "\n");
    $res = mysql_query(
        $query, $dbRes);
    $tmp = array();
    while (($row = mysql_fetch_assoc($res))) {
        $tmp[] = $row;
    }
    $memcache->set($key, $tmp, 0, 30);
}

// 最後の絞込み
$curDate = date("Y-m-d H:i:s", $curTS);
$result = array();
foreach ($tmp as $row) {
    if ($row['startDate'] <= $curDate && $curDate <= $row['endDate']) {
        $result[] = array(
                'title' => $row['title'],
                'contents' => $row['contents'],
            );
    }
}

print_r($result);

検証はやったけど、書くのが面倒なので省略(謎)。
で、DB上のデータが変わるときがちょっと面倒。
たとえば、「('abc', 'abcABC', '2011-02-19 11:00:00', '2011-02-19 14:00:00')」というような行が挿入される場合は、現在時刻が「2011-02-19 11」「2011-02-19 12」「2011-02-19 13」「2011-02-19 14」時台のいずれかである場合にはキャッシュのデータを削除する必要がある。
更新の場合はさらにもう少し面倒で、「更新前」のデータのstartDateとendDateを取得し、それが現在時刻を含むようであればキャッシュのデータを削除する必要があるし、「更新後」のデータのstartDateとendDateが現在時刻を含むようであれば同様に削除する必要がある。
まぁ、この辺の処理は省略(待て)。