Tối ưu hóa SQLite là khó khăn. Hiệu suất chèn hàng loạt của ứng dụng C có thể thay đổi từ 85 lần chèn mỗi giây đến hơn 96.000 lần chèn mỗi giây!
Bối cảnh: Chúng tôi đang sử dụng SQLite như một phần của ứng dụng máy tính để bàn. Chúng tôi có một lượng lớn dữ liệu cấu hình được lưu trữ trong các tệp XML được phân tích cú pháp và được tải vào cơ sở dữ liệu SQLite để xử lý thêm khi ứng dụng được khởi tạo. SQLite là lý tưởng cho tình huống này vì nó nhanh, không yêu cầu cấu hình chuyên biệt và cơ sở dữ liệu được lưu trữ trên đĩa dưới dạng một tệp.
Đặt vấn đề: Ban đầu tôi thất vọng với màn trình diễn mà tôi đang xem. Hóa ra, hiệu suất của SQLite có thể thay đổi đáng kể (cả cho chèn và chọn hàng loạt) tùy thuộc vào cách cơ sở dữ liệu được định cấu hình và cách bạn sử dụng API. Việc tìm ra tất cả các tùy chọn và kỹ thuật là gì không phải là vấn đề nhỏ, vì vậy tôi nghĩ nên thận trọng khi tạo mục nhập wiki cộng đồng này để chia sẻ kết quả với độc giả Stack Overflow để cứu những người khác gặp rắc rối trong cùng một cuộc điều tra.
Thử nghiệm: Thay vì chỉ nói về các mẹo hiệu suất theo nghĩa chung (nghĩa là "Sử dụng giao dịch!" ), Tôi nghĩ tốt nhất nên viết một số mã C và thực sự đo lường tác động của các tùy chọn khác nhau. Chúng ta sẽ bắt đầu với một số dữ liệu đơn giản:
- Tệp văn bản được phân định bằng TAB 28 MB (khoảng 865.000 bản ghi) về lịch trình quá cảnh hoàn chỉnh cho thành phố Toronto
- Máy thử nghiệm của tôi là P4 3,60 GHz chạy Windows XP.
- Mã được biên dịch với Visual C ++ 2005 dưới dạng "Phát hành" với "Tối ưu hóa hoàn toàn" (/ Ox) và Mã nhanh ưu tiên (/ Ot).
- Tôi đang sử dụng "Hợp nhất" SQLite, được biên dịch trực tiếp vào ứng dụng thử nghiệm của tôi. Phiên bản SQLite mà tôi tình cờ có một chút cũ hơn (3.6.7), nhưng tôi nghi ngờ những kết quả này sẽ tương đương với phiên bản mới nhất (vui lòng để lại nhận xét nếu bạn nghĩ khác).
Hãy viết một số mã!
Mã: Một chương trình C đơn giản đọc từng dòng tệp văn bản, chia chuỗi thành các giá trị và sau đó chèn dữ liệu vào cơ sở dữ liệu SQLite. Trong phiên bản "cơ sở" của mã này, cơ sở dữ liệu được tạo, nhưng chúng tôi sẽ không thực sự chèn dữ liệu:
/*************************************************************
Baseline code to experiment with SQLite performance.
Input data is a 28 MB TAB-delimited text file of the
complete Toronto Transit System schedule/route info
from http://www.toronto.ca/open/datasets/ttc-routes/
**************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include "sqlite3.h"
#define INPUTDATA "C:\\TTC_schedule_scheduleitem_10-27-2009.txt"
#define DATABASE "c:\\TTC_schedule_scheduleitem_10-27-2009.sqlite"
#define TABLE "CREATE TABLE IF NOT EXISTS TTC (id INTEGER PRIMARY KEY, Route_ID TEXT, Branch_Code TEXT, Version INTEGER, Stop INTEGER, Vehicle_Index INTEGER, Day Integer, Time TEXT)"
#define BUFFER_SIZE 256
int main(int argc, char **argv) {
sqlite3 * db;
sqlite3_stmt * stmt;
char * sErrMsg = 0;
char * tail = 0;
int nRetCode;
int n = 0;
clock_t cStartClock;
FILE * pFile;
char sInputBuf [BUFFER_SIZE] = "\0";
char * sRT = 0; /* Route */
char * sBR = 0; /* Branch */
char * sVR = 0; /* Version */
char * sST = 0; /* Stop Number */
char * sVI = 0; /* Vehicle */
char * sDT = 0; /* Date */
char * sTM = 0; /* Time */
char sSQL [BUFFER_SIZE] = "\0";
/*********************************************/
/* Open the Database and create the Schema */
sqlite3_open(DATABASE, &db);
sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg);
/*********************************************/
/* Open input file and import into Database*/
cStartClock = clock();
pFile = fopen (INPUTDATA,"r");
while (!feof(pFile)) {
fgets (sInputBuf, BUFFER_SIZE, pFile);
sRT = strtok (sInputBuf, "\t"); /* Get Route */
sBR = strtok (NULL, "\t"); /* Get Branch */
sVR = strtok (NULL, "\t"); /* Get Version */
sST = strtok (NULL, "\t"); /* Get Stop Number */
sVI = strtok (NULL, "\t"); /* Get Vehicle */
sDT = strtok (NULL, "\t"); /* Get Date */
sTM = strtok (NULL, "\t"); /* Get Time */
/* ACTUAL INSERT WILL GO HERE */
n++;
}
fclose (pFile);
printf("Imported %d records in %4.2f seconds\n", n, (clock() - cStartClock) / (double)CLOCKS_PER_SEC);
sqlite3_close(db);
return 0;
}
"Điều khiển"
Chạy mã như không thực sự thực hiện bất kỳ hoạt động cơ sở dữ liệu nào, nhưng nó sẽ cho chúng ta ý tưởng về tốc độ I / O của tệp C thô và các hoạt động xử lý chuỗi.
Nhập 864913 hồ sơ trong 0,94 giây
Tuyệt quá! Chúng tôi có thể thực hiện 920.000 lần chèn mỗi giây, miễn là chúng tôi thực sự không thực hiện bất kỳ thao tác chèn nào :-)
"Kịch bản tồi tệ nhất"
Chúng tôi sẽ tạo chuỗi SQL bằng cách sử dụng các giá trị được đọc từ tệp và gọi hoạt động SQL đó bằng sqlite3_exec:
sprintf(sSQL, "INSERT INTO TTC VALUES (NULL, '%s', '%s', '%s', '%s', '%s', '%s', '%s')", sRT, sBR, sVR, sST, sVI, sDT, sTM);
sqlite3_exec(db, sSQL, NULL, NULL, &sErrMsg);
Điều này sẽ chậm vì SQL sẽ được biên dịch thành mã VDBE cho mỗi lần chèn và mỗi lần chèn sẽ xảy ra trong giao dịch của chính nó. Làm thế nào chậm?
Nhập 864913 hồ sơ trong 9933,61 giây
Rất tiếc! 2 giờ 45 phút! Đó chỉ là 85 lần chèn mỗi giây.
Sử dụng giao dịch
Theo mặc định, SQLite sẽ đánh giá mọi câu lệnh INSERT / UPDATE trong một giao dịch duy nhất. Nếu thực hiện một số lượng lớn các phần chèn, bạn nên bọc hoạt động của mình trong một giao dịch:
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg);
pFile = fopen (INPUTDATA,"r");
while (!feof(pFile)) {
...
}
fclose (pFile);
sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg);
Nhập 864913 hồ sơ trong 38,03 giây
Cái đó tốt hơn. Chỉ cần gói tất cả các chèn của chúng tôi trong một giao dịch đã cải thiện hiệu suất của chúng tôi lên 23.000 chèn mỗi giây.
Sử dụng một tuyên bố chuẩn bị
Sử dụng một giao dịch là một cải tiến lớn, nhưng biên dịch lại câu lệnh SQL cho mỗi lần chèn không có ý nghĩa gì nếu chúng ta sử dụng cùng một SQL. Chúng ta hãy sử dụng sqlite3_prepare_v2
để biên dịch câu lệnh SQL của chúng ta một lần và sau đó liên kết các tham số của chúng ta với câu lệnh đó bằng cách sử dụng sqlite3_bind_text
:
/* Open input file and import into the database */
cStartClock = clock();
sprintf(sSQL, "INSERT INTO TTC VALUES (NULL, @RT, @BR, @VR, @ST, @VI, @DT, @TM)");
sqlite3_prepare_v2(db, sSQL, BUFFER_SIZE, &stmt, &tail);
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg);
pFile = fopen (INPUTDATA,"r");
while (!feof(pFile)) {
fgets (sInputBuf, BUFFER_SIZE, pFile);
sRT = strtok (sInputBuf, "\t"); /* Get Route */
sBR = strtok (NULL, "\t"); /* Get Branch */
sVR = strtok (NULL, "\t"); /* Get Version */
sST = strtok (NULL, "\t"); /* Get Stop Number */
sVI = strtok (NULL, "\t"); /* Get Vehicle */
sDT = strtok (NULL, "\t"); /* Get Date */
sTM = strtok (NULL, "\t"); /* Get Time */
sqlite3_bind_text(stmt, 1, sRT, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, sBR, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, sVR, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, sST, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, sVI, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, sDT, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 7, sTM, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_clear_bindings(stmt);
sqlite3_reset(stmt);
n++;
}
fclose (pFile);
sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg);
printf("Imported %d records in %4.2f seconds\n", n, (clock() - cStartClock) / (double)CLOCKS_PER_SEC);
sqlite3_finalize(stmt);
sqlite3_close(db);
return 0;
Nhập 864913 hồ sơ trong 16,27 giây
Đẹp! Có thêm một chút mã (đừng quên gọi sqlite3_clear_bindings
và sqlite3_reset
), nhưng chúng tôi đã tăng gấp đôi hiệu suất của chúng tôi lên 53.000 lần chèn mỗi giây.
PRAGMA đồng bộ = TẮT
Theo mặc định, SQLite sẽ tạm dừng sau khi ban hành lệnh ghi mức hệ điều hành. Điều này đảm bảo rằng dữ liệu được ghi vào đĩa. Bằng cách cài đặt synchronous = OFF
, chúng tôi đang hướng dẫn SQLite chỉ cần đưa dữ liệu lên HĐH để viết và sau đó tiếp tục. Có khả năng tệp cơ sở dữ liệu có thể bị hỏng nếu máy tính gặp sự cố thảm khốc (hoặc mất điện) trước khi dữ liệu được ghi vào đĩa:
/* Open the database and create the schema */
sqlite3_open(DATABASE, &db);
sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg);
sqlite3_exec(db, "PRAGMA synchronous = OFF", NULL, NULL, &sErrMsg);
Nhập 864913 hồ sơ trong 12,41 giây
Các cải tiến bây giờ nhỏ hơn, nhưng chúng tôi có tới 69.600 lần chèn mỗi giây.
Tạp chí PRAGMA_mode = BỘ NHỚ
Xem xét việc lưu trữ tạp chí rollback trong bộ nhớ bằng cách đánh giá PRAGMA journal_mode = MEMORY
. Giao dịch của bạn sẽ nhanh hơn, nhưng nếu bạn mất điện hoặc chương trình của bạn gặp sự cố trong khi giao dịch, cơ sở dữ liệu của bạn có thể bị chuyển sang trạng thái bị hỏng với giao dịch đã hoàn thành một phần:
/* Open the database and create the schema */
sqlite3_open(DATABASE, &db);
sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg);
sqlite3_exec(db, "PRAGMA journal_mode = MEMORY", NULL, NULL, &sErrMsg);
Nhập 864913 hồ sơ trong 13,50 giây
Chậm hơn một chút so với tối ưu hóa trước đó với 64.000 lần chèn mỗi giây.
PRAGMA đồng bộ = TẮT và tạp chí PRAGMA_mode = MEMOR
Hãy kết hợp hai tối ưu hóa trước. Sẽ rủi ro hơn một chút (trong trường hợp xảy ra sự cố), nhưng chúng tôi chỉ nhập dữ liệu (không chạy ngân hàng):
/* Open the database and create the schema */
sqlite3_open(DATABASE, &db);
sqlite3_exec(db, TABLE, NULL, NULL, &sErrMsg);
sqlite3_exec(db, "PRAGMA synchronous = OFF", NULL, NULL, &sErrMsg);
sqlite3_exec(db, "PRAGMA journal_mode = MEMORY", NULL, NULL, &sErrMsg);
Nhập 864913 hồ sơ trong 12 giây
Tuyệt diệu! Chúng tôi có thể thực hiện 72.000 lần chèn mỗi giây.
Sử dụng cơ sở dữ liệu trong bộ nhớ
Chỉ với các cú đá, hãy xây dựng dựa trên tất cả các tối ưu hóa trước đó và xác định lại tên tệp cơ sở dữ liệu để chúng tôi làm việc hoàn toàn trong RAM:
#define DATABASE ":memory:"
Nhập 864913 hồ sơ trong 10,94 giây
Việc lưu trữ cơ sở dữ liệu của chúng tôi trong RAM không thực tế lắm, nhưng thật ấn tượng khi chúng tôi có thể thực hiện 79.000 lần chèn mỗi giây.
Tái cấu trúc mã C
Mặc dù không đặc biệt là một cải tiến SQLite, tôi không thích các char*
hoạt động gán thêm trong while
vòng lặp. Chúng ta hãy nhanh chóng cấu trúc lại mã đó để chuyển đầu ra strtok()
trực tiếp vào sqlite3_bind_text()
và để trình biên dịch cố gắng tăng tốc mọi thứ cho chúng ta:
pFile = fopen (INPUTDATA,"r");
while (!feof(pFile)) {
fgets (sInputBuf, BUFFER_SIZE, pFile);
sqlite3_bind_text(stmt, 1, strtok (sInputBuf, "\t"), -1, SQLITE_TRANSIENT); /* Get Route */
sqlite3_bind_text(stmt, 2, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Branch */
sqlite3_bind_text(stmt, 3, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Version */
sqlite3_bind_text(stmt, 4, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Stop Number */
sqlite3_bind_text(stmt, 5, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Vehicle */
sqlite3_bind_text(stmt, 6, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Date */
sqlite3_bind_text(stmt, 7, strtok (NULL, "\t"), -1, SQLITE_TRANSIENT); /* Get Time */
sqlite3_step(stmt); /* Execute the SQL Statement */
sqlite3_clear_bindings(stmt); /* Clear bindings */
sqlite3_reset(stmt); /* Reset VDBE */
n++;
}
fclose (pFile);
Lưu ý: Chúng tôi quay lại sử dụng tệp cơ sở dữ liệu thực. Cơ sở dữ liệu trong bộ nhớ nhanh, nhưng không nhất thiết phải thực tế
Nhập 864913 hồ sơ trong 8,94 giây
Một cấu trúc lại một chút cho mã xử lý chuỗi được sử dụng trong liên kết tham số của chúng tôi đã cho phép chúng tôi thực hiện 96.700 lần chèn mỗi giây. Tôi nghĩ thật an toàn khi nói rằng điều này rất nhanh . Khi chúng tôi bắt đầu điều chỉnh các biến khác (ví dụ kích thước trang, tạo chỉ mục, v.v.), đây sẽ là điểm chuẩn của chúng tôi.
Tóm tắt (cho đến nay)
Tôi hy vọng bạn vẫn còn với tôi! Lý do chúng tôi bắt đầu trên con đường này là vì hiệu suất chèn số lượng lớn thay đổi rất lớn với SQLite và không phải lúc nào cũng rõ ràng những thay đổi cần được thực hiện để tăng tốc hoạt động của chúng tôi. Sử dụng cùng một trình biên dịch (và các tùy chọn trình biên dịch), cùng một phiên bản SQLite và cùng một dữ liệu chúng tôi đã tối ưu hóa mã của chúng tôi và việc sử dụng SQLite của chúng tôi để đi từ một trường hợp xấu nhất là 85 lần chèn mỗi giây lên hơn 96.000 lần chèn mỗi giây!
TẠO INDEX rồi INSERT so với INSERT rồi CREATE INDEX
Trước khi bắt đầu đo SELECT
hiệu suất, chúng tôi biết rằng chúng tôi sẽ tạo các chỉ số. Một trong những câu trả lời dưới đây được đề xuất là khi thực hiện chèn số lượng lớn, việc tạo chỉ mục sẽ nhanh hơn sau khi dữ liệu được chèn vào (trái ngược với việc tạo chỉ mục trước sau đó chèn dữ liệu). Hãy thử:
Tạo chỉ mục sau đó chèn dữ liệu
sqlite3_exec(db, "CREATE INDEX 'TTC_Stop_Index' ON 'TTC' ('Stop')", NULL, NULL, &sErrMsg);
sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, &sErrMsg);
...
Nhập 864913 hồ sơ trong 18,13 giây
Chèn dữ liệu rồi tạo chỉ mục
...
sqlite3_exec(db, "END TRANSACTION", NULL, NULL, &sErrMsg);
sqlite3_exec(db, "CREATE INDEX 'TTC_Stop_Index' ON 'TTC' ('Stop')", NULL, NULL, &sErrMsg);
Nhập 864913 hồ sơ trong 13,66 giây
Như mong đợi, chèn hàng loạt sẽ chậm hơn nếu một cột được lập chỉ mục, nhưng nó sẽ tạo ra sự khác biệt nếu chỉ mục được tạo sau khi dữ liệu được chèn. Đường cơ sở không có chỉ số của chúng tôi là 96.000 chèn mỗi giây. Tạo chỉ mục trước sau đó chèn dữ liệu cho chúng tôi 47.700 lần chèn mỗi giây, trong khi chèn dữ liệu trước sau đó tạo chỉ mục cho chúng tôi 63.300 lần chèn mỗi giây.
Tôi sẵn sàng nhận đề xuất cho các kịch bản khác để thử ... Và sẽ sớm biên dịch dữ liệu tương tự cho các truy vấn CHỌN.
sqlite3_clear_bindings(stmt);
? Bạn đặt các liên kết mỗi lần là đủ: Trước khi gọi sqlite3_step () lần đầu tiên hoặc ngay sau sqlite3_reset (), ứng dụng có thể gọi một trong các giao diện sqlite3_bind () để gắn các giá trị vào các tham số. Mỗi lệnh gọi sqlite3_bind () ghi đè các ràng buộc trước đó trên cùng một tham số (xem: sqlite.org/cintro.html ). Không có gì trong các tài liệu cho chức năng đó nói rằng bạn phải gọi nó.
feof()
để kiểm soát việc chấm dứt vòng lặp đầu vào của bạn. Sử dụng kết quả trả về bởi fgets()
. stackoverflow.com/a/15485689/827263