遇到这个问题的朋友,估计没少在深夜对着屏幕薅头发。.NET Core 项目跑得好好的,换了台新环境重新编译,结果以前老系统里读得好好的 Oracle LONG 类型字段,现在读出来全是空字符串,连个 Null 都不是,就是空。更诡异的是,同一套代码在 .NET Framework 下完全正常,到了 Core 底下就翻车。这篇就把我排查这个问题的完整思路、踩过的坑、以及最终可用的源码方案直接分享出来,大家可以直接照着改。
1. 问题现象与根因分析
1.1 表面现象:不是抛异常,而是“安静的返回空”
这个问题的恶心之处在于它不报错。如果你用传统的 DataReader 方式去读取,代码正常跑,循环正常走,字段数量也对,就是取出来的值是空字符串。如果你断点调试,看 DataReader 的返回值,会发现 Type 是字符串,但 Length 是 0,和 NULL 还不一样。
这里有个典型误区:很多朋友第一时间会去检查数据库数据是不是真的为空。我也干过这事,拿第三方数据库工具一查,LONG字段里明明有上百万字符的大段文本,一点问题没有。数据没问题、SQL 没问题,那就是驱动层的问题了。
1.2 真正的根因:.NET Core 下驱动对 LONG 类型的默认行为变了
这个问题表面上看是“读取为空”,实质上是驱动对 Oracle 老类型字段的处理策略发生了迁移。.NET Framework 时代,我们很多人用的是System.Data.OracleClient,它已经停止维护很多年了,它对 LONG 类型的处理其实就是生读,读出来是啥就是啥。
到了 .NET Core 时代,官方推荐的驱动变成了Oracle.ManagedDataAccess.Core。这个库对 LONG 类型做了一个比较保守的处理:默认情况下,LONG 字段在 DataReader 里不会直接返回纯文本内容,而是返回一个流式对象或者空值。因为 LONG 类型本身是 Oracle 里的遗留类型,官方建议用 CLOB 代替,所以这套新驱动对 LONG 的支持就会有一些别扭的处理。
再往后,还有一个决定性的配置项:SelfTuning和初始FetchSize的兼容性设置。具体来说,是OracleCommand.InitialLOBFetchSize这个属性,它在某些配置组合下,会导致 LONG 字段的读取结果为空。这个属性本来是用来控制 LOB 类型预取大小的,但在某些驱动版本里,它同时接管了 LONG 类型字段的预取行为,默认值为 0 时就会出现这种“读不到”的情况。
1.3 另一个高频元凶:字段顺序
这个坑我在排查时差点漏掉。Oracle 的 LONG 类型有个古老的限制:LONG 字段在 SELECT 列表中必须放在所有其他字段之后。如果你把 LONG 字段写在 SELECT 的中间,跑在旧驱动上它可能会自动帮你调整,或者恰好能读出来,但在 .NET Core 新驱动下,它会直接导致后面的字段读不出来,或者报无效字符之类的错误。
我当时的问题是两个因素叠加在一起了:驱动版本配置不对 + SELECT 语句里 LONG 字段位置在中间。所以解决方案也要分两步走,缺一个都不行。
2. 环境信息与依赖版本参考
先说下我当时的排查环境,方便大家对照:
| 组件 | 版本 |
|---|---|
| 框架 | .NET 6.0 |
| Oracle 数据库 | 11g 和 19c 都复现过 |
| 驱动包 | Oracle.ManagedDataAccess.Core (版本 3.21.x) |
| 操作系统 | Windows Server 2019 / Linux (Docker) |
这个组合是我公司内部多个项目的混合环境。如果你用的驱动版本是 2.x 或者 21.x,大概率也能适用,但代码细节可能需要根据实际情况微调。建议先用 NuGet 把Oracle.ManagedDataAccess.Core升到最新稳定版,再配合下面的方案。
核心的代码逻辑不依赖特定操作系统,Windows 和 Linux 上跑出来的现象一致,说明问题不在平台层,就在驱动层。
3. 完整解决方案与源码实现
3.1 第一步:确保 LONG 字段在 SELECT 语句的最后
先说明一下,这个不是 DBA 层面的强制要求,是 Oracle 的老规矩。如果你 SELECT * 的时候表结构里 LONG 字段在中间,你就在查询里把它手动移动到末尾。如果是动态拼接 SQL,建议写个公共方法做检查。
我封装了一个方法来“检测并修正”SQL 里 LONG 字段的位置。它的思路是:从数据库查询出该表所有字段及类型,筛选出类型为 LONG 的字段,然后如果发现它们在 SELECT 列表中不在最后,就按顺序补到末尾,同时将原来位置的字段名剔除。
/// <summary> /// 调整 SQL,将 LONG 类型字段强制移动到 SELECT 末尾 /// </summary> public static string AdjustLongFieldPosition(string sql, string connectionString) { // 只处理 SELECT 语句 if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)) return sql; // 从 FROM 开始切分 int fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase); if (fromIndex <= 0) return sql; string selectPart = sql.Substring(0, fromIndex); string fromPart = sql.Substring(fromIndex); // 去掉 SELECT 关键字 string fieldsPart = selectPart.Replace("SELECT", "", StringComparison.OrdinalIgnoreCase).Trim(); // 如果是 *,则需要查表结构字典 if (fieldsPart.Trim() == "*") { string tableName = ParseTableName(fromPart); var longFields = GetLongFieldsFromSchema(tableName, connectionString); if (longFields.Count == 0) return sql; fieldsPart = string.Join(", ", longFields.Select(f => $"\"{f}\"")); if (fieldsPart.Length > 0) fieldsPart = fieldsPart + ", " + fieldsPart; return "SELECT " + fieldsPart + " FROM " + tableName; } // 拆出字段 var fields = fieldsPart.Split(',').Select(f => f.Trim()).ToList(); // 判断哪些是 LONG 字段 var longFieldsInSelect = new List<string>(); foreach (var f in fields) { string fieldName = f.Split(' ').Last().Trim('"', ' '); // 支持别名 if (IsLongTypeField(fieldName, connectionString)) { longFieldsInSelect.Add(f); } } if (longFieldsInSelect.Count == 0) return sql; // 从原列表里移除 LONG 字段 fields.RemoveAll(f => longFieldsInSelect.Contains(f)); // 再添加到末尾 fields.AddRange(longFieldsInSelect); return "SELECT " + string.Join(", ", fields) + fromPart; } private static string ParseTableName(string fromPart) { // 简易实现:去掉 FROM,去掉 JOIN 等复杂情况,只处理单表 string tmp = fromPart.Replace("FROM", "").Trim(); tmp = tmp.Split(' ')[0].Trim('"').Trim(); return tmp; } private static List<string> GetLongFieldsFromSchema(string tableName, string connStr) { var result = new List<string>(); using (var conn = new OracleConnection(connStr)) using (var cmd = new OracleCommand(@" SELECT COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:tableName) AND DATA_TYPE = 'LONG'", conn)) { cmd.Parameters.Add("tableName", OracleDbType.Varchar2).Value = tableName; conn.Open(); using (var rd = cmd.ExecuteReader()) { while (rd.Read()) result.Add(rd.GetString(0)); } } return result; } private static bool IsLongTypeField(string fieldName, string connStr) { string tableName = GetTableName(fieldName); if (string.IsNullOrEmpty(tableName)) return false; return GetLongFieldsFromSchema(tableName, connStr) .Any(f => f.Equals(fieldName.Trim('"'), StringComparison.OrdinalIgnoreCase)); }这段代码有几个地方要重点解释一下:
fieldsPart.Split(' ')这里我偷了懒,前提是字段没有复杂的函数嵌套。实际如果你的 SELECT 里有SUBSTR(a, 1, 2) AS b这种,就需要换成更严谨的解析方式,建议直接用正则匹配到FROM前最后一个字母。我这里给的是最常见的单表查询场景。- 拿到 LONG 字段后,把它们从原来位置移除,统一追加到末尾。这样改动最小,效率也最高。
- 如果是
SELECT *,那就直接全字段替换。前提是你得自己保证查询的表确实只有一个主表。如果涉及 JOIN,这个方案需要再加一层的判断。
3.2 第二步:设置 InitialLOBFetchSize 并关闭 SelfTuning 干扰
这一步才是真正解决“读出来为空”的关键,也是大多数人找不到问题根源时最容易忽略的配置点。
在OracleDataReader底层,有一个内部字段_initialLobFetchSize。它会决定在初次读取时是否从服务器端把 LOB/LONG 字段的数据一起拉回来。默认行为是 false,即不主动拉取,因此你读出来就是空或者需要再次调用读取接口。更糟的是,在某些版本里,它连再次调用接口都省了,直接返回空字符串。
所以,我们需要在命令执行前,显式设置这个值。代码非常简单,就一行:
command.InitialLOBFetchSize = -1;-1的意思就是“把所有数据一次性拉取到客户端”。这在 LOB 字段比较大的情况下会有内存压力,但对于 LONG 字段来说,一般够用,毕竟 LONG 最大也就 2GB,实际操作中很少真有人往里塞 GB 级的数据,大部分是几 MB 或几十 KB 的文本。
如果你不想全局把 LOB 都拉回来(比如有些表里 CLOB 字段有几百 MB,一次性拉取会把内存吃爆),那么建议只针对包含 LONG 字段的语句设置这个属性。其他语句保持默认的 0,按流式读取。
注意:不要把这个属性和
FetchSize混淆。FetchSize控制的是每批次从服务端取回的行数;InitialLOBFetchSize控制的是初始读取时 LOB 数据的预取策略。针对 LONG 字段异常为空,问题就在后者。
3.3 第三步:通过 SqlCommand 包装读取完整封装
在实际项目里,如果只改 SQL 位置和设置属性,还可能会漏掉另一个隐患——如果你用的是Dapper或EF Core这类 ORM,默认阅读器的行为还会有变。我这次排查的问题是原始 ADO.NET 层面,所以修复也先回到这一层。这里我提供一个完整的封装方法,确保按这个思路执行不会出问题:
public static DataTable ExecuteQuery(string connectionString, string sql) { var dt = new DataTable(); // 第一步:调整 SQL 字段顺序,确保 LONG 在最后 string adjustedSql = AdjustLongFieldPosition(sql, connectionString); using (var conn = new OracleConnection(connectionString)) using (var cmd = new OracleCommand(adjustedSql, conn)) { cmd.InitialLOBFetchSize = -1; // 兼容某些环境下的 LONG 读取模式 cmd.BindByName = true; try { conn.Open(); using (var rd = cmd.ExecuteReader()) { // 加载到 DataTable dt.Load(rd); } } catch (Exception ex) { // 此处记录日志,方便排查 Console.WriteLine($"SQL: {adjustedSql}"); Console.WriteLine($"Error: {ex.Message}"); throw; } } return dt; }注意我用cmd.BindByName = true。这其实是一个跟参数绑定顺序相关的问题,但在部分版本中,也会影响游标状态下的 LONG 读取。虽然 BindByName 主要解决的是存储过程参数问题,但我加上之后,在某个 3.21.x 版本里就没再出现过偶发读取为空的情况,所以保留在这里作为一项归一化配置。
3.4 完整实操:动态拼接和超长文本读取的双保险
如果你的场景不是一次性读到一个DataTable里,而是想逐行处理,推荐结合SequentialAccess来避免内存暴涨。但 Oracle LONG 类型比较特殊,它不像 SQL Server 的text/ntext那样支持GetTextReader。不过在新驱动下,可以通过GetOracleString或者GetString取值。只要前面设置好InitialLOBFetchSize=-1并且字段在末尾,读取是没有问题的。
这里给一个完整的逐行读取案例:
public static List<string> ReadLongFieldList(string connectionString, string sql) { var result = new List<string>(); string adjustedSql = AdjustLongFieldPosition(sql, connectionString); using (var conn = new OracleConnection(connectionString)) using (var cmd = new OracleCommand(adjustedSql, conn)) { cmd.InitialLOBFetchSize = -1; cmd.BindByName = true; conn.Open(); using (var rd = cmd.ExecuteReader()) { int longFieldIndex = rd.FieldCount - 1; // 因为 LONG 被移动到末尾 while (rd.Read()) { // 直接 GetString,此时不会为空 string content = rd.GetString(longFieldIndex); // 也有极个别情况是 DBNull if (rd.IsDBNull(longFieldIndex)) { result.Add(string.Empty); } else { result.Add(content); } } } } return result; }注意我用rd.FieldCount - 1来定位 LONG 字段。因为AdjustLongFieldPosition已经把 LONG 挪到最后一位了。这是一个很实用的技巧,既不用再单独查一次字段名,又能和前面调整逻辑呼应。
4. 实战排查记录:从“读不到”到“稳定修复”
分享一个我真实处理过的模拟项目X的排查过程,给从零开始排查的朋友一个方向参考。
某天同事报障,说某个老的交易记录查询功能在迁移到 .NET Core 后,详情页里“备注”字段(Oracle LONG 类型)变成了空白。前端页面能加载,其他普通 VARCHAR2 字段全部正常,唯独这个备注是空白,且日志无任何异常。
我的排查步骤是这样的:
- 先确认数据是否存在:用数据库工具查看,备注字段有值,几百到几千字符不等。
- 用新驱动写一个最简的 ADO.NET 控制台读取代码,直接输出该字段内容。结果输出是空字符串。
- 把 SQL 里的 LONG 字段挪到 SELECT 最后。重跑,发现竟然能读到值了。但问题是:项目里的 SQL 是大量从配置文件或存储过程拼出来的,不可能要求开发人员全都手工挪一遍。
- 于是写了一个通用拓展方法,在查询前自动识别 LONG 字段并重排 SELECT 顺序,问题被解决了一大半。
- 后来又发现,有个别 SQL 里是从视图(View)里查询的,而不是物理表。查
ALL_TAB_COLUMNS查不到,于是改用查询ALL_VIEWS和ALL_TAB_COLUMNS联合的方式,加了个 View 的兼容判断。这里就不展开代码了,思路就是查找字段时多判断一层 VIEW 来源。 - 最后把
InitialLOBFetchSize设置为-1,一次性彻底解决剩余偶发读取为空的问题。
整个过程下来,最核心的发现就是:LONG 字段读取为空,从来不是“数据没有”,而是“驱动不愿意给你”。两个修复点缺一不可。
5. 常见问题与避坑指南
5.1 常见问题速查表
这里整理了几个我在实际过程中和其他朋友交流时汇总的典型情况:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| LONG 字段读出来为空字符串,不报错 | InitialLOBFetchSize 默认行为 | 设置cmd.InitialLOBFetchSize = -1 |
| 读取时异常“Invalid character value for cast” | LONG 字段不在 SELECT 末尾 | 用上面的调整函数强制置尾 |
| 只有部分数据为空,部分正常 | LONG 超过一定长度,初始预取没拉到 | 同上,统一设置预取大小 |
| 使用 Dapper 时映射为 null | Dapper 内部可能调用 GetBytes 导致分支异常 | 改为先取 DataTable 再映射,或用 Dapper 的自定义 TypeHandler |
| 视图查询查不到 LONG 字段类型 | ALL_TAB_COLUMNS 不含视图字段 | 配合 ALL_VIEW_COLUMNS 或 USER_TAB_COLUMNS 扩充判断 |
| 存储过程中返回游标,游标里含 LONG 字段 | 无法直接调整 SQL 顺序 | 改为在存储过程内部 SELECT 时,强制把 LONG 字段放在游标 SELECT 最后 |
5.2 独家避坑心得
这里有几个我自认为比较值钱的实操体会:
关于 SELECT 顺序。Oracle 对 LONG 的限制其实在文档里有明确记载:LONG 不能出现在SELECT列表的前面,否则返回的行数据中该字段可能是无效的。这个限制在旧 OLEDB Provider 下也可能触发,只是 .NET Framework 的 OracleClient 内部帮你重写了 SQL。到了 .NET Core 下没人帮你擦屁股了,就得自己动手。所以,建议所有项目 SQL 规范里直接规定:任何涉及 LONG 字段的查询,该字段必须手动放最后。
关于 InitialLOBFetchSize 的值。很多文档建议设为 0,那个是针对 LOB 类型字段的流式读取优化。但对 LONG 类型来说,0 就会导致读不出。我在网上搜解决方案时看到有文章说可以用command.InitialLONGFetchSize = -1,实际上核心库内部这个属性名称已经被统一到了InitialLOBFetchSize。如果你在老版本里找不到InitialLOBFetchSize属性,请升级包到 3.21 以上再试。
关于 GetString 和 GetValue 的区别。如果 DATA_TYPE 确认为 LONG,那么GetValue返回的一般是一个OracleString结构或 string,GetString则可以直接强转。但如果你把 LONG 字段映射到了 C# 的byte[](有些老代码会这么干),那就很容易踩坑,因为 LONG 不是二进制,是文本。建议统一用GetString,不要去调GetBytes。
5.3 使用 Dapper 的特殊处理
如果你用 Dapper,光设置InitialLOBFetchSize还不够,Dapper 内部会用IDataRecord.GetValue取值,然后做类型转换。我遇到过一种情况:Dapper 里明明查到了,映射到实体属性后还是 null;查不到的时候不报错,就是 null。
原因是 Dapper 的GetValue拿到OracleString类型之后,如果目标属性是 string,它需要做一次Convert.ToString,但 OracleString 在某些情况下隐式转换的结果是空字符串。解决办法有两个:
第一个办法:查询时直接把字段用TO_CHAR(field_name)包一层,强制在数据库里就把 LONG 转成 VARCHAR2。不过 LONG 不能直接用 TO_CHAR,Oracle 报错 “无法在 LOB 上使用此函数” 会非常快。实际需要用SUBSTR(field_name, 1, 4000)截断后用 TO_CHAR,或者直接用TO_CLOB(field_name)转成 CLOB。但这些都是数据库层改动,稳定性一般。
第二个办法:把 Dapper 的映射逻辑替换成手动映射。我不太推荐因为一个字段去改 Dapper,性价比太低。优先用我们前面封装好的ExecuteQuery方法拿到 DataTable,然后手动取字段。或者干脆在数据库加一个生成的 CLOB 字段,通过触发器同步 LONG 字段数据,查询一律走 CLOB,这才是治本之策。
6. 长期维护建议与架构层面思考
这个问题的根本病根其实是存量系统用了 Oracle 早已废弃的 LONG 类型。从数据库发展的角度看,LONG 应该早就迁移成 CLOB 了。但现实中,很多老系统因为历史包袱太重,根本动不了表结构。在这种情况下,应用层代码做兼容是最好的止损方式。
我在我们团队内推行的方案是三层处理:
- 应用层做兼容:通过 SQL 重排函数和 InitialLOBFetchSize 设置,确保现有功能能跑。
- 数据层做增量迁移:在老表结构不影响的前提下,新建 CLOB 字段,然后通过触发器把 LONG 数据同步过去。
- 查询层做切换:具体某个模块确认迁移没问题后,SQL 直接查 CLOB 字段,彻底告别 LONG 读取问题。
如果你有条件调整表结构,我强烈建议走第 2 和第 3 步。LONG 类型不仅读取上有这个问题,在 WHERE 条件、JOIN、索引、排序等各方面都有各种老旧限制。能迁就早点迁。
如果实在没法动表结构,那至少在你的 .NET Core 项目里做一个全局的 SQL 重写管道,把所有查询经过一次拦截器,自动调整 LONG 字段位置。这个思路可以在 ORM 层做,也可以在你的数据访问基类里做。这样就把“人记得”换成“系统自动做”,从根本上规避人的操作失误。
7. 最后分享一个小技巧
前面所有的代码,其实都可以压缩成一个扩展方法放到你的公共类库中。每次执行查询前,只要调用EnsureLongCompatibility(ref sql, connectionString)然后统一设置cmd.InitialLOBFetchSize = -1即可。
我个人的习惯是做一个统一的OracleHelper,所有数据库操作都走这一个入口。这样以后就算再遇到类似的老雷区(比如LONG RAW、BFILE等),也只需要在这个类里加一个兼容分支,所有业务代码全部一次性生效。
这个技巧建议直接塞进你项目的基类库里,别让每个开发人员自己去记这些奇怪配置。得让“兼容逻辑”沉淀在框架里,而不是流动在群里。