用 Rust + GPUI 重做数据库客户端

0 阅读17分钟

用 Rust + GPUI 重做数据库客户端:我如何把查询、大数据表格和 SQL 补全做成“跟手”的

image.png

数据库客户端看起来只是“连上库、写 SQL、看表格”,真正用起来却处处是性能问题:

  • 一条 SELECT 不小心带回十几万行,界面先卡住了;
  • 单元格里塞着一段几 MB 的 JSON,滚动时每一帧都在复制字符串;
  • 补全列表弹出一堆 DDL 关键字,却不知道光标这里应该填表名、列名还是 JOIN 条件;
  • 改了十几格数据,还没点提交,刷新一下全没了。

我在做 FluxDB 时,给这些问题定了一个统一原则:

该有界的地方有界,该后台的地方后台,该取消的地方取消。

FluxDB 是一个基于 Rust + GPUI 的原生跨平台数据库客户端,不依赖 Electron 或 WebView。它支持 MySQL、PostgreSQL、SQL Server、SQLite、TiDB、达梦 DM8 和 Redis。本文不堆功能清单,而是从代码出发,拆解三个最影响体感的部分:

  1. 查询结果和表格怎样避免被大数据拖垮;
  2. SQL 补全怎样从“字符串联想”变成“语法槽位推理”;
  3. 数据编辑怎样做到先暂存、再安全提交。

完整操作视频:

【我做了个原生数据库客户端:SQL、Redis、ER 图一个窗口搞定|FluxDB 快速上手】 www.bilibili.com/video/BV1bR…


一、先把“原生”这件事说清楚

“Rust + GPUI”不是为了喊口号,而是为了少一层运行时。

FluxDB 的桌面层只负责渲染和交互,业务编排在 fluxdb-app,领域模型和 Connector 接口在 fluxdb-core,具体数据库实现在 fluxdb-connectors,持久化在 fluxdb-storage。UI 通过 AppCommand 发起操作,用 AppState / AppEvent 接收变化,不直接在界面里拼 SQL、读写配置或保存密码。

这个分层带来的直接好处,是性能问题可以被拆开定位:

  • 查询慢,查 Connector 和网络,不在 UI 里打转;
  • 渲染卡,查 TableDelegate、虚拟列表和单帧预算;
  • 补全慢,查元数据索引和取消机制;
  • 数据不安全,查 ChangeSet、事务和回执,不靠界面“自觉”。

下面从最容易被大数据击穿的表格开始。


二、大数据表格:只取一页,只画看得见的行

1. 查询结果从源头就有边界

数据库客户端最忌讳“先全查出来,再考虑怎么显示”。FluxDB 的查询编辑器有“默认查询行数”设置,默认 100,也允许用户显式选择“不限制”。真正执行时,SQL 会按当前页大小补上 LIMIT;SQL Server 则使用 TOP 或 OFFSET ... FETCH。

核心逻辑在 crates/fluxdb-app/src/parts/sql_format.rs:

fn limit_select_statement(statement: &str, page_size: u64) -> String {
    if !starts_with_sql_keyword(statement, "select") || page_size == 0 {
        return statement.to_string();
    }
    if let Some(limit_start) = find_top_level_sql_keyword(statement, "limit") {
        return cap_existing_limit(statement, limit_start, page_size)
            .unwrap_or_else(|| statement.to_string());
    }
    format!("{statement} LIMIT {page_size}")
}

这里有两个细节值得注意:

  • page_size == 0 表示“用户明确选择不限制”,不能被 clamp(1, MAX) 偷偷改成 1;
  • 用户已经写了 LIMIT 时,只在超过封顶值时改写,不会把合法的小 LIMIT 变大。

数据表浏览又是另一条路径。以 PostgreSQL 为例,查询会主动多取一行来判断是否还有下一页:

// limit+1 探测是否还有下一页。
sql.push_str(&format!(" LIMIT {}", pagination.limit + 1));
if pagination.offset > 0 {
    sql.push_str(&format!(" OFFSET {}", pagination.offset));
}

多取的这一行不展示,只用来设置 has_more。这样分页按钮的状态不依赖 COUNT(*),避免为了一个“下一页”按钮再扫一遍全表。

分页查询还带主键 tie-breaker。如果用户按一个非唯一列排序,同一批数据在两次查询之间可能换位置,翻页就会重复或漏行。FluxDB 会在排序后补上主键列,保持结果稳定。

2. 结果集内存也有预算

只限制行数还不够。一行里可能有超大 JSON、BLOB 或长文本。在 PostgreSQL 的查询执行路径中,除了行数上限,还有 16 MiB 的结果内存预算:

