🗄️ Cloud Bigtable テーブルと行キー設計

NoSQLデータベースの基礎と最適な行キー設計

📚 Cloud Bigtableの基本構造

🎯 Bigtableの特徴

Bigtableのデータ構造

🔑 Row Key: customer#12345#9223370450809775807
📁 Column Family: "purchase"
product_id: "PROD-789"
@ 2024-11-25 10:30:00
amount: "15000"
@ 2024-11-25 10:30:00
quantity: "2"
@ 2024-11-25 10:30:00
📁 Column Family: "metadata"
store: "Tokyo-Shibuya"
@ 2024-11-25 10:30:00
payment_method: "credit_card"
@ 2024-11-25 10:30:00
🔍 重要な概念:

🔑 行キー(Row Key)設計の重要性

行キーはBigtableで最も重要な設計要素です。 行キーの設計が、パフォーマンス、スケーラビリティ、クエリ効率の全てを決定します。

✨ 良い行キー設計がもたらす効果

行キー設計の基本原則

原則 説明
アクセスパターンを考慮 最も頻繁なクエリを最適化する設計 顧客IDで検索が多い → 顧客IDを行キーの先頭に
辞書順ソートを活用 関連データが隣接するように設計 同じ顧客のデータを連続配置
ホットスポット回避 単調増加するIDは避ける タイムスタンプのみの行キーは NG
逆順タイムスタンプ 最新データを先頭に配置 Long.MAX_VALUE - timestamp
適切な長さ 短すぎず長すぎず(推奨: 4KB未満) UUID(36文字)は適切

🛍️ ケーススタディ: オンライン小売業者の購入履歴

📋 要件

❌ 悪い設計例

❌ タイムスタンプのみ
// 行キー: タイムスタンプのみ rowKey = "20241125103000"
問題点:
  • 顧客ごとのデータ取得が不可能
  • 全テーブルスキャンが必要
  • ホットスポット発生(最新データに集中)
❌ 顧客ID + 通常タイムスタンプ
// 行キー: 顧客ID#タイムスタンプ rowKey = "customer#12345#20241125103000"
問題点:
  • 最新データが末尾に配置される
  • 最新データ取得に全行スキャンが必要
  • 範囲スキャンの開始位置が不明

✅ 最適な設計: 顧客ID + 逆タイムスタンプ

✅ 推奨される設計
// 行キー: 顧客ID#逆タイムスタンプ long reverseTimestamp = Long.MAX_VALUE - System.currentTimeMillis(); String rowKey = "customer#" + customerId + "#" + reverseTimestamp; // 例: // customer#12345#9223370450809775807 ← 最新(2024-11-25 10:30:00) // customer#12345#9223370450809775808 ← 1秒前 // customer#12345#9223370450809775809 ← 2秒前

行キーの構造

customer#12345 # 9223370450809775807
顧客ID部分

同じ顧客のデータを連続配置
顧客ごとのクエリが効率的

逆タイムスタンプ部分

最新データが先頭に配置
最新N件の取得が高速

🔍 なぜこの設計が最適なのか

理由1️⃣: 顧客ごとのデータが連続配置される

行キー(辞書順でソート) 商品ID 金額 実際の日時
customer#12345#9223370450809775807 PROD-789 ¥15,000 2024-11-25 10:30
customer#12345#9223370453809775807 PROD-456 ¥8,500 2024-11-24 15:20
customer#12345#9223370460809775807 PROD-123 ¥12,000 2024-11-20 09:15
customer#67890#9223370449809775807 PROD-321 ¥5,000 2024-11-25 11:00
customer#67890#9223370455809775807 PROD-654 ¥25,000 2024-11-23 14:30

