📖 Bản tiếng Anh (English Edition)

Bài trước: Bài 3 — Tấm Khiên Lưu Lượng: WAF & Kafka Peak Shaving | Mục lục Series | Bài tiếp theo: Bài 5 — Giám Sát Cực Đại: OpenTelemetry & ClickHouse


Answer-first: Shopee xóa bỏ giới hạn cơ sở dữ liệu bằng cách chuyển từ sharded MySQL sang TiDB NewSQL phân tán. Việc tách biệt tầng tính toán với lưu trữ đồng thuận Multi-Raft trên các Region 96MB đem lại khả năng mở rộng đàn hồi, tự động tái cân bằng và bảo đảm an toàn dữ liệu tuyệt đối.


Điều kiện tiên quyết: Nắm vững cấu trúc nội bộ của cơ sở dữ liệu quan hệ (InnoDB B+ tree, clustered index), các giao thức đồng thuận phân tán (Raft, Paxos), chuẩn Two-Phase Commit (2PC), Snapshot Isolation (SI) và kiến trúc Distributed SQL.


1. Sự Sụp Đổ Của Mô Hình Phân Mảnh MySQL Truyền Thống

Trong những giai đoạn đầu của sự bùng nổ thương mại điện tử, Shopee đã mở rộng tầng lưu trữ dữ liệu quan hệ bằng cách phân mảnh ngang (horizontal sharding) cơ sở dữ liệu MySQL truyền thống thông qua các middleware định tuyến ứng dụng (như ShardingSphere, Vitess hoặc thư viện bọc GORM dbresolver tự phát triển). Cơ sở dữ liệu đơn hàng và sổ cái được chia thành 64 máy chủ MySQL vật lý độc lập phân vùng theo user_id hoặc merchant_id.

Mặc dù kiến trúc sharding này đã giúp sàn vận hành ổn định qua giai đoạn 2018–2019, sự tăng trưởng nhảy vọt của khối lượng giao dịch lên tới hàng trăm triệu đơn mỗi ngày đã làm bộc lộ những giới hạn vật lý không thể khắc phục:

Giới Hạn Kiến Trúc Của Sharded MySQL:
┌───────────────────────────────────────┐   Truy vấn liên shard / Chi phí khóa 2PC (> 350ms)
│  Các Shard Đơn Hàng & Sổ Cái (1..64)  │ ──► Khóa XA phân tán đắt đỏ và nghẽn luồng
│  Giới hạn dung lượng mỗi Shard (>600G)│ ──► Tái phân mảnh mất hàng tuần chạy song song
│  Độ trễ nhân bản bất đối xứng (GTID) │ ──► Binlog bị trễ hàng chục giây vào đêm 11.11
└───────────────────────────────────────┘

Ba Hạn Chế Cốt Tử Của Hệ Thống Sharded MySQL

  1. Gánh Nặng Tái Phân Mảnh (The Re-Sharding Tax): Khi sản lượng đơn hàng tăng gấp 4 lần qua từng năm, dung lượng dữ liệu trên từng máy chủ MySQL vượt mốc 600GB. Vượt qua ngưỡng này, cây chỉ mục B+ tree của InnoDB đạt độ sâu 5 tầng, đòi hỏi nhiều lần đọc I/O ngẫu nhiên trên ổ cứng NVMe cho mỗi lệnh truy vấn đơn lẻ. Việc chia nhỏ 64 shard thành 128 shard đòi hỏi hàng tuần trời chuẩn bị kỹ thuật: dựng phần cứng mới, thiết lập đường ống đồng bộ ghi hai đầu (double-write), đối soát dữ liệu và lên kế hoạch đóng cửa bảo trì ban đêm vô cùng rủi ro.
  2. Suy Thoái Hiệu Năng Truy Vấn Liên Phân Mảnh: Mặc dù các truy vấn có kèm khóa phân vùng (WHERE user_id = 9821 AND order_id = 'ORD-101') được chuyển tiếp rất nhanh tới đúng một máy chủ MySQL, các câu lệnh tổng hợp báo cáo cho người bán (ví dụ: “Thống kê doanh thu tất cả gian hàng tại Hà Nội trong 1 giờ qua”) buộc phải thực hiện scatter-gather quét trên toàn bộ 64 máy chủ. Độ trễ mạng cộng dồn làm tê liệt máy chủ ứng dụng khi phải gom và ghép dữ liệu trung gian trong RAM.
  3. Độ Trễ Nhân Bản Và Mất Dữ Liệu Khi Gặp Sự Cố (RPO > 0): MySQL phụ thuộc vào cơ chế nhân bản binlog bất đồng bộ hoặc bán đồng bộ. Dưới áp lực ghi cực lớn vào đêm 11.11, luồng ghi lại trên máy chủ replica bị tụt hậu so với máy chủ primary hàng chục giây do mô hình đơn luồng hoặc phân luồng thô sơ. Nếu máy chủ chính bị sập nguồn đột ngột, công cụ tự động chuyển đổi dự phòng (như Orchestrator) buộc phải đối mặt với bài toán bất khả thi: chấp nhận mất dữ liệu vĩnh viễn (vi phạm nguyên tắc RPO = 0) hoặc khóa hệ thống sang chế độ chỉ đọc.

