资讯动态

自建表与核心数据表三部分拆分:动态建模与数据治理的落地实践

发布时间:2026/10/22 22:27:24 来源:尧图企业网站定制
做技术这么多年最怕遇到一种需求业务方一边说着“我们的数据就一张表很简单”一边又在项目上线前突然抛出一句“自建表要搞起来哈客户随时要加字段还有那个数据表帮我们拆成三部分”。乍一听像废话但真落地过大型表单类系统的开发应该都懂这句话背后的分量。“自建表与数据表分成三部分”这件事光听标题容易当成一次普通的表结构设计调整实际上它是一个典型的动态建模加数据治理问题。做不好后面每个需求都够呛客户要加字段你改表结构改到想掀桌子业务要按不同部门配权限你写了一大堆if else还是漏数据量稍微上来全表扫描查得人头皮发麻索引都不知道往哪建。这篇文章不聊虚的直接讲清楚我在这类项目里怎么拆、为什么这么拆、具体每一步怎么落地以及在运维中被坑出来的经验教训。1. 为什么要拆三部分业务和技术上的双重驱动先说背景。当时我参与的是一个偏行业垂直领域的管理系统业务方需要同时管理两类数据一类是系统的核心基础数据比如客户档案、订单头、人员信息。这些表结构相对固定字段名、类型、校验规则都是上线前就确定好的业务方也达成了一致不太允许随便动。另一类是“自建表”也就是业务方在运行时自己定义的台账、扩展信息登记表。比如销售部门想登记每个客户的拜访记录除了固定的客户编号、跟进人他们还想自定义“拜访方式”“下次跟进日期”“客户意向度”等字段。这些字段今天可能是这三个下周可能就换成五个。一开始我们也图省事业务方提一个自建表我们就直接建物理表新字段就加列。表面上挺灵活实际上埋了一堆雷存量表不停加字段几百张表的结构越来越难维护文档根本没法写。字段权限控制很痛苦。系统里没有内置的“字段级权限”逻辑只能靠物理表独立授权要是某张表有三十个字段权限配置就成灾难了。数据耦合严重。自建表和核心数据表绞在一起归档、备份、同步全成了麻烦事。真正让我下决心重构的是一次事故。某天业务方在后台自定义了一张“项目风险登记表”加了十几列前台读取数据时拼SQL漏了几个字段导致项目经理看到的风险等级全部对不上差点开会时闹出笑话。那次之后我就意识到自建表必须从物理表里抽出来不能和数据表混着玩。当时我们重新拉了一遍需求最终把整个数据管理分成了三个部分部分职责放什么第一部分核心数据表所有固定结构的基础业务数据如客户表、订单表、员工表第二部分自建表定义区自建表本身的元数据包括表的名称、字段名、字段类型、校验规则、排序、默认值等第三部分自建表数据区自建表实际录入的业务数据按统一结构存储和定义区做关联这三部分看起来只是文件柜里多了几个抽屉但意义完全不同。核心数据表解决的是“系统必须稳定”的问题自建表定义区解决的是“字段随便加”的问题自建表数据区解决的是“加了字段数据往哪放”的问题。彼此解耦各司其职。2. 三部分的数据模型设计思路重点在设计两层元数据很多第一次接触这个方案的人容易把“自建表定义”想得很简单不就是给字段起个名字建个字典吗实际操作时会发现如果定义表设计得不充分后面所有查询、校验、格式转换都会一行一行的程序码写到怀疑人生。我按三部分拆完后真正的核心在于不只建“自建表定义表”还要建“自建字段定义表”两层元数据又分别对应到数据区的不同列。2.1 第一部分核心数据表怎么设计核心数据表在这一套体系里等于“定海神针”。必须保持一个原则核心表里只放所有业务方公认的、长期稳定的、不带“临时想加”这种属性的字段。比如我维护的“客户主表”基础字段通常就是这一堆客户编号唯一索引客户名称所属区域客户类型联系人联系电话创建时间更新时间状态标记这些字段在设计时就要想清楚任何一个字段能不能支撑整个生命周期比如“客户类型”后面可能会被业务方改成“潜在客户、正式客户、流失客户”以外的枚举值这个变更还算可控。但如果业务方说“想给客户加三个扩展编号”那这个需求绝对不能回车直接加到客户主表上而是应该引导业务方走自建表方案。这一点必须克制。客户主表一旦膨胀后续无论是索引维护、数据质量还是权限控制成本都会成倍上涨。我之前见过某公司的客户表里有几百列光一个列表页查询都能把数据库CPU干到90%根上的原因就是他们把每个临时需求都塞进了主表。2.2 第二部分自建表定义区设计自建表定义区分两层第一层是“自建表注册表”也就是描述了现在系统里到底有哪几张用户自定义的虚拟表。这一层记录的是虚拟表编号 虚拟表名称 所属模块 创建人 创建时间 使用状态启用/停用 排序号 扩展配置比如列表页默认是否展示第二层是“自建字段定义表”这一段是重头戏字段定义是否写清楚直接决定自建表数据区的数据能不能被正确解析。每个出现在自建表里的字段都要在这张表里有一行记录字段编号 所属虚拟表编号 字段名称英文标识写入数据时用的键名 字段显示名称界面上看到的文字 字段类型字符串、数字、日期、下拉、多选、布尔等 是否必填 默认值 取值范围下拉选项、数值上下限等 排序号 是否作查询条件 是否列表展示设计这张表的时候有几个细节非常值得注意字段名称必须采用固定英文标识。中文用户很容易在数据库里直接使用中文列名但一旦自建表定义允许业务方随意起中文标识后面写动态查询SQL、做导出、生成接口时全是转码的地狱。这个问题的解决之道是后台维护字段时同时维护“英文标识”和“显示名称”数据区存储用英文标识界面展示用显示名称。字段类型必须做一次收拢。不能太散自建表能创建的类型无非就是文本、长文本、整数、小数、日期、日期时间、单选、多选、布尔这么几种。每种类型在后端解析时又对应不同的数据列。取值范围最好直接存在字段定义表里。如果你在前台做一个单选字段却没有把下拉选项写到定义表那数据区就没法校验合法性查询时也不能按选项值下拉过滤。这两层元数据表就是“第二部分”的实体。2.3 第三部分自建表数据区设计数据区是整个方案里最容易设计歪的地方。我见过有的人把每个自建字段单独弄一列动态加列结果又把我们最初在物理表上遇到的问题原样搬进了自建表区。正确做法是打破“一个字段一列”的传统思路用统一结构的存储模型。数据区我一般会准备这几张表自建表记录主表负责存“哪一条记录属于哪个自建表、是谁创建的、什么时候创建或修改”。自建表字段值表负责存每一条记录里每个字段的值以“一条记录一个字段占一行”的形式存在。自建表关联映射表负责存自建记录和核心数据表记录之间的关联关系比如这张自建表记录关联了哪个客户主表ID、哪个订单ID。这三个表和前面的“三部分”是两个维度。前面的三部分是把数据按照业务生命周期切割开来而数据区下面的这三张表则是为了让自建表数据本身也能以标准化、可检索的方式存储。拿字段值表举例它的核心字段大致如下记录主表ID 字段编号 字符串值 数值值 日期值 文本值 创建人 创建时间为什么要拆成多个值列而不是直接放一个“value”字段这是我在实际开发里踩坑换来的经验。如果只用一个value列所有字段类型全部强转成字符串存是可以存但查的时候就头痛了按日期范围查字符串没法走索引按数值排序字符串排序结果也不对统计某个数值字段的汇总值更是要先把全表字符串转成数值再计算。正确的做法是值列按类型分好短文本、单选、多选、布尔存到字符值列长文本存到文本值列整数、小数存到数值值列日期、日期时间存到日期值列查询时根据字段定义表里的字段类型决定用什么值列来筛选和排序这样既保住了字段的动态性又照顾了数据库的索引和类型转换。这个设计其实很多人眼熟它就是EAVEntity-Attribute-Value模型。EAV这种模式以前被不少同行嫌弃说查询复杂性能差。但说实话在自建表这种“字段个数和内容都不可预知”的场景里它就是最合理的妥协。不能用传统设计思维嫌它复杂因为更复杂的是你已经无从预测业务方下一步会加什么字段。为了把动态字段查询的性能做上去我还额外设计了一张“自建表索引映射表”把高频查询的字段绑定到某个数据库索引方案上比如为日期字段单独维护索引为下拉字段单独维护前缀索引。这个后面实操环节再细讲。3. 三部分拆完后的完整落地过程从建表到写数据查数据空谈模型没意思重要的是每一张表具体长什么样、每个操作怎么做。我这里把项目中的核心步骤重放一遍所有脚本都是可参考的版本。3.1 三部分对应的数据库DDL怎么写第一部分核心数据表标准的业务表设计不多说直接看示例CREATE TABLE customer_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, customer_no VARCHAR(32) NOT NULL COMMENT 客户编号, customer_name VARCHAR(128) NOT NULL, region_code VARCHAR(16) COMMENT 区域编码, customer_type VARCHAR(16) COMMENT 客户类型, contact_name VARCHAR(32), contact_phone VARCHAR(32), status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-正常0-停用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_customer_no (customer_no) );第二部分自建表定义区需要两张表CREATE TABLE custom_table_def ( table_id BIGINT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(64) NOT NULL COMMENT 虚拟表英文名称, table_label VARCHAR(64) NOT NULL COMMENT 虚拟表显示名称, module_code VARCHAR(32) COMMENT 所属模块, status TINYINT NOT NULL DEFAULT 1, sort_no INT NOT NULL DEFAULT 0, created_by BIGINT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_table_name (table_name) ); CREATE TABLE custom_field_def ( field_id BIGINT PRIMARY KEY AUTO_INCREMENT, table_id BIGINT NOT NULL, field_name VARCHAR(64) NOT NULL COMMENT 字段英文标识, field_label VARCHAR(64) NOT NULL COMMENT 字段显示名称, field_type VARCHAR(16) NOT NULL COMMENT 字段类型varchar,text,int,decimal,date,datetime,select,mselect,bool, is_required TINYINT NOT NULL DEFAULT 0, default_value VARCHAR(255), option_values TEXT COMMENT 下拉选项JSON格式, sort_no INT NOT NULL DEFAULT 0, is_query_condition TINYINT NOT NULL DEFAULT 0, is_list_show TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_table_field (table_id, field_name) );第三部分自建表数据区CREATE TABLE custom_table_record ( record_id BIGINT PRIMARY KEY AUTO_INCREMENT, table_id BIGINT NOT NULL COMMENT 自建表ID, ref_customer_id BIGINT COMMENT 关联客户主表ID可空, ref_order_id BIGINT COMMENT 关联订单主表ID可空, title VARCHAR(128) COMMENT 记录的标题字段用于列表页 created_by BIGINT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_table_created (table_id, created_at), KEY idx_ref_customer (ref_customer_id) ); CREATE TABLE custom_table_field_value ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL COMMENT 对应记录主表ID, field_id BIGINT NOT NULL COMMENT 对应字段定义ID, string_value VARCHAR(255), number_value DECIMAL(18, 4), date_value DATETIME, text_value TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_record_field (record_id, field_id), KEY idx_field_string (field_id, string_value), KEY idx_field_date (field_id, date_value) );这套DDL有几个细节我想特别解释一下记录主表里放了ref_customer_id、ref_order_id这种关联核心表的字段是为了一对一、一对多地关联核心数据。如果以后还有其他核心数据表要引用继续在记录主表上追加关联ID列或者放到专门的映射表里都可以。字段值表建了(field_id, string_value)和(field_id, date_value)的联合索引这是为了保证按某个自建字段过滤查询时还能有一点性能兜底。低频场景下走普通查询没问题高频场景下这种做法至少比全表扫描强。记录主表上加了title字段这是为了通用列表页直接展示。因为自建表可能有几十个字段但列表页一般只需要一个标题性的字段没必要每次查列表都去分析字段定义。3.2 业务方创建一个自建表的完整流程演示一下完整链路。业务方在后台点“新建自建表”输入表名叫“重点客户拜访记录”然后添加以下字段拜访日期日期型必填拜访方式单选选项是“电话/上门/视频”客户意向度下拉选项是“高/中/低”本次拜访纪要长文本下次跟进时间日期时间后端要做的操作在custom_table_def插入一行拿到table_id。在custom_field_def批量插入五行每行记录一个字段字段类型分别是date、select、select、text、datetime。给业务方返回“创建成功”同时缓存这个自建表的字段定义方便后续写入和查询。这里还要注意一点必须在事务里完成两张定义表的写入。不然表注册成功了字段定义还没写全前端一点开就是半个空表。3.3 自建表记录新增时数据怎么分配到三部分当用户在前台往“重点客户拜访记录”里新增一条记录后端必须同时操作记录主表和字段值表。逻辑大概是这样1. 校验所有必填字段是否有值 2. 根据字段类型校验值格式比如日期字段不能塞“abc” 3. 往 custom_table_record 插入一条记录拿到 record_id 4. 遍历前端提交的字段值每个字段在 custom_table_field_value 中插入一行 5. 如果是 select 或多选要先比对 option_values 里是否存在防止脏数据具体写代码时建议用一个统一的字段值解析器。解析器的作用是输入一个字段定义对象和用户提交的原始值输出该字段对应的值列类型和数据。例如字段类型是decimal原始值是“12.5”解析器就把numeric_value设为12.5string_value设为“12.5”方便以后模糊搜索。字段类型是date原始值是“2025-04-15”解析器会把date_value转成日期对象string_value存成“2025-04-15”。有条件的项目还可以在解析器里做额外拦截。比如手机号字段如果定义了正则解析前先校验一遍。这个校验规则也需要存进custom_field_def我后来给定义表加了validation_rule VARCHAR(255)一列放正则表达式算是不错的扩展。3.4 自建表数据查询怎样做动态拼条件自建表查询是最容易让人写错的地方。简单说用户在前台输入拜访日期从2025-04-01到2025-04-30客户意向度为“高”后端要先从custom_table_def拿到这个自建表信息从custom_field_def查出所有字段定义然后动态构造SQLSELECT r.record_id, r.title, r.created_by, r.created_at FROM custom_table_record r WHERE r.table_id 1001 AND EXISTS ( SELECT 1 FROM custom_table_field_value v1 WHERE v1.record_id r.record_id AND v1.field_id 101 AND v1.date_value 2025-04-01 AND v1.date_value 2025-04-30 23:59:59 ) AND EXISTS ( SELECT 1 FROM custom_table_field_value v2 WHERE v2.record_id r.record_id AND v2.field_id 102 AND v2.string_value 高 ) ORDER BY r.created_at DESC这里的EXISTS子查询专门用来关联字段值表。如果你把多个字段条件直接JOIN字段一多结果集直接笛卡尔积膨胀性能会非常难看。用EXISTS的好处是每个条件独立判断写起来清晰也方便代码根据条件个数动态拼接。但要注意这个SQL只在数据量小、索引建得好的时候才舒服。一旦某个自建表的数据超过几十万行依然用这种EAV查询方式就有点吃力了。这时候我有几个后手对高频查询的字段把字段值冗余到一张专门的自建表快照表上。或者做一份“宽表”把最常用的筛选字段从值表里捞出来重新放回核心记录主表后面的扩展列。还可以利用数据库的JSON能力不过我对直接把整个自建表数据塞进核心表的JSON字段持保守态度因为索引和查询都很受限制。3.5 更新、删除和核心数据表的联动自建表记录的更新逻辑与插入基本一致1. 删除该记录在 custom_table_field_value 里的所有旧值 2. 重新插入新值 3. 如果有标题字段同步更新 custom_table_record.title删除则分两种逻辑删除给记录主表加一个删除标记字段字段值表不动查询时统一过滤。物理删除删除记录主表的同时必须批量删除字段值表里的值否则会在值表里留下大量孤儿数据导致按字段统计时数据虚高。我强烈建议线上生产环境的自建表数据都走逻辑删除。因为自建表往往承载业务方自己的台账误删以后很难找到责任人物理删掉的数据连审计都做不到。和核心数据表联动上比如客户主表要删除一个客户那么所有ref_customer_id指向该客户的自建表记录要么限制删除要么提示业务方先处理关联数据。这里没有标准答案我在不同项目里做过两种方式最终发现还是“提示确认并同步置为无效”最稳妥直接限制删除容易被业务方投诉影响操作效率。4. 拆完三部分以后怎样把权限和数据隔离做得更细很多做数据表拆分的人一开始只想解决“动态加字段”但实际用下来会发现三部分设计对权限控制的价值甚至更大。传统物理表模式下要给自建表做权限只能“整表授权”。如果两个部门都是做项目管理的他们都想自己维护一份“里程碑台账”但A部门不应该看到B部门的数据物理表方案就不好办。你总不能给每个部门都建一张结构相同的物理表吧那样未来维护字段变更时要把每张表都改一遍。三部分拆分以后权限可以拆到三个层面表级权限控制业务方能不能看到或使用某个自建表。这个直接查custom_table_def的所属模块和状态就行。记录级权限控制业务方只能看到哪些记录。在custom_table_record上增加一个“数据归属部门”字段或者根据created_by判断创建人所在部门查询时额外拼一个归属过滤条件。字段级权限控制某些字段只对特定角色可见。以前这种权限只能靠硬编码现在可以引入一张“自建表字段授权表”记录指定角色能看到哪些field_id查询时在字段定义列表里过滤一遍再返回给前端。这种改造以后同样的自建表底子可按部门配数据范围可按角色配可见字段。实操中经常遇到“销售部门想在自己客户拜访表里加一个‘竞争厂商分析’但这句话只有销售总监能看”这种需求在字段级权限的加持下配置项点几下就能搞定。性能方面三部分拆分的优点在于核心数据表保持小而精自建表的数据落在统一结构里索引和归档的策略可以统一处理。数据量大的自建表可以单独做分区比如按created_at按月分区。核心数据表和自建表定义区互不干扰备份也能拆分对待核心表做每日备份自建表数据区做增量备份磁盘压力和恢复时间都更可控。5. 常见问题和排查技巧都是踩坑换来的这个方案我在应用过程中没少遇坑这里把最高频的几个问题列出来给遇到类似问题的同行做个参考。5.1 查询速度越来越慢怎么办自建表数据区最怕的就是“查一个列表先把所有字段值都扫一遍”。某一个自建表如果很长时间没维护字段值表行数可能比记录主表多几十倍。这时候查询慢是必然的。先检查SQL的过滤条件有没有走到索引。常见问题在于值表里多条件组合使用了OR或者拼了复杂的JOIN。排查时第一步看执行计划确认过滤字段是不是命中了(field_id, string_value)或(field_id, date_value)的联合索引。如果一条记录要查回字段值表十几次横向扩展再大也扛不住。我后来处理性能时说一不二的原则就是自建表数据量超过一定阈值高频业务必须做“宽表冗余”。虽然宽表又沾了一点“动态加列”的味道但这里的加列是后台定时任务根据高频查询字段自动维护不再是业务方手动乱加。5.2 字段值在值表里出现类型错乱这个问题往往不是数据库的锅而是后端解析器写得不严谨。比如明明字段类型是整数有人直接把字符串“2.5”塞进number_value数据库字段是DECIMAL它不会主动报错只会做隐式转换结果就出现了脏数据。排查技巧定期跑一条SQL按field_id关联字段定义表检查值类型和定义是否一致。比如对field_type int的字段查所有number_value的小数位大于0的情况。SELECT v.record_id, v.field_id, v.number_value FROM custom_table_field_value v JOIN custom_field_def f ON v.field_id f.field_id WHERE f.field_type int AND ROUND(v.number_value, 0) v.number_value查到以后批量修正。这个脚本我在项目上线初期基本每周都要跑一次后来后端解析器和前端校验补齐了脏数据才慢慢消失。5.3 自建表数据被“配错权限”导致越权查看在给自建表配字段级权限时容易出现一种尴尬角色A本来看不到“意向度”字段但是通过接口直接从客户端看见了。原因很简单后端的权限判断只做了列表返回时的字段过滤却没有在详情查询接口再过滤一遍。排查这种问题要统一封装“自建表字段获取方法”所有接口都从这个方法里拿字段列表不能有的接口自己查字段定义表有的接口又走另一个方法。这是我在代码里反复强调的权限过滤要放在字段元数据获取的源头而不是散落在每个接口里。5.4 并发新增自建表记录导致死锁或主键冲突有时候业务方会多个终端同时录入自建表数据如果代码是先插入记录主表再逐条插入字段值表中途发生异常事务回滚不彻底很容易出现记录主表插进去了值表缺一部分。这里我建议把整个自建表记录和值表写入放在同一个数据库事务里并且对记录主表的主键用数据库自增ID不要用业务方传入的字段做复合主键。项目里出现过的死锁就是因为在值表上用了(record_id, field_id)做主键并发插入时先删后插的顺序不一致导致互相锁住。调整成唯一索引而不是主键以后配合事务问题才缓解。5.5 自建表删了字段旧数据怎么办业务方常常会删掉某个自定义字段但历史数据可能还大量存在。如果直接在字段定义表里删掉一行旧数据的值就留在值表里成为孤儿行。我的做法是给字段定义表加一个“停用标记”不直接物理删除。查询时过滤掉停用字段后台维护和审计还能看到旧字段。这样既不影响新录入也能保留历史数据等确认一年后没人再用它查询了再做物理清理。6. 针对数据和扩展的几点个人心得“自建表与数据表分成三部分”听起来是个结构性话题但真正做完这一套我最大的感受是它改变的不只是表结构更是团队面对业务需求的姿势。以前业务方提“加个字段”开发第一反应是评估工作量、改表、上线现在业务方提“加个字段”开发只需要看是不是建立一个新的自建表或者在已有自建表定义区加一行剩下的全部由统一机制承接。三部分拆分以后还有一件辅助工作不能省尽量给业务方配置一个可视化的字段设计器不要让他们去写配置JSON或者SQL。我自己第一次做这个的时候用的是后台网页拖拽式表单设计器字段类型、选项、校验规则都在界面上操作然后自动生成字段定义表数据。这一步体验做好能减少大量的无效沟通。最后再分享一个经验和提醒这套方案是“扩展性优先”并不代表所有场景都要无脑拆。如果你的项目核心数据表就是固定几张业务方从来没有动态加字段的需求那硬拆成三部分反而增加系统复杂度不值得。只有在面临表单可变、业务方不能每次提需求都走开发排期、数据又有权限隔离需求的时候三部分拆法才会变成真正的核心竞争力。思路可以提前储备但不要为一个不存在的问题过度设计。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