✅ 同じ顧客(customer#12345)のデータが連続して配置される
✅ 顧客IDで範囲スキャンすると、その顧客の全購入履歴を効率的に取得

理由2️⃣: 最新データが常に先頭に配置される

時系列とBigtableでの配置順序

実際の時系列(古→新)
2024-11-20 09:15
→ 最古
2024-11-24 15:20
2024-11-25 10:30
→ 最新
Bigtableでの配置順序
...#9223370450809775807
← 先頭(最新)
...#9223370453809775807
...#9223370460809775807
← 最古

✅ 逆タイムスタンプにより、最新データが行キーの辞書順で先頭に
✅ 「最新N件取得」が範囲スキャンの最初のN行を取るだけで完了

理由3️⃣: 最新データの取得が超高速

// 顧客12345の最新の購入履歴を1件取得 String startKey = "customer#12345#"; String endKey = "customer#12345$"; // $ は # の次の文字 Query query = Query.create(TABLE_ID) .range(startKey, endKey) .limit(1); // ← たった1行読むだけ! // 結果: customer#12345#9223370450809775807 ← 最新データ // 最新10件取得も簡単 query.limit(10); // ← 最初の10行を読むだけ

✅ 範囲スキャンの開始位置が明確
✅ 最初のN行を読むだけで最新N件が取得可能
✅ 全行スキャン不要で、レイテンシが極めて低い

理由4️⃣: ホットスポットを回避

❌ タイムスタンプのみの場合

最新のタイムスタンプ範囲に全ての書き込みが集中
→ 特定のノードに負荷が集中
→ ボトルネックが発生

ノードA: 🔥🔥🔥 100%
✅ 顧客ID + 逆タイムスタンプ

顧客IDが先頭にあるため書き込みが分散
→ 各ノードに均等に負荷分散
→ スケーラブル

ノードA: 25%
ノードB: 25%
ノードC: 25%
ノードD: 25%

📊 パフォーマンス比較

❌ 通常のタイムスタンプ

~500ms

最新データ取得に
全行スキャンが必要

読み取り行数: 数千〜数万行

✅ 逆タイムスタンプ

~5ms

最初の1行を読むだけ
100倍高速

読み取り行数: 1行のみ

💰 コスト削減効果

💻 実装例

Java実装

import com.google.cloud.bigtable.data.v2.BigtableDataClient; import com.google.cloud.bigtable.data.v2.models.*; public class PurchaseHistoryService { private static final String TABLE_ID = "purchase_history"; private static final String COLUMN_FAMILY = "purchase"; // 購入履歴の保存 public void savePurchase(String customerId, Purchase purchase) { // 逆タイムスタンプの計算 long reverseTimestamp = Long.MAX_VALUE - purchase.getTimestamp(); String rowKey = "customer#" + customerId + "#" + reverseTimestamp; RowMutation mutation = RowMutation.create(TABLE_ID, rowKey) .setCell(COLUMN_FAMILY, "product_id", purchase.getProductId()) .setCell(COLUMN_FAMILY, "amount", purchase.getAmount()) .setCell(COLUMN_FAMILY, "quantity", purchase.getQuantity()); dataClient.mutateRow(mutation); } // 最新の購入履歴を取得 public Purchase getLatestPurchase(String customerId) { String prefix = "customer#" + customerId + "#"; Query query = Query.create(TABLE_ID) .prefix(prefix) .limit(1); // 最新の1件のみ ServerStream<Row> rows = dataClient.readRows(query); for (Row row : rows) { return parseRowToPurchase(row); } return null; } // 最新N件の購入履歴を取得 public List<Purchase> getRecentPurchases(String customerId, int limit) { String prefix = "customer#" + customerId + "#"; Query query = Query.create(TABLE_ID) .prefix(prefix) .limit(limit); List<Purchase> purchases = new ArrayList<>(); ServerStream<Row> rows = dataClient.readRows(query); for (Row row : rows) { purchases.add(parseRowToPurchase(row)); } return purchases; } // 特定期間の購入履歴を取得 public List<Purchase> getPurchasesByDateRange( String customerId, long startTime, long endTime) { // 逆タイムスタンプは時間が逆になることに注意 long reverseStart = Long.MAX_VALUE - endTime; // 新しい方 long reverseEnd = Long.MAX_VALUE - startTime; // 古い方 String startKey = "customer#" + customerId + "#" + reverseStart; String endKey = "customer#" + customerId + "#" + reverseEnd; Query query = Query.create(TABLE_ID) .range(startKey, endKey); List<Purchase> purchases = new ArrayList<>(); ServerStream<Row> rows = dataClient.readRows(query); for (Row row : rows) { purchases.add(parseRowToPurchase(row)); } return purchases; } }

Python実装

from google.cloud import bigtable import sys class PurchaseHistoryService: def __init__(self, project_id, instance_id): client = bigtable.Client(project=project_id) instance = client.instance(instance_id) self.table = instance.table('purchase_history') self.column_family = 'purchase' def save_purchase(self, customer_id, purchase): # 逆タイムスタンプの計算 reverse_timestamp = sys.maxsize - purchase['timestamp'] row_key = f"customer#{customer_id}#{reverse_timestamp}" row = self.table.direct_row(row_key) row.set_cell(self.column_family, 'product_id', purchase['product_id']) row.set_cell(self.column_family, 'amount', str(purchase['amount'])) row.set_cell(self.column_family, 'quantity', str(purchase['quantity'])) row.commit() def get_latest_purchase(self, customer_id): # プレフィックススキャンで最新1件取得 prefix = f"customer#{customer_id}#" rows = self.table.read_rows( start_key=prefix.encode(), end_key=f"{prefix}\xff".encode(), limit=1 ) for row in rows: return self._parse_row(row) return None def get_recent_purchases(self, customer_id, limit=10): prefix = f"customer#{customer_id}#" rows = self.table.read_rows( start_key=prefix.encode(), end_key=f"{prefix}\xff".encode(), limit=limit ) purchases = [] for row in rows: purchases.append(self._parse_row(row)) return purchases

⚠️ その他の設計上の注意点

🔍 行キー設計のアンチパターン
シナリオ 推奨される行キー設計 理由
IoTセンサーデータ device_id#reverse_timestamp デバイスごとの最新データを高速取得
ユーザーアクティビティログ user_id#reverse_timestamp#event_id ユーザーごとの最新アクティビティを効率的に取得
株価時系列データ stock_symbol#reverse_timestamp 銘柄ごとの最新価格を即座に取得
チャットメッセージ room_id#reverse_timestamp#message_id チャットルームごとの最新メッセージを高速表示
ソーシャルメディア投稿 user_id#reverse_timestamp#post_id ユーザーのタイムラインを効率的に表示

🎯 行キー設計のゴールデンルール

  1. アクセスパターンを最優先: 最も頻繁なクエリを最適化する
  2. グルーピング要素を先頭に: 関連データを連続配置
  3. 時系列データは逆順: 最新データへの高速アクセス
  4. 負荷分散を考慮: 書き込みが均等に分散されるように
  5. 将来の拡張性: データ量増加を見越した設計

📚 まとめ

🎓 オンライン小売業者の購入履歴における最適解

customer#{customer_id}#{reverse_timestamp}
💡 この設計により:
  • レイテンシ: 500ms → 5ms(100倍改善)
  • 読み取りコスト: 数千行 → 1行(1/1000に削減)
  • スループット: 同じリソースで100倍以上のクエリを処理