资讯动态

Unity项目C#封装SQLite SQL命令:参数绑定与事务实践

发布时间:2026/10/27 22:33:27 来源:尧图企业网站定制
Unity 项目里接 SQLite第一件事通常是把驱动跑通第二件事是把表建出来第三件事——也是最容易被拖到最后、又最影响后面所有代码的一件事——就是在 C# 层把 SQL 命令封一层。我在好几个项目里反复经历过这个过程一开始图快业务脚本里到处 new 一个命令对象、到处拼字符串等到要加字段、改查询条件、或者排查一次莫名其妙的锁库问题时才发现散落在几十个文件里的 SQL 已经没人敢动了。这篇文章聊的就是我最后沉淀下来的那套 C# 封装 SQL 命令的思路它该管什么、不该管什么核心代码长什么样参数怎么绑事务怎么包以及那些只有真正在设备上跑过才会撞见的坑。不管你是刚把数据库连上的新手还是已经写了几十个查询想回头重构的老手下面这些内容都能直接抄。1. 封装之前先定边界这层到底该管什么很多人一上来就写代码写到一半发现这层封装既不像工具类也不像数据访问层职责混在一起最后比不封装还难维护。所以在动手敲第一行之前先把边界想清楚比写代码本身更重要。1.1 业务代码里直接写 SQL 的四种翻车现场我把这些年见过的、自己也犯过的问题归了归类基本逃不出四种。第一种是字符串拼接带来的注入与转义问题玩家昵称里带一个单引号插入语句当场报语法错误如果拼接的是查询条件那更是把数据安全完全交出去了。第二种是连接与命令对象没人释放IDisposable写了但从来没 using跑久了句柄堆积最后表现成偶发的打开失败。第三种是同一条 SQL 在五个地方各写一遍改表结构时漏改两处运行时报列不存在。第四种最隐蔽——事务缺失导致批量写入奇慢一千条数据写了七八秒新人以为是设备性能差实际上是每条 INSERT 都单独提交了一次。这四类问题的共同点是它们都不是 SQL 语法问题而是调用方式问题。也就是说靠写 SQL 的时候小心一点是解决不了的必须在调用层做约束。这就是封装存在的理由。1.2 封装层的三条职责与三条不该管的事我的划分方式比较土但好用。这层该管三件事统一连接与释放保证任何路径下资源都能回收、统一参数绑定杜绝拼接把类型转换收口到一处、统一异常与事务出错能回滚日志能定位。这三件事是横切的散在业务里必然重复收上来才有价值。不该管的三件事也得说清楚。第一不做 SQL 生成器别搞什么链式Where().OrderBy()拼语句那是在重新发明 ORM复杂度爆炸而收益有限尤其在没有完整生态支持的运行环境里更容易出问题。第二不做业务校验参数合法性是上层的事这层只负责安全地把值送到数据库。第三不做缓存缓存策略跟业务强相关硬塞进来只会让失效逻辑变得不可预测。把这三条守住封装层通常能控制在几百行以内谁都能读懂。1.3 三种封装粒度的取舍实际可选的路子大概三条我做了个对比方案代码量上手成本适合场景主要代价静态工具类一堆 static 方法最少极低小工具、单表配置、原型验证全局状态、难测试、并发下易出问题实例化执行器本文主推中等低绝大多数游戏/应用的数据层需要管理实例归属仓储层 实体映射最多中等偏高表多、关系复杂、长期维护前期投入大容易过度设计我最终选的是第二条也就是一个可实例化的执行器对象配合可选的仓储层包在外面。原因很直接静态类在小项目里爽但一旦有两个模块同时写库你就得靠全局锁来兜底而完整仓储层在中小规模项目里是明显的过度设计光是把实体映射的样板代码写完可能比业务本身还长。实例化执行器刚好卡在中间——作用域清晰用完就扔并发问题从全局降级成局部排查范围小了一个数量级。2. 三件套设计连接、命令、参数封装的核心其实就是把连接、命令、参数这三样东西管好。听起来简单但每一件都有几个必须提前想明白的决策点。2.1 连接的生命周期短连接、长连接与线程边界先说连接。常见做法有两种一种是一次操作开一个连接用完就关另一种是全局持有一个长连接。短连接的好处是状态干净不用担心残留的事务或未关闭的读取器影响后续操作缺点是频繁打开有开销——不过对本地文件型数据库来说这个开销远没有想象中大打开一个本地库文件通常就是毫秒级。长连接的好处是省掉反复打开而且在需要开启 WAL 之类模式时只需要设一次缺点是一旦某个环节忘了释放读取器整条连接就被占住后面所有操作都会卡或者报database is locked。我自己的选择是主线程上持有一个长连接配合严格的作用域约束跨线程的操作各自开短连接。这么定的理由是游戏里绝大多数数据访问都集中在主线程的加载、存档、UI 刷新这几个时机用一个长连接最省事而像后台批量导入这种任务单独开连接更安全。这里有一条硬规矩必须写在代码注释里同一个连接对象绝不允许被两个线程同时使用托管层不保证这个安全性出了问题表现是随机的数据错乱极难复现。还有一个容易被忽略的细节——连接打开之后要立刻设置几个参数其中最关键的是忙等待超时。本地库在多连接场景下写锁冲突是常态如果超时设得太短稍微并发一下就直接抛异常设成几秒比较稳妥。public void Open() { if (_conn ! null _conn.State ConnectionState.Open) return; _conn new SQLiteConnection($Data Source{_dbPath};Version3;); _conn.Open(); // 写锁冲突时最多等待 3 秒再报错而不是立刻失败 Execute(PRAGMA busy_timeout 3000;); // 每次事务提交都完整落盘牺牲一点速度换断电安全 Execute(PRAGMA synchronous FULL;); }2.2 命令对象的复用与释放时机命令对象是这层里最容易被滥用的东西。有人图省事把命令对象也做成成员变量缓存起来结果参数越绑越多上一次查询的残留参数还在下一次执行时莫名其妙带进去一个值。正确的做法是命令对象跟着单次调用走——每次执行创建、执行完释放用 using 包住。它的创建成本远低于连接没必要省这一下。真正值得复用的情况只有一种同一条 SQL 在循环里被执行成千上万次这时候才值得提前准备语句把编译阶段的开销摊薄。但即便如此也应该用同一个命令对象重复设置参数值而不是重建。释放顺序上有个小陷阱读取器必须先于命令关闭命令必须先于连接关闭。反过来的话某些实现下会留下未释放的原生句柄。我在代码里干脆把它写死成固定顺序不允许调用方自己决定。private int ExecuteInternal(string sql, SqlParam[] args) { using (var cmd _conn.CreateCommand()) { cmd.CommandText sql; BindParams(cmd, args); return cmd.ExecuteNonQuery(); } // 命令在这里必然被释放无论是否抛异常 }2.3 参数化绑定把类型转换收口到一处参数化是整个封装里价值最高的部分没有之一。它同时解决了注入、转义、类型三个问题。但要用好得先定义清楚这个项目里哪些 C# 类型可以进数据库、分别怎么存。我维护的映射规则大致是这样整数、浮点直接走原生类型布尔存 0/1时间存 ISO 格式的文本yyyy-MM-dd HH:mm:ss.fff原因是可读性好、字符串比较天然就是时间顺序排查问题时打开库文件一眼就能看懂比存 Unix 时间戳省心得多代价是稍微占点空间二进制直接走 BLOB可空类型统一在绑定层判断为 null 时送 DBNull。最后这条特别重要。很多新手直接传 null 进参数某些实现会抛类型转换异常某些会静默写成一个空字符串行为不一致。统一在绑定层拦一道把 null 全部转成DBNull.Value这个坑就一次性堵死了。private static void BindParams(SQLiteCommand cmd, SqlParam[] args) { if (args null) return; foreach (var p in args) { object v p.Value; if (v null) v DBNull.Value; else if (v is bool b) v b ? 1 : 0; else if (v is DateTime dt) v dt.ToString(yyyy-MM-dd HH:mm:ss.fff); else if (v is Enum e) v Convert.ToInt32(e); cmd.Parameters.AddWithValue(p.Name, v); } }SqlParam本身我不做成类而是用一个轻量的只读结构体配合几个静态工厂方法调用处写起来短public readonly struct SqlParam { public readonly string Name; public readonly object Value; public SqlParam(string name, object value) { Name name; Value value; } public static SqlParam Of(string name, object value) new SqlParam(name, value); }还有一类东西参数化解决不了就是表名、列名、排序方向。这些不是值是标识符占位符绑不进去。对于这种场景绝不能直接拼业务传来的字符串必须走白名单把允许的表名列名写成一个集合传入的字符串必须在集合里否则直接拒绝执行。这条规则我在每个项目里都写因为总有人会想反正表名是内部定的然后某天它就从配置表里读出来了。3. 核心代码落地执行器、映射与事务前面是设计这一节是具体怎么把它写出来。执行器我按一个入口、三种出口来组织读起来清爽用起来也统一。3.1 三种执行入口的统一写法不管上层要干什么落到数据库上只有三种动作不返回结果的写操作、返回单个值的查询、返回多行结果集的查询。我把它们做成三个方法签名统一都是SQL 参数数组public int Execute(string sql, params SqlParam[] args); // 影响行数 public object Scalar(string sql, params SqlParam[] args); // 首行首列 public ListT QueryT(string sql, FuncIDataReader, T map, params SqlParam[] args); // 结果集统一成这个形状有两个好处。第一调用处的写法高度一致代码审阅时一眼就能看出这条 SQL 是读还是写。第二内部实现可以抽一个公共的打开校验 参数绑定 异常包装流程三个方法各自只负责最后那一下调用。异常包装这一步我会顺手做掉把原始异常里的 SQL 语句和参数名附在消息里抛一个自定义异常出去。这样线上日志里直接能看到是哪条语句出的问题不用再回去翻代码猜。Scalar这里有个细节要处理如果没有结果行ExecuteScalar返回 null但很多业务代码把它当数字用直接强转就崩了。我在封装里统一转成返回 null 由调用方判断并且约定计数类查询用Convert.ToInt32(Scalar(...) ?? 0)的写法把它变成肌肉记忆。3.2 结果集到对象的映射策略读结果集有两条路。一条是手写映射调用时传一个委托进来把读取器变成对象另一条是反射自动映射按字段名匹配属性。我默认用手写。理由是在这个运行环境下反射映射有两个现实问题一是性能几千行数据的列表遍历反射赋值开销比手写高一个量级二是裁剪与 AOT 编译下的保留问题反射用到的属性和构造函数有可能被裁掉打包后表现为编辑器里好好的出包就报找不到成员。手写映射虽然啰嗦但每一行都在明面上出问题一眼能看出是哪一列没对上。public ListPlayerData LoadAllPlayers() { const string sql SELECT id, name, level, last_login FROM player ORDER BY level DESC;; return Db.Query(sql, r new PlayerData { Id r.GetInt32(0), Name r.GetString(1), Level r.GetInt32(2), LastLogin DateTime.Parse(r.GetString(3)) }); }注意这里用的是列序号而不是列名。列名查找要走一次哈希而且一旦 SELECT 里用了别名或者拼接名字对不上就直接运行时报错。用序号的前提是 SELECT 列表固定这一点在封装里靠约定保证——所有查询都显式列出字段禁止SELECT *。这条约定看起来像洁癖实际上它同时解决了三个问题列顺序稳定、表结构变更时能立刻发现、不会多读用不上的大字段。如果表特别多、实体特别杂手写映射的样板代码确实会累人这时候可以上一个轻量的反射映射作为补充但要满足两个条件映射结果缓存起来用字典按类型存属性列表别每次都反射一遍以及打好保留配置防止被裁剪。两者都做到这条路才能跑稳。3.3 事务封装的正确姿势事务是性能与一致性两条线的交汇点也是封装里最值得下功夫的地方。我的做法是提供一个把一段操作包进事务的方法调用方只需要把要执行的语句传进来public void InTransaction(ActionDbExecutor body) { using (var tran _conn.BeginTransaction()) { try { body(this); tran.Commit(); } catch { tran.Rollback(); throw; } } }用起来是这样的Db.InTransaction(db { foreach (var item in batch) db.Execute(INSERT INTO item(id, count) VALUES(id, c);, SqlParam.Of(id, item.Id), SqlParam.Of(c, item.Count)); });这里必须提一个我实测过的量级差距同一批几千条插入如果逐条提交每一条都要触发一次落盘机械硬盘上跑十几秒很常见包进一个事务之后整个批次只在提交时落一次盘时间通常能压到一个数量级以下。这不是玄学是提交语义决定的。所以我在封装里定了一条规矩循环里出现写操作必须包事务代码审阅时看到循环里裸调Execute就打回。事务还有两个容易踩的地方。第一是嵌套有些实现不支持真正的嵌套事务外层开完内层再开就会报错。我的处理方式很简单——不支持嵌套封装层直接加一个是否已在事务中的标记检测到嵌套调用就抛出明确异常而不是让它静默失败。第二是异常吞掉catch里只打日志不throw事务看起来提交了其实回滚了数据没进去还查不出原因。回滚之后必须把异常往外抛这一点写死在封装里不给调用方选择的机会。3.4 异步化的边界在哪这块得说点实话。底层的托管实现是同步 API没有真正的异步接口。想让写库不卡主线程只能自己把它扔到线程池里跑。可以做但边界要划清楚。第一同一个连接不能跨线程异步任务里必须自己开连接不能复用主线程那个。第二写完之后的回调不能直接碰 UI 对象得回到主线程再更新。第三也是最重要的——写操作异步化之后你必须自己保证顺序两个异步任务同时写同一张表谁先谁后是不确定的如果业务上依赖顺序那就老老实实串行或者加队列。我的实际做法是读可以异步写尽量同步大批量写走后台任务 完成回调的模式。加载界面时把几张大表的读取扔到后台主线程继续跑加载动画读完再切回主线程填数据体验提升很明显。而存档这类写操作数据量不大同步做完几百毫秒反而比异步更好控制不用担心玩家在写入过程中强退。4. 部署与运行环境的坑代码写对了不等于能跑起来。这一层的问题大多不在语法而在文件放哪、库怎么打包、编译选项怎么配。4.1 数据库文件该放哪这里只有一个原则可写目录和只读目录必须分清。安装包内的资源目录在多数平台上是只读的往那里写必然失败而运行时可写目录里的文件在卸载或更新时可能被清掉。所以标准做法是把初始数据库作为资源随包发布首次启动时复制到可写目录之后所有读写都针对副本。string src Path.Combine(Application.streamingAssetsPath, game.db); string dst Path.Combine(Application.persistentDataPath, game.db); if (!File.Exists(dst)) { Directory.CreateDirectory(Path.GetDirectoryName(dst)); File.Copy(src, dst); }这段代码看着平平无奇但有两个坑。第一个是资源目录在不同平台上不一定是普通文件路径某些平台需要通过特定的读取接口拿字节流再落盘直接File.Copy会失败。稳妥的写法是先判断路径形态能直接读就直接读不能就退化到用资源加载接口读成字节数组再写出去。第二个是目录必须先创建persistentDataPath在某些环境下首次启动时子目录并不存在直接打开数据库会报无法打开文件错误信息完全不提目录的事很容易往权限方向排查半天。4.2 各平台原生库的打包差异托管层只是壳真正干活的是原生库。打包时最常见的事故是编辑器里一切正常出包到设备上打开数据库直接崩或者报找不到入口点。原因基本是原生库没有跟着打进包或者打进去了但架构不对。处理方式按平台分桌面平台通常把对应架构的动态库放在插件目录下注意区分 32 位和 64 位两个都放也无妨但要确认打包时选中了正确的目标架构移动平台则要求把库打进应用包体并且架构要和目标设备匹配只打了一种架构而设备是另一种表现就是启动即崩。我一般的做法是在打包脚本里加一道检查扫描产物里是否包含预期的库文件缺了就直接让构建失败而不是等到装到机器上才发现。4.3 IL2CPP 下的反射与泛型限制如果你的项目用的是 AOT 编译路径有几个点要提前知道。反射访问成员、泛型方法在值类型上的实例化这些在 AOT 下都需要提前生成代码没生成到就会在运行时抛异常而编辑器里的即时编译模式完全发现不了。表现特别有迷惑性编辑器跑一整天没事出包第一次查询就炸。对应措施有三条能用非泛型就用非泛型比如把QueryT的反射映射换成显式委托确实要用反射的实体类加保留标记或写保留配置文件泛型方法尽量约束在引用类型上避免值类型实例化带来的一堆隐式生成。这三条做到之后AOT 下的数据库层基本就稳了。5. 排错实录常见异常速查数据库层的报错有个共同特点——错误信息往往只说了哪里不对没说为什么。这一节按症状整理都是我实际遇到过并且记下来的。5.1 一份按症状索引的排查表现象 / 报错关键词最可能的三个原因排查动作提示数据库被锁定有未关闭的读取器两个连接同时写事务开了没提交检查ExecuteReader是否读完并释放确认写操作串行全局搜BeginTransaction提示表不存在打开的是新建的空库路径错建表脚本没执行打印实际打开的绝对路径确认初始化流程在第一次查询之前跑完无法打开数据库文件目录不存在路径含特殊字符只读目录先创建目录打印路径逐段核对确认写在可写目录参数未定义占位符名字和绑定名字不一致大小写、前缀统一用带前缀的写法绑定和 SQL 里的名字逐字比对中文变乱码读写两端编码不一致确认文本按同一编码存取避免中途用字节数组直接转字符串写入后数据消失事务没提交异常被吞掉检查catch里是否吞了异常确认提交语句被执行到出包后启动即崩原生库缺失或架构不匹配检查产物里是否含目标架构的库文件查询越来越慢没建索引用了SELECT *在循环里查询看执行计划补索引把循环内查询改成一次批量取回这张表我基本是照着用八成的问题在前三行就能定位。5.2 几个只有跑起来才会遇到的细节第一个是读取器没读完就关闭。某些实现下读取器中途被Dispose而结果集还有剩余行时连接会处于一种奇怪的状态后续操作报错但报得莫名其妙。我的处理方式是所有读取都用while (reader.Read())读干净读不干净也要保证在using作用域里走完。第二个是浮点数比较。用浮点类型存金额或者坐标做等值查询时经常查不到因为存进去和取出来经过了一次精度转换。涉及精确比较的数值我一律改用整数存最小单位分、厘米或者用文本存定点小数字符串牺牲一点运算便利换确定性。第三个是时间字段的时区。存文本时间如果不带时区信息跨设备、跨时区就会出现昨天的存档显示成明天。我的做法是统一存 UTC 时间展示层再转本地时区。这个约定必须写进文档否则总有人图方便存本地时间后面数据就再也统一不了了。第四个是索引漏建。表刚建的时候数据少全表扫描毫无感觉等到几十万行才开始卡顿而此时定位问题会很难。我的习惯是建表脚本里就把查询条件涉及的字段索引一并写上宁可多一两个索引也别等到线上卡了再补——补索引要重建涉及数据迁移成本高得多。第五个是并发写的顺序。本地库同一时间只允许一个写入者两个模块同时写后到的会等锁等不到就抛异常。一旦出现这种情况说明架构上有两个写入源光加重试是治标不治本该做的是把所有写操作收拢到一个队列里串行执行。我在一个项目里就是这么改的之前偶发的写入失败几乎一夜之间归零。6. 我个人在这层封装上的几条经验最后说几条从实际项目里磨出来的体会不一定普适但对我确实管用。封装层的代码量应该和业务规模脱钩。意思是不管项目做了多大这一层的行数最好稳定在几百行。一旦发现它开始跟着业务一起膨胀说明有业务逻辑漏进去了该往外挪。所有约束都用异常来表达不要用返回值。参数个数对不上、嵌套事务、非法表名这些都属于程序写错了不是业务上允许的失败直接抛异常让它在开发阶段就暴露别用 bool 返回值给调用方留偷懒的空间。日志里一定要打出完整 SQL 和参数值。排查问题时最痛苦的不是不知道哪条语句错了而是知道错了但不知道当时的参数是什么。在封装的异常包装里把这两样都塞进去线上问题的定位时间能缩短一大半。当然涉及敏感数据的字段要在打日志前脱敏这个也得在封装层做掉。别急着上 ORM。我见过几个项目前期为了架构干净引入完整的对象映射框架结果为了绕开框架的各种限制写了一大堆适配代码最后比手写还难维护。这层的目标始终是让人写得快、改得动、查得出问题不是架构好看。先用执行器把手写 SQL 管住等确实出现了几十张表、大量重复查询的时候再考虑往上抽象这个顺序通常不会错。

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

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

免费获取报价 →
↑