このページは機械翻訳されています。英語の原文をお読みください。 English

IBSurgeon ライブラリ

Firebirdにおけるデータの並列読み取り

D.Simonov、V.Horsun

版本1.0.5,日期2023年12月5日

本材料由IBSurgeon www.ib-aid.com赞助并创建,IBSurgeon是HQbird(Firebird的高级发行版)的供应商,并为Firebird提供性能优化、迁移和技术支持服务。

本材料根据公共文档许可证授权 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工具内部创建并行备份时使用的功能,我们还将展示如何在您的应用程序中使用它们进行并行数据读取。

需要注意的是,这里我们不是在讨论执行SQL查询时Firebird引擎内部的表并行扫描,而是在您的应用程序并行流中读取数据。

示例工具FBCSVExport

为了演示从Firebird DBMS并行读取数据,编写了一个示例工具,用于将一个或多个表中的数据导出为CSV格式。

其描述和开源代码位于:https://github.com/IBSurgeon/FBCSVExport.git

正如您在工具描述和下面的文章中所看到的,通过并行处理,可以比单线程快2-10倍的速度导出数据和执行其他并行操作(取决于硬件)。

如有任何问题,请联系 [email protected]

并行读取

让我们思考如何并行读取多个表中的数据。众所周知,Firebird只允许在每个查询在单独连接中执行时并行执行查询。

让我们创建一个工作线程池。主应用程序线程也是一个工作线程,因此额外工作线程的数量应为N - 1,其中N是并行工作线程的总数。每个工作线程将运行自己的连接和事务。

第一个问题:如何确保读取数据的一致性?

一致的数据读取

由于每个工作线程使用自己的连接和事务,因此会出现不一致读取的问题–如果表同时被其他用户更改,则读取的数据可能不一致。在单线程模式下,gbak使用SNAPSHOT隔离模式的事务,这使得可以在SNAPSHOT事务开始时读取一致的信息。但这里我们有多个事务,需要它们看到相同的“快照”,以便它们读取相同的不变数据。

在Firebird 4.0中引入了为具有SNAPSHOT隔离模式的不同事务创建共享快照的机制(最初在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_infoITransaction.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,即所有CPU完全由我们支配。那么建议:

a) 使用服务器CPU核心数的两倍作为最大并行部分数。为什么是2倍核心数?我们知道肯定会有与IO相关的延迟,因此我们可以允许一些额外的CPU使用。但是,此数字应视为初始设置,实际上取决于数据。

b) 考虑客户端的核心数:如果服务器上的核心数多得多(通常情况),那么可能有必要进一步限制分区部分的数量,以免使客户端过载(无论如何它都无法处理更多,而且切换流的成本不会消失)。通过监控客户端和服务器的CPU负载可以更精确地决定–如果客户端为100%,但服务器明显较低,则有必要减少部分数量。

c) 如果客户端和服务器是同一台主机,则参见 (a)。

如果客户端和/或服务器正忙于处理其他事务,您可能需要减少分片数量。这也可能受到服务器磁盘同时处理大量 IO 请求的能力的影响(请监控队列大小和响应时间)。

从数据访问的角度来看,划分表的最佳方式是什么?

要实现有效的并行处理,确保任务在各处理器之间均匀分布并尽量减少它们之间的相互同步非常重要。此外,您需要记住,处理器的同步可能发生在服务器端,也可能发生在客户端。例如,多个处理器不应使用同一个数据库连接。一个不太明显的例子:不同的处理器从相同的数据库页面读取记录是不好的。例如,当两个处理器分别读取偶数和奇数记录时,效率不高。客户端的同步可能发生在任务分发期间、处理接收到的数据期间(为结果分配内存)等。

“公平”分区的一个问题是,客户端不知道记录在页面(以及索引键)之间是如何分布的,也不知道有多少记录或数据页(对于大型表,预先计算记录数量可能耗时过长)。

让我们看看 gbak 是如何解决这个问题的。

对于 gbak,一个工作单元是来自属于同一指针页(PP)的数据页(DP)的一组记录。一方面,这组记录的数量足够大,可以让处理器保持忙碌,而无需频繁请求新的数据块(同步)。另一方面,即使这些记录集的大小不完全相同,它也能相对均匀地加载工作进程。也就是说,一个工作进程可能从一个 PP 读取 N 条记录,而另一个读取 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 个工作进程长时间读取其 1000 万条记录的情况。

