---
title: "Khi UNIQUE vẫn trùng: điều tra MySQL corrupt"
description: "Trong tình huống giả định, quá trình dựng replica đã phơi bày 43 bảng InnoDB bị corrupt âm thầm: secondary index trả về một dòng, trong khi scan theo PRIMARY cho thấy hai bản ghi trùng `obj_id`. Chuỗi bằng chứng cho thấy ZFS-backed NFS với synchronous-write semantics bị nới lỏng có thể phá vỡ cam kết `fsync()`, khiến index lệch khỏi clustered data sau các sự cố và làm truy vấn production trả kết quả sai mà không báo lỗi. Bài học cốt lõi là phải dùng storage bảo đảm durability, duy trì backup đã thử restore và kiểm tra tính toàn vẹn định kỳ, bởi replica không thay thế backup nhưng có thể vô tình trở thành phép thử phát hiện corruption."
date: 2026-07-23T17:58:22.000Z
reviewed_at: 2026-07-24T05:40:25.304Z
author: "GPT-5.6"
reviewer: "Nguyễn Ngọc Nam"
tags: ["MySQL", "InnoDB", "ZFS", "SRE", "Postmortem"]
lang: vi
canonical: https://namnn.com/blog/khi-unique-van-trung-dieu-tra-mysql-corrupt/
---

Gần nửa đêm, lần restore thứ ba dừng đúng ở bảng `orders`. Màn hình không có stack trace dài, chỉ một dòng quen thuộc đến mức tưởng như vô hại:

```text
ERROR 1062 (23000): Duplicate entry 'XXXX'
for key 'orders.obj_id_UNIQUE'
```

Nhưng `obj_id` có UNIQUE index. MySQL production vẫn nhận traffic, đơn hàng vẫn được tạo, dashboard vẫn lên số. Trên source, truy vấn theo giá trị `XXXX` trả đúng một dòng.

File dump lại chứa hai dòng.

Đó là lúc một công việc tưởng như thuần vận hành — dựng replica — biến thành cuộc điều tra về tính toàn vẹn của toàn bộ database.

Bài viết dựng lại một sự cố hư cấu để phân tích failure mode có thể xảy ra khi durability contract giữa MySQL và tầng storage bị phá vỡ. Công ty X, trình tự điều tra và các con số như 45GB dữ liệu, gần 600 bảng, 35 triệu dòng trong `orders` hay 43 bảng corrupt đều thuộc tình huống giả định; chúng không phải dữ liệu lấy từ một hệ thống production cụ thể.

Trong tình huống này, các lần dump lặp lại cùng một duplicate, kết quả khác nhau giữa secondary index và PRIMARY, thông báo từ `CHECK TABLE` cùng cấu hình ZFS-backed NFS bị nới lỏng được dùng làm chuỗi bằng chứng. Chuỗi bằng chứng ấy cho phép loại trừ snapshot inconsistency và đưa ra giả thuyết nguyên nhân phù hợp với cơ chế durability của InnoDB. Dẫu vậy, nếu đây là một incident thật, muốn quy kết chính xác còn cần thêm error log, lịch sử mất điện hoặc restart, telemetry của storage, cấu hình NFS/ZFS tại thời điểm xảy ra lỗi và thử nghiệm tái hiện trên một môi trường cô lập. Vì thế, bài viết không khẳng định mọi database đặt trên ZFS hoặc NFS đều sẽ hỏng; rủi ro nằm ở việc các tầng bên dưới không thực hiện đúng synchronous-write semantics mà database dựa vào.

## Một database phục vụ gần như cả công ty

Công ty X là một doanh nghiệp thương mại điện tử vừa và nhỏ, với đội kỹ thuật chỉ khoảng 6–8 người. Không có DBA chuyên trách; backend engineer vừa theo dõi slow query, điều chỉnh MySQL, vừa xử lý sự cố lúc nửa đêm.

Trong tình huống giả định này, hạ tầng của Công ty X được xây dựng từ giai đoạn mà hai ưu tiên lớn nhất là tiết kiệm chi phí và sớm đưa hệ thống vào hoạt động. Một instance Percona Server for MySQL 8.0 phục vụ gần như toàn bộ module nghiệp vụ: đơn hàng, giao vận, khách hàng, catalog, tồn kho và thanh toán. Database được giả định chứa khoảng 45GB dữ liệu trên gần 600 bảng InnoDB; riêng bảng `orders` có xấp xỉ 35 triệu dòng.

Database chạy standalone, không có replica và cũng không có bản backup đã được kiểm chứng khả năng restore. Data directory nằm trên NFS, còn NFS server lưu dữ liệu trong một ZFS pool dùng thiết bị giá rẻ. ZFS được chọn vì copy-on-write, checksum và snapshot; kết hợp `zfs send`/`zfs receive`, snapshot còn có thể được truyền sang một pool khác để tạo bản sao phục vụ backup hoặc replication. Tuy nhiên, snapshot nằm cùng pool không bảo vệ được trước việc mất cả pool, còn bản sao chưa từng restore cũng chưa thể xem là backup đã được kiểm chứng.

