ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#实战:SQLite轻量级数据库连接与操作全攻略

C#实战:SQLite轻量级数据库连接与操作全攻略 一直在写上位机和各种中小型工具的兄弟们应该都有体会很多项目根本犯不上上MySQL或者Sql Server数据量就几万条、一个月几十万次读写你搞一个数据库服务端还要配账号权限纯粹是给自己找麻烦。这时候SQLite就是最好的选择服务端的活它全包文件往那一放就是一个完整的数据库C#这边几十行代码就能玩起来。这篇就是把我在实际项目里用C#连接和操作SQLite的经验整个沉淀一遍从选型到环境、从基础函数到完整案例、再到我踩过的几个坑一次讲透适合正在入门C#做工具类应用、或者想在桌面软件里加一个轻量数据库但不知道怎么下手的同学。1. 连接SQLite前的必知思路1.1 SQLite为什么能和C#玩到一块先说点背景。SQLite是一个嵌入式关系型数据库它的核心引擎是以库文件形式存在的。其他数据库比如MySQL是“客户端连服务端”你需要安装服务、监听端口、建账号。而SQLite是“代码里直接调库”你的程序进程本身就是数据库服务。打个比方MySQL像是一家你打电话订餐的餐厅SQLite则是你自己家里厨房里的一口锅要用直接用不需要等别人接电话。在C#这边官方和社区提供了多个数据访问层让我用起来就像是操作内存列表一样去读写文件数据库。你不需要理解复杂的SQL Server网络协议也不需要为连接池发愁就是打开一个文件、执行SQL语句、关闭文件。而且SQLite的体积非常小一个数据库文件可以小到只有几KB随U盘拷走、随邮件发出去整个库就是一个文件。对于C#桌面应用、工控上位机、工具类程序、嵌入式设备这个特性简直太舒服了。我做过一套车间工位数据采集程序程序装了十几台电脑每台机器本地一个SQLite文件做离线缓存数据量一天也就是几千条根本没必要让每台工位都挂到中心数据库。1.2 三套常用组件怎么选C#世界里主流的三套SQLite访问组件分别是组件包名特点适合场景System.Data.SQLiteSystem.Data.SQLite.Core功能最全支持ADO.NET标准接口还有个Lite版本老项目、需要兼容ADO.NET数据适配器Microsoft.Data.SqliteMicrosoft.Data.Sqlite.Core微软官方维护轻量、和EF Core配合最顺新项目、ASP.NET Core、跨平台Dapper Microsoft.Data.Sqlite上面两个任意组合不用EF Core用轻量ORMSQL自己写工具类项目、追求执行效率我现在的自用推荐组合是“Microsoft.Data.Sqlite Dapper”除非是特别老的System.Data.SQLite项目需要维护否则新项目我很少用System.Data.SQLite了。微软官方那个包在.NET Core/5/6上体验好得多跨平台没有坑API也清爽配合Dapper写起来就是几行SQL映射成实体类的节奏。如果你非要用EF Core那更简单安装Microsoft.EntityFrameworkCore.SqliteDbContext里指定UseSqlite就可以了。但实话说C#上位机、小工具这种项目用EF Core有点杀鸡用牛刀多绕了一层排问题的时候多一层麻烦。2. 环境准备与第一个连接2.1 用NuGet装包两种主流驱动的安装差异无论你是用Visual Studio还是Rider创建项目后第一步都是打开“管理NuGet程序包”。如果要装官方轻量版执行dotnet add package Microsoft.Data.Sqlite如果是System.Data.SQLite执行dotnet add package System.Data.SQLite.Core这里有个很多人第一次玩就懵的地方用System.Data.SQLite的时候NuGet装完包之后你会看到一个x64和x86目录下面带了个SQLite.Interop.dll那是一个非托管的原生动态库记得要把生成选项里的平台目标跟它匹配好。我见过不少人在AnyCPU下编译运行时直接报“Unable to load DLL SQLite.Interop.dll”其实就是平台不一致导致的。而Microsoft.Data.Sqlite这边就不用关心这个它是把SQLite原生库通过自身打包带入的跨平台也省心。这也是我偏向它的原因之一少操心。2.2 连接字符串的秘密SQLite的连接字符串跟大数据库比起来可以说是简单得让人感动string connectionString Data Sourceapp.db;就这么一句话就够用。但是千万别忽视几个常用参数它们决定了这个库的并发和性能行为参数示例作用Data SourceData Source./data/mydb.db数据库文件路径可以是相对路径或绝对路径ModeModeReadWriteCreate打开方式读写、只读、创建CacheCacheShared共享缓存多连接同库时有用PoolingPoolingTrue启用连接池注意只对System.Data.SQLite有效Default TimeoutDefault Timeout30默认超时秒数我实际项目里最常用的是这一条string connStr $Data Source{dbPath};ModeReadWrite;CacheShared;CacheShared这个东西值得多说一句。默认情况下SQLite每个连接各自管自己的缓存一旦一个连接写了数据还没提交另一个连接到同一数据库时可能会出现短暂看不到数据的情况。用Shared Cache可以减少这种误伤。当然如果你的程序里只有单个连接写不写都无所谓。路径问题也要小心。Data Sourceapp.db是相对路径会根据程序的工作目录解析。如果你用调试器跑WindowsForms程序工作目录通常是bin\Debug\net8.0-windows你看到数据库文件生成在那个目录下是正常的。如果想把路径固定到程序目录旁边可以这么拼var baseDir AppDomain.CurrentDomain.BaseDirectory; var dbPath Path.Combine(baseDir, data, app.db);这样数据库文件永远在exe旁边的data目录里拷贝整个程序文件夹的时候不会找不到库文件。2.3 连接生命周期管理别让你MySQL的坏习惯坑了SQLiteMySQL或者Sql Server时代很多人习惯了“一个长连接到处传”。在SQLite这里这个习惯基本没问题但要注意一点它跟大数据库最大的区别是写操作会锁库。具体来说SQLite在同一时刻只允许一个写事务持有写锁其他写操作就得排队。所以我建议你的连接对象不要设计成全局静态长期存活的同一个实例而是“每个数据库操作各自开连接、用完即关”。这不是浪费反而是保护自己。类里面可以这么写一个连接工厂public class SQLiteHelper { private readonly string _connStr; public SQLiteHelper(string dbPath) { _connStr $Data Source{dbPath};CacheShared;; } public SqliteConnection CreateConnection() { var conn new SqliteConnection(_connStr); conn.Open(); return conn; } }每次用using包住这个连接using var conn helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText SELECT 1; var result cmd.ExecuteScalar();为什么这么干而不是一个全局连接放着因为你的程序可能会在不同线程里访问数据库。两个线程同时通过同一个连接对象访问SQLite很容易遇到“数据库被锁”的异常。而每条SQL语句各开各的连接连接池和文件锁的冲突会少很多。特别是配合Shared Cache之后多连接同库的体验已经很接近普通数据库了。3. 核心函数逐个拆解3.1 ExecuteNonQuery没有返回值的活也要认真对待ExecuteNonQuery是我用得最频繁的一个方法它的用途是执行不返回结果集的SQL语句包括INSERT、UPDATE、DELETE、CREATE TABLE这些DDL和DML。一个最精简的插入using var conn helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText INSERT INTO user (name, age) VALUES ($name, $age);; cmd.Parameters.AddWithValue($name, 张三); cmd.Parameters.AddWithValue($age, 28); int affected cmd.ExecuteNonQuery();返回的affected是“受影响的行数”。插入一条返回1删除两条返回2。这个返回值很有用可以拿它判断操作是否真的生效了。比如执行删除的时候返回0基本可以判定条件匹配不上。这里要特别强调一个Microsoft.Data.Sqlite和System.Data.SQLite都支持、但新手经常写错的点参数的占位符是$name这样的格式不推荐用name。旧版的System.Data.SQLite支持微软新包用$或都行但官方例子里都是$开头保持一致能少踩坑。还有个高频问题参数值是否为null。AddWithValue的时候如果传了null底层会当成DBNull处理但如果你传的是一个string变量里面值是null不会报错只是插进去的是NULL。很多时候这跟你业务预期不一致最好在传参前做空值判断。3.2 ExecuteReader读数据之前先搞清楚游标查询数据最常用的是ExecuteReader拿到一个SqliteDataReader去逐行读取using var conn helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText SELECT id, name, age FROM user WHERE age $age;; cmd.Parameters.AddWithValue($age, 18); using var reader cmd.ExecuteReader(); while (reader.Read()) { var id reader.GetInt64(0); var name reader.GetString(1); var age reader.GetInt32(2); Console.WriteLine($ID{id}, Name{name}, Age{age}); }这里有几个我实际用了很多次才发现的小点。第一reader游标初始状态是位置在“第一行之前”必须先调用一次Read()才能拿到第一行数据。所以标准套路就是while(reader.Read())循环。如果你只查一条却还写循环逻辑没错但看代码的人会以为你预期是多条。第二读取字段时不要盲目用GetString(0)。如果字段类型不匹配比如数据库是INTEGER但你用GetString去拿会抛异常。稳妥的办法是先看GetFieldType或者直接用索引器的GetValue然后转型。对于可能为NULL的字段优先用var name reader.IsDBNull(1) ? : reader.GetString(1);再一个如果字段列表很长建议在SQL里只SELECT你需要的列不要无脑SELECT *。这不只是性能问题更是防止将来表结构增加字段后你的索引位置全乱套。3.3 ExecuteScalar拿单个值最舒服当你只需要一个聚合结果或者单个字段时ExecuteScalar是最爽的cmd.CommandText SELECT COUNT(*) FROM user;; long count (long)cmd.ExecuteScalar();注意SQLite的COUNT(*)返回的是整型在Microsoft.Data.Sqlite中拿到的类型是longInt64你如果强转int会直接报InvalidCastException。再比如查用户名是否存在cmd.CommandText SELECT name FROM user WHERE id 1;; var name cmd.ExecuteScalar() as string;如果查询没有结果ExecuteScalar返回的是null。用as string可以安全判断如果直接(string)cmd.ExecuteScalar()当结果是null时也不会抛异常因为string是引用类型拆箱null是可以的。但如果是值类型就得特别小心。实际项目里有种常见的坑是不确认是否有记录直接“SELECT id FROM xxx WHERE yyy”然后用GetInt32去转换一旦没有记录就会抛异常。解决方式就是判断null或者先查存在性再取数据。3.4 事务与参数化防注入和保住一致性永远不要小看SQL注入。即便是本地SQLite文件只要程序被外部输入影响依然存在注入风险。比如// 千万不要这么写 cmd.CommandText $INSERT INTO user (name) VALUES ({userInput});如果userInput里面包含一个单引号比如“张O”你整条SQL就废了如果包含); DROP TABLE user;--那就是灾难。虽然本地数据库被删了损害没有服务器大但一个掉了数据的上位机程序工厂产线可能直接停线这个后果可比你想的严重。参数化写法并不复杂上面几个例子里已经展示过了统一用AddWithValue就行。事务一般用在这几类场景批量写数据、删除多个有关联的表、更新一条数据时同时更新另一条统计表任何“要么全成功、要么全失败”的操作都应该包事务。using var conn helper.CreateConnection(); using var tx conn.BeginTransaction(); try { using var cmd conn.CreateCommand(); cmd.Transaction tx; cmd.CommandText INSERT INTO log (msg) VALUES ($msg);; cmd.Parameters.AddWithValue($msg, 第一条); cmd.ExecuteNonQuery(); cmd.CommandText UPDATE counter SET total total 1 WHERE id 1;; cmd.ExecuteNonQuery(); tx.Commit(); } catch { tx.Rollback(); throw; }这里有个细节同一个连接开始了事务之后命令对象一定要把Transaction属性指过去不然命令会在一个不带事务的上下文中执行要么抛错要么你的修改根本没在事务保护范围内。新手最容易在这里翻车。4. 完整案例做一个迷你记账工具纸上谈兵讲了半天不如跑一个完整例子。下面我以一个“本地笔记/记账工具”为例把建库、增删改查全套流程走一遍。不需要数据库预装任何东西程序启动时自动建库建表。4.1 数据表设计先定一个简单的表记录日常支出项目。字段就四个CREATE TABLE IF NOT EXISTS expense ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, category TEXT NOT NULL, created_at TEXT NOT NULL );INTEGER PRIMARY KEY AUTOINCREMENT这行要解释一下这是SQLite约定俗成的自增主键写法不写AUTOINCREMENT直接INTEGER PRIMARY KEY也能自增加上AUTOINCREMENT只是保证绝对不复用旧ID。对于日志、流水这类数据加上更安全。created_at用TEXT存ISO时间串是因为SQLite的日期处理比较薄弱存文本反而方便排序和展示读出来可以直接当字符串用。4.2 核心CRUD代码用一个ExpenseRepository类把数据操作包起来内部复用前面的SQLiteHelper这样业务层完全不需要碰SQL语句细节。public class ExpenseRepository { private readonly SQLiteHelper _helper; public ExpenseRepository(string dbPath) { _helper new SQLiteHelper(dbPath); Initialize(); } private void Initialize() { using var conn _helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText CREATE TABLE IF NOT EXISTS expense ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, category TEXT NOT NULL, created_at TEXT NOT NULL );; cmd.ExecuteNonQuery(); } public void Insert(double amount, string category, DateTime time) { using var conn _helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText INSERT INTO expense (amount, category, created_at) VALUES ($amount, $category, $createdAt);; cmd.Parameters.AddWithValue($amount, amount); cmd.Parameters.AddWithValue($category, category); cmd.Parameters.AddWithValue($createdAt, time.ToString(yyyy-MM-dd HH:mm:ss)); cmd.ExecuteNonQuery(); } public void Update(int id, double amount, string category) { using var conn _helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText UPDATE expense SET amount $amount, category $category WHERE id $id;; cmd.Parameters.AddWithValue($amount, amount); cmd.Parameters.AddWithValue($category, category); cmd.Parameters.AddWithValue($id, id); cmd.ExecuteNonQuery(); } public void Delete(int id) { using var conn _helper.CreateConnection(); using var cmd conn.CreateCommand(); cmd.CommandText DELETE FROM expense WHERE id $id;; cmd.Parameters.AddWithValue($id, id); cmd.ExecuteNonQuery(); } }这个类在程序启动时调一下构造函数就能很自然地完成“没有库就建库、没有表就建表”的初始化。我第一次写这种自启动建库的设计时还专门写了一个初始化窗口去检查数据库文件是否存在后来想想纯属多余——IF NOT EXISTS配合App启动时执行一次初始化就是最优雅的建库方案。4.3 查询与展示接着是查询列表。这里直接用Dapper来写代码会简洁很多也更接近实际项目的写法。先安装包dotnet add package Dapper然后public class ExpenseItem { public long Id { get; set; } public double Amount { get; set; } public string Category { get; set; } public string Created_at { get; set; } } public ListExpenseItem GetRecent(int limit) { using var conn _helper.CreateConnection(); return conn.QueryExpenseItem( SELECT id, amount, category, created_at FROM expense ORDER BY id DESC LIMIT $limit;, new { limit } ).ToList(); }Dapper的QueryT会自动把列名映射到属性名前提是名称一致或者下划线能对得上。我直接用和列名一致的属性名图个省事。按分类统计也是常规操作public Dictionarystring, double GetTotalByCategory() { using var conn _helper.CreateConnection(); var rows conn.Query( SELECT category, SUM(amount) AS total FROM expense GROUP BY category; ); var result new Dictionarystring, double(); foreach (var row in rows) { result[row.category] (double)row.total; } return result; }一套迷你记账工具的核心功能就这样齐了。如果你要接UIWinForm里拖一个DataGridView把GetRecent(100)的结果DataSource绑定上三分钟出界面。WPF那边稍微麻烦点但逻辑也是通的。5. 高频问题排查实录5.1 Unable to load DLL SQLite.Interop.dll这个是System.Data.SQLite用户最常碰到的拦路虎。多半是因为项目平台目标跟原生DLL不匹配。排查思路三步走打开项目属性 - 生成 - 平台目标确认是x64还是x86。如果程序跑在64位系统就选x64。检查bin目录下是否生成了runtimes文件夹里面应该有对应平台的SQLite.Interop.dll。如果还是不行直接在NuGet里强制重装System.Data.SQLite.Core。用Microsoft.Data.Sqlite的话基本不会遇到这个DLL问题因为它背后用的是SQLitePCLRaw初始化时会自动分发原生库。所以如果你是个怕麻烦的人就直接选微软官方包。5.2 并发写入导致“database is locked”我在写工位数据采集程序时遇到过两三个线程同时写库然后抛SQLite Error 5: database is locked。原因就是SQLite同一时刻只允许一个写事务。解决思路有两个层面。第一个层面是“重试”。数据库锁一般持续很短写冲突时等几十到几百毫秒再执行一次往往就成功了。网上很多人推荐PRAGMA busy_timeout3000;这个能解决大部分偶发锁冲突让SQLite在3秒内等待而不是立即报错。命令用法cmd.CommandText PRAGMA busy_timeout 3000;;第二个层面是“串行化”。如果你多条写操作本身就是强顺序、无并发意义干脆用一个专用锁对象把写操作串起来比如用C#的SemaphoreSlim或lock让同一时间只有一个线程进入写库逻辑。数据库层面用busy_timeout兜底程序层面做串行双保险我用了很久非常稳。再说一个进阶选项WAL模式。执行一次PRAGMA journal_modeWAL;之后SQLite允许读和写并发执行读不会阻塞写写不会阻塞读。对于并发量稍高的桌面工具这是我强烈推荐的设置。启动时执行一次cmd.CommandText PRAGMA journal_modeWAL;; cmd.ExecuteNonQuery();设完之后你会在数据库文件旁边看到app.db-wal和app.db-shm两个临时文件这是正常现象程序正常关闭后它们会融合回主库文件。不要把这两个文件手动删掉否则可能丢数据。5.3 一个连接 vs 多个连接很多人喜欢在成员变量里保存一个静态连接所有方法共用。这个做法的坑上面提过多线程下容易锁冲突。更隐蔽的问题是如果连接被异常中断整个对象后面的所有操作都会在一种半死不活的状态下反复报错。我的建议是每次数据库操作都通过SQLiteHelper.CreateConnection()创建新连接用完释放。连接池能复用底层物理连接创建新连接的成本远比你想象的低。实测一个普通函数里连续执行10次查询每个查询都新建连接耗时才增加几十毫秒对于桌面程序完全无感。换来的是代码的健壮性和清晰度这笔买卖很值。5.4 参数占位符的写法混乱前面说过Microsoft.Data.Sqlite里推荐使用$name但很多人从老博客抄来了name混着用。这两者在Microsoft.Data.Sqlite里都支持但System.Data.SQLite有些老版本对$支持不完整。如果你的代码要在两套驱动之间切换最好统一用或者统一用$。我的习惯是跟着官方走用Microsoft.Data.Sqlite就全用$代码统一排斥歧义。还有一点Parameters.AddWithValue在不同驱动下的行为有一处细微差别System.Data.SQLite下如果值为null可能会被当作文本null而不是SQL NULL。所以凡是可能为null的变量我都建议显式处理cmd.Parameters.AddWithValue($remark, (object?)remark ?? DBNull.Value);5.5 中文路径和乱码问题SQLite对中文路径的支持没问题但如果你在WinForm里拿OpenFileDialog选中一个路径再拼进连接字符串而得路径里恰好带|或分号这类特殊字符就要小心连接字符串解析。稳妥的做法是连接字符串里的路径外面不额外加引号就可以正常解析。实际项目里真正烦人的是中文乱码往SQLite里插入中文读出来变成“????”。这个问题的根源不是SQLite本身而是SQL语句的编码和参数传递。用了参数化之后基本不会出现这种问题如果你非得拼SQL字符串请确保cs文件保存为UTF-8编码并且连接字符串中不要设置错误的CharSet相关的属性SQLite本身没这个配置不用折腾。6. 我后来还在哪里用到了这套东西写完这个迷你记账工具之后我把“Microsoft.Data.Sqlite Dapper SQLiteHelper”这套组合复用到了好几个真实项目里最典型的是一个工位扫码追溯程序。每个工位电脑往本地SQLite写入产品条码、工位号、操作时间、检测结果上位机UI定时刷新列表每天下班前一键导出Excel。这套架构跑了大半年没有出过数据丢失、锁死的问题。还有一个经验可以分享如果你的程序要长期运行比如一个月不关机建议每天凌晨或者启动时做一次VACUUM、ANALYZE操作压缩数据库文件并刷新查询计划。命令很简单cmd.CommandText VACUUM;; cmd.ExecuteNonQuery();但注意VACUUM执行时会重建整个数据库文件消耗时间和文件大小成正比数据量大时别放在前台线程里跑。我一般放到程序启动后异步执行用户完全无感。另外备份SQLite数据库最简单的方式就是“复制文件”但前提是程序先关掉连接或者数据库处于WAL模式的非写入状态。所以我的公共工具类里提供一个Backup()方法先执行一次PRAGMA wal_checkpoint(TRUNCATE);再用File.Copy把db文件拷到备份目录。这比数据库自带的备份API好用得多代码也直观。最后再分享一个检查SQLite文件的实用小技巧用DB Browser for SQLitedb4s这个免费工具打开数据库文件可以直接看表结构、浏览数据、执行SQL。每当我怀疑代码写出来的库有结构问题时第一反应就是用这个工具打开文件确认一下。手动修改字段类型、加索引也可以直接在工具里操作比写一堆临时迁移脚本快得多。配合这个工具C#代码里数据库相关的调试效率能翻一倍。说了这么多核心无非三个选对包、用参数化、管好连接生命周期。把这三点抓好SQLite在你的C#项目里就是一个特别省心的伙伴绝不给你添乱。
返回列表