PostgreSQL 9.4 深度合并 jsonb 值

Tho*_*mas 5 postgresql json postgresql-9.4

要求和情况

我们目前正在使用 JSONB 列来加速我们数据库中的任意搜索,并且到目前为止工作正常。更新数据时,要求如下:

  • 任何新值都将覆盖任何现有值。这包括空值和数组。
  • 任何(嵌套)对象都将被合并。
  • 空值以及空数组和对象将被删除(如果它们存在的话)。

为了说明这一点,请考虑以下示例:

现有(为了说明目的包含空值):

{
  "a":null, 
  "b":1, 
  "c":1,
  "f":1, 
  "g": { 
     "nested": 1 
  }
}
Run Code Online (Sandbox Code Playgroud)

这应该合并到现有对象中:

{
  "b":2, 
  "d":null, 
  "e":2, 
  "f":null, 
  "g":{
    "nested": 2
  }
}
Run Code Online (Sandbox Code Playgroud)

如您所见,我们覆盖了一些字段并删除了f. 所以预期的输出是:

{
  "b": 2, //overridden
  "c": 1, //kept
  "e": 2, //added
  "g": {
    "nested": 2 //overridden
  }
}
Run Code Online (Sandbox Code Playgroud)

为了实现这一点,我们使用以下函数:

CREATE OR REPLACE FUNCTION jsonb_merge(jsonb1 JSONB, jsonb2 JSONB) 
RETURNS JSONB LANGUAGE sql IMMUTABLE
AS $$
  SELECT  
    CASE    
      WHEN jsonb_typeof($1) = 'object' AND jsonb_typeof($2) = 'object' THEN
        (
          SELECT jsonb_object_agg(merged.key, merged.value) FROM 
          (
            SELECT
              COALESCE( p1.key, p2.key ) as key,          
              CASE 
                WHEN p1.key IS NULL then p2.value
                WHEN p2.key IS NULL THEN  p1.value
                ELSE jsonb_merge(  p1.value, p2.value ) 
              END AS value 
            FROM  jsonb_each($1) p1 
            FULL OUTER JOIN jsonb_each($2) p2 ON p1.key = p2.key                 
          ) AS merged
          -- Removing this condition reduces runtime by 70%
          WHERE NOT (merged.value IS NULL OR merged.value in ( '[]', 'null', '{}') )
        ) 
      WHEN jsonb_typeof($2) = 'null' OR (jsonb_typeof($2) = 'array' AND jsonb_array_length($2) < 1) OR $2 = '{}' THEN
        NULL
      ELSE    
        $2    
    END     
$$;
Run Code Online (Sandbox Code Playgroud)

问题和疑问

正如我所说,从功能的角度来看,这非常有效。但是,该功能非常慢。

一个发现是条件 onmerged.value减慢了查询速度。删除它会导致执行时间减少约 70%,但显然结果并不符合要求。

那么我们如何才能实现 jsonb 对象的快速深度合并呢?

请注意 Postgres 9.5||操作符没有按预期工作,即它保留了不需要的元素。虽然我们可以使用会使我们的查询复杂化的特殊删除操作,但我不确定这会更快。

考虑的选项

到目前为止,我们考虑了以下(不满意的)选项:

  • 将我们的 9.4 服务器升级到 9.5 或 9.6。问题:new 运算符不能按照我们需要的方式工作,因此我们仍然必须使用函数或大量重构我们的查询。另外升级甚至重新启动我们的生产服务器是我们尽可能避免的事情。
  • 使用一些脚本语言,如 Python 等。同样存在必须避免服务器重启的问题。此外,我们必须首先进行全面的安全审查。

话虽如此,我们希望解决 Postgres 9.4 上的问题,并尽可能使用 SQL 或 PL/pgSQL。

更新

我进一步试验,发现以下函数通过了我的测试,并且比我以前的(10 倍)快得多。我很确定这会按预期工作,但由于我不是数据库专家,欢迎再次查看:

CREATE OR REPLACE FUNCTION jsonb_update(jsonb1 JSONB, jsonb2 JSONB)
RETURNS JSONB  LANGUAGE sql IMMUTABLE
AS $$
  SELECT json_object_agg(merged.key, merged.value)::jsonb FROM 
  (
    WITH existing_object AS (
      SELECT key, value FROM jsonb_each($1) 
        WHERE NOT (value IS NULL OR value in ('[]', 'null', '{}') ) 
    ),
    new_object AS (
      SELECT key, value FROM jsonb_each($2)
    ),
    deep_merge AS (
      SELECT lft.key as key, jsonb_merge( lft.value, rgt.value ) as value
        FROM existing_object lft
        JOIN new_object rgt ON ( lft.key = rgt.key) 
          AND jsonb_typeof( lft.value ) = 'object' 
          AND jsonb_typeof( rgt.value ) = 'object' 
    )

    -- Any non-empty element of jsonb1 that's not in jsonb2 (the keepers)
    SELECT key, value FROM existing_object 
      WHERE key NOT IN (SELECT key FROM new_object )
    UNION
    -- Any non-empty element from jsonb2 that's not to be deep merged (the simple updates and new elements)
    SELECT key, value FROM new_object 
      WHERE key NOT IN (SELECT key FROM deep_merge )                 
        AND NOT (value IS NULL OR value in ('[]', 'null', '{}') ) 
    UNION
    -- All elements that need a deep merge
    SELECT key, value FROM deep_merge
  ) AS merged 
