前言:这不是简单的"搬个家"
最近参与了好几个数据库迁移项目,说实话,最让我头疼的从来不是"数据能不能导进去",而是"导进去对不对"。特别是从Oracle替换到国产数据库的时候,那些看似不起眼的精度丢失、字符集转换、LOB字段损坏,有时候能让你排查好几天都找不到根源。
我记得有个项目,迁移完几个月后才发现,某几个时间字段精度被截断了,导致某些对时间戳敏感的业务逻辑跑出诡异结果。更惨的是LOB字段的问题——合同扫描件上传是成功的,下载下来却打不开。这种问题最坑的地方在于,它不会立马炸锅,而是像慢性病一样慢慢暴露出来。
今天就把这些年踩过的坑整理一下,用比较口语化的方式聊聊迁移过程中的那些隐蔽风险。内容可能有点乱,有些错别字大家将就着看吧,毕竟我更想把那些真实的、混乱的现场感受传递出来。
一、字符集/时区/精度:最让人抓狂的"隐性失真"
1.1 时间精度丢得悄无声息
先说个真实的案例。某金融系统从Oracle迁到国产数据库,里面有大量的交易时间戳字段,Oracle是TIMESTAMP(6),也就是微秒级精度。迁移工具配置的时候,默认把精度降到了毫秒。
迁移的时候全程没报错,日志看着都是绿的。结果上线后,有些高频交易场景下,同一秒内可能发生多笔交易,亚秒级的顺序用来做排序和去重。精度一降,这几笔交易的顺序就乱了。
-- 源库Oracle里的测试数据
SELECT transaction_id,
TO_CHAR(transaction_time, 'YYYY-MM-DD HH24:MI:SS.FF6') AS ts_full
FROM transactions
WHERE transaction_id BETWEEN 1001 AND 1005;
-- 迁移后在目标库查,精度只有3位
SELECT transaction_id,
transaction_time::text AS ts_full
FROM transactions
WHERE transaction_id BETWEEN 1001 AND 1005;
排查的时候我们试过各种办法,最后发现是迁移工具的类型映射问题。Oracle的NUMBER(38,0)有时候会被映射成目标库的INT,但如果是用来存时间戳数值的,精度就被砍掉了。
// 类型映射文件里的问题配置
{
"sourceType": {
"name": "NUMBER",
"precisionMin": 0,
"precisionMax": 38,
"scaleMin": 0,
"scaleMax": 0
},
"targetType": {
"name": "int" // 这里应该用bigint或者numeric
}
}
1.2 时区这东西,稍不注意就差8小时
时区问题更隐蔽。Oracle里很多表用TIMESTAMP WITH TIME ZONE,存储的时候带时区信息。但是迁移工具有时候会自动转换,或者应用连接字符串里没有指定时区,就会出现各种诡异的时间差。
-- 检查源库时区设置
SELECT * FROM nls_database_parameters
WHERE parameter LIKE '%TIME%';
-- 目标库检查
SHOW timezone;
SHOW datestyle;
我遇到过一个项目,源库是UTC,目标库配置成Asia/Shanghai,但是迁移脚本没有做转换,结果所有时间都差了8小时。更麻烦的是,有些表是用字符串存时间的,'2026-02-15 10:30:00'这种,在两地数据库里解释完全不一样。
-- 这个查询在两个库里可能结果完全不同
SELECT CURRENT_TIMESTAMP,
NOW(),
LOCALTIMESTAMP;
1.3 字符集转换:乱码只是冰山一角
字符集问题最显性的表现就是乱码,但更危险的是那些"看着差不多但实际不一样"的情况。特别是从GBK到UTF8的转换,有些生僻字、特殊符号会出问题。
-- 快速排查字符长度和字节长度的差异
SELECT COUNT(*) AS total_cnt,
SUM(CASE
WHEN LENGTH(COALESCE(remark, '')) <> OCTET_LENGTH(COALESCE(remark, ''))
THEN 1
ELSE 0
END) AS multibyte_cnt
FROM t_order;
-- 找出最容易出问题的样本
SELECT order_id, remark,
LENGTH(remark) AS char_len,
OCTET_LENGTH(remark) AS byte_len
FROM t_order
WHERE remark IS NOT NULL
ORDER BY OCTET_LENGTH(remark) DESC
LIMIT 20;
我之前处理过一个政务系统的迁移,里面有大量的少数民族人名,有些4字节的UTF8字符在转换的时候被截断了。更糟糕的是,这种截断有时候不会报错,只会把字符变成"?"或者干脆丢掉。
-- 测试一下时区是否一致
SET TIME ZONE 'Asia/Shanghai';
SELECT CURRENT_TIMESTAMP;
-- 然后切换回UTC再查一遍
SET TIME ZONE 'UTC';
SELECT CURRENT_TIMESTAMP;
-- 看看差了多少
二、LOB字段:迁移"成功"不代表"可用"
2.1 那些打不开的合同文件
LOB字段迁移是最头疼的部分,没有之一。BLOB存的是二进制,比如合同扫描件、医疗影像;CLOB存的是长文本,比如日志详情、审批材料。
我见过一个项目,迁移完合同管理模块,用户反馈有些合同下载下来打不开。查了半天发现,迁移工具默认的LOB缓冲区只有64MB,超过这个大小的文件就被截断了。
// 这种写法有问题,大文件会内存溢出
public void migrateBlobWrong() throws Exception {
String sql = "SELECT doc_id, doc_content FROM documents";
Statement stmt = sourceConn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
String insertSql = "INSERT INTO documents VALUES(?, ?)";
PreparedStatement pstmt = targetConn.prepareStatement(insertSql);
while (rs.next()) {
int docId = rs.getInt("doc_id");
Blob blob = rs.getBlob("doc_content");
// 直接读到内存,大文件必炸
byte[] data = blob.getBytes(1, (int) blob.length());
pstmt.setInt(1, docId);
pstmt.setBytes(2, data);
pstmt.executeUpdate();
}
}
2.2 正确的LOB迁移姿势
正确的做法是用流式传输,分批处理:
public void migrateBlobRight() throws Exception {
String sql = "SELECT doc_id, doc_content FROM documents";
Statement stmt = sourceConn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
String insertSql = "INSERT INTO documents VALUES(?, ?)";
PreparedStatement pstmt = targetConn.prepareStatement(insertSql);
targetConn.setAutoCommit(false);
int batchCount = 0;
while (rs.next()) {
int docId = rs.getInt("doc_id");
Blob sourceBlob = rs.getBlob("doc_content");
// 用流式传输,避免内存溢出
InputStream inputStream = sourceBlob.getBinaryStream();
pstmt.setInt(1, docId);
pstmt.setBinaryStream(2, inputStream, (int) sourceBlob.length());
pstmt.addBatch();
batchCount++;
if (batchCount % 100 == 0) {
pstmt.executeBatch();
targetConn.commit();
}
}
pstmt.executeBatch();
targetConn.commit();
}
但即使这样,还有个坑:有些迁移工具会自动把BLOB按字符集转一下,二进制数据就坏了。这种情况特别容易发生在配置文件里字符集没设对的时候。
2.3 CLOB的字符集陷阱
CLOB的问题主要是字符集。Oracle里如果用ZHS16GBK,目标库用UTF8,迁移工具如果处理不当,就会出现各种奇怪的乱码。
public void migrateClobWithCharset() throws Exception {
String sql = "SELECT doc_id, doc_content FROM documents";
Statement stmt = sourceConn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
String insertSql = "INSERT INTO documents VALUES(?, ?)";
PreparedStatement pstmt = targetConn.prepareStatement(insertSql);
while (rs.next()) {
int docId = rs.getInt("doc_id");
Clob sourceClob = rs.getClob("doc_content");
// 用Reader读,确保字符正确解析
Reader reader = sourceClob.getCharacterStream();
StringBuilder content = new StringBuilder();
char[] buffer = new char[4096];
int charsRead;
while ((charsRead = reader.read(buffer)) != -1) {
content.append(buffer, 0, charsRead);
}
reader.close();
pstmt.setInt(1, docId);
pstmt.setString(2, content.toString());
pstmt.executeUpdate();
}
}
2.4 LOB完整性校验
LOB迁移完千万别觉得"没报错就是对的",一定要做校验:
-- BLOB用MD5校验
SELECT doc_id, MD5(doc_content::bytea) AS blob_md5
FROM documents
WHERE doc_id IN (1001, 1002, 1003);
-- CLOB用长度和内容校验
SELECT doc_id,
LENGTH(doc_content) AS clob_length,
SUBSTR(doc_content, 1, 100) AS clob_sample
FROM documents
ORDER BY LENGTH(doc_content) DESC
LIMIT 20;
我强烈建议把关键数据导出成文件,比对文件哈希值:
# 导出源库的样本
\copy (SELECT doc_id, doc_content FROM documents WHERE doc_id IN (1001, 1002, 1003)) TO 'source_sample.csv' WITH (FORMAT csv, DELIMITER E'\t', HEADER true)
# 导出目标库的样本
\copy (SELECT doc_id, doc_content FROM documents WHERE doc_id IN (1001, 1002, 1003)) TO 'target_sample.csv' WITH (FORMAT csv, DELIMITER E'\t', HEADER true)
# 比较文件哈希
certutil -hashfile source_sample.csv SHA256
certutil -hashfile target_sample.csv SHA256
三、增量同步断点:丢数和重复一起来的噩梦
3.1 那个只记时间戳的断点
增量同步最坑的设计就是只记一个时间戳当断点。比如断点存的是'2026-02-15 10:30:00',下次增量的时候用WHERE update_time > '2026-02-15 10:30:00'抽数据。
这有个大问题:同一秒内可能有多条变更,如果断点用>就丢了,用>=就重复。
-- 只用时间戳的断点,容易丢数
SELECT * FROM t_order
WHERE update_time > '2026-02-15 10:30:00';
-- 推荐用时间戳+主键双断点
SELECT * FROM t_order
WHERE update_time > :last_ts
OR (update_time = :last_ts AND order_id > :last_pk)
ORDER BY update_time, order_id;
我之前处理过一个项目,就是因为断点设计问题,某天凌晨的几百笔交易记录丢了,排查的时候发现是同一秒内的交易,断点用>导致后面的都没抽到。
3.2 SCN断点也有坑
Oracle用SCN做增量断点更精准,但SCN本身也有问题。如果归档日志被清理了,或者RAC环境下节点间SCN跳跃,增量工具就会出问题。
-- 检查SCN是否连续
SELECT CURRENT_SCN,
file#,
change#,
time
FROM v$datafile_history
ORDER BY change#;
有些增量工具只记录单一SCN,没有考虑未提交事务,一旦事务回滚,断点就乱套了。
3.3 回放窗口的必要性
我强烈建议增量同步的时候做回放窗口,比如每次都回放最近5分钟的数据,目标端用主键做幂等处理。
-- 目标端的幂等更新
INSERT INTO t_order (order_id, order_time, amount, status)
VALUES (:order_id, :order_time, :amount, :status)
ON CONFLICT (order_id)
DO UPDATE SET
order_time = EXCLUDED.order_time,
amount = EXCLUDED.amount,
status = EXCLUDED.status;
这样就算乱序或者延迟写入,也能在下一轮被兜住。
3.4 断点表设计
给对账留个断点表很重要:
CREATE TABLE IF NOT EXISTS mig_checkpoint (
job_name VARCHAR(50) PRIMARY KEY,
last_ts TIMESTAMP,
last_pk VARCHAR(200),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
如果是复合主键,就把last_pk存成拼接字符串就行。
四、校验手段:别只数行数
4.1 那个只查行数的对账脚本
我见过太多项目,迁移完就跑个SELECT COUNT(*)对一下行数,觉得相等就完事了。这根本不够,行数相等不代表内容对得上。
-- 最基础的分段对账
SELECT TO_CHAR(order_time, 'YYYY-MM-DD') AS d,
COUNT(*) AS cnt
FROM t_order
WHERE order_time >= TO_TIMESTAMP('2026-02-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
AND order_time < TO_TIMESTAMP('2026-03-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
GROUP BY TO_CHAR(order_time, 'YYYY-MM-DD')
ORDER BY d;
按时间或者按分区分段对账,一旦不一致,定位起来会快很多。
4.2 主键完整性检查
主键重复不一定会当场报错,但一定会在后面变成各种诡异问题:
-- 检查主键空值
SELECT COUNT(*) AS pk_null_cnt
FROM t_order
WHERE order_id IS NULL;
-- 检查主键重复
SELECT order_id, COUNT(*) AS dup_cnt
FROM t_order
GROUP BY order_id
HAVING COUNT(*) > 1
ORDER BY dup_cnt DESC;
4.3 参照关系完整性
外键如果在导入后才启用,一定要检查断链:
-- 找孤儿行
SELECT COUNT(*) AS orphan_cnt
FROM t_order_item i
LEFT JOIN t_order o ON o.order_id = i.order_id
WHERE o.order_id IS NULL;
4.4 逐行哈希校验
真正严谨的校验是逐行算哈希值:
-- 逐行内容比对
CREATE OR REPLACE FUNCTION kb_full_verify(
p_src_schema VARCHAR,
p_tgt_schema VARCHAR,
p_table_name VARCHAR
) RETURNS TABLE (
table_name VARCHAR,
src_cnt BIGINT,
tgt_cnt BIGINT,
diff_cnt BIGINT,
match_cnt BIGINT,
status VARCHAR
) AS $$
DECLARE
v_src_count BIGINT;
v_tgt_count BIGINT;
v_match_count BIGINT;
BEGIN
-- 统计行数
EXECUTE format('SELECT COUNT(*) FROM %I.%I', p_src_schema, p_table_name)
INTO v_src_count;
EXECUTE format('SELECT COUNT(*) FROM %I.%I', p_tgt_schema, p_table_name)
INTO v_tgt_count;
-- 逐行对比哈希
EXECUTE format('
SELECT COUNT(*)
FROM (
SELECT user_id, MD5(CONCAT_WS(''|'', username, phone, email, create_time)) AS row_hash
FROM %I.%I
) src
JOIN (
SELECT user_id, MD5(CONCAT_WS(''|'', username, phone, email, create_time)) AS row_hash
FROM %I.%I
) tgt ON src.user_id = tgt.user_id AND src.row_hash = tgt.row_hash
', p_src_schema, p_table_name, p_tgt_schema, p_table_name)
INTO v_match_count;
RETURN QUERY
SELECT p_table_name,
v_src_count,
v_tgt_count,
v_src_count - v_tgt_count,
v_match_count,
CASE
WHEN v_src_count = v_tgt_count AND v_src_count = v_match_count THEN '完全一致'
WHEN v_src_count = v_tgt_count AND v_src_count > v_match_count THEN '行数一致内容差异'
ELSE '行数不一致'
END;
END;
$$ LANGUAGE plpgsql;
五、切换前的基线检查
5.1 会话参数统一
迁移前先把会话参数钉死:
SELECT CURRENT_USER;
SELECT CURRENT_TIMESTAMP;
SHOW server_encoding;
SHOW client_encoding;
SHOW lc_collate;
SHOW lc_ctype;
SHOW timezone;
SHOW datestyle;
这些参数不一致,会导致很多"玄学"问题。建议把这些输出保存到迁移记录里,后面出了问题能查。
5.2 强一致表清单
把表分三类:
- 强一致表:订单、资金、余额、库存,切换日必须逐表核对
- 弱一致表:日志、行为、统计,允许延迟补齐
- 可重建表:缓存、索引、聚合,允许重刷
先覆盖强一致表,把最大的风险关掉。
5.3 切换日对账闭环
冻结写入 → 最后一次全量落地 → 增量回放 → 强一致表逐表对账 → 应用切换 → 切换后监控
↓ ↓ ↓ ↓
定位范围 重跑分段 异常则回滚
每一步都要有可执行的验证输出,出了问题能定位、能重跑、能回滚。
六、那些被忽视的研究盲点
6.1 精度截断的理论缺口
学术界对数据迁移的研究大多集中在性能优化和冲突检测上,但对精度截断的关注明显不足。现有的文献大多把精度问题简化成"数据类型映射",实际上精度截断往往会导致业务逻辑层面的连锁反应,比如时间戳排序变化、数值计算累积误差等。
更关键的是,不同数据库对精度的处理策略不同:有的截断,有的四舍五入,有的向上取整。这种差异在文献中很少有系统性的对比研究。
6.2 增量断点的理论争鸣
关于增量同步的断点设计,业界存在明显分歧。一类观点主张用SCN作为绝对断点,认为它具有全局唯一性和单调递增性;另一类观点则认为SCN在分布式环境下存在跳跃风险,建议用时间戳+业务主键的组合键。
这个争鸣其实暴露了一个更深层的问题:现有的理论框架大多基于单节点数据库的假设,对于分布式环境下的断点一致性缺乏足够的理论支撑。特别是RAC环境下节点间SCN的协调机制,学术界和工业界都没有达成共识。
6.3 校验方法的效度争议
传统上,大家认为行数+主键完整性+外键完整性这三层校验就够了。但实践证明,这种校验方法对"行内数据偏移"完全无效——行数对得上,主键也没重复,但某个字段的精度被截断了,或者字符被转换了,传统校验根本发现不了。
学术界对"数据一致性"的界定本身就存在争议:有的认为"模式一致"就够了,有的认为"内容一致"才叫一致,还有的认为"行为一致"才是最终目标。这种概念界定的复杂性直接影响了校验方法的设计。
6.4 LOB迁移的研究空白
LOB迁移是研究空白最多的领域。现有的文献大多集中在BLOB/CLOB的基本迁移方法上,但对于二进制数据在跨平台传输中的完整性保障、字符集转换对CLOB数据的影响、大对象的存储格式差异等问题,几乎没有系统性的研究。
更糟糕的是,不同的迁移工具对LOB的处理策略完全不同:有的用流式传输,有的用批量导入,有的先转成文件再导入。这种实践层面的混乱,导致学术界很难形成统一的理论框架。
七、文献演进的时间维度
7.1 早期研究:关注"能不能迁"
早期的迁移研究(2010年前后)主要关注的是"能不能迁"。那时候的核心问题是数据类型映射、SQL语法兼容、存储过程转换。研究方法大多是实验性质的,用几个小数据集测试一下迁移工具的功能完整性。
那个时期的文献有个特点:很少关注"迁得对不对",重点是"迁得过去就行"。这也反映了当时的技术背景:数据库迁移主要在开发测试环境,生产环境的迁移案例还比较少。
7.2 中期研究:关注"能不能快"
到了2015年左右,随着大数据量迁移的普及,研究重点转向了性能优化。并行迁移、断点续传、增量同步这些概念开始频繁出现在文献中。研究方法也开始引入数学模型,用TPS、延迟、吞吐量这些指标来评估迁移效率。
但这个时期的研究仍然有一个盲点:过于关注"快不快",对"对不对"的关注还是不够。很多文献的性能测试用的是随机生成的数据,根本没有考虑真实业务场景中的数据质量问题和一致性要求。
7.3 近年研究:开始关注"能不能稳"
最近几年(2020年以后),随着金融、政务等关键行业的大规模数据库替换,研究重点开始转向"能不能稳"。数据完整性校验、增量同步的一致性保障、迁移过程的监控告警这些课题开始受到重视。
但现有的研究还是有明显的局限性:大多是工具层面的优化,缺乏系统性的理论框架。特别是对于跨平台、跨字符集、跨时区的复杂迁移场景,学术界还没有形成统一的研究范式。
7.4 被忽视的研究空白
纵观文献演进,有几个明显的研究空白:
-
精度截断的业务影响评估:现有文献大多只讨论精度截断的技术成因,但对业务影响的量化评估几乎没有。
-
增量断点的理论证明:关于不同断点策略的正确性、完备性,缺乏数学层面的证明。
-
LOB跨平台传输的完整性保障:特别是二进制数据在不同字符集、不同编码方式下的转换机制,几乎没有系统性的研究。
-
校验方法的效度验证:不同的校验方法在不同场景下的漏检率、误报率,缺乏大规模的实证研究。
八、实操中的混乱与教训
8.1 那些被忽视的配置文件
很多时候,问题的根源就在配置文件里。比如迁移工具的JDBC连接字符串,没有指定字符集,就会自动用系统默认的字符集。
# 错误的连接字符串
jdbc:oracle:thin:@192.168.1.100:1521:orcl
# 正确的连接字符串
jdbc:oracle:thin:@192.168.1.100:1521:orcl?useUnicode=true&characterEncoding=UTF8
还有迁移工具的内存配置,处理LOB数据的时候,如果JVM堆内存设得太小,就会频繁OOM。
# 增加JVM堆内存
export JAVA_OPTS="-Xms8g -Xmx8g -XX:MaxPermSize=512m"
8.2 那些被忽略的日志
迁移日志里有很多线索,但经常被忽略。比如"LOB数据长度不匹配"这种警告,很多人觉得不影响迁移成功就直接跳过了。
# 查找LOB相关的警告
grep -i "lob\|blob\|clob" migrate.log | grep -i "warn\|error"
还有SCN断点的警告,如果SCN不连续,增量同步就会出问题。
-- 检查SCN连续性
SELECT file#, change#, time
FROM v$datafile_history
WHERE change# > (SELECT CURRENT_SCN - 10000 FROM v$database)
ORDER BY change#;
8.3 那些被简化的测试
很多项目的测试都是用小数据集跑的,根本没有覆盖真实场景。比如LOB迁移,测试数据都是几百KB的小文件,结果上线后遇到几百MB的大文件就炸了。
-- 找最大的LOB数据
SELECT doc_id, LENGTH(doc_content) AS doc_len
FROM documents
ORDER BY LENGTH(doc_content) DESC
LIMIT 10;
还有时间精度,测试数据都是每秒一条,结果上线后高频场景下同一秒几百条,精度截断的问题就暴露了。
-- 找同一秒内的多条记录
SELECT order_time, COUNT(*) AS cnt
FROM t_order
WHERE order_time >= TO_TIMESTAMP('2026-02-15 10:00:00', 'YYYY-MM-DD HH24:MI:SS')
AND order_time < TO_TIMESTAMP('2026-02-15 11:00:00', 'YYYY-MM-DD HH24:MI:SS')
GROUP BY order_time
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
8.4 那个半夜两点的回滚
我印象最深的一次是某次迁移,切换到目标库后凌晨两点突然对不上账,业务回滚回源库。排查的时候发现,是增量同步的断点用>导致同一秒内的数据丢了。
那次教训特别深刻:所有关键步骤都要有回滚预案,而且要提前演练。别等到线上出问题了才临时想办法。
-- 回滚脚本示例
-- 1. 停止应用连接目标库
-- 2. 修改应用配置,切回源库
-- 3. 重启应用
-- 4. 验证服务状态
九、最后的总结
说实话,数据库迁移这事儿,最难的从来不是技术本身,而是那种"不知道哪里会出问题"的不确定性。精度丢了、字符集转错了、LOB损坏了、增量断点偏了,这些问题在迁移的时候可能完全没报错,只有业务跑起来才会慢慢暴露出来。
我这些年总结出来的经验就是:
-
别信工具的"成功"提示:工具说迁移成功,不代表数据没问题。一定要做深度校验。
-
别只数行数:行数相等不等于内容一致。至少要做行级哈希校验。
-
别忽略LOB:BLOB/CLOB最容易出问题,迁移完一定要抽样校验。
-
别简化测试:要用真实数据规模测试,特别是LOB和时间精度。
-
别忘了回滚预案:所有关键步骤都要有回滚方案,而且要提前演练。
迁移这事儿,说到底就是个"细心活"。该查的地方都得查到,该验的地方都得验透,该留的证据都得留着。不然出了问题,排查起来能把人折腾死。
金仓官网:kingbase.com.cn