メインコンテンツまでスキップ

「Rails」タグの記事が2件件あります

全てのタグを見る

· 約14分
中川 優

初めまして、中川優(すぐる)と申します!サービス開発部/プロダクト開発部門/従業員サーベイチームのプロダクトエンジニアです!

仕事で使用している主な言語はRuby・TypeScriptです。個人的にGolangあたりを最近触ったりしています。趣味はヘヴィメタルです。

今回は、当社プロダクトの一つであるミキワメウェルビーイングサーベイが過去に直面した、閲覧範囲制御機能のパフォーマンス上の課題、および改善に至るまでの事例をご紹介いたします。

ミキワメウェルビーイングサーベイとは?

心理学・行動科学の知見に基づき従業員のウェルビーイング(幸福度)を測定・分析する、実名性の”パルスサーベイ”ツールです。

  • 短時間の回答(3分間)によって、即座に回答結果・アドバイスを確認できる
  • 個人の性格に基づいた、ウェルビーイング向上のためのアドバイスを提供
  • 組織全体の回答結果を可視化し、組織単位でのアドバイスも受けることができる

詳しくは下記もご覧ください。

『ミキワメ ウェルビーイングサーベイ』とは?社員の幸福度を高め離職・休職を防ぐ|ミキワメラボ

閲覧範囲制御機能とは?

ミキワメでは大きく分けて2つの画面を提供しています。

  1. 従業員向け画面:従業員が自身の回答結果を閲覧するためのページ
  2. 企業向け管理画面:企業担当者が組織・各従業員の回答結果を閲覧するためのページ

閲覧範囲制御は、この内の企業向け管理画面における新機能として、2024年5月30日にリリースされたアップデートです。

アップデート前

企業向け管理画面のログインユーザーでは複数の権限が存在し、その中に”マネージャー”という権限があります。マネージャーは、特定の部署・および部署に所属する従業員のサーベイ回答結果のみを閲覧することができます。 しかし、そのマネージャーが現実でその役職に任命以前の結果まで閲覧できてしまう状態でした。

  • 2024年6月1日にサービス開発部のマネージャーに任命されたAさんは、任命されるより以前のサービス開発部のサーベイ回答結果も閲覧できてしまう

ミキワメウェルビーイングサーベイでは、従業員が自由記述できるコメント機能、企業担当者が従業員に対する打ち手を記録するサポート履歴機能などもあります。 その為、誰かに見られては困る内容を、その誰かが閲覧できてしまうリスクがありました。

アップデート後

マネージャーが特定の部署のみ回答結果を閲覧することができるという条件に加え、その部署を閲覧権限を付与した以前の結果は、閲覧不可とする制御ができるようになりました。

  • 2024年6月1日にサービス開発部のマネージャーに任命されたAさんは、任命されるより以前のサービス開発部のサーベイの回答結果は閲覧することができない

これによって、アップデート前のリスクを防止することができ、より安心して利用できる仕様にすることはできました。

ところが、このアップデートのリリース直後、とある問題が起きてしまいます・・・

画面の表示速度がめっちゃ遅くなっている!!!

それは、サービス開発部で毎月末に実施している、各チームの進捗報告などの定例の中での話でした(確かその初回だったと記憶)。

EM「サーベイで画面表示速度が遅くなっているらしいので、従業員サーベイチームは調査に入ってください」

わたし「!!?」

企業向け管理画面では、全社・部署単位で組織のサーベイ回答結果を閲覧できるページがあり、ミキワメウェルビーイングサーベイにおけるコア機能の一つなのですが、このページにおいて著しいパフォーマンス低下が発覚。

例:数千人分の回答結果を持つページの場合

  • 初期表示までに10~20秒、あるいはそれ以上かかってしまう
  • サーベイに回答した社員一覧データを取得するための非同期処理1~3分以上かかってしまう

当時はエンタープライズ企業への導入が活発化し始めていた状況だったのですが、その矢先にこのような事態に見舞われてしまったのです・・・!!

ボトルネックを探し、仮説を立てる

