Однажды мне потребовалось забирать регулярно относительно большие объемы данных в MS SQL из PostgreSQL. Неожиданно выяснилось, что самый очевидный способ, через Linked Server на родные ODBC к PostgreSQL, очень медленный.
История вопроса
На этапе прототипирования все было хорошо. Просто потому, что протипировалось всего несколько тысяч записей. Как только перешли к разработке, сразу возникло подозрение, что с производительностью что-то не то:
SET STATISTICS TIME ON
DECLARE
@sql_str nvarchar(max)
DROP TABLE IF EXISTS #t
CREATE TABLE #t (
N int,
T datetime,
S varchar(256)
)
SELECT @sql_str='
SELECT N, T, md5((R*100000*random())::text) S
FROM generate_series(1,1000,1) N
CROSS JOIN generate_series($$2020-01-01$$::timestamp,
$$2020-12-31$$::timestamp, $$1 day$$::interval) T
CROSS JOIN generate_series(1,100,1) R'
INSERT #t (N, T)
EXEC (@sql_str) AT LINKED_SERVER_TO_POSTGRES
Такой простейший пример выборки 36,6 миллионов записей оказался жутко медленным:
SQL Server Execution Times:
CPU time = 927640 ms, elapsed time = 1705275 ms.
Решение
В первую очередь, захотелось исключить самый подозрительный элемент - ODBC. К тому времени MS уже предоставлял утилиту bcp для Linux. Поэтому bcp был установлен на сервер, где работал PostgreSQL и проведен следующий тест:
SET STATISTICS TIME ON
DECLARE
@sql_str nvarchar(max),
@proxy_account sysname='proxy_account',
@proxy_password sysname='111111'
DROP TABLE IF EXISTS ##t
CREATE TABLE ##t (
N int,
T datetime,
S varchar(256)
)
SELECT @sql_str='
COPY (
SELECT N, T, md5((R*100000*random())::text) S
FROM generate_series(1,1000,1) N
CROSS JOIN generate_series($$2020-01-01$$::timestamp,
$$2020-12-31$$::timestamp, $$1 day$$::interval) T
CROSS JOIN generate_series(1,100,1) R )
TO PROGRAM $pgm$ tmp_file=$'+'(mktemp /tmp/pgsql_bcp_to_mssql.XXXXXXXXX); '
+'cat > $tmp_file; /opt/mssql-tools/bin/bcp ''##t'' '
+'in $tmp_file -S '+REPLACE(@@SERVERNAME,'','\')
+' -U '+@proxy_account+' -P '''
+@proxy_password+''' -c -b 10000000 -a 65535; '
+'rm $tmp_file $pgm$ NULL $nil$$nil$;'
EXEC (@sql_str) AT LINKED_SERVER_TO_POSTGRES
Результат сразу порадовал, причем сильно:
SQL Server Execution Times:
CPU time = 0 ms, elapsed time = 132794 ms.
Реализация
Не сложно заметить, что такой подход требует явного указания логина и пароля. Причем, bcp для Linux до сих пор не умеет авторизоваться через Kerberos. Поэтому использовать его можно только указывая кредентиалы в командной строке.
Вторая проблема в том, что в обычную временную таблицу bcp записать не может. Он ее просто не увидит. Значит нужно использовать постоянную таблицу или глобальную временную.
Давать права пользователю, кредентиалы которого открытым текстом видны в SQL запросе, на таблицы своей БД совершенно не хочется. Тем более на запись. Поэтому остается только вариант с глобальной временной таблицей.
В связи с тем, что процессы на сервере могут запускаться асинхронно и одновременно, использовать фиксированное имя глобальной временной таблицы опасно. Но тут нас опять спасает динамический SQL.
Итоговое решение следующее:
DECLARE
@sql_str nvarchar(max),
@proxy_account sysname='proxy_account',
@proxy_password sysname='111111'
SELECT @sql_str='
DROP TABLE IF EXISTS ##proxy_table_'+CONVERT(nvarchar(max),@@SPID)+'
CREATE TABLE ##proxy_table_'+CONVERT(nvarchar(max),@@SPID)+' (
N int,
T datetime,
S varchar(256)
)'
EXEC (@sql_str)
SELECT @sql_str='
COPY (
SELECT N, T, md5((R*100000*random())::text) S
FROM generate_series(1,1000,1) N
CROSS JOIN generate_series($$2020-01-01$$::timestamp,
$$2020-12-31$$::timestamp, $$1 day$$::interval) T
CROSS JOIN generate_series(1,100,1) R )
TO PROGRAM $pgm$ tmp_file=$'+'(mktemp /tmp/pgsql_bcp_to_mssql.XXXXXXXXX); '
+'cat > $tmp_file; /opt/mssql-tools/bin/bcp ''##proxy_table_'''
+CONVERT(nvarchar(max),@@SPID)+' '
+'in $tmp_file -S '+REPLACE(@@SERVERNAME,'\','\\')
+' -U '+@proxy_account+' -P '''
+@proxy_password+''' -c -b 10000000 -a 65535; '
+'rm $tmp_file $pgm$ NULL $nil$$nil$;'
EXEC (@sql_str) AT LINKED_SERVER_TO_POSTGRES
Пояснения
В PostgreSQL команда COPY может писать в файл или на стандартный ввод вызываемой ей программы. В данном случае вместо программы использован скрипт на sh. Вывод COPY, поступающий на стандартный ввод, записывается во временный файл с уникальным именем, формируемым mktemp. К сожалению, bcp не умеет читать данные со стандартного ввода, поэтому приходится ему создавать файл.
Для совместимости формата, формируемого командой COPY и формата, ожидаемого bcp, обязательно следует указывать в COPY параметр NULL $nil$$nil$
Остальные параметры bcp:
-c - символьный формат, так как бинарный формат PostgreSQL не совместим с бинарным форматом MS SQL и мы вынуждены использовать только символьный;
-b - количество записей, вставляемых одной транзакцией. В моей конфигурации десять миллионов оказалось оптимальным значением. В иной конфигурации это число, скорее, может потребоваться уменьшить, чем увеличить;
-a - размер пакета. В нашем случае лучше указывать сразу максимальный. Если сервер не поддерживает указанную длину пакета, то просто будет использована максимальная длина пакета, поддерживаемая сервером.
Если кто-то знает более быстрый способ получения данных на MS SQL из PostgreSQL - буду очень рад увидеть описание этого способа в комментариях.
Сравнение с SSIS
DTSX
<?xml version="1.0"?>
<DTS:Executable xmlns:DTS="www.microsoft.com/SqlServer/Dts"
DTS:refId="Package"
DTS:CreationDate="4/23/2021 10:48:39 PM"
DTS:CreationName="Microsoft.Package"
DTS:CreatorComputerName="server"
DTS:CreatorName="DOMAIN\user"
DTS:Description="SSIS Package Description"
DTS:DTSID="{9AF1F924-8357-4B7E-9766-2A78BB9E063D}"
DTS:ExecutableType="Microsoft.Package"
DTS:LastModifiedProductVersion="15.0.1900.63"
DTS:LocaleID="1049"
DTS:MaxErrorCount="0"
DTS:ObjectName="Package"
DTS:ProtectionLevel="0"
DTS:VersionGUID="{92B9BB2D-B8C6-4C73-BB9E-A2ADFFE8A8C0}">
<DTS:Property
DTS:Name="PackageFormatVersion">8</DTS:Property>
<DTS:ConnectionManagers>
<DTS:ConnectionManager
DTS:refId="Package.ConnectionManagers[DestinationConnectionOLEDB]"
DTS:CreationName="OLEDB"
DTS:DTSID="{33E470E0-8AB7-4D9B-B0B2-53C9411BB976}"
DTS:ObjectName="DestinationConnectionOLEDB">
<DTS:ObjectData>
<DTS:ConnectionManager
DTS:ConnectionString="Data Source=ms-sql;Initial Catalog=db;Provider=SQLNCLI11;Integrated Security=SSPI;Auto Translate=false;" />
</DTS:ObjectData>
</DTS:ConnectionManager>
<DTS:ConnectionManager
DTS:refId="Package.ConnectionManagers[SourceConnectionAdoNET]"
DTS:CreationName="ADO.NET:System.Data.Odbc.OdbcConnection, System.Data, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
DTS:DTSID="{2840A792-1CF7-495A-A55F-875331D032BB}"
DTS:ObjectName="SourceConnectionAdoNET">
<DTS:ObjectData>
<DTS:ConnectionManager
DTS:ConnectionString="Dsn=PostgreSQL;" />
</DTS:ObjectData>
</DTS:ConnectionManager>
</DTS:ConnectionManagers>
<DTS:Variables />
<DTS:Executables>
<DTS:Executable
DTS:refId="Package\Data Flow Task 1"
DTS:CreationName="Microsoft.Pipeline"
DTS:DelayValidation="True"
DTS:DTSID="{A38FAC70-DCF8-4AD6-9416-E6204931F4FA}"
DTS:ExecutableType="Microsoft.Pipeline"
DTS:FailPackageOnFailure="True"
DTS:LocaleID="-1"
DTS:ObjectName="Data Flow Task 1"
DTS:TaskContact="Performs high-performance data extraction, transformation and loading;Microsoft Corporation; Microsoft SQL Server; (C) Microsoft Corporation; All Rights Reserved;http://www.microsoft.com/sql/support/default.asp;1">
<DTS:Variables />
<DTS:ObjectData>
<pipeline
defaultBufferSize="3145728"
version="1">
<components>
<component
refId="Package\Data Flow Task 1\Destination - Query3"
componentClassID="Microsoft.OLEDBDestination"
contactInfo="OLE DB Destination;Microsoft Corporation; Microsoft SQL Server; (C) Microsoft Corporation; All Rights Reserved; http://www.microsoft.com/sql/support;4"
description="OLE DB Destination"
name="Destination - Query3"
usesDispositions="true"
validateExternalMetadata="False"
version="4">
<properties>
<property
dataType="System.Int32"
description="The number of seconds before a command times out. A value of 0 indicates an infinite time-out."
name="CommandTimeout">0</property>
<property
dataType="System.String"
description="Specifies the name of the database object used to open a rowset."
name="OpenRowset">[dbo].[Query3]</property>
<property
dataType="System.String"
description="Specifies the variable that contains the name of the database object used to open a rowset."
name="OpenRowsetVariable"></property>
<property
dataType="System.String"
description="The SQL command to be executed."
name="SqlCommand"
UITypeEditor="Microsoft.DataTransformationServices.Controls.ModalMultilineStringEditor"></property>
<property
dataType="System.Int32"
description="Specifies the column code page to use when code page information is unavailable from the data source."
name="DefaultCodePage">1251</property>
<property
dataType="System.Boolean"
description="Forces the use of the DefaultCodePage property value when describing character data."
name="AlwaysUseDefaultCodePage">false</property>
<property
dataType="System.Int32"
description="Specifies the mode used to access the database."
name="AccessMode"
typeConverter="AccessMode">3</property>
<property
dataType="System.Boolean"
description="Indicates whether the values supplied for identity columns will be copied to the destination. If false, values for identity columns will be auto-generated at the destination. Applies only if fast load is turned on."
name="FastLoadKeepIdentity">false</property>
<property
dataType="System.Boolean"
description="Indicates whether the columns containing null will have null inserted in the destination. If false, columns containing null will have their default values inserted at the destination. Applies only if fast load is turned on."
name="FastLoadKeepNulls">false</property>
<property
dataType="System.String"
description="Specifies options to be used with fast load. Applies only if fast load is turned on."
name="FastLoadOptions">TABLOCK,CHECK_CONSTRAINTS</property>
<property
dataType="System.Int32"
description="Specifies when commits are issued during data insertion. A value of 0 specifies that one commit will be issued at the end of data insertion. Applies only if fast load is turned on."
name="FastLoadMaxInsertCommitSize">2147483647</property>
</properties>
<connections>
<connection
refId="Package\Data Flow Task 1\Destination - Query3.Connections[OleDbConnection]"
connectionManagerID="Package.ConnectionManagers[DestinationConnectionOLEDB]"
connectionManagerRefId="Package.ConnectionManagers[DestinationConnectionOLEDB]"
description="The OLE DB runtime connection used to access the database."
name="OleDbConnection" />
</connections>
<inputs>
<input
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input]"
errorOrTruncationOperation="Insert"
errorRowDisposition="FailComponent"
hasSideEffects="true"
name="Destination Input">
<inputColumns>
<inputColumn
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].Columns[n]"
cachedDataType="i4"
cachedName="n"
externalMetadataColumnId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].ExternalColumns[n]"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[n]" />
<inputColumn
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].Columns[t]"
cachedDataType="dbTimeStamp"
cachedName="t"
externalMetadataColumnId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].ExternalColumns[t]"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[t]" />
<inputColumn
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].Columns[s]"
cachedDataType="nText"
cachedName="s"
externalMetadataColumnId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].ExternalColumns[s]"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[s]" />
</inputColumns>
<externalMetadataColumns
isUsed="True">
<externalMetadataColumn
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].ExternalColumns[n]"
dataType="i4"
name="n" />
<externalMetadataColumn
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].ExternalColumns[t]"
dataType="dbTimeStamp"
name="t"
scale="6" />
<externalMetadataColumn
refId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input].ExternalColumns[s]"
dataType="nText"
name="s" />
</externalMetadataColumns>
</input>
</inputs>
<outputs>
<output
refId="Package\Data Flow Task 1\Destination - Query3.Outputs[OLE DB Destination Error Output]"
exclusionGroup="1"
isErrorOut="true"
name="OLE DB Destination Error Output"
synchronousInputId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input]">
<outputColumns>
<outputColumn
refId="Package\Data Flow Task 1\Destination - Query3.Outputs[OLE DB Destination Error Output].Columns[ErrorCode]"
dataType="i4"
lineageId="Package\Data Flow Task 1\Destination - Query3.Outputs[OLE DB Destination Error Output].Columns[ErrorCode]"
name="ErrorCode"
specialFlags="1" />
<outputColumn
refId="Package\Data Flow Task 1\Destination - Query3.Outputs[OLE DB Destination Error Output].Columns[ErrorColumn]"
dataType="i4"
lineageId="Package\Data Flow Task 1\Destination - Query3.Outputs[OLE DB Destination Error Output].Columns[ErrorColumn]"
name="ErrorColumn"
specialFlags="2" />
</outputColumns>
<externalMetadataColumns />
</output>
</outputs>
</component>
<component
refId="Package\Data Flow Task 1\Source - Query"
componentClassID="Microsoft.ManagedComponentHost"
contactInfo="Consumes data from SQL Server, OLE DB, ODBC, or Oracle, using the corresponding .NET Framework data provider. Use a Transact-SQL statement to define the result set. For example, extract data from SQL Server with the .NET Framework data provider for SQL Server.;Microsoft Corporation; Microsoft SQL Server; © Microsoft Corporation; All Rights Reserved; http://www.microsoft.com/sql/support;4"
description="Consumes data from SQL Server, OLE DB, ODBC, or Oracle, using the corresponding .NET Framework data provider. Use a Transact-SQL statement to define the result set. For example, extract data from SQL Server with the .NET Framework data provider for SQL Server."
name="Source - Query"
usesDispositions="true"
version="4">
<properties>
<property
dataType="System.String"
description="Specifies the SQL statement used by the component to extract data."
expressionType="Notify"
name="SqlCommand"
UITypeEditor="Microsoft.DataTransformationServices.Controls.ModalMultilineStringEditor">SELECT N, T, md5((R*100000*random())::text) S
FROM generate_series(1,1000,1) N
CROSS JOIN generate_series($$2020-01-01$$::timestamp,
$$2020-12-31$$::timestamp, $$1 day$$::interval) T
CROSS JOIN generate_series(1,100,1) R</property>
<property
dataType="System.Int32"
description="The number of seconds before a command times out. A value of 0 indicates an infinite time-out."
name="CommandTimeout">0</property>
<property
dataType="System.Boolean"
description="Indicates whether to allow non-string external columns to be implicitly converted to strings at the output columns."
name="AllowImplicitStringConversion">true</property>
<property
dataType="System.String"
description="The Source database table name."
expressionType="Notify"
name="TableOrViewName"
UITypeEditor="Microsoft.DataTransformationServices.Controls.ModalMultilineStringEditor"></property>
<property
dataType="System.Int32"
description="Specify the mode to retrieve external column information"
name="AccessMode"
typeConverter="Microsoft.SqlServer.Dts.Pipeline.DataReaderSourceAdapter+EnumToStringConverter">2</property>
<property
dataType="System.String"
name="UserComponentTypeName">Microsoft.DataReaderSourceAdapter</property>
</properties>
<connections>
<connection
refId="Package\Data Flow Task 1\Source - Query.Connections[IDbConnection]"
connectionManagerID="Package.ConnectionManagers[SourceConnectionAdoNET]"
connectionManagerRefId="Package.ConnectionManagers[SourceConnectionAdoNET]"
description="Managed connection manager"
name="IDbConnection" />
</connections>
<outputs>
<output
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output]"
name="ADO NET Source Output">
<outputColumns>
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[n]"
dataType="i4"
errorOrTruncationOperation="Conversion"
errorRowDisposition="FailComponent"
externalMetadataColumnId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].ExternalColumns[n]"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[n]"
name="n"
truncationRowDisposition="FailComponent" />
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[t]"
dataType="dbTimeStamp"
errorOrTruncationOperation="Conversion"
errorRowDisposition="FailComponent"
externalMetadataColumnId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].ExternalColumns[t]"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[t]"
name="t"
truncationRowDisposition="FailComponent" />
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[s]"
dataType="nText"
errorOrTruncationOperation="Conversion"
errorRowDisposition="FailComponent"
externalMetadataColumnId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].ExternalColumns[s]"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].Columns[s]"
name="s"
truncationRowDisposition="FailComponent" />
</outputColumns>
<externalMetadataColumns
isUsed="True">
<externalMetadataColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].ExternalColumns[n]"
dataType="i4"
name="n" />
<externalMetadataColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].ExternalColumns[t]"
dataType="dbTimeStamp"
name="t" />
<externalMetadataColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output].ExternalColumns[s]"
dataType="nText"
name="s" />
</externalMetadataColumns>
</output>
<output
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output]"
isErrorOut="true"
name="ADO NET Source Error Output">
<outputColumns>
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[n]"
dataType="i4"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[n]"
name="n" />
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[t]"
dataType="dbTimeStamp"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[t]"
name="t" />
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[s]"
dataType="nText"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[s]"
name="s" />
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[ErrorCode]"
dataType="i4"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[ErrorCode]"
name="ErrorCode"
specialFlags="1" />
<outputColumn
refId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[ErrorColumn]"
dataType="i4"
lineageId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Error Output].Columns[ErrorColumn]"
name="ErrorColumn"
specialFlags="2" />
</outputColumns>
<externalMetadataColumns />
</output>
</outputs>
</component>
</components>
<paths>
<path
refId="Package\Data Flow Task 1.Paths[ADO NET Source Output]"
endId="Package\Data Flow Task 1\Destination - Query3.Inputs[Destination Input]"
name="ADO NET Source Output"
startId="Package\Data Flow Task 1\Source - Query.Outputs[ADO NET Source Output]" />
</paths>
</pipeline>
</DTS:ObjectData>
</DTS:Executable>
<DTS:Executable
DTS:refId="Package\Preparation SQL Task 1"
DTS:CreationName="Microsoft.ExecuteSQLTask"
DTS:DTSID="{1B2F804A-9E69-4B6A-A509-B50A15DC0779}"
DTS:ExecutableType="Microsoft.ExecuteSQLTask"
DTS:FailPackageOnFailure="True"
DTS:LocaleID="-1"
DTS:ObjectName="Preparation SQL Task 1"
DTS:ThreadHint="0">
<DTS:Variables />
<DTS:ObjectData>
<SQLTask:SqlTaskData
SQLTask:Connection="{33E470E0-8AB7-4D9B-B0B2-53C9411BB976}"
SQLTask:SqlStatementSource="CREATE TABLE [dbo].[Query3] (
[n] int,
[t] datetime2(6),
[s] ntext
)
GO
" xmlns:SQLTask="www.microsoft.com/sqlserver/dts/tasks/sqltask" />
</DTS:ObjectData>
</DTS:Executable>
</DTS:Executables>
<DTS:PrecedenceConstraints>
<DTS:PrecedenceConstraint
DTS:refId="Package.PrecedenceConstraints[{55D4743D-F3F0-4EA4-ABFD-A394E4D51C48\}]"
DTS:CreationName=""
DTS:DTSID="{55D4743D-F3F0-4EA4-ABFD-A394E4D51C48}"
DTS:From="Package\Preparation SQL Task 1"
DTS:LogicalAnd="True"
DTS:ObjectName="{55D4743D-F3F0-4EA4-ABFD-A394E4D51C48}"
DTS:To="Package\Data Flow Task 1" />
</DTS:PrecedenceConstraints>
</DTS:Executable>
Started: 22:52:26
Finished: 23:11:25
Elapsed: 1138.16 seconds
Итого в 8.5 раз медленней, чем через ramfs/tmpfs и BCP.
x893
Странно, что начиналось не с «байт состоит из 8 битов».