📖

Misskey Fanout Timeline Technologyについて少し掘り下げてみる

2023/12/22に公開

みすてむず いず みすきーしすてむず (2) Advent Calendar 2023 22日目の記事です。
※このアドカレはMisskeyサーバのひとつであるみすてむずにアカウントがある人たちが記事を書いています。

Misskey Fanout Timeline Technologyとは

Misskey Fanout Timeline Technology(以降、FTTと表記)とは、一言で言ってしまうとMisskeyの各種タイムライン取得時にかかる負荷を軽減するための仕組みです。

ご存じの通り、Misskeyにはタイムラインが複数あります。連合しているサーバから送られてくる投稿が流れるグローバルタイムライン(GTL)、サーバに閉じたグローバルスコープとして扱われるローカルタイムライン(LTL)、各ユーザがフォローしているユーザの投稿が流れてくるホームタイムライン(HTL)、LTLとHTLの特徴を併せ持つソーシャルタイムライン(STL)、各ユーザが独自に作成できる簡易タイムラインであるチャンネル等々、様々な種類のタイムラインが存在しています。

ユーザがこれらのタイムラインを同時に表示できるような機能(デッキ表示)が実装されているほか、デッキ表示を使用していないユーザも何らかのタイムラインを必ず表示する画面導線となっている故、タイムライン取得のためのリクエスト数は膨大なものとなります。ユーザ数が少ないうちはあまり問題にはなりませんでしたが、あること[1]がきっかけでMisskeyが注目を浴びるようになり、ユーザ数が一気に増えることになりました。そこで、ユーザ数の増大に耐えうる構造とすべく検討が進められたのがFTTです。

具体的にどう変わったのか

FTT実装前後でどのように変わったのかを見ていきましょう。

以下はFTT実装前のシーケンス図です。

とてもシンプルです。
しかし、リクエスト数が増えてくると図中の②がネックになります。
この「取得処理」が主キーを指定して抽出するものなら最小限の負荷で抑えられるのですが、今回のケースはそのパターンに該当せず、各タイムラインに準じた条件でノートの一覧を蓄えたテーブルから絞り込み検索する必要があります。更にブロックやミュート、チャンネルのフォロー有無など条件は複雑化していき、1回の実行でとてもコストのかかるクエリになってしまっていました。その状態でユーザ数が増え、リクエスト数もそれに応じて増えていくと…大変なことになってしまいますよね。

続いて、FTT実装後のシーケンス図を見ていきましょう。

なんと、新たな登場人物が増えてしまいました。
しかし、このRedis(Key-Vlue型のインメモリデータベース)をうまく使うことで、FTT実装前よりも圧倒的に良いパフォーマンスを出すことが出来ます。Redisは各タイムラインに投稿されたノートのIDを記憶しており、これを使用することでノートの一覧を蓄えたテーブルから主キーを使用しての抽出が可能となります。これは実装前の絞り込み検索よりも圧倒的に低負荷で、なおかつ高速に動作するのです。

Pull型タイムラインとPush型タイムライン

RedisはRDBMSではなくKey-Valueストアなので、複雑な条件文を用いてノートを抽出することに向いていません。しかし、各タイムラインにはそれぞれ条件に合うノートを表示させる必要があります。RDBMSのように検索できず、なおかつ条件そのものは維持しなければならないという中々厳しい条件ですが、Misskeyはこれを実現しています。
では、どのようにしてそれを実現しているのでしょうか。その答えは、Misskeyにおいてノートのデータを作成する責務を担うNoteCreateServiceに答えがありました。

事前のチェック処理やデータベースへの登録処理の後に、pushToTlという関数が呼び出されています。実際に関数で行われている処理を少し覗いてみましょう。

NoteCreateService.ts
  @bindThis
  private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
    const meta = await this.metaService.fetch();
    if (!meta.enableFanoutTimeline) return;

    const r = this.redisForTimelines.pipeline();

    if (note.channelId) {
      this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
      this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);

      const channelFollowings = await this.channelFollowingsRepository.find({
        where: {
          followeeId: note.channelId,
        },
        select: ['followerId'],
      });

      for (const channelFollowing of channelFollowings) {
        this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
        if (note.fileIds.length > 0) {
          this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
        }
      }

    // ... 以降、条件判定とRedisへの挿入処理が続いている
  }

