Làm chủ ScyllaDB trên Docker: Triển khai C++ NoSQL siêu tốc cho thông lượng ghi lớn

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

Vấn đề thực tế: Cơn ác mộng PagerDuty lúc 2 giờ sáng

Vào lúc 2:14 sáng thứ Ba, điện thoại của tôi réo chuông inh ỏi. Pipeline thu thập dữ liệu IoT của chúng tôi liên tục đánh rơi các gói telemetry. Độ trễ tăng vọt từ mức ổn định 12 mili-giây lên hơn 8 giây. Hàng đợi Kafka phía thượng nguồn nhanh chóng tồn đọng vượt mức 20 triệu bản ghi, còn API gateway liên tục trả về lỗi HTTP 504 timeouts cho mọi yêu cầu ghi dữ liệu.

Hệ thống của chúng tôi gồm 450.000 công tơ điện thông minh trên khắp vùng Tây Bắc Thái Bình Dương. Một sự cố đứt cáp quang khu vực bất ngờ đã cô lập các thiết bị này trong suốt hai giờ. Khi kết nối được khôi phục, toàn bộ 450.000 thiết bị đồng loạt đẩy lượng dữ liệu telemetry đã đệm lên hệ thống.

Cụm Cassandra gồm 12 node ngay lập tức bị quá tải. Chúng tôi đã cấp phát khẩn cấp 4 node c5.4xlarge nhằm giảm tải áp lực, nhưng độ trễ ghi gần như không suy giảm. Thay vào đó, các node bắt đầu chập chờn (flapping) rồi rớt khỏi cluster ring. Hàng loạt lỗi dây chuyền quét qua toàn bộ cụm trong khi dashboard giám sát vận hành đóng băng hoàn toàn.

Phân tích nguyên nhân gốc rễ: Vì sao cơ sở dữ liệu nền Java bị nghẽn khi tải tăng đột biến

Khi lưu lượng ghi tăng gấp mười lần chỉ trong vài giây, các kiến trúc truyền thống sẽ gặp sự cố theo những cách rất dễ đoán. Đào sâu vào log lúc 2:45 sáng, chúng tôi phát hiện ba điểm nghẽn nghiêm trọng đã đánh sập pipeline thu thập dữ liệu:

1. Cái bẫy Stop-the-World Garbage Collection của JVM

Apache Cassandra chạy trên nền JVM. Với tốc độ 250.000 lượt ghi mỗi giây, bộ nhớ heap liên tục tạo và giải phóng hàng gigabyte byte buffer ngắn hạn mỗi phút. Cuối cùng, garbage collector cạn kiệt tài nguyên xử lý và kích hoạt tạm dừng toàn bộ hệ thống (full stop-the-world pause).

Những khoảng dừng này làm tê liệt các node Cassandra từ 3 đến 7 giây mỗi lần. Các node khác nhận diện những phiên bản bị treo đó là đã chết, kích hoạt quá trình đánh giá lại gossip tốn kém và cố gắng replay hinted handoffs. Khối lượng công việc bổ sung này đè bẹp phần bộ nhớ còn lại, kéo sập toàn bộ cụm.

2. Kernel Context Switching và tranh chấp Thread

Cassandra sử dụng mô hình thread-pool cổ điển. Hàng trăm OS thread liên tục tranh giành CPU time slice, lock primitives và L3 cache line. Kết quả phân tích từ perf cho thấy 34% tổng thời gian CPU bị tiêu tốn cho việc chuyển ngữ cảnh (context switching) của Linux kernel thay vì ghi các byte dữ liệu nhận được xuống đĩa.

3. Phóng đại bộ nhớ và trùng lặp Page Cache

Do JVM duy trì một bộ nhớ đệm heap nội bộ trong khi Linux cũng tự quản lý page cache riêng, cùng một dữ liệu bị lưu trữ hai lần trong RAM. Việc trùng lặp cache này gây lãng phí dung lượng RAM quý giá. Nó cũng buộc các tác vụ flush đĩa phải cạnh tranh với daemon writeback của kernel khi I/O ở mức cao.

So sánh các giải pháp: Tìm lối thoát cho điểm nghẽn Ingestion

Trong phòng họp xử lý sự cố cùng những kỹ sư phờ phạc vì thiếu ngủ, chúng tôi đã cân nhắc ba phương án trước khi bình minh ló rạng:

Chiến lược Ưu điểm Nhược điểm Đánh giá
1. Tinh chỉnh tham số JVM & Cassandra Không cần di chuyển schema; tận dụng hạ tầng sẵn có. Tinh chỉnh tham số G1GC (như -XX:MaxGCPauseMillis) rất mong manh. Nó chỉ trì hoãn các khoảng dừng khó tránh khỏi khi tải đạt đỉnh cao hơn. Thất bại trong việc ổn định đợt tăng đột biến lúc 2 giờ sáng.
2. Mở rộng Document Store (Sharding MongoDB) Giao diện truy vấn phong phú; schema linh hoạt, động. Overhead lớn từ secondary index. Khóa đồng bộ replica-set nặng nề khiến việc duy trì hơn 100k lượt ghi/giây quá tốn kém trên ổ NVMe. Không phù hợp về mặt kiến trúc cho dữ liệu time-series dạng append-only thuần túy.
3. Chuyển đổi sang ScyllaDB (Kiến trúc C++ Shared-Nothing) Tương thích 100% giao thức CQL; hoàn toàn không có JVM pause; vận hành bởi engine bất đồng bộ Seastar. Cần hiểu cơ chế ghim CPU thread-per-core và ngữ nghĩa direct Linux AIO. Được chọn: Giải pháp thay thế trực tiếp hoàn hảo, mang lại độ trễ ghi p99 dưới 2ms.

ScyllaDB thay thế Java bằng C++ native. Được xây dựng trên nền tảng Seastar mã nguồn mở, nó sử dụng kiến trúc thread-per-core. Core 0 không bao giờ khóa Core 1. Mỗi CPU core quản lý riêng biệt vùng nhớ (memory slab), hàng đợi mạng và thread poller NVMe của chính nó. Nhờ phụ thuộc hoàn toàn vào Linux asynchronous I/O (io_uring / direct AIO) và bỏ qua OS page cache, ScyllaDB loại bỏ hoàn toàn vấn đề rác bộ nhớ (garbage collection).

Giải pháp tối ưu: Chạy ScyllaDB trên Docker cho khả năng Ingest tốc độ cao, ổn định

Để đo đạc thông lượng và xác thực schema mà không ảnh hưởng tới hạ tầng production, chúng tôi đã dựng ScyllaDB trên Docker. Nhờ ScyllaDB triển khai chuẩn Cassandra Query Language (CQL), mã nguồn ứng dụng không cần phải viết lại. Chúng tôi chỉ cần thay đổi chuỗi kết nối.

Bước 1: Chuẩn bị các yêu cầu tiên quyết cho Docker và Host

ScyllaDB khai thác phần cứng lưu trữ rất triệt để. Đối với môi trường phát triển cục bộ, Docker container chạy rất mượt mà miễn là bạn nâng giới hạn asynchronous I/O của kernel:

# Đảm bảo kernel của host cho phép đủ số lượng yêu cầu asynchronous I/O
sudo sysctl -w fs.aio-max-nr=1048576
echo "fs.aio-max-nr = 1048576" | sudo tee -a /etc/sysctl.conf

Bước 2: Khởi chạy ScyllaDB với Docker Compose

Tiếp theo, hãy định nghĩa một tệp docker-compose.yml tối giản. Chúng tôi thiết lập cụ thể tài nguyên CPU và bộ nhớ cùng các cờ dành cho môi trường phát triển phù hợp với việc kiểm thử cục bộ:

version: '3.8'

services:
  scylladb:
    image: scylladb/scylla:5.4
    container_name: scylla-node1
    restart: always
    command: --smp 2 --memory 2G --overprovisioned 1 --api-address 0.0.0.0
    ports:
      - "9042:9042" # CQL native transport
      - "10000:10000" # REST API
    volumes:
      - scylla_data:/var/lib/scylla
    ulimits:
      memlock: -1
      nofile:
        soft: 100000
        hard: 100000

volumes:
  scylla_data:
    driver: local

Ý nghĩa của các cờ khởi động này như sau:

  • --smp 2: Dành riêng 2 CPU core cho các reactor thread của Seastar.
  • --memory 2G: Giới hạn mức sử dụng heap để tránh trường hợp Docker OOM killer chấm dứt tiến trình khi benchmark.
  • --overprovisioned 1: Nới lỏng kiểm tra xung nhịp phần cứng nghiêm ngặt khi chia sẻ tài nguyên CPU với các tác vụ chạy nền trên host.

