pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

ピクシブ社内広告サーバーに新機能を追加するためにボクがやったこと

この記事は ピクシブ株式会社 Advent Calendar 2015 15日目の記事です。

qiita.com


インフラチームの @catatsuy です。

普段はインフラの仕事が中心ですが、広告サーバーの開発にも関わっています。今回は少し前にリリースされた広告サーバーの新機能について、私が実装した配信サーバーを中心に解説したいと思います。

広告サーバーの実装については何回か発表を行いました。以下のスライドをご覧ください。

ピクシブ広告サーバー開発・運用の軌跡 2015春インターン講義資料 // Speaker Deck

ピクシブ社内広告サーバーでのGoの開発・運用 #gocon /p_ads_server_gocon2015 // Speaker Deck

タグ指定で特定の広告を出したい

ピクシブではユーザーの皆様に最適な広告をどうすれば出せるのか、日々テストをしています。その一環としてpixivに投稿されたイラストについているタグや検索フォームのタグで広告の出し分けをするとどうなるかを調査しました。例えば『イカ』というタグがついている作品なら『イカ焼きの広告』を出すというようなテストです。

そのテストでは広告とタグの相性が良い場合、非常に高いCTRやコンバージョン率が得られることが分かりました。特定のタグがついたイラストにしか配信しないため、全体のインプレッション数というのは他の広告枠に比べてかなり少ないものになります。そのため外部の会社などに販売することは難しいのですが、少ないインプレッション数で社内の他のサービスの活性化など様々な場面で利用できそうという話になり、広告サーバーにタグを指定した広告を配信する機能を追加することになりました。

キーワード機能の開発

社内的には『タグ指定配信』をする機能ですが、社内の広告サーバーはpixivからは独立したサービスとなっており、pixivとはデータベースなどのリソースを共有していません。広告サーバーではイラストにつけられたタグかどうかは判別できないため、特定のキーワードが渡されたら特定の広告を表示する機能が必要になります。そのため『キーワード機能』という名前で開発がスタートしました。

キーワードが覆す前提

今までの自社広告サーバーは表示する広告枠と属性で表示する広告を決めていました。そのため広告枠と属性をキーにした値が必ず存在することを前提にしていました。しかしキーワードの場合は該当する広告が存在するのはわずかで、ほとんどの場合はヒットしません。

今まではpatrickmn/go-cacheを使ってmemcachedの結果をキャッシュすることでmemcachedへのアクセスを減らして現実的な速度で動かしていました。しかしキーワードのように量が多くかつ、ほとんどがヒットしないものはネガティブキャッシュを持つ必要が出てしまいます。memcachedへのアクセスも大きく増えますし、とても現実速度で動かすことができません。

cacheFetcherについて

今まではユーザーのリクエストを受け取ってからキャッシュがあるかexpireしているか確認し、expireしていなければキャッシュの値を利用。キャッシュが無いもしくはexpireしていればmemcachedにアクセスをするという実装でした。新形式では事前に必要なデータをキャッシュとして持っておき、リクエストとは別のスレッドでmemcachedにアクセスして常にキャッシュを更新し続ける実装にしました。cacheをfetchし続けるのでcacheFetcherという関数名を使って実装しました。

f:id:catatsuy:20151213223050p:plain

言語によってはこのような実装はかなり難しいと思いますが、Go言語ではgoroutineを使って簡単に実装できます。実際に広告配信サーバーで使っている実装から今回のcacheをfetchする部分とサーバー部分を切り出してサンプルコードにしました。興味のある方はぜひ参考にしてください。

cache_fetcher/server.go at master · catatsuy/cache_fetcher

実行したときのデモをYoutubeに上げておきました。cacheをfetchするのに2秒かかり、10秒ごとにcacheをfetchしてきます。分かりやすいように0.2秒ごとに数字が出力されています。SIGTERMを送ると5秒後に終了します。SIGTERMのことはデプロイのところで説明します。

またcacheの実装も今回はexpireが必要ないものだったので自前で実装しました。Goのドキュメント(Frequently Asked Questions (FAQ) - The Go Programming Language)にも記載がありますが、Goのmapはgoroutineで複数のスレッドからアクセスされたときの挙動が保証されていません。なのでgo-cacheのように書き込み時に読み込みができない、または読み込み時に書き込みができないようにロックを取る必要があります。ためしに作ったので以下を参考にしてください。実際の広告サーバーではinterfaceではなく特定の構造体のキャッシュを保存できるように作りました。

gocache/gocache.go at master · catatsuy/gocache

cacheFetcher化によって煩雑になるのはデプロイです。cacheFetcher関数の実行が終わるまでユーザーのリクエストを受け付けることができないので、適当にデプロイをするとユーザーのリクエストを受け付けられないタイミングが発生してしまいます。実装を見てもらえればcacheFetcher関数の実行が終わってからリクエストを受け付けていることがわかります。この実装ではcacheFetcher関数が終わるまでのダウンタイムが発生してしまいます。これは広告サーバーとしては許容できない問題のため、デプロイ側に対策を施しました。

