
开头先说我为什么突然折腾起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 Sourcetest.db)这种连接字符串了。这里有个新手很容易忽略的点Data Sourcetest.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的核心APIDapper的常用API数量极少翻来覆去就那几个这点特别适合入门方法作用典型场景QueryT执行查询返回IEnumerableT查多条记录QueryFirstOrDefaultT执行查询返回第一条或默认值按主键查单条记录QuerySingleT执行查询必须返回恰好一条否则报错对唯一性有严格要求时ExecuteScalarT执行查询返回第一行第一列取COUNT、SUM等聚合结果Execute执行增删改返回受影响行数INSERT/UPDATE/DELETEQueryMultiple一条SQL返回多个结果集批量加载主表和子表数据这几个方法足够覆盖绝大多数日常操作。它们都能接收SQL字符串和参数对象参数对象可以是匿名类型也可以是DynamicParameters。比如var user connection.QueryFirstOrDefaultUser( 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.Sqlite和Dapper。4.1 建库和建立连接先说一句可能会让新手惊讶的事实SQLite的“建库”根本不需要任何CREATE DATABASE语句。只要你用new SqliteConnection(Data Sourcexxx.db)创建了一个连接并且调用Open()或执行任意命令文件就会自动生成。当然此时数据库是空的里面没有任何表要用SQL自己建表。using Microsoft.Data.Sqlite; using Dapper; var connectionString Data Sourceappdata.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没有独立的日期时间类型日常处理时间通常以TEXTISO8601格式或INTEGERUnix时间戳存储。我用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.ExecuteScalarlong( INSERT INTO DeviceRecord (DeviceName, ReadingValue, RecordTime) VALUES (DeviceName, ReadingValue, RecordTime); SELECT last_insert_rowid();, record);是不是有点反直觉一条SQL里同时做了插入和取Id但SQLite就是支持这种写法Dapper也能正确处理这个批处理。这个技巧写日志类应用时特别实用避免了再查一次。4.3 查询数据查询的基础用法很简单var allRecords connection.QueryDeviceRecord(SELECT * FROM DeviceRecord).ToList(); var highReadings connection.QueryDeviceRecord( SELECT * FROM DeviceRecord WHERE ReadingValue threshold ORDER BY ReadingValue DESC, new { threshold 30.0 }).ToList(); var firstRecord connection.QueryFirstOrDefaultDeviceRecord( SELECT * FROM DeviceRecord WHERE DeviceName name, new { name 温度传感器_01 });Dapper会把查询结果的每一行映射成DeviceRecord对象前提是列名能对应上实体的公开属性。SQLite对大小写不敏感DEVICENAME、devicename都能映射到DeviceName这一点比很多数据库都宽松初学者不用太焦虑大小写问题。如果真的遇到列名和属性名不一致的情况比如表里是reading_value实体里是ReadingValue可以在SQL里用别名解决SELECT DeviceName, ReadingValue AS ReadingValue FROM DeviceRecord或者在查询方法里传一个自定义映射字典。优先推荐SQL别名方案简单明确。4.4 更新和删除更新和删除同样走Executevar 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模式写事务时性能一般对高频写入场景建议在连接字符串里加上CacheShared、ModeReadWriteCreate这些选项或者执行PRAGMA journal_mode WAL;把日志模式切到WAL能显著提升并发读性能。在工控类应用里如果涉及几个进程同时读、一个进程写的情况WAL几乎是刚需否则锁冲突会直接导致业务中断。5. 第一次用SQLite最容易踩的坑编码、并发与连接管理这一节是我特别想写的。光看SQLite官方文档你不会意识到实际用起来会有那么多奇奇怪怪的问题。下面这几个坑我根据自己实践和给朋友排查的经验挑了最典型的按出现频率排序全是血泪。5.1 中文乱码的真相“乱码”这个事在SQLite开发里出现的频率高到离谱。这里头其实要分清两种乱码来源一种是数据本身坏了另一种更常见是本来没坏但显示或读取时编码没对上。SQLite内部存储字符串按UTF-8处理这在跨平台时其实是个优势。但如果你使用老旧的客户端工具、老版本的第三方封装组件或者用早就被淘汰的编码方式手工拼SQL插入的中文就会以错误编码存储在磁盘上之后任何工具读出来都是乱码。Delphi的sqlite相关组件在中文环境下就出过不少这种问题网上经常搜到“delphi sqlite 亂碼”的提问主要就是Delphi使用的字符串编码是ANSIGBK往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 lockedSQLite的锁模型导致高并发写入很容易踩雷。默认情况下一个进程在写数据库时会获得RESERVED锁其他进程要等它提交完才能写如果等待超时还没拿到锁就报database is locked。可能你会想这数据库文件就在本地谁写数据能持续那么久实际操作中一个大事务、或者有人手贱打开了GUI工具并开启了某个写事务就足以阻塞其他写入者。这让很多人栽过跟头包括我在内。去年帮朋友调一个上位机软件历史数据采集程序时不时报锁错误最后发现罪魁祸首是同一个数据库文件被另一个调试工具以独占方式打开着。所以处理SQLite高并发写入思路往往不是“提高数据库性能”而是方案做法适用场景WAL模式执行PRAGMA journal_modeWAL;读写可并行读多写少、一个写者写入串行化在应用层对写操作加锁或放进队列单进程内多个写线程增大超时连接字符串加Default Timeout30或调用方等待重试偶尔冲突可接受合并写入多条记录拼成一次事务再写工控高频采集、批量日志至于多进程都想写同一个SQLite库的场景建议重新评估一下技术选型。如果数据结构不算复杂也可以考虑让其中一个进程专门负责数据库写入其他进程通过本地IPC把数据传过去从根上绕开文件锁。5.3 连接字符串隐藏的坑Data Sourcetest.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的Query、Execute这些扩展方法会帮你管理连接生命周期——如果连接是关闭的它会自动打开并在操作完成后关闭如果连接本来就是打开的它不会自动关闭。所以如果整个程序共享同一个长连接要自己控制Open/Close如果每次操作都新建连接那放心交给Dapper自动托管即可。5.4 SQLite类型亲和性看起来很宽松实际有陷阱SQLite是个“动态类型类型亲和性”的系统不像SQL Server那样有强类型约束。你给一个INTEGER列插入字符串SQLite也不一定会报错它会按“亲缘性转换”规则尝试变成整数转不了就存文本。这在快速开发时很爽但也埋了很多雷比如你把000123插入INTEGER列存进去的数字是123而不是字符串“000123”再读出来时指望前面补零一定会出问题。我的建议是在建表阶段就想清楚每列的类型程序端在插入前做一次严谨的类型检查或to-string转换读出来的数据别太依赖SQLite自己转出来尤其在处理数值时用Convert.ToDouble或Convert.ToInt64显式转换避免隐式转型带来的精度问题。对于时间字段坚持使用TEXT ISO8601格式或INTEGER时间戳二者选其一不要混用。混用的结果就是查某一天的数据时SQL写起来特别痛苦。5.5 顺带一提KingsCada这类工控软件连SQLite的场景热搜里出现kingscada连接sqlite我猜测大概率是组态软件/上位机项目里要对接本地历史库。组态软件写SQLite的方式五花八门有的通过ODBC桥接有的直接内嵌驱动。这里最值得注意的还是并发和编码问题数据采集服务会高频写入而画面显示端在同时读取如果不把WAL模式和busy_timeout配好运行一段时间必然出现锁冲突。另外很多组态软件的版本自带的老SQLite引擎可能不支持新特性如果发现PRAGMA journal_modeWAL执行报错先检查当前实际加载的SQLite版本SELECT sqlite_version();。版本过老的话考虑升级组件或绕开旧特性。6. 小结和进阶方向如果你把上面的代码全部跑通那么SQLite和Dapper的入门基本就完成了。你会发现SQLite的价值在于“用最小的成本获得一个可靠的关系型数据存储”而Dapper的价值在于“在保持SQL掌控力的同时减少无营养的重复编码”。两者配合起来特别适合中小工具软件、客户端存储、工控项目和快速原型。回顾实际开发中我比较推荐的做法数据库文件路径用绝对路径拼出来连接字符串集中管理建表SQL用IF NOT EXISTS兜底大量写入尽量合并成事务顺手开WAL模式读出来的数据做强类型转换中文数据不要迷信任何“自动编码”统一走UTF-8。等你把这套基础跑熟后面可以往这几个方向继续深入Dapper的QueryMultiple处理多结果集DynamicParameters处理动态查询条件批量插入的性能优化SQLite的JSON扩展函数以及索引设计对查询性能的影响。这些内容每一个单独拿出来都能写一整篇。下次有空我再把批量插入和JSON处理的实际案例整理出来分享。