Help us understand the problem. What is going on with this article?

タイムラインへ一斉通知するたびにサーバーが落ちかけるのでチューニングした話

経緯

toC向けのサービス(Railsで動いてる)を扱っていて、太古から存在するユーザーさんへの一斉通知のロジックがひどかったので、改修をすることになりました。
今回はその際の対応をまとめました。

状況の前提

前提として、下記のような感じ。

  • イベントなどで特定のユーザさんに対してタイムラインにお知らせを流す
  • お知らせ対象者の数だけTimelineテーブルにINSERT処理が走る
  • お知らせ対象はだいたい数万~数十万人規模
  • INSERT対象のDBはメインのAPIサーバーが繋いでいるものと同じ
  • お知らせ処理はメインのAPIサーバーとは別サーバーからsidekiqで非同期処理している
  • DBへのINSERTの繰り返し処理はfind_in_batchesの中でbulk insertしてる

上記のため、一斉通知をするたびに、APIサーバーからDBへの接続待ちでCPUがアップアップする。
3回に1回くらいはOOM-Killerが発動してサーバーが死んでた :innocent:

解決案

ユーザがそのお知らせ表示の対象者かどうかを判定するテーブルを追加する。
ユーザが表示対象であればタイムラインにお知らせを表示し、対象外であれば表示しない。
これにより、Timelineテーブルに登録される件数を数万件→1件にすることが可能!
例:年齢が20代、プロフィールの都道府県が東京都、など

*DB分割やNoSQLで処理する方法もありますが、今回の場合は「そもそも1件登録するだけでよくね?」というのがあったのでこうしました。

変更イメージ(雑)

before

一斉通知ボタンを押すと、Timelineに対象者分のINSERT処理が走る
スクリーンショット 2019-04-06 15.31.24.png

after

Timelineには1件だけ登録し、お知らせ表示対象のユーザ条件を判定するテーブルを作る
スクリーンショット 2019-04-06 16.01.52.png

具体的な対応

タイムラインにどのお知らせを表示するか決めるための条件を管理するテーブル(TimelineType)を作る。
TimelineTypeは表示条件の種類(年齢、国籍など)を管理するtypeカラムと、その閾値を管理するvalueを持つ。
Railsではtypeカラムに入った値がそのモデルのサブクラスとして扱えるので、STIを利用して各サブクラスの中でvalueの値を使って、判定条件を実装する。
ref: シングルテーブル継承 (STI)

マイグレーション
class CreateTimelineTypes < ActiveRecord::Migration[5.2]
  def change
    create_table :timeline_types do |t|
      t.references :timeline, index: true, foreign_key: true
      t.string :type, null: false # タイムラインをどのユーザに表示するかの種類
      t.string :value, null: false # typeに対する条件
      t.timestamps
    end
  end
end

モデル
class Timeline < ApplicationRecord
  has_many :timeline_types

  # ユーザに表示すべきお知らせだけを返す
  scope :my_timeline, -> (_user) {
    timeline_ids = TimelineType.my_timeline_ids(_user)
    where(id: timeline_ids)
  }
end

class TimelineType < ApplicationRecord
  belongs_to :timeline

  class << self
    # 対象ユーザへの表示条件を満たしているものだけを返す
    def my_timeline_ids(_user)
      timeline_ids = []
      group_by_timeline.each do |timeline_id, timeline_types|
        timeline_ids << timeline_id if timeline_types.all?{ |timeline_type| timeline_type.fulfill?(_user) }
      end
      timeline_ids
    end

    # 一斉通知実施でINSERTされるデータ量は多くても数百件程度なのでひとまずこれで
    def group_by_timeline
      all.group_by(&:timeline_id)
    end
  end

  # ユーザはその条件を満たしているかをチェックする
  def fulfill?
    raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
  end
end

# 国籍を決定するサブクラス
# typeが'TimelineType::Country'のオブジェクトはこのクラスの処理が適応される
class TimelineType::Country < TimelineType
  # valueじゃわかりにくいので、サブクラス側で適宜aliasを貼る
  alias_attribute :country_key, :value

  # valueに保存された国がuserの国と一致するかを判定 
  def fulfill?(_user)
    country == _user.country
  end
end

雑感

これで夜も安心して眠れる!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした