Firebird에서 데이터 병렬 읽기
D.Simonov, V.Horsun
버전 1.0.5, 2023년 12월 5일
이 자료는 IBSurgeon www.ib-aid.com의 후원과 지원으로 제작되었습니다. IBSurgeon은 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 유틸리티 내부에서 병렬 백업을 생성할 때 사용되는 기능을 살펴보고, 이를 애플리케이션에서 데이터를 병렬로 읽는 데 어떻게 사용할 수 있는지도 보여드리겠습니다.
중요한 점은 여기서 SQL 쿼리를 실행할 때 Firebird 엔진 내부의 테이블 병렬 스캔에 대해 말하는 것이 아니라, 애플리케이션 내부의 병렬 스트림에서 데이터를 읽는 것에 대해 이야기한다는 것입니다.
샘플 도구 FBCSVExport
Firebird DBMS에서 데이터를 병렬로 읽는 것을 시연하기 위해 하나 이상의 테이블에서 CSV 형식으로 데이터를 내보내는 예제 유틸리티가 작성되었습니다.
그 설명과 오픈 소스 코드는 여기에 있습니다: https://github.com/IBSurgeon/FBCSVExport.git
유틸리티 설명과 아래 기사에서 볼 수 있듯이, 병렬 처리를 통해 데이터를 내보내고 다른 병렬 작업을 1개 스레드보다 2~10배 더 빠르게 수행할 수 있습니다(하드웨어에 따라 다름).
질문이 있으시면 [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 전용이고 모든 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 테이블에서 계산하는 것은 매우 쉽고, 가장 중요하게는 빠릅니다:
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번째 작업자가 오랫동안 1,000만 개의 레코드를 읽는 상황은 보고 싶지 않습니다.
그래서 gbak은 다르게 처리합니다. 각 프로세서에게 한 번에 1개의 PP를 발급하는 작업 코디네이터가 있습니다. 코디네이터는 총 PP 수와 이미 작업에 발급된 PP 수를 알고 있습니다. 작업자가 레코드 읽기를 완료하면 새 PP 번호를 위해 코디네이터에게 연락합니다. PP가 소진될 때까지(또는 활성 작업자가 있을 때까지) 계속됩니다. 물론 작업자와 코디네이터의 이러한 상호 작용에는 동기화가 필요합니다. 경험상 하나의 PP에 주어진 작업량으로 너무 자주 동기화하지 않아도 됩니다. 이 접근 방식을 통해 각 PP에 속한 실제 레코드 수와 관계없이 모든 작업자(따라서 CPU 코어)를 거의 고르게 작업으로 로드할 수 있습니다.
핸들러는 자신의 PP에서 레코드를 어떻게 읽나요? 이를 위해 Firebird 4.0부터(HQBird 2.5에서 처음 등장) 내장 함수 MAKE_DBKEY()가 있습니다. 이를 사용하여 지정된 PP의 첫 번째 레코드에 대한 RDB$DB_KEY(물리적 레코드 번호)를 얻을 수 있습니다.
그리고 이러한 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인 모든 레코드가 해당 PP에서만 읽힙니다.
이제 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 범위로 필터링하여 쿼리를 작성합니다:
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 메서드는 요청이 RDB$DB_KEY 범위 필터를 사용하여 준비된 경우 PP 페이지 번호 ppNum부터 테이블 데이터를 CSV 파일로 출력하고, 그렇지 않은 경우 모든 테이블 데이터를 출력합니다.
이제 단일 스레드 모드의 코드를 살펴보겠습니다
...
// 기본 연결 열기
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;
// 테이블 또는 해당 부분이 끝나면 종료
// 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);
}
...
남은 것은 테이블의 일부에 대해 생성된 파일들을 각 테이블에 대한 단일 파일로 결합하는 것입니다.
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
결과:
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.
-
프로세서: Intel Xeon E5-2603 v4 2개, 총 12코어, 12스레드.
-
메모리: 32 GB
-
디스크 하위 시스템: 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개입니다 (Firebird용 6개 스레드와 FBCSVExport 유틸리티용 6개 스레드). 동시에 5배 가속을 달성했으며, 이는 상당히 좋은 확장성을 나타냅니다. Linux 서버와 Windows 컴퓨터에서 동일한 데이터베이스를 사용했으며, Windows에서의 단일 스레드 내보내기가 거의 2배 더 빨랐다는 것을 눈치채셨을 것입니다. 이는 더 빠른 디스크 하위 시스템 (NVME 드라이브가 RAID로 결합된 SAS 드라이브보다 훨씬 빠름) 때문입니다.
요약
이 기사에서는 병렬 처리를 사용하여 Firebird DBMS 테이블에서 데이터를 효과적으로 읽는 방법을 고려했습니다. 또한 소프트웨어에서 이러한 읽기를 구성하기 위해 Firebird DBMS의 일부 기능을 사용하는 방법의 예를 보여주었습니다.
이 자료에 도움을 주신 Firebird 코어 개발자 Vladislav Khorsun에게 감사드립니다.
질문이나 의견이 있으시면 [email protected]으로 이메일을 보내주십시오.