这就是为什么 gbak 采用不同的方式。有一个工作协调器,每次向每个处理器发放 1 个 PP。协调器知道总共有多少个 PP,以及已经发放了多少个用于工作。当工作进程完成其记录读取后,它会联系协调器获取新的 PP 编号。这个过程一直持续到 PP 用完(或有活动的工作进程)。当然,工作进程与协调器的这种交互需要同步。经验表明,一个 PP 所分配的工作量允许您不必过于频繁地同步。这种方法可以几乎均匀地将工作加载到所有工作进程(以及 CPU 核心)上,而不管每个 PP 实际关联的记录数量如何。

处理器如何从其 PP 读取记录?为此,从 Firebird 4.0 开始(首次出现在 HQBird 2.5 中),有一个内置函数 MAKE_DBKEY()。借助它,您可以获取指定 PP 上第一条记录的 RDB$DB_KEY(物理记录号)。

并借助这些 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 的所有记录,并且仅从该 PP 读取。

现在您已经了解了 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_SEQUENCEPP_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 メソッドは、リクエストが RDB$DB_KEY の範囲によるフィルタを使用して準備された場合はPPページ番号 ppNum からテーブルデータをCSVファイルに出力し、それ以外の場合はすべてのテーブルデータを出力します。

次に、シングルスレッドモードのコードを見てみましょう

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;
        // テーブルまたはその部分が終了した場合は、終了します
cpp
// 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);
    }
    ...

残る作業は、テーブルの一部ごとに作成されたファイルを、各テーブルごとに1つのファイルに結合することだけです。

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

それでは、このツールのパフォーマンスをシングルスレッドモードとマルチスレッドモードで測定してみましょう。

FBCSVExport ツールのベンチマーク

まず、一般的な家庭用コンピュータで、マルチスレッドとシングルスレッドのエクスポートモードを比較した結果を見てみましょう。 === Windows

  • オペレーティングシステム: Windows 10 x64。

  • プロセッサ: Intel Core i3 8100、4コア、4スレッド。

  • メモリ: 16 GB

  • ディスクサブシステム: 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

テスト結果から、2つのスレッドを使用した場合、速度が1.8倍向上しており、これは良い結果です。しかし、4スレッドでの並列エクスポート実行も1.8倍の改善を示しました。なぜ3〜4倍にならないのでしょうか? 実際には、Firebirdサーバーとエクスポートユーティリティが同じコンピュータ上で実行されており、そのコンピュータには4コアしかありません。したがって、Firebirdサーバー自体がテーブルの読み取りに4スレッドを使用し、FBCSVExport ユーティリティも4スレッドを使用します。明らかに、この場合、2倍以上の高速化を達成することはかなり困難です。そこで、コア数が大幅に多い別のハードウェアで試してみます。

Linux

  • オペレーティングシステム: CentOS 8。

  • プロセッサ: Intel Xeon E5-2603 v4 プロセッサ 2基、合計12コア、12スレッド。

  • メモリ: 32 GB

  • ディスクサブシステム: 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です(Firebird用に6スレッド、FBCSVExport ユーティリティ用に6スレッド)。同時に、5倍の高速化を達成することができ、これはかなり優れたスケーラビリティを示しています。LinuxサーバーとWindowsコンピュータでは同一のデータベースを使用しましたが、お気づきかもしれませんが、Windowsでのシングルスレッドエクスポートはほぼ2倍高速でした。これは、より高速なディスクサブシステム(NVMEドライブは、RAIDで構成されたSASドライブよりもはるかに高速)によるものです。

まとめ

この記事では、並列処理を使用してFirebird DBMSテーブルからデータを効率的に読み取る方法を検討しました。また、ソフトウェアでこのような読み取りを編成するためにFirebird DBMSのいくつかの機能をどのように使用できるかの例を示しました。

この資料の作成に協力してくださったFirebirdコア開発者のVladislav Khorsun氏に心から感謝いたします。

ご質問やコメントがございましたら、[email protected] までメールでお問い合わせください。

Code