Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

DELISH KITCHENにおけるマスタデータキャッシュ戦略とその歴史的変遷

DELISH KITCHENにおけるマスタデータキャッシュ戦略とその歴史的変遷

DELISH KITCHENで扱うデータには、大別してレシピデータなどの主に入稿によってしか変動しないものと、お気に入りなどユーザの行動によって変動するものとが存在しています。
両者を比較すると、マスタデータの方は変動性が低い傾向があるため、キャシュについても異なる戦略が必要となります。
DELISH KITCHENのリリース当初から現在に至るまでのキャッシュ戦略の変遷や、その際どのような試行錯誤を経てきたか、また今後の展望について、Goの実装を交えてお話します。

Akira Uchihara

June 08, 2024
Tweet

Other Decks in Programming

Transcript

  1. 2 Copyright © 2015 every, Inc. All rights reserved. 目次

    1. 自己紹介 2. DELISH KITCHENの紹介 3. DELISH KITCHENにおけるデータ種別とキャッシュの考え方 4. 第一次キャッシュ戦略(2016〜2019あたり) 5. 第二次キャッシュ戦略(2020あたり) 6. 次世代キャッシュ戦略検討における試行錯誤(2023あたり) 7. 第三次キャッシュ戦略(2023) 8. 今後のキャッシュ戦略構想(2024〜) 9. まとめ 10. 宣伝 がっつりGoのお話というより、 DELISH KITCHENにおけるシステム開発の歴史+Goでの実装解説 みたいな感じです
  2. 3 Copyright © 2015 every, Inc. All rights reserved. 自己紹介

    • 内原 章 • 経歴 • 1996〜 • パソコン通信サービス( Nifty-Serve) • 2004〜 ヤフー • 無料ホームページサービス( Geocities) • 社内向けプラットフォーム基盤の開発、運用 • 2010〜 頓智ドット • ARサービス(セカイカメラ)のバックエンド開発、運用 • その後CTOに就任 • 2017〜 エブリー • DELISH KITCHENのバックエンド開発、運用 • インフラ基盤、プッシュ通知基盤、プレミアムサービス基盤など • 現在TIMELINE開発部部長、DELISH KITCHEN開発部副部長 • 趣味 • 文字コードに関する話 • 歴史モノ • 世界史、生物史、人類 史、宇宙史、言語史な ど もののけ姫におけるア シタカは蝦夷の一族で あり史実を踏まえると 室町時代中期の東北 地方から云々
  3. 5 Copyright © 2015 every, Inc. All rights reserved. レシピ動画サービス『

    DELISH KITCHEN』 ▪ 日本最大級のレシピ動画サービス ▪ 「誰でも簡単においしく作れるレシピ動画」毎日配信 ▪ 全レシピ、管理栄養士が監修!蓄積データによるオリジナルレシピを考案 ▪ SNS/APP/WEBを通じて延べ50,000本以上のレシピ動画を提供 レシピ提案 / レシピ検索 / 献立機能 買い物リスト / チラシ / 店頭ビーコン連動 調理手順動画 /キッチンモード / 作った・レビュー / お気に入り 菅原千遥  DELISH KITCHEN カンパニー長 料理研究家 斎藤 香織 副編集長 管理栄養士 • 30名以上の食のプロが、蓄積 データに基づきオリジナル レシピを日々考案 • 月間1,500本以上の動画配信 サービス・機能 レシピ検討 買い物 料理中・料理後 専門家によるレシピ考案・監修
  4. 6 Copyright © 2015 every, Inc. All rights reserved. DELISH

    KITCHENのこれまでの成長 総ユーザー数 3500万人以上
 DELISH KITCHEN リリース 
 Go v1.7.1

  5. 8 Copyright © 2015 every, Inc. All rights reserved. DELISH

    KITCHENのシステム概要 • DELISH KITCHENは複数の機能を提供している • 基本機能 • 課金機能 • 特売情報機能 • サイネージ機能 • etc • 今回のトピックは基本機能のシステムのお話 • レシピを動画で閲覧 • おすすめレシピ • お気に入り管理 • 特集、カテゴリ • 認証 • etc
  6. 9 Copyright © 2015 every, Inc. All rights reserved. DELISH

    KITCHENにおけるデータの種別 マスタデータはなるべく積極的かつ長期的にキャッシュしておきたい データ種別 説明 変化タイミング 更新頻度 レコード数 トランザクション データ ユーザ情報、お気に入りな ど ユーザの行動 高 多 マスタデータ レシピ、材料、カテゴリ、タ グなど 管理ツールによる入稿作業 低 少
  7. 11 Copyright © 2015 every, Inc. All rights reserved. 第一次キャッシュ戦略

    • 全テーブル、全レコードを一括してメモリ上に格納 • なので、キャッシュというよりオンメモリストレージというか • データ更新遅延を避けるため定期的に再読み込み処理を行う
  8. 12 Copyright © 2015 every, Inc. All rights reserved. 実装

    // cache全体の初期化処理 func (c *cache) Reset() { for { c.recipeCache.Reset() … time.Sleep(1 * time.Minute) } } // recipe cacheの初期化処理 func (c *recipeCache) Reset() { recipes, err := sql.Recipe.FindAll() c.mutex.Lock() defer c.mutex.Unlock() … c.recipeMap := c.mapping(recipes) } // recipe cacheからidで参照して返却 func (s *recipeService) Get(id int) (*model.Recipe, error) { recipe, err := cache.Recipe.Find(id) … return recipe, nil }
  9. 14 Copyright © 2015 every, Inc. All rights reserved. 問題点

    その1 • ロジックにおいてデータがキャッシュ上に存在する前提になっており、データ格納場所を意識 したコードになってしまっている 単純に綺麗じゃない 本来ロジックとデータの格納場所とは無関係なはず
  10. 15 Copyright © 2015 every, Inc. All rights reserved. 問題点

    その2 • 同じデータ構造であっても、状況によってキャッシュしたいケースとしたくないケースがある • 管理ツール上ではマスタデータはキャッシュする必要がないとか • ロジックを流用できなくなる • アプリで使っているロジックを管理ツールでも使いたいが、データ格納場所に依存するコードになっ ているので流用できない 類似コードを別々に実装しなければならない
  11. 16 Copyright © 2015 every, Inc. All rights reserved. 実装

    // アプリ向けのレシピ参照処理 (cacheから読み込み) func (s *recipeService) Get(id int) (*model.Recipe, error) { recipe, err := cache.Recipe.Find(id) … } … // 管理ツール向けのレシピ参照処理 (rdbから読み込み) func (s *recipeAdminService) Get(id int) (*model.Recipe, error) { recipe, err := sql.Recipe.Find(id) … }
  12. 17 Copyright © 2015 every, Inc. All rights reserved. 問題点

    その3 • テスト実装時にデータ作成が面倒 • DBデータとキャッシュ両方投入しなければならない
  13. 18 Copyright © 2015 every, Inc. All rights reserved. 実装

    func TestRecipeGet(t *testing.T) { // DBにテストデータを投入 sql.Recipe.Create(&recipeModel) // DBからキャッシュに読み込み cache.Recipe.Reset() recipe, err := recipeService.Get(id) … }
  14. 20 Copyright © 2015 every, Inc. All rights reserved. 第二次キャッシュ戦略

    • テーブル単位ではなくレコード単位でキャッシュ • DBの前段にキャッシュを配置 • 有効期限あり • キャッシュ格納ストレージを選択可能 要するに普通のキャッシュに立ち帰りたい
  15. 21 Copyright © 2015 every, Inc. All rights reserved. 実装

    package repository type Recipe interface { Find(id int) (*model.Recipe, error) } … // RDB経由でメモリ上にcacheされるrepositoryを保持 func NewRecipeService() RecipeService { return &recipeService{ recipeRepo: memorycache.NewRecipe(sql.NewRecipe()), } } // 抽象化されたrepositoryからrecipeを返却 func (s *recipeService) Get(id int) (*model.Recipe, error) { recipe, err := s.recipeRepo.Find(id) … return recipe, nil }
  16. 22 Copyright © 2015 every, Inc. All rights reserved. 実装

    func TestRecipeGet(t *testing.T) { s := &recipeService{ recipeRepo: recipeMock, // mock経由でデータを返却する場合 // recipeRepo : sql.NewRecipe(), // RDB経由でデータを返却する場合 } recipe, err := s.recipeRepo.Find(id) … }
  17. 23 Copyright © 2015 every, Inc. All rights reserved. 第二次キャッシュ戦略のその後

    • テーブル単位で実装を切り替えていたが、この時点では20/150とかそんな感じの進捗 • キャッシュ効率を考えてリモートキャッシュサーバ(Redisとか)に移行したいが、転送量の問 題もあり一気に進めるのは怖いので慎重に • キャッシュ移行専任の作業担当者を置いていたが、作業が単調すぎてこのまま続けるのは 辛いということでいったん別のタスクにスイッチしたため、改修作業は保留 • その後担当者が退職するなどでそのままコードフリーズ状態に
  18. 25 Copyright © 2015 every, Inc. All rights reserved. 問題点

    その4 サービス成長に伴いデータ量アクセス数が増加し以下の問題が無視できなくなってくる • 起動時に一括で読み込みするので立ち上げに時間がかかる • 特にローカルで起動するたびに時間がかかるのが辛い • RDBへの負荷増加 • スケールアウトによりタスク数が増えた場合定期的なRDB読み込みで負荷が高まる • RDBへの負荷を減らす目的でキャッシュしているはずなのに・・・ • CPU負荷増加 • 構造体への変換時やGCでCPU時間を消費
  19. 27 Copyright © 2015 every, Inc. All rights reserved. 次世代キャッシュ戦略構想における試行錯誤

    • Hazelcast (In Memory Database) • MySQL binlogによる同期機構があるので移行コストが低そうに思えたが • Cloudだとコストが高い、オンプレだと運用が辛い • リモートキャッシュとローカルキャッシュの多段構成 • 多段にしたところでメモリ効率はそこまで向上しなさそう • Pyroscopeを用いた負荷計測&地道な改善
  20. 28 Copyright © 2015 every, Inc. All rights reserved. キャッシュ処理のCPU負荷軽減

    • キャッシュ構築時にgocraft/dbrがCPUを消費している箇所があった • dbr内部でmodel用の構造体にマッピングする部分で、カラムのデータ構造と構造体メンバ 変数の関係性のためreflectionしている箇所で重くなっていた • またオブジェクトが頻繁に生成←→破棄されることでGC時の負荷が増えていた • 対応策 • model別にハードコードされたGo実装を自動生成 • sync.Pool を用いたオブジェクトの再利用
  21. 29 Copyright © 2015 every, Inc. All rights reserved. 実装

    import ( "sync” "github.com/jmoiron/sqlx" ) var poolRecipe = &sync.Pool{ New: func() interface{} { return &model.Recipe{} }, } func ScanRecipe(scanner sqlx.ColScanner, columns []string, ptr []interface{}) (model.Recipe, error) { v := poolRecipe.Get().(*model.Recipe) defer poolRecipe.Put(v) for i := 0; i < len(columns); i++ { switch columns[i] { case "id": ptr[i] = &v.ID … } } return *v, scanner.Scan(ptr...) }
  22. 30 Copyright © 2015 every, Inc. All rights reserved. キャッシュ処理のCPU負荷軽減

    (全てのmodelを置換したら)CPU負荷が半減 変更前 変更後 2.76 seconds 1.27 seconds
  23. 32 Copyright © 2015 every, Inc. All rights reserved. 第三次キャッシュ戦略

    • Remote Cache Loaderの導入 • 定期的にキャッシュをキャッシュサーバに読み込むタスク • キャッシュ読み込み処理を一元管理 • 多数のタスクが一斉にキャッシュを構築する無駄をなくし、負荷のコントロールをしたかった • キャッシュ圧縮の実装 • キャッシュ時にmsgpack+gzipで圧縮←→展開 • キャッシュ不整合の回避 • model構造が変化した場合に旧データ構造がキャッシュされているとおかしなことになる • 当初commit idをキャッシュキーにして不整合を回避していたが、デプロイ時にキャッシュ領域が倍 になる問題があった • model構造をメタ情報としてハッシュ化してキャッシュキーに用いることで効率性が向上
  24. 33 Copyright © 2015 every, Inc. All rights reserved. 実装(圧縮)

    import ( “compress/gzip" "github.com/ugorji/go/codec" ) var mh = codec.MsgpackHandle{RawToString: true} func DecodeGZipValue[T any](value []byte) (T, error) { var res T body := bytes.NewBuffer(value) r, err := gzip.NewReader(body) … err = codec.NewDecoder(r, &mh).Decode(&res) … return res, nil }
  25. 34 Copyright © 2015 every, Inc. All rights reserved. 実装(キャッシュキー)

    // mから "Recipe{ID:int64;Title:string;Attention:*string}" のようなデータを返却 func SerializeTypeInfo(m interface{}) (bytes []byte) { … } func HashTypeInfo(m interface{}) string { digest := sha1.Sum(SerializeTypeInfo(m)) return hex.EncodeToString(digest[:]) } var recipeCacheKey = "recipe:" + HashTypeInfo(model.Recipe{})
  26. 35 Copyright © 2015 every, Inc. All rights reserved. 第三次キャッシュ戦略のその後

    • キャッシュサーバとの転送量がえらいことになり一部対応のみで中断 • 現状の実装ではデータ取得をカジュアルにやりすぎているため • 本来はEager Loadingを考慮した読み込み&キャッシュが必要
  27. 36 Copyright © 2015 every, Inc. All rights reserved. 実装

    func (s *recipeService) Search(query string) (model.Recipes, error) { recipes, err := s.recipeRepo.Search(query) … for _, recipe := range recipes { // N+1になってしまっているが、オンメモリなら問題が顕在化しない ingredients, err := s.recipeIngredientRepo.FindByRecipeID(recipe.ID) … } }
  28. 38 Copyright © 2015 every, Inc. All rights reserved. 今後の構想など

    • オンメモリキャッシュはそのままに、差分更新するアプローチ? • レコード単位ではなくアプリケーションにおける基本データ構造単位でキャッシュ? • recipe serviceのようにデータ構造単位でmicro service化して責務を分担?
  29. 41 Copyright © 2015 every, Inc. All rights reserved. まとめ

    • たかがキャッシュ、されどキャッシュ • 実装の大部分はデータ構造によって定められる。キャッシュと言えど例外ではない • 急いでいるからといって雑に実装すると後々自分の首を絞めることになる • 早め早めの対処が重要
  30. 42 Copyright © 2015 every, Inc. All rights reserved. エブリーからのお知らせ

    Go Conference 2024スポンサー4社共催
 アフターイベント開催します🎉 🔍 エブリー オウンドメディア 🔍 エブリー テックブログ ・Go Conference 2024 で高まった Go の余熱を分かち合いたい Gopher ・Go Conference 2024 に参加できなかったけど、カンファレンスの 雰囲気を感じたい Gopher ・Go / Gopher コミュニティが大好きな Gopher やエンジニア 🔍 connpass ANDPAD こんな人におすすめ!!