1. Home
  2. テクノロジー
  3. RDB脳からNoSQL脳に切り替える非正規化の思想

RDB脳からNoSQL脳に切り替える非正規化の思想

この記事は、ニフティグループ Advent Calendar 2023 16日目の記事です。

こんにちは。システム開発部のsonohaです。ニフティ温泉のWEBサイトの開発全般を担当しています。

普段はデータベースとして主にRDB(リレーショナルデータベース)を使っており、入社5年目にしてようやく設計やチューニングに慣れてきました。
一方、NoSQLについてはこれまでの担当業務ではあまり触れてこなかったのですが、弊社の共通会員基盤で使われていたり、プライベートでは触れる機会があったりしたので、満を持して少し勉強してみることにしました。
今回は、勉強の過程で得た知見の中から、特に非正規化について学びのあった点をまとめていきます。

目次

正規化・非正規化とは

データベースにおける正規化とは、端的に言えば「複数箇所で同じデータを持たないようにする」ことです。

例えば、会社の従業員をデータベースで管理するとします。
従業員は課に属しており、課は部に属しています。これをひとつのテーブルで管理しようとすると、次のようになります。
※実際の組織体制とは一切関係ありません。

employee_idemployee_namesection_idsection_namedepartment_iddepartment_name
1温泉太郎1温泉企画課1ウェルネステック部
2温泉花子2温泉開発課1ウェルネステック部
3温泉次郎2温泉開発課1ウェルネステック部
4不動太郎3不動産企画課2不動産テック部
5不動花子4不動産開発課2不動産テック部
6出島太郎5デジタルマーケ企画課3クロステック部

ここで、「温泉開発課」が「温泉システム開発課」に改名となったとします。
すると、section_idが2であるようなレコードを全て探し出してsection_nameを変更する必要があります。
ひとつでも更新漏れがあるとデータ不整合が生じてしまいます。

このように、同じものを表すデータが複数箇所に冗長に保持されている状態にすることを「非正規化」と呼びます。

逆に「正規化」とは、データを冗長に持たずに各データに唯一性を持たせることです。
先の例で正規化を行うと、ただひとつのレコードを更新するだけで良くなります。

employee_idemployee_namesection_id
1温泉太郎1
2温泉花子2
3温泉次郎2
4不動太郎3
5不動花子4
6出島太郎5
employees
section_idsection_namedepartment_id
1温泉企画課1
2温泉開発課1
3不動産企画課2
4不動産開発課2
5デジタルマーケ企画課3
sections
department_iddepartment_name
1ウェルネステック部
2不動産テック部
3クロステック部
departments

ことRDBについては、極力正規化することが良しとされています。正規化することでデータ整合性を担保しつつ、ストレージ容量も最適化できるのです。
※場合によっては、設計次第であえて非正規化することもあります。

正規化についてもう少し詳しく知りたい場合は、Microsoftのドキュメントが簡潔かつ詳しくまとまっているのでご参考ください。
参考:データベースの正規化の基礎 | Microsoft Learn

実際に非正規化してみる

前提・課題設定

NoSQLとは非リレーショナルデータベース全般を指しますが、ここでは特にキーバリュー型およびドキュメント型データベースを取り上げます。
キーバリュー型データベースの代表例としてはRedisやAmazon DynamoDB、ドキュメント型データベースの代表例としてはMongoDBやCloud Firestoreが挙げられます。

シンプルなSNSアプリケーションを想像しながらデータベース設計をしてみましょう。

  • ユーザーは名前やプロフィールなどの情報を持つ
  • ユーザーは他のユーザーをフォローできる
  • ユーザーは他のユーザーからフォローされる
  • ユーザーは200字程度の文章を1単位として投稿できる
  • ユーザーはフォロー中のユーザー全員の投稿を時系列順に並べて閲覧できる
  • ユーザー1人の投稿に絞って時系列順に並べて閲覧もできる
  • 総ユーザー数は数十万から数百万
  • ユーザーあたりのフォロイー/フォロワー数はそれぞれ数十から数千
  • ユーザーあたりの投稿数は無制限

RDBの場合

