简介:面向Delphi与C++Builder开发者的ODAC(Oracle Data Access Components)连接控件资料包,聚焦在应用中高效接入Oracle数据库这一核心问题。内容围绕TOracleConnection连接管理展开,并覆盖TOracleQuery、TOracleTable、事务处理、存储过程与函数调用、VCL/FMX数据绑定、异步执行、性能优化、错误处理及多版本Oracle兼容等模块,适合需要实现数据库连接、查询与事务控制的中级开发者查阅和复用。压缩包共1807个文件,约9.7MB,包含396个pas单元、263个dcu编译单元、140个dfm窗体、134个dpk包及res、bmp、xfm、cfg、bdsproj等配套文件,既有源码也有示例与工程配置,便于按组件和示例模块检索。已有471人学习下载。资料可用于理解ODAC组件结构、参照示例工程搭建连接与查询逻辑,并借助事务、存储过程和异步操作等示例排查实际开发中的连接与数据处理问题。
1. 老 Delphi 系统连 Oracle,为什么还在用 ODAC 这类连接控件
接手一个跑了七八年的 Delphi 老系统,数据库在 Oracle 上,中间层没人敢动,新需求还得往上加。你翻数据访问层会发现,用的多半不是要额外装客户端的那套方案,而是 ODAC 这类 Oracle 连接控件:一套 TOraSession、TOraQuery、TOraSQL 把 Delphi 进程和 Oracle 直接连起来,不走 BDE,也不绕 dbExpress 那层壳。
它解决的问题很具体:让一个纯 Delphi 进程在不给目标机器装 Oracle 客户端的条件下,稳定连库、取数、跑事务、进连接池;同时保留 Delphi 那套 DataSet 语义,老代码迁移成本低。适合谁?维护存量 Delphi + Oracle 项目的人、要给老系统做数据导出和报表的人,以及部署环境受限、只能直连数据库的项目。下面按「怎么连上、查询怎么写、并发怎么扛、坑在哪」拆开讲。
2. 第一次连上库:直连模式与客户端模式怎么选、连接串怎么写
选型这一步走错,后面所有调试都是在错误的地基上打补丁。ODAC 这类控件有两种连库模式,很多人是装完控件直接抄一段别人的代码,连上了就再也没回头看,等到换一台部署机才发现根本跑不起来。
2.1 直连和 OCI 两种模式,先定死一个
直连模式(Direct)是控件自己实现 Oracle 的网络协议,进程加载的是控件自带的运行库,不需要目标机器上存在 Oracle 客户端目录、不需要配 tnsnames.ora、不需要设 ORACLE_HOME。它的代价是:控件要自己跟服务端的协议版本对齐,一些很偏的特性(比如某些高级队列、部分对象类型)支持度不如原生客户端。
OCI 模式是控件去调用本机的 Oracle 客户端动态库,把连接、游标、绑定这些活儿交给客户端做。它的前提很硬:位数必须一致。你的 Delphi 编译出来是 32 位进程,就必须装 32 位的客户端;是 64 位进程,就装 64 位。混装的典型表现不是报「位数不匹配」这种友好错误,而是加载动态库失败,或者直接一个语焉不详的异常。
我一般的判断顺序是这样:
| 判断条件 | 选直连 | 选 OCI |
|---|---|---|
| 部署机器能不能装额外软件 | 不能 | 能 |
| 进程位数和现有客户端是否一致 | 无所谓 | 必须一致 |
| 是否用到冷门 Oracle 特性 | 慎用 | 优先 |
| 部署包体积敏感 | 敏感 | 不敏感 |
| 现场运维是否熟悉 tnsnames 配置 | 不熟 | 熟 |
中小型业务系统、只做增删改查和存储过程调用的,直连模式基本够用,而且部署最省事。涉及大量对象类型、XMLType、或者要和现场既有客户端配置保持一致的,老实走 OCI。
一个血泪经验:不要在同一个项目里一部分模块直连、一部分模块走 OCI。两条路径的连接串格式、字符集行为、错误码包装都不一样,出了问题你会在两个方向上同时排查,非常浪费时间。
2.2 最小可跑通的连接与探活代码
先别急着往窗体上拖控件,用纯代码建 Session,跑通一次再谈别的。这样做的好处是连接参数集中在一个地方,后面抽成配置或者连接工厂都方便。
uses Ora, DB, SysUtils, OraError; // AHost 形如 db-host;AService 是服务名或 SID,取决于直连模式的写法 function OpenSession(const AHost: string; APort: Integer; const AService, AUser, APwd: string): TOraSession; begin Result := TOraSession.Create(nil); try Result.LoginPrompt := False; // 后台服务里绝不能让登录框弹出来 Result.Options.Direct := True; // 直连,不依赖本机 Oracle 客户端 Result.Options.UseUnicode := True; // 统一走 Unicode,减少字符集踩坑 Result.Server := Format('%s:%d:%s', [AHost, APort, AService]); Result.Username := AUser; Result.Password := APwd; Result.Connect; // 连不上会抛 EOraError,交给上层处理 except Result.Free; // 失败必须释放,否则句柄泄漏 raise; end; end;这段代码里有三个地方值得单独说。LoginPrompt := False是硬性要求,只要你的程序有可能跑在服务、计划任务、无人值守环境里,任何会弹窗的属性都要关掉,否则进程会挂在那里等一个永远不会有人点的确定按钮。Options.Direct必须在Connect之前设置,连上之后再改是无效的。Server的拼接格式在直连模式下和 OCI 模式不同,直连一般用「主机:端口:服务标识」这种冒号分隔形式,而走 OCI 时这里通常填的是 tnsnames 里的别名。具体到某个控件版本可能还有差异,拿不准的时候以控件自带的帮助文档为准,别拿网上的老帖子当标准。
连上之后别急着写业务,先做一次探活。生产环境最怕的不是连不上,是「连接对象还在、底层链路已经断了」这种半死状态。
function IsSessionAlive(ASession: TOraSession): Boolean; var Q: TOraQuery; begin Result := False; if (ASession = nil) or (not ASession.Connected) then Exit; Q := TOraQuery.Create(nil); try Q.Session := ASession; Q.SQL.Text := 'SELECT 1 FROM DUAL'; Q.Open; Result := Q.Fields[0].AsInteger = 1; Q.Close; except on E: EOraError do Result := False; // 网络层异常一律判死,交给外层重连 end; Q.Free; end;探活用SELECT 1 FROM DUAL而不是SELECT SYSDATE,因为前者不涉及任何字符集和日期格式转换,是最小开销的往返。返回一个整型比对,避免有些环境把字段值包装成字符串再比较。探活频率不要太高,空闲连接池里每分钟一次已经够用,太频繁反而会给数据库增加无谓的解析压力。
2.3 连接串与 Session 参数逐项拆解
连接失败十次里有七次是参数问题。把下面这张表对着填一遍,能省掉大量试错。
| 参数 | 典型值 | 说明 | 踩坑点 |
|---|---|---|---|
| Server | host:1521:ORCL | 直连模式下的目标描述 | 服务名和 SID 混用是最常见错因 |
| Username | app_user | 业务账号 | 别用高权限账号跑业务 |
| Password | 密文或从配置读取 | 不要硬编码 | 明文写在源码里迟早出事 |
| Options.Direct | True | 是否直连 | 必须在 Connect 前设置 |
| Options.UseUnicode | True | 是否按 Unicode 处理字符串 | 开着和关着,中文字段两种表现 |
| LoginPrompt | False | 是否弹登录框 | 后台进程必须关 |
| ConnectTimeout | 10 到 15 秒 | 建连超时 | 默认太长,故障时线程全卡住 |
Server这一项最容易被想当然。Oracle 的「服务名」和「SID」是两个概念,很多现场给你的信息是服务名,但示例代码里写的是 SID 格式,照着填就是连不上,报的还是「无监听」这类容易误导人的错误。判断方法很简单:问清楚对方 DBA 给的是服务名还是 SID,直连模式下两者写法不同;拿不准就先用 OCI 模式的 tns 别名打通一次,确认服务名之后再回来配直连。
超时参数不要留默认。一个典型的翻车场景是:数据库主机被下线,网络层不回 RST,TCP 连接要等到系统级超时才失败。这期间你的每个工作线程都卡在Connect上,界面看着像死了。把建连超时压到 10 到 15 秒,配上外层的失败快速返回,用户体验完全不一样。
3. TOraQuery 的写法决定成败:参数化、取数模式与事务边界
连上库只是开始,真正拉开代码质量差距的是查询怎么写。同一张表、同样的数据量,不同的写法在内存占用和响应时间上能差出一个数量级,而这两种写法在代码上看往往只差几个属性赋值。
3.1 参数化查询与字段类型映射
拼接 SQL 字符串是 Delphi 老代码里最普遍的坏习惯。除了注入风险,它还有一个更现实的代价:每次拼接出来的文本都不一样,Oracle 每次都要做硬解析,共享池很快被这些一次性语句撑爆。用参数绑定,语句文本固定,游标可以复用。
procedure TOrderService.LoadOrders(const ACustomerId: Integer; AList: TList<TOrder>); var Q: TOraQuery; begin Q := TOraQuery.Create(nil); try Q.Session := FSession; Q.SQL.Text := 'SELECT ORDER_ID, ORDER_NO, AMOUNT, CREATED_AT ' + 'FROM T_ORDER ' + 'WHERE CUSTOMER_ID = :CUSTOMER_ID ' + ' AND CREATED_AT >= :FROM_TIME ' + 'ORDER BY CREATED_AT DESC'; Q.ParamByName('CUSTOMER_ID').AsInteger := ACustomerId; Q.ParamByName('FROM_TIME').AsDateTime := IncDay(Now, -30); Q.FetchAll := False; // 关键:不要一次性把结果全读进内存 Q.FetchRows := 200; // 每次从服务端取 200 行 Q.Open; try while not Q.Eof do begin AList.Add(ReadOrderFrom(Q)); Q.Next; end; finally Q.Close; end; finally Q.Free; end; end;参数的命名前缀用冒号,控件会把它解析成绑定变量,ParamByName按名字取,顺序无关。这里我特意用了AsInteger和AsDateTime而不是统统AsString,因为类型转换交给数据库做的时候,隐式转换可能导致索引失效。一个典型现象是:CREATED_AT上有索引,但你传的是字符串,执行计划里走到了全表扫描,开发环境数据少看不出来,生产环境一上来就是几秒。
字段读取这块有个对应关系值得记牢:
| Delphi 侧类型 | Oracle 侧类型 | 备注 |
|---|---|---|
| AsInteger | NUMBER(整数位) | 超出范围要用 AsInt64 |
| AsFloat | NUMBER(p,s) | 金额建议在数据库侧定好精度 |
| AsDateTime | DATE | 不含时区 |
| AsDateTime | TIMESTAMP | 毫秒会被截断,需要单独处理 |
| AsString | VARCHAR2 | 长度超限会静默截断 |
| AsString | CLOB | 大文本要注意流式读取 |
AsString读 CLOB 是常见的性能陷阱。字段小的时候没问题,一旦碰上几兆的文本,每个字段都完整加载到内存,一页二十条记录就是几十兆。稳妥做法是对 CLOB 字段单独用流式读取,只在你确实需要内容的时候才拉全文。
3.2 FetchAll 与 FetchRows:取数模式对内存的影响
FetchAll默认是打开的时候全量拉到本地,对客户端来说方便,对内存来说灾难。一个十万行的结果集,如果每行有十几个字符串字段,进程占用冲到几百兆是常态,而且这些内存在Close之前不会归还。
正确姿势分三种情况:
- 结果集确定很小(配置表、字典表),
FetchAll := True无所谓,代码还简单。 - 结果集中等或用一次就丢,
FetchAll := False,FetchRows设成 100 到 500,边读边处理。 - 只需要往前读一遍做导出,把
UniDirectional也打开,客户端不用维护双向游标,内存和速度都会更好。
FetchRows的值不是越大越好。设成 500 看着很激进,实际取决于你的行宽和服务端往返延迟:内网环境可以放大到 500 到 1000,跨机房或者专线要高一些才划算,延迟大的链路反而该调大到 2000 以上减少往返次数。判断依据是看一次取数的耗时里网络等待占多少,不是凭感觉。
还有一个容易忽略的点:FetchAll := False之后,如果结果集没读完就Close,控件会向服务端发一条取消请求。如果你的循环里因为异常提前退出了,Close一定要放在finally里,否则游标会一直挂在服务端,长期跑下来就是「超出最大打开游标数」那类报错。
3.3 事务边界、锁等待与批量提交
控件的会话默认不是自动提交的,这一点和某些数据库的驱动不一样,很多人是靠「跑完发现数据真的进去了」来反推的,早晚要出事。显式事务,边界画在业务方法这一层,别散落在各个查询里。
const BATCH_SIZE = 500; procedure WriteLogs(const ARows: TArray<LogRow>); var Q: TOraQuery; i, N: Integer; begin Q := TOraQuery.Create(nil); try Q.Session := FSession; Q.SQL.Text := 'INSERT INTO T_LOG(ID, MSG, CREATED_AT) ' + 'VALUES(:ID, :MSG, :CREATED_AT)'; FSession.StartTransaction; try N := 0; for i := 0 to High(ARows) do begin Q.ParamByName('ID').AsInteger := ARows[i].Id; Q.ParamByName('MSG').AsString := ARows[i].Msg; Q.ParamByName('CREATED_AT').AsDateTime := ARows[i].CreatedAt; Q.ExecSQL; Inc(N); if (N mod BATCH_SIZE) = 0 then begin FSession.Commit; // 分段提交,控制 undo 增长 FSession.StartTransaction; end; end; FSession.Commit; except FSession.Rollback; raise; end; finally Q.Free; end; end;分段提交的取舍很明确:一次大事务提交慢、undo 表空间压力大、失败要全量回滚;分段提交速度快、失败只影响当前段,但代价是前几段已经落盘了,回滚不回来。所以分段提交只能用于「幂等写入」或者「允许部分成功」的场景,比如日志、指标、可重跑的同步任务。有严格一致性要求的业务写入,老老实实一个事务到底。
锁的问题多半出在SELECT ... FOR UPDATE上。默认行为是无限等待,一旦有个会话忘了提交,整条业务链路就挂在那里,直到有人发现。加上NOWAIT或者用带超时的写法,让它在几秒内失败抛出,比一直挂着强得多——挂着的时候你连「是谁锁的」都查不出来,失败抛出至少能立刻定位。
4. 多线程与连接池:TOraSession 能不能共享这件事
图形界面程序单线程跑惯了,一遇到批量导出、报表计算就卡界面的问题,直觉反应是扔到线程里。这一步做对了性能起飞,做错了就是随机崩溃加数据串行错乱,而且极难复现。
4.1 为什么一个线程一个 Session
会话对象内部维护着网络连接、游标状态、事务上下文,这些东西天然不是线程安全的。两个线程共用一个会话去执行查询,最典型的症状是:本来查订单的语句,返回了客户表的数据;或者抛出「游标无效」的异常,但单独跑又完全正常。这类问题的玄学之处在于它依赖线程切换时机,测试环境压不出来,上线压测一跑就炸。
规则简单粗暴:一个工作线程,一个独立 Session,线程结束就释放。不要为了省连接把会话做成全局单例给所有线程用,那是在赌运气。
type TOrderLoadTask = class(TThread) private FHost: string; FPort: Integer; FService, FUser, FPwd: string; FRows: TArray<OrderRow>; protected procedure Execute; override; end; procedure TOrderLoadTask.Execute; var S: TOraSession; begin S := TOraSession.Create(nil); try S.LoginPrompt := False; S.Options.Direct := True; S.Options.UseUnicode := True; S.ConnectTimeout := 15; S.Server := Format('%s:%d:%s', [FHost, FPort, FService]); S.Username := FUser; S.Password := FPwd; S.Connect; FRows := QueryOrders(S); // 线程内完成取数,结果留在对象上 finally S.Free; // 线程结束即释放,不缓存到全局 end; end;注意Execute里捕获异常的处理。TThread里抛出的异常如果不接住,会被吞掉或者变成一条弹窗,主线程根本不知道任务失败了。规范做法是把异常对象也存到线程字段上,等OnTerminate里在主线程统一处理。
4.2 池化参数怎么调才不把你拖死
每个线程建一次连接,在线程数量不多的时候没问题。一旦是「导出任务队列,每个任务一个线程」这种模式,建连开销会变成瓶颈:一次 TCP 握手加 Oracle 的认证,几十毫秒到上百毫秒,任务本身可能才跑 10 毫秒。
会话池化就是为这个场景准备的,把连接建好放进池子,用完归还而不是真正关闭。
| 参数 | 含义 | 建议起点 | 调整方向 |
|---|---|---|---|
| Pooling | 是否启用池化 | True | 长驻服务建议开 |
| MaxPoolSize | 池内最大连接数 | 10 到 20 | 按并发任务数上限调 |
| MinPoolSize | 保底连接数 | 2 到 5 | 有常驻任务时不要为 0 |
| ConnectionLifetime | 连接最长存活时间 | 30 到 60 分钟 | 配合中间网络设备的空闲回收 |
| Validate on acquire | 取用时是否校验 | 开启 | 生产环境建议开,省掉大量半死连接 |
MaxPoolSize是最容易设错的一个。设太小,任务排队等连接,吞吐上不去;设太大,数据库侧的会话数被打满,报「进程数超限」。参考值是数据库允许的会话数除以你的应用实例数,再留 20% 余量给运维操作。别拍脑袋写个 100,那不是性能优化,那是 DDoS 自己家的库。
ConnectionLifetime这个参数很多人不设,结果连接一直活着,直到某天被中间的防护设备静默掐断,池子里全是一堆看着正常、实际已经断掉的连接。设一个比设备空闲回收时间短的值,让连接主动轮换,比事后做探活补救要省心。
4.3 线程里取数、主线程渲染的交接方式
取数在线程里,往界面上填必须回主线程。常见的翻车写法是在线程里直接操作表格的Rows.Add,单机测试时因为切换很快看着没事,多核机器上一跑就出错。
两种主流交接方式。一是Synchronize,简单直接,但会阻塞工作线程等主线程空闲,数据量大时明显拖慢。二是把结果放进线程安全队列,主线程用定时器批量消费,一次刷新 500 条,界面流畅度提升明显。批量刷新的逻辑里记得先BeginUpdate,填完再EndUpdate,这个细节能省掉大量重绘开销。
另外,TThread的OnTerminate触发时,Execute已经结束,这时候再去读线程对象上的业务字段要保证已经写完,别让主线程读到一半的数据。
5. 避坑排查:五类让程序半夜报警的连接故障
线上问题不会按分类出现,但排查时可以按层次收窄:先确认是连不上、还是连上了但数据不对、还是跑着跑着自己断了。这三层对应的原因完全不同。
5.1 现象:报「无监听」或连接超时,但别人能连上
原因:多半是连接标识写错了,服务名当成 SID 用,或者端口填成了别的实例。直连模式下格式要求严格,少一个冒号都连不上。另一种情况是目标机器有多个网卡或多个实例,你连的和别人连的不是同一个。
解决:先让现场把服务名和 SID 明确区分开,直连模式下分别用对应格式各试一次;再用数据库侧的工具确认目标实例的监听端口。如果本地能连、服务器上不能连,检查服务器出站策略和 DNS 解析,很多「连不上」其实是主机名解析到了错误的内网地址。日志里把实际使用的连接串(脱敏后)打出来,比反复猜要快。
5.2 现象:中文全变成问号,或者日期报格式错误
原因:字符集在两个层面可能不一致。一是客户端侧的字符集设置和数据库不一致,二是驱动在处理字符串时没走 Unicode。日期问题则来自会话的日期格式,同一句 SQL 在 A 机器上能跑,在 B 机器上就报「格式模型无效」。
解决:字符串统一按 Unicode 处理,连接参数里把对应选项打开;如果必须用本地字符集,就在连接建立之后显式执行一次会话级的格式设置,别依赖环境变量。日期一律用参数绑定传AsDateTime,不要拼字符串,也不要写依赖隐式转换的TO_DATE调用。这个坑的隐蔽之处在于:它对日期的月/日顺序不敏感的环境下不报错,只是把 3 月 5 日悄悄存成了 5 月 3 日,等到对账才发现。
5.3 现象:程序跑了一两小时,突然开始报连接已关闭,重启就好
原因:连接被中间设备或服务端按空闲时间回收了,客户端这边还认为连接有效。另一种可能是池里的连接有泄漏,用完了没归还,池被耗尽之后新建连接又被拒绝。
解决:分两步。第一步把连接最长存活时间设得比网络设备的空闲回收时间短,主动轮换;第二步在取用连接的时候做一次轻量校验,失败就丢弃重建。连接泄漏的排查靠计数:在借用和归还处各打一条日志,跑一小时统计差值,差值持续增长就说明有代码路径忘了归还,多半是异常分支里提前Exit了。
5.4 现象:取数越来越慢,进程内存一路涨到几 GB
原因:全量取数没关,FetchAll默认为真,几十万行一次性进内存。或者每轮循环都新建了查询对象但只Free了一部分,句柄没释放。
解决:把取数模式改成流式,FetchRows设为 100 到 500,逐行处理完立刻释放。用内存快照对比一次任务前后的占用,正常应该回到基线附近,回不去就说明有对象没释放。另外检查try..finally是否覆盖了所有Create,尤其是Open抛异常的那条路径,很多人只在成功分支写了Free。
5.5 现象:多线程下偶发崩溃,日志里看不出任何规律的报错
原因:会话或者数据集被多个线程共享。也可能是界面控件在工作线程里被直接操作。
解决:审查所有会话对象的创建点,确认它们的作用域不跨越线程。给每个工作线程配独立会话,线程结束即释放。界面更新全部收敛到主线程,用队列加批量刷新替代逐条Synchronize。这类问题别指望靠加锁解决,把共享改成不共享才是正路,加锁只会把偶发崩溃变成稳定变慢。
6. 加一层薄封装:把重试和错误翻译收在一个地方
写到这里,最后说一个我自己每接一个 Delphi + Oracle 项目都会做的事:在会话之上加一层不超过两百行的封装,把「哪些错误可以重试」「重试几次」「错误怎么变成人话」这三件事收口。不这么做,你会发现重试逻辑散落在几十个调用点,每个地方写得还不一样。
重试的核心不是「失败就再来一次」,而是分辨错误性质。网络闪断、监听短暂不可用这类瞬时故障,重试有意义;语法错误、约束冲突,重试一万次结果一样,还白白多跑几轮。
function IsTransient(const ACode: Integer): Boolean; begin case ACode of 12541, 12545, 12537, // 监听/连接层瞬时不可用 3113, 3114, // 连接被意外中断 12170: // 连接超时 Result := True; else Result := False; // 其余一律不重试,交给业务层处理 end; end; function ExecWithRetry(ASession: TOraSession; AProc: TProc; const AMaxTry: Integer = 3): Boolean; var I: Integer; begin Result := False; for I := 1 to AMaxTry do begin try AProc(); Exit(True); except on E: EOraError do begin if (not IsTransient(E.ErrorCode)) or (I = AMaxTry) then raise; // 不可重试或已到上限,原样抛出 Sleep(100 * I); // 退避,避免瞬时故障被连续击穿 if not ASession.Connected then ASession.Connect; // 连接层断了才重连 end; end; end; end;这段代码有三个约束要守住。第一,被包进AProc的操作必须是幂等的,或者你能保证失败时事务已完整回滚,否则重试等于重复写入。第二,重试只包裹到「连接层」这一级,业务校验失败不要放进重试范围。第三,退避时间随重试次数递增,别用固定间隔把故障窗口再压一遍。
验证这套封装有没有用,不要靠感觉。我在会话上挂了两个计数器:总执行次数和重试次数,外加每次Connect的耗时。跑一周之后看比例,重试率长期高于千分之一,说明网络或数据库侧有稳定问题,得治根,不能靠重试掩盖;建连耗时如果出现离散的长尾,那多半是服务端监听在抖动,日志里能直接定位到时段。
踩过最贵的一次坑,是在错误处理里对OraError只取了Message没取ErrorCode,结果所有错误翻译成同一句「数据库操作失败」,运维只能拿着时间去对日志。从那以后我的习惯是:任何跨进程边界的异常,第一件事是把错误码和原始消息一起落盘,第二件事才是翻译成人话给界面看。
希望帮到你。
本文还有配套的精品资源,点击获取