主从数据不一致怎么办?

在分布式系统中,主从数据库架构是常见的数据存储方案。然而,由于网络延迟、系统故障等原因,主从数据库之间可能出现数据不一致的情况。本文将介绍一个基于 Java 的数据库表数据比对工具 TableDiff,它能够检测主从数据库之间的差异,并可选择性地将主库数据同步到从库中。

已有的比较成熟的工具集有: https://github.com/percona/percona-toolkit

核心设计思路

该工具的核心思路是通过逐行比对主从数据库中的表数据,识别出三种类型的差异:

  1. 仅在主库存在的记录
  2. 仅在从库存在的记录
  3. 主从库都存在但字段值不一致的记录

对于仅在主库存在的记录,工具支持自动将这些数据插入到从库中,从而实现数据同步。

类的基本结构和构造

首先,我们来看看 TableDiff 类的基本结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class TableDiff {
private static final Logger logger = LoggerFactory.getLogger(TableDiff.class);

// 主库数据源
private DataSource master;
// 从库数据源
private DataSource slave;
// 是否打印仅在从库存在的记录
private boolean printOnlyInSlave = false;
// 是否自动将主库独有数据插入从库
private boolean autoInsertOnlyInMaster = false;

public TableDiff(DataSource master, DataSource slave) {
this.master = master;
this.slave = slave;
}
}

该类接收两个数据源参数:主库和从库。通过配置参数可以控制是否自动同步数据以及输出详细程度。

主键获取策略

数据比对的关键是确定每条记录的唯一标识。工具首先尝试获取表的主键,如果表没有主键,则使用所有列的组合作为唯一标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private List<String> getPrimaryKeys(DataSource ds, String tableName) throws SQLException {
Set<String> primaryKeys = new LinkedHashSet<>();

try (Connection conn = ds.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();

// 首先尝试获取主键
try (ResultSet rs = metaData.getPrimaryKeys(null, null, tableName)) {
while (rs.next()) {
primaryKeys.add(rs.getString("COLUMN_NAME"));
}
}

// 如果没有主键,获取所有列作为组合键
if (primaryKeys.isEmpty()) {
try (ResultSet rs = metaData.getColumns(null, null, tableName, null)) {
while (rs.next()) {
String columnName = rs.getString("COLUMN_NAME");
primaryKeys.add(columnName);
}
}
// 按列名排序以确保一致性
List<String> sortedKeys = new ArrayList<>(primaryKeys);
Collections.sort(sortedKeys);
primaryKeys = new LinkedHashSet<>(sortedKeys);
logger.info("表 {} 没有主键,使用所有列作为组合键: [{}]",
tableName, String.join(", ", primaryKeys));
}
}

return new ArrayList<>(primaryKeys);
}

这种策略保证了即使在没有明确主键的情况下,也能准确地标识和比对每条记录。

数据获取和键值构建

