C#操作SQLite3从入门到工程实践:增删改查、事务与WAL模式详解
简介面向.NET初学者的C# SQLite3增删改查Demo通过完整可运行示例演示轻量级数据库的接入流程覆盖连接配置、建表操作、数据插入、结果查询、记录更新与删除等增删改查核心环节适用于桌面应用、工具软件以及移动端的本地离线存储场景。资源打包为rar压缩包大小约32.56MB共175个文件核心内容包括System.Data.SQLite.dll等40个动态链接库、9个C#工程源码文件、14个targets与14个xml工程配置、6个nupkg依赖包外加1个db数据库样例工程源码与运行依赖齐全下载解压后即可在Visual Studio中编译运行。目前已有2256人学习下载。Demo还重点展示了参数化查询防SQL注入、BeginTransaction事务提交与回滚、操作完成后及时关闭连接释放资源等工程化写法并将增删改查封装为辅助类便于直接复制到自身项目中按需调整。通过连接字符串配置、建表与命令执行等完整调用链开发者可以清晰理解SQLite3在C#中的使用方式适合希望快速落地SQLite3本地数据库的.NET开发者学习参考。1. C# SQLite3增删改查Demo本地数据存储最务实的入口做C#上位机或者桌面工具早晚会撞到“数据得存下来”这道坎。SQLite3是一个不需要安装服务端的嵌入式数据库整个数据库就是一个单文件官方驱动一个NuGet包就能引进来。这份Demo把数据库增删改查整个闭环拆成了可以直接调用的C#代码从建表、插入、查询到更新、删除每一步都和实际项目贴得很近。适合刚开始接触SQLite3的C#开发也适合给WinForm、WPF项目快速落地一个本地存储模块。读完之后你手里会有一套改改就能用的CRUD模板不是零散的知识点是能直接拖进项目跑的东西。2. 选型与初始化为什么是SQLite3连接字符串怎么配2.1 嵌入式数据库的选型理由与适用场景在C#这边做本地数据持久化最常见的三个方向是SQL Server、Access和SQLite3。三者差异不是功能强弱而是部署模型和应用场景。SQL Server是服务型数据库功能最全但安装实例、维护账号权限、管理端口这些成本对单机工具来说太重。Access是老牌桌面数据库胜在简单但驱动在32位和64位进程里频繁踩坑文件锁也让人头疼。SQLite3是嵌入式数据库不需要独立服务进程数据库就是一个.db文件驱动以DLL形式嵌入程序。方案部署成本单文件并发能力典型场景SQL Server高需要服务端和运维否强多客户端、服务端集中管理Access中驱动兼容性问题多是弱旧版桌面程序、小型工具SQLite3低DLL随程序走是中配合WAL可用单机应用、上位机本地缓存我的习惯很直接凡是单机桌面程序、上位机数据采集的本地缓冲、工具软件的配置存档优先SQLite3。特别是C#上位机场景设备采集数据往往高频写入本地隔一段时间再同步到服务器SQLite3在这种读写模式下非常合适。单文件也让部署和排障变得简单——程序出问题直接把.db文件拷回来就是一手证据。需要说明的是SQLite3并不适合高并发写入的服务端场景它是为嵌入式场景设计的别指望它替代SQL Server。2.2 NuGet包引入与连接字符串初始化我用的是Microsoft.Data.Sqlite这是.NET基金会维护的官方驱动。相比老牌的System.Data.SQLite它更贴近现代.NET接口风格接口长得像ADO.NET从别的数据库迁移过来学习成本低。在Visual Studio里通过NuGet包管理器搜索Microsoft.Data.Sqlite安装对应你项目目标框架的版本即可。我一般还会同时安装SQLitePCLRaw.bundle_e_sqlite3它负责提供原生SQLite引擎避免程序在精简系统上找不到原生组件。using Microsoft.Data.Sqlite; // 构建连接字符串Data Source指定数据库文件路径 string connectionString Data Sourceapp.db;DefaultTimeout30;; using var connection new SqliteConnection(connectionString); connection.Open(); // 验证连接是否可用SELECT 1 是最轻量的探活语句 using var cmd connection.CreateCommand(); cmd.CommandText SELECT 1; var result cmd.ExecuteScalar(); Console.WriteLine($连接测试结果: {result});这段代码做了三件事定义连接字符串、打开连接、执行一个SELECT 1验证连接可用。Data Sourceapp.db表示数据库文件在当前工作目录下如果文件不存在SQLite会自动创建。注意using var保证连接对象在方法结束时自动释放这是避免连接泄漏的关键写法后面避坑章节会详细展开。连接字符串里几个常用参数需要说明一下。ModeReadWriteCreate是默认值意思是以读写方式打开文件不存在就创建如果只想只读打开改成ModeReadOnly。CacheShared可以开启共享缓存某些跨线程场景用得着。DefaultTimeout30设置默认命令超时时间单位是秒推荐在生产代码里显式写出来告诉排障的人超时阈值在哪。string connStr Data Sourceapp.db;ModeReadWriteCreate;CacheShared;DefaultTimeout30;;这行配置是我在项目里最常用的连接字符串模板。CacheShared在多线程共享同一个连接时能减少数据库锁冲突的概率但前提是线程之间不能在同一个连接上同时执行写入否则还是要自己加锁。DefaultTimeout也是给SQLite的锁等待兜底写入冲突时线程会阻塞等待而不是立刻抛异常。2.3 初始化安全路径处理与启动自检初始化还有一个容易被忽略的点数据库文件路径。工作目录变了app.db就可能落到你意想不到的地方。我一般会显式指定路径用Path.Combine(AppDomain.CurrentDomain.BaseDirectory, data, app.db)并把目录创建好而不是依赖相对路径。否则程序装了服务或者换了启动目录连接日志里全是unable to open database file。// 显式指定数据库目录防止工作目录变化导致文件位置漂移 string dataDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, data); Directory.CreateDirectory(dataDir); string dbPath Path.Combine(dataDir, app.db); string connStr $Data Source{dbPath};CacheShared;DefaultTimeout30;; using var conn new SqliteConnection(connStr); conn.Open(); // 启动自检确认数据库可读写并校验完整性 using var cmd conn.CreateCommand(); cmd.CommandText PRAGMA integrity_check;; var integrity cmd.ExecuteScalar(); if (integrity.ToString() ! ok) { throw new Exception($数据库完整性检查失败: {integrity}); }Directory.CreateDirectory不会在目录已存在时报错可以放心调用。PRAGMA integrity_check是SQLite自带的完整性校验命令返回ok说明文件结构正常。这段启动自检放在程序入口处数据库文件出问题的时候能在第一时间暴露而不是等到查询报错才去排查这个习惯帮我省了不少上线后的尴尬时间。3. 核心CRUD实操增删改查四类操作的完整代码与参数说明3.1 建表与插入主键策略和写入的正确姿势SQLite3基本操作里建表是第一步。这个Demo里的表结构用了一个自增主键id跟实际项目里设备数据采集的场景很贴合。SQLite的INTEGER PRIMARY KEY本身就会自增不需要额外加AUTOINCREMENT。加上AUTOINCREMENT之后SQLite会额外维护一个序列保证自增值永不重用代价是多一点写开销。如果数据量不大、业务上不需要“删掉最大ID后不重复使用”用INTEGER PRIMARY KEY就够了。// 执行建表语句IF NOT EXISTS 保证重复初始化不会报错 string createSql CREATE TABLE IF NOT EXISTS device_data ( id INTEGER PRIMARY KEY, device_id TEXT NOT NULL, temperature REAL NOT NULL DEFAULT 0, humidity REAL, record_time TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); using (var cmd connection.CreateCommand()) { cmd.CommandText createSql; cmd.ExecuteNonQuery(); } // 插入一条设备数据参数化写法避免SQL注入 using (var cmd connection.CreateCommand()) { cmd.CommandText INSERT INTO device_data (device_id, temperature, humidity) VALUES ($deviceId, $temperature, $humidity); cmd.Parameters.AddWithValue($deviceId, sensor_001); cmd.Parameters.AddWithValue($temperature, 26.5); cmd.Parameters.AddWithValue($humidity, 58.2); int rows cmd.ExecuteNonQuery(); Console.WriteLine($插入影响行数: {rows}); }CREATE TABLE IF NOT EXISTS保证连接初始化时重复执行也不会报错这在程序多次启动的场景下很实用。record_time字段用datetime(now, localtime)直接写入本地时间比在C#代码里取DateTime.Now.ToString()再拼字符串要干净时区处理统一交给SQLite。插入时我用$deviceId这样的参数名这是Microsoft.Data.Sqlite支持的参数格式也可以写deviceId但要注意命令文本里的参数名必须和Parameters.AddWithValue里的一致连$符号都要带上。ExecuteNonQuery()返回值是受影响的行数。插入一条数据正常情况下返回1如果返回0或者负数说明SQL语句没有匹配到任何数据或者触发了约束需要检查参数值。这里temperature定义为NOT NULL DEFAULT 0即使某条数据没填温度也不会因为空值导致查询端崩溃。humidity没有加NOT NULL这是故意的——有些采集设备没有湿度传感器留空比填0更符合业务语义。3.2 查询与更新DataReader读取和参数化写法查询是增删改查里最见功底的。SQLite3查询结果有两种读取方式一种是ExecuteScalar拿单个值适合COUNT、SUM这类聚合一种是ExecuteReader拿结果集适合逐行处理。这个Demo里展示的是后者贴近实际数据展示场景。// 按设备ID查询最近100条记录 using (var cmd connection.CreateCommand()) { cmd.CommandText SELECT id, device_id, temperature, humidity, record_time FROM device_data WHERE device_id $deviceId ORDER BY id DESC LIMIT 100; cmd.Parameters.AddWithValue($deviceId, sensor_001); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { long id reader.GetInt64(0); string deviceId reader.GetString(1); double temperature reader.GetDouble(2); string recordTime reader.GetString(4); Console.WriteLine(${id} | {deviceId} | {temperature} | {recordTime}); } } }ORDER BY id DESC按自增主键倒序排列最新数据排在最前面。LIMIT 100控制返回行数避免一次查询把全表拖进内存。GetInt64(0)的0是列的序号从0开始也可以用reader.GetInt64(reader.GetOrdinal(id))按列名取值可读性更好但略慢一点。对于SQLite的INTEGER主键驱动里对应的是long类型用GetInt32在数据量超过21亿时会翻车这个习惯建议从一开始就养成。更新操作跟插入的结构很接近关键是WHERE条件要写明白// 根据ID更新温度和湿度WHERE条件必须精确到唯一记录 using (var cmd connection.CreateCommand()) { cmd.CommandText UPDATE device_data SET temperature $temperature, humidity $humidity WHERE id $id; cmd.Parameters.AddWithValue($temperature, 27.1); cmd.Parameters.AddWithValue($humidity, 59.0); cmd.Parameters.AddWithValue($id, 1); int rows cmd.ExecuteNonQuery(); Console.WriteLine($更新影响行数: {rows}); }WHERE id $id只更新明确指定的一条记录。实际项目里经常犯的错是UPDATE语句漏掉WHERE结果全表都被改了。执行ExecuteNonQuery后务必要判断rows如果大于1就要警觉是不是WHERE条件不够精确。执行更新前可以先跑一条同条件SELECT确认影响范围再执行更新这是个便宜又有效的习惯。3.3 删除操作物理删除与软删除的取舍删除操作的写法本身很简单// 按ID物理删除记录 using (var cmd connection.CreateCommand()) { cmd.CommandText DELETE FROM device_data WHERE id $id; cmd.Parameters.AddWithValue($id, 1); int rows cmd.ExecuteNonQuery(); Console.WriteLine($删除影响行数: {rows}); }但删除策略值得多说两句。DELETE是物理删除数据一旦执行在没有备份的情况下基本找不回来。我在上位机数据采集项目里常见做法是软删除加一个deleted字段默认0删除时把deleted置为1查询条件统一加WHERE deleted 0。好处是可以恢复误删数据而且在排查数据问题时能看到完整历史。// 软删除标记deleted字段为1代替物理删除 using (var cmd connection.CreateCommand()) { cmd.CommandText UPDATE device_data SET deleted 1 WHERE id $id; cmd.Parameters.AddWithValue($id, 1); cmd.ExecuteNonQuery(); }软删除的代价是每次查询都要记得过滤deleted字段忘了就出现“删了还在”的诡异现象。如果表数据有明确的生命周期、不需要恢复物理删除反而更省心。这个取舍没有标准答案我一般按数据的业务价值来定设备采集的原始数据软删除临时缓存数据物理删除。3.4 分页查询与LIMIT/OFFSET边界分页查询在CRUD里也常被问到。SQLite的LIMIT/OFFSET用法几乎和MySQL一致SELECT id, device_id, temperature, record_time FROM device_data WHERE deleted 0 ORDER BY id DESC LIMIT 20 OFFSET 40;OFFSET 40表示跳过前40条配合LIMIT 20实现第三页的效果。数据量大了之后OFFSET越深越慢因为SQLite要扫描并丢弃前面的行。真正到了几万行以上的分页我会改用游标式分页写法// 游标式分页传入上一页最后一条记录的id性能稳定 string pageSql SELECT id, device_id, temperature, humidity, record_time FROM device_data WHERE deleted 0 AND id $lastId ORDER BY id DESC LIMIT 20; cmd.Parameters.AddWithValue($lastId, lastIdFromPreviousPage);这里id $lastId替代了OFFSETSQLite可以直接走主键索引定位到游标位置不需要扫描被跳过的行。页码越深这个写法的优势越明显。如果是桌面应用的数据列表配合“加载更多”按钮体验比传统分页好得多。4. 避坑排查C# SQLite3中的五个高频翻车点4.1 database is locked并发写引发的经典报错现象程序运行一阵子后写入操作随机抛出SQLiteException: database is locked有时候重启程序就好了过一会又出现。原因SQLite默认的日志模式是DELETE模式同一时间只允许一个进程执行写操作。上位机场景里采集线程和UI线程同时对数据库写入或者两个连接分别开了事务没及时提交都会触发锁。还有一种隐蔽情况一个连接对象没释放事务一直悬挂后续所有写操作都会排队超时。解决先确保所有连接都用using释放事务一定在finally里提交或回滚。然后给命令设置超时时间cmd.CommandTimeout 10;让线程等待锁而不是立刻抛异常。更高层的做法是换成WAL日志模式执行一次PRAGMA journal_modeWAL;后读和写可以并发写与写之间冲突概率大幅下降。我通常在建库初始化时就把WAL模式持久化打开。4.2 中文路径打不开数据库现象数据库文件放在中文目录下连接能创建执行SQL时报错或直接抛出SqliteException英文路径下一切正常。原因SQLite原生层对路径字符串的解析依赖编码环境。某些环境中旧版驱动把路径按ANSI编码传给原生库遇到中文就乱码找不到文件。用相对路径加中文工作目录最容易触发。解决要么用英文路径绕开要么把路径转成URI格式给驱动。Microsoft.Data.Sqlite支持Data Sourcefile:D:/数据/app.db?moderwc的URI写法或者更省心的做法是启动时把数据库固定到英文目录再拼接路径。我的习惯是建库前统一Path.GetFullPath并检查目录是否存在避免各种斜杠和相对路径的坑。4.3 DateTime存进去再读出来变了样现象把DateTime.Now存进TEXT字段读出来变成一个奇怪的格式或者参与排序时顺序不对。原因SQLite本身没有时间类型Microsoft.Data.Sqlite默认把它序列化成TEXT字符串。不同驱动序列化格式有差异比如部分版本存成yyyy-MM-dd HH:mm:ss另一部分存成带时区的ISO8601格式。如果不统一格式查出来之后还得手工解析。解决我一般不在数据库里依赖驱动做DateTime转换而是自己统一格式写入时用DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss)读取时用DateTime.ParseExact(recordTime, yyyy-MM-dd HH:mm:ss, null)。也可以给连接字符串加DateTimeFormatTicks这样存取的是自增Tick数排序天然正确但数据可读性差排查问题时不够直白。两种方案按项目需求选我是可读性优先。4.4 参数名前缀不一致直接报错现象命令文本写WHERE id id代码里Parameters.AddWithValue(id, 1)执行时报unrecognized token或者一直查不到数据。原因Microsoft.Data.Sqlite的参数名其实不在乎前缀是$还是但命令文本里的占位符和AddWithValue的参数名必须完全一致。常见翻车是把id传给$id或者传的时候没写前缀。解决养成一个统一习惯。我在所有C#项目里统一用$前缀命令文本和AddWithValue里都带$因为$在SQLite里和C#字符串插值的$共存时更直观。一旦项目里出现SQL报错先检查参数名八成是这里的问题。4.5 批量插入慢到怀疑人生现象循环执行INSERT语句插入5000条记录耗时几十秒CPU不高但磁盘写入不断。把同样的逻辑搬到MySQL上反而快得多。原因默认情况下每条INSERT都是一个独立事务SQLite每执行一条都要做一次磁盘同步操作把数据落盘。循环写入的每一轮都在同步5000条就是5000次磁盘同步慢是必然。解决批量写入时手动开一个事务把所有INSERT包在事务里最后统一提交。这样SQLite会把中间数据留在缓存里最后一次落盘。5000条数据从几十秒压到一两秒是常态。写法在下一章事务部分给出。5. 进阶技巧事务批量写入与WAL模式并发优化5.1 事务批量写入从几十秒到一两秒上面4.5提到的问题解决方案就是事务。SQLite的事务机制可以把多条写操作打包成原子操作要么全部成功要么全部回滚。批量写入场景下事务带来的收益不仅是正确性更是性能数量级上的提升。// 批量插入1万条数据通过事务统一提交 using var connection new SqliteConnection(Data Sourceapp.db); connection.Open(); using var transaction connection.BeginTransaction(); try { using var cmd connection.CreateCommand(); cmd.Transaction transaction; cmd.CommandText INSERT INTO device_data (device_id, temperature, humidity) VALUES ($deviceId, $temperature, $humidity); for (int i 0; i 10000; i) { cmd.Parameters.Clear(); // 清空上一轮参数防止参数累积 cmd.Parameters.AddWithValue($deviceId, $sensor_{i % 10}); cmd.Parameters.AddWithValue($temperature, 20 i * 0.01); cmd.Parameters.AddWithValue($humidity, 50 i % 20); cmd.ExecuteNonQuery(); } transaction.Commit(); Console.WriteLine(1万条数据批量写入完成); } catch { transaction.Rollback(); throw; }这个写法的关键有两点。第一cmd.Transaction transaction把命令绑定到事务上如果不设置命令会用隐式事务等于每条又单独提交事务白开。第二循环里cmd.Parameters.Clear()加AddWithValue重新添加参数这是很多人容易漏的一步——不清空参数上一轮的参数值会被下一轮复用绑定对象越来越多内存和性能都会受影响。值得注意的一个细节SQLite参数绑定AddWithValue重复调用同一参数名时行为是替换旧值但代码上显式Clear()再添加更不容易出错。实际压测里1万条数据开启事务后耗时通常在1到2秒左右比起不开事务时几十秒的体验差距非常明显。如果你写入量更大还可以进一步用Prepare()预编译语句提前让SQLite解析SQL循环里只改参数值性能又上一层。5.2 WAL日志模式让读写并发成为可能SQLite默认的DELETE日志模式里写事务进行时其他连接连读都会被阻塞。WALWrite-Ahead Logging模式把写入操作先追加到.wal文件读操作仍然可以从原来的主库文件读取数据读写互不阻塞。对上位机这种有采集线程写、界面线程读的场景非常实用。// 开启WAL模式PRAGMA语句返回值为wal表示切换成功 string connStr Data Sourceapp.db;CacheShared;DefaultTimeout30;; using var conn new SqliteConnection(connStr); conn.Open(); using var cmd conn.CreateCommand(); cmd.CommandText PRAGMA journal_modeWAL;; var mode cmd.ExecuteScalar(); Console.WriteLine($当前日志模式: {mode});ExecuteScalar返回的mode应该是wal代表成功切换。WAL模式是持久化的数据库文件一旦设置过WAL下次打开仍然是WAL模式不需要每次连接都执行一遍PRAGMA。但要注意这个设置是跟随数据库文件的不是跟随连接字符串你把.db文件拷贝到另一个环境那个环境打开时也会自动走WAL。WAL模式下还有两个关联PRAGMA值得设一下。一个是PRAGMA synchronousNORMAL;配合WAL使用能在掉电时保持数据完整性同时比FULL性能更好另一个是PRAGMA wal_autocheckpoint1000;控制WAL文件每隔多少页自动合并回主库文件。默认值1000一般够用如果写入量很大WAL文件长得很快可以把这个值调小比如200换取频繁一点的自动合并避免WAL文件膨胀到几GB。我不建议在Demo阶段过早调这些参数。先把WAL模式打开synchronous保持默认FULL跑一段时间观察WAL文件增长速度再决定要不要做调整。优化类的参数调整一定以监控数据为准不要凭感觉这是我在好几个项目里换来的血泪经验。5.3 索引设计与慢查询排查单表数据少的时候全表扫描也无所谓。但当device_data表涨到几十万行按device_id查询的响应时间会明显退化。SQLite的索引设计很简单但踩过的坑也不少。-- 为经常查询的字段创建索引 CREATE INDEX IF NOT EXISTS idx_device_data_device_id ON device_data(device_id); CREATE INDEX IF NOT EXISTS idx_device_data_record_time ON device_data(record_time);WHERE device_id $deviceId如果走全表扫描耗时随数据量线性增长。加了idx_device_data_device_id索引之后查询复杂度降到对数级。要注意的是SQLite的复合索引有最左前缀原则如果你经常用WHERE device_id ? AND record_time BETWEEN ? AND ?这种组合查询应该建复合索引(device_id, record_time)而不是两个独立索引。独立索引在复合查询时只能命中一个另一个字段还是得回表扫描。排查慢查询我一般用EXPLAIN QUERY PLANEXPLAIN QUERY PLAN SELECT * FROM device_data WHERE device_id sensor_001;输出结果里如果看到SCAN说明没走索引是全表扫描看到SEARCH device_data USING INDEX idx_device_data_device_id说明索引生效了。这个命令比猜索引有没有生效靠谱得多我每次优化完SQL都会执行一遍确认。5.4 连接管理与多线程访问边界ADO.NET有默认连接池Microsoft.Data.Sqlite也沿用了这套机制。连接字符串相同的情况下连接会被池化复用所以“每次操作打开一个新连接”并不是什么大问题。但在WinForm/WPF项目里我一直建议用一个简单的单例封装控制并发访问public class SqliteHelper { private static SqliteConnection _connection; public static SqliteConnection GetConnection() { if (_connection null) { _connection new SqliteConnection(Data Sourceapp.db;CacheShared;DefaultTimeout30;); _connection.Open(); } return _connection; } }但单例连接有一个隐藏风险多个线程同时在这个连接上执行写入会互相踩。SqliteConnection不是线程安全的跨线程共享同一个连接时写操作必须自己加锁或者保证每个线程创建自己的连接。我一般这么分工采集线程单独持有连接写入UI线程用只读查询连接走WAL模式两边各用各的不共享同一个连接对象。这比试图用一个锁保护单例连接要干净得多。6. 验证方法用sqlite3命令行核对数据与索引完整性6.1 命令行工具基本核对程序跑完了代码能增删改查了怎么确认数据和预想的一致我习惯在调试阶段开一个终端用sqlite3命令行工具直接检查数据库文件。SQLite3安装配置教程网上很多装好之后在命令行进入数据库文件所在目录执行sqlite3 app.db .tables .headers on .mode column SELECT COUNT(*) AS total FROM device_data; SELECT * FROM device_data LIMIT 5;.tables列出所有表确认建表成功。.headers on加.mode column让查询结果以表格形式展示列名对齐。SELECT COUNT(*)核对总行数和程序里插入的数量比对SELECT * FROM device_data LIMIT 5抽样看几条数据检查字段值和类型对不对。这套命令我几乎每天都会用比打开数据库客户端工具快得多。6.2 完整性检查与索引核对数据核对之外还有两个命令我建议每次写完CRUD跑一遍PRAGMA integrity_check; PRAGMA journal_mode; .indexes device_dataPRAGMA integrity_check返回ok说明数据库文件结构完整没有损坏。PRAGMA journal_mode确认WAL模式是否已持久化生效。.indexes device_data查看表上所有索引确认之前建的索引确实存在。这些都是我做增删改查功能后的固定收尾动作能拦截掉一批“程序不报错但数据库状态不对”的隐蔽问题。如果用的是Microsoft.Data.Sqlite还可以在程序里用代码做一次同样的验证方便集成到自动化测试里using var cmd connection.CreateCommand(); cmd.CommandText PRAGMA integrity_check;; var integrity cmd.ExecuteScalar(); if (integrity.ToString() ! ok) { throw new Exception($数据库完整性检查失败: {integrity}); }这段代码放在程序启动自检里数据库文件出问题的时候能在第一时间暴露而不是等到查询报错才去排查。我最后一个建议是Demo跑通之后把连接字符串里的DefaultTimeout显式写出来把WAL模式固定下来然后把上面这段完整性检查放进初始化流程。这三件事做完你的SQLite3增删改查才算真正从Demo代码变成了工程代码。说实话我第一次连续遇到“database is locked”时也是各种怀疑人生最后一步步查连接泄漏、查事务悬挂才发现问题出在自己没释放连接。从那以后我每次写完SQLite相关代码都会强制走一遍查连接是否using释放、查事务是否提交、用sqlite3命令行验证PRAGMA和索引。这个习惯帮我挡掉了不少上线后的尴尬问题希望帮到你。本文还有配套的精品资源点击获取