Tương thích wire protocol: kết nối thì OK, còn EXPLAIN và schema-diff thì không
Mình làm trong nhóm phát triển LibreDB Studio, một DB client chạy trên trình duyệt và tự host (mã nguồn mở, giấy phép MIT). Nói vậy cho minh bạch, còn bài này thuần kỹ thuật, không phải bài giới thiệu sản phẩm.
Nhóm từng có bài trên Qiita chạm tới chuyện "tương thích wire" ở góc Cassandra, Druid, Trino. Bài này đi sâu vào một họ khác và cụ thể hơn hẳn: MySQL, MariaDB, TiDB và Valkey. Câu hỏi trọng tâm rất gọn: khi bốn thứ này nói cùng một giao thức, chính xác thì cái gì tương thích và cái gì thì không? Trả lời ngắn: kết nối tương thích, còn EXPLAIN, schema-diff (so sánh cấu trúc bảng) và sơ đồ ER thì mỗi engine một kiểu. Cả bài là mổ xẻ đúng mấy chỗ lệch đó, kèm câu lệnh để bạn chạy lại được.
Các con số trong bài lấy từ môi trường nhóm test: MySQL 8.0.39 và 8.4 (LTS), MariaDB 10.11 và 11.4 (hai bản LTS), TiDB v8.5.0, Redis 7.4 và Valkey 8.0. Ghi rõ phiên bản vì gần như mọi khác biệt bên dưới đều đổi theo đúng số hiệu này.
Wire-compatible bảo đảm điều gì, và không bảo đảm điều gì
Vì cùng một protocol ở tầng dây (wire, tức tầng gói tin đi trên socket), nên MariaDB và TiDB nói được giao thức MySQL, còn Valkey nói được giao thức RESP của Redis. Hệ quả rất tiện: bạn lấy đúng client MySQL, trỏ vào MariaDB hay TiDB, nó bắt tay, xác thực, chạy được SELECT. Panel kết nối sáng đèn xanh. Với Valkey thì client Redis kết nối và GET/SET chạy ngon.
Vấn đề nằm ở chỗ tầng protocol tương thích không có nghĩa mọi thứ phía trên nó giống nhau. Một DB client đâu chỉ chạy SELECT. Nó còn vẽ sơ đồ ER, so sánh schema giữa hai database, trực quan hóa EXPLAIN, đọc system table để hiển thị thống kê. Mấy màn hình đó không dựng trên protocol, chúng dựng trên định dạng output của EXPLAIN, trên nội dung của information_schema, trên cách server tự khai báo phiên bản. Đúng mấy thứ này thì mỗi engine mỗi kiểu.
Cái bẫy là ở màn kết nối. Đèn xanh khiến bạn tưởng xong. Panel gãy chỉ lộ ra khi có người thật mở đúng cái tab đó với đúng cái engine đó.
Đằng sau một kết nối "MySQL" là engine nào?
Trước khi nói tới panel, phải xử chuyện này đã. Khi người dùng tạo một kết nối kiểu MySQL, engine thật đằng sau có thể là MySQL, MariaDB hoặc TiDB. Client không tự biết. Bạn phải tự dò (fingerprint, tức nhận diện server qua vài dấu hiệu đặc trưng).
SELECT VERSION();
-- MySQL: 8.0.39
-- MariaDB: 11.4.4-MariaDB
-- TiDB: 8.0.11-TiDB-v8.5.0
SELECT @@version_comment;
-- MySQL: MySQL Community Server - GPL
-- MariaDB: mariadb.org binary distribution
TiDB cố tình gắn tiền tố phiên bản MySQL (bản cũ để 5.7.25, bản mới hơn để 8.0.11) để client cũ khỏi vấp, rồi nhét chuỗi -TiDB- vào sau. Muốn nhận ra TiDB thì bắt chuỗi -TiDB- trong VERSION(), hoặc gọi hàm riêng tidb_version(). Muốn tách MariaDB khỏi MySQL thì nhìn @@version_comment hoặc hậu tố -MariaDB. Gom lại thành một hàm nhận diện, thứ tự kiểm tra có chủ đích:
function detectEngine(conn):
v = query("SELECT VERSION()") // vd: 8.0.11-TiDB-v8.5.0
vc = query("SELECT @@version_comment") // vd: mariadb.org binary distribution
if v contains "-TiDB-": return TIDB
if v ends_with "-MariaDB" or vc has "mariadb": return MARIADB
return MYSQL
Bài học: engine mà người dùng chọn ở màn kết nối chỉ là "họ protocol", không phải engine thật. Mọi panel phía sau phải hỏi lại "thật ra mày là ai" rồi mới quyết định hành xử.
Panel EXPLAIN: cùng một câu lệnh, ba định dạng khác hẳn
Đây là chỗ vỡ lẽ rõ nhất. Ban đầu panel trực quan hóa EXPLAIN được viết dựa trên output của MySQL, với các cột quen thuộc: id, select_type, table, type, possible_keys, key, rows, Extra. Trỏ sang MariaDB, tưởng y hệt, hóa ra không.
Cùng một câu lệnh:
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
Ở MySQL, EXPLAIN mặc định trả về cả partitions và filtered:
id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra
Ở MariaDB, EXPLAIN mặc định không có hai cột partitions và filtered (chúng nằm ở biến thể riêng như EXPLAIN PARTITIONS, EXPLAIN EXTENDED):
id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra
Chỉ chênh nhau ở output mặc định thôi mà đủ làm code đọc cứng theo vị trí cột hoặc theo tên cột bị lệch. Chưa hết, EXPLAIN FORMAT=JSON của hai bên có cấu trúc cây khác nhau, và bản "chạy thật để lấy số liệu" thì hai bên hai cú pháp:
-- MySQL
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 42;
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42; -- trả text dạng cây
-- MariaDB
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 42; -- cây khác cấu trúc
ANALYZE FORMAT=JSON SELECT * FROM orders WHERE user_id = 42; -- bản chạy thật, cú pháp khác
Hai cú pháp, hai định dạng, không tráo cho nhau được.
TiDB thì thẳng thừng là một thế giới khác. EXPLAIN SELECT * FROM t; của nó không hề có mấy cột kia:
+-----------------------+----------+-----------+---------------+--------------------------------+
| id | estRows | task | access object | operator info |
+-----------------------+----------+-----------+---------------+--------------------------------+
| TableReader_7 | 10000.00 | root | | data:TableFullScan_6 |
| └─TableFullScan_6 | 10000.00 | cop[tikv] | table:t | keep order:false, stats:pseudo |
+-----------------------+----------+-----------+---------------+--------------------------------+
Đây là cây toán tử phân tán (root chạy ở TiDB, cop[tikv] đẩy tính toán xuống TiKV), khái niệm khác hẳn execution plan của MySQL. Một parser viết cho cột type với possible_keys gặp output này thì không có gì để bám, tốt nhất là dừng lại chứ đừng cố vẽ một cái cây sai. EXPLAIN ANALYZE của TiDB còn thêm actRows, execution info, memory, disk, những thứ MySQL không có.
Kết cục: panel EXPLAIN không thể xài chung một bộ parser cho cả họ MySQL. Phải có nhánh riêng cho MySQL, cho MariaDB, cho TiDB, và mỗi nhánh test tay bằng plan thật.
System table và schema-diff: nơi đẻ ra hàng loạt "khác biệt" giả
Panel so sánh schema đọc information_schema để đối chiếu cột, kiểu dữ liệu, khóa, index giữa hai bên. Nghe vô hại. Nhưng đem so một schema logic giống hệt nhau giữa MySQL 8 và MariaDB, nó phun ra một đống khác biệt mà thực ra không phải lỗi người dùng:
- Độ rộng hiển thị của số nguyên. MySQL 8 bỏ, nên
COLUMN_TYPElàint. MariaDB giữ, nên làint(11). Mọi cột số nguyên bị đánh dấu khác nhau. - Collation mặc định. MySQL 8 mặc định
utf8mb4_0900_ai_ci, collation này không tồn tại bên MariaDB. MariaDB bản cũ mặc địnhutf8mb4_general_ci; họ collationutf8mb4_uca1400_...được thêm từ 10.10.1 và trở thành mặc định củautf8mb4từ nhánh 11.4 (bản 11.4.2 trở đi). Nên tùy bạn so với MariaDB bản nào, mọi cột chuỗi bị đánh dấu khác nhau, vì hai lý do khác nhau. - Cách in giá trị mặc định. MySQL ghi
CURRENT_TIMESTAMP, MariaDB ghicurrent_timestamp(). Cùng một ý nghĩa, khác chuỗi. - Cột
EXTRAvới default sinh bởi hàm, MySQL 8 gắn thêmDEFAULT_GENERATED, MariaDB thì không.
Nếu so theo kiểu khớp chuỗi thẳng, panel báo "khác nhau" ở gần như mọi dòng, và báo cáo đó vô dụng. Muốn nó có ích thì phải chuẩn hóa (normalize) theo từng engine trước khi so, tức là lại phải biết engine thật là ai:
function normalizeColumnType(engine, colType):
t = lower(colType)
// bỏ độ rộng hiển thị của số nguyên: int(11) -> int
t = regex_replace(t, /\b(tinyint|smallint|mediumint|int|bigint)\(\d+\)/, "$1")
return t
function normalizeDefault(expr):
e = lower(trim(expr))
return e.replace("current_timestamp()", "current_timestamp")
// Collation: chỉ trung hòa đúng trường hợp cả hai bên đều để mặc định của engine mình,
// còn collation khai báo tường minh thì vẫn phải so thật.
function collationEqual(a, b):
if isDefaultCollation(a.engine, a.coll) and isDefaultCollation(b.engine, b.coll):
return true
return a.coll == b.coll
Với TiDB còn thêm chuyện: information_schema của nó có cả tá bảng riêng cho phân tán (region, cluster), và một số thống kê chỉ là ước lượng.
Sơ đồ ER: khóa ngoại có đó nhưng chưa chắc đọc ra
Panel ER dựng quan hệ bằng cách đọc khóa ngoại từ information_schema.KEY_COLUMN_USAGE và REFERENTIAL_CONSTRAINTS. Với MySQL/MariaDB dùng InnoDB thì ổn. TiDB thì phải soi theo phiên bản, và đây là mốc cụ thể để bạn kiểm chứng:
- Trước v6.6.0: TiDB parse được cú pháp
FOREIGN KEYnhưng ràng buộc tạo ra không có hiệu lực, tức không thực sự ghi nhận quan hệ. - Từ v6.6.0: khóa ngoại có hiệu lực nhưng ở dạng thử nghiệm (experimental).
- Từ v8.5.0: khóa ngoại được đưa lên GA (chính thức).
Thêm một cái bẫy khi nâng cấp: khóa ngoại tạo trước v6.6.0 vẫn ở trạng thái vô hiệu ngay cả sau khi lên bản mới, chỉ khóa ngoại tạo từ v6.6.0 trở đi mới có hiệu lực. Hệ quả: cùng một lược đồ, sơ đồ ER vẽ trên MySQL đầy đủ đường nối, còn trên một TiDB đời cũ lại thiếu quan hệ, không phải panel sai mà do metadata không có ở đó. Cái này chỉ lộ ra khi test tay đúng phiên bản TiDB đó.
Họ Redis: Valkey nói cùng thứ tiếng nhưng khai tên khác
Bên Redis nhẹ hơn nhưng dính đúng kiểu vấn đề. Valkey là bản fork của Redis 7.2, giữ nguyên RESP, nên client Redis kết nối và chạy lệnh bình thường. Chỗ gãy là mấy panel đọc INFO để hiển thị bộ nhớ, keyspace, slowlog, và logic dò phiên bản server. Để tương thích ngược, Valkey vẫn giữ trường redis_version:
# INFO server (Valkey)
server_name:valkey
valkey_version:8.0.0
redis_version:7.2.4
Nếu bạn định danh engine chỉ dựa vào redis_version, bạn sẽ tưởng đây là Redis 7.2. Muốn tách Valkey thì soi trường server_name (giá trị valkey) hoặc sự có mặt của valkey_version. Ngoài ra một số lệnh và tùy chọn config ở bản mới của hai bên đã bắt đầu lệch nhau, nên panel nào parse INFO theo giả định cứng đều phải rà lại cho Valkey, dù RESP thì chẳng có vấn đề gì.
Nhóm đổi cách làm thế nào
Sau mấy lần vấp, nhóm bỏ hẳn ý định làm một "interface metadata dùng chung cho mọi engine". Nó nghe hay nhưng luôn rò rỉ ở chi tiết. Cách đang dùng là để từng engine tự khai báo nó hỗ trợ hay không hỗ trợ từng thứ, rồi UI dựa vào bản khai đó mà bật hay tắt panel và chọn parser, chứ không đoán từ tên protocol. Bản khai (capability descriptor) đại loại:
{
"mysql": { "explain": "mysql", "explainAnalyze": "EXPLAIN ANALYZE", "intDisplayWidth": false, "fkEnforced": true },
"mariadb": { "explain": "mariadb", "explainAnalyze": "ANALYZE FORMAT=JSON", "intDisplayWidth": true, "fkEnforced": true },
"tidb": { "explain": "tidb", "explainAnalyze": "EXPLAIN ANALYZE", "intDisplayWidth": false, "fkEnforced": "8.5.0+" }
}
explain quyết định parser nào chạy, intDisplayWidth bảo schema-diff có phải bỏ int(11) hay không, fkEnforced cho panel ER biết có nên tin vào khóa ngoại của engine này không (và từ bản nào).
Quy tắc thứ hai, không tránh được: mỗi panel phải test tay với từng engine tương thích. "Chạy tốt trên MySQL" không suy ra được "chạy tốt trên MariaDB hay TiDB". Danh sách kiểm nhóm thực sự chạy cho mỗi engine:
VERSION()và@@version_commentdò ra đúng engine thật chưa.- Panel EXPLAIN parse đúng định dạng của engine đó, bằng plan thật chứ không phải plan mẫu.
- Schema-diff so hai schema giống hệt nhau phải ra khác biệt bằng không sau khi chuẩn hóa.
- ER vẽ đúng số quan hệ mà engine đó thực sự ghi nhận (nhớ ghim phiên bản với TiDB).
- Với họ Redis,
INFOparse được và định danh engine đúng quaserver_name.
Chốt lại
Wire-compatible là lời hứa ở tầng protocol, và nó giữ đúng lời ở đúng tầng đó: client kết nối được, query cơ bản chạy. Nhưng mọi tính năng đọc metadata, execution plan hay system table đều nằm trên định dạng do từng engine tự định nghĩa, và mấy cái đó không tương thích theo. Nếu bạn làm công cụ đứng trên nhiều engine cùng họ protocol: đừng tin màn kết nối, hãy dò ra engine thật, cho mỗi engine tự khai khả năng của nó, rồi test tay từng panel. Đèn xanh ở màn kết nối là điểm bắt đầu của việc kiểm thử, không phải điểm kết thúc.
All rights reserved