この事態を受け、従業員サーベイチームではリードエンジニアの力を借りながら、とにかくやれることをやっていくこととしました。

  • 不足しているeager loadingの追加
  • メモ化できていない所のメモ化
  • 画面表示の都度発生していた計算処理を、DBから参照する形に修正
  • さらに非同期取得してよさそうな所があれば非同期化
  • etc

このあたりは一定改善があったのですが、元の速度に戻るまでには至らず。そして気づけば1ヶ月経過・・・。別の観点でも調査を継続していくこととなりました。

遅くなっている画面でrack-mini-profilerを用いたクエリ発行数の調査をしていく中で、多くのクエリが発行している箇所を確認しました。この箇所では、サーベイの回答結果が閲覧できるかどうかを以下のように制御しており、これが速度低下に影響しているのではないか?という仮説を立てることとしました。

  • 回答者がサーベイに回答した日時の時点で所属していた部署が、マネージャーの閲覧可能な部署である

一人一人回答タイミングは異なることから、配信対象になった社員全員分を対象をループさせ、回答者ごとに都度閲覧可能かを判定する必要があります。

# コードはイメージ。実際のものは異なります。

# cq_recruitment_examinees: サーベイ配信対象者になった社員
# supervisor: 企業向け管理画面のログインユーザー。ここではマネージャーを想定
# division: 部署

cq_recruitment_examinees.select do |cq_recruitment_examinee|
# 社員がサーベイに回答した日を取得するメソッド
deadline = cq_recruitment_examinee.completed_time

# 部署IDと回答日を渡してマネージャーが閲覧可能かを判定
# 内部で閲覧可否判定用のデータを取得するクエリの発行をしている
supervisor.survey_permission?(deadline, division.id)
end
  • ループごとにクエリが発行されN+1が発生
  • 社員が多いほど処理時間が線型に増加する

上記のコード例は説明のために簡略化させていますが、もう少し色々処理が伴っていたり、類似した処理が複数箇所で実装されていたりします。これらが速度低下のボトルネックなのでは!?という話になったのです。

解決に向けたアプローチ

一定ボトルネックが明確になったので、下記の改善案を考案する形になりました。

  • 配信されたサーベイの配信終了日の時点で回答者が所属していた部署が、マネージャーの閲覧可能な部署である

これにより、全社員に対して同一の時点で閲覧可否を判断することができます。

# コードはイメージ。実際のものとは異なります。

# cq_recruitment: 配信されたサーベイ。cq_recruitment->cq_recruitment_examineesで1対多のリレーション。
# サーベイが終了した日付を取得するメソッド
deadline = cq_recruitment.deadline
CqRecruitmentExaminee.none if supervisor.survey_permission?(deadline, division.id)

cq_recruitment_examinees

ボトルネックとなっていたループ処理が不要になり、回答者人数に依存することなく制御できるようになりました。 ただし元々の仕様自体を変えることになるので、PdM・カスタマーサクセスのメンバーとの合意を得る必要がありましたが、一定議論を重ね、パフォーマンス以外のUX改善も盛り込む形で上記の対策を取る方針で合意することができました!

まとめ

閲覧範囲制御の実装範囲は広く、実装自体にも約2ヶ月弱かかりましたが、概ね狙い通りにパフォーマンスを改善!

beforeafter
初期表示期間10〜20秒(または数分以上)1秒未満
一覧データ取得時間(非同期)1〜3分以上数秒〜15秒程度

これで、エンタープライズ企業への導入の障壁を大きく下げることができました。

このアップデートのリリース時点で2024年9月半ば。3ヶ月半の長い道のりで、この間メンバーの入れ替わりなどもありましたが、無事にここまでやり遂げられたことは今でも貴重な経験だったなと思っております。特に下記は教訓となりました。

  • 大規模データを想定して、アップデート前後で速度の低下が発生していないかを検証した上で実装すること。非機能要件の重要性を再認識した。
  • 時にはビジネスロジックの再考も話し合える姿勢を持つこと。役割を超えてより良い開発をする機会となった。

ネクストアクション

非同期処理の中には、まだ社員がサーベイに回答した日起点で閲覧可否を制御せざるを得ないパターンがあり、10〜15秒程度かかる場合があります。 こちらも更に速度が出るように改善し、より快適なサービスにしていきたいです。

