C# DbHelper 适配 Oracle/SQL Server/SQLite
简介dataDemo.rar 是一份面向 C# 初学者与.NET 开发者的数据库操作示例包重点演示 Oracle、SQL Server 与 SQLite 的连接、查询、结果集处理及事务管理等常见场景适合用来对照学习不同数据库的 ADO.NET 访问方式。压缩包共 38 个文件以 cs 源码、sln/csproj 工程配置、config 连接配置和 resx 资源文件为主并包含 exe、dll、pdb 及 db 数据库文件整体约 623KB可打开工程梳理代码结构。目前已有 755 人学习说明其对入门与查漏补缺有一定参考价值。包内按 OracleHelper、SqlServerHelper、SqliteHelper 等模块组织示例便于读者理解连接字符串配置、命令执行、数据读取与异常处理思路也能据此比较不同数据库在 C# 项目中的接入差异。1. 三种库一套代码dataDemo 里到底装了什么上个月帮朋友看一个现场部署的老系统总部机房跑的是 Oracle分支节点装的是 SQL Server单机版客户端又必须塞一个 SQLite 进去做离线缓存。同一句查询三套方言、三个驱动、三份连接串改一个字段要动三个地方改漏一处就是现场报错。dataDemo.rar 就是为这种局面准备的最小可运行样例用 C# 一套 DbHelper把 Oracle、SQL Server、SQLite 三种库的连接创建、增删改查、分页、事务收进同一个入口压缩包里是可直接编译的工程加三份建表脚本。它适合两类人一类是长期只写 SQL Server、第一次被要求适配 Oracle 的 C# 开发者另一类是手里有个老系统想加一层轻量数据访问层又不想引一堆 ORM 依赖的人。代码量不大但多库适配里那些真正会绊人的地方——参数前缀、分页语法、驱动版本——它都摆出来了。2. 选型与目录Oracle、SQL Server、SQLite 怎么被一套 C# 代码收编2.1 为什么不用 ORM 一把梭多库项目第一个岔路口就是选数据访问方式。很多人第一反应是上 EF Core 或者某个轻量 ORM觉得「换 Provider 就行」。真跑起来会发现换 Provider 只解决了连接层解决不了方言层Oracle 的ROWNUM、NVL、TO_DATESQL Server 的TOP、ISNULL、OFFSET FETCHSQLite 的LIMIT OFFSET、IFNULL这些在 LINQ 表达式里绕不过去最后还是要落到裸 SQL。这个 Demo 走的是 ADO.NET 原生接口路线核心是 .NET 里的System.Data.Common抽象DbConnection、DbCommand、DbParameter、DbDataAdapter四个基类加上DbProviderFactory这个工厂入口。三种数据库的驱动各自提供自己的 Factory 实现业务代码只跟基类打交道。好处很直接——依赖少压缩包解开装三个 NuGet 包就能编译驱动版本可控Oracle 那边托管驱动和客户端驱动的坑可以自己决定踩哪个SQL 完全自己写方言差异显式暴露不用猜 ORM 翻译成了什么。代价也有没有实体映射DataTable转对象的代码要自己写没有变更跟踪更新要自己拼UPDATE。对 Demo 来说是优点对业务系统来说我一般会在这一层上面再包一层仓储把DataTable换成强类型对象这个改造放到最后一章讲。2.2 压缩包解开后的目录与分层工程结构不复杂但分层是清晰的照着这个骨架加文件不会乱dataDemo/ ├── DataDemo.sln ├── src/ │ ├── DataDemo.Core/ # 数据访问核心不依赖任何 UI │ │ ├── DbFactory.cs # DbProviderFactory 封装按类型创建连接 │ │ ├── DbHelper.cs # 统一增删改查入口 │ │ ├── DbTypeKind.cs # 数据库类型枚举 │ │ ├── SqlDialect.cs # 分页、日期函数等方言拼装 │ │ └── ConnectionConfig.cs # 连接串读取 │ ├── DataDemo.Console/ # 控制台冒烟入口先跑这个 │ │ └── Program.cs │ └── DataDemo.Models/ # 实体与 DataTable 转换 │ └── UserInfo.cs ├── sql/ │ ├── sqlserver_init.sql │ ├── oracle_init.sql │ └── sqlite_init.sql └── config/ └── connections.config # 三套连接串样例这个划分的关键点在于DataDemo.Core不引用控制台、不引用 UI。多库项目最常见的腐化方式就是连接串和 SQL 慢慢散落到各个窗体的按钮事件里半年后没人说得清哪句 SQL 只在 Oracle 下能跑。把数据访问收进一个类库控制台只负责调用和打印换 WinForms、换 Web API 都不用动核心。sql/目录下三份初始化脚本建议逐字执行一遍别跳过。三种库的建表语法差异比想象中大自增主键SQL Server 是IDENTITY(1,1)Oracle 12c 之后是GENERATED BY DEFAULT AS IDENTITYSQLite 是INTEGER PRIMARY KEY AUTOINCREMENT字符串类型Oracle 里VARCHAR2和VARCHAR是两回事长度语义也不一样日期类型Oracle 的DATE带时分秒SQL Server 的DATE只有日期。这些差异在 Demo 阶段看着琐碎上了生产就是数据错乱。2.3 连接串集中管理配置节怎么组织三套连接串如果写在代码里换个环境就要重新编译。Demo 用的是 .NET 的connectionStrings配置节控制台工程里对应的App.config长这样?xml version1.0 encodingutf-8? configuration connectionStrings !-- SQL Server本地实例名按实际环境改TrustServerCertificate 仅用于内网自签证书 -- add nameSqlServer connectionStringServer127.0.0.1,1433;DatabaseDataDemo;User Iddemouser;Password***;TrustServerCertificateTrue;Connect Timeout15 providerNameMicrosoft.Data.SqlClient / !-- OracleData Source 用 EZConnect 格式host:port/service_name -- add nameOracle connectionStringUser Iddemouser;Password***;Data Source127.0.0.1:1521/ORCLPDB;Connection Timeout15 providerNameOracle.ManagedDataAccess.Client / !-- SQLiteDataDirectory 指向程序运行目录避免写死绝对路径 -- add nameSQLite connectionStringData Source|DataDirectory|\datademo.db;Version3;PoolingTrue; providerNameSystem.Data.SQLite / /connectionStrings /configuration读取这一段建议单独封装不要在业务代码里到处ConfigurationManager.ConnectionStrings[xxx]public static class ConnectionConfig { // 按数据库类型取连接串缺失时直接抛别返回 null 让调用方去猜 public static string Get(DbTypeKind kind) { var name kind.ToString(); var setting ConfigurationManager.ConnectionStrings[name]; if (setting null || string.IsNullOrWhiteSpace(setting.ConnectionString)) throw new ConfigurationErrorsException($未配置名为 {name} 的连接串); return setting.ConnectionString; } }这里的逻辑很朴素但有两个细节值得说。一是缺失时立刻抛异常而不是返回空串——多库项目里「连接串配错了但程序照跑」是最难查的一类问题通常会表现成查出来 0 行数据而不是报错。二是|DataDirectory|这个占位符SQLite 用它把数据库文件锚定到应用的AppDomain.CurrentDomain.DataDirectory在控制台和 WinForms 下默认都是 exe 所在目录但 IIS 托管时会被改到App_Data如果部署形态会变最好在启动时显式AppDomain.CurrentDomain.SetData(DataDirectory, path)覆盖掉。另外提醒一句配置里的Password***是占位真实密码不要跟着源码走。Demo 图省事写在配置里没问题生产环境我会走环境变量或者一个加密的配置提供程序尤其是 Oracle 那种一个库挂多个应用的场景账号泄露的影响面比想象中大。3. 核心链路拆解从 DbProviderFactory 到 DataTable 的完整走法3.1 数据库类型枚举与连接工厂整个多库适配的支点是DbProviderFactory。它是 .NET 提供的抽象工厂每种数据库驱动都提供一个静态的Instance拿到之后就能用同一套代码创建连接、命令、参数、适配器。Demo 里的工厂封装如下using System.Data.Common; using Microsoft.Data.SqlClient; using Oracle.ManagedDataAccess.Client; using System.Data.SQLite; public enum DbTypeKind { SqlServer, Oracle, SQLite } public static class DbFactory { // 拿到对应驱动的 ProviderFactory后续所有对象都由它创建 public static DbProviderFactory GetFactory(DbTypeKind kind) { switch (kind) { case DbTypeKind.SqlServer: return SqlClientFactory.Instance; case DbTypeKind.Oracle: return OracleClientFactory.Instance; case DbTypeKind.SQLite: return SQLiteFactory.Instance; default: throw new NotSupportedException($未支持的数据库类型{kind}); } } // 创建并打开连接连接串由 ConnectionConfig 提供 public static DbConnection OpenConnection(DbTypeKind kind) { var factory GetFactory(kind); var conn factory.CreateConnection(); conn.ConnectionString ConnectionConfig.Get(kind); conn.Open(); return conn; } }逻辑上分两步先按枚举拿到 Factory再由 Factory 创建连接。关键参数是DbTypeKind它决定了后面所有方言分支走向也决定读哪个连接串所以命名必须和配置节的name严格一致——这里用kind.ToString()直接拼名字省了一次映射表代价是枚举值改名就要同步改配置属于可以接受的小约束。这里有一个必须说清楚的点OracleClientFactory.Instance来自Oracle.ManagedDataAccess.Client命名空间是纯托管驱动另一个常见选择是System.Data.OracleClient那是 .NET Framework 自带的老驱动早已停止维护还在一些老项目里被引用。压缩包里用的是托管驱动这也是我现在的默认选择——托管驱动不需要在机器上装 Oracle 客户端部署时少一个很大的不确定因素。SQLite 那边同理System.Data.SQLite需要匹配 x86/x64 的原生互操作 DLLMicrosoft.Data.Sqlite则是另一套包两者 API 相似但混用会出乱子一个工程里只留一个。3.2 DbHelper 的三个执行入口业务代码真正调用的不是工厂而是 DbHelper。它把「开连接、建命令、绑参数、执行、关连接」这套重复动作封掉对外只留几个语义明确的方法public class DbHelper { private readonly DbTypeKind _kind; private readonly DbProviderFactory _factory; public DbHelper(DbTypeKind kind) { _kind kind; _factory DbFactory.GetFactory(kind); } // 增删改返回受影响行数 public int ExecuteNonQuery(string sql, params DbParameter[] parameters) { using (var conn DbFactory.OpenConnection(_kind)) using (var cmd BuildCommand(conn, sql, parameters)) { return cmd.ExecuteNonQuery(); } } // 单值查询取第一行第一列常用于 COUNT / 序列 / 自增回填 public object ExecuteScalar(string sql, params DbParameter[] parameters) { using (var conn DbFactory.OpenConnection(_kind)) using (var cmd BuildCommand(conn, sql, parameters)) { return cmd.ExecuteScalar(); } } // 多行查询返回 DataTable后续再转实体 public DataTable ExecuteDataTable(string sql, params DbParameter[] parameters) { using (var conn DbFactory.OpenConnection(_kind)) using (var cmd BuildCommand(conn, sql, parameters)) using (var adapter _factory.CreateDataAdapter()) { adapter.SelectCommand cmd; var table new DataTable(); adapter.Fill(table); return table; } } // 统一装配命令对象参数绑定逻辑只在这里出现一次 private DbCommand BuildCommand(DbConnection conn, string sql, DbParameter[] parameters) { var cmd _factory.CreateCommand(); cmd.Connection conn; cmd.CommandText sql; cmd.CommandTimeout 30; if (parameters ! null parameters.Length 0) cmd.Parameters.AddRange(parameters); return cmd; } }三个入口的差别在于返回形态和底层方法ExecuteNonQuery走DbCommand.ExecuteNonQuery返回受影响行数INSERT/UPDATE/DELETE用它ExecuteScalar走ExecuteScalar只取结果集第一行第一列做COUNT(*)或者插入后取当前值最快ExecuteDataTable走DbDataAdapter.Fill一次性把结果集灌进DataTable适合数据量可控的列表查询。CommandTimeout 30这行别小看。默认值是 30 秒没错但不同驱动对它的解释有细微差别而且 Oracle 那边如果遇到执行计划突变30 秒够卡住一个线程很久。我一般在BuildCommand里把它做成可配置报表类查询单独给 120 秒普通增删改给 15 秒超时早失败总比线程池被拖垮强。另外注意using的嵌套写法连接、命令、适配器全部包在using里。DbConnection实现了IDisposableClose和Dispose的组合在不同驱动下行为不完全一致显式using是最省心的做法不要指望 GC 帮你收尾——连接池被占满的报错八成都是某处忘了关连接。3.3 参数前缀冒号与 的方言差异多库适配里最容易翻车的地方不是 SQL 语法而是参数前缀。SQL Server 和 SQLite 用nameOracle 用:name。如果业务代码里写死id在 Oracle 下会直接抛「未绑定变量」。Demo 的处理办法是把前缀收敛到一个方法里业务层只传参数名和值前缀由 DbHelper 补public DbParameter CreateParameter(string name, object value) { // 去掉调用方可能带上的前缀统一由这里补 var pureName name.TrimStart(, :); var p _factory.CreateParameter(); // Oracle 必须用冒号其他两种用 p.ParameterName _kind DbTypeKind.Oracle ? : pureName : pureName; p.Value value ?? DBNull.Value; // null 要转 DBNull否则驱动报错 return p; }逻辑上只有三步但每一步都有坑。第一步TrimStart(, :)是为了容错调用方写id还是id都能用避免同一份业务代码在不同库之间复制粘贴时前缀打架。第二步按类型补前缀这是整段的核心。第三步的?? DBNull.Value尤其重要C# 的null和数据库的NULL是两回事直接把null塞进Parameter.ValueSQL Server 驱动会当成「未设置值」Oracle 驱动会抛ORA-01084SQLite 驱动则可能静默写入一个空串。这个坑我在三个库上各踩过一次后来一律在参数层兜住。配套的 SQL 文本也要注意写法。Demo 里的约定是 SQL 里只写不带前缀的名字执行前由SqlDialect做一次替换// 把 SQL 里的 {p} 占位替换成对应方言的参数前缀 public string BindParam(string sql, params string[] names) { var prefix _kind DbTypeKind.Oracle ? : : ; foreach (var n in names) sql sql.Replace({ n }, prefix n); return sql; }调用方写SELECT * FROM T_USER WHERE ID {id}BindParam(sql, id)之后在 Oracle 下变成:id在另两种库下变成id。多一层替换看着啰嗦但它让同一份 SQL 常量能复用到三个库改语句的时候不用三个文件同步改。names参数我一般要求显式列全不做自动正则扫描——自动扫描容易把字符串字面量里的花括号误伤显式列全虽然多敲几个字但出错时一眼能看出是哪个参数没绑。3.4 分页 SQL 的三套写法分页是方言差异最集中的地方也是最容易写出「在 A 库能跑、在 B 库报语法错」的语句。Demo 的SqlDialect里用了一个方法统一出口public string BuildPaging(string baseSql, string orderBy, int pageIndex, int pageSize) { // pageIndex 从 1 开始内部换算成偏移量 int offset (pageIndex - 1) * pageSize; switch (_kind) { case DbTypeKind.SQLite: // SQLiteLIMIT ... OFFSET ...orderBy 不能为空 return ${baseSql} ORDER BY {orderBy} LIMIT {pageSize} OFFSET {offset}; case DbTypeKind.SqlServer: // SQL Server 2012OFFSET FETCH 必须配 ORDER BY return ${baseSql} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY; case DbTypeKind.Oracle: // Oracle 12c语法与 SQL Server 接近但不支持 参数直接拼在 FETCH 后 return ${baseSql} ORDER BY {orderBy} OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY; default: throw new NotSupportedException(_kind.ToString()); } }三个分支里SQLite 和 SQL Server 是标准写法Oracle 那支要看版本12c 及以上支持OFFSET ... FETCH11g 及以下必须用ROWNUM嵌套。如果目标环境里有 11g得换成另一个分支-- Oracle 11g 分页三层嵌套内层排序编号外层卡区间 SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT * FROM T_USER ORDER BY ID ) t WHERE ROWNUM :endRow ) WHERE rn :startRow这段的:endRow offset pageSize:startRow offset两个都是开区间和闭区间的组合写错一个边界就会少一行或者多一行。我在一个老项目里因为把写成导致每页第一条重复出现查了两天才定位到血泪经验就是分页边界一定要用「总数为 N、每页 M 条、逐页拉完去重后条数等于 N」这个断言去验。另外ORDER BY是分页的前置条件三种库都要求它存在但表现不同SQL Server 缺ORDER BY直接语法报错SQLite 和 Oracle 允许省略但结果顺序不确定。所以BuildPaging里我把orderBy设计成必填参数从签名上就不给省略的机会。这个方法用字符串插值拼 SQL 是安全的因为pageSize和offset都是int不可能注入但orderBy是字符串调用方千万不能直接把前端传来的排序字段拼进去必须走白名单映射这一点在最后一章会展开。3.5 事务封装与批量提交单条 SQL 的事务好写多库项目真正麻烦的是「同一个事务里混着三种库的操作」——这种场景通常出现在数据同步任务里从 Oracle 读、往 SQLite 写。跨库分布式事务要上事务协调器Demo 不碰这块它只封单库事务但把写法固定下来public int ExecuteInTransaction(ActionDbHelper, DbTransaction action) { using (var conn DbFactory.OpenConnection(_kind)) using (var tran conn.BeginTransaction()) { try { action(this, tran); // 业务逻辑在外层传入复用同一个事务对象 tran.Commit(); return 1; } catch (DbException ex) { tran.Rollback(); // 驱动层异常必须回滚 throw new DataAccessException(事务执行失败, ex); } } }关键在于事务对象要显式挂到命令上。很多人写完BeginTransaction就忘了给cmd.Transaction赋值结果 SQL Server 抛「当分配给命令的连接处于挂起的本地事务中时该命令必须具有事务对象」Oracle 抛ORA-01084SQLite 则可能把语句放在事务外面直接执行回滚的时候才发现根本没生效。Demo 在BuildCommand之外又加了一个重载专门接收DbTransaction并赋值就是为了堵这个口子。批量提交方面三种库的策略不一样。SQL Server 可以用SqlBulkCopy性能最好但只有 SQL Server 专属Oracle 用OracleBulkCopy或者ArrayBindingSQLite 最朴素包一个事务循环INSERT就是几个数量级的差距。Demo 走的是通用路线——ExecuteInTransaction里循环执行参数化INSERT靠事务把每次提交的 fsync 开销摊薄。实测下来SQLite 单条插入一千行要十几秒包进一个事务后能到几百毫秒这个差异在实际项目里足够决定用户是夸你还是骂你。4. 跑起来先 SQLite 后 Oracle 的最小可运行路径4.1 环境与依赖清单跑这个 Demo 不需要三套数据库全装齐按依赖从轻到重依次上先 SQLite零安装再 SQL ServerExpress 版就够最后 Oracle最重可以最后配。依赖清单如下组件版本要求用途是否必装.NET SDK6.0 或以上编译运行控制台工程必装Microsoft.Data.SqlClient5.xSQL Server 驱动需接 SQL Server 时装Oracle.ManagedDataAccess.Core3.xOracle 托管驱动需接 Oracle 时装System.Data.SQLite.Core1.0.118 左右SQLite 驱动含原生库必装SQL Server Express2019 或以上本地测试实例可选Oracle Database12c 及以上本地测试实例可选装包命令直接抄# 数据访问核心库需要这三个按实际要接的库裁剪 dotnet add src/DataDemo.Core package System.Data.SQLite.Core dotnet add src/DataDemo.Core package Microsoft.Data.SqlClient dotnet add src/DataDemo.Core package Oracle.ManagedDataAccess.Core # 编译整个解决方案先确认能过 dotnet build DataDemo.sln -c DebugOracle.ManagedDataAccess.Core是 .NET Core / .NET 5 用的托管驱动包和 .NET Framework 时代的Oracle.ManagedDataAccess是两个包名字容易看混。工程目标框架如果是net6.0却装了后者编译会提示不兼容反过来在net48工程里装 Core 版本也一样。这是新手最常见的一个卡点装包前先看 csproj 里的TargetFramework。System.Data.SQLite.Core会自动带上SQLite.Interop.dll这个原生库分 x86 和 x64。控制台工程默认 AnyCPU 且勾了「优先 32 位」的话运行时会找 x86 版本如果只拷贝了 x64 目录就会报「无法加载 DLL SQLite.Interop.dll」。稳妥做法是把平台显式设成 x64或者干脆换成Microsoft.Data.Sqlite后者把原生库打包进了运行时包省掉这层麻烦。4.2 第一步SQLite 零依赖跑通建表与查询SQLite 的好处是不用装任何服务一个文件就是一个库。先用它把整条链路走通能排除掉「代码问题」还是「数据库环境问题」的干扰。# 在工程输出目录建库文件路径和连接串里的 |DataDirectory| 对应 cd src/DataDemo.Console/bin/Debug/net6.0 sqlite3 datademo.db ../../../../../sql/sqlite_init.sql # 确认表建出来了 sqlite3 datademo.db .tables sqlite3 datademo.db SELECT COUNT(*) FROM T_USER;sqlite3命令行工具不一定在系统里没有的话用 DB Browser for SQLite 打开文件执行脚本也一样。建表脚本的核心部分-- SQLite 建表自增主键必须写 INTEGER PRIMARY KEYAUTOINCREMENT 可选 CREATE TABLE IF NOT EXISTS T_USER ( ID INTEGER PRIMARY KEY AUTOINCREMENT, USER_NAME VARCHAR(50) NOT NULL, AGE INTEGER NULL, CREATE_TIME DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 建个索引后面测分页时能看出差别 CREATE INDEX IF NOT EXISTS IX_T_USER_NAME ON T_USER(USER_NAME); -- 灌几条测试数据 INSERT INTO T_USER (USER_NAME, AGE) VALUES (user_a, 28); INSERT INTO T_USER (USER_NAME, AGE) VALUES (user_b, 34);SQLite 的类型系统是「动态类型」VARCHAR(50)里的 50 它根本不校验写多长都收。所以别指望靠字段长度在 SQLite 层挡住脏数据长度校验得放在应用层。另外DATETIME在 SQLite 里其实是文本存的是 ISO8601 字符串比较大小的前提是格式统一——这一点在跨库同步时会造成麻烦Oracle 的DATE读出来是DateTime写进 SQLite 变成字符串再读回来如果格式带时区就会解析失败。我一般统一约定存 UTC 的yyyy-MM-dd HH:mm:ss不用参数化日期以外的任何格式。控制台入口改成先跑 SQLitestatic void Main(string[] args) { // 显式指定 DataDirectory避免不同宿主环境下解析到别处 AppDomain.CurrentDomain.SetData(DataDirectory, AppContext.BaseDirectory); var db new DbHelper(DbTypeKind.SQLite); var table db.ExecuteDataTable(SELECT ID, USER_NAME, AGE FROM T_USER ORDER BY ID); Console.WriteLine($SQLite 查到 {table.Rows.Count} 行); foreach (DataRow row in table.Rows) Console.WriteLine(${row[ID]}\t{row[USER_NAME]}\t{row[AGE]}); }这段跑通说明工厂、连接串读取、命令装配、DataTable填充四个环节都没问题。剩下的库只需要换DbTypeKind和连接串代码一行不用动——这正是整个 Demo 想证明的事。4.3 第二步接 SQL ServerSQL Server 这一侧最省事本地装个 Express 版或者用 Docker 起一个都行。建库、跑脚本、改连接串三步# 用 sqlcmd 跑初始化脚本-S 指定实例-U/-P 指定账号 sqlcmd -S 127.0.0.1,1433 -U sa -P YourPassword -C -i sql/sqlserver_init.sql-C是信任服务器证书本地自签证书不加这个会连不上。生产环境别这么干正经做法是把证书导入信任链。脚本里的关键差异点-- SQL Server 建表自增用 IDENTITY日期默认值用 GETDATE() CREATE TABLE T_USER ( ID INT IDENTITY(1,1) PRIMARY KEY, USER_NAME NVARCHAR(50) NOT NULL, -- 中文名用 NVARCHARVARCHAR 会丢字符 AGE INT NULL, CREATE_TIME DATETIME2 DEFAULT GETDATE() );NVARCHAR这一点必须强调。VARCHAR在 SQL Server 里是非 Unicode存中文要看排序规则排序规则不对就是一堆问号。跨库项目里Oracle 那边用NVARCHAR2或者字符集是AL32UTF8的VARCHAR2SQLite 本身就是 UTF-8只有 SQL Server 有这层坑。改配置里的SqlServer连接串然后把控制台的DbTypeKind.SQLite换成DbTypeKind.SqlServer重跑。如果这时候报「用户登录失败」先确认是不是只启用了 Windows 身份验证——Express 版默认如此需要手动切到混合模式再重启服务。这个报错信息很含糊容易让人怀疑密码写错了实际上是服务端压根没开 SQL 登录。4.4 第三步接 OracleOracle 是三套里最重的但如果用托管驱动可以省掉装客户端的步骤。本地起一个实例之后先建用户再跑脚本-- 用 sys 登录后先建业务用户Oracle 里没有「数据库」这层概念用户即 schema CREATE USER demouser IDENTIFIED BY YourPassword; GRANT CONNECT, RESOURCE TO demouser; GRANT UNLIMITED TABLESPACE TO demouser;建表脚本里几个必须注意的差异-- Oracle 建表12c 及以上用 IDENTITY字符串优先 VARCHAR2 CREATE TABLE T_USER ( ID NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, USER_NAME VARCHAR2(50 CHAR) NOT NULL, -- 带 CHAR 语义才是按字符算长度 AGE NUMBER(3) NULL, CREATE_TIME DATE DEFAULT SYSDATE );VARCHAR2(50 CHAR)里的CHAR是关键字不加的话默认按字节算一个中文字符在 UTF-8 下占 3 字节50 字节只能存 16 个汉字写入超长直接报ORA-12899。这是我从 SQL Server 迁过来时踩的第一个坑报错信息里会写「实际值大于允许值」但看着字段定义明明是 50很容易懵。连接串里的Data Source用 EZConnect 格式host:port/service_nameservice_name不是 SID两者搞混会报ORA-12505。如果实例是老式的 SID 配置得写成host:port:SID。分辨方法很简单连上去执行SELECT sys_context(USERENV,SERVICE_NAME) FROM dual;能查到就说明是服务名。4.5 冒烟验证四条断言确认链路通三套库都配好之后别靠肉眼看输出写一个冒烟方法逐条断言static void Smoke(DbTypeKind kind) { var db new DbHelper(kind); // 1. 连通性能取到 1 就说明连接和基本查询正常 var one Convert.ToInt32(db.ExecuteScalar(SELECT 1 FROM DUAL.Replace(FROM DUAL, kind DbTypeKind.Oracle ? FROM DUAL : ))); if (one ! 1) throw new Exception(${kind} 连通性检查失败); // 2. 写入插入一条带参数的记录 var insertSql db.Dialect.BindParam( INSERT INTO T_USER (USER_NAME, AGE) VALUES ({name}, {age}), name, age); var rows db.ExecuteNonQuery(insertSql, db.CreateParameter(name, smoke_ kind), db.CreateParameter(age, 20)); if (rows ! 1) throw new Exception(${kind} 插入影响行数异常{rows}); // 3. 参数化查询确认参数绑定没串味 var count Convert.ToInt32(db.ExecuteScalar( db.Dialect.BindParam(SELECT COUNT(*) FROM T_USER WHERE USER_NAME {name}, name), db.CreateParameter(name, smoke_ kind))); if (count ! 1) throw new Exception(${kind} 参数化查询失败); // 4. 分页取第二页条数必须等于 pageSize var page db.ExecuteDataTable(db.Dialect.BuildPaging( SELECT ID, USER_NAME FROM T_USER, ID, 2, 5)); if (page.Rows.Count 5) throw new Exception(${kind} 分页条数超限); Console.WriteLine(${kind} 冒烟通过); }四条断言分别覆盖连接、写入、参数绑定、方言分页恰好是三个库差异最大的四个点。冒烟方法写成接收DbTypeKind参数的形式三种库共用一份代码这样任何一处适配退化都会立刻暴露。实际项目里我会把这个方法挂到 CI 上每次改动数据访问层就跑一遍比人肉点界面可靠得多。注意第一行的SELECT 1 FROM DUAL处理方式有点糙正式代码里应该在SqlDialect里加一个GetDummyTable()方法Oracle 返回 FROM DUAL其他两种返回空串。Demo 里这么写是为了让冒烟代码短一些抄的时候建议改掉。5. 排查与避坑驱动、连接串、事务的五条血泪记录5.1 驱动与依赖相关现象编译能过运行时抛「Could not load file or assembly Oracle.ManagedDataAccess」或版本冲突。原因通常是两个包引用了同一个驱动的不同大版本NuGet 解析时选了低的那个而代码里用了高版本才有的 API。也可能是Oracle.ManagedDataAccess和Oracle.ManagedDataAccess.Core同时被间接引用。解决方式是把 csproj 里的显式引用统一到一个包用dotnet list package --include-transitive把传递依赖打出来看发现有重复的直接在 csproj 里ExcludeAssets掉或者统一升到同一个大版本。我一般还会在输出目录里放一个app.config的bindingRedirect.NET Framework 工程尤其需要。现象SQLite 报「无法加载 DLL SQLite.Interop.dll找不到指定的模块」。原因是原生互操作库的位数和进程位数不匹配。AnyCPU 的控制台在 64 位系统上默认跑 64 位但发布时只拷贝了 x86 目录或者在 IIS 里应用池开了 32 位却只有 x64 的文件。解决办法是先把工程的PlatformTarget显式设成x64然后确认输出目录下的x64/SQLite.Interop.dll存在且被打包。如果还是不行换成Microsoft.Data.Sqlite它的原生依赖通过运行时包带进来不需要手动管目录。5.2 连接串与网络相关现象本地能连部署到另一台机器上连 Oracle 报ORA-12541: TNS: 无监听程序。原因一般是连接串里写的是host:port/SID但实例用的是服务名或者目标机器的监听端口不是默认的 1521又或者防火墙只放行了应用服务器到数据库的部分端口。排查顺序是先在数据库服务器上用lsnrctl status看监听状态和实际端口再从应用服务器用telnet host port确认网络可达最后核对连接串里是服务名还是 SID。这三步能排掉九成的连接问题剩下的才是账号密码和权限。现象SQL Server 报「连接超时时间已到在从池中获取连接之前超时时间已过」。这句话的关键词是「从池中获取」说明不是网络慢是连接池被占满了。原因基本只有一个某处代码打开了连接但没关通常是DbConnection没有包在using里或者异常路径上跳过了Close。定位办法是在连接串里临时加Max Pool Size10把池压小让问题更快复现然后在DbFactory.OpenConnection里加一行计数日志看打开和关闭是否配平。长期方案是统一走using别在业务层手动管连接。5.3 事务与类型相关现象事务里第一条INSERT成功第二条报「该命令必须具有事务对象」回滚后发现第一条也没了。原因是第二条命令创建时没有把DbTransaction赋给cmd.Transaction。SQL Server 和 Oracle 都会显式拒绝SQLite 的表现更隐蔽——它可能把语句放到事务外执行回滚时看起来「没生效」实际数据已经落盘。解决办法是给DbHelper加一个必须传DbTransaction的BuildCommand重载并且把ExecuteInTransaction设计成唯一的事务入口业务代码没有别的路径可以绕过。现象Oracle 里存进去的中文变成问号或者插入时报ORA-12899值太大。两个原因常常一起出现一是字段定义没加CHAR语义VARCHAR2(50)按字节算只够 16 个汉字二是数据库字符集不是AL32UTF8客户端和服务端字符集不匹配导致转换丢字符。排查先用SELECT * FROM nls_database_parameters WHERE parameter LIKE %CHARACTERSET%;看服务端字符集再用SELECT length(中文), lengthb(中文) FROM dual;看字符长度和字节长度的差异。修法是把字段改成VARCHAR2(50 CHAR)字符集问题则要在建库阶段就定好事后改代价很大。6. 二次开发把 Demo 改成能进生产的访问层Demo 的定位是「证明这条路能走通」离生产还有几步。改造的第一步是给DbHelper加接口约束让上层不直接依赖具体类抽出IDbHelper方法签名保持ExecuteNonQuery / ExecuteScalar / ExecuteDataTable / ExecuteInTransaction四个然后按库写三个实现只会增加重复代码更好的做法是保留一个实现把DbTypeKind做成构造参数通过 DI 容器注册时按配置决定传哪个值。这样单元测试里可以塞一个 SQLite 内存库连接串写Data Source:memory:跑得飞快还不用起服务。第二个改造点是DataTable转实体。生产代码里到处row[USER_NAME].ToString()是很糟的体验字段改名编译器不报错空值还要判DBNull。我的习惯是在DataDemo.Models里加一个轻量映射// 用反射做列名到属性的映射字段名与属性名一致时零配置 public static ListT ToListT(DataTable table) where T : new() { var props typeof(T).GetProperties() .ToDictionary(p p.Name, p p, StringComparer.OrdinalIgnoreCase); var list new ListT(table.Rows.Count); foreach (DataRow row in table.Rows) { var item new T(); foreach (DataColumn col in table.Columns) { if (!props.TryGetValue(col.ColumnName, out var prop)) continue; var value row[col]; // DBNull 不写进属性保留默认值避免 int 字段被赋成 0 和「真的 0」混淆 if (value DBNull.Value) continue; prop.SetValue(item, Convert.ChangeType(value, Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType)); } list.Add(item); } return list; }StringComparer.OrdinalIgnoreCase是为了兼容 Oracle 返回全大写列名、SQL Server 保留建表时的大小写这种差异——不加这一行同一份代码在 Oracle 下会全部映射不上属性而且不报错只是拿到一堆默认值。DBNull那个continue也是刻意为之int类型的属性如果被赋成 0你没法区分「数据库里是 NULL」和「数据库里就是 0」保留默认值至少能让int?的字段正确表达空值语义。第三个改造点是防护。Demo 里BuildPaging的orderBy是直接拼进 SQL 的示例代码里传的是字面量所以安全但真实业务里排序字段往往来自前端。我会加一个白名单// 排序字段白名单不在表里的直接拒绝杜绝拼接注入 private static readonly Dictionarystring, string SortWhitelist new() { [id] ID, [name] USER_NAME, [createTime] CREATE_TIME }; public string SafeOrderBy(string input) { return SortWhitelist.TryGetValue(input ?? , out var col) ? col : ID; }pageSize也一样虽然它是int不会注入但要卡上限比如最大 500防止有人传个 100000 把内存打爆。这类参数校验放在SqlDialect里比放在业务层更合适因为它是方言层的一部分改了分页实现也不会漏掉校验。再往上一层如果项目规模上来了可以考虑把部分查询换成 Dapper——它跟 Demo 的思路其实是一路的都是裸 SQL 加参数化只是把DataTable到对象的映射做掉了还能直接返回IEnumerableT。但要注意 Dapper 的参数前缀处理和DbTypeKind的耦合跨库时还是要自己管方言。我的做法是保留DbHelper作为底层执行器Dapper 只用在那些纯查询、不涉及方言差异的地方两边共用同一个DbProviderFactory。最后说个验证习惯。每改一次Query或BuildPaging我会先把第 4 章那个Smoke方法在三个库上各跑一遍再手动跑一条「总数 23 条、每页 5 条」的分页把三页的 ID 拉出来去重条数必须等于 23。这个动作看着笨但它挡掉过我至少三次「边界差一」的低级错误。从那以后我每接一个多库项目都先把连接串、参数前缀、分页语法这三件事写成一张对照表贴在显示器边上改代码前先看一眼表比事后翻日志省太多时间。希望帮到你。本文还有配套的精品资源点击获取