SQLite结合Dapper实战:嵌入式数据库本地存储与CRUD指南
2026/9/15 18:05:00 网站建设 项目流程

开头:先说我为什么突然折腾起SQLite和Dapper。前阵子接了一个内部小工具,要在客户那台没有网络、也不允许装任何数据库服务的Windows机器上处理几万条测试记录。SQL Server Express装不了,用Excel又扛不住,想来想去只剩文件型数据库这一条路。于是回到我很久没认真碰过的SQLite,顺便把Dapper这个一直躺在收藏夹里的轻量ORM正式用了一遍。如果你也正打算在本地小项目里存点数据、又不想被完整数据库服务的部署折腾得头疼,这篇文章应该能帮你省不少事。我尽量把原理讲明白,代码也给全,按着敲就能跑通。

1. SQLite是什么,为什么本地工具软件总选它

先说清楚SQLite到底是个什么东西。它不是那种需要单独安装服务、配置端口、创建账号的数据库系统,而是一个嵌入式关系型数据库。所谓“嵌入式”,意思是它不跑独立的服务进程,而是以程序库的方式直接静态或动态链接进你的应用程序里,你的程序一调用,它立刻在当前进程内干活,干完就收工。这个设计思路最大的好处就是你不需要维护任何后台服务,不需要操心开机自启、服务崩溃恢复这些乱七八糟的事。

体现在磁盘上,整个数据库通常就是单独一个文件,比如mydata.db。这个文件里包含了你所有的表、索引、视图、触发器,以及实际数据。文件格式是跨平台的,你在Windows上生成的db文件拷贝到Linux或者macOS上一样能打开。这个特性让“用U盘把整个中间结果拷走”变成了一件特别自然的事,也解释了为什么那么多客户端软件、工具类程序、工控系统、甚至手机App都拿SQLite当本地存储。

和传统数据库做个直观对比会更清楚:

特性SQLiteSQL Server / MySQL
架构嵌入式,进程内运行独立服务进程,客户端/服务端网络通信
部署引入程序集/二进制文件即可安装服务、配置账号、开放端口、处理防火墙
数据库形态单个文件一堆数据文件,通常分散在特定目录
适用连接数低并发、单机场景最舒服高并发、多客户端共享场景
备份直接复制文件(若开启WAL需额外处理)备份工具或导出脚本
开销极小,适合嵌入式大,资源占用明显

当然它不是万能的。SQLite的并发写能力天生就比较弱,同一时间只允许一个进程对数据库执行写操作。多个进程同时往里写,后发起的通常会收到“database is locked”的错误。所以如果你的场景是多用户、高并发写,或者需要复杂的事务协调,那应该直接选正经的数据库服务。但反过来说,如果你要的是“我程序要在本地落一批数据,随时查,客户机器可能没网没环境”,SQLite几乎是最优解。

在实际项目里,我能想到的最典型使用场景有这么几类:桌面工具软件存配置和业务数据;网站或服务端的低并发辅助存储;测试环境快速搭库,方便在CI里跑自动化用例;工控/上位机软件记录历史数据和点位数据;甚至是数据分析脚本的中间结果存储。很多人在这些场景里去装完整的数据库服务,其实从成本和维护角度看都有点浪费,SQLite一个文件搞定,重启机器也不怕丢。

2. Windows下跑起SQLite:安装、配置与常用GUI工具

很多人在Windows上第一次接触SQLite时会困惑一件事:官网到底下载哪个文件?或者说“这不就是个文件数据库吗,为什么还要安装?”其实“Windows下怎么安装SQLite”这个问题本身就隐含了一个误解。SQLite在Windows上不是一个需要“安装”的软件,而是看你打算怎么用它,分几种情况。

2.1 获取命令行工具和DLL

如果你只是想临时体验一下,或者想做点管理操作,去SQLite官网下载预编译的二进制包,里面有个sqlite3.exe,这就是一个完整的命令行客户端,可以直接用来创建数据库、执行SQL、查看数据、导出结果。这个exe不需要任何安装过程,放进某个目录,把该目录加到PATH里,然后在任意终端敲sqlite3 mydb.db就能进入交互界面。

