Ова страница је машински преведена. Прочитајте енглески оригинал. English

IBSurgeon библиотека

Паралелно читање података у Firebird-у

D.Simonov, V.Horsun

верзија 1.0.5 од 05.12.2023

Овај материјал је спонзорисан и креиран уз спонзорство и подршку IBSurgeon www.ib-aid.com, произвођача HQbird (напредне дистрибуције Firebird) и добављача услуга оптимизације перформанси, миграције и техничке подршке за Firebird.

Материјал је лиценциран под Public Documentation License https://www.firebirdsql.org/file/documentation/html/en/licenses/pdl/public-documentation-license.html

Повезани материјали:

Предговор

Firebird 5.0 је увео могућност коришћења паралелизма при креирању резервне копије помоћу алатке gbak, између осталих паралелних функција. У почетку се ова функција појавила у HQbird 2.5, затим у HQbird 3.0 и 4.0, а потом је пренета у Firebird 5.0.

У овом чланку ћемо размотрити функције које се користе при креирању паралелних резервних копија унутар алатке gbak, такође ћемо показати како се могу користити у вашим апликацијама за паралелно читање података.

Важно је напоменути да овде не говоримо о паралелном скенирању табела унутар Firebird мотора при извршавању SQL упита, већ о читању података унутар ваше апликације у паралелним токовима.

Пример алатке FBCSVExport

Да би се демонстрирало паралелно читање података из Firebird DBMS-а, написан је пример алатке која извози податке из једне или више табела у CSV формат.

Њен опис и њен отворени код су овде: https://github.com/IBSurgeon/FBCSVExport.git

Као што можете видети у опису алатке и из чланка испод, кроз паралелну обраду могуће је извозити податке и обављати друге паралелне операције 2-10 пута брже него у 1 нити (у зависности од хардвера).

За сва питања, молимо контактирајте [email protected].

Паралелно читање

Размислимо о томе како читати податке из неколико табела паралелно. Као што знате, Firebird дозвољава извршавање упита паралелно само ако се сваки упит извршава у посебној вези.

Хајде да направимо групу радних нити. Главна нит апликације је такође радна нит, тако да број додатних радних нити треба да буде N - 1, где је N укупан број паралелних радника. Свака радна нит ће покретати своју везу и трансакцију.

Први проблем: како обезбедити конзистентност прочитаних података?

Конзистентно читање података

Пошто свака радна нит користи своју везу и своју трансакцију, јавља се проблем неконзистентног читања - ако табелу истовремено мењају други корисници, онда прочитани подаци могу бити неконзистентни. У једнонитном режиму, gbak користи трансакцију са SNAPSHOT изолационим режимом, што омогућава читање конзистентних информација на почетку SNAPSHOT трансакције. Али овде имамо више трансакција и неопходно је да оне виде исти “снимак” како би читале исте непроменљиве податке.

Механизам за креирање заједничког снимка за различите трансакције са SNAPSHOT изолационим режимом је уведен у Firebird 4.0 (првобитно у HQBird 2.5, али у Firebird 4.0/HQBird 4.0 је једноставнији и ефикаснији). Постоје два начина за креирање заједничког снимка:

  1. Са SQL
  • добијте број снимка из главне трансакције (која је покренута у главној радној нити).
sql
SELECT RDB$GET_CONTEXT('SYSTEM', 'SNAPSHOT_NUMBER') FROM RDB$DATABASE
  • покрените друге трансакције са следећим SQL-ом:
sql
SET TRANSACTION SNAPSHOT AT NUMBER snapshot_number

где је snapshot_number број добијен претходним упитом.

  1. Са API
  • добијте број снимка из главне трансакције (која је покренута у главној радној нити) помоћу функције isc_transaction_info или ITransaction.getInfo са ознаком fb_info_tra_snapshot_number;

  • покрените друге трансакције са ознаком isc_tpb_at_snapshot_number са добијеним бројем снимка.

Пример алатке FBCSVExport, као и gbak, користи други приступ. Ови приступи се могу мешати - на пример, добити број снимка са SQL-ом, а користити добијени број снимка за покретање других трансакција са API-јем, или обрнуто.

У FBCSVExport добијамо број снимка са следећим кодом:

cpp
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;
}

За покретање трансакције са бројем снимка користимо следећи код:

sql
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)
    )
);

Сада ће подаци прочитани из различитих веза бити конзистентни, тако да можемо распоредити оптерећење на радне нити.

