此页面为机器翻译。请阅读英文原文。 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 工具内部创建并行备份时使用的功能,我们还将展示如何在您的应用程序中使用它们进行并行数据读取。

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

示例工具 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 方法将表数据打印到 CSV 文件中,如果请求是通过 RDB$DB_KEY 范围过滤准备的,则从 PP 页码 ppNum 开始打印,否则打印所有表数据。

现在让我们看一下单线程模式的代码

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

从测试结果可以清楚地看出,使用两个线程时,加速比为 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 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]