世界を動かす技術を、日本語で。

良いシステム設計について私が知っているすべてのこと

概要

  • システム設計に関する誤ったアドバイスの多さ
  • 良いシステム設計とは「目立たない安定性」である点の強調
  • 状態管理(State)と無状態(Stateless)の重要性
  • データベース設計とパフォーマンス最適化の基本
  • バックグラウンドジョブやキャッシュの使い方に関する実践的指針

システム設計の本質と誤解

  • LinkedInTwitter で見かける「キューを使えば全て解決」や「データベースにBooleanを保存するな」といった極端なアドバイスの危険性
  • Designing Data-Intensive Applications などの名著も、現場の多くの問題には適用しづらいケースが多い点
  • ソフトウェア設計が「コードの組み立て」なら、 システム設計 は「サービスの組み立て」であるという定義
    • ソフトウェア設計のプリミティブ:変数、関数、クラスなど
    • システム設計のプリミティブ:アプリサーバ、データベース、キャッシュ、キュー、イベントバス、プロキシなど
  • 良いシステム設計の特徴は「何も起きない」ことで、派手さよりも安定運用が重要
  • 複雑なシステムは、しばしば設計ミスや過剰設計の証拠

状態管理と無状態設計

  • ソフトウェア設計の難所は 状態管理
  • 情報を保存しないサービスは「 Stateless(無状態)
    • 例:GitHubのPDF→HTML変換APIなど
  • データベースへ書き込むサービスは「 Stateful(有状態)
  • システム内の 有状態コンポーネント は最小限に抑えるべき
    • 有状態のものは障害時の自動修復が難しく、手動対応が必要になる
  • 状態を扱うサービスを1つに集約し、他はAPI経由やイベント駆動で処理分担を推奨
  • 読み取りも一元化できれば理想だが、パフォーマンス次第で柔軟に対応

データベース設計と運用の基本

  • スキーマ設計 は柔軟性と可読性のバランスが重要
    • 何でもJSONやKey-Valueで保存するとアプリ側が複雑化しやすい
    • テーブル設計は「人間が見て分かる」ことを重視
  • インデックス はよく使うクエリに合わせて設計
    • インデックスの付けすぎは書き込みコスト増加の原因
  • データベースアクセスが ボトルネック になりやすい
    • JOINでデータ取得を効率化、ORMの無駄なクエリ発行に注意
    • 複雑すぎるクエリは場合によって分割も選択肢
  • リードレプリカ 活用で書き込みノードの負荷分散
    • レプリケーション遅延への配慮も必要
  • クエリやトランザクションのスパイク時は スロットリング の検討

バックグラウンドジョブと遅延処理

  • ユーザー体験に直結する処理は 高速化、それ以外は バックグラウンドジョブ に分離
  • バックグラウンドジョブの基本構成
    • キュー(例:Redis)
    • ジョブランナーサービス
  • 定期実行や将来実行が必要な場合は DBテーブル で管理、スケジューラで処理

キャッシュ設計の注意点

  • キャッシュは「高コストな処理の再利用」が目的
    • 例:価格APIの頻繁な呼び出しを5分単位でキャッシュ
  • キャッシュはアプリ内メモリや Redis / Memcached など外部KVSを活用
  • キャッシュの乱用は 状態管理の複雑化不整合 の原因
    • インデックス追加等、まずは根本的な高速化を優先
  • 大規模・長期キャッシュが必要な場合は S3Azure Blob Storage とスケジュールジョブの組み合わせも有効

この内容は、実践的なシステム設計の原則と注意点をまとめたものです。安定性・シンプルさ・状態管理の徹底が、良いシステム設計の鍵となります。

Hackerたちの意見

ロギングとメトリクスに関するアドバイスは良かった。状態やプッシュ/プルについて頷いていたけど、この部分は特に気を引かれた。こんなに明確に説明されているのは初めて見たから。

