1. 从一次“数据库文件打不开”的现场说起
前两天帮朋友排查一个C#的小工具,现象很典型:程序在开发机上跑得好好的,拷到客户那边就报“unable to open database file”。查了一圈,路径没写错,文件也存在,最后发现是客户把程序放在了一个没有写权限的目录里,SQLite压根没法创建临时文件。这种问题,光靠搜索引擎是搜不出答案的,得真踩过坑才知道。
C#连SQLite,听起来是个老话题,网上教程一大把,但大部分要么只贴几个方法名,要么直接扔一段代码让人复制,完全没讲背后的设计逻辑。这篇不打算那么干。我会从连接字符串的构造、基础增删改查的套路,到事务、并发、参数化这些绕不开的细节,一层层拆开讲。中间会穿插一些实操中才能碰到的坑,比如类型映射、路径权限、连接池副作用这些常规教程不会提的东西。适合三种人看:刚入门的C#新手想找一个能直接跑的方案,写上位机或者桌面工具的老手想查漏补缺,还有那些被SQLite坑过、想搞清楚“为什么”的人。
SQLite在C#生态里最典型的应用场景是本地单机软件——配置存储、日志落盘、离线数据缓存、上位机的运行参数记录。它不需要安装服务,不需要账号密码,就是一个文件,挪走就能带走,部署成本几乎为零。但也正因为“太简单”,很多人反而会在一些基础环节上栽跟头。下面从选型开始,把整个链路捋一遍。
2. 环境准备与驱动选型:为什么推荐Microsoft.Data.Sqlite
2.1 两个主流驱动怎么选
C#里操作SQLite,绕不开两个包:System.Data.SQLite和Microsoft.Data.Sqlite。前者出道早,功能全,自带一个完整的ADO.NET实现,还能做加密;后者是微软官方维护的,轻量,专门为.NET Core和.NET 5+设计的。
我的建议很直接:新项目一律用Microsoft.Data.Sqlite。理由有三个:
- 微软官方在维护,和EF Core的集成最顺畅;
- 包体积小,依赖少,发布的时候不拖泥带水;
- API风格更现代,写起来顺手。
System.Data.SQLite也不是没有价值,如果你需要SQLite的加密扩展或者要用到一些冷门特性,它仍然是唯一选择。但对绝大多数应用场景,微软官方包足够,而且踩坑的几率低得多。
安装方式,Visual Studio里打开“管理NuGet程序包”,搜索“Microsoft.Data.Sqlite”,装最新稳定版就行。或者用包管理器控制台:
Install-Package Microsoft.Data.Sqlite这里补一个细节:包版本和.NET版本有对应关系。如果你还在用.NET Framework 4.x,需要装2.x版本的Microsoft.Data.Sqlite;.NET Core 3.1以上才能用最新版。装之前看一眼项目目标框架,免得装完编译报一堆类型冲突。
2.2 可视化工具:DB Browser for SQLite够用吗
光有代码不够,调试SQLite的时候我强烈建议配一个可视化工具。我用得最顺的是DB Browser for SQLite,也就是常说的DB4S。免费、开源、跨平台,Windows和Linux都能跑。
这个工具能干嘛?直接打开数据库文件浏览表结构、看数据、执行SQL语句、导出数据,还能可视化编辑表结构。最常用的场景是程序写进去的数据,怀疑有问题的时候,用DB4S打开看一眼是不是真的写进去了,死盯代码效率高得多。另一个高频场景是改表结构。SQLite早期版本的ALTER TABLE能力极弱,想删一列都费劲。DB4S有个“修改表”的图形化入口,它会自动帮你重建表结构,省去手写一堆迁移SQL的麻烦。
2.3 路径与权限:第一个隐藏坑
环境准备好以后,第一件事不是写代码,而是搞清楚文件路径怎么给。SQLite的数据库文件路径天然就是连接字符串的一部分,看起来很简单,但路径有讲究:
var connectionString = "Data Source=mydatabase.db";这个相对路径写法,工作目录是程序启动时所在目录。开发环境没问题,但发布之后,程序可能被放在任务计划程序、Windows服务、或者别的进程拉起的环境里,工作目录会变。典型故障:明明文件生成在程序目录下,却报找不到文件。原因很简单——工作目录变了。
经验做法:连接字符串里的路径写成绝对路径,或者基于程序集位置拼接的路径。
var baseDir = AppContext.BaseDirectory; // 程序集所在目录 var dbPath = Path.Combine(baseDir, "app_data", "mydatabase.db"); var connectionString = $"Data Source={dbPath}";再补一个权限细节:SQLite运行时不只是读写数据库文件本身,还要在同一目录下创建-journal或-wal文件,用于事务日志和预写日志。这意味着程序不仅要有数据库文件的读写权限,还要有所在目录的创建文件权限。开篇提到的“unable to open database file”,多半就是这个原因。部署到生产环境时,先确认运行账户对这个目录有完全控制权,别把程序放在Program Files这类受保护目录下还不做权限调整。
3. 连接管理和基础增删改查:从第一个连接开始
3.1 连接字符串与连接生命周期
连接字符串看起来最简单的写法:
Data Source=xxx.db但真实场景往往还要加几个参数。常用参数:
| 参数 | 作用 | 建议值 |
|---|---|---|
Data Source | 数据库文件路径 | 必填 |
Mode | 连接模式:ReadWriteCreate是默认,能读能写不存在就创建 | ReadWriteCreate / ReadOnly |
Cache | 连接缓存模式,Shared能让多个连接共享同一缓存 | 默认即可 |
Password | 加密数据库的密码,前提是SQLite编译了加密扩展 | 非必须 |
Foreign Keys | 是否启用外键约束 | 建议True |
连接创建和释放,直接用using块托管:
using var connection = new SqliteConnection(connectionString); connection.Open(); // 你的操作 // 离开作用域自动Dispose,连接自动关闭为什么要强调“用完即关”?SQLite是文件型数据库,连接本质上就是一个文件句柄加一堆内部状态。连接开太多不放,轻则内存和句柄泄漏,重则影响其他进程对这个文件的访问。虽然SQLite做了很多并发保护,但不要挑战它的底线。
有人喜欢在程序里维护一个全局唯一的静态连接,省得反复开关。我试过,短期没问题,时间一长就出怪事:连接状态错乱、事务脏数据、文件锁不释放。SQLite的最佳实践恰恰是“短连接”——每次操作开一个连接,用完成立刻关,这是它性能最稳的使用方式。连接池的开销并不大,正常情况下每次Open也就是微秒级到毫秒级的事,完全没必要为了省这个时间引入状态管理的复杂度。
3.2 建表与插入:理解SqliteCommand的工作方式
建表和增删改查,核心对象就三个:SqliteConnection、SqliteCommand、SqliteDataReader。先看建表:
using var connection = new SqliteConnection(connectionString); connection.Open(); var createSql = @" CREATE TABLE IF NOT EXISTS device_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_name TEXT NOT NULL, temperature REAL NOT NULL, record_time TEXT NOT NULL )"; using var command = connection.CreateCommand(); command.CommandText = createSql; command.ExecuteNonQuery();建表SQL和别的数据库差不多。有几个SQLite特有的细节:
INTEGER PRIMARY KEY AUTOINCREMENT,自增主键的标准写法;- SQLite没有专门的日期时间类型,一般用TEXT存ISO格式字符串;
REAL对应C#的double或float。
插入数据,最忌讳的是拼字符串。新手常犯的错误:
// 反面教材 var sql = $"INSERT INTO device_records (device_name, temperature, record_time) VALUES ('{name}', {temp}, '{time}')";这在SQLite里同样有SQL注入风险。比如name是abc'); DROP TABLE device_records;--,整个表就没了。更重要的是,拼字符串还会导致SQLite无法复用SQL语句的执行计划,性能白白损耗。正确做法是用参数化:
using var connection = new SqliteConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = @" INSERT INTO device_records (device_name, temperature, record_time) VALUES ($name, $temp, $time)"; command.Parameters.AddWithValue("$name", deviceName); command.Parameters.AddWithValue("$temp", temperature); command.Parameters.AddWithValue("$time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.ExecuteNonQuery();参数名用$或@前缀都可以,SQLite都认。建议统一用$,可以避免某些驱动里@被解析成变量的歧义。
3.3 查询与读取:DataReader的正确打开方式
查询数据,用ExecuteReader拿到SqliteDataReader,然后遍历:
using var connection = new SqliteConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = "SELECT id, device_name, temperature, record_time FROM device_records"; command.Parameters.AddWithValue("$minTemp", 25.0); command.CommandText += " WHERE temperature > $minTemp"; using var reader = command.ExecuteReader(); while (reader.Read()) { var id = reader.GetInt32(0); var name = reader.GetString(1); var temp = reader.GetDouble(2); var time = reader.GetString(3); Console.WriteLine($"{id} - {name} - {temp} - {time}"); }这里有几个容易犯糊涂的地方:
GetInt32、GetString这类方法要求索引对应的列类型必须匹配,否则抛异常。比如列是INTEGER,你用GetString去想拿到“123”,对不起,报错。真要拿通用值,用reader["columnName"]返回的是object,再自己转换。另一个重点是:Read()方法每调用一次,指针移动一行,数据是顺序读的,不能跳行,想回头读得重新查询。
读取大批量数据的时候,不要用DataTable——那会把全部数据加载进内存。直接用DataReader一条条消费,内存占用小且速度快,这是ADO.NET生态里被过度遗忘的好习惯。
3.4 更新与删除:别忘了一个关键细节
更新删除的写法逻辑上一样,都是ExecuteNonQuery:
using var command = connection.CreateCommand(); command.CommandText = "UPDATE device_records SET temperature = $temp WHERE id = $id"; command.Parameters.AddWithValue("$temp", 26.5); command.Parameters.AddWithValue("$id", 1); command.ExecuteNonQuery();这里补一个SQLite特有的行为:DELETE和UPDATE对参数的使用,同样应该参数化,原因和INSERT完全一样。
另一个小知识:ExecuteNonQuery的返回值是受影响的行数。判断删除或更新是否真的命中,直接检查返回值即可,不用再额外查询一遍——这招在处理“按条件删除,但行可能不存在”的热点问题上很省事。
3.5 万能查询帮手:写好一个迷你工具类
写上位机或工具类程序时,最简单高效的封装是做一个“查询Template”方法:
public static List<T> QueryList<T>(string sql, Func<SqliteDataReader, T> mapper, params SqliteParameter[] parameters) { var result = new List<T>(); using var connection = new SqliteConnection(_connectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = sql; if (parameters != null) command.Parameters.AddRange(parameters); using var reader = command.ExecuteReader(); while (reader.Read()) result.Add(mapper(reader)); return result; }用起来就是:
var list = QueryList("SELECT * FROM device_records", r => new DeviceRecord { Id = r.GetInt32(0), Name = r.GetString(1), Temperature = r.GetDouble(2) });这套思路参考了Dapper这类微ORM的映射逻辑,但不用引第三方包,轻巧直接。写日志、读配置、查历史记录,一块代码全搞定。如果你愿意,还能进一步做泛型加表达式树,不过那就是另一个深度话题了,这个迷你版在大多数场景下已经能把代码量砍掉一大半。
4. 进阶实操:事务、并发、类型异常与防坑指南
4.1 事务到底怎么用:批量操作为王
SQLite的事务是一条分界线——不会事务,就只能做“玩具级”应用;用好了,才能承接真正的业务数据。事务有两个核心价值:原子性和批量提交性能。
原子性好理解:一个事务内的操作要么全部成功,要么全部回滚。比如要同时更新设备状态表和写入一条日志,第二条失败时第一条不能留着。用事务包裹:
using var connection = new SqliteConnection(connectionString); connection.Open(); using var transaction = connection.BeginTransaction(); using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = "INSERT INTO device_records (device_name, temperature, record_time) VALUES ($name, $temp, $time)"; try { for (int i = 0; i < 1000; i++) { command.Parameters.Clear(); command.Parameters.AddWithValue("$name", $"device_{i}"); command.Parameters.AddWithValue("$temp", 20 + i); command.Parameters.AddWithValue("$time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); command.ExecuteNonQuery(); } transaction.Commit(); } catch { transaction.Rollback(); throw; }这里有个重要细节:同一个SqliteCommand可以循环使用,但每次都要清空再添加参数,或者直接给参数重新赋值。
command.Parameters["$name"].Value = $"device_{i}";这种赋值方式更快,省去频繁Add的开销。
性能方面,事务是批量插入的救星。默认情况下,SQLite每次ExecuteNonQuery都自动开启一个隐式事务,每次都要做磁盘同步(fsync),那才叫慢。把1000条插入包在一个事务里,实测速度能提升两个数量级——从秒级降到毫秒级。原理是:磁盘IO从每次插入一次变成整个事务一次,代价是事务期间内存占用会高一点,但对几千条数据来说,影响可以忽略。
注意:事务提交前,
SqliteConnection不能关闭,连接关闭会自动回滚未提交事务。这一条最容易被忽略——很多人写完代码发现数据“不见了”,多半就是早把连接关掉了。
4.2 SQLite并发模型:谁在锁谁
SQLite的并发和MySQL、SQL Server完全是两个世界。它走的是文件级锁,同一时刻只能有一个事务在写数据库(严格说是同一个数据库文件)。
实践中的取舍:
- 多线程并发读,没问题,WAL模式(预写日志)下读不阻塞写;
- 一读一写并发,WAL模式下也没问题;
- 两个写事务并发,无论什么模式,只能排队,而且超时默认会报“database is locked”。
怎么办?两条路:
第一条,开启WAL模式。执行一句SQL就能开启:
PRAGMA journal_mode=WAL;这条命令开启后,SQLite的读写并发能力大幅提升。注意它改变的是数据库文件的持久属性,改动一次,后续所有连接默认都带WAL属性(除非再次改回DELETE模式)。
第二条,控制业务写法:短事务、快事务、串行化写操作。所有写操作走同一把业务锁,比如用C#的SemaphoreSlim(1,1)包住写入逻辑。这不是SQLite的缺陷,而是“本地文件型数据库”的固有特性。认识到这一点,架构设计上就不会把SQLite当作高并发服务器数据库来用了。
4.3 类型映射对照:C#与SQLite之间的数据转换
SQLite宣称“动态类型”,但驱动层会做类型检查,表定义的声明类型仍会影响读写行为。我整理了常用对照表:
| SQLite类型 | C#推荐类型 | 说明 |
|---|---|---|
| INTEGER | long / int | 读出来用GetInt64或GetInt32 |
| REAL | double / float | GetDouble |
| TEXT | string | GetString |
| BLOB | byte[] | GetFieldValue<byte[]> |
| NULL | DBNull / 可空类型 | IsDBNull判断 |
最容易踩的坑是INTEGER读出长整型。SQLite的INTEGER存储本身是64位,驱动默认返回long,但如果代码里用GetInt32读一个超过2^31的大数,会抛OverflowException。反过来,插入int值没问题,SQLite会自动扩展为INTEGER。稳妥做法:不确定数值范围时,读出来一律用Convert.ToInt32(reader["column"])做转换,它能内部处理类型兼容问题。
另一个常见坑:SQLite没有专门的DateTime类型,存TEXT用字符串。读写时格式不一致可能引发解析异常。我在工具里习惯统一用"yyyy-MM-dd HH:mm:ss"格式,简单直观,避免驱动自动转换的小数点格式问题。
4.4 参数化的隐藏价值:不只是防注入
参数化除了防SQL注入,还有一个容易被忽略的好处——类型保真。看这个例子:
command.Parameters.AddWithValue("$time", DateTime.Now);驱动会按DateTime类型绑定参数,自动格式化成SQLite能识别的文本。但如果你直接拼字符串:
var sql = $"INSERT ... VALUES ('{DateTime.Now}')";ToString的格式受系统区域语言影响,上线前好好的,上线后换台电脑格式变了,数据存进去就坏。参数化把这个隐患彻底消化掉了。同理,带小数点的数值拼字符串,还会遇到小数点符号是“.”还是“,”的问题——参数化一律不存在。
4.5 查询大量数据时的内存控制
举个例子:需要从SQLite里读10万条传感器数据,然后做一次简单统计。
不推荐的做法:
var dt = new DataTable(); dbAdapter.Fill(dt); // 一次性全载入内存推荐做法:
double sum = 0; int count = 0; using var reader = command.ExecuteReader(); while (reader.Read()) { sum += reader.GetDouble(1); count++; }DataReader是流式读取,逐行消费,内存占用恒定;DataTable则是先攒到内存里,量大时内存飙到几百MB也是轻轻松松。日常工具类应用里,这条经验比什么花哨的ORM技巧都管用。
4.6 并发写冲突处理与重试机制
“database is locked”是SQLite实操中最常见的运行时异常,尤其是不同进程同时写同一个库文件的时候。处理思路很简单:捕获异常,稍后重试。写一个带重试的执行器:
public static void ExecuteWithRetry(Action action, int maxRetryCount = 5) { for (int i = 0; i < maxRetryCount; i++) { try { action(); return; } catch (SqliteException ex) when (ex.SqliteErrorCode == 5) // SQLITE_BUSY { Thread.Sleep(50 * (i + 1)); } } }这里SqliteErrorCode == 5对应的是SQLITE_BUSY,即数据库被锁。重试间隔用线性退避:第一次等50毫秒,第二次100毫秒,逐步变长。实测在WAL模式下,多进程写冲突概率大幅下降,重试基本在第一次就成功。
5. 常见报错与排查技巧:遇到问题别再瞎猜
5.1 高频异常速查表
| 现象 | 根本原因 | 排查/解决 |
|---|---|---|
| unable to open database file | 目录无写权限/路径错误 | 检查运行账户对目录的写权限,改用绝对路径 |
| no such table: xxx | 表确实不存在,或连接到的数据库文件不对 | 用DB4S打开确认文件,确认Data Source路径,确认建表SQL已执行 |
| database is locked | 并发写冲突或长事务未提交 | 加重试机制、开启WAL模式、缩短事务时间 |
| file is not a database | 打开了一个不是SQLite格式的文件 | 文件损坏或路径指向了非数据库文件(比如日志文件) |
| SqliteException: constraint failed | 违反约束,如UNIQUE冲突,NOT NULL冲突 | 检查插入/更新数据是否满足表结构约束 |
| 找不到问题但数据没写入 | 可能连接被提前关闭导致事务回滚 | 确保Commit、确保连接的using块包含整个事务生命周期 |
5.2 “no such table”的高频迷案
这个错误很有迷惑性。程序明明执行过建表语句,但每次查询都报这张表不存在。最常见的原因有两个:
一是连接到了不同的文件。比如建表时用的连接是相对路径app.db,查询时用的是拼接出来的绝对路径,两个路径实际指向不同文件。开发时注意在连接字符串构造处加日志打印,把实际路径打出来看一眼,十次里有八次能秒定位。
二是建表的ExecuteNonQuery根本没执行成功。常见于把建表逻辑放在某个条件分支里,或者建表语句有语法错误但被吞掉。排查办法:把建表SQL复制到DB4S里手动执行一次,如果报错,优先检查SQL语法。
5.3 命令无法识别:你的环境可能连错了
有时候问题压根不在SQLite本身,而是环境不对。网上经常看到类似“sqlite3无法识别”的报错——这不怪SQLite,是系统没装命令行工具或者没配环境变量。解决路径很简单:
- Windows下,去SQLite官网下载
sqlite-tools-win-x64压缩包,解压后把目录加进PATH; - 或者干脆用DB4S图形化管理,不需要命令行。
这跟C#开发经常碰到的“dotnet命令无法识别”是一个道理,工具链没有装好而已,别去怀疑代码逻辑。
5.4 数据文件损坏怎么抢救
SQLite也会损坏,概率不高但一坏就头大。特别是程序中途崩溃、磁盘写满、断电的场景,可能把数据库文件搞坏。处理优先顺序:
- 先备份。任何操作前先把.db文件复制一份到安全位置;
- 用DB4S打开看能不能读,能读就导出为SQL文件或CSV;
- 如果打不开,在命令行里执行:
sqlite3 corrupted.db ".recover" | sqlite3 recovered.dbSQLite自带recover命令,能把能读的内容尽可能恢复到新文件;4. 如果还是不行,.dump命令强制导出文本SQL,再导入新库。
实测经验:日常备份才是根治手段。写个简单任务,定时把数据库文件复制到备份目录,成本极低,收益极高。别等数据没了再想对策。
5.5 调试技巧:用一条日志看清全局
我调试SQLite程序时,习惯在关键位置打印SQL和参数值:
Console.WriteLine($"SQL: {command.CommandText}"); foreach (SqliteParameter p in command.Parameters) Console.WriteLine($"{p.ParameterName} = {p.Value}");别看这土办法简单,它能瞬间暴露两类问题:SQL写错、参数绑错。特别是从别的数据库迁移过来的SQL,语法差异经常在这里现原形。调试完再删掉日志代码就行,或者用条件编译包在#if DEBUG里,让正式发布不输出。
5.6 跨平台部署的额外注意
C#的跨平台能力这些年确实强了,但SQLite在不同平台上的行为还是有细微差别。
Windows下路径分隔符是\,Linux/macOS下是/,最好用Path.Combine统一处理。大小写敏感性在Linux上不可忽略,文件名和表名的大小写错误在Windows上可能侥幸通过,Linux上直接报错。部署到Linux服务器时,先确认libe_sqlite3依赖已经安装,否则运行时会报找不到native库的错误。
一个更隐蔽的问题:SMB网络共享上使用SQLite。文件锁在网络文件系统上有时候不生效,两个客户端同时写,容易损坏数据库。这是一个“设计层面就不该做”的事,SQLite定位是本地数据库,不要把网络共享上的SQLite文件当作多机共享数据库来用。
6. 完整案例分析:一个设备数据采集与查询程序
6.1 需求:从采集到展示
我做一个具体的案例帮你把前面所有知识串起来。场景很常见:
一个上位机程序,定时从一个传感器读取温湿度数据,存入SQLite,提供按时间范围查询和统计功能。
这个案例覆盖了连接管理、参数化写数据、事务批量插入、查询与统计。整套代码可以直接拆开用。
6.2 建库与初始化
using System; using Microsoft.Data.Sqlite; namespace SQLiteDemo { class Program { static string _baseDir = AppContext.BaseDirectory; static string _connStr = $"Data Source={Path.Combine(_baseDir, "sensor_data.db")}"; static void Main(string[] args) { InitializeDatabase(); // 模拟写入25条数据 InsertSensorData(25); // 查询最近5条 QueryLatest(5); // 统计平均值 AverageTemperature(); } static void InitializeDatabase() { using var conn = new SqliteConnection(_connStr); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = @" CREATE TABLE IF NOT EXISTS sensor_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, sensor_name TEXT NOT NULL, temperature REAL NOT NULL, humidity REAL NOT NULL, record_time TEXT NOT NULL )"; cmd.ExecuteNonQuery(); }这个初始化方法有几个细节:CREATE TABLE IF NOT EXISTS保证重复运行不报错;字段类型TEXT存储时间,匹配SQLite推荐做法;主键自增让插入更省心。
6.3 事务写入与模拟采集
static void InsertSensorData(int count) { using var conn = new SqliteConnection(_connStr); conn.Open(); using var transaction = conn.BeginTransaction(); using var cmd = conn.CreateCommand(); cmd.Transaction = transaction; cmd.CommandText = @" INSERT INTO sensor_records (sensor_name, temperature, humidity, record_time) VALUES ($name, $temp, $hum, $time)"; var rnd = new Random(); try { for (int i = 0; i < count; i++) { cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("$name", "sensor_a"); cmd.Parameters.AddWithValue("$temp", 18.5 + rnd.NextDouble() * 10); cmd.Parameters.AddWithValue("$hum", 40 + rnd.NextDouble() * 20); cmd.Parameters.AddWithValue("$time", DateTime.Now.AddMinutes(-count + i).ToString("yyyy-MM-dd HH:mm:ss")); cmd.ExecuteNonQuery(); } transaction.Commit(); Console.WriteLine($"成功写入 {count} 条记录"); } catch { transaction.Rollback(); throw; } }每次循环cmd.Parameters.Clear()再重新Add,是为了避免参数值残留,也避免参数集合无限膨胀。事务包裹确保25条要么全进、要么全不进。如果一次写入更多数据(上千条),推荐继续维持这个结构,只是把循环拆成几个批次,每批次一个事务,避免单个事务太大导致日志文件暴涨。
6.4 查询与统计展示
static void QueryLatest(int limit) { using var conn = new SqliteConnection(_connStr); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id, sensor_name, temperature, humidity, record_time FROM sensor_records ORDER BY id DESC LIMIT $limit"; cmd.Parameters.AddWithValue("$limit", limit); using var reader = cmd.ExecuteReader(); Console.WriteLine("--- 最近记录 ---"); while (reader.Read()) { var id = reader.GetInt32(0); var name = reader.GetString(1); var temp = reader.GetDouble(2); var hum = reader.GetDouble(3); var time = reader.GetString(4); Console.WriteLine($"#{id} | {name} | {temp:F1}°C | {hum:F1}% | {time}"); } } static void AverageTemperature() { using var conn = new SqliteConnection(_connStr); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT AVG(temperature), MIN(temperature), MAX(temperature), COUNT(*) FROM sensor_records"; using var reader = cmd.ExecuteReader(); if (reader.Read()) { Console.WriteLine($"平均温度: {reader.GetDouble(0):F2}°C"); Console.WriteLine($"最低温度: {reader.GetDouble(1):F2}°C"); Console.WriteLine($"最高温度: {reader.GetDouble(2):F2}°C"); Console.WriteLine($"总记录数: {reader.GetInt32(3)}"); } } } }注意ORDER BY id DESC LIMIT $limit这个写法,查询最近N条记录不用先全量加载再排序,高效直接。聚合函数AVG/MIN/MAX直接让SQLite算,数据再多也不用把明细拉回C#内存算。这套查法在上位机场景里非常够用,几分钟就能跑的秒级查询,SQLite完全吃得消。
7. 踩坑实录与效率提升心得
7.1 一个让我记忆犹新的索引问题
有一次同事反馈:设备记录表才几万条数据,查询一天的历史数据居然要两秒多。表结构很简单,就是时间字段record_time,但查询语句写的是:
WHERE record_time >= '2024-01-01 00:00:00' AND record_time <= '2024-01-01 23:59:59'没建索引,SQLite只能全表扫描。解决方案一句话:
CREATE INDEX idx_record_time ON sensor_records(record_time);加完再看,查询直接变成几十毫秒。索引是SQLite性能的第一课。表不大,不觉得;一旦数据量到十万级以上,索引和没索引,是两个世界的体验。注意索引不要建太多,它虽然加速查询,但也会拖慢写操作,权衡标准是“查询远多于写入的字段才值得建索引”。
7.2 可视化和权限管理的小习惯
用DB4S调试完,记得关闭它的连接再运行程序。DB4S默认打开数据库时会持有一个连接,有些模式下会把数据库锁住,程序要修改写数据时就会碰到“database is locked”。不是SQLite出问题,是工具占着茅坑。
另一个我养成的习惯:生产环境中,SQLite数据库文件所在目录一律单独建一个data子目录,并在部署脚本里预设写权限。这样数据库文件放在一个干净路径,程序日志、临时文件各放各的,排查问题也方便。这个习惯帮我避免过至少三起“目录权限不足导致程序静默失败”的事件。
7.3 版本升级与迁移
程序升级时,SQLite表结构往往要变。SQLite的ALTER TABLE能力有限,早期版本只支持改表名和加列,不支持直接删列、改约束。我当时做一个工具,需求是给一张老表加两列再加一个索引,方案是:
ALTER TABLE sensor_records ADD COLUMN alarm_level TEXT; ALTER TABLE sensor_records ADD COLUMN remark TEXT; CREATE INDEX IF NOT EXISTS idx_sensor_time ON sensor_records(record_time);加列是老表升级最高效、最不破坏性的操作。如果真要删列,就别偷懒:把目标表数据导到临时表,重建新结构,把数据导回来,改表名。DB4S的“修改表”功能能帮你自动完成这个Shuffle过程,但我建议你在代码里也写一个类似的迁移逻辑,不然生产环境的数据库迟早等你手撸SQL。
7.4 自增主键的“空隙”陷阱
INTEGER PRIMARY KEY AUTOINCREMENT会生成严格递增且不重复的主键,但删除一行后,这个ID不会被复用。有些业务会因此产生“为什么ID跳到100了,表里只有30行”的困惑。解决办法看需求:只是展示用,不用管;业务逻辑依赖连续ID,那就说明表设计得重新考虑,连续ID本来就不该作为主键依赖。
7.5 经验:把数据库当作一个日志文件
说句大实话,C#开发里SQLite最顺手的用法,就是把数据库当成一个“结构化的日志文件”。写入频繁,查询按时间切片,数据定期清理。用这个思路设计代码,逻辑最顺:一条数据一个Insert,一批数据一个事务,查询走索引,老数据定时DELETE。别想着在SQLite上搞复杂的关系模型,那是在违背它的天性。
8. 写在最后的几条个人经验
这条路走了不少弯,有几条经验是实实在在从生产环境的故障里换来的,再说一遍:
事务和短连接是SQLite稳定性的两块基石。每一个单条写入都裸奔没有事务包裹,出问题时数据半条不条,那是常态。反过来连接开在那里不关闭,文件锁慢慢失控,性能一路下滑,也是大家最容易忽略的慢性病。这两件事做对了,SQLite在本地应用里稳如老狗。
路径和权限,永远是SQLite应用搬上生产环境后最先爆炸的问题。开发机跑得好好的,部署就报错,十有八九是这里。把路径拼接打印出来、把权限确认好,能省掉一个通宵的排查时间。
参数化不是可选项,是必须项。它同时解决注入风险和类型保真两个问题,写任何SQLite命令时请无条件使用。
索引一定要建,但别乱建。查询性能翻倍是它,写入变慢也是它。建索引前先想清楚查询频率,别一股脑每个字段都建。
最后分享一个小技巧:上线前花十分钟用DB4S把数据库文件整个检查一遍,跑几个关键查询,确认表和索引都在,顺手备份一份文件。这个习惯让我避开过好几次“数据库文件损坏”“表结构没来得及建对”这种低级但致命的问题。SQLite用起来简单,但越简单的东西,越值得保持敬畏。