☰
Unity SQLite C# 数据库命令封装与参数化查询实践
2026/10/27 6:25:15 网站建设 项目流程

一个玩家的昵称里带了个单引号,存档写进数据库的时候整条INSERT语句被撕成了两半,读取存档直接抛异常——这场景我在两个不同类型的项目里都遇到过。第一次是图省事用字符串拼 SQL,第二次是同事接手后按同样的路子又抄了一遍。所以当 Unity 接入 SQLite 这类本地数据库跑到第三篇的时候,真正该聊的已经不是"怎么把数据库打开",而是"C# 这一层该怎么封装 SQL 命令,才不至于让拼接字符串这种写法在每个脚本里重复出现"。这篇就围绕 Unity + SQLite 场景下的 C# 命令封装展开,把参数化查询、结果集映射、事务边界、异常定位、连接生命周期这几件事一次性讲透。适合已经能让数据库跑起来、但代码里还散落着一堆CommandText += ...的中级开发者,也适合想给小型项目做一套轻量数据层的人参考。

1. 封装之前先想清楚:裸写命令到底会烂在哪

动手写封装类之前,得先承认一件事——封装不是为了好看,是因为裸写SqliteCommand在真实项目里会以几种非常稳定的方式出问题。这些问题不是偶发的,只要项目活过三个月、参与的人超过两个,就一定会出现。所以先把病灶列清楚,后面每一段封装代码都能对应上它要解决的具体问题,而不是凭感觉堆 API。

1.1 三种反复出现的翻车现场

第一种,字符串拼接。这是最常见也最致命的。写法通常长这样:

var sql = "INSERT INTO player (name, level) VALUES ('" + name + "', " + level + ")";

看着能用,直到某个用户名里带了单引号,或者昵称里塞了分号加一段别的语句。轻则插入失败,重则整条语句被改写。有人会想到做转义,把单引号替换成两个单引号,这条路能挡住一部分问题,但挡不住数值类型被塞进非数字字符串、挡不住DateTime的格式在不同区域设置下变成不同样子。手工转义本质上是在用业务代码模拟数据库驱动本来就能做的事。

第二种,参数对象建了但没设值。这种情况在用了Parameters.Add之后很常见:参数顺序对了,名字拼错了,或者把null直接赋给了Value。SqliteParameter.Value = null会抛异常,必须用DBNull.Value。这个小坑我在早期项目里踩过,报错信息是一句很笼统的"参数无效",排查了半天才定位到某个可能为空的字段。后来我把null统一在封装层转成DBNull.Value,这类问题就再没出现过。

第三种,DataReader没关。SqliteDataReader持有底层语句句柄,只要它还没关闭,同一个连接上的后续操作就可能被挡住,报错通常是"database is locked"或者"another command is already in progress"。有人习惯在try里打开 reader,用完之后忘了关,异常一抛就彻底泄漏。这种问题的症状很迷惑——单次测试完全正常,跑久了才偶尔报锁,非常像并发问题,实际是资源没释放。封装层统一用using把 reader 圈起来,是成本最低的解法。

提示:SqliteConnection关闭时通常会连带清理挂起的命令,但依赖这个行为不如自己主动释放可靠,尤其在频繁开关连接的项目里。

1.2 封装层的职责边界:只做命令执行与结果映射

想清楚不做什么,比想清楚做什么更重要。我见过不少封装最后演变成半个 ORM:属性上加注解、自动建表、自动生成迁移、支持嵌套事务、支持延迟加载。等到需要改一个字段类型的时候,几乎要在三层抽象里来回跳。

我的建议是把这一层的职责压缩到两件事:把一条 SQL 和一组参数安全地送进数据库,以及把结果集里的行按调用方给的方式取出来。建表、迁移、索引优化这些,宁可单独写 SQL 脚本,运行时执行,也不要在封装层里搞声明式定义。理由很实在——SQLite 的能力上限不高,一旦封装层做得太厚,性能瓶颈会藏在抽象后面,你连哪句 SQL 慢都看不到。

一个合理的边界大概是这样:

