1. 从“想看一眼表关系”到“干脆做个在线工具”:需求是怎么变具体的
前阵子接手一个老系统的维护,代码还在,数据库设计文档早不知道丢哪去了。我唯一能拿到的,是一份几百行、几十张表的建表SQL脚本。没有权限连生产库,不敢在测试库乱跑,手头也没有现成的ER图。最朴素的需求就一个:我想快速知道两张表之间到底有没有关联、是通过哪个字段关联的。
这时候你会发现一个尴尬的事实:市面上的ER图工具,思路几乎全是“连上数据库,从库里反向生成”。MySQL Workbench 要你先建连接,Navicat 要你先连库,DBeaver 也一样。可问题恰恰是我没有库可以连,或者我不想为了看个结构就去申请数据库账号。更常见的情况是,团队里某个同事发来一个 .sql 文件,说“新项目的表结构都在这了,你帮我看下关系”,你总不能让他先把库搭起来。这就是我决定做一个 SQL 转 ER 图在线生成网站的起点:把建表脚本贴进去,马上出关系图,不装客户端、不上传服务器、不办会员。
下面这张表是我当时对比现有方案的真实感受:
| 方案 | 前置条件 | 适合场景 | 明显短板 |
|---|---|---|---|
| MySQL Workbench | 需要本机安装,并连接 MySQL 实例 | 完整的可视化建模、反向工程 | 安装包大,配置驱动繁琐,为了看表结构而引入重型工具 |
| Navicat | 需要连接数据库,商业授权 | 日常数据库管理,顺手看关系 | 收费,且必须先有库连接才能出图 |
| DBeaver | 需要连接数据库,配置驱动 | 多数据库统一管理 | 对只拿到脚本的人没有任何帮助 |
| draw.io 手绘 ER 图 | 不需要任何库连接 | 三五张表的小范围说明 | 表一多就画到崩溃,画完还容易过期 |
| 在线 SQL 转 ER 图 | 只需要一段 DDL 文本 | 快速理清关系、沉淀文档、临时讲解 | 不能反推修改表结构,不做正向建模 |
这个对比让我确认了一件事:很多人需要的其实不是“数据库建模IDE”,而是一个“文本到关系图”的翻译器。需求一旦被说清楚,实现路径就很短了。整个项目最核心的工作量不在界面,而在于两层:DDL解析器能不能扛住各种方言和脏数据,渲染层能不能让几十张表的关系图不变成一团乱麻。
我最初也搜过“er图生成工具”“sql代码排版工具”“mysql的表导出er关系图”这些词,看了一圈后发现,要么是付费工具,要么是必须上传SQL到人家服务器的服务端解析站点。上传这一点就让我直接放弃——业务表结构属于内部资产,哪能随手传到外面。所以这个项目从一开始就确定了一个硬约束:解析必须放在浏览器本地跑,文件不出本机。这个决定后来在选型上帮了大忙,也变成了产品对外最有说服力的卖点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解析链路是怎么设计的:DDL文本到ER图一共四步
很多第一次写类似工具的人会陷入一个误区:用正则去匹配 CREATE TABLE、匹配 FOREIGN KEY,觉得自己半小时就能写出一个 MVP。等真正跑起来就会发现,真实世界里的SQL脚本远比想象中脏:有注释、有存储过程、有 INSERT INTO 数据区、有 CREATE TRIGGER,同一份文件里甚至混着多种数据库的写法。正则方案在这些场景下会全面失控。
我的做法是把解析拆成四层,每一层只做一件事。
2.1 第一层:词法清洗与语句切分
这一层解决的是“把代码变成干净的语句块”的问题,不直接抽表结构。核心不是 split(';'),而是一个状态机。脚本里的字符流会处在几种状态里:普通SQL文本、行注释(--)、块注释(/* ... */)、字符串(单引号内)。只有处于“普通SQL文本”状态时,遇到 CREATE TABLE 关键字才触发建表块的收集。
为什么一定要状态机?因为注释和字符串里会混入大量干扰信息。比如团队有人习惯在文件头部写一段说明:“/* 本脚本包含订单模块所有建表语句,CREATE TABLE 请参考以下脚本 */”。如果你用 indexOf('CREATE TABLE') 去截取,第一个结果就落在注释里,后面全乱。字符串里出现分号、逗号、括号同样常见,典型的就是 DEFAULT 'a,b' 或 COMMENT '状态:0-待支付,1-已支付',这些逗号如果被当成字段分隔符,解析结果直接崩。
状态机里还有一个关键处理:遇到 CREATE TABLE 后,通过括号深度判断表定义何时结束。因为表定义内部可能有几十个字段、多个 KEY、多个 CONSTRAINT,嵌套括号并不多,但括号深度追踪能可靠应对“一行一个字段”和“所有字段挤在一行”两种极端风格。
2.2 第二层:表定义结构解析
拿到一个完整的建表块以后,第二步是把表名、字段、类型、约束、注释拆出来。这一层的最优解也不是“整段正则”,而是逐行扫描配合字段状态累积。
举个最常见的坑:一个字段行以逗号结尾,但下一行可能是另一个字段,也可能是 PRIMARY KEY (...) 子句,还可能是 ) ENGINE=InnoDB 的表尾。解析器需要在每个新行开始时判断当前是否仍在“字段定义区”。这里我用一个简单策略:从 ( 后的第一个非空行开始,逐行读取,遇到不以 ) 结尾的行就继续累积,遇到单独的 ) 表示字段区结束。字段区内,每一行再按“先标识符、后类型、再修饰符”的顺序拆分。
对于标识符,我统一封装了一个“标识符抽取函数”。它要兼容三种包裹方式:MySQL 的反引号(`user`)、SQL Server 的方括号([user])、PostgreSQL 的双引号("user")。这个函数输出的是干净的标识符内部名。与此同时,对字段类型的处理则保留原文:INT、INTEGER、BIGINT UNSIGNED、VARCHAR(50)、NVARCHAR(100)、DECIMAL(10,2) 都原样保留,不强行归一化。因为用户最终要看的是“原表长什么样”,类型名只要在渲染时能展示就够了。
主键和外键的解析需要单列出来说。主键可能出现在两个位置:字段行内直接写 id INT PRIMARY KEY,或者表尾单独写 PRIMARY KEY (id, user_id)。联合主键在表尾子句里更常见。外键的写法就更多了:带 CONSTRAINT 名的、不带名的、多列联合外键、还带 ON DELETE CASCADE ON UPDATE CASCADE。我的解析器会同时收集字段行内的引用关系和表尾的 CONSTRAINT 子句,最后统一生成边列表。
2.3 第三层:关系识别
显式外键是最可靠的关系来源,解析到 FOREIGN KEY (user_id) REFERENCES user(id) 就直接生成一条 user <- orders 的边。但现实是,很多老系统的表之间根本没有外键约束,只有普通索引,甚至索引都没有,只有字段命名恰好叫 user_id。这时候就需要“命名推断”。
命名推断的逻辑不复杂:字段名以 _id 结尾,去掉后缀得到 user,然后去找是否存在名为 user 的表,存在就给出一条推断关系。但这里有个大坑:creator_id、operator_id、receiver_id 去掉 _id 后分别指向 creator、operator、receiver,它们往往都不存在对应表,而真正的意图大概率是关联 user 表。所以很多工具把规则粗暴设为“以 _id 结尾就找同名表”,结果就是推断出大量错误连线。
我的做法是把所有推断关系统一渲染成“虚线”,并且需要手动确认后才变成实线。这个交互设计非常重要——自动推断出来的关系只作为建议,不作为事实。没有数据库约束做背书,任何推断都可能出错,宁可让用户多点一下确认,也不能在文档里画出错误的表关系。
还有一个值得单独处理的场景:中间表。如果一个表只有两个外键字段和一个主键,比如 order_product( order_id, product_id ),它就是一个典型的多对多关联表。工具可以识别出这种模式,并在渲染时提供“隐藏中间表”的选项,直接显示 orders <-> product 的多对多连线。这个功能在向业务方讲解时很实用,因为业务人员不关心冗余的中间表,只关心实体间的关系。
2.4 第四层:布局与渲染
解析完成后,关系图能不能看得清,一半靠算法,一半靠交互。最初我也想过直接用纯力导向布局,节点会自动弹开、自动连线,看起来高大上。但实际测试几十张表以后发现,力导向在无环图上有一种“越跑越乱”的特性:明明上游下游有清晰的层级关系,力导向却把整张图揉成一团,长链关系尤其难看。
所以我最终用的是“分层布局 + 局部力导向微调”的组合方案。分层布局负责给每个表分配层级,保证从“上游表”到“下游表”有一个清晰的阅读方向;力导向只做小范围偏移,让连线不要完全重叠。渲染方面,我放弃了用 HTML 卡片渲染每个表的方案,改用 Canvas/SVG。因为 HTML 卡片在五六十张表时会直接拖垮 DOM 性能,而 Canvas 对上千个节点的绘制毫无压力,SVG 则在导出时能保证矢量清晰度。
交互上我做了三个核心功能:点击节点高亮所有邻居,悬停显示关联字段名,拖拽节点后自动把坐标存到 localStorage。第三个功能看似不起眼,实际上很关键——用户重新打开页面时,之前调整好的布局还在,这份图才真正变成了一个可以反复使用的文档。如果每次打开都要重新拖一遍,没人愿意用第二次。
3. 实操示范:一份订单系统的建表SQL,从粘贴到导出
下面用一段完整的建表脚本演示整个使用流程。这是一个典型的小型订单系统,包含用户、商品分类、商品、订单、订单明细五张表,既有外键,也有自关联字段,足够说明问题。
sql复制CREATE TABLE user (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
name VARCHAR(50) NOT NULL COMMENT '用户名',
email VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE category (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID',
name VARCHAR(50) NOT NULL COMMENT '分类名',
parent_id INT UNSIGNED DEFAULT NULL COMMENT '父分类ID,顶级分类为NULL',
PRIMARY KEY (id),
KEY idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';
CREATE TABLE product (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品ID',
category_id INT UNSIGNED NOT NULL COMMENT '所属分类ID',
title VARCHAR(200) NOT NULL COMMENT '商品标题',
price DECIMAL(10,2) NOT NULL COMMENT '售价',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
PRIMARY KEY (id),
KEY idx_category (category_id),
CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID',
user_id INT UNSIGNED NOT NULL COMMENT '下单用户ID',
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付,1已支付,2已取消',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
PRIMARY KEY (id),
KEY idx_user (user_id),
CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
CREATE TABLE order_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '明细ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
product_id INT UNSIGNED NOT NULL COMMENT '商品ID',
quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量',
price DECIMAL(10,2) NOT NULL COMMENT '下单时的商品单价快照',
PRIMARY KEY (id),
KEY idx_order (order_id),
KEY idx_product (product_id),
CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders (id),
CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
把这段SQL复制到工具里,第一步是选择方言。工具会通过几个特征自动判断:发现反引号、ENGINE=InnoDB、AUTO_INCREMENT,基本可以锁定为 MySQL。你不改这个自动识别结果也没关系,解析器会以同样的内部流程处理标准 DDL。
第二步,先别急着开“命名推断”。直接点生成,出来的结果应该是一张清晰的五表关系图:user 被 orders 引用,orders 被 order_item 引用,product 被 order_item 引用,product 又引用 category。实线都是来自 FOREIGN KEY 的硬关系,不用人工确认。这四张表构成了一条很直观的“用户 -> 订单 -> 明细 -> 商品 -> 分类”的链路。
与此同时,页面会弹出一条提示:category.parent_id 疑似自关联,但未定义外键,问你是否开启命名推断。这一步就体现出“虚线确认”机制的实用性——如果我直接自动连线,大概率也没有错;但在更复杂的脚本里,自动推断很可能会把 creator_id 错误连接到一张叫 creator 的表上。所以这里我选择先确认,让系统把推断关系以虚线画出来,我点了接受,它才变成实线。
第三步,展开字段级视图。默认渲染的是表卡片,只显示表名和主键字段。点击某张表右上角的展开按钮,就能看到全部字段、类型和注释。这一步对核对“订单明细里的 price 到底是下单快照价还是商品现价”这类问题非常有用。字段注释在脚本里写得好,ER图就有了真正的解释作用,不只是画几条线。
第四步,拖一拖布局。把 user 拖到最左边,category 拖到最右边,让整个链路从左往右展开。Web端会自动把坐标存到 localStorage,下次打开还是这个布局。
第五步,导出。我平时最常用的两个导出格式是 SVG 和 Mermaid。SVG 可以直接粘进公司内部的 Wiki 文档,放大不糊;Mermaid 文本则可以被 Git 仓库保管,作为建表脚本的伴生文档。每次表结构变更,重新生成一份 Mermaid 提交到仓库,ER图就不会像手绘那种一样“画完就过期”。
4. 做这个工具才会碰到的坑:方言、注释、大文件和隐私
这章的内容,是我在真实测试各种“脏SQL”时踩出来的。很多需求在理想状态下“解析一下就行”,但实际脚本千奇百怪。
4.1 方言兼容:三套标识符包裹规则
同一个 CREATE TABLE,MySQL 写反引号,SQL Server 写方括号和 schema,PostgreSQL 写双引号。SQL Server 的脚本最常见的是 CREATE TABLE [dbo].[user],里面包含 schema 和表名两层。解析器需要把 schema 和表名拆开,展示时默认只展示表名,只有当不同 schema 下存在同名表时,才完整展示 schema.table。
PostgreSQL 还有一套大小写规则:不加双引号的标识符会被折叠成小写,加双引号的则严格区分大小写。但解析器没必要去模拟数据库行为,它只需要把“原书写内容”提取出来即可。我的原则是:解析器忠实呈现用户写的内容,不做大小写归一化。如果两个地方大小写不一致导致关系没匹配上,那说明原脚本本来就有问题,工具不应该自作主张去纠正。
4.2 注释和字符串里的“伪语句”怎么防
这是第一个版本翻车最惨的地方。有人把 SQL 文件发给我的时候,文件头部写着:
code复制/*
* 说明:该脚本用于初始化订单库。
* 历史:2023-05-01 新增 create table 逻辑。
*/
如果解析器用“遇到 CREATE TABLE 就判定为建表语句”的粗暴逻辑,这段注释里的 “create table” 直接触发错误收集。更阴险的情况是字段注释里出现分号,例如 COMMENT '状态:0-待支付;1-已支付'——按分号切分就把一个字段切成了两段。
处理这类问题的核心还是回到状态机:行注释、块注释、字符串内的内容,一律不参与关键字识别。只有状态为“普通SQL”时才扫描 CREATE TABLE。这听起来简单,但想清楚这一点之前,我至少浪费了两三天在救火式修复上。
4.3 大DDL里混着大量 INSERT:数据区必须跳过
很多数据库导出工具生成的 .sql 文件是“结构+数据”一体的,建表语句后面跟着几千上万条 INSERT INTO。一份原本只有 100 张表定义的脚本,混入数据后可能有几十兆。如果解析器老老实实把整个文件读完,前端内存会直接爆炸。
我加了一个“数据区跳过”机制:在状态机里识别到建表块结束,如果接下来的内容是 INSERT INTO、LOCK TABLES、SET FOREIGN_KEY_CHECKS 这类明显不属于表定义的关键字,就快速跳过,直到下一个 CREATE TABLE 出现再恢复收集。实测一份 8MB 的脚本,跳过数据区后解析内存占用不到 50MB,浏览器毫无压力;不跳过的话,页面直接卡死。
这也引出一个体验优化点:解析器在界面里不要搞“全量解析成功/失败”的二元结果。不同表语法风格不一样,可能一张表解析失败、其余 99 张都成功。我最终的做法是每张表单独解析,失败的表在结果列表里标红,并给出失败位置。用户可以直接看到是哪张表出了问题,而不是整份脚本被整体拒绝。
4.4 文件不出本机:本地解析的安全价值
这一条是我认为这个项目最重要的决策之一。早年的在线转换工具,大部分是把你贴的 SQL 传到服务器,由后端解析完再返回结果。用户把整个库表结构交了出去,却完全不知道它被存到了哪里、日志里有没有记录、会不会被第三方拿去。对业务敏感度高的公司,这直接是不可接受的。
所以我坚持用浏览器本地解析,整个处理流程不发起任何网络请求,断网状态下也可以用。从用户角度看,这是一个“不发数据”的工具,天然规避了上传环节的各种风险。更妙的是,因为整个过程不连接数据库、不执行 SQL,工具本身也避开了“SQL注入”相关的攻击面——解析器只读取语法结构,从不把用户输入当可执行的代码去处理。
4.5 性能瓶颈不在解析,而在布局
我拿一份 100 张表、约 3000 行 DDL 的脚本做压测,解析阶段一般在几十毫秒内完成,布局阶段大概两三百毫秒。真正让人头疼的是字段级视图的渲染:如果 100 张表的字段全部展开并渲染成 HTML 卡片,DOM 节点数量会飙到上万,滚动和拖拽都会严重卡顿。
解决方案是分层渲染和按需展开。默认只渲染表级节点,展开字段时才真正创建字段 DOM 节点,同时配合 Canvas 绘图,把“表数量大”的压力留在渲染层内部,而不是全部压在 DOM 上。我还加了一个“专注模式”:选中一张表,只显示它和相邻表的连线,其余全部淡化。这样即使整个项目有几十张表,讲解业务时也能一屏一屏讲清楚,而不是让读者盯着一张密不透风的全局图发呆。
5. 哪些场景不推荐用,以及我下一步想把它做到什么程度
有了这个工具以后,我的日常工作里“快速理清一张老脚本”的效率明显上来了,但它不是什么场景都能覆盖。把边界讲清楚,比把功能吹上天更有价值。
我明确不做的事有三件。第一,不做正向建模:用户通过画 ER 图来生成建表 SQL,那是 MySQL Workbench 这类专业建模工具的领域,在线轻量工具没有必要去对抗。第二,不做反向同步:把 ER 图编辑结果回推成变更脚本(ALTER TABLE),牵扯到索引策略、分区方案、字符集修改等大量复杂语义,做了很难不出错。第三,不做多人协作账号体系:既然是本地优先的轻量工具,就不需要用户注册、登录、建项目。一旦引入账号,就多了数据存储和权限管理,这跟“文件不出本机”的定位直接冲突。
反过来,我觉得后续可以延展的方向有三个,而且性价比都不错。
第一个方向是字段血缘,而不是停留在线级关系。很多查询SQL里其实藏着更深层的关系,比如 a.user_id = b.id 这种 JOIN 条件并不一定出现在建表外键里。解析 SELECT 语句,抽取 column -> column 的对应关系,能补足外键识别覆盖不到的那部分隐式关系。
第二个方向是自然语言导航。不是让用户去学 SQL,而是用户问一句“哪些表关联到了订单表”,工具返回一条路径,比如 user -> orders -> order_item -> product -> category。这对新人理解业务特别友好,也比自己在一张大图里找快得多。
第三个方向是上游格式适配。很多团队根本没有 SQL 脚本,只有 Java 实体类或 Go struct。从这些代码反推表结构,再生成 ER 图,等于把工具的输入源从“SQL文本”扩展到“任意结构定义”。这一步价值很大,因为它真正解决的是“代码与设计文档脱节”的普遍痛点。
最后给想写同类工具的朋友一个排序建议:先做可靠的表解析,再做表级视图,然后做外键关系展示和导出,这三个功能已经能覆盖 80% 的真实需求。别一上来就追求全场拖拽流畅、自动布局优雅、AI 推断精准,那些都是加分项,不是地基。我自己的体会是,这种工具最大的价值不是“自动画图”本身,而是它逼着人把库表结构以可视化的方式沉淀下来。每次新同事入职,我直接甩一张导出的 SVG 文档,省掉的沟通时间远超当初开发这个工具花掉的时间。
