ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

网页编辑器动态公式的国产化数据库存储设计与实践

网页编辑器动态公式的国产化数据库存储设计与实践 做国产化适配改造这小半年我最大的体会是很多问题不是“能不能存”的问题而是“怎么存才能不给自己埋雷”。拿网页编辑器里的动态公式来说表面上看就是一个字符串存进数据库不就完事了真上手你会遇到字段类型怎么选、JSON和TEXT哪个更稳、中文公式名乱码、版本怎么回溯、怎么反查引用字段这一连串问题。而且只要数据库换成了国产库原来在MySQL里养成的习惯往往直接失效——函数对不上、类型不兼容、JSON操作符脾气完全不一样。这篇文章我把这套“网页编辑器动态公式 国产化数据库存储”的方案完整捋一遍。内容不挑具体产品形态表单设计器、报表引擎、规则引擎、低代码平台的表达式配置都能套用。我尽量把每一步为什么这么做讲清楚也会把我在达梦、openGauss、KingbaseES上踩过的坑直接摊开说免得你再走一遍弯路。1. 先想清楚网页编辑器里的动态公式到底是“什么”1.1 动态公式的三种存在形态很多人一说动态公式脑子里蹦出来的就是一行字符串比如做个库存预警规则前端编辑器拖一拖后端拿到的是IF([库存量] [安全库存], 补货, 正常)。这确实是公式但它只是公式在系统里的一种存在形态。我在实际项目里见过三种形态而且经常是同一条公式同时具备两三种形态纯文本表达式给人看、给解析引擎直接执行的字符串。上例里的IF(...)就是。报表引擎拿到这段文本后做词法分析、语法树构建然后执行。结构化JSON/AST给网页编辑器还原用的。前端拖拽生成的不是字符串而是一棵对象树比如{op:multiply,left:{op:sum,field:order_amount},right:{value:0.8}}。编辑器靠这棵AST把公式的图形化界面恢复出来用户才能继续拖拽修改。带元数据的公式描述不只有表达式本身还包含公式分类、所属模块、创建人、版本号、依赖的数据源编号等。这一层往往容易被忽略但恰恰是它决定了你能不能在几千条公式里快速找到“到底哪些公式用到了[安全库存]这个字段”。如果你的存储方案只盯着第一种那等于把公式当成一段毫无结构的文本丢进数据库。短期能跑后期做公式检索、版本对比、血缘分析的时候就会痛苦不堪。1.2 “动态”二字背后的存储要求关于公式字面意思都懂但“动态”这两个字才是存储设计的命门。我梳理了一下动态性主要体现在三个地方公式内容是动态拼出来的编辑器允许用户拖字段、选函数、填参数公式在用户操作过程中随时变化保存时才是最终态。这意味着数据库要做的是“快照存储”而不是“流式存储”。公式引用的对象是动态的公式里的[库存量]、[安全库存]这类字段在数据库里可能有独立的数据字典或字段注册表。公式存下来之后这些引用对象本身可能改名、下线、变更口径。你的表结构必须能支撑这种关联关系否则字段删了公式就成了悬空引用运行时报错都查不到原因。公式自身会动态演化运营人员这个月觉得预警阈值是“低于10补货”下个月改成“低于20且近7天销售额下降10%才补货”。旧版本不能直接覆盖丢失因为你正在跑的报表可能还依赖旧版逻辑出问题时要能回滚对比。这三点总结下来就一句话动态公式的存储本质上是在存“可解析、可追溯、可关联的结构化对象”而不是存“一段会变化的话”。1.3 存储需求的本质存下来只是开始我见过不少团队设计公式存储表就三列id、公式内容、创建时间。等业务跑起来就会发现四个逃不掉的诉求可解析公式文本要能被后端引擎重新解析执行也就是存取格式必须标准不能掺杂编辑器私有字符。可查询做运营分析时要能回答“哪条规则超时了”“哪些公式引用了已删除字段”这类问题靠的就不是光秃秃的TEXT字段。可回溯公式是谁在什么时候改的内容前后差了什么审计和排障都要用。可关联公式与报表、数据源、数据字典之间存在外键或引用关系设计上要给这些关系留位置。把这些诉求列出来你就明白为什么“存字符串”只是起点。下面我们顺着这个思路看国产化数据库里到底怎么选型、怎么建表。2. 国产化数据库选型字段类型与JSON支持的差异2.1 TEXT还是CLOB还是VARCHAR先解决“能放多少”动态公式的长度跨度非常大。简单的四则运算十来个字符就够复杂的嵌套规则、多条件组合几百上千个字符很正常。再加上我前边说的JSON/AST形态只会更长。所以我们第一个要决策的字段类型。各库对长文本的支持路径不完全一样数据库长文本类型我的实测印象openGaussTEXT / CLOB / VARCHAR有长度上限基于PostgreSQL用TEXT最顺手KingbaseESTEXT / CLOB也是PG系兼容性最像PGOceanBaseMySQL模式TEXT / LONGTEXT按MySQL习惯来不会有问题达梦DM8TEXT / CLOB / VARCHAR官方文档强调VARCHAR长度有上限超了大文本建议用CLOB我自己有一条非常朴素的原则能确定长度的元数据字段用VARCHAR公式主内容一律用TEXT或CLOB。为什么因为公式内容的长度一定会随业务膨胀。你规划时以为一条规则撑死200字符结果运营塞进来一段带注释模板的表达式直接破千VARCHAR就要面临改造。而且长VARCHAR在国产库里做索引扫描时并不占优势反而TEXT/CLOB在大部分PG系实现里处理得干净利落。注意换算到达梦时如果你习惯用MySQL的LONGTEXT达梦没有这个类型要用CLOB。反过来在openGauss里用CLOB本质上还是长文本类型别把Oracle的CLOB操作习惯完全照搬有些字符串函数在达梦和openGauss里的行为还是有差异。2.2 JSON类型每个国产库的脾气不一样公式的AST结构、元数据、引擎配置最佳载体就是JSON。可国产库对JSON的支持差异坑比想象中大。我把常见几种列出来KingbaseES V8/V9底层继承PostgreSQLJSON类型和JSONB类型都能用函数算子也基本对齐PG生态。这是我用下来最省心的。openGauss主打PG兼容有JSONB类型但部分JSON操作函数、操作符的行为跟原版PostgreSQL有细微差异。比如某些隐式类型转换场景你在PG里写能跑在openGauss上就得加类型声明。多版本之间的差异也明显需要以当前版本的官方文档为准。OceanBaseMySQL模式JSON类型向MySQL对齐用-和-提取字段的语法更自然。达梦DM8JSON是“套”在关系模型上的一种能力语法和Oracle的JSON函数路线更接近。你用习惯了PG系的jsonb_set、jsonb_array_elements到达梦会发现函数体系完全不是一回事。所以我在这里给一个很关键的提醒技术选型的时候一定先确认你们前端编辑器产出的AST是PG风格的JSON函数处理顺手还是MySQL风格的顺手。如果前端已经定了后端换库成本就明摆在那儿。2.3 字符集与排序规则中文公式名是最大变量国产化改造的项目里公式内容的中文化程度往往超出预期。字段名可能是“[本月销售额]”、函数注释可能是中文、页面配置里还可能存大段的模板说明。这时候存储端有两个坑库/表/字段的字符集有些库默认字符集是GBK系列或者安装时被设置成了非UTF-8。你把UTF-8编码的公式文本插进去再查出来中文乱码前端渲染直接花屏。排序规则做公式名称排序、去重统计的时候不同collation对中文的排序结果天差地别。你甚至会在同一个库的不同表里因为collation不一致导致JOIN时出现“字符集不一致无法比较”的报错。我的建议是在建库时就锁定UTF-8并且所有业务表的字符集、排序规则保持统一。不要在客户端连接串里靠characterEncoding硬转数据库端和连接串两端都要确认。公式里还经常出现≤、≥、→、中文引号这类符号有些库的GBK字符集压根收不下一插就报“字符串截断”或“无效字符”。2.4 我的选型建议主字段用TEXT结构化信息用JSONB我在多个国产化项目里最后落地的方案基本是“双轨存储”公式主文本存标准化后的表达式文本给后端解析引擎执行。这就是业务上的“真相源”。结构化JSON字段存AST、元数据、依赖字段列表给前端编辑器还原、给后端做检索和分析。两个字段在存的时候由后端统一生成保证一致。为什么不只存JSON因为后端引擎要执行公式直接跑AST虽然也行但很多自研表达式引擎本身就是基于文本解析的你让引擎执行JSON树反而要再写一套解释器。而且排查问题的时候能直接打开数据库看到一行可读的公式文本比解析一坨嵌套JSON要快得多。为什么不只存TEXT因为前端编辑器打开时如果只有文本要做一次完整parse才能还原AST这个parse过程在复杂公式上性能和稳定性都不可控而且容易丢失前端特有的布局参数。所以双轨存储生气少一点。3. 表结构落地一套经得起业务考验的动态公式存储设计3.1 公式主表一张表管住当前态主表管理的是公式的“当前生效版本”这是业务里的最新态。字段设计如下CREATE TABLE formula_main ( formula_id VARCHAR(64) PRIMARY KEY, formula_code VARCHAR(128) NOT NULL UNIQUE, formula_name VARCHAR(256) NOT NULL, formula_type SMALLINT NOT NULL DEFAULT 1, formula_text TEXT NOT NULL, formula_json JSONB, version_no INTEGER NOT NULL DEFAULT 1, status SMALLINT NOT NULL DEFAULT 1, creator VARCHAR(64), create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modifier VARCHAR(64), update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );每个字段的用意说一下formula_code业务编号用户可读比如“INVENTORY_ALERT_001”通常要做唯一约束。formula_type公式分类。不同模块的公式解析规则可能不一样留一个类型字段后面做策略分发。formula_textformula_json上个章节说的双轨内容。version_no当前版本号。这个字段很关键后面做版本表和并发乐观锁都靠它。status启用、停用、草稿。动态公式在编辑过程中往往先存草稿审核后才启用没有这个状态位流程会很难受。主表不要放冗余的大文本历史比如上一次的公式内容。历史在版本表里管主表永远是当前态这样查询性能才可控。3.2 版本表动态公式必须能回溯版本表记录每次变更的快照。设计上要和主表按formula_id version_no关联CREATE TABLE formula_version ( formula_id VARCHAR(64) NOT NULL, version_no INTEGER NOT NULL, formula_text TEXT NOT NULL, formula_json JSONB, change_note VARCHAR(512), update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (formula_id, version_no) );为什么必须拆这张表因为我经历过一次线上事故运营修改促销公式直接UPDATE主表结果正在执行的定时报表还在用旧规则两边对不上账最后排查了两天才发现是公式被覆盖了。有了版本表每次保存都是在版本表里追加一行主表的version_no递增旧逻辑随时能查出来重新执行。实际操作中建议保存公式的接口做成“读当前版本 → 生成新版本号 → 同事务写入版本表并更新主表”的流程后面3.4小节会展开事务细节。3.3 引用关系表让“哪些公式用了这个字段”变得可查公式里引用的字段、函数、参数需要一张关系表把它们剥出来单独管理。这是早期最容易偷懒的设计点也是后期最救命的一张表。CREATE TABLE formula_ref ( formula_id VARCHAR(64) NOT NULL, ref_type SMALLINT NOT NULL, ref_code VARCHAR(128) NOT NULL, ref_name VARCHAR(256), PRIMARY KEY (formula_id, ref_type, ref_code) );ref_type表示引用的是数据字段、函数、还是全局参数ref_code是数据字典里的唯一编码比如“stock_qty”ref_name冗余一个显示名方便排查。每次保存公式时从AST里解析出所有引用对象全量重插这张关系表。有了它业务上最有价值的一个查询就变得很简单某个字段要下线时反查“哪些公式引用了它”评估影响面。SQL长这样SELECT fm.formula_code, fm.formula_name, fm.status FROM formula_main fm WHERE fm.formula_id IN ( SELECT formula_id FROM formula_ref WHERE ref_code stock_qty );这类血缘/引用查询在MySQL里也能做但在国产化场景里字段名、表名大小写敏感度的问题更麻烦所以建表时我建议统一用大小写敏感策略并在代码里固定好大小写风格。3.4 完整建表脚本与事务操作要点实际建表时还要补几个索引。下面是我常用的索引段CREATE INDEX idx_formula_status ON formula_main(status); CREATE INDEX idx_formula_update ON formula_main(update_time); CREATE INDEX idx_formula_ref_refcode ON formula_ref(ref_code);保存公式的事务流程用事务性语言描述的话大致是开启事务读取主表当前版本old_version用FOR UPDATE锁住这条公式记录防止并发修改新版本号 old_version 1向formula_version插入一条完整快照更新formula_main的formula_text、formula_json、version_no、update_time删除formula_ref中当前公式的全部旧引用重新插入AST解析出的引用清单提交事务这套操作在达梦、openGauss、KingbaseES上我都跑过SQL语法本身高度兼容但要注意两点达梦的FOR UPDATE和PG系的都支持但如果事务隔离级别设置不当锁等待超时时间要单独调参。系统默认值不一定适合高并发编辑场景。formula_ref每次全删重插数据量大了之后会产生大量binlog/redo日志。建议确认表的更新频率如果单条公式一天能改几十次考虑把formula_ref做成按version_no区分而不是只按formula_id区分历史引用。提示查历史版本的公式时不要只查formula_version里的text字段还要连formula_ref把当时引用的字段也还原出来。否则旧公式的依赖关系就丢了排障时等于少一半线索。4. 存储之外的“存储”查询、索引与缓存配合4.1 别对TEXT字段乱建索引公式主文本存进TEXT后新手最常见的操作是“为了查询快给TEXT建个普通索引”。这在国产化数据库里基本是负优化。原因很简单TEXT/CLOB字段上建普通B-tree索引索引条目要复制整段文本或者做前缀截断存储膨胀更新维护成本高。真正需要精确查询的是公式的formula_code、formula_id、status这些短字段建索引足够了。如果你想按公式名称模糊查formula_name是VARCHAR可以建索引但formula_text里面的关键词检索普通索引帮不上忙。我的做法是公式内容的模糊匹配只在小范围内用比如用户在前端列表页搜公式名称、公式编号、变更备注全部走短字段索引。前端编辑器打开单条公式的时候直接用主键formula_id查TEXT的读取开销完全可控。4.2 全文检索在国产库里的替代方案有一种需求很折磨人运营想搜“所有含‘同比’关键词的公式”或者“所有引用了‘去年销售额’字段的公式”。这已经不是前缀匹配了是全文级检索。在PostgreSQL里你可以用GIN索引配tsvector但国产库的支持情况参差不齐KingbaseES兼容PG的全文检索能力基本可以按PG习惯做。openGauss也有全文索引但配置和PG有区别需要确认版本特性。达梦有全文索引模块但语法和配置跟PG系是两个路子要单独学。最稳妥、成本最低的方案其实是“引用关系表兜底检索”。公式里引用的字段我在解析AST时已经拆进了formula_ref公式里出现的关键词我在后端保存时可以额外建立一个公式标签表把文本里解析出的领域词、指标名、函数名存成结构化标签。这样运营搜索“同比”时实际是查标签表而不是扫TEXT。全文检索当然能做到但标签表方案在所有国产库里行为一致不需要为每种数据库专门调全文索引运维省心。4.3 缓存策略降低数据库压力的关键动作动态公式的读写比例往往极度不均衡写一次可能被执行成百上千次。公式内容又不像交易流水那样不断变化非常适合做缓存。我的缓存层级设计是本地进程缓存Caffeine/Guava按formula_id缓存formula_text设置短过期时间比如5分钟。报表引擎频繁执行同一条公式时直接命中本地缓存连Redis都不走。分布式缓存Redis存JSON串key设计为formula:detail:{formulaId}:{versionNo}。带上版本号的目的是防止旧缓存污染。数据库作为兜底。这里最大的坑是缓存失效滞后。运营改了公式主表版本号变成5但报表引擎的本地缓存里还是版本4的文本。我的解决办法是双管齐下保存公式时发一个明确的变更事件Redis Pub/Sub或消息队列订阅方把本地缓存按formula_id清掉。缓存值里带上version_no执行时如果调用方传来指定版本号先比缓存版本不一致就去DB拉取。实测下来这套方案在KingbaseES和openGauss上都没有踩到兼容性问题因为缓存层跟具体数据库无关关键是把版本号这颗“定心丸”加上。5. 实战踩坑记录这些问题我一个个排过5.1 乱码问题的真正源头客户端连接编码某次在达梦上部署前端编辑器保存的公式里带中文一存就变乱码。排查了服务器、应用、数据库三端最后发现是JDBC连接的编码参数没设对。国产化数据库在这一点上跟MySQL一样连接串里的characterEncoding和数据库端字符集两边要一致只靠一端设置不管用。我的建议是专门写一个压测用例插入一条“公式名称 库存预警华北区”、内容里带≤和→符号的数据然后立刻读取比对。这套用例在所有环境部署完成后先跑一遍乱码问题当场就能暴露。5.2 JSON类型不是“有就一样”函数差异要吃透我在openGauss上写了一段jsonb_set操作在本机PostgreSQL 15上跑得好好的部署到openGauss上报错。一看文档openGauss的JSONB函数在版本演进中有过调整有些PG函数名它支持但参数严格度不同。当时我们前端编辑器产出的AST里有一步操作是“给JSON对象里某个节点追加子节点”用jsonb_set最方便结果在openGauss上必须改成“先jsonb_insert再合并”的写法。这种问题怎么防我的经验是在数据访问层封装一个JSON操作接口内部按数据库方言适配。业务代码永远别直接拼jsonb_set这样的方言函数。今天你在openGauss上踩坑明天迁移到达梦可能又是另一个语法封装一层能省掉无数麻烦。5.3 并发更新导致版本号错乱版本号用version_no自增的设计在单用户编辑时没问题一旦多人同时编辑同一条公式就可能出现两方都读到版本号3然后各自写入版本号4后提交的覆盖先提交的情况。排查起来很隐蔽因为数据没有丢失只是被“合理覆盖”了。解决方式是保存接口里做乐观锁校验前端编辑器在保存请求里带上自己打开时的version_no后端事务里先比较这个值与主表当前值不一致就拒绝保存让用户刷新后再改。我在前面3.4小节里写的FOR UPDATE是悲观锁兜底两者可以共存事务内锁行事务前校验版本。这样既防丢更新也防无意义的锁等待。5.4 迁移踩坑从MySQL迁到达梦要注意什么如果你跟我一样之前的数据在MySQL里现在要迁到达梦有几个点很容易翻车JSON类型转换MySQL的JSON列到达梦没有一一对应的类型迁移工具通常会转成CLOB。原来用JSON_EXTRACT(user_json, $.name)的查询到了达梦全要改成JSON_VALUE(user_json, $.name)等于把所有SQL重写一遍。TEXT默认长度MySQL的TEXT本身就是长文本达梦的TEXT和CLOB都能用但迁移工具偶尔会把TEXT映射成VARCHAR并截断。迁移后一定要检查数据量最大的公式表逐条对比长度。自增列语法MySQL用AUTO_INCREMENT达梦建表时要用IDENTITY或序列触发器如果迁移工具没帮你处理好插入数据时主键冲突就会爆发。5.5 问题排查速查表现象常见原因排查思路公式中文乱码数据库字符集或连接串编码不一致先压测用例复现再查库、表、连接串三级编码保存公式超时事务锁等待或CLOB写入慢查FOR UPDATE锁等待事件调事务超时参数公式列表查询慢TEXT字段被用来LIKE改写查询走formula_name、status索引或建标签表JSON解析报错数据库方言函数不兼容在数据访问层封装JSON操作按方言适配版本覆盖丢失缺少乐观锁校验保存请求带version_no后端比对后拒绝旧版写入迁移后字段截断TEXT映射成VARCHAR迁移后执行长度对比脚本逐表核对最后分享一点我自己的体会动态公式存储这个事说到底是给“变化的东西”设计一个“稳定的家”。数据库换成国产化之后很多以前不用想的细节都会冒出来——同样是JSONBKingbaseES和openGauss用起来就是不完全一样同样是长文本达梦的CLOB和MySQL的TEXT迁移起来就是要多留个心眼。我在项目里最后沉淀下来的核心就三条文本和JSON双轨存储别偷懒、版本表和引用关系表一定要有、所有数据库方言操作全部封装到数据访问层。只要你把这三条钉死不管底下的国产库换哪个牌子你的动态公式存储层都能稳住。如果你正在做类似的国产化改造或者前端编辑器、公式引擎这条线上有自己的坑欢迎照着这套思路去验证有问题随时交流。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进