Logo Wecommit
Về chúng tôiCộng đồngLời chứng thựcTài nguyên
Logo Wecommit

Kiến trúc MySQL: hiểu InnoDB để biết vì sao SQL chậm

Kiến trúc MySQL gồm bộ nhớ và file trên đĩa. Hiểu InnoDB, table space, index nhiều cột và partition là biết ngay câu lệnh của mình chậm ở chỗ nào.

Kiến trúc MySQL chỉ có hai khối: một khối nằm trên bộ nhớ, một khối nằm trên ổ cứng. Anh em nắm được hai khối đó thì mọi chuyện nhanh chậm về sau đều giải thích được, không phải đoán nữa.

Tôi có một video dài 1 giờ 42 phút đi hết từ cài đặt tới sao lưu. Bài này không chép lại video. Bài này lấy đúng cái xương sống mà tôi nhấn trong video, rồi ghép thêm những chỗ anh em lập trình hay hiểu sai khiến hệ thống chậm mà không biết vì sao.

Video gốc: Hiểu toàn bộ MySQL Database trong 1 giờ 42 phút. Bài viết này gom phần kiến trúc, phần index và phần thông số vào một mạch, kèm ảnh chụp thật từ buổi demo.

Bài này đi qua những gì?

  1. Bộ nhớ của MySQL gồm những phần nào, và vì sao phải phân biệt redo với undo.
  2. Trên ổ cứng nó để dữ liệu ở đâu, table space là cái gì.
  3. Ba chỗ dev hay hiểu sai khiến câu lệnh chậm.
  4. Vào một hệ thống lạ thì nhìn thông số nào trước.
  5. Giới hạn thật của mấy công thức trong bài.

Vì sao phải hiểu kiến trúc trước khi tối ưu?

Bản chất của tối ưu là giảm khối lượng công việc mà database phải làm. Mà muốn biết nó đang làm việc gì, anh em phải biết dữ liệu đang nằm ở đâu và đi đường nào.

Tôi ví dụ thế này. Anh em gõ một câu select. Dữ liệu không bao giờ được đẩy thẳng từ ổ cứng ra cho người dùng. Nó phải được nạp lên bộ nhớ trước, xào nấu ở trên đó, rồi mới trả ra. Một yêu cầu chạy được ngay trên bộ nhớ nhanh hơn hàng nghìn lần so với một yêu cầu phải mò xuống file vật lý.

Hiểu chỗ này rồi thì anh em sẽ thấy mọi kỹ thuật tối ưu sau đó đều xoay quanh đúng một câu hỏi: làm sao để nó bớt phải xuống đĩa.

Bộ nhớ của MySQL gồm hai phần nào?

Với anh em lập trình, chỉ cần nhớ hai phần thôi.

Phần thứ nhất là buffer pool. Nó giữ những dữ liệu hay dùng, cả dữ liệu bảng lẫn dữ liệu index. Lần sau có ông nào cần đúng vùng đó thì nó lấy luôn trên bộ nhớ, không phải xuống đĩa nữa.

Phần thứ hai là vùng chuyên xử lý câu lệnh DML. Update, insert, delete thì ghi vào đây. Trong tài liệu của hãng nó tên là log buffer, và thứ nó sinh ra là redo log.

Sơ đồ vẽ tay kiến trúc MySQL với buffer pool, redo log, table space và các file vật lý trên đĩa
Toàn bộ kiến trúc gói trong một hình: khối bộ nhớ ở trên, khối file vật lý ở dưới, table space đứng giữa làm lớp quy hoạch.

Redo và undo khác nhau ở chỗ nào?

Đây là chỗ nhầm kinh điển. Hai ông này cùng phục vụ câu lệnh DML, nhưng trả lời hai câu hỏi khác hẳn nhau.

RedoUndo
Trả lời câu hỏiNó đang là 10, làm sao nó thành 11?Nó đang là 11, giá trị cũ là bao nhiêu?
Dùng đểKhôi phục lại thao tác đã làmRollback về giá trị trước đó
Ghi xuống file#ib_redo_Nundo_001, undo_002

Tôi ví dụ cho dễ hình dung. Một giá trị đang là 10, anh em update nó thành 11. Ông redo ghi lại chuyện "có câu update chạy qua đây". Ông undo giữ lại con số 10. Chẳng may anh em gõ rollback, hệ thống phải hỏi ông undo mới biết đường quay về.

Nhớ được cặp này là anh em đọc tài liệu hãng đỡ loạn hẳn.

Trên ổ cứng thì MySQL để dữ liệu ở đâu?

Bộ nhớ tắt điện một phát là trắng. Nên bao giờ nó cũng phải định kỳ ghi xuống file. Phần file vật lý đó được quy hoạch bằng một khái niệm tên là table space.

Anh em hình dung table space giống như phòng trong một tòa nhà. Tòa nhà là con database. Người ta chia phòng để những file cùng tính chất nằm chung một chỗ cho dễ quản. Nhưng table space chỉ là khái niệm logic. Nhìn vào hệ điều hành anh em không thấy table space đâu cả, anh em chỉ thấy file.

Sơ đồ kiến trúc InnoDB MySQL 8 với in-memory structures và on-disk structures theo tài liệu hãng
Bản đầy đủ theo tài liệu MySQL 8: bên trái là buffer pool, adaptive hash index, change buffer, log buffer. Bên phải là system tablespace ibdata1, undo tablespaces, temporary tablespaces, doublewrite buffer files và redo log.

Ba table space hệ thống nào không được đụng vào?

Cài xong một con MySQL trắng trơn, chưa đổ dữ liệu gì, nó đã tự tạo sẵn mấy phòng bắt buộc.

Table spaceFile anh em nhìn thấyNó giữ cái gì
Systemibdata1Data dictionary và thông tin hoạt động hệ thống
Undoundo_001, undo_002Dữ liệu phục vụ rollback
Temporaryibtmp1, temp_1.ibtBảng tạm, tạo lại mỗi lần restart

Ba ông này xóa là chết. Anh em nghịch ngợm vào thư mục data rồi dọn dẹp cho gọn là teo ngay, không cứu được.

Ngoài ba ông bắt buộc, anh em hoàn toàn tạo được table space riêng. Đây là việc tôi làm thường xuyên khi tối ưu cho hệ thống lớn. Dữ liệu lịch sử giao dịch một chỗ, dữ liệu online một chỗ, quy hoạch rõ ràng từ đầu thì về sau đỡ khổ.

-- Tao table space rieng roi ep bang nam vao do
CREATE TABLESPACE data_tbs1
  ADD DATAFILE 'datafile1.ibd';

CREATE TABLE lich_su_giao_dich (
  id        INT,
  ngay      DATE
) TABLESPACE data_tbs1;

-- Kiem tra bang dang nam o table space nao
SELECT NAME, SPACE_TYPE
FROM   INFORMATION_SCHEMA.INNODB_TABLESPACES;

Tham số innodb_file_per_table bật lên thì chuyện gì xảy ra?

Mặc định innodb_file_per_table bằng ON. Nghĩa là cứ tạo một bảng thì nó đẻ ra một file .ibd riêng cho bảng đó. Tạo bảng test thì trong thư mục database hiện thêm một file, drop bảng đi thì file biến mất theo.

Tiện, nhưng có giá của nó. Hệ thống nhỏ thì để mặc định thoải mái. Hệ thống lớn mà số lượng bảng lên tới hàng nghìn thì riêng phần metadata đã nặng, database phải quản lý một đống file rời rạc. Tới lúc đó tôi khuyến cáo anh em phải tự quy hoạch table space theo chiến lược của mình, đừng để mặc định.

Còn doublewrite buffer thì nó ghi hai lần: ghi tạm vào file .dblwr trước, rồi mới ghi vào file dữ liệu thật. Nghe thì tốn, nhưng dữ liệu được ghi thành một khối lớn liên tục và chỉ gọi fsync() một lần, nên chi phí không hề gấp đôi. Nó tồn tại để lúc server chết giữa chừng còn có bản lành mà khôi phục.

Ba chỗ dev hay hiểu sai khiến câu lệnh chậm

Phần này là phần tôi thấy nhiều anh em vấp nhất. Cả ba lỗi đều không báo lỗi gì cả, câu lệnh vẫn chạy ra kết quả đúng, chỉ có điều nó chậm.

Sai thứ nhất: đánh index rồi mà vẫn quét full bảng?

Bắt đầu từ một bảng customers có hơn 112 nghìn bản ghi, chưa có index nào ngoài primary key. Câu lệnh lọc theo hai điều kiện nam_sinhngay_giao_dich.

Kết quả EXPLAIN trên MySQL cho thấy type ALL và rows 112200 khi bảng customers chưa có index
Chưa có index thì type bằng ALLrows bằng 112200. Nó phải quét sạch bảng rồi mới lọc ra đúng hai bản ghi.

Nhìn thấy type bằng ALL là phải dừng lại xem kỹ. Vài chục bản ghi thì không sao. Hàng trăm nghìn bản ghi mà ALL thì rất tốn.

Đánh index trên riêng cột nam_sinh, số bản ghi phải quét tụt từ 112200 xuống 2147, nhưng cột Extra vẫn ghi Using where, tức là nó vẫn phải lọc tiếp. Đánh index trên cả hai cột thì rows xuống còn 2 và Extra bằng NULL, không phải lọc thêm gì nữa.

Đến đây thì đẹp. Nhưng bây giờ bỏ điều kiện nam_sinh đi, chỉ tìm theo ngay_giao_dich thôi, chuyện gì xảy ra?

So sánh EXPLAIN MySQL khi đổi thứ tự cột trong composite index, rows giảm từ 112200 xuống 39
Index idx_namsinh_ngaygd không dùng được nữa, key bằng NULL, quay lại quét 112200 bản ghi. Tạo lại index đảo thứ tự thành idx_ngaygiaodich_namsinh thì nó dùng được ngay, chỉ còn quét 39 bản ghi.

Cùng hai cột đó, cùng dữ liệu đó, chỉ đổi thứ tự là một đằng quét 112200 một đằng quét 39. Vì sao?

Bản chất là index nhiều cột được xếp như một mảng đã sắp xếp, ghép giá trị các cột lại theo đúng thứ tự anh em khai báo. Tài liệu MySQL gọi đó là leftmost prefix: có index trên (col1, col2, col3) thì anh em tra được theo (col1), theo (col1, col2), theo cả ba. Nhưng tra riêng (col2) thì không, vì nó không phải tiền tố bên trái.

Cột đầu tiên quyết định gần như toàn bộ chuyện index đó có dùng được hay không. Nên đừng có thấy câu lệnh nào cũng đánh index lên hết mọi cột. Đánh index phải có chiến lược. Đánh index phải có chiến lược.

Chuyện thứ tự cột này không riêng gì MySQL. Tôi cũng có phân tích đúng cơ chế đó trên PostgreSQL trong bài tầm quan trọng của thứ tự các cột trong index. Và nếu anh em đang gặp cảnh có index rồi mà vẫn chậm, bài vì sao có index mà SQL vẫn chậm liệt kê thêm mấy kiểu vấp khác trên Oracle và SQL Server.

Sai thứ hai: partition rồi mà vẫn chậm?

Partition là kỹ thuật mà ai làm dữ liệu lớn cũng phải biết. Khuyến cáo của tôi: từ 2 GB trở lên, hoặc từ 10 triệu bản ghi trở lên, là phải cân nhắc partition.

Ý tưởng đơn giản như chia tầng cho tòa nhà. Không partition thì cả tòa nhà trải phẳng ra một mặt sàn. Partition theo năm giao dịch thì ông nào giao dịch năm 2018 ở tầng 18, ông nào năm 2019 ở tầng 19.

EXPLAIN trên bảng customers_partition của MySQL cho thấy partitions p_2019 và rows giảm còn 15797
Cùng câu lệnh đó, chạy trên bảng đã partition theo year(Ngay_giao_dich). Cột partitions chỉ đúng p_2019, số bản ghi phải quét còn 15797. Chưa đánh index gì cả.

Đây là chỗ nhiều anh em ngộ nhận. Partition không phải thần thánh. Vẫn câu lệnh đó, đổi điều kiện sang tìm theo nam_sinh thay vì ngay_giao_dich, thì hệ thống chịu. Nó không biết ông sinh năm 1961 giao dịch vào năm nào, nên nó phải quét qua toàn bộ partition từ 2018 tới 2024, mà partition nào cũng ALL. Lúc này partition không giúp gì, đôi khi còn tệ hơn.

Luật rút ra chỉ một dòng: cột đem đi partition bắt buộc phải xuất hiện trong điều kiện WHERE. Tài liệu MySQL gọi cơ chế cắt bỏ partition thừa là partition pruning, và nó chỉ chạy được khi điều kiện quy được về dạng cột_partition = hằng số hoặc cột_partition IN (...).

Trong dự án thật, tôi thường ghép cả hai. Partition để cắt bớt vùng phải nhìn, index để nhảy thẳng vào bản ghi. Bảng có partition vẫn đánh index bình thường, không xung khắc gì cả.

Sai thứ ba: nhìn số bản ghi hay nhìn cost?

Nhìn cả hai, nhưng phải hiểu chúng khác nhau. EXPLAIN chỉ ước lượng, nó không hề chạy câu lệnh của anh em. EXPLAIN ANALYZE thì chạy thật và trả về số liệu thật.

Kết quả EXPLAIN ANALYZE của MySQL so sánh cost 11324 với actual time 33.7 giây khi table scan
Câu dùng index: cost=13.7, rows=39, thời gian thực tế 0.209. Câu quét cả bảng: cost=11324, rows=112200, thời gian thực tế chạy tới 33.7.

Cost là chi phí ước tính của một chiến lược thực thi. Khái niệm này không riêng MySQL, Oracle, SQL Server hay PostgreSQL đều có. Một câu lệnh luôn có nhiều đường để chạy, hệ thống tính chi phí từng đường rồi chọn đường rẻ nhất. Riêng bản 8 thì cái ông chọn đường này được nâng cấp khá nhiều, tôi có phân tích riêng trong bài hiệu năng MySQL 8 và những nâng cấp trong query optimizer.

Con số rows là ước lượng, sai được. Trong ảnh trên, nó đoán 112200 trong khi bảng thật có 112225 bản ghi. Sai lệch đó bình thường, nhưng ở hệ thống mà thống kê cũ mèm thì sai lệch to hơn nhiều, và đó là lúc optimizer chọn nhầm đường. Cách đọc chi tiết hai lệnh này tôi có chia sẻ riêng trong bài tối ưu MySQL với EXPLAIN và EXPLAIN ANALYZE.

Một kỹ thuật nữa từ MySQL 8 mà tôi hay dùng: invisible index. Anh em đang phân vân có nên drop một index nào đó không, thay vì drop luôn thì cho nó invisible trước.

-- Giau index khoi optimizer, index van con nguyen tren he thong
ALTER TABLE customers ALTER INDEX idx_namsinh_ngaygd INVISIBLE;

-- Xem index nao dang bi giau
SHOW INDEX FROM customers;

-- Chay lai cau lenh, xem ke hoach co doi khong
EXPLAIN SELECT * FROM customers
WHERE  ngay_giao_dich = '2019-01-29';

-- On thi tra lai
ALTER TABLE customers ALTER INDEX idx_namsinh_ngaygd VISIBLE;

Index invisible vẫn nằm nguyên trên hệ thống, insert update vẫn phải cập nhật nó, chỉ có optimizer là không nhìn thấy. Theo tài liệu hãng, bật tắt visible là thao tác tại chỗ và nhanh, còn drop rồi tạo lại index trên bảng lớn thì rất đắt. Theo dõi một thời gian dài thấy hiệu năng không đổi thì hẵng drop.

Vào một hệ thống lạ thì nhìn thông số nào trước?

Nhìn bốn tỉ lệ. Bốn tỉ lệ này đều cùng một logic: tổng số yêu cầu là bao nhiêu, trong đó bao nhiêu phần được phục vụ ngay trên bộ nhớ. Tất cả đều lấy bằng SHOW GLOBAL STATUS.

Sơ đồ công thức tính Buffer Cache Hit của MySQL ra kết quả 98 phần trăm
Công thức buffer cache hit và kết quả đo thật trên con database demo: (85049 - 1147) * 100 / 85049 ra 98 phần trăm.
Tỉ lệCông thứcNgưỡng cần xửHướng chữa
Buffer cache hit(Innodb_buffer_pool_read_requests - Innodb_buffer_pool_reads) * 100 / Innodb_buffer_pool_read_requestsDưới 90 phần trămTăng innodb_buffer_pool_size
Table cache hitOpen_tables / Opened_tables * 100Dưới 80 phần trămTăng table_open_cache
Table definition cache hitOpen_table_definitions / Opened_table_definitions * 100Dưới 80 phần trămTăng table_definition_cache
Temporary table trên bộ nhớ(Created_tmp_tables - Created_tmp_disk_tables) * 100 / Created_tmp_tablesDưới 80 phần trămTối ưu câu lệnh, không phải tăng tham số

Ba dòng trên chữa bằng tham số. Riêng dòng cuối thì khác, và đây là chỗ hay bị làm sai. Bảng tạm phải xuống đĩa là do câu lệnh của anh em bắt nó sắp xếp và gom nhóm quá nhiều. Cứ đè ra tăng tham số là chữa vào ngọn. Đánh index cho đúng, partition cho đúng, lượng dữ liệu phải tính toán nhỏ đi thì tỉ lệ này tự lên.

Đây là bộ lệnh anh em chép về chạy luôn.

-- [1] Buffer cache hit
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';

-- [2] Table cache hit
SHOW GLOBAL STATUS LIKE 'Open_tables';
SHOW GLOBAL STATUS LIKE 'Opened_tables';

-- [3] Table definition cache hit
SHOW GLOBAL STATUS LIKE 'Open_table_definitions';
SHOW GLOBAL STATUS LIKE 'Opened_table_definitions';

-- [4] Temporary table xuong dia
SHOW GLOBAL STATUS LIKE 'Created_tmp_tables';
SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables';

-- Xem tham so dang cau hinh bao nhieu
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW VARIABLES LIKE 'table_open_cache';
SHOW VARIABLES LIKE 'table_definition_cache';
SHOW VARIABLES LIKE 'innodb_file_per_table';

Giới hạn thật

Phần này tôi phải nói thẳng, vì mấy công thức trên rất dễ bị dùng sai chỗ.

Bốn công thức đó dành cho hệ thống vừa và nhỏ. Đó là bộ thông số dễ tiếp cận nhất, ốp vào là biết ngay có cần tinh chỉnh hay không. Hệ thống core ngân hàng hay core chứng khoán thì tôi phải nhìn nhiều thứ hơn hẳn, bốn con số này chưa đủ để kết luận.

Ngưỡng không phải hằng số. Tôi để 90 phần trăm cho buffer cache hit vì đó là mức chung. Dự án nào ngặt nghèo thì xuống dưới 95 đã phải để ý rồi. Anh em đừng chép ngưỡng của tôi rồi coi đó là chuẩn cho mọi hệ thống.

Tỉ lệ đẹp không có nghĩa là hệ thống khỏe. Buffer cache hit 99 phần trăm mà một câu lệnh vẫn quét hết bảng trên bộ nhớ thì nó vẫn tốn CPU, vẫn chậm. Tỉ lệ cache chỉ nói chuyện đọc từ bộ nhớ hay từ đĩa, nó không nói chuyện câu lệnh viết hay hay dở.

Số liệu trong bài là số của một con database demo trên máy tôi. Bảng customers hơn 112 nghìn bản ghi, dữ liệu tự sinh. Anh em chạy trên hệ thống thật thì con số sẽ khác, cách đọc mới là thứ mang đi được.

SHOW GLOBAL STATUS là số cộng dồn từ lúc khởi động. Server vừa restart xong thì mẫu quá bé, tỉ lệ nhìn đẹp hay xấu đều không có nghĩa. Muốn có số dùng được thì để nó chạy đủ một chu kỳ tải bình thường đã.

Tăng tham số không miễn phí. innodb_buffer_pool_size tăng lên là ăn RAM của máy. Máy hết RAM thì hệ điều hành swap, mà swap còn chậm hơn cả việc đọc đĩa mà anh em đang tránh.

Bảy ngày tới anh em làm được gì?

  1. Mở đúng thư mục data của con MySQL mình đang chạy, tìm cho ra ibdata1, undo_001 và thư mục redo. Đối chiếu với sơ đồ kiến trúc ở trên.
  2. Chạy bốn cặp lệnh SHOW GLOBAL STATUS ở khối trên, tính ra bốn tỉ lệ, ghi lại làm mốc.
  3. Lấy ba câu lệnh chậm nhất trong ứng dụng, chạy EXPLAIN, khoanh hết chỗ nào type bằng ALL.
  4. Với mỗi chỗ ALL, kiểm tra lại thứ tự cột trong index xem có khớp điều kiện WHERE không.
  5. Bảng nào trên 10 triệu bản ghi thì thử partition trên môi trường test, nhớ kiểm tra cột partition có nằm trong WHERE không.
  6. Index nào nghi ngờ thừa thì cho INVISIBLE, theo dõi, đừng drop vội.
  7. Ghi lại bốn tỉ lệ một lần nữa sau khi sửa, so với mốc ban đầu.

Câu hỏi hay gặp

Kiến trúc MySQL gồm mấy phần? Hai phần. Phần bộ nhớ gồm buffer pool giữ dữ liệu hay dùng và log buffer giữ thay đổi của câu lệnh DML. Phần ổ cứng gồm các table space, doublewrite buffer files và redo log. Nắm hai phần này là đủ để làm việc.

Table space là file hay là khái niệm? Là khái niệm logic. Nhìn vào hệ điều hành anh em chỉ thấy file như ibdata1 hay t1.ibd. Table space là lớp quy hoạch gom những file cùng tính chất lại để dễ quản lý.

Vì sao đánh index rồi mà câu lệnh vẫn quét full bảng? Phần lớn là do thứ tự cột trong index nhiều cột. MySQL chỉ dùng được index theo tiền tố bên trái. Index trên (A, B) thì tra theo A được, tra theo riêng B thì không, nó quay lại quét cả bảng.

Khi nào nên dùng partition? Từ 2 GB dữ liệu hoặc từ 10 triệu bản ghi trở lên thì cân nhắc. Điều kiện bắt buộc là cột đem đi partition phải xuất hiện trong WHERE. Không thì partition vô nghĩa, đôi khi còn chậm hơn.

EXPLAIN và EXPLAIN ANALYZE khác nhau thế nào? EXPLAIN chỉ ước lượng, không chạy câu lệnh. EXPLAIN ANALYZE chạy thật và trả về thời gian thực tế. Muốn giỏi tối ưu thì đọc được cả hai, một cái cho kế hoạch, một cái cho kết quả.

Buffer cache hit bao nhiêu là ổn? Trên 90 phần trăm thì hệ thống vừa và nhỏ coi là ổn. Dưới 90 là phải đánh giá lại. Dự án ngặt nghèo thì dưới 95 đã phải để ý. Hướng chữa đầu tiên là tăng innodb_buffer_pool_size.

Đúc kết

Kiến trúc không phải thứ để học thuộc. Nó là cái bản đồ giúp anh em biết mình đang đứng ở đâu khi hệ thống chậm.

Ba thứ đáng nhớ nhất trong bài: dữ liệu luôn phải lên bộ nhớ trước khi ra tới người dùng, cột đầu tiên của index nhiều cột quyết định gần hết, và cột partition phải nằm trong WHERE. Ba thứ đó chữa được phần lớn ca chậm mà tôi gặp.

Anh em cứ mở con database của mình lên, chạy bốn cặp lệnh trong bài, xem bốn tỉ lệ ra bao nhiêu. Sai lại sửa. Có chết đâu mà ngại. Muốn đi sâu hơn về cách đọc kế hoạch thực thi thì xem tiếp bài tối ưu SQL nên bắt đầu từ đâu, còn nếu hệ thống của anh em bị cảnh cùng một câu lệnh lúc nhanh lúc chậm thì bài tối ưu SQL khi câu lệnh lúc nhanh lúc chậm nói đúng chuyện đó.

Nguồn tham khảo