const RESULT_BYTES: usize = 16 * 1024 * 1024;

if stored_bytes.saturating_add(row_bytes) <= RESULT_BYTES {
    page.rows.push(Row { values });
    stored_bytes += row_bytes;
} else {
    page.has_more = true;
}

宁可明确告诉用户“结果被截断”,也不让一个异常大的结果集把整个进程的内存打满。截断不是静默丢数据,has_more 会在摘要和界面上体现出来。

3. UI 只渲染可见行

数据到了内存之后,真正的卡顿常常来自渲染:如果 1000 行 × 50 列全部生成 GPUI Element,滚动会变成一场灾难。

FluxDB 的数据表格复用了 gpui-component 的 DataTable,底层表格体使用 uniform_list,只渲染当前视口附近的行;横向列也做了同样的虚拟化。业务侧只需要提供“行数”和“单元格内容”:

impl TableDelegate for DataPageTableDelegate {
    fn rows_count(&self, _: &App) -> usize {
        self.page.rows.len()
    }

    fn render_td(
        &mut self,
        row_ix: usize,
        col_ix: usize,
        _: &mut Window,
        cx: &mut Context<TableState<Self>>,
    ) -> impl IntoElement {
        // 只构造当前可见单元格;完整值在编辑、复制、详情面板中再取。
    }
}

这意味着“百万行表”的下一页仍然只把一页数据交给 UI,而不是把百万行都变成 Element。表格可以滚动,是因为总高度和可见窗口由虚拟列表管理,不是因为它真的画了百万行。

4. 单元格显示有界,完整值不丢

数据库里最不可控的是单元格长度。一个 TEXT 或 JSON 字段可能比整页数据还大。

FluxDB 在渲染时只取前 300 个字符,并按字符边界截断,避免把多字节字符切成乱码:

const DATA_CELL_DISPLAY_LIMIT: usize = 300;

fn clip_cell_text(text: &str) -> String {
    let mut shown = String::new();
    for (count, ch) in text.chars().enumerate() {
        if count == DATA_CELL_DISPLAY_LIMIT {
            shown.push('…');
            return shown;
        }
        shown.push(if matches!(ch, '\r' | '\n') { ' ' } else { ch });
    }
    shown
}

关键不是“截断”本身,而是截断只发生在展示层:

  • 打开单元格详情,取完整值;
  • 复制、导出、编辑,取完整值;
  • 普通滚动时,只处理有限字符。

这就是为什么长 JSON 列不会让整张表每一帧都复制一遍完整字符串。


三、数据不只是“看见”:筛选、详情、编辑都要有闭环

1. 筛选和排序尽量下推数据库

数据表支持字段筛选、排序、分页。能下推的条件会转成参数化 SQL,而不是先全量加载到本地再过滤。

以 PostgreSQL 为例,过滤条件会被编译成带 $n 的 WHERE 子句,值通过参数绑定传入。列不存在、缺少 BETWEEN 端点、空 IN 列表这类非法操作会明确报错,不会静默忽略一个条件然后返回“看起来正常”的数据。

这类“错误要显式”的设计很重要。数据库客户端最危险的行为不是报错,而是悄悄少了一个筛选条件。

2. 编辑先进入本地 ChangeSet

用户在表格里改单元格时,FluxDB 不会每改一格就发一条 UPDATE。

DataChangeSet 只保存三类操作:

pub struct DataChangeSet {
    pub object: ObjectPath,
    pub inserts: Vec<Row>,
    pub updates: Vec<RowUpdate>,
    pub deletes: Vec<RowIdentity>,
    pub insert_intents: Option<Vec<Vec<WriteValue>>>,
}

它对应:

  • 新增了哪些行;
  • 哪些行的哪些列被改;
  • 哪些行被删除;
  • 插入时每一列是“写默认值”“写 NULL”还是“写具体值”。

这个三态很重要。很多客户端会把 NULL 当成“省略”,导致 PostgreSQL 的 DEFAULT 和显式 NULL 被混为一谈。FluxDB 用 WriteValue 把意图保留到提交阶段。

未提交时,页面顶部会显示 dirty 标记和待提交数量;切换分页、筛选、刷新或关闭标签时,会先确认,避免本地改动被直接丢掉。

3. 提交是一个事务,不是一个一个碰运气

以 PostgreSQL 的数据编辑提交为例,执行顺序固定为:删除 → 更新 → 插入,整个过程在同一个事务里:

client.batch_execute("BEGIN").await?;

let result = async {
    pg_apply_deletes(client, &table, changes, &columns).await?;
    pg_apply_updates(client, &table, changes, &columns, &generated).await?;
    pg_apply_inserts(client, &table, changes, &columns, &generated).await
}.await;

match result {
    Ok(outcome) => client.batch_execute("COMMIT").await,
    Err(error) => {
        let _ = client.batch_execute("ROLLBACK").await;
        Err(error)
    }
}

更新和删除不是“执行了就算成功”,而是检查影响行数必须恰好为 1:

if affected != 1 {
    return Err(update_conflict_error(affected));
}

0 行表示目标行已经不存在,或已被并发修改;大于 1 行表示行身份不唯一。两种情况都会回滚整批提交,而不是留下半套修改。

行身份也不只看主键。提交前会把原始页里的完整列值补进行身份,定位到具体的那一行;服务端执行时再校验影响行数。这样即使有人在你编辑期间改了同一行,也不会盲目覆盖。


四、SQL 补全:不是猜前缀,而是判断“这里能写什么”

很多 SQL 补全的实现方式,是把光标前的字符串拿去和关键字表做 contains 或前缀匹配。这样的问题很明显:

  • SELECT * FROM 后面应该补表,不该补 WHERE;
  • SELECT u. 后面应该补列,不该补整个库的表;
  • JOIN orders o ON u.id = o. 后面应该优先补外键对应的列;
  • -- 注释里的 JOIN 不应该弹补全;
  • SELECT * FROM 用户 里的中文偏移不能算错一个字节。

FluxDB 的补全引擎在独立 crate fluxdb-completion 中,设计目标写得很直接:

补全候选由“光标所在的语法槽位”决定,而不是由光标前的字符串决定。

完整管线是:

词法上下文 → 容错解析 → 作用域符号表 → 槽位定位 → 槽位策略 → 过滤排序

主入口在 crates/fluxdb-completion/src/engine.rs:

let word = word_context(statement, cursor_in_statement, parser_dialect);

let index = LineColIndex::new(statement);
let mask = Some((word.replace_start, word.replace_end));
let parsed = parse_tolerant(statement, mask, cursor_in_statement, parser_dialect);

let symbols = match &parsed {
    Some(parsed) => collect(&parsed.statements, &index).visible_at(cursor_in_statement),
    None => Symbols::default(),
};

let slot = match &parsed {
    Some(parsed) => locate_slot(
        parsed,
        &index,
        cursor_in_statement,
        &word,
        prefix_is_blank,
        prefix,
    ),
    None => lexical_slot(&word, prefix_is_blank, prefix),
};

let mut items = slot_candidates(&slot, &ctx);
retain_matching(&mut items, &word);
items.sort_by_key(|left| sort_key(left, &filter));

这当中最值得讲的是“容错解析”和“作用域符号表”。

1. 残句也能解析:把光标处的半截词掩码掉

用户输入到一半时,SQL 往往不是合法语句:

SELECT u.na FROM users u

光标停在 na 后面时,整句不是一个完整 SQL。如果直接解析失败,就只能退回字符串猜测。

FluxDB 的做法是把光标处的残词替换成等长的占位标识符,再解析:

fn mask_word(statement: &str, mask: Option<(usize, usize)>) -> Option<String> {
    let (start, end) = mask?;
    let mut masked = String::with_capacity(statement.len());
    masked.push_str(&statement[..start]);
    masked.push_str(&"x".repeat(end - start));
    masked.push_str(&statement[end..]);
    Some(masked)
}

因为掩码前后长度一致,AST 里的偏移可以直接映射回原文。相比在光标处插入或删除字符,不需要额外做偏移修正,也不会丢掉后面的 FROM users u。

如果掩码失败,还会继续尝试:

  1. 原文直接解析;
  2. 在光标处插入占位标识符;
  3. 从尾部按 token 边界截断,取最长可解析前缀;
  4. 全部失败才降级到词法通道。

这就是为什么补全需要处理“用户正在输入”的状态,而不是只处理已经写好的 SQL。

2. 作用域符号表:内层别名会遮蔽外层

复杂 SQL 里,别名不是全局唯一的。比如:

SELECT *
FROM users u
WHERE EXISTS (
  SELECT 1
  FROM orders u
  WHERE u.id > 0
);

内层的 u 指向 orders,外层的 u 指向 users。如果只把整条语句的关系平铺成一张表,补全就可能把 orders 的列错误地补到外层,或者反过来。

