在 Delphi FireDAC 中加载数组 DML 的最快方法

Posted

技术标签:

【中文标题】在 Delphi FireDAC 中加载数组 DML 的最快方法【英文标题】:The Fastest Way to Load an Array DML in Delphi FireDAC 【发布时间】:2015-10-09 22:21:15 【问题描述】:

我正在使用带有 FireDAC 的 Delphi XE8 来加载大型 SQLite 数据库。为此,我使用 Array DML 执行技术来一次有效地插入大量记录,如下所示:

FDQueryAddINDI.SQL.Text := 'insert into indi values ('
  + ':indikey, :hasdata, :gedcomnames, :sex, :birthdate, :died, '
  + ':deathdate, :changed, :eventlinesneedprocessing, :eventlines, '
  + ':famc, :fams, :linkinfo, :todo, :nextreportindi, :firstancestralloop'
  + ')';
FDQueryAddINDI.Params.Bindmode := pbByNumber; more efficient than by name 
FDQueryAddINDI.Params.ArraySize := MaxParams;  large enough to load all of them  

NumParams := 0;
repeat
   the code to determin IndiKey,... is not shown, but goes here 

  FDQueryAddINDI.Params[0].AsStrings[NumParams] := IndiKey;   
  FDQueryAddINDI.Params[1].AsIntegers[NumParams] := HasData;
  FDQueryAddINDI.Params[2].AsStrings[NumParams] := GedcomNames;
  FDQueryAddINDI.Params[3].AsStrings[NumParams] := Sex;
  FDQueryAddINDI.Params[4].AsStrings[NumParams] := Birthdate;
  FDQueryAddINDI.Params[5].AsIntegers[NumParams] := Died;
  FDQueryAddINDI.Params[6].AsStrings[NumParams] := Deathdate;
  FDQueryAddINDI.Params[7].AsStrings[NumParams] := Changed;
  FDQueryAddINDI.Params[8].AsIntegers[NumParams] := EventLinesNeedProcessing;
  FDQueryAddINDI.Params[9].AsStrings[NumParams] := EventLines;
  FDQueryAddINDI.Params[10].AsIntegers[NumParams] := FamC;
  FDQueryAddINDI.Params[11].AsIntegers[NumParams] := FamS;
  FDQueryAddINDI.Params[12].AsIntegers[NumParams] := Linkinfo;
  FDQueryAddINDI.Params[13].AsIntegers[NumParams] := ToDo;
  FDQueryAddINDI.Params[14].AsIntegers[NumParams] := NextReportIndi;
  FDQueryAddINDI.Params[15].AsIntegers[NumParams] := FirstAncestralLoop;
  inc(NumParams);
until done;
FDQueryAddINDI.Params.ArraySize := NumParams;   Reset to actual number 

FDQueryAddINDI.Execute(LogoAppForm.FDQueryAddINDI.Params.ArraySize);

实际将数据加载到 SQLite 数据库中是非常快的,我对那个速度没有任何问题。

让我慢下来的是在重复循环中将所有值分配给参数所花费的时间。

Params 内置在 FireDAC 中并且是一个 TCollection。我无权访问源代码,所以我看不到 AsStrings 和 AsIntegers 方法实际在做什么。

在我看来,为每个插入的每个参数分配每个值并不是加载此 TCollection 的一种非常有效的方法。有没有更快的方法来加载这个?我在想一种方法可以一次加载一整套参数,例如(IndiKey, HasData, ... FirstAncestralLoop) 全部作为一个。或者尽可能高效地加载我自己的TCollection,然后使用TCollection 的Assign 方法将我的TCollection 复制到FireDAC 的TCollection 中。

所以我的问题是,加载 FireDAC 所需的 TCollection 参数的最快方法是什么?


更新:我包括了 Arnaud 的一些时间安排。

如Using SQLite with FireDAC 中所述(参见其数组 DML 部分):

从 v 3.7.11 开始,SQLite 支持 INSERT 命令 多个值。 FireDAC 使用这个特性来实现 Array DML, 当 Params.BindMode = pbByNumber。否则,FireDAC 模拟 Array DML。

