Triển khai CockroachDB trên Docker: Xây dựng cụm Distributed SQL có khả năng phục hồi cao

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

Bắt đầu nhanh (5 phút): Khởi chạy cụm 3 node

Sáu tháng trước, cổng thanh toán của chúng tôi liên tục bị rớt giao dịch mỗi khi cơ sở dữ liệu chính gặp sự cố chuyển đổi dự phòng (failover). Mô hình PostgreSQL một node duy nhất khi đó mất gần 45 giây mới chuyển một node dự phòng (standby node) lên làm node chính. Khoảng thời gian gián đoạn này khiến chúng tôi mất nhiều đơn hàng, vì vậy chúng tôi quyết định chuyển đổi sang CockroachDB. Mục tiêu rất rõ ràng: hệ thống phải trụ vững trước các sự cố phần cứng mà không cần can thiệp thủ công. Dưới đây là cách khởi chạy một cụm 3 node hoàn chỉnh bằng Docker Compose trong vòng chưa đầy 5 phút.

Đầu tiên, hãy tạo một mạng bridge biệt lập để các node có thể tự nhận diện lẫn nhau qua tên container:

docker network create -d bridge roach-net

Lưu cấu hình sau thành file docker-compose.yml. File này khai báo ba node cơ sở dữ liệu cùng một container chạy một lần để khởi tạo cụm Raft:

version: '3.8'

networks:
  roach-net:
    external: true

services:
  roach1:
    image: cockroachdb/cockroach:v23.2.0
    container_name: roach1
    hostname: roach1
    networks:
      - roach-net
    command: start --insecure --store=type=mem,size=1Gi --listen-addr=0.0.0.0:26257 --advertise-addr=roach1:26257 --http-addr=0.0.0.0:8080 --join=roach1,roach2,roach3
    ports:
      - "26257:26257"
      - "8080:8080"

  roach2:
    image: cockroachdb/cockroach:v23.2.0
    container_name: roach2
    hostname: roach2
    networks:
      - roach-net
    command: start --insecure --store=type=mem,size=1Gi --listen-addr=0.0.0.0:26257 --advertise-addr=roach2:26257 --http-addr=0.0.0.0:8081 --join=roach1,roach2,roach3
    ports:
      - "8081:8081"

  roach3:
    image: cockroachdb/cockroach:v23.2.0
    container_name: roach3
    hostname: roach3
    networks:
      - roach-net
    command: start --insecure --store=type=mem,size=1Gi --listen-addr=0.0.0.0:26257 --advertise-addr=roach3:26257 --http-addr=0.0.0.0:8082 --join=roach1,roach2,roach3
    ports:
      - "8082:8082"

  init-cluster:
    image: cockroachdb/cockroach:v23.2.0
    container_name: init-cluster
    networks:
      - roach-net
    command: init --insecure --host=roach1:26257
    depends_on:
      - roach1
      - roach2
      - roach3

Khởi chạy cụm ở chế độ chạy nền:

docker compose up -d

Khi tất cả các container đã báo trạng thái healthy, hãy mở một phiên SQL tương tác trên roach1:

docker exec -it roach1 ./cockroach sql --insecure

Mở http://localhost:8080 trên trình duyệt của bạn. Console tích hợp sẵn của CockroachDB sẽ hiển thị các chỉ số theo thời gian thực, bao gồm 3 node đang hoạt động, lượng bộ nhớ được cấp phát và thông lượng truy vấn.

Kiến trúc, thuật toán đồng thuận và khả năng tương thích PostgreSQL

Đừng coi CockroachDB như một mô hình PostgreSQL primary truyền thống đi kèm các read replica. Nó không nhân bản tuần tự toàn bộ cơ sở dữ liệu. Về bản chất bên dưới, nó hoạt động như một kho lưu trữ key-value có sắp xếp, hỗ trợ giao dịch, được chia nhỏ thành nhiều phần và quản lý phân tán trên các node.

