
简介dataDemo.rar 是一份面向中初级 C# 开发者、在校学生及数据库入门者的多数据库操作演示包围绕 Oracle、MySQL、SQL Server、SQLite 的连接配置、SQL 执行、结果集读取与事务处理展开适合对比 ADO.NET 访问不同数据库的共性与差异。压缩包共 38 个文件约 623KB以 12 个 cs 源码、3 个 exe、3 个 dll、3 个 config 配置、1 个 sln 解决方案和 1 个 db 数据库文件为主另有 resx、resources 等窗体资源体量轻便且工程结构完整。已有 755 人学习。读者可获得可运行的窗体示例、数据库辅助类封装思路、连接字符串配置参考以及多数据库切换的对照案例也能了解命令对象、数据适配器、异常处理与事务管理在示例中的组织方式便于快速理解 C# 数据库访问项目的搭建流程并注意不同数据库驱动、命名空间与连接参数的差异在此基础上改造成自己的练习或小型应用。1. 三种数据库一套代码这个 Demo 到底解决了什么问题有个场景在交付型项目里反复出现同一套业务系统要装到三个客户那里A 客户机房只给一台 OracleB 客户运维只认 SQL ServerC 客户要求单机部署、数据落在本地文件里。业务逻辑只有一份数据库方言却有三套每接一个客户就把数据访问层重写一遍维护成本很快就失控。dataDemo 这个 C# 示例包解决的就是这件事——用一层不厚的抽象把 Oracle、SQL Server、SQLite 在连接串、参数占位符、自增主键、分页语法上的差异收拢到一处上层业务代码只认一套接口。它适合两类人一类是刚接手多库适配任务的 C# 开发者需要一份能跑、能改的参照另一类是想搞清楚 ADO.NET 工厂模式到底怎么落地的人光看文档容易停在概念上抄一遍代码才知道哪些地方会翻车。后面按「结构长什么样、核心代码怎么写、怎么跑起来、哪里容易踩坑、怎么改成能上生产的样子」的顺序拆代码都可以直接抄进项目里改。2. 工程结构与技术选型先看清目录再动手改拿到一个压缩包我习惯先看目录再动手因为目录结构基本暴露了作者的取舍。dataDemo 的组织方式是按「抽象层 各库实现 演示入口」三段拆的而不是把所有逻辑塞进一个DbHelper.cs里用switch分支判断数据库类型。这个选择看起来只是文件多了几个实际影响的是编译依赖和发布体积——用 SQLite 的客户不需要在机器上装 Oracle 驱动。2.1 目录分层与项目组织解压后的结构大致是这样中间省略了bin/obj和资源文件dataDemo/ ├── DataDemo.sln ├── src/ │ ├── DataDemo.Core/ # 抽象层接口、工厂、配置模型 │ │ ├── IDbProvider.cs │ │ ├── DbProviderFactory.cs │ │ ├── DatabaseKind.cs │ │ ├── QueryParameter.cs │ │ └── ConnectionOptions.cs │ ├── DataDemo.Providers.SqlServer/ # 只引用 Microsoft.Data.SqlClient │ ├── DataDemo.Providers.Oracle/ # 只引用 Oracle.ManagedDataAccess.Core │ ├── DataDemo.Providers.Sqlite/ # 只引用 Microsoft.Data.Sqlite │ └── DataDemo.App/ # 控制台入口跑演示用例 ├── sql/ │ ├── oracle/01_schema.sql │ ├── sqlserver/01_schema.sql │ └── sqlite/01_schema.sql └── README.mdDataDemo.Core里没有任何数据库驱动的引用只定义接口和值对象这一层可以被打包成独立的 NuGet 包给别的项目用。三个Providers.*项目各自只依赖一个驱动互不干扰。DataDemo.App引用 Core 加任意一个 Provider 就能跑——这一点很关键意味着发布给 SQLite 客户时产物里不会混进一个几十兆的 Oracle 托管程序集。sql/目录下三份建表脚本是另一个容易被忽略的重点。同一个业务表在三套库里的写法差异比想象中大-- OracleNUMBER(19) 做主键VARCHAR2 存字符串时间用 TIMESTAMP CREATE TABLE T_ORDER ( ID NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, ORDER_NO VARCHAR2(64) NOT NULL, AMOUNT NUMBER(18,2) NOT NULL, CREATED_AT TIMESTAMP NOT NULL );-- SQL ServerBIGINT IDENTITY字符串用 NVARCHAR 避免中文丢失 CREATE TABLE T_ORDER ( ID BIGINT IDENTITY(1,1) PRIMARY KEY, ORDER_NO NVARCHAR(64) NOT NULL, AMOUNT DECIMAL(18,2) NOT NULL, CREATED_AT DATETIME2 NOT NULL );-- SQLite没有真正的 decimal日期只能存字符串或整数 CREATE TABLE T_ORDER ( ID INTEGER PRIMARY KEY AUTOINCREMENT, ORDER_NO TEXT NOT NULL, AMOUNT NUMERIC NOT NULL, CREATED_AT TEXT NOT NULL -- ISO8601 字符串如 2024-05-01T10:00:00 );三份脚本的字段名刻意保持一致这样上层 SQL 的列名不用改差异全部集中到类型映射和分页语法上。SQLite 那份的CREATED_AT用 TEXT 存 ISO8601是踩过坑之后的做法——早期用DATETIME声明写入时被驱动按本地时区转成字符串跨时区读回来就对不上改成显式 TEXT 后行为完全可控。2.2 数据访问层的抽象设计抽象层的核心是一个接口把「一个数据库需要提供的能力」列全。写得薄一点没关系但不能少方法否则上层迟早要if (kind ...)兜底public interface IDbProvider { // 数据库类型标识用于日志和分支判断 DatabaseKind Kind { get; } // 创建一个未打开的连接由调用方负责 Open/Dispose DbConnection CreateConnection(string connectionString); // 把统一的 {name} 占位符翻译成当前方言的写法 string TranslateSql(string templateSql); // 创建参数并绑定值内部处理类型映射 DbParameter CreateParameter(string name, object? value); // 表名/列名包裹Oracle 用双引号SQL Server 用方括号 string QuoteIdentifier(string rawName); // 生成分页 SQL三套库写法完全不同 string BuildPagingSql(string baseSql, string orderBy, int skip, int take); }CreateConnection返回未打开的连接而不是直接Open()是因为调用方往往需要在打开后立刻设置CommandTimeout或者挂事件提前打开会让这些操作无处安放。这个约定写在接口注释里实际项目中最容易被后来的人改坏。配套的工厂类负责根据配置创建具体实现用的是延迟加载避免在只跑 SQLite 的环境里触发 Oracle 程序集的加载public static class DbProviderFactory { // 缓存已创建的 providerConfiguration 里通常是单例注册 private static readonly ConcurrentDictionaryDatabaseKind, FuncIDbProvider Registry new() { [DatabaseKind.SqlServer] () new SqlServerProvider(), [DatabaseKind.Oracle] () new OracleProvider(), [DatabaseKind.Sqlite] () new SqliteProvider() }; public static IDbProvider Create(DatabaseKind kind) { // 用函数注册而不是直接 new保证只有被选中的那个 provider 才会加载对应程序集 if (!Registry.TryGetValue(kind, out var factory)) throw new NotSupportedException($未注册的数据库类型: {kind}); return factory(); } }这里用FuncIDbProvider而不是直接存实例原因是程序集加载时机如果字典里直接new SqlServerProvider()静态构造函数执行时就会去解析Microsoft.Data.SqlClient的引用在没装这个包的机器上会直接抛FileNotFoundException报错信息还很难看懂。改成函数之后只有真正调用Create的那一刻才会触发加载。ConnectionOptions是一个简单的配置模型对应appsettings.json里的一段属性类型说明KindDatabaseKind枚举值SqlServer / Oracle / SqliteConnectionStringstring原始连接串不做任何加工CommandTimeoutSecondsint默认 30报表类查询要调到 120 以上EnableSensitiveLoggingbool打开后日志里会打印参数值测试环境用生产必须关2.3 依赖与版本三个驱动怎么选选型这块没有太多悬念但每个包都有坑值得单独说一句数据库推荐 NuGet 包关键取舍SQL ServerMicrosoft.Data.SqlClient不要再用System.Data.SqlClient后者已停止演进且不支持新版 TLSOracleOracle.ManagedDataAccess.Core纯托管实现不需要在机器上装 Instant ClientSQLiteMicrosoft.Data.Sqlite轻量底层是 SQLitePCLRaw跨平台打包省事.csproj里按项目分别引用不要图省事全塞进 App!-- DataDemo.Providers.SqlServer.csproj -- ItemGroup PackageReference IncludeMicrosoft.Data.SqlClient Version5.* / /ItemGroup !-- DataDemo.Providers.Oracle.csproj -- ItemGroup PackageReference IncludeOracle.ManagedDataAccess.Core Version23.* / /ItemGroup !-- DataDemo.Providers.Sqlite.csproj -- ItemGroup PackageReference IncludeMicrosoft.Data.Sqlite Version8.* / /ItemGroup版本号用5.*这种浮动写法是因为这几个包的安全补丁发得比较勤锁死小版本过几个月就会在漏洞扫描里被标红。但如果项目要发布到客户现场且长时间不更新我一般会把*换成具体版本并在 README 里记一笔避免不同机器还原出不同版本导致行为不一致。如果团队里还有人在用System.Data.OracleClient那套东西早就从 .NET Core 里移除了迁移成本比想象中高——连接串格式、异常类型、参数绑定行为都不一样别指望改个包名就能编译通过。3. 核心代码拆解一次查询从模板到结果的完整链路抽象层定义好之后真正花时间的是三套 Provider 的具体实现。这一章按调用链顺序拆先创建连接再把统一 SQL 模板翻译成方言然后绑定参数执行最后处理分页和自增主键。这四步每一步都有方言差异抄代码的时候按顺序对照着改就行。3.1 连接创建与生命周期管理连接对象必须由调用方用using释放这一点三套库都一样但连接池的行为差别不小。SQL Server 和 SQLite 的驱动默认开启池化Oracle 托管驱动也默认开启问题在于很多人不知道「关闭连接」只是把连接还给池真正的物理连接还挂着public sealed class SqliteProvider : IDbProvider { public DatabaseKind Kind DatabaseKind.Sqlite; public DbConnection CreateConnection(string connectionString) { // Microsoft.Data.Sqlite 默认 Poolingtrue连接串里不需要再写 // 注意SQLite 的 Data Source 是文件路径相对路径会以工作目录为基准 var builder new SqliteConnectionStringBuilder(connectionString) { // 开启外键约束SQLite 默认是关的这个坑很深 ForeignKeys true, // 忙等待超时单位秒并发写的时候能少一堆 database is locked DefaultTimeout 30 }; return new SqliteConnection(builder.ToString()); } }ForeignKeys true这行不是可有可无的装饰。SQLite 的外键约束默认关闭建表脚本里写了REFERENCES也不会生效测试时插一堆脏数据都不报错等上线后发现关联表数据对不上排查起来非常痛苦。这个开关只能在连接级别打开写在建表脚本里是无效的。Oracle 那边需要注意连接串的写法托管驱动支持 EZConnect 格式不需要tnsnames.orapublic sealed class OracleProvider : IDbProvider { public DatabaseKind Kind DatabaseKind.Oracle; public DbConnection CreateConnection(string connectionString) { // EZConnect 格式: User Idxxx;Passwordxxx;Data Sourcehost:1521/service_name // 如果用 SID 而不是 service_name要写成 (DESCRIPTION(ADDRESS...)(CONNECT_DATA(SIDxxx))) var conn new OracleConnection(connectionString); // 关闭语句缓存可以规避某些版本的游标泄漏问题代价是解析开销上升 // 一般保持默认 true 即可只有在压测发现游标数持续上涨时才改 return conn; } }CreateConnection只创建不打开调用方拿到后自己Open()。这样做的另一个好处是可以在Open()前后加日志计时统计连接建立耗时——Oracle 首次连接往往要一两秒这个数据在排查「接口偶发超时」时很有用。3.2 参数占位符归一化一处模板三处方言这是整个 Demo 里最值得抄的一段。三套库的参数前缀和绑定规则完全不同数据库参数前缀参数名大小写备注SQL Servername不敏感后跟标识符Oracle:name不敏感但绑定按位置参数名不要带冒号SQLite$name/name/:name敏感三种前缀都支持选一种统一即可统一的做法是在业务 SQL 里写{name}执行前翻译public sealed class ParameterBinder { private readonly char _prefix; public ParameterBinder(DatabaseKind kind) { _prefix kind switch { DatabaseKind.SqlServer , DatabaseKind.Oracle :, DatabaseKind.Sqlite $, _ throw new NotSupportedException(kind.ToString()) }; } /// summary /// 把 {name} 形式的模板翻译成方言占位符。 /// 注意这里替换的是占位符名称不是值值仍然走 DbParameter 绑定不存在注入风险。 /// /summary public string Translate(string templateSql) { if (string.IsNullOrWhiteSpace(templateSql)) throw new ArgumentException(SQL 不能为空, nameof(templateSql)); var sb new StringBuilder(templateSql.Length 16); var i 0; while (i templateSql.Length) { // 找到 { 并且后面紧跟合法标识符才认定是占位符 if (templateSql[i] {) { var end templateSql.IndexOf(}, i 1); if (end i 1) { var name templateSql.Substring(i 1, end - i - 1); if (IsValidName(name)) { sb.Append(_prefix).Append(name); i end 1; continue; } } } sb.Append(templateSql[i]); i; } return sb.ToString(); } // 只允许字母、数字、下划线避免把 SQL 里的其他花括号误判成占位符 private static bool IsValidName(string name) name.Length 0 name.All(c char.IsLetterOrDigit(c) || c _); }逐字扫描而不是用Regex.Replace是因为正则在超长 SQL 上会有性能开销而且{在 JSON 函数、正则表达式字面量里出现得很频繁误伤概率不低。逐字判断「{ 合法标识符 }」这个组合实际项目里几乎不会误判。翻译完之后绑定参数这一步是类型映射的战场public DbParameter CreateParameter(string name, object? value) { var p _command.CreateParameter(); // Oracle 的参数名不能带冒号且 ParameterName 大小写要与 SQL 中一致 p.ParameterName name; // DBNull 和 null 要区分开直接赋 null 会抛异常 p.Value value ?? DBNull.Value; // 显式指定类型避免驱动做隐式转换导致索引失效 if (value is DateTime dt) { // Oracle 的 DATE 只精确到秒TIMESTAMP 才有毫秒 // 这里统一按 TIMESTAMP 处理建表脚本里对应字段也必须是 TIMESTAMP p.DbType DbType.DateTime2; p.Value dt; } return p; }p.Value value ?? DBNull.Value这一行看着简单实际是新手最容易写错的地方之一。直接给null在 SQL Server 上可能不报错但在 Oracle 托管驱动上会抛ArgumentNullException而且异常信息不指向具体参数名调试时只能一个个排除。SQLite 的类型映射更宽松NUMERIC存的是 double金额字段如果直接走decimal会有精度损失。Demo 里的做法是在写入前把decimal转成string存 TEXT读取时再decimal.Parse牺牲一点查询灵活性换取精度准确。这个取舍在财务类字段上是必须的。3.3 事务与批量写入单条插入通常不是瓶颈真正要处理的是批量。三套库的批量方式完全不同Demo 里用同一个入口方法封装public int InsertOrders(IDbProvider provider, string connStr, IReadOnlyListOrderRow rows) { if (rows.Count 0) return 0; using var conn provider.CreateConnection(connStr); conn.Open(); // 显式事务SQLite 如果不包事务每条 insert 都会 fsync 一次速度差几十倍 using var tx conn.BeginTransaction(); try { using var cmd conn.CreateCommand(); cmd.Transaction tx; cmd.CommandText provider.Translate( INSERT INTO T_ORDER (ORDER_NO, AMOUNT, CREATED_AT) VALUES ({no}, {amount}, {created})); // 参数只创建一次循环里改 Value这是批量插入的标准写法 var pNo provider.CreateParameter(no, null); var pAmount provider.CreateParameter(amount, null); var pCreated provider.CreateParameter(created, null); cmd.Parameters.Add(pNo); cmd.Parameters.Add(pAmount); cmd.Parameters.Add(pCreated); foreach (var row in rows) { pNo.Value row.OrderNo; pAmount.Value row.Amount; pCreated.Value row.CreatedAt; cmd.ExecuteNonQuery(); } tx.Commit(); return rows.Count; } catch { // 不吞异常回滚后原样抛出让上层决定重试还是记日志 tx.Rollback(); throw; } }参数对象在循环外创建、循环内改Value这是 ADO.NET 的标准复用方式。如果在循环里cmd.Parameters.Clear()再重新Add每次都会触发一次参数解析实测在十万条量级上能差出三四倍时间。行数上到十万级以上逐条ExecuteNonQuery就不够看了。SQL Server 用SqlBulkCopyOracle 用ArrayBindingcmd.ArrayBindCountSQLite 只能靠事务包裹加PRAGMA synchronousOFF提速。Demo 里预留了IBulkInserter接口但没全部实现改的时候按目标库补上就行。分批次提交也是常见做法每 5000 条Commit一次再开新事务避免一个超大事务把回滚段撑爆。Oracle 对这个特别敏感一个几十万行的事务很容易把 UNDO 表空间写满。3.4 分页与自增主键的方言处理分页语法三套库各写各的而且同一套库的不同版本还不一样public string BuildPagingSql(string baseSql, string orderBy, int skip, int take) { return Kind switch { // SQL Server 2012 支持 OFFSET/FETCH必须带 ORDER BY DatabaseKind.SqlServer ${baseSql} ORDER BY {orderBy} OFFSET {skip} ROWS FETCH NEXT {take} ROWS ONLY, // Oracle 12c 才有 OFFSET/FETCH之前的版本要套两层 ROWNUM DatabaseKind.Oracle $SELECT * FROM (SELECT a.*, ROWNUM rn FROM ({baseSql} ORDER BY {orderBy}) a $WHERE ROWNUM {skip take}) WHERE rn {skip}, // SQLite 最直接LIMIT 在前 OFFSET 在后 DatabaseKind.Sqlite ${baseSql} ORDER BY {orderBy} LIMIT {take} OFFSET {skip}, _ throw new NotSupportedException(Kind.ToString()) }; }这里把skip和take直接拼进 SQL 而不是走参数是因为 Oracle 的 ROWNUM 分页在外层还要再包一层参数位置不好统一。安全上没问题因为这两个值来自代码内部的int不来自用户输入——但如果哪天有人把它们改成从请求里取必须加校验。自增主键的获取方式差异更大。SQL Server 用SCOPE_IDENTITY()Oracle 12c 之前要靠序列加RETURNING INTOOracle 12c 之后可以用GENERATED BY DEFAULT AS IDENTITYSQLite 用last_insert_rowid()而且必须在同一个连接上调用// SQL Server cmd.CommandText INSERT INTO T_ORDER (...) VALUES (...); SELECT CAST(SCOPE_IDENTITY() AS BIGINT);; var id Convert.ToInt64(cmd.ExecuteScalar()); // SQLite必须在同一连接、同一事务内 cmd.CommandText INSERT INTO T_ORDER (...) VALUES (...); SELECT last_insert_rowid();; var id Convert.ToInt64(cmd.ExecuteScalar()); // Oracle 12c cmd.CommandText INSERT INTO T_ORDER (...) VALUES (...) RETURNING ID INTO :newId; var outParam new OracleParameter(newId, OracleDbType.Decimal, ParameterDirection.Output); cmd.Parameters.Add(outParam); cmd.ExecuteNonQuery(); var id Convert.ToInt64(outParam.Value.ToString());Oracle 那段用了输出参数返回的是decimal转long时要注意别直接强转。last_insert_rowid()是连接级别的如果用连接池拿到的是另一个连接返回的会是 0 或者别的表的 ID这个坑在并发场景下很难复现。4. 跑起来三套环境从零连通看完代码就该动手了。建议的顺序是先 SQLite 后 SQL Server 最后 Oracle——SQLite 零配置能最快验证抽象层和 SQL 翻译逻辑是否正常SQL Server 本地实例好搞Oracle 最麻烦放在最后前面跑通了心里有底。4.1 第一步用 SQLite 把链路跑通SQLite 不需要装任何服务端一个文件就是一个库。先确认 .NET SDK 版本然后还原依赖# 查看 SDK 版本Demo 用的是 .NET 8最低要求 6.0 dotnet --list-sdks # 还原所有项目依赖 dotnet restore DataDemo.sln # 编译先确保没有引用错误 dotnet build DataDemo.sln -c Debug建库直接执行脚本用sqlite3命令行或者任意可视化工具都行# 创建库文件并导入建表脚本 sqlite3 ./data/demo.db ./sql/sqlite/01_schema.sql # 验证表和字段 sqlite3 ./data/demo.db .schema T_ORDER配置文件里把Kind改成SqliteConnectionString指向刚生成的库文件。注意相对路径的基准是进程工作目录dotnet run的时候是项目目录发布之后是 exe 所在目录两边不一致会导致「本地能跑、发布后找不到库」。稳妥的写法是用AppContext.BaseDirectory拼绝对路径var dbPath Path.Combine(AppContext.BaseDirectory, data, demo.db); var connStr $Data Source{dbPath};跑起来之后控制台会依次输出插入条数、分页查询结果、事务回滚验证。三个用例都通过说明抽象层和 SQLite Provider 是通的。4.2 SQL Server本地实例与容器二选一本机已经装了 SQL Server Express 或者 LocalDB 的直接改连接串就行Serverlocalhost\SQLEXPRESS;DatabaseDataDemo;Trusted_ConnectionTrue;TrustServerCertificateTrue;TrustServerCertificateTrue在新版驱动里几乎是必加项。Microsoft.Data.SqlClient4.0 之后默认EncryptTrue本地实例用的是自签证书不加这个参数会直接报证书链错误错误码还比较绕第一次遇到容易懵。没有本地实例的用容器起一个最省事docker run -d --name demodbsql \ -e ACCEPT_EULAY \ -e MSSQL_SA_PASSWORDStr0ng!Passw0rd \ -p 1433:1433 \ mcr.microsoft.com/mssql/server:2022-latest起来之后用sqlcmd或者任意客户端执行sql/sqlserver/01_schema.sql。连接串换成Serverlocalhost,1433;DatabaseDataDemo;User Idsa;PasswordStr0ng!Passw0rd;TrustServerCertificateTrue;注意端口那里是逗号不是冒号这是 SQL Server 连接串的固定格式写错了会一直超时。4.3 Oracle托管驱动省掉客户端安装用Oracle.ManagedDataAccess.Core的最大好处是不用装 Instant Client省掉一堆ORA-12154之类的环境问题。有现成 Oracle 实例的连接串按 EZConnect 写User Iddemo;Passworddemo123;Data Source192.168.1.10:1521/ORCLPDB1;Data Source最后那段是 service name不是 SID。这两个搞混会报ORA-12505: TNS:listener does not currently know of SID given in connect descriptor看起来像网络问题实际是名字写错了。查 service name 可以连上去执行-- 查当前实例的 service name SELECT value FROM v$parameter WHERE name service_names;如果只有 SID 没有 service name连接串要写成完整描述符Data Source(DESCRIPTION(ADDRESS(PROTOCOLTCP)(HOST192.168.1.10)(PORT1521))(CONNECT_DATA(SIDORCL)));执行建表脚本时Oracle 的IDENTITY列需要 12c 及以上版本。老版本要改成序列加触发器CREATE SEQUENCE SEQ_T_ORDER START WITH 1 INCREMENT BY 1; CREATE OR REPLACE TRIGGER TRG_T_ORDER_ID BEFORE INSERT ON T_ORDER FOR EACH ROW WHEN (NEW.ID IS NULL) BEGIN SELECT SEQ_T_ORDER.NEXTVAL INTO :NEW.ID FROM DUAL; END;这种写法在并发下有序列缓存丢失的问题但业务上一般不敏感。改的时候记得把 Provider 里获取自增 ID 的逻辑一起换掉用RETURNING INTO配合序列的NEXTVAL。4.4 配置开关与运行命令三种环境都准备好之后靠配置文件切换不用改代码{ Database: { Kind: Sqlite, ConnectionString: Data Source./data/demo.db, CommandTimeoutSeconds: 30, EnableSensitiveLogging: false } }启动时按Kind走工厂# 用 SQLite 跑 dotnet run --project src/DataDemo.App -- --dbSqlite # 用 SQL Server 跑 dotnet run --project src/DataDemo.App -- --dbSqlServer # 用 Oracle 跑 dotnet run --project src/DataDemo.App -- --dbOracle三个都跑通再往下看踩坑章节因为下面列的问题基本都出现在「一套代码三套库」这种组合场景里单库环境反而遇不到。5. 踩坑排查跨库适配最容易翻车的五个地方这一章是血泪经验汇总。多库适配的坑有个共同特点单库测试时完全正常切到另一套库才暴露而且报错信息往往指向错误的地方。下面五条按「现象 → 原因 → 解决」写遇到对应的现象直接对照。5.1 连接与字符集相关的坑现象一中文写成问号读出来是???。在 SQL Server 上一切正常切到 Oracle 或者 SQLite 就变问号。原因通常有两层Oracle 那边是客户端字符集NLS_LANG和服务端字符集不一致托管驱动虽然不依赖NLS_LANG环境变量但数据库本身的字符集如果是WE8ISO8859P1这类单字节集中文根本存不进去SQLite 那边则是列类型用了VARCHAR而不是TEXT虽然 SQLite 类型亲和性会兜底但某些 ORM 会按声明类型做转换。解决方式Oracle 建库时确认字符集是AL32UTF8查询用SELECT * FROM nls_database_parameters WHERE parameter NLS_CHARACTERSET;字段类型统一用NVARCHAR2。SQLite 的字符串列一律写TEXT不要写VARCHAR(n)长度限制在 SQLite 里本来也不生效。现象二连接池耗尽报Timeout expired... The pool has reached its maximum size。本地开发不复现压测或者上生产之后偶发。原因八成是某处DbConnection没有Dispose异常分支里提前return了连接一直挂在池里不还。解决方式所有连接创建必须包在using里异常路径也不例外。检查时可以临时把Max Pool Size调小到 5 再压测问题会暴露得非常快。Oracle 那边还要注意DbDataReader也必须Dispose只关连接不关 reader游标数会一直涨。5.2 参数与类型映射相关的坑现象三ORA-01008: not all variables bound。SQL 里明明写了三个参数也Add了三个Oracle 就是不认。原因通常是参数名带了冒号p.ParameterName :no或者 SQL 里的占位符和参数名大小写不一致。Oracle 绑定按名字匹配带前缀会匹配不上。解决方式ParameterName一定不带前缀SQL 里的:no和参数名no严格对应。翻译层统一处理前缀别在业务代码里手写。现象四时间字段读出来少了毫秒或者差 8 小时。SQL Server 用DATETIME只精确到 3.33 毫秒Oracle 的DATE只到秒SQLite 存字符串的格式和时区更是完全靠自己控制。跨库对时间做比较或者排序时结果会不一致。解决方式建表时统一用高精度类型SQL ServerDATETIME2(3)、OracleTIMESTAMP(3)、SQLite TEXT 存 ISO8601 带时区偏移写入前统一转 UTC读出后由展示层转本地时区。这一条定死之后跨库的时间比较才可靠。5.3 并发与资源释放相关的坑现象五SQLite 报database is locked。单线程跑没问题一旦有并发写入就偶发。原因是 SQLite 的写锁是库级别的同一时刻只允许一个写事务另一个连接写入时会等待超过DefaultTimeout就抛异常。解决方式连接串里加DefaultTimeout30写操作全部包在事务里减少持锁时间读多写少的场景开启 WAL 模式PRAGMA journal_modeWAL;读写可以并发。如果业务上写并发确实高SQLite 就不是合适的选择该换库就换库别硬扛。再补一条容易被忽略的事务里调用另一个 Provider 的方法。比如在 SQL Server 的事务里顺手调了一个走 SQLite 的日志方法两个事务互不相干回滚的时候日志已经写进去了数据对不上还查不出原因。跨库操作要么走分布式事务成本高要么改成事务提交后再写日志后者在实际项目里更常见。6. 二次开发把 Demo 改成能上生产的数据访问层Demo 能跑通不等于能上生产中间差的那层东西才是真正花时间的。这部分说的是改造思路不是让你把 Demo 原样搬进项目里。6.1 抽出仓储接口让业务层不认识 DbCommand最该做的第一件事是把IDbProvider再包一层。业务代码里到处都是DbCommand、DbDataReader换库的时候改起来很痛苦。加一层仓储接口泛型约束到实体类型public interface IOrderRepository { Tasklong InsertAsync(OrderRow row, CancellationToken ct default); TaskOrderRow? GetByNoAsync(string orderNo, CancellationToken ct default); TaskPagedResultOrderRow QueryAsync(OrderQuery query, int page, int size, CancellationToken ct default); }实现类里用IDbProvider完成方言处理业务层只依赖接口。这样即使将来要加第四种数据库比如 PostgreSQL只需要新增一个 Provider 和一个仓储实现业务代码一行不改。PagedResultT里放Items、Total、Page分页总数的查询单独写一条COUNT(*)别指望OFFSET/FETCH能顺手带回来。6.2 异步、取消与重试Demo 里的同步方法在生产环境基本不能用。改造时把ExecuteNonQuery换成ExecuteNonQueryAsyncDbDataReader换成ExecuteReaderAsync并且每个方法都带上CancellationTokenpublic async Taskint InsertAsync(OrderRow row, CancellationToken ct default) { await using var conn _provider.CreateConnection(_connStr); await conn.OpenAsync(ct); await using var cmd conn.CreateCommand(); cmd.CommandText _provider.Translate( INSERT INTO T_ORDER (ORDER_NO, AMOUNT, CREATED_AT) VALUES ({no}, {amount}, {created})); cmd.Parameters.Add(_provider.CreateParameter(no, row.OrderNo)); cmd.Parameters.Add(_provider.CreateParameter(amount, row.Amount)); cmd.Parameters.Add(_provider.CreateParameter(created, row.CreatedAt)); // CommandTimeout 对异步方法同样生效超时后取消令牌会触发 cmd.CommandTimeout _options.CommandTimeoutSeconds; return await cmd.ExecuteNonQueryAsync(ct); }这里注意await using而不是using连接和命令对象的异步释放会等着底层资源真正归还。CommandTimeout是秒级不设的话默认 30 秒报表查询很容易超时。重试策略要小心用。插入类操作重试必须靠唯一索引兜底否则网络抖动时会插重复数据。查询类操作可以放心重试。加一个轻量的策略操作类型是否重试重试次数备注单条查询是2指数退避100ms 起批量插入否-改为幂等写入靠唯一键去重连接获取失败是3池满或网络抖动间隔 500ms6.3 上线前的验证清单改造完之后别急着上按下面几步走一遍能省掉很多回滚三个 Provider 用同一套集成测试跑一遍测试用例里包含中文、emoji、null、超长字符串、边界时间用Max Pool Size5压测观察连接数是否稳定回落断网 30 秒再恢复看重试逻辑是否按预期生效日志里能不能看出重试了几次故意在事务中途抛异常确认回滚干净关联表没有残留把 Oracle 的DATE字段改成TIMESTAMP之后重新跑一次全量测试这条最容易漏。最后说一句经验之谈跨库适配这件事代码本身不难难的是「一套代码在三套库上行为一致」。我现在的习惯是每加一个新 SQL先在三个库上手工跑一遍确认结果集、字段类型、错误信息都符合预期再合进主分支。那一次因为 Oracle 的DATE精度问题在凌晨三点排查数据的教训让我从那以后每次改 SQL 都强制走一遍这个流程。这份 dataDemo 的价值不在代码有多复杂而在于它把三套方言的差异摆在一张桌面上让你改的时候知道该动哪里。仓库里的sql/目录和三个 Provider 对照着看基本一下午就能把骨架摸清。希望帮到你。本文还有配套的精品资源点击获取