Misskey & Webテクノロジー最前線

リアクションを効率化するRBT⁠レンダリングを軽量化するcontent-visibility

本連載は分散型マイクロブログ用ソフトウェアMisskeyの開発に関する紹介と、関連するWeb技術について解説を行っています。

Misskeyでは、新機能の追加や改修・バグ修正はもちろんですが、運営者がより少ないコストでサーバーを維持できるよう、Misskeyのスケーラビリティ改善も継続して行っています。

今回はそういった最近のMisskeyのパフォーマンス改善の取り組みについて紹介します。

Reactions Boost Technology(RBT)

Misskey® Reactions Boost Technology™(RBT)は、Misskey 2024.9.0で実装されたサーバーサイドのリアクション処理時のパフォーマンスを向上させる仕組みです。

ノート: Misskey®︎ Fanout Timeline Technology™︎(FTT)と同様に某半導体メーカーのネーミングを意識しています。

リアクションを効率化させたいというのは昔から構想があり、Issue(#11093)に実装に至るまでの思案が残っています。

背景

Misskeyでは以前から、ノートの取得時のパフォーマンスを向上させるために、ノートレコードに非正規化したリアクション情報を持つようにしています。

リアクション情報は2種類あり、ひとつは「誰がどのリアクションをしたのか」というデータと、もうひとつは「どのリアクションがどれだけ行われたか」というデータです。

前者は要素数に制限のある文字列の配列として保存されており、⁠自分がそのノートに対してリアクションしたことがあるか、あるならどのリアクションか」という情報を効率的に取得できるようにするために使われます(詳細は「Misskeyのパフォーマンス改善の取り組み⁠⁠⁠⁠・2023年11月」で解説しています⁠⁠。

後者はJSON型で、Keyにリアクションの種類、Valueにそのリアクションがされている数を持ちます。

PostgreSQLでは、MongoDBなどのNoSQLよろしくJSON型のアトミックな更新を行えるクエリが用意されており、特定のキーの値をインクリメント/デクリメントするのにクエリ発行は1回だけで済みます。

ノート具体的には以下のようなクエリでアトミックな操作が可能です。

jsonb_set("reactions", '{👍}', (COALESCE("reactions"->>'👍', '0')::int + 1)::text::jsonb)

1回のクエリで済むので、トランザクションやロックの必要もありません。

……と、ここまでは良いのですが、PostgreSQLにおいてレコードの更新は内部的に「新規レコード挿入+(後から)削除」という実装になっており、大量のレコードの更新が行われるとデータベースの効率が低下してしまう問題がありました。

解決策

そこで実装されたのがRBTです。このRBTですが、実装中はReactions Buffering Technologyという呼称でした(Boostではなく⁠⁠。

この名前からある程度想像がつくかと思いますが、RBTは以下のアプローチでリアクション処理の効率化を図っています。

  • リアクションが作成されてもすぐにはDBのノートレコードを更新せず、一旦Redisに情報を溜めておく(バッファリング)
  • 一定の間隔でRedisにバッファリングされているリアクション情報をデータベースにまとめて書き込む(ベイクと呼んでいます[1]
  • ノート情報を取得する際は、Redisにもクエリを発行し、バッファリングされているリアクション情報も持ってきてAPIのレスポンスを行う

これにより、同じノートにリアクションが複数回行われても、ノートレコードの更新はベイク時の1回だけで済むようになります。

いわゆるバズったような投稿だと、短時間に数百・数千のリアクションが行われるのでRBTが効いてきます。

Redisの負荷は増えますが、PostgreSQLに比べるとはるかにこういった処理に向いていて、スケールもしやすいです。

デメリットはリアクションの頻度が少ないサーバーだと効果が薄い点と、Redisにバッファリングするためサーバーのメモリ使用量が増加する点です。

実装

主な実装はReactionsBufferingService.tsにあります。

Redisにも、ノートレコードと同じく2種類のリアクション情報を持つようにしています。

「誰がどのリアクションをしたのか」は、リアクションの種類とユーザーIDの文字列がメンバーで、その日時がスコアであるSorted Setsデータとして持ちます。

Sorted Setsを使う理由は、この情報はデータ量の観点から無制限に保存し続けるわけにはいかず、数に制限を設けて古いものから消していく必要があるためです。

Sorted Setsはメンバーごとに異なったスコアを持たせることができ、そのスコアによってソートした結果を効率的に得ることができます。

日時情報をスコアとして利用すれば、メンバーを新しい順・古い順にソートでき、末尾から順に消していくといった実装が容易です(この手法はFTTでも利用しています⁠⁠。

「どのリアクションがどれだけ行われたか」は、リアクションの種類がKeyで、その数がValueであるHashデータとして持ちます。

Redisでも、Hashの特定のKeyのValueをインクリメント/デクリメントする操作は1コマンドでアトミックに行えますHINCRBY⁠。

これらのデータ構造を活用することでRBTを実現し、データベースの負荷を低減させることができました。

content-visibility: auto

Misskeyではv2024.10.1から、CSSのcontent-visibilityプロパティを利用してクライアントのパフォーマンスを向上させています。

content-visibilityの値をautoに指定すると、その要素が不可視の場合にレンダリングがスキップされるようになるという、ブラウザネイティブの半仮想スクロールのような機能です。

図 content-visibility

ノート仮想スクロール(virtual scroll)は、表示されていない部分にある要素をDOMに追加せず、ブラウザにレンダリングさせないことでパフォーマンスの向上を実現する技術です。

主に大量の要素があるリストのレンダリング(Misskeyのタイムライン等)で効果を発揮します。

前からBlinkやGeckoでは実装されていたのですが、WebkitはiOS 18からの対応なのでメジャーなブラウザすべてで使えるようになったのはつい最近です。

なお対応していないブラウザで使用しても無視されるだけなので、後述するデメリットを許容できるユースケースであれば使っておいて損はありません。

vs 仮想スクロール

ReactにしろVueにしろ、仮想スクロールはトリッキーな実装になり、

  • ユースケースによっては上手く動かない
  • バグを生みやすい
  • アクティブにメンテナンスされているライブラリが少ない
  • 導入コストが高い
  • DOMが作られないので、ページ内検索やフォーカスが機能せずアクセシビリティが低い

などの理由があってなかなか採用が難しい面があります。

もちろん、⁠これは仮想スクロールに限った話ではないですが)ライブラリを使用すればその分クライアントのスクリプトサイズも増加します。

またMisskeyにおいては、タイムラインは単なるリストのレンダリングではなく、

  • 引っ張って更新できる機能(pull to refresh)がある
  • 途中で日付の区切りをレンダリングするようになっている
  • ノートごとの高さが一定でなく、しかもリアクションがついたりすると動的に高さが変わる

といった事情があるため、実装が不可ではないにしても仮想スクロールとあまり相性が良くないのではないかという懸念がありました。

実装できたとしても、コンポーネントが比較的複雑で(特にリアクションが多いと)子コンポーネントの数も多いため、スクロールに合わせてマウント/アンマウントを頻繁に繰り返すこと自体の負荷が無視できない可能性がありました。

ノートMisskeyにおける仮想スクロール導入はIssue(#6045)で議論されています。

その点、content-visibilityはブラウザネイティブで実装されている機能ですからそういったデメリットは少なく、気軽に導入できます。使用するのもCSSを1行追加するだけです。

パフォーマンス

パフォーマンスについては、⁠真の」仮想スクロールに比べて、レンダリングの対象にはならないとはいえメモリ上にDOM自体は作成されます。実装のしやすさやアクセシビリティなどを考えるとそれがメリットでもあるのですが、やはりパフォーマンス面では不利だろう……と思っていました。

ところが、筆者が試した限りでは大量のDOMがあるケースでもcontent-visibilityautoに設定するとスイスイで、明らかに重くなるようなことは確認できませんでした。大げさかもしれませんが、⁠これなら仮想スクロール不要なのでは?」と思ってしまうくらいでした。

おそらくブラウザの実装が優秀で、レンダリングの対象にならなければDOMが多くてもそこまでの負荷にはならないのかもしれません。

ノート仮想スクロールとの違いが明確に表れるほどの長大なリストをレンダリングするようなユースケースでは、そもそもUIの設計が良くない可能性があります。ユーザビリティの観点からも、ページングなどの方式も検討するべきでしょう。

詳細な検証はまだ行っていませんが、いずれにせよなかなかのポテンシャルはありそうです。

注意点

使用する際の注意点は、⁠これは仮想スクロールにも言えることですが)可視範囲に入るまで要素のレンダリングが行われないので、⁠ページ全体の高さをあらかじめ決定できない」点です。

高さが分からないと、実際に最後までスクロールしてみるまでスクロールバーの正確な表示が行えないことになります。

この問題を緩和するためにcontain-intrinsic-sizeというプロパティが用意されており、要素の大体の(予想される)高さを指定しておくことができます。

ただ、それを使ったとしても完全に正確にはできないので、ある程度のスクロールバーの精度低下はパフォーマンス向上のためのトレードオフとして受け入れる必要があります。

とはいえ、仮想スクロールやcontent-visibilityを使わなくても動的なコンテンツの読み込みなどでスクロールバーがズレることはよくありますし、そもそもスクロールバーが完璧であることが求められるシチュエーションは少ない思うので、このデメリットは無視できることが多いのではないでしょうか。

なお、一度でもレンダリングされた要素のサイズはブラウザが記憶してくれ、可視範囲外になってもレイアウトシフトが発生するようなことはありません。

ノート筆者が3年ほど前にこの機能をChromeで試したときは、下のほうにスクロールしていくと上にあった要素のレンダリングがスキップされ、かつサイズが記憶されなかったためにレイアウトシフトが発生していました。現在は同様の現象は起こらないのでバグだったのでしょう。

まとめ

今回RBTの実装で、リアクションのパフォーマンス向上を図りました。

動的なデータの扱いはPostgreSQLは苦手としていて、Redisなどのインメモリデータベースは得意としている傾向があります。このようにデータベースはそれぞれ得手不得手があるので、適材適所で使っていくことが必要です。

リストのレンダリングについては、仮想スクロールは銀の弾丸ではありません。一方、content-visibilityは大きなデメリットもなく手軽に導入できますので、一旦使ってみてそれでもパフォーマンスの問題がある場合に別の方法を検討するのでもよいと思います。

ブラウザは、Webアプリが複雑化していくのをただ黙って見ているわけではなく、それに耐えられるように改修や新しい仕組みを実装します。仮想スクロールのような複雑な実装をしなくても済む未来を夢見て、今後もブラウザネイティブでアプリケーションのパフォーマンスを向上させる仕組みが実装されていくことを期待しています。

おすすめ記事

記事・ニュース一覧