如果你要和Delphi、C#、Python之类的编程语言集成,那就不是“安装”SQLite本身了,而是往你的项目里引用对应的库。比如.NET环境,是装NuGet包;Delphi环境,则是引入对应的单元文件和动态库。核心的数据库引擎总是同一个,只是封装不同。

2.2 GUI管理工具选型

命令行工具适合写脚本和自动化,但日常开发时我更推荐配一个可视化工具。网上最常被问到的三款是SQLiteStudio、DB Browser for SQLite和DBeaver。我都用过,简单说下区别:

工具特点适合人群
SQLiteStudio绿色单文件,中文支持好,轻量,功能足够日常增删改查、查看表结构、导出导入想要开箱即用、不喜欢折腾的人
DB Browser for SQLite界面直观,编辑数据体验好,支持SQL语法高亮,插件丰富需要经常手工维护数据的人
DBeaver功能最全,支持几十种数据库,自带ER图同时要连多种数据库的开发人员

个人观点:如果你只是围绕SQLite干活,首选SQLiteStudio,它启动快,窗口布局也符合直觉;如果你工作中还经常要碰MySQL、PostgreSQL之类的,那直接统一用DBeaver,省得装一堆客户端。另外提一句,这些工具的版本尽量保持更新,老版本的SQLite引擎版本较低,对窗口函数、JSON函数这类新特性的支持不全,排查问题的时候会多出很多干扰。

2.3 在.NET项目里引用SQLite

我这次实操是在.NET 8环境下用C#完成的,引用的包是Microsoft.Data.Sqlite,这是微软官方维护的SQLite ADO.NET提供程序,由SQLite官方团队和微软合作出品,跨平台支持做得很好。需要注意不要和另一个老牌包System.Data.SQLite搞混,后者功能也完整,但维护节奏和API风格不同。对于新项目,如果不用EF Core,直接用Microsoft.Data.Sqlite会依赖更少,安装体积更小,对后续Dapper配合也更友好。

引用方式很简单,在项目目录执行:

dotnet add package Microsoft.Data.Sqlite dotnet add package Dapper

或者直接在Visual Studio的NuGet管理器里搜索安装。装完之后,你就能在代码里写new SqliteConnection("Data Source=test.db")这种连接字符串了。这里有个新手很容易忽略的点:Data Source=test.db里的文件路径是相对当前工作目录解析的。控制台程序直接dotnet run时,当前工作目录通常是项目根目录,文件会出现在那里;发布成服务或开机启动程序后,工作目录可能会变,导致找不到数据库文件。建议在正式项目里始终用绝对路径,或者通过AppContext.BaseDirectory拼出一个能确定的路径。

3. Dapper到底做了什么,为什么说它“轻”

SQLite本身只提供了底层的读取和存储能力,你在C#里要操作它,要么直接写ADO.NET那一套(Connection、Command、DataReader),要么借助ORM框架把对象和SQL之间的映射关系处理好。Dapper就是这套映射关系中非常特别的一个存在。

3.1 Dapper的出身背景

Dapper是Stack Overflow团队开发并开源的,目的很简单:他们发现Entity Framework在超大规模流量下性能不够用,但完全回到裸写ADO.NET又太繁琐,于是做了这么一层极薄的封装。它不是要去替代SQL,而是想让你写SQL的同时,少写那些重复性的“从DataReader里取列、转类型、塞进对象”的机械代码。这一点决定了Dapper的定位和后面所有行为——它是个micro ORM,不是full ORM。

3.2 Dapper和EF的差异,以及和原生ADO.NET的比较

提到ORM,很多人第一反应是Entity Framework Core。但EF和Dapper做的是完全不同性质的事情。EF是重量级ORM,它替你管理实体状态、自动生成SQL、处理导航属性、做变更追踪,这带来了极大的开发便利,但也带来了不小的抽象开销和性能损耗。Dapper几乎没有状态概念,它不追踪实体,不自动生成SQL,你写什么SQL它就执行什么SQL,然后尽力把结果映射成你要的对象。它给你的是掌控力,换走的是自动化。

性能上的差异非常直观:

方案速度代码量可控性学习成本
原生ADO.NET最快最大完全可控中等
Dapper接近原生SQL自己写,映射交给它
EF Core有明显的动态Expression编译开销自动生成SQL,遇复杂查询还得插手

Dapper的实现原理也不复杂:它给IDbConnection写了一系列扩展方法,通过Emit动态生成IL代码完成实体属性与列的绑定。第一次调用时会做一次反射和IL编译,后续执行直接复用,所以性能很高,几乎接近手写DataReader。这一点网上不少性能测试都能佐证。

3.3 Dapper的核心API

Dapper的常用API数量极少,翻来覆去就那几个,这点特别适合入门:

方法作用典型场景
Query<T>执行查询,返回IEnumerable<T>查多条记录
QueryFirstOrDefault<T>执行查询,返回第一条或默认值按主键查单条记录
QuerySingle<T>执行查询,必须返回恰好一条,否则报错对唯一性有严格要求时
ExecuteScalar<T>执行查询,返回第一行第一列取COUNT、SUM等聚合结果
Execute执行增删改,返回受影响行数INSERT/UPDATE/DELETE
QueryMultiple一条SQL返回多个结果集批量加载主表和子表数据

这几个方法足够覆盖绝大多数日常操作。它们都能接收SQL字符串和参数对象,参数对象可以是匿名类型,也可以是DynamicParameters。比如:

var user = connection.QueryFirstOrDefault<User>( "SELECT * FROM Users WHERE Id = @Id", new { Id = 1 });

这里Dapper会自动把@Id和匿名对象里的Id属性对应,并且默认创建的是参数化查询,不是字符串拼接,SQL注入风险天然就被处理掉了。这是个很重要的设计点:它不是帮你写SQL,而是帮你在写SQL时保证安全和效率。

3.4 为什么选择Dapper而不是EF

选Dapper的原因,对我来说核心就三条。一是性能需求能直接满足,特别是数据量上来以后,EF的表达式树翻译和变更追踪开销会被放大,而Dapper几乎不增加额外成本。二是心智负担小,我完全清楚我的SQL在干什么,不用猜框架把LINQ翻译成了什么鬼东西。三是不绑架数据库特性,SQLite某些特有的语法、函数,直接写在SQL里就好,不用和EF的Provider较劲。

Dapper不适合的场景也有:如果你的业务模型非常复杂,比如有深层继承、大量关联导航、频繁的增删改且需要自动收集脏属性,那手写SQL会让你体力枯竭。这时候老老实实用EF,反而效率更高。Dapper适合的是那种“数据模型不复杂,但性能要稳,SQL要可控”的项目——恰恰是我这类工具型小软件最典型的需求。

4. 首次实战:用Dapper对SQLite做增删改查

理论讲再多,不如直接跑一遍。下面我完整记录一次用Dapper操作SQLite的实战过程,从建库、建表到增删改查,包括事务处理。代码环境是.NET 8控制台应用,NuGet包就两个:Microsoft.Data.SqliteDapper

4.1 建库和建立连接

先说一句可能会让新手惊讶的事实:SQLite的“建库”根本不需要任何CREATE DATABASE语句。只要你用new SqliteConnection("Data Source=xxx.db")创建了一个连接,并且调用Open()或执行任意命令,文件就会自动生成。当然,此时数据库是空的,里面没有任何表,要用SQL自己建表。

using Microsoft.Data.Sqlite; using Dapper; var connectionString = "Data Source=appdata.db"; using var connection = new SqliteConnection(connectionString); connection.Open(); // 建表 connection.Execute(@" CREATE TABLE IF NOT EXISTS DeviceRecord ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DeviceName TEXT NOT NULL, ReadingValue REAL NOT NULL, RecordTime TEXT NOT NULL );");

IF NOT EXISTS是我强烈建议加上的,因为SQLite没有DROP TABLE IF EXISTS以外的方式判断“表是否已存在”,有了这个子句,程序不管启动多少遍都不会报错。