2. Kiến Trúc NewSQL Của TiDB: Tách Rời Tính Toán Khỏi Lưu Trữ

Để đạt được khả năng mở rộng quy mô đàn hồi vô hạn mà vẫn duy trì toàn vẹn các cam kết giao dịch ACID và tương thích 100% với giao thức mạng của MySQL, Shopee đã lựa chọn chuyển đổi sang TiDB — hệ quản trị cơ sở dữ liệu NewSQL phân tán nguồn mở hàng đầu thế giới.

Nền tảng kiến trúc cốt lõi của TiDB là sự tách biệt hoàn toàn giữa tầng tính toán SQL không trạng thái và tầng lưu trữ phân tán có trạng thái:

flowchart TD
    subgraph TangTruyCapClient ["Tầng Khách Hàng & Mạng"]
        App["Microservices Shopee (Go Kitex / gRPC)"] --> L4["Cân Bằng Tải L4 / HAProxy"]
    end

    subgraph TangTinhToan ["Tầng Tính Toán SQL Không Trạng Thái (Cụm TiDB)"]
        L4 --> TiDB1["TiDB Node 1 (Phân Tích Cú Pháp SQL / Tối Ưu Chi Phí)"]
        L4 --> TiDB2["TiDB Node 2 (Thực Thi Phân Tán Không Trạng Thái)"]
        L4 --> TiDBN["TiDB Node N (Tự Động Mở Rộng Co Giãn Trên Kubernetes)"]
    end

    subgraph TangDieuKhien ["Bộ Não Điều Khiển Phân Tán (Placement Driver)"]
        PD1["PD Leader (Timestamp Oracle TSO / Lập Lịch Region)"]
        PD2["PD Follower (Đồng Thuận Raft)"]
        PD3["PD Follower (Đồng Thuận Raft)"]
        PD1 --- PD2 --- PD3
    end

    subgraph TangLuuTru ["Tầng Lưu Trữ Giao Dịch Phân Tán (Cụm TiKV)"]
        TiKV1["TiKV Node A (Động Cơ RocksDB / Multi-Raft)"]
        TiKV2["TiKV Node B (Động Cơ RocksDB / Multi-Raft)"]
        TiKV3["TiKV Node C (Động Cơ RocksDB / Multi-Raft)"]
        TiKV4["TiKV Node D (Động Cơ RocksDB / Multi-Raft)"]
    end

    subgraph TangCotPhanTich ["Tầng Phân Tích Thời Gian Thực (TiFlash HTAP)"]
        TiF1["TiFlash Node 1 (Lưu Trữ Cột / Raft Learner)"]
        TiF2["TiFlash Node 2 (Vector Hóa Kiểu ClickHouse)"]
    end

    TiDB1 & TiDB2 & TiDBN <-->|Xin Cấp Dấu Thời Gian Tăng Dần| PD1
    TiDB1 & TiDB2 & TiDBN <-->|Giao Thức gRPC Coprocessor| TiKV1 & TiKV2 & TiKV3 & TiKV4
    TiKV1 & TiKV2 -.->|Đồng Bộ Raft Log Bất Đồng Bộ| TiF1 & TiF2
    TiDB1 & TiDB2 -.->|Đẩy Câu Lệnh Phân Tích Xuống| TiF1 & TiF2

    classDef client fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    classDef compute fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
    classDef control fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;
    classDef storage fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
    classDef htap fill:#fce4ec,stroke:#c2185b,stroke-width:2px;
    class TangTruyCapClient client;
    class TangTinhToan compute;
    class TangDieuKhien control;
    class TangLuuTru storage;
    class TangCotPhanTich htap;

