📚

[Firebase]Firestoreの設計の思考法(正規化/非正規化)

2020/12/02に公開2

なぜ書くのか?

SIerでがっつりSQLServer(RDB)を使って開発をしていたため、
正規化思考が抜けずFirestore(NoSQL)のDB設計をするにあたってかなり苦労したので、
同じ苦労をしている方へFirestoreの設計を学習する入り口を書こうと思いました。

RDBとFirestoreの違い

個人的に一番苦しんだ違いは**テーブル結合(inner join, outer join)**ができなかったことです。
Twitterのフォロー一覧のような機能を作ることを例に見ていきます。

image.png

RDBの場合

ユーザーテーブル

id name imageUrl profile_text
1 inoue https://sample.com/image/1 井上です!趣味はゴルフです!
2 miyashita https://sample.com/image/2 政治が好きです。広告系で働いてます。
3 sakai https://sample.com/image/3 マクロ経済学が好きです。
4 takagi https://sample.com/image/4 サブカルな音楽が好きです。

ユーザーフォローテーブル

id user_id follow_user_id
1 1 2
2 1 4
3 2 3
4 2 4

上記のようにテーブルを作成して、例えばinoueのフォロー一覧データを取得する時は
以下のようにfollow_user_idをキーにして、テーブル結合したものを取得するという感じになると思います。

SELECT
  name,
  imageUrl,
  prifile_text,
FROM
  ユーザーフォローテーブル
INNER JOIN 
  ユーザーテーブル
  ON
  ユーザーフォローテーブル.follow_user_id == ユーザーテーブル.id 
WHERE
  ユーザーテーブル.id == 1

今回のテーブルの状態だと取得した時のテーブルはこのようになります。

name imageUrl profile_text
miyashita https://sample.com/image/2 政治が好きです。広告系で働いてます。
takagi https://sample.com/image/4 サブカルな音楽が好きです。

こうしてフォロー一覧の取得は完成です。

正規化について

ここでRDBでは当たり前の正規化について少し触れておきます。

今回ユーザーフォローテーブルのフォローについて正規化を行っています。
正規化しない場合以下のようになります。

ユーザーフォローテーブル

id user_id follow_user_id name imageUrl profile_text
1 1 2 miyashita https://sample.com/image/2 政治が好きです。広告系で働いてます。
2 1 4 takagi https://sample.com/image/4 サブカルな音楽が好きです。
3 2 3 sakai https://sample.com/image/3 マクロ経済学が好きです。
4 2 4 takagi https://sample.com/image/4 サブカルな音楽が好きです。

いわゆるフォローしているユーザーの情報をそのまま持たせるパターンです。
RDBを経験している人であれば気持ち悪くて正規化したくなると思います・・。

正規化するメリットは大きく2点だと思っています。

1.変更が合った場合に最小限の変更で済む

上記の正規化していないテーブルだと、例えばtakagiさんがユーザー情報を変更(profile_textを変更)をした場合、
ユーザーテーブルのprofile_text,ユーザーフォローテーブルのtakagiさんのprofile_textを全て変更する必要があります。
※上記の例だと3行の変更ですが、ユーザーフォローテーブルでtakagiさんの情報が増えれば増えるほど(フォローされればされるほど)データ量は増えます。

ユーザーフォローテーブル

id user_id follow_user_id
1 1 2
2 1 4
3 2 3
4 2 4

こちらのように正規化をしておけば、ユーザーテーブルだけ変更して
取得時に結合してあげればいいので、変更範囲は小さくてすみます。

2.重複した不要なデータを持たなくて良い

今回の状態だと、ユーザーフォローテーブルにはtakagiさんのデータが2回登場しています。
登場回数が増えれば増えるほど同じユーザー情報が書かれているのは明らかに無駄な情報(結合すれば取れる)なので
正規化することでこれを防ぐことができます。

以上2点メリットを書きましたが、
上記の通りRDBを扱う上では**正規化をしない(非正規化)**データは気持ち悪く見えてしまうのです。

Firestoreの場合

RDBの時はとりあえずこれ!みたいな設計がありそうなのに対して、
Firestoreの設計はとりあえずこれ!というものはなく
状況を考えて設計する必要があります。