Để giảm latency ghi, một tầng trong đường I/O được giả định đã nới lỏng synchronous-write semantics: ZFS dataset dùng `sync=disabled`, NFS export hoạt động ở chế độ asynchronous, hoặc cả hai. Vì vậy, yêu cầu ghi có thể được ACK trước khi dữ liệu đạt stable storage. Copy-on-write và checksum không bù được lỗ hổng này: chúng bảo vệ cách ZFS ghi và kiểm tra block, nhưng không thể khôi phục một write mà storage đã báo hoàn tất khi dữ liệu vẫn còn trong bộ nhớ rồi biến mất sau sự cố.

Với kiến trúc giả định ấy, nếu volume không còn đọc được thì toàn bộ sàn có thể ngừng bán, trong khi dữ liệu không có nguồn khôi phục đã được kiểm chứng. Team quyết định seed một replica trên block storage NVMe ở cluster khác, vừa để tạo bản sao đang hoạt động, vừa bắt đầu đưa database rời khỏi NFS.

Chính quá trình dựng replica đã làm lộ ra trạng thái corruption mà production được giả định đã che giấu suốt nhiều tháng.

### Vì sao `obj_id` là UNIQUE nhưng không phải PRIMARY KEY?

Bảng `orders` dùng hai loại định danh cho hai mục đích khác nhau:

```sql
CREATE TABLE orders (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    obj_id VARCHAR(64) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY obj_id_UNIQUE (obj_id)
) ENGINE=InnoDB;
```

`id` là <span class="inline-note" data-note-id="surrogate-key" data-note-text="Surrogate key là khóa định danh do hệ thống tạo ra, không mang ý nghĩa nghiệp vụ; trong bảng này, đó là cột id tự tăng.">surrogate key</span> do database sinh ra, có kích thước cố định, tăng dần và không thay đổi trong suốt vòng đời record. Nó phù hợp làm PRIMARY KEY vì InnoDB tổ chức toàn bộ dữ liệu theo clustered index; mọi secondary index cũng lưu kèm giá trị primary key để tìm lại row. Một primary key ngắn và tăng dần thường giúp giảm kích thước secondary index, hạn chế page split và tránh phải cập nhật hàng loạt foreign key nếu định danh nghiệp vụ thay đổi.

`obj_id` là business key được dùng khi trao đổi với application hoặc hệ thống bên ngoài. Nó phải duy nhất, nhưng có thể dài hơn `id`, được tạo ngoài database hoặc chịu quy tắc định dạng thay đổi theo thời gian. Vì vậy, chọn `id` làm PRIMARY KEY và đặt UNIQUE index trên `obj_id` là một thiết kế phổ biến, không phải nguyên nhân trực tiếp gây corruption.

Điểm liên quan tới sự cố nằm ở cách InnoDB hiện thực thiết kế đó. Dữ liệu đầy đủ của row nằm trong clustered index theo `id`, còn ràng buộc duy nhất của `obj_id` được kiểm tra qua một B-tree riêng. Khi secondary index này còn chính xác, lần insert thứ hai với cùng `obj_id` sẽ bị từ chối. Nhưng nếu một index page hoặc thay đổi của index không còn tồn tại sau sự cố storage, MySQL có thể không tìm thấy giá trị cũ qua `obj_id_UNIQUE`, dù row tương ứng vẫn còn trong clustered index. Lần insert sau đó có thể đi qua bước kiểm tra uniqueness, tạo ra hai row khác `id` nhưng cùng `obj_id` trong dữ liệu gốc.


![](/covers/khi-obj-id-unique-van-trung-dieu-tra-43-bang-mysql-corrupt-2026-07-24T04-11-26-218Z.jpg)

Nếu dùng `obj_id` làm PRIMARY KEY, không còn secondary index riêng chỉ để bảo đảm uniqueness của chính cột này; lookup và kiểm tra trùng đều dựa trên clustered index chứa row. Điều đó thu hẹp failure mode cụ thể đang được điều tra, nhưng không biến storage vi phạm `fsync()` thành an toàn. Clustered index vẫn có thể corrupt, và một business key dài hoặc có khả năng thay đổi sẽ kéo theo chi phí lớn hơn cho mọi secondary index cùng foreign key. Vì thế, bài học không phải “business key luôn phải làm PRIMARY KEY”, mà là phải hiểu ràng buộc nào đang phụ thuộc vào secondary index và không được để tầng storage phá vỡ durability contract của InnoDB.

## Giả thuyết đầu tiên: dump không nhất quán

Để khởi tạo replica, team cần chép dữ liệu hiện có từ source sang một instance MySQL sạch trước khi replication có thể tiếp tục đồng bộ các thay đổi mới. Lần seed đầu dùng `mysqldump` nhằm xuất schema và dữ liệu thành file SQL, sau đó restore file này vào MySQL mới trên block storage NVMe. Mục đích của lần dump không phải tạo thêm một bản backup dài hạn, mà là dựng trạng thái dữ liệu ban đầu cho replica và đồng thời kiểm tra xem toàn bộ database có thể được đọc rồi tái tạo trên một cấu trúc sạch hay không.

Quá trình restore chạy được một đoạn rồi dừng khi một câu `INSERT` cố nạp `obj_id` thứ hai vào bảng `orders`, nơi UNIQUE index đã được tạo cùng schema.

```bash
mysqldump \
  --all-databases \
  --routines \
  --triggers \
  --events \
  --single-transaction \
  > seed.sql

mysql < seed.sql
```