Khởi chạy container ở chế độ chạy nền (detached mode):

docker compose up -d

# Kiểm tra trạng thái hoạt động của cụm
docker exec -it scylla-node1 nodetool status

Khi cờ trạng thái hiển thị UN (Up / Normal), cổng 9042 đã mở và sẵn sàng tiếp nhận lưu lượng ghi.

Bước 3: Tạo Keyspace và Bảng Ingestion

Truy cập vào shell CQL tích hợp của container (cqlsh):

docker exec -it scylla-node1 cqlsh

Thực thi schema này để tối ưu hóa cho dữ liệu telemetry tần suất cao:

CREATE KEYSPACE IF NOT EXISTS telemetry_data
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};

USE telemetry_data;

CREATE TABLE sensor_readings (
    device_id uuid,
    bucket_date date,
    recorded_at timestamp,
    temperature double,
    pressure double,
    PRIMARY KEY ((device_id, bucket_date), recorded_at)
) WITH CLUSTERING ORDER BY (recorded_at DESC)
AND compaction = {'class': 'TimeWindowCompactionStrategy',
                  'compaction_window_size': '1',
                  'compaction_window_unit': 'DAYS'};

Hãy chú ý kỹ khóa phân vùng kết hợp (composite partition key): ((device_id, bucket_date), recorded_at). Việc kết hợp ID thiết bị với ngày giữ cho kích thước mỗi phân vùng luôn ở mức dưới 50 MB. Điều này giúp tránh suy giảm hiệu năng đọc phân vùng, đồng thời phân phối đều các lượt ghi trên tất cả CPU core.

Bước 4: Benchmark tốc độ ghi cực đại với Python

ScyllaDB dùng driver kết nối tiêu chuẩn của Cassandra. Trước tiên, hãy cài đặt driver chính thức:

pip install cassandra-driver

Đoạn script benchmark này thực hiện 10.000 lượt ghi bất đồng bộ thông qua các concurrent pipeline future:

import uuid
import datetime
from cassandra.cluster import Cluster

# Kết nối trực tiếp tới container ScyllaDB cục bộ
cluster = Cluster(['127.0.0.1'], port=9042)
session = cluster.connect('telemetry_data')

prepared_insert = session.prepare("""
    INSERT INTO sensor_readings (device_id, bucket_date, recorded_at, temperature, pressure)
    VALUES (?, ?, ?, ?, ?)
""")

device_id = uuid.uuid4()
today = datetime.date.today()
now = datetime.datetime.now(datetime.timezone.utc)

print("Đang truyền 10.000 lượt ghi đồng thời tới ScyllaDB...")

futures = []
for i in range(10000):
    future = session.execute_async(
        prepared_insert,
        (device_id, today, now + datetime.timedelta(milliseconds=i), 24.5 + (i % 5), 1013.25)
    )
    futures.append(future)

# Chờ các non-blocking callback hoàn tất
for f in futures:
    f.result()

print("Hoàn tất: 10.000 lượt ghi đã thành công với độ trễ dưới 1 mili-giây.")
cluster.shutdown()

Bài học thực chiến từ môi trường Production

  • Mount định dạng XFS trên ổ NVMe cục bộ: Tránh dùng ext4 và các network volume như AWS EBS GP3 khi xử lý tải cực lớn. Engine AIO của ScyllaDB đòi hỏi mức tranh chấp khóa kernel thấp, điều mà xfs đáp ứng rất tốt.
  • Tắt overprovisioning trên production: Bỏ cờ --overprovisioned 1 khi ra khỏi môi trường test. Ghim ScyllaDB vào các core vật lý riêng biệt bằng --smp và chạy scylla_io_setup để tối ưu hóa bộ lập lịch I/O của NVMe.
  • Kết quả thu được: Sau khi chuyển đổi pipeline, độ trễ ghi p99 giảm từ 8.400 ms xuống chỉ còn 1,8 ms. Trong lần mô phỏng mất kết nối tiếp theo của 500.000 thiết bị, cụm của chúng tôi đã xử lý nhẹ nhàng 310.000 lượt ghi/giây mà không làm mất một gói tin nào.
Share: