简介:System.Data.SQLite 是面向 .NET 开发者的 SQLite 增强组件,内置 ADO.NET 2.0 引擎,无需额外安装 .NET Framework,就能让 .NET 程序直接使用 SQLite 数据库,非常适合轻量级、独立部署或资源受限环境。这份 PDF 文档围绕该组件展开,先介绍 SQLite 的单文件存储、事务支持、跨平台运行等核心特点,再分析 System.Data.SQLite 在 ADO.NET 集成、自包含部署和开发环境兼容性上的增强,并给出在 Visual Studio 中连接与操作数据库的具体方式。
文档还重点整理了一组通用数据库操作类的设计思路,涵盖创建数据库文件、返回 DataTable 或 DataReader、执行增删改与聚合查询、获取所有表名等典型方法;所有操作均采用参数化 SQL 语句,可有效防止注入攻击。同时结合一个处理 Excel 数据的实际案例,说明在缺少 Access 等数据库管理系统的场景下,如何通过 System.Data.SQLite 提高查询效率,减少内存表方式的性能损耗。
整个资源仅包含 1 个 PDF 文件,压缩包大小约 300KB,内容精炼,已有 209 人学习/下载,适合初学 .NET 数据库开发、希望在项目中快速集成 SQLite 的工程师参考查阅。
1. System.Data.SQLite:把 SQLite 引擎直接嵌进 .NET 项目的落地选择
做桌面工具和离线缓存的时候,我经常要面对一个选择:程序需要一个能随装随走的本地数据库,不想在客户机器上部署完整版数据库服务,也不希望数据散落在多个配置文件里。System.Data.SQLite 就是这类场景里最省事的一条路:它把完整的 SQLite 引擎和 ADO.NET 适配器打包成一个库,C# 这边连上就能建表、查询、事务,数据库就落在一个 .db 文件里。它的适用人群很直接:写 WPF/WinForms 桌面工具、Windows 服务、离线缓存或单元测试的 .NET 开发者。它解决的痛点也很明确:零配置、单文件、跨平台,同时不丢掉 SQL 能力。
下面从选型到踩坑,按我实际拆过的几个项目顺序来讲。本文不会只贴 API,重点放在参数怎么设、并发怎么写、上线前查什么。
2. 选型与安装:先搞清楚它和普通 SQLite 的区别再动手
2.1 它其实是一层 ADO.NET 适配器
很多人第一次接触 System.Data.SQLite 时有个误解,以为它只是把 SQLite 源码编译成了 .NET 版本。其实它的结构是两层:底层是原生 SQLite 引擎,顶层是一套完整的 ADO.NET 实现,包括 SQLiteConnection、SQLiteCommand、SQLiteDataReader 这些类。也就是说,你在 .NET 里写的连接、命令、事务代码,最终会通过 P/Invoke 调用原生库执行。这套东西单独抽出来的好处是,凡是熟悉 ADO.NET 的开发者,上手成本几乎为零;缺点也很明显——原生层的存在让部署和线程模式变得比纯粹托管代码复杂,后面避坑章节会专门讲。
选型时还要注意打包方式的区别:常见的是分开的两个 DLL(主程序集加原生互操作 DLL),也有 Bundle 版本把原生库合并进主程序集。桌面工具分发时我一般倾向 Bundle,少一个文件就少一个"文件丢失"的投诉渠道。
2.2 用 NuGet 安装并核对运行时
在包管理器控制台执行安装:
Install-Package System.Data.SQLite.Core如果要完整功能(包括加密支持),可以选择带完整功能的包,不过日常项目用 Core 就够。安装完成后在项目引用里确认两个东西:一是主程序集出现在引用列表,二是输出目录里出现了原生互操作文件。如果只看到托管 DLL,说明装的可能是非 Bundle 版本,原生文件没有自动复制,后面跑起来会报加载失败。
版本核对有个常见做法:运行时在代码里查一次引擎版本,避免低级环境问题。
using System.Data.SQLite; var conn = new SQLiteConnection("Data Source=:memory:"); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT SQLITE_VERSION();"; var version = cmd.ExecuteScalar(); Console.WriteLine("引擎版本: " + version); conn.Close();这段代码用内存库做连接,目的是只验证原生引擎是否加载成功。执行结果会输出类似 3.4x.x 的引擎版本号。如果在这一步抛出 DLL 加载异常,说明环境不匹配,需要在输出目录检查原生库是否复制到位。
2.3 第一个连接:连接字符串参数逐个说
连接字符串看着简单,但对运行行为影响很大。我常用的基础写法:
string connStr = "Data Source=appdata.db;Version=3;Pooling=True;Foreign Keys=True;"; using (var conn = new SQLiteConnection(connStr)) { conn.Open(); // 建表 var createSql = @"CREATE TABLE IF NOT EXISTS projects( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, path TEXT UNIQUE, created_at TEXT)"; var cmd = new SQLiteCommand(createSql, conn); cmd.ExecuteNonQuery(); }参数逐个说明:
- Data Source 指向数据库文件路径,如果文件不存在,SQLite 会按需创建。用
:memory:则创建内存库。 - Version=3 表示使用 SQLite 3.x 数据格式,现在没有理由写 2。
- Pooling=True 开启连接池。System.Data.SQLite 默认就开启连接池,显式写出来是防止有人误关。连接池对短连接场景收益很大,桌面工具里频繁开关连接时尤其明显。
- Foreign Keys=True 很关键,SQLite 的外键约束默认是关闭的,不写这一项,建了外键也不生效。
这一步做完,数据库文件会在程序启动目录生成。要验证文件落盘正常,先关闭连接,再用文件管理器看体积不是 0 字节。有人在这里图省事用内存库做全流程测试,结果换到文件库时才发现路径权限有问题,这个习惯不建议学。
3. CRUD 与参数化:把一行数据的读写写对
3.1 参数化插入:为什么必须用 @ 参数
SQLite 的 .NET 适配器支持字符串拼接 SQL,但血泪教训是:所有传入值必须走参数化。不只是防注入,更是因为字符串拼接会造成 SQLite 每次都重新解析 SQL,参数化可以复用执行计划。插入并取回自增主键的写法:
using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { var sql = @"INSERT INTO projects(name, path, created_at) VALUES (@name, @path, @createdAt); SELECT last_insert_rowid();"; using (var cmd = new SQLiteCommand(sql, conn, tx)) { // AddWithValue 简单直观,但类型推断偶尔有偏差,敏感场景用显式 Add cmd.Parameters.AddWithValue("@name", "模拟项目X"); cmd.Parameters.AddWithValue("@path", @"D:\data\proj.db"); cmd.Parameters.AddWithValue("@createdAt", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); long newId = (long)cmd.ExecuteScalar(); Console.WriteLine("新主键: " + newId); } tx.Commit(); } }逻辑说明:INSERT 语句后面紧跟SELECT last_insert_rowid(),同一连接、同一事务内执行,可以拿到刚才插入生成的自增主键。注意这里用了事务,即使单条插入也建议包一层事务,因为 SQLite 默认每条语句自动提交,频繁自动提交会带来不必要的文件写入开销。
参数说明:AddWithValue 的便利背后有代价——它可能把 C# 的 int 推断成 INTEGER 以外的类型,导致存储格式不统一。日期类型我一般显式转成 ISO 格式字符串再存,SQLite 没有原生的日期时间类型,统一成 TEXT 反而让排序和比较都可靠。
3.2 批量写入:事务包裹让 1 万条写入不再是噩梦
日志类、埋点类数据批量写入是桌面工具的常见需求。最容易翻车的写法是循环里逐条执行,每条一个隐式事务,几分钟都插不完。正确打开方式:
var watch = System.Diagnostics.Stopwatch.StartNew(); using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) using (var cmd = new SQLiteCommand( @"INSERT INTO logs(ts, level, message) VALUES(@ts, @level, @msg);", conn, tx)) { // 复用参数对象,避免每次循环重建 var ts = cmd.Parameters.Add("@ts", DbType.String); var lvl = cmd.Parameters.Add("@level", DbType.String); var msg = cmd.Parameters.Add("@msg", DbType.String); for (int i = 0; i < 10000; i++) { ts.Value = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); lvl.Value = i % 3 == 0 ? "INFO" : "DEBUG"; msg.Value = "批量日志 #" + i; cmd.ExecuteNonQuery(); } tx.Commit(); } } Console.WriteLine("耗时: " + watch.ElapsedMilliseconds + " ms");逻辑说明:事务把所有写入合并成一次提交,SQLite 在事务内先把数据写到页缓存,Commit 时才统一刷盘,IO 次数从 1 万次降到 1 次。参数对象在循环外创建,每轮只赋值不重建,省掉参数绑定开销。
数值参考:机械硬盘上这个量级通常能压到几百毫秒以内;如果还慢,优先检查是不是每次循环都在 new SQLiteCommand。事务内执行的命令不要不带事务对象,Command 构造的第三个参数传事务,否则命令不认当前事务上下文。
3.3 读取结果集:类型映射与 null 处理
读取端同样有细节。SQLite 列本身是动态类型,但 API 层提供了强类型读取方法。常用写法:
using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT id, name, size, remark FROM files WHERE parent_id = @pid;"; cmd.Parameters.AddWithValue("@pid", 128); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { long id = reader.GetInt64(0); string name = reader.GetString(1); // 可空列建议先判空再取 long? size = reader.IsDBNull(2) ? (long?)null : reader.GetInt64(2); string remark = reader.IsDBNull(3) ? "" : reader.GetString(3); Console.WriteLine($"{id} | {name} | {size} | {remark}"); } } } }类型映射上,SQLite 的 INTEGER 对应 .NET 的 long,REAL 对应 double,TEXT 对应 string,BLOB 对应 byte[]。这里有个玄学点:如果用 AddWithValue 插入了一个普通 int,读出时 GetInt64 仍然没问题,因为 SQLite 存的是 INTEGER 类型,只是数值范围不同。但反过来,如果某列混存了 TEXT 和 INTEGER,GetInt64 会直接抛异常,根源是写入端没有统一类型,这种脏数据比进程崩溃还难排查。
4. 避坑清单:System.Data.SQLite 上线前的 5 个高频问题
4.1 部署时提示未能加载原生互操作 DLL
现象:开发机跑得好好的,打包发给别人,双击就报"未能加载文件或程序集 SQLite.Interop.dll"。
原因:非 Bundle 版本依赖原生互操作文件,它的输出目录按平台分了 x86、x64 子目录。默认复制策略有时没把对应平台的文件带进发布包。
解决:项目属性里把原生互操作文件的"复制到输出目录"改成"始终复制";或者直接换 Bundle 包,一劳永逸。用这个项目的,我一般推荐 Bundle。
4.2 外键约束悄悄失效
现象:子表插入了父表不存在的 ID,居然没有报错,业务逻辑因此出现孤儿数据。
原因:SQLite 外键默认关闭,连接字符串没加Foreign Keys=True,约束只是建表时写在了 DDL 里,运行时完全不检查。
解决:连接字符串统一加上Foreign Keys=True;。注意这是连接级设置,每个连接都要带。另外可以执行PRAGMA foreign_keys;查询当前会话是否开启,返回 1 才是开。
4.3 32/64 位进程加载失败
现象:同一套代码,在 64 位机器上可以运行,在 32 位进程环境里就报"试图加载格式不正确的程序集"。
原因:原生库分 x86 和 x64 两个版本,进程位数和库位数不一致时无法加载。
解决:首选把项目平台目标设为"AnyCPU",并确保两个平台的原生库都存在;如果引用的其他组件强制了 x86,那就统一让整个进程跑 32 位,并只保留 x86 的原生库。上线前把发布目录打开看一眼平台子目录是否存在,能省一大半这种事。
4.4 数据库文件被锁:SQLITE_BUSY
现象:程序偶发报"database is locked",出现在并发读取或写入时,重试一次又好了。
原因:SQLite 的锁粒度基于整个数据库文件。一个连接持有写锁时,另一个连接的写入会失败。桌面工具里最常见的是多线程共用一条连接或者多个进程同时操作同一个文件。
解决:连接字符串加Busy Timeout=3000;让等待锁的请求多等 3 秒而不是立刻失败;同时把并发控制交给下一章要讲的 WAL 模式。进程级别的并发(多个程序同时写同一个库文件)需要改用服务端数据库,SQLite 不是为这种场景设计的,别硬上。
4.5 密码参数不生效
现象:连接字符串里写了Password=123456,文件却仍然是未加密的明文数据库。
原因:标准版构建不包含加密支持,密码参数只在带加密功能的构建里才生效。很多人装的是普通包,所以密码被静默忽略。
解决:需要加密就在包管理器里选带加密的包,并确认初始化的包里确实包含了加密实现。如果拿不准,建库后用一个普通文本编辑器打开 .db 文件,看到可读的表名说明没加密成功。
5. 事务、WAL 与并发:多人同时写该怎么控制
5.1 事务边界:BeginTransaction 的正确用法
System.Data.SQLite 的默认行为是自动提交模式,每条语句独立成一个隐式事务。批量操作、多表联动更新时,必须显式开启事务。推荐用 using 包住事务对象:
using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { // 多个更新语句 using (var cmd = conn.CreateCommand()) { cmd.Transaction = tx; cmd.CommandText = "UPDATE projects SET path = @p WHERE id = @id;"; cmd.Parameters.AddWithValue("@p", @"D:\newpath\proj.db"); cmd.Parameters.AddWithValue("@id", 1); cmd.ExecuteNonQuery(); } tx.Commit(); } catch { tx.Rollback(); throw; } } }事务的隔离级别默认是 Serializable,写多读少的桌面工具基本够用。命令对象必须显式设置Transaction = tx,否则它会认为自己在独立事务里执行,这是新手最常见的翻车点——开了事务,但命令没进事务。
5.2 WAL 模式:把并发读写冲突降下来
SQLite 默认的日志模式是 DELETE,写事务提交时直接操作主数据库文件,读写互相阻塞。WAL(Write-Ahead Logging)模式把新写入追加到独立的 -wal 文件,提交后由检查点机制合并回主库,这样读操作不会被写阻塞。
using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var cmd = conn.CreateCommand()) { // journal_mode 是持久的,设置一次后所有连接都生效 cmd.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"; cmd.ExecuteNonQuery(); } }参数说明:synchronous=NORMAL在 WAL 模式下是推荐搭配——它降低 fsync 频率,减少提交延迟,同时因为 WAL 本身具备崩溃恢复,不容易产生文件损坏。要注意 WAL 只在文件库上有意义,内存库设置它没有效果。
启用 WAL 后,同级目录会出现 -wal 和 -shm 两个辅助文件。做备份和分发时要把这三个文件一起处理,只拷主文件会导致数据不一致。程序正常关闭时检查点会合并,但崩溃场景下辅助文件还是可能在。
5.3 连接池与线程安全:并发场景怎么设计
System.Data.SQLite 默认开启连接池,但有一个容易被忽略的机制:连接池按连接字符串分组。如果每次动态拼接出不同的连接字符串(比如 Data Source 大小写变一变),池就分裂成多个,达不到复用效果,还可能积累大量空闲连接。
线程安全方面,SQLite 原生层默认以序列化模式运行,多条连接在不同线程下可以并发操作同一个库文件。但同一连接对象不要跨线程同时使用,这个限制来自 ADO.NET 而非 SQLite。我的习惯是一线程一连接,用完即关,让连接池处理复用。
需要多进程共享同一个内存库时,可以用共享缓存连接字符串:
string memConnStr = "Data Source=file:memdb1?mode=memory&cache=shared";这段写法允许同进程内不同连接访问同一个内存库。注意跨进程时内存库不可见,别在服务进程和客户端之间试图共享内存库,文件库才是唯一选择。
6. 验证技巧:上线前用三个 PRAGMA 和 EXPLAIN 检查库健康
6.1 用 EXPLAIN QUERY PLAN 确认查询真的走了索引
写好的查询,执行计划对不对,直接决定数据量上来后是毫秒级还是秒级。把查询前加 EXPLAIN QUERY PLAN:
EXPLAIN QUERY PLAN SELECT * FROM logs WHERE created_at BETWEEN '2024-01-01' AND '2024-02-01';如果结果里出现SCAN logs,说明全表扫描,表大了必慢;出现SEARCH logs USING INDEX ...才是走了索引。没有索引就建一个:
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at);复合查询条件(比如同时按 created_at 和 level 过滤)建议建复合索引,单列索引只能命中其中一个条件。
6.2 三个 PRAGMA 快速体检
| PRAGMA 命令 | 作用 | 健康判断 |
|---|---|---|
PRAGMA journal_mode; | 查看当前日志模式 | 返回 wal 或 delete 都是正常,别返回 memory |
PRAGMA integrity_check; | 检查页级数据完整性 | 返回 ok 即无损坏 |
PRAGMA foreign_key_check; | 查找违反外键约束的记录 | 空结果即全部正常 |
integrity_check 在文件损坏初期能发现信号,尤其是磁盘突然断电后。foreign_key_check 则能揪出早期外键没开时写入的孤儿数据。这两条命令我在每次版本发布前都会跑一遍,工具化以后就写进了一个小脚本。
从那次因为漏了索引导致客户数据量涨到几万行就开始卡之后,我养成了一个习惯:每次交付前,强制走一遍 EXPLAIN QUERY PLAN 确认热点查询走索引,再跑一遍三个 PRAGMA 体检。这套流程救过我至少两次,希望也能帮到你。
本文还有配套的精品资源,点击获取