做开发这些年,我越来越觉得“装客户端”这件事特别反人类。数据库管理工具更是重灾区,DBeaver 要装 Java 环境,Navicat 要到处找激活码,MySQL Workbench 在 Linux 上每次升级都像在赌博。你明明只想去查一条数据、导出个表结构,却得先应付一整套本地环境。DBViewer 这个项目,就是想把这堆麻烦全干掉——把数据库工作台直接搬到浏览器里,打开页面连上库就能干活。
这个思路并不新鲜,phpMyAdmin、Adminer 早就证明了 Web 版可行,但它们的体验还停留在“能用”的水平:界面老旧、多库支持弱、缺少现代的 SQL 编辑器。DBViewer 要做的,是像 VSCode 之于记事本那样,把浏览器数据库管理工具的体验拉到一个新高度。它适合三类人:一是懒得在每台服务器上都装一遍客户端的人,二是带学生做数据库课程设计需要演示环境的老师,三是想用最少成本搭一个团队共用查询后台的开发小组。下面我就把这个项目从设计到落地完整拆一遍,顺便把我在实操中踩过的坑一并交代清楚。
1. 为什么要把数据库工作台放进浏览器
1.1 桌面工具与浏览器工具的真实差距
在动手之前,我先花了不少时间对比了桌面客户端和 Web 工具的差异,不是因为“Web 潮”才选浏览器,而是因为使用场景真的不一样。
桌面客户端强在性能。以 DBeaver 为例,它能直接和 JDBC 驱动深度集成,SQL 编辑器、数据编辑器、ER 图、任务调度全都本地跑,查几十万行数据也不卡。但它的代价是安装链太长:装驱动、装 Java、调内存参数、处理不同系统下的权限问题。我见过太多同事因为一台新电脑配不好环境,浪费掉一整天。
浏览器工具的核心价值是“零门槛触达”。只要有一个服务端地址和一个浏览器标签页,任何设备都能连上,Chromebook 也行,iPad 也行,甚至临时借来的电脑也行。数据库如果部署在远程服务器上,这种优势就更明显:你用浏览器打开的不是本机,而是数据库所在网络里的一个工作台,不需要把数据库端口暴露到公网。
但问题也随之而来:浏览器跑不了原生 TCP 长连接,Web 应用也不能直接持有一个 MySQL 连接,查询结果集的传输效率也远不如本地内存操作。所以 DBViewer 不能只是“把 DBeaver 抄到网页里”,必须针对 Web 环境重新设计数据流。这是整个项目最核心的出发点。
1.2 合适的使用场景:哪些人最需要这样的工具
想清楚场景,项目才不会做偏。我参考了常见工作流之后,总结出 DBViewer 真正能打得过的场景主要有三类。
第一类是“演示型场景”。比如数据库课程设计答辩时,老师不可能每台电脑都安装 Navicat,你也不希望环境变量出错导致现场翻车。把 DBViewer 部署在一台服务器上,演示时打开浏览器即可,连完库直接展示表结构、执行 SQL,既体面又稳。
第二类是“协作型场景”。团队里经常有非后端成员需要查询线上数据,比如运营、数据分析、产品经理。让他们去记 MySQL 命令行不现实,给他们装付费客户端成本又高。DBViewer 可以做成一个内部查询平台,所有人共用一套浏览器入口,后端统一控制账号和连接配置。
第三类是“嵌入式与容器化场景”。现在 Docker、K8s 部署非常普遍,如果数据库跑在容器网段里,外面的桌面工具需要配置端口映射才能连上,非常麻烦。但如果你把 DBViewer 也放进同一个内网,甚至编排成同一个 Compose 服务,浏览器直接访问工作台即可,网络问题瞬间消解。
桌面工具确实不可替代,但浏览器工具也不是弱化版。它是一个在特定场景下比桌面工具更顺手的选项,而 DBViewer 存在的意义就是把这个选项做到专业级。
2. DBViewer 整体设计与架构选型
2.1 分层架构:前端、后端、数据源之间怎么分工
数据库工作台放在浏览器里,不等于浏览器直接连数据库。主要原因有两个:一是浏览器没有通用的数据库驱动能力,二是把数据库密码下发到每个浏览器终端,本身就是安全隐患。所以 DBViewer 必须要有一个“中间层”,也就是后端服务。
我在项目里把整体架构分成三层:
- 前端层:负责连接表单、SQL 编辑器、结果表格、表结构视图等 UI 交互,运行在用户的浏览器里。
- 后端服务层:负责接收 SQL、管理数据库连接、校验权限、处理元数据请求,运行在服务器上。
- 数据源层:支持 MySQL、PostgreSQL、SQLite 等不同类型的数据库实例,通过标准驱动接入。
前端与后端之间,用 REST API 做常规操作,比如保存连接配置、获取表列表、读取表结构;用 WebSocket 做 SQL 执行和结果流式返回。为什么 SQL 要走 WebSocket?我下面会单独讲,这是整个架构里最关键的取舍之一。
2.2 关键选型:WebSocket、数据库驱动与编辑器组件
选型我一开始偷懒,直接用 AJAX 提交 SQL,后端等查询跑完再把结果一次性塞回给前端。结果非常尴尬:遇到大查询,用户点完执行按钮后整个页面就悬在 loading 状态,等十几秒甚至超时,体验非常糟糕。后来我果断把执行链路切到 WebSocket 上。
WebSocket 的优势在于“双向、实时、可分段”。SQL 执行不再是一条请求一条响应,而是客户端发起执行事件,服务端在查询过程中持续推送“已开始”“读取中”“返回第 N 批结果”等事件。这样前端可以实时更新进度,也可以在任何时候发送“取消查询”指令,把那个失控的慢查询杀掉。
技术选型上,我后端用的是 Node.js + Express,配合两个驱动:mysql2 和 pg。为什么不直接用 TypeORM 之类的 ORM?因为 DBViewer 是面向“原生 SQL 管理和元数据浏览”的工具,ORM 反而会把 SQL 语义过滤掉,我必须拿到最原始的 SQL 字符串和原生结果集。SQLite 的接入我用 better-sqlite3,单机测试特别方便,整个演示环境不依赖外置数据库也能跑起来。
前端我用了 React + CodeMirror 6,没有选 Monaco Editor,虽然 VSCode 的编辑器就是 Monaco,功能无敌,但它包体很大,首屏加载很慢。CodeMirror 6 的模块化做得好,可以做 SQL 语法高亮、自动补全、括号匹配,包体控制在几百 KB,适合这种工具型页面。
2.3 功能边界:第一版只做这四件事
做工具最怕贪多。我第一版只想清楚四件事,别的一律不做:
第一,多数据库连接的配置与切换。能保存连接、测试连接、一键切换。第二,SQL 编辑与执行。支持多语句、高亮、自动补全,执行后能看到结果集和耗时。第三,数据库对象浏览。表、视图、索引、字段信息都能看,双击表名生成 SELECT *。第四,结果集导出。最多导出 5 万行 CSV,够日常用就行。
DDL 自动生成、ER 图、图表分析、历史 SQL 记录这些,都属于“以后再说”。有这个边界,项目才不会失控,我也才有足够精力把核心链路打磨稳。
3. 核心功能拆解与实现要点
3.1 连接管理:多数据库实例、连接池与密码安全
连接配置是 DBViewer 的地基。我在设计里,每条连接配置包含:连接名、类型(mysql/pg/sqlite)、主机、端口、数据库名、用户名、密码。密码不能明文落库,我用了 Node.js 的 crypto 模块做 AES-256-GCM 加密,加密密钥从环境变量里读,不写进代码仓库。
const crypto = require('crypto'); const ALGORITHM = 'aes-256-gcm'; const SECRET_KEY = crypto.createHash('sha256') .update(process.env.DBVIEWER_SECRET || 'dev-secret') .digest(); function encryptPassword(plain) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, iv); const encrypted = Buffer.concat([ cipher.update(plain, 'utf8'), cipher.final(), ]); const tag = cipher.getAuthTag(); return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; } function decryptPassword(stored) { const [ivHex, tagHex, dataHex] = stored.split(':'); const decipher = crypto.createDecipheriv( ALGORITHM, SECRET_KEY, Buffer.from(ivHex, 'hex') ); decipher.setAuthTag(Buffer.from(tagHex, 'hex')); return Buffer.concat([ decipher.update(Buffer.from(dataHex, 'hex')), decipher.final(), ]).toString('utf8'); }光有加密还不够,后端连接必须走连接池,不能每次查询都新建连接。mysql2 里我直接用 createPool,合理设置 connectionLimit 和 idleTimeout,避免高并发查询时把数据库连接数打满。
const mysql = require('mysql2/promise'); let pool; function getPool(config) { if (pool) return pool; pool = mysql.createPool({ host: config.host, port: config.port, user: config.user, password: config.password, database: config.database, waitForConnections: true, connectionLimit: 10, idleTimeout: 60000, }); return pool; }这里有个很容易踩的坑:连接池一旦配上,即使数据库密码改了,旧连接还是会存活一段时间,用户就会看到“密码已改但还报旧错”的诡异现象。我在实现里加了“重建连接池”的接口,用户只要在连接配置页点一下“刷新连接”,就直接 pool.end() 再重新创建。
3.2 SQL 编辑器:自动补全、执行与结果流式返回
SQL 编辑体验是 DBViewer 的门面。我基于 CodeMirror 6 做了三层增强:关键字语法高亮、表名和字段名自动补全、多语句拆分执行。
自动补全的数据来源,是当前数据库的 information_schema。后端在用户切换到某个数据库时,会缓存一份表结构和字段名列表,前端在用户输入“FROM t”或者“SELECT col”时触发补全。做补全时要注意大小写匹配和关键字优先级,否则会频繁打断用户输入。
执行链路是最核心的部分。我定义了 WebSocket 消息格式,包含以下几类事件:
{ "type": "execute", "requestId": "uuid-1", "sql": "SELECT * FROM users LIMIT 10" }后端接收到 execute 事件后,先做超时设置,再调用驱动执行。MySQL 查询可以用 pool.execute 加 timeout 参数,但我更推荐在服务端维护一个 Map 来跟踪每个 requestId 对应的连接。这样用户取消查询时,后端可以直接调用 connection.destroy() 把占用连接杀掉,而不是傻等数据库回话。
const runningQueries = new Map(); wsServer.on('connection', (ws) => { ws.on('message', async (raw) => { const msg = JSON.parse(raw); if (msg.type === 'execute') { const { requestId, sql } = msg; const conn = await pool.getConnection(); runningQueries.set(requestId, conn); try { // 关键:每 10 秒检查一次,超过阈值就主动杀连接 const timer = setTimeout(() => { const leaked = runningQueries.get(requestId); if (leaked) { leaked.destroy(); runningQueries.delete(requestId); } }, 30000); const [rows] = await conn.query(sql); clearTimeout(timer); runningQueries.delete(requestId); ws.send(JSON.stringify({ type: 'result', requestId, rows: rows.slice(0, 1000), })); } catch (err) { runningQueries.delete(requestId); ws.send(JSON.stringify({ type: 'error', requestId, message: err.message })); } finally { conn.release(); } } }); });这个实现有个不能忽略的细节:千万不要在前端拿到完整 rows 后一次性渲染上万行。我先在后端截断 1000 行,再配合前端分页和虚拟滚动,体验才算正常。
3.3 结果表渲染:分页、虚拟滚动与一键导出
结果集渲染是我迭代最多的地方,因为数据库工具的用户对表格操作非常敏感。第一版我用普通表格把 5000 行直接渲染到 DOM,页面卡到鼠标都动不了。后来我改成了“分页 + 虚拟滚动”的组合方案。
分页的逻辑很简单,点击下一页时向后端重新发送一条带 LIMIT 和 OFFSET 的查询。但要注意,很多用户会执行一条不带 ORDER BY 的 SQL,此时翻页结果可能不稳定,同一行数据会在两页里重复出现。我在 UI 上要求用户至少加一个主键字段用于排序,如果没加,就提示“结果集无稳定排序”。
虚拟滚动我用的是 react-window,只渲染当前视口内的行。这样即使前端内存里存了 5 万行数据,DOM 节点也不会爆炸。
导出功能我做得比较粗暴但稳定:前端把当前结果集拼成 CSV,在浏览器端用 Blob 触发下载。这样做避免了大文件经过后端,后端只负责把数据传给前端,导出不占服务端内存。
function exportCsv(rows) { if (!rows.length) return; const headers = Object.keys(rows[0]); const escape = (val) => { const str = val === null ? '' : String(val); if (/[",\n]/.test(str)) { return `"${str.replace(/"/g, '""')}"`; } return str; }; const lines = [ headers.map(escape).join(','), ...rows.map((row) => headers.map((h) => escape(row[h])).join(',')), ]; const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'export.csv'; a.click(); URL.revokeObjectURL(url); }这里有个坑:Excel 打开 CSV 时,如果不加 \uFEFF(BOM),中文会乱码。我第一次做的时候没加,导出的文件在记事本正常,Excel 里全是乱码,被同事吐槽半天。加 BOM 是最简单的解决方式,没有之一。
3.4 对数据库对象的可视化:表结构、索引、字段类型
查数据只是数据库工作台的一半,另一半是“看懂库结构”。我实现了一个对象列表侧边栏,展示当前连接下的表、视图、索引,点击某个表,右侧会展示字段名、类型、是否为空、默认值、主键信息。
这个功能的实现主要靠查询系统表。MySQL 下我查 information_schema.COLUMNS,PostgreSQL 下查 information_schema.columns 配合 pg_indexes。SQLite 更简单,直接读 sqlite_master 和 PRAGMA table_info。
-- MySQL 表结构查询 SELECT COLUMN_NAME AS field, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS default_value, COLUMN_KEY AS key_type, EXTRA AS extra FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION;这个查询我一开始没加 TABLE_SCHEMA 条件,结果在连接了多个库的环境里,表结构张冠李戴,查出来的字段完全不对。加上 schema 条件后,问题彻底消失。
PostgreSQL 的路径和 MySQL 不一样,这一点我在开发时才意识到。各个数据库的系统表字段命名差异非常大,所以在数据访问层必须做一层“方言适配”,不能直接拼 SQL 去查系统表。
4. 实操:从零搭建一个最小可用的 DBViewer
4.1 准备工程:目录、依赖与启动脚本
光说不练假把式。这里我给出一个最小可复现的版本,用 Node.js 做后端,前端用原生 HTML + CodeMirror CDN,不引入复杂脚手架,方便你自己在本地跑通。
目录结构很简单:
dbviewer/ ├── package.json ├── server.js ├── db.js └── public/ └── index.html初始化工程并安装依赖:
mkdir dbviewer cd dbviewer npm init -y npm install express mysql2 better-sqlite3 ws dotenvpackage.json 里配置启动脚本:
{ "name": "dbviewer", "version": "0.1.0", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.19.2", "mysql2": "^3.11.0", "better-sqlite3": "^11.3.0", "ws": "^8.17.0", "dotenv": "^16.4.5" } }这个项目不写测试、不引入 webpack,先把核心链路跑通。等你有心思再慢慢加前端工程化,否则容易在初期就陷入构建工具的泥潭。
4.2 后端实现:连接配置、查询接口与 WebSocket 服务
我先写一个 db.js,统一管理连接配置和查询逻辑。为了演示方便,这里用 SQLite 作为默认数据库,同时保留 MySQL 的接入代码。
// db.js const Database = require('better-sqlite3'); const mysql = require('mysql2/promise'); class DbManager { constructor() { this.connections = new Map(); } registerConnection(id, type, options) { this.connections.set(id, { id, type, options }); } query(id, sql) { const conn = this.connections.get(id); if (!conn) throw new Error('connection not found'); if (conn.type === 'sqlite') { const db = new Database(conn.options.path); const stmt = db.prepare(sql); let rows = []; let columns = []; if (/^select|^pragma|^with/i.test(sql.trim())) { rows = stmt.all().slice(0, 1000); columns = Object.keys(rows[0] || {}); } else { const info = stmt.run(); rows = [{ affectedRows: info.changes }]; columns = ['affectedRows']; } db.close(); return { columns, rows }; } if (conn.type === 'mysql') { return this.queryMysql(conn.options, sql); } throw new Error('unsupported db type'); } async queryMysql(options, sql) { const pool = mysql.createPool({ ...options, connectionLimit: 5 }); try { const [rows] = await pool.query(sql); const first = rows[0] || {}; return { columns: Object.keys(first), rows: rows.slice(0, 1000), }; } finally { await pool.end(); } } } module.exports = new DbManager();server.js 里同时启动 HTTP 服务和 WebSocket 服务。HTTP 负责静态页面和 REST 接口,WebSocket 负责执行 SQL。
// server.js require('dotenv').config(); const express = require('express'); const http = require('http'); const path = require('path'); const { WebSocketServer } = require('ws'); const dbManager = require('./db'); const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); const server = http.createServer(app); const wss = new WebSocketServer({ server }); dbManager.registerConnection('demo', 'sqlite', { path: './northwind.db', }); // REST:获取仓库里的表列表 app.get('/api/tables', (req, res) => { const result = dbManager.query('demo', "SELECT name FROM sqlite_master WHERE type='table'"); res.json({ tables: result.rows }); }); // WebSocket:执行 SQL wss.on('connection', (ws) => { ws.on('message', async (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'query') { const start = Date.now(); try { const result = dbManager.query('demo', msg.sql); ws.send(JSON.stringify({ type: 'result', columns: result.columns, rows: result.rows, elapsed: Date.now() - start, })); } catch (err) { ws.send(JSON.stringify({ type: 'error', message: err.message })); } } }); }); server.listen(3000, () => { console.log('DBViewer running at http://localhost:3000'); });这段代码里我用了一个细节:SQLite 查询结束后立刻 db.close(),因为 better-sqlite3 是同步阻塞的,如果不关闭,后面再查询时文件句柄会一直累积,Linux 下很快就会报 too many open files。
4.3 前端实现:连接表单、SQL 控制台与结果表格
前端我刻意减少了 React 的复杂度,直接用原生 JavaScript 写了一个演示页,保证代码足够短,任何人复制过去就能跑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>DBViewer</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/dracula.min.css"> <style> body { margin: 0; font-family: system-ui, sans-serif; background: #282a36; color: #f8f8f2; } .toolbar { padding: 12px 16px; background: #21222c; display: flex; gap: 8px; align-items: center; } .editor-wrap { padding: 12px; } .CodeMirror { height: 200px; border-radius: 8px; } .result { padding: 12px; overflow: auto; } table { border-collapse: collapse; min-width: 50%; } th, td { border: 1px solid #444; padding: 6px 10px; font-size: 13px; } .elapsed { color: #50fa7b; } </style> </head> <body> <div class="toolbar"> <button onclick="runSql()">运行</button> <span id="elapsed" class="elapsed"></span> </div> <div class="editor-wrap"> <textarea id="sqlEditor">SELECT * FROM users LIMIT 10;</textarea> </div> <div id="result" class="result"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/sql/sql.min.js"></script> <script> const editor = CodeMirror.fromTextArea( document.getElementById('sqlEditor'), { mode: 'text/x-sql', theme: 'dracula' } ); const ws = new WebSocket('ws://localhost:3000'); function runSql() { const sql = editor.getValue(); ws.send(JSON.stringify({ type: 'query', sql })); } ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'error') { document.getElementById('result').innerHTML = `<p style="color:#ff5555">${msg.message}</p>`; return; } renderTable(msg.columns, msg.rows, msg.elapsed); }; function renderTable(columns, rows, elapsed) { let html = `<p class="elapsed">耗时: ${elapsed}ms</p><table><thead><tr>`; columns.forEach((col) => { html += `<th>${col}</th>`; }); html += '</tr></thead><tbody>'; rows.forEach((row) => { html += '<tr>'; columns.forEach((col) => { html += `<td>${row[col] ?? ''}</td>`; }); html += '</tr>'; }); html += '</tbody></table>'; document.getElementById('result').innerHTML = html; } </script> </body> </html>这个版本故意做得很朴素,但逻辑是完整的:编辑器高亮、WebSocket 通信、结果渲染全有。你可以在这个基础上逐步扩展成 React 版本,思路完全一致。
4.4 验证效果:连接一个本地数据库并执行查询
启动时,我先用 code3 生成一个北风数据库(Northwind)的简化版,这样演示时既有表结构又有业务数据,比空库直观得多。
node server.js打开 http://localhost:3000,在编辑器里执行:
SELECT c.customer_name, o.order_date, SUM(od.quantity * od.unit_price) AS total FROM customers c JOIN orders o ON c.customer_id = o.customer_id JOIN order_details od ON o.order_id = od.order_id GROUP BY c.customer_name, o.order_date ORDER BY total DESC LIMIT 10;执行后,右侧结果表会显示字段名、前 10 行数据,以及耗时。WebSocket 的实时推送让你几乎感觉不到“先请求后响应”这个过程,这就是为什么我用 WebSocket 而不是一次 HTTP 请求的关键。
5. 上线后的常见坑与排查经验
5.1 表名大小写、时区与编码问题
数据库方言差异在第一版就给我上了一课。同样的表,MySQL 在 Windows 上表名不区分大小写,在 Linux 上严格区分;PostgreSQL 默认把所有未加引号的表名强制转成小写。DBViewer 如果直接把用户输入丢给驱动,用户写 SELECT * FROM Users,在 Postgres 里就会报 “relation users does not exist”。
我加了一层“表名校验”:执行 SQL 前,先从元数据缓存里查一下目标表名,如果发现大小写不匹配,就提示用户用引号包起来。另外时区问题也坑过我。MySQL 默认 time_zone 和 Node.js 进程的时区不一致,查出来的 DATETIME 会有 8 小时偏差。我在连接配置里强制指定 timezone: '+08:00',才把问题稳定下来。
5.2 连接泄漏、挂起查询与内存暴涨
浏览器里跑工作台,最怕的就是“点了一次执行,连接一直在数据库那边挂着”。我遇到过一次经典的连接泄漏:前端 WebSocket 断线了,后端 runningQueries 这个 Map 里的连接对象没有被清理,数据库连接数持续上升,最后把 MySQL 的连接池打满。
后来我在服务端加了“心跳检测 + 自动回收”机制。WebSocket 每 30 秒 ping 一次客户端,如果连续 3 次没收到 pong,后端就把该客户端相关的所有 runningQueries 强制销毁。对于超过 30 秒未返回的查询,也统一杀连接。这招虽然粗暴,但胜在可靠,至少不会把数据库拖垮。
内存暴涨问题主要出现在“一次导出全表”的场景。我后来把导出上限定为 5 万行,并且在前端用增量写入 Blob 的方式分批生成 CSV,而不是一次性拼接字符串。实测下来,导出 5 万行数据时内存占用稳定在 100MB 以内。
5.3 浏览器与传输层的隐藏限制
经常有人在浏览器终端里看到“Maximum call stack size exceeded”,其实不是你的代码有问题,而是 WebSocket 传入的消息过大导致 JSON.parse 爆栈。我用 URL 参数方案替代了一部分大 payload 传输,或者把大结果集拆分成多帧发送,前端按 requestId 排序后再拼接。
另外,Edge 浏览器对 WebSocket 连接数有上限,大量标签页同时开着 DBViewer 时,会遇到连接失败。我在前端做了“WebSocket 断线自动重连”,并在服务端限制同一客户端最多建立 2 个 WebSocket 连接,超出的直接拒绝。
5.4 常见问题速查表
把我在交付后几个月的实操问题整理成表格,方便你排查时按图索骥:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 连接 MySQL 超时 | 防火墙未放行 3306,或服务器 bind-address 设置错误 | 用 telnet 验证端口连通性,确认 mysql 监听地址 |
| 密码已改但查询仍报旧密码 | 连接池未刷新,旧连接仍在复用 | 在界面提供“重建连接池”操作,pool.end 后重建 |
| SQL 结果顺序每次不固定 | 缺少 ORDER BY,数据库无固定返回顺序 | UI 提示添加排序字段 |
| SQL 执行后无任何响应 | WebSocket 已断开,或服务端 runningQueries 超时被杀 | 检查 ws 连接状态,查看服务端日志 |
| 大查询导致内存暴涨 | 前端一次性渲染全部行 | 增加虚拟滚动,分页展示,限制最大返回行数 |
| Excel 打开 CSV 中文乱码 | 导出文件缺少 BOM 头 | 在 CSV 前加 \uFEFF |
| 跨域请求被浏览器拦截 | 前端域名和后端端口不一致 | 配置 CORS 白名单,或使用同源服务托管前端页面 |
| SQLite 报 too many open files | better-sqlite3 连接文件未及时关闭 | 每次查询后显式 db.close() |
表格里第 4 行是最容易忽略的:当查询超时被服务端杀掉连接后,前端 WebSocket 其实还是好的,所以要给 result 消息加一个 requestId 与原始请求对应,否则前端会收到一个没有源头的响应,用户看到的现象就是“点了执行没反应”。
我还吃过一个亏:把页面静态文件托管在 3000 端口,WebSocket 也建在 3000 上,看起来没问题,但一旦把前端打包部署到 CDN,CDN 域名和后端域名不同,浏览器就会以跨域为由拒绝 WebSocket 握手。最后我统一改成“同源部署”,用 Express 同时托管静态服务和 WebSocket 服务,从根源上规避了跨域问题。
6. 版本迭代方向:从“能用”到“好用”
DBViewer 现在这个状态,已经能解决 80% 的日常查询和管理需求,但我很清楚它离“好用”还有距离。后续我已经排好了三个迭代方向。
第一个方向是 SQL 历史记录与收藏。数据库工具的使用者经常要反复执行同一类查询,比如“查看今日订单量”“查看库存低于警戒线的商品”。把这些查询保存成模板,下次一键点击执行,效率能提升非常多。
第二个方向是 ER 图与表关系可视化。现在只能看到单表结构,看不到表之间的外键关系。我计划基于 information_schema 里的外键信息,在前端用 Canvas 画出简单的 ER 图,让用户一眼理解整个库的关联结构。
第三个方向是权限层面的精细化。当前版本的连接配置是全局共享的,但凡能访问 DBViewer 的人都能连所有数据库。下一步我会引入用户体系,让不同用户只能看到分配给自己的连接,SQL 也加入只读模式,避免有人误写数据。
说实话,做到第三个版本的时候,我的心得很明确:工具类项目永远不是一蹴而就的,先解决最痛的点,再通过实际使用者的反馈逐步补齐功能,远比一开始设计一个庞大但没人用的“全功能工作台”更靠谱。
7. 最后一点经验和建议
浏览器数据库工作台这个方向,最大的挑战不是写代码,而是“如何让网页端的体验逼近桌面端”。我实测下来,只要在结果集分页、虚拟滚动、WebSocket 实时推送这几个关键环节做扎实,用户的体感差距完全可以忽略。
给想复刻这个项目的朋友几个发自肺腑的建议:
第一,一定不要在一开始就想着支持十几种数据库。先支持 SQLite 和 MySQL 两种,跑通完整链路,再往 PostgreSQL、Oracle 扩展。数据库方言是无穷的,你没有精力和义务在第一版就吞下所有差异。
第二,SQL 执行功能的取消按钮一定要有。数据库工具最糟糕的使用体验,就是误执行了一条大查询后无法取消,只能干等着。连接销毁虽然粗暴,但比用户关浏览器强一万倍。
第三,密码安全不是可选项。把密码加密存储、环境变量管理密钥、连接池隔离,这三条缺一不可。哪怕只是内部工具,也一定要按生产标准对待,否则迟早出事。
我个人的经验是,这类工具最大的成就感不是写出了多华丽的界面,而是某天运营同学跟我说:“这个网页查询平台太好用了,以后不用每次让研发帮忙导数据了。”那一刻我就知道,把数据库工作台装进浏览器这件事,确实做对了。