Како тачно распоредити оптерећење између радних нити? У случају потпуног извоза свих табела или резервне копије, најједноставнија опција ће бити једна радна нит по табели. Али са овим приступом имамо следећи проблем: ако у бази података постоји много малих табела и једна велика табела, или чак само једна табела и она је огромна, нећемо видети побољшање. У овом случају, нека нит ће добити велику табелу, а остале нити ће бити неактивне. Да се то не би десило, неопходно је обрадити велику табелу у деловима.

Напомена Материјал испод је посвећен потпуном читању табела, ако желите да организујете паралелно читање из неког упита (или погледа), то ће захтевати мало другачији приступ, који зависи од стварних података.

Подела велике табеле на делове

Рецимо да имамо само једну велику табелу коју желимо да прочитамо у целини и што је брже могуће. Предлаже се да је поделимо на неколико делова и читамо сваки део из сопственог тока независно. Свака нит мора имати своју везу са базом података.

У овом случају се постављају следећа питања:

  • На колико делова за обраду треба поделити табелу?

  • Који је најбољи начин да се табела подели у смислу приступа подацима?

Хајде да одговоримо на ова питања редом.

На колико делова за обраду треба поделити табелу?

Претпоставимо идеалан сценарио - сервер и клијент су посвећени Firebird-у, односно сви процесори су потпуно на располагању. Онда се препоручује:

а) Користите као максимални број паралелних делова удвостручени број процесорских језгара на серверу. Зашто 2x језгара? Сигурно знамо да ћемо имати кашњења повезана са IO, тако да можемо дозволити мало додатног коришћења процесора. Међутим, овај број треба сматрати почетним подешавањем, практично зависи од података.

б) Узмите у обзир број језгара на клијенту: ако их је много више на серверу (уобичајена ситуација), онда би могло имати смисла додатно ограничити број делова партиције, како се клијент не би преоптеретио (свеједно неће моћи да обради више, а трошкови пребацивања токова никуда не нестају). Биће могуће одлучити прецизније праћењем оптерећења процесора на клијенту и серверу - ако је 100% на клијенту, а приметно мање на серверу, онда има смисла смањити број делова.

ц) ако су клијент и сервер исти хост, онда погледати (а).

Ако су клијент и/или сервер заузети нечим другим, можда ћете морати да смањите број делова. На то такође може утицати способност дискова на серверу да обрађују много ИО захтева истовремено (пратите величину реда и време одговора).

Који је најбољи начин да се табела подели у погледу приступа подацима?

Да би се имплементирала ефикасна паралелна обрада, важно је обезбедити равномерну расподелу послова између обрађивача и минимизирати њихову међусобну синхронизацију. Штавише, морате имати на уму да се синхронизација обрађивача може десити и на страни сервера и на страни клијента. На пример, неколико обрађивача не би требало да користи исту везу са базом података. Мање очигледан пример: лоше је ако различити обрађивачи читају записе са истих страница базе података. На пример, када два обрађивача читају парне и непарне записе - то није ефикасно. Синхронизација на клијенту може се десити током расподеле задатака, током обраде примљених података (алокација меморије за резултате) и тако даље.

Један од проблема са “праведном” поделом је што клијент не зна како су записи распоређени по страницама (и по кључевима индекса), колико записа или страница података постоји (за велике табеле било би предуго да се унапред изброји број записа).

Погледајмо како gbak решава овај проблем.

За gbak, јединица рада је скуп записа са страница података (DP) које припадају истој страници показивача (PP). С једне стране, то је прилично велики број записа да би обрађивач био заузет без потребе да често тражи нови део података (синхронизација). С друге стране, чак и ако такви скупови записа немају потпуно исту величину, то ће омогућити релативно равномерно оптерећење радника. Односно, сасвим је могуће да ће један радник прочитати N записа са једног PP, а други M записа, и M ће се прилично разликовати од N. Овај приступ није идеалан, али је прилично једноставан за имплементацију и обично је прилично ефикасан, барем на великим размерама (са десетинама или стотинама (или више) PP).

Како добити број PP (страница показивача) за дату табелу? Прилично је лако и, што је најважније, брзо израчунати из табеле RDB$PAGES:

sql
SELECT RDB$PAGE_SEQUENCE
FROM RDB$PAGES
WHERE RDB$RELATION_ID = ? AND RDB$PAGE_TYPE = 4
ORDER BY RDB$PAGE_SEQUENCE DESC ROWS 1