ロギングの部分はその通りだね。「ああ、これをログに残しておけばよかった」と思うことが何度もあったし、問題やインシデントに直面したときに結局ログを導入することになる。

そうそう。みんな少しの時間を使ってロギングやメトリクスを整備すべきだよ。テストみたいなもので、0から1のテストを作るのは心理的に難しいけど、1から1000になると「これなしでどうやって生きてたんだろう」ってなる。Grafanaにはそこそこ使える無料プランがあるし、自分でホスティングすることもできるよ。

タイムスタンプを保存するべきで、タイムスタンプの存在を真実として扱うべきだよ。たまにこれを実践するけど、いつもではないかな。データベースのスキーマをすぐに読めるように保つことにも価値があると思う。良いパターンに対するアドバイスとしてはちょっと否定的すぎる気がする。is_on => true on_at => 1023030 なるほど、理解できる。is_a_bear => true a_bear_at => 12312231231 これはあまり良くないね。ほとんどのクマは、クマでない時期があったりしないから。

その発言をそのまま受け取ると、基本的にデータベースにブール値を保存するのは悪臭だと言えるね。彼の言う通りだと思う。ただ、これが広く良い原則かどうかは疑問だし、on_atの場合でもそう。もしこういうことを気にするなら、適切に監査テーブルに保存すべきだよ。ブール値をタイムスタンプに切り替えるのは、実際にはあまり役に立たない奇妙な怠け者のハックだと思う。なぜなら、そんな風に追跡されるのはランダムなデータのサブセットだけだから。ブールデータ型が更新時間を追跡するのに重要かどうかを決める要因ではないし。これが提案される主な理由は、たぶん「無料」だからだと思う。ブール値にタイムスタンプを忍ばせることができるし、偶然にいくらかの手間を省いたんだろうけど、解決しようとしている問題のセットに対する完全な解決策ではないと思う。ソフトデリートにも同じ疑念を抱いている。実際には役に立たないと確信しているし、適切な監査を避けるための精神的に怠惰な解決策に過ぎない。確実に「元に戻す」ことはできないし、更新履歴を解決するわけでもないから、実際に守っているのは偶発的な一括削除を即座にキャッチすることだけ?それがバックアップの半分の目的だよね。

その状況では、Bearと他のカテゴリを含むenum値を持つことができると思うよ。

でも、なぜブール値を特別扱いして、これを持たない整数に対してタイムスタンプを保持するの?: isDarkTheme: {timestamped} paginationItems: 50 ダークテーマがいつ有効になったかはわかるけど、ページネーションが50に設定されたのはわからないし、ダークテーマが無効になった時もわからない。貧乏人の変更履歴みたいだ。使い道はあるかもしれないけど、正直言って思いつかないな。

こういう一般的なアドバイスは全然役に立たないし、何百万ものアスタリスクが必要だよ。良いシステム設計は、目の前の問題に最適なシステムを設計することなんだ。

ほとんどのケースでブール値は悪いものだと思う。ブール値の代わりにタイムスタンプや整数フィールド(後で拡張可能)を使うべきだ。is_aの場合、ほとんど常にタイプや種類の方が良い。たとえ最初はクマだけでも、クマだけの状態はあまりないし、ステータスフィールドも(オンかオフだけではなく)通常はサスペンド、削除、スリープなどに広がるからね。だから、一般的にはブール値は避けるべきだと思う。相互排他的な状態(ライブ、削除、サスペンドなど)をカバーする時に、ブール値は増殖して複雑さを増すことが多いから。is_visible、is_deleted、is_suspendedが同じテーブルにあって(ステータスなしで)、その結果のコードやクエリは見栄えが良くない。代わりにタイムスタンプではなく整数を使うべきだと思う。

ブール値はサイズが小さいから、特定のワークロードには重要な考慮事項だよ。例えば、関連するタイムスタンプを気にしない分析クエリのセットに対して、大量のデータを事前に集計している場合がある。その場合、小さいデータ型の方がストレージとクエリ実行の両方で効率的なんだ。さらに、ブール値を保存するのが論理的な状況もあるよ。例えば、ブール値が結果を示す場合:process_executed_at タイムスタンプは null でない、process_succeeded ブール値は null でない。

データベースをクエリするときは、データベースをクエリするべきだよ。自分でやるよりも、データベースに仕事をさせる方がほぼ常に効率的だよ。例えば、複数のテーブルからデータが必要な場合は、別々のクエリを作るんじゃなくて、JOINして一緒に取得するべき。そうそう!アプリケーションコードでJOINは絶対にやっちゃダメ!でも、ビューも使ってね!(できればストアドプロシージャも) ビューは基盤となるデータの抽象化で、機能的な性質を持っていて、将来的にランダムな理由で壊れる可能性も低いし、うまくやれば基盤のSQLコードは驚くほど読みやすくて理解しやすいよ。

ビューは、チェックインできるときには理にかなっているよ。DBマイグレーションはその不変性のために良い方法ではないし。コードベースが採用するエコシステムによっては、良いORMを使ってJOINする方が良い選択かもしれないね。

俺はここに正反対のことを言いに来たんだ。重いJOINがうまくいかないことが何回かあったけど、何を試してもダメだった。だから、goroutinesを使ってデータを読み込んだり結合したりする方が早かったんだ。結局、そのやり方を選んだよ。SQLは簡単だけど、インデックスやプランナーのことを理解するのは難しいよね。

このルールを最初の目安として持つのはいいと思うけど、デザインルールは破るタイミングを理解しておくべきだよね。たくさんのテーブルをJOINするアプリケーションに関わったことがあるけど、数十件のレコードが何千件にも膨れ上がって、結果に大きな冗長性が出てしまった。例えば、ある概念的な結果が、テーブル1からA、B、C、テーブル2からX、Y、テーブル3から1、2、3を持っているとする。結果として8行(メインテーブルのトップレベルを含めると9行)ではなく、18行(AX1、AX2、AX3、AY1…)になっちゃう。テーブルが増えると指数関数的に悪化するんだ。そこで、異なるテーブルごとに別のクエリを使うことにしたんだ。重要なのは、同じ条件でフィルタリングできたから、トップレベルの結果がたくさんあるときに子テーブルに対して複数のクエリを作る必要がなかったこと。結果的に、ネットワークのオーバーヘッドがクエリ処理と返されるデータ量の節約に隠れて、ずっと速くなった。アプリケーションコードも実際にはシンプルになったし、大きなJOINからユニークな子結果を選ぶのは面倒だったからね。全ての面でメリットしかなかった。後で、全てのデータを一つのテーブルのJSONBに詰め込んだんだけど、それもさらに良かった。でも、それも古い正規化ルールを破る一例だね。

これがORMが問題になる大きな要因だよね。SSRのアレンジメントでMVCビューごとに生のSQLビューやクエリを書くのは、複雑なウェブプロダクトを構築するための最もエレガントでパフォーマンスの良い方法の一つなんだ。RDBMSにデータの重い処理を任せよう。古いエンタープライズ系のMSSQLやOracleを使っていると、最適化がたくさんあって思い出せないこともある。ウェブサーバーは、各行ごとにラウンドトリップしたり、追加のメモリ内結合操作を行うことなく、SQLの結果セットを対応する要素に直接補完できるべきなんだ。典型的なORMの実装はこれとは真逆で、どこでも使わなきゃいけない厳格なオブジェクトモデルになってる。これ以上柔軟性がないってくらいだね。

「データベースでやる」っていうのにどれだけ頼るかには注意が必要だよ。そうしないと、アプリケーションが一つの値を挿入して、全然違う形で保存されるなんてことになりかねないから。

それには同意できないな。現代のスケーラブルなアーキテクチャでは、データベースの前の層(バックエンド)でジョインを行う方がいいと思う。バックエンドはデータベースよりもスケールしやすいからね。シンプルなインデックス(例えば、user_id)でデータを読み込んでバックエンドでジョインすることで、DBを速く保てる。バックエンドのインスタンスを追加するのは簡単だけど、DBインスタンスはそうはいかないから。もし、データが大きすぎてバックエンドのメモリに読み込めないからDBでジョインしなきゃならないと思ってるなら、構造を見直してみて。フロントエンドにジョインを移動させると、データがキャッシュしやすくなって、読み込みも速くなるし、サーバー側のリソースも解放されるよ。

本当にそう思ってるの?例えば、ウェブショップを運営していて、1つは5フィールドの注文テーブル、もう1つは20フィールドの顧客テーブルがあるとしよう。顧客が1万、注文が100万あると仮定して、これらをフルジョインして全データを取得するクエリだと、2500万フィールドが送信されることになる。一方で、2つの別々のクエリとクライアント側での手動ジョインだと、注文は500万、顧客は20万で済むよ。

ストアドプロシージャは良さそうに見えるけど、大きな問題は、残りのソフトウェアをRustみたいな素晴らしいモダンな言語で書けるのに、ストアドプロシージャを書くときはTransact-SQLで書かなきゃいけないことだよ。これが唯一の選択肢だからね。T-SQLは、ぼんやりと現役だった時代には良いプログラミング言語ではなかったし、だから大規模なコードをT-SQLで書きたくないんだ。私の罪として、巨大なT-SQLプロシージャを持つソフトウェアを維持しているけど(本当にこの手のものが好きな人による多ページの elaboration)、それは悪夢だよ。ツールはバージョン管理を信じていないし、間違いを犯したときの診断は存在しないか、C++スタイルの役に立たないゴミ情報だ。私たちは非常に若い開発者をたくさん雇っている。リリース時にコードをコメントアウトしないように言われる必要がある人たちで、変数の数字は人間が読むためのもので、機械のためではない、みたいなことを教えなきゃいけない。物理学者を雇ってソフトウェアを書くわけではないけど(スタートアップでやったことはある)、それに近いね。でも、新しく雇った人のマージリクエストで見る「私の初めてのプログラム」コードは、すでに持っているT-SQLよりも読みづらくはないよ。

一番大事な質問は、結局どれくらい高いのか、どれくらいの頻度でやらなきゃいけないのか、そしてそのスピードがどれくらい重要なのかってことだと思う。もし、毎回のページロードで複雑なクエリがあって、ユーザーがめっちゃ多いなら、できるだけDBに入れた方がいいよ。もし、たくさんのレコードを繰り返し処理して、いくつかの値の組み合わせに基づいて何かをする必要があるなら、週次報告用のやつだったら、複雑なSQL文を2日かけて作るよりも、早めに抜けられる3重のforeachループの方が全然いいと思う。

ステートフルとステートレスの区別は、プラットフォームインフラと開発の責任を分ける主な基準の一つなんだ。ちょっと不正確かもしれないけど、コンテナで動いているステートレスアプリケーションなら、そんなに多くのことを間違えないよ。よくある答えは「殺して再デプロイ」だしね。悪いマイグレーションやひどいデータベースコードでデータセットを壊さなければ、大体の問題は数分で再デプロイで解決できる。経験や時間、注意を持った人が多い方がいいと思ってる。データベースやファイルストアのような永続性があるものは、システム周りの経験が必要で、ビジネスリスクにならないようにしないといけない。簡単に言うと、データベースが完璧に動いていても、バックアップを設定していなければ大きなビジネスリスクになるんだ。だから、うちのストレージは何年もやってきた専任の人たちが管理してるんだ。悪いデータベースの損失は、簡単に船を沈めるからね。

