在 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 lockedSQLITE_BUSY)不是「补一段错误处理代码」就能解决的问题,而是该重新审视设计的信号Microsoft.Data.Sqlite 会自动重试直到超时(默认 30 秒)3,但根本对策是把写入路径归并为一条。
  • 大量执行细碎的 INSERT 时,仅靠显式事务归并就能让速度提升两到三个数量级。一旦感觉「SQLite 太慢」,首先要怀疑的是提交的粒度。
  • SQLite 实际上只有 INTEGER / REAL / TEXT / BLOB 这 4 种类型,DateTimeGuiddecimal 都会以 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=3UseUTF16Encoding,以及用于改变日期格式的 DateTimeFormat 等关键字在 Microsoft.Data.Sqlite 中并不存在,指定它们会抛出异常。可用的关键字只有 Data Source / Mode / Cache / Password / Foreign Keys / Default Timeout / Pooling 等寥寥数个。6
  • 类型的处理方式不同。 例如 GuidSystem.Data.SQLite 中默认以 BLOB 存储,而在 Microsoft.Data.Sqlite 中则以 TEXT 存储。如果在迁移期间同时用两个库读写同一个数据库文件,这个差异就会表现为数据不一致。

Microsoft.Data.Sqlite 是有意做得很薄的一层封装,没有自己的类型转换或便利功能,因此 SQLite 官方文档的说明可以直接套用。如果现有应用在 System.Data.SQLite 上已经稳定运行,不必强行迁移;但新代码应该向 Microsoft.Data.Sqlite 靠拢,如果要迁移,原则上先把上述不兼容之处列成迁移清单再动手。

3. 连接与连接字符串 ── 连接池会占用文件

连接字符串的基本形式只是一个文件路径。实务中需要留意的是 ModePooling6

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 的方针下,它没有出场的机会。6
  • Password: 指定后会在连接建立后立即发送 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 INTOBackupDatabase 则用于与内存数据库之间的相互复制,或者在写入已经停止的时间段、维护处理中进行复制。如果要用任务计划程序做定期执行,不要从外部直接复制文件,而应该让应用自身(或者一个能正确打开 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 不直接支持修改或删除列的类型,因此包含 AlterColumnDropColumn 的迁移会以「新建表 → 复制数据 → 删除旧表 → 重命名」的方式重建执行。在数据量较大的环境中,这会明显影响应用时间和磁盘占用,因此对大表的 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 INTOBackupDatabase,不要在运行中直接复制文件
  • 删除或替换数据库文件的处理中,别忘了调用 SqliteConnection.ClearPool

如果你的系统正靠不断重试来硬顶 database is locked,或者备份是靠直接复制文件来完成的,不妨趁还没出问题之前,用本文的这些要点做一次盘点。这些问题本身,修复起来都只是很小的改动。

相关文章

相关咨询领域

小村软件有限公司承接嵌入 SQLite 的业务应用设计评审(排他控制、备份、迁移设计),也承接 database is locked、数据损坏、性能劣化等运行中应用的故障排查,以及从 Access 等现有数据存储的迁移支持。

参考链接

  1. Microsoft Learn,Microsoft.Data.Sqlite overview。关于它是由 Microsoft 维护的轻量级 ADO.NET 提供程序,也是 EF Core SQLite 提供程序的基础。  2

  2. SQLite,Write-Ahead Logging。关于 -wal / -shm 文件的作用、检查点(默认 1000 页)、读写并发性、模式的持久化,以及无法在网络文件系统上工作。  2 3 4 5

  3. Microsoft Learn,Database errors (Microsoft.Data.Sqlite)。关于对 busy / locked 错误会自动重试直到命令超时(默认 30 秒),以及连接、命令等对象并非线程安全。  2

  4. Microsoft Learn,Data types (Microsoft.Data.Sqlite)。关于 SQLite 的 4 种基础类型、DateTime / Guid / decimal 会映射到 TEXT,以及列类型名也应限定为这 4 种基础类型名。  2 3 4

  5. SQLite,How To Corrupt An SQLite Database File。关于在运行中(事务执行期间)复制数据库文件,以及删除或分离热日志、WAL 文件会成为损坏原因。  2 3

  6. Microsoft Learn,Connection strings (Microsoft.Data.Sqlite)。关于连接字符串关键字列表、Pooling 默认启用、Password 在原生库不支持加密时不会生效,以及 Cache=Shared 与 WAL 一起使用不被推荐。  2 3 4 5 6

  7. Microsoft Learn,Async limitations (Microsoft.Data.Sqlite)。关于 SQLite 不支持异步 I/O、async 方法会同步执行,以及 EF Core 创建的数据库默认启用 WAL。  2

  8. SQLite,VACUUM。关于通过 VACUUM INTO 子句,可以在不修改原文件的情况下,向另一个文件生成一致且体积最小的副本。 

  9. Microsoft Learn,Backup (Microsoft.Data.Sqlite)。关于 BackupDatabase 目前的实现会尽可能快地备份,并在完成之前阻塞其他连接写入。 

  10. Microsoft Learn,SQLite EF Core Database Provider Limitations。关于迁移中的许多操作会通过表重建来执行、无法生成幂等脚本,以及 decimal / DateTimeOffset 的运算会变成客户端求值。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

为什么 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表