C# 多数据库访问层实战:Oracle、SQL Server 与 SQLite 统一封装

发布时间:2026/10/27 17:49:27
C# 多数据库访问层实战:Oracle、SQL Server 与 SQLite 统一封装 简介一份围绕 C# 多数据库访问的示例代码合集面向正在学习 .NET 数据访问的开发者以及需要横向对比不同数据库写法的技术人员重点解决 Oracle、SQL Server、SQLite 在连接、查询与事务处理上的入门与对照实践问题。压缩包共 38 个文件、约 623KB以 12 个 C# 源码文件为主体配合 sln 解决方案与 csproj 项目文件、config 配置、exe 与 dll 编译产物以及一个 SQLite 数据库文件构成可直接打开调试的 WinForms 小项目。内容围绕 ADO.NET 展开分别给出针对 SQLite、SQL Server、Oracle 的数据库帮助类演示连接字符串配置、增删改查、结果集读取与异常处理等核心环节界面层则提供调用入口。目前已有 755 人学习读者可借此理解三种数据库在 C# 中的接口差异复用帮助类结构并在此基础上扩展自己的数据访问层。1. 从一份压缩包切入三种数据库的 C# 访问代码该怎么合并到一起接手一个老系统改造时数据库清单里同时列出 Oracle、SQL Server 和 SQLite前两者跑在服务器上SQLite 负责客户端本地缓存。要在 C# 里写一套能同时跑通的访问层最省事的起点是找一个 c# oracle sqlserver demo sqlite 四个关键字全占的示例包照着改而不是直接翻三套官方文档。dataDemo.rar 就是这种定位一份以 C# 为主把三种数据源的连接、建表、增删改查、参数化、事务并排放在一起的示例集合。它不解决业务问题解决的是“换个库就得把数据访问层重写一遍”的心理负担。适合手上同时维护多套库的 .NET 开发者、做课程设计要交多库对比作业的学生以及准备给团队统一数据访问层做预研的人。先明确一点示例代码跑得通不等于能直接上生产中间隔着连接池、绑定顺序、字符集三道关后面几章逐个拆开讲。2. 三种数据源的接入差异驱动、连接串与类型映射这一章解决的是“为什么同一段 CRUD 在三个库里写法不一样”。差异集中在三个位置驱动包选哪一个、连接串怎么写、参数类型怎么对应。这三处不理解拿到 demo 也只能照着抄出问题完全无从下手。2.1 驱动选型托管驱动为什么该优先三种库在 .NET 生态里各有两个常见驱动选错一个就会遇到莫名的“找不到提供程序”或“BadImageFormatException”。SQL Server 这边老项目引用的是随框架内置的System.Data.SqlClient新项目按惯例换成 NuGet 上的Microsoft.Data.SqlClient。后者默认开启加密握手连一些没配证书的内网实例会直接报证书链错误这也是很多人升级后第一秒就失败的原因。SQLite 这边带原生混合编译的System.Data.SQLite需要区分 x86 与 x64 两套 DLL进程位数和 DLL 位数对不上就会抛类型初始化异常Microsoft.Data.Sqlite走的是 SQLitePCLRaw 打包方式单包适配多平台配置负担更小。Oracle 的两条路更分明Oracle.DataAccess.Client依赖已安装的客户端OCI位数必须和进程严格一致Oracle.ManagedDataAccess是纯托管实现不用安装任何客户端内部用 TCP 直连监听端口。我的取舍很直接新写的访问层一律使用纯托管或托管驱动只有历史项目已经深度绑定 OCI 且类型行为已经验证过时才保持不动因为切换驱动的风险不在连接本身而在NUMBER、DATE、CLOB的取值行为会有细微差别。解压后先检查bin与lib目录里的 DLL 名字能省掉大量猜测看到的 DLL对应驱动部署要求System.Data.SqlClient.dll框架内置无额外依赖Microsoft.Data.SqlClient.dllNuGet 包需配套的Microsoft.Data.SqlClient.SNISystem.Data.SQLite.dll原生混合编译必须区分 x86、x64Microsoft.Data.Sqlite.dll托管包需SQLitePCLRaw系列Oracle.ManagedDataAccess.dll纯托管无需安装客户端Oracle.DataAccess.Client.dllOCI必须安装客户端且位数匹配提示同一个解决方案里同时出现System.Data.SqlClient和Microsoft.Data.SqlClient是很常见的混用情况两者类型同名不同命名空间using写错一行就会编译过、运行错。2.2 连接串三段能直接抄的最小配置连接串是 demo 里最值得逐字读的部分因为参数含义在各库之间并不通用。// SQL Server注意端口用逗号分隔TrustServerCertificate 用于自签名证书实例 string sqlServer Server127.0.0.1,1433;DatabaseDemoDb;User Iddemo_user;Password***; TrustServerCertificateTrue;MultipleActiveResultSetsTrue; Connect Timeout5;PoolingTrue;Max Pool Size100;; // Oracle冒号后面是端口斜杠后面是服务名若实例只暴露 SID要改用 SID 写法 string oracle Data Source127.0.0.1:1521/ORCLPDB;User Iddemo_user;Password***; PoolingTrue;Min Pool Size1;Connection Timeout15;; // SQLiteData Source 的相对路径以进程工作目录为基准不是 exe 所在目录 string sqlite Data Source./data/demo.db;CacheShared;PoolingTrue;Foreign KeysTrue;;第一段里MultipleActiveResultSets只在需要在一个连接上嵌套开DataReader时才打开开着会略微增加连接开销。第二段最容易出错的是服务名和 SID 混淆主机:端口/服务名与主机:端口:SID是两种格式写反了报的是 ORA-12505提示“监听程序无法识别所请求的服务”跟账号密码毫无关系。第三段也是老问题单元测试里跑得好好的装到服务里就找不到数据库文件因为 Windows 服务的工作目录默认是system32。2.3 类型映射表跨库字段设计的公共子集如果这套访问层要同时跑三个库建表时就得取三者的交集别用任何一方独有的类型。逻辑类型SQL ServerOracleSQLite 存储类整数主键INT IDENTITYNUMBER(10) 序列INTEGER PRIMARY KEY AUTOINCREMENT短文本NVARCHAR(200)NVARCHAR2(200)TEXT金额DECIMAL(18,2)NUMBER(18,2)NUMERIC时间DATETIME2(3)TIMESTAMP(3)TEXTISO8601或INTEGER毫秒布尔BITNUMBER(1)INTEGER0/1大文本NVARCHAR(MAX)CLOBTEXT两个必须记住的语义差别Oracle 里空字符串等同NULL前端传空串进来字段直接变 NULL业务代码里string.IsNullOrEmpty的判断结果会和 SQL Server 上完全不同SQLite 是动态类型列上写的类型只是“亲和性”往INTEGER列里塞字符串它照样收读取时才按亲和规则转换。这两个差异带来的 bug 都属于不抛异常、数据静默出错的那一类。3. 把 demo 跑起来依赖还原、建表与冒烟测试拿到压缩包后别急着打开每个.cs文件逐个读先按“跑通一条读路径”的顺序走十分钟内就能判断这份代码能不能用。3.1 解包后的目录结构与依赖处理典型的多库 demo 会按数据源分目录或者按“连接 / CRUD / 事务 / 批量”分目录。先看项目文件里引用了哪些包再决定还原方式。老式.csproj用packages.config的话还原走的是 NuGet 命令行SDK 风格的项目直接dotnet restore就行。# SDK 风格项目还原并编译先看有没有编译错误 dotnet restore ./DataDemo.sln dotnet build ./DataDemo.sln -c Debug # 老式 packages.config 项目用 nuget 命令行还原到 packages 目录 nuget restore ./DataDemo.sln -PackagesDirectory ./packages还原完先编译不要直接跑。编译报未能加载文件或程序集八成是原生 DLL 没跟着复制到输出目录检查.csproj里那几个Content或None CopyToOutputDirectory节点。编译通过但一运行就BadImageFormatException基本可以确定是 x86/x64 不匹配把平台目标从Any CPU改成明确的x64再试。数据库侧的准备不用追求完整建两张表足够验证链路-- SQL Server CREATE TABLE T_USER (ID INT IDENTITY PRIMARY KEY, NAME NVARCHAR(200), AMOUNT DECIMAL(18,2)); -- Oracle CREATE TABLE T_USER (ID NUMBER(10) PRIMARY KEY, NAME NVARCHAR2(200), AMOUNT NUMBER(18,2)); CREATE SEQUENCE SEQ_T_USER START WITH 1 INCREMENT BY 1; -- SQLite CREATE TABLE T_USER (ID INTEGER PRIMARY KEY AUTOINCREMENT, NAME TEXT, AMOUNT NUMERIC);Oracle 那张表故意用序列而不是IDENTITY是因为不少生产库仍然是这个结构示例里提前适应一下后面写插入语句时就不会想当然。3.2 一段代码同时连接三个库验证连接不必写三份代码用DbConnection抽象就能一次性跑完using System.Data.Common; static void SmokeTest(string name, FuncDbConnection factory, string probeSql) { try { using var conn factory(); conn.Open(); // 连接串错误在这里暴露别放进 try 外 using var cmd conn.CreateCommand(); cmd.CommandText probeSql; // Oracle 的 SELECT 必须带 FROM DUAL var result cmd.ExecuteScalar(); Console.WriteLine(${name} OK - {result}, ServerVersion{conn.ServerVersion}); } catch (DbException ex) { Console.WriteLine(${name} FAIL - {ex.GetType().Name}: {ex.Message}); } } SmokeTest(SqlServer, () new Microsoft.Data.SqlClient.SqlConnection(sqlServer), SELECT 1); SmokeTest(Oracle, () new Oracle.ManagedDataAccess.Client.OracleConnection(oracle), SELECT 1 FROM DUAL); SmokeTest(SQLite, () new Microsoft.Data.Sqlite.SqliteConnection(sqlite), SELECT 1);factory用委托而不用现成连接对象是为了让using在方法内部完成释放避免连接池被长期占住。probeSql单独传参而不是拼在方法里正是因为 Oracle 的SELECT 1缺FROM DUAL会直接报 ORA-00923这个差异没有跨库兼容写法只能显式区分。捕获DbException而不是Exception能过滤掉掉驱动加载失败这类非数据库异常让报错信息更聚焦。3.3 读一条数据确认类型转换没错连接通了不代表映射对了NUMBER取回来是decimal还是double、DATE有没有带时分秒都得单独验一遍。using var conn new Oracle.ManagedDataAccess.Client.OracleConnection(oracle); conn.Open(); using var cmd conn.CreateCommand(); cmd.CommandText SELECT ID, NAME, AMOUNT FROM T_USER WHERE ROWNUM 5; using var reader cmd.ExecuteReader(); while (reader.Read()) { // GetFieldType 看驱动真实返回的类型比看建表语句可靠 Console.WriteLine(${reader.GetName(0)}:{reader.GetFieldType(0).Name} ${reader.GetValue(0)} | {reader.GetName(2)}:{reader.GetFieldType(2).Name}); }GetFieldType是我排查类型映射问题的首选手段。建表写的是NUMBER(18,2)驱动给的可能是decimal也可能因为精度不足给double后者在金额累加时会出现0.1 0.2 ! 0.3这种典型问题。这一步做完三种库的读取路径就算验完了剩下的写入和事务才有意义。4. 参数化、事务与批量写入把 demo 改成能用的写法示例代码常见问题是不做参数化、事务只写一半、批量插入逐条执行。这三处在演示数据上看不出差别数据量一上来就是性能事故或者注入风险。4.1 参数化占位符前缀与绑定方式三种库的参数前缀不统一SQL Server 与 SQLite 用Oracle 用:。跨库代码里我一般让 Provider 自己补前缀参数名不带符号。static DbParameter AddParam(DbCommand cmd, string name, object? value) { var p cmd.CreateParameter(); p.ParameterName name; // 不要手写 或 :驱动会补 p.Value value ?? DBNull.Value; // null 必须显式转 DBNull否则抛异常 cmd.Parameters.Add(p); return p; } // Oracle 专属不设置 BindByName参数按位置绑定顺序错了会静默写错列 if (cmd is Oracle.ManagedDataAccess.Client.OracleCommand oraCmd) oraCmd.BindByName true;p.Value value ?? DBNull.Value这行看似多余实际是必写。传null给OracleParameter会抛InvalidOperationException传null给SqlParameter在某些版本上会被当成“未赋值”最终报“未提供参数”。BindByName的问题更隐蔽Oracle 默认按参数加入集合的顺序与 SQL 里占位符出现的顺序绑定你写SET NAME:name WHERE ID:id却先Add(id)再Add(name)语句照常执行不报错只是把 id 的值写进了 name 列。4.2 事务容易漏掉的cmd.Transactionusing var conn factory(); conn.Open(); using var tx conn.BeginTransaction(System.Data.IsolationLevel.ReadCommitted); try { using var cmd conn.CreateCommand(); cmd.Transaction tx; // 漏掉这行命令会跑在事务之外 cmd.CommandText UPDATE T_USER SET AMOUNT AMOUNT - :amt WHERE ID :id; AddParam(cmd, amt, 100m); AddParam(cmd, id, 1); var rows cmd.ExecuteNonQuery(); if (rows ! 1) throw new InvalidOperationException(扣减行数异常回滚); tx.Commit(); } catch { tx.Rollback(); throw; }cmd.Transaction tx是高频遗漏点。不赋值时SQL Server 会直接抛“当前事务的上下文正在被另一线程使用”SQLite 则可能干脆按自动提交执行最后Commit正常返回数据却没进事务。rows ! 1的判断也别省扣款类操作里行数为 0 说明条件没命中静默提交等于丢数据。隔离级别上SQLite 只支持串行化语义设成ReadCommitted也会被提升这点在读写并发场景下会变成“database is locked”后文单独说。4.3 批量写入三条路线的取舍逐条ExecuteNonQuery一万次是最慢的写法三种库各有自己的加速通道。// SQL ServerSqlBulkCopy需要 DataTable 或 IDataReader列名要能对上 using var bulk new Microsoft.Data.SqlClient.SqlBulkCopy(sqlServer, Microsoft.Data.SqlClient.SqlBulkCopyOptions.TableLock); bulk.DestinationTableName T_USER; bulk.BatchSize 5000; bulk.BulkCopyTimeout 60; bulk.WriteToServer(dataTable); // Oracle数组绑定一次往返提交 N 行ArrayBindCount 决定批大小 var oraCmd new Oracle.ManagedDataAccess.Client.OracleCommand( INSERT INTO T_USER (ID, NAME, AMOUNT) VALUES (:id, :name, :amt), oraConn); oraCmd.ArrayBindCount ids.Length; // 三个数组长度必须一致 oraCmd.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter(id, OracleDbType.Int32, ids, ParameterDirection.Input)); // SQLite没有批量 API靠显式事务包住预编译命令 using var tx sqliteConn.BeginTransaction(); using var cmd sqliteConn.CreateCommand(); cmd.Transaction tx; cmd.CommandText INSERT INTO T_USER (NAME, AMOUNT) VALUES (n, a); var pn AddParam(cmd, n, ); var pa AddParam(cmd, a, 0m); foreach (var row in rows) { pn.Value row.Name; pa.Value row.Amount; cmd.ExecuteNonQuery(); } tx.Commit();SQL Server 的TableLock选项在目标表无并发写入时能显著提速但会阻塞其他写入别在业务高峰用。Oracle 的ArrayBindCount要求每个参数都塞数组而不是单值写成单值会报“数组绑定数量不匹配”。SQLite 的核心不是 API 而是那个事务——不包事务时每行都是一次独立提交每次提交触发一次 fsync一万行能跑出几十秒包上事务后通常压到一秒内。5. 避坑与排查五条真实踩坑记录5.1 Oracle 参数顺序错位数据静默写错列现象插入语句执行成功查询发现 name 列的值来自 idid 列是空。 原因OracleCommand默认按位置绑定Parameters.Add的顺序与 SQL 中占位符出现顺序不一致时不会报错。 解决给每条OracleCommand显式设置BindByName true并把这句放进团队的基础封装里靠人记迟早会漏。5.2 SQLite 报 database is locked现象写入线程一多就抛SQLite Error 5: database is locked重试偶尔成功。 原因SQLite 同一时刻只允许一个写事务默认日志模式rollback journal下写操作会阻塞读并发一上来就互相等。 解决连接串里加CacheShared并开启 WAL 模式把写操作集中到单一队列串行执行。WAL 允许读写并行但写写依然互斥别指望靠加锁参数解决并发模型问题。using var pragma conn.CreateCommand(); pragma.CommandText PRAGMA journal_modeWAL; PRAGMA busy_timeout5000;; pragma.ExecuteNonQuery(); // busy_timeout 让冲突时等待而不是立刻抛错busy_timeout是从“立刻失败”变成“等一会儿”的关键设成 5000 毫秒后绝大多数瞬时冲突会自愈。5.3 连接 SQL Server 2019 及以上实例报证书错误现象A connection was successfully established with the server, but then an error occurred during the login process内层提示证书链不受信任。 原因新版驱动默认EncryptTrue而测试实例用的是自签名证书。 解决连接串加TrustServerCertificateTrue。生产环境正确做法是把 CA 证书导入受信任存储而不是长期关掉校验。5.4、Oracle 客户端位数与进程位数不一致现象Attempt to load Oracle client libraries threw BadImageFormatException。 原因用 OCI 驱动时32 位进程加载 64 位客户端 DLL反之亦然。 解决要么把项目平台目标固定为与客户端一致要么直接换Oracle.ManagedDataAccess不依赖本地客户端。这条属于“早换早轻松”的类型。5.5、SQLite 的相对路径在服务里失效现象控制台能跑装成 Windows 服务后报unable to open database file。 原因Data Source./data/demo.db相对的是进程当前工作目录服务启动时默认目录是系统目录。 解决统一用AppDomain.CurrentDomain.BaseDirectory拼绝对路径并在启动时检查目录是否存在、是否有写权限。6. 收敛成一套工厂三个库一套调用、一个验证清单三种库写到后面重复代码会明显集中在“建连接、建命令、加参数”三个动作上。我的做法是只抽一层薄工厂不引入 ORM保持 SQL 仍然可读可调。public static class DbFactory { // 用委托注册避免 .NET Core 下 DbProviderFactories 的配置文件依赖 private static readonly Dictionarystring, Funcstring, DbConnection Map new() { [sqlserver] cs new Microsoft.Data.SqlClient.SqlConnection(cs), [oracle] cs new Oracle.ManagedDataAccess.Client.OracleConnection(cs), [sqlite] cs new Microsoft.Data.Sqlite.SqliteConnection(cs), }; public static DbConnection Open(string provider, string connStr) { if (!Map.TryGetValue(provider, out var create)) throw new ArgumentException($未注册的提供程序: {provider}); var conn create(connStr); conn.Open(); return conn; } // 占位符前缀集中在这里改一处全库生效 public static string P(string provider) provider oracle ? : : ; }Map用字典而不是DbProviderFactories.GetFactory(...)原因是后者在 .NET Core 上依赖machine.config或运行时注册跨平台部署时会抛“找不到提供程序”。字典写法把注册放在代码里编译期就能看出支持哪些库加库只改一行。调用侧只保留一种形态using var conn DbFactory.Open(oracle, oracle); using var cmd conn.CreateCommand(); var ph DbFactory.P(string.Empty); // 由 provider 决定这里示意 cmd.CommandText $SELECT ID, NAME FROM T_USER WHERE AMOUNT {ph}minAmt AND ROWNUM {ph}top;注意分页写法没法完全统一SQL Server 用OFFSET ... FETCH NEXTSQLite 用LIMIT ... OFFSETOracle 新版支持OFFSET ... FETCH但老库还得靠ROWNUM两层嵌套。这块我建议不要强行抽象保留三份分页 SQL 反而更清楚抽象只在“参数、连接、事务”这三层做。验证清单我每次接新库都会走一遍缺一项都不敢上连接串里Connect Timeout是否设了默认 15 秒服务不可达时界面会长时间无响应null是否统一转DBNull时间字段在三个库里的精度是否一致DATETIME2(3)对TIMESTAMP(3)多一位都可能比对失败自增主键取回方式是否各自正确SCOPE_IDENTITY()、序列CURRVAL、last_insert_rowid()批量写入是否真的在一个事务里。早些年我图省事把BindByName漏在了一个导入模块里一万多条客户数据把姓名写进了编号列靠备份回滚才救回来。从那以后我每次接新库、新驱动都先把三条写死的用例跑一遍插入一条含空串的记录、插入一条含null的记录、按非主键条件更新并检查行数。这三条过不了后面的功能一律不写。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询