```text
ERROR 1062 (23000) at line 18427391:
Duplicate entry 'XXXX' for key 'orders.obj_id_UNIQUE'
```

Giả thuyết hợp lý nhất là snapshot không nhất quán. Nếu dump đọc bảng trong khi application tiếp tục ghi, liệu hai trạng thái khác nhau có bị trộn vào cùng file không?

`--single-transaction` vốn tạo consistent snapshot cho các bảng transactional như InnoDB, miễn là trong lúc dump không có DDL làm thay đổi cấu trúc bảng. Team vẫn thử lại, kiểm tra option và giảm các tác vụ schema trong thời gian seed. Kết quả không đổi.

Để loại trừ thêm, team thử riêng với `--lock-all-tables`. Option này dùng global read lock thay vì consistent snapshot; nó không được dùng đồng thời với `--single-transaction`. Trong maintenance window lúc nửa đêm, toàn bộ write bị chặn trong thời gian dump.

```bash
mysqldump \
  --all-databases \
  --lock-all-tables \
  --routines \
  --triggers \
  --events \
  > seed-locked.sql
```

Restore vẫn fail ở cùng giá trị `XXXX`.

Ba lần dump, hai chiến lược consistency, cùng một duplicate. Snapshot inconsistency không còn giải thích được hiện tượng.

## Hai kết quả khác nhau cho cùng một truy vấn

Team quay lại source và hỏi trực tiếp bảng production:

```sql
SELECT COUNT(*)
FROM orders
WHERE obj_id = 'XXXX';
```

```text
+----------+
| COUNT(*) |
+----------+
|        1 |
+----------+
```

Nếu chỉ có một dòng, tại sao dump xuất hai `INSERT` mang cùng `obj_id`?

Bước ngoặt đến từ execution plan. Với điều kiện equality trên `obj_id`, optimizer chọn `obj_id_UNIQUE`. InnoDB không cần đọc toàn bộ row trong clustered index để tính số bản ghi phù hợp; nó có thể trả lời từ secondary index. Vậy nếu chính index đó không còn phản ánh đầy đủ dữ liệu thì sao?

Team buộc MySQL đọc theo PRIMARY:

```sql
SELECT COUNT(*)
FROM orders FORCE INDEX (PRIMARY)
WHERE obj_id = 'XXXX';
```

```text
+----------+
| COUNT(*) |
+----------+
|        2 |
+----------+
```

Sau đó, hai row được đọc theo primary key:

```sql
SELECT id, obj_id, created_at, updated_at
FROM orders FORCE INDEX (PRIMARY)
WHERE obj_id = 'XXXX'
ORDER BY id;
```

```text
+----------+--------+---------------------+---------------------+
| id       | obj_id | created_at          | updated_at          |
+----------+--------+---------------------+---------------------+
| 18290431 | XXXX   | 2025-01-08 14:12:31 | 2025-01-08 14:12:31 |
| 19733502 | XXXX   | 2025-02-19 09:44:07 | 2025-02-19 09:44:07 |
+----------+--------+---------------------+---------------------+
```

Hai record thật, khác primary key, khác thời điểm tạo, cùng `obj_id`. UNIQUE index chỉ chứa hoặc chỉ dẫn được tới một trong hai record, nên truy vấn dùng index trả về `1`. `mysqldump`, trong quá trình quét dữ liệu theo đường đọc khác, lại xuất cả hai. Restore tạo một UNIQUE index sạch và từ chối trạng thái mà source đang âm thầm chứa.

`EXPLAIN ANALYZE` hoặc optimizer trace có thể xác nhận access path trong từng phiên bản và từng câu query. Điều quan trọng ở đây không phải giả định rằng mọi `COUNT(*)` đều dùng secondary index, mà là phải kiểm tra plan rồi đối chiếu với một access path độc lập.

```sql
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM orders
WHERE obj_id = 'XXXX';
```

MySQL mô tả clustered index của InnoDB như B-tree chứa toàn bộ row và được tổ chức theo primary key. Mỗi entry trong secondary index chứa secondary key cùng primary key dùng để tìm record trong clustered index.<sup class="cite"><a href="#ref-1">1</a></sup> Vì hai cấu trúc được lưu trên những page khác nhau, hư hỏng hoặc thiếu page ở secondary index có thể khiến access path qua index trả về tập row khác với scan qua PRIMARY.

![](/covers/khi-unique-van-trung-dieu-tra-mysql-corrupt-2026-07-24T05-13-16-526Z.jpg)

Lúc ấy, lỗi restore không còn là trở ngại của migration. Nó là bằng chứng rằng production đang trả lời sai.

## 43 bảng cùng có index corrupt

Team chạy kiểm tra trực tiếp trên `orders`:

```sql
CHECK TABLE orders;
```

```text
+----------------+-------+----------+-------------------------------------------+
| Table          | Op    | Msg_type | Msg_text                                  |
+----------------+-------+----------+-------------------------------------------+
| commerce.orders| check | error    | Index obj_id_UNIQUE is marked as corrupted|
| commerce.orders| check | error    | Corrupt                                   |
+----------------+-------+----------+-------------------------------------------+
```

Sau đó là toàn bộ instance:

```bash
mysqlcheck \
  --all-databases \
  --check \
  --user=check_user \
  --password
```

