资讯动态

Unity SQLite C# 数据库命令封装与参数化查询实践

发布时间:2026/10/27 6:25:22 来源:尧图企业网站定制
一个玩家的昵称里带了个单引号存档写进数据库的时候整条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都能工作。这个细节看着小但它避免了团队里两种写法混用造成的隐性 bugprivate 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 ScalarT(string sql, params (string, object)[] args); // 单值 public ListT QueryT(string sql, FuncSqliteDataReader, T map, params (string, object)[] args); // 列表Execute返回受影响行数够用来判断UPDATE或DELETE是不是真的命中了记录。这一点经常被忽略更新一条不存在的记录SQL 不报错返回 0如果业务层不看返回值就会出现提示保存成功但数据没变的诡异现象。ScalarT需要处理查不到的情况。ExecuteScalar在无结果时返回null直接强转成值类型会抛NullReferenceException。封装里统一判断一下public T ScalarT(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判断。QueryT的三个参数里map是整个设计的关键它把怎么把一行变成一个对象的决定权交回给了调用方封装层不猜、不反射、不做约定。这个选择在下一节展开。3. 结果集到对象映射委托比反射更值得默认采用查询结果的映射是封装里最容易做过头的地方。很多人第一反应是写一个通用反射映射器按属性名匹配列名自动赋值。写完那一刻确实很爽QueryPlayer(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 Actionobject, object GetSetter(Type type, string propName) { return (Actionobject, 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.LambdaActionobject, 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(ActionSqlExecutor 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};Version3;); _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 IReadOnlyDictionarystring, string Parameters { get; } public long ElapsedMs { get; } public SqlExecutionException(string sql, IReadOnlyDictionarystring, 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 MeasureT(string sql, FuncT 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, IEnumerableobject[] 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 Scalarint(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 的项目多写点防御性检查和明确的异常信息是划算的。但无论哪种情况都不要让调用方猜这个封装层做了什么——方法名、参数、返回值、异常每一处都应该让人一眼看懂看不懂的地方就是后面埋雷的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