ユーザーの基本情報テーブル、ユーザーの投稿テーブル、フォロー/フォロイーの関係を示す交差テーブルの3つに正規化できそうです。
場合によってはさらに分割できるかもしれませんが、ここでは簡単にするために3つで考えます。

user_iduser_nameprofileicon_image_urlcreated_atupdated_at
1温泉太郎温泉が好きですxxxXXXX-XX-XX XX:XX:XXXXXX-XX-XX XX:XX:XX
2温泉花子サウナが好きですxxxXXXX-XX-XX XX:XX:XXXXXX-XX-XX XX:XX:XX
users
post_idposted_user_idtextcreated_at
11今日はどこの温泉に行こうかなXXXX-XX-XX XX:XX:XX
21箱根が良いかなXXXX-XX-XX XX:XX:XX
posts
user_idfollowing_user_idcreated_at
21XXXX-XX-XX XX:XX:XX
23XXXX-XX-XX XX:XX:XX
followings

投稿の一覧を取得したい場合はテーブル同士をJOINします。

-- ある特定のユーザーの投稿を取得
SELECT
  posts.post_id,
  users.user_id,
  users.user_name,
  posts.text
FROM users
INNER JOIN posts
  ON users.user_id = posts.posted_user_id
  AND users.user_id = 12345
ORDER BY posts.created_at DESC
LIMIT 100;

-- フォロー中のユーザーの投稿を取得
SELECT
  posts.post_id,
  users.user_id,
  users.user_name,
  posts.text
FROM users
INNER JOIN posts
  ON users.user_id = posts.posted_user_id
  AND users.user_id IN (
    SELECT following_user_id
    FROM followings
    WHERE user_id = 12345
  )
ORDER BY posts.created_at DESC
LIMIT 100;

RDBの設計をそのままNoSQLに適用する場合

上で考えたテーブル設計をそのままNoSQLに適用してみましょう。
次のようなモデル設計になるはずです。

users:
  collections:
    # nothing
  fields:
    user_id: integer
    user_name: string
    profile: string
    icon_image_url: string
    created_at: timestamp
    updated_at: timestamp

posts:
  collections:
    # nothing
  fields:
    post_id: integer
    posted_user_id: integer
    text: string
    created_at: timestamp

followings:
  collections:
    # nothing
  fields:
    user_id: integer
    following_user_id: integer
    created_at: timestamp

この設計で投稿の一覧を取得をしようとすると、NoSQLでは基本的にJOINの機能が提供されないため、アプリケーション側の処理が冗長になります。
これを擬似コードで表現すると、次のようなイメージです。

//フォロー中のユーザーの投稿を取得

const followingUserIds = [];
db.collection('follwings')
  .where('user_id = 12345')
  .get()
  .then(results => {
    results.data.forEach(result => {
      followingUserIds.push(result);
    });
  });

const posts = [];
db.collection('users')
  .where('user_id in ${followingUserIds}')
  .orderBy('created_at desc')
  .limit(100)
  .get()
  .then(results => {
    posts = results.data;
  });

const postedUserIds = [];
posts.forEach(post => {
  postedUserIds.push(post.postedUserId);
});

const users = [];
db.collection('users')
  .where('user_id in ${postedUserIds}')
  .get().then(results => {
    users = results.data;
  });

DBへのアクセスが3回発生し、そのたびにアプリケーション側で結果の保持や加工を行わなければなりません。
レコードの数や加工の内容次第では、かなり無駄にメモリなどのリソースを消費することにもなりかねせん。

NoSQLの思想に沿って非正規化

では、非正規化を用いてアプリケーション側が苦しまないようなモデル設計にしてみましょう。

まずはfollowingsをユーザーに持たせます。
フォローの情報を知りたいときは、必ず「あるユーザーの」であることが前提となるはずだからです。

users:
  collections:
    followings:
      collections:
        # nothing
      fields:
        user_id: integer
        followed_at: timestamp
  fields:
    user_id: integer
    user_name: string
    profile: string
    icon_image_url: string
    created_at: timestamp
    updated_at: timestamp

posts:
  collections:
    # nothing
  fields:
    post_id: integer
    posted_user_id: integer
    text: string
    created_at: timestamp

