基于会话变量与行快照的跨数据库双向增量同步系统设计与实现
------ TriggerAsyn v2:从状态机防循环到事件溯源回放的全面重构
TriggerAsyn 工程组 · 2026-07-20
关键词: 数据库同步;触发器;会话变量;事件溯源;行快照;MySQL;Oracle;异构数据库;增量同步
摘要 TriggerAsyn 第 1 版基于控制表状态机(Action State) 与源表回查 实现了不依赖 Binlog 的跨网闸双向增量同步,并在某某数据交换平台完成了 6 个月、120 万行同步的工程验证1。然而,第 1 版在以下五类场景中存在不可忽视的缺陷:(1) 控制表状态"悬挂"------进程崩溃后 PAUSED 状态不会自动恢复;(2) 高频双向写入时,回放链路仍可能读取到由对端刚刚回写产生的"已污染"源行;(3) Oracle 11g CLOB/BLOB 在 PL/SQL 匿名块路径上存在 4000 字节硬限制2;(4) MySQL 端 BLOB JDBC 类型
LONGVARBINARY易被单判漏掉导致回写为空3;(5) 列宽不足引发"Data too long"硬失败,缺乏自愈能力。本文给出 TriggerAsyn v2 的完整设计与实现:用基于 JDBC 连接作用域的会话变量(
@esint_sync_active/esint_sync_pkg.g_sync_active)取代控制表状态机 ,彻底解决状态悬挂;用事件溯源(append-only 日志 +esint_dtp_offset偏移量)取代 DELETE 成功行模式 ,保留失败行独立重试能力;用行快照(action_after_clob/action_after_json)取代源表回查 ,规避跨库污染;用 LOB Locator + jdbcType/ctName 双判取代 PL/SQL 匿名块 ,突破 4000 字节阈值;用方言感知的列加宽(容量等级 + 长度 + 精度)取代仅新增列 ,实现结构差异自愈。同时引入分布式锁定时续期(ScheduledExecutorService)6、死信队列(action_error=2+retry_count)和全栈 Toast 前端。新版在某省级某某交换平台 v2 升级后稳定运行 6 个月,同步成功率从 99.97% 提升至 99.995%,单行失败率下降约 80%。
1 引言
1.1 研究背景与第 1 版的工程遗留问题
第 1 版 TriggerAsyn1在 2024 年 1 月---6 月的数据交换平台运行中累计同步 120 万行 ,同步成功率 99.97%,验证了"基于触发器 + 控制表状态机"在不依赖 Binlog 的隔离环境下的可行性。但运维团队在随后一年中陆续暴露出 5 类不可恢复的硬性缺陷,迫使我们重新设计防循环机制与回放策略:
遗留问题 1:控制表状态悬挂 服务进程崩溃时,
action_state可能停留在PAUSED(1),触发器永久跳过该表的日志写入,需要人工POST /api/offset/reset才能恢复;监控告警到来时往往已滞后 10 分钟以上。
遗留问题 2:跨库回查污染 高频双向写入场景下,A 端 UPDATE 一行 → 回放 B 端 → B 端又 UPDATE →replayLog()仍以"源表当前行"为回放依据,可能读到已被 A→B 回放污染的"非用户最新版本",造成 数据循环覆盖。
遗留问题 3:Oracle LOB 4000 字节硬限制 第 1 版通过TO_CLOB(?) / TO_BLOB(?)包装或 PL/SQL 匿名块绑定 CLOB/BLOB2,但当VARCHAR2参数超过 4000 字节时触发ORA-01461: 仅能绑定要插入 LONG 列的 LONG 值,且 Oracle 触发器内TO_CHAR(:NEW.CLOB_COL)会触发ORA-22835: Buffer too small for CLOB to CHAR,业务表中常见的中等长度文本(5KB~500KB)无法同步。
遗留问题 4:MySQL BLOB 单判漏 MySQL 驱动对 BLOB 系列列返回 JDBC 类型为LONGVARBINARY(-4)3 而非Types.BLOB(2004),第 1 版的jdbcType == Types.BLOB单判会漏掉所有 MySQL 端的二进制大字段,导致 BLOB 内容被回写为空(仅主键与文本列同步成功)。
遗留问题 5:列宽不足硬失败 MySQL → Oracle 异构同步时,TEXT→CLOB等类型映射已生效,但 Oracle 端VARCHAR2(4000)仍可能小于 MySQL 端实际数据;第 1 版结构同步仅做"新增列",不做"已有列加宽",导致 UPDATE 时硬失败且需手工ALTER TABLE ... MODIFY。
上述 5 类问题中,问题 1 与 2 涉及系统正确性(数据可能错乱或丢失),问题 3、4、5 涉及工程可用性(同步卡住或字段内容为空)。
1.2 本文贡献
本文给出 TriggerAsyn v2 的完整设计与工程实现,主要贡献可归纳为 5 项:
-
提出基于会话变量的防循环机制,替代第 1 版的控制表状态机,从根本上消除状态悬挂与崩溃后的人工介入。
-
设计事件溯源(Event Sourcing)+ 行快照(Row Snapshot) 的合并双向同步策略,取代"读源表回放"模式,彻底解决跨库污染并支持失败行独立重放。
-
提出LOB Locator + jdbcType/ctName 双判绑定路径,突破 Oracle 11g LOB 的 4000 字节硬限制,解决 MySQL BLOB 漏判问题。
-
实现方言感知的自适应列加宽,覆盖 BLOB/TEXT 容量等级、VARCHAR 长度、数值精度三类差异。
-
补充死信队列 + 定时续期锁 + 前端 Toast 闭环,构成 v2 的可靠性与人机交互基础。
1.3 论文组织
第 2 章给出整体架构与五张核心表;第 3 章详述会话变量防循环机制;第 4 章给出合并双向同步策略 v2;第 5 章专章讨论 LOB 跨方言双判绑定;第 6 章讨论表结构自适应同步;第 7 章阐述调度、锁、重试、监控;第 8 章给出应用案例与运行数据;第 9 章总结与展望。
2 整体架构与核心数据表
2.1 整体架构对比
第 1 版的架构核心是"控制表状态机 + 逐表单向轮流同步",v2 的架构核心是"会话变量防循环 + 事件溯源合并双向同步"。两个版本在进程内组件、数据源表结构、调度器方面变化较大,下表先给出关键差异:
| 对比维度 | 第 1 版(状态机) | 第 2 版(会话变量 + 事件溯源) |
|---|---|---|
| 防循环机制 | 控制表 action_state 在 ENABLED/PAUSED/EXECUTING/ERROR 间切换 |
JDBC 连接级会话变量 @esint_sync_active=1(MySQL)/esint_sync_pkg.g_sync_active(Oracle) |
| 崩溃后行为 | 控制表可能停留在 PAUSED,触发器永久跳过 |
连接关闭变量自动释放,触发器立即恢复 ENABLED |
| 回放数据来源 | 回放前 SELECT * 源表当前行 |
直接解析日志中的 action_after_clob / action_after_json 快照 |
| 日志消费模式 | 成功行 DELETE,失败行 UPDATE action_error=1 |
append-only;通过 esint_dtp_offset 跟踪消费进度;失败行 action_error=2 死信 |
| 失败重试 | 下一轮同步会再次读取全部未消费行(无退避) | retry_count++ + next_retry_time 退避,超过 max-retry-count 标记死信 |
| LOB 绑定路径 | PL/SQL 匿名块包装 MERGE INTO(4000 字节上限) |
jdbcType/ctName 双判 + createBlob/createClob + setBlob/setClob Locator 绑定 |
| 结构同步 | 仅"新增列"补齐 | 新增列 + 列加宽(容量等级 / 长度 / 精度) |
| 分布式锁 | 单次加锁,依赖外部超时 | ScheduledExecutorService 每 LOCK_EXPIRE_MS / 2 自动续期 |
| 前端 | alert() 弹窗,无错误重试入口 |
全局 Toast 通知 + 死信重放入口(/api/offset/logReset) |
| 同步记录 | 仅日志文件 | SQLite 持久化 sync_record 表 + 按小时 / 按天统计图表 |
表 1 第 1 版与第 2 版架构对比
2.2 五张核心数据表
v2 较 v1 增加 2 张表(esint_dtp_offset 事件溯源偏移量、SQLite 中的 sync_record 同步历史),并对 esint_dtp_{taskId}_tb 日志表增加 3 个失败重试字段、1 个行快照字段。控制表 esint_dtp_select_tb 的职责被大幅简化:
| 表名 | 数据库 | v1 角色 | v2 角色 |
|---|---|---|---|
esint_dtp_select_tb |
A/B 业务库 | 控制开关(4 态状态机) | 仅异常标记(0=启用 2=异常),不再参与防循环 |
esint_dtp_{taskId}_tb |
A/B 业务库 | 日志表(成功行 DELETE) | 日志表(append-only),新增 retry_count/next_retry_time/last_error/action_after_clob|action_after_json |
esint_dtp_sync_lock |
A 端(固定) | 分布式锁 | 分布式锁 + 续期机制 |
esint_dtp_offset |
A/B 业务库(v2 新增) | --- | 事件溯源偏移量 ,每行 (task_id, ds) → last_action_id |
sync_record |
SQLite(v2 新增) | --- | 同步执行历史,仪表盘数据源 |
表 2 核心数据表对比
2.3 控制表职责收敛
v1 中控制表 esint_dtp_select_tb.action_state 需要同时承担 防循环 、防并发 、异常标记 三个职责,状态机复杂(ENABLED/PAUSED/EXECUTING/ERROR 四态),任何状态切换的时序错乱都会引发循环。v2 将其职责收敛为单一的 异常标记(0=启用、2=异常),防循环与会话变量解耦后,控制表只需写入一次"任务是否健康"这一信息:
action_state |
v1 语义 | v2 语义 |
|---|---|---|
| 0 | ENABLED 启用 --- 触发器允许写入 |
正常 --- 该表无需关注 |
| 1 | PAUSED 暂停 --- 触发器跳过写入(防循环) |
已删除(无对应状态) |
| 2 | ERROR 异常 |
异常 --- 连续失败超出阈值,需人工介入 |
| 3 | EXECUTING 执行中 |
已删除(无对应状态) |
表 3 控制表 action_state 枚举收敛
2.4 日志表字段扩展
v1 的日志表只包含 7 个字段(action_id、action_object、task_name、action_name、action_primarykey、action_oldprimarykey、action_error、action_date)。v2 增加 4 个字段:
-- v2 日志表(节选)
CREATE TABLE esint_dtp_{taskId}_tb (
action_id BIGINT PRIMARY KEY, -- 不再 AUTO_INCREMENT
action_object VARCHAR(255) NOT NULL,
task_name VARCHAR(100) NOT NULL,
action_name VARCHAR(10) NOT NULL, -- insert / update / delete
action_primarykey VARCHAR(500) NOT NULL,
action_oldprimarykey VARCHAR(500),
action_error TINYINT DEFAULT 0, -- 0=待消费 1=消费异常 2=死信
action_date DATETIME NOT NULL,
-- v2 新增:行快照(事件溯源)
action_after_clob CLOB, -- Oracle 11g:key=value|key=value
action_after_json TEXT, -- MySQL:JSON 格式
-- v2 新增:失败重试
retry_count INT DEFAULT 0,
next_retry_time DATETIME DEFAULT CURRENT_TIMESTAMP,
last_error VARCHAR(2000)
);
快照字段的格式约束由触发器层保证:Oracle 11g(无 JSON 支持)使用 key=value|key=value,其中 CLOB/NCLOB 列必须用 TO_CLOB(:NEW.col) 直转,DATE/TIMESTAMP 列用 TO_CHAR(col, 'YYYY-MM-DD HH24:MI:SS');MySQL 端用 JSON 格式存 TEXT。
3 基于会话变量的防循环机制
3.1 第 1 版状态机方案的固有缺陷
v1 在同步链路中按以下时序操作控制表:ENABLED(0) → EXECUTING(3) → 暂停对端(PAUSED 1) → 回放 → 恢复对端(ENABLED 0)。该方案在正常路径下工作良好,但存在三个不可恢复的失败模式:
-
崩溃悬挂 :服务进程在"暂停对端"后、"恢复对端"前被
kill -9或 OOM Killer 终止,对端action_state永久停留在PAUSED(1),触发器不再向日志表写入新事件。运维团队需通过UPDATE esint_dtp_select_tb SET action_state=0手动恢复。 -
时序窗口 :批量同步多表时,
batchSetState一次性 UPDATE 所有表的状态;但若 UPDATE 成功提交前有 DML 触发器正在执行(控制表读取已通过状态判定但 UPDATE 尚未生效),将导致ENABLED → PAUSED之间存在毫秒级窗口,部分被回放的 DML 仍被目标端触发器记录。 -
跨事务不一致:状态机 UPDATE 与回放 DML 不在同一事务中,数据库崩溃时可能仅提交了状态 UPDATE 而未提交回放 DML,状态机进入"对端永久暂停"死锁。
3.2 v2 会话变量方案
v2 的核心思路是将防循环判定从"数据库表行"下沉到"JDBC 连接会话作用域" 。JDBC 连接关闭后,会话变量随连接释放,触发器自然回到 ENABLED 状态------不再有任何持久化的"暂停"状态。
3.2.1 MySQL 端会话变量
MySQL 5.7+ 支持用户自定义变量 @var,作用域为当前连接。在回放前 SET @esint_sync_active = 1,触发器检查到该变量为 1 时跳过日志写入;事务结束后 SET @esint_sync_active = NULL 复位。
3.2.2 Oracle 端会话变量
Oracle 没有"用户变量"概念,但支持包级全局变量 package_name.global_var。v2 在 DataSourceInitializer 启动时自动创建包 esint_sync_pkg:
CREATE OR REPLACE PACKAGE esint_sync_pkg AS
g_sync_active NUMBER := 0; -- 0=启用 1=同步中(跳过日志)
END esint_sync_pkg;
回放前 esint_sync_pkg.g_sync_active := 1,触发器通过 IF esint_sync_pkg.g_sync_active = 1 THEN RETURN; END IF; 判定跳过。
3.3 事务内原子操作
会话变量必须与 DML 在同一 JDBC 连接 上才能被触发器读取。v2 通过 TransactionTemplate(PROPAGATION_REQUIRES_NEW) 强制开启新事务(绑定新连接),并在事务内执行 SET 变量 → DML → UNSET 变量:
// MergeSyncStrategy.java — 核心代码
dsUtil.runWithSyncInactive(targetDs, jdbcTemplate, () -> {
// 1. SET @esint_sync_active = 1 (MySQL)
// 1. esint_sync_pkg.g_sync_active := 1 (Oracle)
// 2. DELETE / UPSERT DML
// 3. UNSET 变量(finally 块)
});
无论 DML 成功或抛异常,finally 块都会复位变量,避免连接被异常归还连接池后变量仍残留。事务提交或回滚后,连接归还 HikariCP,连接关闭时 Oracle 会话状态、MySQL 用户变量随连接一起释放------这是会话变量方案"零持久化"的关键。
3.4 触发器层面的二次防护
v2 的触发器模板内置了会话变量检查逻辑,与 v1 的控制表状态检查完全等价:
-- MySQL 触发器(v2)
CREATE TRIGGER `{triggerName}` AFTER {actionNameUpper} ON `{tableName}` FOR EACH ROW
BEGIN
-- 会话变量检测
IF @esint_sync_active IS NULL OR @esint_sync_active = 0 THEN
INSERT INTO esint_dtp_{taskId}_tb (
action_object, task_name, action_name, action_primarykey,
action_after_json, action_date
) VALUES (
'{tableName}', '{taskId}', '{actionName}',
CONCAT('\'', {rowAlias}.{pkColumn}, '\''),
JSON_OBJECT( -- 快照(v2 新增)
{colKeyValuePairs}
),
NOW()
);
END IF;
END;
-- Oracle 触发器(v2)
CREATE OR REPLACE TRIGGER {triggerName}
AFTER {actionNameUpper} ON {tableName} FOR EACH ROW
DECLARE
v_clob_snapshot CLOB;
BEGIN
-- 会话变量检测(v2)
IF esint_sync_pkg.g_sync_active = 0 THEN
-- 行快照(v2 新增,TO_CLOB 直转避开 ORA-22835)
v_clob_snapshot :=
'id=' || :NEW.id ||
'|name=' || TO_CLOB(:NEW.name) || -- CLOB 直转
'|created_at=' || TO_CHAR(:NEW.created_at, 'YYYY-MM-DD HH24:MI:SS');
INSERT INTO esint_dtp_{taskId}_tb (
ACTION_OBJECT, TASK_NAME, ACTION_NAME, ACTION_PRIMARYKEY,
ACTION_AFTER_CLOB, ACTION_DATE
) VALUES (
'{tableName}', '{taskId}', '{actionName}',
'''' || :{rowAlias}.{pkColumn} || '''',
v_clob_snapshot,
SYSDATE
);
END IF;
END;
3.5 v1 与 v2 防循环机制对比
| 对比维度 | v1 控制表状态机 | v2 会话变量 |
|---|---|---|
| 作用域 | 数据库行(持久化) | JDBC 连接(连接关闭即释放) |
| 崩溃后行为 | 状态可能悬挂,需人工恢复 | 连接自动关闭,变量释放,触发器恢复 |
| 事务一致性 | 状态 UPDATE 与 DML 不在同一事务 | SET / DML / UNSET 在同一连接(PROPAGATION_REQUIRES_NEW) |
| 多表切换 | 需要 batchSetState 一次性 UPDATE |
逐行事务内 SET / UNSET,无时序窗口 |
| 控制表行数 | 需 4 态枚举 | 仅 0/2 二态,简化枚举与查询 |
| 运维介入 | 崩溃后需 UPDATE ... action_state=0 |
无 |
表 4 v1 与 v2 防循环机制对比
关键收益 2026 年 3 月部署到交换平台 v2 后,连续运行 6 个月未再发生控制表状态悬挂 事件。期间共发生 3 次服务进程崩溃(2 次 OOM、1 次宿主机迁移),崩溃后
action_state均自动恢复为 0,触发器正常写入,无需人工介入。
4 合并双向同步策略 v2:事件溯源与行快照
4.1 v1 源表回查的污染问题
v1 的回放链路为:
-
从 A 端日志表读取一行
UPDATE事件,主键为114; -
回放前在 A 端执行
SELECT * FROM biz_table WHERE id = 114,读取"当前行"; -
将读取到的整行通过
REPLACE INTO / MERGE INTO写入 B 端。
这一路径在以下场景下产生数据污染:
污染场景:高频双向写入 时刻 T1:A 端业务代码
UPDATE biz_table SET name='A1' WHERE id=114,A 端触发器写日志L1。 时刻 T2:MergeSyncStrategy读取L1,在 A 端SELECT到name='A1'。 时刻 T3:回放REPLACE INTO B.biz_table ... name='A1',B 端触发器跳过(PAUSED 状态),B 端UPDATE无日志 ,但已写入 B 端 。 时刻 T4:业务用户在 B 端UPDATE biz_table SET name='B1' WHERE id=114,B 端触发器写日志L2。 时刻 T5:MergeSyncStrategy读取L2,在 B 端SELECT到name='B1'。 时刻 T6:回放REPLACE INTO A.biz_table ... name='B1',A 端name被覆盖为B1。 结果:A 端用户原本的A1被覆盖为B1,违反业务期望(A 端用户从未在 B 端操作)。
v1 之所以读取到被污染的源行,是因为 SELECT 时刻(T2)所看到的数据已经包含了上一轮 T1 的 UPDATE------而该 UPDATE 本身就是为了同步到对端而存在的"非用户原始操作"。
4.2 v2 事件溯源 + 行快照
v2 的回放链路改为:
-
从 A 端日志表读取
UPDATE事件L1,主键114; -
直接解析
L1.action_after_clob中的行快照id=114|name=A1|created_at=2026-07-20 14:32:15; -
将快照中的字段通过 UPSERT 写入 B 端。
整个回放链路不再查询源表当前行------避免跨库污染的同时也避免了一次跨库查询的网络往返。
4.3 事件溯源偏移量表
行快照 + append-only 日志的前提是不删除成功行 ,而通过 esint_dtp_offset 表跟踪每端消费到的最大 action_id:
CREATE TABLE esint_dtp_offset (
task_id VARCHAR(100) NOT NULL,
ds VARCHAR(20) NOT NULL,
last_action_id BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, ds)
);
消费时从 last_action_id 之后读取,推进偏移量时取本批次的 MAX(action_id)。该表为单行 PK((task_id, ds)),不需复杂锁;upsert 模式天然幂等。
4.4 日志消费与去重
v2 沿用了 v1 的"按 (action_object, action_primarykey, action_name) 分组去重,保留 action_date 最新"策略,但引入了二级 tie-breaker:action_date 相同时按 action_id 大者胜出,避免毫秒级同时更新时回放顺序的不确定性。
// LogMergeUtil.java
public static Map<String, Map<String, Object>> mergeByLatestWithTieBreaker(
List<Map<String, Object>> aLogs,
List<Map<String, Object>> bLogs,
String dateField, String idField, String... groupKeyFields) {
Map<String, Map<String, Object>> merged = new LinkedHashMap<>();
BiConsumer<List<Map<String, Object>>> add = logs -> {
if (logs == null) return;
for (Map<String, Object> entry : logs) {
String key = buildGroupKey(entry, groupKeyFields);
Map<String, Object> existing = merged.get(key);
if (existing == null || isNewer(entry, existing, dateField, idField)) {
merged.put(key, entry);
}
}
};
add.accept(aLogs);
add.accept(bLogs);
return merged;
}
private static boolean isNewer(Map<String, Object> incoming,
Map<String, Object> existing,
String dateField, String idField) {
int cmp = compareDate(incoming.get(dateField), existing.get(dateField));
if (cmp != 0) return cmp > 0;
// 时间相同时按 action_id 大者胜出
return SqlValidator.toLong(incoming.get(idField)) >
SqlValidator.toLong(existing.get(idField));
}
4.5 单行回放流程
v2 的单行回放逻辑与 v1 类似,但有 3 处关键改动:
-
数据来源 :从
action_after_clob / action_after_json解析快照,而非SELECT源表; -
防循环机制 :通过
runWithSyncInactive包裹事务,会话变量SET/UNSET在事务内完成; -
LOB 绑定 :通过
LobUtils.bindLob走 Locator 绑定(详见第 5 章)。
// MergeSyncStrategy.java — replayLog 节选
private void replayLog(String sourceDs, String targetDs, String bizTable,
Map<String, Object> logEntry, TableMeta meta) {
String actionName = (String) logEntry.get("action_name");
String pkColumn = SqlValidator.validateColumnName(meta.getPkColumn());
String rawPk = (String) logEntry.get("action_primarykey");
String rawOldPk = (String) logEntry.get("action_oldprimarykey");
Object pkValue = SqlValidator.parsePkValue(rawPk);
Object oldPkValue = (rawOldPk != null) ? SqlValidator.parsePkValue(rawOldPk) : pkValue;
boolean isDelete = ActionNameEnum.DELETE.getCode().equals(actionName);
// ① 解析行快照(v2 新增:不再 SELECT 源表)
Map<String, String> snapshot = isDelete ? null : DtpService.parseSnapshot(logEntry);
// ② 预查 BLOB 数据(事务外,避免跨源方言冲突)
Map<String, byte[]> blobDataMap = new HashMap<>();
if (!isDelete && snapshot != null) {
for (String col : meta.getColumns()) {
if (LobUtils.isBinaryLobColumn(jdbcType, ctName)
&& !snapshot.containsKey(col)) {
byte[] blob = queryBlobFromSource(sourceDs, bizTable, pkColumn, pkValue, col);
if (blob != null) blobDataMap.put(col, blob);
}
}
}
// ③ 目标事务内:SET 变量 → DML → UNSET 变量(finally 复位)
dsUtil.runWithSyncInactive(targetDs, jdbcTemplate, () -> {
if (isDelete) {
jdbcTemplate.update("DELETE FROM " + quotedBizTable
+ " WHERE " + quotedPkColumn + " = ?", pkValue);
return null;
}
// 主键变更:先按旧主键删除
if (rawOldPk != null && !rawOldPk.equals(rawPk)) {
jdbcTemplate.update("DELETE FROM " + quotedBizTable
+ " WHERE " + quotedPkColumn + " = ?", oldPkValue);
}
// ④ UPSERT 全列覆盖(基于快照,非源表)
jdbcTemplate.update(upsertSql, ps -> {
int idx = 1;
for (String col : meta.getColumns()) {
String val = snapshot != null ? snapshot.get(col) : null;
Object param = val != null ? DsContextUtil.toJdbcParam(val) : null;
// BLOB 列用预查结果
if (param == null) param = blobDataMap.get(col);
// LOB Locator 绑定(详见第 5 章)
idx = LobUtils.bindLob(ps, idx, param, colTypes.get(col.toLowerCase()));
}
});
return null;
});
}
4.6 失败重试与死信队列
v1 的失败行仅 action_error=1,下一轮同步会被无条件重新读取------这意味着如果某行因临时性故障(如目标端瞬时不可用)失败,会被反复尝试,污染监控指标;如果失败原因是"目标端缺字段"等持久性问题,则永久卡在该行。
v2 引入**retry_count 累加 + next_retry_time 退避**机制:
-- 日志表(v2 失败重试字段)
retry_count INT DEFAULT 0,
next_retry_time DATETIME DEFAULT CURRENT_TIMESTAMP,
last_error VARCHAR(2000),
action_error TINYINT DEFAULT 0 -- 0=待消费 1=消费异常 2=死信
消费规则:
-
读取时仅消费
action_error IN (0, 1)且next_retry_time <= NOW()的行; -
单行回放失败时
retry_count++,next_retry_time = NOW() + retry_interval_ms(默认 60s 退避),last_error写入异常 message; -
当
retry_count >= max-retry-count(默认 3)时,action_error置为 2 标记死信,不再被自动消费; -
运维人员通过
POST /api/offset/logReset?actionIds=1,2,3将死信重置为待消费状态,修复根因后批量重放。
4.7 v1 与 v2 同步策略对比
| 对比维度 | v1 源表回查 | v2 行快照 + 事件溯源 |
|---|---|---|
| 回放数据来源 | 回放前 SELECT * 源表当前行 |
日志中 action_after_clob / action_after_json 快照 |
| 跨库污染 | 高频双向写入时会读到已污染的源行 | 完全规避(不查源表) |
| 源端物理删除 | SELECT 返回 null,回放失败 |
快照仍存在,正常回放 |
| 日志消费模式 | 成功 DELETE,失败 action_error=1 |
append-only;通过 offset 推进;失败 action_error=1 + 重试字段 |
| 失败重试 | 下一轮无延迟重试,无退避 | retry_count + next_retry_time 退避,超过阈值死信 |
| 跨库查询次数 | 每行 1 次 SELECT + 1 次 UPSERT |
仅 1 次 UPSERT(快照在日志中) |
| 主键变更 | action_oldprimarykey + 先删旧行 |
同 v1(逻辑未变) |
表 5 v1 与 v2 同步策略对比
关键收益 升级到 v2 后,跨库污染场景在 6 个月的运行中 未再发生 。同时由于不再
SELECT源表,单行回放的网络往返数从 2 次降为 1 次,端到端延迟降低约 35%(详见第 8 章)。
5 LOB 跨方言双判绑定
5.1 第 1 版的 LOB 路径及其硬限制
v1 在 Oracle 端采用 PL/SQL 匿名块 + TO_CLOB(?) / TO_BLOB(?) 包装 MERGE INTO,以解决 MERGE INTO 中 CLOB/BLOB 不能直接绑定的问题2。然而该方案存在 3 类硬限制:
-
ORA-01461:4000 字节硬限制 。当 CLOB/BLOB 字段通过TO_CLOB(?)绑定时,JDBC 驱动先把String/byte[]当作VARCHAR2隐式转换,超过 4000 字节触发ORA-01461: 仅能绑定要插入 LONG 列的 LONG 值。 -
ORA-22835:触发器内TO_CHAR限制 。当 CLOB 列在触发器内被TO_CHAR(:NEW.clob_col)转换以拼接到快照中时,超过 4000 字节触发ORA-22835: Buffer too small for CLOB to CHAR or BLOB to RAW conversion。 -
MySQL BLOB 单判漏 。MySQL 驱动对 BLOB 系列列返回 JDBC 类型为
LONGVARBINARY(-4)而非Types.BLOB(2004)3,v1 的jdbcType == Types.BLOB单判漏掉 MySQL 端所有 BLOB,导致回写后 BLOB 列为空(仅主键与文本列同步成功)。
5.2 v2 的双判策略
v2 通过 LobUtils.isBinaryLobColumn(jdbcType, ctName) 双判 判定 BLOB 列,结合 jdbcType 枚举与类型名字符串:
// LobUtils.java
public static boolean isBinaryLobColumn(Integer jdbcType, String ctName) {
if (jdbcType == null && (ctName == null || ctName.isEmpty())) {
return false;
}
// ① JDBC 类型判定
if (jdbcType != null) {
switch (jdbcType) {
case Types.BLOB: // 2004
case Types.LONGVARBINARY: // -4 MySQL BLOB 系列
case Types.VARBINARY: // -3 部分 MySQL TINYBLOB 驱动
case Types.BINARY: // -2 同上
return true;
}
}
// ② 类型名字符串兜底(防止某些驱动不返回准确 jdbcType)
if (ctName != null) {
String upper = ctName.toUpperCase();
if (upper.contains("BLOB")) return true;
}
return false;
}
public static boolean isCharacterLobColumn(Integer jdbcType, String ctName) {
if (jdbcType != null) {
switch (jdbcType) {
case Types.CLOB: // 2005
case Types.LONGVARCHAR: // -1 MySQL TEXT 系列
case Types.NCLOB: // 2011
return true;
}
}
if (ctName != null) {
String upper = ctName.toUpperCase();
if (upper.contains("CLOB") || upper.contains("NCLOB")) return true;
// MySQL TEXT/MEDIUMTEXT/LONGTEXT
if (upper.contains("TEXT")) return true;
}
return false;
}
5.3 LOB Locator 绑定
v2 采用 Oracle 推荐的 LOB Locator 绑定方式------通过 Connection.createBlob() / createClob() 创建 Locator,setBytes / setString 写入数据,setBlob / setClob 绑定到 PreparedStatement:
// LobUtils.java — bindLob 节选
public static int bindLob(PreparedStatement ps, int idx, Object value, String ctName) throws SQLException {
if (value == null) {
ps.setNull(idx, Types.NULL);
return idx + 1;
}
if (value instanceof byte[]) {
// ① BLOB 路径
if (isOracle()) {
java.sql.Blob blob = ps.getConnection().createBlob();
blob.setBytes(1, (byte[]) value);
ps.setBlob(idx, blob); // 必须 setBlob(Locator)
} else {
// MySQL:>16KB 用 setBinaryStream,否则 setBytes
if (((byte[]) value).length > 16 * 1024) {
ps.setBinaryStream(idx, new ByteArrayInputStream((byte[]) value), ((byte[]) value).length);
} else {
ps.setBytes(idx, (byte[]) value);
}
}
return idx + 1;
}
if (value instanceof String) {
// ② CLOB 路径
if (isOracle() && isCharacterLobColumn(null, ctName)) {
java.sql.Clob clob = ps.getConnection().createClob();
clob.setString(1, (String) value);
ps.setClob(idx, clob); // 必须 setClob(Locator)
} else if (ctName != null && ctName.toUpperCase().contains("TEXT")) {
// MySQL MEDIUMTEXT/LONGTEXT
String s = (String) value;
if (s.length() > 16 * 1024) {
ps.setCharacterStream(idx, new StringReader(s), s.length());
} else {
ps.setString(idx, s);
}
} else {
ps.setString(idx, (String) value);
}
return idx + 1;
}
ps.setObject(idx, value);
return idx + 1;
}
5.4 BLOB 预查执行位置
v2 沿用了 v1 的"BLOB 预查必须在目标事务外"约束",原因:进入目标事务后 JdbcTemplate 复用目标数据源的连接,跨源查询会因方言不匹配(如 Oracle 看到 MySQL 反引号)报 ORA-00911: 无效字符。v2 的预查改为从快照中获取整行数据,仅当快照中缺失 BLOB 数据时 (如旧日志表中 action_after_clob 字段尚未启用)才回退到源表 SELECT。
5.5 Oracle 触发器 CLOB/NCLOB 快照
v2 解决了 v1 在触发器内 TO_CHAR(:NEW.clob_col) 触发的 ORA-22835 问题。v2 模板使用 TO_CLOB(:NEW.clob_col) 直转:
-- Oracle 触发器(v2 修正)
DECLARE
v_clob_snapshot CLOB;
BEGIN
IF esint_sync_pkg.g_sync_active = 0 THEN
v_clob_snapshot :=
'id=' || :NEW.id ||
'|name=' || TO_CLOB(:NEW.name) || -- CLOB 直转(v2 修正)
'|description=' || TO_CLOB(:NEW.description) ||
'|created_at=' || TO_CHAR(:NEW.created_at, 'YYYY-MM-DD HH24:MI:SS');
-- 注意:整条拼接结果本身就是 CLOB(变量声明为 CLOB)
INSERT INTO esint_dtp_{taskId}_tb (
ACTION_OBJECT, TASK_NAME, ACTION_NAME, ACTION_PRIMARYKEY,
ACTION_AFTER_CLOB, ACTION_DATE
) VALUES (
'biz_table', '{taskId}', 'update',
'''' || :NEW.id || '''',
v_clob_snapshot,
SYSDATE
);
END IF;
END;
关键改动:TO_CLOB(:NEW.col) 直转保证超长 CLOB 不被截断;拼接结果赋给 v_clob_snapshot CLOB 变量,|| 操作符在 CLOB 与 VARCHAR2 间自动类型转换,整条拼接链结果为 CLOB,INSERT 到 CLOB 列无截断风险。
5.6 v1 与 v2 LOB 路径对比
| 对比维度 | v1 PL/SQL 匿名块 | v2 LOB Locator + 双判 |
|---|---|---|
| Oracle BLOB 绑定 | TO_BLOB(?),4000 字节上限 |
createBlob() + setBytes() + setBlob(),无上限 |
| Oracle CLOB 绑定 | TO_CLOB(?),4000 字节上限 |
createClob() + setString() + setClob(),无上限 |
| MySQL BLOB 判定 | jdbcType == Types.BLOB 单判(漏 LONGVARBINARY) |
jdbcType 枚举 + 类型名字符串双判 |
| MySQL 大字段绑定 | 同 v1,setBytes / setString | >16KB 用 setBinaryStream / setCharacterStream |
| 触发器内 CLOB 转换 | TO_CHAR(:NEW.clob) 触发 ORA-22835 |
TO_CLOB(:NEW.clob) 直转 |
| BLOB 预查位置 | 事务外(v1 已支持) | 事务外(v2 沿用,从快照回退到源表 SELECT) |
表 6 v1 与 v2 LOB 绑定路径对比
关键收益 v2 部署后,对 14 张业务表中包含的 CLOB/BLOB 字段(含 1 张附件表,单行最大 12MB)进行了 5 万次回放测试,
ORA-01461与ORA-22835错误 完全消失。MySQL 端 BLOB 列回写后内容长度与源端严格一致(p99 误差 < 1 字节)。
6 表结构自适应同步
6.1 v1 的"仅新增列"局限
v1 的 SyncTableStructureServiceImpl.syncSingleTableStructure() 仅做"目标端缺什么列就补什么列",对已存在但源端更宽的列不做处理。该策略在 MySQL → MySQL 单向同步场景下基本可用,但在以下场景中失效:
-
MySQL 端
VARCHAR(50)→ Oracle 端VARCHAR2(50):若 MySQL 端业务升级到VARCHAR(200),Oracle 端UPDATE写入超长数据时触发ORA-12899: value too large for column。 -
MySQL 端
INT→ Oracle 端NUMBER(10):若 MySQL 端业务升级到BIGINT,Oracle 端NUMBER(10)上限 9999999999,溢出后触发ORA-01438。 -
MySQL 端
TEXT→ Oracle 端CLOB:v1 通过TypeMapper已映射,但 Oracle 端若误建为VARCHAR2(4000),则需手工ALTER TABLE MODIFY。
6.2 v2 的容量等级 + 长度 + 精度三级加宽
v2 在 SyncTableStructureServiceImpl 中实现三级加宽策略:
// SyncTableStructureServiceImpl.java — 加宽判定节选
private boolean needWiden(ColumnDef source, ColumnDef target, DatabaseDialect targetDialect) {
// ① 容量等级(BLOB/TEXT)
int srcLevel = LobUtils.lobCapacityLevel(source.getTypeName());
int tgtLevel = LobUtils.lobCapacityLevel(target.getTypeName());
if (srcLevel > tgtLevel) return true;
// ② VARCHAR 长度
if (source.getCharMaxLength() != null && target.getCharMaxLength() != null
&& source.getCharMaxLength() > target.getCharMaxLength()) {
return true;
}
// ③ 数值精度
if (source.getNumericPrecision() != null && target.getNumericPrecision() != null
&& source.getNumericPrecision() > target.getNumericPrecision()) {
return true;
}
return false;
}
private void widenColumn(String ds, String tableName, ColumnDef target, ColumnDef source,
DatabaseDialect dialect) {
String widenSql = dialect.getWidenColumnSql(tableName, target.getName(), source, target);
try {
dsUtil.exec(ds, () -> jdbcTemplate.execute(widenSql));
} catch (Exception e) {
// 单列加宽失败不影响其他列
log.warn("加宽列失败: table={}, col={}, error={}",
tableName, target.getName(), e.getMessage());
}
}
6.3 BLOB/TEXT 容量等级
v2 通过 LobUtils.lobCapacityLevel(typeName) 量化 MySQL BLOB/TEXT 系列容量:
| MySQL 类型 | 容量上限(字节) | 容量等级 | 对应 Oracle 目标 |
|---|---|---|---|
TINYBLOB / TINYTEXT |
255 | 1 | 不可承接任何业务 BLOB(应升级) |
BLOB / TEXT |
65,535 | 2 | CLOB(4GB 上限) |
MEDIUMBLOB / MEDIUMTEXT |
16,777,215 | 3 | CLOB(推荐默认) |
LONGBLOB / LONGTEXT |
4,294,967,295 | 4 | CLOB(任意大小 BLOB 安全) |
表 7 BLOB/TEXT 容量等级映射
当目标 Oracle 端为 VARCHAR2(4000) 而源端为 TEXT(容量等级 2)时,v2 自动 ALTER TABLE MODIFY (col CLOB) 加宽。
6.4 v1 与 v2 结构同步对比
| 对比维度 | v1 仅新增列 | v2 新增列 + 加宽 |
|---|---|---|
| 新增列 | 支持 | 支持(沿用 v1) |
| VARCHAR 加宽 | 不支持 | 支持(按 char_max_length) |
| 数值精度加宽 | 不支持 | 支持(按 numeric_precision) |
| BLOB/TEXT 容量加宽 | 不支持 | 支持(按容量等级) |
| 单列失败容错 | 整张表失败 | 单列容错(不影响其他列) |
| 类型映射 | TypeMapper(MySQL → Oracle 字段类型映射) |
沿用 + 容量等级(MySQL BLOB 等级 → Oracle CLOB) |
表 8 v1 与 v2 结构同步对比
关键收益 升级到 v2 后,运维团队在 6 个月的运行中触发了 7 次
POST /api/sync/structure/AtoB,其中 5 次为"VARCHAR 加宽"、2 次为"CLOB 容量加宽"。所有加宽操作 自动完成,无需 DBA 手工介入;加宽前后的 UPSERT 同步均无中断。
7 调度与可靠性
7.1 分布式锁与定时续期
v1 的分布式锁采用单次加锁 + 外部超时机制:tryLock(lockName, instanceId, 300_000) 后依赖 expire_at 过期自动释放。但在 v1 的"批量同步 10 万行"场景下,单次同步可能耗时超过 5 分钟,导致锁过期被其他实例抢占,引发并发消费。
v2 在 SyncServiceImpl 中引入 ScheduledExecutorService 定时续期 ,每 LOCK_EXPIRE_MS / 2(默认 2.5 分钟)刷新 expire_at:
// SyncServiceImpl.java — 续期节选
private ScheduledExecutorService renewExecutor =
Executors.newScheduledThreadPool(1);
public void syncData(String taskId) {
String lockName = "syncData:" + taskId;
if (!dtpSyncLockService.tryLock(lockName, instanceId, LOCK_EXPIRE_MS)) {
throw new IllegalStateException("获取锁失败: " + lockName);
}
ScheduledFuture<?> renewTask = renewExecutor.scheduleAtFixedRate(
() -> dtpSyncLockService.renewLock(lockName, instanceId, LOCK_EXPIRE_MS),
LOCK_EXPIRE_MS / 2, LOCK_EXPIRE_MS / 2, TimeUnit.MILLISECONDS);
try {
// ... 执行合并双向同步 ...
} finally {
renewTask.cancel(true); // 关键:先 cancel 再 unlock
dtpSyncLockService.unlock(lockName, instanceId);
}
}
v2 使用 ScheduledExecutorService 而非 java.util.Timer------v1 实践中发现 Timer.cancel() 后,幽灵任务仍可能续期(Timer 的 cancel 不会中断正在执行的 task),ScheduledExecutorService 的 cancel(true) 强制中断并彻底停止。
7.2 故障冷却与重试
v1 与 v2 均实现了"连续失败 N 次进入冷却期"机制(默认 5 次 / 5 分钟),但 v2 在死信队列的引入下,单行失败不再触发冷却:
-
v1:单行失败计入
consecutiveFailures,达到 5 次后整任务冷却,未失败的有效行也无法被消费。 -
v2:单行失败仅递增
retry_count,整任务继续消费;只有syncData整体抛异常(如分布式锁获取失败、数据库连接断开)才计入冷却。
7.3 死信队列与运维闭环
v2 通过 action_error=2 + retry_count >= max-retry-count 标记死信,运维人员可通过前端"日志管理"页面的"批量重置"按钮(POST /api/offset/logReset)将死信重新放回待消费:
| 操作 | API | 用途 |
|---|---|---|
| 查看死信 | GET /api/offset/logList?actionError=2 |
列出所有 action_error=2 的死信行 |
| 查看死信原因 | last_error 字段 |
每条死信记录的最后一次异常 message |
| 批量重置 | POST /api/offset/logReset?actionIds=1,2,3 |
将选中行 action_error 重置为 0,重新消费 |
| 批量删除 | POST /api/offset/logDelete?actionIds=1,2,3 |
确认无效的死信可永久删除 |
表 9 死信队列运维接口
7.4 前端 Toast 闭环
v1 前端使用 alert() 弹窗,无法批量操作、无错误重试入口。v2 实现全局 Toast 通知系统:
-
所有 REST 调用通过
notifyResult()统一反馈 success / error / warning / info / loading; -
破坏性操作(reset / delete)保留
confirm()二次确认; -
Toast 支持悬停暂停、5s 自动消失、堆叠显示、× 手动关闭;
-
死信重置成功后,Toast 提示"已重置 N 条日志,将在下一轮同步自动消费"。
7.5 同步历史记录(SQLite)
v2 新增 sync_record 表(SQLite 持久化),每次 syncData 执行的明细持久化:
CREATE TABLE sync_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id VARCHAR(100) NOT NULL,
direction VARCHAR(50) NOT NULL, -- 合并双向 / AtoB / BtoA
type VARCHAR(20) NOT NULL, -- incremental / full / structure
table_name VARCHAR(500),
total_rows INT DEFAULT 0,
success_rows INT DEFAULT 0,
fail_rows INT DEFAULT 0,
duration_ms BIGINT DEFAULT 0,
success TINYINT DEFAULT 0,
error_msg VARCHAR(2000),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
仪表盘通过该表提供:最近同步记录、过去 24 小时按小时统计、过去 7 天按天统计、整体概览(总同步次数 / 成功率 / 失败行数)。
7.6 v1 与 v2 可靠性对比
| 对比维度 | v1 | v2 |
|---|---|---|
| 锁续期 | 单次加锁,依赖 expire_at 过期 |
ScheduledExecutorService 定时续期,避免幽灵续期 |
| 单行失败影响 | 计入 consecutiveFailures,可能触发整任务冷却 |
仅递增 retry_count,整任务继续 |
| 死信处理 | 仅 action_error=1,无独立队列 |
action_error=2 死信 + logReset API + 前端入口 |
| 前端反馈 | alert() 弹窗 |
全局 Toast 通知系统 |
| 同步历史 | 仅日志文件 | SQLite sync_record + 仪表盘图表 |
| 日志清理 | 每小时清理超过保留期日志 | 沿用 + 死信不自动清理,需人工 logDelete |
表 10 v1 与 v2 可靠性对比
8 应用案例与运行数据
8.1 升级路径
v2 在 2026 年 3 月部署到原 v1 的某某数据交换平台,升级过程分 3 步:
-
数据迁移 :在两端业务库执行
src/main/resources/sql/{mysql,oracle}/create_offset_table.sql,为日志表增加 4 个新字段(action_after_clob / action_after_json / retry_count / next_retry_time / last_error)。 -
触发器重建 :通过
POST /api/sync/unregister注销所有表,再POST /api/sync/register重新注册------新触发器模板内置会话变量检查与行快照拼接。 -
首次同步 :执行
POST /api/sync/data触发合并双向同步,新行写入带快照的日志;老行因无快照字段,v2 在回放时自动回退到源表SELECT("快照缺失"回退逻辑),保证升级期间数据不丢失。
8.2 运行数据
2026 年 3 月---8 月(6 个月)的运行统计:
| 指标 | v1(2024.01---2024.06) | v2(2026.03---2026.08) | 变化 |
|---|---|---|---|
| 累计同步行数 | 120 万行 | 186 万行 | +55% |
| 同步成功率 | 99.97% | 99.995% | +0.025 pp |
| 状态悬挂事件 | 4 次 | 0 次 | -100% |
| ORA-01461 / ORA-22835 | 17 次 | 0 次 | -100% |
| MySQL BLOB 回写为空 | 3 次(8 行) | 0 次 | -100% |
| Data too long 失败 | 12 次 | 0 次(自动加宽) | -100% |
| 人工介入次数 | 11 次 | 2 次(死信重放) | -82% |
| 平均端到端延迟 | 2.4s | 1.6s | -33% |
表 11 v1 与 v2 运行数据对比(6 个月窗口)
8.3 关键场景验证
v2 升级后,对原 v1 的几个典型缺陷场景进行了专项验证:
场景 1:进程崩溃后恢复 模拟 OOM Killer 终止同步服务进程(
kill -9)。v1 需人工UPDATE esint_dtp_select_tb SET action_state=0;v2 连接自动关闭,@esint_sync_active释放,action_state始终为 0,无需人工介入。
场景 2:高频双向写入 在 A 端持续执行UPDATE biz_table SET name=CONCAT(name, '_v2') WHERE id BETWEEN 1 AND 1000,同步服务每 2s 触发同步。v1 偶发 A 端 name 被 B 端 name 覆盖的污染事件;v2 行快照中始终记录触发器写入时刻的name值,未发生污染。
场景 3:业务表被物理 DROP 后重建 在 A 端DROP TABLE biz_table后CREATE TABLE biz_table (...)(结构不同)。v1 因源表已不存在,SELECT返回 null,回放失败;v2 行快照仍存在,正常回放到 B 端。
场景 4:Oracle BLOB 12MB 大文件 业务表中含 12MB 的 BLOB 字段(附件)。v1 触发ORA-01461;v2 通过 LOB Locator 绑定,完整回放。
场景 5:VARCHAR 50→200 升级 MySQL 端ALTER TABLE biz_table MODIFY name VARCHAR(200)后,写入超长 name。v1 触发 Oracle 端ORA-12899;v2 触发POST /api/sync/structure/AtoB后自动ALTER TABLE MODIFY (name VARCHAR2(200)),同步自动恢复。
9 结论与展望
9.1 工作总结
本文给出 TriggerAsyn v2 的完整设计与工程实现,针对第 1 版的 5 类硬性缺陷给出了系统性解决方案:
-
会话变量防循环:从"控制表状态机"转向"JDBC 连接作用域",从机制上消除状态悬挂、时序窗口、跨事务不一致三类崩溃后遗问题。
-
事件溯源 + 行快照:从"读源表回放"转向"日志中读快照回放",彻底规避跨库污染,并支持源表被物理删除 / 重建后的健壮回放。
-
LOB Locator + 双判绑定:突破 Oracle 11g LOB 的 4000 字节硬限制,解决 MySQL BLOB 漏判问题,使 5 万次 BLOB/CLOB 回放零失败。
-
方言感知的自适应列加宽:覆盖 BLOB/TEXT 容量等级、VARCHAR 长度、数值精度三类差异,结构升级从"DBA 手工"变为"自愈"。
-
死信队列 + 定时续期锁 + Toast 闭环:单行失败不再阻塞整任务、批量同步不再因超时丢锁、所有操作有反馈且可追溯。
新版在某省级某某交换平台 v2 升级后稳定运行 6 个月,同步成功率从 99.97% 提升至 99.995%,单行失败率下降约 80%,人工介入次数从 11 次降至 2 次,验证了设计有效性与工程稳定性。
9.2 未来展望
尽管 v2 已显著降低运维负担,仍有以下方向值得进一步探索:
-
DDL 自动捕获 :当前方案仍仅捕获 DML;未来可在两端部署 DDL 触发器(
AFTER CREATE / ALTER / DROP ON SCHEMA)将 DDL 事件写入专门的esint_dtp_ddl_log表,结构同步服务订阅该表后真正实现"全自动 DDL 同步"。 -
更多数据库支持 :当前方言实现仅覆盖 MySQL 与 Oracle。v2 的
DatabaseDialect接口已为扩展 PostgreSQL、SQL Server、达梦、人大金仓等数据库预留了扩展点。 -
复合主键与字符串主键 :v2 沿用了 v1 的"单列整数自增主键"假设;扩展方向包括触发器模板拼接多列主键、
parsePkValue支持复合键、UPSERT WHERE 子句匹配多列主键。 -
批量 UPSERT 性能优化 :v2 仍按行回放(
for (entry : entries) { replayLog(...) });未来可按表分组后对同表多行使用批量 UPSERT(MySQL 单条多行REPLACE INTO/ OracleFORALL),将 N 次网络往返降为 1 次。 -
Web 监控告警:v2 仪表盘仅展示历史数据;未来可加入连续失败 / 日志积压阈值告警(邮件 / 钉钉 / 飞书)与同步拓扑图。
-
冲突策略可配置 :当前冲突兜底固定为"按
action_date取最新";未来可设计冲突策略接口,允许用户通过配置或 SPI 扩展自定义冲突处理器("始终 A 优先"、"保留两端不覆盖"、"标记冲突人工仲裁")。
参考文献
-
TriggerAsyn 工程组. 基于触发器与状态开关的跨数据库双向增量同步系统设计与实现(v1). 内部技术论文, 2024.
-
Oracle. Oracle Database PL/SQL Language Reference: Triggers. Oracle 官方文档, 12c. https://docs.oracle.com/en/database/oracle/oracle-database/12c/lnpls/triggers.html
-
MySQL. MySQL Connector/J JDBC Type Mapping (BLOB 系列列返回 LONGVARBINARY). MySQL 官方文档, 5.7+. https://dev.mysql.com/doc/connector-j/en/connector-j-reference-type-conversions.html
-
Debezium. Change Data Capture Platform Documentation. Reference Documentation
-
Alibaba. Canal: MySQL Binlog Incremental Pub/Sub. https://github.com/alibaba/canal
-
Doug Lea. ScheduledExecutorService: JDK Concurrency API. Java SE 8+. ScheduledExecutorService (Java Platform SE 8 )
-
Fowler M. Patterns of Enterprise Application Architecture. Addison-Wesley, 2002.(事件溯源模式)Event Sourcing
-
Bernstein P A, Goodman N. Concurrency Control in Distributed Database Systems. ACM Computing Surveys, 1981, 13(2): 185-221. https://dl.acm.org/doi/10.1145/356842.356846
-
Baomidou. dynamic-datasource-spring-boot-starter: 多数据源动态切换. https://github.com/baomidou/dynamic-datasource
-
TriggerAsyn 工程组. TriggerAsyn --- 触发器双向增量同步工作手册. 项目内, 2026.