接下来是从数据库中获取表数据,并根据键列构建唯一标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private Map<String, Map<String, Object>> getTableData(DataSource ds, String tableName, 
List<String> keyColumns) throws SQLException {
Map<String, Map<String, Object>> data = new HashMap<>();
String sql = "SELECT * FROM " + tableName;

try (Connection conn = ds.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {

ResultSetMetaData metaData = rs.getMetaData();
int columnCount = metaData.getColumnCount();

while (rs.next()) {
Map<String, Object> row = new HashMap<>();
StringBuilder keyBuilder = new StringBuilder();

// 按照keyColumns的顺序拼接键值
for (int i = 0; i < keyColumns.size(); i++) {
String keyColumn = keyColumns.get(i);
Object value = rs.getObject(keyColumn);
if (i > 0) keyBuilder.append("|");
keyBuilder.append(value != null ? value.toString() : "NULL");
}

// 获取所有字段值
for (int i = 1; i <= columnCount; i++) {
String columnName = metaData.getColumnName(i);
Object value = rs.getObject(i);
row.put(columnName, value);
}

data.put(keyBuilder.toString(), row);
}
}

return data;
}

该方法将每条记录的键列值用 “|” 分隔符连接成唯一字符串作为 Map 的键,整行数据作为值存储。

数据比对核心逻辑

数据比对的核心逻辑在 compareTableData 方法中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public void compareTableData(String tableName) {
try {
logger.info("开始对比表 {} 的数据", tableName);

// 获取主键信息(或所有列信息)
List<String> keyColumns = getPrimaryKeys(master, tableName);

// 获取主从库数据
Map<String, Map<String, Object>> masterData = getTableData(master, tableName, keyColumns);
Map<String, Map<String, Object>> slaveData = getTableData(slave, tableName, keyColumns);

Set<String> masterKeys = masterData.keySet();
Set<String> slaveKeys = slaveData.keySet();

// 计算数据差异
Set<String> onlyInMaster = new HashSet<>(masterKeys);
onlyInMaster.removeAll(slaveKeys); // 仅在主库存在

Set<String> onlyInSlave = new HashSet<>(slaveKeys);
onlyInSlave.removeAll(masterKeys); // 仅在从库存在

Set<String> common = new HashSet<>(masterKeys);
common.retainAll(slaveKeys); // 共同记录

// 检查共同记录的字段差异
List<String> diffRecords = new ArrayList<>();
for (String key : common) {
Map<String, Object> masterRow = masterData.get(key);
Map<String, Object> slaveRow = slaveData.get(key);
if (!compareRows(masterRow, slaveRow)) {
diffRecords.add(key);
}
}

// 生成比对报告...
} catch (SQLException e) {
logger.error("数据比较过程中发生异常", e);
}
}

这段代码使用集合运算来识别三种不同类型的数据差异,逻辑清晰且高效。

行数据比对

对于存在于两个数据库中的相同记录,需要逐字段比对内容是否一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private boolean compareRows(Map<String, Object> row1, Map<String, Object> row2) {
if (row1.size() != row2.size()) {
return false;
}

for (String column : row1.keySet()) {
Object value1 = row1.get(column);
Object value2 = row2.get(column);

// 处理 null 值比较
if (value1 == null && value2 == null) continue;
if (value1 == null || value2 == null) return false;
if (!value1.equals(value2)) return false;
}

return true;
}

该方法仔细处理了 null 值的比较情况,确保比对结果的准确性。

自动数据同步功能

当检测到仅在主库存在的记录时,如果启用了自动同步功能,工具会将这些数据插入到从库中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void insertRowToSlave(String tableName, Map<String, Object> row) throws SQLException {
StringBuilder sql = new StringBuilder("INSERT INTO ").append(tableName).append(" (");
StringBuilder placeholders = new StringBuilder();
List<Object> values = new ArrayList<>();

// 构建插入 SQL
for (String column : row.keySet()) {
sql.append(column).append(",");
placeholders.append("?,");
values.add(row.get(column));
}

// 去掉最后一个逗号
sql.setLength(sql.length() - 1);
placeholders.setLength(placeholders.length() - 1);

sql.append(") VALUES (").append(placeholders).append(")");

// 执行插入
try (Connection conn = slave.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql.toString())) {
for (int i = 0; i < values.size(); i++) {
stmt.setObject(i + 1, values.get(i));
}
stmt.executeUpdate();
}
}

实时监控和告警

工具还集成了 Swing GUI 组件,当检测到数据不一致时会弹出告警对话框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (!onlyInMaster.isEmpty()) {
logger.info("仅在主库存在的记录 ({} 条):", onlyInMaster.size());

// 在EDT线程中显示对话框
SwingUtilities.invokeLater(() ->
JOptionPane.showMessageDialog(null,
"仅在主库存在的记录 " + onlyInMaster.size(),
"数据库监控告警",
JOptionPane.ERROR_MESSAGE)
);

// 如果启用自动同步,执行数据插入
if (autoInsertOnlyInMaster) {
for (String key : onlyInMaster) {
Map<String, Object> row = masterData.get(key);
try {
insertRowToSlave(tableName, row);
logger.info("已插入从库: {}", key);
} catch (SQLException e) {
logger.error("插入从库失败: {}", key, e);
}
}
}
}

