はじめに
今回は駆け出しエンジニアとして、バックエンドの開発で躓いたところがあったので共有したいと思います。
私と同じバックエンド初学者向けの記事ではありますが、経験豊富なエンジニアの方々からのFBにも期待して書いておりますので、是非ともコメントお願いします!!
TL;DL
Goを用いたAPIの処理速度の改善
- BulkInsert
- マスターデータをメモリで保持する
前提
バックエンドに関する学習の一環で、ガチャAPIの実装を行っていました。概要は、ガチャを回すことで排出されるキャラクターをユーザごとに保管する簡素なアプリケーションのAPIです。テーブルは以下の通りです。
- ユーザテーブル : ユーザ名など
- キャラクターテーブル : キャラクター名
- ポゼッションテーブル : ユーザとキャラクターの中間テーブル
- エミッションテーブル : キャラクターのレアリティを保存している
それでは、実際にガチャを排出するハンドラーを見てみましょう。
func (gh *GatchaHandler) PlayGatcha(c echo.Context) error {
var req GatchaPlayRequest
//リクエストを受け取る
if err := c.Bind(&req); err != nil {
log.Println("err :", err.Error())
return c.String(http.StatusInternalServerError, "err: Could not bind request struct!")
}
var results []CharacterResponse
var user model.User
//ユーザ情報を受け取る
token := c.Request().Header.Get("X-Token")
if err := gh.db.Where("token=?", token).First(&user).Error; err != nil {
log.Println("err:", err.Error())
return c.String(http.StatusInternalServerError, "err: Could not find user")
}
for i := 0; i < req.Times; i++ {
//1~100のrandomな整数→レアリティ
randInt := rand.Intn(MAX-MIN) + MIN
rarity := utils.NumToRarity(randInt)
//log.Println(rarity)
var emissions []model.Emission
//レアリティからキャラクターID
if err := gh.db.Where("rarity=?", rarity).Find(&emissions).Error; err != nil {
log.Println("err :", err)
return c.String(http.StatusInternalServerError, "err: Could not find emission")
}
lenEmissions := len(emissions)
randEmission := rand.Intn(lenEmissions)
var character model.Character
//キャラクターIDからキャラクター
if err := gh.db.Where("id=?", emissions[randEmission].CaracterID).First(&character).Error; err != nil {
log.Println("err :", err)
return c.String(http.StatusInternalServerError, "err: Could not find character")
}
//userIDとcharacterIDからposessionを抽出
var possession model.Possession
if err := gh.db.Where("user_id=? and character_id=?", user.ID, character.ID).First(&possession).Error; err != nil {
//Possessionsの作成
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Println("new create")
newPossession := model.Possession{
UserID: user.ID,
CharacterID: character.ID,
Quantity: 1,
}
gh.db.Create(&newPossession)
} else {
log.Println("err :", err)
return c.String(http.StatusInternalServerError, "err: Could not find or create possessions")
}
} else {
//Possessionsの更新
newQuantity := possession.Quantity + 1
possession.Quantity = newQuantity
gh.db.Save(&possession)
}
results = append(results, CharacterResponse{CharacterID: strconv.FormatUint(uint64(character.ID), 10), Name: character.Name})
}
return c.JSON(http.StatusOK, GatchaPlayResponse{Results: results})
}
..仕様だけを満たしたコードって感じですよね。
まずは、このコードで行っている処理を簡潔に説明すると以下のようになります。
- リクエストから値を受け取る
- ヘッダーに含まれたユーザ情報を取得し、ユーザIDを獲得
- リクエストに含まれたtimes回分のガチャを実施(以下、一回づつの処理)
- 1~100のランダムの値を取得し、その値によって排出するキャラのレアリティを決定
- キャラクターテーブルから決定されたレアリティに合うキャラを抽出
- その中からランダムな一体をポゼッションテーブルに作成or更新
今回は、何らかのアーキテクチャに沿って開発を行っているわけではないので、リクエストの取得やDB操作まで一つのハンドラー内で行っていることになります。
問題点
実際に、このハンドラーは想定通りの処理は行いますが、ガチャの回数が増えれば増えるほど莫大な時間を要します。タイトルにもある通り、1000回のガチャを行うとレスポンスに10秒かかってしまうポンコツAPIでした。このハンドラーから読み取れる問題点は大きく3つあります (もっとあるかも...) 。
1. 一回づつキャラを保存することで大量のクエリを発行している点
2. レアリティのような滅多に変化することのない同一データを毎回DBから呼び出して参照している点
3. ポゼッションテーブルのquantityがsqlアンチパターンに該当する点
今回は、主に処理速度に関係する(1),(2)の改善をした話をします。
問題点(1)-BulkInsertの実装
BulkInsertはテーブルに行を追加する処理を一回のSQL文の実行で行うことです。GoのORMであるgormではBulkInsertが簡単に行えるようになっているので、それを活用したいと思います。以下はgormでのBulkInsertの実行例になります。
// BulkInsertのデータを作成
users := []User{
{Name: "John"},
{Name: "Jane"},
{Name: "Doe"},
}
// バルクインサートの実行
result := db.Create(&users)
つまり、排出キャラクターが決まる度にポゼッションテーブルに保存するのではなく、リストに追加して一気に追加することで実装できます。
問題点(2)-メモリの活用
キャラクターのレアリティは滅多に変更されないことが想定できます。このようなマスターデータをDB起動時に一度だけ抽出してメモリに保管しておくことで、処理速度の改善が見込めます。そのため、以下のようにレアリティごとに分類されたキャラクターIDを格納する辞書を用意し、DB起動時にそれらをメモリに保管しておきます。
//main.goから抜粋
var emissions []model.Emission
if err := db.Find(&emissions).Error; err != nil {
log.Fatalln("err :", err)
}
//rarity別にキャラクターIDを格納するmap
//contextにこれを格納してhandlerで使用する.
emissionsByRarity := make(types.EmissionsByRarity)
for _, v := range emissions {
emissionsByRarity[v.Rarity] = append(emissionsByRarity[v.Rarity], v.CaracterID)
}
最終的なコードと処理速度
上記の問題点を改善した結果、ガチャを1000回実行するのにかかる処理時間は10秒から1秒にすることができました。以下に改善後のコードを示します。
func (gh *GatchaHandler) PlayGatcha(c echo.Context) error {
var req GatchaPlayRequest
if err := c.Bind(&req); err != nil {
log.Println("err :", err.Error())
return c.String(http.StatusInternalServerError, "err: Could not bind request struct!")
}
var results []CharacterResponse
//header tokenからuser情報の取得はmiddlewareにしとくといいかも
var user model.User
token := c.Request().Header.Get("X-Token")
if err := gh.db.Where("token=?", token).First(&user).Error; err != nil {
log.Println("err:", err.Error())
return c.String(http.StatusInternalServerError, "err: Could not find user")
}
var possessions []model.Possession
emissions := c.Get("emissions").(types.EmissionsByRarity)
for i := 0; i < req.Times; i++ {
//1~100のrandomな整数→レアリティ
randInt := rand.Intn(MAX-MIN) + MIN
rarity := utils.NumToRarity(randInt)
//レアリティからキャラクターID
lenEmissions := len(emissions[rarity])
randEmission := rand.Intn(lenEmissions)
var character model.Character
//キャラクターIDからキャラクター
if err := gh.db.Where("id=?", emissions[rarity][randEmission]).First(&character).Error; err != nil {
log.Println("err :", err)
return c.String(http.StatusInternalServerError, "err: Could not find character")
}
possessions = append(possessions, model.Possession{UserID: user.ID, CharacterID: character.ID})
//userIDとcharacterIDからposessionから抽出
results = append(results, CharacterResponse{CharacterID: strconv.FormatUint(uint64(character.ID), 10), Name: character.Name})
}
if err := gh.db.Create(&possessions).Error; err != nil {
log.Println("err:", err)
return c.String(http.StatusInternalServerError, "err: Could not record possessions")
}
return c.JSON(http.StatusOK, GatchaPlayResponse{Results: results})
}