isyumi_netブログ

isyumi_netがプログラミングのこととかを書くブログ

キャッシュを設計する時に考えること

キャッシュは難しい。そこで安全な設計ができるように考えるべきことをまとめた。

まず、キャッシュにはどのようなものがあるか。

  • 前回のHTTPリクエストの結果をローカルに保存しておき、次回はそこから結果を返す
  • HDDに書き込んだデータをメモリにも保存しておき次回はそこから読み込む
  • RDBで時間がかかるクエリを高頻度で実行する時、あらかじめ別テーブルに集計結果を入れておく
  • 数学のアルゴリズムを書く際に、CPU負荷の高い平方根を先に済ませて結果を連想配列に入れておく

このようなものが挙げられるだろう。

本題に入る前にいろいろなタイプのキャッシュを分類をしたい。キャッシュは二種類に分けられる。一つは元データとキャッシュデータが一対一で対応するものだ。キャッシュデータが元データのどの部分をキャッシュしているか明確に答えらえるものとも言える。例えば1万ファイルの中で数ファイルをメモリにも持っているようなキャッシュだ。これは、キャッシュしたファイルがHDD上のどのファイルかはっきりしている。もう一つは、元データの集計結果に対するキャッシュだ。例えば、1年間の売り上げが全てDBに入っているとしよう。その総売上金額をメモリにキャッシュしているとする。そのキャッシュデータは売り上げデータ全体に対するキャッシュだ。逆に言えば元データのどこか個別の場所のキャッシュではない。この違いによってキャッシュ設計で考えるべきことが変わってくるのできちんと区別しよう。

次にキャッシュの難しさについて考えたい。なぜキャッシュは難しいのか。そもそも真実は一箇所に管理していた方が都合がいい。同じデータを複数の場所に保存しているとデータに更新をかけたい時どこを書き換えればいいかわからないくなる。また、元データでなくキャッシュの計算式が変わる場合もある。税込みの売上データをキャッシュしていたらある日消費税率が変わったりする。その時キャッシュデータをどう更新しなければいけないのかが難しい。このように、キャッシュはデータやシステムに変更があったら正確にキャッシュを更新しなければ不整合が起こることが難しい。

では、どのような心構えで挑めばいいだろうか。まず、不整合とは何かはっきりさせたい。一つは元データが更新されたのにキャッシュデータが更新されていない状態だ。次に、キャッシュの計算式を変えた時は、キャッシュを更新するまで全てのキャッシュが不整合な状態になる。さらに、今キャッシュされているデータが整合しているか自信がない時も不整合と同じである。例えばDBの一箇所を手動でUpdateしたが、このUpdateはキャッシュとの間に不整合を起こすかわからなかったとしよう。心配になってる時点で不整合が起きていると一緒だ。

そこで、何が正になるデータかはっきりさせよう。HDDのデータをメモリにも持つならHDDが正だ。RDBで正規化したテーブルとパフォーマンスのために結合結果を保存してあるテーブルがあれば正規化したテーブルの方が正だ。正のデータはキャッシュのデータに依存してはいけない。存在を知っていてもいけない。キャッシュ管理層は正のデータを取りに行っていいが、反対に正のデータ管理層はキャッシュデータに触らなければ整合性を維持できない状態になってはいけない。また、キャッシュデータは知っているけど正のデータは知らない情報があってもいけない(ただし、キャッシュの作成時刻のようなメタデータはOK)。このようにレイヤーを意識して依存関係が複雑にならないようにするといい。

次に、どんな時にキャッシュのどの部分を更新するかきちんと洗い出そう。決定事項はReadme.mdなどにきちんと書き出して管理しておくことをお勧めする。この部分は特に「ソースコードが正解」という事態は避けた方がいい。

そして、不整合はいつか起きるものだと思っておこう。起こらないようにするより大事なことだ。

では、ここからいいキャッシュのためどんな作戦を立てればいいかを考えよう。

まず、元データとキャッシュが1対1で対応するようなキャッシュではここに気をつけたい

  • キャッシュはあってもなくても同じ動きになるようにしたい
  • あった方が速いがなくてもいいという状態が最善
  • 元データ管理層がキャッシュのクリアを指示できるように
  • キャッシュがおかしくなったらとりあえずキャッシュをクリアすればいいようにしておきたい
  • キャッシュを簡単に削除できるように

逆に、全体に対するキャッシュの場合は

  • キャッシュを簡単い作り直せるように

