Postgres pour vos besoins NoSQL



Symfony Live, Paris, France - 27.3.2025

© David Buchmann







David Buchmann - david@liip.ch

PHP Engineer, Liip SA, Suisse

C'est quoi, le NoSQL?


Même EWikipedia nous propose qu'une définition négative:
«A database that focuses on data that is modeled in means other than tabular relations»

Des données JSON dans une bd relational?

Repository

Repository at github.com/dbu/postgres-json




SQL JSON syntax

Définir des colonnes JSON

CREATE TABLE auction_jsonb (
    id SERIAL NOT NULL,
    title VARCHAR(255) NOT NULL,
    current_price INT DEFAULT NULL,
    item JSONB NOT NULL,
    PRIMARY KEY(id)
);
            

heap.io blog

JSON vs JSONB

JSON: Non typé, stoqué verbatim JSONB: Analysé et optimisé
  • Garde les whitespace
  • Preserve l'ordre des clés d'objet
  • Accepte du JSON invalide, garde des clés dupliqué
  • Analyse le JSON
  • Beaucoup plus pérformante pour les requettes

=> Préfèrez le JSONB sauf si vous avez absolument besoin des comportements non-standard.

item->'author' = '"David"'

ou, 2.5 fois plus vite

item->>'author' = 'David'

Similaire avec MySQL
item->>'$.author'

JSON data ou valeur?

>
L'info JSON
>>
Extrair les données native

Filtrer par des valeurs dans le JSON

SELECT COUNT(*)
FROM auction_jsonb
WHERE item->>'author' = 'David';
            
Lignes json jsonb MySQL
10k 136 31 21
100k 319 46 148
1M 1'280 157 1'225
10M 15'505 2'768 11'758

Temps de requête en milliseconds

On ajoute un indexe

CREATE INDEX json_author
ON auction_json_indexed ((item->>'author'));
            
Lignes json json indexé jsonb jsonb indexé MySQL MySQL indexé
10k 136 25 31 20 21 9
100k 319 12 46 13 148 15
1M 1'280 3 157 3 1225 146
10M 15'505 7 2'768 6 11'758 1'276

Temps de requête en milliseconds

Apropos: Optimiser le query planner

A 10M lignes, postgres ne voulait plus utiliser l'index, et a décidé d'executer un full table scan

-- Value <= 1.15 makes postgres use the index
ALTER TABLESPACE pg_default
SET (random_page_cost=1.1);
            

Merci à Frank Heikens sur stackoverflow pour proposer random_page_cost

On peut aussi extraire (générer) une colonne

CREATE TABLE auction_generated_fields (
    id SERIAL NOT NULL,
    author VARCHAR(255) generated always
        as (item->>'author') stored,
    item JSONB NOT NULL, PRIMARY KEY(id));
            
Lignes jsonb idx generated generated idx MySQL gen MySQL gen idx
10k 20 21 18 15 11
100k 13 30 10 55 8
1M 3 88 3 741 20
10M 6 786 4 7'097 21

Temps de requête en milliseconds

Apropos: Extraire une date (merci jamie@zomglol.wtf)

CREATE FUNCTION text_to_timestamp(text) RETURNS TIMESTAMP
LANGUAGE sql IMMUTABLE AS
$$
SELECT CASE
WHEN $1 ~ '^\d{4}-\d{2}-\d{2}[ |T]\d{2}:\d{2}:\d{2}(\.\d+)?(\+00:00)?$'
    THEN CAST($1 AS timestamp without time zone)
END
$$;

CREATE TABLE auction_generated_fields (
    id SERIAL NOT NULL,
    start_date TIMESTAMP(0) generated always
        AS (text_to_timestamp(item->>'startDate')) stored,
    item JSONB NOT NULL, PRIMARY KEY(id));
            

Postgres nous propose trop des options rédontant

Tous ces constructions ne partagent pas leurs indexes.
=> Choisissez lequel de ces construction vous voulez utiliser




Recherche complèxe en JSON

contient
item @> '{"author": "David"}'

clé existe
item ? 'author'
jsonb_exists(item, 'author')


