1. 这不是“导入工具说明书”,而是Oracle数据装载的底层逻辑课
你有没有遇到过这样的场景:一张千万级订单表,用INSERT一条条插要跑两小时;SQL*Loader跑完只要93秒,但日志里突然冒出一行“ORA-01722: invalid number”,排查半天发现是源文件里某行多了一个不可见空格;或者建了个外部表,SELECT时明明文件路径没错、字段对得上,却死活查不出数据,最后发现是字符集没对齐,UTF-8和AL32UTF8混用导致整行被跳过。这些不是操作失误,而是你还没真正看懂Oracle数据装载这台“引擎”的活塞怎么运动、气门怎么开闭。
今天聊的Oracle数据装载,核心就三块:SQL*Loader(sqlldr)、外部表(External Table),以及它们共同依赖的底层原理——Oracle如何把磁盘上的原始字节流,变成内存里可被SQL引擎识别的结构化行数据。这不是教你怎么敲命令,而是带你拆开外壳,看清数据从文本文件进入数据库缓冲区的每一步转化:字符编码怎么映射、字段分隔符怎么被识别、空值和默认值怎么触发、错误记录怎么被隔离、并行加载时锁和内存怎么协同。关键词“Oracle 原理”四个字是题眼——所有操作都必须锚定在Oracle的体系架构上:SGA里的共享池要缓存控制文件,PGA要分配排序区,数据文件头要更新检查点SCN,甚至归档日志里会记下每一批加载的事务边界。我带过的某高校数据库实训项目里,学生用sqlldr加载学生档案时反复失败,最后发现根本不是控制文件写错了,而是服务器临时表空间不足,导致sqlldr在构建索引时触发了ORA-01652,这种问题不看原理,光查语法文档永远找不到根因。
这篇文章适合三类人:第一类是刚接触Oracle运维的DBA,需要快速建立数据迁移的系统性认知,避免“改一个参数就重启实例”的粗放操作;第二类是做ETL开发的工程师,常要对接上游CSV/Excel数据,需要知道什么场景该用sqlldr、什么场景该切外部表、什么情况必须上Data Pump;第三类是备考OCP的考生,官方考纲里“Data Loading Methods”这一章的实操细节,90%的模拟题陷阱都藏在字符集转换和拒绝文件(bad file)的生成逻辑里。下面我们就从设计思路开始,一层层剥开这三层技术的内核。
2. 为什么不是“选一个工具就行”?三种方案的底层架构差异与选型逻辑
2.1 SQL*Loader:进程级数据泵,强控制弱交互
SQL*Loader本质是一个独立于Oracle实例之外的客户端进程,它不走SQL解析器,不占用Library Cache,也不生成SQL执行计划。当你执行sqlldr userid=scott/tiger control=load.ctl时,发生的是这样一系列动作:
- 预处理阶段:sqlldr进程读取控制文件(.ctl),解析
INFILE指定的源文件路径、INTO TABLE目标表名、FIELDS TERMINATED BY分隔符规则。此时它只做语法校验,不连接数据库。 - 连接与准备阶段:启动Oracle Net服务,建立到目标实例的专用连接(Dedicated Server)。注意:它不会像普通SQL那样走Shared Server模式,因为需要独占PGA内存来缓存数据块。
- 数据搬运阶段:这才是核心。sqlldr把源文件按行读入内存缓冲区(默认64KB,可通过
BINDSIZE调整),然后逐字段按控制文件定义的格式进行类型强制转换。比如COLUMN_NAME CHAR(10)遇到"123",会自动右补7个空格;COLUMN_NAME INTEGER EXTERNAL遇到" 456 ",会先trim再转整型。这个过程完全在客户端内存完成,不经过SQL引擎的隐式转换规则。 - 批量提交阶段:当缓冲区满(由
ROWS参数控制,默认64行),sqlldr向数据库发送一个直接路径插入(Direct Path Insert)请求。此时数据绕过Buffer Cache,直接写入数据文件的空闲数据块,并在Redo Log中记录块级变更(而非SQL语句级)。这也是它快的根本原因——省掉了Buffer Cache的LRU链表管理、脏块写回等开销。
提示:Direct Path Insert会自动禁用目标表上的所有触发器(TRIGGER),且无法回滚单条记录。这是性能换可控性的典型设计,也是为什么生产环境加载前必须停业务或确保无触发器依赖。
2.2 外部表:数据库原生视图,强集成弱性能
外部表则完全是另一套逻辑。它不是进程,而是Oracle数据字典里的一张虚拟表(Virtual Table)。创建语句CREATE TABLE ext_orders (...) ORGANIZATION EXTERNAL (...)执行后,Oracle只在数据字典里存下元数据:文件路径、字符集、字段定义。真正的数据读取发生在SQL执行时。
当你SELECT * FROM ext_orders WHERE order_date > '2023-01-01',Oracle优化器会做三件事:
- 第一,确认外部表访问驱动程序(Access Driver),最常用的是
ORACLE_LOADER(对应sqlldr的逻辑)或ORACLE_DATAPUMP(对应Data Pump); - 第二,将WHERE条件下推(Predicate Pushdown)到文件扫描层——这意味着Oracle不会把整文件读进内存再过滤,而是边读边判,类似grep的流式处理;
- 第三,调用操作系统API打开文件,按
RECORDS DELIMITED BY规则逐行解析,字段值通过PREPROCESSOR(可选)脚本预处理,再经字符集转换(CHARACTERSET参数指定)后返回给SQL引擎。
关键差异在于:外部表的每一次SELECT,都是一次完整的文件I/O+解析+转换流程,没有缓存机制。所以它适合“只读查询+少量高频访问”,不适合“一次性全量加载”。某金融公司曾用外部表替代每日ETL脚本,结果报表查询响应时间从2秒飙升到47秒,根源就是外部表每次查询都要重新打开、定位、解析10GB的交易日志文件。
2.3 直接路径 vs 常规路径:Oracle加载的“高速公路”与“城市道路”
所有Oracle数据装载技术,最终都逃不开这两个路径模式的选择。理解它们的区别,是选型的底层依据:
| 对比维度 | 直接路径(Direct Path) | 常规路径(Conventional Path) |
|---|---|---|
| 数据流向 | 客户端→数据文件(绕过Buffer Cache) | 客户端→Buffer Cache→数据文件 |
| 锁机制 | 表级锁(LOAD时锁定整个目标表) | 行级锁(逐行INSERT,可并发) |
| 触发器 | 自动禁用 | 正常触发 |
| 约束检查 | 仅检查NOT NULL,其他约束(如CHECK、FK)延迟到加载后 | 实时检查所有约束 |
| 日志生成 | Redo Log记录数据块变更(体积小) | Redo Log记录完整SQL语句(体积大) |
| 适用场景 | 大批量初始化、灾备恢复、测试数据生成 | 小批量更新、需触发器逻辑、在线业务维护 |
SQL*Loader默认走直接路径,但加DIRECT=FALSE可切常规路径;外部表只能走常规路径(因为它本质是SELECT);而Data Pump(expdp/impdp)则混合使用——元数据导出走常规路径,表数据导入默认直接路径。
注意:直接路径要求目标表不能有位图索引(Bitmap Index)、不能是索引组织表(IOT)、且必须处于NOLOGGING模式才能最小化日志。这些限制不是bug,而是Oracle为保证直接写入原子性做的架构妥协。
3. SQL*Loader实战:从控制文件编写到错误隔离的全流程拆解
3.1 控制文件(.ctl)不是配置模板,而是数据契约
控制文件是sqlldr的“宪法”,它定义了数据从文件到数据库的映射契约。一个典型的订单加载控制文件order_load.ctl长这样:
LOAD DATA INFILE '/data/incoming/orders_202405.csv' BADFILE '/data/bad/orders_202405.bad' DISCARDFILE '/data/discard/orders_202405.dis' APPEND INTO TABLE orders WHEN (1) != 'H' AND (1) != 'T' -- 跳过首尾标记行 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' TRAILING NULLCOLS ( order_id POSITION(1:10) INTEGER EXTERNAL, cust_name POSITION(12:50) CHAR, order_date POSITION(52:61) DATE "YYYY-MM-DD", amount POSITION(63:72) DECIMAL EXTERNAL, status POSITION(74:75) CHAR "UPPER(:status)", load_time SYSDATE, file_name CONSTANT '/data/incoming/orders_202405.csv' )这段代码里藏着五个关键设计点:
行过滤逻辑(WHEN子句):
(1) != 'H'表示跳过第1列是'H'的行(通常是Header行),(1) != 'T'跳过'T'(Trailer行)。这里POSITION(1)指文件每行的第1个字符,不是字段位置。很多初学者误以为WHEN是对字段值判断,结果所有数据都被跳过。字段定位(POSITION):
POSITION(1:10)明确指定order_id占每行的1-10个字节,这比TERMINATED BY更精准。当源文件存在空字段(如,,"北京",)时,TERMINATED BY会把空字符串当NULL,而POSITION能严格按列宽截取,避免字段错位。某电商项目曾因CSV中地址字段含逗号,用TERMINATED BY导致后续所有字段偏移,改用POSITION后问题消失。类型转换(INTEGER EXTERNAL / DATE "YYYY-MM-DD"):
EXTERNAL表示源文件中是字符形式,需转换;DATE后的格式串告诉Oracle如何解析"2024-05-20"。如果源日期是"20/05/2024",必须写DATE "DD/MM/YYYY",否则报ORA-01840。函数注入(UPPER(:status)):
:status是字段别名,UPPER()在加载时实时执行。这比在数据库里用UPDATE转换更高效,因为避免了二次I/O。常量与系统值(SYSDATE / CONSTANT):
load_time SYSDATE把当前时间写入字段;file_name CONSTANT把文件路径作为固定值存入。这解决了“数据来源追溯”问题,审计时可直接查file_name字段知道哪条记录来自哪个文件。
3.2 错误处理三件套:BADFILE、DISCARDFILE、LOGFILE的分工
sqlldr的健壮性,全靠这三个文件协同:
BADFILE(.bad):存储格式错误的原始行。比如
order_id字段写了"ABC123",而定义是INTEGER EXTERNAL,这行就会被写入bad文件。bad文件内容是原始文本,不带任何修改,方便人工修复后重载。DISCARDFILE(.dis):存储逻辑拒绝的行。由
WHEN子句或NULLIF条件触发。比如status CHAR NULLIF status = 'X',当status='X'时整行被丢弃,写入dis文件。dis文件也保留原始格式,但它是“业务规则过滤”,不是技术错误。LOGFILE(.log):运行日志,记录加载统计、内存使用、错误摘要。关键要看
Rejected和Discarded两行数字。如果Rejected=0但Discarded=1000,说明是业务规则在起作用,不是数据质量问题。
实操心得:生产环境必须配置
ERRORS=0(默认50)。否则当第51行出错时,sqlldr会自动停止,但前面50行已提交,造成数据不一致。设为0意味着“全成功才提交,任一失败即回滚”。
3.3 性能调优四把刀:BINDSIZE、ROWS、READSIZE、SILENT
sqlldr的默认参数是为通用场景设计的,面对TB级数据必须手动调优:
BINDSIZE(绑定缓冲区大小):默认64KB,决定每次向数据库发送的数据量。增大它可减少网络往返次数。实测某10亿行用户表,
BINDSIZE=4194304(4MB)比默认快3.2倍。但不能无限大——超过PGA_AGGREGATE_TARGET的20%会触发ORA-04030(内存耗尽)。ROWS(每次提交行数):默认64行。增大它降低提交频率,但会增加单次事务的Redo Log体积。平衡点通常在1000-5000行。计算公式:
ROWS ≈ BINDSIZE / 平均行宽。比如平均行宽200字节,BINDSIZE=2MB,则ROWS=10000较合理。READSIZE(读取缓冲区大小):控制sqlldr从文件读取的缓冲区,影响I/O效率。应≥
BINDSIZE,否则读取成为瓶颈。READSIZE=4194304是安全值。SILENT(静默模式):
SILENT=(HEADER,FEEDBACK)可关闭日志头和进度反馈,减少I/O等待。在后台批处理中,这能提升5%-8%吞吐量。
注意:所有参数必须在控制文件顶部
OPTIONS段声明,如OPTIONS (BINDSIZE=4194304, ROWS=5000, SILENT=(HEADER,FEEDBACK))。命令行参数优先级低于控制文件,易被覆盖。
4. 外部表深度解析:从创建语法到字符集陷阱的避坑指南
4.1 创建外部表的五个必填要素
外部表创建不是CREATE TABLE AS SELECT那么简单,它需要五个硬性组件:
CREATE TABLE ext_sales ( sale_id NUMBER, prod_name VARCHAR2(100), sale_date DATE, amount NUMBER(10,2) ) ORGANIZATION EXTERNAL ( TYPE ORACLE_LOADER DEFAULT DIRECTORY data_dir ACCESS PARAMETERS ( RECORDS DELIMITED BY NEWLINE BADFILE log_dir:'sales_%p.bad' LOGFILE log_dir:'sales_%p.log' FIELDS TERMINATED BY '|' LDRTRIM MISSING FIELD VALUES ARE NULL REJECT ROWS WITH ALL NULL FIELDS ( sale_id CHAR(10), prod_name CHAR(100), sale_date CHAR(10) DATE_FORMAT DATE MASK "YYYY-MM-DD", amount CHAR(12) ) ) LOCATION ('sales_202405.dat') ) REJECT LIMIT UNLIMITED;拆解这五个要素:
TYPE:必须是
ORACLE_LOADER(对应sqlldr逻辑)或ORACLE_DATAPUMP(对应Data Pump导出文件)。ORACLE_LOADER支持文本文件,ORACLE_DATAPUMP只支持.dmp文件。DEFAULT DIRECTORY:这是Oracle的安全沙箱。
data_dir必须是DBA创建的目录对象(CREATE DIRECTORY data_dir AS '/u01/data'),且赋予READ权限。绝对不能写绝对路径,否则报ORA-29400。ACCESS PARAMETERS:外部表的“控制文件”,定义文件解析规则。其中
LDRTRIM表示自动trim字段前后空格,MISSING FIELD VALUES ARE NULL处理缺失字段(如a|b||d中的第三个空字段),REJECT ROWS WITH ALL NULL FIELDS拒绝全空行。LOCATION:指定文件名,支持通配符
*.dat,但文件必须在DEFAULT DIRECTORY指向的物理路径下。Oracle不会递归子目录。REJECT LIMIT:控制错误容忍度。
UNLIMITED表示遇到错误继续,0表示任一错误即终止查询。注意:这是查询时的限制,不是创建时的。
4.2 字符集:90%外部表失败的罪魁祸首
字符集不匹配是外部表最常见的故障源。Oracle外部表涉及三套字符集:
- 数据库字符集(
SELECT * FROM NLS_DATABASE_PARAMETERS WHERE PARAMETER='NLS_CHARACTERSET') - 外部文件字符集(源文件保存时的编码,如UTF-8、GBK、ISO-8859-1)
- Oracle会话字符集(
NLS_LANG环境变量,如AMERICAN_AMERICA.AL32UTF8)
三者必须两两兼容。典型错误场景:
- 数据库是
AL32UTF8(Oracle的UTF-8实现),文件是Windows记事本保存的GBK,NLS_LANG设为AMERICAN_AMERICA.ZHS16GBK→ 查询正常; - 同样文件,
NLS_LANG设为AMERICAN_AMERICA.AL32UTF8→ 中文变乱码,因为Oracle用UTF-8解码GBK字节; - 文件是
UTF-8,但NLS_LANG未设置(默认US7ASCII)→ 所有非ASCII字符被替换为?。
解决方案只有两个:
- 统一为AL32UTF8:将源文件转为UTF-8(用Notepad++的“编码→转为UTF-8无BOM”),
NLS_LANG=AMERICAN_AMERICA.AL32UTF8; - 显式声明字符集:在
ACCESS PARAMETERS中加CHARACTERSET ZHS16GBK,强制Oracle用GBK解码。
实操技巧:用Linux命令
file -i filename.dat查看文件真实编码;用iconv -f GBK -t UTF-8 input.dat > output.dat批量转码。
4.3 外部表的“伪索引”与性能优化
外部表不支持传统索引,但Oracle提供了两种加速查询的机制:
分区裁剪(Partition Pruning):如果外部表按日期分区,如
LOCATION ('sales_202401.dat','sales_202402.dat'),查询WHERE sale_date BETWEEN '2024-01-01' AND '2024-01-31'时,Oracle只扫描sales_202401.dat,跳过其他文件。预处理器(PREPROCESSOR):在
ACCESS PARAMETERS中指定Shell脚本,对文件做前置处理。例如:PREPROCESSOR exec_dir:'filter.sh'filter.sh内容:#!/bin/bash grep "2024-05" $1 # 只输出5月数据这样
SELECT * FROM ext_sales实际只读取包含"2024-05"的行,大幅减少I/O。
注意:
exec_dir必须是DBA创建的目录对象,且赋予EXECUTE权限。脚本必须有执行权限(chmod +x),且输出必须是标准输入流(stdout)。
5. 常见问题与排查技巧实录:从ORA-29913到字符集乱码的实战手册
5.1 ORA-29913: error in executing ODCIEXTTABLEOPEN callout
这是外部表最经典的报错,表面意思是“执行ODCI扩展表打开调用时出错”,根源几乎全是权限或路径问题:
| 错误现象 | 根本原因 | 解决方案 |
|---|---|---|
ORA-29913: error in executing ODCIEXTTABLEOPEN callout+KUP-04043: table EXT_SALES does not exist | DEFAULT DIRECTORY指向的物理路径不存在,或Oracle用户无读取权限 | ls -ld /u01/data检查路径权限;chown oracle:oinstall /u01/data;chmod 755 /u01/data |
ORA-29913+ORA-29400: data cartridge error KUP-00554: error encountered while parsing access parameters | ACCESS PARAMETERS语法错误,如少括号、引号不匹配、DATE_FORMAT拼写错为DATE_FORMATE | 用sqlldr单独测试控制文件:sqlldr userid=/ control=test.ctl,复用同一套语法 |
ORA-29913+KUP-04063: unable to open log file | LOGFILE指定的目录对象(如log_dir)未创建,或无写入权限 | CREATE DIRECTORY log_dir AS '/u01/log'; GRANT READ,WRITE ON DIRECTORY log_dir TO public; |
排查口诀:“先查目录,再查语法,最后看日志”。90%的ORA-29913,
ls -l和cat两个命令就能定位。
5.2 SQL*Loader的“静默失败”:数据没报错却没进表
现象:sqlldr日志显示Commit point reached - logical record count 10000,Table ORDERS:下Loaded rows: 10000,但SELECT COUNT(*) FROM orders返回0。
原因有三:
- 表名大小写问题:控制文件写
INTO TABLE Orders,而实际表是ORDERS(Oracle默认大写)。解决方案:表名加双引号INTO TABLE "Orders",或统一用大写。 - Schema未指定:控制文件写
INTO TABLE orders,但用户scott没有orders表权限,实际加载到了sys.orders(如果存在)。解决方案:显式写INTO TABLE scott.orders。 - Direct Path的约束延迟:
CONSTRAINTS未启用,加载后SELECT查不到,但ANALYZE TABLE orders VALIDATE STRUCTURE CASCADE会报错。解决方案:加载后手动ALTER TABLE orders ENABLE CONSTRAINTS。
5.3 字符集乱码的终极诊断树
当SELECT外部表出现????或方块,按此顺序排查:
- 确认文件真实编码:
file -i sales.dat→ 若输出charset=iso-8859-1,但你想用UTF-8,则必须转码。 - 确认NLS_LANG设置:
echo $NLS_LANG→ 必须与文件编码匹配。若文件是UTF-8,NLS_LANG必须含AL32UTF8。 - 确认数据库字符集:
SELECT value FROM nls_database_parameters WHERE parameter='NLS_CHARACTERSET'→ 若是ZHS16GBK,则NLS_LANG不能设AL32UTF8,否则冲突。 - 检查ACCESS PARAMETERS:是否有
CHARACTERSET显式声明?若有,必须与文件编码一致。 - 验证Oracle内部转换:
SELECT DUMP('中文',1016) FROM dual→ 查看Oracle内部存储的十六进制,对比文件hexdump。
独家技巧:用Python快速验证编码。
python3 -c "import sys; print(sys.getdefaultencoding())"看Python默认编码;python3 -c "print(open('sales.dat','rb').read()[:20])"看文件前20字节原始值。
5.4 拒绝文件(.bad)分析:从错误日志反推数据质量
.bad文件不是垃圾,是数据质量的体检报告。典型bad文件内容:
"1001","张三","2024-05-20","123.45","P" "1002","李四","2024-05-20","abc","P" "1003","王五","2024-05-20","678.90","P"第2行"abc"导致错误,因为amount定义为DECIMAL EXTERNAL。这时不要急着重载,先做三件事:
- 统计错误类型分布:
grep -o "ORA-[0-9]*" orders.bad | sort | uniq -c,看是ORA-01722(数字格式)多,还是ORA-01840(日期格式)多; - 提取错误行样本:
head -20 orders.bad > sample.bad,用Excel打开分析规律; - 关联源系统日志:如果源是Java应用,查其日志中
"abc"生成的上下文,很可能是某个字段为空时未做空值处理,直接拼了字符串。
经验:bad文件行数超过总数据量5%,说明源数据质量堪忧,应推动上游系统修复,而不是在加载层打补丁。
6. 场景化选型决策树:什么情况下该用SQL*Loader,什么该用外部表?
6.1 决策树主干:基于数据生命周期的三阶段判断
我把数据装载需求分为三个生命周期阶段,每个阶段对应最优技术:
| 生命周期阶段 | 特征描述 | 推荐技术 | 原因说明 |
|---|---|---|---|
| 一次性初始化 | 新建系统上线,需将历史10年数据全量导入 | SQL*Loader | Direct Path极致性能,支持并行(PARALLEL=TRUE),且可精确控制错误处理(ERRORS=0) |
| 周期性增量同步 | 每日从上游系统拉取新增订单,数据量<100万行 | 外部表+INSERT SELECT | 外部表提供SQL接口,可与现有ETL流程无缝集成;INSERT /*+ APPEND */ INTO target SELECT * FROM ext_source复用Oracle优化器 |
| 临时数据分析 | 运营人员需快速分析一份新拿到的CSV销售报表,只读不写 | 外部表 | 零加载时间,CREATE TABLE后立即SELECT;支持JOIN、GROUP BY等复杂分析,无需建临时表 |
6.2 关键分支:当遇到这些条件时,必须切换技术
- 如果源文件大于50GB→ 强制用SQL*Loader。外部表在扫描大文件时,PGA内存消耗呈线性增长,极易触发ORA-04030。sqlldr的
BINDSIZE可控制内存峰值。 - 如果需要加载后立即查询→ 选SQL*Loader。外部表每次查询都是实时I/O,500万行文件查询可能耗时分钟级;sqlldr加载后数据在Buffer Cache中,查询毫秒级。
- 如果源文件格式不稳定(如字段数每天变化)→ 选外部表。可动态修改
ACCESS PARAMETERS中的字段定义,无需重建表;sqlldr必须改控制文件并重跑。 - 如果需审计数据来源→ 两者都可,但实现方式不同。sqlldr用
CONSTANT字段;外部表用PREPROCESSOR脚本注入文件名和时间戳。
6.3 混合方案:用外部表做“探针”,SQL*Loader做“主力”
某物流公司的最佳实践:
- 每日凌晨2点,用外部表
ext_new_orders挂载当日新文件; - 执行
SELECT COUNT(*), MIN(order_date), MAX(order_date) FROM ext_new_orders,验证数据完整性; - 若
COUNT(*) > 0且日期范围合理,则触发sqlldr加载脚本; - 加载完成后,
DROP TABLE ext_new_orders并重建指向新文件。
这个方案把外部表的灵活性(快速验证)和sqlldr的高性能(稳定加载)结合,既规避了外部表的性能缺陷,又避免了sqlldr的“黑盒”风险——毕竟,没人敢在没看一眼数据前就直接加载。
最后分享一个小技巧:在sqlldr控制文件里,用
BEGINDATA内嵌测试数据,这样不用依赖外部文件就能调试语法。例如:LOAD DATA INFILE * ... BEGINDATA 1001,"张三","2024-05-20",123.45,"P" 1002,"李四","2024-05-20",456.78,"S"
我在实际操作中发现,真正卡住团队的往往不是技术本身,而是对“为什么这样设计”的理解断层。比如看到DIRECT=TRUE就盲目开启,却不了解它会禁用触发器;或者为追求速度把ROWS设到10万,结果Redo Log暴涨导致归档失败。Oracle的数据装载不是魔法,它是一套精密的工程系统,每个参数都是工程师在性能、一致性、可维护性之间权衡的结果。理解这些权衡,你才能在服务器告警时,一眼看出是BINDSIZE设太大撑爆了PGA,而不是慌忙去查MOS文档。