Công cụ đồng thuận Raft

CockroachDB chia mỗi bảng thành các khối 64 MB gọi là Range. Trong mô hình 3 node, mỗi range được nhân bản trên cả 3 node thông qua thuật toán Raft. Một bản sao (replica) sẽ đóng vai trò là leaseholder chịu trách nhiệm điều phối việc đọc và ghi:

  • Các thao tác ghi yêu cầu đạt số đông biểu quyết (quorum) là (N/2) + 1 node. Với cụm 3 node, phải có ít nhất 2 node xác nhận cho mỗi lượt ghi.
  • Nếu một node bị chết, các giao dịch vẫn tiếp tục diễn ra bình thường vì hai node còn lại vẫn đáp ứng đủ quorum.
  • Nếu một leaseholder bị sập, các bản sao còn sống sẽ phát hiện việc mất tín hiệu heartbeat trong vòng 9 giây và bầu ra một leaseholder kế nhiệm chỉ trong khoảng 300 mili giây.

Thực tế về khả năng tương thích với PostgreSQL

CockroachDB giao tiếp thông qua wire protocol chuẩn của PostgreSQL trên cổng 26257. Các driver hiện tại của bạn—chẳng hạn như pgx của Go, psycopg2 của Python, hay pg của Node—đều có thể kết nối bình thường mà không cần bất kỳ bản patch riêng nào. Tuy nhiên, một số khác biệt về hành vi có thể khiến bạn bất ngờ nếu chưa chuẩn bị trước.

Thay đổi lớn nhất nằm ở mức cô lập giao dịch (isolation level). Mặc định, CockroachDB áp dụng mức cô lập SERIALIZABLE rất nghiêm ngặt, trong khi PostgreSQL thông thường mặc định là READ COMMITTED. Nếu hai giao dịch đồng thời tác động vào các hàng dữ liệu trùng lặp, một giao dịch sẽ bị hủy với mã lỗi SQLSTATE 40001 (serialization_failure). Code ứng dụng của bạn phải bắt được mã lỗi này và thực hiện thử lại (retry) giao dịch bằng chiến lược exponential backoff.

Khôi phục sự cố và nạp dữ liệu

Khả năng phục hồi nghe rất tuyệt trên tài liệu, nhưng nó chỉ thực sự có ý nghĩa khi các node đột ngột biến mất dưới tải lớn. Dưới đây là thử nghiệm thực tế quá trình failover cùng với các luồng nạp dữ liệu tối ưu.

Mô phỏng sự cố node sập đột ngột

Hãy ghi dữ liệu kiểm thử trực tiếp vào roach2:

docker exec -it roach2 ./cockroach sql --insecure -e "
CREATE DATABASE IF NOT EXISTS shop;
CREATE TABLE shop.orders (id UUID DEFAULT gen_random_uuid() PRIMARY KEY, total DECIMAL);
INSERT INTO shop.orders (total) VALUES (49.99), (120.50);
"

Bây giờ hãy mô phỏng một sự cố máy chủ đột ngột bằng cách dừng roach1:

docker stop roach1

Truy vấn tập dữ liệu ngay lập tức từ roach3:

docker exec -it roach3 ./cockroach sql --insecure -e "SELECT * FROM shop.orders;"

Truy vấn trả về kết quả chỉ trong 2 mili giây. Không hề bị đứt kết nối, không cần script chuyển đổi dự phòng và không cần can thiệp thủ công. Nếu roach1 ngừng hoạt động quá 5 phút (ngưỡng server.time_until_store_dead mặc định), cụm sẽ tự động tái phân phối các range bị thiếu bản sao sang các node còn lại.

Nạp các tập dữ liệu lớn