使用示例

以下是如何使用该工具的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) {
// 配置数据库连接信息
String masterUrl = "jdbc:mysql://IP1:PORT/db1?useSSL=false&characterEncoding=utf8";
String slaveUrl = "jdbc:mysql://IP2:PORT/db1?useSSL=false&characterEncoding=utf8";

// 创建数据源
DataSource master = DbUtil.createDataSource(masterUrl, "root", "password");
DataSource slave = DbUtil.createDataSource(slaveUrl, "root", "password");

// 创建比对工具实例
TableDiff tableDiff = new TableDiff(master, slave);
tableDiff.setAutoInsertOnlyInMaster(true); // 启用自动同步

// 执行表数据比对
List<String> tables = Arrays.asList("table1", "table2", "table3");
tables.forEach(tableName -> {
tableDiff.compareTableData(tableName);
});

// 清理资源
DbUtil.closeDataSource(master);
DbUtil.closeDataSource(slave);
}

完整代码

TableDiff 完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.sql.DataSource;
import javax.swing.*;
import java.sql.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;

/**
* 表数据比对
*/
public class TableDiff {
private static final Logger logger = LoggerFactory.getLogger(TableDiff.class);

private DataSource master;
private DataSource slave;
private boolean printOnlyInSlave = false;
private boolean autoInsertOnlyInMaster = false;

public TableDiff(DataSource master, DataSource slave) {
this.master = master;
this.slave = slave;
}

// 获取主键字段列表,如果没有主键则返回所有列
private List<String> getPrimaryKeys(DataSource ds, String tableName) throws SQLException {
Set<String> primaryKeys = new LinkedHashSet<>();

try (Connection conn = ds.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();

// 首先尝试获取主键
try (ResultSet rs = metaData.getPrimaryKeys(null, null, tableName)) {
while (rs.next()) {
primaryKeys.add(rs.getString("COLUMN_NAME"));
}
}

// 如果没有主键,获取所有列作为组合键
if (primaryKeys.isEmpty()) {
try (ResultSet rs = metaData.getColumns(null, null, tableName, null)) {
while (rs.next()) {
String columnName = rs.getString("COLUMN_NAME");
primaryKeys.add(columnName);
}
}
// 按列名排序以确保一致性
List<String> sortedKeys = new ArrayList<>(primaryKeys);
Collections.sort(sortedKeys);
primaryKeys = new LinkedHashSet<>(sortedKeys);
logger.info("表 {} 没有主键,使用所有列作为组合键: [{}]", tableName, String.join(", ", primaryKeys));
} else {
// 按主键序号排序
List<String> sortedKeys = new ArrayList<>(primaryKeys);
Collections.sort(sortedKeys);
primaryKeys = new LinkedHashSet<>(sortedKeys);
logger.info("表 {} 的主键字段: [{}]", tableName, String.join(", ", primaryKeys));
}
}

return new ArrayList<>(primaryKeys);
}

// 新增:比对表的全部字段是否一致
public void compareTableData(String tableName) {
try {
logger.info("开始对比表 {} 的数据", tableName);

// 获取主键信息(或所有列信息)
List<String> keyColumns = getPrimaryKeys(master, tableName);

Map<String, Map<String, Object>> masterData = getTableData(master, tableName, keyColumns);
Map<String, Map<String, Object>> slaveData = getTableData(slave, tableName, keyColumns);

Set<String> masterKeys = masterData.keySet();
Set<String> slaveKeys = slaveData.keySet();

Set<String> onlyInMaster = new HashSet<>(masterKeys);
onlyInMaster.removeAll(slaveKeys);

Set<String> onlyInSlave = new HashSet<>(slaveKeys);
onlyInSlave.removeAll(masterKeys);

Set<String> common = new HashSet<>(masterKeys);
common.retainAll(slaveKeys);

// 检查共同记录的字段差异
List<String> diffRecords = new ArrayList<>();
for (String key : common) {
Map<String, Object> masterRow = masterData.get(key);
Map<String, Object> slaveRow = slaveData.get(key);
if (!compareRows(masterRow, slaveRow)) {
diffRecords.add(key);
}
}

// 使用 logger 输出报告
logger.info("=========== 表数据完整性对比报告 ===========");
logger.info("表名: {}", tableName);
String keyDescription = getKeyDescription(tableName, keyColumns);
logger.info("键字段: {}", keyDescription);
logger.info("生成时间: {}", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
logger.info("");

logger.info("数据统计:");
logger.info("主库记录总数: {}", masterKeys.size());
logger.info("从库记录总数: {}", slaveKeys.size());
logger.info("仅主库存在: {}", onlyInMaster.size());
logger.info("仅从库存在: {}", onlyInSlave.size());
logger.info("共同记录数: {}", common.size());
logger.info("字段不一致记录数: {}", diffRecords.size());
logger.info("");

if (!onlyInMaster.isEmpty()) {
logger.info("仅在主库存在的记录 ({} 条):", onlyInMaster.size());
// 在EDT线程中显示对话框
SwingUtilities.invokeLater(() ->
JOptionPane.showMessageDialog(null,
"仅在主库存在的记录 " + onlyInMaster.size(),
"数据库监控告警",
JOptionPane.ERROR_MESSAGE)
);
List<String> list = new ArrayList<>(onlyInMaster);
Collections.sort(list);
logger.info(keyDescription);
for (String key : list) {
if(keyColumns.size() == 1) {
logger.info("{} = {}", key, masterData.get(key));
} else {
logger.info(key);
}
}
logger.info("");

if (autoInsertOnlyInMaster) {
for (String key : onlyInMaster) {
Map<String, Object> row = masterData.get(key);
try {
insertRowToSlave(tableName, row);
logger.info("已插入从库: {}", key);
} catch (SQLException e) {
logger.error("插入从库失败: {}", key, e);
}
}
}
}

if (printOnlyInSlave && !onlyInSlave.isEmpty()) {
logger.info("仅在从库存在的记录 ({} 条):", onlyInSlave.size());
List<String> list = new ArrayList<>(onlyInSlave);
Collections.sort(list);
logger.info(keyDescription);
for (String key : list) {
if(keyColumns.size() == 1) {
logger.info("{} = {}", key, slaveData.get(key));
} else {
logger.info(key);
}
}
logger.info("");
}

if (!diffRecords.isEmpty()) {
logger.info("字段不一致的记录 ({} 条):", diffRecords.size());
Collections.sort(diffRecords);
logger.info(keyDescription);
for (String key : diffRecords) {
logger.info(key);
Map<String, Object> masterRow = masterData.get(key);
Map<String, Object> slaveRow = slaveData.get(key);
showRowDifferences(masterRow, slaveRow);
logger.info("");
}
}

logger.info("=========== 报告结束 ===========");
logger.info("表 {} 数据对比完成", tableName);

} catch (SQLException e) {
logger.error("数据比较过程中发生异常", e);
}
}

// 获取键描述信息
private String getKeyDescription(String tableName, List<String> keyColumns) {
// 检查是否是真正的主键(通过重新查询主键信息)
try {
Set<String> actualPrimaryKeys = new LinkedHashSet<>();
try (Connection conn = master.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();
try (ResultSet rs = metaData.getPrimaryKeys(null, null, tableName)) {
while (rs.next()) {
actualPrimaryKeys.add(rs.getString("COLUMN_NAME"));
}
}
}

if (!actualPrimaryKeys.isEmpty() && keyColumns.equals(new ArrayList<>(actualPrimaryKeys))) {
if (keyColumns.size() == 1) {
return "主键(" + keyColumns.get(0) + ")";
} else {
return "复合主键(" + String.join("|", keyColumns) + ")";
}
} else {
return "全列组合键(" + String.join("|", keyColumns) + ")";
}
} catch (SQLException e) {
return "组合键(" + String.join("|", keyColumns) + ")";
}
}

// 修改后的获取表数据方法,接收键列参数
private Map<String, Map<String, Object>> getTableData(DataSource ds, String tableName, List<String> keyColumns) throws SQLException {
Map<String, Map<String, Object>> data = new HashMap<>();
String sql = "SELECT * FROM " + tableName;

try (Connection conn = ds.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {

ResultSetMetaData metaData = rs.getMetaData();
int columnCount = metaData.getColumnCount();

while (rs.next()) {
Map<String, Object> row = new HashMap<>();
StringBuilder keyBuilder = new StringBuilder();

// 按照keyColumns的顺序拼接键值
for (int i = 0; i < keyColumns.size(); i++) {
String keyColumn = keyColumns.get(i);
Object value = rs.getObject(keyColumn);
if (i > 0) keyBuilder.append("|");
keyBuilder.append(value != null ? value.toString() : "NULL");
}

// 获取所有字段值
for (int i = 1; i <= columnCount; i++) {
String columnName = metaData.getColumnName(i);
Object value = rs.getObject(i);
row.put(columnName, value);
}

data.put(keyBuilder.toString(), row);
}
}

return data;
}

// 比较两行数据是否相同
private boolean compareRows(Map<String, Object> row1, Map<String, Object> row2) {
if (row1.size() != row2.size()) {
return false;
}

for (String column : row1.keySet()) {
Object value1 = row1.get(column);
Object value2 = row2.get(column);

if (value1 == null && value2 == null) continue;
if (value1 == null || value2 == null) return false;
if (!value1.equals(value2)) return false;
}

return true;
}

// 显示行差异详情
private void showRowDifferences(Map<String, Object> row1, Map<String, Object> row2) {
Set<String> allColumns = new HashSet<>(row1.keySet());
allColumns.addAll(row2.keySet());

for (String column : allColumns) {
Object value1 = row1.get(column);
Object value2 = row2.get(column);

if (value1 == null && value2 == null) continue;
if (value1 == null || value2 == null || !value1.equals(value2)) {
logger.info(" {} : 主库=[{}], 从库=[{}]", column,
value1 != null ? value1.toString() : "NULL",
value2 != null ? value2.toString() : "NULL");
}
}
}

// 静态方法供外部调用
public static void compareTable(DataSource master, DataSource slave, String tableName) {
new TableDiff(master, slave).compareTableData(tableName);
}

private void insertRowToSlave(String tableName, Map<String, Object> row) throws SQLException {
StringBuilder sql = new StringBuilder("INSERT INTO ").append(tableName).append(" (");
StringBuilder placeholders = new StringBuilder();
List<Object> values = new ArrayList<>();

for (String column : row.keySet()) {
sql.append(column).append(",");
placeholders.append("?,");
values.add(row.get(column));
}
// 去掉最后一个逗号
sql.setLength(sql.length() - 1);
placeholders.setLength(placeholders.length() - 1);

sql.append(") VALUES (").append(placeholders).append(")");

try (Connection conn = slave.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql.toString())) {
for (int i = 0; i < values.size(); i++) {
stmt.setObject(i + 1, values.get(i));
}
stmt.executeUpdate();
}
}

public void setAutoInsertOnlyInMaster(boolean autoInsertOnlyInMaster) {
this.autoInsertOnlyInMaster = autoInsertOnlyInMaster;
}

public void setPrintOnlyInSlave(boolean printOnlyInSlave) {
this.printOnlyInSlave = printOnlyInSlave;
}
}

DbUtil 工具类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class DbUtil {

// 创建 DataSource 的静态方法
public static DataSource createDataSource(String url, String username, String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);

// 连接池配置
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
config.setLeakDetectionThreshold(60000);

return new HikariDataSource(config);
}

// 关闭连接池资源的通用方法
public static void closeDataSource(DataSource dataSource) {
if (dataSource instanceof HikariDataSource) {
((HikariDataSource) dataSource).close();
}
}
}

