FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

GANMA!でのCache実装例

こんにちは、杉谷と申します。

いま運用している"GANMA!"のCache周りは比較的ちゃんと出来ている気がしていまして、 サービスの特性もありますがアクセス数の割にはMasterDB1台Slave無しでも負荷すっかすか、というくらいには負荷が押さえられています。

GANMA!はDDDを採用していますが、 "Facebookの数千台規模のmemcached運用について" のような高度な物と比べるととても原始的とはいえ、 DDDとパフォーマンスを絡めた記事は余り見かけないので、せっかくなのでどのような実装を行っているのかをご紹介させていただきます。

どなたかのご参考になれば幸いです。

Cache方針

Cache機構は

  • 使う側がCacheの都合を気にしなくて良い
    • データ更新後に明示的にCache更新しなくても良いとか
  • 儀式をやらなくても勝手にCacheが使われる
  • 富豪的に呼んで良い
  • "反映待ち"が無い

といった性質を備え、透過的に使えるのが理想ですが 実現するのは難しいので、特性の違う複数のCache機構を作って使い分ける実装にしました。

現時点では以下の3機構が存在します。

  • インフラ層Cacheと呼んでいる物 《副作用ほぼ無し・結構早い》
  • インスタンスCacheと呼んでいる物 《副作用すこし有り・もうちょい早い》
  • アプリ層Cacheと呼んでいる物 《副作用大・ウルトラ早い》

個別に紹介していきます。

サーバ構成

GANMA!ではElastiCacheのMemcacheを利用しています。

AZ毎を跨ぐと遅くなるので

といった小細工を行うクラスを作成しました。

インフラ層Cache 《副作用無し》

DBアクセスクラスなど、インフラ層の存在に実装したCache機構です。

  • 読み取り系のMethodはCacheを確認し、あればそれを取得・なければ取得とCache更新を
  • 更新系はデータの更新をした後Cacheの消去

を行う原始的な実装です。

複数スレッド・複数機材で同一オブジェクトを同時更新する可能性が無ければ平和に動作します。

実装イメージ

// テーブル読み書き
class CatsTable {

  def get(catName: String): Option[Row] = ???  //※1

  def list(offset: Int = 0): Seq[Row] = ???

  def store(cat: RawData): Unit = ???

  // 〜
}

// Cache読み書き
object Cache {

  def makeKey(tag: String, args: Any*): String = ???

  def getOrElseUpdate[A: ClassTag](key: String, op: => A, expire: Int = 300): A = ???

  def clear(key: String): Unit = ???
 
 // 〜
}

// Cache付きテーブル読み書き
class CachedCatsTable extends CatsTable {

  private val className = getClass.getName

  override def get(catName: String): Option[Row] = {
    val key = Cache.makeKey(className + ".get", catName)
    Cache.getOrElseUpdate(key, super.get(catName))
  }

  override def list(offset: Int = 0): Seq[Row] = {
    offset match {
      case 0 => // ※2
        val key = Cache.makeKey(className + ".list")
        Cache.getOrElseUpdate(key, super.list(offset))

      case _ =>
        super.list(offset)
    }
  }

  override def store(cat: RawData) = {
    super.store(cat)
    clearCache(cat)
  }

  private def clearCache(cat: RawData): Unit = {
    Cache.makeKey(className + ".get", cat.name)
    Cache.makeKey(className + ".list")

    // ※3
  }

}

※1 - 実際に組むときはFutureで返しましょう。

※2 - 引数の組み合わせが膨大になる物に関しては、offset=0のときだけ、などよく使われるものだけに限定してCacheすると結構効率よいです。

※3 - この消す処理が膨らむ場合はアプローチを変える必要があります。

なおMemcacheの代わりにRedisを使えば、Hash型が使えるので※2も※3ももうすこし融通が利くようになります。 (どちらも利用経験があったが、より手に馴染んでいたMemcacheを選んでしまった)

インスタンスCache 《場合によっては気をつける必要あり》

