Hướng Dẫn Triển Khai Apache Cassandra: NoSQL cho High Availability và Workload Ghi Nặng

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

Khởi Động Nhanh: Chạy Cassandra Trong 5 Phút

Nếu bạn đã từng chứng kiến cảnh PostgreSQL master chết đứng ở 50.000 lượt ghi mỗi giây, bạn sẽ hiểu ngay tại sao Cassandra ra đời. Nó được thiết kế đặc biệt cho workload phân tán, ghi nặng — không có single point of failure, không có master node, chỉ là một vòng các peer xử lý đọc và ghi ngang hàng nhau.

Hãy khởi động một node đơn để bạn có thể theo dõi các phần sâu hơn bên dưới.

Cài qua Docker (nhanh nhất)

# Kéo và khởi động một Cassandra node
docker run --name cassandra-dev \
  -p 9042:9042 \
  -d cassandra:5.0

# Đợi ~30 giây để khởi động, sau đó kết nối
docker exec -it cassandra-dev cqlsh

Khi vào được cqlsh, tạo một keyspace và bảng thử nghiệm:

-- Tạo keyspace với replication factor 1 (single node)
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);

-- Chèn một dòng dữ liệu
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'login', '{"ip": "1.2.3.4"}');

Đó là vòng lặp cốt lõi: keyspace → table → ghi dữ liệu. Giờ hãy hiểu tại sao nó scale được.

Tìm Hiểu Sâu: Cassandra Thực Sự Hoạt Động Như Thế Nào

Kiến Trúc Ring

Mỗi node trong cluster Cassandra sở hữu một dải token — một lát cắt của một vòng hash khổng lồ. Khi bạn ghi một dòng dữ liệu, Cassandra hash partition key và định tuyến lượt ghi đó đến node sở hữu dải token tương ứng. Không cần bầu chọn leader, không có độ trễ failover. Nếu một node chết, các replica đảm nhiệm dải token của nó tiếp tục phục vụ traffic mà không bỏ sót một nhịp nào.

So sánh với PostgreSQL streaming replication hay MongoDB replica sets — cả hai vẫn phụ thuộc vào primary để ghi. Mất primary là bạn phải chờ bầu chọn. Với Cassandra, mọi node đều bình đẳng.

Replication Factor và Consistency Level

Cluster production chạy ít nhất 3 node với replication factor là 3. Mỗi dòng dữ liệu tồn tại trên 3 máy khác nhau. Consistency level sau đó kiểm soát bao nhiêu replica phải xác nhận một lượt đọc hoặc ghi trước khi Cassandra trả về kết quả thành công.

-- Ghi với QUORUM consistency (đa số replica phải xác nhận)
CONSISTENCY QUORUM;
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'purchase', '{"amount": 99}');

-- Để tối đa throughput ghi, dùng ONE (ghi ngay, replicate bất đồng bộ)
CONSISTENCY ONE;
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'pageview', '{"url": "/pricing"}');

Quy tắc tôi áp dụng trong mọi dự án: ghi ở mức ONE hoặc ANY để tối đa throughput, đọc ở mức LOCAL_QUORUM để tránh trả về dữ liệu cũ. Với các pipeline analytics và event-logging, sự đánh đổi CAP này hoàn toàn phù hợp.

Data Modeling: Nghĩ Đến Query Trước

Hầu hết các team chuyển từ cơ sở dữ liệu quan hệ đều vấp phải điều này. Trong Cassandra, bạn thiết kế bảng xoay quanh access pattern — không phải các entity được chuẩn hóa.

Giả sử bạn có người dùng tạo ra các sự kiện và cần hai query pattern:

  • Lấy 100 sự kiện mới nhất của một người dùng cụ thể
  • Lấy tất cả sự kiện loại “purchase” trong giờ vừa qua

Đó là hai bảng riêng biệt trong Cassandra:

-- Bảng 1: sự kiện theo người dùng
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);