だけど、コンテナで動いているステートレスアプリケーションで、そんなに多くのことを間違えることはないよ。 > 悪いマイグレーションや悪いデータベースコードでデータセットを壊さない限り、このレベルでのほとんどの悪いことは、数分でいくつかの再デプロイで修正できるよ。これらの発言の間で、ステートレスからステートフルに切り替わったみたいだけど、その後の議論がついていけないな。

スキーマ設計は柔軟であるべきだ。なぜなら、何千、何百万のレコードがあると、スキーマを変更するのがものすごく面倒になるからだ。でも、あまりにも柔軟にしすぎると(例えば、すべてを「値」のJSONカラムに入れたり、任意のデータを追跡するために「キー」と「値」のテーブルを使ったりすると)、アプリケーションコードに多くの複雑さを持ち込むことになるし、非常に厄介なパフォーマンス制約を買ってしまう可能性がある。ここで線を引くのは判断が必要で、具体的な状況によるけど、一般的にはテーブルが人間に読みやすいことを目指している。データベーススキーマを見て、アプリケーションが何を保存しているのか、なぜそうしているのかを大まかに理解できるべきだと思う。EAVやリレーショナルデータベースでのJSON使用の欠点がもっと指摘されないのが不思議だ。明確な目的を持った20個のテーブルがある方が、同僚がまた「分類器」メカニズムを作って、ポリモーフィックリンクを使って(実際の外部キーや「セクション」や「entity_id」みたいなカラムなしで)それを雑多なものとして扱っているのを見るよりもずっといい。理解するためにはアプリケーションコードをたくさん読まないといけないようなものだ。そういうのを見るたびに、キャリアを変えたくなる。EAVには使い道があるのはわかるけど、他のほとんどのケースではEAVはクソだ。N+1問題や、ビューで十分なのに複雑に動的に生成されたSQL、監査データを同じDBに保存して、それに対して機能が書かれてしまい、監査データがビジネスロジックの一部になってしまうのと同じくらい最悪だ。ああ、共有データベースインスタンスや、自分のを簡単にブートストラップできないこと、一般的にOracleと関わること、アプリ内に置くべきものをDBに入れたり、その逆をしたりすること。データの保存やアクセスに関して、生活の質を下げる方法はたくさんあるよね。

ビル・カーワインの「SQL Antipatterns」っていう素晴らしい本があって、そこでこの特定のアンチパターンについて議論されてるんだよね。とはいえ、ざっくりとしたスキーマ(例えば、フロントエンドに返す設定オブジェクトみたいな)すら思いつかないときは、PostgresのJSONBカラムを使うこともあるよ。ただ、基本的には、何かを正規化できるなら、すべきだと思う。結局、PostgresにはJSON(B)の便利さや最適化があっても、まだリレーショナルデータベースなんだから。

監査データを同じDBに保存して、それに対して機能が書かれてしまうと、監査データがビジネスロジックの一部になってしまう。 これを「適切に」やる方法は?別のDB?別のデータストア?

実際、イベントソーシングはほとんどの問題を解決するんだよね。イベント、スキーマ、プッシュ/プル、キャッシング、分散…何でも。欠点は、小規模プロジェクトには全然向いてないし、オーバーヘッドがかなり大きいこと(特に、できるだけ早くプロダクトを出したい開発段階では)。でも、一旦うまくいくと、もう止められないモンスターになるよ。

逆説的だけど、良いデザインは自己抑制的で、悪いデザインはしばしば良いデザインよりも印象的だ。 すごく真実だね。エンジニアは、自分がやる仕事の「複雑さ」に基づいて評価される。こういうシステムは、すべての問題に対して過剰設計された解決策を促しているように見える。KISSの重要性が十分に評価されていないと思う。これは、20年前に学部生のときに初めて知ったことなんだけど。

