Tato stránka byla strojově přeložena. Přečtěte si anglický originál. English

Knihovna IBSurgeon

Souběžné čtení dat ve Firebirdu

D.Simonov, V.Horsun

verze 1.0.5 z 05.12.2023

Tento materiál je sponzorován a vytvořen se sponzorstvím a podporou IBSurgeon www.ib-aid.com, dodavatele HQbird (pokročilé distribuce Firebird) a poskytovatele služeb optimalizace výkonu, migrace a technické podpory pro Firebird.

Materiál je licencován pod Public Documentation License https://www.firebirdsql.org/file/documentation/html/en/licenses/pdl/public-documentation-license.html

Související materiály:

Předmluva

Firebird 5.0 zavedl možnost používat paralelismus při vytváření zálohy pomocí nástroje gbak, mimo jiné paralelní funkce. Zpočátku se tato funkce objevila v HQbird 2.5, poté v HQbird 3.0 a 4.0, a poté byla portována do Firebird 5.0.

V tomto článku se budeme zabývat funkcemi, které se používají při vytváření paralelních záloh uvnitř nástroje gbak. Také ukážeme, jak je lze použít ve vašich aplikacích pro paralelní čtení dat.

Je důležité poznamenat, že zde nemluvíme o paralelním skenování tabulek uvnitř enginu Firebird při provádění SQL dotazů, ale o čtení dat uvnitř vaší aplikace v paralelních vláknech.

Vzorový nástroj FBCSVExport

Pro demonstraci paralelního čtení dat z DBMS Firebird byl napsán příkladový nástroj, který exportuje data z jedné nebo více tabulek do formátu CSV.

Jeho popis a jeho otevřený zdrojový kód jsou zde: https://github.com/IBSurgeon/FBCSVExport.git

Jak můžete vidět v popisu nástroje a z článku níže, prostřednictvím paralelního zpracování je možné exportovat data a provádět další paralelní operace 2-10x rychleji než v 1 vlákně (v závislosti na hardwaru).

Pro jakékoli dotazy kontaktujte prosím [email protected].

Paralelní čtení

Zamysleme se nad tím, jak číst data z několika tabulek paralelně. Jak víte, Firebird umožňuje provádět dotazy paralelně pouze v případě, že každý dotaz je proveden v samostatném připojení.

Vytvořme fond pracovních vláken. Hlavní vlákno aplikace je také pracovní vlákno, takže počet dalších pracovních vláken by měl být N - 1, kde N je celkový počet paralelních pracovníků. Každé pracovní vlákno bude spouštět své vlastní připojení a transakci.

První problém: jak zajistit konzistenci čtených dat?

Konzistentní čtení dat

Protože každé pracovní vlákno používá své vlastní připojení a svou vlastní transakci, vzniká problém nekonzistentního čtení - pokud je tabulka současně měněna jinými uživateli, pak čtená data mohou být nekonzistentní. V režimu jednoho vlákna používá gbak transakci s izolačním režimem SNAPSHOT, což umožňuje číst konzistentní informace na začátku transakce SNAPSHOT. Ale zde máme více transakcí a je nutné, aby viděly stejný “snímek”, aby četly stejná neměnná data.

Mechanismus vytváření sdíleného snímku pro různé transakce s izolačním režimem SNAPSHOT byl zaveden ve Firebird 4.0 (původně v HQBird 2.5, ale ve Firebird 4.0/HQBird 4.0 je jednodušší a efektivnější). Existují dva způsoby, jak vytvořit sdílený snímek:

  1. Pomocí SQL
  • získat číslo snímku z hlavní transakce (která je spuštěna v hlavním pracovním vlákně).
sql
SELECT RDB$GET_CONTEXT('SYSTEM', 'SNAPSHOT_NUMBER') FROM RDB$DATABASE
  • spustit další transakce s následujícím SQL:
sql
SET TRANSACTION SNAPSHOT AT NUMBER snapshot_number

kde snapshot_number je číslo získané předchozím dotazem.

  1. Pomocí API
  • získat číslo snímku z hlavní transakce (která je spuštěna v hlavním pracovním vlákně) pomocí funkce isc_transaction_info nebo ITransaction.getInfo se značkou fb_info_tra_snapshot_number;

  • spustit další transakce se značkou isc_tpb_at_snapshot_number se získaným číslem snímku.

