RDB脳からNoSQL脳に切り替える非正規化の思想
この記事は、ニフティグループ Advent Calendar 2023 16日目の記事です。
こんにちは。システム開発部のsonohaです。ニフティ温泉のWEBサイトの開発全般を担当しています。
普段はデータベースとして主にRDB(リレーショナルデータベース)を使っており、入社5年目にしてようやく設計やチューニングに慣れてきました。
一方、NoSQLについてはこれまでの担当業務ではあまり触れてこなかったのですが、弊社の共通会員基盤で使われていたり、プライベートでは触れる機会があったりしたので、満を持して少し勉強してみることにしました。
今回は、勉強の過程で得た知見の中から、特に非正規化について学びのあった点をまとめていきます。
目次
正規化・非正規化とは
データベースにおける正規化とは、端的に言えば「複数箇所で同じデータを持たないようにする」ことです。
例えば、会社の従業員をデータベースで管理するとします。
従業員は課に属しており、課は部に属しています。これをひとつのテーブルで管理しようとすると、次のようになります。
※実際の組織体制とは一切関係ありません。
employee_id | employee_name | section_id | section_name | department_id | department_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_id | employee_name | section_id |
---|---|---|
1 | 温泉太郎 | 1 |
2 | 温泉花子 | 2 |
3 | 温泉次郎 | 2 |
4 | 不動太郎 | 3 |
5 | 不動花子 | 4 |
6 | 出島太郎 | 5 |
… | … | … |
section_id | section_name | department_id |
---|---|---|
1 | 温泉企画課 | 1 |
2 | 温泉開発課 | 1 |
3 | 不動産企画課 | 2 |
4 | 不動産開発課 | 2 |
5 | デジタルマーケ企画課 | 3 |
… | … | … |
department_id | department_name |
---|---|
1 | ウェルネステック部 |
2 | 不動産テック部 |
3 | クロステック部 |
… | … |
ことRDBについては、極力正規化することが良しとされています。正規化することでデータ整合性を担保しつつ、ストレージ容量も最適化できるのです。
※場合によっては、設計次第であえて非正規化することもあります。
正規化についてもう少し詳しく知りたい場合は、Microsoftのドキュメントが簡潔かつ詳しくまとまっているのでご参考ください。
参考:データベースの正規化の基礎 | Microsoft Learn
実際に非正規化してみる
前提・課題設定
NoSQLとは非リレーショナルデータベース全般を指しますが、ここでは特にキーバリュー型およびドキュメント型データベースを取り上げます。
キーバリュー型データベースの代表例としてはRedisやAmazon DynamoDB、ドキュメント型データベースの代表例としてはMongoDBやCloud Firestoreが挙げられます。
シンプルなSNSアプリケーションを想像しながらデータベース設計をしてみましょう。
- ユーザーは名前やプロフィールなどの情報を持つ
- ユーザーは他のユーザーをフォローできる
- ユーザーは他のユーザーからフォローされる
- ユーザーは200字程度の文章を1単位として投稿できる
- ユーザーはフォロー中のユーザー全員の投稿を時系列順に並べて閲覧できる
- ユーザー1人の投稿に絞って時系列順に並べて閲覧もできる
- 総ユーザー数は数十万から数百万
- ユーザーあたりのフォロイー/フォロワー数はそれぞれ数十から数千
- ユーザーあたりの投稿数は無制限
RDBの場合
ユーザーの基本情報テーブル、ユーザーの投稿テーブル、フォロー/フォロイーの関係を示す交差テーブルの3つに正規化できそうです。
場合によってはさらに分割できるかもしれませんが、ここでは簡単にするために3つで考えます。
user_id | user_name | profile | icon_image_url | created_at | updated_at |
---|---|---|---|---|---|
1 | 温泉太郎 | 温泉が好きです | xxx | XXXX-XX-XX XX:XX:XX | XXXX-XX-XX XX:XX:XX |
2 | 温泉花子 | サウナが好きです | xxx | XXXX-XX-XX XX:XX:XX | XXXX-XX-XX XX:XX:XX |
… | … | … | … | … | … |
post_id | posted_user_id | text | created_at |
---|---|---|---|
1 | 1 | 今日はどこの温泉に行こうかな | XXXX-XX-XX XX:XX:XX |
2 | 1 | 箱根が良いかな | XXXX-XX-XX XX:XX:XX |
… | … | … | … |
user_id | following_user_id | created_at |
---|---|---|
2 | 1 | XXXX-XX-XX XX:XX:XX |
2 | 3 | XXXX-XX-XX XX:XX:XX |
… | … | … |
投稿の一覧を取得したい場合はテーブル同士を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
の中から該当のユーザーを参照しているfollowings
とfollowing_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の思想
- データの取得時ではなく追加更新時に負荷を寄せる
- そのために、取得したい形式にデータを非正規化して保持しておく
- 注意点
- ユースケースを掘り下げ、以下をクリアにしておくべし
- 具体的に何のデータをどんな形式で取得したいか
- 「結果整合性」を許容できるか
- ユースケースを掘り下げ、以下をクリアにしておくべし
個人的にはちょっとしたカルチャーショックだったので、今回の学びを今後の技術選定や設計に活かしていければと思います。
以上、ご参考になりましたら幸いです。
最後までお読みいただきありがとうございました!
その他の参考文献
掲載内容は、記事執筆時点の情報をもとにしています。