を気をつけよう。

具体例は次回以降に話したい。

バッチ処理には必ずドライラン機能をつけよう

バッチ処理の負債化は早い。理由はよくわからない。想像するに、普段ほとんど意識しないからどんな機能があるか忘れる、外部のシステムとつながっているので手動テストすらし辛い、処理が長い、などの理由がありそうだ。

バッチ処理は簡単に動作を再現できることが重要だ。もちろん、開発環境は用意してあるだろう。そこで本番に近しい結果を得ることはできるかもしれない。しかし、今動いている本番サーバーがどんな動きをするか簡単に確認出来てほしい。

例えば、一日に一回1週間ログインしていない人にメールを送るとする。そうであれば、今バッチ処理が実行されたら誰にどんなメールが送られるか確認できるようにしてほしい。出力形式はWebの画面でもCSVでもなんでもいい。このようにテスタブルにすることで微修正を重ねやすくなりソースの腐敗が防がれる。

これが実現可能であるということこそ、これまでこのブログで支持してきた副作用を抑えるプログラミングスタイルのメリットの一つである。

 

blog.isyumi.net

例えば先述のメールのバッチ処理の場合

  1. DBにQueryを発行する
  2. 1件ずつFetchし、メール送信対象者ならメールを送る

というバッチ処理ではドライラン機能をつけるのが難しい。しかし

  1. DBにQueryを発行する
  2. 1件ずつFetchし、メール送信対象者なら「メールを送る人配列」に追加していく
  3. まとめてメールを送る

というように書いておけば最後の3番をIF文で切り替えて「メールを送る人配列」をCSVに出力するだけで簡単にドライランが実装できる。

 

追記

本当に配列に入れちゃうとパフォーマンスとかヤバそうな場合あるので、その言語のイベントストリーム機能を調べよう。例えばGoならchannelの受信先を挿げ替えるだけで同じことが実現できる。

複数の言語で開発する時は定数を一元管理できるようにしよう

一つのシステムを複数の言語で開発することがある。例えばサーバーをJavaで開発しクライアントをTypeScriptで開発する、などだ。こんな時、定数の一覧をYAMLなどでファイル化しよう。そして、YAMLから各言語の定数ファイルを自動生成するものも作ろう。すると、サーバーとクライアントのやりとりでtypoによるエラーがなくなって便利だ。例えばプロフィール入力画面があったとする。そこで入力する性別がman/womanとmale/femaleのどちらにしようか、また大文字か小文字か間違えないようにしたい。こんなYAMLを作ろう。

MALE: MALE
FEMALE: FEMALE

そこから生成したGoとTypeScriptはこんな感じだ。

const (
MALE = "MALE"
FEMALE = "FEMALE"
)

 

const  MALE = "MALE",
FEMALE = "FEMALE";

 

TSXからこんな感じに使える

return <select>
<option value={MALE}>{MALE}</option>
<option value={FEMALE}>{FEMALE}</option>
</select>

 サーバーサイドはこんな風に描ける

func PutGender(userid , gender string) {
if gender != MALE && gender != FEMALE {
panic(gender)
}

db.put(userid , gender)

}

このようにサーバークライアント通じて安全にプログラミングができて便利だ。

Swaggerなど関連する便利なツールもあるので利用していきたい。

 

VからCにメッセージを送る時は最小限のデータにしよう

いろんなMVCがあると思う。特定の実装に依存する話じゃない。

  • RoR的なサーバー・クライアントモデル
  • ReactやAngularを使ってクライアントサイドMVCを構築する
  • CLI

など、色々ある。大雑把に話の前提を揃えよう。Mは様々なデータを管理している。VはMを加工して画面に表示する。Vでクライアントからの入力を受け付けてCがそれを処理する。VはMからデータを取り出すことができる。今日の問題提起だが、なぜかVからCに送るデータが肥大化しがちと言うことである。例えば、

  • 画面がありそこに価格が書いてある
  • それを押すとサーバーで課金システムが動く

というシステムだとしよう。ここでクリックされた時にサーバーに送られるデータは商品IDだけであるべきだ。価格は省いて送信するべきだ。Cは自分でMから商品の料金を参照するべきだ。

なぜか。一つにセキュリティの問題だ。そして変更に弱くなる。しかし、もう一つ大きな理由としてメンタルモデルをあげたい。「システムの様々な場所に点在するデータは、最終的にMを正にする」と思い込んでいる。その中で、CがVから必要ないデータを受け取っていたら、「これ何かに使うのかな?」と言う気がしてくる。