这里有个细节值得说明:SQLite没有独立的日期时间类型,日常处理时间通常以TEXT(ISO8601格式)或INTEGER(Unix时间戳)存储。我用TEXT存储RecordTime,配合DateTime.ToString("yyyy-MM-dd HH:mm:ss")转成字符串,排序、查询、显示都很直观。在SQLite里选类型时,别完全照搬SQL Server的习惯,顺应它的“动态类型”思路会更省心。

4.2 插入数据

插入数据是Dapper里最直接的场景。建一个实体类:

public class DeviceRecord { public long Id { get; set; } public string DeviceName { get; set; } = ""; public double ReadingValue { get; set; } public string RecordTime { get; set; } = ""; }

然后插入几条记录:

var record = new DeviceRecord { DeviceName = "温度传感器_01", ReadingValue = 36.5, RecordTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") }; var affectedRows = connection.Execute( "INSERT INTO DeviceRecord (DeviceName, ReadingValue, RecordTime) VALUES (@DeviceName, @ReadingValue, @RecordTime)", record);

Execute会返回受影响行数。这里注意一下,我不建议在INSERT INTO的VALUES里写(NULL, @DeviceName, ...)去显式处理自增主键。直接省略Id列就好,让SQLite自动生成。如果插入后马上需要拿到这个新生成的Id,在SQLite里可以用last_insert_rowid()

var newId = connection.ExecuteScalar<long>( "INSERT INTO DeviceRecord (DeviceName, ReadingValue, RecordTime) VALUES (@DeviceName, @ReadingValue, @RecordTime); SELECT last_insert_rowid();", record);

是不是有点反直觉?一条SQL里同时做了插入和取Id,但SQLite就是支持这种写法,Dapper也能正确处理这个批处理。这个技巧写日志类应用时特别实用,避免了再查一次。

4.3 查询数据

查询的基础用法很简单:

var allRecords = connection.Query<DeviceRecord>("SELECT * FROM DeviceRecord").ToList(); var highReadings = connection.Query<DeviceRecord>( "SELECT * FROM DeviceRecord WHERE ReadingValue > @threshold ORDER BY ReadingValue DESC", new { threshold = 30.0 }).ToList(); var firstRecord = connection.QueryFirstOrDefault<DeviceRecord>( "SELECT * FROM DeviceRecord WHERE DeviceName = @name", new { name = "温度传感器_01" });

Dapper会把查询结果的每一行映射成DeviceRecord对象,前提是列名能对应上实体的公开属性。SQLite对大小写不敏感,DEVICENAMEdevicename都能映射到DeviceName,这一点比很多数据库都宽松,初学者不用太焦虑大小写问题。

如果真的遇到列名和属性名不一致的情况(比如表里是reading_value,实体里是ReadingValue),可以在SQL里用别名解决:

SELECT DeviceName, ReadingValue AS ReadingValue FROM DeviceRecord

或者在查询方法里传一个自定义映射字典。优先推荐SQL别名方案,简单明确。

4.4 更新和删除

更新和删除同样走Execute

var updated = connection.Execute( "UPDATE DeviceRecord SET ReadingValue = @value WHERE Id = @id", new { value = 40.2, id = newId }); var deleted = connection.Execute( "DELETE FROM DeviceRecord WHERE Id = @id", new { id = newId });

是不是发现套路了?Dapper的日常使用,本质上就是“写SQL + 传参数”。记住这句话,你基本已经掌握了Dapper百分之八十的用法。剩下百分之二十是事务、批量操作、多结果集和类型处理。

4.5 事务操作

SQLite虽然单机,但事务依然重要。比如你有一条业务规则:必须同时写入设备记录和操作日志,任何一边失败都要整体回滚。用Dapper配合事务很直接:

using var transaction = connection.BeginTransaction(); try { connection.Execute( "INSERT INTO DeviceRecord (DeviceName, ReadingValue, RecordTime) VALUES (@DeviceName, @ReadingValue, @RecordTime)", record, transaction); connection.Execute( "INSERT INTO OpLog (ActionName, LogTime) VALUES (@ActionName, @LogTime)", new { ActionName = "AddRecord", LogTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") }, transaction); transaction.Commit(); } catch { transaction.Rollback(); throw; }

这里的关键是第3个参数需要传入事务对象。Dapper的Execute系列重载都支持这个参数。不传的话,每条语句各自独立提交,就失去了事务的原子性。另外一个容易踩的坑是:BeginTransaction()必须在Open()之后调用,并且同一个事务里的操作必须使用同一个连接对象。

SQLite本身的细节处理也很重要。默认的日志模式是DELETE模式,写事务时性能一般;对高频写入场景,建议在连接字符串里加上Cache=SharedMode=ReadWriteCreate这些选项,或者执行PRAGMA journal_mode = WAL;把日志模式切到WAL,能显著提升并发读性能。在工控类应用里,如果涉及几个进程同时读、一个进程写的情况,WAL几乎是刚需,否则锁冲突会直接导致业务中断。

5. 第一次用SQLite最容易踩的坑:编码、并发与连接管理

这一节是我特别想写的。光看SQLite官方文档,你不会意识到实际用起来会有那么多奇奇怪怪的问题。下面这几个坑,我根据自己实践和给朋友排查的经验挑了最典型的,按出现频率排序,全是血泪。

5.1 中文乱码的真相

“乱码”这个事在SQLite开发里出现的频率高到离谱。这里头其实要分清两种乱码来源:一种是数据本身坏了,另一种(更常见)是本来没坏,但显示或读取时编码没对上。

SQLite内部存储字符串按UTF-8处理,这在跨平台时其实是个优势。但如果你使用老旧的客户端工具、老版本的第三方封装组件,或者用早就被淘汰的编码方式手工拼SQL,插入的中文就会以错误编码存储在磁盘上,之后任何工具读出来都是乱码。Delphi的sqlite相关组件在中文环境下就出过不少这种问题,网上经常搜到“delphi sqlite 亂碼”的提问,主要就是Delphi使用的字符串编码是ANSI(GBK),往SQLite写数据时没有正确转成UTF-8。

解决思路,按优先级排序:

层级操作
源头在客户端组件里明确调用UTF-8转换函数,统一以UTF-8写入
连接确保连接字符串里的编码参数正确,新版库一般默认就是UTF-8,不用动
工具使用新版GUI工具查看数据,老工具可能存在编码BUG
检查用十六进制查看器或直接SELECT HEX(字段)确认字节序列到底是E6...(UTF-8中文)还是D6...(GBK中文)

实操里最有效的判断方法:如果HEX(列)输出E69C8DE58AA1E599A8这种E字开头序列,说明是UTF-8存的;如果是B7FECEF1BDF8这种D7/B7开头序列,大概率是按GBK或GB2312存的。知道这个就能判断问题是出在“写”还是“读”了。千万别在SQLite层面尝试强制改编码,没有这种功能,安全做法是在程序的边界处处理好编码。

5.2 写并发与database is locked

SQLite的锁模型导致高并发写入很容易踩雷。默认情况下,一个进程在写数据库时会获得RESERVED锁,其他进程要等它提交完才能写;如果等待超时还没拿到锁,就报database is locked。可能你会想:这数据库文件就在本地,谁写数据能持续那么久?实际操作中,一个大事务、或者有人手贱打开了GUI工具并开启了某个写事务,就足以阻塞其他写入者。

这让很多人栽过跟头,包括我在内。去年帮朋友调一个上位机软件,历史数据采集程序时不时报锁错误,最后发现罪魁祸首是同一个数据库文件被另一个调试工具以独占方式打开着。所以处理SQLite高并发写入,思路往往不是“提高数据库性能”,而是:

方案做法适用场景
WAL模式执行PRAGMA journal_mode=WAL;,读写可并行读多写少、一个写者
写入串行化在应用层对写操作加锁或放进队列单进程内多个写线程
增大超时连接字符串加Default Timeout=30或调用方等待重试偶尔冲突可接受
合并写入多条记录拼成一次事务再写工控高频采集、批量日志

至于多进程都想写同一个SQLite库的场景,建议重新评估一下技术选型。如果数据结构不算复杂,也可以考虑让其中一个进程专门负责数据库写入,其他进程通过本地IPC把数据传过去,从根上绕开文件锁。

5.3 连接字符串:隐藏的坑

Data Source=test.db是最常见的连接写法,但实际项目里你可能需要更精确的控制。一个真实案例:我同事把一个WinForms程序配上开机自启后,数据库就异常了,排查半天发现是程序的默认工作目录变成了C:\Windows\System32,数据库文件被自动创建到了系统目录。这个坑很隐蔽,因为开发时一切正常,发布后才原形毕露。

Windows服务、计划任务、开机启动程序的工作目录都不一定是程序所在目录,所以请一定这样写:

var dbPath = Path.Combine(AppContext.BaseDirectory, "appdata.db"); var connectionString = $"Data Source={dbPath}";

这样无论程序从哪个目录被启动,数据库文件一定生成在exe同目录下,可预期、好排查。

另一个高频问题是连接未释放。有段时间我见过有人在循环里反复new SqliteConnection但从不Dispose,数据库文件被一堆僵尸连接占着,最后程序卡死。在C#里,请务必用using声明连接对象:

using var conn = new SqliteConnection(connectionString); // 使用完自动释放

要注意的是,Dapper的QueryExecute这些扩展方法会帮你管理连接生命周期——如果连接是关闭的,它会自动打开并在操作完成后关闭;如果连接本来就是打开的,它不会自动关闭。所以如果整个程序共享同一个长连接,要自己控制Open/Close;如果每次操作都新建连接,那放心交给Dapper自动托管即可。

5.4 SQLite类型亲和性:看起来很宽松,实际有陷阱

SQLite是个“动态类型+类型亲和性”的系统,不像SQL Server那样有强类型约束。你给一个INTEGER列插入字符串,SQLite也不一定会报错,它会按“亲缘性转换”规则尝试变成整数,转不了就存文本。这在快速开发时很爽,但也埋了很多雷:比如你把000123插入INTEGER列,存进去的数字是123而不是字符串“000123”,再读出来时指望前面补零,一定会出问题。

我的建议是:在建表阶段就想清楚每列的类型;程序端在插入前做一次严谨的类型检查或to-string转换;读出来的数据别太依赖SQLite自己转出来,尤其在处理数值时用Convert.ToDoubleConvert.ToInt64显式转换,避免隐式转型带来的精度问题。对于时间字段,坚持使用TEXT ISO8601格式或INTEGER时间戳,二者选其一,不要混用。混用的结果就是查某一天的数据时SQL写起来特别痛苦。

5.5 顺带一提KingsCada这类工控软件连SQLite的场景

热搜里出现kingscada连接sqlite,我猜测大概率是组态软件/上位机项目里要对接本地历史库。组态软件写SQLite的方式五花八门,有的通过ODBC桥接,有的直接内嵌驱动。这里最值得注意的还是并发和编码问题:数据采集服务会高频写入,而画面显示端在同时读取,如果不把WAL模式和busy_timeout配好,运行一段时间必然出现锁冲突。另外,很多组态软件的版本自带的老SQLite引擎可能不支持新特性,如果发现PRAGMA journal_mode=WAL执行报错,先检查当前实际加载的SQLite版本(SELECT sqlite_version();)。版本过老的话,考虑升级组件或绕开旧特性。

6. 小结和进阶方向

如果你把上面的代码全部跑通,那么SQLite和Dapper的入门基本就完成了。你会发现,SQLite的价值在于“用最小的成本获得一个可靠的关系型数据存储”,而Dapper的价值在于“在保持SQL掌控力的同时,减少无营养的重复编码”。两者配合起来,特别适合中小工具软件、客户端存储、工控项目和快速原型。

回顾实际开发中我比较推荐的做法:数据库文件路径用绝对路径拼出来,连接字符串集中管理;建表SQL用IF NOT EXISTS兜底;大量写入尽量合并成事务,顺手开WAL模式;读出来的数据做强类型转换;中文数据不要迷信任何“自动编码”,统一走UTF-8。

等你把这套基础跑熟,后面可以往这几个方向继续深入:Dapper的QueryMultiple处理多结果集,DynamicParameters处理动态查询条件,批量插入的性能优化,SQLite的JSON扩展函数,以及索引设计对查询性能的影响。这些内容每一个单独拿出来都能写一整篇。下次有空,我再把批量插入和JSON处理的实际案例整理出来分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询