第94章 数据库数据同步:实时同步、批量同步、冲突解决、双向同步

数据同步,说白了就是让两个或多个数据库里的数据保持一致。你想想看,在实际项目中,很少有系统只用单库单表跑到底的。读写分离、主从备份、异地多活……这些场景都离不开数据同步。

我最早接触数据同步,是在一个物联网项目里。设备端用SQLite,云端用MySQL,两边数据要实时对齐。那时候我天真地以为,写个定时任务把数据搬过去就行了。结果呢?数据冲突、主键重复、时间戳错乱……踩了一堆坑。今天我就把这些经验掰开揉碎了讲给你听。

核心要点:数据同步不是简单的“复制粘贴”,它涉及三个关键问题——什么时候同步(实时 vs 批量)、怎么解决冲突(谁覆盖谁)、以及双向同步时如何避免死循环。

94.1 实时同步 vs 批量同步

先说说同步的时机。我个人习惯把同步策略分成两类:实时同步和批量同步。它们各有各的适用场景,选错了会出大问题。

94.1.1 实时同步

实时同步,就是数据一变化,立刻同步到目标库。典型实现方式有:

  • 触发器(Trigger):在源库建触发器,INSERT/UPDATE/DELETE 时自动写日志表或直接推数据
  • 变更数据捕获(CDC):解析数据库的 binlog 或 WAL 日志,提取变更事件
  • 双写模式:应用层同时写两个库,但事务一致性很难保证

我在项目中用过触发器方案。当时的需求是:设备上报数据写入本地SQLite后,必须实时同步到云端MySQL。我在SQLite里建了一个同步日志表,每次数据变更都往日志表里插一条记录。然后有个后台线程每秒轮询日志表,把未同步的记录推送到云端。

-- SQLite 端:创建同步日志表
CREATE TABLE sync_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    table_name TEXT NOT NULL,
    row_id INTEGER NOT NULL,
    operation TEXT NOT NULL,  -- 'INSERT', 'UPDATE', 'DELETE'
    sync_time TEXT,
    synced INTEGER DEFAULT 0
);

-- 创建触发器:数据插入时自动记录
CREATE TRIGGER trg_after_insert_device_data
AFTER INSERT ON device_data
BEGIN
    INSERT INTO sync_log(table_name, row_id, operation, sync_time)
    VALUES('device_data', NEW.id, 'INSERT', datetime('now'));
END;

我的经验:实时同步的延迟通常在毫秒到秒级。但要注意,如果源库写入量很大,触发器会拖慢写入性能。我曾经在一个日增百万条记录的系统里用触发器,结果写入速度下降了30%。后来改成了CDC方案,才把性能拉回来。

94.1.2 批量同步

批量同步,就是按固定时间间隔(比如每小时、每天)或固定数据量(比如每1000条)进行一次同步。这种方式适合对实时性要求不高的场景,比如报表数据、历史归档。

批量同步的典型实现:

  • 定时任务 + 全量导出:把源库数据全部导出,再导入目标库。简单粗暴,但数据量大时很慢
  • 增量同步:只同步上次同步之后发生变化的数据。需要记录上次同步的时间戳或版本号
  • 分页拉取:每次拉取固定数量的记录,避免一次性加载太多数据导致内存溢出
// C 语言示例:增量同步的核心逻辑
// 假设源库和目标库都是 SQLite

void sync_incremental(sqlite3 *src_db, sqlite3 *dst_db, const char *last_sync_time) {
    const char *sql = "SELECT id, data, update_time FROM source_table "
                      "WHERE update_time > ? ORDER BY update_time ASC LIMIT 1000";
    sqlite3_stmt *stmt;
    
    sqlite3_prepare_v2(src_db, sql, -1, &stmt, NULL);
    sqlite3_bind_text(stmt, 1, last_sync_time, -1, SQLITE_STATIC);
    
    while (sqlite3_step(stmt) == SQLITE_ROW) {
        int id = sqlite3_column_int(stmt, 0);
        const char *data = sqlite3_column_text(stmt, 1);
        const char *update_time = sqlite3_column_text(stmt, 2);
        
        // 写入目标库(使用 UPSERT 避免主键冲突)
        const char *upsert = "INSERT INTO target_table(id, data, update_time) "
                             "VALUES(?, ?, ?) "
                             "ON CONFLICT(id) DO UPDATE SET data=excluded.data, update_time=excluded.update_time";
        sqlite3_stmt *upsert_stmt;
        sqlite3_prepare_v2(dst_db, upsert, -1, &upsert_stmt, NULL);
        sqlite3_bind_int(upsert_stmt, 1, id);
        sqlite3_bind_text(upsert_stmt, 2, data, -1, SQLITE_STATIC);
        sqlite3_bind_text(upsert_stmt, 3, update_time, -1, SQLITE_STATIC);
        sqlite3_step(upsert_stmt);
        sqlite3_finalize(upsert_stmt);
    }
    sqlite3_finalize(stmt);
}

注意:批量同步时,一定要处理好“同步窗口期”的问题。如果同步过程中源库数据还在变化,可能会出现数据不一致。我建议在同步开始时记录一个快照时间点,所有变更都基于这个时间点来判断。