そして、実は今回の取り上げた事例以外にも表示速度が遅いページが残っています・・・!同じく企業向け管理画面にある、企業上に作成された部署の一覧表示するページです。これもエンタープライズ企業にて顕在化した課題であり、同様にパフォーマンスチューニングが必要な状況です。こちらにも積極的に関わっていきたいなと思っております。

以上です。最後までご覧いただきありがとうございました!

· 約19分
梶原 悠司

こんにちは。リーディングマークのミキワメ適性検査開発チーム、エンジニアリングマネージャーの梶原です。
本稿では、2023年末から2024年初頭にかけて実施した「受検者データ分離プロジェクト」について解説します。このプロジェクトは、リーディングマークが提供しているミキワメ適性検査とミキワメウェルビーイングサーベイ(以下、「適性検査」「サーベイ」)のデータ構造を改善し、開発スピードの向上とシステムのスケーラビリティを実現することを目的としていました。


「受検者データ分離プロジェクト」は、2023年末に起案され、2024年1月から3月の約3ヶ月かけて開発が行われました。このプロジェクトの背景には、ミキワメ適性検査における課題がありました。従来、新卒・中途・社員の受検データを単一のテーブルで管理していましたが、事業拡大と新サービスの追加に伴い、このデータ構造が開発スピードの制約要因となっていました。
そこで、80万件を超えるデータを分割し、よりスケーラブルなシステムを構築することを目指しました。具体的には、単一のテーブルで管理していた新卒・中途・社員の3区分のデータを、3つの独立したテーブルに分割することが主な目的でした。
開発チームは、テックリード1名、適性検査開発チームから1名、サーベイ開発チームから1名の計3名で構成されました。私は主に適性検査の範囲を担当しました。
プロジェクト実施時点で、バックエンドにはRuby on Rails(バージョン6.1)を使用していました。 この記事では、このプロジェクトの設計方針、技術選定、移行プロセス、直面した課題とその解決策について詳しく解説していきます。


プロダクトの課題とプロジェクト背景

前提として、弊社プロダクトのうち、適性検査・サーベイは同一のデータベースを参照しています。
ミキワメ適性検査の受検者データは、単一の examinees テーブルで管理され、新卒・中途・社員の3種類のユーザーを区別するために examinee_type カラムを使用していました。
このデータ設計では、企業に属する社員の情報を取得する際に、適性検査の受検データを経由する必要がありました。(図1)

[図1] 図1

この構造では、サーベイ機能を独立させる際に不要な依存関係が生じるため、受検者のテーブルをstudentsworkersemployees に分離することで直接企業と紐付けられるようにしました。受検者と紐づいている受検データにはリレーションを張っていたのですが、ポリモーフィック関連を用いて擬似的にリレーションを張ることにより、データの相関を実現する形となっています。(図2)

[図2] 図2

先の図1で示したプロジェクト開始前のデータ構造では、ユーザーは適性検査の受検を通じて初めて企業に属することがわかるかと思います。
この構造の問題点は、社員がサーベイに回答するには、事前に適性検査を受検して企業に属している必要があったことです。当初は運用上大きな問題にはなりませんでしたが、企業が社員に適性検査とサーベイを同時に依頼する機能を実装する際、データ上企業に属していない社員へのサーベイ発行が課題となりました。
一時的な対応も可能でしたが、将来的に社員向けサービスを展開する上で、この問題が繰り返し障害になることが予想されました。結果として、このデータ構造がプロダクト全体の拡張性を制限し、開発の難易度を高めていました。
既存の構造では、企業に属する社員の情報を取得するために、適性検査の受検依頼を介する必要があり、これは無駄に複雑な構造でした。


プロジェクトを進行するにあたっての課題

プロダクトの課題を改善できる理想状態を定義できたものの、プロジェクト進行上の課題も多く存在しました。

プロジェクトの主な課題:

  1. データ構造の大規模変更
    受検データはプロダクトのコアであり、影響範囲が広い
  2. ビッグバンリリースの回避
    他プロジェクトと並行して進めるため、段階的な移行が必要
  3. データ同期の複雑性
    旧テーブルと新テーブルの間で一貫性を保つ必要がある
  4. トランザクション管理
    エラー時のロールバックや同期処理の信頼性が求められる(図3)