执行结果示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[main] 21:32:43.769 INFO  - 开始对比表 page_refer 的数据
[main] 21:32:44.254 INFO - 表 page_refer 没有主键,使用所有列作为组合键: [formField, guid, headguid, referField, showField, templateid, idevie]
[main] 21:32:44.485 INFO - =========== 表数据完整性对比报告 ===========
[main] 21:32:44.485 INFO - 表名: page_refer
[main] 21:32:44.497 INFO - 键字段: 全列组合键(formField|guid|headguid|referField|showField|templateid|idevie)
[main] 21:32:44.498 INFO - 生成时间: 2025-06-26 21:32:44
[main] 21:32:44.498 INFO -
[main] 21:32:44.498 INFO - 数据统计:
[main] 21:32:44.498 INFO - 主库记录总数: 4182
[main] 21:32:44.498 INFO - 从库记录总数: 7650
[main] 21:32:44.498 INFO - 仅主库存在: 20
[main] 21:32:44.498 INFO - 仅从库存在: 3488
[main] 21:32:44.498 INFO - 共同记录数: 4162
[main] 21:32:44.498 INFO - 字段不一致记录数: 0
[main] 21:32:44.498 INFO -
[main] 21:32:44.498 INFO - 仅在主库存在的记录 (20 条):
[main] 21:32:44.498 INFO - 全列组合键(formField|guid|headguid|referField|showField|templateid|idevie)
[main] 21:32:44.498 INFO - appliant_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.498 INFO - department_name|29499763|29499763|部门名称|name|payplans_bill|0
[main] 21:32:44.499 INFO - department_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - departmentid|29499763|29499763|部门id|name|payplans_bill|0
[main] 21:32:44.499 INFO - departmentid|29499763|29499763|guid|name|payplans_bill|0
[main] 21:32:44.499 INFO - department|29499763|29499763|部门id|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetitems_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetitemsid|29499763|29499763|budgetprojet|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetitemsid|29499763|29499763|guid|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetitems|29499763|29499763|budgetprojet_name|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetmetris_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetmetrisid|29499763|29499763|guid|name|payplans_bill|0
[main] 21:32:44.499 INFO - budgetyear_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - urrenyguid_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - fundsquality_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - fundsqualityid|29499763|29499763|funds|name|payplans_bill|0
[main] 21:32:44.499 INFO - fundsqualityid|29499763|29499763|guid|name|payplans_bill|0
[main] 21:32:44.499 INFO - fundsquality|29499763|29499763|funds_name|name|payplans_bill|0
[main] 21:32:44.499 INFO - moneyusedeptid_name|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.499 INFO - moneyusedept|29499763|29499763|name|name|payplans_bill|0
[main] 21:32:44.500 INFO -
[main] 21:32:44.500 INFO - =========== 报告结束 ===========
[main] 21:32:44.500 INFO - 表 page_refer 数据对比完成

总结

这个 TableDiff 工具提供了一个完整的主从数据库数据一致性检查和同步解决方案。它具有以下特点:

  1. 智能键值识别:自动检测主键,无主键表使用全列组合
  2. 全面数据比对:识别三种类型的数据差异
  3. 可选自动同步:支持将主库数据自动同步到从库
  4. 详细差异报告:提供完整的数据不一致分析
  5. 实时监控告警:GUI 弹窗提醒数据差异
  6. 高度可配置:支持多种运行模式和参数配置

该工具特别适用于需要定期检查和维护主从数据库一致性的生产环境,能够有效降低数据不一致带来的业务风险。


主从数据不一致怎么办?
https://blog.mybatis.io/post/bbf64191.html
作者
Liuzh
发布于
2025年6月25日
许可协议