上記の引用部分を読み解いてみると、channelTimelineuserTimelineWithChannelhomeTimelineなどの文字列をキーとしてRedisにノートのIDを登録しているのが分かります。
すでにお気づきの方もいらっしゃるかもしれませんが、これがFTT実装後における「タイムライン」の実体です。ローカルタイムライン、ホームタイムライン、各チャンネル用タイムラインなど、タイムラインの数だけ存在しています[2]

FTT実装前はタイムラインの実体はなく、ユーザのリクエストを契機としてその場で条件に合うノートを探し、リクエストの中でタイムラインを作っていたのに対し、FTT実装後はRedis上に作成済みであるタイムラインの実体に向けて作成した直後のノートを振り分けるようになりました。この仕組みによりリクエスト時にノートを探す必要がなくなり(すでに振り分け済みなので)、かつデータベースから主キーを使用してノート本体を取り出せるようになっています。

しゅいろ氏はご自身の連載にて、FTT実装前の状態を「Pull型タイムライン」、FTT実装後の状態を「Push型タイムライン」と呼称しています。FTT実装前の状態ではユーザがDBからタイムラインを引っ張り出すからPull型タイムライン、FTT実装後の状態はユーザのタイムラインに向けてノートを押し込むからPush型タイムラインと覚えておけば良いでしょう。

Push型タイムラインを支える工夫

ここまでの内容を見てみると、Push型タイムラインはとても素晴らしいものであるように映りますが、実装直後は少々扱いづらい状態でした。

先に述べた通り、Push型タイムラインはRedisの上にノートのIDを蓄積してタイムラインを構成します。しかし、これまでのタイムラインの内容はRedis上に取り込まれていませんでした。このPush型タイムラインの構築がRedis上で行われていることと、過去のタイムラインが取り込まれていなかったことが重なり、サーバを更新した時点から過去のタイムラインにアクセスできない状態に陥ってしまいました。

さらに、Redisそのものを再起動するなどしてメモリの内容がすべて揮発すると、その上に蓄積されたタイムラインそのものも揮発します。MisskeyではRedisを永続化して使っておらず、あくまでインメモリのKey-Valueストアとしてしか用いていなかったためです。この変更により、タイムラインとして構築された一連の流れは永続化されず、不安定な状態に置かれることになりました。

しかし、しゅいろ氏をはじめとするコントリビュータ各位の尽力により、この記事を執筆している当初のdevelop最新(bb38e62)では以下のように大幅な改善が施されています。

  • FTTのON/OFFを切り替えられるようになり、従来のPull型タイムラインのみでの運用も可能となった
  • Push型タイムラインの仕組みで一定数のノートを取得できない場合、Pull型タイムラインの仕組みにより差分を補うようになった(ハイブリッド化)

これらの工夫により、サーバの規模に応じて仕組みを切り替えつつ、Push型タイムラインの仕組みによる軽量化の恩恵にも与れるようになりました。

まとめ

FTTの件ももちろんのこと、Misskeyはものすごい速度で発展を続けています。本体そのものも機能が増え続けており、またMisskeyを使用したコミュニティも増加しています。
それに拍車をかけるように、Misskeyをプリセットとして提供してくれるようなレンタルサーバ業者も登場しており、今後が大変楽しみなプロダクトです。

コントリビューターとしてお手伝いしてくれる人も増えるといいなァ(小声)

23日はせるかすさんの記事となります。乞うご期待。

脚注
  1. 青い鳥 ↩︎

  2. ソーシャルタイムラインは特殊で、同タイムライン用のキャッシュは存在していません。ホームタイムラインとローカルタイムラインがそれぞれ持っているノートIDの一覧を合成して作られています。 ↩︎

GitHubで編集を提案

Discussion