[図3] 図3

これらの複雑かつ大規模な課題に対して、システムの安定性を維持しながら慎重に対応することが求められました。
それ考慮した上でどのような戦略を立て、どのような手順を踏んでプロジェクトを進めていったのかを説明していきます。


プロジェクト進行の戦略と手順

図4の手順に従ってプロジェクトを進行しました。

[図4] 図4

これら手順を具体的に解説していきます。

前準備

  • 受検者区分ごとに必要なカラムの精査
  • 受検者情報を置き換える必要があるアプリケーションのリソースの洗い出し
  • 新受検者テーブル、およびそれらの関連テーブル(サインイン情報を持つテーブルなど)の作成

新テーブル作成と既存周辺テーブルの関連付け

  • 各区分の受検者テーブルへ旧受検者テーブルの情報を関連付けするためのカラム設定
  • 旧受検者テーブルに対する外部キーを保持しているテーブル(受検データなど)へ、新受検者テーブルの情報をポリモーフィックで保持するためのカラムの追加、または社員のみのリレーションで十分なテーブルも存在していたため、その場合は社員テーブルに対する外部キーのカラム追加

旧受検者データを新受検者データへ複製

約80万人(2024年1月1日時点)存在していた受検者のデータをstudents, workers, employeesそれぞれのテーブルへ移管する必要がありました。
問題点として、プロダクトリリース当初のバリデーションが十分に記述されていなかった時代に保存された受検者のデータが存在しており、それらを検知するためにいつ終わるかわからない分量のデータを移行するバッチを走らせて確認していました。
具体的には以下のような不整合のパターンが存在しました。

  • employees テーブルでは現在、セイメイ(仮名姓名)の保存が必須だが、過去にはブランクが許容されていた
  • 新しい受検者のテーブルには、メールアドレスのフォーマットに制約があるが、以前のデータには全角英数が許容されていたりと、制約が不十分であった
    このような過去データを新しい制約のあるテーブルへ複製する際、バッチ処理を実行して初めて問題に気付き、その対応に苦労しました。
    最終的には、目的が「複製すること」だったので、セイメイ(姓名)のnot NULL制約などDBレベルで制約がかかっているものに関しては仮の値を、メールアドレスのフォーマットなどアプリケーションレベルで制約がかかっているものに関しては一時的にアプリケーションの制約を外して対応することになりました。

新旧テーブル間データ同期の設定

  • 旧受検者テーブルのデータに変更が加わった時に、新受検者テーブルの対応するデータも変更するための処理
  • またその逆の、新受検者テーブルのデータに変更が加わった時に、旧受検者テーブルの対応するデータも変更するための処理
    これらは更新・作成・削除のそれぞれで行う必要があり、モデルにコールバックを記述することで対応しました。それぞれafter_update, after_create , after_destroy を用い、同期対象となるテーブルのデータを操作する形としました。
    以下のコードは、ExamineeモデルとStudentモデルにおけるコールバックの処理の記述を表したものです。(Employeeモデル, Workerモデル, update, destroyに関しては省略します。)
class Examinee < ApplicationRecord
after_update :update_student

private

def update_student
self.student.update!(**{some_attributes})
end
end

class Student < ApplicationRecord
after_update :update_examinee

private

def update_examinee
self.examinee.update!(**{some_attributes})
end
end

コールバック内でデータを操作する処理の中で仮にエラーが発生した場合、トランザクションはどうなるのかという問題がありましたが、最初に操作を受けたテーブルのデータの処理からコールバック内で発火したデータ操作の処理は、同一トランザクションで囲まれる形となっているため、特段気にすることなくコールバックの記述をすることができました。
しかしながらここには一点問題があり、例えばExamineeの更新がコールバックでStudentの更新を呼んだ時、Studentの更新もコールバックでExamineeの更新を呼んで、さらにまた、、、という無限ループが発生してしまいます。
この無限ループ問題に対して、他社事例も参考にしながらの対処を行いました。
まず、両方のモデルでattr_accessorを使用して、一時的なインスタンス変数skip_double_writeを定義します。このようにすることで、インスタンスのライフサイクルの中でしか存在しないskip_double_write がそれぞれのActiveRecordインスタンスのカラムのように振る舞います。
このキーを、Examineeクラスのコールバックで呼ばれるプライベートメソッド内でtrueとしてStudentインスタンスに受け渡し、Studentクラスのコールバックの発火条件として利用することで、「ExamineeクラスのコールバックでStudentインスタンスが更新された場合、StudentクラスでExamineeインスタンスの更新を行うコールバックを発火しない」という、更新が再帰的に発生しないような挙動の実現が可能となります。
一部簡略化していますが、具体的なコードとしては以下です。