Vai Trò Của Các Thành Phần Kiến Trúc

  • TiDB Server (Tầng Tính Toán Không Trạng Thái): Giao tiếp chuẩn giao thức MySQL. Ứng dụng kết nối thông qua các driver MySQL tiêu chuẩn của Go, Java hay Python mà không hề biết bên dưới là một cụm phân tán khổng lồ. Các node TiDB phân tích cú pháp SQL, xây dựng kế hoạch thực thi logic và vật lý, tính toán chi phí tối ưu (CBO) và đẩy các mệnh đề lọc xuống thẳng các node lưu trữ thông qua giao thức Coprocessor.
  • TiKV (Động Cơ Lưu Trữ Khóa-Giá Trị Phân Tán): Được viết bằng ngôn ngữ Rust để đạt hiệu năng tối đa và an toàn bộ nhớ tuyệt đối. Dữ liệu được ghi bền vững xuống ổ cứng bằng RocksDB và quản lý tính nhất quán thông qua các nhóm đồng thuận Multi-Raft độc lập.
  • Placement Driver (PD): Đóng vai trò là trung tâm chỉ huy của toàn bộ cụm. PD đảm nhận hai nhiệm vụ sống còn:
    1. Timestamp Oracle (TSO): Cấp phát các nhãn thời gian vật lý - logic kết hợp tăng dần đơn điệu phục vụ cho việc sắp xếp thứ tự giao dịch phân tán theo mô hình Snapshot Isolation.
    2. Bộ Điều Phối Thông Minh (Scheduler): Tiếp nhận nhịp tim từ toàn bộ các máy chủ TiKV, chủ động ra lệnh chia tách (split), hợp nhất (merge) hoặc di dời các vùng dữ liệu (Region) để cân bằng tải CPU và dung lượng ổ cứng.
  • TiFlash (Động Cơ Lưu Trữ Theo Cột HTAP): Lưu trữ dữ liệu dạng cột được đồng bộ liên tục dưới dạng node Raft Learner. Khi ban giám đốc và các nhà phân tích mở dashboard doanh thu theo thời gian thực đêm 11.11, bộ tối ưu của TiDB sẽ tự động chuyển hướng truy vấn sang TiFlash, giải phóng hoàn toàn tài nguyên CPU và đĩa của các node TiKV đang bận rộn xử lý thanh toán.

3. Đồng Thuận Multi-Raft Trong TiKV Đảm Bảo Không Mất Mát Dữ Liệu

Trong mô hình Master-Replica truyền thống, đơn vị nhân bản dữ liệu là toàn bộ một máy chủ vật lý. Nếu một máy chủ hỏng, toàn bộ dữ liệu trên đó đều bị ảnh hưởng. Ngược lại, TiKV băm nhỏ không gian dữ liệu thành các khoảng byte liên tục gọi là Region:

flowchart LR
    subgraph AZ1 ["Trung Tâm Dữ Liệu 1 (AZ A)"]
        direction TB
        Node1["TiKV Node 1"]
        R1_L["Region 1 (Leader)"]
        R2_F1["Region 2 (Follower)"]
        Node1 --- R1_L
        Node1 --- R2_F1
    end

    subgraph AZ2 ["Trung Tâm Dữ Liệu 2 (AZ B)"]
        direction TB
        Node2["TiKV Node 2"]
        R1_F1["Region 1 (Follower)"]
        R2_L["Region 2 (Leader)"]
        Node2 --- R1_F1
        Node2 --- R2_L
    end

    subgraph AZ3 ["Trung Tâm Dữ Liệu 3 (AZ C)"]
        direction TB
        Node3["TiKV Node 3"]
        R1_F2["Region 1 (Follower)"]
        R2_F2["Region 2 (Follower)"]
        Node3 --- R1_F2
        Node3 --- R2_F2
    end

    R1_L ==>|"Đồng Bộ Raft Log (Túc Số Quorum 2/3)"| R1_F1 & R1_F2
    R2_L ==>|"Đồng Bộ Raft Log (Túc Số Quorum 2/3)"| R2_F1 & R2_F2

    classDef az fill:#f5f5f5,stroke:#9e9e9e,stroke-width:1px;
    classDef leader fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
    classDef follower fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    class AZ1,AZ2,AZ3 az;
    class R1_L,R2_L leader;
    class R1_F1,R1_F2,R2_F1,R2_F2 follower;