フォロー中のユーザーのIDのみが必要なケースは少ないはずなので、ユーザー情報も一緒に持たせます。
これにより1ユーザーの情報が複数箇所に登場することになりますが、これこそが非正規化です。

users:
  collections:
    followings:
      collections:
        # nothing
      fields:
        user_id: integer
        user_name: string
        profile: string
        icon_image_url: string
        followed_at: timestamp
        created_at: timestamp
        updated_at: timestamp
  fields:
    user_id: integer
    user_name: string
    profile: string
    icon_image_url: string
    created_at: timestamp
    updated_at: timestamp

posts:
  collections:
    # nothing
  fields:
    post_id: integer
    posted_user_id: integer
    text: string
    created_at: timestamp

これでユーザーに紐づくユーザー情報は一発で取得できるようになりましたが、投稿を取得しようとするとアプリケーション側の処理量は変わりません。

そこで、postsのうち、そのユーザーがフォロー中のユーザーによる投稿のみをユーザーに持たせます。
ただし、元のpostsも全体検索などで必要と考えられるため、両方を残します。これにより、同一の投稿データが複数箇所に存在することになります。

users:
  collections:
    followings:
      collections:
        # nothing
      fields:
        user_id: integer
        user_name: string
        profile: string
        icon_image_url: string
        followed_at: timestamp
        created_at: timestamp
        updated_at: timestamp
    following_posts: # limit 1000
      collections:
        # nothing
      fields:
        post_id: integer
        user_id: integer
        user_name: string
        icon_image_url: string
        text: string
        posted_at: timestamp
        created_at: timestamp
        updated_at: timestamp
  fields:
    user_id: integer
    user_name: string
    profile: string
    icon_image_url: string
    created_at: timestamp
    updated_at: timestamp

posts:
  collections:
    # nothing
  fields:
    post_id: integer
    user_id: integer
    user_name: string
    icon_image_url: string
    text: string
    created_at: timestamp
    updated_at: timestamp

これにより、一発でフォロー中のユーザーの投稿を取得できるようになりました。

また、ある特定のユーザーの投稿を一発で取得できるようにするためには、さらにユーザーに自分自身の投稿一覧を持たせることで解決できます。

users:
  collections:
    followings:
      collections:
        # nothing
      fields:
        user_id: integer
        user_name: string
        profile: string
        icon_image_url: string
        followed_at: timestamp
        created_at: timestamp
        updated_at: timestamp
    own_posts:
      collections:
        # nothing
      fields:
        post_id: integer
        text: string
        posted_at: timestamp
        created_at: timestamp
    following_posts: # limit 1000
      collections:
        # nothing
      fields:
        post_id: integer
        user_id: integer
        user_name: string
        icon_image_url: string
        text: string
        posted_at: timestamp
        created_at: timestamp
        updated_at: timestamp
  fields:
    user_id: integer
    user_name: string
    profile: string
    icon_image_url: string
    created_at: timestamp
    updated_at: timestamp

posts:
  collections:
    # nothing
  fields:
    post_id: integer
    user_id: integer
    user_name: string
    icon_image_url: string
    text: string
    created_at: timestamp
    updated_at: timestamp

RDB脳からすると複数箇所に何度も同じデータ(例えばuser_name)が登場して気持ちが悪い感じがしますが、NoSQLにおいてはこれは日常茶飯事のようです。
今回は説明用に非正規化しすぎている側面もありつつ、例えば過去のX(旧Twitter)の公演を要約している記事「Design twitter timeline. problem statement | Medium」を参考にすると、どうやらXのタイムラインも遠からずな設計をしていたようですね。

気づき・学び

負荷をどこに寄せるのかの違いである

上の例ではデータ取得の最適化にフォーカスして非正規化を行なっていきました。
その結果、取得はスムーズに行えるデータになったものの、データの追加や更新については苦しい設計になったように見えます。
例えば、ユーザーが表示名を変えたとすると、posts内の該当のユーザーの投稿を全て探し出してuser_nameをアップデートする、さらにusersの中から該当のユーザーを参照しているfollowingsfollowing_postsの項目を全て探し出してuser_nameをアップデートする…、といったように相当数の書き込みが発生します。

