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»
Repository at github.com/dbu/postgres-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)
);
| JSON: Non typé, stoqué verbatim | JSONB: Analysé et optimisé |
|---|---|
|
|
=> 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'
|
> L'info JSON |
>> Extrair les données native |
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
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
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
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
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));
item->authorJSON_QUERY(item, '$.author')item #> '{author}'item['author']Tous ces constructions ne partagent pas leurs indexes.
=> Choisissez lequel de ces construction vous voulez utiliser
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..']
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
Accelère tous les recherches "JSON contient", mais pas d'autres requêtes
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
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
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é
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
-- 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']
| raise_exception | Erreur si valeur est null |
| use_json_null | Mettre le valeur du champs JSON Ã null (defaut) |
| delete_key | Supprimer la clé si le valeur est null |
| return_target | Ne pas changer le valeur existant si le nouveau est null |
set_jsonb_lax(item, '{author}', null, true, 'return_target');
pg_jsonschema / postgres-json-schema)
JSON_SCHEMA_VALID, à spécifier dans la partie CHECK|| concatener array / merger des objectsjsonb_build_object('key', 'value', 'k2', 'v2')
#[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\'))');
#[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
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é
$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();
$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();
| Lignes | jsonb | dbal | dbal, batch 1k | entities |
|---|---|---|---|---|
| 10k | 60 | 4519 | 163 | 6'290 |
| 100k | 550 | 49'964 | 1'587 | 53'823 |
See pgdash.io
json_each(item)
jsonb_each(item)
SELECT *
FROM jsonb_each
((SELECT item FROM auction_jsonb WHERE id=1));