mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat: optimize data compare memory usage
Thanks @SuLea-IT for the contribution! This significantly reduces memory usage (53% lower RSS peak) by avoiding per-row HashMap materialization and cloning. Appreciate the detailed memory audit docs as well.
This commit is contained in:
+259
-126
@@ -167,24 +167,32 @@ pub struct DataCompareFromTablesPreparation {
|
||||
}
|
||||
|
||||
pub fn prepare_data_compare(options: DataComparePreparationOptions) -> Result<DataComparePreparation, String> {
|
||||
let DataComparePreparationOptions {
|
||||
table_name,
|
||||
schema,
|
||||
columns,
|
||||
key_columns,
|
||||
column_info,
|
||||
source_rows,
|
||||
target_rows,
|
||||
database_type,
|
||||
} = options;
|
||||
let result = compare_data_rows(CompareDataRowsOptions {
|
||||
columns: options.columns.clone(),
|
||||
key_columns: options.key_columns.clone(),
|
||||
source_rows: options.source_rows,
|
||||
target_rows: options.target_rows,
|
||||
columns: columns.clone(),
|
||||
key_columns: key_columns.clone(),
|
||||
source_rows,
|
||||
target_rows,
|
||||
})?;
|
||||
let sync_plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions {
|
||||
tables: vec![DataCompareSyncPlanTableOptions {
|
||||
table_name: options.table_name,
|
||||
schema: options.schema,
|
||||
columns: options.columns,
|
||||
key_columns: options.key_columns,
|
||||
column_info: options.column_info,
|
||||
diff: result.clone(),
|
||||
database_type: options.database_type,
|
||||
pre_sync_statements: Vec::new(),
|
||||
}],
|
||||
});
|
||||
let sync_plan = build_data_compare_sync_plan_from_refs(&[DataCompareSyncPlanTableRef {
|
||||
table_name: &table_name,
|
||||
schema: schema.as_deref(),
|
||||
columns: &columns,
|
||||
key_columns: &key_columns,
|
||||
column_info: &column_info,
|
||||
diff: &result,
|
||||
database_type,
|
||||
pre_sync_statements: &[],
|
||||
}]);
|
||||
Ok(DataComparePreparation { result, sync_statements: sync_plan.sync_statements, sync_sql: sync_plan.sync_sql })
|
||||
}
|
||||
|
||||
@@ -348,18 +356,17 @@ pub async fn prepare_data_compare_missing_target(
|
||||
.map(|statement| format!("{statement};")),
|
||||
);
|
||||
|
||||
let sync_plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions {
|
||||
tables: vec![DataCompareSyncPlanTableOptions {
|
||||
table_name: options.target_table,
|
||||
schema: Some(options.target_schema),
|
||||
columns: column_names,
|
||||
key_columns: options.key_columns,
|
||||
column_info: source_columns.iter().cloned().map(data_grid_column_info).collect(),
|
||||
diff: result.clone(),
|
||||
database_type: Some(target_database_type),
|
||||
pre_sync_statements: pre_sync_statements.clone(),
|
||||
}],
|
||||
});
|
||||
let column_info = source_columns.iter().cloned().map(data_grid_column_info).collect::<Vec<_>>();
|
||||
let sync_plan = build_data_compare_sync_plan_from_refs(&[DataCompareSyncPlanTableRef {
|
||||
table_name: &options.target_table,
|
||||
schema: Some(&options.target_schema),
|
||||
columns: &column_names,
|
||||
key_columns: &options.key_columns,
|
||||
column_info: &column_info,
|
||||
diff: &result,
|
||||
database_type: Some(target_database_type),
|
||||
pre_sync_statements: &pre_sync_statements,
|
||||
}]);
|
||||
|
||||
Ok(DataCompareFromTablesPreparation {
|
||||
result,
|
||||
@@ -374,16 +381,46 @@ pub async fn prepare_data_compare_missing_target(
|
||||
}
|
||||
|
||||
pub fn build_data_compare_sync_plan(options: DataCompareSyncPlanOptions) -> DataCompareSyncPlan {
|
||||
let tables = options
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| DataCompareSyncPlanTableRef {
|
||||
table_name: &table.table_name,
|
||||
schema: table.schema.as_deref(),
|
||||
columns: &table.columns,
|
||||
key_columns: &table.key_columns,
|
||||
column_info: &table.column_info,
|
||||
diff: &table.diff,
|
||||
database_type: table.database_type,
|
||||
pre_sync_statements: &table.pre_sync_statements,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
build_data_compare_sync_plan_from_refs(&tables)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct DataCompareSyncPlanTableRef<'a> {
|
||||
table_name: &'a str,
|
||||
schema: Option<&'a str>,
|
||||
columns: &'a [String],
|
||||
key_columns: &'a [String],
|
||||
column_info: &'a [DataGridColumnInfo],
|
||||
diff: &'a DataCompareResult,
|
||||
database_type: Option<DatabaseType>,
|
||||
pre_sync_statements: &'a [String],
|
||||
}
|
||||
|
||||
fn build_data_compare_sync_plan_from_refs(tables: &[DataCompareSyncPlanTableRef<'_>]) -> DataCompareSyncPlan {
|
||||
let mut sync_statements = Vec::new();
|
||||
let mut insert_count = 0;
|
||||
let mut update_count = 0;
|
||||
let mut delete_count = 0;
|
||||
|
||||
for table in options.tables {
|
||||
for table in tables {
|
||||
insert_count += table.diff.added.len();
|
||||
update_count += table.diff.modified.len();
|
||||
delete_count += table.diff.removed.len();
|
||||
sync_statements.extend(table.pre_sync_statements);
|
||||
sync_statements.extend(table.pre_sync_statements.iter().cloned());
|
||||
sync_statements.extend(generate_data_sync_statements(&GenerateDataSyncSqlOptions {
|
||||
table_name: table.table_name,
|
||||
schema: table.schema,
|
||||
@@ -402,10 +439,10 @@ pub fn build_data_compare_sync_plan(options: DataCompareSyncPlanOptions) -> Data
|
||||
|
||||
fn missing_target_diff(columns: &[String], key_columns: &[String], source_rows: Vec<Vec<Value>>) -> DataCompareResult {
|
||||
let added = source_rows
|
||||
.iter()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let values = row_object(columns, row);
|
||||
let values = row_object_owned(columns, row);
|
||||
let key = if key_columns.is_empty() { index.to_string() } else { key_for(&values, key_columns) };
|
||||
DataCompareRow { key, key_values: key_values(&values, key_columns), values }
|
||||
})
|
||||
@@ -419,124 +456,111 @@ pub fn compare_data_rows(options: CompareDataRowsOptions) -> Result<DataCompareR
|
||||
return Err("At least one key column is required for data comparison".to_string());
|
||||
}
|
||||
|
||||
let source_rows = parallel_compare_rows(&options.columns, &options.key_columns, &options.source_rows);
|
||||
let target_rows = parallel_compare_rows(&options.columns, &options.key_columns, &options.target_rows);
|
||||
let (source, source_order) = collect_compare_rows(source_rows, "source")?;
|
||||
let (target, target_order) = collect_compare_rows(target_rows, "target")?;
|
||||
let column_indexes = column_index_map(&options.columns);
|
||||
let (source, source_order) =
|
||||
collect_compare_rows(&options.columns, &options.key_columns, &column_indexes, options.source_rows, "source")?;
|
||||
let (target, target_order) =
|
||||
collect_compare_rows(&options.columns, &options.key_columns, &column_indexes, options.target_rows, "target")?;
|
||||
let key_columns: HashSet<&str> = options.key_columns.iter().map(String::as_str).collect();
|
||||
|
||||
let source_diffs = source_order
|
||||
.par_iter()
|
||||
.map(|key| {
|
||||
let source_values = source.get(key).expect("source key should exist");
|
||||
let Some(target_values) = target.get(key) else {
|
||||
return Some(SourceDiff::Added(DataCompareRow {
|
||||
key: key.clone(),
|
||||
key_values: key_values(source_values, &options.key_columns),
|
||||
values: source_values.clone(),
|
||||
}));
|
||||
};
|
||||
|
||||
let changes: Vec<DataCompareChangedCell> = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| !key_columns.contains(column.as_str()))
|
||||
.filter(|column| value_for(source_values, column) != value_for(target_values, column))
|
||||
.map(|column| DataCompareChangedCell {
|
||||
column: column.clone(),
|
||||
source: value_for(source_values, column),
|
||||
target: value_for(target_values, column),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if changes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(SourceDiff::Modified(DataCompareModifiedRow {
|
||||
key: key.clone(),
|
||||
key_values: key_values(source_values, &options.key_columns),
|
||||
source_values: source_values.clone(),
|
||||
target_values: target_values.clone(),
|
||||
changes,
|
||||
}))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut added = Vec::new();
|
||||
let mut modified = Vec::new();
|
||||
for diff in source_diffs.into_iter().flatten() {
|
||||
match diff {
|
||||
SourceDiff::Added(row) => added.push(row),
|
||||
SourceDiff::Modified(row) => modified.push(row),
|
||||
|
||||
for key in &source_order {
|
||||
let source_values = source.get(key).expect("source key should exist");
|
||||
let Some(target_values) = target.get(key) else {
|
||||
added.push(DataCompareRow {
|
||||
key: key.clone(),
|
||||
key_values: key_values_for_row(source_values, &options.key_columns, &column_indexes),
|
||||
values: row_object(&options.columns, source_values),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
let changes = options
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| !key_columns.contains(column.as_str()))
|
||||
.filter_map(|column| {
|
||||
let index = column_indexes.get(column.as_str()).copied()?;
|
||||
let source_value = row_value(source_values, index);
|
||||
let target_value = row_value(target_values, index);
|
||||
(source_value != target_value).then(|| DataCompareChangedCell {
|
||||
column: column.clone(),
|
||||
source: source_value.clone(),
|
||||
target: target_value.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !changes.is_empty() {
|
||||
modified.push(DataCompareModifiedRow {
|
||||
key: key.clone(),
|
||||
key_values: key_values_for_row(source_values, &options.key_columns, &column_indexes),
|
||||
source_values: row_object(&options.columns, source_values),
|
||||
target_values: row_object(&options.columns, target_values),
|
||||
changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let removed = target_order
|
||||
.par_iter()
|
||||
.map(|key| {
|
||||
target.get(key).filter(|_| !source.contains_key(key)).map(|target_values| DataCompareRow {
|
||||
let mut removed = Vec::new();
|
||||
for key in &target_order {
|
||||
if let Some(target_values) = target.get(key).filter(|_| !source.contains_key(key)) {
|
||||
removed.push(DataCompareRow {
|
||||
key: key.clone(),
|
||||
key_values: key_values(target_values, &options.key_columns),
|
||||
values: target_values.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
key_values: key_values_for_row(target_values, &options.key_columns, &column_indexes),
|
||||
values: row_object(&options.columns, target_values),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DataCompareResult { added, removed, modified })
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CompareRow {
|
||||
key: String,
|
||||
values: HashMap<String, Value>,
|
||||
}
|
||||
static NULL_VALUE: Value = Value::Null;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SourceDiff {
|
||||
Added(DataCompareRow),
|
||||
Modified(DataCompareModifiedRow),
|
||||
}
|
||||
|
||||
type CompareRowValues = HashMap<String, Value>;
|
||||
type CompareRowValues = Vec<Value>;
|
||||
type CompareRowMap = HashMap<String, CompareRowValues>;
|
||||
|
||||
fn parallel_compare_rows(columns: &[String], key_columns: &[String], rows: &[Vec<Value>]) -> Vec<CompareRow> {
|
||||
rows.par_iter()
|
||||
.map(|row| {
|
||||
let values = row_object(columns, row);
|
||||
let key = key_for(&values, key_columns);
|
||||
CompareRow { key, values }
|
||||
})
|
||||
.collect()
|
||||
fn column_index_map<'a>(columns: &'a [String]) -> HashMap<&'a str, usize> {
|
||||
let mut indexes = HashMap::with_capacity(columns.len());
|
||||
for (index, column) in columns.iter().enumerate() {
|
||||
indexes.insert(column.as_str(), index);
|
||||
}
|
||||
indexes
|
||||
}
|
||||
|
||||
fn collect_compare_rows(rows: Vec<CompareRow>, label: &str) -> Result<(CompareRowMap, Vec<String>), String> {
|
||||
fn collect_compare_rows(
|
||||
columns: &[String],
|
||||
key_columns: &[String],
|
||||
column_indexes: &HashMap<&str, usize>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
label: &str,
|
||||
) -> Result<(CompareRowMap, Vec<String>), String> {
|
||||
let mut items = HashMap::with_capacity(rows.len());
|
||||
let mut order = Vec::with_capacity(rows.len());
|
||||
|
||||
for row in rows {
|
||||
if items.contains_key(&row.key) {
|
||||
return Err(format!("Duplicate {label} key: {}", row.key));
|
||||
let key = key_for_row(&row, key_columns, column_indexes);
|
||||
if items.contains_key(&key) {
|
||||
return Err(format!("Duplicate {label} key: {key}"));
|
||||
}
|
||||
order.push(row.key.clone());
|
||||
items.insert(row.key, row.values);
|
||||
order.push(key.clone());
|
||||
items.insert(key, normalize_row_len(row, columns.len()));
|
||||
}
|
||||
|
||||
Ok((items, order))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GenerateDataSyncSqlOptions {
|
||||
table_name: String,
|
||||
schema: Option<String>,
|
||||
columns: Vec<String>,
|
||||
key_columns: Vec<String>,
|
||||
column_info: Vec<DataGridColumnInfo>,
|
||||
diff: DataCompareResult,
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct GenerateDataSyncSqlOptions<'a> {
|
||||
table_name: &'a str,
|
||||
schema: Option<&'a str>,
|
||||
columns: &'a [String],
|
||||
key_columns: &'a [String],
|
||||
column_info: &'a [DataGridColumnInfo],
|
||||
diff: &'a DataCompareResult,
|
||||
database_type: Option<DatabaseType>,
|
||||
}
|
||||
|
||||
@@ -548,31 +572,79 @@ fn row_object(columns: &[String], row: &[Value]) -> HashMap<String, Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn row_object_owned(columns: &[String], row: Vec<Value>) -> HashMap<String, Value> {
|
||||
let mut values = HashMap::with_capacity(columns.len());
|
||||
let mut row_values = row.into_iter();
|
||||
for column in columns {
|
||||
values.insert(column.clone(), row_values.next().unwrap_or(Value::Null));
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn key_for(row: &HashMap<String, Value>, key_columns: &[String]) -> String {
|
||||
key_columns.iter().map(|column| json_stringify(&value_for(row, column))).collect::<Vec<_>>().join("\u{001f}")
|
||||
}
|
||||
|
||||
fn key_for_row(row: &[Value], key_columns: &[String], column_indexes: &HashMap<&str, usize>) -> String {
|
||||
key_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
column_indexes
|
||||
.get(column.as_str())
|
||||
.map(|index| json_stringify(row_value(row, *index)))
|
||||
.unwrap_or_else(|| json_stringify(&NULL_VALUE))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\u{001f}")
|
||||
}
|
||||
|
||||
fn key_values(row: &HashMap<String, Value>, key_columns: &[String]) -> HashMap<String, Value> {
|
||||
key_columns.iter().map(|column| (column.clone(), value_for(row, column))).collect()
|
||||
}
|
||||
|
||||
fn key_values_for_row(
|
||||
row: &[Value],
|
||||
key_columns: &[String],
|
||||
column_indexes: &HashMap<&str, usize>,
|
||||
) -> HashMap<String, Value> {
|
||||
key_columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let value =
|
||||
column_indexes.get(column.as_str()).map(|index| row_value(row, *index).clone()).unwrap_or(Value::Null);
|
||||
(column.clone(), value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn value_for(row: &HashMap<String, Value>, column: &str) -> Value {
|
||||
row.get(column).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn row_value(row: &[Value], index: usize) -> &Value {
|
||||
row.get(index).unwrap_or(&NULL_VALUE)
|
||||
}
|
||||
|
||||
fn normalize_row_len(mut row: Vec<Value>, column_len: usize) -> Vec<Value> {
|
||||
if row.len() < column_len {
|
||||
row.resize(column_len, Value::Null);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
fn json_stringify(value: &Value) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
|
||||
fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec<String> {
|
||||
let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
|
||||
fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions<'_>) -> Vec<String> {
|
||||
let table = qualified_table_name(options.database_type, options.schema, options.table_name);
|
||||
let columns = options
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| quote_table_identifier(options.database_type, column))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let column_info = options.column_info.as_slice();
|
||||
let column_info = options.column_info;
|
||||
let added = options
|
||||
.diff
|
||||
.added
|
||||
@@ -958,6 +1030,67 @@ mod tests {
|
||||
assert_eq!(plan.statement_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_sync_plan_builder_matches_owned_plan() {
|
||||
let columns = vec!["id".to_string(), "name".to_string()];
|
||||
let key_columns = vec!["id".to_string()];
|
||||
let column_info = Vec::new();
|
||||
let pre_sync_statements = vec!["CREATE TABLE \"public\".\"users\" (\"id\" integer);".to_string()];
|
||||
let diff = DataCompareResult {
|
||||
added: vec![DataCompareRow {
|
||||
key: "1".to_string(),
|
||||
key_values: HashMap::from([(String::from("id"), json!(1))]),
|
||||
values: HashMap::from([(String::from("id"), json!(1)), (String::from("name"), json!("Ada"))]),
|
||||
}],
|
||||
removed: vec![DataCompareRow {
|
||||
key: "3".to_string(),
|
||||
key_values: HashMap::from([(String::from("id"), json!(3))]),
|
||||
values: HashMap::from([(String::from("id"), json!(3)), (String::from("name"), json!("Cara"))]),
|
||||
}],
|
||||
modified: vec![DataCompareModifiedRow {
|
||||
key: "2".to_string(),
|
||||
key_values: HashMap::from([(String::from("id"), json!(2))]),
|
||||
source_values: HashMap::from([(String::from("id"), json!(2)), (String::from("name"), json!("Bob"))]),
|
||||
target_values: HashMap::from([(String::from("id"), json!(2)), (String::from("name"), json!("Bobby"))]),
|
||||
changes: vec![DataCompareChangedCell {
|
||||
column: "name".to_string(),
|
||||
source: json!("Bob"),
|
||||
target: json!("Bobby"),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
let owned_plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions {
|
||||
tables: vec![DataCompareSyncPlanTableOptions {
|
||||
table_name: "users".to_string(),
|
||||
schema: Some("public".to_string()),
|
||||
columns: columns.clone(),
|
||||
key_columns: key_columns.clone(),
|
||||
column_info: column_info.clone(),
|
||||
diff: diff.clone(),
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
pre_sync_statements: pre_sync_statements.clone(),
|
||||
}],
|
||||
});
|
||||
let borrowed_plan = build_data_compare_sync_plan_from_refs(&[DataCompareSyncPlanTableRef {
|
||||
table_name: "users",
|
||||
schema: Some("public"),
|
||||
columns: &columns,
|
||||
key_columns: &key_columns,
|
||||
column_info: &column_info,
|
||||
diff: &diff,
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
pre_sync_statements: &pre_sync_statements,
|
||||
}]);
|
||||
|
||||
assert_eq!(borrowed_plan.insert_count, owned_plan.insert_count);
|
||||
assert_eq!(borrowed_plan.update_count, owned_plan.update_count);
|
||||
assert_eq!(borrowed_plan.delete_count, owned_plan.delete_count);
|
||||
assert_eq!(borrowed_plan.statement_count, owned_plan.statement_count);
|
||||
assert_eq!(borrowed_plan.sync_statements, owned_plan.sync_statements);
|
||||
assert_eq!(borrowed_plan.sync_sql, owned_plan.sync_sql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sync_plan_keeps_missing_target_create_table_statement() {
|
||||
let plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# DBX 内存占用功能排查报告
|
||||
|
||||
日期:2026-06-11
|
||||
|
||||
## 结论摘要
|
||||
|
||||
本次排查结合了当前运行进程快照、前端 bundle 体积、以及主要功能的代码路径。当前进程里最大的 repo 相关内存占用是开发服务器 `vite`,RSS 约 442 MiB;这属于开发工具链占用,不应直接归因到 DBX 某个业务功能。
|
||||
|
||||
业务功能层面,最容易导致内存占用率升高的是这些路径:
|
||||
|
||||
1. 查询结果 / 表数据 DataGrid / 导出:高风险,结果集会在原始 rows、筛选索引、显示项、搜索命中、导出 rows、磁盘缓存转换中多份存在。
|
||||
2. 数据对比 Data Compare:高风险,后端一次性持有源表和目标表全量数据,并构建 HashMap、diff、同步 SQL;前端也会保留完整 diff 和 sync plan。
|
||||
3. Redis Key Browser:高风险,`fetch all` 和 value search 可能扫描并保留大量 key,同时维护 flat list、tree list、visible rows 和命令历史。
|
||||
4. Mongo 文档表格视图:中高风险,`documents` 会转换成 DataGrid 的二维 rows,嵌套对象还会被 `JSON.stringify`,随后再进入 DataGrid 的派生结构。
|
||||
5. 查询图表 QueryChart:中高风险,打开图表会加载 ECharts,并把查询结果再次映射为 x/y series 数组。
|
||||
6. Schema / Object Browser / ER Diagram:中风险,连接树、补全缓存、schema 关系和图节点会随库规模增长。
|
||||
7. QueryEditor 补全、AI Assistant:中低风险,通常不是首要来源,但长会话、多 tab、频繁补全或长聊天会持续积累状态。
|
||||
|
||||
## 当前进程快照
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
ps -axo pid,ppid,rss,vsz,comm,args | awk 'NR==1 || /dbx|vite|pnpm|tauri|WebKit|node_repl|electron|cargo/ {print}' | sort -k3 -nr | head -40
|
||||
```
|
||||
|
||||
主要观察:
|
||||
|
||||
| 进程 | RSS | 说明 |
|
||||
| --- | ---: | --- |
|
||||
| `node ... vite --config apps/desktop/vite.config.ts --port 5173 --mode web` | 452,880 KB,约 442 MiB | 当前最大 repo 相关进程,属于开发服务器和 HMR,占用通常高于生产包 |
|
||||
| `com.apple.WebKit.WebContent` | 197,232 KB,约 193 MiB | WebKit 渲染进程,可能来自 Codex 内嵌浏览器或本地预览,需结合具体窗口确认 |
|
||||
| `com.apple.WebKit.WebContent` | 165,872 KB,约 162 MiB | 同上 |
|
||||
| `com.apple.WebKit.WebContent` | 130,672 KB,约 128 MiB | 同上 |
|
||||
| `pnpm dev:web` | 52,352 KB,约 51 MiB | Vite 父进程 |
|
||||
|
||||
补充:`target`、`node_modules` 等目录体积是磁盘占用,不是运行时内存。当前 `target` 目录约 27 GiB,`node_modules` 约 543 MiB,主要影响磁盘空间和构建缓存,不直接解释内存占用率。
|
||||
|
||||
## 前端 Chunk 观察
|
||||
|
||||
当前 `dist/assets` 中较大的 JS chunk:
|
||||
|
||||
| Chunk | 大小 | 相关功能 |
|
||||
| --- | ---: | --- |
|
||||
| `index-C0boAXCG.js` | 600 KB | 主应用入口 |
|
||||
| `QueryChart-DxFWVk_s.js` | 548 KB | 查询图表,包含 ECharts 相关逻辑 |
|
||||
| `codemirror-Dy52TtRW.js` | 464 KB | SQL 编辑器 |
|
||||
| `sql-formatter-BTQ26GQc.js` | 288 KB | SQL 格式化 |
|
||||
| `DataGrid-kZSBZBlT.js` | 148 KB | 查询结果表格 |
|
||||
| `RedisKeyBrowser-CdulxmUR.js` | 60 KB | Redis key 浏览器 |
|
||||
| `AiAssistant-BrEVPSc_.js` | 52 KB | AI 助手 |
|
||||
|
||||
Chunk 体积不等于运行时内存,但能提示哪些功能首次打开会引入较重依赖。这里 `QueryChart` 的包体明显偏大,且运行时还会复制查询数据到 ECharts option。
|
||||
|
||||
## 功能排名与占用来源
|
||||
|
||||
| 排名 | 功能 | 风险 | 主要占用来源 | 典型触发 |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 1 | 查询结果 / 表数据 / DataGrid / 导出 | 高 | 查询 rows、inactive tab 缓存、DataGrid 派生数组、搜索命中、导出 rows、缓存序列化峰值 | 大查询结果、多结果 tab、客户端搜索、全量导出 |
|
||||
| 2 | 数据对比 Data Compare | 高 | 源表 rows、目标表 rows、HashMap、diff clone、sync statements、sync SQL、前端 selectable diff | 大表对比、批量表对比、差异很多 |
|
||||
| 3 | Redis Key Browser | 高 | flat key list、tree key list、visible rows、checked set、命令历史 | `fetch all`、value search、大 keyspace |
|
||||
| 4 | Mongo 文档表格视图 | 中高 | 原始 documents、转换后的 grid rows、对象 JSON 字符串、DataGrid 派生数组 | 大 page size、宽文档、嵌套对象多 |
|
||||
| 5 | 查询图表 QueryChart | 中高 | ECharts 依赖、xData、series data、pie data | 大结果集打开图表、多 Y 轴列 |
|
||||
| 6 | Schema / Object Browser / ER Diagram | 中 | treeNodes、schema cache、completion cache、diagram tables / relationships | 多库多 schema、超大表结构 |
|
||||
| 7 | QueryEditor 补全和诊断 | 中 | CodeMirror、per-editor table/column/FK cache、语义诊断状态 | 多编辑器 tab、频繁触发补全 |
|
||||
| 8 | AI Assistant | 中低 | conversations、messages、Markdown/Shiki 高亮缓存 | 很长对话、代码块多 |
|
||||
| 9 | 连接池 / Agent / JDBC / SSH | 中低到中 | 后端连接、外部 driver/session、后台状态 | 打开很多连接或长时间不关闭 |
|
||||
|
||||
## 1. 查询结果 / DataGrid / 导出
|
||||
|
||||
这是最需要优先处理的业务路径。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `apps/desktop/src/stores/queryStore.ts:136` 将 inactive 结果内存缓存上限设为 `MAX_CACHED_RESULTS = 5`。实际效果是当前 active tab 加最多 5 个 inactive tab 的结果仍可留在内存。
|
||||
- `apps/desktop/src/stores/queryStore.ts:1406` 到 `1410` 只按 tab 数量淘汰 inactive 结果,没有按行数、列数或估算字节数淘汰。
|
||||
- `apps/desktop/src/components/grid/DataGrid.vue:518` 到 `530` 即使没有本地列筛选,也会为所有 rows 构造一份索引数组。
|
||||
- `apps/desktop/src/components/grid/DataGrid.vue:2350` 到 `2374` 会为显示行构造 `displayRowRefs`,`2404` 又映射成 `displayItems`。虚拟滚动减少 DOM 节点,但没有避免 JS 内存里存在全量派生数组。
|
||||
- `apps/desktop/src/components/grid/DataGrid.vue:2445` 到 `2467` 客户端搜索会扫描所有显示行和列,并保存每个命中的坐标。
|
||||
- `apps/desktop/src/stores/queryStore.ts:1469` 到 `1592` 导出时循环分页并 `rows.push(...result.rows)`,最终返回一个包含全量 rows 的 `QueryResult`。这会让导出期间出现明显内存峰值。
|
||||
- `apps/desktop/src/lib/tabResultCache.ts:129` 到 `146` 在结果被转成列式 MessagePack 缓存时,会从 row-major rows 生成 column-major `columnValues`,恢复时又重建 rows。淘汰/恢复期间可能出现原始 rows、列式副本、编码 bytes 多份共存。
|
||||
|
||||
建议:
|
||||
|
||||
1. 将结果缓存从“最多 5 个 inactive tab”改为“按估算字节数 + tab 数”双上限,例如默认只保留当前 tab + 最近 1 到 2 个小结果;大结果立即落盘或只保留当前页。
|
||||
2. DataGrid 内部避免为全量 rows 同时维护 `localFilteredRows`、`displayRowRefs`、`displayItems`。可改为基于索引的懒计算,虚拟滚动只生成可视范围 item。
|
||||
3. 客户端搜索增加上限和渐进式扫描,例如只扫描当前页或前 N 行,超限提示用户改用 SQL 查询。
|
||||
4. 导出改成 streaming writer,不返回包含全量 rows 的 `QueryResult`。CSV/Excel/JSON 都应边拉边写,避免 `rows.push(...)` 聚合。
|
||||
5. 缓存落盘时避免 `structuredClone`、row-to-column、encode 同时叠加;可用分块序列化,或在清空原始引用后再进行后台转换。
|
||||
6. 对 `MAX_RESULT_PAGE_SIZE` 做产品级收紧。当前最大可到 100,000 行,配合宽表和 DataGrid 派生数组会非常容易冲高内存。
|
||||
|
||||
## 2. 数据对比 Data Compare
|
||||
|
||||
这是第二个最高风险路径,而且会同时占用 Rust 后端和前端内存。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `crates/dbx-core/src/data_compare.rs:227` 到 `250` 通过 `tokio::try_join!` 同时拉取源表和目标表 rows。
|
||||
- `crates/dbx-core/src/data_compare.rs:723` 到 `768` 虽然按 batch 查询,但最终 `rows.extend(result.rows)` 聚合到一个 `Vec<Vec<Value>>` 后返回。
|
||||
- `crates/dbx-core/src/data_compare.rs:422` 到 `489` 会把两边 rows 转为 compare rows,再收集到 HashMap/order,并 clone added / removed / modified 的 values。
|
||||
- `crates/dbx-core/src/data_compare.rs:376` 到 `400` 会生成 `sync_statements`,再 join 成完整 `sync_sql`,SQL 文本本身也可能很大。
|
||||
- `apps/desktop/src/components/diff/DataCompareDialog.vue:413` 到 `418` 会把 diff row 映射成 selectable diff。
|
||||
- `apps/desktop/src/components/diff/DataCompareDialog.vue:500` 到 `528` 会把选中的 diff 发送给后端重建 sync plan。
|
||||
- `apps/desktop/src/components/diff/DataCompareDialog.vue:538` 到 `692` 批量表对比会逐表 push 结果,最后 `batchResults.value = results`,完整 diff 留在前端状态里。
|
||||
|
||||
建议:
|
||||
|
||||
1. 对单表对比加硬上限,例如行数超过阈值时要求用户确认,或默认只对比 key/hash 摘要。
|
||||
2. 改为按主键有序流式对比,避免源表和目标表全量同时驻留内存。
|
||||
3. diff 明细分页加载,只在概览里保留计数和少量样例。
|
||||
4. sync SQL 按需生成或流式下载,不在内存里长期保存完整 `sync_sql`。
|
||||
5. 批量对比时完成一张表就释放中间 rows,只保留摘要;用户展开表时再加载明细。
|
||||
|
||||
## 3. Redis Key Browser
|
||||
|
||||
Redis 浏览器在大 keyspace 下容易膨胀。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `apps/desktop/src/components/redis/RedisKeyBrowser.vue:61` 到 `82` 同时持有 `flatKeys`、`treeKeys`、`checkedKeys`、`commandHistory` 等状态。
|
||||
- `apps/desktop/src/components/redis/RedisKeyBrowser.vue:141` 到 `146` 会从 tree 计算 `visibleRows`。
|
||||
- `apps/desktop/src/components/redis/RedisKeyBrowser.vue:154` 到 `177` 会基于 `flatKeys` 重建或合并 tree。
|
||||
- `apps/desktop/src/components/redis/RedisKeyBrowser.vue:222` 到 `227` value search 会持续 scan,直到没有更多结果。
|
||||
- `apps/desktop/src/components/redis/RedisKeyBrowser.vue:285` 到 `299` `fetchAll` 会循环 scan,直到所有 key 加载完。
|
||||
- `apps/desktop/src/components/redis/RedisKeyBrowser.vue:387` 到 `389` 命令历史追加后没有长度上限。
|
||||
|
||||
建议:
|
||||
|
||||
1. `fetch all` 加数量上限、内存提示和二次确认;默认不应全量加载百万级 key。
|
||||
2. value search 改成最多加载 N 条,然后提示继续加载。
|
||||
3. `commandHistory` 做 ring buffer,例如最多保留 200 条。
|
||||
4. tree 结构尽量使用紧凑节点,避免同时保留 flat、tree、visible 三份完整数据。
|
||||
|
||||
## 4. Mongo 文档表格视图
|
||||
|
||||
Mongo 单页默认受 page size 控制,但表格模式会复制数据。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `apps/desktop/src/components/mongo/MongoDocBrowser.vue:35` 保存原始 `documents`。
|
||||
- `apps/desktop/src/components/mongo/MongoDocBrowser.vue:122` 到 `154` 将 documents 转成 DataGrid `QueryResult`,为所有文档推导 columns 并生成二维 rows。
|
||||
- `apps/desktop/src/components/mongo/MongoDocBrowser.vue:143` 到 `149` 对对象字段执行 `JSON.stringify`,宽文档或深嵌套对象会扩大字符串内存。
|
||||
- `apps/desktop/src/components/mongo/MongoDocBrowser.vue:332` 到 `340` 每次加载 page 后替换 documents。
|
||||
|
||||
建议:
|
||||
|
||||
1. 表格视图只展开顶层标量字段;对象字段延迟 stringify,用户展开单元格时再格式化。
|
||||
2. Mongo 表格模式使用更小的默认 page size。
|
||||
3. 对宽文档增加列数或单元格字符串长度限制。
|
||||
|
||||
## 5. 查询图表 QueryChart
|
||||
|
||||
QueryChart 的包体和运行时复制都偏重。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `apps/desktop/src/components/chart/QueryChart.vue:4` 到 `15` 引入并注册 ECharts CanvasRenderer、Line、Bar、Pie、Grid、Tooltip、Legend。
|
||||
- `apps/desktop/src/components/chart/QueryChart.vue:29` `numericColumns` 会扫描 rows 判断数值列。
|
||||
- `apps/desktop/src/components/chart/QueryChart.vue:57` 生成全量 `xData`。
|
||||
- `apps/desktop/src/components/chart/QueryChart.vue:69` 到 `72` pie chart 为每一行生成 `{ name, value }`。
|
||||
- `apps/desktop/src/components/chart/QueryChart.vue:96` 到 `100` 每个 Y 列都会再 map 一份 series data。
|
||||
|
||||
建议:
|
||||
|
||||
1. 图表默认只取前 N 行或采样,例如 5,000 行以内。
|
||||
2. 多 Y 列时提示 series 数据量,超限则要求用户确认。
|
||||
3. `numericColumns` 可以基于抽样判断,不需要扫描所有 rows。
|
||||
|
||||
## 6. Schema / Object Browser / ER Diagram
|
||||
|
||||
这类功能一般不会像大结果集那样瞬间冲高,但在多连接、多库、多 schema 长会话里会累积。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `apps/desktop/src/stores/connectionStore.ts` 中维护 `treeNodes`、`loadedTreeNodeChildrenIds`、`completionTablesCache`、`completionObjectsCache`、`completionColumnsCache`、`schemaListCache` 等长期状态。
|
||||
- 全局 completion cache 有 `COMPLETION_CACHE_MAX = 50`,但 schema tree 和已展开节点会随用户浏览而保留。
|
||||
- `apps/desktop/src/components/diagram/SchemaDiagramDialog.vue` 会持有 diagram tables、positions、relationships、visible map,超大 schema 下会增长明显。
|
||||
|
||||
建议:
|
||||
|
||||
1. 对 schema tree 增加“清理连接缓存”入口。
|
||||
2. 大 schema 的 ER 图只加载用户选择的表及一跳关系。
|
||||
3. completion cache 继续保留上限,但 per-connection/schema tree 也应考虑 LRU 或手动释放。
|
||||
|
||||
## 7. QueryEditor 补全与 AI Assistant
|
||||
|
||||
这两块不是当前首要嫌疑,但长时间使用会有累积。
|
||||
|
||||
代码证据:
|
||||
|
||||
- `apps/desktop/src/components/editor/QueryEditor.vue` 每个编辑器实例维护 `cachedTables`、`cachedCompletionObjects`、`cachedColumnsByTable`、`cachedForeignKeysByTable`。
|
||||
- CodeMirror、language packages、SQL formatter 是较大的编辑器相关依赖。
|
||||
- `apps/desktop/src/components/editor/AiAssistant.vue` 维护 messages、conversations、mention cache,并使用 Markdown / Shiki 代码高亮。
|
||||
|
||||
建议:
|
||||
|
||||
1. 编辑器 per-tab cache 增加容量或生命周期限制,tab 关闭时确认释放。
|
||||
2. AI conversations 只在当前会话保留最近 N 条渲染消息,旧消息可折叠或按需加载。
|
||||
3. 代码高亮按可见消息懒加载,避免一次性渲染长历史。
|
||||
|
||||
## 优先优化清单
|
||||
|
||||
短期优先做:
|
||||
|
||||
1. 把查询结果缓存从 tab 数上限改成内存估算上限,先降低 inactive result 的保留数量。
|
||||
2. 导出改为 streaming,避免在 `fetchTabResultForExport` 聚合全量 rows。
|
||||
3. DataGrid 去掉或延迟 `displayItems` 全量数组,搜索命中做分批扫描和上限。
|
||||
4. Data Compare 加行数阈值、确认提示和 diff 明细分页。
|
||||
5. Redis `fetch all`、value search、command history 加上限。
|
||||
|
||||
中期优化:
|
||||
|
||||
1. Data Compare 改为主键有序流式对比或 hash 摘要对比。
|
||||
2. tab result cache 改成分块序列化,减少落盘时的峰值副本。
|
||||
3. QueryChart 加采样和最大点数。
|
||||
4. Mongo table mode 对对象字段做懒格式化。
|
||||
|
||||
长期优化:
|
||||
|
||||
1. 加内存诊断面板,显示当前 tab rows、列数、派生索引数量、缓存结果数量。
|
||||
2. 在开发和生产 Tauri 下分别采集 JS heap snapshot,建立固定的大数据压测场景。
|
||||
3. 对 Rust 后端的 Data Compare、Agent cursor、JDBC session 做 heap / allocation profile。
|
||||
|
||||
## 需要进一步实测
|
||||
|
||||
本报告没有拿到用户实际数据库数据集,因此功能排序主要基于代码路径和当前进程快照。若要定位“现在”具体是哪一个功能占用最多,建议补跑以下场景:
|
||||
|
||||
1. 生产 Tauri 包下打开空应用,记录基线 RSS。
|
||||
2. 执行 10k、50k、100k 行查询,分别记录 DataGrid 首屏、搜索、切 tab、导出时的 JS heap 和 RSS。
|
||||
3. 对比 10 万、100 万行表,记录 Rust 进程峰值内存。
|
||||
4. Redis 分别加载 10k、100k、1M keys,记录 flat/tree/visibleRows 的前端 heap。
|
||||
5. Mongo 表格视图加载宽文档和深嵌套文档,比较 document mode 与 table mode。
|
||||
|
||||
如果只看当前代码风险,首要优化对象应是“查询结果/DataGrid/导出”和“数据对比”。
|
||||
@@ -0,0 +1,253 @@
|
||||
# DBX Web 运行内存功能排查报告
|
||||
|
||||
日期:2026-06-11
|
||||
|
||||
## 结论
|
||||
|
||||
已将网页版 DBX 的访问密码设置为 `test`,并通过接口验证登录成功。当前可访问地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5173/login
|
||||
```
|
||||
|
||||
本次用独立 Chrome profile 打开 DBX Web,创建 5 万行 SQLite 测试库,逐项触发主要功能并采集运行内存。当前测到最占运行内存的功能是:
|
||||
|
||||
1. 数据对比 Data Compare:优化前后端 `dbx-web` RSS 从约 213 MiB 峰值升到约 496 MiB,是原始基线中最高。
|
||||
2. 查询图表 QueryChart:在 5 万行结果上打开图表后,前端 JS heap 升到约 109 MiB,Chrome renderer 峰值约 567 MiB。
|
||||
3. 查询结果 DataGrid:5 万行、12 列查询结果进入 DataGrid 后,前端 JS heap 约 78 MiB,Chrome renderer 约 480 MiB,后端 RSS 约 194 MiB。
|
||||
4. 驱动管理 Driver Store:DOM 节点明显增加,但 JS heap 被 GC 后下降,未表现为主要内存来源。
|
||||
5. 空数据对比对话框:只打开对话框增量很小,不是内存大头;真正占用来自执行对比。
|
||||
|
||||
第一轮已优化 Data Compare 后端内存路径:同样 50,000 vs 49,649 行场景下,`dbx-web` RSS 峰值从 507,504 KiB 降到 239,696 KiB,下降约 53%;接口输出计数、SQL 数量和响应大小保持一致。
|
||||
|
||||
开发服务器 Vite 本身 RSS 很高,后续一度到约 1.1-1.2 GiB。这是 dev/HMR 工具链占用,不属于某个业务功能,但会显著抬高当前机器的总内存占用。
|
||||
|
||||
## 密码重置
|
||||
|
||||
认证实现:
|
||||
|
||||
- 前端登录接口:`/api/auth/login`
|
||||
- 初始设置接口:`/api/auth/setup`
|
||||
- 后端密码存储:`~/.dbx-web/dbx.db` 的 `app_settings.settings_json.password_hash`
|
||||
- `DBX_PASSWORD` 环境变量会覆盖数据库密码,但本次没有用环境变量覆盖,而是走 `/api/auth/setup` 持久化设置。
|
||||
|
||||
验证结果:
|
||||
|
||||
```text
|
||||
POST /api/auth/setup {"password":"test"} -> 200 OK
|
||||
POST /api/auth/login {"password":"test"} -> 200 OK
|
||||
GET /api/auth/check with cookie -> {"authenticated":true,"required":true,"setup_required":false}
|
||||
```
|
||||
|
||||
## 测试环境
|
||||
|
||||
运行进程:
|
||||
|
||||
| 组件 | 地址 / 进程 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Web 前端 | `http://127.0.0.1:5173` | 已存在的 Vite dev server |
|
||||
| Web 后端 | `target/debug/dbx-web`,端口 `4224` | 本次启动,用于 API 和认证 |
|
||||
| 测量浏览器 | Chrome 独立 profile:`/tmp/dbx-runtime-chrome-profile` | 只打开 DBX 页面,避免混入日常 Chrome tab |
|
||||
|
||||
测试数据:
|
||||
|
||||
| 对象 | 规模 |
|
||||
| --- | ---: |
|
||||
| SQLite 文件 | `/tmp/dbx-runtime-memory-probe.sqlite` |
|
||||
| DBX 连接名 | `Runtime Memory SQLite` |
|
||||
| `runtime_probe` | 50,000 行,12 列 |
|
||||
| `runtime_probe_target` | 49,649 行,12 列 |
|
||||
| DataGrid page size | 50,000 |
|
||||
| DataGrid render mode | Canvas |
|
||||
|
||||
## 测量方法
|
||||
|
||||
采集方式:
|
||||
|
||||
- Chrome DevTools Protocol `Performance.getMetrics`
|
||||
- `JSHeapUsedSize`
|
||||
- `JSHeapTotalSize`
|
||||
- DOM node count
|
||||
- macOS `ps`
|
||||
- 独立 Chrome profile 总 RSS
|
||||
- Chrome renderer RSS
|
||||
- Vite RSS
|
||||
- `dbx-web` RSS
|
||||
- 数据对比 API 额外用 50ms 间隔采样 `dbx-web` RSS 峰值。
|
||||
|
||||
注意:
|
||||
|
||||
- JS heap 是前端页面 heap,不包含浏览器共享库、GPU、网络进程等。
|
||||
- Chrome renderer RSS 更接近页面实际进程内存,但仍包含渲染器基础开销。
|
||||
- Vite 是开发工具链,不应归因到业务功能。
|
||||
- 数据对比 API 测到的是后端执行峰值;本轮没有自动填完整数据对比对话框并渲染 diff 明细。
|
||||
|
||||
## 运行内存记录
|
||||
|
||||
单位说明:
|
||||
|
||||
- JS heap:MiB
|
||||
- RSS:KiB,括号内为约 MiB
|
||||
|
||||
| 场景 | JS heap used | Chrome renderer RSS | Chrome profile 总 RSS | `dbx-web` RSS | Vite RSS | 备注 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 登录页 | 13.30 MiB | 未拆分 | 906,576 KiB(885 MiB) | 未启动/低 | 475,200 KiB(464 MiB) | 登录页 DOM 约 255 |
|
||||
| 登录后主界面 | 25.12 MiB | 未拆分 | 961,760 KiB(939 MiB) | 72,688 KiB(71 MiB) | 481,376 KiB(470 MiB) | 已加载连接列表 |
|
||||
| 打开查询编辑器 | 25.86 MiB | 未拆分 | 未记录 | 未记录 | 未记录 | CodeMirror 出现,增量很小 |
|
||||
| 设置 page size 后刷新 | 31.83 MiB | 408,608 KiB(399 MiB) | 1,003,440 KiB(980 MiB) | 54,048 KiB(53 MiB) | 1,224,272 KiB(1,196 MiB) | Dev server RSS 明显上升 |
|
||||
| 填入 SQL | 34.94 MiB | 414,144 KiB(404 MiB) | 1,016,384 KiB(993 MiB) | 54,720 KiB(53 MiB) | 1,224,272 KiB(1,196 MiB) | 编辑器文本变化 |
|
||||
| DataGrid 5 万行结果 | 77.98 MiB | 491,440 KiB(480 MiB) | 1,089,552 KiB(1,064 MiB) | 198,448 KiB(194 MiB) | 1,249,856 KiB(1,221 MiB) | 50,000 行、12 列、Canvas |
|
||||
| 图表前,GC 后 | 61.15 MiB | 443,040 KiB(433 MiB) | 1,046,128 KiB(1,022 MiB) | 198,448 KiB(194 MiB) | 1,161,296 KiB(1,134 MiB) | DataGrid 结果仍在 |
|
||||
| QueryChart 5 秒 | 109.77 MiB | 580,976 KiB(567 MiB) | 1,185,408 KiB(1,158 MiB) | 198,448 KiB(194 MiB) | 1,172,096 KiB(1,145 MiB) | ECharts + series 数据峰值 |
|
||||
| QueryChart 10 秒 | 108.74 MiB | 524,160 KiB(512 MiB) | 1,128,544 KiB(1,102 MiB) | 198,448 KiB(194 MiB) | 1,172,096 KiB(1,145 MiB) | 图表稳定后 |
|
||||
| 空数据对比对话框 | 110.72 MiB | 531,520 KiB(519 MiB) | 1,141,376 KiB(1,115 MiB) | 198,448 KiB(194 MiB) | 1,187,120 KiB(1,159 MiB) | 只打开弹窗,增量小 |
|
||||
| Data Compare API | 不适用 | 不适用 | 不适用 | 峰值 507,504 KiB(496 MiB) | 不适用 | 50k vs 49,649 行,两表对比 |
|
||||
| 驱动管理 | 67.49 MiB | 535,072 KiB(522 MiB) | 1,145,696 KiB(1,119 MiB) | 478,928 KiB(468 MiB) | 1,188,624 KiB(1,161 MiB) | DOM 到 1,157,但 JS heap 被 GC |
|
||||
|
||||
## 数据对比明细
|
||||
|
||||
### 优化前基线
|
||||
|
||||
接口:
|
||||
|
||||
```text
|
||||
POST /api/data-compare/prepare-from-tables
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```text
|
||||
source table: main.runtime_probe
|
||||
target table: main.runtime_probe_target
|
||||
key columns: id
|
||||
fetch batch size: 5000
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
| 指标 | 值 |
|
||||
| --- | ---: |
|
||||
| 耗时 | 842 ms |
|
||||
| `dbx-web` RSS before | 217,904 KiB(213 MiB) |
|
||||
| `dbx-web` RSS peak | 507,504 KiB(496 MiB) |
|
||||
| `dbx-web` RSS after | 472,416 KiB(461 MiB) |
|
||||
| 响应大小 | 10,291,638 bytes |
|
||||
| source rows | 50,000 |
|
||||
| target rows | 49,649 |
|
||||
| added | 1,351 |
|
||||
| removed | 1,000 |
|
||||
| modified | 4,865 |
|
||||
| sync statements | 7,216 |
|
||||
| sync SQL 大小 | 1,537,738 bytes |
|
||||
|
||||
判断:数据对比是本轮最高内存来源。原因是后端会同时持有两边 rows、HashMap、diff、sync statements 和完整 sync SQL;请求结束后 RSS 也没有立刻回到对比前。
|
||||
|
||||
### 第一轮优化复测
|
||||
|
||||
代码改动:
|
||||
|
||||
- `crates/dbx-core/src/data_compare.rs`
|
||||
- 对比中间态从“每一行都转成 `HashMap<String, Value>`”改为“按列索引保存 `Vec<Value>`,只在 added/removed/modified 输出时组装 HashMap”。
|
||||
- 去掉 `prepare_data_compare` 和缺目标表路径里的 `DataCompareResult` clone。
|
||||
- 单元格比较改为借用比较,只有确认为变化时才 clone 到返回结果。
|
||||
- 同步计划生成改为内部借用 diff,公共 API 和返回字段不变。
|
||||
|
||||
复测结果:
|
||||
|
||||
| 指标 | 优化前 | 优化后 | 变化 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 耗时 | 842 ms | 942 ms | +100 ms |
|
||||
| `dbx-web` RSS before | 217,904 KiB(213 MiB) | 65,168 KiB(64 MiB) | 启动基线更干净 |
|
||||
| `dbx-web` RSS peak | 507,504 KiB(496 MiB) | 239,696 KiB(234 MiB) | -267,808 KiB(约 -53%) |
|
||||
| `dbx-web` RSS after | 472,416 KiB(461 MiB) | 233,840 KiB(228 MiB) | -238,576 KiB |
|
||||
| 响应大小 | 10,291,638 bytes | 10,291,638 bytes | 不变 |
|
||||
| added / removed / modified | 1,351 / 1,000 / 4,865 | 1,351 / 1,000 / 4,865 | 不变 |
|
||||
| sync statements | 7,216 | 7,216 | 不变 |
|
||||
| sync SQL 大小 | 1,537,738 bytes | 1,537,738 bytes | 不变 |
|
||||
|
||||
结论:这次优化不改变接口协议和功能结果,主要降低后端比较过程的峰值和结束后的 RSS 留存。剩余内存主要来自必须返回给前端的 diff 详情、`syncStatements` 数组、完整 `syncSql` 字符串以及约 10 MB JSON 响应。
|
||||
|
||||
## 功能排名
|
||||
|
||||
### 1. 数据对比 Data Compare
|
||||
|
||||
原始基线最高。50k 级别两表对比让后端 RSS 峰值接近 496 MiB,且结束后仍约 461 MiB。第一轮优化后,同场景峰值降到约 234 MiB,结束后约 228 MiB。
|
||||
|
||||
主要来源:
|
||||
|
||||
- 源表 rows + 目标表 rows
|
||||
- diff 明细对象
|
||||
- sync statements
|
||||
- joined sync SQL
|
||||
- 约 10 MB JSON 响应
|
||||
|
||||
建议:
|
||||
|
||||
- 进一步对比可改为主键有序 merge,不一次性保留两边全量 rows。
|
||||
- diff 明细分页,默认只返回计数和样例。
|
||||
- sync SQL 按需生成或流式下载。
|
||||
- 大表对比增加行数阈值和用户确认。
|
||||
|
||||
### 2. QueryChart
|
||||
|
||||
本轮最高的前端内存增量。基于同一份 5 万行查询结果,打开图表后 JS heap 稳定在约 109 MiB,较图表前增加约 48 MiB;renderer RSS 峰值增加约 138 MiB。
|
||||
|
||||
主要来源:
|
||||
|
||||
- ECharts / vue-echarts 运行时
|
||||
- `xData = rows.map(...)`
|
||||
- 每个 Y 列再生成一份 series data
|
||||
- Canvas 图表内部缓存
|
||||
|
||||
建议:
|
||||
|
||||
- 图表默认采样或限制最大点数,例如 5,000。
|
||||
- 多 Y 轴列时显示数据点数量并要求确认。
|
||||
- 数值列识别用抽样,不扫描全部 rows。
|
||||
|
||||
### 3. 查询结果 DataGrid
|
||||
|
||||
5 万行、12 列进入 DataGrid 后,JS heap 约 78 MiB,较填 SQL 后增加约 43 MiB;后端 RSS 也从约 53 MiB 增至约 194 MiB。
|
||||
|
||||
主要来源:
|
||||
|
||||
- 查询结果 rows
|
||||
- DataGrid 显示索引和派生结构
|
||||
- Canvas 渲染状态
|
||||
- 后端查询结果构造和 JSON 序列化
|
||||
|
||||
建议:
|
||||
|
||||
- 默认 page size 不要轻易升到 50,000。
|
||||
- DataGrid 派生数组按可视范围懒生成。
|
||||
- 导出和大结果操作走 streaming。
|
||||
- 对大结果显示明确内存提示。
|
||||
|
||||
### 4. 驱动管理
|
||||
|
||||
驱动管理页面 DOM 节点从 426 增到 1,157,但 JS heap 因 GC 从约 112 MiB 降到约 67 MiB,说明它不是当前主要内存来源。它主要增加 DOM 和列表渲染,不像 DataGrid/Chart/DataCompare 那样复制大数据。
|
||||
|
||||
### 5. 空数据对比对话框
|
||||
|
||||
只打开对话框时 JS heap 从约 109 MiB 到约 111 MiB,增量很小。风险不在弹窗本身,而在点击“开始比较数据”后的后端对比和 diff 明细。
|
||||
|
||||
## 未覆盖功能
|
||||
|
||||
本轮没有实测以下功能,因为当前环境没有对应服务或没有构造完整数据流:
|
||||
|
||||
- Redis Key Browser
|
||||
- Mongo 文档浏览
|
||||
- 数据传输
|
||||
- SQL 文件执行
|
||||
- 导出下载全流程
|
||||
- ER Diagram / Schema Diff 大 schema 场景
|
||||
- AI Assistant 长对话
|
||||
|
||||
这些功能仍可能在真实环境中占用较高内存,尤其是 Redis 全量 key、Mongo 宽文档表格视图、导出聚合和大 schema diff。代码风险分析见同目录的 `memory-usage-audit.md`。
|
||||
|
||||
## 当前最值得优化的点
|
||||
|
||||
1. 继续优化 Data Compare 返回体:第一轮已降峰值,下一步是 diff 分页、SQL 按需生成,减少必须返回给前端的数据量。
|
||||
2. 限制 QueryChart 数据点:图表对大结果集的前端复制非常明显。
|
||||
3. 限制 DataGrid 大 page size:50,000 行能明显抬高前端和后端内存。
|
||||
4. 把 Vite/dev 内存与业务内存分开看:当前开发模式下 Vite RSS 本身可超过 1 GiB,生产 Tauri 或生产 Web 包需要另测。
|
||||
Reference in New Issue
Block a user