`mysqlcheck --check` gọi kiểm tra bảng và không có chủ đích sửa dữ liệu. Tuy vậy, `CHECK TABLE` trên InnoDB lớn vẫn tạo I/O đáng kể, có thể chặn thread khác và trong một số dạng corruption còn khiến server thoát để ngăn lỗi lan rộng. Tài liệu MySQL cảnh báo rõ các hành vi này.<sup class="cite"><a href="#ref-2">2</a></sup> Vì vậy, “read-only” không đồng nghĩa “không có rủi ro vận hành”; cần chạy trên replica hoặc maintenance window nếu có thể, theo dõi latency và error log, đồng thời chuẩn bị phương án dừng.

Trên gần 600 bảng nằm sau NFS, quá trình kéo dài nhiều giờ. Kết quả cuối cùng không phải một index đơn lẻ: **43 bảng corrupt**, phần lớn cùng mẫu `Index ... is marked as corrupted`, rải qua `orders`, `order_shipping`, `inventory`, `customers` và các bảng nghiệp vụ khác.

Không có incident lớn tương ứng. Không có một thời điểm duy nhất mà tất cả query fail. Application vẫn chạy vì clustered data của nhiều bảng còn đọc được, còn optimizer chỉ chạm vào vùng hỏng khi access path đi qua index liên quan.

Đây là đặc trưng nguy hiểm của silent data corruption: hệ thống không nhất thiết crash. Một query theo PRIMARY có thể thấy row, còn query theo secondary index lại bỏ sót nó. Báo cáo doanh thu, số tồn kho, đối soát vận chuyển hoặc tiến trình tìm đơn theo business key đều có thể sai mà không phát sinh exception.

Crash buộc đội vận hành phản ứng. Một con số hợp lệ về kiểu dữ liệu nhưng sai về nội dung có thể tồn tại qua nhiều tháng.

## InnoDB đã tin storage ở điểm nào?

Một transaction bền vững không chỉ phụ thuộc vào một biến trong `my.cnf`. MySQL mô tả durability như kết quả phối hợp giữa doublewrite buffer, redo log, `innodb_flush_log_at_trx_commit`, `sync_binlog`, operating system, controller cache và thiết bị lưu trữ.<sup class="cite"><a href="#ref-3">3</a></sup>

Với cấu hình an toàn thường dùng cho source có replication:

```ini
[mysqld]
innodb_flush_log_at_trx_commit=1
sync_binlog=1
```

`innodb_flush_log_at_trx_commit=1` yêu cầu redo log được ghi và flush ở mỗi commit. `sync_binlog=1` yêu cầu binary log được đồng bộ sau mỗi transaction. MySQL cũng khuyến nghị cặp giá trị này để đạt durability và consistency cao nhất cho replication.<sup class="cite"><a href="#ref-4">4</a></sup>

Redo log giúp crash recovery phát lại thay đổi đã commit. Doublewrite buffer giải quyết bài toán khác: một InnoDB page thường có kích thước 16KiB, còn storage có thể chỉ ghi được một phần page trước khi mất điện. InnoDB ghi page vào doublewrite area trước, flush, rồi mới ghi tới vị trí cuối. Nếu page đích bị torn write, recovery còn một bản đầy đủ để phục hồi.

Nhưng cả redo lẫn doublewrite đều dựa trên một điều kiện nền: khi MySQL yêu cầu flush và hệ điều hành trả về thành công, dữ liệu phải đạt trạng thái mà storage cam kết sẽ sống sót qua power loss phù hợp với mô hình phần cứng.

![](/covers/khi-unique-van-trung-dieu-tra-mysql-corrupt-2026-07-24T05-15-14-491Z.jpg)

Trong case này, tầng storage đã phá vỡ điều kiện đó: ZFS-backed NFS ACK một synchronous write khi dữ liệu chưa đạt stable storage. InnoDB nhận kết quả thành công nên tiếp tục vận hành với giả định rằng yêu cầu ghi đã được bảo đảm.

Tài liệu MySQL nói thẳng rằng nếu `fsync()` trả về trước khi dữ liệu được flush, power loss hoặc OS crash có thể làm mất transaction vừa commit; trong trường hợp xấu, write bị reorder còn có thể làm corrupt database.<sup class="cite"><a href="#ref-5">5</a></sup>

Sau một lần mất điện, OS crash hoặc NFS/ZFS server bị restart cưỡng bức, một số buffered write chưa đạt stable storage có thể biến mất. Không phải mọi page đều mất cùng lúc. Một page thuộc secondary index có thể quay về trạng thái cũ trong khi clustered page chứa row đã tồn tại ở trạng thái khác. Khi `obj_id_UNIQUE` thiếu entry của row cũ, lần kiểm tra uniqueness tiếp theo có thể không phát hiện business key đã tồn tại; clustered index theo `id` vẫn chấp nhận row mới vì hai giá trị primary key khác nhau. Lần sau lại mất vài page ở các index khác. Trên 45GB dữ liệu và hàng trăm bảng, nhiều sự cố nhỏ có thể tích tụ thành phạm vi corruption rộng.

Đây là cơ chế phù hợp với bằng chứng của vụ việc: cấu hình sync bị nới lỏng, các lần restart bất thường trong lịch sử và 43 bảng cùng hỏng secondary index. Nếu không có telemetry storage, image trước sự cố và thử nghiệm tái hiện, ta không thể xác định chính xác power event nào làm hỏng page nào. Tuy nhiên, snapshot inconsistency đã bị loại trừ, còn failure mode khớp trực tiếp với cảnh báo durability của MySQL.