急遽個別対応するときはコンパイルエラーを出しながら元に戻せるようにしよう

あなたはショッピングサイトを運営していたとする。ある日急に担当者から「来週からセールを始めるので13:00になったらこの告知バナーを表示してほしい」と依頼された。しかし、既存のシステムにはセール時にバナーを差し込むようなレイヤーは用意していない。そこで、少々お行儀は悪いがテンプレートに直接<img>タグを差し込むことにした。

void topPage(HttpResponse res) {
// ここからセール対応 セールが終わったら消し忘れないで><
var isSale = new DateTime.now().isAfter(new DateTime(2017, 10, 21, 13));
var saleTag = isSale ? "<img src='sale.png'/>" : "";
// ここまでセール対応

res.write("""
<div>
<h1>●●ショップへようこそ</h1>
${saleTag}
</div>
""");
}

 その後担当者から追加の依頼があった。「商品一覧画面にも出して><!」

void productListPage(HttpResponse res) {
// ここからセール対応 セールが終わったら消し忘れないで><
var isSale = new DateTime.now().isAfter(new DateTime(2017, 10, 21, 13));
var saleTag = isSale ? "<img src='sale.png'/>" : "";
// ここまでセール対応

res.write("""
<div>
${saleTag}
<h1>防寒具一覧</h1>
</div>
""");
}

 

 さらにその後担当者から依頼があった。「マイページにも出して(:3_ヽ)_」

void myPage(HttpResponse res) {
// ここからセール対応 セールが終わったら消し忘れないで><
var isSale = new DateTime.now().isAfter(new DateTime(2017, 10, 21, 13));
var saleTag = isSale ? "<img src='sale.png'/>" : "";
// ここまでセール対応

res.write("""
<div>
${saleTag}
<h1>購買履歴</h1>
</div>
""");
}

 

 さてあなたはここで不安になる。自分はセールが終わったときに正しくすべての個別対応を除去できるだろうか。消し忘れたら恥ずかしい。あなたにはいくつかの選択肢がある。

これらも間違ってはいないと思うが、もう少し便利な方法を紹介したい。元に戻すときにすべての該当箇所にコンパイルエラーが出せるようにしておこう。方法はたくさんあるが一番手軽な方法として、セールが始まっているかどうか判定する関数を一個作っておこう。そして個別対応した場所はすべてその関数を参照するようにしよう。

 

 

bool isSale() {
return new DateTime.now().isAfter(new DateTime(2017, 10, 21, 13));
}

void myPage(HttpResponse res) {
// ここからセール対応 セールが終わったら消し忘れないで><
var saleTag = isSale() ? "<img src='sale.png'/>" : "";
// ここまでセール対応

res.write("""
<div>
${saleTag}
<h1>購買履歴</h1>
</div>
""");
}

 今回はisSaleという関数を定義し、myPageやその他バナーを張ったページから呼び出している。

すると、セールが終わった後にisSale関数を削除するとこのようにコンパイルエラーが出る。

f:id:isyumi-net:20171020202125p:plain

赤い波線が出てわかりやすい



f:id:isyumi-net:20171020202317p:plain

IDEコンパイルエラー一覧を見れる



 

連想配列をclassでくるもう

連想配列を使う時の些細な一手間について。連想配列は自由度が高い機能だ。反面、その連想配列をどう使っているのかがコードを書いた本人にしかわからない。そこで、コードの中で連想配列の使い方を明示する方法を紹介する。

あなたは刻々と流れてくる売り上げ情報を集計し、リアルタイムな商品ごとの売り上げを出したいとする。次のような流れのコードを書こうと思う。

アウトライン

  • 連想配列のkeyを商品IDにする
  • valueを通算の売り上げにする
  • 商品の売り上げ情報が流れてくるごとにvalueに値を追加する
  • ただし初めてそのIDの商品が来た時に足し算をするとエラーになるので0を代入する

まず、このようなコードを書いた。

Map<String,int> sales = {};

// 売り上げイベントが流れてくる
void onSales(String productID , int price) {
// この商品IDが初めてきた時は0を代入する
if(!sales.containsKey(productID)) {
sales[productID] = 0;
}
// 商品代金を売り上げに加算する
sales[productID] += price;
}

 

