用 Rust + GPUI 重做数据库客户端:我如何把查询、大数据表格和 SQL 补全做成“跟手”的
数据库客户端看起来只是“连上库、写 SQL、看表格”,真正用起来却处处是性能问题:
- 一条
SELECT不小心带回十几万行,界面先卡住了; - 单元格里塞着一段几 MB 的 JSON,滚动时每一帧都在复制字符串;
- 补全列表弹出一堆 DDL 关键字,却不知道光标这里应该填表名、列名还是 JOIN 条件;
- 改了十几格数据,还没点提交,刷新一下全没了。
我在做 FluxDB 时,给这些问题定了一个统一原则:
该有界的地方有界,该后台的地方后台,该取消的地方取消。
FluxDB 是一个基于 Rust + GPUI 的原生跨平台数据库客户端,不依赖 Electron 或 WebView。它支持 MySQL、PostgreSQL、SQL Server、SQLite、TiDB、达梦 DM8 和 Redis。本文不堆功能清单,而是从代码出发,拆解三个最影响体感的部分:
- 查询结果和表格怎样避免被大数据拖垮;
- SQL 补全怎样从“字符串联想”变成“语法槽位推理”;
- 数据编辑怎样做到先暂存、再安全提交。
完整操作视频:
【我做了个原生数据库客户端: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。
如果掩码失败,还会继续尝试:
- 原文直接解析;
- 在光标处插入占位标识符;
- 从尾部按 token 边界截断,取最长可解析前缀;
- 全部失败才降级到词法通道。
这就是为什么补全需要处理“用户正在输入”的状态,而不是只处理已经写好的 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。请求进入后台后,还必须在三个条件都满足时才能写回:
- 请求 ID 仍是最新;
- buffer 版本没有变化;
- 编辑 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 写了一个界面”,而是可以把查询、渲染、元数据、事务这些链路拆成可观测、可取消、可验证的模块。
如果你也在做数据库工具,建议不要一开始就追求“支持多少种数据库”。先把这三件事做对:
- 大数据结果如何进入 UI;
- 补全如何知道光标所在的语法槽位;
- 编辑如何安全地提交和回滚。
这三件事做稳了,功能才会越加越顺,而不是越加越卡。
体验与源码
- 项目地址: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 虚拟表格实践,或者 数据库客户端的事务编辑设计。