メインコンテンツまでスキップ

テナント統計機能 実装ガイド

このドキュメントでは、テナント統計機能の実装詳細について説明します。概要については テナント統計管理 を参照してください。


アーキテクチャの選択

イベント駆動 vs バッチ処理

idp-serverでは2つの統計更新方式を提供しています。運用負荷に応じて選択・併用できます。

アプローチ方式メリットデメリット
イベント駆動(アプリ側)イベント発生時に増分UPSERTリアルタイム反映高負荷時にロック競合
バッチ集計(DB側)pg_cron/Event Schedulerで日次集計ロック競合なし、冪等最大1日の遅延

イベント駆動方式

SecurityEventHandler.updateStatistics() がイベント発生ごとに statistics_events を UPSERT。即時性が高いが、高負荷時にはフック実行(メール/Slack等のHTTP I/O)と同一トランザクション内で行ロックを保持するため、ロック競合が発生しうる。

#1442 での対策: 統計書き込みをフック実行のに配置し、ロック保持時間を 500ms+ → 数ms に短縮。

バッチ集計方式

security_event テーブル(ソースデータ)からDB側で日次集計。アプリ側のリアルタイムUPSERTを完全に代替できる。

探索起点: libs/idp-server-database/postgresql/statistics-aggregation/
関数用途
aggregate_statistics_for_timezone(tz, date)特定TZ・特定日を集計(手動/固定スケジュール用)
aggregate_statistics_by_timezone()毎時実行用。hour=0のTZグループを自動検出
aggregate_daily_statistics(date)全TZ一括集計