Vzorový nástroj FBCSVExport, stejně jako gbak, používá druhý přístup. Tyto přístupy lze kombinovat - například získat číslo snímku pomocí SQL a použít získané číslo snímku ke spuštění dalších transakcí pomocí API, nebo naopak.

V FBCSVExport získáváme číslo snímku následujícím kódem:

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

Pro spuštění transakce s číslem snímku používáme následující kód:

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

Nyní budou data čtená z různých připojení konzistentní, takže můžeme rozdělit zátěž mezi pracovní vlákna.

Jak přesně rozdělit zátěž mezi pracovní vlákna? V případě úplného exportu všech tabulek nebo záložní kopie bude nejjednodušší variantou jedno pracovní vlákno na tabulku. Ale s tímto přístupem máme následující problém: pokud je v databázi mnoho malých tabulek a jedna velká tabulka, nebo dokonce jen jedna tabulka a je obrovská, nezaznamenáme zlepšení. V tomto případě některé vlákno dostane velkou tabulku a zbývající vlákna budou nečinná. Aby se tomu zabránilo, je nutné zpracovávat velkou tabulku po částech.

Poznámka Níže uvedený materiál se věnuje úplnému čtení tabulek. Pokud chcete organizovat paralelní čtení z nějakého dotazu (nebo pohledu), bude to vyžadovat poněkud odlišný přístup, který závisí na skutečných datech.

Rozdělení velké tabulky na části

Řekněme, že máme pouze jednu velkou tabulku, kterou chceme přečíst celou a co nejrychleji. Navrhuje se rozdělit ji na několik částí a číst každou část z vlastního vlákna nezávisle. Každé vlákno musí mít své vlastní připojení k databázi.

V tomto případě vyvstávají následující otázky:

  • Na kolik zpracovávaných částí by měla být tabulka rozdělena?

  • Jaký je nejlepší způsob rozdělení tabulky z hlediska přístupu k datům?

Odpovězme na tyto otázky v pořadí.

Na kolik zpracovávaných částí by měla být tabulka rozdělena?

Předpokládejme ideální scénář - server a klient jsou vyhrazeny pro Firebird, to znamená, že všechny CPU jsou zcela k dispozici. Pak se doporučuje:

a) Použít jako maximální počet paralelních částí dvojnásobný počet jader CPU na serveru. Proč 2x jádra? Víme jistě, že budeme mít zpoždění spojená s IO, takže si můžeme dovolit určité nadměrné využití CPU. Toto číslo by však mělo být považováno za počáteční nastavení, prakticky závisí na datech.

b) Vzít v úvahu počet jader na klientovi: pokud je jich na serveru mnohem více (obvyklá situace), pak může mít smysl dále omezit počet částí rozdělení, aby nedošlo k přetížení klienta (stejně nebude schopen zpracovat více a náklady na přepínání toků nikam nezmizí). Přesněji bude možné rozhodnout sledováním zatížení CPU klienta a serveru - pokud je na klientovi 100%, ale na serveru znatelně méně, pak má smysl snížit počet částí.

c) pokud jsou klient a server na stejném hostiteli, viz (a).

Pokud jsou klient a/nebo server zaneprázdněny něčím jiným, možná budete muset snížit počet částí. Může to být také ovlivněno schopností disků na serveru zpracovávat mnoho požadavků IO současně (sledujte velikost fronty a dobu odezvy).

Jaký je nejlepší způsob rozdělení tabulky z hlediska přístupu k datům?

Pro implementaci efektivního paralelního zpracování je důležité zajistit rovnoměrné rozdělení úloh mezi obslužné rutiny a minimalizovat jejich vzájemnou synchronizaci. Navíc je třeba si uvědomit, že synchronizace obslužných rutin může nastat jak na straně serveru, tak na straně klienta. Například několik obslužných rutin by nemělo používat stejné připojení k databázi. Méně zřejmý příklad: je špatné, pokud různé obslužné rutiny čtou záznamy ze stejných databázových stránek. Například když dvě obslužné rutiny čtou sudé a liché záznamy - to není efektivní. Synchronizace na klientovi může nastat během rozdělování úloh, během zpracování přijatých dat (alokace paměti pro výsledky) a tak dále.

Jedním z problémů “spravedlivého” rozdělení je, že klient neví, jak jsou záznamy rozděleny mezi stránky (a mezi klíče indexů), kolik záznamů nebo datových stránek existuje (u velkých tabulek by bylo příliš dlouhé počítat počet záznamů předem).

Podívejme se, jak gbak řeší tento problém.

