GBase 8a
其他
文章

南大通用GBase 8a之listagg/string_agg 函数的反函数实现

发表于2026-04-17 13:53:31224次浏览7个评论

GBase8a数据库中 listagg/string_agg 函数的反函数实现


一、业务场景背景
在日常数据开发中,我们经常会遇到这种场景:某张表的字段里存储了用逗号(或其他分隔符)拼接的多个值,比如商品分类、标签、关联系统名称等,业务上需要把这些逗号分隔的字符串拆分成多行数据,方便后续的统计分析、关联查询或ETL处理。
这就是我们常说的“字符串拆分”,也被称为listagg/string_agg的“反函数”操作。


不同数据库生态下,实现方案差异较大:
•Oracle 中常用 CONNECT BY 层级查询配合正则实现拆分;
•GBase 8a MPP 这类国产数据库也支持类似的层级查询语法;
•但在部分环境中,层级查询语法存在兼容性限制,或在数据量较大时性能不够稳定,这时候就需要更通用、可移植的实现方案。

二、问题分析与通用方案选型
1. 原方案(层级查询法)的局限性
你最开始使用的 Oracle/GBase 层级查询方案,核心逻辑是通过 CONNECT BY LEVEL 生成序列,再用 regexp_substr 按位置截取字符串:
 

SELECT
   id,
   regexp_substr(names, '[^,]+', 1, LEVEL) AS name
FROM t_demo
WHERE names IS NOT NULL
CONNECT BY PRIOR id = id
 AND PRIOR rand() IS NOT null
 AND LEVEL <= length(names) - length(replace(names, ',', '')) + 1;


这种方案在小数据量下能快速实现需求,但存在几个明显的短板:
•兼容性差:依赖数据库的层级查询和正则扩展函数,无法直接迁移到不支持该语法的数据库;
•性能瓶颈:当单条记录的分隔项数量极多(比如上千个)时,层级查询会产生大量递归操作,执行效率下降明显;
•特殊字符风险:原方案对中文逗号、全角符号等分隔符的处理不够友好,容易出现拆分失败的情况。


2. 通用方案:数字辅助表法
我们最终选择的“数字辅助表+关联拆分”方案,是一种几乎兼容所有关系型数据库的通用实现,核心思路是:
•先创建一个存储连续整数的“数字辅助表”(numbers),生成从1到N的序列;
•用辅助表的数字,和原表做关联,关联条件为“数字 ≤ 分隔符数量+1”(也就是拆分后的行数);
•通过两次 SUBSTRING_INDEX 函数,按位置截取第N个分隔项;
•统一分隔符格式,兼容全角逗号、半角逗号等不同写法。


这种方案的优势在于:
•兼容性极强:不依赖任何数据库的特殊语法,MySQL、GBase、PostgreSQL 等主流数据库都支持;
•性能稳定:基于普通的表关联实现,执行计划清晰,大数据量下的表现远优于层级递归;
•扩展性强:可以轻松适配不同分隔符(逗号、分号、竖线等),也支持处理全角/半角混合的场景。

三、完整实现步骤

下面我们一步步实现这个通用的字符串拆分方案。
 

步骤1:创建测试表与初始化数据
先创建一张测试表,模拟业务中常见的场景,同时包含半角逗号、全角逗号两种分隔符:
SQL
-- 先删除旧表(避免报错)
DROP TABLE IF EXISTS t_demo;

CREATE TABLE t_demo (
   id INT ,
   names VARCHAR(255)    
);

-- 插入测试数据,包含全角逗号、半角逗号两种分隔符
INSERT INTO t_demo VALUES
   (101, '苹果,香蕉,橙子'),           -- 全角逗号分隔
   (102, '电脑,手机'),                 -- 全角逗号分隔
   (103, 'GBase,MySQL,Oracle,SQLServer'); -- 半角逗号分隔
步骤2:创建数字辅助表并初始化数据
数字辅助表是整个方案的核心,我们通过存储过程批量生成连续整数,方便后续使用:
SQL
-- 删除旧的辅助表
DROP TABLE IF EXISTS numbers;

-- 创建数字辅助表,存储从1开始的连续整数
CREATE TABLE numbers (
   n INT  
);

-- 如果存储过程已存在,先删除
DROP PROCEDURE IF EXISTS InsertNumbers;

-- 修改语句结束符,避免存储过程内部的分号和外部冲突
DELIMITER $$

