Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

vận hành database cluster & tính Idempotent

Tung Bui Thanh (TungDaDev)9 mins read

Database operations at scale.webp

Script tự động hoá nào không được thiết kế Idempotent thì đều là một quả bom nằm chờ ngày đánh sập cụm database production của bạn.

# tình huống quen: đang bảo trì thì đứt giữa chừng

Khi quản lý hạ tầng phân tán cỡ lớn (vài chục tới vài trăm node database như ScyllaDB, Cassandra hay Elasticsearch), mấy việc như bảo trì, cập nhật kernel OS hay nâng version phần mềm thường được chạy tự động bằng script (Ansible, Python, Bash hoặc Go operator).

Một sự cố rất hay gặp diễn ra thế này:

  1. Script rolling restart lần lượt từng node để cập nhật cụm 100 node.
  2. Tới node thứ 46 thì script đứt SSH, hoặc bị SIGKILL vì runner CI/CD hết timeout.
  3. Node 46 bị bỏ lửng: database đã dừng (SIGTERM), gói phần mềm mới đang giải nén dở, config chưa được reload.

Lúc này câu hỏi của dân SRE là: chạy lại script từ đầu thì hệ thống có tự hồi phục an toàn không? Hay nó lại đè lên mấy node đang gánh traffic rồi làm cả cluster mất Quorum?


# Idempotent trong tự động hoá hạ tầng

Một thao tác gọi là Idempotent khi chạy nó một lần hay chạy đi chạy lại nhiều lần thì kết quả cuối cùng vẫn y như nhau:

f(f(x)) = f(x)

Với tự động hoá database, một tác vụ muốn Idempotent thật sự thì phải giữ chặt 3 nguyên tắc:

  1. Check-Before-Act (xem trước rồi mới làm): đừng bao giờ đoán trạng thái node dựa vào lần chạy trước đã làm tới đâu. Luôn hỏi thẳng hệ thống (qua JMX, REST API, systemd D-Bus hoặc /proc) xem thực tế nó đang thế nào.
  2. Atomic Transitions (đổi trạng thái một phát ăn ngay): thay config hay cập nhật file thực thi thì ghi ra file tạm trước, rồi đổi chỗ một phát (mv temp target). Như vậy script có bị ngắt giữa chừng thì file config cũng không bị rỗng. Lưu ý mv chỉ nguyên tử khi file tạm nằm cùng filesystem với file đích (nên tạo nó ngay cạnh, kiểu target.tmp), và muốn chắc qua cả lần mất điện thì fsync file tạm trước khi rename. Với binary, cách hay dùng là cài mỗi version vào thư mục riêng rồi đổi symlink current (tạo symlink mới rồi mv -T đè lên).
  3. No-Op khi đã đạt: node đã chạy đúng version, đúng config và đang phục vụ traffic bình thường thì script phải bỏ qua luôn (No-Op), không được restart nó.

# thiết kế Finite State Machine (FSM) cho từng node

Thay vì viết script tuần tự kiểu ra lệnh từng bước (Imperative scripting), các hệ thống tự động hoá đáng tin chuyển sang kiểu khai báo (Declarative Reconciliation) kết hợp Finite State Machine.

Mỗi node trong cluster có một trạng thái chính thức, lưu ở một kho dữ liệu phân tán tin cậy được (như etcd hoặc Consul):

Trạng thái NodeĐang làm gìKhi nào được chuyển tiếp
HEALTHYĐang phục vụ traffic đọc ghiChỉ sang DRAINING khi cả cluster còn đủ Quorum
DRAININGĐang ngắt dần kết nối của clientChờ connection về 0 (nodetool drain với Cassandra)
UPDATINGĐang thay binary hoặc configChỉ chạy trong phạm vi node đó
STARTINGDatabase đang khởi động lạiChờ socket mở và SSTable load xong
CONVERGINGĐang đồng bộ gossip và token ringChờ nodetool status báo UN (Up/Normal)
VERIFIEDĐã kiểm tra dữ liệu toàn vẹnQuay lại HEALTHY

# tính Quorum và Failure Domain

Với các hệ thống phân tán dùng thuật toán đồng thuận hoặc mô hình Quorum (Cassandra, ScyllaDB, CockroachDB):

Quorum = ⌊R / 2⌋ + 1

Với Replication Factor R = 3, muốn câu lệnh đọc ghi ở mức LOCAL_QUORUM vẫn chạy được (Availability) thì mỗi phần dữ liệu cần tối thiểu 2 replica còn sống.

Số node tối đa được phép bảo trì cùng lúc (M) trong cùng một bộ replica mà hệ thống không bị gián đoạn là:

M = R - Quorum = 3 - 2 = 1

Nghĩa là: tuyệt đối không được dừng cùng lúc từ 2 node trở lên trong cùng một Replica Set.