-- Bảng 2: sự kiện theo loại và khung giờ
CREATE TABLE events_by_type (
  event_type TEXT,
  hour_bucket TEXT,   -- ví dụ '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);

Đúng vậy, bạn lưu cùng một dữ liệu hai lần. Điều đó là bình thường — disk rẻ, còn join giữa các partition đơn giản là không tồn tại trong Cassandra.

Nâng Cao: Thiết Lập Cluster Production

Cluster Nhiều Node với Docker Compose

Dưới đây là cấu hình 3 node bạn có thể khởi động ở local để kiểm tra hành vi replication thực tế:

# 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

# Đợi ~90 giây, sau đó kiểm tra trạng thái cluster
docker exec cassandra-1 nodetool status

Một cluster khỏe mạnh trông như thế này:

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 nghĩa là Up + Normal. Cả ba node sở hữu dải token xấp xỉ bằng nhau — ring đang hoạt động đúng.

Kết Nối từ 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

# Kết nối đến cluster (trỏ đến bất kỳ seed node nào)
cluster = Cluster(
    ['127.0.0.1'],
    port=9042,
    load_balancing_policy=DCAwareRoundRobinPolicy(local_dc='datacenter1')
)
session = cluster.connect('app_dev')

# Prepared statement (luôn dùng cái này — đã được biên dịch sẵn)
insert_stmt = session.prepare("""
    INSERT INTO events_by_user (user_id, event_time, event_type, payload)
    VALUES (?, ?, ?, ?)
""")
insert_stmt.consistency_level = ConsistencyLevel.ONE

# Ghi một sự kiện
session.execute(insert_stmt, [
    uuid.uuid4(),
    datetime.utcnow(),
    'purchase',
    '{"amount": 149, "currency": "USD"}'
])

# Đọc sự kiện mới nhất của một người dùng
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)

Một việc tôi luôn làm trước khi import dữ liệu hàng loạt vào Cassandra là chuẩn bị file CSV trước. Khi cần chuyển đổi nhanh CSV sang JSON cho việc import dữ liệu, tôi dùng toolcraft.app/vi/tools/data/csv-to-json — chạy hoàn toàn trên trình duyệt nên không có dữ liệu nào rời khỏi máy bạn. Rất tiện khi xử lý dữ liệu khách hàng theo GDPR mà không thể đẩy qua API trực tuyến.

Kinh Nghiệm Thực Tế từ Thực Chiến

Chiến Lược Compaction Rất Quan Trọng

Cassandra mặc định dùng SizeTieredCompactionStrategy, xử lý tốt workload ghi nặng. Nhưng khi dữ liệu của bạn có chiều thời gian rõ ràng — event log, dữ liệu cảm biến, hoạt động người dùng — hãy chuyển sang TimeWindowCompactionStrategy:

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

SSTable được nhóm theo time window, nên việc hết hạn dữ liệu cũ bằng TTL tốn chi phí chỉ bằng một phần nhỏ so với STCS. Trên bảng có retention 30 ngày, tôi đã thấy read latency giảm ~40% sau khi thay đổi này.

Dùng TTL cho Dữ Liệu Dạng Log

-- Tự động hết hạn sự kiện sau 90 ngày (7776000 giây)
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 Là Người Bạn Tốt Nhất Của Bạn

# Kiểm tra sức khỏe tổng thể của cluster
nodetool status

# Xem thống kê đọc/ghi theo từng bảng
nodetool tablestats app_dev.events_by_user

# Ép buộc compaction trên một bảng (thực hiện lúc traffic thấp)
nodetool compact app_dev events_by_user

# Flush memtable xuống đĩa (hữu ích trước khi tạo snapshot backup)
nodetool flush app_dev

Tránh Những Lỗi Phổ Biến Này

  • Partition quá lớn: Nếu một người dùng tạo ra 10 triệu sự kiện, partition đó sẽ phình to nhanh chóng. Thêm time bucket vào partition key (ví dụ: (user_id, day_bucket)) để giữ kích thước partition dưới 100MB.
  • ALLOW FILTERING: Đừng bao giờ dùng cái này trong query production. Nó kích hoạt full cluster scan. Hãy thiết kế lại bảng thay vì dùng nó.
  • Mệnh đề IN không giới hạn: WHERE user_id IN (1000 UUID) giết chết hiệu năng. Hãy chia nhỏ chúng bằng async query ở tầng ứng dụng.
  • Bỏ qua repair: Chạy nodetool repair hàng tuần. Nó đồng bộ dữ liệu giữa các replica bị lệch trong quá trình node bị down. Bỏ qua đủ lâu và bạn sẽ đọc phải dữ liệu cũ — chắc chắn như vậy.

Khi Nào Cassandra Là Lựa Chọn Sai

Time-series khối lượng lớn, event stream, dữ liệu cảm biến IoT, log hoạt động người dùng — Cassandra xử lý tất cả những thứ này một cách dễ dàng. Ngay khi bạn cần join phức tạp, transaction trên nhiều dòng, hay ad-hoc analytical query, hãy tìm đến công cụ khác. PostgreSQL lo phần transaction; ClickHouse xử lý analytical workload ở quy mô lớn. Chọn đúng công cụ ngay từ đầu còn hơn phải đối mặt với một cuộc migration dữ liệu đau đớn sau sáu tháng.

Share: