Đọc song song dữ liệu trong Firebird
D.Simonov, V.Horsun
phiên bản 1.0.5 từ 05.12.2023
Tài liệu này được tài trợ và tạo ra với sự tài trợ và hỗ trợ của IBSurgeon www.ib-aid.com, nhà cung cấp HQbird (bản phân phối nâng cao của Firebird) và nhà cung cấp dịch vụ tối ưu hóa hiệu suất, di chuyển và hỗ trợ kỹ thuật cho Firebird.
Tài liệu được cấp phép theo Giấy phép Tài liệu Công cộng https://www.firebirdsql.org/file/documentation/html/en/licenses/pdl/public-documentation-license.html
Tài liệu liên quan:
- Sách miễn phí “Detailed New Featuires Of Firebird 5”: có sẵn dưới dạng HTML và PDF (119 trang).
Lời nói đầu
Firebird 5.0 đã giới thiệu khả năng sử dụng song song khi tạo bản sao lưu bằng tiện ích gbak, cùng với các chức năng song song khác. Ban đầu, chức năng này xuất hiện trong HQbird 2.5, sau đó là HQbird 3.0 và 4.0, và sau đó nó được chuyển sang Firebird 5.0.
Trong bài viết này, chúng ta sẽ xem xét các chức năng được sử dụng khi tạo bản sao lưu song song bên trong tiện ích gbak. Chúng ta cũng sẽ chỉ ra cách chúng có thể được sử dụng trong các ứng dụng của bạn để đọc dữ liệu song song.
Điều quan trọng cần lưu ý là ở đây chúng ta không nói về việc quét song song các bảng bên trong engine Firebird khi thực thi các truy vấn SQL, mà là đọc dữ liệu bên trong các luồng song song của ứng dụng của bạn.
Công cụ mẫu FBCSVExport
Để minh họa việc đọc dữ liệu song song từ DBMS Firebird, một tiện ích mẫu đã được viết để xuất dữ liệu từ một hoặc nhiều bảng sang định dạng CSV.
Mô tả của nó và mã nguồn mở của nó ở đây: https://github.com/IBSurgeon/FBCSVExport.git
Như bạn có thể thấy trong mô tả của tiện ích và từ bài viết dưới đây, thông qua xử lý song song, bạn có thể xuất dữ liệu và thực hiện các thao tác song song khác nhanh hơn 2-10 lần so với 1 luồng (tùy thuộc vào phần cứng).
Đối với bất kỳ câu hỏi nào, vui lòng liên hệ [email protected].
Đọc song song
Hãy nghĩ về cách đọc dữ liệu từ nhiều bảng song song. Như bạn đã biết, Firebird cho phép bạn thực thi các truy vấn song song chỉ khi mỗi truy vấn được thực thi trong một kết nối riêng biệt.
Hãy tạo một nhóm các luồng công việc. Luồng ứng dụng chính cũng là một luồng công việc, vì vậy số lượng luồng công việc bổ sung phải là N - 1, trong đó N là tổng số luồng công việc song song. Mỗi luồng công việc sẽ chạy kết nối và giao dịch riêng của nó.
Vấn đề đầu tiên: làm thế nào để đảm bảo tính nhất quán của dữ liệu được đọc?
Đọc dữ liệu nhất quán
Vì mỗi luồng công việc sử dụng kết nối và giao dịch riêng của nó, vấn đề đọc không nhất quán phát sinh - nếu bảng bị thay đổi đồng thời bởi những người dùng khác, thì dữ liệu được đọc có thể không nhất quán. Trong chế độ đơn luồng, gbak sử dụng giao dịch với chế độ cô lập SNAPSHOT, giúp có thể đọc thông tin nhất quán tại thời điểm bắt đầu giao dịch SNAPSHOT. Nhưng ở đây chúng ta có nhiều giao dịch và cần thiết rằng chúng nhìn thấy cùng một “snapshot” để chúng đọc cùng một dữ liệu bất biến.
Cơ chế tạo snapshot dùng chung cho các giao dịch khác nhau với chế độ cô lập SNAPSHOT đã được giới thiệu trong Firebird 4.0 (ban đầu trong HQBird 2.5, nhưng trong Firebird 4.0/HQBird 4.0 thì đơn giản và hiệu quả hơn). Có hai cách để tạo snapshot dùng chung:
- Với SQL
- lấy số snapshot từ giao dịch chính (được bắt đầu trong luồng công việc chính).
SELECT RDB$GET_CONTEXT('SYSTEM', 'SNAPSHOT_NUMBER') FROM RDB$DATABASE
- bắt đầu các giao dịch khác với SQL sau:
SET TRANSACTION SNAPSHOT AT NUMBER snapshot_number
trong đó snapshot_number là số được truy xuất bởi truy vấn trước đó.
- Với API
-
lấy số snapshot từ giao dịch chính (được bắt đầu trong luồng công việc chính) bằng hàm
isc_transaction_infohoặcITransaction.getInfovới thẻfb_info_tra_snapshot_number; -
bắt đầu các giao dịch khác với thẻ
isc_tpb_at_snapshot_numbervới số snapshot thu được.
Công cụ mẫu FBCSVExport, cũng như gbak, sử dụng cách tiếp cận thứ hai. Các cách tiếp cận này có thể được trộn lẫn - ví dụ, lấy số snapshot bằng SQL và sử dụng số snapshot thu được để bắt đầu các giao dịch khác bằng API, hoặc ngược lại.
Trong FBCSVExport, chúng ta lấy số snapshot bằng mã sau:
ISC_INT64 getSnapshotNumber(Firebird::ThrowStatusWrapper* status, Firebird::ITransaction* tra)
{
ISC_INT64 ret = 0;
unsigned char in_buf[] = { fb_info_tra_snapshot_number, isc_info_end };
unsigned char out_buf[16] = { 0 };
tra->getInfo(status, sizeof(in_buf), in_buf, sizeof(out_buf), out_buf);
unsigned char* p = out_buf, * e = out_buf + sizeof(out_buf);
while (p < e)
{
short len = 0;
switch (*p++)
{
case isc_info_error:
case isc_info_end:
p = e;
break;
case fb_info_tra_snapshot_number:
len = static_cast(isc_vax_integer(reinterpret_cast<char*>(p), 2));
p += 2;
ret = isc_portable_integer(p, len);
p += len;
break;
}
}
return ret;
}
Để bắt đầu giao dịch với số snapshot, chúng ta sử dụng mã sau:
Firebird::AutoDispose tpbWorkerBuilder(fbUtil->getXpbBuilder(&status, Firebird::IXpbBuilder::TPB, nullptr, 0));
tpbWorkerBuilder->insertTag(&status, isc_tpb_concurrency);
tpbWorkerBuilder->insertBigInt(&status, isc_tpb_at_snapshot_number, snapshotNumber);
Firebird::AutoRelease workerTra(
workerAtt->startTransaction(
&status,
tpbWorkerBuilder->getBufferLength(&status),
tpbWorkerBuilder->getBuffer(&status)
)
);
Bây giờ dữ liệu được đọc từ các kết nối khác nhau sẽ nhất quán, vì vậy chúng ta có thể phân phối tải giữa các luồng công việc.
Làm thế nào để phân phối tải giữa các luồng công việc một cách chính xác? Trong trường hợp xuất toàn bộ tất cả các bảng hoặc bản sao lưu, tùy chọn đơn giản nhất sẽ là một luồng công việc cho mỗi bảng. Nhưng với cách tiếp cận này, chúng ta có vấn đề sau: nếu có nhiều bảng nhỏ trong cơ sở dữ liệu và một bảng lớn, hoặc thậm chí chỉ có một bảng và nó rất lớn, chúng ta sẽ không thấy sự cải thiện. Trong trường hợp này, một số luồng sẽ nhận được một bảng lớn và các luồng còn lại sẽ nhàn rỗi. Để ngăn điều này xảy ra, cần phải xử lý một bảng lớn thành nhiều phần.
| Lưu ý | Tài liệu dưới đây dành cho việc đọc toàn bộ các bảng, nếu bạn muốn tổ chức đọc song song từ một truy vấn (hoặc view) nào đó, nó sẽ yêu cầu cách tiếp cận hơi khác, phụ thuộc vào dữ liệu thực tế. |
Chia bảng lớn thành các phần
Giả sử chúng ta chỉ có một bảng lớn mà chúng ta muốn đọc toàn bộ và nhanh nhất có thể. Đề xuất chia nó thành nhiều phần và đọc mỗi phần từ luồng riêng của nó một cách độc lập. Mỗi luồng phải có kết nối riêng đến cơ sở dữ liệu.
Trong trường hợp này, các câu hỏi sau phát sinh:
-
Bảng nên được chia thành bao nhiêu phần xử lý?
-
Cách tốt nhất để chia bảng về mặt truy cập dữ liệu là gì?
Hãy trả lời các câu hỏi này theo thứ tự.
Bảng nên được chia thành bao nhiêu phần xử lý?
Giả sử kịch bản lý tưởng - máy chủ và máy khách được dành riêng cho Firebird, nghĩa là tất cả các CPU hoàn toàn thuộc quyền sử dụng của chúng ta. Sau đó, khuyến nghị:
a) Sử dụng số phần song song tối đa là gấp đôi số lõi CPU trên máy chủ. Tại sao gấp 2 lần số lõi? Chúng ta biết chắc chắn rằng sẽ có độ trễ liên quan đến IO, vì vậy chúng ta có thể cho phép sử dụng CPU thêm một chút. Tuy nhiên, con số này nên được coi là cài đặt ban đầu, trên thực tế nó phụ thuộc vào dữ liệu.
b) Tính đến số lõi trên máy khách: nếu có nhiều lõi hơn trên máy chủ (tình huống thông thường), thì có thể hợp lý để giới hạn thêm số phần phân vùng, để không làm quá tải máy khách (dù sao nó cũng không thể xử lý thêm được nữa, và chi phí chuyển đổi luồng là không thể tránh khỏi). Sẽ có thể quyết định chính xác hơn bằng cách theo dõi tải CPU của máy khách và máy chủ - nếu máy khách ở mức 100% nhưng máy chủ thấp hơn đáng kể, thì có lý để giảm số phần.
c) nếu máy khách và máy chủ là cùng một máy chủ, thì xem (a).
Nếu máy khách và/hoặc máy chủ đang bận với việc khác, bạn có thể phải giảm số phần. Điều này cũng có thể bị ảnh hưởng bởi khả năng của các ổ đĩa trên máy chủ để xử lý nhiều yêu cầu IO đồng thời (theo dõi kích thước hàng đợi và thời gian phản hồi).
Cách tốt nhất để chia bảng về mặt truy cập dữ liệu là gì?
Để triển khai xử lý song song hiệu quả, điều quan trọng là đảm bảo phân phối đều công việc giữa các bộ xử lý và giảm thiểu sự đồng bộ hóa lẫn nhau của chúng. Hơn nữa, bạn cần nhớ rằng sự đồng bộ hóa của các bộ xử lý có thể xảy ra cả ở phía máy chủ và phía máy khách. Ví dụ, một số bộ xử lý không nên sử dụng cùng một kết nối đến cơ sở dữ liệu. Một ví dụ ít rõ ràng hơn: thật tệ nếu các bộ xử lý khác nhau đọc các bản ghi từ cùng một trang cơ sở dữ liệu. Ví dụ, khi hai bộ xử lý đọc các bản ghi chẵn và lẻ - điều đó không hiệu quả. Sự đồng bộ hóa trên máy khách có thể xảy ra trong quá trình phân phối tác vụ, trong quá trình xử lý dữ liệu nhận được (cấp phát bộ nhớ cho kết quả), v.v.
Một trong những vấn đề của việc phân vùng “công bằng” là máy khách không biết các bản ghi được phân phối như thế nào trên các trang (và trên các khóa chỉ mục), có bao nhiêu bản ghi hoặc trang dữ liệu (đối với các bảng lớn, việc đếm số bản ghi trước sẽ quá lâu).
Hãy xem gbak giải quyết vấn đề này như thế nào.
Đối với gbak, một đơn vị công việc là một tập hợp các bản ghi từ các trang dữ liệu (DP) thuộc cùng một trang con trỏ (PP). Một mặt, đây là một số lượng bản ghi khá lớn để giữ cho bộ xử lý bận rộn mà không cần phải thường xuyên yêu cầu một phần dữ liệu mới (đồng bộ hóa). Mặt khác, ngay cả khi các tập bản ghi như vậy không có kích thước chính xác giống nhau, nó sẽ cho phép tải các luồng công việc tương đối đều. Nghĩa là, khá có thể một luồng công việc sẽ đọc N bản ghi từ một PP và luồng khác là M bản ghi, và M có thể khá khác so với N. Cách tiếp cận này không lý tưởng, nhưng nó khá đơn giản để triển khai và thường khá hiệu quả, ít nhất là ở quy mô lớn (với hàng chục hoặc hàng trăm (hoặc nhiều hơn) PP).
Làm thế nào để lấy số PP (Pointer Pages) cho một bảng nhất định? Khá dễ dàng và quan trọng nhất là nhanh chóng tính toán nó từ bảng RDB$PAGES:
SELECT RDB$PAGE_SEQUENCE
FROM RDB$PAGES
WHERE RDB$RELATION_ID = ? AND RDB$PAGE_TYPE = 4
ORDER BY RDB$PAGE_SEQUENCE DESC ROWS 1
Tiếp theo, chúng ta có thể chỉ cần chia số PP cho số luồng công việc và đưa cho mỗi luồng công việc phần riêng của chúng. Điều này phù hợp với kịch bản khi xử lý song song được thực hiện bởi nhà phát triển biết phân phối dữ liệu. Nhưng đối với kịch bản phổ biến hơn, không có gì đảm bảo rằng các phần “lớn” như vậy sẽ có cùng lượng công việc. Chúng ta không muốn thấy tình huống 15 luồng công việc hoàn thành công việc của chúng và đứng nhàn rỗi, trong khi luồng thứ 16 đọc 10 triệu bản ghi của nó trong một thời gian dài.
Đó là lý do tại sao gbak làm điều đó khác đi. Có một điều phối viên công việc cấp cho mỗi bộ xử lý 1 PP tại một thời điểm. Điều phối viên biết có bao nhiêu PP trong tổng số và bao nhiêu đã được cấp cho công việc. Khi luồng công việc hoàn thành việc đọc các bản ghi của nó, nó liên hệ với điều phối viên để lấy số PP mới. Nó tiếp tục cho đến khi PP cạn kiệt (hoặc có các luồng công việc đang hoạt động). Tất nhiên, sự tương tác như vậy của các luồng công việc với điều phối viên đòi hỏi sự đồng bộ hóa. Kinh nghiệm cho thấy rằng lượng công việc được giao cho một PP cho phép bạn không phải đồng bộ hóa quá thường xuyên. Cách tiếp cận này cho phép tải gần như đều tất cả các luồng công việc (và do đó là các lõi CPU) với công việc, bất kể số lượng bản ghi thực tế thuộc về mỗi PP.
Trình xử lý đọc bản ghi từ PP của nó như thế nào? Để làm điều này, bắt đầu từ Firebird 4.0 (xuất hiện lần đầu trong HQBird 2.5) có một hàm tích hợp MAKE_DBKEY(). Với sự trợ giúp của nó, bạn có thể lấy RDB$DB_KEY (số bản ghi vật lý) cho bản ghi đầu tiên trên PP được chỉ định.
Và với sự trợ giúp của các RDB$DB_KEY này, các bản ghi cần thiết được chọn:
SELECT *
FROM relation
WHERE RDB$DB_KEY >= MAKE_DBKEY(:rel_id, 0, 0, :loPP)
AND RDB$DB_KEY < MAKE_DBKEY(:rel_id, 0, 0, :hiPP)
Ví dụ: nếu bạn đặt loPP = 0 và hiPP = 1, thì tất cả các bản ghi có PP = 0 sẽ được đọc, và chỉ từ PP đó.
Bây giờ bạn đã có ý tưởng về cách gbak hoạt động, bạn có thể chuyển sang mô tả việc triển khai tiện ích FBCSVExport.
Triển khai tiện ích FBCSVExport
Tiện ích FBCSVExport được thiết kế để xuất dữ liệu từ các bảng cơ sở dữ liệu Firebird sang định dạng CSV.
Mỗi bảng được xuất ra một tệp có tên .csv. Trong chế độ bình thường (đơn luồng), dữ liệu từ các bảng được xuất tuần tự theo thứ tự bảng chữ cái của tên bảng.
Trong chế độ song song, các bảng được xuất song song, mỗi bảng trong một luồng riêng. Nếu bảng rất lớn, nó được chia thành các phần, và mỗi phần được xuất trong một luồng riêng. Đối với mỗi phần của bảng lớn, một tệp riêng được tạo với tên .csv.partN, trong đó N là số phần.
Khi tất cả các phần của bảng lớn được xuất, các tệp phần được hợp nhất thành một tệp có tên .csv.
Một biểu thức chính quy được sử dụng để chỉ định bảng nào sẽ được xuất. Chỉ các bảng thông thường mới có thể được xuất (các bảng hệ thống, GTT, view, bảng ngoài không được hỗ trợ). Biểu thức chính quy phải ở cú pháp SQL, tức là những biểu thức được sử dụng trong vị từ SIMILAR TO.
Để chọn danh sách các bảng được xuất, cũng như danh sách các PP của chúng trong chế độ đa luồng, chúng tôi sử dụng truy vấn sau:
SELECT
R.RDB$RELATION_ID AS RELATION_ID,
TRIM(R.RDB$RELATION_NAME) AS RELATION_NAME,
P.RDB$PAGE_SEQUENCE AS PAGE_SEQUENCE,
COUNT(P.RDB$PAGE_SEQUENCE) OVER(PARTITION BY R.RDB$RELATION_NAME) AS PP_CNT
FROM RDB$RELATIONS R
JOIN RDB$PAGES P ON P.RDB$RELATION_ID = R.RDB$RELATION_ID
WHERE R.RDB$SYSTEM_FLAG = 0 AND
R.RDB$RELATION_TYPE = 0 AND
P.RDB$PAGE_TYPE = 4 AND
TRIM(R.RDB$RELATION_NAME) SIMILAR TO CAST(? AS VARCHAR(8191))
ORDER BY R.RDB$RELATION_NAME, P.RDB$PAGE_SEQUENCE
Trong chế độ đơn luồng, truy vấn này có thể được đơn giản hóa thành
SELECT
R.RDB$RELATION_ID AS RELATION_ID,
TRIM(R.RDB$RELATION_NAME) AS RELATION_NAME,
0 AS PAGE_SEQUENCE,
1 AS PP_CNT
FROM RDB$RELATIONS R
WHERE R.RDB$SYSTEM_FLAG = 0 AND
R.RDB$RELATION_TYPE = 0 AND
TRIM(R.RDB$RELATION_NAME) SIMILAR TO CAST(? AS VARCHAR(8191))
ORDER BY R.RDB$RELATION_NAME
Trong chế độ đơn luồng, các giá trị của trường PAGE_SEQUENCE và PP_CNT không được sử dụng; chúng được thêm vào yêu cầu để thống nhất các thông báo đầu ra.
Kết quả của truy vấn này được hình thành thành một vector các cấu trúc:
struct TableDesc
{
TableDesc() = default;
TableDesc(const OutputRecord& rec)
: releation_id(rec->releation_id)
, relation_name(rec->relation_name.str, rec->relation_name.length)
, page_sequence(rec->page_sequence)
, pp_cnt(rec->pp_cnt)
{}
short releation_id;
std::string relation_name;
int32_t page_sequence;
int64_t pp_cnt;
};
Vector này được điền bằng cách sử dụng một hàm được khai báo như sau:
std::vector getTablesDesc(
Firebird::ThrowStatusWrapper* status,
Firebird::IAttachment* att,
Firebird::ITransaction* tra,
unsigned int sqlDialect,
const std::string& tableIncludeFilter,
bool singleWorker = true);
Tham số cuối cùng singleWorker chuyển đổi chế độ điền của std::vector - nếu singleWorker = true, thì yêu cầu cho chế độ đơn luồng được sử dụng; nếu singleWorker = false, thì một truy vấn phức tạp và tốn kém hơn được sử dụng cho chế độ đa luồng. Tôi sẽ không đưa ra phần triển khai cụ thể, nó khá đơn giản và bạn có thể xem nó trong mã nguồn của dự án.
Để xuất một bảng sang định dạng CSV, lớp CSVExportTable đã được phát triển, chứa các phương thức sau:
void prepare(Firebird::ThrowStatusWrapper* status, const std::string& tableName,
unsigned int sqlDialect, bool withDbkeyFilter = false);
void printHeader(Firebird::ThrowStatusWrapper* status, csv::CSVFile& csv);
void printData(Firebird::ThrowStatusWrapper* status, csv::CSVFile& csv, int64_t ppNum = 0);
Phương thức prepare được thiết kế để xây dựng và chuẩn bị một truy vấn được sử dụng để xuất bảng sang định dạng CSV. Truy vấn bên trong được xây dựng khác nhau tùy thuộc vào tham số withDbkeyFilter. Nếu withDbkeyFilter = true, thì truy vấn được xây dựng với bộ lọc theo phạm vi RDB$DB_KEY:
SELECT *
FROM tableName
WHERE RDB$DB_KEY >= MAKE_DBKEY('tableName', 0, 0, ?)
AND RDB$DB_KEY < MAKE_DBKEY('tableName', 0, 0, ?)
nếu không, một truy vấn đơn giản hóa được sử dụng:
SELECT *
FROM tableName
Giá trị của tham số withDbkeyFilter được đặt thành true nếu chế độ đa luồng được sử dụng và bảng lớn. Chúng tôi coi bảng là lớn nếu pp_cnt > 1.
Phương thức printHeader được thiết kế để in tiêu đề của tệp CSV (tên cột của bảng).
Phương thức printData in dữ liệu bảng vào tệp CSV từ số trang PP ppNum, nếu yêu cầu được chuẩn bị bằng bộ lọc theo phạm vi RDB$DB_KEY, và tất cả dữ liệu bảng trong trường hợp khác.
Bây giờ hãy xem mã cho chế độ đơn luồng
...
// Mở kết nối chính
Firebird::AutoRelease att(
provider->attachDatabase(
&status,
m_database.c_str(),
dbpLength,
dpb
)
);
// Bắt đầu giao dịch chính ở chế độ cô lập SNAPSHOT
Firebird::AutoDispose tpbBuilder(fbUtil->getXpbBuilder(&status, Firebird::IXpbBuilder::TPB, nullptr, 0));
tpbBuilder->insertTag(&status, isc_tpb_concurrency);
Firebird::AutoRelease tra(
att->startTransaction(
&status,
tpbBuilder->getBufferLength(&status),
tpbBuilder->getBuffer(&status)
)
);
// Lấy danh sách các bảng bằng biểu thức chính quy trong m_filter.
// m_parallel đặt số luồng song song khi nó bằng 1,
// thì một truy vấn đơn giản hóa được sử dụng để lấy danh sách các bảng,
// nếu không, danh sách các PP và số lượng của chúng được tạo cho mỗi bảng.
auto tables = getTablesDesc(&status, att, tra, m_sqlDialect, m_filter, m_parallel == 1);
if (m_parallel == 1) {
FBExport::CSVExportTable csvExport(att, tra, fb_master);
for (const auto& tableDesc : tables) {
// không có lý do gì để sử dụng bộ lọc phạm vi RDB$DB_KEY ở đây
csvExport.prepare(&status, tableDesc.relation_name, m_sqlDialect, false);
const std::string fileName = tableDesc.relation_name + ".csv";
csv::CSVFile csv(m_outputDir / fileName);
if (m_printHeader) {
csvExport.printHeader(&status, csv);
}
csvExport.printData(&status, csv);
}
}
Mọi thứ ở đây khá đơn giản và không cần giải thích thêm, vì vậy hãy chuyển sang phần đa luồng.
Để quá trình xuất diễn ra ở chế độ đa luồng, cần phải tạo thêm m_parallel - 1 luồng công nhân. Tại sao số luồng bổ sung lại ít hơn 1? Đúng, vì luồng chính cũng sẽ xuất dữ liệu và nó ngang bằng với các luồng bổ sung. Hãy chuyển phần chung của luồng chính và luồng bổ sung vào một hàm riêng:
void ExportApp::exportByTableDesc(Firebird::ThrowStatusWrapper* status, FBExport::CSVExportTable& csvExport, const TableDesc& tableDesc)
{
// Nếu tableDesc có pp_cnt > 1, thì nó chỉ mô tả một phần của bảng, và cần phải xây dựng
// truy vấn bằng bộ lọc theo phạm vi RDB$DB_KEY.
bool withDbKeyFilter = tableDesc.pp_cnt > 1;
csvExport.prepare(status, tableDesc.relation_name, m_sqlDialect, withDbKeyFilter);
std::string fileName = tableDesc.relation_name + ".csv";
// Nếu đây không phải là phần đầu tiên của bảng, thì hãy ghi phần này vào tệp .csv.part, trong đó
// N - số PP. Sau đó các phần của bảng sẽ được kết hợp thành một tệp .csv duy nhất
if (tableDesc.page_sequence > 0) {
fileName += ".part_" + std::to_string(tableDesc.page_sequence);
}
csv::CSVFile csv(m_outputDir / fileName);
// Tiêu đề của tệp CSV chỉ nên được in trong phần đầu tiên của bảng.
if (tableDesc.page_sequence == 0 && m_printHeader) {
csvExport.printHeader(status, csv);
}
csvExport.printData(status, csv, tableDesc.page_sequence);
}
Mô tả của các bảng hoặc các phần của chúng nằm trong một vector chung với các cấu trúc TableDesc. Từ vector này, mỗi luồng công nhân lấy một bảng hoặc phần tiếp theo. Để ngăn chặn các cuộc đua dữ liệu, cần phải đồng bộ hóa quyền truy cập vào tài nguyên dùng chung. Nhưng bản thân std::vector không thay đổi, vì vậy bạn chỉ có thể đồng bộ hóa biến dùng chung, đó là chỉ mục trong vector này. Điều này có thể dễ dàng thực hiện bằng cách sử dụng std::atomic làm biến như vậy.
if (m_parallel == 1) {
...
}
else {
// Xác định số luồng công nhân bổ sung
const auto workerCount = m_parallel - 1;
// Lấy số snapshot từ giao dịch chính
auto snapshotNumber = getSnapshotNumber(&status, tra);
// biến để lưu trữ ngoại lệ trong luồng
std::exception_ptr exceptionPointer = nullptr;
std::mutex m;
// bộ đếm nguyên tử
// là chỉ mục của bảng tiếp theo hoặc phần của nó
std::atomic<size_t> counter = 0;
// nhóm các luồng công nhân
std::vector<std::thread> thread_pool;
thread_pool.reserve(workerCount);
for (int i = 0; i < workerCount; i++) {
// cho mỗi luồng, chúng tôi tạo kết nối riêng của mình
Firebird::AutoRelease workerAtt(
provider->attachDatabase(
&status,
m_database.c_str(),
dbpLength,
dpb
)
);
// và giao dịch riêng của chúng tôi mà chúng tôi truyền số snapshot
// để tạo snapshot dùng chung
Firebird::AutoDispose tpbWorkerBuilder(fbUtil->getXpbBuilder(&status, Firebird::IXpbBuilder::TPB, nullptr, 0));
tpbWorkerBuilder->insertTag(&status, isc_tpb_concurrency);
tpbWorkerBuilder->insertBigInt(&status, isc_tpb_at_snapshot_number, snapshotNumber);
Firebird::AutoRelease workerTra(
workerAtt->startTransaction(
&status,
tpbWorkerBuilder->getBufferLength(&status),
tpbWorkerBuilder->getBuffer(&status)
)
);
// tạo một luồng
std::thread t([att = std::move(workerAtt), tra = std::move(workerTra), this,
&m, &tables, &counter, &exceptionPointer]() mutable {
Firebird::ThrowStatusWrapper status(fb_master->getStatus());
try {
FBExport::CSVExportTable csvExport(att, tra, fb_master);
while (true) {
// tăng bộ đếm nguyên tử
size_t localCounter = counter++;
// nếu các bảng hoặc các phần của chúng đã hết, hãy thoát
// khỏi vòng lặp vô hạn và kết thúc luồng
if (localCounter >= tables.size())
break;
// lấy mô tả của bảng hoặc phần của nó
const auto& tableDesc = tables[localCounter];
// và thực hiện xuất
exportByTableDesc(&status, csvExport, tableDesc);
}
if (tra) {
tra->commit(&status);
tra.release();
}
if (att) {
att->detach(&status);
att.release();
}
}
catch (...) {
// nếu một ngoại lệ xảy ra, hãy lưu nó để
// giải phóng sau đó trong luồng chính
std::unique_lock<std::mutex> lock(m);
exceptionPointer = std::current_exception();
}
});
thread_pool.push_back(std::move(t));
}
// export in the main thread
FBExport::CSVExportTable csvExport(att, tra, fb_master);
while (true) {
// increment the atomic counter
size_t localCounter = counter++;
if (localCounter >= tables.size())
break;
// if the tables or their parts are over, exit
// from an endless loop
const auto& tableDesc = tables[localCounter];
exportByTableDesc(&status, csvExport, tableDesc);
}
// wait for the worker threads to complete
for (auto& th : thread_pool) {
th.join();
}
// if there was an exception in the worker threads, throw it again
if (exceptionPointer) {
std::rethrow_exception(exceptionPointer);
}
...
Tất cả những gì còn lại là kết hợp các tệp đã được tạo cho các phần của bảng thành một tệp duy nhất cho mỗi bảng này.
for (size_t i = 0; i < tables.size(); i++) {
const auto& tableDesc = tables[i];
// if the number of PP is greater than 1,
// then the table is large and there were several parts for it
if (tableDesc.pp_cnt > 1) {
// main file for the table
std::string fileName = tableDesc.relation_name + ".csv";
std::ofstream ofile(m_outputDir / fileName, std::ios::out | std::ios::app);
i++;
for (int64_t j = 1; j < tableDesc.pp_cnt; j++, i++) {
// files of table parts
std::string partFileName = fileName + ".part_" + std::to_string(j);
auto partFilePath = m_outputDir / partFileName;
std::ifstream ifile(partFilePath, std::ios::in);
ofile << ifile.rdbuf();
ifile.close();
fs::remove(partFilePath);
}
ofile.close();
}
}
Hãy đo hiệu suất của công cụ ở chế độ đơn luồng và đa luồng.
Đo điểm chuẩn của công cụ FBCSVExport
Trước tiên, hãy xem kết quả so sánh giữa chế độ xuất đa luồng và đơn luồng trên máy tính gia đình tầm trung. === Windows
-
Hệ điều hành: Windows 10 x64.
-
Bộ xử lý: Intel Core i3 8100, 4 lõi, 4 luồng.
-
Bộ nhớ: 16 GB
-
Hệ thống đĩa: NVME SSD (cơ sở dữ liệu), SATA SSD (thư mục lưu trữ tệp CSV).
-
Firebird 4.0.4 x64
Kết quả:
CSVExport.exe -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=1 \
-d inet://localhost:3054/horses -u SYSDBA -p masterkey --charset=WIN1251 -o ./single
Elapsed time in milliseconds parallel_part: 35894 ms
Elapsed time in milliseconds: 36317 ms
CSVExport.exe -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=2 \
-d inet://localhost:3054/horses -u SYSDBA -p masterkey --charset=WIN1251 -o ./multi
Elapsed time in milliseconds parallel_part: 19259 ms
Elapsed time in milliseconds: 20760 ms
CSVExport.exe -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=4 \
-d inet://localhost:3054/horses -u SYSDBA -p masterkey --charset=WIN1251 -o ./multi
Elapsed time in milliseconds parallel_part: 19600 ms
Elapsed time in milliseconds: 21137 ms
Từ kết quả kiểm tra, rõ ràng rằng khi sử dụng hai luồng, tốc độ đã tăng 1,8 lần, đây là một kết quả tốt. Nhưng việc thực thi xuất song song với 4 luồng cũng cho thấy cải thiện 1,8 lần. Tại sao không phải là 3-4 lần? Thực tế là máy chủ Firebird và tiện ích xuất đang chạy trên cùng một máy tính, chỉ có 4 lõi. Do đó, bản thân máy chủ Firebird sử dụng 4 luồng để đọc bảng, và tiện ích FBCSVExport cũng sử dụng 4 luồng. Rõ ràng, trong trường hợp này khá khó để đạt được mức tăng tốc hơn 2 lần. Vì vậy, chúng tôi sẽ thử trên một phần cứng khác, nơi số lượng lõi lớn hơn đáng kể.
Linux
-
Hệ điều hành: CentOS 8.
-
Bộ xử lý: 2 bộ xử lý Intel Xeon E5-2603 v4, tổng cộng 12 lõi, 12 luồng.
-
Bộ nhớ: 32 GB
-
Hệ thống đĩa: SAS HDD (RAID 10)
-
Firebird 4.0.4 x64
Kết quả:
[denis@copyserver build]$ ./CSVExport -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=1 \
-d inet://localhost/horses -u SYSDBA -p masterkey --charset=UTF8 -o ./single
Elapsed time in milliseconds parallel_part: 57547 ms
Elapsed time in milliseconds: 57595 ms
[denis@copyserver build]$ ./CSVExport -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=4 \
-d inet://localhost/horses -u SYSDBA -p masterkey --charset=UTF8 -o ./multi
Elapsed time in milliseconds parallel_part: 17755 ms
Elapsed time in milliseconds: 18148 ms
[denis@copyserver build]$ ./CSVExport -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=6 \
-d inet://localhost/horses -u SYSDBA -p masterkey --charset=UTF8 -o ./multi
Elapsed time in milliseconds parallel_part: 13243 ms
Elapsed time in milliseconds: 13624 ms
[denis@copyserver build]$ ./CSVExport -H --table-filter="COLOR|BREED|HORSE|COVER|MEASURE|LAB_LINE|SEX" --parallel=12 \
-d inet://localhost/horses -u SYSDBA -p masterkey --charset=UTF8 -o ./multi
Elapsed time in milliseconds parallel_part: 12712 ms
Elapsed time in milliseconds: 13140 ms
Trong trường hợp này, số luồng tối ưu cho việc xuất là 6 (6 luồng cho Firebird và 6 luồng cho tiện ích FBCSVExport). Đồng thời, chúng tôi đã đạt được mức tăng tốc 5 lần, điều này cho thấy khả năng mở rộng khá tốt. Trên máy chủ Linux và máy tính Windows, chúng tôi đã sử dụng các cơ sở dữ liệu giống hệt nhau, và bạn có thể đã nhận thấy rằng xuất đơn luồng trên Windows nhanh hơn gần 2 lần: điều này là do hệ thống đĩa nhanh hơn (ổ NVME nhanh hơn nhiều so với ổ SAS kết hợp trong RAID).
Tóm tắt
Trong bài viết này, chúng tôi đã xem xét cách đọc dữ liệu hiệu quả từ các bảng của hệ quản trị cơ sở dữ liệu Firebird bằng cách sử dụng song song. Ngoài ra, ví dụ đã được trình bày về cách bạn có thể sử dụng một số khả năng của hệ quản trị cơ sở dữ liệu Firebird để tổ chức việc đọc như vậy trong phần mềm của bạn.
Xin chân thành cảm ơn Vladislav Khorsun, nhà phát triển lõi Firebird, vì đã giúp đỡ với tài liệu này.
Mọi câu hỏi hoặc nhận xét, vui lòng gửi email đến [email protected].