## ZIL, SLOG và cái giá thật của `sync=disabled`

Với `sync=standard`, OpenZFS tôn trọng synchronous request như `fsync()` và `O_DSYNC`: lời gọi chỉ hoàn tất sau khi dữ liệu cần thiết được đưa tới stable storage theo semantics tương ứng. Với `sync=disabled`, synchronous request bị xử lý như asynchronous write; lời gọi có thể trả về trong khi dữ liệu vẫn chỉ nằm trong bộ nhớ.<sup class="cite"><a href="#ref-6">6</a></sup>

ZFS Intent Log (ZIL) ghi lại các synchronous operation cần replay nếu hệ thống dừng trước khi transaction group chính được commit xuống pool. ZIL không phải bản sao lâu dài của toàn bộ filesystem. Trong vận hành bình thường, dữ liệu được ghi vào các transaction group của pool; ZIL chủ yếu bảo đảm khả năng replay phần synchronous write chưa kịp đi theo luồng đó.

SLOG là thiết bị riêng dùng để chứa ZIL; nó không phải write cache thông thường và cũng không thay thế RAM, ARC hay main pool. Luồng ghi có thể hình dung như sau:

![Minh họa cho “ZIL, SLOG và cái giá thật của sync=disabled”](/covers/khi-unique-van-trung-dieu-tra-mysql-corrupt-2026-07-24T05-39-04-230Z.jpg)

Với `sync=standard`, ZFS ghi thông tin cần thiết để phục hồi synchronous operation vào ZIL trước khi trả ACK. Nếu hệ thống dừng trước lúc transaction group (TXG) hoàn tất việc ghi dữ liệu chính xuống main pool, ZFS có thể replay ZIL khi khởi động lại. Khi có SLOG, ZIL nằm trên thiết bị riêng; nếu không, ZIL vẫn được đặt trên main pool.

Một SLOG có latency thấp, power-loss protection và độ bền phù hợp có thể rút ngắn thời gian hoàn tất synchronous write, nhất là với workload commit-heavy. Nếu ZIL phải dùng main pool có latency cao, mỗi commit phải chờ lâu hơn và throughput giảm. Đó là cám dỗ của `sync=disabled`: benchmark tăng ngay vì ACK được trả trước khi dữ liệu đạt stable storage, còn phần dữ liệu chưa ổn định chỉ biến mất khi xảy ra mất điện hoặc restart bất thường.

`logbias=latency` là mặc định và cho phép ZFS ưu tiên latency của synchronous request, bao gồm sử dụng log device khi có. `logbias=throughput` hướng tới throughput tổng thể và có thể không sử dụng SLOG.<sup class="cite"><a href="#ref-6">6</a></sup> Vì vậy, không nên sao chép `logbias=throughput` như một “tuning chuẩn cho MySQL”. Nó cần benchmark bằng workload thật và phải giữ nguyên durability semantics.

### Copy-on-write không cho phép tùy tiện tắt doublewrite

ZFS dùng copy-on-write (COW): block đang được tham chiếu không bị ghi đè tại chỗ; phiên bản mới được ghi ở vị trí khác rồi metadata mới được cập nhật. Checksumming còn giúp phát hiện block không khớp nội dung mong đợi. Hai đặc tính này làm giảm một số failure mode kiểu torn in-place write, nên có hệ thống chọn `innodb_doublewrite=0` để tránh ghi lặp.

Nhưng đó không phải khuyến nghị có thể áp dụng vô điều kiện. Tài liệu MySQL khuyên giữ doublewrite bật khi quan tâm tới tính toàn vẹn hoặc failure bất ngờ.<sup class="cite"><a href="#ref-5">5</a></sup> Trước khi tắt, cần xác nhận phiên bản MySQL, đường I/O, kích thước block, thiết bị, power-loss protection và kết quả pull-the-plug test. COW không sửa được việc `fsync()` bị ACK sai.

Nếu vừa tắt doublewrite vừa đặt `sync=disabled`, hai lớp bảo vệ khác nhau cùng bị loại bỏ. Trong postmortem này, đó không còn là tuning hiệu năng mà là một cấu hình không đáp ứng durability contract của database.

### ``recordsize=16K` là giá trị khởi đầu để benchmark

InnoDB mặc định dùng page 16KiB, trong khi ZFS dataset thường có `recordsize` lớn hơn, chẳng hạn 128KiB. Với workload OLTP cập nhật ngẫu nhiên, chênh lệch này có thể làm tăng read-modify-write và write amplification. Vì vậy, `recordsize=16K` là một giá trị khởi đầu hợp lý để benchmark dataset mới chuyên chứa InnoDB files, nhưng không mặc nhiên là cấu hình tối ưu cho mọi workload.

```bash
zfs set recordsize=16K pool/mysql
zfs set sync=standard pool/mysql
zfs set atime=off pool/mysql
zfs set compression=lz4 pool/mysql
```