CREATE PROCEDURE InsertNumbers(IN max_num INT)
BEGIN
   DECLARE i INT DEFAULT 1;    

   -- 清空辅助表数据
   TRUNCATE TABLE numbers;
   
   -- 处理异常参数:如果传入的max_num为空或小于1,默认生成100条数据
   IF max_num IS NULL OR max_num < 1 THEN
       SET max_num = 100;
   END IF;
   
   -- 循环插入从1到max_num的连续整数
   WHILE i <= max_num DO
       INSERT INTO numbers (n) VALUES (i);
       SET i = i + 1;
   END WHILE;
END$$

DELIMITER ;

-- 调用存储过程,生成1-100的数字序列
CALL InsertNumbers(100);
说明:max_num 的取值建议设置为业务中“单条记录最大分隔项数量”的上限,比如你的业务中最多单条记录有50个标签,设置为100就完全够用了。
步骤3:核心拆分SQL实现
通过子查询统一分隔符,再关联数字辅助表,完成字符串拆分:
SQL
SELECT
   t.id,
   -- 两次SUBSTRING_INDEX截取第n个分隔项,再用TRIM去除首尾空格
   TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(unified_names, ',', n.n), ',', -1)) AS name
FROM (
   -- 子查询:先把所有全角逗号替换为半角逗号,统一分隔符格式
   SELECT 
       id,
       REPLACE(names, ',', ',') AS unified_names    
   FROM t_demo
   WHERE names IS NOT NULL
) t
-- 关联数字辅助表:关联条件为n不超过拆分后的总行数
JOIN numbers n
   ON n.n <= LENGTH(t.unified_names) - LENGTH(REPLACE(t.unified_names, ',', '')) + 1
-- 按原表id和拆分顺序排序,结果更易读
ORDER BY t.id, n.n;

gbase> SELECT
    ->     t.id,
    ->     TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(unified_names, ',', n.n), ',', -1)) AS name
    -> FROM (
    ->     SELECT
    ->         id,
    ->         REPLACE(names, ',', ',') AS unified_names
    ->     FROM t_demo
    ->     WHERE names IS NOT NULL
    -> ) t
    -> JOIN numbers n
    ->     ON n.n <= LENGTH(t.unified_names) - LENGTH(REPLACE(t.unified_names, ',', '')) + 1
    -> ORDER BY t.id, n.n;
+------+-----------+
| id   | name      |
+------+-----------+
|  101 | 苹果      |
|  101 | 香蕉      |
|  101 | 橙子      |
|  102 | 电脑      |
|  102 | 手机      |
|  103 | GBase     |
|  103 | MySQL     |
|  103 | Oracle    |
|  103 | SQLServer |
+------+-----------+
9 rows in set (Elapsed: 00:00:00.12)

 
可以看到,所有逗号分隔的字符串都被拆分成了独立的行,全角/半角逗号也被统一处理,拆分结果完全符合预期。

四、核心原理详解
1. 分隔符统一处理
REPLACE(names, ',', ',') 这一步是为了处理全角逗号和半角逗号混用的场景,把所有全角逗号替换为半角逗号,避免因为分隔符格式不统一导致拆分失败。
2. 计算拆分后的总行数
LENGTH(t.unified_names) - LENGTH(REPLACE(t.unified_names, ',', '')) + 1 这个公式,是计算单条记录需要拆分成多少行:
•原字符串长度:LENGTH(unified_names)
•去掉所有逗号后的字符串长度:LENGTH(REPLACE(unified_names, ',', ''))
•两者的差值,就是字符串中逗号的数量;
•逗号数量 + 1,就是拆分后的总行数(比如a,b,c有2个逗号,拆分后是3行)。
3. 两次 SUBSTRING_INDEX 截取
SUBSTRING_INDEX(str, delim, count) 函数的作用是按分隔符截取字符串:
•SUBSTRING_INDEX(unified_names, ',', n.n):截取字符串的前n.n个分隔项,比如n=2时,GBase,MySQL,Oracle会被截取为GBase,MySQL;
•再用 SUBSTRING_INDEX(..., ',', -1):从截取后的字符串中,取最后一个分隔项,也就是MySQL;
•最后用 TRIM() 去除可能存在的首尾空格,得到干净的结果。

 

评论

登录后才可以发表评论
GBase用户21182发表于 3个月前
优秀!
GBase用户47954发表于 3个月前
感谢作者的精彩分享!
曾浩轩发表于 2个月前
来了
用户头像
山佳发表于 2个月前
向大佬学习
zixuwang发表于 2个月前
来了
白芷发表于 2个月前
闪闪发光
zixuwang发表于 2个月前
学习了