我已经测试了插入 33,790 条记录来更改数组大小(每次执行要加载的记录数),并使用 pbByName(用于仿真)和 pbByNumber(使用多个值插入)来计时加载时间。

这是时机:

Arraysize: 1, Executes: 33,790, Timing: 1530 ms (pbByName), 1449 ms (pbByNumber)
Arraysize: 10, Executes: 3,379, Timing: 1034 ms (pbByName), 782 ms (pbByNumber)
Arraysize: 100, Executes: 338, Timing:  946 ms (pbByName), 499 ms (pbByNumber)
Arraysize: 1000, Executes: 34, Timing: 890 ms (pbByName), 259 ms (pbByNumber)
Arraysize: 10000, Executes: 4, Timing: 849 ms (pbByName), 227 ms (pbByNumber)
Arraysize: 20000, Executes: 2, Timing: 594 ms (pbByName), 172 ms (pbByNumber)
Arraysize: 50000, Executes: 1, Timing: 94 ms (pbByName), 94 ms (pbByNumber)

现在关于这些时间的有趣之处在于,将这 33,790 条记录加载到 TCollection 中每次测试运行都需要整整 93 毫秒。无论是一次添加 1 还是一次添加 10000,填充 TCollection 的 Params 的开销始终存在。

为了比较,我只为 pbByNumber 做了一个更大的测试,插入了 198,522 个:

Arraysize: 100, Executes: 1986, Timing: 2774 ms (pbByNumber)
Arraysize: 1000, Executes: 199, Timing: 1371 ms (pbByNumber)
Arraysize: 10000, Executes: 20, Timing: 1292 ms (pbByNumber)
Arraysize: 100000, Executes: 2, Timing: 894 ms (pbByNumber)
Arraysize: 1000000, Executes: 1, Timing: 506 ms (pbByNumber)

对于此测试的所有情况,加载 TCollection of Params 的开销大约需要 503 毫秒。

因此,TCollection 的加载似乎是每秒大约 400,000 条记录。这是插入时间的很大一部分,一旦我开始处理数百万的大型数据库,我的程序用户会注意到这个增加的时间。

我想改进这一点,但我还没有找到加速参数加载的方法。


更新 2:通过将我的所有代码放在 StartTransaction 和 Commit 之间,我能够获得大约 10% 的时间改进,以便一次处理所有块。

但我仍在寻找更快加载 TCollection 参数的方法。


另一个想法:

如the ParamValues method 之类的东西可能会运行良好并且速度可能会提高 16 倍。这一次分配了多个参数,并具有直接提供变量数组的额外优势,并且避免了转换值的需要。

它会像这样工作:

    FDQueryAddINDI.Params.ParamValues['indikey;hasdata;gedcomnames;sex;birthdate;died;deathdate;changed;eventlinesneedprocessing;eventlines;famc;fams;linkinfo;todo;nextreportindi;firstancestralloop']
       := VarArrayOf([Indikey, 0, ' ', ' ', ' ', 0, ' ', ' ', 1, ' ', -1, -1, -1, -1, -1, -1]);

但是,ParamValues 只会分配给第一组 Params,即 NumIndiParms = 0。

有没有办法为循环中的每个索引(即 NumIndiParms 的每个实例)执行此操作?


Bounty:我真的很想加快 Params 的加载速度。我现在为某人提供赏金以帮助我找到一种方法来加快在 FireDAC 中实现的 Params 数组 TCollection 的加载。

【问题讨论】:

【参考方案1】:

对我来说,这听起来有点像过早的优化。恕我直言,分析器会显示repeat .... until done 循环比Execute 调用本身花费的时间要少得多。分配integer 几乎是即时的,就像分配string 一样,这要归功于Delphi string 类型的CopyOnWrite 范例,它通过引用复制文本。

请注意,实际上,SQLite3 中没有数组 DML 功能。 FireDac 通过创建多个插入来模拟数组 DML,即执行

