Ở tầng database của mô hình nhiều website trên Cloud VPS, mỗi máy thường chạy một MySQL 8.0 chứa hàng chục schema của hàng chục website khác nhau. Máy chỉ có vài GB RAM, không có replica, và phải phục vụ cùng lúc site thương mại điện tử đông khách lẫn site giới thiệu gần như không ai vào. Tài liệu này hướng dẫn cấu hình MySQL đa tenant trên VPS nhỏ: chia RAM cho buffer pool, đặt giới hạn kết nối, đánh đổi khi tắt binlog, phân quyền theo database, câu lệnh kiểm tra nhanh khi máy chậm và checklist vận hành.

1. MySQL đa tenant là gì và rủi ro nằm ở đâu

"Đa tenant" ở đây đơn giản là nhiều website dùng chung một tiến trình mysqld, mỗi site một schema và một user riêng. Cách này tiết kiệm: một máy database phục vụ được hàng chục site, backup và giám sát tập trung một chỗ. Rủi ro cũng nằm ngay ở chữ "chung":

  • RAM chung: buffer pool là bộ nhớ đệm dùng chung. Site có bảng lớn, query quét nhiều sẽ đẩy dữ liệu của site khác ra khỏi RAM.
  • Kết nối chung: max_connections tính cho cả máy. Một site để query treo có thể chiếm hết slot của mọi site.
  • Đĩa chung: một schema phình to làm đầy đĩa thì mọi site cùng ngừng ghi.

2. MySQL dùng RAM vào đâu

Bộ nhớ của mysqld gồm hai phần:

  • Phần toàn cục, cấp khi khởi động: InnoDB buffer pool, log buffer, table cache, Performance Schema. Performance Schema tự định cỡ theo max_connections và table_open_cache, nên đặt hai giá trị này quá cao cũng làm tăng RAM nền.
  • Phần theo kết nối: sort_buffer_size, join_buffer_size, bảng tạm trong bộ nhớ. Mỗi giá trị nhỏ, nhưng nhân với vài trăm kết nối và vài query nặng cùng lúc thì đáng kể.

Lưu ý: sau một đợt tải cao, RSS của mysqld trong top vẫn cao dù tải đã giảm, vì bộ cấp phát của glibc giữ lại bộ nhớ để dùng tiếp. RSS cao chưa chắc là rò rỉ; nó phản ánh mức đỉnh máy từng chịu.

3. Cấu hình MySQL theo RAM của máy

Cấu hình tối thiểu thường gặp trên máy database đa tenant chỉ khác mặc định vài dòng:

# /etc/mysql/mysql.conf.d/mysqld.cnf (trích)
[mysqld]
datadir                 = /var/lib/mysql
log-error               = /var/log/mysql/error.log
bind-address            = 10.0.0.31        # chỉ lắng nghe IP private
max_connections         = 300
innodb_buffer_pool_size = 2G               # ví dụ cho máy 4 GB RAM
skip-log-bin

Nếu không khai innodb_buffer_pool_size, MySQL dùng mặc định 128 MB, quá nhỏ cho hàng chục schema. Đây là tham số cần chỉnh đầu tiên.

innodb_buffer_pool_size

Buffer pool là nơi InnoDB giữ trang dữ liệu và index trong RAM. Dữ liệu "nóng" nằm gọn trong buffer pool thì query đọc từ RAM, không đủ chỗ thì phải đọc đĩa. Tài liệu InnoDB buffer pool của MySQL gợi ý tới 80% RAM cho máy chỉ chạy database. Trên VPS nhỏ nên để dư nhiều hơn, vì hệ điều hành, bộ nhớ theo kết nối và công cụ dump cũng cần chỗ:

RAM máyBuffer pool gợi ýGhi chú
1–2 GB256–768 MBGiảm max_connections đi kèm, nên có swap nhỏ
4 GBkhoảng 2 GBTheo dõi tỷ lệ đọc đĩa sau khi đổi
8 GB4–5 GBPhù hợp máy gom nhiều schema lớn

Bảng trên là điểm xuất phát, không phải công thức. Cách đánh giá buffer pool đủ hay chưa là so Innodb_buffer_pool_reads (số lần phải đọc từ đĩa) với Innodb_buffer_pool_read_requests (tổng số lần đọc logic). Tỷ lệ này càng nhỏ càng tốt; khi nó vượt khoảng 1%, máy đang đọc đĩa nhiều và tăng buffer pool thường hiệu quả rõ rệt nhất.

innodb_dedicated_server tự định cỡ theo RAM nhưng giả định cả máy chỉ dành cho database, nên trên VPS nhỏ hãy đặt tay.

max_connections

Mặc định là 151. Con số cần đặt phụ thuộc vào phía ứng dụng: mỗi worker PHP-FPM đang xử lý request có thể giữ một kết nối. Ví dụ ba minion trỏ về cùng một máy database, mỗi minion pm.max_children = 50, thì đỉnh lý thuyết đã là 150 kết nối, chưa kể queue worker và cron.

Nhưng nâng max_connections không làm máy chịu được nhiều hơn, nó chỉ cho phép nhiều kết nối chờ hơn. Trên máy 2 GB, vài trăm kết nối cùng chạy query nặng sẽ làm cạn RAM trước khi chạm giới hạn. Giới hạn kết nối nên bằng tổng số worker thực sự có thể kết nối cộng một khoảng dư, không phải một con số thật lớn cho yên tâm.

skip-log-bin: tiết kiệm đổi lấy gì

Từ MySQL 8.0, binary log được bật mặc định và giữ 30 ngày. Trên VPS đĩa nhỏ, binlog của hàng chục site ghi liên tục có thể chiếm đáng kể dung lượng và I/O. Tắt binlog bằng skip-log-bin đem lại:

  • Ít I/O ghi hơn, đĩa không bị binlog lấp đầy.
  • Đổi lại: không có replica, vì replication dựa trên binlog.
  • Đổi lại: không khôi phục được về một thời điểm bất kỳ. Chỉ có thể quay về bản dump gần nhất, tức có thể mất gần trọn khoảng thời gian giữa hai lần backup.

Chỉ chấp nhận đánh đổi này một cách có ý thức. Site cần an toàn dữ liệu cao hơn nên đặt trên máy riêng có bật binlog.

Slow query log

Slow log mặc định tắt. Có thể bật khi điều tra, ngay lúc máy đang chạy, không cần restart:

SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log = ON;

-- sau một thời gian, tổng hợp query chậm theo tổng thời gian
-- (chạy trên shell): mysqldumpslow -s t -t 20 /var/log/mysql/slow.log

long_query_time chỉ áp dụng cho kết nối mở sau khi đổi. Cấu hình đặt bằng SET GLOBAL cũng mất khi MySQL khởi động lại: vừa là ưu điểm (không quên tắt) vừa là nhược điểm (mất dữ liệu điều tra). Các tuỳ chọn khác có trong tài liệu slow query log.

4. User, quyền và mạng

Mỗi site một schema, một user, quyền chỉ trên schema đó, và chỉ được đăng nhập từ dải IP private của minion:

CREATE DATABASE site_a CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'site_a'@'10.0.0.%' IDENTIFIED BY '<mật khẩu sinh ngẫu nhiên>';
GRANT ALL PRIVILEGES ON site_a.* TO 'site_a'@'10.0.0.%';
  • Không ứng dụng nào dùng user root. Một site bị lộ .env chỉ lộ đúng dữ liệu của site đó.
  • Phần host '10.0.0.%' giới hạn nơi đăng nhập ở mạng private, kể cả khi mật khẩu bị lộ.
  • Máy database chỉ có IP private, và bind-address bằng IP private để mysqld chỉ lắng nghe đúng giao diện đó. Không bao giờ mở cổng 3306 ra Internet.

Người vận hành không nên SSH vào máy database để chạy lệnh. Như trong bài tổng quan mô hình Cloud VPS, mọi truy vấn đi qua máy vận hành với mysql --login-path=<tên-máy>. Tên máy là tên trong DNS nội bộ, nên đọc lệnh là biết đang thao tác trên máy nào.

5. Kiểm tra nhanh một máy MySQL đang chậm

-- ai đang chạy gì, chạy bao lâu
SHOW FULL PROCESSLIST;

-- kết nối: đang mở, đang chạy, đỉnh từ lúc khởi động
SHOW GLOBAL STATUS WHERE Variable_name IN
  ('Threads_connected','Threads_running','Max_used_connections',
   'Connection_errors_max_connections','Aborted_clients','Aborted_connects');

-- buffer pool đủ hay thiếu
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

-- schema nào đang chiếm đĩa
SELECT table_schema,
       ROUND(SUM(data_length + index_length)/1024/1024/1024, 2) AS gb
FROM information_schema.tables
GROUP BY table_schema ORDER BY gb DESC LIMIT 10;

Đọc kết quả theo thứ tự: Threads_running cao trong khi Threads_connected bình thường nghĩa là có query nặng đang chạy, xem PROCESSLIST để tìm câu lệnh có cột Time lớn. Nhiều kết nối Sleep rất lâu là kết nối ứng dụng mở ra mà không đóng. Max_used_connections sát max_connections là cảnh báo sớm trước khi lỗi xảy ra thật.

6. Lỗi thường gặp với MySQL đa tenant

ERROR 1040: Too many connections

Phản xạ đầu tiên thường là tăng max_connections. Hầu hết trường hợp, nguyên nhân thật là một query chậm: mỗi request chờ query lâu hơn, giữ kết nối lâu hơn, kết nối dồn lại cho tới khi chạm trần. Tìm query đó trong PROCESSLIST và slow log, thêm index hoặc sửa code, thì số kết nối tự giảm. Biến Connection_errors_max_connections cho biết lỗi này đã thực sự xảy ra hay chưa kể từ lần khởi động gần nhất.

Đầy đĩa vì database test và bản clone

Một kiểu đầy đĩa rất "đa tenant": để test tính năng hay dựng bản demo, ai đó sao nguyên database production thành schema mới, xong việc thì quên xoá. Khi đĩa đầy, InnoDB không ghi được và query ghi của mọi site trên máy cùng treo. Phòng ngừa: đặt tên schema test theo quy ước dễ nhận ra, chạy định kỳ truy vấn dung lượng theo schema ở trên, và đặt ngưỡng cảnh báo đĩa thấp hơn hẳn mức 100%.

Aborted_clients tăng dần

Aborted_clients đếm kết nối bị ngắt mà client không đóng đúng cách, thường do worker PHP bị kill giữa chừng hoặc kết nối ngủ quá wait_timeout. Tăng chậm là bình thường; tăng vọt theo đợt thì đối chiếu log PHP-FPM. Còn Aborted_connects là kết nối không đăng nhập được, thường do sai mật khẩu hoặc host chưa được cấp quyền.

7. Checklist và đánh đổi của MySQL đa tenant

  • Đặt innodb_buffer_pool_size theo RAM, chừa chỗ cho hệ điều hành và kết nối.
  • max_connections bằng tổng worker thật sự kết nối cộng khoảng dư.
  • Mỗi site một schema, một user, host giới hạn ở dải private; bind-address là IP private.
  • Quyết định rõ có bật binlog hay không, và ghi lại mức mất dữ liệu chấp nhận được.
  • Biết cách bật slow log nhanh; có cảnh báo dung lượng đĩa và Max_used_connections.
  • Dọn schema test, bản clone định kỳ.
Lựa chọnĐượcMất
Nhiều schema trên một máyÍt máy, rẻ, backup tập trungMột site xấu ảnh hưởng mọi site cùng máy
Buffer pool nhỏ trên máy RAM thấpChạy được trên VPS vài GB RAMDữ liệu lớn hơn buffer pool thì đọc đĩa nhiều
skip-log-binÍt I/O, đĩa không đầy vì binlogKhông replica, không khôi phục theo thời điểm
Slow log chỉ bật khi cầnKhông tốn đĩa, không tốn I/OKhi sự cố xảy ra, không có sẵn dữ liệu của lúc đó

MySQL đa tenant chạy tốt trên VPS nhỏ khi biết rõ mình tiết kiệm ở đâu và trả giá bằng gì.

Bài tiếp theo

PHP 7.4 hết hỗ trợ: sống chung nhiều phiên bản và lộ trình nâng cấp. EOL nghĩa là gì, vì sao vẫn phải chạy PHP 7.2 và 7.4 cho site cũ, và cách nâng cấp từng site mà không làm gián đoạn site khác.