SQLite + Dapper:轻量级本地存储与数据访问实践指南

SQLite + Dapper:轻量级本地存储与数据访问实践指南 最近在做一个本地数据采集的小工具需要把设备数据存下来又不想在用户机器上装一套完整的数据库服务最后选了 SQLite Dapper 这套组合。先说结论这个组合在中小规模的本地应用里是真的舒服一个文件就是整个数据库Dapper 又轻又直接几乎感觉不到 ORM 的存在。这篇文章就从一个“初识”的角度把 SQLite 是什么、Dapper 为什么值得用、怎么在 Windows 下把环境搭起来、怎么完成增删改查以及我实际踩过的坑一起写出来。适合正在学 .NET 数据访问、想给桌面程序或小工具加本地存储的朋友也适合工控、嵌入式这种“不想维护数据库服务”的场景。1. 认识 SQLite一个文件就是一个完整的数据库1.1 为什么说 SQLite 是“零配置”的数据库很多人在接触 SQLite 之前脑子里对“数据库”的印象是这样的安装一个 MySQL 或 SQL Server 服务配置账号密码建库建表然后应用程序通过网络连过去。SQLite 完全不是这个路子它不跑独立服务进程也不搞端口和账号它直接以库的形式嵌入到你的程序里数据库的全部内容——表结构、索引、视图、触发器、数据——都放在一个普通的文件里。正因为这样SQLite 特别适合单机应用。桌面软件、移动 App、嵌入式设备、工控上位机这些场景都不想额外维护一个数据库进程SQLite 就是最好的选择。而且在 Windows 下使用 SQLite 非常省心你不需要“安装”什么服务只要拿到它的库文件或 NuGet 包程序里直接调用就行。再说几个它比较能打的点支持标准 SQL 的绝大部分语法基本的增删改查、子查询、窗口函数较新版本都能用。支持 ACID 事务写入过程有日志和回滚机制不容易把数据搞坏。跨平台Windows、Linux、macOS、Android、iOS 都能跑数据库文件可以直接拷来拷去。无版权限制属于公共领域商用完全没有授权费。单个数据库文件可以做到几个 TB 甚至更大对小应用来说容量根本不用愁。我自己用下来最大的感受是它就像程序里的一个“内置变量”不想用了把文件删掉就行完全不留痕。1.2 SQLite 适合什么场景又不适合什么场景先说它不适合的地方这比吹优点更重要。高并发写入是短板。SQLite 同一时刻一般只允许一个写事务多个进程一起写会碰到锁冲突。没有独立的账号体系和权限控制所有连接的人都能读写文件。不太适合大数据量的集中式服务几十 GB 以上、需要在线扩容和复杂运维的场景还是交给专业数据库。不擅长需要存储过程、自定义函数等复杂服务端逻辑的场合虽然有用户自定义函数机制但不是常规做法。但它适合的场景非常多桌面软件的配置、本地缓存和历史数据。局域网内少量用户的小工具读多写少比如报表展示。工控上位机、组态软件的数据存储很多组态产品包括一些叫“King”的组态软件在连接历史数据库时都用过 SQLite。移动端和物联网网关离线攒数据再上报。单元测试、原型项目用它做存储可以零成本起跑。记住一句话SQLite 是把“轻量”发挥到极致的数据库只要你的瓶颈不是多进程并发写它基本都是好用的。2. 初识 Dapper为什么一个“微型 ORM”值得学2.1 开发者在数据访问上遇到的普遍痛点在 .NET 里访问数据库第一反应可能是 ADO.NET 原生方式。写起来大概是这样创建一个 Connection再创建一个 Command然后拼接 SQL 文本给 Command 加参数执行 ExecuteNonQuery 或者 DataReader 循环读取拿到结果后再逐条塞进实体对象里。这套流程如果不封装代码里全是样板代码。后来有了 EF Core 这类重量级 ORM开发效率确实高但换来的是不少学习成本和框架约束。比如实体映射、导航属性、懒加载、迁移这些概念小项目用起来有点杀鸡用牛刀。而且用了 EF Core 后很多时候你需要迁就它的生命周期和查询生成方式一旦遇到特别复杂的 SQL反而还要退回原生查询。Dapper 走了一条中间路线。它不实现完整的 ORM 功能没有变更追踪没有导航属性没有自动建表它只做一件事把你写好的 SQL 拿到数据库执行然后把结果映射回对象。就这么简单。2.2 Dapper 的核心价值在哪里Dapper 是 Stack Overflow 团队开源的一个微型 ORM它扩展了 ADO.NET 里的IDbConnection接口让你原本熟悉的 Connection 对象直接具备Query、Execute这些方法。核心特点我总结成三点性能好。Dapper 内部做了大量缓存和 IL 生成性能几乎接近手写 ADO.NET比很多重量级 ORM 快得多。安全参数化。它强制推荐你用参数化查询配合Name这种方式天然防 SQL 注入。轻量。整个库里就几个关键方法做一层增删改查半天就能上手没有任何概念负担。我用 Dapper 做了几个项目后最大的感觉是“SQL 还是你的只是少写了那些无聊的赋值循环”。你脑子里怎么写 SQL代码里就怎么写 SQLDapper 乖乖帮你执行然后返回实打实的对象。2.3 Dapper、EF Core、ADO.NET 三者的实际取舍说句实在话没有哪个技术是“万能银弹”Dapper 也不是所有项目的最佳选择。我个人的选择逻辑是这样的维度ADO.NETDapperEF Core学习成本中低高代码量多少很少SQL 控制力完全掌握完全掌握弱一些复杂 SQL 要绕性能最高接近原生有额外开销自动建表/迁移无无有完整迁移机制适合项目偏底层框架中小项目、性能敏感中大型业务系统所以 Dapper 的定位很清晰中小项目、SQL 本来就不复杂、想把底层访问写得干净又不想引入太多框架依赖的场景。你再配合 SQLite 这种同样轻量的数据库整个数据层几乎没有重量负担。3. 环境准备Windows 下搭建 SQLite Dapper 开发环境3.1 SQLite 在 Windows 下到底怎么安装因为 SQLite 不是服务型数据库所以“在 Windows 下安装”这句话本身就有歧义。你在开发阶段一般不需要单独安装什么东西真正要做的是让你的程序能够拿到 SQLite 的驱动。在 .NET 生态中有两个主流的 SQLite 驱动Microsoft.Data.Sqlite微软官方维护API 风格接近 ADO.NET是现在 .NET 平台的首选。System.Data.SQLite社区老牌驱动功能更全甚至支持加密但包体更大以前在 .NET Framework 时代用得很多。我的建议是新项目直接用Microsoft.Data.Sqlite够用而且官方维护。如果你只是想在命令行里快速体验一下 SQLite也可以从官方下载一个命令行工具包sqlite-tools-win解压后得到一个sqlite3.exe用它可以直接CREATE TABLE、INSERT、SELECT。不过正经写程序直接 NuGet 引用就行。3.2 查看工具DB Browser for SQLite 和 SQLiteStudio写代码跟数据打交道光靠代码看数据太难受了一定要装一个可视化工具。我常用的有两款DB Browser for SQLite免费开源支持建表、浏览数据、执行 SQL、导出 CSVWindows 下有安装版和免安装版体积不大。SQLiteStudio绿色版功能也很全特别适合拿着就走。DBeaver如果项目里还对接了 MySQL、PostgreSQL用这个统一管理DBeaver 对 SQLite 的支持也挺好。实操中我一般这样分工写代码的时候用 DB Browser for SQLite 快速查看表结构和数据确认程序里的 SQL 有没有写对。遇到数据不对了直接在工具里执行一句SELECT定位问题比在程序里打日志快得多。3.3 创建一个 .NET 控制台项目并引用 Dapper咱来实际动手搞一下。我用的是 .NET 8 命令行方式你也可以直接用 Visual Studio 的“控制台应用”模板效果一样。dotnet new console -n SqliteDapperDemo cd SqliteDapperDemo然后安装两个包dotnet add package Microsoft.Data.Sqlite dotnet add package Dapper装完之后Program.cs里就可以直接开始写了。注意一下Dapper 本身不依赖某个数据库厂商它要求你传入的IDbConnection实例是已经打开的连接。我们用Microsoft.Data.Sqlite创建连接就同时兼容了 Dapper 的所有方法。环境搭建就这么简单。接下来进入正题建库建表、写增删改查。4. 实操SQLite 数据库设计与 Dapper 增删改查4.1 建库建表与连接字符串的那些事SQLite 有一个方便到不真实的行为当你在连接字符串里指定的数据库文件不存在时它会在打开连接时自动创建这个文件。也就是说一个文件从无到有完全可以通过连接打开的动作完成。一个最简单的连接字符串长这样var connectionString Data Sourceapp.db;; using var connection new SqliteConnection(connectionString); await connection.OpenAsync();这里有个容易踩的坑Data Source里面写的是相对路径那这个相对路径是以“程序的工作目录”为准的。你在 Visual Studio 里 F5 调试工作目录大概率是bin\Debug\net8.0所以数据库文件会被创建在那里。这没问题但你要知道自己打开的文件到底在哪否则容易对着项目根目录找半天空文件。想省心一点可以直接拼绝对路径。比如这样var dbPath Path.Combine(AppContext.BaseDirectory, app.db); var connectionString $Data Source{dbPath};;用AppContext.BaseDirectory拿到的是程序集所在目录不管调试还是正式发布行为都比较可预期。接下来说建表。我习惯在程序启动时执行一个CREATE TABLE IF NOT EXISTS保证表结构存在简单粗暴但有效。以一个设备数据表为例await connection.ExecuteAsync( CREATE TABLE IF NOT EXISTS DeviceData ( Id INTEGER PRIMARY KEY, DeviceName TEXT NOT NULL, Value REAL NOT NULL, RecordTime TEXT NOT NULL ); );这里说明一点SQLite 里INTEGER PRIMARY KEY本身就是自增主键不强制写AUTOINCREMENT。加不加的差别在于AUTOINCREMENT防止被删除的行主键被复用但会多一些额外开销。绝大多数业务用普通的INTEGER PRIMARY KEY就够了。4.2 Dapper 的核心方法Query 和 ExecuteDapper 的重点就三个方法学会了这三板斧日常 80% 的需求都能搞定。Execute执行增删改和 DDL返回受影响的行数。Query执行查询把每行结果映射成一个对象返回集合。QueryFirst/QuerySingle查询单条记录区别在于没有结果时First是报错还是取第一条建议按需选择FirstOrDefault更安全。来一段完整的增删改查示例。先插入一条数据var insertSql INSERT INTO DeviceData (DeviceName, Value, RecordTime) VALUES (DeviceName, Value, RecordTime); ; var affected await connection.ExecuteAsync(insertSql, new { DeviceName 温度传感器A, Value 36.5, RecordTime DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) });注意这里我们没有做任何字符串拼接全程用占位符DeviceName加上匿名对象。Dapper 会自动把匿名对象的属性名和 SQL 参数对应上既安全又简洁。然后查询var querySql SELECT Id, DeviceName, Value, RecordTime FROM DeviceData;; var list await connection.QueryAsyncDeviceData(querySql); foreach (var item in list) { Console.WriteLine(${item.DeviceName} - {item.Value} - {item.RecordTime}); }DeviceData是一个普通的 POCO 类字段名跟数据库列名对应即可。如果数据库列名和属性名不一致Dapper 支持别名映射SQL 里用AS重命名就行。更新和删除同样通过Executeawait connection.ExecuteAsync( UPDATE DeviceData SET Value Value WHERE Id Id;, new { Id 1, Value 42.0 }); await connection.ExecuteAsync( DELETE FROM DeviceData WHERE Id Id;, new { Id 1 });这套写法的好处在于SQL 全部在明处你可以在 DB Browser for SQLite 里先执行一遍确认结果再贴到代码里。出了问题不用怀疑框架替你“优化优化”。4.3 事务、批量写入与分页如果一次要执行多条 SQL而且要求“要么全成功、要么全失败”就需要事务。Dapper 没有重造事务机制它直接复用 ADO.NET 的Transaction你只需要把事务对象作为参数传给Execute方法。例如using var connection new SqliteConnection(connectionString); await connection.OpenAsync(); using var transaction connection.BeginTransaction(); try { await connection.ExecuteAsync(INSERT INTO DeviceData (DeviceName, Value, RecordTime) VALUES (DeviceName, Value, RecordTime);, new { DeviceName 温度传感器A, Value 36.5, RecordTime DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) }, transaction); await connection.ExecuteAsync(INSERT INTO DeviceData (DeviceName, Value, RecordTime) VALUES (DeviceName, Value, RecordTime);, new { DeviceName 湿度传感器B, Value 60.2, RecordTime DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) }, transaction); transaction.Commit(); } catch { transaction.Rollback(); throw; }批量写入这块SQLite 本身单条插入不快常见优化手段是“一个事务包住几百条插入”实测下来比逐条提交快好几个数量级。如果你要一次性插入几万条测试数据可以试试把 SQL 拼成多行VALUES一次执行Dapper 传一个分页后的集合进去效果也很好。分页在 SQLite 里就是LIMIT offset, count或者LIMIT count OFFSET offsetvar pageSql SELECT Id, DeviceName, Value, RecordTime FROM DeviceData ORDER BY Id DESC LIMIT PageSize OFFSET Skip;; var page await connection.QueryAsyncDeviceData(pageSql, new { PageSize 20, Skip 0 });4.4 一个完整的例子设备数据采集小工具最后放一个稍微完整点的例子把上面这些串起来。场景设备每分钟上报一次数据程序把数据写入 SQLite并查询最近 10 条记录。模拟代码如下using Microsoft.Data.Sqlite; using Dapper; var dbPath Path.Combine(AppContext.BaseDirectory, device.db); var connectionString $Data Source{dbPath};; using var connection new SqliteConnection(connectionString); await connection.OpenAsync(); await connection.ExecuteAsync( CREATE TABLE IF NOT EXISTS DeviceData ( Id INTEGER PRIMARY KEY, DeviceName TEXT NOT NULL, Value REAL NOT NULL, RecordTime TEXT NOT NULL ); ); var now DateTime.Now; // 模拟写入 for (int i 1; i 10; i) { await connection.ExecuteAsync( INSERT INTO DeviceData (DeviceName, Value, RecordTime) VALUES (DeviceName, Value, RecordTime); , new { DeviceName $设备{i}, Value Random.Shared.NextDouble() * 100, RecordTime now.AddSeconds(i).ToString(yyyy-MM-dd HH:mm:ss) }); } // 查询最近 10 条 var list await connection.QueryAsyncDeviceData( SELECT Id, DeviceName, Value, RecordTime FROM DeviceData ORDER BY Id DESC LIMIT 10; ); foreach (var item in list) { Console.WriteLine(${item.Id} | {item.DeviceName} | {item.Value:F2} | {item.RecordTime}); } public class DeviceData { public int Id { get; set; } public string DeviceName { get; set; } public double Value { get; set; } public string RecordTime { get; set; } }跑完这个程序你用 DB Browser for SQLite 打开device.db就能看到刚才插入的数据。5. 踩坑实录乱码、路径、并发与排查技巧5.1 中文乱码的真相很多人在 Windows 下用 SQLite 遇到过中文乱码大概有两种情况。第一种情况是存储的时候就已经乱码了。SQLite 内部存储字符串用的是 UTF-8 编码如果你的 SQL 语句或传入参数在代码里本身是 GBK 编码存进去再读出来自然会出问题。在 .NET 环境下这种情况不多但如果你用了老库、老版本驱动或者从 Delphi、C 这类传统 Win32 程序里操作 SQLite就容易遇到。第二种情况是用客户端工具打开时显示乱码。有些老式工具默认按 ANSI 或本地编码去解析整个文件遇到 UTF-8 的 TEXT 数据就显示成“锟斤拷”。这时候别慌先用 DB Browser for SQLite 或 SQLiteStudio 打开看看如果它们显示正常说明数据本身没问题是你的工具或读取端编码没对上。我的处理建议写入前统一转成 UTF-8特别是在非 .NET 环境里要注意字符集转换。读取时也按 UTF-8 解析避免二次转码。排查数据本身是否正常时只用主流的 SQLite 工具别用自己的程序加日志去猜。5.2 连接字符串和相对路径的坑前面提过相对路径的问题再展开说说。你在 Visual Studio 里调试Environment.CurrentDirectory是bin\Debug\net8.0但如果你在代码里写了一个CREATE TABLE IF NOT EXISTS随后却没找到数据库文件多半是它被创建到了别的目录。我在项目里踩过一次比较痛的坑程序在开发环境正常发布到用户机器上后偶尔报“Unable to open database file”。后来发现是因为用户把程序放在了C:\Program Files下普通权限根本没写权SQLite 想创建数据库文件但被系统拒绝了。解决办法很简单第一数据库文件路径不要写相对路径用AppContext.BaseDirectory或专门的C:\ProgramData、用户文档目录第二如果程序装在系统目录下要么给目录写权限要么把数据库放到用户可以写的路径。5.3 SQLite 并发写限制SQLite 支持多读一个写锁。在多线程、多进程同时对同一库文件做写操作时会报database is locked或者说 SQLITE_BUSY。很多人第一反应是“SQLite 不行”其实是没按它的规则来。常用的优化方案按优先级排是这样给连接字符串加Default Timeout或者在打开连接后执行PRAGMA busy_timeout 3000;这样遇到锁时会等待而不是立即报错。开启 WAL 模式执行PRAGMA journal_modeWAL;。WAL 模式下读写可以并发明显提升多线程场景的爽快度。把多个写入合并成一个事务缩短持锁时间。别一秒钟开几百个连接各自写一条。如果真要高并发写入考虑换 PostgreSQL 或 SQL ServerSQLite 定位就不是这种场景。我自己的经验是单机桌面程序、工控采集端几十个线程同时读、少量线程写SQLite 完全扛得住前提是你把busy_timeout和 WAL 配好。5.4 常见问题速查表现象可能原因解决办法database is locked写锁冲突设置busy_timeout、开启 WAL、减少写事务频次no such table忘了建表或连接了错误的数据库文件先确认文件路径再执行CREATE TABLE IF NOT EXISTSunable to open database file路径权限不足或目录不存在使用绝对路径确保目录有写权限中文乱码存储/读取编码不一致或工具兼容性差统一 UTF-8 编码用 DB Browser for SQLite 核验程序一启动找不到sqlite3.dll未正确引用驱动包通过 NuGet 安装Microsoft.Data.Sqlite或System.Data.SQLite查询结果属性全是默认值POCO 属性名和数据库列名不完全匹配使用 SQL 别名AS或调整类属性名6. 扩展SQLite 在工控和嵌入式场景里的实战启示6.1 为什么很多组态软件喜欢 SQLite我在工控行业见过不少上位机和组态软件它们对历史数据存储的需求高度相似数据量不小但不至于大到上集群现场没有专职 DBA装 SqlServer 显得过于笨重数据必须断电可靠不能随便丢。这几点正好全踩在 SQLite 的强项上。比如采集系统需要把温度、压力、流量这些值定时存下来SQLite 单文件随用随走备份打包直接把.db文件拷走就行。现场调试时我用 DB Browser for SQLite 打开上位机目录下的库文件几秒钟就能看到历史曲线对应的原始数据排查问题效率极高。6.2 再往远一点移动端和物联网场景SQLite 在移动端是事实标准Android 和 iOS 系统本身就内置了 SQLite。物联网网关也是典型场景设备离线时先本地攒数据网络恢复了再批量上报SQLite 作为本地缓冲再合适不过。配合 Dapper 这类轻量 ORMC# 写的数据层甚至可以跟服务器端共用一部分代码逻辑减少重复开发。如果你面向的是这类场景主键、索引、时间字段的存储格式最好在项目一开始就想好。比如时间尽量存统一格式的字符串或 Unix 时间戳别一会儿存yyyy-MM-dd HH:mm:ss一会儿存DateTime.Ticks后面查历史数据会非常难受。7. 聊聊我这几天的实操体会文章写到这里SQLite 和 Dapper 的基础内容基本都覆盖了。最后说几点我的个人心得。第一SQLite 不是玩具。以前我也有“轻量 不正经”的偏见但深入了解后才发现它在可靠性、事务、SQL 支持度上表现得非常扎实单机数据量在几百 MB 到几个 GB 的场景里极其好用。第二Dapper 让“写数据库代码”变得很轻松。它不是那种帮你把 SQL 都藏起来的框架而是老老实实执行你写的 SQL然后把结果变成对象。这种“看得见”的感觉对我来说很重要出了问题不会在框架层上找人帮忙背锅。第三遇到报错不要急着怪 SQLite。大多数坑都是路径、并发、编码这些细节造成的。把连接字符串打出来看一遍用 DB Browser for SQLite 打开文件手动执行一遍 SQL80% 的问题当场就水落石出。如果你也是刚开始接触这两个东西我建议你按文章里的代码敲一遍不用扩展任何花哨功能就从建表、插入、查询开始。跑通了以后你再考虑事务、WAL、批量写入这些东西。你会发现原来一个“数据库”和一个小小.db文件之间距离并没有你想的那么远。