コードが短いのであまり効果が実感できないかもしれないが、このコードの改善できる点として

  • 上にあげたアウトラインがコードで表現できていない
  • 連想配列をどのように使うかのルールがコメントと書いた人の頭の中にしかない
  • コードを書いている最中にそのルールを覚えていなければいけない

もっと複雑なデータ構造(例えばMapのなかにMapがあり、その中にListがあるような状況)であればこの問題は大きくなる。では、どう改善できるだろうか。連想配列を包んだクラスを定義しよう。そしてそのクラスのメソッド経由で連想配列を操作しよう。

class ProductSales {
Map<String, int> map = {};

void productInitialize(String productID) {
if (map.containsKey(productID)) {
map[productID] = 0;
}
}

void addSales(String productID, int price) {
map[productID] += price;
}

}

final productSales = new ProductSales();

// 売り上げイベントが流れてくる
void onSales(String productID, int price) {
productSales.productInitialize(productID);
productSales.addSales(productID,price);
}

 

余計長くなってしまったが 、それでも

  • 連想配列をどう使うかプログラムで表現できた
  • 一番上流のメソッドから連想配列の具体的な操作を除去でき、代わりに分かりやすい名前のメソッド呼び出しが出現した
  • onSalesを書いてる時(と読んでいる時)は連想配列の存在や使い方についてのことを忘れていられる

と言う効果が実感できると思う。普段はこんなことをしなくてもいいが、すごく長くて複雑なメソッドができてしまった場合にぜひ使ってみて欲しい小手先のテクニックだ。

再代入は初期化の直後に限ろう

下のコードのように一度変数に値を代入した後に別の値を代入することを再代入という。

例:

 

 

func main() {
i := hoge()
i = "iii"
log.Print(i)
}

まず、極力再代入を減らすように心がけよう。再代入を減らすとバグが減るしデバッグの手間も減る。このブログで何度も言ってきたことだが、変数はとにかく早く値を確定させよう。下のコードは悪い例だ。関数の上のほうで定義された途中で書き換えられていることに気づいただろうか。

 

func main() {
i := hoge()
a = 3 * 7
b = a % 2
c := fuga(i)
if c == 0 {
a = functionA(d * c)
i = "iii"
}
for x := 0; x < 10; x ++ {
piyo(len(i) * x)
}
log.Print(i)
}

 このようなコードはiの値はなんだっけと考えてしまう。変数を最初に宣言してから最後に代入するまでの間は脳のワーキングメモリを消耗しながらコードを読んでいると考えてほしい。逆に、その間の距離を極力短くすることができれば疲れないコードになったといえる。さらに、変数の再代入を一切排除できればそれだけいいコードといえる。下はいい例だ。

func main() {

i := hoge()
if c == 0 {
i = "iii"
}
// この時点でiの値は確定している

fuga(i)
a = 3 * 7
b = a % 2
if c == 0 {
a = functionA(b * c)
}
for x := 0; x < 10; x ++ {
piyo(len(i) * x)
}
log.Print(i)
}
 

上のコードのように最初にiが宣言されてから即座に再代入をしていればあまり心が消耗することがない。このルールを適用すべき場面として二つあげたい。

  • デフォルト値を入れる
  • for文で計算する

一つ目のデフォルト値だが、関数を実行して戻り値を得るがもしエラーになったらデフォルト値を入れるというコードを書くことは多い。その場合も

i, err := hoge()
if err != nil {
i = ""
}

 

 のように即再代入をすることを心がけよう。決して隙間にほかの処理を挟まないようにしてほしい。

i, err := hoge()
// ここに処理を一切書かない
if err != nil {
// ここにデフォルト値を入れるために必要な処理以外を一切書かない
i = ""
// ここに処理を一切書かない
}

 

二つ目のfor文だが、for文で変数の値を順番に書き換えることは多い。その場合も、書き換える専用のfor文を用意しよう。

i := 0
for x := 0 ; x < 100 ; x ++{
i += x
}

 

このようにこの四行以外に余計な処理が一切なければコードを読んだ人は「ああ、この四行一固まりでiの値が確定するんだ」と理解できる。これも同じようにコードの隙間に別の処理を差し込まないようにしたい。

i := 0
// ここに処理を書かない
for x := 0; x < 100; x ++ {
// ここにiを上書きするために必要な処理以外を書かない
i += x
// ここに処理を書かない
}

 ぜひ心掛けてほしい。