65.9K
CodeProject 正在变化。 阅读更多。
Home

在树莓派上选择你的“毒药”:Entity Framework、Linq2DB 还是 ADO.NET

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (16投票s)

2019年1月20日

CPOL

11分钟阅读

viewsIcon

35169

downloadIcon

154

rPi 上的性能差异

目录

引言

在我 早期关于设置树莓派(包括 Postgres)的文章中,我曾沮丧地发现 Entity Framework (EF) Core 平均需要花费大约 8 秒钟来建立初始连接并从运行在树莓派上的 Postgres 数据库获取一些数据。因此,我决定进一步深入研究这个问题,测试 EF Core、Linq2DB 和 ADO.NET(自 .NET 1.0 以来的旧版 DataTable / DataSet / DataAdapter)。

需要牢记的一点是:我的树莓派使用的是通过 USB 2.0 连接的外部机械硬盘(非 SSD),这可能会影响性能,也可能不会。

关于 Linq2DB,不要认为它是 Linq2SQL 的替代品。Linq2DB 很棒,但不包含变更跟踪等功能,因此你现有的 Linq2SQL 代码中,你只需更新模型中的属性,然后调用 "SaveChanges" 就不会生效。Linq2DB 更精简,使用 Set 方法进行部分更新。

NuGet

Linq2DB 包

有关在树莓派上设置 Postgres(或 .NET Core 方面)的信息,请参阅我 早期文章

安装必要的程序包后,你的 NuGet 引用应与此类似

控制台

我编写了控制台程序来设置各种测试场景,所以你会看到一个选项菜单

创建 100,000 条记录需要很长时间。

测试代码

以下是我用于 ADO.NET、Linq2DB 和 EF Core 的测试例程。它们都非常相似。

架构

由 pdgadmin4 生成,这是我使用的两个测试表——它们除了表名之外完全相同

CREATE TABLE public."TestTable"
(
  "ID" integer NOT NULL DEFAULT nextval('"TestTable_ID_seq"'::regclass),
  "FirstName" text COLLATE pg_catalog."default",
  "LastName" text COLLATE pg_catalog."default",
  CONSTRAINT "TestTable_pkey" PRIMARY KEY ("ID")
)
WITH (
  OIDS = FALSE
)
TABLESPACE pg_default;

ALTER TABLE public."TestTable"
  OWNER to pi;

CREATE TABLE public."TestTable2"
(
  "ID" integer NOT NULL DEFAULT nextval('"TestTable_ID_seq"'::regclass),
  "FirstName" text COLLATE pg_catalog."default",
  "LastName" text COLLATE pg_catalog."default",
  CONSTRAINT "TestTable2_pkey" PRIMARY KEY ("ID")
)
WITH (
  OIDS = FALSE
)
TABLESPACE pg_default;

ALTER TABLE public."TestTable2"
  OWNER to pi;

模型

public class TestTable
{
  [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
  public int ID { get; set; }
  public string FirstName { get; set; }
  public string LastName { get; set; }
}

public class TestTable2
{
  [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
  public int ID { get; set; }
  public string FirstName { get; set; }
  public string LastName { get; set; }
}

public class Context : DbContext
{
  public Context(DbContextOptions options) : base(options) { }