特徴:

  • テナントタイムゾーン対応(tenant.attributes->>'timezone'
  • 会計年度対応(tenant.attributes->>'fiscal_year_start_month'
  • 冪等性保証(ON CONFLICT ... SET count = EXCLUDED.count で絶対値上書き)
  • パーティションプルーニング最適化(定数窓でスキャン対象を限定)

選択ガイド:

状況推奨
テナント少数、フック未使用イベント駆動のみで十分
フック使用、中程度の負荷イベント駆動 + フック順序最適化(#1442)
高負荷(数万イベント/日以上)バッチ集計に移行
翌日反映で十分バッチ集計(TZ別スケジュール推奨)

データモデル

ER図

テーブル構造

集計テーブル(非パーティション)

テーブル用途パーティション
statistics_monthly月次集計メトリクスなし
statistics_yearly年次集計メトリクスなし

ユーザートラッキングテーブル(パーティション)

テーブル用途パーティション間隔保持期間備考
statistics_daily_usersDAU重複排除日別90日
statistics_monthly_usersMAU重複排除月別13ヶ月
statistics_yearly_usersYAU重複排除月別60ヶ月会計年度対応

Note: statistics_yearly_users は会計年度対応のため月単位パーティションを使用します。 stat_year には会計年度開始日(例: 2025-04-01 = 4月開始の2025年度)が格納されます。

カラム詳細

statistics_monthly

カラム説明
idUUID統計レコードID
tenant_idUUIDテナント識別子
stat_monthDATE統計対象月(月初日、例: 2025-01-01)
monthly_summaryJSONB月次集計メトリクス
daily_metricsJSONB日別メトリクス(キー: YYYY-MM-DD形式)

Note: stat_monthはDATE型で月初日(1日)を格納します。pg_partman対応のため、CHAR(7)からDATE型に変更されました。

monthly_summary の例:

{
"mau": 5000,
"login_success": 45000,
"dau": 3200,
"issue_token_success": 12000
}

daily_metrics の例:

{
"2025-01-01": {"dau": 100, "login_success": 1500},
"2025-01-02": {"dau": 110, "login_success": 1600}
}

statistics_yearly

カラム説明
idUUID統計レコードID
tenant_idUUIDテナント識別子
stat_yearDATE統計対象年(年初日、例: 2025-01-01)
yearly_summaryJSONB年次集計メトリクス

yearly_summary の例:

{
"yau": 15000,
"total_logins": 500000,
"total_login_failures": 5000,
"new_users": 2000
}

統計更新フロー

SecurityEventHandlerによる統計収集

セキュリティイベント発行時に、SecurityEventHandlerが統計を同期的に更新します。

アクティブユーザーイベントの判定

以下のセキュリティイベントがアクティブユーザーイベントとして判定されます(DefaultSecurityEventType.isActiveUserEvent()):

イベントタイプ説明
login_successログイン成功
issue_token_successトークン発行成功
refresh_token_successトークンリフレッシュ成功
inspect_token_successトークン検証成功

統計更新処理の詳細

処理フロー:

  1. イベント種別判定: DefaultSecurityEventType.isActiveUserEvent() でアクティブユーザーイベントか判定
  2. タイムゾーン変換: UTCタイムスタンプをテナントのタイムゾーンに変換して日付を決定
  3. DAU処理: statistics_daily_users テーブルでユーザーの初回アクティビティを検出
  4. MAU処理: statistics_monthly_users テーブルでユーザーの月初アクティビティを検出
  5. YAU処理: statistics_yearly_users テーブルでユーザーの年初アクティビティを検出
  6. メトリクス更新: 日次・月次・年次メトリクスをJSONBで増分更新

タイムゾーン処理

グローバルなマルチテナント環境では、タイムゾーン処理が重要です。

// SecurityEventHandler.updateStatistics() より
LocalDate eventDate = securityEvent
.createdAt()
.value()
.atZone(ZoneOffset.UTC)
.withZoneSameInstant(tenant.timezone())
.toLocalDate();

原則:

  • イベントのタイムスタンプはUTCで記録
  • 統計日付はテナントのタイムゾーンで計算
  • 例: 東京のテナント(JST +9)の23:30 UTC → 翌日08:30 JST として集計

データベース実装

PostgreSQL vs MySQL

機能PostgreSQLMySQL
UUID生成gen_random_uuid()UUID()
JSON型JSONBJSON
JSONパス更新jsonb_set()JSON_SET()
UPSERTON CONFLICT ... DO UPDATEON DUPLICATE KEY UPDATE
行レベルセキュリティRLSポリシーなし(アプリ層で制御)

UPSERT による増分更新

アトミックな増分更新により、同時実行制御を実現しています。

PostgreSQL例:

INSERT INTO statistics_monthly (...)
VALUES (...)
ON CONFLICT (tenant_id, stat_month)
DO UPDATE SET
daily_metrics = jsonb_set(
COALESCE(statistics_monthly.daily_metrics, '{}'::jsonb),
ARRAY[?::text],
jsonb_set(
COALESCE(statistics_monthly.daily_metrics->?::text, '{}'::jsonb),
ARRAY[?::text],
to_jsonb(COALESCE((statistics_monthly.daily_metrics->?::text->>?::text)::int, 0) + ?::int)
)
),
updated_at = now()

メリット:

  • ロック不要
  • デッドロック回避
  • 高スループット

実装パターン

LocalDate型の使用

統計機能では、日付パラメータにjava.time.LocalDate型を使用します。

// SecurityEventHandler.updateStatistics() より
LocalDate eventDate = securityEvent
.createdAt()
.value()
.atZone(ZoneOffset.UTC)
.withZoneSameInstant(tenant.timezone())
.toLocalDate();

// 月初日の計算
LocalDate monthStart = eventDate.withDayOfMonth(1);

// 会計年度開始日の計算(テナント固有の会計年度に対応)
LocalDate yearStart = calculateFiscalYearStart(eventDate, tenant.fiscalYearStartMonth());

// Repository呼び出し
statisticsRepository.incrementDailyMetric(tenant.identifier(), monthStart, day, "dau", 1);
yearlyStatisticsRepository.incrementYau(tenant.identifier(), yearStart, 1);

会計年度の計算

テナントごとに設定された会計年度開始月に基づいて、会計年度開始日を計算します。

/**
* 会計年度開始日を計算
*
* 例: startMonth = 4(4月開始)の場合
* 2025-06-15 → 2025-04-01
* 2025-02-15 → 2024-04-01
* 2025-04-01 → 2025-04-01
* 2025-03-31 → 2024-04-01
*/
private LocalDate calculateFiscalYearStart(LocalDate date, int startMonth) {
LocalDate candidateStart = date.withMonth(startMonth).withDayOfMonth(1);
if (date.isBefore(candidateStart)) {
return candidateStart.minusYears(1);
}
return candidateStart;
}

会計年度の設定

テナントの会計年度開始月は TenantAttributes で設定します:

{
"attributes": {
"fiscal_year_start_month": 4
}
}
設定値会計年度
1 (デフォルト)1月〜12月カレンダー年
44月〜翌3月日本企業の一般的な会計年度
1010月〜翌9月一部の米国企業

設計原則:

  • API層(TenantStatisticsQueries)ではString型("YYYY-MM"形式)でパラメータを受け取る
  • クエリ実行時にLocalDateに変換(fromAsLocalDate(), toAsLocalDate()
  • データベース層ではLocalDateを直接使用(JDBCが自動変換)

パーティションテーブルでの新規レコード検出

PostgreSQLのパーティションテーブルでは、システムカラムxmaxを使用した新規レコード検出ができません。

-- ❌ 誤り: パーティションテーブルでは使用不可
INSERT INTO statistics_yearly_users (...)
ON CONFLICT (...) DO UPDATE SET last_used_at = NOW()
RETURNING (xmax = 0) AS is_new

-- ✅ 正解: RETURNING + DO NOTHING パターン
INSERT INTO statistics_yearly_users (...)
ON CONFLICT (...) DO NOTHING
RETURNING user_id

判定ロジック:

  • RETURNINGで行が返る = 新規INSERT成功(新規ユーザー)
  • RETURNINGで行が返らない = CONFLICT発生(既存ユーザー)

パーティショニング戦略

pg_partmanによる自動管理

統計ユーザーテーブルは pg_partman 拡張を使用した自動パーティション管理を行います。

テーブルパーティション間隔保持期間premake備考
statistics_daily_users日別90日90DAU追跡(データ量最大)
statistics_monthly_users月別13ヶ月13MAU追跡(YoY比較用)
statistics_yearly_users月別60ヶ月60YAU追跡(会計年度対応)

会計年度対応: statistics_yearly_users は月単位パーティションを使用します。 これにより、テナントごとに異なる会計年度開始月(1月、4月、10月など)に対応できます。 例: 4月開始テナントは partition_2025_04、10月開始テナントは partition_2025_10 に格納されます。

DDL参照: V0_9_21_2__statistics.sql

パーティショニングのメリット

  1. クエリ性能向上: パーティションプルーニングにより、特定期間のクエリが高速化
  2. メンテナンス効率化: パーティション単位での管理が可能
  3. 自動管理: pg_partmanが新規パーティションを自動作成・古いパーティションを自動アーカイブ

アーカイブ方式

古いパーティションは削除せず、archiveスキーマに移動します。

-- pg_partman設定
retention_schema = 'archive'
retention_keep_table = true
retention_keep_index = true

メリット:

  • 監査・コンプライアンス要件への対応
  • 必要時にアーカイブデータへのアクセスが可能
  • 誤削除のリスク軽減

アーカイブデータの参照:

-- アーカイブされたDAUデータを参照
SELECT * FROM archive.statistics_daily_users_p2024_01_01;

運用カスタマイズ

保持期間やスケジュールの変更方法は database-partitioning-guide.md を参照してください。


パフォーマンス考慮事項

インデックス戦略

-- 時系列クエリ用(最頻出)
CREATE INDEX idx_statistics_monthly_tenant_month
ON statistics_monthly (tenant_id, stat_month DESC);

CREATE INDEX idx_statistics_yearly_tenant_year
ON statistics_yearly (tenant_id, stat_year DESC);

-- DAU/MAU/YAUカウントクエリ用
CREATE INDEX idx_statistics_daily_users_tenant_date
ON statistics_daily_users (tenant_id, stat_date);

CREATE INDEX idx_statistics_monthly_users_tenant_month
ON statistics_monthly_users (tenant_id, stat_month);

CREATE INDEX idx_statistics_yearly_users_tenant_year
ON statistics_yearly_users (tenant_id, stat_year);

-- 非アクティブユーザー検索用
CREATE INDEX idx_statistics_yearly_users_last_used
ON statistics_yearly_users (tenant_id, last_used_at);

増分更新による負荷軽減

避けるべきパターン:

❌ 毎回全レコードを再計算(高コスト)
statistics = load_all_events_today()
recalculate(statistics)

推奨パターン:

✅ 増分更新のみ(低コスト)
incrementDailyMetric(tenant, month, day, metric, +1)

モニタリング

統計更新エラーの挙動

統計更新エラーは認証フローを停止しません(疎結合設計)。

監視すべき指標

  • 統計更新の成功率
  • DAU/MAUの急激な変動
  • メトリクス更新のレイテンシ
  • pg_cronジョブの実行状態

データ保持と移行

クリーンアップ関数

非パーティションテーブル用のクリーンアップ関数が提供されています:

-- 月次統計データの削除(指定月数より古いデータ)
SELECT cleanup_old_statistics_monthly(25); -- 25ヶ月より古いデータを削除

-- 年次統計データの削除(指定年数より古いデータ)
SELECT cleanup_old_statistics_yearly(5); -- 5年より古いデータを削除

パーティションテーブルのクリーンアップ: pg_partman が retention 設定に基づいて自動削除します。

過去データの集計

既存の security_event データから統計を再構築するスクリプトが提供されています:

参照: libs/idp-server-database/postgresql/operation/aggregate_historical_statistics.sql

# 基本実行(過去12ヶ月)
psql -h <host> -U <user> -d <database> -f aggregate_historical_statistics.sql

# 日付範囲指定
psql -h <host> -U <user> -d <database> \
-v start_date="'2024-01-01'" \
-v end_date="'2024-12-31'" \
-f aggregate_historical_statistics.sql

検証スクリプト

pg_partman検証用のスクリプトが用意されています:

# pg_partmanセットアップ
./scripts/pg_partman/setup-pg_partman.sh

# 統計ユーザーテーブルのパーティション設定
./scripts/pg_partman/statistics-users-pg_partman.sh

# 動作検証
./scripts/pg_partman/verify-pg_partman.sh

# クリーンアップ
./scripts/pg_partman/cleanup-pg_partman.sh

関連ドキュメント