デプロイ

広告配信サーバーはCircusを使ってダウンタイムのないデプロイを行っています。まずCircusのデプロイについて解説します。

Circusがソケットファイルを作成し、GoのアプリケーションはCircusが作ったソケットファイルのファイルディスクリプタをListenすることで複数のアプリケーションを起動できるようにしています。circusctl reloadを実行することでCircusがもう1つアプリケーションを起動して、古いアプリケーションにはSIGTERMを送って正常終了させます。この間にソケットファイルが消えることはないのでユーザーのリクエストを落とすことなく処理出来るというのがCircusのデプロイです。

この方法は今までGoのアプリケーションはすぐに起動してリクエストを受け付けていたので問題ありませんでしたが、cacheFetcherを使うことですぐにリクエストを受け付けられなくなります。まだリクエストを受け付けられない段階で古いアプリケーションのプロセスが死んでしまうとリクエストが受けられなくなり、ユーザーはTimeoutまで待ってしまいます。それでは困ります。古いアプリケーションにはしばらく死なないで欲しいです。

そこで上で紹介したサンプルコードにはすでに入っていますが、SIGTERMが送られてもすぐには死なず、5秒間待つようにしています。こうすることで数秒間古いアプリケーションと新しいアプリケーションが共存するようになります。このように複数のプロセスが1つのソケットファイルのファイルディスクリプタを持っている場合はLinuxカーネルがランダムにリクエストを割り振ります。新旧どちらのアプリケーションでも同一のリクエストを受け付けられるように当然しているので問題なく処理できます。

f:id:catatsuy:20151213224311p:plain

f:id:catatsuy:20151213224314p:plain

この変更でアプリケーションが複数起動する状態が数秒間続くようになったのでログの書き込みの挙動が心配と思った人もいると思います。次はログの書き込みについて解説します。

ksk.logへの書き込み

広告配信サーバーではインプレッションやクリックが発生する度にksk.log(計測ログ)と呼ばれるログに広告のIDなどを保存しています。このksk.logからインプレッション数などの集計を行っているので、書き込み時にすでに書き込んだログを他のプロセスが一部だけ上書きしてしまったりすると広告サーバーとしての信頼性がなくなってしまいます。

ここでGoのアプリケーションからksk.logにはどういうアクセスモードが必要なのか考えてみましょう。

  • ksk.logがなければ作り、すでにあるならそのファイルに書き込む
  • 書き込みだけできればよい
    • 読み込みはfluentdから行っているため
  • 書き込みも追記をするだけで、上書きはしない

f:id:catatsuy:20151213222958p:plain

以上の仕様はアクセスモードをO_WRONLY | O_APPEND | O_CREATEにすることで実現できます。C言語などではアクセスモードはそれぞれの定数がフラグになっていてORでつなぐことで表現します。それはGo言語でも同一です。ただしGo言語の場合はosパッケージに含まれる定数なので呼び出すときはos.O_WRONLY|os.O_APPEND|os.O_CREATEと指定する必要があります。

この辺りのことは以下の『LINUXシステムプラグラミング』という本が非常に参考になりました。他にも参考になる情報が多く載っているため大変おすすめな本です。

Linuxシステムプログラミング

Linuxシステムプログラミング

キーワードをどう渡すのか

今回キーワードとしてイラストに付いているタグを広告サーバーにGETクエリで渡します。pixivについているタグはほとんどが日本語で、それもひらがな・カタカナが多いです。ひらがな・カタカナはUTF-8だと1文字3バイトです。URLに含めるのでURLエンコードを行う必要もあるので、最終的にひらがな・カタカナ1文字を表すのにURLで9文字必要になります。pixivのイラストにはタグを10個までつけることができます。そもそもURLはどれくらいの長さまで許容出来るのでしょうか?

どうやらIEでは最大で2,083文字までしか使用できないようです。今後の機能拡張なども考えるとイラストについているタグをそのままGETクエリにつけるのは無理と判断しました。

そこでMD5を使ってハッシュ化をして、その上位の決められた桁数だけを送るような仕様にできないか考えました。ハッシュ化する場合、気にしなければならないのは衝突です。ハッシュの衝突について考えていきたいと思います。

ハッシュの衝突確率

何人集まればその中に同じ誕生日の人がいる確率が50%を超えるでしょうか? 答えは23人という直感よりもかなり少ない人数です。このことは『誕生日のパラドックス』と呼ばれています。Wikipediaの誕生日のパラドックス - Wikipediaが詳しいので気になる方は参照してください。

誕生日のパラドックスと全く同じ理由で、ハッシュ関数によるハッシュ値が衝突する確率は直感よりも高いです。では実際にイラストにつけられたタグのMD5値を取ったときに衝突する確率はいくつか計算してみましょう。タグが610万個あったとして上位44bitの場合以下のようなRubyのプログラムで確率を計算することができます。

N = 2**44
r = 1.0

(1..6_100_000).each do |i|
  r *= ((N.to_f - i + 1) / N)
