国产化数据库下动态公式存储的表结构设计与实践
这几年国产化数据库替换是一个躲不开的话题从办公系统到核心业务系统都在逐步迁移。我手上正好有一个比较有代表性的场景网页编辑器里的动态公式存储。这里说的动态公式可不是Word里那种录进去就完事的静态公式而是像财务计算、工程项目造价、甚至在线考试判分里那种带变量、带引用的公式——用户在前端编辑器里拖拖拉拉拼出一条公式存下来之后还要参与后续的计算和渲染。这个需求看着简单真正动手做的时候会发现坑不少。尤其是放到国产化数据库这个背景下很多在MySQL或Oracle里用得非常顺手的方案换个库就得重新评估。这篇文章把我踩过的坑、验证过的方案、最终落地的表结构设计都梳理一遍如果你也在做类似的事情可以直接参考。1. 网页编辑器里的动态公式到底是个什么东西先明确一个事我们需要存储的对象是什么。只有把这个问题想清楚后面设计表结构、选字段类型时才有依据。1.1 三种常见形态LaTeX源码、MathML和业务DSL网页编辑器里的公式从技术形态上分大致有三类第一类是LaTeX源码。很多数学公式编辑器像MathJax、KaTeX、富文本编辑器里的公式插件底层都是把用户输入的公式编译成LaTeX字符串比如\frac{ab}{c-d}或者\sum_{i1}^{n} x_i。LaTeX的好处是体积小、表达能力强、可读性也还行坏处是前端要渲染时必须引入解析库。第二类是MathML。这是W3C标准的数学标记语言属于XML的一种方言结构化程度非常高每个符号、每个运算符在树上都有自己的节点。好处是标准、可被程序精确解析坏处是极度冗长——一条ab的公式MathML能写出上百个标签。第三类是业务自定义DSL。这类公式和数学公式关系不大更像是电子表格里的表达式比如{unit_price} * {quantity} * (1 - {discount_rate})甚至带条件分支if(score 60, pass, fail)。这种公式在造价计算、计费系统、审批流引擎里非常常见。在真实项目里这三种形态经常混着出现。财务报销的系统可能既有报销金额计算表达式又有打印单据时需要的数学公式展示。1.2 动态性体现在哪几个层面动态公式这个概念里的动态两个字才是存储设计的核心难点。我总结下来动态性主要体现在三个层面变量动态公式里的参数不是固定值而是引用业务字段比如单价、数量、税率。这些字段的值在运行时从数据库或前端请求里取。结构动态公式本身不是固定的用户可以随时在编辑器里增删运算符、调整括号嵌套结构。今天存进去的公式和明天存进去的公式结构可能完全不同。结果动态同样的公式因为引用的变量值不同每次计算的结果都不一样。这要求存储不仅需要保留公式本身还要保留公式变量的取数逻辑。这三个层面叠加在一起就让公式不再是简单的一行文本更接近一个结构化的、有依赖关系的逻辑节点。1.3 为什么不能当普通纯文本直接存很多团队图省事直接在业务表里加一个formula_content varchar(2000)把前端编辑器里的公式源码整个存进去。这个做法在演示Demo里跑得通真正上线后必然出问题。最直接的问题有三个第一LaTeX源码里全是反斜杠和花括号如果编辑器还允许用户写HTML标签或者自定义脚本表达式那么SQL注入和存储型XSS的防线就会变得很脆弱第二公式引用的业务变量一旦改名或删除库里存的公式字符串完全感知不到等到计算的时候才发现全是空值第三如果公式需要参与版本管理、对比差异、回收站恢复纯文本字段根本提供不了细粒度的能力。所以我的结论是动态公式必须作为结构化数据来存而不是当作普通文本字符串来存。这个认知是整个方案的地基后面所有设计都围绕它展开。2. 国产化数据库的字段类型先摸清底牌再动手国产化数据库这个说法覆盖的范围挺广的达梦、人大金仓、GaussDB、OceanBase、TiDB这些都算。它们虽然大体上都兼容MySQL或PostgreSQL的语法但在字段类型支持上各有差异。如果你像我一样在设计表结构前没有先确认目标库的类型支持清单后面写SQL的时候绝对会碰壁。2.1 CLOB/NTEXT最朴素但最粗糙的保底方案所有的国产化数据库都支持CLOB或对应的长文本类型这个没悬念。但用CLOB存公式有两个很现实的问题。一是操作受限。很多数据库里CLOB字段不能直接参与LIKE模糊查询不能直接在ORDER BY里排序不能建普通索引。如果公式库里要做按内容搜索CLOB这条路基本走不通除非建全文索引而全文索引对LaTeX这种满是符号的内容效果很差。二是在ORM框架里处理麻烦。比如MyBatis-Plus在映射CLOB字段时如果Java实体用String接有时会报类型转换异常需要额外用TypeHandler处理。这些小毛病不至于挡住上线但确实会让日常开发很烦躁。所以我的建议是CLOB只适合当存档层——比如历史版本的公式快照、渲染出来的HTML缓存而主存储不要用CLOB。2.2 JSON/JSONB半结构化数据的甜点区现在主流的国产化数据库基本都支持JSON或JSONB类型比如GaussDB、OceanBase、人大金仓基于PostgreSQL内核的版本、TiDB这些。JSON类型的核心价值在于公式的各个组成部分可以拆成独立的JSON键值来存。举个例子一条公式在存储层可以长这样{ latex: \\frac{a}{b}, mathml: math.../math, variables: [a, b], dsl: {a} / {b}, renderType: latex, version: 3 }这样一个JSON对象就把LaTeX源、MathML、变量清单、业务DSL全放一起了查询时可以按变量名过滤也可以直接取整个对象。这里要特别说一下JSON和JSONB的区别。JSONB在写入时会解析并规范化JSON内容存的是二进制格式支持GIN索引可以进行、?这类高效操作符查询。而JSON只是存原始文本解析开销在每次查询时才发生。人大金仓、GaussDB这些基于PostgreSQL内核的库强烈建议直接用JSONB达梦的JSON支持做得也还行但语法细节上跟PG有出入用之前一定先看版本对应的文档。2.3 XML类型当公式自带结构树时MathML本质上是XML所以如果你们的公式编辑器输出的核心形态就是MathML并且后续有大量的结构遍历需求比如计算某个节点的运算优先级、提取公式里所有变量名那可以考虑用数据库自带的XML类型。国产化数据库里达梦对XML的支持相对完善有XMLQuery、XMLTable这类函数GaussDB也支持XML类型。但我的实际感受是除非你们团队对XPath、XQuery非常熟否则别轻易上XML类型。原因是业务开发人员普遍对XML操作不熟调试成本高而且ORM映射XML类型比JSON麻烦得多。2.4 不同库的支持差异对照表我在项目里验证过几个比较有代表性的库把它们的表现整理成了下表方便你们选型时心里有数数据库长文本JSONJSONBXML备注达梦DM8CLOB/TEXT支持不支持约等于JSON支持语法与Oracle接近但并非完全一致人大金仓KingbaseESTEXT支持支持支持PostgreSQL系兼容性最好GaussDBCLOB/TEXT支持支持支持分布式部署时JSONB有分片约束OceanBaseMySQL模式LONGTEXT支持不支持不支持语法兼容MySQLJSON函数用JSON_EXTRACT等TiDBLONGTEXT支持不支持为JSON不支持兼容MySQL但JSON函数实现不完全一致表格里不支持不代表不能用而是说不要指望它有JSONB那么强的索引和查询能力。如果你的目标库是OceanBase或TiDB那方案就要调整为JSON或者干脆用多个独立字段。3. 我最终采用的存储模型一张主表加两张辅助表摸清了库的类型能力之后我开始设计表结构。这里的原则是公式的展示形态和计算逻辑分离但存储时放在同一条记录里避免前端展示用一个表、后端计算用另一个表导致数据不一致。3.1 主表formula_definition一个公式一个主记录先看主表的设计CREATE TABLE formula_definition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, formula_code VARCHAR(64) NOT NULL COMMENT 公式业务编码如 FEE_CALC_001, formula_name VARCHAR(128) NOT NULL COMMENT 公式名称给用户看的, formula_type VARCHAR(16) NOT NULL COMMENT 公式类型: CHEMICAL/MATH/BUSINESS_EXPRESSION, latex_source CLOB COMMENT LaTeX源码前端数学公式渲染用, mathml_source CLOB COMMENT MathML结构兼容Office和标准解析, dsl_expression CLOB COMMENT 业务DSL表达式后端计算引擎用, variable_schema JSONB COMMENT 变量定义JSON详细结构见3.2, ui_schema JSONB COMMENT 编辑器UI布局信息用于恢复编辑器状态, version INT NOT NULL DEFAULT 1 COMMENT 版本号乐观锁控制, status TINYINT NOT NULL DEFAULT 1 COMMENT 1生效 0草稿 2已下线, created_by VARCHAR(32), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_code_version (formula_code, version), KEY idx_type_status (formula_type, status) ) COMMENT 动态公式定义主表;几个字段的设计意图我要特别说明一下。formula_type字段把公式分成数学公式、化学公式、业务表达式几大类因为它们的渲染和校验逻辑完全不同。化学公式里会有下标数字业务表达式里会有变量引用不能混用一套校验规则。latex_source、mathml_source、dsl_expression三个字段同时存在是刻意为之的。最初我想过只存LaTeX因为它是人类可读性最好的。但后来发现LaTeX渲染成前端展示没问题可如果要和其他系统做数据交换比如导入导出Word格式MathML是绕不开的桥梁。而业务计算只能认DSL表达式。所以三个字段各管一段LaTeX负责前端展示MathML负责系统间互操作DSL负责后端计算。3.2 变量Schema动态公式的变量注册表动态公式的存储难点很大程度上在变量管理上。我单独设计了一个JSONB字段variable_schema专门记录公式用到的所有变量。结构如下{ variables: [ { var_code: unit_price, var_name: 单价, var_type: DECIMAL, source: BIZ_FIELD, source_table: product, source_column: price, required: true, default_value: 0 }, { var_code: discount_rate, var_name: 折扣率, var_type: DECIMAL, source: BIZ_FIELD, source_table: contract, source_column: discount_rate, required: false, default_value: 1 }, { var_code: calibrated_coefficient, var_name: 校准系数, var_type: DECIMAL, source: FORMULA_REF, ref_formula_code: ADJUST_FACTOR_007, required: true, default_value: null } ] }这个结构里的几个设计点值得展开source字段区分了变量的来源。BIZ_FIELD表示直接映射业务表字段FORMULA_REF表示这个变量是另一条公式的计算结果。这就形成了公式之间的依赖链。在实际业务里一条费用计算公式经常要引用另一条税率公式的结果所以有了这个字段后端在计算时就能自动递归展开依赖树。required和default_value配套使用。前端编辑器保存公式时后端会校验这两个值必填变量不能为空非必填变量给了默认值。这个校验动作在保存时做一次计算时做一次能挡掉大部分运行时的空指针问题。为什么要把变量信息嵌套存JSONB而不是单独建一张formula_variable关联表我权衡过也实际试过关联表方案最后放弃的原因很实际公式的变量集合是跟着公式版本走的不是独立实体。每次编辑公式变量集合大概率同步变化如果分表存事务控制要跨两张表版本回滚时要同时处理公式和变量数据复杂度翻倍。JSONB嵌套在一条记录里天然和公式同生共死版本回滚时只需切换一条记录。3.3 依赖关系快照表formula_dependency捕获公式与公式的引用链前面提到变量可以引用其他公式那公式之间的依赖就不能不管理。我建了一张依赖表CREATE TABLE formula_dependency ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_formula BIGINT NOT NULL COMMENT 引用方公式定义ID, child_formula BIGINT NOT NULL COMMENT 被引用方公式定义ID, relation_type VARCHAR(16) DEFAULT VARIABLE_REF COMMENT 依赖类型: 变量引用/子公式调用, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_parent_child (parent_formula, child_formula) ) COMMENT 公式依赖关系表;这张表最大的用处是做循环依赖检测。线上真的出现过这样的场景公式A引用了公式B的结果作为变量公式B引用了公式A的结果作为变量两边一算就死循环了数据库连接池被拖垮。有了依赖表保存公式时用递归CTE查一下引用图里有没有环有就直接拦下。依赖表的数据是在保存公式时动态生成的解析JSONB里的variable_schema把所有sourceFORMULA_REF的变量提取出来转成依赖记录。这个解析动作不是在前端做的而是在后端一个独立的解析服务里。3.4 历史版本表formula_version_history公式的时光机公式这种东西涉及计算逻辑一旦线上出错最迫切的需求就是回到上一个版本。主表里虽然有version字段但版本之间是覆盖关系上一个版本被UPDATE后信息就没了。我单独做了一个历史版本设计用同结构的表加上快照存储方式实现CREATE TABLE formula_version_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, formula_id BIGINT NOT NULL COMMENT 对应主表formula_definition.id, formula_code VARCHAR(64) NOT NULL, version INT NOT NULL, latex_source CLOB, mathml_source CLOB, dsl_expression CLOB, variable_schema JSONB, ui_schema JSONB, change_reason VARCHAR(255) COMMENT 变更原因比如税率调整/修复除零问题, changed_by VARCHAR(32), changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_formula_version (formula_id, version) ) COMMENT 公式历史版本表;每次主表更新公式都会先INSERT一条旧版本数据到history表。这里有个实际操作的细节乐观锁version字段的更新要跟历史表插入放在同一个事务里否则并发编辑时会出现版本号覆盖丢失。4. 存储链路里的四个大坑我都踩过替你们标记好设计讲完了接下来是存储链路里我实打实踩过的坑。这几个坑每个都得花一两天排查希望你们看完能直接绕开。4.1 大坑一LaTeX源码里的反斜杠在JSON和Java字符串里会被三重转义LaTeX源码的特征就是满屏的反斜杠比如分数\frac{1}{2}。这个反斜杠存到JSONB里时JSON规范要求反斜杠必须转义为\\所以落库的内容会是\\frac{1}{2}。等用Java读到JSONB字段时Java字符串里又会多一层转义。如果中间用的还是MyBatis框架那TypeHandler在序列化和反序列化之间再来一轮转义三层叠加调试的时候光看日志根本看不出问题在哪。我最终的解决方案是在DTO层统一用JSON库Jackson/Fastjson去序列化整个对象而不是手拼JSON字符串。手拼字符串的时候脑子稍微一乱就会多写或少写反斜杠导致JSON解析直接报错。让JSON库统一处理转义至少能保证语法层不出问题。另外在存取LaTeX时建议插入前和读取后都做一次规范化处理用工具方法统一把\r\n和多余空格去掉。因为不同浏览器里的编辑器粘贴进来的公式源码换行符不一样有的用LF有的用CRLF直接落库后两个版本看起来一样实际字节不同做版本对比时就会被误判为内容变更。4.2 大坑二JSONB的键顺序和脏数据比你想的更麻烦MySQL和PostgreSQL系的JSONB有一个隐蔽的区别PG系的JSONB会自动按键名排序存储不保证原始插入的顺序。如果你前后端直接拿JSONB字段做签名校验、做内容比对顺序问题会扰得你怀疑人生。我的经验是JSONB字段在数据库里只承担存储和查询的职责前端传过来的原始JSON字符串如果需要做完整性校验应该另开一个字段存原文或者存摘要。我在设计里没有单独加hash字段但如果你要审计用户提交的原始内容我建议加上raw_json_hash字段存SHA256摘要这样不管JSONB内部怎么排序都不影响审计。脏数据的坑更隐蔽。如果前端编辑器在保存时没做变量去重variable_schema里可能出现两个var_code一样的变量一个requiredtrue一个requiredfalse。后端解析时取最后一个值前端渲染时又只展示第一个两边不一致计算就出错了。解决办法是在后端保存接口里做合并校验发现重复变量直接返回参数错误让前端修复后再提交而不是后台悄悄吞掉。4.3 大坑三公式计算时递归查询依赖性能比想象中差前面提到依赖表用来防循环和追踪引用链那如果公式层级很深比如A依赖BB依赖CC依赖D一直链到十层以上计算时如果每层都发起一次数据库查询性能会直接崩掉。我第一次实现时没考虑这个问题在计算服务里用递归函数一层层查依赖表结果做压测时发现接口P95响应时间超过了3秒根本没法用。后面优化成两段式第一段计算前把当前公式及其所有依赖链上的公式通过一个递归CTE查询一次性拉出来第二段在内存里按依赖关系做拓扑排序然后按顺序计算。这样数据库只查一次后面的计算全部在应用层完成P95降到了200毫秒以内。递归CTE查询在GaussDB和人金仓里都支持得不错语法和PG一致。OceanBase在MySQL模式下对递归CTE的支持会弱一些如果目标库是它建议改成在应用层多次查询后合并。4.4 大坑四全文检索和公式内容的噪音符号问题很多业务场景需要在编辑器上方提供一个公式搜索框让用户按公式名称或公式内容搜。要是直接用数据库自带的全文检索LaTeX和MathML里的反斜杠、花括号、大于小于号会变成一堆噪音词元。比如搜分数两个字结果把库里所有包含\frac的公式都当成相关度很高的记录返回因为反斜杠被分词器拆成了大量单字符词元干扰了相关性评分。我的处理方式很务实全文检索不直接索引公式源码索引的是公式名称和人工维护的业务标签。在formula_definition表里增加一个search_tags字段内容是费用计算 含税 折扣 单价这样的空格分词文本配合数据库的分词器做全文检索。公式源码保持原样不参与全文索引。这样做仍然存在搜索不到的情况——用户搜的恰好是公式里的某个变量名可变量的业务名称在JSONB里而不是search_tags里。所以我在save接口里加了逻辑保存公式时把变量清单里的var_name字段全部提取出来自动追加到search_tags末尾。这样搜索单价也能命中对应的公式又不会引入LaTeX符号的噪音。5. 从存储回到应用一条完整的读写闭环存得好不好最终要看读出来能不能用。我最后把整个读写链路串一下你们会发现公式存储不是孤立的一张表它和编辑器的交互方式强相关。5.1 前端编辑器保存时的数据清单前端公式编辑器在保存时需要提交给后端的不是一条字符串而是一个完整的数据包至少包含四块公式的展示内容LaTeX源码和MathML这两个一起提交后端校验后分别存两个CLOB字段。计算表达式如果是业务表达式类公式前端需要在可视化编辑器里拖拽出的DSL表达式同步提交。同一个公式可能既有展示用的LaTeX又有计算用的DSL两个都要传。变量清单编辑器里引用的变量格式就是我前面定义的variable_schema前端也要按这个结构组装。UI布局信息编辑器里有几个公式块、它们的排列顺序、宽度高度等界面属性存到ui_schema字段里。这样下次用户打开编辑器时能原样还原上次编辑的界面而不是所有公式挤在一起。后端保存接口收到这些数据后做三件事第一对LaTeX做基础语法校验用解析库判断括号是否匹配第二解析DSL表达式中的变量引用与提交的variable_schema做比对发现缺漏就拦截第三把FORMULA_REF类型的变量全部取出做循环依赖检测顺便刷新依赖表数据。这三步检查都过了才允许INSERT或UPDATE主表并写入历史版本。5.2 读取时的双通道展示走LaTeX计算走DSL读取公式数据时后端返回给前端的是组装好的详细视图。前端拿到后分两路使用展示路径用LaTeX源码交给MathJax渲染成图形公式交互路径走DSL表达式和变量Schema用于在前端继续编辑或触发计算。一个我特别留意的细节是前端展示时不要直接从数据库的CLOB字段里取出LaTeX裸文本拼接进HTML。LaTeX源码如果包含script之类的HTML标签编辑器过滤不严格时有可能发生直接渲染就构成存储型XSS。正确做法是后端在返回时用HTML转义函数处理一遍或者用JSON响应把LaTeX作为纯文本字段返回前端用textContent方式渲染。5.3 动态计算时的取数链路公式真正参与业务计算时后端计算引擎拿到DSL表达式后会解析出变量列表再根据variable_schema里的source字段来决定取数方式BIZ_FIELD类型从对应的业务表中按主键取字段值。FORMULA_REF类型递归查找被引用的公式先计算被引用公式的结果再把结果作为当前公式的入参。如果变量在运行时取不到值先看有没有default_value有就用默认值没有就抛出明确的业务异常告诉前端是哪个变量缺值。我在计算引擎外层套了一个缓存层把已经计算过的公式结果按(formula_code, version, biz_data_id)三元组缓存起来。同一份业务数据在同一个版本下多次计算时直接命中缓存性能和稳定性都能得到保障。版本号在这里的作用就非常明显公式版本一变缓存key就变旧缓存自然失效不会出现改完公式结果还是老数据这种极其难排查的线上问题。5.4 公式版本切换的灰度策略最后聊一个存储之外但很关键的点公式改完后新版本是直接全量生效还是需要一个切换过程我的做法是主表的status字段里区分草稿和生效。新版本先存为草稿状态在后台管理页面让业务人员预览、对比新旧版本计算结果确认没问题后一键切换生效。切换动作只是把status从0改成1并把旧版本即时写入历史表。整个过程不用动数据所有公式定义都保留在库里只是当前生效版本的指针变了。这种灰度切换对生产系统特别重要。动态公式往往直接影响计算结果一旦新版本有逻辑错误后果可能影响一大批单据。存储设计如果从一开始就支持版本切换线上出问题时就能快速回滚这比临时改代码重新发版快几个数量级。写在最后的一点个人体会做这个项目给我最大的一个教训是存储设计不能只盯着数据库类型选型要先把公式在业务里的全生命周期想清楚。一条公式从编辑、保存、校验、计算、版本切换、历史追溯每一环都会影响表结构。国产化数据库在类型支持上确实和MySQL/Oracle有差异但核心设计思想是通用的——JSONB也好CLOB也好都只是工具关键还是你有没有把动态这两个字背后的变量、依赖、版本这些概念建好模。如果你也在做类似方案我建议从这三个问题开始自查公式的变量引用会不会变公式之间的依赖有没有可能出现环公式版本回滚时数据能不能恢复这三个问题答案都清楚了表结构自然就清晰了。