$$;  
Run Code Online (Sandbox Code Playgroud)

Tho*_*mas 1

这是一个经过大量重写但优化的版本(比原始版本快大约 20 倍,大约与我的问题中的更新版本一样快,但在要求方面更正确 - 当涉及“本质上为空”的对象时,更新有一些缺陷,请参阅下面了解它们是什么):

它是用 PlPgSQL 重写的,因此非常“漂亮”。;)

首先,我们需要一个新的合并函数类型,它基本上代表 . 返回的记录jsonb_each(...)

CREATE TYPE jsonEachRecord AS (KEY text, value JSONB);
Run Code Online (Sandbox Code Playgroud)

然后有一个函数来检查 json 对象是否“本质上为空”,这意味着它只包含空值、空对象、空数组或嵌套的“本质上为空”对象。

CREATE OR REPLACE FUNCTION jsonb_is_essentially_empty(jsonb1 jsonb )
RETURNS BOOLEAN LANGUAGE plpgsql IMMUTABLE 
AS $func$
DECLARE
  result BOOLEAN = TRUE;  
  r RECORD;
BEGIN
  IF jsonb_typeof( jsonb1 ) <> 'object' THEN
    IF jsonb1 IS NOT NULL AND jsonb1 NOT in ('[]', 'null') THEN
      result = FALSE;
    END IF;
  ELSE
    for r in SELECT key, value FROM jsonb_each(jsonb1) loop    
      if jsonb_typeof(r.value)  = 'object' then
        IF NOT jsonb_is_essentially_empty(r.value) THEN
          result = FALSE;
          exit;
        end if;
      ELSE 
        IF r.value IS NOT NULL AND r.value NOT IN ('[]', 'null', '{}') THEN
          result = FALSE;
          exit;
        END IF;
      END IF;
    end loop;
  END IF;

  return result;
END;
$func$;
Run Code Online (Sandbox Code Playgroud)

最后这是实际的合并函数:

CREATE OR REPLACE FUNCTION jsonb_merge(jsonb1 JSONB, jsonb2 JSONB)
RETURNS JSONB  LANGUAGE plpgsql IMMUTABLE 
AS $func$
 DECLARE
    result jsonEachRecord[]; 
    json_property jsonEachRecord;    
    idx int;
    origArrayLength INT;
    mergedValue JSONB;
    mergedRecord jsonEachRecord;
  BEGIN

    FOR json_property IN (SELECT key, value FROM jsonb_each(jsonb1) ORDER BY key) LOOP
      IF json_property.value IS NOT NULL AND json_property.value NOT IN ('[]', 'null', '{}') THEN
        result = array_append(result, json_property);        
      END IF;
    END LOOP;

    idx = 1;
    origArrayLength = array_length( result, 1);

    FOR json_property IN (SELECT key, value FROM jsonb_each(jsonb2) ORDER BY key) LOOP

      WHILE result[idx].key < json_property.key AND idx <= origArrayLength LOOP
        idx = idx + 1;
      END LOOP;

      IF idx > origArrayLength THEN
        IF NOT jsonb_is_essentially_empty( json_property.value ) THEN      
          result = array_append(result, json_property);      
        END IF;
      ELSIF result[idx].key = json_property.key THEN
        if jsonb_typeof(result[idx].value) = 'object' AND jsonb_typeof(json_property.value) = 'object' THEN

          mergedValue = jsonb_merge( result[idx].value, json_property.value );

          mergedRecord.key = json_property.key;
          mergedRecord.value = mergedValue;
          result[idx] = mergedRecord;

        ELSE
         result[idx] = json_property;
        END IF;

        idx = idx + 1;

      ELSE        
        IF NOT jsonb_is_essentially_empty( json_property.value ) THEN   
          result = array_append(result, json_property);   
        END IF;                
      END IF;

    END LOOP;

    -- remove any remaining potentially empty elements
    IF result IS NOT NULL THEN
      FOR i IN REVERSE array_length( result, 1)..1 LOOP
        IF jsonb_is_essentially_empty( result[i].value ) THEN  
          result = array_remove(result, result[i] );
        END IF;
      END LOOP;
    END IF;

    return (select json_object_agg(key, value) from unnest(result));
  END;
$func$;
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,它非常丑陋,并且可能仍然存在一堆缺陷,例如数组的不断嵌套和 json 对象的聚合。根据我在其他地方读到的内容,通过索引访问数组需要引擎在每次访问元素时再次从前面的元素启动,这样也可以进行优化。