class Examinee < ApplicationRecord
attr_accessor :skip_double_write

after_update :update_student, unless: :skip_double_write

private

def update_student
self.student.update!(**{some_attributes}, skip_double_write: true)
end
end

class Student < ApplicationRecord
attr_accessor :skip_double_write

after_update :update_examinee, unless: :skip_double_write

private

def update_examinee
self.examinee.update!(**{some_attributes}, skip_double_write: true)
end
end

これらの設定を行うことにより、無事同期的にデータの整合性を保つ仕組みを整えることができました。もちろんここにはパフォーマンス観点で問題がありましたが、ここに関しては一定許容していただきつつ運用することとなりました。

アプリケーションが参照しているテーブルを旧テーブルから新テーブルへ全て移行

ここでは前述したように、段階的に既存処理の移行を図りました。
当時、30以上の機能が旧受検者を参照している形であったため、その全てを新受検者のテーブルへ移管する必要がありました。
ここに関しては特に何かを工夫したわけではありませんでしたが、一つ一つドメインを把握している必要があり、入社2年弱であった私もキャッチアップから入る箇所もあったため、正直一番大変な作業でした。
ただ移行作業をするだけでなく、一部大きく処理を変えなければならない箇所も出てきました。
例えば受検データの検索においては、受検者のメールアドレスなどで検索することができるUIが存在していて、その場合関連テーブルとしてexaminees単体の検索で良かったものが、students, workers, employeesそれぞれを跨いだ検索ができるような機能に修正する必要がありました。
そのほかにも煩雑なコードやロジックが残されている箇所に関しては、そのまま移行するのではなく、全体的なリファクタリングを行った上で移行作業をしていったり、そもそも機能をほぼ作り直したような箇所もあったため、思ったようにタスクが消化できない時もありましたが、最終的には無事に移行作業を完了することができました。

対応漏れログの抽出用の設定

移行作業は完了したものの、間髪入れずにexamineeクラスを削除するのはリスクが高かったため、実際にexamineeが呼ばれている箇所がないかをログとして抽出できるように、examineeクラスに以下のような記述をしました。

  after_initialize do |_examinee|
backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/)
Rails.logger.info "Examinee initialize. #{backtrace}"
end

after_find do |_examinee|
backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/)
Rails.logger.info "Examinee find. #{backtrace}"
end

after_initializeでexamineeインスタンスが初期化された後、また、after_find でexamineeモデルを介してデータベースに検索をかけにいった際に、ログを残すような仕組みを残しました。
こうすることで、一定期間ユーザーがサービスを実際に利用する中で本当にexamineesテーブルに対する取得や操作は行われていないのかを確認することができます。
このおかげで、レガシーとなったexamineeモデルを安心して削除できるような状態を実現できています。

旧受検者モデルの削除

対応漏れの確認と検証を十分に行った上で、アプリケーションから旧受検者であるexamineeの概念を取り除きます。
アクセスがされないモデルが残ってしまうため、モデルとそれらに関連するファイルを削除しました。
移行先の新テーブルにデータは残っていますが、念の為旧テーブルのデータ自体の削除は一定期間行わない方針です。


プロジェクトを振り返って

このプロジェクトを通じて、データ構造のシンプル化と、プロダクトの拡張性向上を実現しました。
特に、段階的な移行戦略やデータ同期の仕組み、トランザクション管理の工夫は、同様の課題を抱えるエンジニアにとって参考になるポイントかと思います。
今後も、システムの進化に合わせて最適なデータ設計を模索しながら、継続的に改善を進めていきます。