Pro gbak je jednotkou práce sada záznamů z datových stránek (DP) patřících ke stejné stránce ukazatelů (PP). Na jedné straně je to poměrně velký počet záznamů, aby byla obslužná rutina zaneprázdněna bez nutnosti často žádat o nový kus dat (synchronizace). Na druhé straně, i když takové sady záznamů nemají přesně stejnou velikost, umožní to relativně rovnoměrně zatížit pracovníky. To znamená, že je docela možné, že jeden pracovník přečte N záznamů z jedné PP a druhý M záznamů, a M se může docela lišit od N. Tento přístup není ideální, ale je poměrně jednoduchý na implementaci a obvykle docela efektivní, přinejmenším ve velkém měřítku (s desítkami nebo stovkami (nebo více) PP).

Jak získat počet PP (Pointer Pages) pro danou tabulku? Je to docela snadné a, co je nejdůležitější, rychlé to vypočítat z tabulky 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

Dále bychom mohli jednoduše vydělit počet PP počtem pracovníků a dát každému pracovníkovi jejich vlastní kus. To je vhodné pro scénář, kdy paralelní zpracování provádí vývojář, který zná rozdělení dat. Ale pro běžnější scénář neexistuje žádná záruka, že takové “velké” kusy budou znamenat stejné množství práce. Nezajímá nás situace, kdy 15 pracovníků dokončí svou práci a stojí nečinně, a 16. čte svých 10M záznamů dlouhou dobu.

Proto to gbak dělá jinak. Existuje koordinátor práce, který vydává každému procesoru 1 PP najednou. Koordinátor ví, kolik PP je celkem a kolik jich již bylo vydáno k práci. Když pracovník dokončí čtení svých záznamů, kontaktuje koordinátora pro nové číslo PP. Pokračuje, dokud PP nedojdou (nebo dokud nejsou aktivní pracovníci). Samozřejmě, taková interakce pracovníků s koordinátorem vyžaduje synchronizaci. Zkušenosti ukazují, že množství práce dané jedné PP umožňuje nesynchronizovat příliš často. Tento přístup umožňuje prakticky rovnoměrně zatížit všechny pracovníky (a tedy i jádra CPU) prací, bez ohledu na skutečný počet záznamů patřících ke každé PP.

Jak handler čte záznamy ze svého PP? K tomu, počínaje Firebird 4.0 (poprvé se objevilo v HQBird 2.5), existuje vestavěná funkce MAKE_DBKEY(). S její pomocí můžete získat RDB$DB_KEY (fyzické číslo záznamu) pro první záznam na zadaném PP.

A pomocí těchto RDB$DB_KEY se vyberou potřebné záznamy:

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)

Například, pokud nastavíte loPP = 0 a hiPP = 1, pak budou přečteny všechny záznamy s PP = 0, a pouze z něj.

Nyní, když máte představu o tom, jak gbak funguje, můžete přejít k popisu implementace utility FBCSVExport.

Implementace utility FBCSVExport

Utilita FBCSVExport je navržena pro export dat z tabulek databáze Firebird do formátu CSV.

Každá tabulka je exportována do souboru s názvem .csv. V normálním (jednovláknovém) režimu jsou data z tabulek exportována sekvenčně v abecedním pořadí názvů tabulek.

V paralelním režimu jsou tabulky exportovány paralelně, každá tabulka v samostatném vlákně. Pokud je tabulka velmi velká, je rozdělena na části a každá část je exportována v samostatném proudu. Pro každou část velké tabulky je vytvořen samostatný soubor s názvem .csv.partN, kde N je číslo části.

Když jsou všechny části velké tabulky exportovány, soubory částí jsou sloučeny do souboru s názvem .csv.

K určení, které tabulky budou exportovány, se používá regulární výraz. Lze exportovat pouze běžné tabulky (systémové tabulky, GTT, pohledy, externí tabulky nejsou podporovány). Regulární výrazy musí být v syntaxi SQL, tedy ty, které se používají v predikátu SIMILAR TO.

Pro výběr seznamu exportovaných tabulek, stejně jako seznamu jejich PP ve vícevláknovém režimu, používáme následující dotaz:

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

V jednovláknovém režimu lze tento dotaz zjednodušit na

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

V jednovláknovém režimu se hodnoty polí PAGE_SEQUENCE a PP_CNT nepoužívají; jsou přidány do dotazu pro sjednocení výstupních zpráv.

Výsledek tohoto dotazu je vytvořen do vektoru struktur:

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

