- 良いシステム設計とは、複雑に見えず、長期間にわたって特に問題が発生しない形を指す
- システム設計で最も難しいのは 状態(state) を扱うことであり、可能な限り状態を保存するコンポーネント数を減らす方向が重要
- データベースは主に状態が保管される場所であり、スキーマ設計とインデックス設計、ボトルネック解消に重点を置いたアプローチが必要
- キャッシュ、イベント処理、バックグラウンド作業 などは性能と保守性のために慎重に導入すべきであり、乱用は避けたほうがよい
- 複雑な設計よりも、十分に検証されたシンプルなコンポーネントや方法論を適切に使うことが、持続可能で安定したシステム構築の鍵
システム設計の定義と全体的なアプローチ
- ソフトウェア設計がコードの組み立てだとすれば、システム設計はさまざまなサービスを組み合わせるプロセス
- システム設計の主な構成要素には、アプリサーバー、データベース、キャッシュ、キュー、イベントバス、プロキシ などがある
- 良い設計は「特に問題がない」「思ったより簡単に終わる」「ここは気にしなくてよい」といった反応を引き出す
- 逆に、複雑で目立つ設計は、根本的な問題を隠していたり、過剰設計を示していたりする可能性がある
- 複雑なシステムは最初から導入するよりも、最小限で動作する単純な構造から徐々に発展させる方が有利
状態(state)とステートレス(stateless)の区別
- ソフトウェア設計で最も厄介なのが 状態管理
- 情報を保存せず即座に結果を返すサービス(GitHubのPDFレンダリングのようなもの)はステートレスである
- 一方で、データベースへの書き込みを行うサービスは状態を管理している
- システム内の状態保存コンポーネントは、できる限り減らしたほうがよい。これはシステムの 複雑性と障害発生可能性 を下げる
- 状態管理は1つのサービスだけが担い、他のサービスはAPI呼び出しやイベント発行など ステートレスな役割 に集中する構造が推奨される
データベース設計とボトルネック
スキーマとインデックスの設計
- データ保管のためには、人が読みやすい形のスキーマ設計 が必要
- 柔軟すぎるスキーマ(例: 全体をJSONカラムに保存する方式)は、アプリケーションコードや性能に負担を与える可能性がある
- クエリが頻繁に発生するカラムを基準に 適切なインデックスを設定 すべきであり、何にでもインデックスを張るのはむしろ無駄なオーバーヘッドになる
ボトルネックの解決方法
- データベースアクセスはしばしば重いボトルネック になる
- 可能な限り複雑なデータ処理はアプリケーションではなくデータベース内で JOIN などを使って処理 するほうが性能面で有利
- ORM使用時には、ループ内でクエリを発行してしまうミスに注意すべき
- 必要に応じてクエリを分割し、データベースの負荷やクエリの複雑さを調整するのも1つの方法
- 読み取りクエリはレプリカ(read-replica)に分散 し、主たる書き込みノードの負荷を下げる戦略が有効
- 大量のクエリが集中すると、トランザクションや書き込み処理はデータベースを簡単に過負荷にしうるため、クエリスロットリング(制限) の検討が必要
遅い処理と速い処理の分離
- ユーザーが操作する処理は、数百ミリ秒以内の応答 が必要
- 時間のかかる処理(例: 大容量PDF変換など)は、最小限の作業だけをフロントで即時提供 し、残りはバックグラウンドへ回すパターンが有効
- バックグラウンド処理 は、一般にキュー(例: Redis)とジョブランナーが組み合わさって動作する
- 遠い将来に予約された処理は、RedisよりもDBテーブルを別途作って管理し、スケジューラで実行する形が実用的
キャッシュ
- キャッシュ は、同一または高コストな計算を繰り返す場合に、コスト削減と性能向上に貢献する
- たいてい、キャッシュを最初に学んだ ジュニアエンジニアは何でもキャッシュ したがり、経験豊富なエンジニアほどキャッシュ導入には慎重
- キャッシュは新たな状態を導入するため、同期の問題・エラー・古いデータ(stale data)などのリスク がある
- まずクエリへのインデックス追加のような性能改善を試したうえで、キャッシュを適用するのが望ましい
- 大容量キャッシュはRedis/Memcachedではなく、S3/Azure Blob Storageのような オブジェクトストレージに定期保存 する方法も活用できる
イベント処理
- ほとんどの企業は イベントハブ(例: Kafka) を備え、さまざまなサービスがイベントベースで分散処理を行っている
- イベントを乱発するより、シンプルなリクエスト–レスポンスAPI設計のほうが ロギングや問題解決の面で有用
- イベントベース処理は、送信側が受信側の動作を気にしなくてよい場合、あるいは 高スループット・遅延許容シナリオ に適している
データ伝達方式: PushとPull
- データ伝達には Pull(要求後に応答) と Push(変更時に自動配信) の2つの方式がある
- Pull方式は単純だが、繰り返しリクエストや過負荷の問題が発生する
- Push方式はサーバーでデータ変更が起きた際にクライアントへ即座に配信するため、効率的で最新データの維持に有利
- 大量クライアントを扱うには、それぞれの方式に応じてインフラ(イベントキュー、複数のキャッシュサーバーなど)の拡張が必要
ホットパス(Hot Paths)への集中
- ホットパス とは、システム内で最も重要で、データが多く流れる経路を意味する
- ホットパスは選択肢が少なく、設計に失敗すると サービス全体に深刻な問題 を引き起こしかねないため、慎重な設計が必須
- 選択肢の多い小規模機能よりも、ホットパスに集中して設計とテスト にリソースを配分するほうが効果的
ロギング、メトリクス、トレーシング
- 障害発生時の原因診断のため、異常系(unhappy path)に対する詳細ログ を積極的に記録すべき
- システム資源(CPU/メモリ)、キューサイズ、リクエスト/処理時間など、基本的な 可観測性指標の収集が必要
- 平均値だけでなく、p95、p99レイテンシのような分布指標 も必ず観測すべき。上位少数の遅いリクエストが主要ユーザーの問題かもしれない
キルスイッチ、リトライ、障害復旧
- キルスイッチ(システムの一時停止) と、リトライの戦略的活用が重要
- むやみなリトライは他サービスに負担をかけるだけで、事前にサーキットブレーカー(circuit breaker) などでリクエストを制御してこそ効果がある
- Idempotency Key(冪等キー) を導入すれば、同一リクエスト再処理時の重複作業を防げる
- 一部の障害状況では フェイルオープン(fail open) またはフェイルクローズド(fail closed) の選択が必要。たとえばRate Limitingはfail open(許可)のほうがユーザー影響が小さい。一方で認証はfail closedが必須
まとめ
- サービス分離、コンテナ、VM導入、トレーシングなど一部の話題は省かれているが、十分に検証されたコンポーネント を適材適所で使うことが、長期的に最も安定したシステム構築につながる
- 技術的に特別な設計は実際には非常にまれであり、退屈なくらいシンプルな設計 が、むしろ実務で最もよく使われる
- 本質的に良いシステム設計とは、目立たず、十分に実証された方法論を安全に組み合わせるプロセスである
1件のコメント
Hacker Newsのコメント
この点では自分だけのように感じることがよくある。エンジニアは複雑なシステムを見ると興味深い要素が多くて、「ここで本物のシステム設計が行われている!」と思いがちだが、実際には複雑なシステムは良い設計が欠けている結果であることが多い。求職者なら、この事実は面接中だけは完全に忘れたほうがいい。自分もシステム設計面接でこうした考えを正直に伝えて失敗したことがある。架空のスタートアップアプリの面接で、「この程度のQPSならbackpressureは気にしなくていい」「cron jobの代わりにキューを使う必要はない、もちろんトレードオフはある」「SQL vs NoSQL? チームがいちばんよく知っているものを使えばいい」といった答えをしたが、面接官はこういう答えを求めていない。ホワイトボードを埋め尽くし、KubernetesがKubernetesを管理するレベルの複雑な設計を見せてこそ、相手が欲しがるシグナルを出せる
何百回もシステム設計面接を見て、何人もトレーニングしてきた立場から言う。君が挙げた答えはシグナルが弱い(キューに関する答えは例外)が、面接官が本当に知りたいのは、なぜその判断をしたのか、どんな要素を考慮したのか、つまり思考過程だ。答えを詳しく説明しなければ、面接官からすると「得られる情報があまりない」と受け取られやすい。だから候補者は、面接官が欲しい情報を積極的に渡す必要がある。また、良い面接官であっても、答えを無理に引き出さなければならないなら、「説明は筋が通っているがコミュニケーションが非効率」とメモするだろう。コミュニケーション能力も評価対象だ。最後に、SQL/NoSQLの答えには同意しない。チーム経験も重要だが、技術ごとの差は明確で、状況によって性能差も大きい。その答えでは、さまざまな状況での経験が不足している印象を与える
「面接は双方向」という言葉どおり、君の答えはとても合理的だと思う。自分が面接官ならむしろ高評価を付ける。一方で、その答えで不採用にする会社なら、むしろその会社のほうがいまひとつである可能性が高い。ただ現実には早く職を決めなければならないことも多いので、バランスを取って相手が求める方向に答えを合わせる必要もある
この助言はよくない。シンプルでありながらエレガントな設計は、潜在的な問題を無視することから始まるわけではない。掘り下げ質問は、技術トリビアを並べる時間ではなく、議論しようという合図だ。君の答えは賢さを示しておらず、まだ未熟だという印象を与える。面接官のせいではない
隣のコメントが指摘していた「面接は双方向」という点には共感するが、良い面接官なら「その答えも良いが、今はこのテーマで知識をテストしている」と率直に言ってくれるはずだ。自分が的外れな話ばかり続けるのは、むしろ不安なシグナルだ
LinkedIn-driven developmentがなぜ存在するのかをそのまま示している例だと思う。大量の技術をCVに並べるほうが、1つのPostgresとモジュラーモノリスをうまく使ったと説明するより、ずっと見栄えがいいのが現実だ
本当に良い記事だと思う。ただ、こうしたベストプラクティスには限界もあることに触れておきたい。たとえば「異なる5つのサービスが1つのテーブルに書き込むのではなく、4つはAPI呼び出しかイベント送信だけを行い、1つのサービスだけがテーブルに書き込むべきだ」という助言があるが、現実はそこまできれいに分かれない。5つすべてがDBにアクセスするなら、すでに分散システムを作っているようなものだが、DBはもともと権限・トランザクション・カスタムクエリを支援するので、別のインターフェース設計が不要でもある。一方で、1つのサービスに高水準のインターフェースを作ると、今度は認証・トランザクション・例外処理まで自前で実装しなければならない。実質的には、障害モードと複雑なマイクロサービス運用税が増えるだけではないかという疑問がある。もっとも、複数サービスが1つのDBにアクセスしていること自体がコードスメルなのかもしれない。おそらくこのDBは複数のものが合体した名残で、サービスも実際には2つか3つに減らせるのかもしれない
「何が得られるのか」という問いに対しては、APIは共有DBスキーマを使うよりも、変化への適応力がはるかに高い。いろいろなシステムで働いてきた経験から言って、1つのDBを複数サービスで共有する構成はもう二度と設計しないだろう。2000年代初頭の小規模企業では問題なかったかもしれないが、それ以降は失敗例しか見ていない(同一サービス内で読み取り経路と書き込み経路だけが分かれている場合は例外)
DBがインターフェースだから別途設計はいらない、という主張には同意しない。複数クライアントが同じDBを使うとアクセスパターンが異なり、マイグレーションの問題も大きくなる。結局、ビューや権限管理など追加の設計が必要になり、保守負担も増える。理想的な状況ではAPIのほうがずっときれいだ。現実には、機能を早く出す圧力のせいで近道としてDB直接アクセスを許してしまうが、根本には新しい要件や設計に合わせて全体を作り直すことを多くの人が嫌がるという事情がある
変更が必要になったときの目標は、調整が必要な範囲を最小化することだ。データストアの構造を変える必要があるとき、そのデータストアにアクセスするすべての部分を制御しなければならないので、アクセス経路が少ないほど変更しやすい。たとえば実務でDBを分割したところ、40を超えるチームがコードを修正しなければならなかった。こうした変更が「機能要求」のためでさえこの規模だ。もし「スケーラビリティ」の問題だったなら、製品そのものが壊れていたかもしれない
複数サービスを1つのDBにぶら下げる構成を「コードスメル」と呼んでいたが、逆に各サービスへ物理的に別々のDBを与えなければならないとすると、可用性はNからNのM乗へと増えて、実際にはもっと不安定になるかもしれない(DBクラスタ単位で話すなら)
データベースを問い合わせるなら、本当にDBに問い合わせるのが最も効率的だ。複数テーブルのデータが必要なら、アプリケーション側で個別に問い合わせて結合するより、joinを使うべきだ。そしてビューや、さらにはストアドプロシージャの利用も強く勧める。ビューはデータ抽象化レイヤーなので設計に大きく役立つし、SQLコードも上手く書けば理解しやすく保守しやすい
この点こそORMsが多くの問題を引き起こす理由だ。SSR環境の各MVCビューでSQLビューやカスタムクエリを直接使うことこそ、大規模なWebサービスを効率的かつエレガントにする方法だ。重い処理はRDBMSに任せ、WebサーバーはSQLの結果をそのままテーブルに渡せばいい。MSSQLやOracleのようなレガシーRDBMSには組み込み最適化が非常に多いからだ。一方、ORMsは単一のオブジェクトモデルを強制するため、ほとんど柔軟性がない
ストアドプロシージャは便利そうに見えるが、実際には言語(T-SQLなど)の制約のため、チーム全員が慣れている最新言語に統一して開発しにくい。大規模なT-SQLコードベースを保守しているが、バージョン管理や診断ツールもいまひとつで、新人のコードはまだ読めても、T-SQLは悪夢だ
私は同意しない。現代的なスケーラビリティ重視アーキテクチャでは、joinはDBの手前のバックエンドで行うほうがよい。DBには単純なインデックス検索だけを任せ、joinはバックエンドで行うように構成すれば、DBのスケーラビリティも良くなり、速度も上がる。サーバーインスタンスを増やすほうがDBを増やすより簡単だからだ。joinがDBでしか扱えないほど莫大なデータになるなら、その時点で構造を変えるべきだ。フロントエンド側までjoinを移せるなら、結果キャッシュの面でも有利になる
本当にそうだろうか。たとえば顧客1万人、注文100万件で、顧客20フィールド+注文5フィールドのテーブルを全部joinして送ると2500万フィールドを送ることになる。2つのクエリで独立に取得してjoinすれば、注文500万フィールド+顧客20万フィールドになる。帯域や性能の面ではこちらのほうがずっと良い
このルールは出発点としては悪くないが、いつ例外が必要かをよく知っておく必要がある。自分が担当したアプリは、joinのせいでレコード数が幾何級数的に膨れ上がる構造だった。そこでクエリを分けたら、ネットワークオーバーヘッドよりも結果の加工やフィルタリングの利益のほうが大きく、ずっと速くなった。その後、すべてのデータをJSONBで保存する構造に変えたら、さらに良くなった
良いシステム設計について語りながら、肝心の問題領域にまったく触れていない点が残念だと思う。システム設計で最も中核的で難しいのは、システムがユーザーに提供するインターフェースだ。結局のところソフトウェアシステムは、「この機能を提供する代わりに、こういう構造やモデルを理解してもらう」という問題の交換だ。インターフェース設計の失敗が最大のコストであり、時間の大半をインターフェースの議論に使っていないなら、本当に重要なものを見落としている。そのほかのシステム要素は、あとからユーザーに触れさせずにいくらでも直せる
「良い設計は目立たず、悪い設計のほうがむしろもっともらしく見える」という言葉には、現場感覚として強く共感する。技術者の評価が「複雑さ」を基準に行われることで、過剰設計を奨励する構造になってしまっているようだ。KISS原則は長いあいだ十分に認識されていない
ときどきコードベースを見返して、特に何も考えず通り過ぎていた部分を振り返ることがあるが、そういうところこそ良い設計の痕跡だったりする
これは残念ながら本当だ。大半の人は複雑な解決策のほうに魅力を感じ、単純な答えを出すと無能に見えるという印象さえある。しかし現実には、管理しやすい単純な構造のほうが、プロジェクト全体の成功により大きく貢献する。もちろん本質的に複雑な問題もあるが、ほとんどはただの普通のWebアプリだ
スキーマ設計で最も重要なのは柔軟性だ。データが蓄積するとスキーマ変更が非常に難しくなるからだ。しかし、あまりに柔軟に設計しすぎると(すべてのデータをJSONに入れたりEAV構造にしたり!)、アプリケーションコードが際限なく複雑になり、奇妙な性能問題まで増える。だから普通は、テーブル構造を見るだけで何の用途か直感的に分かるくらい、人間が読みやすいスキーマを好む。EAVやJSONカラム/テーブルを頻繁に見ると、本当に開発をやめたくなる。もちろんEAVにも役立つケースはあるが、ほとんどの場合は現場に混乱をもたらすだけだ。N+1問題、クエリ文の動的生成、監査データを同じDBに保存してそれがビジネスロジックに吸収されるパターン、複雑なOracle環境、そして何をDBに置いて何をアプリに置くかを誤って切り分ける設計など、こうした変数の一つひとつが開発者の生活の質を大きく削っていく
これに関連して、Bill Karwinの『SQL Antipatterns』という本は、EAVパターンの危険性と限界をよく紹介している。それでも、ときにはスキーマを描きにくい場合に(PostgresならJSONBカラムなどで)一時しのぎとして使えることはあるが、模範ルールにはなりえない。正規化できるなら、常に正規化を選ぶのがよい
「監査データを同じDBに保存すると、それが結局ビジネスロジックの一部になって困る」という点について、では「正攻法」は何なのか気になる。別DBか、完全に独立したストレージか?
「5つのサービスが同じテーブルに書くのを避け、4つはAPI呼び出しかイベント送信だけにして、1つだけが直接DBに書くべきだ」という助言について、最善はそもそも5つのサービスが同じテーブルに書く必要がない構造にすることだ。もし書いているなら、実際にはサービス間のロジックがかなり重なっている可能性がある。ならば、その5つのサービスが本当にすべて別である必要があるのか、1つに統合できないのかを考えるべきだ。実際には、別のデータテーブルを与えるか、リファクタリングによってむしろ問題を解決できることがある
状態あり(stateful)/状態なし(stateless)の区別は、インフラと開発責任を分けるうえで核心になる。コンテナでStatelessに動かす場合は、壊れうるものがそれほど多くないので、失敗しても再デプロイすればよい。データセットを破壊するレベルのDBミスさえ避ければ、たいていは素早く復旧できる。キャリア経験、時間、勤勉さがさまざまな人材でも、ここまでは何とかなる。だが、データベースやファイルストレージなどStateを持つ領域はまったく別だ。1つのミスで事業全体が危険にさらされることもあるため、実務経験が豊富な専任人材が担当すべきだ。DBは問題なく動いていても、バックアップがなければそれだけで大きなリスクである。実際、こうした領域は数分でデプロイしても解決しない問題だ
「boolではなくtimestampで表せ」という助言については、少し包括的すぎる指針ではないかと思う。たとえば is_on → true、on_at → 1023030 は明快だが、is_a_bear → true、a_bear_at → 12312231231 は妙だ。たいていのクマはいつクマになったかを記録するものではないのだから……。特定の状況にしか当てはまらない話だ
ほとんどすべての場合で、booleanよりtimestampやintegerを使うほうがよいと思う。特に状態が2つしかないフィールドは、たいてい後で「型の分類」に発展することが多い。たとえばクマしかいないとしても、enum型へ拡張できる可能性を見越すほうがいいし、状態フィールドも単純な有効/無効だけでなく、停止、削除、一時停止など様々な状態へ広がるので、booleanが増えるとかえって複雑になる。integerのほうがよい
命題どおりに受け取るなら、DBでbooleanを使うこと自体が臭いということになるが、それには同意する。ただ、このアプローチ(boolをtimestampへ置き換えること自体)はjoinでは便宜的に使える場合が多いだけで、いわゆる「完結した解法」ではない。リアルタイムの変更が重要なら、そもそも監査テーブルが正しい。soft deleteも同様に中途半端な解法だと思う。本当の意図は削除を防ぐことだが、実際にはバックアップと復元のほうがより効果的な保護になる
boolean型はデータサイズが小さいため、一部のワークロード(分析用の大量データなど)では効率的だ。論理的にbooleanを保存するのが正しい場合もある。たとえば、プロセス結果(成功/失敗の記録)はbooleanが実用的だ
わざわざbooleanだけをtimestampにする理由があるのか疑問だ。isDarkTheme や paginationItems でも変更時刻を知りたいことはある。要するに poor-man changelog のようなものに感じる
そういう場合は、Bear のようなenum値を使うほうがよい
良いシステム設計について、もう少し抽象的な観点から学べる本を探しているなら、John Gallの Systemantics を強く勧める。エンジニアとして読む価値がある