FluxDB 的 Symbols 按 Query 建立词法作用域链,visible_at(offset) 根据光标位置投影出当前可见的关系:

  • 内层同名别名遮蔽外层;
  • 兄弟子查询互不可见;
  • 相关子查询仍能看到外层关系;
  • CTE 和派生表进入独立的关系集合,不当作物理表。

这样补全才知道“当前这个 u 到底是谁”。

3. JOIN 补全:让外键帮你写 ON 条件

JOIN ... ON 是 SQL 补全里最能体现“理解结构”的地方。

如果 orders.user_id 外键指向 users.id,那么:

SELECT * FROM users u JOIN orders o ON u.|

应该优先补 id,而不是 name、email 这些列。

FluxDB 的槽位策略会从元数据里取外键对,限定 ON 左侧的候选:

fn join_on_left_candidates(
    joined: &TableKey,
    left_tables: &[TableKey],
    ctx: &SlotContext<'_>,
) -> Vec<Candidate> {
    // 有外键时,只给参与关联的列;没有外键才回退两侧全列。
}

如果继续写到等号右侧:

SELECT * FROM users u JOIN orders o ON u.id = o.|

就只补与 id 配对的 user_id。反过来写 ON o.user_id = u. 也能识别反向关系。

测试里直接验证了这个行为:

#[test]
fn join_on_right_limits_to_paired_column() {
    let items = labels("SELECT * FROM users u JOIN orders o ON u.id = o.|");
    assert_eq!(items, vec!["user_id".to_string()]);
}

这比“列出所有列让用户自己找”快得多,也更不容易写错关联条件。

4. 同名列自动带限定符

多表查询里经常有两张表都叫 id。如果候选去重时只按 label,最后只会剩下一条 id,另一张表的列就永远选不到。

FluxDB 会在发现同名列来自多个关系时,自动生成带限定符的候选:

u.id
o.id

不歧义的列仍然保持 name 这种简洁形式。补全不是越短越好,而是在歧义处给出可执行的结果。

5. 中文和 UTF-8:偏移不能按字节猜

SQL 是 UTF-8 文本,但 sqlparser 的行列位置按字符计,Rust 字符串切片按字节计。中文、Emoji 或带引号的标识符都会让“字节 + 1”的偏移计算出错。

FluxDB 用 LineColIndex 预先记录每一行每个字符的字节起点,把 parser 的 Location 映射成字节偏移;替换区间还专门处理了行尾和残词后缀。

这类问题很难靠人工点几下发现,但会在中文 SQL 里稳定出现。补全能不能“准”,往往就取决于这些看起来不起眼的偏移处理。


五、补全要快:索引、按需加载和 latest-wins

补全算法正确只是第一步。每次按键都去数据库查一遍 information_schema,同样会卡。

FluxDB 的补全索引只保存元数据,不保存数据行。索引包含表、列、例程、触发器和外键信息,并按连接、数据库、schema 分区。

1. 30 分钟 TTL + 持久化快照

const COMPLETION_INDEX_TTL_SECONDS: u64 = 30 * 60;

索引会落盘成快照,下次启动先读快照,不必立刻重新扫描整个库。DDL、刷新对象或表结构变更时,会按影响范围标 dirty:

  • 整库变化,刷新整库;
  • 单表变化,只刷新对应表;
  • 例程和触发器没有稳定的表级失效粒度,就整体失效、下次按需重取。

索引过期时采用 stale-while-refresh:当前补全继续用旧候选,后台线程刷新新元数据。用户不会因为索引刚好过期而盯着一个转圈。

2. 大库不预热所有列

如果库里超过 500 张表,启动时把每一张表的列都拉一遍,代价可能比用户一天用到的还高:

const COMPLETION_METADATA_LIMIT: u64 = 500;

// 大库仅预热表名;列由实际引用表的补全与详情按需加载。
if tables.len() >= COMPLETION_METADATA_LIMIT as usize {
    self.save_persisted_completion_index(config, connection_id, database, schema);
    return Ok(());
}

小库可以预热列,大库只预热表名。等用户真的在 SQL 里引用某张表,再取该表的列。

3. 只分析光标附近的窗口

编辑器里可能有一份几千行的 SQL 文件,但补全不需要每次都分析全文:

const COMPLETION_WINDOW_BYTES: usize = 64 * 1024;

fn completion_window(snapshot: &BufferSnapshot, cursor: usize) -> (String, usize) {
    let cursor = cursor.min(snapshot.len());
    let start = cursor.saturating_sub(COMPLETION_WINDOW_BYTES);
    let end = cursor
        .saturating_add(COMPLETION_WINDOW_BYTES)
        .min(snapshot.len());
    (snapshot.text_in_range(Range::new(start, end)), start)
}

