在 C# 业务应用中使用 SQLite ── WAL 模式、排他控制、防损坏对策、与 EF Core 的取舍
· 小村 豪 · SQLite, C#, .NET, Microsoft.Data.Sqlite, EF Core, 数据存储, Windows, 运维, 技术咨询
在上一篇文章《Windows 应用数据存储位置的选择》中,我提到对于不断增长的业务数据和历史记录,SQLite 是首选的存储方案。这个判断本身没有问题,但真正着手集成时,又会冒出另外一些困惑。咨询中经常听到的声音是:「在 NuGet 里搜索 SQLite 会出现好几个包,不知道该装哪一个」「虽然能跑,但偶尔会出现 database is locked,靠加重试来顶住,这样做是否正确」「备份是不是只要复制数据库文件就够了」之类的问题。
这些都是把 SQLite 用于业务应用并运行数年后必然会遇到的论点,只要在最初的设计阶段把它们理清楚,后面就不会吃苦头。本文以 Microsoft.Data.Sqlite 为前提,系统整理设计评审中每次都会确认的要点:库的选择、连接字符串与连接池、WAL 模式的原理、如何应对 SQLITE_BUSY、类型映射的陷阱、防损坏对策与备份,以及该在什么场合使用 EF Core。
1. 先说结论
- 新开发时使用的库,基本选择是
Microsoft.Data.Sqlite(或建立在其上的 EF Core SQLite 提供程序)。它与System.Data.SQLite在连接字符串和细节行为上都不兼容,是完全不同的两个东西,使用网上的示例代码时要始终留意它究竟基于哪一个库。1 - 从第一个正式版本开始就启用 WAL 模式。 这样能提升读写之间的并发性,让绝大多数
database is locked问题消失。这个设置会持久化到数据库文件本身,但无法在网络共享上使用。2 database is locked(SQLITE_BUSY)不是「补一段错误处理代码」就能解决的问题,而是该重新审视设计的信号。Microsoft.Data.Sqlite会自动重试直到超时(默认 30 秒)3,但根本对策是把写入路径归并为一条。- 大量执行细碎的 INSERT 时,仅靠显式事务归并就能让速度提升两到三个数量级。一旦感觉「SQLite 太慢」,首先要怀疑的是提交的粒度。
- SQLite 实际上只有 INTEGER / REAL / TEXT / BLOB 这 4 种类型,
DateTime、Guid、decimal都会以 TEXT 形式存储。尤其是 decimal,比较和排序会遵循字符串规则,因此金额最好以最小货币单位的整数来保存才安全。4 - 备份禁止在运行中对文件进行简单复制,应使用
VACUUM INTO或 Backup API(SqliteConnection.BackupDatabase)。手动删除-wal/-shm文件同样是严禁的。5 - 原生的 SQLite 本身不支持加密。连接字符串中的
Password只有在集成了 SQLCipher 系列的原生库时才会生效6,如果只是少量敏感信息,建议优先考虑用 DPAPI 保护,而不是直接对数据库加密。
2. 库的选择 ── 名字相似,内容不同
从 .NET 使用 SQLite 有多个可选的包,名字容易混淆,是最初容易踩坑的地方。整理如下表所示。
| 包 | 定位 | 新项目采用建议 |
|---|---|---|
Microsoft.Data.Sqlite |
Microsoft 维护的 ADO.NET 提供程序。轻量,NuGet 中还附带了原生的 SQLite 本体 | ◎ 首选 |
Microsoft.EntityFrameworkCore.Sqlite |
EF Core 的 SQLite 提供程序,内部使用 Microsoft.Data.Sqlite |
◎ 以实体为中心的应用(见第 8 章) |
System.Data.SQLite |
SQLite 开发团队系列的老牌提供程序,在 .NET Framework 时代有大量应用案例 | △ 仅用于维护现有资产 |
Dapper |
建立在 ADO.NET 之上的轻量映射器,并非专为 SQLite 设计 | ○ 想减少原生 ADO.NET 装填代码时 |
Microsoft.Data.Sqlite 由 EF Core 团队维护,同时也是 EF Core SQLite 提供程序的基础。1 NuGet 包中附带了原生二进制文件(SQLite 本体),因此不需要向客户端 PC 单独分发,x86/x64/ARM64 的差异也由 NuGet 一侧吸收。
需要注意的是,针对 System.Data.SQLite 的资料并不能直接套用。由于它使用历史更长,网上大量示例都是以 System.Data.SQLite 为前提的,会踩到下面这些不兼容之处。
- 连接字符串不兼容。
Version=3、UseUTF16Encoding,以及用于改变日期格式的DateTimeFormat等关键字在Microsoft.Data.Sqlite中并不存在,指定它们会抛出异常。可用的关键字只有Data Source/Mode/Cache/Password/Foreign Keys/Default Timeout/Pooling等寥寥数个。6 - 类型的处理方式不同。 例如
Guid在System.Data.SQLite中默认以 BLOB 存储,而在Microsoft.Data.Sqlite中则以 TEXT 存储。如果在迁移期间同时用两个库读写同一个数据库文件,这个差异就会表现为数据不一致。
Microsoft.Data.Sqlite 是有意做得很薄的一层封装,没有自己的类型转换或便利功能,因此 SQLite 官方文档的说明可以直接套用。如果现有应用在 System.Data.SQLite 上已经稳定运行,不必强行迁移;但新代码应该向 Microsoft.Data.Sqlite 靠拢,如果要迁移,原则上先把上述不兼容之处列成迁移清单再动手。
3. 连接与连接字符串 ── 连接池会占用文件
连接字符串的基本形式只是一个文件路径。实务中需要留意的是 Mode 和 Pooling。6
using Microsoft.Data.Sqlite;
var builder = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate // 默认值。不存在则创建
};
using var conn = new SqliteConnection(builder.ConnectionString);
conn.Open();
Mode: 默认是ReadWriteCreate(不存在则创建)。如果是分发只供参考的主数据库这类场景,指定ReadOnly可以防止因为 bug 或误操作而写入。Cache: 通常保持默认即可。文档中明确写明Cache=Shared不建议与 WAL 模式一起使用,因此在本文推荐使用 WAL 的方针下,它没有出场的机会。6Password: 指定后会在连接建立后立即发送PRAGMA key,但标准的原生库不支持加密,所以什么都不会发生。6 如果需求是对整个数据库加密,就需要替换为 SQLCipher 系列的绑定包(如SQLitePCLRaw.bundle_e_sqlcipher),而保存加密密钥最终还是要依赖 DPAPI(见第 1 章)。Default Timeout: 命令超时时间(默认 30 秒),也是第 5 章重试时间的上限。
还有一点是关于异步 API。由于 SQLite 本身不具备异步 I/O,ExecuteNonQueryAsync 等 async 方法在内部实际上是同步执行的。7 「用了 async 所以 UI 不会卡」这个说法并不成立,因此耗时的查询要显式地用 Task.Run 等方式转移到工作线程上执行。关于 async/await 的判断标准,可以参考《C# async/await 实务判断表》中整理的内容。
3.1 连接池的陷阱 ── 即使 Close,文件依然处于打开状态
Microsoft.Data.Sqlite 从 6.0 版本开始默认启用连接池。6 这带来的好处是不必每次 Open 都重新打开文件,但另一方面,即使调用了 Close / Dispose,原生连接依然会留在池中,持续占用数据库文件的句柄。结果就是下面这些操作会因为「文件正在使用」而失败。
- 「初始化数据」功能中删除数据库文件并重新创建
- 从备份还原时替换数据库文件
- 卸载或转移处理中移动数据库文件
对策是在进行文件操作之前先清空连接池。
// 销毁该连接字符串对应的连接池,释放文件句柄
SqliteConnection.ClearPool(new SqliteConnection(connectionString));
File.Delete(dbPath);
如果使用了 WAL 模式(下一章),-wal / -shm 文件也可能残留,需要一并处理。如果想在应用退出时释放所有连接池,可以调用 SqliteConnection.ClearAllPools();如果本身是不需要连接池的一次性工具,也可以在连接字符串中设置 Pooling=False。「明明调用了 Close 却删不掉文件」是迁移到 SQLite 之后常见的咨询,所以操作数据库文件的工具类处理,最好从一开始就加入 ClearPool。
4. WAL 模式的原理 ── 了解发生了什么再使用
上一篇文章只写了「启用 WAL 模式」这一句,这次深入到原理层面。在默认的回滚日志(rollback journal)方式下,写入过程中读取会被阻塞,这是 database is locked 的主要原因。而 WAL(Write-Ahead Logging,预写日志)方式把变更写入一个专门用于追加的日志文件,而不是数据库本体,因此读取不会阻塞写入,写入也不会阻塞读取。2 「UI 线程显示历史记录的同时,后台线程写入测量值」这种业务应用中常见的构成,可以原样成立。
启用 WAL 模式后,数据库本体(检查点确定的内容)旁边会出现 2 个文件。
| 文件 | 作用 |
|---|---|
app.db-wal |
追加写入的变更日志,包含已提交但尚未写回本体的变更 |
app.db-shm |
称为 wal-index 的共享内存,用于在多个进程间协调 WAL 的读取位置 |
需要掌握的性质有 4 个。
- 检查点(checkpoint): 把
-wal中的内容转写回本体的处理,默认在 WAL 达到 1000 页(约 4MB)时自动执行。2 如果有长时间占用的读取事务,检查点就无法推进,导致-wal不断膨胀,因此要避免「一直打开读取连接反复使用」的设计,改为按需打开、用完即关(有了连接池,重新打开的速度很快)。 - 设置会持久化到数据库中:
PRAGMA journal_mode=WAL只要执行一次,就会记录在数据库文件本身中,之后无论用哪个连接打开都会保持 WAL 模式。2 不需要在每次连接时都重新执行。 - 写入依然只能同时进行一个: WAL 提升的是读写之间的并发性,写入之间仍然是互斥的。如果误以为「用了 WAL 就可以多线程随意写入」,就会遇到第 5 章讲到的
SQLITE_BUSY。 - 无法在网络共享上使用: 由于 wal-index 以共享内存为前提,不同机器上的进程之间无法工作。2 而正如上一篇文章所说,本来就不应该把 SQLite 放在网络共享上。
运维层面需要注意的是,-wal 文件中包含已提交但尚未写回本体的事务。「只复制本体的 .db 就够了」「-wal 是临时文件删掉也无妨」这两种说法都是错误的,会导致最近的提交丢失,最坏情况下数据库会损坏。5 这一点直接关联到第 7 章「备份不能靠复制文件」的话题。
5. 排他与 SQLITE_BUSY ── 把写入归并为一条通路
SQLITE_BUSY(异常消息中显示为 database is locked)会在另一个连接持有写入锁时发生。首先要了解的是,Microsoft.Data.Sqlite 对 busy/locked 错误会自动重试,直到命令超时(默认 30 秒)。3 也就是说,应用侧那种「catch 之后 sleep 再重试」的自制重试逻辑通常是不必要的。如果仍然达到超时并抛出异常,原因通常是下面几种之一。
- 另一个连接(或另一个进程)持有一个超过 30 秒的长事务
- 从读取事务中途尝试升级为写入,与其他写入发生了冲突 ── 这种情况等待也无法解决,会立即失败。「先读后写」的处理,从一开始就设计成写入事务才是稳妥的做法
- 大量细碎的写入从多个线程同时涌入,陷入锁的争夺
以上情况都不是靠「增加重试次数」能解决的。对策是缩短事务和把写入路径归并为一条。
5.1 用 System.Threading.Channels 构建写入队列
对于由多个线程产生的数据(测量值、操作日志等),不要让各个线程直接写入数据库,而是把它们投入队列,由专职的写入循环来处理。在 .NET 中可以直接使用 System.Threading.Channels。
using System.Globalization;
using System.Threading.Channels;
using Microsoft.Data.Sqlite;
public sealed record Measurement(string DeviceId, double Value, DateTime CreatedAtUtc);
public sealed class MeasurementWriter : IAsyncDisposable
{
private readonly Channel<Measurement> _channel =
Channel.CreateBounded<Measurement>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.Wait // 溢出时让生产者一侧等待
});
private readonly string _connectionString;
private readonly Task _loop;
public MeasurementWriter(string connectionString)
{
_connectionString = connectionString;
_loop = Task.Run(WriteLoop);
}
// 可以从任意线程调用。不会直接触碰数据库。如果写入循环
// 已经终止,会通过 ChannelClosedException 让投入方立即知晓
public ValueTask EnqueueAsync(Measurement m) => _channel.Writer.WriteAsync(m);
private async Task WriteLoop()
{
try
{
await WriteLoopCore();
}
catch (Exception ex)
{
// 把因磁盘写满等原因导致写入角色终止的情况,通过 channel
// 传达给投入方。如果疏忽了这一点,一旦队列被填满,EnqueueAsync
// 就会永远等待下去,没有人能察觉到故障
_channel.Writer.TryComplete(ex);
throw;
}
}
private async Task WriteLoopCore()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
var buffer = new List<Measurement>(500);
while (await _channel.Reader.WaitToReadAsync())
{
// 把积压的数据最多收集 500 条,写入到一个事务中
buffer.Clear();
while (buffer.Count < 500 && _channel.Reader.TryRead(out var m))
buffer.Add(m);
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO measurement (device_id, value, created_at) " +
"VALUES ($device, $value, $at)";
var pDevice = cmd.Parameters.Add("$device", SqliteType.Text);
var pValue = cmd.Parameters.Add("$value", SqliteType.Real);
var pAt = cmd.Parameters.Add("$at", SqliteType.Text);
foreach (var m in buffer)
{
pDevice.Value = m.DeviceId;
pValue.Value = m.Value;
// 明确指定 InvariantCulture,以免受到区域默认的历法、数字格式影响
pAt.Value = m.CreatedAtUtc.ToString(
"yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture);
cmd.ExecuteNonQuery();
}
tx.Commit();
}
}
public async ValueTask DisposeAsync()
{
// 即使写入循环因失败已经先关闭了 channel,也不会抛出异常
// (如果用 Complete(),二次关闭会抛错,从而掩盖原始异常)
_channel.Writer.TryComplete();
await _loop; // 把剩余的数据写完。如果循环已经终止,原始异常会在这里抛出
}
}
采用这种结构之后,写入冲突从架构上就不会再发生,并且队列中积压的数据也能自然地被批量化,因此下一节说的提速效果也会随之获得。退出时用 DisposeAsync 把队列中的数据写完再关闭、用 FullMode 明确决定队列满时的行为,以及把写入循环的失败通过 TryComplete(ex) 传播给投入方(如果归并为一条的写入角色悄悄地终止了,故障就会表现为队列满时的永久等待)——这些细节在业务应用中都很关键。这种「把路径归并为一条」的思路,与文件方式协作时的排他设计(《文件协作与锁定最佳实践》)也是一致的。
5.2 用事务归并后,性能会提升一个量级
SQLite 每次提交都会对存储执行一次同步写入(fsync),如果逐条以隐式提交的方式执行 INSERT,即使用 SSD 每秒也只能处理数百到数千条,HDD 的话每秒只有几十条就会封顶。只需把同样的 INSERT 归并到以 1000 条为单位的显式事务(用 BeginTransaction 包裹,最后调用 Commit)中,吞吐量就能达到每秒数万到数十万条的量级。这甚至称不上「调优」,只改三行代码,速度就能变化两到三个数量级。
「导入 CSV 要花 20 分钟」「启动时的数据迁移迟迟跑不完」这类咨询,多半就是这个原因造成的,只需改成事务化并重复使用参数(如上一节代码的做法)就能解决。反过来,如果事务拉得太长又会让其他写入等待,因此实务上把「几百到几千条,或几百毫秒」的量作为一次提交,是比较合适的折中点。
6. 类型映射的陷阱 ── 只有 4 种类型,该分配给谁
SQLite 实际能存储的类型只有 INTEGER / REAL / TEXT / BLOB 这 4 种,.NET 的类型都会被分配到其中之一。Microsoft.Data.Sqlite 的主要映射关系如下表所示。4
| .NET 类型 | SQLite 类型 | 存储形式 | 实务注意事项 |
|---|---|---|---|
| bool / int / long | INTEGER | bool 以 0 / 1 存储 | |
| double | REAL | 浮点误差会照样保留 | |
| string | TEXT | UTF-8 | |
| DateTime | TEXT | yyyy-MM-dd HH:mm:ss.FFFFFFF |
格式与时区必须统一 |
| DateTimeOffset | TEXT | 带偏移量 | 偏移量混用会导致无法排序 |
| Guid | TEXT | 用连字符分隔 | 与 System.Data.SQLite 的默认存储方式(BLOB)不兼容 |
| decimal | TEXT | 0.0###... 形式 |
比较、排序会遵循字符串规则 |
落到 TEXT 的这 3 种类型需要特别注意。
- DateTime: TEXT 中 ISO 8601 系列的格式,只要格式和时区保持统一,字符串排序就等于时间排序,实用上没有问题。反过来说,一旦 UTC 和本地时间混用,就会立刻崩坏。唯一的解法是从一开始就确定「保存用 UTC,显示时再转换为本地时间」并在所有环节都遵守,
datetime('now')等 SQLite 函数返回的也是 UTC。另外一点,如果自己把日期转成字符串,一定要在ToString中传入CultureInfo.InvariantCulture。如果保持区域默认设置,只有在使用和历、佛历等非公历文化区域运行的设备上,年份的表示方式会发生变化,导致排序和读取都会出错(参见 5.1 节的代码示例)。 - Guid: 以字符串形式存储,比对时依赖字符串完全一致。如果不同的工具或库以不同的写法(大小写、BLOB 形式)写入,比对就会失败,因此如果要从多种语言、多个工具访问,请把书写格式明确写入规范文档。
- decimal: 这是最大的陷阱。因为用 REAL 存储会造成精度损失,所以改用 TEXT 存储4,但针对 TEXT 列执行类似
WHERE amount > 1000的比较,由于 SQLite 的类型排序规则中 TEXT 永远大于数值,结果不会符合预期。SUM等聚合运算也会在内部转换为 REAL,这样一来,选择 decimal 本来是为了保留精度,这个意义也就丧失了。
关于 decimal 的实务指导原则很简单:金额应以最小货币单位的整数(INTEGER)来保存。如果是日元,就以「円」为单位用 long 保存,显示时再转换。用整数的话,比较和聚合都既准确又快速,类型的陷阱也就消失了。如果因为现有 schema 的原因,不得不继续用 decimal 处理,那就干脆规定:不在 SQL 侧做比较和聚合,而是读取出来后在 .NET 侧完成。
另外,CREATE TABLE 中写的类型名只是「亲和性(affinity)」的提示,像 STRING 这种自造的类型名会成为隐式转换事故的根源。官方推荐列类型名也只使用 INTEGER / REAL / TEXT / BLOB 这 4 种。4
7. 运维 ── 不损坏、损坏了也能恢复
7.1 启动时执行 quick_check
尽管 SQLite 受到事务保护,但由磁盘故障或错误的文件操作导致的损坏并不能完全归零。为了避免在损坏的数据库上继续运行而扩大损失,需要在启动时加入一致性检查。完整的 integrity_check 在大型数据库上会耗时较长,日常使用轻量版的 quick_check 就足够了。
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check";
var result = (string)cmd.ExecuteScalar()!;
if (result != "ok")
{
// 不要继续向损坏的数据库写入。降级为只读,提示用户进行恢复
logger.LogError("检测到数据库损坏: {Detail}", result);
EnterReadOnlyMode(result);
}
检测到损坏时不做自动修复或自动回滚(而是让用户介入操作)的方针,正如上一篇文章第 6.2 节所写的那样。
7.2 备份 ── 为什么不能直接复制文件
对运行中的数据库文件进行简单复制,可能会拾取到事务执行中途的状态,SQLite 官方文档已将其明确列为损坏原因之一。5 如果是 WAL 模式,还会因为只复制本体而遗漏了 -wal(包含尚未写回本体的提交,见第 4 章),导致最近的数据丢失。
正确的方法有两种,两者都能在运行状态下取得一致的快照。
VACUUM INTO: 用一条 SQL 语句,生成一个消除了碎片、体积最小的副本。8 代码示例已经放在上一篇文章的 6.3 节中,请参考那里。SqliteConnection.BackupDatabase: 是 SQLite Backup API 的封装,用于在连接对象之间进行复制。
using var source = new SqliteConnection($"Data Source={dbPath}");
using var target = new SqliteConnection($"Data Source={backupPath}");
source.Open();
target.Open();
source.BackupDatabase(target); // 即使在运行中,也能得到一致的快照
不过 BackupDatabase 有一点需要注意。Microsoft.Data.Sqlite 目前的实现会尽可能快地复制,并在完成之前阻塞其他连接的写入。9 如果在大型数据库仍在持续测量或用户操作期间执行备份,这段时间内的写入就会表现为 SQLITE_BUSY 或界面卡死。比较安全的分工是:日常的按代备份使用 VACUUM INTO,BackupDatabase 则用于与内存数据库之间的相互复制,或者在写入已经停止的时间段、维护处理中进行复制。如果要用任务计划程序做定期执行,不要从外部直接复制文件,而应该让应用自身(或者一个能正确打开 SQLite 的小工具)来执行上述备份。定期执行本身的设计,可以参考《用任务计划程序安全地运行定期任务》中的内容。
7.3 存放位置与迁移
数据库文件的存放位置基本上是 %LOCALAPPDATA%\公司名\应用名,schema 的版本管理则以 PRAGMA user_version 驱动的启动时迁移作为最小配置。这两点都已经在上一篇文章(第 3 章和 6.1 节)中附带代码写过,这里不再重复。只补充一点:如果在执行迁移之前先用 7.2 节的方法备份一代,那么即使遇到「迁移失败导致无法启动」这种最坏情况,恢复也只是简单地替换文件而已。
8. 该在什么场合使用 EF Core ── ORM 的损益平衡点
到目前为止的内容都基于原生的 Microsoft.Data.Sqlite,但确实也存在应该使用 EF Core SQLite 提供程序的场景。判断的关键在于应用的性质。
| 应用性质 | 推荐 | 理由 |
|---|---|---|
| 画面数量多、以实体为中心的 CRUD 为主(订单管理、主数据管理等) | EF Core + 迁移 | 装填代码和手写 SQL 的总量减少,schema 变更可以通过 dotnet ef migrations 追踪 |
| 专注于写入、schema 较小(测量日志、审计日志、缓存) | 原生的 Microsoft.Data.Sqlite(+ 视需要搭配 Dapper) |
变更追踪的开销是多余的,第 5 章的写入队列 + 批处理能够直接搭建 |
| 两种性质混合存在 | 两者并用 | 对同一个数据库文件,CRUD 界面用 EF Core、日志写入用原生 ADO.NET,并无问题 |
如果选择 EF Core,需要了解 SQLite 提供程序特有的一些限制。10
- 因 ALTER TABLE 限制而进行的表重建: SQLite 不直接支持修改或删除列的类型,因此包含
AlterColumn或DropColumn的迁移会以「新建表 → 复制数据 → 删除旧表 → 重命名」的方式重建执行。在数据量较大的环境中,这会明显影响应用时间和磁盘占用,因此对大表的 schema 变更要有计划地进行。 - 无法生成幂等脚本: 无法像 SQL Server 那样生成带 if-then 判断的迁移脚本,现实的做法是在应用启动时调用
dbContext.Database.Migrate()来应用迁移。 - decimal / DateTimeOffset 的运算是客户端求值: 第 6 章讲到的类型问题在 EF Core 下同样存在。除了等值比较以外的比较和排序都会变成客户端求值,因此「金额以最小单位的整数保存」这条准则在 EF Core 下依然适用(可以用值转换器将其转换为
long存储)。 - WAL 默认启用: EF Core 创建的数据库从一开始就是 WAL 模式7,因此不需要执行第 4 章的设置,但依然需要理解其行为。
另外,即使采用了 EF Core,在仓储层的单元测试中使用 SQLite 内存数据库也是一个很有效的手段。因为运行在与生产环境相同的提供程序上,「用 mock 能通过但真实数据库会失败」这种缝隙就会缩小。关于该在哪一层写测试的思路,可以参考《单元测试与集成测试的边界》。
9. 总结
SQLite 是一个「单纯集成只需要 30 分钟,但要正确运维就需要设计」的库。不过所需的设计其实是有固定套路的,把本文内容整理成清单,就是下面这 6 点。
- 库选用
Microsoft.Data.Sqlite(或建立在其上的 EF Core),不要与以System.Data.SQLite为前提的资料混用 - 从第一个正式版本开始启用 WAL 模式,并理解
-wal/-shm各自的作用 - 把写入归并为一条通路(用 Channels 构建写入队列),细碎的 INSERT 用事务归并
- DateTime 统一使用 UTC,金额以最小货币单位的整数保存。不要对以 TEXT 形式存储的 decimal 直接做比较和聚合
- 启动时执行 quick_check,备份使用
VACUUM INTO或BackupDatabase,不要在运行中直接复制文件 - 删除或替换数据库文件的处理中,别忘了调用
SqliteConnection.ClearPool
如果你的系统正靠不断重试来硬顶 database is locked,或者备份是靠直接复制文件来完成的,不妨趁还没出问题之前,用本文的这些要点做一次盘点。这些问题本身,修复起来都只是很小的改动。
相关文章
- Windows 应用数据存储位置的选择 ── SQLite / JSON / 注册表 / Access 判断表
- Windows 应用机密信息存储 - 用 DPAPI 避免明文配置
- 用任务计划程序安全地运行定期任务
- C# 的 async/await 实务判断表 ── 不卡死 UI、不死锁的写法
相关咨询领域
小村软件有限公司承接嵌入 SQLite 的业务应用设计评审(排他控制、备份、迁移设计),也承接 database is locked、数据损坏、性能劣化等运行中应用的故障排查,以及从 Access 等现有数据存储的迁移支持。
参考链接
-
Microsoft Learn,Microsoft.Data.Sqlite overview。关于它是由 Microsoft 维护的轻量级 ADO.NET 提供程序,也是 EF Core SQLite 提供程序的基础。 ↩ ↩2
-
SQLite,Write-Ahead Logging。关于 -wal / -shm 文件的作用、检查点(默认 1000 页)、读写并发性、模式的持久化,以及无法在网络文件系统上工作。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,Database errors (Microsoft.Data.Sqlite)。关于对 busy / locked 错误会自动重试直到命令超时(默认 30 秒),以及连接、命令等对象并非线程安全。 ↩ ↩2
-
Microsoft Learn,Data types (Microsoft.Data.Sqlite)。关于 SQLite 的 4 种基础类型、DateTime / Guid / decimal 会映射到 TEXT,以及列类型名也应限定为这 4 种基础类型名。 ↩ ↩2 ↩3 ↩4
-
SQLite,How To Corrupt An SQLite Database File。关于在运行中(事务执行期间)复制数据库文件,以及删除或分离热日志、WAL 文件会成为损坏原因。 ↩ ↩2 ↩3
-
Microsoft Learn,Connection strings (Microsoft.Data.Sqlite)。关于连接字符串关键字列表、Pooling 默认启用、Password 在原生库不支持加密时不会生效,以及 Cache=Shared 与 WAL 一起使用不被推荐。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn,Async limitations (Microsoft.Data.Sqlite)。关于 SQLite 不支持异步 I/O、async 方法会同步执行,以及 EF Core 创建的数据库默认启用 WAL。 ↩ ↩2
-
SQLite,VACUUM。关于通过 VACUUM INTO 子句,可以在不修改原文件的情况下,向另一个文件生成一致且体积最小的副本。 ↩
-
Microsoft Learn,Backup (Microsoft.Data.Sqlite)。关于 BackupDatabase 目前的实现会尽可能快地备份,并在完成之前阻塞其他连接写入。 ↩
-
Microsoft Learn,SQLite EF Core Database Provider Limitations。关于迁移中的许多操作会通过表重建来执行、无法生成幂等脚本,以及 decimal / DateTimeOffset 的运算会变成客户端求值。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows应用的数据存储位置怎么选 ── SQLite / JSON / 注册表 / Access 判断表
Windows桌面应用的数据该存在哪里、用什么格式保存?本文整理AppData/ProgramData的使用区分,以及SQLite、JSON文件、注册表、Access(.accdb)各自的优势与陷阱,并附判断表,从实务角度讲解防止数据损坏与位数问题等注意事项。
业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计
服务器迁移后时间整整差了 9 个小时、只有海外分支机构的日期会变成前一天——本文从 DateTime 的 Kind 与隐式转换梳理日期时间事故的根本原因。内容涵盖与 DateTimeOffset 的取舍、UTC 存储与 ISO 8601 原则、TimeZoneInfo 与夏...
Windows 服务的创建与运维 ── 从任务计划程序的取舍到 BackgroundService 服务化
常驻处理应该做成 Windows 服务,还是任务计划程序就够用?本文整理判断表,以及用 .NET Worker Service(UseWindowsService)创建服务的方法、运行账户、恢复选项、事件日志、安全停止处理,从实务角度梳理投入运维所需的设计。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
防止 Windows 应用程序重复启动 ── 命名 Mutex 与重复执行时的窗口前置
整理业务型 Windows 应用程序的常见需求——「不让同一个应用程序启动两次」——如何用命名 Mutex 来实现。涵盖 Global\ 与 Local\ 命名空间差异在 RDP 环境中的陷阱、拥有线程限制与 AbandonedMutexException、在 SetFor...
常见问题
汇总了咨询这一主题时常见的问题。
- 为什么 SQLite 会出现「database is locked」?
- SQLITE_BUSY 会在另一个连接持有写入锁时发生。Microsoft.Data.Sqlite 会自动重试直到命令超时(默认 30 秒),因此通常不需要自己实现重试逻辑。如果仍然抛出异常,原因通常是超过 30 秒的长事务、从读取升级为写入时发生冲突,或者多个线程争抢细碎的写入操作,单纯增加重试次数并不能解决问题。根本对策是缩短事务,并使用 System.Threading.Channels 等队列把写入路径归并为一条。启用 WAL 模式后,读写之间的大部分阻塞也会消失。
- 在 C# 中使用 SQLite 应该选哪个库?
- 新开发的基本选择是由 Microsoft 维护的 ADO.NET 提供程序 Microsoft.Data.Sqlite(或建立在其上的 EF Core SQLite 提供程序)。NuGet 包中已经附带了原生的 SQLite 本体,因此不需要额外的分发工作。老牌的 System.Data.SQLite 与之在连接字符串和类型处理上都不兼容,例如 Guid 在 System.Data.SQLite 中默认以 BLOB 存储,而在 Microsoft.Data.Sqlite 中则以 TEXT 存储。使用网上的示例代码时,要始终留意它是基于哪一个库的。
- SQLite 的备份可以直接复制文件吗?
- 禁止在运行中对文件进行简单复制。这样做可能会拾取到事务执行中途的状态,SQLite 官方文档也把它列为损坏原因之一。在 WAL 模式下,-wal 文件中还包含尚未写回本体的已提交事务,如果只复制本体文件,最近的数据就会丢失。正确的方法是使用 VACUUM INTO(用一条 SQL 语句生成一致且体积最小的副本),或者使用 SqliteConnection.BackupDatabase。不过 BackupDatabase 在完成之前会阻塞其他连接的写入,因此日常的按代备份更适合用 VACUUM INTO。
- 为什么 SQLite 的大批量 INSERT 会很慢?
- 原因几乎都在于提交的粒度。SQLite 每次提交都会对存储执行一次同步写入(fsync),如果逐条以隐式提交的方式 INSERT,即使是 SSD 每秒也只能处理数百到数千条。只需把同样的 INSERT 归并到以 1000 条为单位的显式事务中(用 BeginTransaction 包裹,最后调用 Commit),吞吐量就能达到每秒数万到数十万条的量级,速度提升两到三个数量级。反过来,如果事务拉得太长,又会让其他写入等待,因此实务上把「几百到几千条,或几百毫秒」的量作为一次提交,是比较稳妥的折中方案。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。