Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Gunosy広告配信を支えるGo ~キャッシュ編~

広告技術部の今川です。 わたしは主にGoを使って広告配信APIの改修を担当しています。 今回はAPIの高速化のためにキャッシュを使った話をご紹介します。

データベースへのアクセス頻度を減らすための努力

広告技術部では、広告配信APIのCTRの予測で利用する特徴ベクトルをAmazon Auroraに保存しています。 しかし、単純にデータ取得時に毎回Auroraに問い合わせていては処理速度が遅くなってしまうので、以下の二種類の方法で高速化を図っています。

1. キャッシュする

予測ロジックで利用するデータの中でも、ユーザーに関するベクトルはユーザーIDをキーにユーザー数だけのデータが存在する反面、すべてのユーザーが同じ時間帯にアクセスするとは限らず、一度訪問したユーザーが続けて何回もアクセスする場合が多いという特徴があります。

このため、利用したいデータがキャッシュに無ければAuroraに問い合わせ、取得済みデータは一定期間キャッシュする方針を採りました。 また、最初にmemcacheにキャッシュした後、APIサーバーのメモリにも(go-cacheを使用して)キャッシュするという二段階キャッシュを行っています。

f:id:imagawa_yakata:20171024170001p:plain

こうすることで、ひとつのサーバーが同一ユーザーのリクエストを続けて受ける場合はインメモリキャッシュが効くだけでなく、複数のサーバーが同じユーザーに対するリクエストを受けてもmemcacheにデータがあるのでAuroraに問い合わせる頻度を減らすことができます。

2. 起動時にすべてのデータを読み込んで定期更新する

広告のベクトルは比較的データ量が少なく、ユーザーをまたいで同じデータに頻繁にアクセスするため、キャッシュではなく起動時にすべてのデータを読み込んでしまう方針を採りました。 但し、データは一定間隔で最新状態に更新されるので、サーバー起動後に定期的にデータを入れ替える必要があります。

実装はシンプルにGoのmapとsync.RWMutexを使って行っています。(以下イメージ)

  • 予測ロジックのリクエストが来たら、RWMutexのRLock/RUnlockでロックを取って広告ベクトルを取得する。(この間、他のgoroutineは広告ベクトルの読み取りは可能だが、書き込みができない)
type AdVector struct {
    db      *sql.DB
    data    map[int32]string
    mu      sync.RWMutex
}

func (adv *AdVector) Find(id int32) (string, error) {
    adv.mu.RLock()
    defer adv.mu.RUnlock()
    if s, ok := adv.data[id]; ok {
        return s, nil
    } else {
        return "", fmt.Errorf("not found id=%d", id)
    }
}
  • メインルーチンとは別に自動更新用のgoroutineを動かし、一定間隔で最新データを取得しmapを入れ替える。この場合はRWMutexのLock/Unlockでロックを取る。(入れ替え中、他のgoroutineは読み取りも書き込みもできない)
// このメソッドは別のgoroutineで動かす
func (adv *AdVector) AutoUpdate() {
    ticker := time.NewTicker(30 * time.Second)
    for {
        select {
        case <-ticker.C:
            // エラーが起きたら無視して稼働し続ける
            if err := adv.Load(); err != nil {
                log.Printf("load error %v", err)
            }
        }
    }
}

func (adv *AdVector) Load() error {
    m, err := selectVector(adv.db) // Auroraからデータ取得
    if err != nil {
        return err
    }

    adv.mu.Lock()
    adv.data = m
    adv.mu.Unlock()
    return nil
}

これで、データの入れ替え中だけ予測ロジックのデータアクセスを待ち、ロック待ち時間を抑えられました。

データの特性を考慮した設計が必要

高速な広告配信を実現するために、Gunosyの広告配信APIでは上記のキャッシュ戦略を取っています。

しかし、単にキャッシュすれば速くなるわけではなく、データの特性を考慮せずキャッシュを利用するとむしろ遅くなったりメモリを圧迫するボトルネックになります。

例えば、1の方針を長期間アクセスし続けるデータに適用すれば、キャッシュからデータを消せず次第にキャッシュの容量が不足してきます。これはキャッシュの生存期間を必要以上に長く設定した場合でも同じです。

キャッシュはAPIを高速化するために役立ちますが、同時に別の難しい問題をもたらすのでデータの特性に合わせた設計が必要です。

最後に、Gunosy広告技術部ではパフォーマンスチューニングの腕に自信のある人を募集しています。自分ならもっと速くする方法を知っている・速くしたいという方、ぜひ応募お願いいたします。

hrmos.co

参考リンク

qiita.com