94.2 冲突解决

数据同步最头疼的问题就是冲突。两个库同时修改了同一条记录,到底听谁的?

常见的冲突解决策略有这几种:

策略 说明 适用场景
最后写入胜利(LWW) 以时间戳较新的版本为准 大多数业务场景,简单有效
源库优先 始终以源库的数据为准 主从架构,从库不直接写入
目标库优先 始终以目标库的数据为准 目标库是权威数据源
合并策略 尝试合并两个版本的变更 协同编辑、版本管理
标记冲突,人工处理 发现冲突后记录日志,由人工介入 金融、医疗等强一致性场景

我曾经在一个双向同步的项目里,遇到过“数据震荡”的问题。A库和B库同时修改了同一条记录,A库同步到B库,B库又同步回A库,结果两边数据来回覆盖,永远停不下来。后来我引入了版本号机制,每次同步时检查版本号,只有版本号更大的变更才被接受,才解决了这个问题。

// 基于版本号的冲突解决
typedef struct {
    int id;
    char data[256];
    int version;  // 版本号,每次修改递增
} Record;

int sync_record(sqlite3 *src_db, sqlite3 *dst_db, int record_id) {
    Record src_rec, dst_rec;
    
    // 读取源库记录
    read_record(src_db, record_id, &src_rec);
    // 读取目标库记录
    read_record(dst_db, record_id, &dst_rec);
    
    // 只有源库版本号更大时,才覆盖目标库
    if (src_rec.version > dst_rec.version) {
        update_record(dst_db, &src_rec);
        return 1;  // 同步成功
    } else if (src_rec.version == dst_rec.version) {
        // 版本相同,比较时间戳或内容
        // 这里可以自定义策略
        return 0;  // 无需同步
    } else {
        // 目标库版本更新,不覆盖
        // 记录冲突日志
        log_conflict(record_id, src_rec.version, dst_rec.version);
        return -1; // 冲突未解决
    }
}

避坑指南:我曾经犯过一个错误——只用时间戳来判断冲突。但时间戳在分布式环境下并不可靠,不同机器的系统时间可能有偏差。后来我改用“逻辑时钟”或“版本号”,才真正解决了问题。记住:时间戳只能作为参考,不能作为唯一依据。

94.3 双向同步

双向同步,就是两个数据库互相同步数据。听起来简单,但实现起来坑很多。

双向同步的核心难点有两个:

  1. 同步环路:A同步到B,B又同步回A,形成死循环
  2. 数据一致性:两边同时修改同一条数据,如何保证最终一致

解决同步环路的常用方法:

  • 标记法:每条记录加一个“来源标记”,同步时跳过自己产生的数据
  • 版本向量:每个节点维护一个版本向量,记录其他节点看到的最新版本
  • 单向通道:物理上把同步通道设计成单向的,A到B一个通道,B到A另一个通道,互不干扰

我比较喜欢用标记法,简单有效。每条记录加一个 source_node 字段,同步时如果发现记录的 source_node 是自己,就跳过。

// 双向同步的标记法实现
#define NODE_ID "node_a"

void bidirectional_sync(sqlite3 *local_db, sqlite3 *remote_db) {
    // 1. 从远程库拉取数据
    const char *pull_sql = "SELECT id, data, version, source_node FROM sync_table "
                           "WHERE source_node != ? AND last_sync_time > ?";
    // ... 执行拉取逻辑
    
    // 2. 将拉取到的数据写入本地库
    // 写入时保持 source_node 不变,表示数据来自远程
    
    // 3. 将本地新增/修改的数据推送到远程
    const char *push_sql = "SELECT id, data, version, source_node FROM sync_table "
                           "WHERE source_node = ? AND last_sync_time > ?";
    // ... 执行推送逻辑
    
    // 4. 推送时,远程库会检查 source_node,如果是远程节点自身的数据,则跳过
}

我的建议:双向同步最好配合“最终一致性”来设计。不要追求强一致性,那在分布式环境下代价太高。只要保证在足够短的时间内,所有节点的数据最终一致就可以了。我在项目中通常设置一个“同步延迟阈值”,比如30秒内必须完成一次双向同步。

94.4 知识体系总览

下面这张图总结了本章的核心知识点,你可以把它当作一个快速参考。

数据库数据同步知识体系 数据同步 同步时机 实时同步 批量同步 冲突解决 LWW / 源库优先 / 合并 双向同步 标记法 版本向量 实现方式 触发器 / CDC / 双写 版本号 / 时间戳 / 逻辑时钟 标记法 / 版本向量 / 单向通道

数据同步这个话题,说深了可以写一本书。但核心就这三件事:什么时候同步、冲突了怎么办、双向怎么搞。把这三点想清楚,大部分同步场景都能应对。

最后提醒一句:不要迷信任何一种同步方案。我在项目中见过太多人,拿着一个方案就往所有场景里套,结果到处碰壁。根据你的业务场景,选择合适的同步策略,才是正道。


公众号:蓝海资料掘金营,微信deep3321