Script tự động hoá phải biết được cấu trúc vật lý của cụm (Topology Awareness):

  • Tự gom node theo Availability Zone (AZ) hoặc Rack.
  • Làm xong 100% một Rack, chờ dữ liệu đồng bộ hết rồi mới chuyển sang Rack tiếp theo.

Cách "mỗi lần một rack" chỉ an toàn khi số rack ít nhất bằng RF và database được cấu hình để đặt mỗi replica ở một rack khác nhau (với Cassandra là NetworkTopologyStrategy cộng snitch biết rack). Nếu một rack chứa hai replica của cùng dữ liệu như kiểu xếp sai, dừng rack đó là mất Quorum ngay. Còn nếu không có thông tin rack, cụm dùng vnodes thì gần như cặp node nào cũng chung vài dải token, nên chỉ dừng từng node một.

Thêm một điểm hay bị quên: trong lúc node nghỉ, các node khác giữ tạm phần ghi dành cho nó (hinted handoff), nhưng chỉ trong một khoảng giới hạn (max_hint_window, mặc định 3 giờ ở Cassandra). Node nghỉ lâu hơn thế thì sau khi lên lại phải chạy repair, nếu không dữ liệu giữa các replica sẽ lệch nhau.


# code mẫu: Idempotent Node Reconciler

Dưới đây là khung code minh hoạ một bộ điều phối bảo trì có tính Idempotent:

import time
import requests
 
class NodeMaintenanceReconciler:
    def __init__(self, node_ip, target_version, cluster_state_client):
        self.node_ip = node_ip
        self.target_version = target_version
        self.state_client = cluster_state_client
 
    def reconcile(self):
        # 1. Check-Before-Act: Kiểm tra phiên bản thực tế hiện tại
        current_version = self.get_node_version()
        if current_version == self.target_version and self.is_node_healthy():
            print(f"Node {self.node_ip} đã ở đúng phiên bản {self.target_version}. NO-OP.")
            return True
 
        # 2. Kiểm tra tính toàn vẹn Quorum của toàn cluster trước khi dừng node
        #    (node đang nằm dở từ lần chạy trước thì đã down sẵn, đừng tính nó là "down thêm một")
        if not self.state_client.can_safely_take_down(self.node_ip):
            raise RuntimeError(f"Không thể bảo trì {self.node_ip}: Quorum sẽ bị vi phạm!")
 
        # 3. Tiến hành Drain kết nối có timeout
        self.drain_traffic()
 
        # 4. Cập nhật phần mềm nguyên tử
        self.apply_update_atomically()
 
        # 5. Khởi động lại và chờ đợi Gossip protocol đồng thuận
        self.restart_service()
        self.wait_for_convergence(timeout_seconds=300)
 
        # 6. Xác thực tính nhất quán và hoàn tất
        if self.is_node_healthy():
            self.state_client.mark_completed(self.node_ip)
            return True
        else:
            raise RuntimeError(f"Node {self.node_ip} không đạt chuẩn sức khỏe sau cập nhật.")
 
    def get_node_version(self):
        # Gọi trực tiếp qua API endpoint local của node.
        # Node đang dừng dở (đúng tình huống node 46 ở trên) thì trả None thay vì ném lỗi,
        # để reconcile() hiểu là "chưa đạt trạng thái mong muốn" và làm tiếp.
        try:
            res = requests.get(f"http://{self.node_ip}:8080/api/v1/version", timeout=3)
            res.raise_for_status()
            return res.json().get("version")
        except requests.RequestException:
            return None

Để ý là chính các bước 3 tới 5 cũng phải idempotent: drain_traffic() gọi trên node đã drain rồi thì không được lỗi, apply_update_atomically() thấy gói mới đã giải nén xong thì chỉ cần kiểm tra checksum rồi đi tiếp, restart_service() trên service đang dừng thì chỉ cần start. Có vậy thì chạy lại reconcile() bao nhiêu lần cũng ra cùng một kết quả. Ngoài ra, nên giữ một khoá phân tán (lease trên etcd/Consul) quanh cả quá trình, để hai lần chạy script (một bản cũ còn treo, một bản mới) không cùng đụng vào cluster.


# Circuit Breaker: thấy hỏng dây chuyền thì tự ngắt

Script có Idempotent rồi cũng chưa chắc yên. Một config sai (Bad Configuration) mà cứ để tự động chạy tiếp thì vẫn lan ra cả cụm như thường.

Vì vậy hệ thống tự động hoá chạy production bắt buộc phải có Automation Circuit Breaker:

  • Ngưỡng lỗi liên tiếp (Failure Threshold): có 2 node liên tiếp lỗi sau khi restart thì script phải dừng toàn bộ ngay, và bắn cảnh báo khẩn cho người đang trực.
  • Giữ nguyên hiện trạng (State Preservation): đừng rollback một cách mù quáng trên những node đã cập nhật thành công, nếu rollback có nguy cơ làm cluster mất Quorum thêm lần nữa.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.