ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

dbswitch 数据库迁移工具 Docker 部署与调优实践

dbswitch 数据库迁移工具 Docker 部署与调优实践

1. 先把场景说清楚:什么样的迁移需求值得上容器

1.1 异构库迁移真正难的地方在哪

做过 dbswitch 数据库迁移工具 Docker 部署的朋友大概都有同感:数据迁移这活儿,难的从来不是"把数据从 A 表搬到 B 表",而是搬之前那一堆环境上的烂事。源端可能是跑了七八年的老库,字符集是 GBK 混着 Latin1;目标端是新上的实例,默认 utf8mb4,大小写敏感设置还不一样;中间再夹一个"这台机器上已经装了 JDK 17,但迁移工具只认 JDK 8"的经典冲突。我见过太多团队,方案评审两小时就过了,结果光是把驱动包塞对位置、把时区调对,就耗掉一整周。

迁移工具本身的定位也很尴尬——它不属于业务系统,平时没人管,用的时候又要立刻能用。你要是按常规方式装在宿主机上,装完往那一放,半年后升级操作系统,依赖全乱;想回退到上一个版本,还得先回忆当初改了哪个配置文件。这类"用完就闲置、需要时必须可靠"的工具,天生适合容器化。

dbswitch 这个工具本身是走 Java 路线的,一套程序同时支持库到库、库到文件、文件到库三种迁移模式,表结构迁移和数据同步都能干,覆盖的数据库类型也比较全,常见的关系型库基本都在支持范围内,国产库也有一部分适配。它的核心价值在于:把异构库之间的 DDL 差异、字段类型映射、批量读写这些脏活封装起来,让你在界面上点几下就能跑一个迁移任务,而不是手写几百行 JDBC 代码。

所以我这篇文章要讲的事情很明确:怎么用 Docker 把这套工具稳稳当当地跑起来,从资源评估、卷挂载、驱动管理,一直到任务配置和割接节奏,全流程走一遍。适合两类人看:一是手上正压着一次库迁移、想找个能快速验证方案的人;二是已经决定用这个工具、但不想在环境上反复折腾的人。

1.2 这个工具能覆盖的活,以及它覆盖不了的

先把预期摆正,这比什么都重要。dbswitch 擅长的场景是结构迁移加数据搬运:把源库的表结构按目标库的语法重建出来,字段类型做映射转换,然后把存量数据一批一批灌过去,增量部分靠时间戳字段或者自增主键做水位线持续追平。对于"换库不换业务逻辑"这种最常见的中小型迁移,它的完成度是够的。

但它不是万能的。存储过程、触发器、自定义函数、视图里的复杂方言,这类东西工具能帮你迁过去一部分,剩下的基本还得人工改。序列和自增主键的衔接也要单独处理——源库用的是序列,目标库用的是自增列,工具不会自动帮你把序列的当前值同步成自增列的起始值,这个坑我踩过,迁移完新插入的数据主键直接从 1 开始,跟历史数据撞车。

还有一类容易忽略的:大字段。CLOB、BLOB 这些字段在小数据量测试里一点问题没有,上了生产、单表几十万行带大字段,内存直接被打爆。这不是工具的问题,是批量读取时把整个结果集缓存在内存里的通病,解决办法后面会讲。

提示:如果你的迁移涉及大量存储过程重写,别指望工具一键搞定,把它当成"结构 + 数据"的搬运工,逻辑层的改造单独排期。

1.3 容器化装法与传统装法的取舍

我把三种常见部署方式的差异整理成一张表,你自己对照团队情况选:

维度宿主机直接部署虚拟机独立部署Docker 容器部署
环境隔离性差,JDK 版本容易冲突好,但资源开销大好,进程级隔离
启动速度快慢,分钟级秒级
驱动包管理手工拷进 lib 目录手工拷,但要重装环境时得重来卷挂载,宿主机管一份即可
版本回退麻烦,要备份整个目录靠快照换镜像 tag 即可
资源占用最低最高中等,需预留 JVM 堆
适合场景临时验证长期独占的重型任务反复试错、多环境并存

结论其实很明显:迁移这种事,你大概率要跑好几轮试迁移,每轮都可能要调参数、换驱动、加内存。容器化能让你在五分钟内重建一个干净环境,这个价值在试错阶段被放大了好几倍。我自己的习惯是,本地先用容器跑通全流程,参数定型之后再照着同一份编排文件部署到正式环境,两边配置完全一致,省掉"本地好的、线上不行"这类扯皮。

2. 部署前的环境盘点与几个必须先拍板的决策

2.1 宿主机资源怎么估,别拍脑袋

资源评估是最容易被跳过的一步,也是后面所有性能问题的根源。dbswitch 本身是 Java 应用,吃的是堆内存;但真正吃资源的是迁移过程中的数据缓冲。我的估算方法是这样的:先看单表最大行数和单行平均宽度,算出单表数据量,然后按"批量提交批次大小 × 单行宽度 × 并发线程数 × 安全系数 2"来估峰值内存。

举个例子,某张订单表 800 万行,单行平均 600 字节,批量大小设 2000,并发 4 个线程:2000 × 600B × 4 × 2 ≈ 9.6MB,这只是缓冲层的开销。但 JDBC 驱动自己还有一层缓冲,加上结果集解析的对象开销,实际占用通常要乘 3 到 5 倍。所以我一般直接给 JVM 堆 4G 起步,表特别宽的(比如带多个大字段)给到 8G。

CPU 方面,迁移是 IO 密集型偏一点计算,4 核基本够用,并发线程数不要超过核数太多,否则上下文切换反而拖慢吞吐。磁盘主要看日志和临时文件,容器日志一定要做大小限制,不然一个跑了三天的任务能把磁盘写满——这个我在下面编排文件里会加上。

# 上线前先在宿主机上看一眼真实资源 free -g nproc df -h /var/lib/docker

2.2 网络模式与端口规划

容器里连数据库,最容易翻车的就是网络。默认 bridge 模式下,容器里的127.0.0.1指的是容器自己,不是宿主机。如果源库或目标库跑在宿主机上,你得用宿主机的 bridge 网关地址,通常是172.17.0.1;或者更稳妥的做法,在编排文件里加一条extra_hosts,把host.docker.internal映射到宿主机网关,连接串里直接写这个域名,换机器的时候不用改配置。

端口方面,工具自带 Web 管理界面,容器内监听的是它自己的服务端口。我习惯映射到宿主机的高位端口,比如18080:8080,避免跟宿主机上已有的服务抢 8080。如果你要走 host 网络模式,那就得提前确认宿主机上这个端口空闲,host 模式的好处是容器直接复用宿主机网络栈,连库地址不用绕,坏处是端口冲突排查起来更麻烦,而且失去了端口隔离。我的建议是常规场景就用 bridge,除非你确实碰到了网络性能瓶颈。

2.3 数据卷:决定你会不会白干一遍

这一节我要重点强调,因为它是最容易让人前功尽弃的地方。dbswitch 会把迁移任务的配置、数据源连接信息、任务执行记录这些元数据存在本地文件里(具体是内置的嵌入式数据库文件,不同版本目录结构略有差异)。如果你不挂卷,容器一删,所有配好的数据源和任务定义全没了,下次还得从头配一遍。

我一般挂四个目录,对应四类东西:

  • 数据目录:存元数据库文件和任务配置,最关键,必须持久化
  • 日志目录:迁移日志、错误堆栈,排查问题全靠它
  • 驱动目录:外部 JDBC 驱动包的存放位置,挂出来之后升级驱动不用重建镜像
  • 配置目录:应用配置文件,方便不改镜像就调参数
mkdir -p /data/dbswitch/{data,logs,libs,conf} chmod -R 755 /data/dbswitch

注意:挂载目录的属主权限要对,容器内进程如果不是 root 运行,宿主机目录权限太严会导致写不进去,表现是启动正常但任务跑不起来,日志里报权限拒绝。这个坑很隐蔽,我第一次遇到时查了两个小时。

3. 镜像准备与容器编排落地

3.1 镜像从哪来:两条路的选择

第一条路是直接用现成的镜像,前提是你能拿到跟目标版本对得上的。第二条路是自己构建,可控性最高。我倾向后者,原因有三:一是驱动包可以提前打进镜像,省掉容器起来后再手工拷的步骤;二是 JVM 参数、时区、字符集这些可以在构建阶段就固化;三是版本可追溯,出问题能明确知道跑的是哪次构建。

自己构建的成本其实很低,一个 Dockerfile 十几行就够。基础镜像用团队内网镜像仓里的 JRE 8 就行,没必要追求新版本——这类老牌 Java 工具对高版本 JDK 的兼容性不一定好,JDK 8 是最稳的。

3.2 自己写 Dockerfile 的关键几行

FROM openjdk:8-jre-slim # 时区与字符集,这两行不加,后面时间字段必偏移 ENV TZ=Asia/Shanghai ENV LANG=C.UTF-8 RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /opt/dbswitch # 应用包与默认配置 COPY dbswitch.jar /opt/dbswitch/dbswitch.jar COPY conf/ /opt/dbswitch/conf/ # 驱动目录,构建时先建好,运行时挂卷覆盖 RUN mkdir -p /opt/dbswitch/libs /opt/dbswitch/logs /opt/dbswitch/data EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Duser.timezone=Asia/Shanghai -Dfile.encoding=UTF-8 -jar /opt/dbswitch/dbswitch.jar"]

这里有几个细节值得说。-Duser.timezone单独指定,是因为光设系统时区环境变量,JVM 有时还是会读成 UTC,导致迁移过程中的时间字段整体偏 8 小时——这种数据错误最要命,量小的时候根本发现不了,等核对报表时才炸。字符集同理,-Dfile.encoding=UTF-8不写,中文表名、中文注释容易变乱码。

ENTRYPOINT用sh -c包一层,是为了让$JAVA_OPTS这个环境变量能展开。直接写 exec 形式的话,环境变量里的多个参数会被当成一个整体传给 java,JVM 直接报错退出。

3.3 docker run 快速验证

正式编排之前,先跑一条命令验证镜像能不能起来,这比写完 yaml 再调试省时间:

docker run -d --name dbswitch-test \ -p 18080:8080 \ -e TZ=Asia/Shanghai \ -e JAVA_OPTS="-Xms1g -Xmx2g" \ -v /data/dbswitch/data:/opt/dbswitch/data \ -v /data/dbswitch/logs:/opt/dbswitch/logs \ dbswitch:1.0.0 # 跟一眼启动日志 docker logs -f dbswitch-test

日志里看到服务端口监听成功、应用启动完成字样,就说明基本没问题了。如果卡在启动阶段反复重启,八成是内存给太少,容器被 OOM Killer 干掉,用docker inspect dbswitch-test | grep -i oom能确认。

3.4 docker-compose 正式部署

验证通过后,用编排文件固化下来,方便以后一键重建:

version: "3.8" services: dbswitch: image: dbswitch:1.0.0 container_name: dbswitch restart: unless-stopped ports: - "18080:8080" environment: TZ: Asia/Shanghai JAVA_OPTS: >- -Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/dbswitch/logs volumes: - /data/dbswitch/data:/opt/dbswitch/data - /data/dbswitch/logs:/opt/dbswitch/logs - /data/dbswitch/libs:/opt/dbswitch/libs - /data/dbswitch/conf:/opt/dbswitch/conf extra_hosts: - "host.docker.internal:host-gateway" ulimits: nofile: soft: 65536 hard: 65536 logging: driver: json-file options: max-size: "100m" max-file: "3"

几个参数解释一下。restart: unless-stopped保证宿主机重启后自动拉起,但你自己手动停的不会被自动重启,符合预期。HeapDumpOnOutOfMemoryError是保命设置,真发生内存溢出时能留下堆转储文件,事后分析比看日志猜有用得多。ulimits里的文件句柄数,是为了应对高并发读写时连接数暴涨——默认 1024 在迁移大表时很容易打满,报 "Too many open files",这个报错信息很不直观,容易误判成网络问题。日志轮转必须配,不然一个跑几天的任务能把磁盘写穿。

启动命令就一句:

cd /data/dbswitch && docker compose up -d && docker compose logs -f --tail=100

4. 启动之后:验证、数据源接入与驱动管理

4.1 三步确认服务真的活了

容器状态显示 running 不代表服务可用,得做三层确认。第一层看进程,docker exec -it dbswitch ps -ef | grep java,确认 Java 进程在。第二层看端口,docker exec -it dbswitch netstat -tlnp | grep 8080,确认监听成功。第三层才是浏览器访问,打开http://宿主机IP:18080,看到登录页或者管理界面就齐活了。

初始账号密码看具体版本的说明,一般镜像文档里会写,第一次登录后立刻改掉。有些版本首次启动时会自动初始化元数据库,这个过程需要几秒到十几秒,如果访问报错,先等半分钟再刷新,别急着判定失败。

4.2 驱动包怎么放才不踩坑

这是整个部署过程里最容易出问题的一环。Java 工具连数据库全靠 JDBC 驱动,而有些数据库的驱动包因为授权原因不会随工具一起分发,需要你自己准备。放的位置就是前面挂载的libs目录,宿主机放进去,容器里就能读到。

几个实操要点:

  • 版本要匹配:驱动版本跟数据库服务端版本跨度太大容易出怪问题,比如连老版本库用最新驱动,可能报协议不兼容。保险做法是选跟服务端大版本接近的驱动。
  • 别放重名的包:同一个libs目录下如果存在两个不同版本的同类驱动,类加载时谁先谁后不确定,表现是时好时坏,非常难查。放之前先ls一遍。
  • 放完要重启:驱动是在应用启动时扫描加载的,热放进去不生效,必须重启容器。
  • 文件权限:驱动 jar 包给 644 权限就行,属主跟容器运行用户保持一致。
# 放入驱动后确认容器内能看到 docker exec -it dbswitch ls -lh /opt/dbswitch/libs docker restart dbswitch

4.3 源端与目标端的连接串写法

连接串的写法直接决定能不能连上。容器内访问宿主机上的库,地址写host.docker.internal;访问同网段的其他机器,写实际 IP 就行。这里列几个常见形态供参考:

# 连宿主机上的库 jdbc:mysql://host.docker.internal:3306/source_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai # 连同网段另一台机器 jdbc:postgresql://192.168.10.21:5432/target_db # 连另一台容器(同一 compose 网络内用服务名) jdbc:mysql://mysql-server:3306/target_db

那个serverTimezone参数别省,很多连接超时、时间字段错乱的问题都跟它有关。另外连接串里的库名要写对,有些工具在库名不存在时会报一个很模糊的"连接失败",实际是库名拼错了。

提示:数据源配好之后,界面上一般都有"测试连接"按钮,务必点一下。测试通过再保存,能省掉后面任务跑到一半才发现连不上的尴尬。

5. 迁移任务的配置逻辑与调优参数

5.1 表结构与类型映射,别全信默认值

工具在做结构迁移时,会按内置的映射规则把源库字段类型翻译成目标库类型。这套默认规则覆盖了大部分常见情况,但总有几个地方需要人工干预。

最典型的是数值类型。源库里的NUMBER(10,2)映射到目标库,默认可能给一个精度过大的类型,白白浪费空间;反过来,如果源库有个NUMBER不带精度,映射时可能被压成整数类型,小数部分直接丢掉——这种数据损失是不可逆的。另一个高频问题是日期时间,源库的DATE类型在很多数据库里是带时分秒的,而目标库的DATE只有日期,映射过去时分秒全丢。这类字段必须在任务配置里单独指定映射关系,别偷懒。