Tento vektor je naplněn pomocí funkce deklarované jako:

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

Poslední parametr singleWorker přepíná režim plnění std::vector - pokud singleWorker = true, použije se dotaz pro jednovláknový režim, pokud singleWorker = false, použije se nákladnější a složitější dotaz pro vícevláknový režim. Samotnou implementaci zde nebudu uvádět, je poměrně jednoduchá a můžete ji vidět ve zdrojovém kódu projektu.

Pro export tabulky do formátu CSV byla vyvinuta třída CSVExportTable, která obsahuje následující metody:

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

Metoda prepare je určena k sestavení a přípravě dotazu, který se používá k exportu tabulky do formátu CSV. Vnitřní dotaz je sestaven odlišně v závislosti na parametru withDbkeyFilter. Pokud withDbkeyFilter = true, pak je dotaz sestaven s filtrováním podle rozsahu 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, ?)

jinak se použije zjednodušený dotaz:

sql
SELECT *
FROM tableName

Hodnota parametru withDbkeyFilter je nastavena na true, pokud se používá vícevláknový režim a tabulka je velká. Tabulku považujeme za velkou, pokud pp_cnt > 1.

Metoda printHeader je určena k tisku hlavičky souboru CSV (názvy sloupců tabulky).

Metoda printData tiskne data tabulky do souboru CSV z čísla stránky PP ppNum, pokud byl dotaz připraven pomocí filtru podle rozsahu RDB$DB_KEY, a všechna data tabulky v opačném případě.

Nyní se podívejme na kód pro jednovláknový režim

cpp
...

// Otevření hlavního připojení
Firebird::AutoRelease att(
    provider->attachDatabase(
        &status,
        m_database.c_str(),
        dbpLength,
        dpb
    )
);

// Spuštění hlavní transakce v režimu izolace 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)
    )
);
// Získání seznamu tabulek pomocí regulárního výrazu v m_filter.
// m_parallel nastavuje počet paralelních vláken, když je roven 1,
// pak se použije zjednodušený dotaz pro získání seznamu tabulek,
// jinak se pro každou tabulku vygeneruje seznam PP a jejich počet.
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) {
        // nemá smysl zde používat rozsahový filtr 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);
    }
}

Vše je zde poměrně jednoduché a nevyžaduje další vysvětlení, takže přejděme k vícevláknové části.

Aby export probíhal ve vícevláknovém režimu, je nutné vytvořit dalších m_parallel - 1 pracovních vláken. Proč je počet dalších vláken o 1 menší? Ano, protože hlavní vlákno bude také exportovat data a je rovnocenné s dalšími vlákny. Přesuňme společnou část hlavního a dalšího toku do samostatné funkce:

cpp
void ExportApp::exportByTableDesc(Firebird::ThrowStatusWrapper* status, FBExport::CSVExportTable& csvExport, const TableDesc& tableDesc)
{
    // Pokud má tableDesc pp_cnt > 1, pak popisuje pouze část tabulky a je nutné sestavit
    // dotaz pomocí filtru podle rozsahu 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";

    // Pokud to není první část tabulky, zapište tuto část do souboru .csv.part, kde
    // N - číslo PP. Později budou části tabulky sloučeny do jediného souboru .csv
    if (tableDesc.page_sequence > 0) {
        fileName += ".part_" + std::to_string(tableDesc.page_sequence);
    }
    csv::CSVFile csv(m_outputDir / fileName);
    // Hlavička souboru CSV by měla být vytištěna pouze v první části tabulky.
    if (tableDesc.page_sequence == 0 && m_printHeader) {
        csvExport.printHeader(status, csv);
    }
    csvExport.printData(status, csv, tableDesc.page_sequence);
}

Popisy tabulek nebo jejich částí jsou umístěny ve společném vektoru se strukturami TableDesc. Z tohoto vektoru každé pracovní vlákno bere tabulku nebo další část. Aby se zabránilo datovým závodům, je nutné synchronizovat přístup ke sdílenému prostředku. Ale std::vector se sám nemění, takže lze synchronizovat pouze sdílenou proměnnou, kterou je index v tomto vektoru. To lze snadno provést pomocí std::atomic jako takové proměnné.