end

p 1 - r

44bitの場合は約65%の確率で同じハッシュ値を持つタグが存在します。48bitの場合は約6.4%です。

確率だけはなく、実際にどの程度衝突するのかpixivのイラストにつけられたタグを使って調べました。調べるのはpixivのイラストにつけられたタグを改行区切りで列挙したファイルを用意して、以下のようなプログラムを雑に書いて調べました。

package main

import (
    "bufio"
    "crypto/md5"
    "fmt"
    "os"
    "time"

    goCache "github.com/pmylund/go-cache"
)

func main() {
    fp, err := os.Open(os.Args[1])
    if err != nil {
        panic(err)
    }
    defer fp.Close()

    c := goCache.New(5*time.Minute, 30*time.Second)

    var key string
    scanner := bufio.NewScanner(fp)
    for scanner.Scan() {
        key = fmt.Sprintf("%x", md5.Sum(scanner.Bytes()))[0:12]
        value, found := c.Get(key)
        if found {
            fmt.Println(value, key, scanner.Text())
        } else {
            c.Set(fmt.Sprintf("%x", md5.Sum(scanner.Bytes()))[0:12], scanner.Text(), -1)
        }
    }
}

以下がその結果です。MD5の返り値は16進数であることに気をつけてください。

上位44bitでぶつかったもの(抜粋)

MD5値 タグ タグ
a8eb23f486 だといいなー!(棒) かっこインテグラ
c6625e93a5 未熟な竜のオペレッタ 横山三国志のおもしろさは至高
ed651235f5 痛タンブラー用イラスト ミリ・ヨークス
981a1f8b29 織田信秀 永遠の夫婦
338ef3cd0d ホーンテッド•マンション いぬぼく500users入り

この表にあるものを合わせて、全部で19対衝突しました。

上位48bitでぶつかったもの

MD5値 タグ タグ
338ef3cd0d4 ホーンテッド•マンション いぬぼく500users入り

上位52bit以上で衝突したものはありませんでした。もちろん長ければ長いほど衝突の確率は下がるのでギリギリにするのはよくありません。今回は少し余裕をもった60bitにしました。60bitで700万個のタグがあった場合に衝突する確率は約0.002%なので滅多に衝突しないだろうと考えています。

PHPとGoのURLの取り扱いについて

pixivから広告を呼び出すときのURLを生成する必要があります。URLの生成はURLエンコードをする必要があり、PHPで作られているpixivでは組み込み関数のhttp_build_query関数を使っています。http_build_query関数は配列を渡すといい感じのURLを生成してくれるので便利なのですが、http_build_query関数には配列の挙動に問題がありました。以下にhttp_build_query関数の出力結果をいくつか例示します。

<?php
http_build_query(['key' => 'value']); // key=value
http_build_query(['key' => ['a', 'b', 'c', 'd']]); // key%5B0%5D=a&key%5B1%5D=b&key%5B2%5D=c&key%5B3%5D=d
urldecode(http_build_query(['key' => ['a', 'b', 'c', 'd']])); // key[0]=a&key[1]=b&key[2]=c&key[3]=d

PHPでGETクエリで同一のキー名で値を複数受け取る場合はURLでkey[]=a&key[]=b&key[]=c&key[]=dのように最後に[]と付ける必要があります。このようにすれば$_GET['key']が配列で返ってくるようになります。本来http_build_query(['key' => ['a', 'b', 'c', 'd']]));の出力結果はこの形で返して欲しいところですが、残念ながらそうはなりません(配列と連想配列を明示的に書き分けられないPHP特有の問題とも言うべきかもしれません)。

今回キーワードとして複数の値を統一的に扱いたいのでGoではスライス(Goを知らない人は配列と読み替えてください)として受け取りたいです。GoはPHPと違いどんなキー名でもスライスで受け取ることができます。詳しくはurl.Valuesのドキュメントを見て欲しいのですが、キーを1つしか渡さない前提であればGetを、キーをスライスとして受け取る場合は直接mapとして呼び出すことでスライスとして使用することができます。

なのでGo側でGETクエリで渡されたキーワードをスライスとして扱うには同じキー名でキーワードを渡せば良いということになります。しかしすでに説明したようにPHPのhttp_build_query関数ではこのようなURLを生成することが出来ません。そこで現在の社内のコーディング規約からは逸脱してしまいますが、rawurlencode関数を使って値をエスケープしながら文字列結合でURLを生成することにしました。

まとめ

タグ指定で広告を配信したいという話になったときは、どうすれば実装できるのか悩みました。しかし1つ1つ検証しながら問題を解決していくことで、最終的に少ない実装量で対応することができました。今回の記事に参考になるところがあれば幸いです。

ピクシブ株式会社ではpixivをはじめ様々なサービスを開発・運営しています。自社サービスをユーザーのみなさんと一緒に盛り上げていく広告サーバーの開発に興味のあるエンジニアの方を大歓迎します!

recruit.pixiv.net

明日は、新卒の@FromAtomさんのターンです。ご期待ください!