光标前后各 64 KiB 已经足够覆盖当前语句,又不会因为文件很大而做无意义的全文复制。

候选数量也有上限:

const MAX_CANDIDATES: usize = 200;

弹层只消费靠前的一批,多余的候选既拖慢跨层序列化,也没有人会翻到。

4. 75 ms 防抖 + latest-wins

自动补全不是每个按键都立刻请求数据库:

const PROVIDER_DEBOUNCE_MS: u64 = 75;

连续输入时只让最后一次请求进入 provider。请求进入后台后,还必须在三个条件都满足时才能写回:

  1. 请求 ID 仍是最新;
  2. buffer 版本没有变化;
  3. 编辑 session 没有过期。

任何一项不满足,迟到结果都会被丢弃。GPUI 的 Task 不能真正杀死正在运行的 future,所以“取消”不能只靠 drop,必须靠结果采纳阶段的守卫。

这也是补全不会出现“你打了 SELECT,两秒后弹出一个旧表名”的原因。


六、性能预算:16 ms 和 100 ms

FluxDB 的编辑器性能采样用了两个集中定义的阈值:

pub const FRAME_BUDGET_US: u64 = 16_000;
pub const BACKGROUND_BUDGET_US: u64 = 100_000;

pub fn threshold_us(kind: TaskKind) -> u64 {
    match kind {
        TaskKind::Syntax
        | TaskKind::Diagnostics
        | TaskKind::Hover
        | TaskKind::Completion => BACKGROUND_BUDGET_US,
        TaskKind::Inlay
        | TaskKind::Codelens
        | TaskKind::Wrap
        | TaskKind::Signature => FRAME_BUDGET_US,
    }
}

这两个数字是性能诊断阈值,不是强制杀死任务的超时。超过阈值会记录日志和任务阶段,方便定位是哪一层拖慢了:

  • 语法高亮、诊断、补全属于后台任务,目标是 100 ms 内完成;
  • Inlay、CodeLens、自动换行、签名提示更贴近每帧交互,目标是 16 ms;
  • 超时日志会带上 elapsed_us、buffer_version、request_id、任务阶段等字段。

仅靠“感觉有点卡”无法优化。把预算写进代码,才能知道是解析慢了、数据库慢了,还是渲染慢了。

编辑器还会对大文档降级:超过 512 KiB 时,语法高亮退回词法分析,避免 tree-sitter 一次全文解析阻塞交互;诊断也会使用更大的 debounce。性能不是所有场景都用同一套最重策略,而是随文档规模调整。


七、把“快”和“稳”放在一起

回头看,FluxDB 的性能设计不是某一个神奇算法,而是一组相互配合的边界:

  • 查询有界:默认行数、LIMIT n+1、结果内存预算、截断可见;
  • 渲染有界:虚拟列表只画可见行,单元格只处理有限字符;
  • 补全有界:64 KiB 窗口、200 个候选、500 张表以上不预热全部列;
  • 并发有界:75 ms 防抖、latest-wins、三守卫采纳;
  • 写入有界:ChangeSet 暂存、事务提交、影响行数校验、失败回滚。

这也是原生客户端真正有价值的地方:不是“用 Rust 写了一个界面”,而是可以把查询、渲染、元数据、事务这些链路拆成可观测、可取消、可验证的模块。

如果你也在做数据库工具,建议不要一开始就追求“支持多少种数据库”。先把这三件事做对:

  1. 大数据结果如何进入 UI;
  2. 补全如何知道光标所在的语法槽位;
  3. 编辑如何安全地提交和回滚。

这三件事做稳了,功能才会越加越顺,而不是越加越卡。


体验与源码

  • 项目地址:fluxdb-alt/fluxDB
  • 最新版本:Releases
  • 完整演示:docs/fluxdb-demo.mp4
  • 补全引擎:crates/fluxdb-completion
  • 数据编辑:crates/fluxdb-app/src/parts/data_editor.rs
  • PostgreSQL 数据页与写入:crates/fluxdb-connectors/src/parts/postgres
  • 桌面表格与编辑器:apps/fluxdb-desktop/src/main_parts

如果这篇文章对你有帮助,欢迎点个赞、收藏,或者到 GitHub 提 Issue 和 Star。你也可以告诉我最想看的下一部分:SQL 补全的槽位策略细节、GPUI 虚拟表格实践,或者 数据库客户端的事务编辑设计。