Đừng nạp các file CSV dung lượng lớn bằng hàng loạt câu lệnh INSERT liên tiếp. Các lệnh insert thông thường sẽ phải đi qua cơ chế đồng thuận Raft theo từng hàng, làm nghẽn các luồng điều phối. Thay vào đó, hãy dùng câu lệnh IMPORT INTO nguyên bản. Lệnh này ghi thẳng vào các file của storage engine, dễ dàng xử lý hơn 50.000 hàng mỗi giây.

docker exec -i roach2 ./cockroach sql --insecure -e "
IMPORT INTO shop.orders (id, total) 
CSV DATA ('nodelocal://1/initial_orders.csv');"

Nếu bạn có các file CSV chưa chuẩn hóa cần ánh xạ schema nhanh hoặc chuyển đổi sang JSON trước khi nạp, các công cụ tiện ích như toolcraft.app/vi/tools/data/csv-to-json sẽ xử lý file trực tiếp ngay trên trình duyệt, đảm bảo các bản ghi bảo mật không bao giờ rời khỏi máy trạm của bạn.

Bài học kinh nghiệm thực chiến trên Production

Vận hành cụm cơ sở dữ liệu trong 6 tháng qua đã giúp chúng tôi nhận ra nhiều cạm bẫy mà môi trường Docker cục bộ thường che giấu. Dưới đây là ba nguyên tắc sống còn mà chúng tôi rút ra được:

1. Thiết lập miền chịu lỗi với Locality

Chạy ba container trên cùng một instance EC2 hoặc một máy chủ vật lý đồng nghĩa với việc bạn không hề có tính dự phòng. Chỉ cần một lỗi kernel panic là toàn bộ quorum sẽ sụp đổ. Khi triển khai vượt ra ngoài phạm vi kiểm thử cục bộ, hãy phân bổ các container trên các availability zone khác nhau của đám mây và gắn tag rõ ràng cho từng node:

--locality=region=us-east-1,zone=us-east-1a

CockroachDB sử dụng các cờ locality này để đảm bảo rằng các bản sao của cùng một range 64 MB không bao giờ nằm chung trong một availability zone.

2. Không sử dụng khóa tự tăng tuần tự

Các khóa chính kiểu số nguyên tăng dần (sequential integer) hoặc kiểu SERIAL truyền thống là điều tối kỵ trong các hệ thống phân tán. Các ID tuần tự sẽ điều hướng toàn bộ lượt ghi tiếp theo vào chính xác cùng một range leaseholder. Điều này tạo ra điểm nóng (hotspot) nghiêm trọng về I/O đĩa và CPU trên một node duy nhất trong khi phần còn lại của cụm lại nhàn rỗi. Thay vào đó, hãy dùng UUID qua gen_random_uuid() hoặc hàm unique_rowid() của CockroachDB. Cả hai cách này đều giúp phân bổ lượt ghi đồng đều trên tất cả các ổ đĩa trong cụm.

3. Thiết lập giới hạn cứng cho bộ nhớ

Theo mặc định, CockroachDB tự định kích thước cache dựa trên tổng dung lượng RAM của hệ thống. Khi chạy trong Docker, điều này có thể kích hoạt cơ chế Linux OOM Killer nếu giới hạn bộ nhớ của container không khớp với mức host nhận diện. Hãy thiết lập các giới hạn bộ nhớ rõ ràng ngay khi khởi động:

  • --max-sql-memory=25%: Giới hạn bộ nhớ dành cho các tác vụ tổng hợp (aggregation) đang thực thi, hash join và bộ đệm sắp xếp (sorting buffer).
  • --cache=25%: Cấp phát một vùng cache Pebble chuyên dụng cho các trang đọc thường xuyên (hot read pages).

Khi bạn đã xử lý tốt cơ chế thử lại (retry) trong code và phân tán ngẫu nhiên các khóa chính trên toàn bộ các range, CockroachDB sẽ mang lại khả năng lưu trữ không gián đoạn (zero-downtime) thực sự trên nền tảng Docker.

Share: