feat(database): use real COUNT(*) for small tables, fall back to estimate for large

row_estimate came from pg_class.reltuples, which is -1 for tables that
have never been ANALYZEd (the common case on a fresh/small DB), so the
admin table list showed -1 for every table — meaningless.

Replace row_estimate with row_count + row_count_estimated:
- total_size < 100MB: SELECT count(*) (real value, cost negligible on
  small tables; identifier quoted via PG escaping to stay injection-safe)
- total_size >= 100MB: fall back to reltuples estimate, flagged so the UI
  can prefix with ~

Frontend now shows real counts (1/2/14...) for small tables and ~N for
large ones; header copy updated to reflect the mixed source.
This commit is contained in:
xfy 2026-06-30 13:57:37 +08:00
parent 36168f26d7
commit 0b6a076d78
2 changed files with 47 additions and 21 deletions

View File

@ -43,8 +43,10 @@ pub struct DbStatus {
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TableInfo { pub struct TableInfo {
pub name: String, pub name: String,
/// 行数估算(`pg_class.reltuples`,非 COUNT(*)UI 标注"~估算")。 /// 行数total_size 小于阈值时为真实 COUNT(*),否则回退 reltuples 估算(见 row_count_estimated
pub row_estimate: i64, pub row_count: i64,
/// true 表示 row_count 是 reltuples 估算值(大表回退,未 ANALYZE 时 reltuples=-1 会显示为估算)。
pub row_count_estimated: bool,
pub table_size_bytes: i64, pub table_size_bytes: i64,
pub index_size_bytes: i64, pub index_size_bytes: i64,
pub total_size_bytes: i64, pub total_size_bytes: i64,
@ -118,7 +120,9 @@ pub async fn get_db_status() -> Result<DbStatus, ServerFnError> {
None => (None, None), None => (None, None),
}; };
// 表清单:行数用 reltuples 估算,避免大表 COUNT(*) 拖垮库 // 表清单:大小/统计走 catalog行数对小表用真实 COUNT(*),大表回退 reltuples 估算。
// 阈值 100MB超过则 COUNT(*) 成本过高(全表扫描),降级为估算值并标注。
const COUNT_SIZE_THRESHOLD: i64 = 100 * 1024 * 1024;
let table_rows = client let table_rows = client
.query( .query(
"SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \ "SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \
@ -134,20 +138,40 @@ pub async fn get_db_status() -> Result<DbStatus, ServerFnError> {
) )
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;
let tables = table_rows let tables = {
.into_iter() let mut out = Vec::with_capacity(table_rows.len());
.map(|r| TableInfo { for r in table_rows {
name: r.get(0), let name: String = r.get(0);
row_estimate: r.get(1), let reltuples: i64 = r.get(1);
table_size_bytes: r.get(2), let total_size: i64 = r.get(4);
index_size_bytes: r.get(3), // 小表:真实 COUNT(*);大表:回退 reltuples 估算。
total_size_bytes: r.get(4), // relname 来自 pg_class 可信,但表名可能含大写/特殊字符,需 PG 标识符转义(双引号包裹,内部双引号双写)。
last_vacuum: r.get(5), let (row_count, estimated) = if total_size < COUNT_SIZE_THRESHOLD {
last_analyze: r.get(6), let ident = format!("\"{}\"", name.replace('"', "\"\""));
dead_tuples: r.get(7), let cnt: i64 = client
live_tuples: r.get(8), .query_one(&format!("SELECT count(*)::bigint FROM {}", ident), &[])
}) .await
.collect(); .map_err(AppError::query)?
.get(0);
(cnt, false)
} else {
(reltuples, true)
};
out.push(TableInfo {
name,
row_count,
row_count_estimated: estimated,
table_size_bytes: r.get(2),
index_size_bytes: r.get(3),
total_size_bytes: total_size,
last_vacuum: r.get(5),
last_analyze: r.get(6),
dead_tuples: r.get(7),
live_tuples: r.get(8),
});
}
out
};
// 索引占用 Top 10 // 索引占用 Top 10
let index_rows = client let index_rows = client

View File

@ -273,17 +273,17 @@ fn DbStatusTab() -> Element {
} }
} }
// 表清单 // 表清单(小表显示真实行数;大表回退估算,行数前标 ~
div { class: "{ADMIN_TABLE_CLASS}", div { class: "{ADMIN_TABLE_CLASS}",
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
"表清单(~行数为估算)" "表清单(行数:小表为真实值,大表标 ~ 为估算)"
} }
div { class: "overflow-x-auto", div { class: "overflow-x-auto",
table { class: "w-full text-sm", table { class: "w-full text-sm",
thead { thead {
tr { class: "border-b border-paper-border text-left text-paper-secondary", tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-2 font-medium", "表名" } th { class: "px-4 py-2 font-medium", "表名" }
th { class: "px-4 py-2 font-medium text-right", "~行数" } th { class: "px-4 py-2 font-medium text-right", "行数" }
th { class: "px-4 py-2 font-medium text-right", "表大小" } th { class: "px-4 py-2 font-medium text-right", "表大小" }
th { class: "px-4 py-2 font-medium text-right", "索引大小" } th { class: "px-4 py-2 font-medium text-right", "索引大小" }
th { class: "px-4 py-2 font-medium text-right", "总大小" } th { class: "px-4 py-2 font-medium text-right", "总大小" }
@ -294,7 +294,9 @@ fn DbStatusTab() -> Element {
for t in s.tables.iter() { for t in s.tables.iter() {
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
td { class: "px-4 py-2 font-mono text-paper-primary", "{t.name}" } td { class: "px-4 py-2 font-mono text-paper-primary", "{t.name}" }
td { class: "px-4 py-2 text-right text-paper-secondary", "{t.row_estimate}" } td { class: "px-4 py-2 text-right text-paper-secondary",
if t.row_count_estimated { "~{t.row_count}" } else { "{t.row_count}" }
}
td { class: "px-4 py-2 text-right text-paper-secondary", "{format_bytes(t.table_size_bytes)}" } td { class: "px-4 py-2 text-right text-paper-secondary", "{format_bytes(t.table_size_bytes)}" }
td { class: "px-4 py-2 text-right text-paper-secondary", "{format_bytes(t.index_size_bytes)}" } td { class: "px-4 py-2 text-right text-paper-secondary", "{format_bytes(t.index_size_bytes)}" }
td { class: "px-4 py-2 text-right text-paper-primary font-medium", "{format_bytes(t.total_size_bytes)}" } td { class: "px-4 py-2 text-right text-paper-primary font-medium", "{format_bytes(t.total_size_bytes)}" }