Паралельне читання даних у Firebird
Д.Симонов, В.Хорсун
версія 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
Пов’язані матеріали:
- Безкоштовна книга “Detailed New Featuires Of Firebird 5”: доступна як HTML та як PDF (119 сторінок).
Передмова
Firebird 5.0 представив можливість використання паралелізму при створенні резервної копії за допомогою утиліти gbak, серед інших паралельних функцій. Спочатку ця функція з’явилася в HQbird 2.5, потім у HQbird 3.0 та 4.0, а потім була перенесена до Firebird 5.0.
У цій статті ми розглянемо функції, які використовуються при створенні паралельних резервних копій всередині утиліти gbak. Ми також покажемо, як їх можна використовувати у ваших застосунках для паралельного читання даних.
Важливо зазначити, що тут ми не говоримо про паралельне сканування таблиць всередині рушія Firebird при виконанні SQL-запитів, а про читання даних всередині вашого застосунку паралельними потоками.
Приклад інструменту FBCSVExport
Для демонстрації паралельного читання даних із СКБД Firebird було написано приклад утиліти, яка експортує дані з однієї або кількох таблиць у формат 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 він простіший та ефективніший). Існує два способи створення спільного знімка:
- За допомогою SQL
- отримати номер знімка з головної транзакції (яка запущена в головному робочому потоці).
SELECT RDB$GET_CONTEXT('SYSTEM', 'SNAPSHOT_NUMBER') FROM RDB$DATABASE
- запустити інші транзакції за допомогою наступного SQL:
SET TRANSACTION SNAPSHOT AT NUMBER snapshot_number
де snapshot_number - номер, отриманий попереднім запитом.
- За допомогою API
-
отримати номер знімка з головної транзакції (яка запущена в головному робочому потоці) за допомогою функції
isc_transaction_infoабоITransaction.getInfoз тегомfb_info_tra_snapshot_number; -
запустити інші транзакції з тегом
isc_tpb_at_snapshot_numberз отриманим номером знімка.
Приклад інструменту FBCSVExport, а також gbak, використовує другий підхід. Ці підходи можна змішувати - наприклад, отримати номер знімка за допомогою SQL, а потім використати отриманий номер знімка для запуску інших транзакцій за допомогою API, або навпаки.
У FBCSVExport ми отримуємо номер знімка за допомогою наступного коду:
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;
}
Щоб запустити транзакцію з номером знімка, ми використовуємо наступний код:
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 ядер? Ми точно знаємо, що будуть затримки, пов’язані з введенням/виведенням, тому ми можемо дозволити деяке додаткове використання процесора. Однак це число слід розглядати як початкове налаштування, на практиці воно залежить від даних.
б) Враховувати кількість ядер на клієнті: якщо на сервері їх значно більше (звичайна ситуація), то може мати сенс додатково обмежити кількість частин розбиття, щоб не перевантажувати клієнта (він все одно не зможе обробити більше, а витрати на перемикання потоків нікуди не подінуться). Точніше рішення можна буде прийняти, спостерігаючи за завантаженням процесора клієнта та сервера - якщо на клієнті воно 100%, а на сервері помітно менше, то має сенс зменшити кількість частин.
c) якщо клієнт і сервер знаходяться на одному хості, тоді дивіться (a).
Якщо клієнт та/або сервер зайняті чимось іншим, можливо, доведеться зменшити кількість частин. На це також може впливати здатність дисків на сервері обробляти багато IO-запитів одночасно (слідкуйте за розміром черги та часом відповіді).
Який найкращий спосіб розділити таблицю з точки зору доступу до даних?
Для ефективного паралельного оброблення важливо забезпечити рівномірний розподіл завдань між обробниками та мінімізувати їх взаємну синхронізацію. Крім того, потрібно пам’ятати, що синхронізація обробників може відбуватися як на стороні сервера, так і на стороні клієнта. Наприклад, кілька обробників не повинні використовувати одне й те саме з’єднання з базою даних. Менш очевидний приклад: погано, якщо різні обробники читають записи з одних і тих самих сторінок бази даних. Наприклад, коли два обробники читають парні та непарні записи - це неефективно. Синхронізація на клієнті може відбуватися під час розподілу завдань, під час оброблення отриманих даних (виділення пам’яті для результатів) тощо.
Однією з проблем «справедливого» розділення є те, що клієнт не знає, як записи розподілені по сторінках (і за ключами індексів), скільки записів або сторінок даних існує (для великих таблиць підрахунок кількості записів заздалегідь займе занадто багато часу).
Подивімося, як gbak вирішує цю проблему.
Для gbak одиницею роботи є набір записів із сторінок даних (DP), що належать до однієї сторінки покажчиків (PP). З одного боку, це досить велика кількість записів, щоб тримати обробника зайнятим без необхідності часто запитувати новий фрагмент даних (синхронізація). З іншого боку, навіть якщо такі набори записів не мають точно однакового розміру, це дозволить відносно рівномірно завантажити працівників. Тобто цілком можливо, що один працівник прочитає N записів з одного PP, а інший - M записів, і M може суттєво відрізнятися від N. Цей підхід не ідеальний, але його досить просто реалізувати, і він зазвичай досить ефективний, принаймні у великих масштабах (з десятками або сотнями (або більше) PP).
Як отримати кількість PP (сторінок покажчиків) для заданої таблиці? Це досить легко і, що найважливіше, швидко обчислити з таблиці RDB$PAGES:
SELECT RDB$PAGE_SEQUENCE
FROM RDB$PAGES
WHERE RDB$RELATION_ID = ? AND RDB$PAGE_TYPE = 4
ORDER BY RDB$PAGE_SEQUENCE DESC ROWS 1
Далі ми могли б просто розділити кількість PP на кількість працівників і дати кожному працівнику свою частину. Це підходить для сценарію, коли паралельне оброблення виконує розробник, який знає розподіл даних. Але для більш поширеного сценарію немає гарантії, що такі «великі» частини означатимуть однаковий обсяг роботи. Нам не цікаво бачити ситуацію, коли 15 працівників завершили свою роботу й простоюють, а 16-й довго читає свої 10 мільйонів записів.
Саме тому gbak робить це інакше. Існує координатор роботи, який видає кожному процесору по 1 PP за раз. Координатор знає, скільки всього PP і скільки вже видано для роботи. Коли працівник завершує читання своїх записів, він звертається до координатора за новим номером PP. Це триває, доки PP не закінчаться (або є активні працівники). Звичайно, така взаємодія працівників із координатором потребує синхронізації. Досвід показує, що обсяг роботи, який дає один PP, дозволяє не синхронізуватися занадто часто. Цей підхід дозволяє практично рівномірно завантажити всіх працівників (а отже, і ядра CPU) роботою, незалежно від фактичної кількості записів, що належать кожному PP.
Як обробник читає записи зі свого PP? Для цього, починаючи з Firebird 4.0 (вперше з’явилося в HQBird 2.5), існує вбудована функція MAKE_DBKEY(). З її допомогою можна отримати RDB$DB_KEY (фізичний номер запису) для першого запису на вказаному PP.
А за допомогою цих RDB$DB_KEY вибираються необхідні записи:
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 у багатопотоковому режимі, ми використовуємо наступний запит:
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
В однопотоковому режимі цей запит можна спростити до
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 не використовуються; вони додані до запиту для уніфікації вихідних повідомлень.
Результат цього запиту формується у вектор структур:
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;
};
Цей вектор заповнюється за допомогою функції, оголошеної як:
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, який містить такі методи:
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);
Метод `prepare` призначений для побудови та підготовки запиту, який використовується для експорту таблиці у формат CSV. Внутрішній запит будується по-різному залежно від параметра `withDbkeyFilter`. Якщо `withDbkeyFilter = true`, то запит будується з фільтрацією за діапазоном `RDB$DB_KEY`:
```sql hljs
SELECT *
FROM tableName
WHERE RDB$DB_KEY >= MAKE_DBKEY('tableName', 0, 0, ?)
AND RDB$DB_KEY < MAKE_DBKEY('tableName', 0, 0, ?)
інакше використовується спрощений запит:
SELECT *
FROM tableName
Значення параметра withDbkeyFilter встановлюється в true, якщо використовується багатопотоковий режим і таблиця велика. Ми вважаємо таблицю великою, якщо pp_cnt > 1.
Метод printHeader призначений для виведення заголовка CSV-файлу (назви стовпців таблиці).
Метод printData виводить дані таблиці у CSV-файл, починаючи з номера сторінки PP ppNum, якщо запит був підготовлений з використанням фільтра за діапазоном RDB$DB_KEY, та всі дані таблиці в іншому випадку.
Тепер розглянемо код для однопотокового режиму
...
// Відкриття основного з'єднання
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 менша? Так, тому що головний потік також буде експортувати дані, і він рівний додатковим потокам. Винесемо спільну частину головного та додаткового потоків в окрему функцію:
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 як такої змінної.
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;
// якщо таблиці або їх частини закінчилися, виходимо
```markdown
// з нескінченного циклу
const auto& tableDesc = tables[localCounter];
exportByTableDesc(&status, csvExport, tableDesc);
}
// чекаємо завершення робочих потоків
for (auto& th : thread_pool) {
th.join();
}
// якщо в робочих потоках був виняток, кидаємо його знову
if (exceptionPointer) {
std::rethrow_exception(exceptionPointer);
}
...
Залишається лише об’єднати файли, які були створені для частин таблиць, в один файл для кожної з цих таблиць.
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
Результати:
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
Результати:
[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].