Khi đối mặt với yêu cầu xuất dữ liệu lớn từ MySQL sang định dạng Excel, việc đảm bảo hiệu suất là cực kỳ quan trọng để tránh ảnh hưởng đến hoạt động của máy chủ và trải nghiệm người dùng.
Thách thức và Phân tích ban đầu
Nhiều giải pháp trên mạng đề xuất phương pháp truy vấn phân trang tuần tự. Tuy nhiên, với lượng dữ liệu khổng lồ, việc truy vấn toàn bộ bảng cùng lúc có thể tiêu tốn một lượng lớn bộ nhớ MySQL, gây chậm máy chủ. Việc chọn kích thước trang (page size) phù hợp là một bài toán cân bằng: trang quá lớn tốn bộ nhớ, trang quá nhỏ làm tăng số lượng truy vấn và giảm tốc độ xuất.
Trong quá trình thử nghiệm, một vấn đề nghiêm trọng đã phát sinh: timeout khi xuất dữ liệu. Với 2 triệu bản ghi mà không có bộ lọc, thời gian xuất dữ liệu vượt quá 5 phút, trong khi giới hạn thời gian chờ của giao diện người dùng chỉ là 1 phút.
Phân tích thời gian thực thi
Thông qua kiểm thử đơn vị (unit test), chúng tôi đo lường thời gian tiêu tốn ở các giai đoạn khác nhau:
- Truy vấn dữ liệu: 4 phút 40 giây
- Xử lý và truyền dữ liệu: 20 giây
Rõ ràng, phần lớn thời gian (khoảng 93%) là do truy vấn dữ liệu. Do đó, việc tối ưu hóa quá trình truy vấn là mấu chốt.
Vấn đề với truy vấn phân trang truyền thống
Truy vấn phân trang sử dụng cú pháp như SELECT ... OFFSET m LIMIT n thường không hiệu quả với các bảng lớn. Lệnh EXPLAIN cho thấy các truy vấn này thực hiện quét toàn bộ bảng (full table scan) mà không sử dụng chỉ mục (index). Độ phức tạp thời gian của việc này là O(n), nghĩa là thời gian truy vấn tăng tuyến tính theo số lượng bản ghi.
Ví dụ về sự khác biệt về thời gian:
- Truy vấn nông (offset nhỏ):
SELECT * FROM users OFFSET 2000 LIMIT 2000(khoảng 15ms) - Truy vấn sâu (offset lớn):
SELECT * FROM users OFFSET 200000 LIMIT 2000(khoảng 1.12 giây)
Các phương pháp tối ưu hóa truy vấn
Phương pháp 1: Phân trang dựa trên khoảng giá trị (Offset Pagination)
Nguyên lý: Thay vì sử dụng OFFSET m, ta dùng điều kiện WHERE id > m. Điều này cho phép MySQL truy cập trực tiếp đến vị trí dữ liệu mong muốn thông qua chỉ mục khóa chính (primary key index).
Ưu điểm:
- Truy vấn tận dụng B+ tree index, giảm độ phức tạp thời gian xuống O(log N).
- Giảm đáng kể số lần đọc/ghi (IO). Với 2 triệu bản ghi,
OFFSETyêu cầu khoảng 2 triệu lần IO, trong khi phương pháp này chỉ cần số lần IO tương đương độ sâu của cây B+ (ví dụ: 3 lần cho cây 3 tầng). - Kết quả
EXPLAINcho thấy loại truy vấn làrange.
Thời gian thực thi (ước tính): Khoảng 4 giây cho toàn bộ quá trình truy vấn (tăng hiệu suất khoảng 70 lần so với phân trang truyền thống).
Cấu trúc code ví dụ:
// Khai báo tham số phân trang
lastID := 0
pageSize := 2000
var records []Record
// Vòng lặp truy vấn phân trang
for {
result := db.Limit(pageSize).Where("id > ?", lastID).Find(&records)
if result.Error != nil {
log.Fatalf("Lỗi truy vấn: %v", result.Error)
}
// Nếu số lượng bản ghi trả về ít hơn kích thước trang, kết thúc
if len(records) < pageSize {
break
}
// Cập nhật ID cuối cùng để truy vấn cho lần tiếp theo
lastID = records[len(records)-1].ID
}
Tổng thời gian xuất dữ liệu: Khoảng 24 giây (4 giây truy vấn + 20 giây xử lý/truyền).
Giám sát tài nguyên: CPU khoảng 30%, bộ nhớ tiêu thụ khoảng 0.7MB.
Phương pháp 2: Xuất dữ liệu dạng Snapshot (Snapshot Export)
Nguyên lý: Tận dụng cơ chế hoạt động của driver database/sql trong Go, cho phép truy vấn toàn bộ bảng một cách hiệu quả. Mặc dù câu lệnh là SELECT * FROM users;, driver không tải toàn bộ dữ liệu về bộ nhớ cùng lúc. MySQL sẽ tạo một "snapshot" của dữ liệu tại thời điểm truy vấn. Sau đó, client (ứng dụng Go) sẽ gửi lệnh Next để lấy từng dòng dữ liệu và Scan để đọc vào đối tượng.
Ưu điểm:
- Chỉ cần một lệnh truy vấn duy nhất.
- Dữ liệu được đọc theo luồng từng dòng, không gây áp lực bộ nhớ lớn.
Thời gian thực thi (ước tính): Khoảng 1.5 giây cho toàn bộ quá trình truy vấn (tăng hiệu suất khoảng 200 lần).
Cấu trúc code ví dụ:
rows, err := db.Query("SELECT id, name, tag, phone, create_time FROM users")
if err != nil {
log.Fatalf("Lỗi truy vấn: %v", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
log.Printf("[Lỗi] Lấy tên cột: %v \n", err)
}
colCount := len(columns)
recordPointers := make([]interface{}, colCount)
// Khởi tạo biến để lưu trữ dữ liệu từng cột
var id int
var name, tag, phone string
var createTime time.Time
// Gán các con trỏ vào slice
recordPointers[0] = &id
recordPointers[1] = &name
recordPointers[2] = &tag
recordPointers[3] = &phone
recordPointers[4] = &createTime
for rows.Next() {
err := rows.Scan(recordPointers...)
if err != nil {
log.Fatalf("Lỗi Scan dữ liệu: %v", err)
}
// Xử lý dữ liệu bản ghi (ví dụ: thêm vào slice hoặc xử lý ngay)
// ...
}
Tổng thời gian xuất dữ liệu: Khoảng 21.5 giây (1.5 giây truy vấn + 20 giây xử lý/truyền).
Giám sát tài nguyên: CPU lên tới 95%, bộ nhớ tiêu thụ khoảng 4MB.
Nhược điểm: Mặc dù không tốn nhiều bộ nhớ, phương pháp này tiêu tốn nhiều CPU, có thể ảnh hưởng đến các dịch vụ khác. Do đó, nó không được chọn làm giải pháp cuối cùng.
Tối ưu hóa bổ sung: Xử lý bất đồng bộ và Lưu trữ luồng
Để giải quyết vấn đề về thời gian chờ của client và tối ưu hóa việc xử lý, chúng tôi đã triển khai giải pháp sử dụng hai goroutine:
- Goroutine 1 (Truy vấn): Chỉ chịu trách nhiệm truy vấn dữ liệu từ MySQL và đẩy dữ liệu vào một kênh (channel).
- Goroutine 2 (Xử lý & Lưu trữ): Lấy dữ liệu từ kênh, xử lý và tạo dữ liệu Excel nhị phân. Dữ liệu Excel này sau đó được truyền và lưu trữ vào dịch vụ lưu trữ đối tượng (object storage).
Việc sử dụng object storage mang lại các lợi ích:
- Tránh tình trạng client phải chờ đợi lâu.
- Cho phép xây dựng một giao diện quản lý danh sách các lần xuất, hiển thị trạng thái (hoàn thành, thất bại) và cho phép người dùng tải về bản ghi đã xuất trước đó, tránh việc phải xuất lại nhiều lần.
Kết quả cuối cùng
Với việc áp dụng các kỹ thuật tối ưu hóa, thời gian xuất 2 triệu bản ghi đã được rút ngắn xuống còn khoảng 20 giây (bao gồm 4 giây truy vấn và 16 giây xử lý/truyền). Tổng thể, hiệu suất xuất dữ liệu đã tăng gấp 15 lần so với ban đầu (từ 5 phút xuống 20 giây).