Sqlite 能处理多少?多个线程同时插入 Sqlite
对 SQLite 数据库的一次非正式研究,以及它能处理多少并发插入。将帮助您决定是否要在项目中使用的 SQLite。
引言
我有一个网站,后端数据使用简单的 SQLite 数据库。
这听起来可能有点奇怪,但这是 SQLite 官方文档对此的说法:
网站 SQLite 非常适合作为大多数低流量到中等流量网站的数据库引擎(也就是说,大多数网站)。SQLite 能处理的网站流量取决于网站对数据库的使用程度。总的来说,每天访问量低于 100K 的网站应该都能很好地使用 SQLite。100K 次/天的访问量是一个保守的估计,而不是硬性的上限。SQLite 已经被证明可以处理十倍于此的流量。
SQLite 网站 (https://www.sqlite.org/) 当然也使用 SQLite 本身,在撰写本文时(2015 年),它每天处理约 400K 到 500K 次 HTTP 请求,其中约 15-20% 是涉及数据库的动态页面。动态内容每个网页大约使用 200 个 SQL 语句。
如果您之前没有怎么研究过 SQLite(甚至可能研究过),这些说法可能会让您感到震惊,因为大多数人认为 SQLite 是用于移动设备的数据库。
对您有什么好处
我希望这篇文章能让您快速了解 SQLite 是否适合您的项目。我将提供以下内容:
- 教程/指南,展示如何构建一个快速的 .NET Core 控制台应用程序。
- 一些关于使用“dotnet”命令行的有用解释(各种命令将帮助您学习构建和支持使用 dotnet 构建的应用程序。
- 小型控制台应用程序,您可以根据自己的需要进行修改
- 对 C# 线程的简要介绍,通过生成工作线程并发插入到 SQLite
- 可以检查的数据,以发现/决定 SQLite 是否可以信任/使用。
背景
然而,我仍然对大量并发请求会发生什么感到好奇。
当有大量并发插入到数据库的同时,另一个用户尝试从中读取数据时会发生什么?
这篇文章(轻描淡写地)调查的就是这个。我说轻描淡写是因为我们将创建一个完整的控制台程序来对本地 SQLite 数据库执行一些并发插入,但我很想听听大家对结果的解读。我将提供一种方法来查看一些有趣的数据,并希望听到有人提供关于这些结果意义的反馈。
让我们开始吧。
让我们一起构建控制台应用程序
我使用的是 Visual Studio Code,安装了 .NET Core 8.0.202 (SDK) 和 .NET Runtime 8.0.3。
发现您的 .NET 版本
如果您安装了 .NET Core,并想查看您拥有的版本,只需打开一个终端窗口并运行:
-
$ dotnet --list-sdks
-
$ dotnet --list-runtimes
您将看到一些输出,让您了解您正在运行的版本。
这是我的样子
创建新项目(使用顶级语句)
由于这是一个小巧简单的应用程序,我们将使用基本的控制台应用程序和 顶级语句[^] 来创建它。
我在家用台式机上只使用 Ubuntu 22.04.3 LTS,也使用 Mac Pro (M3)。
我只远程(工作)和本地在 VirtualBox 中运行 Windows,但以下命令允许您在以上三个平台中的任何一个上创建控制台应用程序。
打开一个终端窗口,然后转到您想创建项目的文件夹。
将创建一个单独的项目文件夹来包含项目中的所有文件。
$ dotnet new console -o sqliteThreads
您现在将有一个名为 sqliteThreads 的新文件夹,其中包含基本的控制台应用程序。
运行快速测试
您可以快速运行程序,以确保您的 .NET Core 安装设置正确。
首先,更改目录到新的项目文件夹
-
$ cd sqliteThreads
-
$ dotnet run
第二个命令将构建并运行应用程序,您应该会在控制台窗口中看到基本的“Hello, World!
”输出。
通过 Nuget 添加 Entity Framework
我决定使用 Entity Framework 让这一切变得更快一些,所以这是我们要添加到项目的第一项。
在终端中转到您的项目文件夹并运行以下命令:
$ dotnet add package Microsoft.EntityFrameworkCore.Sqlite
显然,这添加了允许我们使用 Entity Framework 和 SQLite 的库。
如果您对使用 Entity Framework 和 SQLite 创建控制台应用程序的更多详细信息感兴趣,可以查看我引用的 Microsoft 文章 学习如何做到这一点[^]。
基本上,在运行添加包的命令后,所有必需的包都会被下载,并且您的 .csproj 文件将添加以下内容(以引用 EF SQLite 包):
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
在添加 Nuget 引用后,我总是会进行一次构建,以确保一切正常。
$ dotnet build
添加新类来表示数据
让我们添加一个新类,我们将用它来表示将写入表中的数据。
我们需要几个属性来帮助我们检查写入后的数据。
Id
- 数据库为每行插入递增的值ThreadId
-String
,保存插入到数据库的线程的名称。Created
-DateTime
,以便您查看插入行的时间
这些就是我们需要的,因为我们只是想向 SQLite 写入大量数据。
我将它命名为 ThreadData
,并将其放入一个名为 sqliteThreads.Model
的 namespace
中,因为我在项目下创建了一个名为 Model
的新文件夹。稍后,您会看到在我们的 Program.cs 文件中,我们需要添加一个 using
语句,例如:using sqliteThreads.Model
,它看起来会像这样:
namespace sqliteThreads.Model;
class ThreadData{
Int64 Id{get;set;}
String ThreadId{get;set;}
DateTime Created{get;set;}
}
每当我添加新类或代码时,我都会进行构建,所以我们再来一次。
$ dotnet build
执行此操作时,您可能会收到一个警告,即 ThreadId
“在退出构造函数时必须包含一个非空值”。它只是在告诉您需要在使用值之前初始化它们。这将在以后处理。
使用初始化构造一个新的 ThreadData 对象
让我们切换到我们的 Program.cs 文件,并添加一个构造 new
ThreadData
对象。
将以下代码添加到 Program.cs 文件中。
(如果您愿意,可以删除原始的“Hello, World!
”输出行,也可以保留它。)
using sqliteThreads.Model;
ThreadData td = new ThreadData{ThreadId="Main", Created=DateTime.Now};
Console.WriteLine($"Id: {td.Id}, ThreadId:{td.ThreadId}, Created:{td.Created}");
运行后,您将看到类似以下内容:
Id: 0, ThreadId:Main, Created:3/20/2024 1:53:52 PM
现在,我们知道有一些基本数据可以写入我们的 SQLite 数据库。
Entity Framework 上下文类
要访问 SQLite 数据库并写入记录,我们正在使用 Entity Framework,所以现在我们需要添加一个 DBContext
类。
我基本上复制了以下 Microsoft 教程中的代码并对其进行了修改以适应我的需求: EF Core 入门[^]
我创建了 DbContext
类 (ThreadDataContext.cs) 并将其添加到 Model 文件夹。
安装更少:未使用的 dotnet-ef 工具
但是,我不想让您安装 dotnet-ef
工具(正如上面链接的文章所要求的,用于创建数据库),所以我决定通过 Nuget 添加一个直接引用到 SQLite 库(使用 Microsoft.Data.Sqlite
),这样我们就可以在数据库不存在时自己创建数据库。所有这些工作都在 ThreadDataContext
构造函数中完成,因此无需担心创建数据库。
检查 SQLite 数据库文件的存在性
当您运行代码时,上下文类将检查 thread.db 文件的存在性,如果不存在,它将:
- 创建数据库文件
- 将
ThreadData
表添加到数据库
这是 ThreadDataContext
类的快照,应该很清楚 SQLite 数据库是如何创建的。(请记住,SQLite 数据库只是一个文件。)
namespace sqliteThreads.Model;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using Microsoft.Data.Sqlite;
public class ThreadDataContext : DbContext
{
// The variable name must match the name of the table.
public DbSet<threaddata> ThreadData { get; set; }
public string DbPath { get; }
public ThreadDataContext()
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
DbPath = System.IO.Path.Join(path, "thread.db");
Console.WriteLine(DbPath);
SqliteConnection connection = new SqliteConnection($"Data Source={DbPath}");
// ########### FYI THE DB is created when it is OPENED ########
connection.Open();
SqliteCommand command = connection.CreateCommand();
FileInfo fi = new FileInfo(DbPath);
// check to see if db file is 0 length, if so, it needs to have table added
if (fi.Length == 0){
foreach (String tableCreate in allTableCreation){
command.CommandText = tableCreate;
command.ExecuteNonQuery();
}
}
}
// configures the database for use by EF
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite($"Data Source={DbPath}");
protected String [] allTableCreation = {
@"CREATE TABLE ThreadData
(
[ID] INTEGER NOT NULL PRIMARY KEY,
[ThreadId] NVARCHAR(30) NOT NULL check(length(ThreadId) <= 30),
[Created] NVARCHAR(30) default (datetime('now','localtime'))
check(length(Created) <= 30)
)"
};
}
向 Program.cs 添加一些代码并尝试一下
现在,让我们向主程序添加一点代码并尝试一下。
我们只需要在我们 Program.cs 文件中添加一行代码来实例化 ThreadDataContext
类,这将创建数据库文件。
ThreadDataContext tdc = new ThreadDataContext();
就是这样!现在,让我们运行它。
$ dotnet run
您应该会看到类似以下内容:
Id: 0, ThreadId:Main, Created:3/20/2024 2:57:35 PM
/Users/<redacted-user-name>/Library/Application Support/thread.db
注意:我现在在我的 Mac PowerBook 上运行此程序,因此您的路径可能不同。
您确实有 SQLite,不是吗?
既然我们已经创建了数据库并添加了 ThreadData
表,我们就可以使用 sqlite3 应用程序来检查它了。
问题:您的机器上是否已安装 sqlite?
回答:如果您运行的是 Linux 或 macOS,那么很可能您已经安装了。但是,如果您没有,您可能需要 从 SQLite 网站在此处获取[^]。您只需要 sqlite3 可执行文件,可能还有几个包含在其中的 DLL,所以您可能想要一个名为:sqlite-tools-win-x64-3450200.zip 的文件(包含命令行工具和命令行 shell 应用程序)。
运行完我们上面的应用程序后,您将获得一个路径,然后您将转到终端并运行 sqlite3 命令来打开数据库:
$ sqlite3 "/Users/<redacted-user-name>/Library/Application Support/thread.db"
请注意,我的路径中有空格,所以我必须在数据库文件的完整路径周围添加双引号。
一旦 sqlite3 启动,您将看到一个命令行界面。
请继续输入 .schema
<ENTER>(注意该命令前面有一个点)。
这允许您查看数据库中的表。
接下来,您可以执行 select
,但数据库中还没有记录。
select * from ThreadData;
最后,要退出,只需输入 .exit
(再次注意命令前面的点)。
现在,让我们来看看如何向我们的数据库添加一条记录。
向 ThreadData 数据库添加数据
在您的开发环境中打开 Program.cs 文件(您正在使用 Visual Studio Code,对吧?)并添加以下代码:
tdc.Add(td);
tdc.SaveChanges();
Console.WriteLine("Wrote to db");
我们在代码前面的 ThreadData
对象已经命名为 td
,所以现在我们只需使用 ThreadDataContext
类将记录 Add()
到我们的数据库,然后 SaveChanges()
来写入数据。这就是我决定为此小程序使用 Entity Framework 的原因。它太容易了。
运行该代码,每次运行都会向数据库添加一条新记录。
我让您自己研究如何使用 sqlite3 连接到数据库并再次运行 select。
这是我运行该应用程序几次后在我的数据库中看到的数据。
现在,我们可以进行有趣的部分了。让我们创建一些线程,它们将同时写入数据库。由于我们提供了一种添加 ThreadId
的方法,这意味着我们将能够看到是哪个线程在工作。
让我们的应用程序在不同的线程上插入数据
在我们的应用程序中创建新线程非常容易。
我们实际上可以像这样创建 new Thread
:
Thread t = new Thread(() => Console.WriteLine("I'm on a separate thread!"));
t.Start();
作为测试,请继续将这两行添加到 Program.cs 的顶部,您的程序中将有一个新的执行线程。
第一行创建线程,第二行启动它。
根据精彩的书籍 C# 12 In A Nutshell[^],这样创建线程会创建一个前台线程。
C# 12 In a Nutshell“默认情况下,您显式创建的线程是前台线程。只要有任何一个前台线程在运行,应用程序就会一直保持运行状态,而后台线程则不会。”
是的,这意味着如果您创建的线程没有完成,那么您的应用程序将不会关闭。
再次运行该应用程序,您将看到类似以下内容:
Hello, World!
Id: 0, ThreadId:Main, Created:3/20/2024 3:31:28 PM
/Users/<redacted-username>/Library/Application Support/thread.db
Wrote to db
I'm on a separate thread!
我们的代码运行了一个匿名函数(lambda),但我们想能够传入 threadId
的名称,所以我们将创建运行特定函数的线程。让我们现在编写那个函数。
供多线程使用的 WriteData 函数
我在编写和测试此方法时学到了很多细致的细节,所以我将在此处向您展示,然后重点(高点和低点)解释可能不明显之处。
void WriteData(string threadId){
ThreadDataContext db = new ThreadDataContext();
for (int i = 0; i < INSERT_COUNT;i++){
try{
ThreadData td = new ThreadData{ThreadId=threadId, Created=DateTime.Now};
db.Add(td);
db.SaveChanges();
}
catch(Exception ex){
Console.WriteLine($"Error: {threadId} => {ex.InnerException.Message}");
continue;
}
}
}
此函数有几个要点,我将列出其中一些可能引起您注意的点,然后我将尝试解释为什么包含它们。
- 我让每个线程在函数内的循环中完成所有工作。只要
for
循环的条件不满足,每个线程就会一直保持活动状态。 INSERT_COUNT
是一个const int
,允许您(在 Program.cs 的顶部)设置每个线程将执行的插入次数。- 我
catch
数据库insert
过程中的任何失败,然后只是continue
循环。通常,这会是由于数据库使用量过大,以至于在线程尝试insert
的那一刻被锁定。我发现即使我的 12 核机器上运行了 13 个线程,这种情况也发生得很罕见。
清理 Program.cs 的工作量稍大一些,但如果您想运行 13 个线程,就像我一样,它看起来是这样的:
using sqliteThreads.Model;
const int INSERT_COUNT = 100;
int insert_count = 0;
// If user passes number of records as valid integer
// then each thread will insert that number of records
// otherwise program will use INSERT_COUNT
if (args.Length > 0){
try{
insert_count = Int32.Parse(args[0]);
}
catch{
insert_count = INSERT_COUNT;
}
}
else{
insert_count = INSERT_COUNT;
}
Console.WriteLine($"#### Inserting {insert_count} records for each thread. ####");
Thread t = new Thread(() => WriteData("T1"));
Thread t2 = new Thread(() => WriteData("T2"));
Thread t3 = new Thread(()=>WriteData("T3"));
Thread t4 = new Thread(()=>WriteData("T4"));
Thread t5 = new Thread(()=>WriteData("T5"));
Thread t6 = new Thread(()=>WriteData("T6"));
Thread t7 = new Thread(()=>WriteData("T7"));
Thread t8 = new Thread(()=>WriteData("T8"));
Thread t9 = new Thread(()=>WriteData("T9"));
Thread t10 = new Thread(()=>WriteData("T10"));
Thread t11 = new Thread(()=>WriteData("T11"));
Thread t12 = new Thread(()=>WriteData("T12"));
t.Start();
t2.Start();
t3.Start();
t4.Start();
t5.Start();
t6.Start();
t7.Start();
t8.Start();
t9.Start();
t10.Start();
t11.Start();
t12.Start();
WriteData("Main");
void WriteData(string threadId){
ThreadDataContext db = new ThreadDataContext();
var beginTime = DateTime.Now;
for (int i = 0; i < insert_count;i++){
try{
ThreadData td = new ThreadData{ThreadId=threadId, Created=DateTime.Now};
db.Add(td);
db.SaveChanges();
}
catch(Exception ex){
Console.WriteLine($"Error: {threadId} => {ex.InnerException.Message}");
continue;
}
}
Console.WriteLine($"{threadId}: Completed - {DateTime.Now - beginTime}");
}
运行代码
获取代码并尝试一下。我认为您会感到惊讶。
如果按原样运行,您将发现 ThreadData
表中插入了 1300 条记录。
编辑:使用命令行设置插入计数
我修改了代码,并增加了用户从命令行设置 insert_count
的能力。
现在,您可以启动应用程序并设置每个线程要插入的记录数,如下所示:
$ dotnet run <number_of_records> $ dotnet run 1000
默认值
如果您不提供值,则每个线程将执行 100 次插入。
在我的 Linux (Ubuntu 22.04.3 LTS) 机器上运行 AMD® Ryzen 5 2600x 六核处理器 × 12,我很少看到抛出异常,如下所示:
Error: T10 => $SQLite Error 5: 'database is locked'.
我也在我的 MacBook Pro 上运行了它,拥有 36GB 内存和 M3 芯片,以及:
- 12 核 CPU,6 个性能核心和 6 个能效核心
- 18 核 GPU
- 16 核神经网络引擎
我根本看不到任何锁,这很有趣。
由于 SQLite 实际上是一个文件数据库,所有速度可能都基于磁盘/存储设备的速度以及相关的缓存。我不确定。
即使出现错误,也没有数据丢失
但是,即使我看到那个错误发生,我也发现我并没有丢失任何插入。
我需要再考虑一下,但这很了不起。
添加了一些基本计时数据
现在,在版本 2 中,当每个线程启动时,它会获取 beginTime
,当线程完成 for
循环中的工作后,它会计算完成工作所需的时间。这是非常基础的,但可以给您一个关于耗时的概念。
检查数据的有用查询
尝试在 SQLite 中使用这些有用的查询,以便您可以检查您的数据:
// each run should insert 1300 records
select count(*) from threaddata;
// Get counts grouped by threadId so you can tell if each thread
// inserted the proper number of times
select threadId, count(*) from threaddata group by threadId;
// take a look at the data and see that each thread does work
// until it gets context switched and
// another thread starts inserting.
select * from threaddata;
上面第二个查询的结果看起来像这样:它显示了每个线程的插入次数。
结论
我相信如果您查看此代码,您会对 SQLite 印象深刻。
此外,它的易用性可能会鼓励您在自己的项目中使用它。
告诉我您的想法。
历史
- 2024 年 3 月 22 日:修复了多线程首次尝试创建数据库时发生的微小错误。 之前它会抛出异常并结束程序,用户需要重新启动程序。 另外,修复了文章中使用了 order by 的错误查询,本应是 group by。
- 2024 年 3 月 20 日:文章和代码首次发布