これは、データを取り扱う際に発生する負荷を、データ取得時に寄せるのか、データ追加更新時に寄せるのか、という思想の違いによるものだと理解しています。

RDBにおけるデータの追加はかなりシンプルで、テーブルが順当に正規化されているのであれば、あるデータを追加する際には関連テーブルに1行ずつレコードを追加をすれば完了です。
逆に、データを取得する際には、散らばっている個別のデータの中から必要なものを結びつけて加工して絞り込んでさらに結合して…としてようやく目的のデータが取得できます。
つまりRDBは、負荷をデータ追加更新時ではなくデータ取得時に寄せていると見ることができます。

NoSQLにおいては、データ取得が一発で完了するようにモデル構造を非正規化してあるので、データ取得は簡単です。
一方で、データの追加更新については散り散りになったデータたちの中から適切な項目を見つけ出して何百何千回と更新をしていく必要があります。
つまりNoSQLは、負荷をデータ取得時ではなくデータ追加更新時に寄せていると見ることができます。
(これを、取得時にJOINするのではなく保存時にJOINする実行時にJOINするのではなく設計時にJOINする、と表現する方もいます。)

あらゆるアプリケーションでは一般的に追加更新の頻度よりも取得の頻度の方が圧倒的に多いので、データ追加更新時に負荷を寄せるという思想はなるほどと思いました。

RDB以上にユースケースの検討が重要である

注意すべきは、NoSQLがデータ追加更新時に負荷を寄せるといっても、手放しにパフォーマンスを犠牲にするような思想ではないという点です。

NoSQLのデータ追加更新は非同期処理的に行われるイメージで、追加更新をリクエストしたクライアントへのレスポンスは迅速に、その後順々にデータを追加更新していくような動きになります。
これにより、一時的に正しくないデータが共存すること(こちらの投稿に紐づくuser_nameは新しくなったのに、別の投稿に紐づくuser_nameは古いまま!といった状態)がありますが、これは「結果整合性」と呼ばれるもので、なんだかんだあっても最終的に正しいデータに落ち着くのであればその過程で一時的に不整合が発生することは許容するという考え方です。
SNSのようなアプリケーションでは全てのユーザー名がマイクロ秒以下の精度で正確に同時に切り替わる必要はないので、この「結果整合性」を許容することで、取得も追加更新もユーザー体験を損なわないようなパフォーマンスでアプリケーションを展開することができるのです。

「結果整合性」を許容できるかどうかについては、アプリケーションの性質によるところなので、今自分たちが作ろうとしているアプリケーションの性質、解決しようとしている課題、ドメインの特徴をしっかりと理解することが、DBの技術選定では重要であると改めて感じました。

また、NoSQLのモデル設計では非正規化を駆使しますが、これは「どのようなデータが必要なのか」が予め分かっていないと成立しません。
RDBでは「とりあえず綺麗にデータを貯めておいて、使う時によしなに整形しよう」という発想も可能ですが、NoSQLにおいてはそれは通用しないということです。
どのようなデータをどのような形式で、どんな規模や頻度で、いつどんなふうに使うのか、ユースケースをコーディングレベルの詳細設計にまで落とし込んではじめて、NoSQLのモデル設計最適化のスタートラインに立てるように思いました。

まとめ

今回は、RDB脳である私がNoSQLのモデル設計思想について、特に非正規化の観点から得た気づきをまとめていきました。

改めて整理すると、次の通りです。

  • NoSQLの思想
    • データの取得時ではなく追加更新時に負荷を寄せる
    • そのために、取得したい形式にデータを非正規化して保持しておく
  • 注意点
    • ユースケースを掘り下げ、以下をクリアにしておくべし
      • 具体的に何のデータをどんな形式で取得したいか
      • 「結果整合性」を許容できるか

個人的にはちょっとしたカルチャーショックだったので、今回の学びを今後の技術選定や設計に活かしていければと思います。

以上、ご参考になりましたら幸いです。
最後までお読みいただきありがとうございました!

その他の参考文献

この記事をシェア

掲載内容は、記事執筆時点の情報をもとにしています。