データベース (Databases)
はじめに
データベースはアプリケーションの「記憶」だ。ユーザー情報、診療記録、予約データ -- すべてのビジネスデータはデータベースに永続化される。適切なデータベース設計と運用は、アプリケーションのパフォーマンス、信頼性、保守性に直結する。
リレーショナルデータベース (RDB) vs NoSQL
リレーショナルデータベース (RDB)
テーブル (行と列) でデータを表現し、SQL で操作する。PostgreSQL, MySQL が代表的。
-- テーブル定義の例
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
birth_date DATE NOT NULL,
email VARCHAR(255) UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
doctor_id UUID NOT NULL REFERENCES doctors(id),
start_time TIMESTAMP NOT NULL,
status VARCHAR(20) DEFAULT 'scheduled'
);
強み: データの整合性保証、複雑なクエリ、トランザクション、成熟したエコシステム。
NoSQL
RDB 以外のデータベースの総称。用途に応じて複数の種類がある。
| 種類 | 例 | 使いどころ |
|---|---|---|
| ドキュメント DB | MongoDB, DynamoDB | スキーマが柔軟なデータ |
| キーバリュー | Redis, Memcached | キャッシュ、セッション |
| カラム指向 | Cassandra, HBase | 大量の書き込み、時系列データ |
| グラフ DB | Neo4j | 関係性の深い分析 |
どちらを選ぶか
RDB を選ぶ場合 (大半のケース):
- データ間に明確な関係性がある
- トランザクションが必要 (医療データは基本これ)
- データの整合性が重要
- 複雑なクエリが必要
NoSQL を検討する場合:
- 超大量の読み書きが必要
- スキーマが頻繁に変わる
- 水平スケーリングが必須
- 特定のアクセスパターンに最適化したい
ACID 特性
トランザクションが満たすべき4つの性質。医療データを扱う上で極めて重要。
| 特性 | 意味 | 例 |
|---|---|---|
| Atomicity (原子性) | トランザクション内の操作は全部成功か全部失敗 | 予約作成と通知送信を一括で |
| Consistency (一貫性) | トランザクション前後でデータの整合性が保たれる | 外部キー制約の維持 |
| Isolation (分離性) | 並行するトランザクションが互いに干渉しない | 同時予約での二重予約防止 |
| Durability (永続性) | コミットされたデータは失われない | サーバー障害後もデータが残る |
-- トランザクションの例: 予約作成と空き枠の更新を一括で行う
BEGIN;
INSERT INTO appointments (patient_id, doctor_id, start_time, status)
VALUES ('patient-123', 'doctor-456', '2026-03-10 10:00', 'scheduled');
UPDATE time_slots
SET is_available = false
WHERE doctor_id = 'doctor-456' AND start_time = '2026-03-10 10:00';
COMMIT;
-- どちらかが失敗した場合、両方がロールバックされる
インデックス (Index)
データベースの検索を高速化するための仕組み。書籍の索引と同じ概念。
インデックスの仕組み
インデックスなし: テーブル全体を走査 (Full Table Scan) → O(n) インデックスあり: B-Tree を使った探索 → O(log n)
-- インデックスの作成
CREATE INDEX idx_appointments_patient_id ON appointments(patient_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
-- 複合インデックス (よくセットで検索するカラム)
CREATE INDEX idx_appointments_doctor_time
ON appointments(doctor_id, start_time);
インデックスを張るべき場所
- WHERE 句で頻繁に使われるカラム
- JOIN の結合キー
- ORDER BY で使われるカラム
- 外部キー (多くの DB は自動で張らない)
インデックスの注意点
- 書き込みが遅くなる: INSERT/UPDATE のたびにインデックスも更新される
- ストレージを消費する: インデックスもディスク上のデータ
- 多すぎるとリスクが高い: 不要なインデックスは削除する
- EXPLAIN で確認する: クエリが実際にインデックスを使っているか確認
-- クエリの実行計画を確認
EXPLAIN ANALYZE
SELECT * FROM appointments
WHERE doctor_id = 'doctor-456'
AND start_time BETWEEN '2026-03-01' AND '2026-03-31';
クエリ最適化
よくあるパフォーマンス問題と対策
1. N+1 クエリ問題
-- Bad: 患者ごとにクエリを発行
SELECT * FROM patients;
-- ループ内で:
SELECT * FROM appointments WHERE patient_id = ?; -- N回実行
-- Good: JOIN またはバッチ取得
SELECT p.*, a.*
FROM patients p
LEFT JOIN appointments a ON p.id = a.patient_id;
2. SELECT * の回避
-- Bad: 不要なカラムまで取得
SELECT * FROM patients;
-- Good: 必要なカラムだけ取得
SELECT id, name, email FROM patients;
3. 適切な WHERE 句
-- Bad: インデックスが効かないパターン
SELECT * FROM patients WHERE LOWER(name) = 'tanaka';
-- Good: 関数インデックスを使うか、アプリ側で正規化
CREATE INDEX idx_patients_name_lower ON patients(LOWER(name));
4. LIMIT の活用
-- ページネーション
SELECT * FROM appointments
WHERE doctor_id = 'doctor-456'
ORDER BY start_time DESC
LIMIT 20 OFFSET 40;
-- 大量データではカーソルベースのページネーションが効率的
SELECT * FROM appointments
WHERE doctor_id = 'doctor-456'
AND start_time < '2026-03-01T10:00:00'
ORDER BY start_time DESC
LIMIT 20;
正規化 (Normalization)
データの冗長性を排除し、整合性を保つための設計手法。
主要な正規形
第1正規形 (1NF): 各カラムが原子的な値を持つ (繰り返しグループを排除)
-- Bad: 1NF 違反
| patient_id | phone_numbers |
|------------|------------------------|
| 1 | 090-1234-5678, 03-... |
-- Good: 別テーブルに分離
patients: | id | name |
phones: | id | patient_id | number |
第2正規形 (2NF): 1NF + 部分関数従属の排除 第3正規形 (3NF): 2NF + 推移的関数従属の排除
実務的なバランス
- 通常は第3正規形までで十分
- パフォーマンスのために意図的に非正規化することもある (レポーティング用のテーブルなど)
- 非正規化する場合は理由をドキュメントに残す
マイグレーション (Migration)
データベーススキーマの変更を管理・追跡する仕組み。
マイグレーションの原則
- バージョン管理する: マイグレーションファイルは Git で管理
- 前方互換性を意識する: 旧バージョンのアプリケーションとの共存期間を考慮
- ロールバック可能にする:
upとdownの両方を定義 - 破壊的変更を避ける: カラム削除やリネームは段階的に
-- マイグレーション例: カラム追加 (安全)
ALTER TABLE patients ADD COLUMN phone VARCHAR(20);
-- 危険な操作: カラム削除は段階的に行う
-- Step 1: アプリケーションからカラムの参照を削除してデプロイ
-- Step 2: しばらく経ってからカラムを削除
ALTER TABLE patients DROP COLUMN old_column;
大規模テーブルのマイグレーション
数百万行のテーブルへの ALTER TABLE はロックを取得し、サービスが停止する可能性がある。
対策:
pt-online-schema-change(MySQL) やpg_repack(PostgreSQL) の利用- 新テーブル作成 → データコピー → テーブル名変更 のパターン
- NOT NULL 制約の追加は、DEFAULT 値と一緒に行う
ORM の利点と落とし穴
ORM (Object-Relational Mapping) は、プログラムのオブジェクトとデータベースのテーブルを対応付ける。
利点
- 生産性: SQL を書かずにデータ操作ができる
- 型安全性: TypeScript の ORM なら型チェックが効く
- マイグレーション管理: 多くの ORM がマイグレーション機能を内蔵
- DB 方言の吸収: 複数の DB に対応しやすい
落とし穴
- N+1 問題: Lazy Loading のデフォルト設定で発生しがち
- 非効率なクエリ: ORM が生成する SQL が最適でない場合がある
- 学習コスト: ORM 固有の API を学ぶ必要がある
- 複雑なクエリの限界: 集計や分析クエリは生 SQL の方が書きやすい
// ORM の使い方 (Prisma の例)
// Good: リレーションを明示的に include
const patient = await prisma.patient.findUnique({
where: { id: "patient-123" },
include: {
appointments: {
where: { status: "scheduled" },
orderBy: { startTime: "asc" },
take: 10,
},
},
});
// 複雑なクエリは生 SQL を使う
const stats = await prisma.$queryRaw`
SELECT doctor_id, COUNT(*) as appointment_count
FROM appointments
WHERE start_time >= ${startDate}
GROUP BY doctor_id
ORDER BY appointment_count DESC
`;
医療 SaaS でのデータベース設計の考慮点
- 監査ログ: 全てのデータ変更を追跡可能にする (誰が、いつ、何を変更したか)
- 論理削除 (Soft Delete):
deleted_atカラムで削除をマーク。物理削除は原則行わない - 暗号化: 機密性の高いカラムはアプリケーションレベルで暗号化
- バックアップ: 定期的なバックアップとリストアのテスト
- テナント分離: マルチテナント構成では、テナント間のデータ漏洩を防ぐ設計
Agent-first 開発においてこれが重要な理由
データベースはアプリケーションの最も重要な部分であり、エージェントに任せきりにすべきではない。
- スキーマ設計をレビューできる: エージェントが提案するテーブル設計の正規化レベル、インデックス、制約を評価できる
- パフォーマンス問題を予見できる: 「このクエリはインデックスが効かない」「N+1 が発生する」と事前に指摘できる
- マイグレーションの安全性を判断できる: 「この ALTER TABLE は本番で実行すると数分のロックがかかる」といったリスクを評価できる
- 適切なトランザクション境界を指示できる: 「この2つの操作はアトミックに実行する必要がある」と明確に伝えられる
データベースの変更は取り消しが難しい。エージェントが生成するマイグレーションは特に慎重にレビューすべきだ。
Further Reading
- Use The Index, Luke - インデックスの深い理解 (日本語版あり)
- PostgreSQL Documentation - 公式ドキュメント (最も信頼できるリソース)
- Prisma Documentation - モダンな TypeScript ORM
- Database Internals (書籍) - データベースの内部実装を理解したい人向け
- SQLBolt - SQL の対話的チュートリアル