1. 数据从数仓回流业务库,这件事到底在解决什么
Hive 数据同步到 MySQL,几乎是每个离线数仓团队都绕不开的一段活。数仓里跑了几十个 T+1 任务,从 ODS 一路加工到 DWS、ADS,最后一步却总要有人手动或者半自动地把某几张结果表塞进 MySQL,业务系统才能查得到。这件事看起来就是一条sqoop export或者一个 DataX 作业,好像没什么技术含量,但真跑起来,建表字符集不对、分区字段被当成数据列、重跑之后数据翻倍、任务显示成功而表里一行新数据都没有,这些问题全都会找上门。
我在几个不同的项目里都负责过这段链路,从最早的手写 Sqoop 脚本,到后来统一用调度平台包 DataX,再到现在部分场景直接用 Spark 直写,踩过的坑基本能凑成一本小册子。这篇东西想讲清楚一件事:离线数仓数据导出不是一个"搬数据"的动作,而是一次跨存储引擎、跨 SQL 方言、跨事务模型的数据重建。适合正在做或者即将做这块工作的同学参考,也适合那种"任务能跑通但总是出点小毛病"的团队拿来当排查清单。
1.1 三类典型的导出需求,做法完全不一样
很多人一听"导出到 MySQL"就套同一个模板,其实需求差异很大,先把类型分清楚能省一半返工。
第一类是结果集小表,典型的像每日经营汇总、门店排行榜、区域指标看板,特点是行数在几十万到几百万之间,字段不多,每天全量覆盖。这类表最省心,一天一删一插,出问题重跑一次也就几分钟。
第二类是明细数据下沉,比如订单明细、流水记录,行数动辄上千万,业务侧要按订单号点查。这类就不能每天全量,必须按业务日期增量追加,还要在 MySQL 侧建好唯一索引防止重复。
第三类是维度快照,比如商品维度、门店维度,每天存一份切片,MySQL 表里带dt字段,历史多天的数据共存。这类表写的时候要按dt做覆盖而不是整表清空,否则会把历史切片一起干掉。
分清楚自己面对的是哪一类,后面选型、幂等策略、校验方式才能对得上。我见过不少事故的根因,就是把第二类的表按第一类的方式处理了。
1.2 为什么不让业务直接查 Hive
经常有人问,既然数据在 Hive 里了,为什么还要多此一举。
原因不复杂。Hive 的定位是大批量扫描的离线分析,一次查询动辄拉起几十个 MapReduce 或 Spark task,延迟以分钟计。业务系统的查询特征是"按主键拿一条"或者"按条件取前 50 条",并发可能上百。这种小查询打到 Hive 上,一是慢得没法接受,二是会把集群资源拖垮,三是 Hive 不支持行级更新和事务,业务侧改一条数据都做不到。
还有一种情况是下游系统根本连不上大数据集群。比如某个内部的运营后台、某个对外的查询接口、某套第三方对接的中间库,它们只认识 MySQL 协议。数仓再先进,数据出不去就是死数据。
所以导出这件事的定位应该摆正:它不是数据搬运,是给业务侧提供一个"可服务化"的数据视图。理解了这个定位,你就会明白为什么导出后的表结构不能完全照抄 Hive,为什么字段长度、索引、字符集这些细节都值得较真。
2. 三条技术路线的取舍:Sqoop、DataX、Spark
这三条路我都用过,每一家都有自己的脾气。选型时最忌讳的是"哪个新用哪个",或者"哪个同事熟用哪个",还是要看具体约束。
2.1 Sqoop 的适用边界
Sqoop 是最老牌的选择,sqoop export直接把 HDFS 上的文件按分隔符切分后批量 insert 进 MySQL,底层跑 MapReduce,天然能并行。
sqoop export \ --connect "jdbc:mysql://10.0.0.12:3306/report_db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true" \ --username sync_writer \ --password-file /home/hadoop/.mysql.pwd \ --table dws_shop_summary \ --export-dir /warehouse/dws.db/dws_shop_summary/dt=2024-06-01 \ --input-fields-terminated-by '\001' \ --input-null-string '\\N' \ --input-null-non-string '\\N' \ --columns "dt,shop_id,order_cnt,gmv,update_time" \ --batch \ --num-mappers 8 \ --staging-table dws_shop_summary_stg \ --clear-staging-table它的优势是成熟、稳定、参数清晰,问题也很好查,日志里每一行脏数据都会打出来。--staging-table这个参数很关键,它先把数据写进一张临时表,全部成功后再一次性INSERT INTO ... SELECT,能保证要么全成功要么全失败,避免导出到一半挂掉留下半截数据。
缺点也很明显:依赖 MapReduce,启动开销大,小作业(几万行)跑一次光等 container 分配就要一两分钟,不划算。另外 Sqoop 1.4.x 对 Hive 的 ORC、Parquet 格式支持有限,--export-dir直接指到数据文件上,格式不对会读出乱码。所以用 Sqoop 导出的 Hive 表,通常要求是 TextFile 格式,或者先做一次格式转换。
还有一个常被忽略的点:Sqoop 导出时不会读 Hive 元数据,它只是按--export-dir去读文件,按--input-fields-terminated-by切分,按--columns顺序对应目标表。分区字段dt根本不在文件里,如果--columns里写了dt,那一列会变成 null 或者错位。这个坑后面还会展开说。
2.2 DataX 的配置化优势与隐含约束
DataX 是配置驱动的,一个 JSON 描述清楚 reader 和 writer,不用写代码,这是它最大的好处。运维成本低,调度平台集成方便,改字段只改 JSON。
{ "job": { "setting": { "speed": { "channel": 6 }, "errorLimit": { "record": 0, "percentage": 0.0 } }, "content": [{ "reader": { "name": "hdfsreader", "parameter": { "path": "/warehouse/dws.db/dws_shop_summary/dt=${bizdate}", "defaultFS": "hdfs://ns1", "fileType": "orc", "column": ["*"], "encoding": "UTF-8", "nullFormat": "\\N" } }, "writer": { "name": "mysqlwriter", "parameter": { "writeMode": "replace", "username": "sync_writer", "password": "***", "column": ["dt","shop_id","order_cnt","gmv","update_time"], "connection": [{ "jdbcUrl": "jdbc:mysql://10.0.0.12:3306/report_db?rewriteBatchedStatements=true&characterEncoding=utf8", "table": ["dws_shop_summary"] }], "preSql": ["delete from dws_shop_summary where dt = '${bizdate}'"], "batchSize": 2048 } } }] } }几个必须留意的地方:errorLimit里的record设成 0 意味着遇到任何一条脏数据就整个作业失败,这是好事,能逼着你去处理数据质量问题。但如果设成 100,作业会"成功",而实际少了 100 行,这种静默丢失最要命。
writeMode有三个值:insert、replace、update。replace走的是REPLACE INTO,遇到主键冲突先删后插,副作用是会触发目标表的 delete 触发器、丢失自增 ID、并且删掉的旧行里的其他字段值不保留。update走UPDATE,主键不存在时这一行直接丢失,也不报错。所以选哪个,取决于你的表有没有唯一键、业务是否允许覆盖。默认的insert最安全,配合preSql做先删后插是完全可控的。
还有一个隐含约束:DataX 是单机多线程模型,不是分布式。它靠channel数提升并发,一般 6~16 就够了,再往上加大部分时候是被 MySQL 的写入能力卡住。如果数据量到上亿级,单机 DataX 的瓶颈会很明显,吞吐上不去。
2.3 Spark 直写:可控性最强也最费心
当导出逻辑里带了清洗、关联、脱敏、字段拼接这类加工,或者需要在写入前后做一些业务判断,Spark 直写是最合适的。它不用先把中间结果落 HDFS 再导,一步到位。
val props = new java.util.Properties() props.setProperty("user", "sync_writer") props.setProperty("password", "***") props.setProperty("driver", "com.mysql.cj.jdbc.Driver") props.setProperty("rewriteBatchedStatements", "true") props.setProperty("isolationLevel", "READ_COMMITTED") df.repartition(8) .write .mode(SaveMode.Append) .option("batchsize", "2000") .option("truncate", "false") .jdbc("jdbc:mysql://10.0.0.12:3306/report_db", "dws_shop_summary", props)rewriteBatchedStatements=true这个参数必须加。MySQL 的 JDBC 驱动默认会把批量插入拆成一条条单行 insert 发给服务端,你在代码里看到的 batch 是假的。加上这个参数之后,驱动才会把多条 insert 拼成一条多 values 的语句,写入速度通常能提升一档,实测下来差距在三到五倍之间。
代价是可控性带来的复杂度:分区数直接等于写入并发,设多了把 MySQL 打挂,设少了跑不完。SaveMode.Append不带幂等保护,重跑就会重复。所以用 Spark 直写,事务控制和幂等逻辑都得自己兜住。
2.4 一张表看清怎么选
| 维度 | Sqoop | DataX | Spark 直写 |
|---|---|---|---|
| 执行模型 | MapReduce 分布式 | 单机多线程 | 分布式 |
| 适合数据量 | 千万级以上 | 百万到千万级 | 百万到千万级 |
| 配置成本 | 中,参数较多 | 低,纯 JSON | 高,要写代码 |
| 类型转换 | 弱,靠 JDBC | 有类型映射表 | 强,代码里可控 |
| 事务保证 | 靠 staging-table | 靠 preSql | 靠自己写 |
| 前置加工能力 | 无,只能导原文 | 弱,只有简单映射 | 强,任意洗数据 |
| 启动开销 | 高(分钟级) | 低(秒级) | 中(几十秒) |
| 常见故障 | ORC 格式读乱、分区列错位 | 脏数据静默丢弃、preSql 变量没替换 | 并发过高打挂库、重跑重复 |
我自己的选择习惯是:纯搬运、表结构稳定、数据量大,用 Sqoop;日常小表和中等表、字段经常变、要求快速上线,用 DataX;带加工逻辑或者对写入过程有精细控制要求,用 Spark。很多团队最后会三种都用,按场景分,这没什么不好,关键是每条链路上都有统一的监控和校验。
3. 建表与类型映射:导出任务一半的故障出在这里
导出任务失败的原因里,数据质量占三成,剩下的大头都在表结构和类型上。这块必须在动手写作业之前就定死,事后改代价很高。
3.1 Hive 到 MySQL 的类型对照
Hive 的类型系统和 MySQL 差别不小,尤其是 string、decimal 和 timestamp 这三个。
| Hive 类型 | 推荐 MySQL 类型 | 说明 |
|---|---|---|
| string | varchar(n) / text | 必看实际最大长度,别无脑 255 |
| int | int | 基本无损 |
| bigint | bigint | 无损,注意 unsigned 场景 |
| double | double / decimal(20,6) | 金额禁用 double,会飘 |
| decimal(p,s) | decimal(p,s) | 精度和小数位必须一致,否则截断 |
| boolean | tinyint(1) | Hive 的 true/false 写到 MySQL 需要注意转换 |
| timestamp | datetime(3) | 注意时区和精度 |
| array/map/struct | text | 导之前先转成 JSON 字符串 |
最容易被忽略的是decimal。Hive 里是decimal(20,4),MySQL 建成了decimal(10,2),导出时不会报错,数据被静默截断或四舍五入,等到财务对账发现差了几十块钱,回头查是很痛的。经验做法是 MySQL 侧的精度只能大于等于 Hive 侧,宁可留余量。
金额字段坚决不用double。浮点数在二进制下不能精确表示小数,累加多次之后偏差会放大,几百万条记录求和能差出好几毛钱,这对账务类数据是致命的。
3.2 长度、精度与空值的三个高频雷
第一个雷:varchar 太短。Hive 的 string 没有长度约束,业务方往里塞什么东西你控制不了。MySQL 的varchar(255)一旦遇到 300 字符的值,直接抛Data too long for column,整个作业失败。处理方式有两种:一种是分析一下历史数据的最大长度和 P99 长度,把字段放宽到合理值;另一种是在导出前做截断,但截断必须记录日志,不能悄悄截。我倾向第一种,除非字段本身语义就有限制(比如手机号、身份证号)。
第二个雷:空串与 NULL 混用。Hive 里''和null是两个不同的值,但很多 MySQL 表设计时把字段设为NOT NULL DEFAULT '',导出时 null 变成空串,业务侧写IS NULL查不到数据。反过来也有,MySQL 侧允许 null,业务侧判断!= ''时把 null 行漏掉了。这个问题的根源是两边的表定义不一致,必须在建表阶段统一:要么都允许 null,要么都在导出前用coalesce兜成默认值。
第三个雷:时间格式。Hive 的 timestamp 字段导出到 MySQL 的datetime,如果值里带了毫秒或者时区偏移,会报Incorrect datetime value。还有一种情况是 Hive 里存的是字符串2024-06-01,导到datetime字段时,MySQL 严格模式下同样报错。稳妥做法是在导出前统一 cast 成yyyy-MM-dd HH:mm:ss格式的字符串,MySQL 侧用datetime接收,两边都干净。
3.3 分区字段不在文件里:列错位的根因
这个坑我踩过两次,每次都要查半天。
Hive 表的数据文件里不包含分区字段的值,分区信息存在元数据和目录名上。当你用 Sqoop 的--export-dir指向dt=2024-06-01这个目录时,读到的文件内容是 5 列,但目标表可能是 6 列。如果--columns里把dt写在第一位,Sqoop 会把文件里的第一列(实际是业务字段)塞给dt,后面全部错位一格,而且不会报错,因为类型可能刚好都能转。
DataX 的 hdfsreader 读 ORC/Parquet 时同理,它读的是文件里的 schema,分区列需要显式加上。有些版本的 hdfsreader 支持在path里带分区,读出来的结果会补上分区列,但不同版本行为不一致,不能默认。
规避方法很简单:导出前先把分区字段显式写进数据里。用 Hive SQL 加一列:
INSERT OVERWRITE TABLE dws_shop_summary_export PARTITION (dt = '2024-06-01') SELECT shop_id, order_cnt, gmv, update_time, '2024-06-01' AS dt FROM dws_shop_summary WHERE dt = '2024-06-01';多一次落盘,但换来的是列顺序完全可控,后面怎么导都不会错位。如果不想多一次落盘,那就老老实实把--columns里的分区列去掉,再在 MySQL 侧用preSql或者后置 SQL 补上dt值——两种方式都行,只要别让它悬着。
4. 全量、增量与日切片:写入策略决定重跑难度
写入策略选错,后面所有的运维动作都会变得复杂。这一块我的经验是:宁可多占点存储,也要让重跑变成一件无脑的事。
4.1 三种写入模式的取舍
全量覆盖(truncate + insert)适合结果集小、每天完全重算的表。优点是逻辑最简单,不用管水位线,不用管主键冲突,重跑就是再执行一次。缺点是有个真空期——truncate 之后到 insert 完成之前,业务侧查到的是一张空表。如果这张表被接口直接读,真空期就是故障窗口。
解决办法是用中间表做切换:先往t_new写,写完用RENAME TABLE t TO t_old, t_new TO t原子切换。这个操作在 MySQL 里是原子的,切换瞬间完成,业务侧几乎无感知。
增量追加适合明细类大表。每天只处理新产生的数据,按业务时间水位线推进。优点是数据量小、跑得快;缺点是一旦某天的数据处理错了,修复要单独回补,而且追加的方式本身不幂等,重复跑就会多数据。
按日切片是折中方案,MySQL 表里带dt字段,每天写一个切片,历史切片保留。查询时业务侧必须带上dt条件。好处是历史可以随时重算某一天而不影响其他天,坏处是表会持续膨胀,需要定期清理过期分区。
4.2 幂等设计:truncate 与 delete 的并发陷阱
这是我认为整条链路上最值得讲的一个细节。
很多人用 DataX 的时候会写"preSql": ["truncate table dws_shop_summary"],看起来没问题。但 DataX 的preSql是每个 task 执行一次,不是整个 job 执行一次。当channel大于 1 时,多个 task 同时启动,第一个 task 执行 truncate,开始插入;第二个 task 又执行一次 truncate,把第一个 task 刚插进去的数据删掉了。最后的结果是数据残缺,而且作业状态显示成功。
正确的做法有两种:全量清空时把channel设成 1,牺牲并发换正确性;或者用delete from t where dt = '${bizdate}',因为 delete 是幂等的,重复执行结果一样,并发执行也只会互相等待锁,不会丢数据。
还有一个相关的问题:preSql里的变量替换。DataX 支持${bizdate}这种占位符,但必须由调度系统在生成 JSON 时替换掉。如果调度没配好,${bizdate}原样传下去,SQL 变成delete from t where dt = '${bizdate}',要么报语法错,要么删了个叫这个字符串的分区(如果有的话)。我遇到过一次,那天删错了三天的数据,因为那张表的dt字段被人手工塞过一个字面量进去。
提示:任何带变量的 SQL,上线前都要把最终生成的语句打印出来看一眼。不要相信"配置肯定对"。
4.3 水位线表:把每次同步都记下来
建议在 MySQL 里单独建一张同步记录表:
CREATE TABLE etl_sync_log ( id bigint AUTO_INCREMENT PRIMARY KEY, job_name varchar(128) NOT NULL COMMENT '作业名', src_table varchar(128) NOT NULL COMMENT '源表', tgt_table varchar(128) NOT NULL COMMENT '目标表', biz_date varchar(16) NOT NULL COMMENT '业务日期', src_cnt bigint DEFAULT NULL COMMENT '源端行数', tgt_cnt bigint DEFAULT NULL COMMENT '目标端行数', status tinyint NOT NULL DEFAULT 0 COMMENT '0运行中 1成功 2失败', err_msg varchar(1000) DEFAULT NULL, start_time datetime NOT NULL, end_time datetime DEFAULT NULL, KEY idx_job_date (job_name, biz_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;每次作业开始写一条状态 0,结束后更新状态和行数。这张表的价值在排查阶段体现得淋漓尽致:出问题时第一眼看的就是"上次成功是什么时候""那次导了多少行""这次导了多少行",比翻调度日志快十倍。很多团队一开始不做,等出了两次事故之后都会补上。
5. 跑批效率:从切分键到批量提交的完整调优链
导出慢不一定是数据多,多半是并行度和批量参数没调对。这一块有明确的优化路径,按顺序调基本都能见效。
5.1 读端:切分键和文件格式
Sqoop 和 DataX 都支持按某个列切分并行读取。Sqoop 用--split-by,DataX 用splitPk。选哪个列有讲究:
- 必须是数值型或可比较类型,字符串主键切分效率低。
- 分布要均匀,用自增 ID 最理想。如果用一个业务字段做切分键,而它的分布严重倾斜(比如 80% 的记录集中在某几个值上),并行度再高也没用,全压在一个 mapper 上。
- 不能有大量 null,null 值会被分到一个分片里,形成长尾。
文件格式的影响也很大。TextFile 需要逐行解析,ORC 和 Parquet 是列式存储带统计信息,读取效率高一个量级。如果源表是大表,导出前先做一次格式转换是值得的。
5.2 写端:批量大小和事务粒度
写端的两个核心参数是batchSize和并发数。
batchSize控制的是每次发给 MySQL 的行数。太小了网络往返次数多,太大了单条 SQL 超长,可能撞上max_allowed_packet限制。经验值在 1000 到 4096 之间,2000 左右是个不错的起点。如果每行字段多、字段长,就往下调。
并发数不是越高越好。MySQL 的写入能力受限于磁盘 IO、binlog 刷盘和锁竞争。单机 8~16 个并发写入线程,大部分场景下已经接近上限。加到 32、64,往往只是让锁等待时间变长,整体吞吐反而下降,还会把同一实例上的其他业务查询拖慢。
有一个容易被忽略的点:写之前临时关掉二级索引的维护成本。如果目标表上有四五个索引,每插入一行都要更新所有索引,写入速度会掉得很厉害。大批量导入前先ALTER TABLE ... DISABLE KEYS(这个只对 MyISAM 有效),InnoDB 的话可以先把非必要索引 drop 掉,导完再建回来,重建索引比逐行维护快得多。当然这需要业务低峰期操作,不能在有查询压力的时候做。
5.3 MySQL 侧的配合参数
有些参数是 DBA 才能改的,但值得在项目启动时就去沟通:
| 参数 | 建议值 | 作用 |
|---|---|---|
innodb_flush_log_at_trx_commit | 导入期间临时设 2 | 减少刷盘次数,速度提升明显 |
max_allowed_packet | 64M 以上 | 避免大 batch 报错 |
innodb_buffer_pool_size | 物理内存的 60%~70% | 缓存命中率高,写入更快 |
innodb_log_file_size | 1G 以上 | 减少 checkpoint 频率 |
innodb_flush_log_at_trx_commit从 1 改成 2,意味着每次事务提交只写 OS 缓存不刷盘,宕机可能丢最近一秒的数据。导入任务本身可以重跑,丢一秒数据无所谓,所以这个调整是合理的。但一定要在导入结束后改回 1,不能长期开着。
6. 导出完成不等于数据正确:校验与重跑
任务返回成功只是一半,数据对不对得验。我见过的严重事故里,有一半是"任务成功但数据不全",而不是"任务失败"。
6.1 三层校验,从粗到细
第一层是行数比对。最简单也最有效。源端select count(1) from dws_shop_summary where dt='2024-06-01',目标端select count(1) from dws_shop_summary where dt='2024-06-01',两个数不相等就告警。
注意一个细节:如果源表有重复主键,目标表有唯一索引,行数天然会不等。这种情况下比对要改成count(distinct 主键)。
第二层是关键指标汇总。行数相等不代表内容相等。再比几个聚合值:sum(gmv)、sum(order_cnt)、max(update_time)。金额类的字段建议比到分,因为精度问题往往就体现在小数点后两位。
第三层是抽样比对。按主键排序取前 100 行、后 100 行、中间随机 100 行,逐字段比对。这一层能抓出字段错位、类型截断这类结构性错误。如果前两层都过了但业务侧反馈数字不对,基本就是字段错位,抽样比对能立刻定位。
这三层可以写成一个通用的校验脚本,作业跑完自动触发。校验不通过就发告警,不要自动重跑——重跑之前得先知道为什么错。
6.2 重跑的正确姿势
重跑听起来简单,实际要考虑三件事。
第一,清除旧数据。重跑之前必须把目标表里这一批的数据删干净,否则追加模式会翻倍。所以每个作业的配置里都要有对应的清理逻辑,而且这段逻辑必须能独立执行,不能只在作业启动时自动跑。
第二,上游依赖。如果源表本身也是当天加工出来的,重跑导出作业之前要确认源表数据是好的。见过一次重跑三次都不对,最后发现是上游的聚合逻辑有问题,导出的数据本身就不对。
第三,下游通知。重跑完成后,如果下游有缓存或者有依赖这张表的接口,需要刷新。这个容易被忘掉,业务侧看到的一直是旧数据,还以为没修好。
提示:调度系统里每个作业都配一个"清理并重跑"的入口,比让值班同学手工连数据库删数据靠谱得多。
7. 几个把我按在工位上的真实故障
这一节把几个典型故障的排查过程完整写出来,遇到类似情况可以照着走一遍。
7.1 任务成功,但表里一行新数据都没有
调度显示绿色,DataX 日志里也有"读出记录数:128430,写入记录数:128430",但业务侧查 MySQL 说没更新。
排查顺序是这样的:
先确认目标表到底有没有变。select max(update_time) from t一看,还是昨天的。再查select count(1) from t where dt='今天',结果是 0。说明数据确实没进去。
然后回头看 DataX 的 JSON。问题就在preSql上:delete from dws_shop_summary where dt = '${bizdate}',而调度传进来的bizdate格式是2024-06-01,SQL 变成了delete from t where dt = '2024-06-01',删的是当天——看起来没问题。
再往下查,发现writer的column列表里没有dt字段,只写了业务字段。而preSql删的时候是按dt删的,插入的时候dt取了表的默认值或者 null。业务侧查询带dt='2024-06-01'条件,自然查不到。
这个故障的本质是写入列与查询条件不匹配。修复方式是在column里补上dt,并且在源端把这个值查出来带上。
7.2 数据量翻倍
重跑之后发现数据是原来的两倍,再跑一次变成三倍。
这类问题的根因基本就一个:没有幂等保护。要么preSql没配,要么配了但条件是错的(比如用了update_time做条件,重跑时更新了时间戳导致删不到旧数据)。
还有一种是主键设计的问题。目标表如果没有唯一索引,REPLACE INTO退化成普通INSERT,每次都是新增。查一下show create table看有没有UNIQUE KEY,没有就是这个问题。
修复时要注意,先把重复数据清掉再改配置。delete t1 from t t1 inner join t t2 where t1.id > t2.id and t1.业务键 = t2.业务键这种写法能去重,但一定要先在测试库验证,别在生产库上直接跑。
7.3 任务卡在 99% 不动
日志停在"写入记录数:128430,总记录数:128430",但状态一直是运行中,半小时不结束。
这种情况八成是在等锁。连到 MySQL 上show processlist,看一下有没有长时间的update或insert状态,再看information_schema.innodb_trx里有没有长事务。常见的原因是:
- 别的任务正在更新同一张表,行锁冲突。
- 有个没提交的事务一直占着锁。
- 表上有大查询,MDL 锁阻塞了写入。
还有一种可能是max_allowed_packet太小,一条 batch 发过去被服务端拒绝,驱动在重试。这种在日志里通常能看到PacketTooBigException。
处理方式:先kill掉阻塞的会话,让任务跑完;然后调整参数避免下次再出现。如果确实是并发写同一张表导致的,就要在调度层面错开时间,或者给这张表的写入加个分布式锁。
7.4 一个通用的排查顺序
把上面几个案例抽象一下,我现在的排查顺序基本固定:
- 看结果:目标表有没有数据、行数对不对、
max(update_time)变没变。 - 看日志:DataX 或 Sqoop 的日志里,读出数和写入数是否相等,有没有脏数据记录。
- 看 SQL:把最终执行的 SQL(包括 preSql)打印出来,逐字核对,尤其是变量替换后的实际值。
- 看配置:
column列表、writeMode、batchSize、channel,这几项挨个确认。 - 看数据库:
show processlist、锁等待、慢查询日志。 - 看上游:源表本身的数据对不对。
这个顺序的价值在于从前到后,每一步都能排除掉一大块可能性,不会东一榔头西一棒子。按这个链路走,绝大多数问题能在二十分钟内定位到具体原因。
最后说一句体会:这套链路的技术难点不在写 SQL,而在对两端存储引擎行为的理解——Hive 怎么存文件、MySQL 怎么加锁、驱动怎么发 batch、调度怎么传变量。这些细节平时不看文档,出了事就得熬通宵。所以我现在做一个新导出任务,宁可多花半天把建表、主键、清理逻辑、校验脚本都定死,也不愿意上线之后天天救火。