应该做不应该做
参数化绑定、null转DBNull自动解析实体类型、生成建表语句
统一异常包装,保留 SQL 与参数吞掉异常返回默认值
事务开启与回滚自动判断该不该开事务
结果集到对象的映射委托缓存查询结果做内存数据库
慢查询计时与日志重写 SQL 做"优化"

这个边界定下来之后,类的数量控制在两三个就够:一个连接持有者,一个执行器,一个可选的映射缓存。后续所有代码都围绕这三块展开。

2. 让参数化成为唯一路径,而不是可选项

封装层最该强制的一点是:没有拼接这条路。只要留了一个接受原始 SQL 字符串再加可变参数的入口,就一定有人图方便绕过参数化。所以方法签名本身就要把"参数化"变成一个不得不走的过程,而不是一个可选建议。

2.1 参数占位符的命名与自动补前缀

不同 SQLite 适配库对占位符的支持略有差异。System.Data.SQLite和Mono.Data.Sqlite都支持命名参数,形如@name,也都支持:name和$name这类风格。SQLite 原生还支持?这种按位置匹配的写法,但按位置匹配在参数超过三个之后极易出错,改一行 SQL 就要重新数一遍参数顺序,后期维护成本很高。

我倾向于统一成@name,并且在封装层做一次"自动补前缀"的处理——调用方传name还是传@name都能工作。这个细节看着小,但它避免了团队里两种写法混用造成的隐性 bug:

private static string NormalizeParameterName(string name) { if (string.IsNullOrEmpty(name)) throw new ArgumentException("参数名不能为空", nameof(name)); return name[0] == '@' || name[0] == ':' || name[0] == '$' ? name : "@" + name; }

不强制统一前缀,唯一的风险是同一个 SQL 里出现@id和id两种写法时,驱动会认为是两个不同参数,报"缺少参数"的错误,而错误信息往往只说缺了哪个名字,不说为什么名字对不上。封装层吞掉这个不确定性,成本几乎为零。

2.2 参数绑定封装:null、枚举、DateTime 的集中处理

参数值到SqliteParameter.Value之间,有一段必须集中处理的转换逻辑。散落在业务代码里,每个地方都要写一遍x == null ? DBNull.Value : x,而且很容易漏。

private static void BindParameters(SqliteCommand cmd, (string name, object value)[] args) { if (args == null || args.Length == 0) return; foreach (var (rawName, rawValue) in args) { var p = cmd.CreateParameter(); p.ParameterName = NormalizeParameterName(rawName); p.Value = ConvertToDbValue(rawValue); cmd.Parameters.Add(p); } } private static object ConvertToDbValue(object value) { if (value == null) return DBNull.Value; if (value is Enum e) return Convert.ToInt64(e); if (value is bool b) return b ? 1L : 0L; if (value is DateTime dt) return dt.ToString("yyyy-MM-dd HH:mm:ss.fff"); if (value is Guid g) return g.ToString("N"); return value; }

这里每一行都对应一个坑。枚举如果不转成整数,某些驱动会尝试按字符串写入,读回来就变成了文本,后续WHERE比较全部失效。bool在 SQLite 里没有原生类型,存成 0/1 是通用做法,但读回来的时候需要显式转换,这一点后面映射那节会说。DateTime统一成固定格式的字符串,是为了绕开不同库版本默认格式不一致的问题——用ToString("o")也能存,但那串带T和时区后缀的文本,在 SQL 里做范围查询时字符串比较的结果会和你预期不同。

Guid我用的是N格式(32 位无连字符),比带连字符的短,索引体积小一点,代价是可读性差。如果你的表需要人工查数据,用默认格式也完全可以,只要全项目统一就行,最怕的是插入用N、查询用默认格式,那就永远查不到。

2.3 执行入口的三个方法就够

一个执行器对外暴露的方法不需要多。我通常只留三个:

public int Execute(string sql, params (string, object)[] args); // 增删改 public T Scalar<T>(string sql, params (string, object)[] args); // 单值 public List<T> Query<T>(string sql, Func<SqliteDataReader, T> map, params (string, object)[] args); // 列表

Execute返回受影响行数,够用来判断UPDATE或DELETE是不是真的命中了记录。这一点经常被忽略:更新一条不存在的记录,SQL 不报错,返回 0,如果业务层不看返回值,就会出现"提示保存成功但数据没变"的诡异现象。

Scalar<T>需要处理"查不到"的情况。ExecuteScalar在无结果时返回null,直接强转成值类型会抛NullReferenceException。封装里统一判断一下:

public T Scalar<T>(string sql, params (string, object)[] args) { using (var cmd = CreateCommand(sql, args)) { var raw = cmd.ExecuteScalar(); if (raw == null || raw == DBNull.Value) return default(T); return (T)Convert.ChangeType(raw, typeof(T)); } }

Convert.ChangeType这行在类型不匹配时会抛InvalidCastException,比强转抛的异常信息更清楚一点。但要注意它处理不了T为可空类型的情况,Convert.ChangeType对int?会失败,需要先取Nullable.GetUnderlyingType。这个细节在封装阶段处理好,后面业务代码里就少一堆HasValue判断。

Query<T>的三个参数里,map是整个设计的关键,它把"怎么把一行变成一个对象"的决定权交回给了调用方,封装层不猜、不反射、不做约定。这个选择在下一节展开。

3. 结果集到对象:映射委托比反射更值得默认采用

查询结果的映射是封装里最容易做过头的地方。很多人第一反应是写一个通用反射映射器,按属性名匹配列名,自动赋值。写完那一刻确实很爽,Query<Player>("select * from player")一行搞定。但项目跑到后期,select *加反射的组合会变成性能和安全上的双重负担,而且字段改名的时候编译器完全不报错,只能靠运行时。

3.1 手写映射委托的真实成本

先看手写映射长什么样:

public static Player MapPlayer(SqliteDataReader r) { return new Player { Id = r.GetInt32(0), Name = r.GetString(1), Level = r.GetInt32(2), Gold = r.GetInt64(3), LastLogin= DateTime.Parse(r.GetString(4)), IsActive = r.GetInt64(5) != 0 }; }

吵,但直白。写一个映射大概三十秒,改一个字段大概十秒,性能接近理论上限,出错会在第一时间抛在明确的行上。一个中等规模的游戏项目,实体类型撑死几十个,全部手写也就一个下午的事。而反射映射省下的这一个下午,后面往往要用几倍的时间去填坑:列名改了没同步、某个字段读出来类型不对、大批量查询时反射开销把帧率拖下去。

我的实际做法是折中:默认走手写委托,但提供一个基于列名的取值辅助,让手写代码不至于太难看。

public static class ReaderExt { public static int GetInt(this SqliteDataReader r, string col) => r.IsDBNull(r.GetOrdinal(col)) ? 0 : r.GetInt32(r.GetOrdinal(col)); public static string GetStr(this SqliteDataReader r, string col) => r.IsDBNull(r.GetOrdinal(col)) ? null : r.GetString(r.GetOrdinal(col)); public static DateTime GetDate(this SqliteDataReader r, string col) { var s = r.GetStr(col); return string.IsNullOrEmpty(s) ? default : DateTime.Parse(s); } }

用列名取值比用序号可读性好得多,代价是每次调用GetOrdinal都要做一次列名查找。这个开销在单次查询里可以忽略,但在万行级别的遍历里会累积。解决办法很简单:在映射委托外面把序号算好,或者干脆接受这点开销——实测下来,一万行数据、六列的查询,GetOrdinal带来的额外耗时在几十毫秒量级,和磁盘 IO 比起来不算主要矛盾。

3.2 反射映射要做就带上委托缓存

如果确实需要通用映射(比如写工具、写调试面板),反射可以留,但一定要把属性访问器缓存成委托,别每次都走PropertyInfo.SetValue。

