如何使用PostgreSQL jsonb_path_query替代select union优化慢查询
背景说明
我使用的数据库版本为Postgresql-14,本次要做的是低频数据转换任务,希望获得优化建议,以此学习和提升Postgres/json相关技能,同时优化当前运行极慢的查询。
我们从外部API接收大小、结构不固定的json对象,每个json对象对应一份问卷响应,嵌套的“问题/答案”对象结构差异较大,总计有约5种已知结构。
响应对象存储在带jsonb_ops gin索引的jsonb列中,对应表共有约50万行,每行的jsonb列对象约有200个嵌套值。
我们的目标是将所有嵌套的问答响应提取到另一个包含id、question、answer字段的表中。目标表会用于大量FTS和trigram查询,我们希望保持schema简洁,因此选择提取到简单表而非直接做复杂jsonb查询;同时原对象包含大量不需要的元数据冗余,归档原表(含索引共5GB+)也可节省存储空间。
我尤其想学习更优雅的json遍历提取方案写入目标表,另外我暂时找不到方法将查询结果转为原生SQL文本而非带引号的json文本(通常我会使用->>、::text或者jsonb函数的_text后缀版本实现)。
测试用例与初始慢查询
以下是简化版的json对象示例,可直接运行测试:
create table test_survey_processing( id integer generated always as identity constraint test_survey_processing_pkey primary key, json_data jsonb ); insert into test_survey_processing (json_data) values ('{"survey_data": {"2": {"answer": "Option 1", "question": "radiobuttonquesiton"}, "3": {"options": {"10003": {"answer": "Option 1"}, "10004": {"answer": "Option 2"}}, "question": "checkboxquestion"}, "5": {"answer": "Column 2", "question": "Row 1"}, "6": {"answer": "Column 2", "question": "Row 2"}, "7": {"question": "checkboxGRIDquesiton", "subquestions": {"8": {"10007": {"answer": "Column 1", "question": "Row 1 : Column 1"}, "10008": {"answer": "Column 2", "question": "Row 1 : Column 2"}}, "9": {"10007": {"answer": "Column 1", "question": "Row 2 : Column 1"}, "10008": {"answer": "Column 2", "question": "Row 2 : Column 2"}}}}, "11": {"answer": "Option 1", "question": "Row 1"}, "12": {"answer": "Option 2", "question": "Row 2"}, "13": {"options": {"10011": {"answer": "Et molestias est opt", "option": "Option 1"}, "10012": {"answer": "Similique magnam min", "option": "Option 2"}}, "question": "textboxlist"}, "14": {"question": "textboxgridquesiton", "subquestions": {"15": {"10013": {"answer": "Qui error magna omni", "question": "Row 1 : Column 1"}, "10014": {"answer": "Est qui dolore dele", "question": "Row 1 : Column 2"}}, "16": {"10013": {"answer": "vident mol", "question": "Row 2 : Column 1"}, "10014": {"answer": "Consectetur dolor co", "question": "Row 2 : Column 2"}}}}, "17": {"question": "contactformquestion", "subquestions": {"18": {"answer": "Rafael", "question": "First Name"}, "19": {"answer": "Adams", "question": "Last Name"}}}, "33": {"question": "customgroupquestion", "subquestions": {"34": {"answer": "Sed magnam enim non", "question": "customgroupTEXTbox"}, "36": {"answer": "Option 2", "question": "customgroupradiobutton"}, "37": {"options": {"10021": {"answer": "Option 1", "option": "customgroupCHEC KBOX question : Option 1"}, "10022": {"answer": "Option 2", "option": "customgroupCHEC KBOX question : Option 2"}}, "question": "customgroupCHEC KBOX question"}}}, "38": {"question": "customTABLEquestion", "subquestions": {"10001": {"answer": "Option 1", "question": "customTABLEquestioncolumnRADIO"}, "10002": {"answer": "Option 2", "question": "customTABLEquestioncolumnRADIO"}, "10003": {"options": {"10029": {"answer": "OPTION1"}, "10030": {"answer": "OPTION2"}}, "question": "customTABLEquestioncolumnCHECKBOX"}, "10004": {"options": {"10029": {"answer": "OPTION1"}, "10030": {"answer": "OPTION2"}}, "question": "customTABLEquestioncolumnCHECKBOX"}, "10005": {"answer": "Aperiam itaque dolor", "question": "customTABLEquestioncolumnTEXTBOX"}, "10006": {"answer": "Hic qui numquam inci", "question": "customTABLEquestioncolumnTEXTBOX"}}}}}'); create index test_survey_processing_gin_index on test_survey_processing using gin (json_data); -- 我当前使用的查询,能运行但速度极慢 -- EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT JSON) select level1.value['question'] question, level1.value['answer'] as answer ,tgsr.json_data['survey_data'] from test_survey_processing tgsr, jsonb_each(tgsr.json_data['survey_data']::jsonb) level1 -- where survey_id = 6633968 and id = 4 union select level1.value['question'] question, jsonb_path_query(level1.value, '$.answer')::jsonb as answer ,tgsr.json_data['survey_data'] from test_survey_processing tgsr, jsonb_each(tgsr.json_data['survey_data']::jsonb) level1 -- where survey_id = 6633968 and id = 4 union select level1.value['question'] question, jsonb_path_query(level1.value, '$.options.*.answer')::jsonb as answer ,tgsr.json_data['survey_data'] from test_survey_processing tgsr, jsonb_each(tgsr.json_data['survey_data']::jsonb) level1 -- where survey_id = 6633968 and id = 4 union select level1.value['question'] question, jsonb_path_query(level1.value, '$.subquestions.*.*.answer')::jsonb as answer ,tgsr.json_data['survey_data'] from test_survey_processing tgsr, jsonb_each(tgsr.json_data['survey_data']::jsonb) level1 -- where survey_id = 6633968 and id = 4
第一轮调优结果
以下是我最终运行的查询,耗时11分钟处理插入3400万条记录,作为一次性操作该性能可接受。
改动说明
- 我使用
->和->>替代了[]下标访问,因为查到即使在PG14中,下标访问也不会走索引(不确定在FROM子句中是否有影响) - 我使用
to_json(...) #>> '{}'将json字符串转为无引号的字符串
create table respondent_questions_answers as select tgsr.id,tgsr.survey_id,level1.value ->> 'question' question, '' as sub_question, to_json(jsonb_path_query(level1.value, '$.answer')) #>> '{}' as answer from test_survey_processing tgsr, jsonb_each(tgsr.json -> 'survey_data') level1 union select tgsr.id,tgsr.survey_id,level1.value ->> 'question' question, to_json(jsonb_path_query(level1.value, '$.options.*.option')) #>> '{}' as sub_question, to_json(jsonb_path_query(level1.value, '$.options.*.answer')) #>> '{}' as answer from test_survey_processing tgsr, jsonb_each(tgsr.json -> 'survey_data') level1 union select tgsr.id,tgsr.survey_id,level1.value ->> 'question' question, to_json(jsonb_path_query(level1.value, '$.subquestions.*.*.question')) #>> '{}' as sub_question, to_json(jsonb_path_query(level1.value, '$.subquestions.*.*.answer')) #>> '{}' as answer from test_survey_processing tgsr, jsonb_each(tgsr.json -> 'survey_data') level1 union select tgsr.id,tgsr.survey_id,level1.value ->> 'question' question, to_json(jsonb_path_query(level1.value, '$.subquestions.*.question')) #>> '{}' as sub_question, to_json(jsonb_path_query(level1.value, '$.subquestions.*.answer')) #>> '{}' as answer from test_survey_processing tgsr, jsonb_each(tgsr.json -> 'survey_data') level1;
最终优化方案
采纳回答后我进一步掌握了jsonb_path_query的正确用法,最终移除了所有UNION SELECT,还找到了之前遗漏的部分值,也不需要再用to_json的临时处理方案。虽然json函数会隐式触发CROSS JOIN LATERAL,但显式写JOIN比逗号写法绑定更紧密,可读性也更好,以下是我最终使用的查询。
SELECT concat_ws(' ', qu.value::jsonb->>'question' , an.answer::jsonb->>'question' , an.answer::jsonb->>'option') AS question , an.answer::jsonb->>'answer' AS answer -- , tgsr.json_data->>'survey_data' FROM test_survey_processing tgsr CROSS JOIN LATERAL jsonb_each(tgsr.json_data->'survey_data') AS qu CROSS JOIN LATERAL jsonb_path_query(qu.value::jsonb, '$.** ? (exists(@.answer))') AS an(answer)
内容的提问来源于stack exchange,提问作者David

