第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 双向同步
双向同步,就是两个数据库互相同步数据。听起来简单,但实现起来坑很多。
双向同步的核心难点有两个:
- 同步环路:A同步到B,B又同步回A,形成死循环
- 数据一致性:两边同时修改同一条数据,如何保证最终一致
解决同步环路的常用方法:
- 标记法:每条记录加一个“来源标记”,同步时跳过自己产生的数据
- 版本向量:每个节点维护一个版本向量,记录其他节点看到的最新版本
- 单向通道:物理上把同步通道设计成单向的,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 知识体系总览
下面这张图总结了本章的核心知识点,你可以把它当作一个快速参考。
数据同步这个话题,说深了可以写一本书。但核心就这三件事:什么时候同步、冲突了怎么办、双向怎么搞。把这三点想清楚,大部分同步场景都能应对。
最后提醒一句:不要迷信任何一种同步方案。我在项目中见过太多人,拿着一个方案就往所有场景里套,结果到处碰壁。根据你的业务场景,选择合适的同步策略,才是正道。