insert into indi values (?,?,?,....),(?,?,?,....),(?,?,?,....),....,(?,?,?,....);

AFAIK 这是使用 SQLite3 插入数据的最快方式。至少在upcoming OTA feature 可用之前。

还要确保将插入嵌套在多个事务中,并且一次设置的参数数量不要太高。根据我的测试,如果要插入很多行,您还应该创建多个事务。维护单个事务会减慢进程。根据实验,每个事务 10000 行是一个不错的数字。

顺便说一句,我们的 ORM 能够独立完成所有 this low-level plumbing,具体取决于它运行的后端引擎。

更新:听起来如果 FireDac 参数在您的情况下可能是一个真正的瓶颈。因此,您应该绕过 FireDAC,并直接将您的 TCollection 内容与 SQlite3 引擎绑定。尝试例如our SynSQLite3.pas unit。请记住使用多重插入 ((?,?,?,....),(?,?,?,....),....) 准备您的 INSERT 语句,然后直接绑定您的值。 BTW DB.pas 可能是一个真正的瓶颈,这就是为什么我们的整个 ORM 会绕过这一层(但如果需要也可以使用它)。

Update2:既然你要求了,这里有一个使用mORMot的版本。

首先你定义你的记录:

type
  TSQLIndy = class(TSQLRecord)
...
  published
    property indikey: string read findikey write findikey;
    property hasdata: boolean read fhasdata write fhasdata;
    property gedcomnames: string read fgedcomnames write fgedcomnames;
    property sex: string read fsex write fsex;
    property birthdate: string read fbirthdate write fbirthdate;
    property died: boolean read fdied write fdied;
...
  end;

然后你通过 ORM 运行插入:

db := TSQLRestServerDB.CreateWithOwnModel([TSQLIndy],'test.db3');
db.CreateMissingTables; // will CREATE TABLE if not existing
batch := TSQLRestBatch.Create(db,TSQLIndy,10000);
try
  indy := TSQLIndy.Create;
  try
    for i := 1 to COUNT do begin
      indy.indikey := IntToString(i);
      indy.hasdata := i and 1=0;
      ...
      batch.Add(indy,true);
    end;
  finally
    indy.Free;
  end;
  db.BatchSend(batch);

完整的源代码是available online on paste.ee。

这是 1,000,000 条记录的时间安排:

Prepared 1000000 rows in 874.54ms
Inserted 1000000 rows in 5.79s

如果我计算得好的话,每秒插入的行数超过 170,000 行。在这里,ORM 不是开销,而是优势。所有多 INSERT 工作、事务(每 10000 行)、编组将由框架完成。 TSQLRestBatch 会将所有内容作为 JSON 存储在内存中,然后立即计算 SQL。我很好奇 FireDAC 的直接表现如何。如果需要,您可以切换到其他数据库 - 另一个 RDBMS(mysql、Oracle、MSSQL、FireBird)甚至 MongoDB。只需添加一个新行。

希望对你有帮助!

【讨论】:

感谢 Arnaud 提出您的想法。这不是过早的优化。我现在实际上正在做优化。 :-) 我之前一直在测试每个事务的各种数组大小,现在在我的问题更新中为您包含了一些数组大小。我同意你的观点,整数或字符串赋值应该是瞬时的,所以很明显,添加到 TCollection 中不仅仅是赋值。希望有人能给我一个关于如何使这部分更快的见解。 @lkessler 所以不要使用 FireDAC 抽象,而是直接使用 SQLite3 层。查看我的更新。 @lkessler 我刚刚优化了TSQLRequest.BindS 方法以避免在string 参数绑定期间分配任何内存。直接绑定来自TCollection 的字符串值可能会有所帮助。见this commit。 谢谢阿诺。我会试试看。我也知道你的 mORMot 框架,如果我对 FireDAC 不满意,我可能会尝试。 Arnaud:使用 SynSQLite3.pas 单元比我预期的要复杂得多,如果可以的话,我真的想坚持使用 FireDAC 框架。如果您(或其他任何人)可以告诉我如何调整循环中的代码以更有效地设置 FireDAC 参数,那么我将很乐意提供我现在提供的赏金。如果没有,那么现在,我的加载速度将不得不做......直到我决定我真的需要做得更好并为自己购买 FireDAC 的源代码,看看我是否可以加快速度。跨度> 【参考方案2】:

我能找到的最佳改进是将 AsString 和 AsInteger 调用替换为 Values 调用。这样可以防止将数据类型(字符串或整数)分配给每个项目,并节省大约 10% 的开销。

因此,小测试中的 93 毫秒降至 83 毫秒。 大测试中的 503 毫秒降至 456 毫秒。

FDQueryAddINDI.Params[0].Values[NumParams] := IndiKey;   
FDQueryAddINDI.Params[1].Values[NumParams] := HasData;
FDQueryAddINDI.Params[2].Values[NumParams] := GedcomNames;
FDQueryAddINDI.Params[3].Values[NumParams] := Sex;
FDQueryAddINDI.Params[4].Values[NumParams] := Birthdate;
FDQueryAddINDI.Params[5].Values[NumParams] := Died;
FDQueryAddINDI.Params[6].Values[NumParams] := Deathdate;
FDQueryAddINDI.Params[7].Values[NumParams] := Changed;
FDQueryAddINDI.Params[8].Values[NumParams] := EventLinesNeedProcessing;
FDQueryAddINDI.Params[9].Values[NumParams] := EventLines;
FDQueryAddINDI.Params[10].Values[NumParams] := FamC;
FDQueryAddINDI.Params[11].Values[NumParams] := FamS;
FDQueryAddINDI.Params[12].Values[NumParams] := Linkinfo;
FDQueryAddINDI.Params[13].Values[NumParams] := ToDo;
FDQueryAddINDI.Params[14].Values[NumParams] := NextReportIndi;
FDQueryAddINDI.Params[15].Values[NumParams] := FirstAncestralLoop;

可以选择在打开文件时初始设置类型。也可以设置最大字符串长度。这对时间没有任何影响,并且设置长度不会减少使用的内存。类型和长度以这种方式设置:

FDQueryAddINDI.Params[0].DataType := ftString;
FDQueryAddINDI.Params[1].DataType := ftInteger;
FDQueryAddINDI.Params[2].DataType := ftString;
FDQueryAddINDI.Params[3].DataType := ftString;
FDQueryAddINDI.Params[4].DataType := ftString;
FDQueryAddINDI.Params[5].DataType := ftInteger;
FDQueryAddINDI.Params[6].DataType := ftString;
FDQueryAddINDI.Params[7].DataType := ftString;
FDQueryAddINDI.Params[8].DataType := ftInteger;
FDQueryAddINDI.Params[9].DataType := ftString;
FDQueryAddINDI.Params[10].DataType := ftInteger;
FDQueryAddINDI.Params[11].DataType := ftInteger;
FDQueryAddINDI.Params[12].DataType := ftInteger;
FDQueryAddINDI.Params[13].DataType := ftInteger;
FDQueryAddINDI.Params[14].DataType := ftInteger;
FDQueryAddINDI.Params[15].DataType := ftInteger;
FDQueryAddINDI.Params[0].Size := 20;
FDQueryAddINDI.Params[2].Size := 1;
FDQueryAddINDI.Params[3].Size := 1;
FDQueryAddINDI.Params[4].Size := 1;
FDQueryAddINDI.Params[6].Size := 1;
FDQueryAddINDI.Params[7].Size := 1;
FDQueryAddINDI.Params[9].Size := 1;

【讨论】:

以上是关于在 Delphi FireDAC 中加载数组 DML 的最快方法的主要内容,如果未能解决你的问题,请参考以下文章

Delphi:从 IBO 迁移到 FireDac

使用 FireDac 在 Delphi 中动态创建和调用存储过程的正确方法是啥?

Delphi XE FireDac 连接池

Delphi东京版FireDAC连接MSSQL2000提示对象名 'SYS.DATABASES' 无效

Delphi:FireDac 连接阻止应用程序

Delphi - FireDAC的连接配置