Có hai lưu ý quan trọng. Thứ nhất, thay `recordsize` chỉ ảnh hưởng block được ghi mới; file cũ không tự đổi layout.<sup class="cite"><a href="#ref-7">7</a></sup> Thứ hai, kích thước tối ưu phụ thuộc loại file và workload. Redo log, binary log, tablespace lớn và backup tuần tự không nhất thiết có cùng access pattern. Cần đo latency, amplification, <span class="inline-note" data-note-id="arc-hit-ratio" data-note-text="ARC hit ratio là tỷ lệ yêu cầu đọc được đáp ứng từ Adaptive Replacement Cache của ZFS thay vì phải truy cập thiết bị lưu trữ.">ARC hit ratio</span> và throughput trước lẫn sau thay đổi.

`atime=off` tránh phát sinh write chỉ để cập nhật thời điểm đọc. `compression=lz4` thường có chi phí thấp, nhưng vẫn phải benchmark. `primarycache=metadata` đôi khi được dùng để tránh giữ data page đồng thời trong InnoDB buffer pool và ARC; đổi lại, nó có thể làm read workload chậm đi khi buffer pool không chứa đủ working set. Đây cũng là lựa chọn theo workload, không phải mặc định bắt buộc.

## NFS làm durability chain khó kiểm chứng hơn

NFS thêm ít nhất một client, network, server, filesystem và thiết bị lưu trữ vào đường đi của mỗi flush. Một synchronous request chỉ đáng tin khi mọi tầng đều truyền đúng semantics xuống tầng sau.

Modern NFS có cơ chế locking và stable-write semantics; vì vậy, nói rằng mọi NFS đều “không hỗ trợ lock” là không chính xác. Vấn đề thực tế là khác biệt giữa phiên bản protocol, mount option, server implementation, failover, cache và export mode. Close-to-open consistency của NFS cũng không phải cơ chế coherence dành cho hai tiến trình `mysqld` cùng mở một data directory.

Không được xem lock file là hàng rào duy nhất ngăn hai MySQL instance truy cập cùng datadir. Quyền sở hữu volume phải được bảo đảm bằng orchestration và fencing ở tầng hạ tầng. MySQL yêu cầu người vận hành xem xét riêng các vấn đề NFS trước khi dùng với InnoDB.<sup class="cite"><a href="#ref-5">5</a></sup>

Trong case này, NFS không chỉ tăng latency. Ít nhất một tầng trong chuỗi NFS/ZFS đã nới lỏng synchronous-write semantics, khiến yêu cầu flush của InnoDB được ACK trước khi dữ liệu đạt stable storage như database giả định. Chuyển replica sang local NVMe hoặc block storage có durability được kiểm chứng làm đường I/O ngắn hơn và dễ quan sát hơn.

## Cứu dữ liệu mà không tin secondary index

Trước khi sửa, team đóng băng các thay đổi không thiết yếu và tạo nhiều bản sao ở tầng có thể đọc được. Mọi DDL, dedup và rebuild dưới đây đều là thao tác ghi có khả năng khóa bảng, tăng dung lượng tạm thời hoặc làm mất dữ liệu nếu giả định sai. Chúng chỉ được thực hiện sau backup đã kiểm tra restore, trên bản clone hoặc trong maintenance window có rollback plan.

### Trường hợp clustered data còn đúng

Với các bảng chỉ lệch secondary index, phương án là rebuild bảng và toàn bộ index từ clustered index:

```sql
ALTER TABLE order_shipping ENGINE = InnoDB;
```

MySQL tài liệu hóa `ALTER TABLE ... ENGINE=InnoDB` như một “null alteration” dùng để rebuild bảng.<sup class="cite"><a href="#ref-8">8</a></sup> Sau rebuild, cần chạy lại `CHECK TABLE`, đối chiếu row count qua PRIMARY và kiểm tra query nghiệp vụ quan trọng.

Không dùng `mysqlcheck --repair` với InnoDB. `REPAIR TABLE` chỉ áp dụng cho MyISAM, ARCHIVE và CSV, không phải InnoDB.<sup class="cite"><a href="#ref-8">8</a></sup> `OPTIMIZE TABLE` cũng là thao tác thay đổi dữ liệu vật lý, không phải lệnh chẩn đoán an toàn.

### Trường hợp clustered data đã có duplicate thật

`orders` khó hơn: clustered index chứa nhiều `obj_id` trùng thật. Trong case này, bảng đóng vai trò cache có thể tái tạo, và quy tắc nghiệp vụ đã được xác nhận là giữ record mới nhất theo `id`. Không được áp dụng quy tắc đó cho ledger thanh toán, tồn kho hoặc dữ liệu lịch sử nếu chưa có xác nhận của owner nghiệp vụ.

Đầu tiên, tạo backup và chứng minh backup restore được. Sau đó tìm duplicate bằng scan PRIMARY, vì UNIQUE index đang hỏng không còn là nguồn sự thật:

```sql
CREATE TABLE orders_duplicate_keys AS
SELECT obj_id, MAX(id) AS keep_id, COUNT(*) AS copies
FROM orders FORCE INDEX (PRIMARY)
GROUP BY obj_id
HAVING COUNT(*) > 1;
```

Sao lưu toàn bộ row liên quan trước khi xóa:

```sql
CREATE TABLE orders_duplicate_rows LIKE orders;
ALTER TABLE orders_duplicate_rows DROP INDEX obj_id_UNIQUE;

INSERT INTO orders_duplicate_rows
SELECT o.*
FROM orders AS o FORCE INDEX (PRIMARY)
JOIN orders_duplicate_keys AS d
  ON d.obj_id = o.obj_id;
```

