From 0b6a076d7832b6f4b225b9382f44c47f9187e490 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 30 Jun 2026 13:57:37 +0800 Subject: [PATCH] feat(database): use real COUNT(*) for small tables, fall back to estimate for large MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/api/database/status.rs | 58 +++++++++++++++++++++++++++----------- src/pages/admin/system.rs | 10 ++++--- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/api/database/status.rs b/src/api/database/status.rs index c7dd521..85d471d 100644 --- a/src/api/database/status.rs +++ b/src/api/database/status.rs @@ -43,8 +43,10 @@ pub struct DbStatus { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TableInfo { pub name: String, - /// 行数估算(`pg_class.reltuples`,非 COUNT(*),UI 标注"~估算")。 - pub row_estimate: i64, + /// 行数:total_size 小于阈值时为真实 COUNT(*),否则回退 reltuples 估算(见 row_count_estimated)。 + pub row_count: i64, + /// true 表示 row_count 是 reltuples 估算值(大表回退,未 ANALYZE 时 reltuples=-1 会显示为估算)。 + pub row_count_estimated: bool, pub table_size_bytes: i64, pub index_size_bytes: i64, pub total_size_bytes: i64, @@ -118,7 +120,9 @@ pub async fn get_db_status() -> Result { None => (None, None), }; - // 表清单:行数用 reltuples 估算,避免大表 COUNT(*) 拖垮库 + // 表清单:大小/统计走 catalog;行数对小表用真实 COUNT(*),大表回退 reltuples 估算。 + // 阈值 100MB:超过则 COUNT(*) 成本过高(全表扫描),降级为估算值并标注。 + const COUNT_SIZE_THRESHOLD: i64 = 100 * 1024 * 1024; let table_rows = client .query( "SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \ @@ -134,20 +138,40 @@ pub async fn get_db_status() -> Result { ) .await .map_err(AppError::query)?; - let tables = table_rows - .into_iter() - .map(|r| TableInfo { - name: r.get(0), - row_estimate: r.get(1), - table_size_bytes: r.get(2), - index_size_bytes: r.get(3), - total_size_bytes: r.get(4), - last_vacuum: r.get(5), - last_analyze: r.get(6), - dead_tuples: r.get(7), - live_tuples: r.get(8), - }) - .collect(); + let tables = { + let mut out = Vec::with_capacity(table_rows.len()); + for r in table_rows { + let name: String = r.get(0); + let reltuples: i64 = r.get(1); + let total_size: i64 = r.get(4); + // 小表:真实 COUNT(*);大表:回退 reltuples 估算。 + // relname 来自 pg_class 可信,但表名可能含大写/特殊字符,需 PG 标识符转义(双引号包裹,内部双引号双写)。 + let (row_count, estimated) = if total_size < COUNT_SIZE_THRESHOLD { + let ident = format!("\"{}\"", name.replace('"', "\"\"")); + let cnt: i64 = client + .query_one(&format!("SELECT count(*)::bigint FROM {}", ident), &[]) + .await + .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 let index_rows = client diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index 295edb3..fa18b98 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -273,17 +273,17 @@ fn DbStatusTab() -> Element { } } - // 表清单 + // 表清单(小表显示真实行数;大表回退估算,行数前标 ~) 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: "overflow-x-auto", table { class: "w-full text-sm", thead { 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 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() { 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 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.index_size_bytes)}" } td { class: "px-4 py-2 text-right text-paper-primary font-medium", "{format_bytes(t.total_size_bytes)}" }