cpp
if (m_parallel == 1) {
    ...
}
else {
    // Určení počtu dalších pracovních vláken
    const auto workerCount = m_parallel - 1;

    // Získání čísla snapshotu z hlavní transakce
    auto snapshotNumber = getSnapshotNumber(&status, tra);
    // proměnná pro uložení výjimky uvnitř vlákna
    std::exception_ptr exceptionPointer = nullptr;
    std::mutex m;
	// atomický čítač
    // je indexem další tabulky nebo její části
    std::atomic<size_t> counter = 0;
    // fond pracovních vláken
    std::vector<std::thread> thread_pool;
    thread_pool.reserve(workerCount);
    for (int i = 0; i < workerCount; i++) {
		// pro každé vlákno vytvoříme vlastní připojení
        Firebird::AutoRelease workerAtt(
            provider->attachDatabase(
                &status,
                m_database.c_str(),
                dbpLength,
                dpb
            )
        );
		// a vlastní transakci, do které předáme číslo snapshotu
        // pro vytvoření sdíleného snapshotu
        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)
            )
        );
		// vytvoření vlákna
        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) {
                    // inkrementace atomického čítače
                    size_t localCounter = counter++;
					// pokud jsou tabulky nebo jejich části u konce, ukončíme
                    // nekonečnou smyčku a ukončíme vlákno
                    if (localCounter >= tables.size())
                        break;
                    // získání popisu tabulky nebo její části
                    const auto& tableDesc = tables[localCounter];
                    // a provedení exportu
                    exportByTableDesc(&status, csvExport, tableDesc);
                }
                if (tra) {
                    tra->commit(&status);
                    tra.release();
                }

                if (att) {
                    att->detach(&status);
                    att.release();
                }
            }
            catch (...) {
				// pokud dojde k výjimce, uložíme ji pro
                // následné uvolnění v hlavním vlákně
                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);
    }
    ...

Zbývá pouze spojit soubory, které byly vytvořeny pro části tabulek, do jednoho souboru pro každou z těchto tabulek.

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

Pojďme změřit výkon nástroje v jednovláknovém a vícevláknovém režimu.

Benchmark nástroje FBCSVExport

Nejprve se podíváme na výsledky porovnání vícevláknového a jednovláknového režimu exportu na běžném domácím počítači. === Windows

  • Operační systém: Windows 10 x64.

  • Procesor: Intel Core i3 8100, 4 jádra, 4 vlákna.

  • Paměť: 16 GB

  • Diskový subsystém: NVME SSD (databáze), SATA SSD (složka pro ukládání souborů CSV).

  • Firebird 4.0.4 x64

Výsledky:

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

Z výsledků testu je jasné, že při použití dvou vláken bylo zrychlení 1,8krát, což je dobrý výsledek. Ale paralelní provádění exportu ve 4 vláknech také ukázalo zlepšení 1,8krát. Proč ne 3-4? Faktem je, že server Firebird a exportní nástroj běží na stejném počítači, který má pouze 4 jádra. Server Firebird tedy používá 4 vlákna ke čtení tabulky a nástroj FBCSVExport také používá 4 vlákna. Je zřejmé, že v tomto případě je poměrně obtížné dosáhnout zrychlení více než 2krát. Proto to zkusíme na jiném hardwaru, kde je počet jader výrazně větší.

Linux

  • Operační systém: CentOS 8.

  • Procesor: 2 procesory Intel Xeon E5-2603 v4, celkem 12 jader, 12 vláken.

  • Paměť: 32 GB

  • Diskový subsystém: SAS HDD (RAID 10)

  • Firebird 4.0.4 x64

Výsledky:

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

V tomto případě je optimální počet vláken pro export 6 (6 vláken pro Firebird a 6 vláken pro nástroj FBCSVExport). Zároveň se nám podařilo dosáhnout 5násobného zrychlení, což naznačuje poměrně dobrou škálovatelnost. Na serveru Linux a počítači Windows jsme použili identické databáze a pravděpodobně jste si všimli, že jednovláknový export na Windows byl téměř 2krát rychlejší: je to způsobeno rychlejším diskovým subsystémem (NVME disk je mnohem rychlejší než SAS disky spojené do RAID).

Shrnutí

V tomto článku jsme se zabývali tím, jak efektivně číst data z tabulek Firebird DBMS pomocí paralelismu. Také byl ukázán příklad, jak můžete využít některé možnosti Firebird DBMS k organizaci takového čtení ve vašem softwaru.

Velké poděkování patří Vladislavu Khorsunovi, vývojáři jádra Firebird, za pomoc s tímto materiálem.

Pro jakékoli dotazy nebo připomínky nám prosím napište na [email protected].