Sau khi đối chiếu số lượng, checksum ở mức ứng dụng và quy tắc giữ bản mới nhất, mới xóa các bản cũ trong batch nhỏ:

```sql
CREATE TABLE orders_delete_batch AS
SELECT o.id
FROM orders AS o FORCE INDEX (PRIMARY)
JOIN orders_duplicate_keys AS d
  ON d.obj_id = o.obj_id
WHERE o.id <> d.keep_id
LIMIT 1000;

ALTER TABLE orders_delete_batch ADD PRIMARY KEY (id);

DELETE o
FROM orders AS o
JOIN orders_delete_batch AS b
  ON b.id = o.id;
```

MySQL không hỗ trợ `LIMIT` cho multi-table `DELETE`, nên batch phải được cắt ở bước `SELECT`. Team materialize tối đa 1.000 primary key vào `orders_delete_batch`, xóa bằng phép JOIN trên `id`, rồi xóa bảng batch và lặp lại cho tới khi hết bản ghi cần xóa. Điểm bắt buộc là mọi quyết định chọn và xóa đều dựa trên primary key đã đối chiếu, không dựa vào `obj_id_UNIQUE` đang corrupt.

Sau dedup:

```sql
ALTER TABLE orders DROP INDEX obj_id_UNIQUE;
ALTER TABLE orders ENGINE = InnoDB;
ALTER TABLE orders ADD UNIQUE INDEX obj_id_UNIQUE (obj_id);
CHECK TABLE orders;
```

Thứ tự thực tế còn phụ thuộc việc MySQL có cho phép thao tác trên index đã bị đánh dấu corrupt hay không. Nếu bảng không đọc được ổn định, hướng xử lý phải chuyển sang dump phần còn cứu được, `innodb_force_recovery` theo mức tối thiểu cần thiết, hoặc phục hồi từ backup; không tiếp tục thử DDL trực tiếp trên production.

## DDL đứng im vì metadata lock

Một lần `ALTER TABLE` không tiến triển. `SHOW PROCESSLIST` chỉ hiện:

```text
Waiting for table metadata lock
```

Nguyên nhân là một client đã mở transaction, đọc `orders` rồi bỏ quên `COMMIT`. Metadata lock của transaction vẫn được giữ, khiến DDL chờ. Performance Schema cho biết lock đang chờ và lock đã được cấp, còn `INFORMATION_SCHEMA.INNODB_TRX` cho biết transaction đang mở.<sup class="cite"><a href="#ref-9">9</a></sup>

```sql
SELECT
    ml.OBJECT_SCHEMA,
    ml.OBJECT_NAME,
    ml.LOCK_TYPE,
    ml.LOCK_STATUS,
    ml.OWNER_THREAD_ID,
    t.PROCESSLIST_ID,
    t.PROCESSLIST_USER,
    t.PROCESSLIST_HOST,
    t.PROCESSLIST_TIME,
    t.PROCESSLIST_INFO
FROM performance_schema.metadata_locks AS ml
LEFT JOIN performance_schema.threads AS t
  ON t.THREAD_ID = ml.OWNER_THREAD_ID
WHERE ml.OBJECT_SCHEMA = 'commerce'
  AND ml.OBJECT_NAME = 'orders';
```

```sql
SELECT
    trx_mysql_thread_id,
    trx_started,
    trx_state,
    trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
```

Chỉ `KILL <processlist_id>` sau khi xác định owner, tuổi transaction, tác động rollback và application có retry an toàn. Kill nhầm một transaction đang cập nhật đơn hàng có thể tạo sự cố thứ hai ngay trong lúc cứu dữ liệu.

## Một pipeline backup có thể báo thành công khi dump đã fail

Trong lúc xử lý, một bẫy khác xuất hiện:

```bash
mysqldump commerce | gzip > commerce.sql.gz
```

Mặc định, exit status của pipeline Bash là exit status của command cuối. Nếu `mysqldump` fail giữa chừng nhưng `gzip` vẫn đóng file thành công, script có thể trả về `0`. Kết quả là một file gzip hợp lệ về định dạng nhưng thiếu bảng hoặc thiếu phần cuối của dữ liệu.

Pipeline backup phải bật `pipefail`, giữ log và kiểm tra restore:

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

mysqldump \
  --single-transaction \
  --routines \
  --triggers \
  --events \
  commerce \
  | gzip -1 > commerce.sql.gz

gzip -t commerce.sql.gz
```

``pipefail` cũng có thể báo lỗi trong một pipeline đã tìm thấy kết quả. Với lệnh như sau, `grep -m1` thoát ngay sau match, làm `zcat` nhận `SIGPIPE`; pipeline có thể mang exit status `141` dù chuỗi đã được tìm thấy:

```bash
zcat commerce.sql.gz | grep -m1 'CREATE TABLE `orders`'
```

Đừng tắt kiểm tra lỗi cho toàn bộ script để né tình huống này. Có thể kiểm tra integrity bằng `gzip -t` trước, rồi giải nén ra file tạm và chạy `grep` riêng nếu cần kết quả tuyệt đối rõ ràng:

```bash
tmp_sql="$(mktemp)"
trap 'rm -f "$tmp_sql"' EXIT

gzip -t commerce.sql.gz
gzip -cd commerce.sql.gz > "$tmp_sql"
grep -m1 'CREATE TABLE `orders`' "$tmp_sql"
```

Quan trọng hơn mọi exit code là restore drill. Một file backup chỉ trở thành backup đã được kiểm chứng khi có thể restore vào instance sạch và qua các kiểm tra schema, row count, checksum cùng query nghiệp vụ.


![](/covers/khi-unique-van-trung-dieu-tra-mysql-corrupt-2026-07-24T05-26-32-547Z.jpg)

## Những thay đổi sau sự cố

Database được chuyển khỏi ZFS-backed NFS có asynchronous semantics sang block storage hoặc local NVMe với durability đã được kiểm chứng. Source và replica dùng `innodb_flush_log_at_trx_commit=1`, `sync_binlog=1`; backup định kỳ được mã hóa, giữ ngoài cluster và thử restore. Replica cung cấp khả năng failover và giảm recovery time, nhưng không thay thế backup: thao tác xóa nhầm, corruption logic hoặc credential bị lạm dụng vẫn có thể replicate sang bản sao.

Integrity check được đưa vào lịch vận hành. `mysqlcheck --check` chạy trước trên replica, có giới hạn I/O và alert khi output chứa `Corrupt`, `error` hoặc row-count mismatch. Với bảng trọng yếu, team còn so sánh query qua access path mặc định và `FORCE INDEX(PRIMARY)` trên mẫu business key đã chọn.

Nếu buộc phải đặt MySQL trên ZFS, baseline thận trọng là `sync=standard`, SLOG có power-loss protection khi synchronous latency của main pool không đạt yêu cầu, `recordsize` bắt đầu từ 16KiB cho InnoDB data files rồi benchmark, `atime=off` và compression phù hợp. `logbias`, ARC policy và doublewrite chỉ được thay đổi sau đo đạc cùng failure test; không đánh đổi semantics của `fsync()` để lấy một biểu đồ throughput đẹp hơn.

Công ty nhỏ càng cần làm đúng vài lớp nền tảng: storage giữ đúng cam kết, backup có thể restore và ít nhất một đường kiểm tra integrity. Đội sáu người không có DBA để xử lý một cuộc phục hồi kéo dài, nên họ càng không thể dựa vào may mắn.

Replica không cứu được dữ liệu cũ trong vụ việc này. Nhưng quá trình dựng replica đã buộc toàn bộ dữ liệu phải được đọc và tái tạo trên một cấu trúc sạch, và chính phép thử đó làm lộ ra 43 index đã âm thầm sai.

`fsync()` là ranh giới hợp đồng giữa database và storage. Khi tầng dưới trả lời “đã bền vững” trong lúc write vẫn còn được buffer, mọi cơ chế phía trên đang vận hành trên một tiền đề sai. Từ đó, điều đáng sợ nhất không nhất thiết là MySQL không khởi động lại được.

Đáng sợ hơn là MySQL vẫn chạy, query vẫn thành công, còn con số trả về thì thiếu một dòng.

## Tài liệu tham khảo

<ol class="references">
<li id="ref-1">Oracle (2026). <em>MySQL 8.0 Reference Manual: Clustered and Secondary Indexes.</em> <a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-indexes.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
<li id="ref-2">Oracle (2026). <em>MySQL Reference Manual: CHECK TABLE Statement — InnoDB Usage Notes.</em> <a href="https://dev.mysql.com/doc/refman/8.4/en/check-table.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
<li id="ref-3">Oracle (2026). <em>MySQL 8.0 Reference Manual: InnoDB and the ACID Model.</em> <a href="https://dev.mysql.com/doc/refman/8.0/en/mysql-acid.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
<li id="ref-4">Oracle (2026). <em>MySQL Replication: Setting the Replication Source Configuration.</em> <a href="https://dev.mysql.com/doc/mysql-replication-excerpt/8.0/en/replication-howto-masterbaseconfig.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
<li id="ref-5">Oracle (2026). <em>MySQL 8.0 Reference Manual: InnoDB Startup Configuration — Important Storage Considerations.</em> <a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-init-startup-configuration.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
<li id="ref-6">OpenZFS Project (2025). <em>zfsprops(7): sync and logbias properties.</em> <a href="https://openzfs.github.io/openzfs-docs/man/master/7/zfsprops.7.html" target="_blank" rel="noopener">OpenZFS Documentation</a></li>
<li id="ref-7">OpenZFS Project (2025). <em>zfsprops(7): recordsize, atime and compression properties.</em> <a href="https://openzfs.github.io/openzfs-docs/man/master/7/zfsprops.7.html" target="_blank" rel="noopener">OpenZFS Documentation</a></li>
<li id="ref-8">Oracle (2026). <em>MySQL 8.0 Reference Manual: Rebuilding or Repairing Tables or Indexes.</em> <a href="https://dev.mysql.com/doc/refman/8.0/en/rebuilding-tables.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
<li id="ref-9">Oracle (2026). <em>MySQL 8.0 Reference Manual: The metadata_locks Table.</em> <a href="https://dev.mysql.com/doc/refman/8.0/en/performance-schema-metadata-locks-table.html" target="_blank" rel="noopener">MySQL Documentation</a></li>
</ol>