跪拜 Guibai
← Back to the summary

FluxDB Rebuilds the Database Client in Rust + GPUI: Bounded Queries, Virtualized Tables, and Slot-Aware SQL Completion

Rebuilding a Database Client with Rust + GPUI: How I Made Queries, Large Data Tables, and SQL Completion Feel Responsive

image.png

A database client looks like it just "connects to the database, writes SQL, and views tables," but real usage hits performance problems everywhere:

While building FluxDB, I set a unified principle for these problems:

Bound what should be bounded, background what should be backgrounded, and cancel what should be canceled.

FluxDB is a native cross-platform database client based on Rust + GPUI, without Electron or WebView. It supports MySQL, PostgreSQL, SQL Server, SQLite, TiDB, DM8, and Redis. This post doesn't pile up feature lists; instead, starting from code, it breaks down the three parts that most affect feel:

  1. How query results and tables avoid being crushed by large data;
  2. How SQL completion moves from "string association" to "syntax-slot inference";
  3. How data editing stages changes first and commits safely later.

Full operation video:

【我做了个原生数据库客户端:SQL、Redis、ER 图一个窗口搞定|FluxDB 快速上手】 https://www.bilibili.com/video/BV1bRnb6DE6q/?share_source=copy_web&vd_source=a5a364168615bd77fe54559869d0602e


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

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

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

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

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


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

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}")
}

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

数据表浏览又是另一条路径。以 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 当成“省略”,导致 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 或前缀匹配。这样的问题很明显:

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) 根据光标位置投影出当前可见的关系:

这样补全才知道“当前这个 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,
    }
}

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

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

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


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

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

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

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

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

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


体验与源码

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

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

亚雷

How does pagination continue for an extra-large single row?

FluxDB

Currently there is a 16 MiB limit; if exceeded, it prompts truncation. Large fields will be specifically optimized later.