Vòng Đời Của Region: Tự Động Chia Tách Và Hợp Nhất

  • Kích Thước Cố Định Chuẩn: Mỗi Region mặc định được cấu hình giới hạn kích thước mục tiêu là 96 Megabytes (trong phạm vi [start_key, end_key)).
  • Chia Tách Region (Split): Khi dữ liệu đơn hàng mới ghi liên tục khiến kích thước Region vượt quá 144 Megabytes, TiKV sẽ tự động cắt đôi thành hai Region 72MB liên tiếp. Thao tác chia tách này chỉ là cập nhật metadata cục bộ trong RocksDB, diễn ra trong chưa đầy 1 mili-giây. PD nhận được thông báo qua heartbeat và cập nhật ngay vào bảng định tuyến toàn cục.
  • Hợp Nhất Region (Merge): Khi các đơn hàng cũ được chuyển kho lưu trữ hoặc bị xóa bớt khiến kích thước Region tụt xuống dưới 20 Megabytes, PD sẽ tự động ghép nó với Region liền kề để giải phóng tài nguyên quản lý.
  • Thuật Toán Đồng Thuận Multi-Raft: Mỗi Region được nhân bản thành 3 hoặc 5 bản sao nằm trên các máy chủ vật lý ở các trung tâm dữ liệu độc lập. Mỗi Region là một nhóm Raft riêng biệt có Leader riêng. Một máy chủ TiKV vật lý có thể chứa hàng chục ngàn Region, vừa làm Leader của nhóm này, vừa làm Follower của nhóm khác.
  • Cam Kết Tuyệt Đối RPO = 0 Và RTO Dưới 2 Giây: Một lệnh ghi dữ liệu được coi là thành công ngay khi đa số túc số (ví dụ: 2 trên 3 AZ) ghi nhận Raft log. Nếu một trung tâm dữ liệu bị cháy hoặc mất điện hoàn toàn, 2 trung tâm dữ liệu còn lại sẽ tự động bầu Leader mới cho các Region bị ảnh hưởng chỉ trong vòng 1,8 giây, bảo đảm sàn không mất một dòng dữ liệu nào ($\text{RPO} = 0$).

4. Mô Hình Giao Dịch Phân Tán Google Percolator

TiDB triển khai thuật toán giao dịch phân tán nổi tiếng của Google Percolator. Kiến trúc này mang lại mức độ cô lập Snapshot Isolation (SI) và tính toàn vẹn ACID tuyệt đối trên hàng trăm máy chủ lưu trữ mà không cần tới phần cứng đồng hồ nguyên tử đắt tiền:

sequenceDiagram
    autonumber
    participant Client as Ứng Dụng Microservice (Go Kitex)
    participant TiDB as Node Tính Toán TiDB
    participant PD as Bộ Não Điều Khiển PD (TSO)
    participant TiKV_P as Node TiKV (Nắm Khóa Chính Primary)
    participant TiKV_S as Node TiKV (Nắm Khóa Phụ Secondary)

    Client->>TiDB: BEGIN TRANSACTION
    TiDB->>PD: Yêu Cầu Cấp Dấu Thời Gian Bắt Đầu (start_ts = 100)
    PD-->>TiDB: Trả Về start_ts: 100

    Client->>TiDB: UPDATE orders SET status = 'PAID' WHERE id = 101;
    TiDB->>TiDB: Đọc Dữ Liệu Tại Thời Điểm start_ts: 100 (Snapshot Isolation)
    TiDB->>TiDB: Đệm Các Thay Đổi Trong Bộ Nhớ RAM

    Client->>TiDB: COMMIT

    Note over TiDB,TiKV_S: Giai Đoạn 1: Prewrite (Chiếm Giữ Khóa Phân Tán)
    TiDB->>TiDB: Chọn id = 101 Làm Khóa Chính Primary Lock
    TiDB->>TiKV_P: Gửi Lệnh Prewrite Khóa Chính (start_ts: 100, lock: PRIMARY)
    TiKV_P-->>TiDB: Prewrite Thành Công
    TiDB->>TiKV_S: Gửi Lệnh Prewrite Khóa Phụ (start_ts: 100, trỏ về PRIMARY)
    TiKV_S-->>TiDB: Prewrite Thành Công

    Note over TiDB,PD: Cấp Dấu Thời Gian Commit
    TiDB->>PD: Yêu Cầu Cấp Dấu Thời Gian Commit (commit_ts = 105)
    PD-->>TiDB: Trả Về commit_ts: 105

    Note over TiDB,TiKV_S: Giai Đoạn 2: Commit (Chốt Giao Dịch)
    TiDB->>TiKV_P: Chốt Khóa Chính Primary (commit_ts: 105)
    TiKV_P-->>TiDB: Khóa Chính Đã Chốt (Giao Dịch Đã Thành Công Về Toán Học)
    TiDB-->>Client: Phản Hồi HTTP 200 OK (Khách Hàng Nhận Kết Quả Trong 8ms)

    TiDB->>TiKV_S: Mở Khóa Các Khóa Phụ Bất Đồng Bộ Phía Sau (commit_ts: 105)

