做国产化适配改造这小半年,我最大的体会是:很多问题不是“能不能存”的问题,而是“怎么存才能不给自己埋雷”。拿网页编辑器里的动态公式来说,表面上看就是一个字符串,存进数据库不就完事了?真上手你会遇到字段类型怎么选、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形态只会更长。所以我们第一个要决策的字段类型。
各库对长文本的支持路径不完全一样:
| 数据库 | 长文本类型 | 我的实测印象 |
|---|---|---|
| openGauss | TEXT / CLOB / VARCHAR(有长度上限) | 基于PostgreSQL,用TEXT最顺手 |
| KingbaseES | TEXT / CLOB | 也是PG系,兼容性最像PG |
| OceanBase(MySQL模式) | TEXT / LONGTEXT | 按MySQL习惯来不会有问题 |
| 达梦DM8 | TEXT / 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:底层继承PostgreSQL,JSON类型和JSONB类型都能用,函数算子也基本对齐PG生态。这是我用下来最省心的。
- openGauss:主打PG兼容,有JSONB类型,但部分JSON操作函数、操作符的行为跟原版PostgreSQL有细微差异。比如某些隐式类型转换场景,你在PG里写能跑,在openGauss上就得加类型声明。多版本之间的差异也明显,需要以当前版本的官方文档为准。
- OceanBase(MySQL模式):JSON类型向MySQL对齐,用
->和->>提取字段的语法更自然。 - 达梦DM8:JSON是“套”在关系模型上的一种能力,语法和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_text+formula_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 | 迁移后执行长度对比脚本,逐表核对 |
最后分享一点我自己的体会
动态公式存储这个事,说到底是给“变化的东西”设计一个“稳定的家”。数据库换成国产化之后,很多以前不用想的细节都会冒出来——同样是JSONB,KingbaseES和openGauss用起来就是不完全一样;同样是长文本,达梦的CLOB和MySQL的TEXT迁移起来就是要多留个心眼。我在项目里最后沉淀下来的核心就三条:文本和JSON双轨存储别偷懒、版本表和引用关系表一定要有、所有数据库方言操作全部封装到数据访问层。只要你把这三条钉死,不管底下的国产库换哪个牌子,你的动态公式存储层都能稳住。如果你正在做类似的国产化改造,或者前端编辑器、公式引擎这条线上有自己的坑,欢迎照着这套思路去验证,有问题随时交流。