Затим бисмо једноставно могли поделити број PP са бројем радника и дати сваком раднику његов део. То је у реду за сценарио када паралелну обраду ради програмер који зна расподелу података. Али, за чешћи сценарио, нема гаранције да ће такви “велики” делови значити исту количину посла. Не занима нас ситуација када је 15 радника завршило свој посао и стоји беспослено, а 16. чита својих 10М записа дуго времена.

Зато gbak то ради другачије. Постоји координатор посла који издаје сваком процесору по 1 PP истовремено. Координатор зна колико PP укупно има и колико је већ издато за рад. Када радник заврши читање својих записа, контактира координатора за нови број PP. Наставља се док PP не понестане (или док има активних радника). Наравно, таква интеракција радника са координатором захтева синхронизацију. Искуство показује да количина посла дата једном PP омогућава да се не синхронизује пречесто. Овај приступ омогућава практично равномерно оптерећење свих радника (а самим тим и језгара процесора) послом, без обзира на стварни број записа који припадају сваком PP.

Како обрађивач чита записе са свог PP? Да би се то урадило, почевши од Firebird 4.0 (први пут се појавио у HQBird 2.5) постоји уграђена функција MAKE_DBKEY(). Помоћу ње можете добити RDB$DB_KEY (физички број записа) за први запис на наведеном PP.

А помоћу ових RDB$DB_KEY бирају се потребни записи:

sql
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)

На пример, ако поставите loPP = 0 и hiPP = 1, онда ће се читати сви записи са PP = 0, и само са њега.

Сада када имате представу о томе како gbak ради, можете прећи на опис имплементације алатке FBCSVExport.

Имплементација алатке FBCSVExport

Алатка FBCSVExport је дизајнирана за извоз података из табела Firebird базе података у CSV формат.

Свака табела се извози у датотеку под називом .csv. У нормалном (једннитном) режиму подаци из табела се извозе секвенцијално по абецедном реду имена табела.

У паралелном режиму, табеле се извозе паралелно, свака табела у посебној нити. Ако је табела веома велика, дели се на делове, и сваки део се извози у посебном току. За сваки део велике табеле креира се посебна датотека са именом .csv.partN, где је N број дела.

Када се сви делови велике табеле извезу, датотеке делова се спајају у датотеку под називом .csv.

Регуларни израз се користи за одређивање које ће табеле бити извезене. Могу се извозити само регуларне табеле (системске табеле, GTT, погледи, екстерне табеле нису подржане). Регуларни изрази морају бити у SQL синтакси, односно они који се користе у предикату SIMILAR TO.

За избор листе извезених табела, као и листе њихових PP у вишеструком режиму, користимо следећи упит:

sql
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

У једннитном режиму, овај упит се може поједноставити на

sql
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

У једннитном режиму, вредности поља PAGE_SEQUENCE и PP_CNT се не користе; додата су у захтев ради унификације излазних порука.

Резултат овог упита формира се у вектор структура:

cpp
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;
};

Овај вектор се попуњава помоћу функције декларисане као:

cpp
std::vector getTablesDesc(
    Firebird::ThrowStatusWrapper* status,
    Firebird::IAttachment* att,
    Firebird::ITransaction* tra,
    unsigned int sqlDialect,
    const std::string& tableIncludeFilter,
    bool singleWorker = true);

Последњи параметар singleWorker пребацује режим попуњавања std::vector ако је singleWorker = true, онда се користи захтев за једннитни режим, ако је singleWorker = false, онда се користи скупљи и сложенији упит за вишеструки режим. Нећу давати саму имплементацију, прилично је једноставна и можете је видети у изворном коду пројекта.

За извоз табеле у CSV формат, развијена је класа CSVExportTable, која садржи следеће методе:

cpp
    void prepare(Firebird::ThrowStatusWrapper* status, const std::string& tableName,
                 unsigned int sqlDialect, bool withDbkeyFilter = false);

    void printHeader(Firebird::ThrowStatusWrapper* status, csv::CSVFile& csv);
cpp
void printData(Firebird::ThrowStatusWrapper* status, csv::CSVFile& csv, int64_t ppNum = 0);

Метод prepare је намењен за изградњу и припрему упита који се користи за извоз табеле у CSV формат. Унутрашњи упит се конструише различито у зависности од параметра withDbkeyFilter. Ако је withDbkeyFilter = true, онда се упит гради са филтрирањем по опсегу RDB$DB_KEY:

sql
SELECT *
FROM tableName
WHERE RDB$DB_KEY >= MAKE_DBKEY('tableName', 0, 0, ?)
  AND RDB$DB_KEY < MAKE_DBKEY('tableName', 0, 0, ?)

у супротном, користи се поједностављени упит:

sql
SELECT *
FROM tableName

Вредност параметра withDbkeyFilter се поставља на true ако се користи мулти-нитни режим и табела је велика. Сматрамо да је табела велика ако је pp_cnt > 1.

Метод printHeader је дизајниран за штампање заглавља CSV датотеке (имена колона табеле).

Метод printData штампа податке табеле у CSV датотеку са PP странице број ppNum, ако је захтев припремљен коришћењем филтера по опсегу RDB$DB_KEY, и све податке табеле у супротном.

Сада погледајмо код за једнонитни режим

cpp
...

// Отварање главне конекције
Firebird::AutoRelease att(
    provider->attachDatabase(
        &status,
        m_database.c_str(),
        dbpLength,
        dpb
    )
);

// Покретање главне трансакције у режиму изолације 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)
    )
);
// Добијање листе табела коришћењем регуларног израза у m_filter.
// m_parallel поставља број паралелних нити када је једнак 1,
// онда се користи поједностављени упит за добијање листе табела,
// у супротном, за сваку табелу се генерише листа PP-ова и њихов број.
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) {
        // нема смисла користити филтер опсега RDB$DB_KEY овде
        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_parallel - 1 радних нити. Зашто је број додатних нити за 1 мањи? Да, зато што ће и главна нит такође извозити податке и једнака је додатним нитима. Преместимо заједнички део главног и додатног тока у посебну функцију:

cpp
void ExportApp::exportByTableDesc(Firebird::ThrowStatusWrapper* status, FBExport::CSVExportTable& csvExport, const TableDesc& tableDesc)
{
    // Ако tableDesc има pp_cnt > 1, онда описује само део табеле, и потребно је изградити
    // упит користећи филтер по опсегу 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";

    // Ако ово није први део табеле, онда запишите овај део у датотеку .csv.part, где
    // N - PP број. Касније ће делови табеле бити комбиновани у једну датотеку .csv
    if (tableDesc.page_sequence > 0) {
        fileName += ".part_" + std::to_string(tableDesc.page_sequence);
    }
    csv::CSVFile csv(m_outputDir / fileName);
    // Заглавље CSV датотеке треба штампати само у првом делу табеле.
    if (tableDesc.page_sequence == 0 && m_printHeader) {
        csvExport.printHeader(status, csv);
    }
    csvExport.printData(status, csv, tableDesc.page_sequence);
}

Описи табела или њихових делова налазе се у заједничком вектору са TableDesc структурама. Из овог вектора, свака радна нит узима табелу или следећи део. Да би се спречиле трке података, потребно је синхронизовати приступ заједничком ресурсу. Али std::vector се сам не мења, тако да можете синхронизовати само заједничку променљиву, која је индекс у овом вектору. То се лако може урадити коришћењем std::atomic као такве променљиве.

cpp
if (m_parallel == 1) {
    ...
}
else {
    // Одређивање броја додатних радних нити
    const auto workerCount = m_parallel - 1;

    // Добијање броја снимка из главне трансакције
    auto snapshotNumber = getSnapshotNumber(&status, tra);
    // променљива за чување изузетка унутар нити
    std::exception_ptr exceptionPointer = nullptr;
    std::mutex m;
	// атомски бројач
    // је индекс следеће табеле или њеног дела
    std::atomic<size_t> counter = 0;
    // базен радних нити
    std::vector<std::thread> thread_pool;
    thread_pool.reserve(workerCount);
    for (int i = 0; i < workerCount; i++) {
		// за сваку нит креирамо сопствену конекцију
        Firebird::AutoRelease workerAtt(
            provider->attachDatabase(
                &status,
                m_database.c_str(),
                dbpLength,
                dpb
            )
        );
		// и сопствену трансакцију којој прослеђујемо број снимка
        // да бисмо креирали заједнички снимак
        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)
            )
        );
		// креирање нити
        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) {
                    // инкрементирање атомског бројача
                    size_t localCounter = counter++;
					// ако су табеле или њихови делови завршени, изађи
                    // из бесконачне петље и заврши нит
                    if (localCounter >= tables.size())
                        break;
                    // добијање описа табеле или њеног дела
                    const auto& tableDesc = tables[localCounter];
                    // и обављање извоза
                    exportByTableDesc(&status, csvExport, tableDesc);
                }
                if (tra) {
                    tra->commit(&status);
                    tra.release();
                }

                if (att) {
                    att->detach(&status);
                    att.release();
                }
            }
            catch (...) {
				// ако дође до изузетка, сачувајте га за
                // касније ослобађање у главној нити
                std::unique_lock<std::mutex> lock(m);
                exceptionPointer = std::current_exception();
            }
        });
        thread_pool.push_back(std::move(t));
    }

    // извоз у главној нити
    FBExport::CSVExportTable csvExport(att, tra, fb_master);
    while (true) {
        // инкрементирање атомског бројача
        size_t localCounter = counter++;
        if (localCounter >= tables.size())
            break;
        // ако су табеле или њихови делови завршени, изађи

// из бесконечного цикла const auto& tableDesc = tables[localCounter]; exportByTableDesc(&status, csvExport, tableDesc); } // ожидание завершения рабочих потоков for (auto& th : thread_pool) { th.join(); } // если в рабочих потоках было исключение, выбрасываем его снова if (exceptionPointer) { std::rethrow_exception(exceptionPointer); } …

Code

Остаётся только объединить файлы, созданные для частей таблиц, в один файл для каждой из этих таблиц.

```cpp hljs
for (size_t i = 0; i < tables.size(); i++) {
    const auto& tableDesc = tables[i];
    // если количество PP больше 1,
    // то таблица большая и для неё было несколько частей
    if (tableDesc.pp_cnt > 1) {
        // основной файл для таблицы
        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++) {
            // файлы частей таблицы
            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();
    }
}

Давайте измерим производительность инструмента в однопоточном и многопоточном режимах.

Бенчмарк инструмента FBCSVExport

Сначала рассмотрим результаты сравнения многопоточного и однопоточного режимов экспорта на умеренном домашнем компьютере. === Windows

  • Операционная система: Windows 10 x64.

  • Процессор: Intel Core i3 8100, 4 ядра, 4 потока.

  • Память: 16 ГБ

  • Дисковая подсистема: NVME SSD (база данных), SATA SSD (папка для хранения CSV-файлов).

  • Firebird 4.0.4 x64

Результаты:

bash
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

Из результатов тестирования видно, что при использовании двух потоков ускорение составило 1,8 раза, что является хорошим результатом. Но параллельное выполнение экспорта в 4 потока также показало улучшение в 1,8 раза. Почему не в 3-4 раза? Дело в том, что сервер Firebird и утилита экспорта работают на одном компьютере, у которого всего 4 ядра. Таким образом, сам сервер Firebird использует 4 потока для чтения таблицы, а утилита FBCSVExport также использует 4 потока. Очевидно, что в этом случае довольно сложно достичь ускорения более чем в 2 раза. Поэтому попробуем на другом оборудовании, где количество ядер значительно больше.

Linux

  • Операционная система: CentOS 8.

  • Процессор: 2 процессора Intel Xeon E5-2603 v4, всего 12 ядер, 12 потоков.

  • Память: 32 ГБ

  • Дисковая подсистема: SAS HDD (RAID 10)

  • Firebird 4.0.4 x64

Результаты:

bash
[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

В этом случае оптимальное количество потоков для экспорта - 6 (6 потоков для Firebird и 6 потоков для утилиты FBCSVExport). При этом нам удалось достичь ускорения в 5 раз, что свидетельствует о довольно хорошей масштабируемости. На Linux-сервере и Windows-компьютере мы использовали идентичные базы данных, и вы, вероятно, заметили, что однопоточный экспорт на Windows был почти в 2 раза быстрее: это связано с более быстрой дисковой подсистемой (NVME-накопитель намного быстрее, чем SAS-диски, объединённые в RAID).

Итоги

В этой статье мы рассмотрели, как эффективно читать данные из таблиц СУБД Firebird с использованием параллелизма. Также был показан пример того, как можно использовать некоторые возможности СУБД Firebird для организации такого чтения в вашем программном обеспечении.

Огромное спасибо Владиславу Хорсуну, разработчику ядра Firebird, за помощь с этим материалом.

По любым вопросам или комментариям, пожалуйста, пишите на [email protected].