Apache Cassandra デプロイガイド:高可用性と書き込み多用ワークロードのためのNoSQL

Database tutorial - IT technology blog
Database tutorial - IT technology blog

クイックスタート:5分でCassandraを起動する

PostgreSQLのマスターが毎秒5万件の書き込みで詰まるのを見たことがあれば、Cassandraが存在する理由はすでに理解できているはずだ。Cassandraは分散型・書き込み多用ワークロードのために設計されており、単一障害点もマスターノードも存在しない。対等なノードがリングを形成し、読み書きを均等に処理する。

まずシングルノードを起動して、以降の詳細セクションを実際に試せるようにしよう。

Docker経由でインストール(最速の方法)

# Cassandraノードをpullして起動
docker run --name cassandra-dev \
  -p 9042:9042 \
  -d cassandra:5.0

# 起動まで約30秒待ってから接続
docker exec -it cassandra-dev cqlsh

cqlshに入ったら、キースペースとテスト用テーブルを作成する:

-- レプリケーションファクター1でキースペースを作成(シングルノード)
CREATE KEYSPACE IF NOT EXISTS app_dev
  WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};

USE app_dev;

CREATE TABLE events (
  user_id UUID,
  event_time TIMESTAMP,
  event_type TEXT,
  payload TEXT,
  PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

-- 1行挿入
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'login', '{"ip": "1.2.3.4"}');

これが基本の流れだ:キースペース → テーブル → 書き込み。では、なぜスケールするのかを理解しよう。

詳細解説:Cassandraの仕組み

リングアーキテクチャ

Cassandraクラスターの各ノードは、巨大なハッシュリングのトークン範囲を担当している。行を書き込む際、Cassandraはパーティションキーをハッシュ化し、そのトークン範囲を所有するノードに書き込みをルーティングする。リーダー選出も、フェイルオーバーの遅延も存在しない。あるノードがダウンしても、その範囲をカバーするレプリカがトラフィックを継続して処理する。

PostgreSQLのストリーミングレプリケーションやMongoDBのレプリカセットと比べてみよう。どちらも書き込みはプライマリに依存している。プライマリを失えば、選出を待つことになる。Cassandraでは、すべてのノードが対等だ。

レプリケーションファクターと一貫性レベル

本番クラスターはレプリケーションファクター3で最低3ノードを運用する。各行は3台の異なるマシンに保存される。一貫性レベルは、Cassandraが成功を返す前に何台のレプリカが読み書きを確認する必要があるかを制御する。

-- QUORUM一貫性での書き込み(過半数のレプリカによる確認が必要)
CONSISTENCY QUORUM;
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'purchase', '{"amount": 99}');

-- 最大書き込みスループットにはONEを使用(書き込み後に非同期でレプリケーション)
CONSISTENCY ONE;
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'pageview', '{"url": "/pricing"}');

私がすべてのプロジェクトで適用しているルール:最大スループットのために書き込みはONEまたはANY、古いデータの提供を避けるために読み込みはLOCAL_QUORUM。分析やイベントロギングパイプラインでは、このCAPトレードオフは正しい選択だ。

データモデリング:クエリを起点に設計する

リレーショナルデータベースから移行するほぼすべてのチームがここでつまずく。Cassandraでは、正規化されたエンティティではなく、アクセスパターンに合わせてテーブルを設計する。

ユーザーがイベントを生成していて、2つのクエリパターンが必要だとしよう:

  • 特定ユーザーの最新100件のイベントを取得する
  • 直近1時間の「purchase」タイプのイベントをすべて取得する

Cassandraではこれは2つの別テーブルになる:

-- テーブル1:ユーザー別イベント
CREATE TABLE events_by_user (
  user_id UUID,
  event_time TIMESTAMP,
  event_type TEXT,
  payload TEXT,
  PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

-- テーブル2:タイプと時間バケット別イベント
CREATE TABLE events_by_type (
  event_type TEXT,
  hour_bucket TEXT,   -- 例: '2026-05-01-14'
  event_time TIMESTAMP,
  user_id UUID,
  payload TEXT,
  PRIMARY KEY ((event_type, hour_bucket), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

そう、同じデータを2回保存する。これは想定通りだ。ディスクは安く、Cassandraにはクロスパーティション結合が存在しない。

応用編:本番クラスターのセットアップ

Docker Composeによるマルチノードクラスター

ローカルで実際のレプリケーション動作をテストするための3ノード構成を以下に示す:

# docker-compose.yml
version: '3.8'
services:
  cassandra-1:
    image: cassandra:5.0
    container_name: cassandra-1
    environment:
      - CASSANDRA_CLUSTER_NAME=MyCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_SEEDS=cassandra-1
    ports:
      - "9042:9042"
    volumes:
      - cassandra1_data:/var/lib/cassandra

  cassandra-2:
    image: cassandra:5.0
    container_name: cassandra-2
    environment:
      - CASSANDRA_CLUSTER_NAME=MyCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_SEEDS=cassandra-1
    depends_on:
      - cassandra-1
    volumes:
      - cassandra2_data:/var/lib/cassandra

  cassandra-3:
    image: cassandra:5.0
    container_name: cassandra-3
    environment:
      - CASSANDRA_CLUSTER_NAME=MyCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_SEEDS=cassandra-1
    depends_on:
      - cassandra-2
    volumes:
      - cassandra3_data:/var/lib/cassandra

volumes:
  cassandra1_data:
  cassandra2_data:
  cassandra3_data:
docker compose up -d

# 約90秒待ってからクラスターの状態を確認
docker exec cassandra-1 nodetool status

正常なクラスターはこのように表示される:

Datacenter: datacenter1
=======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address     Load       Tokens  Owns
UN  172.18.0.2  89.42 KiB  16      33.3%
UN  172.18.0.3  84.17 KiB  16      33.4%
UN  172.18.0.4  91.08 KiB  16      33.3%

UNはUp(稼働中)+ Normal(正常)を意味する。3つのノードがほぼ均等なトークン範囲を所有しており、リングが正常に機能している。

Pythonからの接続

from cassandra.cluster import Cluster
from cassandra.policies import DCAwareRoundRobinPolicy
from cassandra import ConsistencyLevel
from cassandra.query import SimpleStatement
import uuid
from datetime import datetime

# クラスターに接続(任意のシードノードを指定)
cluster = Cluster(
    ['127.0.0.1'],
    port=9042,
    load_balancing_policy=DCAwareRoundRobinPolicy(local_dc='datacenter1')
)
session = cluster.connect('app_dev')

# プリペアドステートメント(必ず使用すること — 事前コンパイルされる)
insert_stmt = session.prepare("""
    INSERT INTO events_by_user (user_id, event_time, event_type, payload)
    VALUES (?, ?, ?, ?)
""")
insert_stmt.consistency_level = ConsistencyLevel.ONE

# イベントを書き込む
session.execute(insert_stmt, [
    uuid.uuid4(),
    datetime.utcnow(),
    'purchase',
    '{"amount": 149, "currency": "USD"}'
])

# ユーザーの最新イベントを読み込む
user_id = uuid.UUID('some-user-uuid-here')
rows = session.execute(
    SimpleStatement(
        "SELECT * FROM events_by_user WHERE user_id = %s LIMIT 10",
        consistency_level=ConsistencyLevel.LOCAL_QUORUM
    ),
    [user_id]
)
for row in rows:
    print(row.event_time, row.event_type)

Cassandraにデータを一括インポートする前に必ずやること:CSVファイルの準備だ。データインポート用にCSVをJSONへ素早く変換する必要があるときは、toolcraft.app/ja/tools/data/csv-to-jsonを使っている。ブラウザ上で完全に動作するため、データが外部に送信されない。GDPRの対象となる顧客データをオンラインAPIに通せない場合に重宝する。

現場からの実践的なヒント

コンパクション戦略の選択は重要

CassandraのデフォルトはSizeTieredCompactionStrategyで、書き込み多用ワークロードに適している。しかし、イベントログ、センサーデータ、ユーザーアクティビティなど、データに明確な時間軸がある場合はTimeWindowCompactionStrategyに切り替えよう:

ALTER TABLE events_by_user
  WITH compaction = {
    'class': 'TimeWindowCompactionStrategy',
    'compaction_window_unit': 'HOURS',
    'compaction_window_size': 1
  };

SSTabelが時間ウィンドウでグループ化されるため、TTLによる古いデータの削除コストがSTCSと比べて大幅に下がる。30日間のリテンションポリシーを持つテーブルで、この切り替え後に読み込みレイテンシーが約40%低下するのを確認している。

ログ形式のデータにはTTLを活用する

-- 90日後にイベントを自動削除(7776000秒)
INSERT INTO events_by_user (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'login', '{"ip": "1.2.3.4"}')
USING TTL 7776000;

nodetoolは最高の味方

# クラスター全体のヘルスを確認
nodetool status

# テーブルごとの読み書き統計を確認
nodetool tablestats app_dev.events_by_user

# テーブルのコンパクションを強制実行(トラフィックが少ない時間帯に実施)
nodetool compact app_dev events_by_user

# memtableをディスクにフラッシュ(スナップショットバックアップ前に有効)
nodetool flush app_dev

よくある落とし穴を避ける

  • 大きなパーティション:1ユーザーが1千万件のイベントを生成すると、パーティションがあっという間に肥大化する。パーティションキーに時間バケットを追加し(例:(user_id, day_bucket))、パーティションサイズを100MB以下に保つこと。
  • ALLOW FILTERING:本番クエリでは絶対に使用しないこと。クラスター全体のスキャンが発生する。代わりにテーブルを再設計すること。
  • 無制限のIN句WHERE user_id IN (1000件のUUID)はパフォーマンスを著しく低下させる。アプリケーション層で非同期クエリを使ってバッチ処理すること。
  • repairのスキップ:週次でnodetool repairを実行すること。ノード障害中にドリフトしたレプリカ間のデータを整合させる。長期間スキップすると、古いデータを読み込む事態が必ず発生する。

Cassandraが適さない場面

大量の時系列データ、イベントストリーム、IoTセンサーデータ、ユーザーアクティビティログ — これらはCassandraが難なく処理できる得意分野だ。複雑な結合、複数行にまたがるトランザクション、アドホックな分析クエリが必要になった時点で、別のツールを選ぼう。トランザクション処理にはPostgreSQL、大規模な分析ワークロードにはClickHouseが適している。最初から適切なツールを選ぶことが、半年後の苦痛なデータ移行を防ぐ最善策だ。

Share: