定时增量每 5 秒全表扫一遍?我用 DBSyncer 跑了三组对照实验才找到原因

定时增量每 5 秒全表扫一遍?我用 DBSyncer 跑了三组对照实验才找到原因

定时增量每 5 秒全表扫一遍?我用 DBSyncer 跑了三组对照实验才找到原因

做过数据同步的人大多踩过这种坑:任务面板一片绿,“成功”数字天天往上涨,可目标库里就是少了几条新订单,删掉的数据也还躺在那里。问题往往不在工具本身,而在“增量”到底是怎么取数的。

DBSyncer 是一款国产开源的异构数据同步中间件,GitHub 和 Gitee 双仓库维护,采用 Apache-2.0 协议。它的卖点是“组合驱动”:MySQL、Oracle、SQL Server、PostgreSQL、Elasticsearch、Kafka、文件之间可以任意搭配,全量、增量都在一个 Web 控制台里配置。本文不重复功能清单,而是把 DBSyncer 2.1.2 社区版跑起来,用 SQLite 到 SQLite 的同步做三组对照实验,把“定时增量”模式里最容易被忽略的三个坑逐一复现,再顺带记录一个连接器缺陷和绕过方法。

DBSyncer 是什么:一个“连接器 × 监听器”的组合框架

先看定位。DBSyncer 的 README 列出了 23 种连接器(含只能作数据源的自定义 SQL),绝大多数既能做数据源也能做目标源,覆盖的面相当广:

类别 连接器(支持版本以 README 为准)
主流关系型 MySQL 5.7.19+、Oracle 10g-19c、SQL Server 2008+、PostgreSQL 9.5.25+
国产与分布式 达梦 DM8、人大金仓 V8、OceanBase 3.x+、TiDB 5.x+
分析型 ClickHouse 22.x+、Doris 1.2.x+、StarRocks 2.5+、DuckDB 1.5.x
嵌入式 SQLite、H2 2.2.x
非关系型与消息 Elasticsearch 6.0-8.15、MongoDB 4.2+、Redis、Kafka、RocketMQ 4.9+
其他 File(.txt / .unl)、HTTP、自定义 SQL(仅作数据源)

工程按职责拆成十来个 Maven 模块:dbsyncer-connector 放各数据库的读写实现和 SQL 生成器,dbsyncer-parser 负责字段映射、类型转换和批量写入,dbsyncer-manager 管理全量与增量任务的生命周期,dbsyncer-storage 存元数据(默认落本地磁盘,生产推荐换成 MySQL 以支持集群),dbsyncer-web 是 Spring Boot 2.5 写的控制台,dbsyncer-plugin 和 dbsyncer-sdk 提供插件扩展接口。

截至 2026 年 10 月,GitHub 仓库约 949 Star、154 Fork,最近一次提交在 10 月 13 日,Gitee 上最新标签为 v2.1.4(8 月 13 日)。社区版和专业版分开发布,专业版多了执行器并行度等配置,本文全部基于社区版。

需要先建立的一个概念是:DBSyncer 里“增量”有两种完全不同的实现,选哪种由数据源连接器决定,后面所有的坑都源于这一点。

日志解析与定时轮询:两种增量的能力边界

日志解析与定时轮询两种增量方式对比

翻 dbsyncer-connector 下每个连接器的 getListener 方法,可以把增量能力分成三类:

增量方式 典型连接器 取数原理
只支持日志 Kafka、RocketMQ、Redis、File 订阅消息或解析文件追加内容
日志 + 定时都支持 MySQL、Oracle、SQL Server、PostgreSQL、MongoDB、达梦、金仓、OceanBase、ClickHouse 日志模式读 binlog / Redo / CDC / Change Stream;定时模式按 SQL 轮询
只支持定时 SQLite、H2、DuckDB、Doris、StarRocks、Elasticsearch、HTTP 只能按 SQL 条件周期性查询

日志模式是真正意义上的 CDC(变更数据捕获)。以 MySQL 为例,DBSyncer 依赖 mysql-binlog-connector-java 伪装成从库读取 binlog,新增、修改、删除三类事件都能拿到,延迟在秒级。代价是对源库有要求:MySQL 要开启 binlog_format=ROW,同步账号要有 REPLICATION SLAVE 和 REPLICATION CLIENT 权限。

定时模式就朴素得多。源码里的 AbstractQuartzListener 按 Cron 表达式触发,每次执行一条带过滤条件的 SELECT,以 readNum(默认 5000)为页大小翻页,把读到的每一行包装成一个变更事件扔进队列。它对源库零侵入,任何能执行 SQL 的库都能用,但也带来三个天然限制:只能看到“查得出来”的行;能否只取新数据,完全取决于过滤条件;行本身不携带“这是新增还是修改”的信息。

这三个限制就是下面三个坑的来源。SQLite 只支持定时模式,正好拿来做实验。

实验环境:2 万行订单,全量 0.4 秒

实验在一台 8 核、16 GB 内存的 Linux 机器上进行。Docker 守护进程不可用,于是直接使用 Gitee 发布页的 dbsyncer-2.1.2-bin.zip,配合 Temurin JDK 8 启动(官方脚本默认 -Xms3g -Xmx3g,测试机内存紧张,改为 1 GB 堆):

export JAVA_HOME=/path/to/jdk8u504-b01
cd dbsyncer-2.1.2
java -Xms512m -Xmx1g \
-Djava.ext.dirs=$JAVA_HOME/jre/lib/ext:$PWD/lib \
-Dspring.config.location=$PWD/conf/application.properties \
-DLOG_HOME=$PWD/logs -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai \
-Duser.dir=$PWD org.dbsyncer.web.Application
# 日志:Started Application in 4.988 seconds

浏览器访问 http://127.0.0.1:18686,默认账号 admin/admin。为了让实验可复现,我没有在界面上点选,而是写了一个 Python 脚本 practice/run_demo.py,直接调用控制台背后的表单接口:/login 登录,/connector/add 建连接,/mapping/add 建驱动(打开 autoMatchTable 自动匹配同名表),/mapping/edit 切换全量、增量配置,/mapping/start 和 /mapping/stop 启停。

源库是一张 2 万行的 orders 表,包含两个时间列:updated_at 为 TEXT 格式的 2026-10-14 02:09:54,upd_ms 为毫秒时间戳。每组实验的流程完全相同:

1. 全量同步 2 万行,等待目标库行数追平;

2. 切换为定时增量,Cron 设为 */5 * * * * ?,过滤条件为“时间列 > $timestamp_begin$”;

3. 在源库新增 50 行、修改 10 行、物理删除 5 行;

4. 等待 14 秒(约 3 个轮询周期)后核对目标库。

全量阶段三组结果一致:2 万行 0.4 秒写完。作为参考,官方在 Linux 8 核 48 GB 机器上测得 1 亿行全量耗时 37 分 52 秒,增量 TPS 约 8000,峰值 10000。全量这部分没什么可挑剔的,问题都出在增量阶段。

三组对照实验的结果

脚本输出原样贴出:

[runa] full: target=20000 cost=0.4s
[runa] filter=updated_at forceUpdate=False: src=20045 dst=20000 insert=0/50 update=10/10 deleted_still_in_target=5/5
scan log: data=[5000] data=[5000] data=[5000] data=[5000] data=[50] ...(每 5 秒一轮全表)
[runb] full: target=20000 cost=0.4s
[runb] filter=updated_at forceUpdate=True: src=20045 dst=20050 insert=50/50 update=10/10 deleted_still_in_target=5/5
scan log: data=[5000] data=[5000] data=[5000] data=[5000] data=[45] ...(每 5 秒一轮全表)
[runc] full: target=20000 cost=0.4s
[runc] filter=upd_ms forceUpdate=True: src=20045 dst=20050 insert=50/50 update=10/10 deleted_still_in_target=5/5
scan log: data=[60]

整理成表格:

实验 过滤列 覆盖开关 新增 50 修改 10 物理删除 5 每轮扫描量
A updated_at(TEXT) 关 0 条到达 10 条到达 未同步 全表约 2 万行
B updated_at(TEXT) 开 50 条到达 10 条到达 未同步 全表约 2 万行
C upd_ms(毫秒数值) 开 50 条到达 10 条到达 未同步 仅 60 行

控制台“数据同步”列表把代价暴露得很直白:实验 A、B 的“结果”一栏都显示成功 6.2 万行以上,几乎全部来自增量阶段那二十多秒里的重复全表扫描;实验 C 只有 60 行。

三组实验驱动的累计处理量对比

坑一:时间列类型不对,过滤条件会静默失效

实验 A、B 的日志里,每个周期都是 4 页 5000 行加一个尾页,说明过滤条件根本没起作用,每 5 秒都在全表扫描。

原因在 $timestamp_begin$ 的实现。DBSyncer 的定时过滤支持 8 个系统参数($timestamp_begin$、$timestamp_end$、$date_begin$、$date_yes_begin$ 等),其中 TimestampFilter 返回的是 java.sql.Timestamp,断点以毫秒数字保存。在控制台的“定时”配置区可以直接看到这个断点值:0$timestamp_begin$ = 1791915565001。

定时配置:断点值、增量事件与覆盖开关

SQLite 是动态类型,比较规则是“TEXT 永远大于数值”。于是 updated_at > 1791915565001 对每一行都成立,条件恒为真。整个过程不报错,任务显示成功,只是每轮把全表重新同步一遍。在 MySQL、PostgreSQL 这类强类型数据库里,DATETIME 列与 Timestamp 参数可以正常比较,这个问题不会出现。但只要时间存在字符串列里,或者源库像 SQLite 一样做隐式类型转换,就会中招。

实验 C 换成数值型的毫秒列后,每轮只读到真正变化的 60 行(50 条新增加 10 条修改),这才是定时增量应有的样子。

排查技巧:打开 logs/info.log,搜索 驱动名[表名], data=[。如果每个周期打印的数字接近全表行数,就说明过滤条件没生效。

坑二:覆盖开关一关,新增数据全部丢失

实验 A 的修改都到了,新增却一条没到。这和定时模式的事件判定方式有关。

AbstractQuartzListener.start() 里有这样一段逻辑:如果没有配置“事件字段”,就看增量事件开关,开了“修改”就把每一行都当成 UPDATE。这是合理的,因为轮询拿到的只是行,并不知道这行是新是旧。真正决定新行能否写进目标库的,是写入端 AbstractDatabaseConnector 的分支:

} else if (context.isForceUpdate()) {
// 开启覆盖
executeSql = context.getCommand().get(ConnectorConstant.OPERTION_UPSERT);
} else if (isUpdate(event)) {
// 修改操作
executeSql = context.getCommand().get(event);
}

覆盖开关(forceUpdate)打开时,写入走 UPSERT,目标表不存在的行会被插入;关闭时走普通 UPDATE,主键在目标表里不存在就影响 0 行,这批新增被静默跳过。

更隐蔽的是默认值。Mapping 模型里 forceUpdate 默认是 true,但 MappingChecker 每次保存驱动时,会把它设为“请求里有没有这个参数”。界面上这个开关默认是打开的,所以手动操作一般不会出事;而用脚本或 OpenAPI 改配置时,如果漏传这个参数,覆盖就被悄悄关掉了。实验 A 正是这样复现出来的。

坑三:物理删除永远到不了目标库

三组实验里,源库删掉的 5 行在目标库里都还在。这不是配置问题,而是轮询模式的原理限制:被 DELETE 的行已经不在源表里,SELECT 自然查不到。

DBSyncer 给出的解法是“事件字段”。在定时配置里指定一列作为事件标识,并设置 Insert、Update、Delete 对应的取值(默认是 I、U、D)。轮询读到一行后,按这一列的值决定发出哪种事件,读到 D 就在目标库执行删除。这要求源表改为软删除:业务代码不再物理删除,而是把该列更新为 D,同时刷新时间列,让这行能被下一轮轮询命中。

如果源表结构不能改,就只剩两条路:源库支持的话改用日志模式;或者定期跑一次全量比对(DBSyncer 的“订正校验”模块就是做这个的)。

附:SQLite 连接器的大整数缺陷

第一轮实验我把毫秒列声明为 INTEGER,结果全量阶段一行都没写进去,error.log 里刷满了这条:

SQLiteIntType does not support type [class java.lang.Long] convert to upd_ms(INTEGER), val [1789089360000]

看源码就明白了:SQLite 连接器把 INTEGER 列映射到 IntType(Java int),而 SQLiteIntType.merge() 遇到 Long 时直接抛出“不支持”。SQLite 的 INTEGER 实际是 64 位,毫秒时间戳、雪花 ID 这类超过 21 亿的值都会触发这个问题。这次测试用的是 2.1.2 版,我核对了 GitHub master 分支 10 月 12 日的代码,这段实现仍未改动。

临时绕过办法是把这类列声明为 REAL。SQLite 比较时仍按数值处理,DBSyncer 则走 Double 类型的转换,实验 C 就是这样跑通的。如果主键本身就是大整数,这个绕过办法就不适用了,只能等上游修复,或者自己写插件、改连接器。

插件扩展:用 SPI 自定义转换逻辑

字段映射和内置转换不够用时,DBSyncer 允许上传自定义插件。插件接口在 dbsyncer-sdk 的 org.dbsyncer.sdk.spi.PluginService,核心是三个钩子:

public interface PluginService {
default void init() {}
// 全量同步 / 定时增量同步前置处理
default void postProcessBefore(PluginContext pluginContext) {}
// 全量同步 / 增量同步时对数据做转换
void convert(PluginContext pluginContext);
// 写入目标库之后的后置处理
default void postProcessAfter(PluginContext pluginContext) {}
default String getVersion() { return "1.0.0"; }
default String getName() { return getClass().getSimpleName(); }
}

PluginContext 提供 getEvent()(事件类型)、getSourceList() 和 getTargetList()(源行与目标行)。比如要在写入前给手机号打码、把状态码翻译成中文,或者在 postProcessAfter 里给下游发通知,实现 convert 和 postProcessAfter 即可。下面是一个打码插件的示意(未在本次实验中编译验证):

public class MaskPhonePlugin implements PluginService {
@Override
public void convert(PluginContext ctx) {
for (Map row : ctx.getTargetList()) {
Object phone = row.get("phone");
if (phone instanceof String && ((String) phone).length() == 11) {
String p = (String) phone;
row.put("phone", p.substring(0, 3) + "****" + p.substring(7));
}
}
}
}

打包时在 META-INF/services/org.dbsyncer.sdk.spi.PluginService 中注册实现类,然后在“插件管理”页面上传 JAR,驱动的高级配置里就能选择这个插件。仓库自带的 DemoPluginServiceProvider 可以作为模板。

定时增量每 5 秒全表扫一遍?我用 DBSyncer 跑了三组对照实验才找到原因

选型建议:先确认增量能拿到什么

Mermaid Diagram

把实验结论落到操作上,就是一份上线前的检查清单:

检查项 怎么验证 不满足的后果
增量方式 驱动详情里看是“日志”还是“定时” 定时模式拿不到物理删除
时间列类型 确认是数值型或 DATETIME 等日期类型,而非字符串 过滤条件恒真,每轮全表扫描
覆盖开关 驱动“策略配置”里确认处于打开状态,脚本改配置时显式传 forceUpdate 新增行被当成 UPDATE 静默丢弃
轮询扫描量 info.log 中 data=[...] 是否接近全表行数 源库压力和同步量成倍放大
大整数列 SQLite 源或目标中是否有超过 21 亿的 INTEGER 值 整批写入失败
元数据存储 生产环境把 storage 切到 MySQL 本地磁盘存储无法做集群高可用

另外两条部署层面的提醒:官方 Docker 命令给容器分配了 -m 5g,启动脚本默认 3 GB 堆,小规格机器要相应调低;连 MySQL 5.x 需要手动换成 5.1.x 的驱动,自带的是 8.0.21。

总结

DBSyncer 把异构同步做成了“连接器 × 监听器”的组合,全量性能和连接器覆盖面都不错,控制台也足够直观。但“定时增量”这个名字容易让人误以为它和 binlog 同步只是实现不同、效果相同,实际差别很大:它依赖时间列的类型,依赖覆盖开关,并且天生看不到物理删除。三组对照实验说明,这三个问题都不会报错,任务照样显示成功。

源库支持日志模式时优先用日志模式;只能用定时模式时,在上线前花十分钟把上面的检查清单过一遍,并对照 info.log 里每轮的扫描量做一次核对。

参考资料:DBSyncer GitHub / Gitee 仓库 README 与源码(master 分支,2026-10-12)、DBSyncer 官方 Wiki 性能测试章节。


💡 在线实战体验:本文配套免安装的云端 Linux 交互式实验环境与终端操作,可在 边学边练平台 (https://www.skillup.host/) 直接体验运行验证。

💻 配套实训环境与动手练习

本文涉及的相关技术指令、开发环境与工具链已内置在边学边练在线实验室中,无需繁琐安装配置,随时在浏览器中实践体验:

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

最新文章

热门文章

本栏目文章