字符串长度也要看一眼。源库按字节算长度、目标库按字符算,中文字符占多个字节的情况下,长度换算容易出偏差,导致插入时报超长。稳妥做法是结构迁移完成后,把目标库的表结构导出来人工过一遍,重点看数值精度、时间类型、字符串长度这三类。

5.2 全量同步的批量与并发怎么定

批量大小和并发数是影响迁移速度的两个关键旋钮,但它们的取值不是越大越好。批量太大,单次提交事务过长,一方面占内存,另一方面目标库的 undo 或者 WAL 日志会暴涨,极端情况下把库拖垮;批量太小,网络往返次数多,吞吐上不去。

我的经验值是这样的:单表数据行数在百万级以下,批量 1000 到 2000;千万级,批量 2000 到 5000;如果表特别宽(单行超过 2KB),批量压到 500 到 1000。并发数从 2 起步,观察目标库的 CPU 和写入延迟,没压力就加到 4,一般不建议超过 8,再多收益递减明显。

调优过程要有数据支撑,别凭感觉。每轮跑完记录三个数:总耗时、平均每秒写入行数、目标库的写入延迟峰值。做一张简单的对比表,两三轮就能找到这台环境的最优组合。

批量大小并发单表 500 万行耗时目标库写入延迟结论
10002约 42 分钟低稳但慢
30004约 19 分钟中推荐
80008约 21 分钟高,偶发超时不划算

5.3 增量同步的水位线设计

全量跑完之后要接增量,增量靠什么判断"哪些是新数据",这是设计的核心。常见两种做法:一是用时间戳字段,比如update_time,每次同步只取大于上次最大时间的记录;二是用自增主键,取大于上次最大 ID 的记录。

时间戳的坑在于,如果业务写入时时间不是严格递增的(比如批量导入时用了同一个时间),会漏数据。自增主键的坑在于,物理删除的数据感知不到,主键也可能因为回滚而出现空洞。我一般两个都用:主键做分页游标,时间戳做增量判断,再配一个定期全量比对做兜底。

水位线的存储位置也要注意,它一般存在元数据里,所以前面强调的卷挂载就体现价值了——容器重建后水位线还在,不会从头再跑一遍。但反过来说,如果你要重置任务重新跑,得记得把水位线一起清掉,不然它会认为"已经同步到这儿了",直接跳过前面的数据,表现是任务秒完成、一行没迁。

6. 从试跑到割接:一份可复用的执行节奏

6.1 三轮试跑,每轮验证不同的东西

我习惯把试跑拆成三轮,每轮的关注点完全不同。

第一轮只跑结构迁移,不迁数据。目的是看 DDL 转换后目标库能不能正常建表,索引、主键、非空约束这些有没有丢失。这一轮跑完,把目标库的表结构导出来跟设计文档比对。

第二轮跑小批量数据,选几张有代表性的表,每张表限制几千行。目的是验证类型映射是否正确,特别是时间、小数、大字段这三类。跑完抽样比对原始数据,肉眼确认几次就心里有数了。

第三轮才跑全量,同时开着监控看目标库的负载。这一轮的重点不是"能不能跑通",而是"跑的时候业务扛不扛得住"。如果目标库是生产库,一定要在业务低峰期跑,并且提前把批量大小降下来。

6.2 一致性校验怎么做才靠谱

校验分三个层次,从粗到细。

第一层是行数比对,源表 count 和目标表 count 对比。这个最快,但只能发现大问题。注意 count 要在同一时间点做,或者先停写,否则一边写一边数永远对不上。

第二层是关键字段聚合比对,比如对金额字段求和、对最大 ID 取最大值,两边对比。这个能发现"行数对但内容错"的情况。