private static readonly ConcurrentDictionary<(Type, string), Delegate> SetterCache = new ConcurrentDictionary<(Type, string), Delegate>(); private static Action<object, object> GetSetter(Type type, string propName) { return (Action<object, object>)SetterCache.GetOrAdd((type, propName), key => { var prop = key.Item1.GetProperty(key.Item2, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); if (prop == null || !prop.CanWrite) return null; var objParam = Expression.Parameter(typeof(object), "o"); var valParam = Expression.Parameter(typeof(object), "v"); var castObj = Expression.Convert(objParam, key.Item1); var castVal = Expression.Convert(valParam, prop.PropertyType); var assign = Expression.Call(castObj, prop.GetSetMethod(), castVal); return Expression.Lambda<Action<object, object>>(assign, objParam, valParam).Compile(); }); }

缓存之后的委托调用耗时和直接赋值在同一个量级,第一次编译的代价在几十微秒到几百微秒之间,之后都是纯调用。这个改造能让反射映射在常规查询里不再是瓶颈,但它依然解决不了"列名和属性名对不上时静默失败"的问题。所以我在用反射映射时一定会加一条:映射结束后检查有多少列没被消费,数量不对就抛异常。宁可炸,也不要静默丢数据。

3.3 类型读回来不对?先做一次往返自测

SQLite 是动态类型,列上声明的类型只是"亲和性"提示,实际存进去什么类型,读出来就偏向什么类型。这个特性和大多数关系型数据库不一样,也是从别的数据库迁过来的人最容易栽跟头的地方。

我习惯在项目早期写一个往返测试,把常用类型各存一遍读回来,比较值是否一致:

[Test] public void RoundTrip_CommonTypes() { var db = new SqlExecutor(":memory:"); db.Execute(@"CREATE TABLE t ( id INTEGER PRIMARY KEY, i INTEGER, s TEXT, d TEXT, b INTEGER, g TEXT, blob BLOB, num REAL)"); var now = new DateTime(2025, 3, 14, 9, 26, 53, 589); db.Execute("INSERT INTO t (i,s,d,b,g,blob,num) VALUES (@i,@s,@d,@b,@g,@blob,@num)", ("i", 42), ("s", "带'引号的名字"), ("d", now.ToString("yyyy-MM-dd HH:mm:ss.fff")), ("b", true), ("g", Guid.NewGuid().ToString("N")), ("blob", new byte[]{1,2,3}), ("num", 3.14m)); db.Query("SELECT * FROM t", r => { Assert.AreEqual(42, r.GetInt32(1)); Assert.AreEqual("带'引号的名字", r.GetString(2)); Assert.AreEqual(now, DateTime.Parse(r.GetString(3))); Assert.AreEqual(true, r.GetInt64(4) != 0); Assert.AreEqual(3, ((byte[])r.GetValue(6)).Length); return 0; }); }

这个测试的价值不在于通过,而在于它一旦不通过,你能立刻知道是哪一列、哪个类型出了问题,而不是等到上线之后从一堆业务日志里反推。我最开始做的时候就是因为decimal读回来变成了double,精度在第七位之后丢了一点,写进存档的金币数偶尔差个位数,查了两天才定位到。

几个固定结论:decimal在Mono.Data.Sqlite里通常被当成REAL或TEXT处理,具体看版本,涉及金额或必须精确的数值,建议直接存整数分,或者存成定长字符串自己解析;DateTime一律走固定格式字符串,别依赖驱动的自动转换;bool存 0/1 读时显式判断;byte[]走BLOB没问题,但要注意大对象会让单行记录膨胀,超过几十 KB 的二进制数据考虑拆表或存文件路径。

4. 事务与连接:性能差距往往藏在这一层

单条 SQL 跑得快不快,和一万条 SQL 跑得快不快,是两码事。前者你几乎感觉不到差异,后者能让一个操作卡住好几秒。差距的主要来源不是 SQL 本身,而是每次写入触发的磁盘同步,以及连接的开关次数。

4.1 逐条提交为什么会慢一个数量级

SQLite 默认每次INSERT都是一个隐式事务,事务提交时要保证数据落到磁盘(具体行为取决于synchronous设置)。这意味着写一千条记录,就要做一千次磁盘同步动作。机械盘上一次同步在毫秒级,SSD 上也好不到完全忽略,累加起来就是秒级的差别。