Các Bước Của Giao Thức Two-Phase Commit

  1. Khởi Tạo Giao Dịch: Bộ điều phối TiDB xin một start_ts từ PD. Tất cả các lệnh đọc tiếp theo trong giao dịch chỉ nhìn thấy những dữ liệu đã được commit trước thời điểm start_ts này.
  2. Giai Đoạn Prewrite: TiDB chỉ định một khóa làm Primary Lock và các khóa còn lại làm Secondary Lock. Sau đó nó gửi lệnh ghi tạm thời xuống các node TiKV:
    • Dữ liệu mới được ghi vào cột dữ liệu (data column family).
    • Một khóa độc quyền được ghi vào cột khóa (lock CF) mang dấu ấn start_ts. Các khóa phụ chứa đường dẫn con trỏ trỏ trực tiếp về khóa chính.
  3. Giai Đoạn Commit: TiDB lấy commit_ts từ PD. Nó tiến hành chuyển trạng thái của Primary Lock thành bản ghi vĩnh viễn trong cột lịch sử (write CF). Ngay khi khóa chính được commit thành công, toàn bộ giao dịch được coi là đã hoàn tất không thể đảo ngược. Việc mở các khóa phụ diễn ra bất đồng bộ phía sau mà không làm kéo dài thời gian phản hồi của người mua hàng.

Nếu tiến trình điều phối TiDB bị sập giữa chừng, các giao dịch khác khi bắt gặp một khóa phụ dang dở sẽ chỉ cần kiểm tra trạng thái của khóa chính: nếu khóa chính đã commit thì khóa phụ được tự động đẩy tới; nếu khóa chính đã bị hủy thì khóa phụ bị xóa bỏ.


5. Triệt Tiêu Điểm Nghẽn Ghi: AUTO_RANDOM Thay Thế AUTO_INCREMENT

Trong các thiết kế cơ sở dữ liệu MySQL truyền thống, các bảng luôn dùng khóa chính tự tăng (id BIGINT AUTO_INCREMENT PRIMARY KEY). Thiết kế này giúp tối ưu hóa cấu trúc cây B+ tree trên một máy chủ đơn lẻ, nhưng lại tạo ra thảm họa nghẽn điểm nhiệt trên cơ sở dữ liệu phân tán.

Bởi vì TiKV sắp xếp dữ liệu theo thứ tự từ điển liên tục: $$\text{RowKey} = \text{TableID} \mathbin{\Vert} \text{RowID}$$

Khi các ID tăng dần đều (1, 2, 3, ...), mọi câu lệnh ghi đơn hàng mới sẽ liên tục chèn vào phần đuôi của bảng. Do đó, 100% lưu lượng ghi trên toàn quốc sẽ dồn vào đúng một máy chủ TiKV duy nhất đang nắm giữ Region đuôi đó. Hơn 90 máy chủ còn lại trong cụm hoàn toàn nhàn rỗi trong khi máy chủ kia cháy CPU và nghẽn ổ đĩa.

Giải Pháp SOTA: AUTO_RANDOM(5) Phân Tán Luồng Ghi

Shopee giải quyết triệt để vấn đề này bằng cách sử dụng cơ chế sinh khóa ngẫu nhiên nguyên bản của TiDB:

-- Cấu Trúc Bảng Đơn Hàng Chuẩn Doanh Nghiệp Trên TiDB 8.0
CREATE TABLE `orders` (
    `order_id` BIGINT(20) NOT NULL AUTO_RANDOM(5),
    `user_id` BIGINT(20) NOT NULL,
    `merchant_id` BIGINT(20) NOT NULL,
    `sku_id` BIGINT(20) NOT NULL,
    `quantity` INT(11) NOT NULL,
    `total_amount` DECIMAL(12,2) NOT NULL,
    `status` VARCHAR(32) NOT NULL,
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`order_id`) /*T![clustered_index] CLUSTERED */,
    KEY `idx_user_created` (`user_id`, `created_at`),
    KEY `idx_merchant_created` (`merchant_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

Nguyên Lý Vận Hành Của AUTO_RANDOM(5)

  • Tham số AUTO_RANDOM(5) lấy 5 bit đầu tiên của số nguyên 64-bit làm mã phân mảnh ngẫu nhiên:
    • Tổng số luồng ghi đồng thời: $2^5 = 32$ luồng song song.
    • 59 bit còn lại tăng dần theo thời gian.
  • Các đơn hàng được tạo cùng một thời điểm sẽ được rải đều trên 32 Region TiKV khác nhau nằm trên 32 máy chủ vật lý riêng biệt.
  • Tốc độ ghi tăng trưởng tuyến tính theo số lượng máy chủ: bảng đơn hàng từng bị tắc nghẽn ở mức 15.000 đơn/giây trên MySQL dễ dàng vượt mốc 350.000 đơn/giây trên cụm TiDB mà không hề xuất hiện bất kỳ điểm nhiệt Region nào.

6. Cài Đặt Thực Tế Client Golang Với Cơ Chế Tự Động Thử Lại Deadlock

Đoạn mã Golang dưới đây hiện thực hóa kết nối đạt chuẩn doanh nghiệp tới cụm TiDB, tích hợp thuật toán thử lại tự động khi gặp xung đột khóa (Error 1213), cấu hình thời gian sống của kết nối và kỹ thuật đọc từ bản sao quá khứ (Stale Read) giúp giảm tải cho Leader.

package tidb

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"math/rand"
	"time"

	"github.com/go-sql-driver/mysql"
)

const (
	ErrCodeDeadlock = 1213 // Mã lỗi xung đột deadlock trên MySQL và TiDB
	MaxRetries      = 5
)

type DatabaseClient struct {
	db *sql.DB
}

func NewDatabaseClient(dsn string, maxOpenConns, maxIdleConns int) (*DatabaseClient, error) {
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, fmt.Errorf("khong the mo ket noi toi tidb: %w", err)
	}

	// Cấu hình tối ưu hóa bể kết nối connection pool
	db.SetMaxOpenConns(maxOpenConns)
	db.SetMaxIdleConns(maxIdleConns)
	db.SetConnMaxLifetime(30 * time.Minute)
	db.SetConnMaxIdleTime(5 * time.Minute)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := db.PingContext(ctx); err != nil {
		return nil, fmt.Errorf("ping cum tidb that bai: %w", err)
	}

	return &DatabaseClient{db: db}, nil
}

// ExecutePessimisticTx thực thi giao dịch có cơ chế tự động thử lại khi xung đột.
func (c *DatabaseClient) ExecutePessimisticTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
	var lastErr error

	for attempt := 1; attempt <= MaxRetries; attempt++ {
		err := c.runTx(ctx, fn)
		if err == nil {
			return nil
		}

		lastErr = err
		var mysqlErr *mysql.MySQLError
		if errors.As(err, &mysqlErr) && mysqlErr.Number == ErrCodeDeadlock {
			// Thuật toán lùi số mũ kết hợp độ lệch ngẫu nhiên để tránh xung đột bão thử lại
			sleepDuration := time.Duration(attempt*attempt*15)*time.Millisecond + time.Duration(rand.Intn(10))*time.Millisecond
			log.Printf("[WARN] Phat hien xung dot deadlock (lan %d/%d). Thu lai sau %v...", attempt, MaxRetries, sleepDuration)
			select {
			case <-time.After(sleepDuration):
				continue
			case <-ctx.Done():
				return ctx.Err()
			}
		}

		// Gặp lỗi nghiêm trọng không thể thử lại
		return err
	}

	return fmt.Errorf("giao dich that bai sau %d lan thu lai: %w", MaxRetries, lastErr)
}

func (c *DatabaseClient) runTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
	// Kích hoạt chế độ giao dịch bi quan cho TiDB
	tx, err := c.db.BeginTx(ctx, &sql.TxOptions{
		Isolation: sql.LevelRepeatableRead,
	})
	if err != nil {
		return err
	}
	defer tx.Rollback()

	if err := fn(tx); err != nil {
		return err
	}

	return tx.Commit()
}

// QueryStaleRead đọc dữ liệu từ các follower bản sao tại mốc thời gian cách đây 5 giây.
func (c *DatabaseClient) QueryStaleRead(ctx context.Context, userID int64) ([]int64, error) {
	// Giảm tải cho Raft Leader bằng cách đọc từ các bản sao Follower lân cận
	query := `
		SELECT order_id 
		FROM orders AS OF SYSTEM TIME NOW() - INTERVAL 5 SECOND 
		WHERE user_id = ? 
		ORDER BY created_at DESC 
		LIMIT 20;
	`
	rows, err := c.db.QueryContext(ctx, query, userID)
	if err != nil {
		return nil, fmt.Errorf("truy van stale read that bai: %w", err)
	}
	defer rows.Close()

	var orderIDs []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		orderIDs = append(orderIDs, id)
	}

	return orderIDs, rows.Err()
}

7. Chuyển Đổi Dữ Liệu Không Gián Đoạn Từ MySQL Sang TiDB

Việc di chuyển hàng trăm Terabyte dữ liệu tài chính của sàn mà không làm gián đoạn dù chỉ một giây giao dịch mua sắm của khách hàng được thực hiện thông qua bộ công cụ TiDB Data Migration (DM):

flowchart LR
    subgraph HeThongCuMySQL ["Hạ Tầng 64 Shard MySQL Cũ"]
        M1["MySQL Shard 01"]
        M2["MySQL Shard 02"]
        M64["MySQL Shard 64"]
    end

    subgraph CongCuDiChuyen ["Bộ Công Cụ Chuyển Đổi TiDB DM"]
        Dumpling["Dumpling: Trích Xuất Dữ Liệu Đa Luồng Tốc Độ Cao"]
        Lightning["TiDB Lightning: Biên Dịch Trực Tiếp Ra File SST (1GB/s)"]
        DMWorker["TiDB DM: Luồng Đồng Bộ Binlog CDC Thời Gian Thực"]
    end

    subgraph CumMoiTiDB ["Cụm TiDB NewSQL Đích Đến"]
        TiDBCluster["Cụm TiDB (Gom Toàn Bộ Các Bảng Shard Thành Bảng Duy Nhất)"]
    end

    M1 & M2 & M64 -->|1. Trích Xuất Ảnh Toàn Bộ Dữ Liệu Gốc| Dumpling
    Dumpling -->|2. Tạo Tệp Dump Định Dạng Chuẩn| Lightning
    Lightning -->|3. Nạp Thẳng Vào RocksDB Của TiKV Bỏ Qua SQL| TiDBCluster
    M1 & M2 & M64 -->|4. Luồng CDC Đọc Binlog Liên Tục| DMWorker
    DMWorker -->|5. Tự Động Gộp Bảng Và Hòa Giải Schema| TiDBCluster

    classDef src fill:#ffebee,stroke:#c62828,stroke-width:2px;
    classDef tools fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
    classDef dest fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
    class HeThongCuMySQL src;
    class CongCuDiChuyen tools;
    class CumMoiTiDB dest;

Ba Giai Đoạn Chuyển Đổi Kỹ Thuật

  1. Dumpling + TiDB Lightning (Nạp Dữ Liệu Tĩnh Ban Đầu): Dumpling tạo bản chụp ảnh nhất quán trên 64 shard MySQL. TiDB Lightning phân tích bản dump và biên dịch trực tiếp thành các file SST (Sorted String Table) của RocksDB, nạp thẳng vào đĩa của các node TiKV mà không cần đi qua tầng phân tích SQL. Tốc độ nạp dữ liệu vượt mốc 1 Gigabyte/giây trên mỗi máy chủ.
  2. TiDB DM Đồng Bộ Binlog CDC (Bắt Kịp Dữ Liệu Tăng Trưởng): Các tiến trình DM đóng vai trò là slave của MySQL, liên tục đọc và giải mã binlog từ 64 shard. DM tự động gộp các bảng con (như order_00 đến order_63) thành một bảng orders duy nhất và tự động đồng bộ khi có thay đổi cấu trúc bảng (DDL drift).
  3. Ghi Song Song Và Chuyển Đổi Chính Thức: Khi độ trễ đồng bộ binlog giảm xuống dưới 10 mili-giây, hệ thống kích hoạt chế độ ghi đồng thời hai bên để đối soát dữ liệu. Lưu lượng đọc được chuyển sang TiDB trước tiên. Sau 48 giờ đối soát đạt độ chính xác 100%, luồng ghi chính thức được chuyển hẳn sang TiDB, hoàn tất quá trình nâng cấp mà sàn không phải ngừng hoạt động một phút nào.

8. Ma Trận Đánh Đổi Kỹ Thuật Và Các Giải Pháp Bị Bác Bỏ

Việc chuyển dịch từ cơ sở dữ liệu sharded truyền thống sang NewSQL đòi hỏi sự thấu hiểu tường tận về các đánh đổi:

Quyết Định Kiến TrúcPhương Án Bị Bác BỏLý Do Đánh Đổi & Căn Cứ Thực Tế
Hạ tầng TiDB NewSQLDùng Middleware Sharding (Vitess / ShardingSphere)Middleware sharding quá cồng kềnh khi tái phân mảnh và không thể xử lý các giao dịch phân tán phức tạp mà không bị sụt giảm độ trễ.
Khóa chính AUTO_RANDOMDùng khóa tự tăng AUTO_INCREMENT BigIntKhóa tự tăng dồn toàn bộ lưu lượng ghi vào một Region duy nhất tại đuôi bảng, làm nghẽn toàn bộ cụm máy chủ.
Giao dịch bi quan cho nghiệp vụ lõiKiểm soát tương tranh lạc quan (OCC)Khi hàng ngàn giao dịch tranh giành mua một sản phẩm Flash Sale, hơn 95% giao dịch OCC bị hủy bỏ ở bước commit, gây lãng phí CPU.
Đọc dữ liệu quá khứ (Stale Read)Luôn đọc dữ liệu nhất quán từ Raft LeaderBắt buộc mọi truy vấn chỉ đọc phải đi qua Raft Leader sẽ làm nghẽn băng thông của Leader; Stale Read giải phóng hơn 60% tải đọc cho Follower.

Câu Hỏi Thường Gặp (FAQ)

Làm thế nào TiDB bảo đảm tính nhất quán dữ liệu giữa tầng cột TiFlash và tầng hàng TiKV?

TiFlash không sử dụng công cụ ETL bên ngoài hay đọc lén binlog. Thay vào đó, mỗi node TiFlash đăng ký dưới dạng một node Raft Learner bất đồng bộ trong các nhóm Multi-Raft của TiKV. Khi Raft Leader commit một đơn hàng, bản ghi log sẽ được truyền về TiFlash ở chế độ nền. Khi có câu lệnh phân tích gửi tới, TiDB kiểm tra nhãn MVCC để bảo đảm TiFlash đã phát lại đầy đủ các bản ghi log tính tới thời điểm start_ts của truy vấn. Nếu dữ liệu trên TiFlash bị trễ vài mili-giây, câu lệnh sẽ chờ đồng bộ nốt hoặc tự động chuyển hướng đọc sang TiKV, bảo đảm tính nhất quán tuyệt đối.

Tại sao TiDB chuyển sang sử dụng mô hình khóa bi quan làm mặc định thay vì khóa lạc quan?

Trong các phiên bản đầu (v3.0 trở về trước), TiDB mặc định dùng cơ chế tương tranh lạc quan (OCC). Mặc dù OCC mang lại độ trễ cực thấp khi các giao dịch không ghi đè dữ liệu lên nhau, nó lại bộc lộ thảm họa trong các đợt Flash Sale khi hàng ngàn giao dịch cùng tranh chấp một dòng tồn kho. Trong mô hình OCC, các giao dịch không chiếm giữ khóa cho tới tận bước commit cuối cùng; dưới tải cao, hơn 90% giao dịch bị hủy và phải thử lại từ đầu. Mô hình khóa bi quan (mặc định từ v4.0) chiếm giữ khóa ngay khi thực thi câu lệnh SQL (SELECT FOR UPDATE), chặn đứng sớm các xung đột và triệt tiêu các vòng lặp thử lại vô ích.

Chi phí suy hao hiệu năng của Multi-Raft khi một máy chủ vật lý chứa hàng chục ngàn Region là bao nhiêu?

Trong các kiến trúc Raft đời đầu, mỗi nhóm Raft duy trì một bộ đếm nhịp tim độc lập, khiến card mạng của máy chủ bị ngập lụt bởi hàng triệu gói tin heartbeat nhàn rỗi mỗi giây. TiKV đã khắc phục điều này bằng kỹ thuật Batch Raft và Raft Engine: các Region không có biến động dữ liệu sẽ không gửi heartbeat riêng lẻ. Thay vào đó, nhịp tim và log của hàng ngàn Region trên cùng một máy chủ được gom lại thành một gói tin mạng duy nhất. Tối ưu hóa này kéo mức chiếm dụng CPU của tầng điều khiển xuống dưới 3% trên các máy chủ mật độ cao.

Cơ chế Follower Read nâng cao thông lượng đọc mà không vi phạm tính nhất quán tuyến tính ra sao?

Trong giao thức Raft chuẩn, chỉ có Leader mới được phép phục vụ lệnh đọc để bảo đảm client luôn nhìn thấy trạng thái mới nhất. TiDB triển khai cơ chế Follower Read: client có thể gửi lệnh đọc tới node Follower nằm cùng trung tâm dữ liệu cục bộ. Trước khi trả dữ liệu, Follower sẽ gửi một cuộc gọi RPC siêu nhẹ ReadIndex tới Leader để hỏi chỉ số commit hiện tại của Leader. Khi máy trạng thái RocksDB cục bộ của Follower đã áp dụng xong tới chỉ số đó, nó sẽ đọc dữ liệu trực tiếp từ ổ đĩa của mình, cắt giảm một nửa độ trễ mạng liên trung tâm dữ liệu mà vẫn bảo đảm tính toàn vẹn dữ liệu.

Tài Liệu Tham Khảo Kỹ Thuật

Để đào sâu nghiên cứu về các chiến lược mở rộng cơ sở dữ liệu và lưu trữ phân tán:


Bước Tiếp Theo

Mời bạn tiếp tục theo dõi Bài 5: Giám Sát Cực Đại — OpenTelemetry, ClickHouse & Distributed Tracing để khám phá cách thức Shopee thu thập và phân tích hơn 100 tỷ sự kiện log và dấu vết phân tán mỗi ngày bằng ClickHouse và công nghệ eBPF.