0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 12: データベーススキーマ設計

Last updated at Posted at 2025-12-11

🎄 科学と神々株式会社アドベントカレンダー 2025

License System Day 12: データベーススキーマ設計


📖 今日のテーマ

今日は、ライセンスシステムの心臓部であるデータベース設計を学びます。

適切なスキーマ設計は、システムのパフォーマンスと拡張性を左右します。


🎯 データベース選択: SQLite vs PostgreSQL

SQLite(開発・中小規模)

利点:

✅ 設定不要(ファイルベース)
✅ 軽量(数百KB)
✅ 高速(ローカル)
✅ トランザクション対応
✅ クロスプラットフォーム

制限:

❌ 同時書き込みが弱い
❌ レプリケーション機能なし
❌ ユーザー権限管理が限定的
❌ 大規模データに不向き

適用場面:

  • 開発・テスト環境
  • 10万レコード以下
  • 同時接続 < 100

PostgreSQL(本番・大規模)

利点:

✅ 強力な同時実行制御
✅ レプリケーション・HA構成
✅ 高度なインデックス(GIN, GiST)
✅ JSON型サポート
✅ Full-textサーチ

適用場面:

  • 本番環境
  • 数百万レコード以上
  • 高可用性が必要

📊 テーブル設計

1. Users テーブル

CREATE TABLE users (
  user_id TEXT PRIMARY KEY,               -- UUID
  email TEXT UNIQUE NOT NULL,            -- メールアドレス(ユニーク)
  password_hash TEXT NOT NULL,           -- bcryptハッシュ
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  last_login TEXT,
  is_active INTEGER DEFAULT 1,           -- 0=無効, 1=有効
  
  -- インデックス
  INDEX idx_users_email (email),
  INDEX idx_users_active (is_active)
);

Nim実装:

proc createUser*(self: Database, userId, email, passwordHash: string): bool =
  try:
    self.db.exec(sql"""
      INSERT INTO users (user_id, email, password_hash)
      VALUES (?, ?, ?)
    """, userId, email, passwordHash)
    result = true
  except:
    result = false

proc getUserByEmail*(self: Database, email: string): Option[Row] =
  let row = self.db.getRow(sql"""
    SELECT user_id, email, password_hash, is_active
    FROM users
    WHERE email = ?
  """, email)
  
  if row[0] != "":
    result = some(row)
  else:
    result = none(Row)

2. Subscriptions テーブル

CREATE TABLE subscriptions (
  subscription_id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  plan_type TEXT NOT NULL,               -- free/premium_monthly/enterprise_monthly
  status TEXT NOT NULL,                  -- active/expired/cancelled/suspended
  start_date TEXT NOT NULL,
  end_date TEXT,
  
  FOREIGN KEY (user_id) REFERENCES users(user_id)
    ON DELETE CASCADE,                   -- ユーザー削除時に連鎖削除
  
  INDEX idx_subscriptions_user (user_id),
  INDEX idx_subscriptions_status (status),
  INDEX idx_subscriptions_end_date (end_date)
);

Nim実装:

proc createSubscription*(self: Database, subscriptionId, userId: string,
                        planType: PlanType, endDate: DateTime): bool =
  try:
    self.db.exec(sql"""
      INSERT INTO subscriptions 
        (subscription_id, user_id, plan_type, status, start_date, end_date)
      VALUES (?, ?, ?, 'active', datetime('now'), ?)
    """, subscriptionId, userId, $planType, endDate.format("yyyy-MM-dd HH:mm:ss"))
    result = true
  except:
    result = false

proc getActiveSubscription*(self: Database, userId: string): Option[Row] =
  let row = self.db.getRow(sql"""
    SELECT subscription_id, plan_type, end_date, status
    FROM subscriptions
    WHERE user_id = ? AND status = 'active'
    ORDER BY end_date DESC
    LIMIT 1
  """, userId)
  
  if row[0] != "":
    result = some(row)
  else:
    result = none(Row)

3. Licenses テーブル

CREATE TABLE licenses (
  license_id TEXT PRIMARY KEY,
  subscription_id TEXT NOT NULL,
  client_id TEXT NOT NULL,               -- デバイス識別子
  activation_key TEXT UNIQUE NOT NULL,   -- JWT
  activated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  last_validated TEXT,
  validation_count INTEGER DEFAULT 0,
  is_active INTEGER DEFAULT 1,
  device_fingerprint TEXT,
  
  FOREIGN KEY (subscription_id) REFERENCES subscriptions(subscription_id)
    ON DELETE CASCADE,
  
  INDEX idx_licenses_subscription (subscription_id),
  INDEX idx_licenses_key (activation_key),
  INDEX idx_licenses_client (client_id)
);