DDDで実際の実装ではIDだけを引き回して実データは都度Repositoryから取得する、 という場面が結構多くでてきます。(集約の実現あたりとかで。)

何も考えずに挑むと、同一オブジェクトをインフラ層Cacheから何度も取得する、という富豪的な処理になるのですが、 データ量が多くなると遅くなるのでGANMA!では、一度取得したオブジェクトは同一リクエストの間再利用する機構を作り、 オブジェクトの再取得を抑制しています。

具体的な仕組みは以下の通りです。

  1. DI機構をつくる (GANMA!では こちら の記事を参考にさせていただいたものを使っています)
  2. DI機構は1HTTPリクエスト毎に必要になったリポジトリインスタンス化する
  3. リポジトリは一度取得したデータを忘れない。 ただし自分が更新系の処理を行ったらCacheを忘れる。
  4. リクエストが終わったら全部開放

具体的な実装は以下の通りです。

インスタンスCacheの実装例

class InstanceCachedCatsRepository extends CatsRepository {

  import scala.collection._
  import scala.collection.convert.decorateAsScala._

  private object cache {

    val get: concurrent.Map[String, Option[Cat]] = new ConcurrentHashMap[String, Option[Cat]]().asScala
    val list: concurrent.Map[Int, List[Cat]] = new ConcurrentHashMap[Int, List[Cat]].asScala

    def clear(): Unit = {
      get.clear()
      list.clear()
    }
  }

  override def get(catName: String): Option[Cat] =
    cache.get.getOrElseUpdate(catName, super.get(catName))

  override def list(offset: Int = 0): List[Cat] =
    cache.list.getOrElseUpdate(offset, super.list(offset))

  override def store(cat: Cat): Unit = {
    super.store(cat)
    cache.clear()
  }
  
} 

※1 -

DIとコントローラーの実装例

trait CatMomictureServiceDepends {
  implicit lazy val catRepository = new InstanceCachedCatsRepository
}

class DomainInjector extends CatMomictureServiceDepends

class CatMomictureService(implicit depends: CatMomictureServiceDepends) {

  import depends._

  def momi(catName: String): Unit = {
    catRepository.get(catName) foreach doMomicture
  }

  private def doMomicture(cat: Cat) = ???
  
}

class CatsController(implicit injector: DomainInjector = new DomainInjector) extends Controller {

  import injector._
  
  def momi(catName:String) = Action {

    catRepository.get(catName) foreach doSomething

    val service = new CatMomictureService
    service.momi(catName)

    Ok("やったぜ")
  }

インスタンスキャッシュはサーバによる更新が入る前提の処理には向かないため、 ドメイン層として実装し、アプリ層の都合によってCacheの有無を選べるようにしています。

アプリケーション層Cache 《副作用大》

トップページなど誰が見ても内容が固定で、高速に値を返す価値があるものは アプリケーション層だけでレスポンス自体(htmlとかjson)をキャッシュしてしまいます。

Expire処理付きオンメモリCacheの実装は GuavaのCacheBuilderを使うと便利です。

応答速度は最大化されますが、管理ツールなど他のサーバによる更新は(なにも細工をしなければ)Cacheが蒸発するまで反映されないという副作用が発生します。

Akkaのスケジューラを使ってCacheの更新だけは頻繁に行う、といったような小細工を行えば副作用は軽減できるでしょう。

終わりに

ご紹介は以上です。如何でしたでしょうか?

話は変わりまして

弊社はScalaMatsuri 2016の将軍スポンサーを務めさせていただいておりますが 個人としても会社をまるごとScala化する方法 〜Scalaに至るまで物語〜というタイトルで CFP応募をさせて戴いています。

内容は 弊社のScala勉強会 Septeni × Scalaで発表させていただいた内容の豪華版となる予定です。 ご参加なさる方で、もし興味がございましたらご投票いただけるととっても嬉しいです!

GANMA!の求人ももりもりやっておりますので、合わせてよろしくお願い申し上げます。

読んでくださいましてありがとうございました!