au moins un des clés existe
item ?| array['author','foo'] jsonb_exists_any(item, array['a..', 'f..']

tous les clés existent
item ?& array['author','title']
  jsonb_exists_all(item, array['a..', 'f..']


JSON objet contient un objet (seulement jsonb)

CREATE INDEX auction_json_gin_idx
ON auction_jsonb_gin USING GIN (item);

SELECT COUNT(*)
FROM auction_jsonb_gin
WHERE item @> '{"author": "David"}';
            
Lignes ->> indexé @> non indé @> indexé @> avec GIN MySQL
10k 20 5 5 5 29
100k 13 20 19 5 117
1M 3 144 149 13 1'478
10M 6 1'239 1'235 76 13'866

    Temps de requête en milliseconds

Generalized Inverted Index (GIN)


Accelère tous les recherches "JSON contient", mais pas d'autres requêtes

Apropos: Distribution des valeurs

La nature des données dans une colonne indexé peut mener à des differences important

Variants json json idx jsonb jsonb idx GIN @ gen gen idx
10 12'372 784 1'895 676 1'364 1'030 44
1'000 12'424 6 1'583 6 58 931 3
100'000 13'197 3 1'710 3 5 838 3

Temps de requête en milliseconds, table de 10 million lignes

Performance d'écriture

Jsonb, indexes et champs générées tous demandent leurs prix

Lignes json json idx jsonb jsonb idx GIN gen gen idx
10k 60 90 90 120 130 94 143
100k 550 740 820 990 2'110 890 1'000
1M 5'150 8'500 7'670 11'340 24'000 8'440 11'773
10M 55'000 96'000 97'000 115'000 256'000 84'000 116'000

Mésuré avec des lignes synthetiques, duré en milliseconds

La consomption du stockage




Modifier le JSON

Mis-à-jour partiel: Postgres

Manipuler le JSON au lieu de le remplacer complètement

-- update, ignore if not exists
UPDATE auction_jsonb
SET item = jsonb_set(item, '{author}', '"value"', false)
WHERE id=42;

-- update or create
UPDATE auction_jsonb
SET item = jsonb_set(item, '{author}', 'value', true)
WHERE id=42;
            

Postgres remplace tout le JSON, la requête n'est pas accéleré

Mis-à-jour partiel: MySQL

Avec MySQL, on peut aussi manipuler le JSON, les fonctions sont appelé differament

-- update, ignore if not exists
JSON_REPLACE(item, '$.author', '"value"')
-- update or create
JSON_SET(item, '$.author', '"value"')
-- create, ignore if already exists
JSON_INSERT(item, '$.author', '"value"')
            

La doc MySQL postule que le moteur de MySQL peut optimiser pour éviter de recréer tout l'object JSON, mais j'ai pas réussi à mesurer une difference

Supprimer des clés en JSON

-- delete attribute
UPDATE auction_jsonb
SET item = item - 'key'
WHERE ID=42;

-- delete attribute alternate
UPDATE auction_jsonb
SET item = jsonb_set_lax(item, '{author}', null, true, 'delete_key')
WHERE ID=42;

-- delete multiple
item - array['key1', 'key2']
            

set_jsonb_lax, la fonction versatile

raise_exceptionErreur si valeur est null
use_json_nullMettre le valeur du champs JSON à null (defaut)
delete_keySupprimer la clé si le valeur est null
return_targetNe pas changer le valeur existant si le nouveau est null
set_jsonb_lax(item, '{author}', null, true, 'return_target');
            

Validation avec un schema JSON

Quelques autres elements de syntax Postgres




Doctrine ORM

Declarer une colonne JSON

#[ORM\Column(type: Types::JSON)]
private array $item;

#[ORM\Column(type: Types::JSON, options: ['jsonb' => true])]
private array $item;
            

Doctrine ne fournit pas des outils à définir des indices sur JSON. On les ajoute directement dans un scripte de migration.

$this->addSql('CREATE INDEX json_author ON auction_json_indexed ((item->>\'author\'))');
            

Définir une colonne généré

#[ORM\Column(
    length: 255,
    nullable: true,
    insertable: false,
    updatable: false,
    columnDefinition: "VARCHAR(255) generated always as (item->>'author') stored",
    generated: "ALWAYS"
)]
private ?string $author = null;
            

Grace à insertable: false, updatable: false, Doctrine sait qu'il faut jamais écrire ce champs dans la bd.
Attention: columnDefinition s'écrit en SQL simple, pas DQL

DQL ne connait pas les constructions JSON

Vous pouvez utiliser Doctrine DBAL pour écrire des requêtes SQL native

Ou installer scienta/doctrine-json-functions

# config/packages/doctrine.yaml
doctrine:
  orm:
    dql:
      string_functions:
        JSON_GET_TEXT: Scienta\...\Postgresql\JsonGetText
        JSONB_CONTAINS: Scienta\...\Postgresql\JsonbContains

            

Fonctions boolean sont à régistrer comme fonctions string,
   et on doit comparer avec =true pour du DQL accepté

->> comme fonction DQL

$qb = $this->createQueryBuilder('a');
$qb->select('COUNT(a)')
    // Need to qualify with a. in front of item
    ->where("JSON_GET_TEXT(a.item, 'author') = :author")
    ->setParameter('author', $authorName)
;

return $qb->getQuery()->getSingleScalarResult();

@> comme fonction DQL

$qb = $this->createQueryBuilder('a');
$qb->select('COUNT(a)')
    ->where('JSONB_CONTAINS(a.item, :criteria) = true')
    ->setParameter('criteria', ['author' => $authorName], Types::JSON)
;

return $qb->getQuery()->getSingleScalarResult();
            

Doctrine/PHP performance

Lignes jsonb dbal dbal, batch 1k entities
10k 60 4519 163 6'290
100k 550 49'964 1'587 53'823



Conclusions

Standardisation?



Merci & Dankeschön!


github.com/dbu/postgres-json


Mastodon: @dbu@phpc.social

David Buchmann, Liip AG




Scaling/Replicating postgres

Workarounds

Read replicas

Logical replicas

See pgdash.io

json_each(item)

jsonb_each(item)

SELECT *
FROM jsonb_each
((SELECT item FROM auction_jsonb WHERE id=1));