4. Rate_limits テーブル

CREATE TABLE rate_limits (
  user_id TEXT NOT NULL,
  endpoint TEXT NOT NULL,
  request_count INTEGER DEFAULT 0,
  window_start TEXT NOT NULL,
  
  PRIMARY KEY (user_id, endpoint),
  INDEX idx_rate_limits_window (window_start)
);

Nim実装:

proc checkRateLimit*(self: Database, userId, endpoint: string, limit: int): bool =
  let now = now()
  let windowStart = now - initDuration(hours = 1)
  
  let row = self.db.getRow(sql"""
    SELECT request_count, window_start
    FROM rate_limits
    WHERE user_id = ? AND endpoint = ?
  """, userId, endpoint)
  
  if row[0] == "":
    # 初回リクエスト
    self.db.exec(sql"""
      INSERT INTO rate_limits (user_id, endpoint, request_count, window_start)
      VALUES (?, ?, 1, ?)
    """, userId, endpoint, $now)
    return true
  
  let count = parseInt(row[0])
  let storedStart = parse(row[1], "yyyy-MM-dd HH:mm:ss")
  
  # ウィンドウ期限切れ
  if storedStart < windowStart:
    self.db.exec(sql"""
      UPDATE rate_limits
      SET request_count = 1, window_start = ?
      WHERE user_id = ? AND endpoint = ?
    """, $now, userId, endpoint)
    return true
  
  # リミットチェック
  if count >= limit:
    return false
  
  # カウント増加
  self.db.exec(sql"""
    UPDATE rate_limits
    SET request_count = request_count + 1
    WHERE user_id = ? AND endpoint = ?
  """, userId, endpoint)
  
  return true

🔗 正規化と関連

ER図

┌─────────────┐
│   Users     │
│ user_id (PK)│
│ email       │
│ password_   │
│   hash      │
└──────┬──────┘
       │ 1
       │
       │ N
┌──────┴──────────┐
│ Subscriptions   │
│subscription_id  │
│  (PK)           │
│ user_id (FK)    │
│ plan_type       │
│ status          │
└──────┬──────────┘
       │ 1
       │
       │ N
┌──────┴──────────┐
│   Licenses      │
│ license_id (PK) │
│ subscription_id │
│   (FK)          │
│ activation_key  │
└─────────────────┘

リレーションシップ

1. Users 1 : N Subscriptions
   - 1ユーザーは複数のサブスクリプションを持てる
   - 過去のプラン履歴を保持

2. Subscriptions 1 : N Licenses
   - 1サブスクリプションで複数デバイスを管理
   - デバイス制限(例: 5台まで)

3. CASCADE DELETE
   - ユーザー削除 → サブスクリプション削除 → ライセンス削除

🚀 インデックス戦略

プライマリインデックス

-- すべてのテーブルにプライマリキー
PRIMARY KEY (user_id)
PRIMARY KEY (subscription_id)  
PRIMARY KEY (license_id)
PRIMARY KEY (user_id, endpoint)  -- 複合キー

セカンダリインデックス

-- よく検索されるカラム
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_licenses_key ON licenses(activation_key);

-- 範囲検索
CREATE INDEX idx_subscriptions_end_date ON subscriptions(end_date);
CREATE INDEX idx_rate_limits_window ON rate_limits(window_start);

-- 複合インデックス
CREATE INDEX idx_subscriptions_user_status 
  ON subscriptions(user_id, status);

パフォーマンス比較

-- インデックスなし
SELECT * FROM licenses WHERE activation_key = 'xxx';
 Full Table Scan: 10万レコード  500ms

-- インデックスあり
SELECT * FROM licenses WHERE activation_key = 'xxx';
 Index Seek: 1レコード  5ms

100倍高速!

🌟 まとめ

データベース設計の要点:

  1. 適切なテーブル分割

    • Users / Subscriptions / Licenses
  2. 外部キー制約

    • データ整合性の保証
    • CASCADE DELETE
  3. インデックス戦略

    • よく検索されるカラム
    • 複合インデックス
  4. Nim実装

    • Option型でNULL安全
    • プリペアドステートメント

前回: Day 11: RESTful API設計
次回: Day 13: レートリミットの実装

Happy Learning! 🎉

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?