1.RDBのように正規化を行うパターン

- users
  - '1' (documentID)
    - 'inoue' (name)
    - 'https://sample.com/image/1' (imageUrl)
    - '井上です!趣味はゴルフです!' (profile_text)
    - ['2','4'] (follow_user_id) //配列(またはサブコレクション)でもつ
  - '2'
    - 'miyashita'
    - 'https://sample.com/image/2'
    - '政治が好きです。広告系で働いてます。'
    - ['3','4']
  - '3'
    - 'sakai'
    - 'https://sample.com/image/3'
    - 'マクロ経済学が好きです。'
  - '4'
    - 'takagi'
    - 'https://sample.com/image/4'
    - 'サブカルな音楽が好きです。'

follow_user_idを一旦配列で持たせています。(フォロー数がかなり膨れるならサブコレクションの方が良い)

メリット

  • 正規化しているので上記の正規化時のメリットを持つ

デメリット

  • クライアント(フロント)側でユーザーの数だけFirestoreにアクセスをしなければならない(クライアントサイドジョイン)

少しわかりづらいかもですが、下記の図のような状態です。
image.png

2.非正規化するパターン

- users
  - '1' (documentID)
    - 'inoue' (name)
    - 'https://sample.com/image/1' (imageUrl)
    - '井上です!趣味はゴルフです!' (profile_text)
    - follow_users (サブコレクション)
      - '2'
        - 'miyashita'
        - 'https://sample.com/image/2'
        - '政治が好きです。広告系で働いてます。'
      - '4'
        - 'takagi'
        - 'https://sample.com/image/4'
        - 'サブカルな音楽が好きです。'
  - '2'
    - 'miyashita'
    - 'https://sample.com/image/2'
    - '政治が好きです。広告系で働いてます。'
  - '3'
    - 'sakai'
    - 'https://sample.com/image/3'
    - 'マクロ経済学が好きです。'
  - '4'
    - 'takagi'
    - 'https://sample.com/image/4'
    - 'サブカルな音楽が好きです。'

上記のようにユーザー情報をまるまる持ってしまうパターンです。

メリット

  • アクセスがfollow_usersへの一回で取得できるので、表示が早くなる

デメリット

  • フォローしてるユーザーが情報を変更した際に変更範囲が大きい

自分のプロフィールを変更した際には、
自分をフォローしているユーザーの情報を全て更新する必要があります。
変更範囲は数件であればクライアント側で更新の処理を行ってもユーザーはそこまで気にしないかもしれません。

しかし、今回のようにフォローしている人が増えることで変更件数がかなり多くなるため
クライアント側で処理を行っているとかなり時間がかかってしまいユーザーにストレスを与えてしまいます。

そのためFirebaseにはCloud Functionsというサーバー側の処理を実行できるサービスが用意されています。
ここではあまり多くは触れませんが、Cloud Functionsの特徴としては、Firestoreへの更新をトリガーにして
処理を実行できる点です。

image.png

こちらサーバー側で処理を行うので、どうしてもリアルタイムで反映すると言うことは厳しく
ある程度の時間差は発生します。

Firestoreの設計をどう使い分けるか?

設計の観点は多種多様だと思いますが、一旦は2点の基準でみればいいと思います!

  1. 変更のリアルタイム性と読み取り速度のどちらを優先するか?
  2. セキュリティールールは大丈夫か?

1. 変更のリアルタイム性と読み取り速度のどちらを優先するか?

  • 変更時にリアルタイムで反映して欲しい(正規化してデータを持つ)
  • 読み取り速度を早くして欲しい(非正規化してデータを持つ)

こちらの二つはそもそも設計が異なってしまうので、共存できないトレードオフな関係です。
なので要件でどちらを優先するのか?を考える必要があります。

今回のフォロー一覧画面のユーザー情報を例に考えてみます。
image.png

自分のユーザー情報を変更した時にフォロー一覧のユーザー情報をリアルタイムで変更しなければいけないのであれば
正規化してデータを持つほうが良いと思います。

一方で、フォロー一覧に表示されているユーザー情報にそこまでのリアルタイム性を求めていないのであれば
非正規化してデータを持ったほうが表示速度は早くなります。

