资讯动态

一库多模实战:用 KingbaseES 同时搞定 JSON、中文全文检索与标签位图

发布时间:2026/8/5 21:14:33 来源:尧图企业网站定制
一、引言四类数据需求难道真要上四个组件我先说一个情况吧。很多团队其实都踩过这个坑。比如我们要做一个电商后台。那么我们来盘一盘它到底有哪些数据需求。订单还有库存、用户这些是很规矩的结构化数据。关系型数据库处理这个是本来就擅长的。再看商品属性。这个就非常杂了。平板要记屏幕尺寸手表要记防水等级耳机要记降噪情况。字段根本对不上。这种情况其实属于半结构化文档数据。接着是商品描述。这些都是很长的中文文本。做运营的人有个要求就是输入「搜续航」或者「搜降噪」的时候得能查出来。这就是全文检索需求。最后看用户这边。每个用户身上都挂了一大堆画像标签。比如运动啊、高消费啊、数码控之类的。每天还要去做那种圈选就是「同时满足标签 A 和标签 B」的这种情况。这属于标签集合运算。如果按照以前的那种老思路。也就是一种需求就单独上一个专门的组件。那么架构往往就会变成下面这样关系数据订单/用户→ 关系型数据库灵活商品属性 → MongoDB 之类的文档库中文全文检索 → Elasticsearch用户标签圈选 → 自研标签系统 / 专门的 OLAP 引擎这四个组件一搭上去。问题马上就跟着来了。首先是数据要多头同步。商品在一个地方更新了。你还得把它同步到文档库里面去。接着还要推给 ES 去建索引。这个链路随便哪里断一下数据对不上的情况就出现了。然后是运维成本翻几番。四套系统啊。每套系统都有自己的高可用方案。备份的方式也不一样。监控和扩容也都是分开搞的。最后开发也累。比如来了一个复合需求要求是「带运动标签、描述提到续航、品牌国产」。为了这个你要跨三个系统去查。要写三段不同的代码。最后还得在应用层把这些数据给拼装起来。金仓给的方案是什么呢。是一库多模。也就是说上面说的这些能力你在一个 KingbaseES 库里面就能全部给做掉。这其实正好呼应了这次征文的那个主题叫「不止于替代」。也就是说金仓替代的往往仅仅只是那个关系库吗不是的。它还能把周边这一堆专用组件要干的活儿顺手就给接过来了。那么这篇文章的话我就会用电商这个场景一直贯穿下去。接着我会依次去拆解三件套JSON、中文全文检索、Roaring Bitmap。每一块我都会讲清楚原理为什么这么设计具体怎么动手操作还有跑出来的结果说明了什么。最后会把它们融合成一条 SQL。先说一下范围。本文主要是聚焦在 JSON、中文全文检索、还有 Roaring Bitmap 这三种能力上。这三种是最有代表性的。也最能体现「一库多模」的价值。至于地理和向量检索的话就不在本文展开了。二、环境准备建一个干净的演示库为了不污染其它数据单独建一个演示库mm_demoCREATEDATABASEmm_demo;\c mm_demo后续所有扩展和表都建在这个库里。三、模块一JSON 文档——用一列把商品属性存起来为什么用 JSONB不直接拆成单独的列呢不同品类的商品属性差异其实是非常大的。如果用传统关系库里面的建表方式来搞。你要么给每个品类单独建一张表。品类一旦多起来这个维护工作根本做不过来。要么就在一张大宽表里面塞几百个可以空着的列。但是呢大部分行里面大部分列其实都是 NULL。看着很稀疏也不好管理。这种表结构不固定、字段跟着品类变来变去的情况用 JSON 来存就比较合适了。金仓里面的 JSONB 是内置的能力。你不用去装什么扩展。它跟普通的 JSON 文本有什么区别呢。关键点在于JSONB 在数据写进去的时候就直接把文档解析成二进制结构化的格式存起来了。等你读的时候就不需要再去解析一遍了。而且键会自动去重也可以建索引。代价是什么呢。就是写进去的时候要多做一步解析的工作。但是对于商品属性这种读得多写得少的情况其实是很划算的。建表并写入几条不同品类的商品CREATETABLEproduct(idserialPRIMARYKEY,namevarchar(128),attrs jsonb-- 灵活的商品属性);INSERTINTOproduct(name,attrs)VALUES(平板电脑T10,{品牌:国产,屏幕:11寸,内存:8GB,标签:[办公,轻薄]}),(智能手表Pro,{品牌:国产,防水:50米,续航:14天,标签:[运动,健康]}),(无线耳机X1,{品牌:国产,降噪:true,续航:30小时,标签:[音乐,通勤]});三个经常用的操作符取值、过滤还有包含判断JSONB 里面其实最常用的就是三个操作符。你把这几个记住平时基本就够用了-这个用来取出子元素。取出来之后还是 JSON 类型的。也就是说你还可以继续往下取-这个也是取子元素。不过它取出来之后会转成普通的文本。平时要比较或者展示的话就用这个这个是用来判断左边的 JSON 是不是包含了右边的 JSON。平时做条件过滤经常用它。-- 取品牌字段- 返回文本可直接参与比较和展示SELECTname,attrs-品牌ASbrandFROMproduct;-- 找出带「运动」标签的商品- 先取出数组 判断是否包含该元素SELECTnameFROMproductWHEREattrs-标签运动;看一下结果。第一条语句把每个商品的品牌单独抽出来了变成了一列。也就是说这种半结构化的数据也能跟普通字段一样用来查。第二条语句呢正好找出了标签里面有“运动”这两个字的商品。这就说明这个操作符是能深入到数组元素里面去匹配的。它往往仅仅只是匹配最外层的键。给 JSONB 建一个 GIN 索引让包含查询能走索引数据量如果上去了的话要是走全表扫描就会很慢。JSONB 的包含查询想要走索引的话用的是GINGeneralized Inverted Index通用倒排索引CREATEINDEXidx_product_attrsONproductUSINGgin(attrs);GIN 索引是怎么做的呢。它把 JSONB 里面的每一个键、每一个值全都拆成一个个的“词条”。然后去建一个倒排表。等你查的时候它就直接去定位包含目标词条的那些行。这样就不用一行一行去解析了。这里有一个很关键的点。你的 GIN 索引是建在整个attrs这一列上的。那么你想要让包含查询用到这个索引你的查询条件也得写成对这一列顶层的。前面演示操作符的时候我们写的那个attrs - 标签 运动。它是先取出了子元素然后再去判断的。这个逻辑当然没问题结果也是对的。但是呢它实际上是作用在attrs - 标签这个子表达式上面的。它用不到attrs这一列上面的索引。那怎么写才能吃到索引呢。就是把条件整体写成顶层的包含关系-- 顶层包含写法判断标签数组含「运动」可走 idx_product_attrsSELECTnameFROMproductWHEREattrs {标签: [运动]};还有一点要说一下。索引这东西得在有足够多数据的时候才能体现出价值来。如果你表里只有那么几行数据的话优化器通常会去选择全表扫描。它觉得这样更快。所以在演示之前我们先往product表里面灌个几万行商品数据进去-- 灌入 5 万行商品数据标签统一设为「办公」让「运动」保持高选择性INSERTINTOproduct(name,attrs)SELECT商品||g,jsonb_build_object(品牌,国产,标签,to_jsonb(ARRAY[办公]))FROMgenerate_series(1,50000)g;ANALYZEproduct;接着我们用EXPLAIN ANALYZE来对比一下。同一条顶层包含查询在建索引之前和建索引之后它的执行计划到底有什么差别-- 建索引前全表 Seq ScanEXPLAINANALYZESELECTnameFROMproductWHEREattrs {标签: [运动]};CREATEINDEXidx_product_attrsONproductUSINGgin(attrs);ANALYZEproduct;-- 建索引后Bitmap Index Scan on idx_product_attrsEXPLAINANALYZESELECTnameFROMproductWHEREattrs {标签: [运动]};你看建索引之前它走的是全表Seq Scan。5 万行数据它得一行一行去做包含判断。你看那个Rows Removed by Filter: 50002。实际测出来的 Execution Time 是16.032 ms。等建好idx_product_attrs这个索引之后呢。执行计划就变成了Bitmap Index Scan on idx_product_attrs。它通过倒排索引直接去定位了。Heap Blocks 那里只需要精确命中 1 块就行。这个时候 Execution Time 就降到了0.077 ms。这中间差了差不多200 倍。这就是 GIN 倒排索引的作用。它把“一行一行去解析”变成了“按词条直接去定位”。而且随着你表里面的数据量越来越多这个性能差距其实还会继续拉大的。四、模块二中文全文检索——让商品描述真正「能被搜到」中文为什么必须先分词我先讲一个大家平时容易忽略的原理。其实全文检索到底是怎么回事呢说白了就是把一段文本给切开。切成一个个词条。切完之后去建一个倒排索引。这个索引是干嘛的呢也就是记录一下「每个词到底出现在了哪些文档里」。等你要查东西的时候就按词去命中。然后再算一下相关度排个序。英文那就好办了。人家天生就带着空格。the quick brown fox放进去一拆就是四个词很清楚。中文没有天然分隔符。这是一个问题。比如「智能手表续航长」这句话。机器其实并不知道该怎么切。到底是切成「智能/手表/续航/长」呢还是别的什么切法。那如果不去分词直接按单个字去切会怎么样呢「续航」这个词就会被拆成「续」和「航」两个字。这个时候你去搜「续航」那么含有「航班」或者「继续」的无关节档也会被找出来。这个精度就非常差了。那为什么会这样呢原因在于你没法靠单字去理解词意。所以啊做中文全文检索的第一道坎就是必须有一个懂中文的分词器。得靠它把连在一起的汉字切成有意义的词才行。金仓这边给了两个中文分词器的扩展。一个是zhparser它底层是基于 SCWS 的。另一个是sys_jieba也就是结巴分词。这两个你选一个就行。只要能跑通就可以。那在这篇文章里的话我就拿zhparser来做例子了。第一步装分词器、建全文检索配置-- 1) 安装中文分词器扩展CREATEEXTENSIONIFNOTEXISTSzhparser;-- 2) 基于分词器建一个名为 chinese 的全文检索配置CREATETEXTSEARCH CONFIGURATION chinese(PARSERzhparser);-- 3) 关键一步配置哪些词性的分词结果纳入索引ALTERTEXTSEARCH CONFIGURATION chineseADDMAPPINGFORn,v,a,i,e,lWITHsimple;这里面我要特别说一下第 3 步。也就是那个ADD MAPPING。这一步往往是最容易被忽略的。而且特别容易在这里翻车。为什么呢因为zhparser在分词的时候会给每个词打上词性的标签。比如n代表名词v代表动词a代表形容词。后面还有i是成语e是感叹词l是习惯用语等等。那么上面那条 SQL 语句的意思其实就是只有这些词性的词才会被收进 tsvector 里面去。映射要点ADD MAPPING决定了哪些词性的词会进入tsvector。那如果你只映射了n也就是名词的话动词和形容词就会被排除在索引之外。这会有什么问题呢像「降噪」这类词它分词的时候往往会被切成动词性的成分那你就检索不到它了。在咱们演示的这种情况里我建议直接把n,v,a,i,e,l都给加上。名词、动词、形容词、成语全都纳进去这样召回才是最全的。第二步把描述转成 tsvectorCREATETABLEgoods(idserialPRIMARYKEY,titlevarchar(128),bodytext);INSERTINTOgoods(title,body)VALUES(平板电脑T10,国产轻薄办公平板11寸高清屏幕适合移动办公和在线学习),(智能手表Pro,专业运动健康监测支持心率血氧50米防水续航长达14天),(无线耳机X1,主动降噪无线耳机通勤音乐两不误单次续航30小时);-- 先验证分词效果再决定要不要调映射SELECTto_tsvector(chinese,专业运动健康监测续航长达14天);你看这个to_tsvector(chinese, ...)跑出来的结果。它输出的是一串「词:位置」的列表。比如健康:3 运动:2 续航:5 ...这样的形式。这其实就是倒排索引要用的原材料。它是怎么做的呢也就是每个词都记下它在原来那段文本里的位置。这个位置信息有什么用呢在后面我们要算「相邻度」或者做「短语匹配」的时候就用得上它了。第三步GIN 还是 RUM这里要做个选择给 tsvector 建索引的话这里其实有两个选择。一个是GIN另一个是RUM。这是在这个模块里我觉得最值得讲透的一个点。GIN这个是经典的倒排索引。它往往仅仅只是记下「词 → 文档」的这么个映射关系。它能快速告诉你「哪些文档里面含有这个词」。但是呢它不存词的位置和频率信息。那么这就带来一个问题了。当你用 GIN 的时候你要想用ts_rank去做相关度排序它就得回表。也就是回到原文档里面去重新算一遍。文档数量一多的话这个排序就会变成一个很大的瓶颈。RUM这个你可以把它当成是「GIN 加了料之后的版本」。它在倒排索引里面额外存了词的位置信息还有专门用来打分的数据。这么做有什么好处呢两个好处。第一ts_rank排序可以直接在索引里面就搞定了。不需要再去回表。所以带排序的全文检索明显会更快。第二它支持短语查询也就是相邻度查询。比如你要查「运动」这个词紧紧跟着「健康」这个词。当然有好处就有代价。它的索引体积会更大而且写入的时候也会更慢。那么怎么选呢我通常这么说如果你只是判断「有没有」这个词那用 GIN 就够了。但如果你要按相关度去排序或者要做短语匹配的话那就选 RUM。回到咱们的场景商品搜索肯定是要按相关度排序的。所以这里我们选 RUM。-- 安装 RUM 索引扩展CREATEEXTENSIONIFNOTEXISTSrum;-- 给 goods 加一列 tsvector并填充title 权重更高可另配这里从简ALTERTABLEgoodsADDCOLUMNtsv tsvector;UPDATEgoodsSETtsvto_tsvector(chinese,title|| ||body);-- 建 RUM 索引CREATEINDEXidx_goods_tsvONgoodsUSINGrum(tsv);第四步按相关度搜索-- 搜「同时提到 运动 和 续航」的商品按相关度从高到低排SELECTtitle,ts_rank(tsv,q)ASrankFROMgoods,to_tsquery(chinese,运动 续航)qWHEREtsv qORDERBYrankDESC;这里面的逻辑其实不难。to_tsquery把你要查的词也解析成了词条。里面的代表的是「与」的关系。|代表的是「或」的关系。接着看这个符号是用来判断 tsvector 有没有命中 tsquery 的。最后那个ts_rank就是算出一个相关度的分值。这个分值越大说明越相关。结果解读你看这个查询我们用的是运动 续航来做「与」检索。也就是说这两个词得同时存在。跑出来的结果只有「智能手表Pro」的描述里面同时含有这两个词。所以最后命中了 1 条数据。并且它带出了一个相关度的分值rank ≈ 0.0517。这说明了什么呢说明全文检索这东西它不光能判断你「有没有包含」这个词。它还能量化出「到底有多相关」。然后根据这个去给你排序。这其实也就是 RUM 相比 GIN在「带排序的检索」这种场景下显得更合适的一个很直接的体现。五、模块三Roaring Bitmap——标签人群圈选用这个传统标签圈选为什么慢的情况用户画像里面其实是有很多标签的。比如运动、高消费、数码控、宝妈这些。运营平时经常提的需求是什么呢。就是圈出同时满足 A 和 B 的人群。或者是 A 或者 B。再或者是 A 但是不能包含 C。这些东西说白了其实都是集合运算。传统关系库里面怎么搞呢。通常是建一张user_tag(user_id, tag)的关联表。如果要圈“运动且高消费”的人那就得这么写-- 传统写法自连接 去重标签一多、用户上亿就吃力SELECTa.user_idFROMuser_tag aJOINuser_tag bONa.user_idb.user_idWHEREa.tag运动ANDb.tag高消费;问题出在哪里呢。当你的用户量上了亿标签有几百个的时候。这张表可能就有几十亿行了。JOIN 加上 GROUP BY 去重要扫非常多的行。内存也吃得很多。你做的交并集越多它就越慢。存这些东西的话空间也占得很大。Roaring Bitmap 的做法用压缩位图来表示人群我们换一种方式来想。把拥有某个标签的用户集合表示成一张位图。什么意思呢。就是用户的 id 当作下标。第 i 位如果是 1就代表用户 i 有这个标签。那么“运动且高消费”这种情况怎么办。其实就是把两张位图做一下按位与AND。“或”的话就是做按位或OR。这些都是 CPU 算得很快的位运算。跑起来速度很快。普通位图在数据比较稀疏的时候其实挺浪费空间的。一亿用户的话就要一亿位。Roaring Bitmap它做了一件事情就是分块压缩。它把整个 id 的空间切成一个个 64K 大小的桶。每个桶里面会看数据的稀疏或者密集程度。然后自动去选“数组”、“位图”或者“行程编码”里面最省空间的那种方式来存。这样的话它就有了位图的运算速度。而且存储空间也省了很多。这就是它在大规模标签场景下比“关联表 JOIN”好用的原因了。动手写一下建标签位图、做交并集CREATEEXTENSIONIFNOTEXISTSroaringbitmap;-- 每个标签对应一张用户位图把用户 id 灌进 bitmapCREATETABLEtag_users(tagvarchar(32)PRIMARYKEY,users roaringbitmap);-- 用 rb_build 从用户 id 数组构造位图INSERTINTOtag_usersVALUES(运动,rb_build(ARRAY[1,2,3,5,8,13])),(高消费,rb_build(ARRAY[2,3,5,7,11]));-- 同时是「运动」且「高消费」的用户数rb_and 求交、rb_cardinality 计数SELECTrb_cardinality(rb_and((SELECTusersFROMtag_usersWHEREtag运动),(SELECTusersFROMtag_usersWHEREtag高消费)))AS交集人数;-- 具体是哪些用户rb_to_array 把位图还原成 id 数组SELECTrb_to_array(rb_and((SELECTusersFROMtag_usersWHEREtag运动),(SELECTusersFROMtag_usersWHEREtag高消费)))AS用户列表;看一下结果交集的结果是{2,3,5}。也就是说既在运动位图里又在高消费位图里的就是这三个 id。你看整个过程没有用 JOIN也没有用 GROUP BY 去重。其实就是两张位图做了一次按位与的操作。当你的用户规模从几个变成几千万的时候这个性能差距就非常明显了。用法上的几个点rb_build这个函数里面要传的参数是整型数组。你用ARRAY[...]这种格式传进去就行。另外要注意Roaring Bitmap 的 id 用的是无符号整数的意思。所以你在把业务主键映射到位图下标的时候要保持是非负的并且落在合理的范围里面就行。常用的函数其实记一套就够用了。构造的话用rb_build。交、并、差的话分别用rb_and、rb_or、rb_andnot。想数个数的话用rb_cardinality。要把位图变回数组的话用rb_to_array。六、融合起来的场景一条 SQL 把三种能力串起来单独拿出来的话这几种能力其实也没什么特别的。JSON 的话别的库也有。全文检索的话可能 ES 更专业一些。但是呢它们这几个东西都在同一个库里面一条 SQL 就能配合着用。这才是关键的地方。我们来想一个平时经常会碰到的运营需求。找出“品牌是国产、带运动标签、并且描述里面提到了续航”的商品。这三个条件其实对应的是不同的东西。分别是 JSON 属性过滤、JSON 标签包含还有中文全文检索。在同一个库支持多模的情况下面它其实就是一条 SQL 的事按你实际的表结构改一改就行SELECTg.titleFROMgoods gJOINproduct pONp.nameg.titleWHEREg.tsv to_tsquery(chinese,续航)-- 全文检索描述里提到续航ANDp.attrs-品牌国产-- JSON 过滤品牌国产ANDp.attrs-标签运动;-- JSON 标签包含带运动标签看一下结果在一次查询里面全文检索、JSON 字段的比较、JSON 数组的包含这三种能力被AND连在了一起。然后交给同一个优化器去规划。执行一次就直接把结果返回来了。你不需要跨三个系统去分别查然后再回到应用层去拼装数据。这就是“一个库支持多模”很好用的地方了。三种能力都在同一个库里面同一条 SQL同一个事务里面。配合起来非常自然。你把它跟那种用很多组件搭起来的架构比一下的话差别就很明显了。七、总结一下一个库省掉三个组件这篇文章用一个电商的场景把金仓的三种“模”都过了一遍。JSON/JSONB这是它内置的能力。用-/-/这些操作符去存那些半结构化的商品属性。配上 GIN 倒排索引的话过滤起来速度也是很快的中文全文检索用分词器zhparser/sys_jieba加上tsvector倒排索引再加上 RUM 索引。这样中文文本就能被搜到了还能按相关度来排序Roaring Bitmap用压缩位图来表示有标签的人群。做交并差集的话其实就是做位运算。在标签量很大的时候比“关联表 JOIN 加上去重”要快很多也省空间。它们都有一个共同点。那就是不需要你再去额外装别的独立组件了。少弄一个组件你就少做一份数据同步的工作。运维的事情也少了一点。成本也降了一点。做国产化适配的活儿也少了。对于现在正在做信创替代的团队来说“一个库支持多模”不光是把功能补上了。它其实是在架构上把东西变简单了。以前那种“关系库加上 ES再加上文档库再加上标签系统”这一堆东西要干的事现在一个 KingbaseES 就能接住了。如果看完这篇文章你只能记住一句话那记住这个就行一个库省掉三个组件。这其实也是它除了做替代之外很有用的一个地方。金仓替代的并不只是原来那个关系数据库。它还把旁边那一堆专用组件要干的活儿也都一起干了。

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

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

免费获取报价