第三层是抽样明细比对,按主键随机抽几百条,逐字段比对。有些数据库支持对整表算校验和,这个最省事,但要注意字符集和排序规则不同会导致校验和不一致,得先统一。

-- 行数比对示例 SELECT COUNT(*) FROM source_table; SELECT COUNT(*) FROM target_table; -- 关键字段聚合 SELECT SUM(amount), MAX(id), MIN(create_time) FROM source_table; SELECT SUM(amount), MAX(id), MIN(create_time) FROM target_table;

6.3 回滚预案别写在 PPT 里

割接前必须有一个能落地执行的回滚方案,而且要在试跑阶段实际演练一遍。最基本的思路是:割接前对源库做一次完整备份,割接后如果发现严重问题,应用切回源库,同时把割接期间产生的新数据反向同步回去。这个反向同步的通道要提前准备好,别等到出事时才现搭。

还有一个细节:割接当天一定要把容器日志的留存时间放长,max-file调到 5 到 10,因为排查问题往往要回看几小时前的日志。同时把JAVA_OPTS里的堆转储选项留着,真出问题能抓到现场。

7. 常见问题速查与踩坑记录

7.1 启动类问题

现象可能原因排查方式解决
容器起来又立刻退出JVM 堆过大,超出容器限额docker inspect看 OOMKilled 字段降低-Xmx,或放开容器内存限制
日志报配置文件找不到挂载覆盖了镜像内配置目录docker exec ls /opt/dbswitch/conf挂载前先把默认配置拷到宿主机
端口映射了但访问不通宿主机防火墙未放行telnet 宿主机IP 端口放行对应端口
启动很慢、反复重试元数据库初始化失败或目录不可写看日志里的异常栈检查挂载目录属主与权限

7.2 迁移类问题

现象可能原因排查方式解决
任务秒完成但一行没迁水位线残留,工具认为已同步完查任务配置里的增量起点清除水位线后重跑
时间字段整体偏移 8 小时容器与 JVM 时区不一致docker exec date对比宿主机补-Duser.timezone与TZ
中文变问号或乱码连接串缺字符集参数看目标库表里的实际内容连接串加characterEncoding
大表跑到一半内存溢出批量太大,结果集全量缓存看堆转储或日志 OOM 位置降批量、加分页、加大堆
报 Too many open files文件句柄数限制ulimit -n编排里加ulimits
目标库写入超时并发太高或目标库有锁看目标库的会话与锁等待降并发,错峰执行

7.3 几条用时间换来的教训

第一条,永远不要在生产库上做第一次试跑。哪怕只是结构迁移,也可能因为一个类型映射错误把目标库的表建成完全不对的样子,清理起来很麻烦。找个同版本的测试库先跑。

第二条,迁移窗口要留出至少两倍于预估时间的余量。我的经验是,实际耗时基本是试跑耗时的 1.5 到 2 倍,因为正式环境的数据量更大、索引更多、还有业务在跑。时间估太紧,压力之下容易做错决定。

第三条,把编排文件当成代码管起来。改了什么参数、为什么改,写在注释里或者提交记录里。我见过不止一次,两轮试跑之间有人调了批量大小但没记录,结果正式跑的时候性能反而变差,回头查不到是谁改的。

第四条,也是我觉得最值钱的:迁移工具的容器只负责搬数据,别在上面装别的服务。有人图省事,把监控 agent、定时任务都塞进同一个容器,结果一出问题全家遭殃,日志也混在一起没法看。一个容器一件事,这个原则在迁移这种一次性重活上尤其重要。

最后分享一个我自己常用的小技巧。正式割接前,我会单独起一个一模一样的容器实例,只把数据卷指向另一个目录,用它做"彩排"。彩排时把整套流程完整走一遍,包括启动、连库、跑任务、看日志、模拟回滚。等彩排顺了,正式那一次基本就是照抄动作。这套做法看起来多花了一小时,但实际帮我避掉了好几次本来会在凌晨三点发生的意外。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进