どちらを優先するかは要件によると思いますので、
とりあえず正規化などはやめておいたほうがいいと思います。

非正規化する場合の注意点

非正規化する場合に注意点があります。
結論から言いますと、非正規化するデータの更新頻度です。

フォロー一覧のユーザー情報を例に出すと、
ユーザー情報の変更が頻繁に起こるかどうかです。

読み取り速度を優先して非正規化する場合、
Firestoreへの更新をトリガーにして、Cloud Functions(サーバーサイド)で
各フォロワーへのユーザー情報の更新を行うと思いますが、
ユーザー情報が頻繁に更新される場合は、こちらの処理が何度も行われることになります。

例えば
ユーザー情報が月100回行われるとして、
自分のフォロワーが1000人いる場合は
100 × 1000 = 100000回
の更新を毎月行うことになります。

おんなじ条件のユーザーがまた1000人いればさらに×1000と、どんどん倍々になっていくため
更新頻度が大きいかどうかなどは事前に推定しておく必要があります。

Twitterの例でいくと、
タイムラインのデータはいいね数やリツイート、コメント数があり更新頻度がかなり高いと思われるので
読み取り速度を早くした気持ちもわかりますが、注意が必要だと思います。
image.png

2.セキュリティールールは大丈夫か?

2つ目の観点はセキュリティルールについてです。
Firestoreのセキュリティルールはコレクション単位での設定することが多いです。
(ドキュメント単位でも設定は可能です)

- users
  - '1' (documentID)
    - 'inoue' (name)
    - 'https://sample.com/image/1' (imageUrl)
    - '井上です!趣味はゴルフです!' (profile_text)
    - ['2','4'] (follow_user_id) //配列(またはサブコレクション)でもつ
  - '2'
    - 'miyashita'
    - 'https://sample.com/image/2'
    - '政治が好きです。広告系で働いてます。'
    - ['3','4']
  - '3'
    - 'sakai'
    - 'https://sample.com/image/3'
    - 'マクロ経済学が好きです。'
  - '4'
    - 'takagi'
    - 'https://sample.com/image/4'
    - 'サブカルな音楽が好きです。'

上記のRDBのように正規化するで紹介した設計だと
ユーザー情報取得のセキュリティ条件をみたしている場合、
フォローしているユーザーの一覧データを誰でも取得できる状態です。

Twitterのようにフォロー一覧やフォロワー一覧は、フォローされているユーザーのみに公開(鍵アカウント)する
みたいな機能をつける場合は、配列ではなくサブコレクションで持たせて、そこにセキュリティルールを設定したほうがわかりやすいです。

設計した後にセキュリティルールを設定できない設計になってしまっては、かなり危ないので
こちらについても事前にセキュリティを設定できる設計になっているかを確認する必要があります。

最後に

Firestoreの設計について記載してみました!
RDBをかじったことがあったせいで非正規化することに対しての嫌悪感みたいなものがずっと抜けなかったんですが、
最近はやっと落ち着いてきました・・。

Firebaseのアドベントカレンダーの方でTwitterのデータ構造をFirebaseを使って設計してみたを書く予定なので
そちらもよかったらみてみてください!

参考記事

Firestoreのクライアントサイドジョインとは何かを解説します!
Firebaseでアプリを開発するならClient Side Joinを前提にすること
firestoreを本気で使ってみて知った勘違い3つほど

株式会社HAKATOMO tech blog

Discussion

猫チーズ猫チーズ

Firestoreのセキュリティルールはコレクション単位での設定になります。

コレクション単位で纏めてルールを書くことが多いのでこのように書いているのかもしれませんが、一応補足すると、下のようにドキュメント単位でもルール書けますよ👇

service cloud.firestore {
  match /databases/{database}/documents {
    match /cities/tokyo {
      allow read: if <condition>;
      allow write: if <condition>;
    }
  }
}

参照:
『基本的な読み書きのルール | Cloud Firestore セキュリティ ルールを構造化する | Firestore』
https://firebase.google.com/docs/firestore/security/rules-structure?hl=ja#basic_readwrite_rules