PostgreSQL 一次由 string_agg 引发的数据错位 Bug 深度复盘

背景

在一次日常数据排查中,我们发现系统中某个合作伙伴(以下称 PartnerA)的验证状态在界面上显示异常------明明该合作伙伴的数据是正常的,却被系统标记为"校验失败";而另一个真正有问题的合作伙伴(以下称 PartnerB)的错误信息,却莫名其妙地出现在了 PartnerA 的详情里。

更诡异的是:这个问题并非每次都能稳定复现,时而出现、时而消失,极难定位。

经过深入排查,最终将问题根源锁定在一段看似无害的 SQL 聚合查询上。


业务背景简介

系统中存在一张核心的合作伙伴主数据表 (下文统称 partner_data 表),其核心字段如下:

字段(脱敏) 含义
group_id 合作伙伴集团 ID(多个成员共享同一 group_id)
member_id 合作伙伴成员 ID(唯一标识每一行)
check_status 数据校验状态(如 Success / Failed
check_msg 数据校验失败时的错误原因描述

业务上,一个 group_id 对应多个 member_id(一对多关系)。前端需要展示:在某个 group_id 下,所有成员的 ID、校验状态、错误信息。

为了减少查询次数、降低传输数据量,开发者采用了"行转列"的思路:在 SQL 层将同一 group_id 下的多行数据聚合为一行,三个字段分别用特殊分隔符拼接成字符串,由后端程序 split 后再逐一解析对应关系。


原始 SQL 实现

sql 复制代码
SELECT
    group_id,
    string_agg(member_id, '@#@')    AS all_member_ids,
    string_agg(check_status, '@#@') AS all_check_status,
    string_agg(check_msg, '@#@')    AS all_check_msg
FROM partner_data
GROUP BY group_id
ORDER BY group_id DESC
LIMIT 20 OFFSET 0;

后端代码(伪代码):

java 复制代码
String[] memberIds   = result.getAllMemberIds().split("@#@");
String[] statuses    = result.getAllCheckStatus().split("@#@");
String[] messages    = result.getAllCheckMsg().split("@#@");

for (int i = 0; i < memberIds.length; i++) {
    // 假设三个数组下标一一对应
    process(memberIds[i], statuses[i], messages[i]);
}

看起来逻辑清晰、简洁高效,但这里隐藏着两个足以致命的陷阱


Bug 一:string_aggORDER BY,三列聚合顺序各自独立、随机不定

问题根因

PostgreSQL 官方文档对 string_agg 的描述非常明确:

If ORDER BY is not specified, the order of the aggregated values is implementation-dependent.

即:在没有 ORDER BY 子句时,string_agg 聚合的顺序由数据库底层决定,不保证任何顺序。 更关键的是,三个独立的 string_agg 调用,各自有各自的执行顺序,彼此之间毫无关联。

这意味着三列的聚合顺序完全可以各不相同。

具体还原

假设 group_id = G001 下有三条记录:

member_id check_status check_msg
M001 Success NULL
M002 Failed officeCountry is empty
M003 Success NULL

原始 SQL 执行后,三列可能产生如下完全合法但错位的结果:

复制代码
all_member_ids:   "M003@#@M001@#@M002"   ← 顺序 A
all_check_status: "Failed@#@Success@#@Success"  ← 顺序 B(与 member_id 顺序不同!)
all_check_msg:    "officeCountry is empty"       ← (见 Bug 二)

后端 split 后:

复制代码
memberIds[0] = "M003"  →  statuses[0] = "Failed"   ❌ M003 实际是 Success
memberIds[1] = "M001"  →  statuses[1] = "Success"
memberIds[2] = "M002"  →  statuses[2] = "Success"  ❌ M002 实际是 Failed

结果:校验状态完全错位,M003 被错误标记为 Failed,M002 的错误被掩盖。

为什么时而复现、时而消失?

PostgreSQL 的并发写入、VACUUM、autovacuum、表膨胀、TOAST 机制等都会影响物理存储布局,进而影响没有 ORDER BY 时的扫描顺序。在数据量小、写入稳定时,三列恰好顺序一致;一旦表经历频繁更新,顺序就可能悄然改变------这正是该 Bug 间歇性出现的原因。


Bug 二:string_agg 静默跳过 NULL,导致三列数组长度不一致

问题根因

PostgreSQL string_agg 的另一个重要特性:它会自动忽略 NULL 值,既不将 NULL 参与拼接,也不为 NULL 插入分隔符占位。

这意味着:如果某一行的 check_msg 为 NULL,则该行在 all_check_msg 的拼接结果中完全消失,而不是留下一个空槽位。

具体还原

同样是 G001 的三条记录,其中 M001、M003 的 check_msg 为 NULL:

sql 复制代码
-- 即使三列恰好按相同顺序聚合(假设都按 M001, M002, M003)
all_member_ids:   "M001@#@M002@#@M003"   → split → [M001, M002, M003]   长度 = 3
all_check_status: "Success@#@Failed@#@Success"  → split → 3 个元素       长度 = 3
all_check_msg:    "officeCountry is empty"       → split → 1 个元素 ❌    长度 = 1

后端按下标对应:

复制代码
memberIds[0] = "M001"  →  messages[0] = "officeCountry is empty"  ❌ 实际 M001 没有 msg
memberIds[1] = "M002"  →  messages[1] = 数组越界异常 / 取到 null   ❌

结果:错误信息挂错到了没有问题的成员上,真正有问题的成员反而得不到正确的错误原因。

两个 Bug 的叠加效应

在真实场景中,Bug 一(乱序)和 Bug 二(NULL 被吞)同时存在、相互叠加,造成:

  1. 三列数组顺序不一致
  2. 三列数组长度不一致
  3. 同一个 group_id 下,成员 ID、校验状态、错误信息三者无法正确对应
  4. 异常数据(如 check_msg 为 NULL 的脏数据)因 NULL 被跳过,在错误信息维度上彻底隐身,系统无法感知。

Bug 三:修复不完整------遗漏了另一处相同的查询

在排查过程中还发现,系统中存在两处类似的聚合查询:一处(分页查询)被修复了,另一处(条件筛选查询 simpleListByValid)在修复时仅对 check_statuscheck_msg 加了 ORDER BY,却遗漏了 member_id 本身

sql 复制代码
-- 修复不完整的版本(仍有 Bug)
string_agg(member_id, '@#@')                              AS all_member_ids,  -- ❌ 无 ORDER BY
string_agg(COALESCE(check_status,''), '@#@' ORDER BY member_id) AS all_check_status,
string_agg(COALESCE(check_msg,''),    '@#@' ORDER BY member_id) AS all_check_msg

all_member_ids 仍然无序,而后两列已按 member_id 排序------三列排序基准不统一,错位问题依然存在


根本修复方案

核心原则:所有参与"行转列"的 string_agg 列,必须使用完全一致的 ORDER BY 基准,且所有可能为 NULL 的字段必须用 COALESCE 处理为空字符串占位。

sql 复制代码
SELECT
    group_id,
    string_agg(member_id,                      '@#@' ORDER BY member_id) AS all_member_ids,
    string_agg(COALESCE(check_status, ''),      '@#@' ORDER BY member_id) AS all_check_status,
    string_agg(COALESCE(check_msg, ''),         '@#@' ORDER BY member_id) AS all_check_msg
FROM partner_data
GROUP BY group_id
ORDER BY group_id DESC
LIMIT 20 OFFSET 0;

修复要点:

  1. 统一 ORDER BY member_id:三列聚合按同一基准排序,保证顺序严格一致;
  2. COALESCE(field, '') :NULL 值被替换为空字符串 '',不再被跳过,确保三列数组长度始终相等;
  3. 所有同类查询同步修复:避免"修了一处、遗漏另一处"的不完整修复。

举一反三:这类 Bug 的通用识别模式

凡是代码中出现以下模式,均需高度警惕:

sql 复制代码
-- 危险信号 ⚠️
string_agg(col_a, 'separator') AS agg_a,
string_agg(col_b, 'separator') AS agg_b,   -- col_b 可能为 NULL
string_agg(col_c, 'separator') AS agg_c    -- col_c 可能为 NULL

配合后端代码:

java 复制代码
String[] a = result.getAggA().split("separator");
String[] b = result.getAggB().split("separator");
// 按下标 i 对应 a[i] 与 b[i]

只要以下任一条件成立,就存在对应错位的风险:

风险条件 后果
任意一列 string_agg 缺少 ORDER BY 顺序不确定,各列可能乱序
各列 ORDER BY 基准不一致 顺序标准不同,仍可能错位
任意一列的源字段存在 NULL 值 该列数组长度可能小于其他列,下标越界或对应错误

总结

原始 SQL 修复后 SQL
string_agg 排序 无,随机不定 统一 ORDER BY member_id
NULL 值处理 直接聚合,NULL 被跳过 COALESCE(field, '') 占位
三列数组长度 不保证一致 严格一致
三列顺序对应 不保证一致 严格一致
Bug 覆盖范围 两处查询均有问题 两处同步修复

这个 Bug 的教训在于:SQL 聚合函数的"隐式行为"(无序、忽略 NULL)与业务代码的"显式假设"(有序、长度一致)之间存在致命的语义鸿沟。 在涉及多列 string_agg 行转列的场景中,ORDER BY 和 COALESCE 不是可选项,而是必要的正确性保障

"The devil is in the details." --- 在数据库聚合里,这句话尤为贴切。

相关推荐
Gofarlic_OMS1 小时前
Mastercam浮动许可利用率低:软件许可浪费,回收再分配
java·大数据·开发语言·架构·制造
AC赳赳老秦1 小时前
OpenClaw与飞书多维表格联动:自动同步工作数据、生成统计图表,实现高效管理
java·数据库·python·信息可视化·飞书·deepseek·openclaw
开开心心就好1 小时前
带可视化界面的目录文件合并工具
java·运维·科技·游戏·tomcat·自动化·powerpoint
玛卡巴卡ldf1 小时前
【LeetCode 手撕算法】(动态规划)爬楼梯、杨辉三角、打家劫舍、完全平方数、零钱兑换、单词拆分、最长递增子序列、乘积最大子数组、分割等和子集
java·数据结构·算法·leetcode·动态规划·力扣
weelinking1 小时前
2026年三大主流大模型深度对比:GPT-5.5、Claude 4.6与DeepSeek V4谁更值得选择?
java·大数据·人工智能·git·python·gpt·github
橘子海全栈攻城狮1 小时前
【最新源码】基于springboot的快递物流平台的设计与实现C102
java·开发语言·spring boot·后端·spring·web安全
m0_739030001 小时前
mabatis-plus 和mabatis 的区别
java·数据库·mybatis
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ1 小时前
判断两个集合是不是相同
java
huaiixinsi1 小时前
Canal + Outbox、Kafka 选型与高可用、Caffeine 底层原理总结
java·数据库·分布式·mysql·spring·adb·kafka