著者がデータベースの適切な使い方を褒めていて、イベントバスやバックグラウンドジョブ、キャッシングについても触れているので、PythonやTypeScriptのバックエンドを使っているなら、ぜひ https://dbos.dev をチェックしてみて!DBOSはシンプルなシステムでも複雑なシステムでもよくある課題をうまく解決してくれるし、KafkaやRedis、Celeryみたいな別のサービスを運用する必要もなくなるよ。しかも、DBOSは依存関係として使えるから、別のサービスをデプロイする必要もないのが最高。つい先週ここで話題になってたよね: https://news.ycombinator.com/item?id=44840693

すごくいい記事だね、まさにポイントをついてる!でも、著者がテストやドキュメンテーション、QAツールの設計を省いた理由が気になるな。個人的には、チーム全員が一貫したコードを書くために、ちゃんとしたphpcsとかを書くのがめっちゃ重要だと思う。ドキュメンテーションがないと、なんであんなことをしたのか忘れちゃうし、テストがなかったらリファクタリングは悪夢だよ。

特に、ドキュメンテーションやテストを生成するのが、例えばClaude Codeを使うとすごく早いからね。

すごくいい記事だね。こういう視点を読むのはいつも楽しい。ただ、いくつか気になる点があるよ。記事からの引用:> 同じテーブルに5つの異なるサービスが書き込むのは避けるべきだ。代わりに、4つのサービスが最初のサービスにAPIリクエストを送る(またはイベントを発行する)ようにして、その1つのサービスに書き込みロジックを保つべきだ。これは単純じゃないよ。トレードオフは明らかでも受け入れられるものでもない。もし5つのサービスがデータベースにアクセスするなら、データベースが消費されるインターフェースを設計していることになる。これは設計や実装を必要とせず、すでに認証やアクセス制御をサポートしているし、トランザクションやカスタムクエリのサポートもある。一方で、データベースの上に高レベルのインターフェースとして1つのサービスを設計すると、自分自身でカスタムインターフェースやアクセス制御、制約を実装・管理しなきゃいけないし、トランザクションや補償戦略の処理方法も自分で設計・実装しなきゃならない。で、結局何を得られるの?失敗のモードが増えて、マイクロサービスのコストが上がるだけ?それに、5つのサービスが同じデータベースにアクセスするのはコードの匂いがするよ。おそらくそのデータベースは2つか3つの別々のデータベースが統合されたものだろうね。これはよくあることで、ほとんどのサービスは蓄積によって成長していくから、データベースに新しいテーブルを追加する方が、全く新しい永続サービスを作る提案よりもずっと抵抗が少ないんだ。そして、その5つのサービスが実際には1つか2つのサービスに過ぎない可能性もある?

著者が言いたかったのは、一般的に言って、異なるサービスからの同時書き込みは避けた方がいいってことだと思う。これはレースコンディションを引き起こす簡単な方法だからね。

消費されるインターフェースはデータベースで、設計や実装は必要ない あなたは絶対にそれを設計し、実装すべきだよ。なぜなら、それが今やあなたのインターフェースだから。実際、これにより設計にもっと制約が加わることになる。なぜなら、異なる消費者や潜在的なライターが同じリソースを異なるアクセスパターンで競い合うことになるから。さらに、共有テーブルのマイグレーションに伴うメンテナンスオーバーヘッドもあるし、最終的にはこのテーブルに含まれるデータが一部のサービスにしか必要ない場合もあるから、DBレベルでビューやアクセス制御を実装する必要が出てくる。理想的には、実装する機会があれば、APIの方がクリーンで柔軟だよ。ほとんどの場合の問題は、ビジネスがより早い機能を求めていることで、これがしばしば迅速なハックにつながる。例えば、別のサービスからDBテーブルに直接アクセスを与えることになるから、代替案はもっと時間がかかるし、時間がないから、今すぐ機能が欲しいってなる。でも、最後の段落のあなたの考えには同意するよ。新しい設計や再設計を行う努力を避けて、既存のDBに新しいテーブルを追加することでパッチを当てることが非常に多いからね。