  public DbSet<TestTable> TestTable { get; set; }
  public DbSet<TestTable2> TestTable2 { get; set; }
}

ADO.NET

static void TestPostgresAdoNet()
{
  string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
  DataTable dt = new DataTable();
  long total = 0;
  long ms;

  Console.WriteLine("Connection opening...");
  Stopwatch stopwatch = new Stopwatch();
  stopwatch.Start();
  var conn = new NpgsqlConnection(connStr);
  conn.Open();
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine(ms + "ms");

  var cmd = new NpgsqlCommand
            ("select \"ID\", \"FirstName\", \"LastName\" from public.\"TestTable\"", conn);
  stopwatch.Restart();
  NpgsqlDataAdapter da = new NpgsqlDataAdapter(cmd);
  da.Fill(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("First Query: " + ms + "ms");
  Console.WriteLine($"Subtotal: {total} ms");
  Console.WriteLine($"Count: {dt.Rows.Count}");
  conn.Close();

  stopwatch.Restart();
  conn = new NpgsqlConnection(connStr);
  conn.Open();
  dt = new DataTable();
  da.Fill(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Second Query: " + ms + "ms");
  conn.Close();

  stopwatch.Restart();
  conn = new NpgsqlConnection(connStr);
  conn.Open();
  dt = new DataTable();
  da.Fill(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Third Query: " + ms + "ms");
  conn.Close();

  cmd = new NpgsqlCommand
        ("select \"ID\", \"FirstName\", \"LastName\" from public.\"TestTable2\"", conn);
  stopwatch.Restart();
  conn = new NpgsqlConnection(connStr);
  conn.Open();
  da = new NpgsqlDataAdapter(cmd);
  dt = new DataTable();
  da.Fill(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Test Table 2: " + ms + "ms");
  Console.WriteLine($"Total: {total} ms");
  conn.Close();
}

ADO.NET + 模型

static void TestPostgresAdoNetPopulateModel<T>() where T : new()
{
  string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
  DataTable dt = new DataTable();
  long total = 0;
  long ms;

  Console.WriteLine("Connection open...");
  Stopwatch stopwatch = new Stopwatch();
  stopwatch.Start();
  var conn = new NpgsqlConnection(connStr);
  conn.Open();
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine(ms + "ms");

  var cmd = new NpgsqlCommand
            ("select \"ID\", \"FirstName\", \"LastName\" from public.\"TestTable\"", conn);
  stopwatch.Restart();
  NpgsqlDataAdapter da = new NpgsqlDataAdapter(cmd);
  da.Fill(dt);
  PopulateModel<T>(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("First Query: " + ms + "ms");
  Console.WriteLine($"Subtotal: {total} ms");
  Console.WriteLine($"Count: {dt.Rows.Count}");
  conn.Close();

  stopwatch.Restart();
  conn = new NpgsqlConnection(connStr);
  conn.Open();
  dt = new DataTable();
  da.Fill(dt);
  PopulateModel<T>(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Second Query: " + ms + "ms");
  conn.Close();

  stopwatch.Restart();
  conn = new NpgsqlConnection(connStr);
  conn.Open();
  dt = new DataTable();
  da.Fill(dt);
  PopulateModel<T>(dt);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Third Query: " + ms + "ms");
  Console.WriteLine($"Total: {total} ms");
  conn.Close();
}

static List<T> PopulateModel<T>(DataTable dt) where T : new()
{
  Type t = typeof(T);
  var props = t.GetProperties(BindingFlags.Instance | BindingFlags.Public);
  List<T> items = new List<T>();

  foreach (DataRow row in dt.Rows)
  {
    var item = new T();

    foreach (DataColumn col in dt.Columns)
    {
      props.Single(p => p.Name == col.ColumnName).SetValue(item, row[col]);
    }

    items.Add(item);
  }

  return items;
}

Linq2DB

static void TestPostgresLinq2Db()
{
  string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
  long total = 0;
  long ms;

  Console.WriteLine("Create Data Connection...");
  Stopwatch stopwatch = new Stopwatch();
  stopwatch.Start();
  var db = PostgreSQLTools.CreateDataConnection(connStr);
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine(ms + "ms");
  db.Close();

  stopwatch.Restart();
  db = PostgreSQLTools.CreateDataConnection(connStr);
  var recs = db.GetTable<TestTable>().ToList();
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("First Query: " + ms + "ms");
  Console.WriteLine($"Count: {recs.Count}");
  Console.WriteLine($"Subtotal: {total} ms");
  db.Close();

  stopwatch.Restart();
  db = PostgreSQLTools.CreateDataConnection(connStr);
  recs = db.GetTable<TestTable>().ToList();
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Second Query: " + ms + "ms");
  Console.WriteLine($"Count: {recs.Count}");
  db.Close();

  stopwatch.Restart();
  db = PostgreSQLTools.CreateDataConnection(connStr);
  recs = db.GetTable<TestTable>().ToList();
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Third Query: " + ms + "ms");
  Console.WriteLine($"Count: {recs.Count}");
  Console.WriteLine($"Total: {total} ms");
  db.Close();

  stopwatch.Restart();
  db = PostgreSQLTools.CreateDataConnection(connStr);
  var recs2 = db.GetTable<TestTable2>().ToList();
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine("Table 2 Query: " + ms + "ms");
  Console.WriteLine($"Count: {recs2.Count}");
  Console.WriteLine($"Total: {total} ms");
  db.Close();
}

EF Core

static long TestPostgresEfCore()
{
  long firstTotal = 0;
  long total = 0;
  long ms;

  var contextBuilder = new DbContextOptionsBuilder();
  // Database name is case-sensitive
  string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
  contextBuilder.UseNpgsql(connStr);

  Stopwatch stopwatch = new Stopwatch();
  stopwatch.Restart();
  using (var context = new Context(contextBuilder.Options))
  {
    var items = context.TestTable.ToList();
    ms = stopwatch.ElapsedMilliseconds;
    total += ms;
    firstTotal = ms;
    Console.WriteLine("First query: " + ms + "ms");
    Console.WriteLine($"Count: {items.Count}");
    Console.WriteLine($"Subtotal = {total} ms");
  }

  // Query again to see how long a second query takes.
  stopwatch.Restart();
  using (var context = new Context(contextBuilder.Options))
  {
    var items2 = context.TestTable.ToList();
    ms = stopwatch.ElapsedMilliseconds;
    total += ms;
    Console.WriteLine("Second query: " + ms + "ms");
    Console.WriteLine($"Count: {items2.Count}");
}

  // Query again to see how long a third query takes.
  stopwatch.Restart();
  using (var context = new Context(contextBuilder.Options))
  {
    var items2 = context.TestTable.ToList();
    ms = stopwatch.ElapsedMilliseconds;
    total += ms;
    Console.WriteLine("Third query: " + ms + "ms");
    Console.WriteLine($"Count: {items2.Count}");
    Console.WriteLine($"Total: {total} ms");
  }

  // Query again to see how long a different table query takes.
  total = 0;
  stopwatch.Restart();
  using (var context = new Context(contextBuilder.Options))
  {
    var items2 = context.TestTable2.ToList();
    ms = stopwatch.ElapsedMilliseconds;
    total += ms;
    Console.WriteLine("Table 2 query: " + ms + "ms");
    Console.WriteLine($"Count: {items2.Count}");
    Console.WriteLine($"Total: {total} ms");
  }

  return firstTotal;
}

测试结果

在我们深入代码之前,先看看测试结果。这里的重点是执行第一次查询所需的时间——查询本身基本无关紧要,它由 10 行组成,包含 ID、名字和姓氏。每个测试有效地使用了三次连接来执行查询,无论是直接使用还是通过 .NET 的连接池。所有时间单位均为毫秒。

树莓派计时结果

“ADO.NET + Model”测试实现了将 DataTable 记录反序列化为 .NET 对象模型,我主要出于好奇才实现的。

Windows 计时结果

现在,仅为比较起见,这是从我的 Windows 笔记本电脑上运行相同的四个测试连接到树莓派上的 Postgres 的第一次查询结果,通过我的家庭 Wi-Fi(笔记本电脑和树莓派都在无线网络上)

考虑到在树莓派上运行客户端代码的 EF 查询需要 8000ms,而在 Windows 计算机上运行相同的客户端代码(1500ms),树莓派上肯定存在性能瓶颈。第二次和第三次查询大约需要 3ms。

Entity Framework 在做什么?

无论你选择哪种方法,在树莓派上执行第一次查询所需的时间都非常糟糕。尽管如此,我在 Entity Framework 读取这个 SO 帖子时发现了一些有趣的东西。

"使用 EF Core,你可以作弊并提前加载模型……这将在另一个线程中创建 dbcontext 的模型,同时完成应用程序的其他初始化(可能还有其他预热)和请求的开始。这样,它将更快准备就绪。当你需要它时,如果模型尚未创建完成,EFCore 会等待它创建。模型跨所有 DbContext 实例共享,因此可以启动并忘记这个虚拟 dbcontext。"

它似乎并没有真正地在另一个线程中创建模型。执行这个操作,首先

Console.WriteLine("Forcing model creating...");
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();

using (var dbContext = new Context(contextBuilder.Options))
{
  var model = dbContext.Model; //force the model creation
  ms = stopwatch.ElapsedMilliseconds;
  total += ms;
  Console.WriteLine(ms + "ms");
}

我注意到这些结果,在树莓派上运行

=== Testing Postgres EF ===
Forcing model creating...
4265ms
First query: 4847ms
Count: 10
Subtotal = 9112 ms

请注意,时间(好吧,这增加了整个过程一整秒!)现在被分摊在“模型创建”和实际执行首次查询之间。我不想知道如果我有一百个需要“创建”的模型会发生什么。

顺便说一句,在 SO 帖子的后面,有人评论说将 AutoDetectChangesEnabledLazyLoadingEnabled 设置为 false。这(至少在 EF Core 中)对性能(或缺乏性能)没有影响。

这是按表还是按连接计时的延迟?

修改代码以查询另一个表 TestTable2,我们看到查询时间与 TestTable 的第二次和第三次查询相似。

=== Testing Postgres EF ===
First query: 4774ms
Count: 10
Subtotal = 4774 ms
Second query: 20ms
Count: 10
Third query: 5ms
Count: 10
Total: 4799 ms

Table 2 query: 4ms
Count: 10
Total: 4 ms

连接会持续多久?

根据关于 ADO.NET 2.0 中 SQL Server 连接池的这篇文章:“连接池将在连接空闲大约 4-8 分钟后将其从池中移除……”我们可以通过反复测试查询来验证这一点,并以分钟级的精度确定连接池何时丢弃连接。

int min = 1;
int maxWait = 10;

while (min <= maxWait)
{
  Console.WriteLine("=============================");
  Console.WriteLine($"Waiting {min} minute...");
  int sleepFor = min * 60 * 1000;
  Thread.Sleep(sleepFor);
  long q = TestPostgresEfCore();

  if (q > 1000)
  {
    break;
  }

  ++min;
}

if (min <= maxWait)
{
  Console.WriteLine
     ($"Connection pool released all connections in approximately {min} minutes.");
}

结果是,即使等待了 10 分钟,连接仍然是“活动的”。我只能假设连接池,无论是在 .NET Core 中实现还是在 Postgres 适配器中实现,都与文档中的实现不同。或者,第三种可能性是 Thread.Sleep 在所有内容都在同一线程上运行时不是测试此问题的正确方法。

我从上面提到的 Microsoft 页面还注意到:“ADO.NET 2.0 引入了两个新方法来清除池:ClearAllPoolsClearPool。”执行这些方法

NpgsqlConnection.ClearAllPools();
NpgsqlConnection.ClearPool(conn);

对第二次和第三次查询计时没有影响。

查询大量数据

让我们看看第二次和第三次查询的平均性能,读取 100,000 条记录

我对 Linq2DB 的速度感到非常惊讶。

多线程查询

对于这个测试,我正在做一些你通常不会在“客户端”上做的事情,即查询返回测试表中的所有 100,000 条记录。记住,数据库运行在树莓派上。

同时进行 EF Core 查询

请注意,在此代码中,我确保任务数量不超过逻辑处理器的数量。我还通过“模型构建 - 连接 - 查询”过程来预热 EF 和一个连接(稍后我们会对此进行一些操作)。

static void EfCoreMultithreading()
{
  var contextBuilder = new DbContextOptionsBuilder();
  string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
  contextBuilder.UseNpgsql(connStr);
  int numProcs = Environment.ProcessorCount;
  Console.WriteLine($"Logical processors = {numProcs}");
  var trial1 = new Task<(DateTime start, DateTime end)>[numProcs];
  var trial2 = new Task<(DateTime start, DateTime end)>[numProcs];

  var stopwatch = new Stopwatch();
  stopwatch.Start();

  // Priming: run a query to deal with how long it takes for the initial connection+query.
  using (var context = new Context(contextBuilder.Options))
  {
    var items = context.TestTable.Where(t => t.ID == -1).ToList();
  }

  long priming = stopwatch.ElapsedMilliseconds;
  stopwatch.Stop();
  Console.WriteLine($"connect + query took {priming}ms");

  var now = DateTime.Now;

  numProcs.ForEachWithIndex(idx => trial1[idx] = StartEfCoreTask());
  Task.WaitAll(trial1);

  numProcs.ForEachWithIndex(idx => trial2[idx] = StartEfCoreTask());
  Task.WaitAll(trial2);

  Console.WriteLine("Trial 1:");
  trial1.ForEach(t =>
  {
    Console.WriteLine($" Start: {(t.Result.start - now).TotalMilliseconds}ms Duration: 
                                {(t.Result.end - t.Result.start).TotalMilliseconds}ms");
  });

  Console.WriteLine("Trial 2:");
  trial2.ForEach(t =>
  {
    Console.WriteLine($" Start: {(t.Result.start - now).TotalMilliseconds}ms Duration: 
                                {(t.Result.end - t.Result.start).TotalMilliseconds}ms");
  });
}

static Task<(DateTime start, DateTime end)> StartEfCoreTask()
{
  return Task.Run(() =>
  {
    var contextBuilder = new DbContextOptionsBuilder();
    string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
    contextBuilder.UseNpgsql(connStr);
    var start = DateTime.Now;

    using (var context = new Context(contextBuilder.Options))
    {
      var items = context.TestTable.ToList();
    }

    var end = DateTime.Now;

    return (start, end);
  });
}

在 Windows 上运行

回想一下,在单线程上运行一个“预热”的 100,000 条记录查询大约需要 2200ms。

Logical processors = 4
Priming took 1589ms
Trial 1:
Start: 3.3745ms Duration: 3269.5232ms
Start: 3.3745ms Duration: 5263.6895ms
Start: 3.3745ms Duration: 2403.0416ms          <=== re-use of connection?
Start: 3.3745ms Duration: 5459.1072ms
Trial 2:
Start: 5462.846ms Duration: 5404.7242ms
Start: 5462.8351ms Duration: 5399.7877ms
Start: 5462.8351ms Duration: 5403.8799ms
Start: 5462.846ms Duration: 5411.174ms

在树莓派上运行

回想一下,在单线程上运行一个“预热”的 100,000 条记录查询大约需要 4000ms。

Logical processors = 4
Priming took 9221ms
Trial 1:
Start: 24.6442ms Duration: 9613.1905ms
Start: 24.8993ms Duration: 9636.8815ms
Start: 24.6441ms Duration: 9673.5425ms
Start: 24.8986ms Duration: 9655.8864ms
Trial 2:
Start: 9699.3142ms Duration: 9103.8668ms
Start: 9699.3141ms Duration: 8743.3299ms
Start: 9699.314ms Duration: 8969.2488ms
Start: 9699.3142ms Duration: 9024.2545ms

如果我们不通过“预热”连接池来执行一个连接呢?

  • Windows 正在执行查询
    Logical processors = 4
    Trial 1:
    Start: 6.0236ms Duration: 9810.3092ms
    Start: 6.0236ms Duration: 10585.4694ms
    Start: 6.0236ms Duration: 10558.1815ms
    Start: 6.0236ms Duration: 10564.1273ms
    Trial 2:
    Start: 10591.8437ms Duration: 5625.977ms
    Start: 10591.8437ms Duration: 4376.5792ms
    Start: 10591.8595ms Duration: 5570.2906ms
    Start: 10591.8595ms Duration: 1770.9706ms       <=== re-use of connection?
  • 树莓派正在执行查询
    Logical processors = 4
    Trial 1:
    Start: 27.7581ms Duration: 20175.8031ms
    Start: 27.7581ms Duration: 20175.7824ms
    Start: 27.8305ms Duration: 20263.7383ms
    Start: 27.7581ms Duration: 20175.778ms
    Trial 2:
    Start: 20292.7195ms Duration: 10079.8114ms
    Start: 20292.7195ms Duration: 9850.5694ms
    Start: 20292.7195ms Duration: 10071.8997ms
    Start: 20293.1121ms Duration: 10213.0811ms

结果分析

为了理解这些结果,我们需要记住,我们可以通过模型构建步骤来预热 EF Core,这可以减少“连接 + 查询”时间。我主要在“预热”测试中通过在主线程上执行初始查询来做到这一点。

  1. 看起来当从 Windows 运行时,一个线程会重用这个连接。
  2. 当省略模型“构建”预热时,所有从 Windows 运行的四个线程似乎都需要构建模型,但在第二次尝试时,这个模型构建不是必需的,但我们再次看到只有一个线程利用了连接池,尽管理论上,从第一次尝试我们应该有四个连接池。
  3. 在树莓派上运行测试会产生类似的结果,但在这种情况下,我不确定如何看待连接池。第二次尝试中我们看到的时间改进似乎是 EF Core 已经构建了模型的结果,但这似乎表明它不够智能,不知道它正在被其他线程构建。

#3 中的推测可以通过查看仅在树莓派上进行模型构建的计时来证明。为此,我们将完整的“连接 + 查询”预热替换为这段代码

using (var dbContext = new Context(contextBuilder.Options))
{
  dbContext.ChangeTracker.AutoDetectChangesEnabled = false; // <----- trick
  dbContext.ChangeTracker.LazyLoadingEnabled = false; // <----- trick
  var model = dbContext.Model; //force the model creation
}

long priming = stopwatch.ElapsedMilliseconds;
stopwatch.Stop();
Console.WriteLine($"Model building took {priming}ms");

结果

Logical processors = 4
Model building took 4330ms
Trial 1:
Start: 25.583ms Duration: 14884.9089ms
Start: 25.1461ms Duration: 14754.3145ms
Start: 25.1461ms Duration: 14797.9605ms
Start: 25.4297ms Duration: 14854.3302ms
Trial 2:
Start: 14911.8329ms Duration: 11586.8008ms
Start: 14911.6562ms Duration: 10127.6391ms
Start: 14911.6561ms Duration: 11580.0408ms
Start: 14911.6561ms Duration: 11671.2985ms

通过“构建”模型进行预热,第一次尝试的时间减少了 5000ms,而第二次尝试的时间可能重用了第一次尝试的连接池,也可能没有。有趣的是,第二次尝试的时间比使用“构建+连接+查询”预热时要长 1-2 秒。我不会猜测原因。

同样在 Windows 上,当我们仅执行“构建”预热步骤时,第二次尝试需要更长的时间,但在第一次和第二次尝试之间几乎没有区别。

Logical processors = 4
Model building took 816ms
Trial 1:
Start: 3.9416ms Duration: 7166.4065ms
Start: 4.244ms Duration: 8147.1518ms
Start: 3.9416ms Duration: 4244.7402ms                <== why is this one faster?
Start: 3.9416ms Duration: 7848.992ms
Trial 2:
Start: 8151.7212ms Duration: 7786.576ms
Start: 8151.7212ms Duration: 6795.6511ms
Start: 8151.722ms Duration: 6242.4902ms
Start: 8151.722ms Duration: 8786.9947ms

我们确实看到了第一次尝试时间的减少,但没有看到第二次尝试时间的减少,尽管奇怪的是,在第一次尝试中,我们看到一个线程花费的时间少了一半。奇怪的是,在 Windows 上运行时,第二次尝试并没有花费更少的时间,因为代码和包是相同的,所以一定还有其他原因。

此外,当同时在所有 4 个核心上运行这个 100,000 条记录的查询时,我们看到每个核心花费的时间是单线程查询的两倍。在单线程上,树莓派平均需要 4000ms,而 4 个多线程查询大约需要 9000ms。如果我们只使用 2 个核心,理论上剩下 2 个核心用于 Postgres 的内部操作,会发生什么?

Logical processors = 2
Model + connect + query priming took 9567ms
Trial 1:
Start: 25.1111ms Duration: 5637.642ms
Start: 25.1111ms Duration: 5612.4357ms
Trial 2:
Start: 5663.9105ms Duration: 5467.9455ms
Start: 5663.9104ms Duration: 5528.9189ms

好多了。在 2 个线程上运行会带来 28% 的性能损失,而不是在所有四个线程上运行查询带来的 56% 的性能损失。

制造灾难

让我们忽略逻辑处理器数量,强制 8 个线程执行查询。我们将只关注树莓派。

它们开始出发了!前 4 个线程已经冲出了起跑线并开始加速,而另外 4 个线程在离开起跑线时似乎遇到了一些问题!

Logical processors = 8
Model + connect + query priming took 9452ms
Trial 1:
Start: 24.623ms Duration: 10444.4752ms
Start: 24.623ms Duration: 11347.9301ms
Start: 24.623ms Duration: 11646.0459ms
Start: 24.623ms Duration: 11261.6374ms
Start: 4130.0397ms Duration: 12144.0146ms
Start: 9234.6732ms Duration: 9702.6407ms
Start: 10479.0248ms Duration: 9170.6971ms
Start: 11313.1258ms Duration: 8702.2047ms
Trial 2:
Start: 20016.4353ms Duration: 17668.6654ms
Start: 20016.4353ms Duration: 17616.3623ms
Start: 20016.4353ms Duration: 15696.1627ms
Start: 20016.4352ms Duration: 17027.2403ms
Start: 20017.9037ms Duration: 15747.4193ms
Start: 20017.9037ms Duration: 17643.9682ms
Start: 26699.7751ms Duration: 12650.1307ms
Start: 34472.6065ms Duration: 6260.0021ms

首先,通过注意开始时间,我们看到 .NET 线程池的节流正在生效,但在第二次尝试时有点混乱,因为它同时启动了 6 个线程。最糟糕的时候,一个应该花费大约 4000ms 的查询花费了 17,500ms,与单线程查询相比,性能损失了 77%,这主要是因为在第二次尝试中,线程池同时启动了六个线程。

同时进行 Linq2DB 查询

预热

var db = PostgreSQLTools.CreateDataConnection(connStr);
var recs = db.GetTable<TestTable>().Take(1).ToList();

Linq2DB 查询任务

static Task<(DateTime start, DateTime end)> StartLinq2DBTask()
{
  return Task.Run(() =>
  {
    string connStr = Configuration.GetValue<string>("ConnectionStrings:rpidb");
    var start = DateTime.Now;
    var db = PostgreSQLTools.CreateDataConnection(connStr);
    var recs = db.GetTable<TestTable>().ToList();
    var end = DateTime.Now;

    return (start, end);
  });
}

我们来算算数字

回想一下,Linq2DB 执行一个 100,000 条记录的单线程查询大约需要不到 1400ms。在 4 个核心上运行时,我们看到大约有 65% 的性能损失。

Logical processors = 4
priming took 6943ms
Trial 1:
Start: 14.6684ms Duration: 3829.2075ms
Start: 14.6683ms Duration: 3872.5ms
Start: 14.8168ms Duration: 3850.6253ms
Start: 8.7451ms Duration: 3840.4848ms
Trial 2:
Start: 3888.2774ms Duration: 3646.7527ms
Start: 3888.4093ms Duration: 3609.2581ms
Start: 3888.2774ms Duration: 3623.5665ms
Start: 3888.3238ms Duration: 3134.8659ms

在 2 个核心上运行时,我们在第一次尝试时没有性能损失,在第二次尝试时有 33% 的性能损失。不知道为什么,但这似乎是一致的。

Logical processors = 2
priming took 7095ms
Trial 1:
Start: 8.7998ms Duration: 1458.5924ms
Start: 8.7998ms Duration: 1467.26ms
Trial 2:
Start: 1477.2797ms Duration: 2092.9932ms
Start: 1477.4778ms Duration: 2085.4642ms

结论

无论我们是从 Windows 连接到树莓派上的 Postgres 还是直接从树莓派本身连接,我都认为初始连接和查询时间是不可接受的。我不认为这与我在外部 USB 驱动器上安装操作系统和应用程序以及使用树莓派的 USB 2.0 接口有关,而是 .NET Core 的底层实现。此外,EF Core 在设置初始连接+查询时明显有很多开销,这只能通过强制 EF Core 创建模型来部分缓解。我得出它是 .NET Core“问题”的结论,也是因为我从未遇到过使用 Postgres pgadmin 工具时出现的这种延迟。

变通方法

  1. 当应用程序启动时,打开连接并查询一个表——任何查询都可以,即使是不返回任何记录的查询。尽管我没有遇到连接池移除连接的情况,即使在 10 秒后,执行这个“打开 & 查询”活动可能也是明智的,并且可以定期执行。
  2. 尽可能避免长时间运行的查询。这可能涉及优化查询,例如,让数据库对值进行求和或平均,不要在客户端进行。
  3. 在树莓派上,查询不要超过 2 个核心。这意味着你需要自己管理线程池,但目的是我们需要另外 2 个核心用于其他进程。
  4. 考虑不使用 EF Core 而使用 Linq2DB。如果你正在全新开始一个项目,而不是迁移一个现有的 EF 项目,这很容易实现。

可能是最重要的解决方法是不要在与执行查询的同一个树莓派上运行数据库服务器,比如 Postgres。树莓派的价格是 35 美元,足够便宜,你可以把数据库运行在一个树莓派上,应用程序(Web 服务器,等等)运行在第二个树莓派上。总有一天我会尝试的,但我需要另一个树莓派,或者我可能会使用我拥有的一个 Beaglebone。

历史

  • 2019年1月20日:初始版本
© . All rights reserved.