放进一个显式事务里,一千条写入只做一次同步,耗时会掉到原来的几分之一甚至更低。我用一个简单的批量插入做过粗略测试,环境是普通笔记本 SSD,表结构五列,纯文本和整数混合:

写入方式1000 条耗时5000 条耗时
逐条 Execute,无事务约 1200 ms约 6000 ms
单事务包裹约 90 ms约 400 ms
单事务 + 预编译命令复用约 60 ms约 260 ms

数字只能看量级,不同机器差异很大,但比例关系是稳定的——事务包裹带来的提升是一个数量级,命令复用再叠加一层。所以只要涉及批量写,事务不是"要不要用"的问题,而是"必须用"。

4.2 事务封装:让它难以被忘记

事务的常见错误用法是把BeginTransaction写在业务代码里,然后某条路径忘了Commit或者忘了Rollback,连接上就挂着一个未完成的事务,后续所有写入都卡住。更隐蔽的是异常路径:try里抛了异常,跳到catch只记了日志没回滚,事务就这么挂着。

封装成"传一个委托进来"的形式,可以把这个风险压到最低:

public void InTransaction(Action<SqlExecutor> body) { if (body == null) throw new ArgumentNullException(nameof(body)); if (_txDepth > 0) { // 嵌套:用保存点,避免内层提交毁掉外层 var sp = "sp_" + (++_spCounter); ExecuteRaw($"SAVEPOINT {sp}"); _txDepth++; try { body(this); ExecuteRaw($"RELEASE {sp}"); } catch { ExecuteRaw($"ROLLBACK TO {sp}"); throw; } finally { _txDepth--; } return; } using (var tx = _conn.BeginTransaction()) { _txDepth = 1; try { body(this); tx.Commit(); } catch { try { tx.Rollback(); } catch { /* 连接已断时回滚会失败,忽略 */ } throw; } finally { _txDepth = 0; } } }

嵌套这块值得多说一句。很多人以为 SQLite 不支持嵌套事务就直接禁止嵌套,结果是上层开了事务,下层某个公共方法又开一次,直接抛"事务已在进行中"。用保存点模拟嵌套,语义上是正确的:内层失败只回滚内层,外层可以选择继续或者整体放弃。代价是保存点的名字要唯一,不能叫固定的sp1,否则并发或者递归调用时会撞名。

那个catch { }空捕获也值得解释一下。回滚本身可能失败,比如连接已经被关闭或者磁盘出问题。这时候再抛一个回滚异常,会把原始的业务异常盖掉,排查时看到的是一个莫名其妙的"连接未打开",真正的问题反而被埋了。所以回滚失败只记录,不抛。

4.3 连接持有方式与线程边界

连接该开一次还是每次开,这个问题在 Unity 里答案比较明确:长连接 + 单线程访问。原因有两条。第一,SQLite 打开文件的成本不低,频繁开关在移动端上会造成可感知的卡顿,尤其是冷启动阶段。第二,SQLite 的连接对象本身不是线程安全的,跨线程共享连接会出各种难以复现的问题。

Unity 的实际情况是大部分逻辑在主线层跑,但资源加载、网络回调可能在别的线程。稳妥的做法是给每个线程一个独立的执行器实例,或者干脆把数据库操作全部收敛到主线程的一个队列里执行。后者更简单,也更容易保证顺序性——毕竟事务和写入顺序本来就是有依赖的。

public sealed class SqlExecutor : IDisposable { private readonly SqliteConnection _conn; private readonly int _ownerThreadId; private int _txDepth; private int _spCounter; public SqlExecutor(string dbPath) { _conn = new SqliteConnection($"Data Source={dbPath};Version=3;"); _conn.Open(); _ownerThreadId = Thread.CurrentThread.ManagedThreadId; } private void EnsureThread() { if (Thread.CurrentThread.ManagedThreadId != _ownerThreadId) throw new InvalidOperationException("SqlExecutor 只能在创建它的线程上使用"); } }

这个EnsureThread看着像是多余的防御,但它能在问题发生的瞬间给出明确报错,而不是让错误以"database is locked"的形式在几小时后浮现。加上这几行之后,团队里再没人误用跨线程访问,因为一用就炸,炸得很清楚。

5. 报错时能定位到具体那条 SQL,才算封装到位

封装层最容易犯的错,是把底层异常吞掉然后往上抛一个通用的"数据库操作失败"。这条信息对排查毫无帮助——你不知道是哪句 SQL、哪组参数、在什么上下文里出的问题。等到线上出问题,只能靠猜。

5.1 包装异常时该保留什么

我的做法是自定义一个异常类型,把原始 SQL、参数名与值的快照、耗时都带上,同时保留InnerException。

public sealed class SqlExecutionException : Exception { public string Sql { get; } public IReadOnlyDictionary<string, string> Parameters { get; } public long ElapsedMs { get; } public SqlExecutionException(string sql, IReadOnlyDictionary<string, string> ps, long elapsedMs, Exception inner) : base($"SQL 执行失败({elapsedMs}ms): {Truncate(sql, 300)}", inner) { Sql = sql; Parameters = ps; ElapsedMs = elapsedMs; } }

参数快照要序列化成字符串保存,不能直接存object引用——因为在异常被记录的这一刻到最终被读取的这段时间里,对象可能已经被改了,日志里会看到和当时完全不同的值。这个坑我踩过一次:一个列表对象当参传入,异常日志打印出来是空的,因为抛出之后业务代码把列表清空了。

参数值的记录还需要做长度截断。BLOB参数动辄几百 KB,全部写进日志会让日志文件瞬间膨胀,而且没什么信息量。截断到前几十个字符加个总长度提示就够了。

5.2 慢查询计时:比错误日志更早发现问题

错误日志只能抓住"炸了"的情况,慢查询能抓住"快炸了"的情况。在Execute和Query外面套一层计时,超过阈值就输出一条警告,这个成本极低,收益很高。

private T Measure<T>(string sql, Func<T> action) { var sw = Stopwatch.StartNew(); try { return action(); } finally { sw.Stop(); if (sw.ElapsedMilliseconds > SlowThresholdMs) Debug.LogWarning($"[SQL慢查询] {sw.ElapsedMilliseconds}ms | {Summarize(sql)}"); } }

阈值我一般设 50 毫秒。本地数据库的常规查询应该是个位数毫秒,超过 50 基本能说明有问题:可能是缺索引,可能是select *拉了一堆用不上的列,也可能是全表扫描。开发期把这些警告当成待办清单清掉,能省下不少后期的性能优化时间。

Summarize这一步要做的是归一化:把 SQL 里的参数占位符保留,把空格和换行压平,这样同一类查询的慢日志能聚合在一起看。如果不去归一化,日志里会出现几十条长得几乎一样、只是换行位置不同的记录,完全没法统计。

提示:日志里打印参数值时,涉及玩家昵称、账号之类的字段建议统一替换成占位符,只保留长度和类型。开发期无所谓,正式包里留一份带真实内容的日志文件不是好习惯。

6. 几个实测下来真正影响手感的细节

前面几节搭出了骨架,剩下的是一些用起来才发现的小地方。它们单独看都不起眼,但组合起来决定了这套封装是"能用"还是"用着舒服"。

6.1 命令对象的复用

CreateCommand本身有分配开销,参数集合每次也要重建。批量写入场景里,把同一个SqliteCommand复用起来,只改参数值、反复执行,能省掉这部分开销,这也是前面性能表里最后一行的来源。

public void BulkInsert(string sql, IEnumerable<object[]> rows) { using (var cmd = _conn.CreateCommand()) { cmd.CommandText = sql; cmd.Prepare(); var count = cmd.Parameters.Count; foreach (var row in rows) { for (int i = 0; i < count; i++) cmd.Parameters[i].Value = ConvertToDbValue(row[i]); cmd.ExecuteNonQuery(); } } }

注意cmd.Prepare()这一步。它会让 SQLite 提前编译语句,后续执行只做参数绑定和执行。对循环内执行的语句效果明显,但只执行一次的语句没必要Prepare,编译开销反而白花。判断标准很简单:同一个CommandText是否会被执行多次。

还有一个容易忽略的地方:参数集合建好之后,参数顺序必须和 SQL 里第一次解析出来的顺序一致。用命名参数时驱动内部会做匹配,但复用命令对象时改 SQL 文本是个危险操作,参数集合不会自动跟着更新。我的做法是复用期间绝不动CommandText,要换 SQL 就新建命令。

6.2 建库时顺手设的几个 PRAGMA

有几个 PRAGMA 设置,在打开连接之后立刻执行,对写入性能和并发读有实打实的帮助。它们不是必须的,但在移动端和中等数据量下收益明显。

PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;

journal_mode = WAL让读写不再互相阻塞,对"一个线程写、另一个线程读"的场景帮助很大。它会在数据库旁边多出一个-wal文件,这是正常现象,别看到多出文件就以为是残留。synchronous = NORMAL在 WAL 模式下是常见的性能取舍,比FULL快不少,代价是极端断电情况下可能丢最后几条提交——对本地游戏存档这类场景,我觉得可以接受,但如果涉及不可重现的关键数据,还是保守一点用FULL。

foreign_keys = ON一定要显式打开。SQLite 默认不开外键约束,这个默认值坑过太多人:建表时写了REFERENCES,以为有约束,实际插入一条孤儿记录毫无阻力,直到某次关联查询返回空才发现数据早就脏了。

busy_timeout是给多线程或多进程访问留的缓冲。默认值 0 意味着一旦遇到锁就立刻报错,设成几秒会让驱动自动重试,能挡掉一批偶发的database is locked。这个设置不能解决锁竞争的根本问题,但能把"偶发失败"变成"稍微等一下就好"。

6.3 表结构变更时的兼容处理

封装层稳定之后,会面临一个很实际的问题:版本升级时表结构变了怎么办。SQLite 对ALTER TABLE的支持有限,加列可以,删列和改类型在旧版本上不支持。稳妥的方案是"新建表 + 数据迁移 + 改名",用一个版本号控制整个过程。

private void Migrate() { var ver = Scalar<int>("PRAGMA user_version"); if (ver >= CurrentSchemaVersion) return; InTransaction(db => { if (ver < 1) { db.Execute("CREATE TABLE IF NOT EXISTS player (id INTEGER PRIMARY KEY, name TEXT)"); } if (ver < 2) { db.Execute("ALTER TABLE player ADD COLUMN level INTEGER DEFAULT 1"); } db.Execute($"PRAGMA user_version = {CurrentSchemaVersion}"); }); }

PRAGMA user_version是 SQLite 自带的一个整数存储位,专门留给使用方记录自己的版本号,不用额外建表。整个迁移过程放进一个事务里,中途失败就整体回滚,不会留下半新半旧的结构。这个机制我建议从项目第一天就加上,哪怕当前版本号一直是 1——等到真需要改结构的时候再加,你会发现线上已经有一堆旧版本数据库,处理起来麻烦得多。

7. 最后说说我自己的取舍

这套封装我在几个规模不大的项目里反复改过,最终稳定下来的形态比最初设想的要"薄"很多:一个执行器类大概三百行,映射全部手写,事务只有一个入口,日志只保留慢查询和异常两条路径。中间也试过做得更重,加过实体注解、自动建表、查询表达式树,最后都删掉了,原因是每次排查问题都要在抽象层之间跳,反而更慢。

有一条经验我觉得值得单独拎出来:封装层的代码量应该和团队的规模成正比,而不是和项目的想象空间成正比。一个人维护的项目,两百行足够;五个人协作、需要 code review 的项目,多写点防御性检查和明确的异常信息是划算的。但无论哪种情况,都不要让调用方"猜"这个封装层做了什么——方法名、参数、返回值、异常,每一处都应该让人一眼看懂,看不懂的地方就是后面埋雷的地方。

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

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

立即咨询