本文记录一个 Python 桌面工具从需求提出到稳定可用的完整开发过程。工具用于将
MySQL 生产环境数据复制到测试环境,同时支持导入 DBeaver、mysqldump 等工具生成的
大型 SQL 文件。
一、问题背景
最初的问题很常见:使用 DBeaver 将生产 MySQL 数据复制到测试环境时,大型 SQL 文件
经常让界面卡死,甚至出现 OutOfMemoryError。
问题通常不在 MySQL 服务器,而在客户端处理方式。通用数据库客户端为了提供语法
高亮、SQL 分割、错误定位和编辑功能,可能提前读取、解析或者缓存大量文件内容。
当 dump 文件达到数 GB 甚至数十 GB 时,即使电脑物理内存不少,也可能因为 Java
堆内存、字符串展开和语法树等额外开销而耗尽内存。
因此,这个工具的核心目标不是"再做一个 SQL 编辑器",而是:
- 不把整个 SQL 文件加载进内存。
- 使用数据库原生客户端执行 SQL。
- 在图形界面中提供连接、选库、日志和进度。
- 明确限定迁移方向为生产环境到测试环境。
- 兼容阿里云 RDS 等托管数据库的权限限制。
二、需求是如何逐步明确的
开发过程中,需求经历了几次很有价值的补充:
- 最初只需要输入两个环境配置并提供迁移按钮。
- 确认迁移方向为"生产环境 → 测试环境"。
- 连接数据库服务后,不能手写数据库名,而要加载数据库列表后手动选择。
- 除在线迁移外,还要支持已有
.sql和.dump文件。 - 大文件必须采用流式方式,避免重现 DBeaver 的内存问题。
- 界面、日志、确认框和错误提示必须使用中文。
- SQL 导入过程要能看到实时日志。
- MySQL 客户端应放在项目中并自动引用,减少使用门槛。
- 需要兼容 dump 中设置 binlog、GTID 的高权限语句。
- 数据库连接信息应保存到项目配置文件,GUI 修改后可以覆盖保存。
- 密码可以自动回填,但不能以明文形式落盘。
这些补充最终形成了两个独立的数据入口:
#mermaid-svg-c8jh9ajQy177MdYm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c8jh9ajQy177MdYm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c8jh9ajQy177MdYm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c8jh9ajQy177MdYm .error-icon{fill:#552222;}#mermaid-svg-c8jh9ajQy177MdYm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c8jh9ajQy177MdYm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c8jh9ajQy177MdYm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c8jh9ajQy177MdYm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c8jh9ajQy177MdYm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c8jh9ajQy177MdYm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c8jh9ajQy177MdYm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c8jh9ajQy177MdYm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c8jh9ajQy177MdYm .marker.cross{stroke:#333333;}#mermaid-svg-c8jh9ajQy177MdYm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c8jh9ajQy177MdYm p{margin:0;}#mermaid-svg-c8jh9ajQy177MdYm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-c8jh9ajQy177MdYm .cluster-label text{fill:#333;}#mermaid-svg-c8jh9ajQy177MdYm .cluster-label span{color:#333;}#mermaid-svg-c8jh9ajQy177MdYm .cluster-label span p{background-color:transparent;}#mermaid-svg-c8jh9ajQy177MdYm .label text,#mermaid-svg-c8jh9ajQy177MdYm span{fill:#333;color:#333;}#mermaid-svg-c8jh9ajQy177MdYm .node rect,#mermaid-svg-c8jh9ajQy177MdYm .node circle,#mermaid-svg-c8jh9ajQy177MdYm .node ellipse,#mermaid-svg-c8jh9ajQy177MdYm .node polygon,#mermaid-svg-c8jh9ajQy177MdYm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-c8jh9ajQy177MdYm .rough-node .label text,#mermaid-svg-c8jh9ajQy177MdYm .node .label text,#mermaid-svg-c8jh9ajQy177MdYm .image-shape .label,#mermaid-svg-c8jh9ajQy177MdYm .icon-shape .label{text-anchor:middle;}#mermaid-svg-c8jh9ajQy177MdYm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-c8jh9ajQy177MdYm .rough-node .label,#mermaid-svg-c8jh9ajQy177MdYm .node .label,#mermaid-svg-c8jh9ajQy177MdYm .image-shape .label,#mermaid-svg-c8jh9ajQy177MdYm .icon-shape .label{text-align:center;}#mermaid-svg-c8jh9ajQy177MdYm .node.clickable{cursor:pointer;}#mermaid-svg-c8jh9ajQy177MdYm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-c8jh9ajQy177MdYm .arrowheadPath{fill:#333333;}#mermaid-svg-c8jh9ajQy177MdYm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-c8jh9ajQy177MdYm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-c8jh9ajQy177MdYm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c8jh9ajQy177MdYm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-c8jh9ajQy177MdYm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c8jh9ajQy177MdYm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-c8jh9ajQy177MdYm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-c8jh9ajQy177MdYm .cluster text{fill:#333;}#mermaid-svg-c8jh9ajQy177MdYm .cluster span{color:#333;}#mermaid-svg-c8jh9ajQy177MdYm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-c8jh9ajQy177MdYm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-c8jh9ajQy177MdYm rect.text{fill:none;stroke-width:0;}#mermaid-svg-c8jh9ajQy177MdYm .icon-shape,#mermaid-svg-c8jh9ajQy177MdYm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c8jh9ajQy177MdYm .icon-shape p,#mermaid-svg-c8jh9ajQy177MdYm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-c8jh9ajQy177MdYm .icon-shape .label rect,#mermaid-svg-c8jh9ajQy177MdYm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c8jh9ajQy177MdYm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-c8jh9ajQy177MdYm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-c8jh9ajQy177MdYm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 在线生产数据库
Python 分批读取
测试数据库
已有 SQL dump
1 MB 有界流式读取
MySQL 官方客户端
在线迁移适合源库和目标库都能直接访问、且测试库已经存在兼容表结构的场景;SQL
文件导入则适合已经生成 dump、需要复制表结构,或者需要处理超大离线文件的场景。
三、为什么流式导入不会轻易耗尽内存
所谓流式导入,就是文件只读取一小段,立即交给 MySQL 执行,然后再读取下一段。
传统的整体读取方式类似:
python
sql = open("backup.sql", "rb").read()
execute(sql)
如果文件有 20 GB,仅文件内容就需要约 20 GB 内存;经过字符串转换和解析后,实际
占用还可能更高。
本工具采用有界读取:
python
while True:
segment = sql_stream.readline(1024 * 1024)
if not segment:
break
mysql_process.stdin.write(segment)
单次读取上限约为 1 MB。即使一条扩展 INSERT 非常长,readline(size) 也不会
无上限增长内存,而是将长语句分段交给管道。
底层效果相当于经典命令:
powershell
mysql -h <测试RDS地址> -P 3306 -u <账号> -p <目标库> < backup.sql
不同之处是图形工具在外面增加了数据库选择、进度计算、日志捕获、取消操作和防误
操作确认。
四、桌面界面的技术架构
界面使用 Python 自带的 Tkinter,原因很务实:
- Windows 安装 Python 后通常可以直接使用。
- 不需要再引入大型 GUI 框架。
- 适合表单、进度条、日志窗口和确认对话框。
- 项目部署和维护成本较低。
耗时操作不能放在 Tkinter 主线程中,否则连接或迁移时窗口会显示"未响应"。工具
采用"工作线程 + 消息队列"结构:
MySQL/RDS 线程安全队列 后台工作线程 用户界面 MySQL/RDS 线程安全队列 后台工作线程 用户界面 #mermaid-svg-aQohFbwYo6ly7xwZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aQohFbwYo6ly7xwZ .error-icon{fill:#552222;}#mermaid-svg-aQohFbwYo6ly7xwZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aQohFbwYo6ly7xwZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aQohFbwYo6ly7xwZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aQohFbwYo6ly7xwZ .marker.cross{stroke:#333333;}#mermaid-svg-aQohFbwYo6ly7xwZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aQohFbwYo6ly7xwZ p{margin:0;}#mermaid-svg-aQohFbwYo6ly7xwZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aQohFbwYo6ly7xwZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aQohFbwYo6ly7xwZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-aQohFbwYo6ly7xwZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-aQohFbwYo6ly7xwZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-aQohFbwYo6ly7xwZ .sequenceNumber{fill:white;}#mermaid-svg-aQohFbwYo6ly7xwZ #sequencenumber{fill:#333;}#mermaid-svg-aQohFbwYo6ly7xwZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-aQohFbwYo6ly7xwZ .messageText{fill:#333;stroke:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aQohFbwYo6ly7xwZ .labelText,#mermaid-svg-aQohFbwYo6ly7xwZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .loopText,#mermaid-svg-aQohFbwYo6ly7xwZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aQohFbwYo6ly7xwZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-aQohFbwYo6ly7xwZ .noteText,#mermaid-svg-aQohFbwYo6ly7xwZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-aQohFbwYo6ly7xwZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aQohFbwYo6ly7xwZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aQohFbwYo6ly7xwZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aQohFbwYo6ly7xwZ .actorPopupMenu{position:absolute;}#mermaid-svg-aQohFbwYo6ly7xwZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-aQohFbwYo6ly7xwZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aQohFbwYo6ly7xwZ .actor-man circle,#mermaid-svg-aQohFbwYo6ly7xwZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-aQohFbwYo6ly7xwZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启动连接或迁移 执行网络与数据库操作 返回进度、警告或错误 写入事件 每 100 ms 读取事件 更新进度条和日志
后台线程从不直接操作 Tkinter 控件,只把事件写入 queue.Queue;主线程通过
after(100, ...) 定时取出事件。这样既避免线程安全问题,也能保持界面响应。
五、连接服务后再选择数据库
连接表单只要求填写:
- 主机地址
- 端口
- 用户名
- 密码
点击"连接并加载数据库"后,工具执行:
sql
SHOW DATABASES;
然后过滤 information_schema、mysql、performance_schema 和 sys 等系统
数据库,把当前账号可见的业务库放入只读下拉框。
当用户修改主机、端口、用户名或密码时,之前加载的数据库选择立即失效,必须重新
连接。这一点可以避免用户更改服务器后,界面仍保留旧服务器的数据库名。
六、两种迁移方式
1. 在线数据库迁移
在线模式通过 mysql-connector-python 分批读取源表,再批量写入目标表。
主要步骤是:
- 在源库创建一致性快照事务。
- 查询全部基础表。
- 检查目标库是否存在相同表。
- 统计总行数用于进度计算。
- 按配置的批次大小执行
fetchmany()和executemany()。 - 每批提交并更新日志。
源库账号只需要读取权限。目标库缺少表时,工具不会自行猜测建表结构,而是停止并
建议使用包含 DDL 的 SQL dump。
2. SQL dump 流式导入
SQL 文件模式由 Python 启动 mysql.exe,通过标准输入管道持续发送文件内容。
为了避免子进程死锁,stderr 由独立线程持续读取。MySQL 的警告和错误会实时进入
界面日志,而不是等进程退出后一次性显示。
进度根据"已读取字节数 / 文件总字节数"计算。为了避免一个几十 GB 文件产生数万
个 GUI 事件,进度更新还做了节流:读取约 32 MB 或间隔约 250 ms 才更新一次。
七、MySQL 客户端的便携化
SQL 流式导入依赖 MySQL 原生客户端。为了避免要求每位使用者手动安装和配置 PATH,
项目内置了 MySQL Community Server 8.4.11 LTS 发行包中的客户端文件。
工具优先寻找:
text
tools\mysql-client\bin\mysql.exe
若项目内客户端不存在,才继续搜索系统 PATH 或让用户手动选择。
下载的官方 ZIP 使用 MD5
2e833921898a9a030ea6bfe81bd811bc 完成校验,只提取:
mysql.exe- 必要的非调试运行库
- 认证插件
- 字符集文件
- 英文错误消息
- GPL 许可证
项目不包含或启动 MySQL 服务端。
MySQL 官方提供 Windows ZIP Archive 作为免安装分发形式:
八、RDS 为什么会拒绝 SQL_LOG_BIN
DBeaver 或 mysqldump 生成的文件中可能包含:
sql
SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN;
SET @@SESSION.SQL_LOG_BIN = 0;
SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN;
SET @@GLOBAL.GTID_PURGED = ...;
这些不是业务数据,而是用于复制和 GTID 恢复的管理语句。
sql_log_bin 控制当前会话的变更是否写入二进制日志。MySQL 官方说明,在启用
GTID 的服务器上,mysqldump 会加入关闭会话 binlog 的语句,以避免恢复时重新分配
GTID;修改这个受限会话变量需要额外管理权限。
参考:
阿里云 RDS 等托管服务不会轻易授予普通账号这类管理权限,因此直接执行可能中断。
本工具默认开启"兼容云数据库",在流式读取过程中识别并跳过:
@MYSQLDUMP_TEMP_LOG_BIN@@SESSION.SQL_LOG_BIN@@GLOBAL.GTID_PURGED
过滤同样是有界的。工具只在一行开头检查明确的 SET 模式;对于可能非常长的
INSERT,仍按 1 MB 分段透传,不会因为过滤而重新产生大内存问题。
跳过这些语句后,导入产生的变更会正常进入测试 RDS 的 binlog。对于普通的生产数据
复制到测试环境,这是比索取高风险管理权限更合理的默认行为。
需要特别说明:工具没有添加 mysql --force。--force 会让客户端忽略其他 SQL
错误并继续执行,可能导致部分表导入失败却被误认为成功。当前实现只跳过明确识别的
binlog/GTID 管理语句,其他表结构、数据和权限错误仍会中断并显示在日志中。
九、阻止 SQL 文件写错数据库
仅在命令行传入目标数据库,并不能绝对阻止 dump 内部的 USE other_database;
切换数据库。
为此,工具会扫描 dump 开头最多约 8 MB 的内容。如果发现 USE 指定的数据库与
界面选择不一致,就停止导入并提示用户。
执行客户端时还会传入 --one-database,形成第二层保护。
这不能代替操作前核对,但可以拦住最常见的"选了测试库,SQL 文件内部却切换到另一个
库"的错误。
十、一次真实的 Python 3.14 驱动崩溃
开发中遇到过一个普通异常捕获无法解决的问题:点击连接后整个 Python 窗口直接退出。
Windows 事件查看器记录为:
text
python.exe 3.14
faulting module: MSVCP140.dll
exception code: 0xc0000005
这不是用户名、密码或 RDS 白名单错误。普通网络错误应该抛出 Python 异常并显示在
错误框中;0xc0000005 表示本地原生组件访问冲突,Python 进程已经被操作系统终止。
解决方式是强制 MySQL Connector 使用纯 Python 协议实现:
python
mysql.connector.connect(
host=host,
port=port,
user=user,
password=password,
use_pure=True,
)
这样连接失败会变成可捕获异常,界面能够正常记录并展示错误,而不是直接闪退。
这个案例也说明:对桌面数据库工具来说,"异常处理"不仅是多写一个
try/except,还要尽量隔离和减少不稳定的原生依赖。
十一、安全与防误操作设计
生产数据复制到测试环境仍然是高风险操作,尤其是测试库中可能存在其他团队正在使用
的数据。因此工具加入了多层保护:
- 界面固定显示
PRODUCTION → TEST。 - 源端标记为只读,目标端标记为将被写入。
- 阻止源数据库与目标数据库完全相同。
- 执行前展示源、目标、文件和操作方式。
- 用户必须输入大写
TEST才能执行。 - 密码不写入日志,也不放入命令行参数。
- dump 中的
USE与目标库不一致时停止。 - 不使用
--force隐藏真正的 SQL 错误。
导入客户端密码通过子进程环境变量传递。它比把密码直接放到命令行参数中更不容易被
普通进程列表看到,但仍应遵循最小权限原则,并避免在共享管理员机器上长期运行。
此外,生产数据进入测试环境前应先进行脱敏,尤其是手机号、邮箱、身份证、地址、
订单和支付信息。迁移工具解决的是可靠传输问题,不等同于完成数据合规。
十二、配置持久化与 Windows DPAPI 密码保护
1. 为什么不能直接保存明文密码
数据库工具如果每次打开都要求重新填写两端地址、端口、账号、密码和数据库名,使用
体验会很差。但直接把密码写成下面这样同样不可取:
json
{
"host": "test.rds.internal",
"user": "writer",
"password": "plain-text-password"
}
即使配置文件没有主动上传,明文密码仍可能进入 Git 历史、备份软件、聊天附件、
桌面搜索索引或者故障诊断包。
另一个看似可行但实际无效的方案,是在 Python 源码里放一把固定 AES 密钥。只要
应用需要自己解密,密钥就必须随应用分发;拿到源码或可执行文件的人也能找到它。这
只是把明文密码换了一个位置。
2. 为什么选择 Windows DPAPI
工具最终使用 Windows Data Protection API,通过 CryptProtectData 加密密码、
通过 CryptUnprotectData 解密。
DPAPI 的关键优势是应用不需要自行生成、保存或分发加密密钥。默认情况下,加密结果
与当前 Windows 登录用户和当前电脑绑定。微软文档说明,通常只有拥有相同登录凭据
的用户才能解密,而且加解密通常需要发生在同一台电脑上。
参考:
Python 标准库没有直接暴露 DPAPI,因此项目通过 ctypes 调用 Windows
Crypt32.dll。核心过程可以简化为:
python
encrypted_blob = CryptProtectData(
plaintext_password,
flags=CRYPTPROTECT_UI_FORBIDDEN,
)
config["password"]["ciphertext"] = base64.b64encode(
encrypted_blob
).decode("ascii")
Base64 只负责让二进制密文能够安全写入 JSON,它本身不是加密。真正的保护来自
DPAPI。
3. 配置文件结构
项目根目录新增 config.json:
json
{
"version": 1,
"password_protection": "Windows DPAPI (current user)",
"source": {
"host": "prod.rds.internal",
"port": 3306,
"user": "reader",
"password": {
"scheme": "dpapi-current-user",
"ciphertext": "AQAAANCMnd8BFdER..."
},
"database": "production_db"
},
"target": {
"host": "test.rds.internal",
"port": 3306,
"user": "writer",
"password": {
"scheme": "dpapi-current-user",
"ciphertext": "AQAAANCMnd8BFdER..."
},
"database": "testing_db"
}
}
配置包含生产端和测试端的:
- 主机地址
- 端口
- 用户名
- DPAPI 加密密码
- 已选择数据库
程序启动时自动加载配置、解密密码并回填两个环境表单。用户在 GUI 中修改后,点击
"保存连接配置"即可覆盖保存。数据库名也会恢复到下拉框中;需要重新获取服务器
上的完整数据库列表时,仍可点击"重新连接并加载数据库"。
4. 原子保存,避免配置写坏
配置保存不是直接覆盖原文件,而是先写入同目录临时文件:
text
config.json.tmp
完整序列化成功后,再通过 os.replace() 原子替换 config.json。如果程序在写入
中途异常退出,原配置仍然存在,不会只剩下一半 JSON。
config.json 和临时文件都被加入 .gitignore,避免真实环境地址、账号和密文被
误提交到代码仓库。需要注意,密文虽然不是明文密码,但连接地址和用户名仍然属于
内部配置信息。
5. DPAPI 的使用边界
当前设计刻意选择"当前用户"范围,而没有使用
CRYPTPROTECT_LOCAL_MACHINE。后者会允许同一电脑上的其他用户解密,不符合最小
暴露原则。
这也意味着:
- 配置复制到另一台电脑后,密码通常无法解密。
- 其他 Windows 用户打开同一份配置时,密码通常无法解密。
- 管理员重置用户密码等特殊情况可能导致旧 DPAPI 数据无法恢复。
- 更换电脑或账号后,应重新填写密码并点击保存。
如果未来需要多人共享配置,不应把 DPAPI 改成项目内固定密钥,而应接入企业密码
保险库、云密钥管理服务或短期凭证机制。
十三、日志与取消语义
日志区会显示:
- 正在连接的服务,但不显示密码
- 数据库加载结果
- 当前表及已复制行数
- SQL 文件读取量和总大小
- MySQL 客户端实时警告与错误
- RDS 兼容语句的跳过数量
- 完成、失败或取消状态
在线迁移和 SQL 导入都支持取消,但取消不是完整事务回滚:
- 在线模式中,已经提交的批次会保留。
- SQL 模式中,已经由 MySQL 执行的语句会保留。
界面会明确提示这个事实,避免用户把"取消"误解成"恢复到导入前状态"。正式执行前
仍应备份测试库。
十四、界面适配中的一个小坑
工具加入更多选项后,窗口高度随之增加。某些 Windows 分辨率或显示缩放下,位于
最下方的"开始导入 SQL"按钮只显示文字上半部分。
最终处理方式不是继续盲目增加窗口高度,而是:
- 使用高度和内边距明确的原生按钮。
- 让底部操作栏优先保留完整布局空间。
- 让可伸缩的日志区域吸收窗口高度变化。
- 根据屏幕尺寸计算初始窗口大小。
验证时将窗口缩小到 1020 × 760,按钮仍能完整显示。这也是桌面界面设计中一个
常见原则:关键操作区域应固定可见,可滚动或可压缩的内容放在中间。
十五、测试结果
项目当前包含 13 项单元测试,覆盖:
- 密码不会出现在安全描述中
- 数据库身份比较不区分大小写
- SQL 标识符反引号转义
- 服务级连接不强制携带数据库名
- 强制启用
use_pure - 文件大小格式化
- dump 中
USE数据库一致性检查 - binlog/GTID 高权限语句识别
- 普通
SET NAMES不会被错误过滤 - DPAPI 密码加密和解密往返
- 空密码处理
- 配置保存后不包含明文密码
- 配置文件保存、加载及不存在场景
运行测试:
powershell
py -m unittest discover -s tests -v
十六、项目结构
text
mysql-migrator/
├─ app.py # 中文 Tkinter 界面与线程事件调度
├─ config_store.py # JSON 配置和 Windows DPAPI 密码保护
├─ config.json # 本机连接配置,不提交到 Git
├─ migrator.py # 在线迁移和 SQL 流式导入
├─ requirements.txt # mysql-connector-python
├─ .gitignore
├─ tests/
│ ├─ test_config_store.py
│ └─ test_migrator.py
├─ tools/
│ └─ mysql-client/
│ ├─ bin/mysql.exe
│ ├─ lib/plugin/
│ ├─ LICENSE
│ └─ README.md
启动方式:
powershell
cd D:\projects\mysql-migrator
py -m pip install -r requirements.txt
py app.py
启动截图:
十七、总结
这个工具解决问题的关键并不是 GUI 本身,而是为不同风险选择合适的技术边界:
- 大文件交给 MySQL 原生客户端流式执行。
- Python 只做有界读取、进度、日志和安全控制。
- 在线迁移按批次处理,不在内存中堆积整表数据。
- RDS 兼容只过滤明确的管理语句,不掩盖其他错误。
- 原生驱动发生进程级崩溃时,切换到可控的纯 Python 实现。
- 连接信息保存在 JSON 中,密码使用当前 Windows 用户的 DPAPI 加密。
- 对生产到测试的写入操作加入多层确认,而不是依赖用户"记得小心"。
从 DBeaver 卡死这个表面问题出发,最终得到的是一个资源占用稳定、错误可观察、
迁移方向明确、适合托管 MySQL 环境的桌面数据工具。这类内部工具不一定需要复杂的
技术栈,但必须把大文件处理、权限边界、失败语义和操作安全当作一等公民。