Questa pagina descrive Calíope 1.7, la versione che sto costruendo adesso. La 1.6 è sull'App Store per Mac, iPad, iPhone, Apple Watch, Apple TV e Apple Vision Pro. Il changelog dice in quale versione è arrivata ogni funzione.

Aggiunge a ogni argomento un pulsante «Apri in Calíope». Funziona solo con l'app installata.

Manuale del DBA

Tipi di dati

Intervalli, dimensione in byte e casi d'uso dei tipi numerici, di testo, data/ora, JSON e spaziali. Include le differenze rilevanti tra MySQL e MariaDB.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

La scelta del tipo di dato incide sulla dimensione su disco, sulla velocità degli index e sull'integrità dei dati. Questa guida riassume i tipi più usati in MySQL e MariaDB, con i loro intervalli, la loro dimensione in byte e i casi d'uso tipici.

Numerici interi

- TINYINT — 1 byte, intervallo con segno −128…127 (senza segno 0…255). Utile per flag booleani o stati piccoli.
- SMALLINT — 2 byte, −32 768…32 767. Età, quantità piccole.
- MEDIUMINT — 3 byte, −8 388 608…8 388 607. Esclusivo di MySQL/MariaDB; raramente usato al di fuori dell'ecosistema.
- INT (INTEGER) — 4 byte, ±2,1·10⁹. Tipo predefinito per le chiavi primarie in tabelle di dimensioni medie.
- BIGINT — 8 byte, ±9,2·10¹⁸. Chiavi primarie in tabelle grandi, identificatori distribuiti.

MySQL 8.0+

Da MySQL 8.0 il modificatore ZEROFILL e la larghezza di visualizzazione (INT(11)) sono deprecati e vengono ignorati nella maggior parte dei casi. Non usarli nel codice nuovo.

CREATE TABLE pedidos (
    id        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    cantidad  SMALLINT UNSIGNED NOT NULL,
    estado    TINYINT UNSIGNED NOT NULL DEFAULT 0
);

Numerici decimali e in virgola mobile

- DECIMAL(M, D) — precisione esatta, M cifre totali e D decimali. Obbligatorio per il denaro.
- FLOAT — 4 byte, circa 7 cifre significative. Approssimato.
- DOUBLE — 8 byte, circa 15 cifre. Approssimato.

CREATE TABLE precios (
    sku    VARCHAR(32) PRIMARY KEY,
    monto  DECIMAL(12, 2) NOT NULL,
    iva    DECIMAL(4, 2)  NOT NULL DEFAULT 13.00
);

Testo e stringhe

- CHAR(N) — lunghezza fissa di N caratteri, fino a 255. Veloce quando tutte le righe hanno la stessa dimensione (codici di paese, hash).
- VARCHAR(N) — lunghezza variabile, fino a 65 535 byte per riga (condivisi con le altre colonne). Usa 1 o 2 byte aggiuntivi per la lunghezza.
- TEXT, MEDIUMTEXT, LONGTEXT — 64 KiB, 16 MiB, 4 GiB. Vengono memorizzati fuori dalla riga; non si possono usare come chiave senza un prefisso (KEY (col(255))).
- BLOB, MEDIUMBLOB, LONGBLOB — equivalenti binari.

Data e ora

- DATE — 3 byte, '1000-01-01'…'9999-12-31'.
- TIME — 3 byte, '-838:59:59'…'838:59:59'. Sì, può essere maggiore di 24 ore (intervallo, non ora del giorno).
- DATETIME — 5 byte, più fino a 3 per le frazioni di secondo. Senza fuso orario e senza conversione al salvataggio o alla lettura: restituisce il valore salvato.
- TIMESTAMP — 4 byte, intervallo 1970…2038 (in MySQL, anche nella 8.x) o 1970…2106 (in MariaDB 11.5+). Viene memorizzato in UTC e convertito al time_zone della connessione.
- YEAR — 1 byte, 1901…2155.

MySQL 5.7+MariaDB 10.5+

Sia DATETIME sia TIMESTAMP ammettono la precisione delle frazioni di secondo: DATETIME(6) memorizza i microsecondi.

CREATE TABLE eventos (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    ocurrido_en  DATETIME(6) NOT NULL,
    creado_en    TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP
);

JSON

Tipo nativo in MySQL 5.7+ e MariaDB 10.2+. Consente di indicizzare campi estratti con JSON_EXTRACT o ->> e, da MySQL 8.0, colonne generate con index (MULTI-VALUED INDEX sugli array).

MySQL 5.7+

MySQL memorizza il JSON in un formato binario ottimizzato (simile a BSON) e ne valida la sintassi all'inserimento.

MariaDB 10.2+

In MariaDB, JSON è un alias di LONGTEXT con una validazione opzionale tramite CHECK (JSON_VALID(col)). Non è un tipo binario; pesa di più su disco.

CREATE TABLE perfiles (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    atributos   JSON NOT NULL,
    correo      VARCHAR(120) AS (JSON_UNQUOTE(JSON_EXTRACT(atributos, '$.email'))) STORED,
    INDEX idx_correo (correo)
);

Spaziali (GIS)

- POINT, LINESTRING, POLYGON, GEOMETRY, MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION.
- Richiedono un index SPATIAL per query efficienti (MBRContains, ST_Distance, ST_Within).
- In MySQL 8.0, lo SRID è obbligatorio per usare gli index spaziali.

CREATE TABLE ubicaciones (
    id    BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(120),
    punto  POINT NOT NULL SRID 4326,
    SPATIAL INDEX (punto)
) ENGINE = InnoDB;

ENUM e SET

- ENUM — memorizza uno tra N valori predefiniti (fino a 65 535). Compatto (1–2 byte), ma rigido: cambiare la lista richiede un ALTER TABLE.
- SET — combinazione di fino a 64 valori come bitmap. Utile per permessi o etichette fisse.

Evitali se la lista di valori cambia di frequente; una tabella di catalogo + chiave esterna è più mantenibile.

Come scegliere

1. Usa il tipo più piccolo che copra l'intervallo previsto. Un BIGINT dove basta INT quadruplica lo spazio dell'index.
2. Marca le colonne come UNSIGNED quando non ti servono valori negativi: raddoppi l'intervallo.
3. Evita NULL quando puoi: una colonna NOT NULL DEFAULT … risparmia 1 bit per riga e negli index.
4. VARCHAR(255) non è più costoso di VARCHAR(20) se i dati reali entrano in 20 — per gli index con prefisso conta solo la lunghezza dichiarata.

Parole chiave: tipi di dati, INT, BIGINT, VARCHAR, TEXT, JSON, DATETIME, TIMESTAMP, DECIMAL, ENUM, SET, POINT, SPATIAL, BLOB, intervallo, byte

Tipi di index (B-tree, hash, fulltext, spaziale), index semplici e composti e strategie per ottimizzare la lettura senza penalizzare la scrittura.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Un index accelera la ricerca al prezzo di spazio su disco e di un costo su ogni INSERT/UPDATE/DELETE. Una buona progettazione degli index è la differenza tra una query di millisecondi e una di minuti.

Tipi di index

- B-tree — Predefinito in InnoDB. Supporta ricerche per uguaglianza, intervallo (>, <, BETWEEN), prefisso (LIKE 'abc%') e ordinamento (ORDER BY).
- Hash — Supporta solo l'uguaglianza. Disponibile nel motore MEMORY. InnoDB mantiene un adaptive hash index interno che non controlli direttamente.
- FULLTEXT — Ricerca di testo naturale e booleana. Disponibile in InnoDB e MyISAM. Utile per campi TEXT lunghi.
- SPATIAL — R-tree per i tipi POINT, POLYGON, ecc. Richiede una colonna NOT NULL.
- Multi-valued — Indicizza gli elementi di un array JSON. Solo in MySQL 8.0.17+.

-- Indice B-tree composto
CREATE INDEX idx_pedidos_cliente_fecha
    ON pedidos (cliente_id, fecha_pedido DESC);

-- Indice full-text
ALTER TABLE articulos
    ADD FULLTEXT INDEX ft_titulo_cuerpo (titulo, cuerpo);

-- Indice spaziale
ALTER TABLE ubicaciones
    ADD SPATIAL INDEX sp_punto (punto);

Semplici vs composti

Un index composto su (A, B, C) copre le ricerche con prefisso: WHERE A = ?, WHERE A = ? AND B = ?, WHERE A = ? AND B = ? AND C = ?, ma non WHERE B = ? da solo.

Regola pratica: ordina le colonne del composto per selettività (quanti valori univoci ha ciascuna) e per la frequenza dei filtri.

-- Buono: indice con la colonna più selettiva per prima
CREATE INDEX idx_facturas
    ON facturas (cliente_id, estado, fecha)
    -- cliente_id (alta selectividad) → estado → fecha
;

-- Schema sbagliato: indice ridondante
-- (cliente_id) è già coperto da (cliente_id, estado, fecha)
DROP INDEX idx_facturas_cliente ON facturas;

Covering index

Un index che contiene tutte le colonne lette da una query evita di accedere alla tabella. Usa EXPLAIN e cerca Using index nella colonna Extra.

-- La query legge solo cliente_id e total → l'indice la copre
CREATE INDEX idx_pedidos_cobertura
    ON pedidos (cliente_id, total);

SELECT cliente_id, SUM(total)
FROM pedidos
WHERE cliente_id IN (1, 2, 3)
GROUP BY cliente_id;

Index con prefisso

Per colonne TEXT o VARCHAR lunghe, indicizza solo i primi N caratteri. Riduce la dimensione dell'index mantenendo una selettività ragionevole.

CREATE INDEX idx_url
    ON paginas (url(64));   -- primeros 64 caracteres

Index invisibili

MySQL 8.0+

Un index può essere marcato come invisibile: esiste e viene mantenuto, ma l'optimizer lo ignora. Utile per testare l'impatto della rimozione di un index senza rischi:

ALTER TABLE pedidos ALTER INDEX idx_legacy INVISIBLE;
-- monitorear performance durante unas horas
ALTER TABLE pedidos ALTER INDEX idx_legacy VISIBLE; -- revertir
-- o
DROP INDEX idx_legacy ON pedidos; -- confirmar borrado

MariaDB 10.6+

In MariaDB l'index non diventa invisibile ma viene ignorato, con IGNORED (misurato sulla 13.0, dove INVISIBLE è un errore di sintassi), e l'effetto è lo stesso:

ALTER TABLE pedidos ALTER INDEX idx_legacy IGNORED;
ALTER TABLE pedidos ALTER INDEX idx_legacy NOT IGNORED; -- revertir

Impatto lettura vs scrittura

Ogni index in più:
- Accelera le query che lo usano.
- Penalizza ogni INSERT, UPDATE che tocca colonne indicizzate e ogni DELETE.
- Occupa spazio aggiuntivo (spesso tra il 10 % e il 40 % della dimensione della tabella).

Nelle tabelle con scrittura massiccia (log, metriche), mantieni il minimo di index indispensabili.

Strategie di ottimizzazione

1. Parti da EXPLAIN — individua type: ALL (full scan) e key: NULL (nessun index usato).
2. Misura prima di ottimizzare — usa lo slow query log per trovare le query più costose.
3. Combina selettività e ordine — l'index composto deve seguire l'ordine delle clausole WHERE e ORDER BY.
4. Evita gli index ridondanti — (A), (A, B), (A, B, C) sono ridondanti tra loro; basta (A, B, C).
5. Non indicizzare colonne a bassa cardinalità — un index su genero o activo (1 o 2 valori univoci) non aiuta quasi mai.

-- Diagnóstico
EXPLAIN SELECT * FROM pedidos
WHERE cliente_id = 42 AND fecha >= '2024-01-01';

-- Statistiche d'uso degli indici
SELECT object_schema, object_name, index_name, count_star
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = DATABASE()
ORDER BY count_star DESC;

Parole chiave: index, indici, B-tree, hash, fulltext, spatial, covering, composto, selettività, EXPLAIN, prefix, invisible, cardinalità

Normalizzazione

Prima, seconda e terza forma normale, quando denormalizzare e come bilanciare l'integrità dei dati rispetto alle prestazioni.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

La normalizzazione è il processo di organizzazione di uno schema per ridurre la ridondanza e prevenire anomalie di inserimento, aggiornamento e cancellazione. Le forme normali sono cumulative: una tabella in 3FN soddisfa anche 2FN e 1FN.

Prima Forma Normale (1FN)

- Ogni colonna contiene un unico valore atomico (né liste né JSON annidato che rappresenta più entità).
- Ogni riga è identificabile tramite una chiave primaria.
- Nessun gruppo ripetuto nelle colonne (tel1, tel2, tel3).

-- Sbagliato: tre colonne che ripetono la stessa "entidad"
CREATE TABLE clientes_v1 (
    id   BIGINT PRIMARY KEY,
    nombre  VARCHAR(120),
    tel1 VARCHAR(20),
    tel2 VARCHAR(20),
    tel3 VARCHAR(20)
);

-- Buono: una tabella collegata
CREATE TABLE clientes (
    id BIGINT PRIMARY KEY,
    nombre VARCHAR(120)
);
CREATE TABLE clientes_telefonos (
    cliente_id  BIGINT NOT NULL,
    telefono    VARCHAR(20) NOT NULL,
    tipo        VARCHAR(10) NOT NULL,
    PRIMARY KEY (cliente_id, telefono),
    FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
);

MySQL 5.7+MariaDB 10.5+Aurora

L'esempio usa la chiave naturale (cliente_id, telefono) così com'è su qualsiasi motore. Se preferisci una chiave surrogata, su MySQL e MariaDB si scrive id BIGINT AUTO_INCREMENT PRIMARY KEY, e l'insieme chiuso di valori si dichiara con ENUM('movil', 'casa', 'oficina').

PostgreSQL 13+

L'esempio usa la chiave naturale (cliente_id, telefono) così com'è su qualsiasi motore. Su PostgreSQL la chiave surrogata si dichiara id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY — BIGSERIAL è la forma antica e funziona ancora —, e per l'insieme chiuso di valori ci sono due strade: un CHECK (tipo IN ('movil', 'casa', 'oficina')), che si cambia con un ALTER TABLE, oppure un tipo dedicato con CREATE TYPE tipo_tel AS ENUM (…). Attenzione alla seconda: misurato su PostgreSQL 17.6, ALTER TYPE … ADD VALUE funziona, ma ALTER TYPE … DROP VALUE risponde 0A000, «dropping an enum value is not implemented». Un valore che entra in un enumerato di PostgreSQL non ne esce più.

SQLite 3.35+

L'esempio usa la chiave naturale (cliente_id, telefono) per girare così com'è su qualsiasi motore, e su SQLite gira. Quello che non gira è ciò che sta accanto, e fallisce in due modi molto diversi:

- ENUM('movil', 'casa', 'oficina') è un errore di sintassi. L'insieme chiuso si dichiara con CHECK (tipo IN ('movil', 'casa', 'oficina')), e quella lista non si può cambiare dopo: anche ALTER TABLE … ADD CONSTRAINT e DROP CONSTRAINT sono errori di sintassi, quindi si ricostruisce la tabella.
- id BIGINT AUTO_INCREMENT PRIMARY KEY NON fallisce, che è peggio. SQLite accetta qualsiasi nome di tipo, quindi si beve BIGINT AUTO_INCREMENT intero come tipo della colonna e non numera nulla: misurato su 3.51, due inserimenti lasciano id a NULL entrambe le volte. E non è colpa di AUTO_INCREMENT: id BIGINT PRIMARY KEY fa esattamente lo stesso, perché solo INTEGER PRIMARY KEY è alias del rowid e si numera da solo. Se vuoi la numerazione, il tipo è INTEGER, scritto per esteso.

E una trappola che non si vede finché il dato non è già sbagliato: le chiavi esterne sono spente di fabbrica. Misurato su 3.51, con PRAGMA foreign_keys a 0 — il valore predefinito — la tabella qui sopra accetta un telefono di un cliente che non esiste, e cancellare il cliente non fa scattare l'ON DELETE CASCADE. Con PRAGMA foreign_keys = ON entrambe le cose si comportano come sugli altri motori. Il PRAGMA è per connessione, non si salva nel file e dentro una transazione non fa nulla: va subito dopo l'apertura.

-- SQLite: si accende a ogni connessione, prima della prima transazione
PRAGMA foreign_keys = ON;

CREATE TABLE clientes_telefonos (
    cliente_id  INTEGER NOT NULL,
    telefono    TEXT NOT NULL,
    tipo        TEXT NOT NULL CHECK (tipo IN ('movil', 'casa', 'oficina')),
    PRIMARY KEY (cliente_id, telefono),
    FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
);

SQL Server

L'esempio usa la chiave naturale (cliente_id, telefono) per girare così com'è su qualsiasi motore, e su SQL Server gira. Quello che sta accanto no: AUTO_INCREMENT è un errore di sintassi (Msg 102), come ENUM(…), e nemmeno le forme di PostgreSQL servono — GENERATED ALWAYS AS IDENTITY dà Msg 156 e BIGSERIAL non è un tipo (Msg 2715). Qui la chiave surrogata si dichiara id BIGINT IDENTITY(1, 1) PRIMARY KEY, e l'insieme chiuso con un CHECK. Due cose, misurate su SQL Server 2025:

- Dai un nome al CHECK. Senza nome, il server se ne inventa uno con un suffisso che cambia ogni volta che si crea la tabella (CK__clientes_t__tipo__6D0D32F4), e per cambiare la lista bisogna eliminarlo con quel nome. Con un nome tuo, cambiarla sono due ALTER TABLE, DROP CONSTRAINT e ADD CONSTRAINT, e il nuovo vincolo nasce affidabile (is_not_trusted = 0).
- IDENTITY lascia buchi. Un INSERT che il CHECK rifiuta (Msg 547) e un altro annullato con ROLLBACK consumano comunque il loro numero: dopo le righe 1 e 2, la successiva entrata è stata la 5. E scrivere il valore a mano dà Msg 544, salvo con SET IDENTITY_INSERT … ON.

-- SQL Server: chiave surrogata e insieme chiuso con nome
CREATE TABLE clientes_telefonos (
    id          BIGINT IDENTITY(1, 1) PRIMARY KEY,
    cliente_id  BIGINT NOT NULL,
    telefono    VARCHAR(20) NOT NULL,
    tipo        VARCHAR(10) NOT NULL
        CONSTRAINT ck_telefono_tipo CHECK (tipo IN ('movil', 'casa', 'oficina')),
    UNIQUE (cliente_id, telefono),
    FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
);

-- Cambiare la lista dopo
ALTER TABLE clientes_telefonos DROP CONSTRAINT ck_telefono_tipo;
ALTER TABLE clientes_telefonos ADD CONSTRAINT ck_telefono_tipo
    CHECK (tipo IN ('movil', 'casa', 'oficina', 'fax'));

Seconda Forma Normale (2FN)

- Soddisfa la 1FN.
- Ogni colonna non chiave dipende dalla chiave primaria completa, non da una sua parte. Si applica alle chiavi composte.

Esempio: una tabella detalle_pedido (pedido_id, producto_id, cantidad, nombre_producto) viola la 2FN, perché nombre_producto dipende solo da producto_id, non dalla coppia completa.

-- Sbagliato: nombre_producto si ripete in ogni riga dello stesso prodotto
CREATE TABLE detalle_pedido_v1 (
    pedido_id        BIGINT,
    producto_id      BIGINT,
    cantidad         INT,
    nombre_producto  VARCHAR(120),
    PRIMARY KEY (pedido_id, producto_id)
);

-- Giusto: nombre_producto vive nella tabella productos
CREATE TABLE detalle_pedido (
    pedido_id    BIGINT,
    producto_id  BIGINT,
    cantidad     INT NOT NULL,
    PRIMARY KEY (pedido_id, producto_id),
    FOREIGN KEY (producto_id) REFERENCES productos(id)
);

Terza Forma Normale (3FN)

- Soddisfa la 2FN.
- Nessuna colonna non chiave dipende da un'altra colonna non chiave (nessuna dipendenza transitiva).

Esempio classico: empleados (id, nombre, departamento_id, departamento_nombre). Il nome del reparto dipende da departamento_id, non direttamente dalla chiave del dipendente.

-- Mal: dependencia transitiva
CREATE TABLE empleados_v1 (
    id                   BIGINT PRIMARY KEY,
    nombre               VARCHAR(120),
    departamento_id      BIGINT,
    departamento_nombre  VARCHAR(80)
);

-- Bien
CREATE TABLE departamentos (
    id      BIGINT PRIMARY KEY,
    nombre  VARCHAR(80)  NOT NULL
);
CREATE TABLE empleados (
    id              BIGINT PRIMARY KEY,
    nombre          VARCHAR(120),
    departamento_id BIGINT NOT NULL,
    FOREIGN KEY (departamento_id) REFERENCES departamentos(id)
);

BCNF e forme superiori

La forma normale di Boyce-Codd (BCNF) irrigidisce la 3FN, mentre la 4FN/5FN trattano le dipendenze multivalore e di join. Per la maggior parte degli schemi transazionali, arrivare in modo pulito alla 3FN è sufficiente.

Quando denormalizzare

La denormalizzazione deliberata infrange le regole per guadagnare prestazioni. È valida se:

1. Letture massicce, scritture scarse — un campo memorizzato nella tabella (pedidos.total_pagado) evita un SUM(...) ricorrente.
2. Reporting / analitica — gli schemi di tipo star o snowflake denormalizzano di proposito.
3. Risultati precalcolati — viste materializzate o tabelle di riepilogo.

Compromessi che accetti:

- Anomalie di aggiornamento — se il dato denormalizzato cambia, va aggiornato in N righe.
- Incoerenza transitoria — il campo memorizzato può disallinearsi se l'aggiornamento fallisce a metà.
- Trigger o logica applicativa — devi mantenere il dato sincronizzato.

-- Esempio: memorizzare il totale dell'ordine per evitare SUM a ogni lettura
ALTER TABLE pedidos
    ADD COLUMN total DECIMAL(12, 2) NOT NULL DEFAULT 0;

MySQL 5.7+MariaDB 10.5+Aurora

DELIMITER non è SQL: lo capisce il client, non il server.

-- Mantenerlo con un trigger
DELIMITER //
CREATE TRIGGER detalle_pedido_after_insert
AFTER INSERT ON detalle_pedido
FOR EACH ROW
BEGIN
    UPDATE pedidos
       SET total = (SELECT COALESCE(SUM(cantidad * precio_unit), 0)
                    FROM detalle_pedido
                    WHERE pedido_id = NEW.pedido_id)
     WHERE id = NEW.pedido_id;
END//
DELIMITER ;

PostgreSQL 13+

Su PostgreSQL il trigger non ha corpo: chiama una funzione che restituisce trigger, quindi sono due istruzioni. Non serve nemmeno DELIMITER, che è cosa del client MySQL; il corpo va tra $$.

CREATE FUNCTION recalcular_total() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    UPDATE pedidos
       SET total = (SELECT COALESCE(SUM(cantidad * precio_unit), 0)
                    FROM detalle_pedido
                    WHERE pedido_id = NEW.pedido_id)
     WHERE id = NEW.pedido_id;
    RETURN NULL;
END;
$$;

CREATE TRIGGER detalle_pedido_after_insert
AFTER INSERT ON detalle_pedido
FOR EACH ROW EXECUTE FUNCTION recalcular_total();

Misurato su PostgreSQL 17.6: dopo aver inserito due righe, 3 × 25,50 e 2 × 10,00, pedidos.total è rimasto a 96,50 senza toccarlo. RETURN NULL va bene perché il trigger è AFTER; in uno BEFORE bisognerebbe restituire NEW.

SQLite 3.35+

In SQLite il trigger porta il corpo dentro, come in MySQL, ma senza DELIMITER — che è del client di MySQL e qui è un errore di sintassi — perché il BEGIN … END già delimita. Non c'è una funzione a parte, FOR EACH ROW è l'unico modo che esiste, e il ; dopo END serve sempre.

CREATE TRIGGER detalle_pedido_after_insert
AFTER INSERT ON detalle_pedido
FOR EACH ROW
BEGIN
    UPDATE pedidos
       SET total = (SELECT COALESCE(SUM(cantidad * precio_unit), 0)
                    FROM detalle_pedido
                    WHERE pedido_id = NEW.pedido_id)
     WHERE id = NEW.pedido_id;
END;

Misurato su SQLite 3.51 con lo stesso esempio: dopo aver inserito due righe, 3 × 25,50 e 2 × 10,00, pedidos.total è rimasto a 96,5 senza toccarlo.

SQL Server

Su SQL Server l'ALTER TABLE qui sopra non gira: qui è ADD e basta, e ADD COLUMN è un errore di sintassi (Msg 156).

ALTER TABLE pedidos
    ADD total DECIMAL(12, 2) NOT NULL DEFAULT 0;

E il trigger cambia forma, perché qui non c'è FOR EACH ROW: scatta una volta per istruzione, e le righe nuove arrivano insieme nella tabella inserted. Non c'è nemmeno NEW. Quindi si scrive per un insieme di righe:

CREATE TRIGGER detalle_pedido_after_insert
ON detalle_pedido
AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;
    UPDATE p
       SET total = (SELECT COALESCE(SUM(d.cantidad * d.precio_unit), 0)
                    FROM detalle_pedido d
                    WHERE d.pedido_id = p.id)
      FROM pedidos p
     WHERE p.id IN (SELECT pedido_id FROM inserted);
END;

Misurato su SQL Server 2025: le due righe dell'esempio, 3 × 25,50 e 2 × 10,00, in un solo INSERT, hanno lasciato pedidos.total a 96,50, e un INSERT con righe di due ordini diversi li ha aggiornati entrambi. La trappola è scriverlo «per riga», come in MySQL, tenendo pedido_id in una variabile: con SELECT @pedido = pedido_id FROM inserted, un INSERT di due ordini ne ha aggiornato uno e ha lasciato l'altro a 0,00 senza nessun errore; con SET @pedido = (SELECT pedido_id FROM inserted), è fallito con Msg 512 e l'intero INSERT è stato annullato.

CREATE TRIGGER deve essere la prima cosa di ciò che si invia (Msg 111 se viene dopo un'altra istruzione), e il suo corpo contiene ;: in Calíope, eseguilo da solo o con il delimitatore $$.

Per il totale in cache c'è anche una strada senza trigger. SQL Server non ha viste materializzate, ma una vista indicizzata fa lo stesso e il server la mantiene da solo. Richiede WITH SCHEMABINDING, nomi in due parti e un COUNT_BIG(*) nella lista — senza, Msg 10138 alla creazione dell'indice —, e anche CREATE VIEW va da solo nel suo invio. Misurato: dopo aver inserito una riga in più, la vista dava già il nuovo totale senza toccarla.

CREATE VIEW dbo.pedidos_total WITH SCHEMABINDING AS
SELECT pedido_id, SUM(cantidad * precio_unit) AS total, COUNT_BIG(*) AS lineas
FROM dbo.detalle_pedido
GROUP BY pedido_id;

CREATE UNIQUE CLUSTERED INDEX ux_pedidos_total ON dbo.pedidos_total (pedido_id);

Raccomandazione

1. Progetta in 3FN come impostazione predefinita. L'integrità ringrazia.
2. Denormalizza solo con i dati alla mano — misura la query lenta, prova una cache e confronta.
3. Documenta la denormalizzazione. Senza un commento nel DDL, il prossimo DBA la "normalizzerà" pensando che sia un errore.

Parole chiave: normalizzazione, 1FN, 2FN, 3FN, BCNF, denormalizzazione, ridondanza, dipendenza funzionale, chiavi, integrità, trigger

INNER, LEFT, RIGHT, CROSS, SELF e FULL OUTER JOIN: quando usare ciascuno, con esempi su uno schema tipico di ordini.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

Un JOIN combina righe di due o più tabelle in base a una condizione. Il tipo di join determina cosa succede alle righe che non trovano una corrispondenza.

Gli esempi usano queste tabelle:

CREATE TABLE clientes (
    id      BIGINT PRIMARY KEY,
    nombre  VARCHAR(120) NOT NULL
);
CREATE TABLE pedidos (
    id          BIGINT PRIMARY KEY,
    cliente_id  BIGINT NOT NULL,
    total       DECIMAL(12, 2) NOT NULL,
    FOREIGN KEY (cliente_id) REFERENCES clientes(id)
);

INNER JOIN

Restituisce solo le righe con corrispondenza in entrambe le tabelle. È il join predefinito e il più usato.

SELECT c.nombre, p.total
FROM clientes c
INNER JOIN pedidos p ON p.cliente_id = c.id;

LEFT JOIN (LEFT OUTER JOIN)

Tutte le righe della tabella di sinistra + le corrispondenze di quella di destra. I campi senza corrispondenza a destra compaiono come NULL. Utile per "tutti gli X, con la loro Y se esiste".

-- Tutti i clienti, che abbiano fatto ordini o no
SELECT c.nombre, COUNT(p.id) AS pedidos
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
GROUP BY c.id, c.nombre;

Clienti senza ordini — pattern classico con WHERE … IS NULL:

SELECT c.id, c.nombre
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
WHERE p.id IS NULL;

RIGHT JOIN

Inverso del LEFT. Quasi sempre si scrive come LEFT JOIN invertendo l'ordine, più leggibile.

-- Equivalente a LEFT JOIN con ordine invertito
SELECT c.nombre, p.id
FROM pedidos p
RIGHT JOIN clientes c ON p.cliente_id = c.id;

SQLite 3.39+

SQLite ha RIGHT JOIN dalla 3.39; prima di quella versione bisogna invertire l'ordine e scriverlo come LEFT JOIN. Misurato su SQLite 3.51 con 5 clienti e 6 ordini distribuiti fra tre di loro, la query qui sopra restituisce 8 righe: le 6 con corrispondenza e i 2 clienti senza ordini, con NULL in p.id.

CROSS JOIN

Prodotto cartesiano: ogni riga di A con ogni riga di B. Senza clausola ON. Utile per generare tutte le combinazioni (calendari × prodotti per i report).

-- Genera tutte le combinazioni (cliente, mese) per un report
SELECT c.id, m.mes
FROM clientes c
CROSS JOIN (
    SELECT 1 AS mes UNION ALL SELECT 2 UNION ALL SELECT 3
    -- ...hasta 12
) m;

SELF JOIN

La stessa tabella compare due volte con alias diversi. Utile per gerarchie o confronti tra righe della stessa tabella.

CREATE TABLE empleados (
    id           BIGINT PRIMARY KEY,
    nombre       VARCHAR(120),
    jefe_id      BIGINT NULL,
    FOREIGN KEY (jefe_id) REFERENCES empleados(id)
);

-- Ogni dipendente con il nome del suo responsabile
SELECT e.nombre AS empleado, j.nombre AS jefe
FROM empleados e
LEFT JOIN empleados j ON j.id = e.jefe_id;

FULL OUTER JOIN

Tutte le righe di entrambe le tabelle; quelle senza corrispondenza mostrano NULL sul lato mancante.

MySQL 5.7+MariaDB 10.5+Aurora

Né MySQL né MariaDB hanno FULL OUTER JOIN: misurato su MySQL 8.0.46 e 26.7 e su MariaDB 13.0, è un errore di sintassi. Si emula con UNION:

-- Emulazione in MySQL
SELECT c.nombre, p.id
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id

UNION

SELECT c.nombre, p.id
FROM clientes c
RIGHT JOIN pedidos p ON p.cliente_id = c.id
WHERE c.id IS NULL;

PostgreSQL 13+

PostgreSQL ce l'ha nativo, e OUTER è facoltativo: FULL JOIN significa lo stesso. Misurato su PostgreSQL 17.6 con 5 clienti e 6 ordini, 3 dei quali senza cliente, restituisce tutte le 8 righe e il piano è un Hash Full Join.

-- PostgreSQL nativo
SELECT c.nombre, p.id
FROM clientes c
FULL OUTER JOIN pedidos p ON p.cliente_id = c.id;

SQLite 3.39+

Anche SQLite lo ha nativo dalla 3.39, e OUTER è opzionale pure lì. Quello che non ha è un nodo di piano proprio: misurato su SQLite 3.51, EXPLAIN QUERY PLAN mostra il solito LEFT-JOIN e sotto una seconda passata, RIGHT-JOIN pedidos, perché lo risolve come due scansioni concatenate.

-- SQLite 3.39+
SELECT c.nombre, p.id
FROM clientes c
FULL OUTER JOIN pedidos p ON p.cliente_id = c.id;

SQL Server

SQL Server lo ha nativo, e OUTER è facoltativo. Misurato su SQL Server 2025 con 5 clienti e 6 ordini, 3 dei quali senza cliente: restituisce le 8 righe. Il nodo del piano dipende dalla dimensione. Con così poche righe non c'è un nodo proprio: è una Concatenation di due passate, un Nested Loops (Left Outer Join) e un Nested Loops (Left Anti Semi Join) per gli ordini senza corrispondenza. Su 50.000 clienti e 200.000 ordini, con un indice su pedidos.cliente_id, è un Merge Join (Full Outer Join).

-- SQL Server nativo
SELECT c.nombre, p.id
FROM clientes c
FULL OUTER JOIN pedidos p ON p.cliente_id = c.id;

STRAIGHT_JOIN

MySQL 5.7+MariaDB 10.5+Aurora

Forza l'optimizer a leggere le tabelle nell'ordine indicato. Usalo solo se hai misurato che il piano automatico è peggiore:

SELECT STRAIGHT_JOIN c.nombre, p.id
FROM clientes c, pedidos p
WHERE p.cliente_id = c.id;

PostgreSQL 13+

PostgreSQL non ha STRAIGHT_JOIN né altre indicazioni dentro la query: scriverlo è un errore di sintassi (42601). Quello che c'è sono parametri di sessione: join_collapse_limit e from_collapse_limit, entrambi a 8 per impostazione predefinita, e gli interruttori enable_nestloop, enable_hashjoin e enable_mergejoin, tutti e tre su on. Servono a diagnosticare nella tua sessione; spegnere un metodo in produzione nasconde il problema invece di risolverlo.

SQLite 3.35+

Nemmeno SQLite ha STRAIGHT_JOIN: scriverlo è un errore di sintassi. Quello che ha è un modo di fissare l'ordine dentro la query stessa — CROSS JOIN non cambia il risultato, ma vieta al planner di riordinare le tabelle — più due indicazioni per tabella, INDEXED BY <indice> e NOT INDEXED. Misurato su SQLite 3.51 su 50 000 clienti e 200 000 ordini: con JOIN il planner legge prima clientes, e con CROSS JOIN rispetta quello che è scritto e legge prima pedidos. Attenzione all'indicazione: INDEXED BY con un indice che non esiste fa fallire la query invece di essere ignorato.

-- SQLite: l'ordine scritto comanda
SELECT c.nombre, p.id
FROM pedidos p
CROSS JOIN clientes c ON p.cliente_id = c.id;

SQL Server

Nemmeno SQL Server ha STRAIGHT_JOIN: scriverlo è un errore di sintassi (Msg 102). Quello che ha sono indicazioni dentro la query. OPTION (FORCE ORDER) fissa l'ordine scritto per tutta la query, e LOOP, HASH o MERGE fissano il metodo: in OPTION (HASH JOIN) per tutti i join, o tra le parole del join (INNER HASH JOIN) per uno solo. Attenzione alla seconda forma: un'indicazione sul join fissa anche l'ordine, e il server lo segnala con Warning: The join order has been enforced because a local join hint is used. Misurato su SQL Server 2025 su 50.000 clienti e 200.000 ordini: scritto FROM pedidos p JOIN clientes c, l'ottimizzatore legge prima clientes; con OPTION (FORCE ORDER) rispetta lo scritto e legge prima pedidos, e il Merge Join diventa MANY-TO-MANY. Come negli altri motori: solo se hai misurato che il piano automatico è peggiore.

-- SQL Server: comanda l'ordine scritto
SELECT c.nombre, p.id
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.id
OPTION (FORCE ORDER);

SQL Server

CROSS APPLY e OUTER APPLY

Sono il LATERAL di PostgreSQL con un altro nome: la tabella a destra è una sottoquery che può leggere le colonne della riga a sinistra. LATERAL non esiste in SQL Server (Msg 156). CROSS APPLY tiene solo le righe di sinistra per cui la sottoquery restituisce qualcosa, come un INNER JOIN; OUTER APPLY le tiene tutte, con NULL, come un LEFT JOIN. Il caso tipico sono i primi N di ogni gruppo. Misurato su SQL Server 2025 con 5 clienti, 2 dei quali con ordini: CROSS APPLY restituisce 2 righe e OUTER APPLY 5.

-- L'ordine più caro di ogni cliente
SELECT c.nombre, x.total
FROM clientes c
CROSS APPLY (
    SELECT TOP (1) p.total
    FROM pedidos p
    WHERE p.cliente_id = c.id
    ORDER BY p.total DESC
) x;

Il piano è un Nested Loops che esegue la sottoquery una volta per cliente. Su 50.000 clienti e 200.000 ordini, senza un indice che serva la sottoquery, l'ottimizzatore se ne costruisce uno temporaneo a ogni esecuzione: un Index Spool su pedidos. Vederlo nel piano è il segnale che manca un indice su pedidos (cliente_id, …).

Anti-join e semi-join

Pattern logici, non parole chiave SQL:

- Semi-join (esiste almeno una corrispondenza) → EXISTS o IN.
- Anti-join (non esiste corrispondenza) → NOT EXISTS o LEFT JOIN ... WHERE ... IS NULL.

-- Semi-join: clienti con almeno un ordine
SELECT c.id, c.nombre
FROM clientes c
WHERE EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);

-- Anti-join: clienti senza ordini
SELECT c.id, c.nombre
FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);

NOT IN non è un anti-join. Se la sottoquery restituisce anche un solo NULL, il confronto non è mai vero e il risultato sono zero righe. Misurato con 5 clienti e 6 ordini, 3 dei quali con cliente_id a NULL: NOT EXISTS e LEFT JOIN … IS NULL restituiscono 2, e NOT IN restituisce 0. Succede uguale su PostgreSQL 17.6, su MySQL 8.0.46, su SQLite 3.51 e su SQL Server 2025.

Prestazioni

1. Le colonne dell'ON devono essere indicizzate, specialmente sul lato "join interno" (quello che viene cercato per ogni riga di quello esterno).
2. Filtra il più possibile prima del join (WHERE su ogni tabella quando applicabile).
3. Evita i JOIN su espressioni (ON LOWER(a.cod) = LOWER(b.cod)) — l'index non viene usato.
4. EXPLAIN rivela l'ordine di lettura e il metodo, con il vocabolario di ciascun motore.

MySQL 5.7+MariaDB 10.5+Aurora

Nested Loop, Hash Join (MySQL dalla 8.0.18; dalla 8.0.20 sostituisce il Block Nested Loop) e, in MariaDB, Block Nested Loop e la sua variante hash, BNLH.

PostgreSQL 13+

Nested Loop, Hash Join e Merge Join, più un nodo dedicato per alcuni pattern: Hash Full Join per il FULL OUTER JOIN e Hash Anti Join per il NOT EXISTS.

L'anti-join mostra una differenza che altrimenti non si vede: misurato su PostgreSQL 17.6 su 50 000 clienti e 200 000 ordini, NOT EXISTS produce un Parallel Hash Anti Join, mentre il LEFT JOIN … WHERE p.id IS NULL equivalente produce un Hash Right Join con un Filter dietro, cioè il planner non lo riconosce come anti-join. I tempi sono usciti uguali, 14,98 ms e 13,22 ms, quindi la differenza è nel piano e non nell'orologio: non riscrivere la query per questo senza misurare la tua.

SQLite 3.35+

SQLite non ha né Hash Join né Merge Join: tutti i suoi join sono cicli annidati, e l'unica cosa che cambia è se la tabella interna viene percorsa per intero o cercata tramite un indice. Il piano non lo dà nemmeno EXPLAIN, che restituisce il bytecode della macchina virtuale — 19 righe di addr, opcode, p1… per il join più semplice —, ma EXPLAIN QUERY PLAN, con tre parole: SCAN (percorsa per intero), SEARCH … USING INDEX (cercata) e USING COVERING INDEX (l'indice porta già le colonne e la tabella non viene toccata).

Qui l'anti-join non mostra la differenza che mostra PostgreSQL. Misurato su SQLite 3.51 su 50 000 clienti e 200 000 ordini, NOT EXISTS dà una CORRELATED SCALAR SUBQUERY con dentro un SEARCH … USING COVERING INDEX, 5,53 ms, e il LEFT JOIN … WHERE p.id IS NULL equivalente dà SEARCH … USING COVERING INDEX … LEFT-JOIN, 8,38 ms: entrambi tramite lo stesso indice, senza alcun nodo speciale.

-- Il piano a SQLite si chiede così, non con EXPLAIN da solo
EXPLAIN QUERY PLAN
SELECT c.id, c.nombre
FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);

SQL Server

Nested Loops, Merge Join e Hash Match, ciascuno con l'operazione logica tra parentesi: (Inner Join), (Left Outer Join), (Full Outer Join), (Left Anti Semi Join). SQL Server non ha EXPLAIN: il piano si chiede con SET SHOWPLAN_XML ON, che non esegue la query, ed è quello che fa Visual EXPLAIN in Calíope.

Qui nemmeno l'anti-join mostra la differenza di PostgreSQL, e per la ragione opposta a SQLite: l'ottimizzatore riconosce entrambi. Misurato su SQL Server 2025 su 50.000 clienti e 200.000 ordini, NOT EXISTS e il LEFT JOIN … WHERE p.id IS NULL equivalente danno lo stesso piano, un Merge Join (Left Anti Semi Join) tramite l'indice di pedidos.cliente_id, e tempi uguali: 21,6 e 19,8 ms di media su cinque passate. L'EXISTS esce come un Merge Join (Inner Join) preceduto da uno Stream Aggregate, che lascia un solo ordine per cliente prima di unire.

Parole chiave: join, INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN, SELF JOIN, EXISTS, semi-join, anti-join, STRAIGHT_JOIN, nested loop, CROSS APPLY, OUTER APPLY, FORCE ORDER

Configurazione del server

Variabili critiche per le prestazioni: buffer pool, connessioni, pacchetto massimo, cache delle query e differenze chiave tra MySQL e MariaDB.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

La configurazione predefinita del server non è quasi mai quella ottimale in produzione. Queste sono le variabili con il maggiore impatto su prestazioni e stabilità.

innodb_buffer_pool_size

La cache principale di InnoDB: tabelle, index e dati. È la variabile più importante.

- Regola pratica: 60 %–80 % della RAM su un server dedicato a MySQL.
- Minimo consigliato: 1 GiB in produzione.
- In MySQL 8.0+ e MariaDB 10.5+ si può cambiare a caldo (senza riavviare).

-- Ver tamaño actual
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

-- Cambiare a caldo (8 GB)
SET GLOBAL innodb_buffer_pool_size = 8 * 1024 * 1024 * 1024;

max_connections

Numero massimo di connessioni simultanee. Per impostazione predefinita 151, in MySQL come in MariaDB.

- Ogni connessione consuma memoria (thread_stack + buffer per sessione, ~256 KiB).
- Un valore troppo alto peggiora le prestazioni sotto carico (contesa).
- Misura con SHOW STATUS LIKE 'Max_used_connections'. Se raggiunge il tetto, aumenta gradualmente.

SHOW STATUS LIKE 'Max_used_connections';
SHOW STATUS LIKE 'Threads_connected';
SET GLOBAL max_connections = 500;

max_allowed_packet

Dimensione massima di un pacchetto di protocollo (un INSERT grande, un LOAD DATA, un BLOB).

- Per impostazione predefinita 64 MiB in MySQL 8.0, 16 MiB nelle versioni precedenti.
- Se un'operazione lo supera: errore Got a packet bigger than 'max_allowed_packet' bytes.
- Aumentarlo a 256 MiB o 1 GiB è abituale nei carichi con BLOB.

SET GLOBAL max_allowed_packet = 256 * 1024 * 1024;
-- Anche il client deve passare il parametro
-- (da riga di comando: --max-allowed-packet=256M)

Query cache

Memorizza nella cache il risultato completo delle query SELECT.

MySQL 5.7+

Deprecata in MySQL 5.7, rimossa in MySQL 8.0. Se il tuo workload beneficiava della query cache, oggi la si delega all'applicazione (Redis, Memcached) o alle viste materializzate.

MariaDB 10.5+

Resta disponibile in MariaDB, ma disabilitata per impostazione predefinita. È utile solo nei carichi con query identiche, ripetitive e su tabelle che cambiano poco.

SHOW VARIABLES LIKE 'query_cache%';
SET GLOBAL query_cache_type = 'ON';
SET GLOBAL query_cache_size = 64 * 1024 * 1024;

Log e durabilità

innodb_flush_log_at_trx_commit controlla quando viene scaricato il redo log:

- 1 (predefinito) — scarica ed esegue fsync a ogni commit. Massima durabilità, minima velocità. ACID rigoroso.
- 2 — scarica a ogni commit, fsync ogni secondo. Un crash del SO può far perdere circa 1s. Quasi ACID.
- 0 — scarica ed esegue fsync ogni secondo. Un crash di MySQL può far perdere circa 1s. Non ACID.

Nelle repliche o negli ambienti dove tolleri una perdita limitata, 2 può moltiplicare il throughput per 3–5. Non cambiarlo sul primario senza aver compreso il rischio.

thread_pool

MariaDB 10.5+

MariaDB include il thread pool in modo nativo (thread_handling = pool-of-threads). Riduce il costo di creazione dei thread nei carichi con molte connessioni brevi.

MySQL 5.7+MySQL 8.0+

In MySQL Community non esiste; solo in MySQL Enterprise Edition.

tmp_table_size / max_heap_table_size

Dimensione massima delle tabelle temporanee in memoria. Se un'operazione supera il limite, MySQL la sposta su disco e perde velocità. Mantieni entrambi i valori uguali.

SHOW VARIABLES LIKE 'tmp_table_size';
SHOW VARIABLES LIKE 'max_heap_table_size';
SET GLOBAL tmp_table_size = 256 * 1024 * 1024;
SET GLOBAL max_heap_table_size = 256 * 1024 * 1024;

-- Cuántas tablas temporales fueron a disco
SHOW STATUS LIKE 'Created_tmp_disk_tables';

innodb_io_capacity / innodb_io_capacity_max

IOPS che InnoDB può consumare per la pulizia delle pagine sporche e le purge. Per impostazione predefinita 200 / 2000 in MySQL 8.0 e in MariaDB, e 10 000 / 20 000 nel MySQL attuale (misurato sulla 26.7).

- SSD moderni: 2000 / 4000 o più.
- HDD: lascia i valori predefiniti.

Configurazione persistente

MySQL 8.0+

MySQL 8.0 consente di rendere persistenti le modifiche globali senza modificare my.cnf:

SET PERSIST innodb_buffer_pool_size = 8589934592;
SET PERSIST_ONLY max_connections = 500; -- si applica solo al riavvio
RESET PERSIST innodb_buffer_pool_size;   -- togliere la persistenza

In MariaDB, la persistenza si ottiene modificando my.cnf (/etc/my.cnf.d/) e riavviando, oppure usando gli include (!include).

Raccomandazione generale

1. Conosci il workload prima di toccare qualsiasi cosa. Un OLTP con scritture intense si configura in modo diverso da un data warehouse di letture.
2. Cambia una variabile alla volta e misura l'impatto.
3. Documenta ogni modifica in my.cnf con un commento che ne spieghi il motivo.
4. Non copiare le configurazioni dai blog senza comprenderle — i valori "ottimali" dipendono molto dall'hardware e dal carico.

Aurora

Su Amazon Aurora niente di tutto questo sta in un file. Non esiste my.cnf: la configurazione vive nei parameter group del cluster e dell'istanza, applicati dalla console AWS o dalla CLI. innodb_buffer_pool_size lo gestisce AWS in base alla dimensione dell'istanza — non fissarlo a mano. E un SET GLOBAL dura fino al riavvio successivo: perché persista, cambialo nel parameter group.

Parole chiave: configurazione, buffer pool, innodb_buffer_pool_size, max_connections, max_allowed_packet, query_cache, thread_pool, tmp_table_size, innodb_flush_log_at_trx_commit, io_capacity, my.cnf

Limiti e restrizioni

Dimensioni massime di database, tabelle, colonne, lunghezze dei nomi e caratteri consentiti negli identificatori.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Conoscere i limiti del motore evita sorprese durante la crescita. Questi sono i tetti pratici in MySQL e MariaDB moderni.

Per database

- Dimensione totale: limitata dal filesystem. Con innodb_file_per_table = ON (default), ogni tabella è un file .ibd. Su ext4 / XFS il limite teorico si misura in exabyte — il limite reale lo pone il tuo storage.
- Tabelle per database: praticamente illimitate. Il catalogo (information_schema, mysql.tables) gestisce diverse centinaia di migliaia senza problemi. I carichi con oltre 10 000 tabelle richiedono di regolare table_open_cache.

Per tabella

- Righe: 2⁶⁴ righe teoriche. In pratica: centinaia di miliardi se lo schema e gli index sono buoni.
- Dimensione massima della tabella: 64 TiB con la INNODB_PAGE_SIZE predefinita (16 KiB).
- Colonne: massimo 4 096 per tabella nel server e 1 017 in InnoDB, ma il limite reale è dettato dal row size, non dal numero.
- Dimensione massima della riga: 65 535 byte (esclusi BLOB/TEXT che vengono memorizzati fuori dalla riga).
- Index per tabella: 64.
- Colonne per index: 16 (B-tree InnoDB).
- Lunghezza massima della chiave di un index: 3072 byte con DYNAMIC/COMPRESSED (formato predefinito in MySQL 5.7+ / MariaDB 10.2+).

-- Controllare la dimensione delle tabelle
SELECT table_schema, table_name,
       ROUND((data_length + index_length) / 1024 / 1024, 2) AS mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 20;

Per colonna

TipoDimensione massima
VARCHAR(N)65 535 byte (condivisi con il resto della riga)
TEXT64 KiB
MEDIUMTEXT16 MiB
LONGTEXT4 GiB
BLOBuguale all'equivalente TEXT
JSON4 GiB

Identificatori (nomi degli oggetti)

- Database, tabelle, colonne, index, viste: 64 caratteri.
- Alias di colonne: 256 caratteri.
- Funzioni, procedure, trigger, eventi: 64 caratteri.
- Constraint (FK, CHECK, UNIQUE): 64 caratteri.

Caratteri consentiti negli identificatori

- Senza backtick: lettere ASCII, cifre, _ e $. Non possono iniziare con una cifra pura né essere composti solo da cifre.
- Con backtick (`nombre raro`): qualsiasi carattere Unicode tranne U+0000 (NUL).

Convenzione consigliata: snake_case ASCII (pedido_cliente_id). Evita spazi, accenti e maiuscole — alcuni sistemi li normalizzano in modo diverso tra Linux e macOS.

-- Valido ma sconsigliato
CREATE TABLE `pedidos del año 2024` (`Número de Orden` INT);

-- Recomendado
CREATE TABLE pedidos_2024 (numero_orden INT);

Sensibilità a maiuscole/minuscole

lower_case_table_names:

- 0 — i nomi vengono memorizzati così come sono stati creati e sono sensibili alle maiuscole. Default su Linux.
- 1 — i nomi vengono salvati in minuscolo e i confronti ignorano il case. Default su Windows.
- 2 — vengono memorizzati così come sono ma i confronti ignorano il case. Solo macOS/Windows; default su macOS.

Cambiare questo valore in un'installazione esistente è distruttivo. Decidi al momento dell'inizializzazione del server.

Per query

- Sottoquery annidate: fino a 64 livelli.
- UNION: teoricamente illimitato, ma l'optimizer degrada oltre alcune centinaia.
- Parametri in un prepared statement: 65 535.
- Righe in un IN(...): in pratica fino a qualche migliaio; oltre, meglio un JOIN con tabella temporanea.

Per sessione

- Variabili di sessione (@@SESSION.xxx): possono impostare quasi qualsiasi variabile globale runtime.
- Variabili utente (@variable): fino a 64 caratteri nel nome.

Charset e collation

- Charset consigliato: utf8mb4 (UTF-8 completo, 4 byte). L'alias utf8 è storico e limitato a 3 byte (senza emoji).
- Collation consigliata in MySQL 8.0+: utf8mb4_0900_ai_ci (case-insensitive, accent-insensitive, basata su Unicode 9).
- In MariaDB: utf8mb4_unicode_ci o uca1400_ai_ci (10.10+).

ALTER DATABASE mi_base
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

ALTER TABLE clientes
    CONVERT TO CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

Raccomandazioni

1. Progetta con margine: se prevedi 10 milioni di righe, dimensiona index e partizioni per 100 M.
2. Usa BIGINT UNSIGNED nelle chiavi primarie delle tabelle che possono crescere. INT si riempie a circa 2 miliardi.
3. Definisci charset e collation espliciti quando crei database, tabelle e colonne. Ereditare dal default può fallire durante la migrazione.
4. Documenta i limiti del tuo modello (righe attese/anno, dimensione massima per colonna). Serve per il capacity planning e per rilevare query anomale.

Parole chiave: limiti, massimo, dimensione, colonne, righe, identificatori, charset, collation, utf8mb4, lower_case_table_names, index, row size

Buone pratiche

Progettazione degli schemi, convenzioni sui nomi, backup, replica, sicurezza degli utenti, GRANT minimi e audit.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ SQL Server 2022+

Raccomandazioni operative che distinguono un database amatoriale da uno mantenibile in produzione.

Progettazione degli schemi

MySQL 5.7+MariaDB 10.5+Aurora

1. Ogni tabella ha una chiave primaria. Se non è naturale, aggiungi id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY.
2. Tipi espliciti: dichiara NOT NULL e DEFAULT ogni volta che la colonna lo consente. NULL deve significare "non applicabile", non "non compilato".
3. Foreign key obbligatorie tra tabelle correlate. Perdi microsecondi in scrittura, guadagni un'integrità referenziale inviolabile.
4. InnoDB sempre. MyISAM non supporta né FK né transazioni; resta solo nei sistemi legacy.

CREATE TABLE pedidos (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    cliente_id    BIGINT UNSIGNED NOT NULL,
    estado        TINYINT UNSIGNED NOT NULL DEFAULT 0,
    creado_en     DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    actualizado_en DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                              ON UPDATE CURRENT_TIMESTAMP(6),
    FOREIGN KEY (cliente_id) REFERENCES clientes(id)
        ON DELETE RESTRICT ON UPDATE CASCADE,
    INDEX idx_pedidos_cliente (cliente_id),
    INDEX idx_pedidos_creado (creado_en)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci;

SQL Server

1. Ogni tabella ha una chiave primaria, con un nome. Se non ce n'è una naturale, id BIGINT IDENTITY(1, 1): AUTO_INCREMENT e UNSIGNED danno Msg 102. Se più tabelle condividono una numerazione, una SEQUENCE con DEFAULT NEXT VALUE FOR: due tabelle sulla stessa sequenza hanno ricevuto 1, 2 e 3 senza ripetizioni. Un INSERT fallito o annullato consuma il suo numero di IDENTITY, quindi i buchi sono normali.
2. Tipi espliciti: NOT NULL e DEFAULT ogni volta che si può, e le date in datetime2, non in datetime, che arrotonda a 1/300 s (lo spiega l'argomento «Tipi di dati (SQL Server)»). Non esiste ON UPDATE CURRENT_TIMESTAMP (Msg 156): la data di modifica la mette l'applicazione o un trigger.
3. Ogni vincolo ha un nome (pk_, fk_, df_, ck_). Senza, SQL Server ne inventa uno diverso a ogni creazione (CK__<tabella>__<colonna>__<hex>), e un DEFAULT senza nome impedisce poi il DROP COLUMN (Msg 5074).
4. Ogni tabella nel suo schema, non tutte in dbo: lo schema è il confine dei permessi (più sotto, in «Utenti e permessi»). Una tabella creata senza schema finisce nello schema predefinito di chi la crea, che per sa è dbo.

-- Lo schema prima, in un batch a sé: CREATE SCHEMA ventas;
CREATE TABLE ventas.pedidos (
    id             BIGINT IDENTITY(1, 1) CONSTRAINT pk_pedidos PRIMARY KEY,
    cliente_id     BIGINT       NOT NULL,
    estado         TINYINT      NOT NULL CONSTRAINT df_pedidos_estado DEFAULT 0,
    creado_en      DATETIME2(3) NOT NULL CONSTRAINT df_pedidos_creado_en DEFAULT SYSUTCDATETIME(),
    actualizado_en DATETIME2(3) NOT NULL CONSTRAINT df_pedidos_actualizado_en DEFAULT SYSUTCDATETIME(),
    CONSTRAINT fk_pedidos_clientes FOREIGN KEY (cliente_id)
        REFERENCES ventas.clientes (id) ON UPDATE CASCADE,
    CONSTRAINT ck_pedidos_estado CHECK (estado BETWEEN 0 AND 4)
);
CREATE INDEX idx_pedidos_cliente_id ON ventas.pedidos (cliente_id);
CREATE INDEX idx_pedidos_creado_en  ON ventas.pedidos (creado_en);

Convenzioni sui nomi

- Tabelle: snake_case, plurale se rappresentano collezioni (pedidos, clientes).
- Colonne: snake_case, senza prefisso ridondante (nombre, non cliente_nombre dentro clientes).
- Chiavi esterne: <tabla>_id (cliente_id).
- Indici: idx_<tabla>_<columnas> o uq_<tabla>_<columnas> per gli univoci.
- Foreign key esplicite: fk_<tabla>_<tabla_destino>.
- Procedure / funzioni: prefisso sp_ / fn_ opzionale, verbo all'infinito (fn_calcular_descuento).

Coerenza > preferenza personale. Concorda la convenzione nel tuo team e applicala universalmente.

SQL Server

In SQL Server il nome che conta ha due parti, schema.oggetto, e si scrivono sempre entrambe, nel DDL e nelle query. Un nome di una sola parte si risolve contro lo schema predefinito di ciascun utente: SELECT … FROM pedidos è stato ventas.pedidos per un utente con DEFAULT_SCHEMA = ventas, e per sa ha dato Msg 208, perché cercava dbo.pedidos.

Mai il prefisso sp_ per le tue procedure: è quello delle procedure di sistema. Un tuo dbo.sp_who non viene mai eseguito: sia EXEC sp_who sia EXEC dbo.sp_who hanno lanciato quella di sistema. Usa un altro prefisso, o nessuno (ventas.calcular_descuento).

Backup

MySQL 5.7+MariaDB 10.5+Aurora

1. Strategia 3-2-1: 3 copie, 2 supporti diversi, 1 fuori sede.
2. Tipi:
- mysqldump — logico, portabile, lento nel ripristino (~5–10 MB/s).
- mariabackup / xtrabackup — fisico, molto più veloce, richiede una breve pausa dell'I/O.
- Snapshot del filesystem (LVM, ZFS) — istantaneo ma legato al filesystem.
3. Provare il ripristino — un backup senza ripristino verificato non è un backup.
4. Retention: giornalieri 7 giorni + settimanali 4 + mensili 12 è un punto di partenza ragionevole.

Calíope ha un modulo di backup integrato: Aiuto › Backup.

-- Dump logico con consistenza transazionale
-- (dalla shell, non SQL):
-- mysqldump --single-transaction --routines --triggers --events \\
--           -u root -p mi_base > mi_base.sql

-- Backup físico (mariabackup):
-- mariabackup --backup --target-dir=/srv/backup/full \\
--             --user=root --password=...

SQL Server

1. Strategia 3-2-1: 3 copie, 2 supporti diversi, 1 fuori sede.
2. Modello di recupero FULL e backup del log in ogni database dove perdere il lavoro di oggi non è accettabile: completo, differenziale e BACKUP LOG ogni pochi minuti. In FULL senza BACKUP LOG il log cresce senza fine; in SIMPLE si torna solo all'ultimo completo.
3. Sempre WITH CHECKSUM, e poi RESTORE VERIFYONLY … WITH CHECKSUM: senza CHECKSUM nel backup, quella verifica non si può fare (Msg 3187). COMPRESSION —che Express non ha— ha portato un completo da 4 056 kB a 647 kB.
4. Ogni backup occasionale con COPY_ONLY, altrimenti i differenziali successivi si contano da lui.
5. Provare il ripristino, con un altro nome, ogni tanto: VERIFYONLY dice che il file si legge, non che il database torni.
6. Conservazione: 7 giornalieri + 4 settimanali + 12 mensili come punto di partenza, tenendo conto che un backup del log serve solo insieme al completo che lo precede.

ALTER DATABASE mi_base SET RECOVERY FULL;
BACKUP DATABASE mi_base TO DISK = N'/var/opt/mssql/data/mi_base_full.bak'
    WITH CHECKSUM, COMPRESSION, INIT;
BACKUP LOG mi_base TO DISK = N'/var/opt/mssql/data/mi_base_log.trn'
    WITH CHECKSUM, INIT;
RESTORE VERIFYONLY FROM DISK = N'/var/opt/mssql/data/mi_base_full.bak'
    WITH CHECKSUM;

I file restano sul disco del server. Il Backup di Calíope è un'altra cosa —uno script per portare il database, o parte di esso, su un'altra macchina— e non sostituisce questi: cosa fa ciascuno lo spiega l'argomento «Backup e ripristino (SQL Server)».

Replica

MySQL 5.7+MariaDB 10.5+Aurora

- Replica asincrona (default) — il primario non aspetta la replica. Rischio: perdita delle ultime transazioni se il primario si guasta.
- Replica semisincrona — il primario attende la conferma di almeno una replica prima di confermare al client.
- Gruppo di replica (MySQL InnoDB Cluster, MariaDB Galera) — multi-primario con consenso.

Buone pratiche:

1. GTID attivato (gtid_mode = ON) — necessario per il failover automatico e per gli strumenti moderni.
2. binlog_format = ROW — più robusto di STATEMENT di fronte a funzioni non deterministiche.
3. Replica dedicata — un utente replica con solo REPLICATION SLAVE, IP ristretta.
4. Lag monitorato — SHOW REPLICA STATUS (SHOW SLAVE STATUS nelle versioni vecchie), avviso quando Seconds_Behind_Source > 30.

SQL Server

SQL Server replica con strumenti propri —gruppi di disponibilità Always On, log shipping e replica transazionale o di tipo merge—, e nessuno somiglia al binlog: non ci sono GTID né binlog_format da regolare. Calíope non li configura né li sorveglia: Monitor della replica e Topologia in una sessione di SQL Server non compaiono.

Utenti e permessi

Principio del privilegio minimo: ogni connessione usa l'utente più restrittivo possibile.

MySQL 5.7+MariaDB 10.5+Aurora

-- Creare un utente applicativo con permessi limitati
CREATE USER 'app_pedidos'@'10.0.%.%' IDENTIFIED BY 'contraseña_fuerte';

GRANT SELECT, INSERT, UPDATE, DELETE
   ON mi_base.pedidos        TO 'app_pedidos'@'10.0.%.%';
GRANT SELECT
   ON mi_base.clientes       TO 'app_pedidos'@'10.0.%.%';

-- MAI in produzione
-- GRANT ALL PRIVILEGES ON *.* TO 'app'@'%';
FLUSH PRIVILEGES;

Regole:

1. Un utente per applicazione / per funzione. Facilita l'audit.
2. Nessun privilegio *.* per gli utenti applicativi. Concedi per database o per tabella.
3. Nessun accesso applicativo all'utente root. È riservato alle attività amministrative.
4. Ruota le password e usa un'autenticazione robusta (caching_sha2_password in MySQL 8, ed25519 in MariaDB).
5. Restringi l'host ('app'@'10.0.%.%'), non usare '%'.

SQL Server

Qui sono due cose: il login entra nel server e l'utente esiste dentro ogni database. I permessi si concedono all'utente, e meglio per schema che tabella per tabella: GRANT … ON SCHEMA::ventas raggiunge anche le tabelle create in seguito.

USE master;
CREATE LOGIN app_pedidos WITH PASSWORD = N'Contraseña-Fuerte-2026',
    CHECK_POLICY = ON, DEFAULT_DATABASE = mi_base;
USE mi_base;
CREATE USER app_pedidos FOR LOGIN app_pedidos WITH DEFAULT_SCHEMA = ventas;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::ventas TO app_pedidos;

-- MAI in produzione
-- ALTER SERVER ROLE sysadmin ADD MEMBER app_pedidos;
-- ALTER ROLE db_owner ADD MEMBER app_pedidos;

Regole:

1. Un login per applicazione / per funzione, con CHECK_POLICY = ON, che rifiuta per esempio la password che contiene il nome del login (Msg 33064).
2. Né sysadmin né db_owner per un'applicazione. Un DENY non ferma sysadmin, che entra come dbo e non passa nessun controllo.
3. sa solo per amministrare, e disattivato (ALTER LOGIN sa DISABLE) appena c'è un altro amministratore.
4. L'applicazione non crea oggetti: con soli permessi sui dati del suo schema, un suo CREATE TABLE ha dato Msg 262.
5. Spostando un database su un altro server, crea il login con il suo SID di origine, o l'utente resta orfano.

Lo racconta per intero l'argomento «Login, utenti e permessi (SQL Server)».

SQL Server

Query, transazioni e migrazioni

1. Sempre parametri, mai testo concatenato. Una lista IN di parametri ha un tetto: sp_executesql ne ha accettati 2 098 (con 2 099, Msg 180); per di più, una tabella temporanea o un parametro con valori di tabella.
2. SET XACT_ABORT ON in ogni batch o procedura che scrive, e un TRY…CATCH che finisca con IF @@TRANCOUNT > 0 ROLLBACK; THROW;. Senza, un 2627 annulla solo la sua istruzione e il COMMIT conferma tutto quello che c'è intorno.
3. Non annidare BEGIN TRAN: il COMMIT interno non conferma niente e il ROLLBACK interno annulla tutto. Per annullare solo una parte, SAVE TRANSACTION.
4. Nessuna transazione aperta in attesa di una persona: una sessione addormentata con una transazione aperta ha trattenuto 73,8 MB di log. DBCC OPENTRAN la trova.
5. Misura in letture logiche, non in tempo, e diffida della procedura che va bene con alcuni valori e male con altri: è il parameter sniffing. Compilata per un cliente di 20 righe, quella del cliente da 100 000 ha letto 300 177 pagine contro le 6 085 della scansione. OPTION (RECOMPILE) la cura al prezzo di compilare a ogni chiamata; OPTIMIZE FOR UNKNOWN non l'ha curata.
6. A ogni migrazione, SET LOCK_TIMEOUT prima dell'ALTER TABLE —senza, un SELECT altrui ha aspettato 6 s in coda dietro la modifica—, un DEFAULT con nome, e un ALTER INDEX … REBUILD dopo un ALTER COLUMN che riscrive la tabella, che fino ad allora occupa il doppio.
7. La tabella che cresce senza fine si partiziona prima che diventi grande, con gli indici allineati e sempre una partizione vuota alla fine: eliminare un anno con SWITCH ha richiesto meno di 1 ms, contro 180 ms e 21 MB di log con DELETE.
8. La configurazione del server, di proposito: max server memory (MB) arriva senza tetto e gliene si mette sempre uno, e prima di un RECONFIGURE si guarda SELECT … FROM sys.configurations WHERE value <> value_in_use, perché installa tutto ciò che è in sospeso, anche quello che un altro ha lasciato scritto.

Ogni punto ha il suo argomento con «(SQL Server)» nel titolo: «Limiti», «Transazioni e livelli di isolamento», «Prestazioni e diagnosi», «Modifiche di schema a caldo», «Partizionamento» e «Configurazione del server».

Audit

MySQL 8.0+

MySQL Enterprise ha un plugin di audit. La Community Edition no — di solito lo si supplisce con il general log (costoso in termini di prestazioni) o con plugin esterni.

MariaDB 10.5+

MariaDB include il plugin server_audit:

INSTALL SONAME 'server_audit';
SET GLOBAL server_audit_logging = ON;
SET GLOBAL server_audit_events = 'CONNECT,QUERY,TABLE';
SET GLOBAL server_audit_file_path = '/var/log/mysql/audit.log';

SQL Server

SQL Server include SQL Server Audit: un audit di server che scrive su file e, in ogni database, una specifica che dice quali azioni su quali oggetti vengono registrate. Secondo la documentazione, quella di database è in tutte le edizioni dalla 2016 SP1. Si legge con sys.fn_get_audit_file: con quella qui sotto, un SELECT e un UPDATE di app_pedidos sono usciti come righe SL e UP, con il loro login.

USE master;
CREATE SERVER AUDIT aud_ventas TO FILE (FILEPATH = N'/var/opt/mssql/data/');
ALTER SERVER AUDIT aud_ventas WITH (STATE = ON);
USE mi_base;
CREATE DATABASE AUDIT SPECIFICATION das_ventas FOR SERVER AUDIT aud_ventas
    ADD (SELECT, INSERT, UPDATE, DELETE ON SCHEMA::ventas BY public)
    WITH (STATE = ON);

SELECT event_time, action_id, server_principal_name, statement
FROM sys.fn_get_audit_file(N'/var/opt/mssql/data/aud_ventas*.sqlaudit', DEFAULT, DEFAULT);

Calíope conserva un log locale delle query eseguite (Registro SQL) per ogni sessione di connessione, indipendente dal log del server.

Lista minima per la produzione

MySQL 5.7+MariaDB 10.5+Aurora

1. ✅ Backup automatici + ripristino verificato mensilmente.
2. ✅ Replica con lag monitorato.
3. ✅ Utenti applicativi senza privilegi eccessivi.
4. ✅ TLS obbligatorio per le connessioni esterne.
5. ✅ Slow query log attivato (long_query_time = 1).
6. ✅ Monitoraggio dello spazio su disco (avviso all'80 %).
7. ✅ Aggiornamenti di sicurezza applicati trimestralmente.

SQL Server

1. ✅ Modello FULL con BACKUP LOG pianificato, WITH CHECKSUM, e un ripristino provato ogni mese.
2. ✅ log_reuse_wait_desc sorvegliato: dice perché il log non si svuota.
3. ✅ Login di applicazione senza sysadmin né db_owner, e sa disattivato.
4. ✅ TLS con un certificato tuo: senza, SQL Server 2022+ non accetta il TLS rigoroso (TDS 8.0), e Calíope entra da quello di TDS 7.x e lo dice.
5. ✅ Query Store attivo —lo è di fabbrica sui database nuovi— e max server memory (MB) con un tetto.
6. ✅ Monitoraggio dello spazio su disco, anche quello del log e di tempdb (avviso all'80 %).
7. ✅ Aggiornamenti cumulativi (CU) applicati con un calendario.

Aurora

Su Amazon Aurora la replica dentro il cluster non si configura. I nodi lettori condividono il volume con lo scrittore, quindi non c'è binlog di mezzo né Seconds_Behind_Source da sorvegliare: il ritardo si misura in information_schema.replica_host_status e di solito è nell'ordine dei millisecondi. binlog_format e GTID contano solo se replichi anche fuori dal cluster — verso un altro cluster, verso RDS o verso un MySQL esterno.

Parole chiave: buone pratiche, progettazione, convenzioni, backup, replica, GTID, binlog, GRANT, audit, sicurezza, InnoDB, foreign key, TLS, SQL Server, IDENTITY, SEQUENCE, schema, login, XACT_ABORT, SQL Server Audit

Performance e ottimizzazione

Analisi delle query con EXPLAIN, slow query log, individuazione dei colli di bottiglia, cache di InnoDB e uso di performance_schema.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

L'ottimizzazione inizia con il misurare. Senza dati, ottimizzare è tirare a indovinare. Questi sono gli strumenti di base.

EXPLAIN

Mostra il piano che l'optimizer ha scelto per una query. Non la esegue — è sicuro lanciarlo in produzione.

EXPLAIN SELECT c.nombre, COUNT(p.id) AS pedidos
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
WHERE c.activo = 1
GROUP BY c.id;

Colonne chiave:

- type — metodo di accesso. Dal migliore al peggiore: system → const → eq_ref → ref → range → index → ALL. ALL = full table scan = pessimo su tabelle grandi.
- key — index scelto. NULL = non usa index.
- rows — stima delle righe esaminate. Se è molto maggiore delle righe restituite, c'è margine di miglioramento.
- Extra — indizi utili:
- Using index — covering index (ottimo).
- Using where — filtro applicato dopo la lettura delle righe.
- Using temporary — necessita di una tabella temporanea (costoso).
- Using filesort — ordinamento fuori index (costoso su tabelle grandi).

EXPLAIN ANALYZE (MySQL 8.0.18+; in MariaDB, ANALYZE e basta)

Esegue la query e mostra i tempi reali per nodo. Più costoso di EXPLAIN, ma molto più informativo.

EXPLAIN ANALYZE
SELECT c.nombre, COUNT(p.id) AS pedidos
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
GROUP BY c.id;

Calíope dispone di un Visual Explain integrato che renderizza l'albero del piano: il pulsante Visual EXPLAIN della barra dell'Editor SQL, sulla query selezionata.

Slow query log

Registra tutte le query che impiegano più di long_query_time secondi.

SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;     -- 1 segundo
SET GLOBAL log_queries_not_using_indexes = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

Analisi del log:

- mysqldumpslow — strumento classico incluso con MySQL.
- pt-query-digest (Percona Toolkit) — lo standard de facto, raggruppa per fingerprint e mostra statistiche.

performance_schema

Schema di tabelle con statistiche dettagliate del server.

-- Prime 10 query per tempo totale accumulato
SELECT digest_text,
       count_star            AS exec,
       ROUND(sum_timer_wait/1e9, 2) AS total_ms,
       ROUND(avg_timer_wait/1e9, 2) AS avg_ms,
       sum_rows_examined     AS rows_exam
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC
LIMIT 10;

-- Tabelle con più I/O
SELECT object_schema, object_name,
       count_read, count_write,
       ROUND(sum_timer_wait/1e9, 2) AS total_ms
FROM performance_schema.table_io_waits_summary_by_table
WHERE object_schema = DATABASE()
ORDER BY sum_timer_wait DESC
LIMIT 10;

MariaDB 10.5+

MariaDB ha anche la variabile userstat, che una volta accesa aggiunge statistiche per utente, index e tabella con meno overhead di performance_schema in alcuni casi.

Cache di InnoDB

- Buffer pool — dati e index. Metrica chiave: hit ratio (1 − Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests). Obiettivo: >99 %.

SELECT ROUND((1 - r.v / q.v) * 100, 2) AS buffer_pool_hit_pct
FROM (SELECT VARIABLE_VALUE AS v FROM performance_schema.global_status
      WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') r,
     (SELECT VARIABLE_VALUE AS v FROM performance_schema.global_status
      WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') q;

-- Senza performance_schema (spento di fabbrica in MariaDB), i due contatori a mano:
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';

- Adaptive hash index — hash automatico sulle pagine calde del buffer pool. Attivo di fabbrica in MySQL 8.0, spento nel MySQL attuale e in MariaDB (misurato: 1 su 8.0.46; 0 su 26.7 e su MariaDB 13.0).
- Change buffer — bufferizza le modifiche a pagine non presenti nel buffer pool.

Colli di bottiglia frequenti

SintomoCausa probabileAzione
CPU al 100 %Query senza index o stime errateEXPLAIN, slow log
I/O al 100 %Buffer pool insufficienteAumentare innodb_buffer_pool_size
Connessioni al limiteConnection leak nell'appVerificare il pool nell'applicazione
Threads_running altoContesa sui lockVedi SHOW ENGINE INNODB STATUS
tmp_disk_tables crescetmp_table_size piccoloAumentare tmp_table_size
Replication lagSingle-thread o transazioni lungheAttivare slave_parallel_workers

Ottimizzazioni delle query — pattern comuni

1. Seleziona solo ciò che ti serve. Evita SELECT * nelle applicazioni.
2. Evita le funzioni sulle colonne indicizzate:
- Male: WHERE YEAR(fecha) = 2024 → non usa index.
- Bene: WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01'.
3. LIMIT con offset grande è costoso — per la paginazione profonda, usa la keyset pagination: WHERE id > :last_seen ORDER BY id LIMIT 50.
4. COUNT(*) su tabelle grandi — InnoDB non mantiene un contatore. Valuta colonne di riepilogo o stime (information_schema.tables.table_rows).
5. Le subquery non correlate vengono eseguite una volta; le correlate, una volta per ogni riga esterna. Riscrivile come JOIN se possibile.

Raccomandazione

Crea un dashboard di monitoraggio di base (Calíope ne ha uno: Dashboard) con:

- Connessioni (Threads_connected, Threads_running).
- Buffer pool hit ratio.
- Query lente al minuto.
- Replication lag.
- Spazio su disco per tablespace.

Prima rilevi una degradazione, più facile è correggerla.

Parole chiave: performance, ottimizzazione, EXPLAIN, EXPLAIN ANALYZE, slow query log, performance_schema, buffer pool, filesort, covering index, filter, collo di bottiglia, pt-query-digest

Transazioni e livelli di isolamento

ACID, COMMIT e ROLLBACK, i quattro livelli di isolamento e quale anomalia consente ciascuno.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Una transazione raggruppa più istruzioni in un'unità che si applica per intero o non si applica affatto. In InnoDB ogni istruzione gira dentro una transazione: se non ne apri una, il server ne apre e conferma una per istruzione (autocommit = 1).

ACID
- Atomicità — o si applicano tutte le modifiche, o nessuna.
- Coerenza — il database passa da uno stato valido a un altro; i vincoli restano rispettati.
- Isolamento — le transazioni concorrenti non si vedono mai a metà.
- Durabilità — ciò che è confermato sopravvive a un crash del server.

Controllo manuale
COMMIT conferma e ROLLBACK annulla tutto ciò che è stato fatto da START TRANSACTION.

START TRANSACTION;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2;
COMMIT;

Punti di salvataggio
Un SAVEPOINT annulla solo una parte senza perdere il resto della transazione:

START TRANSACTION;
INSERT INTO pedidos (cliente_id) VALUES (42);
SAVEPOINT tras_pedido;
INSERT INTO lineas (pedido_id, sku) VALUES (LAST_INSERT_ID(), 'X-1');
ROLLBACK TO SAVEPOINT tras_pedido;
COMMIT;

I quattro livelli

LivelloLettura sporcaLettura non ripetibileLettura fantasma
READ UNCOMMITTEDsìsìsì
READ COMMITTEDnosìsì
REPEATABLE READnonono (InnoDB)
SERIALIZABLEnonono

Il livello predefinito di InnoDB è REPEATABLE READ. Grazie a MVCC e ai gap lock, InnoDB evita a quel livello anche le letture fantasma, cosa che lo standard SQL non richiede.

Cambiare livello

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

SELECT @@transaction_isolation;

Attenzione al DDL
CREATE, ALTER, DROP e TRUNCATE provocano un commit implicito: non si annullano con ROLLBACK. Una migrazione a metà lascia la tabella com'è rimasta.

Transazioni lunghe
Una transazione aperta obbliga InnoDB a conservare le vecchie versioni di ogni riga per le letture coerenti. Uno START TRANSACTION dimenticato fa crescere l'undo log e degrada l'intero server. Trovale così:

SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

Raccomandazione
Transazioni brevi, con la logica applicativa fuori e solo l'SQL dentro. READ COMMITTED riduce i lock ed è ciò che usano molte applicazioni web; resta su REPEATABLE READ se ti serve che due letture nella stessa transazione restituiscano la stessa cosa.

Parole chiave: transazione, commit, rollback, savepoint, isolamento, acid, mvcc, read committed, repeatable read, serializable, autocommit, innodb_trx

Lock e deadlock

Cosa blocca InnoDB, perché nasce un deadlock e come diagnosticarlo senza tirare a indovinare.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

InnoDB blocca righe, non tabelle, e lo fa automaticamente in scrittura. Quasi ogni problema di concorrenza si spiega con quali righe una query ha finito per bloccare, e questo dipende dall'indice che ha usato.

Tipi di lock
- Condiviso (S) — più transazioni possono leggere la stessa riga insieme.
- Esclusivo (X) — lo prende chi scrive; nessun altro può leggerla con lock né scriverla.
- Gap — blocca lo spazio tra due valori dell'indice per impedire inserimenti. Solo in REPEATABLE READ e SERIALIZABLE.
- Next-key — la riga più il gap che la precede. È la modalità normale di InnoDB quando percorre un indice.
- Di intenzione (IS/IX) — segnala a livello di tabella che dentro ci sono lock di riga; impedisce a un LOCK TABLES di infilarsi.

La conseguenza pratica: se la query non usa un indice, InnoDB percorre l'intera tabella e blocca ogni riga esaminata, non solo quelle che corrispondono. Un buon indice non accelera soltanto: riduce ciò che viene bloccato.

Letture bloccanti
Un SELECT normale non blocca nulla (legge una versione coerente tramite MVCC). Se devi leggere e poi scrivere senza che nessuno si infili in mezzo, chiedi il lock esplicitamente con FOR UPDATE o FOR SHARE:

START TRANSACTION;
SELECT saldo FROM cuentas WHERE id = 1 FOR UPDATE;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;

Cos'è un deadlock
Due transazioni che aspettano ciascuna un lock tenuto dall'altra. Nessuna può proseguire:

-- A
START TRANSACTION;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 2;

-- B
START TRANSACTION;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 2;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 1;

InnoDB lo rileva da solo e uccide la transazione più economica da annullare, che riceve l'errore 1213 Deadlock found when trying to get lock. Non è un guasto del server né una corruzione: è il comportamento corretto, e l'applicazione deve riprovare quella transazione.

Diverso è l'errore 1205 Lock wait timeout exceeded: lì non c'è ciclo, solo un'attesa che ha superato innodb_lock_wait_timeout (50 s per impostazione predefinita).

Diagnosi
La sezione LATEST DETECTED DEADLOCK di SHOW ENGINE INNODB STATUS conserva l'ultimo deadlock con entrambe le transazioni e le istruzioni coinvolte. Per vedere i lock in questo momento c'è performance_schema in MySQL 8.0+ (MariaDB non ha data_locks, lì sono information_schema.INNODB_LOCK_WAITS e INNODB_TRX):

SHOW ENGINE INNODB STATUS;

SELECT * FROM performance_schema.data_locks;

SELECT * FROM performance_schema.data_lock_waits;

SELECT @@innodb_lock_wait_timeout;

Come evitarli
1. Accedere sempre nello stesso ordine — se tutto il codice tocca tabelle e righe nello stesso ordine, nessun ciclo è possibile.
2. Transazioni brevi — meno tempo con i lock presi, meno occasioni di scontro.
3. Indicizzare ciò che si filtra — evita di bloccare righe che nemmeno corrispondevano.
4. Riprovare — un deadlock occasionale è normale in un sistema concorrente; avvolgi la transazione in un ritentativo con attesa crescente.
5. Evitare SELECT ... FOR UPDATE inutili — se non devi scrivere, non chiederlo.

Raccomandazione
Davanti ai lock guarda prima il piano della query: la maggior parte dei deadlock reali sparisce appena si aggiunge l'indice mancante. L'Elenco processi di Calíope ti mostra quale sessione sta aspettando.

Parole chiave: lock, deadlock, gap lock, next-key, for update, for share, errore 1213, innodb status, data_locks, lock wait timeout

Partizionamento delle tabelle

RANGE, LIST, HASH e KEY, pruning delle partizioni e pulizia istantanea con DROP PARTITION.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Partizionare divide una tabella in più pezzi fisici che il server continua a vedere come una sola. Non rende magicamente veloci le query: quello che dà è il pruning delle partizioni e soprattutto la possibilità di cancellare milioni di righe in un istante.

Quando conviene
Il caso netto è una tabella che cresce per data e da cui si ripulisce il vecchio: log, eventi, metriche, audit. Lì DROP PARTITION sostituisce un DELETE da ore.

CREATE TABLE eventos (
    id        BIGINT NOT NULL AUTO_INCREMENT,
    ocurrido  DATE   NOT NULL,
    payload   JSON,
    PRIMARY KEY (id, ocurrido)
)
PARTITION BY RANGE (YEAR(ocurrido)) (
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION pmax  VALUES LESS THAN MAXVALUE
);

I quattro tipi
- RANGE — per intervalli di un valore ordinabile, quasi sempre una data. Il più utile.
- LIST — per appartenenza a un insieme discreto di valori.
- HASH — distribuzione uniforme su un'espressione intera; serve a distribuire le scritture, non a fare pruning.
- KEY — come HASH ma con la funzione interna del server; accetta colonne non intere.

Le varianti RANGE COLUMNS e LIST COLUMNS accettano più colonne e tipi non interi senza avvolgerli in una funzione:

PARTITION BY LIST (region_id) (
    PARTITION europa VALUES IN (1, 2, 3),
    PARTITION asia   VALUES IN (4, 5)
);

PARTITION BY HASH (cliente_id) PARTITIONS 8;

PARTITION BY KEY (uuid) PARTITIONS 4;

PARTITION BY RANGE COLUMNS (pais, alta) (
    PARTITION p_es_2024 VALUES LESS THAN ('ES', '2025-01-01')
);

Pruning delle partizioni
Il vantaggio vero: se il WHERE filtra sulla colonna di partizionamento, il server legge solo le partizioni che possono contenere risultati. Verificalo nella colonna partitions di EXPLAIN — se compaiono tutte, non stai facendo pruning e il partizionamento ti sta solo costando.

EXPLAIN SELECT COUNT(*) FROM eventos
WHERE ocurrido BETWEEN '2025-03-01' AND '2025-03-31';

SELECT partition_name, table_rows
FROM information_schema.PARTITIONS
WHERE table_name = 'eventos';

Limiti da conoscere prima
1. La chiave di partizionamento deve far parte di ogni chiave univoca, primaria inclusa. Per questo l'esempio porta PRIMARY KEY (id, ocurrido) e non solo id.
2. Niente chiavi esterne: una tabella partizionata non può avere né ricevere una FOREIGN KEY.
3. Al massimo 8192 partizioni per tabella, e ognuna consuma descrittori di file.
4. Le query che non filtrano sulla chiave toccano tutte le partizioni e risultano più lente che senza partizionamento.
5. Gli indici sono locali a ogni partizione: l'indice globale non esiste.

Manutenzione
Aggiungere la partizione del periodo successivo e lasciar andare la più vecchia è la routine normale. DROP PARTITION è praticamente istantaneo e libera davvero lo spazio, cosa che un DELETE massivo non fa:

ALTER TABLE eventos DROP PARTITION p2023;

ALTER TABLE eventos REORGANIZE PARTITION pmax INTO (
    PARTITION p2026 VALUES LESS THAN (2027),
    PARTITION pmax  VALUES LESS THAN MAXVALUE
);

ALTER TABLE eventos REBUILD PARTITION p2025;

Raccomandazione
Partiziona per data solo se hai intenzione di ripulire per data, e crea le partizioni future in anticipo (o con un evento pianificato): se arriva una riga che non rientra in alcun intervallo, l'INSERT fallisce. Lascia sempre una pmax di rete.

Parole chiave: partizionamento, partition, range, list, hash, key, pruning, drop partition, reorganize, information_schema.partitions, pulizia

CTE e funzioni finestra

WITH, WITH RECURSIVE e OVER (): l'SQL moderno che evita sottoquery annidate e tabelle temporanee.

Si applica a: MySQL 8.0+ MariaDB 10.2+ Aurora 3+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

Le CTE (WITH) e le funzioni finestra (OVER ()) sono arrivate in MySQL 8.0 e MariaDB 10.2; su PostgreSQL non c'è versione ancora supportata che non le abbia, e su SQLite entrambe stanno ben sotto la soglia di questo manuale: le CTE dalla 3.8.3 e le finestre dalla 3.25. Risolvono in una query leggibile ciò che prima richiedeva sottoquery annidate, tabelle temporanee o variabili di sessione.

CTE: dare un nome a un passaggio intermedio
Una CTE è un risultato con nome che vive solo per la durata della query. Serve a spezzare una query lunga in passaggi e a riferirsi due volte allo stesso sotto-risultato senza ripeterlo:

MySQL 5.7+MariaDB 10.5+Aurora

Il mese si ricava con DATE_FORMAT:

WITH ventas_mes AS (
    SELECT vendedor_id, DATE_FORMAT(fecha, '%Y-%m') AS mes, SUM(total) AS total
    FROM pedidos
    GROUP BY vendedor_id, mes
)
SELECT * FROM ventas_mes WHERE total > 10000;

PostgreSQL 13+

DATE_FORMAT non esiste su PostgreSQL: risponde 42883, function date_format(date, unknown) does not exist. L'equivalente è to_char, e per raggruppare per mese di solito conviene date_trunc, che restituisce una data invece di un testo. Raggruppare per l'alias di output funziona, come su MySQL:

WITH ventas_mes AS (
    SELECT vendedor_id, date_trunc('month', fecha) AS mes, SUM(total) AS total
    FROM pedidos
    GROUP BY vendedor_id, mes
)
SELECT * FROM ventas_mes WHERE total > 10000;

SQLite 3.35+

DATE_FORMAT non esiste nemmeno in SQLite: misurato su 3.51, risponde no such function: DATE_FORMAT. L'equivalente è strftime, con gli stessi codici di %Y-%m. Raggruppare per l'alias dell'uscita funziona come sugli altri due motori.

WITH ventas_mes AS (
    SELECT vendedor_id, strftime('%Y-%m', fecha) AS mes, SUM(total) AS total
    FROM pedidos
    GROUP BY vendedor_id, mes
)
SELECT * FROM ventas_mes WHERE total > 10000;

SQL Server

DATE_FORMAT non esiste nemmeno in SQL Server: misurato su 2025, risponde 'DATE_FORMAT' is not a recognized built-in function name (errore 195). Il mese si ottiene con FORMAT(fecha, 'yyyy-MM') o, dalla 2022, con DATETRUNC(month, fecha), che restituisce una data. E qui non si raggruppa per l'alias dell'output: GROUP BY vendedor_id, mes risponde Invalid column name 'mes' (errore 207), quindi l'espressione si ripete:

WITH ventas_mes AS (
    SELECT vendedor_id, DATETRUNC(month, fecha) AS mes, SUM(total) AS total
    FROM pedidos
    GROUP BY vendedor_id, DATETRUNC(month, fecha)
)
SELECT * FROM ventas_mes WHERE total > 10000;

CTE ricorsiva: gerarchie
WITH RECURSIVE percorre strutture ad albero — organigrammi, categorie annidate, distinte base — senza cicli nell'applicazione. Il primo ramo è il caso base e il secondo si ripete finché non restituisce righe:

WITH RECURSIVE arbol AS (
    SELECT id, nombre, jefe_id, 1 AS nivel
    FROM empleados
    WHERE jefe_id IS NULL

    UNION ALL

    SELECT e.id, e.nombre, e.jefe_id, a.nivel + 1
    FROM empleados e
    JOIN arbol a ON e.jefe_id = a.id
)
SELECT * FROM arbol ORDER BY nivel, nombre;

PostgreSQL 13+

Su PostgreSQL RECURSIVE non è facoltativo, e l'errore che si ottiene dimenticandolo depista: misurato su 17.6, la stessa query senza RECURSIVE risponde 42P01, lo stesso errore che si avrebbe se arbol fosse una tabella inesistente.

SQLite 3.35+

In SQLite succede il contrario: RECURSIVE è opzionale. Misurato su 3.51, la stessa query scritta WITH arbol AS (…) restituisce esattamente le stesse righe che con WITH RECURSIVE. Scriverlo comunque costa una parola e tiene la query pronta per PostgreSQL, che lo esige; SQL Server invece lo rifiuta.

SQL Server

In SQL Server RECURSIVE non si scrive: misurato su 2025, WITH RECURSIVE arbol AS (…) è un errore di sintassi (Incorrect syntax near 'arbol', errore 102), e WITH arbol AS (…) restituisce l'albero. Inoltre la ricorsione si ferma a 100 livelli (errore 530): per andare più a fondo, OPTION (MAXRECURSION n) alla fine della query, e 0 toglie il limite.

Funzioni finestra: calcolare senza raggruppare
Un GROUP BY collassa le righe; una funzione finestra calcola su un insieme di righe correlate e conserva ogni riga. È ciò che rende possibile un cumulato, una media mobile o una posizione dentro il gruppo in una sola passata:

SELECT
    vendedor_id,
    fecha,
    total,
    SUM(total)    OVER (PARTITION BY vendedor_id ORDER BY fecha) AS acumulado,
    AVG(total)    OVER (PARTITION BY vendedor_id
                        ORDER BY fecha
                        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS media_7,
    RANK()        OVER (PARTITION BY vendedor_id ORDER BY total DESC) AS puesto,
    LAG(total, 1) OVER (PARTITION BY vendedor_id ORDER BY fecha) AS anterior
FROM pedidos;

Le più usate
- ROW_NUMBER() — numero progressivo nella partizione, senza pari merito.
- RANK() / DENSE_RANK() — posizione con pari merito; RANK lascia buchi, DENSE_RANK no.
- LAG() / LEAD() — il valore della riga precedente o successiva, senza self-join.
- FIRST_VALUE() / LAST_VALUE() — gli estremi della finestra.
- NTILE(n) — divide le righe in n secchi, per quartili e percentili.
- Aggregati con OVER — SUM, AVG, COUNT, MIN, MAX senza collassare le righe.

Il frame (ROWS BETWEEN ...) definisce quali righe entrano nel calcolo di ciascuna. Per impostazione predefinita un aggregato con ORDER BY va dall'inizio della partizione alla riga corrente, che è esattamente ciò che dà il cumulato.

PostgreSQL 13+

PostgreSQL porta inoltre pezzi che MySQL 8.0.46 ancora non ha, misurati su entrambi:

- FILTER (WHERE …) — condiziona un aggregato senza infilarci un CASE: count(*) FILTER (WHERE total > 1000). Su MySQL è un errore di sintassi.
- Frame GROUPS e clausola EXCLUDE, oltre a ROWS e RANGE. MySQL 8.0.46 risponde This version of MySQL doesn't yet support 'GROUPS', errore 1235.
- DISTINCT ON — una riga per gruppo, la prima secondo l'ORDER BY, senza ROW_NUMBER() né CTE. È di PostgreSQL e di nessun altro.

La finestra con nome — OVER w … WINDOW w AS (…) — c'è in entrambi, e risparmia di ripetere la definizione su ogni colonna.

SQLite 3.35+

Di quei pezzi che PostgreSQL ha e MySQL no, SQLite li ha quasi tutti. Misurato su 3.51:

- FILTER (WHERE …) — funziona sugli aggregati: count(*) FILTER (WHERE total > 1000).
- Frame GROUPS e clausola EXCLUDE — funzionano entrambi, oltre a ROWS e RANGE.
- Finestra con nome — OVER w … WINDOW w AS (…) funziona come sugli altri due.
- DISTINCT ON — non esiste: è un errore di sintassi. Una riga per gruppo si ottiene con ROW_NUMBER(), che è il modello qui sotto.

SQL Server

SQL Server resta più vicino a MySQL che a PostgreSQL. Misurato su 2025:

- FILTER (WHERE …) — non esiste: è un errore di sintassi. Si scrive COUNT(CASE WHEN total > 1000 THEN 1 END).
- Cornici GROUPS e clausola EXCLUDE — nemmeno. E RANGE ammette solo UNBOUNDED e CURRENT ROW: RANGE BETWEEN 100 PRECEDING AND CURRENT ROW risponde con l'errore 4194.
- Finestra con nome — OVER w … WINDOW w AS (…) funziona dalla 2022.
- DISTINCT ON — non esiste. Una riga per gruppo si ottiene con ROW_NUMBER(), che è lo schema qui sotto.

Il pattern che rende di più: i primi N per gruppo
Tirare fuori i tre prodotti più venduti di ogni categoria senza finestre richiede una sottoquery correlata per riga. Con ROW_NUMBER() è diretto:

WITH ranking AS (
    SELECT
        p.*,
        ROW_NUMBER() OVER (PARTITION BY categoria_id ORDER BY ventas DESC) AS rn
    FROM productos p
)
SELECT * FROM ranking WHERE rn <= 3;

Prestazioni
Nessuna delle due è gratis: la finestra deve ordinare dentro ogni partizione, quindi un indice che consegni già le righe nell'ordine di PARTITION BY più ORDER BY risparmia quell'ordinamento. Verificalo con EXPLAIN prima di dare per buona la versione elegante.

MySQL 5.7+MariaDB 10.5+Aurora

Quell'ordinamento è il filesort dell'EXPLAIN. E occhio alle CTE: in MySQL 8.0 l'ottimizzatore può materializzarle in una tabella temporanea, che a volte esce peggio della sottoquery equivalente.

PostgreSQL 13+

Con le CTE succede il contrario, ed è per questo che il consiglio di MySQL non si trasferisce: da PostgreSQL 12, una CTE usata una sola volta viene appiattita dentro la query. Misurato su 17.6 su 20 000 righe, WITH v AS (SELECT * FROM ventas) SELECT * FROM v WHERE vendedor_id = 3 non lascia nessun CTE Scan nel piano e usa l'indice: 0,297 ms. La stessa con AS MATERIALIZED disegna il CTE Scan sopra un Seq Scan dell'intera tabella e sale a 1,662 ms. Se la CTE è referenziata due o più volte si materializza da sola; e prima della 12 era sempre una barriera per l'ottimizzatore.

SQLite 3.35+

In SQLite quell'ordinamento esce nel piano come USE TEMP B-TREE FOR ORDER BY, e con un indice che già consegna il PARTITION BY scende a USE TEMP B-TREE FOR LAST TERM OF ORDER BY. L'intera finestra si risolve dentro una CO-ROUTINE.

Con le CTE fa come PostgreSQL, e un passo oltre. Misurato su 3.51 su 20 000 righe, WITH v AS (SELECT * FROM ventas) SELECT * FROM v WHERE vendedor_id = 3 non lascia alcun MATERIALIZE nel piano e usa l'indice: 0,108 ms. La stessa con AS MATERIALIZED — che SQLite capisce dalla 3.35, come AS NOT MATERIALIZED — disegna il MATERIALIZE su uno SCAN dell'intera tabella e sale a 2,019 ms. Ed ecco il passo in più: referenziarla due volte non la materializza nemmeno, al contrario di PostgreSQL. Continua ad appiattirsi, con un SEARCH per indice in ogni ramo, 0,203 ms.

SQL Server

Qui una CTE non viene mai materializzata, e questo taglia in entrambi i sensi: usata una volta viene appiattita nella query, come in PostgreSQL, ma referenziata due volte viene calcolata due volte. Misurato su 2025 su 20.000 righe, una CTE con GROUP BY unita a sé stessa lascia nel piano due Index Scan e due Stream Aggregate, uno per riferimento. Non c'è AS MATERIALIZED: se il risultato costa, lo si salva prima in una tabella temporanea (#t).

Raccomandazione
Usa le CTE perché la query si capisca e le finestre per non fare nell'applicazione ciò che il server fa in una passata. Se il tuo server è MySQL 5.7 o MariaDB 10.1, nessuna delle due è disponibile: lì comandano ancora le sottoquery.

Parole chiave: cte, with, with recursive, funzione finestra, over, partition by, row_number, rank, dense_rank, lag, lead, ntile, frame, gerarchia, top n per gruppo

Transazioni e livelli di isolamento (PostgreSQL)

Perché un errore blocca l'intera transazione, come se ne esce con un punto di salvataggio, cosa fa davvero ogni livello, e il DDL che si annulla.

Si applica a: PostgreSQL 13+

Una transazione raggruppa più istruzioni in un'unità che si applica per intero o non si applica. Senza un BEGIN esplicito, PostgreSQL conferma ogni istruzione per conto suo.

La prima sorpresa arrivando da MySQL
Un errore annulla l'intera transazione. Da lì in poi ogni istruzione risponde la stessa cosa — current transaction is aborted, commands ignored until end of transaction block, SQLSTATE 25P02 — finché non fai ROLLBACK. Non è un difetto dell'applicazione: è il progetto, ed evita che una transazione prosegua su uno stato che non è più quello che credevi.

L'uscita è un punto di salvataggio
SAVEPOINT segna un punto a cui tornare, e ROLLBACK TO SAVEPOINT salva la transazione senza perdere quanto fatto prima:

BEGIN;
INSERT INTO cuentas (id, saldo) VALUES (3, 0);
SAVEPOINT tras_alta;
INSERT INTO cuentas (id, saldo) VALUES (3, 0);  -- fallisce: 23505
ROLLBACK TO SAVEPOINT tras_alta;
COMMIT;

Il DDL si annulla davvero
CREATE, ALTER e DROP stanno dentro la transazione: qui non c'è commit implicito. Una migrazione che fallisce a metà non lascia mezza tabella.

BEGIN;
ALTER TABLE cuentas ADD COLUMN moneda text;
ROLLBACK;   -- la colonna non è mai esistita

I quattro livelli

LivelloLettura sporcaLettura non ripetibileLettura fantasma
READ UNCOMMITTEDnosìsì
READ COMMITTEDnosìsì
REPEATABLE READnonono
SERIALIZABLEnonono

READ UNCOMMITTED viene accettato e segnalato come tale, ma si comporta come READ COMMITTED: in PostgreSQL non ci sono letture sporche a nessun livello. Il predefinito è READ COMMITTED.

REPEATABLE READ e SERIALIZABLE non bloccano: annullano
Invece di aspettare, la transazione che non si può serializzare termina con SQLSTATE 40001 (could not serialize access…). Vuol dire che l'applicazione deve riprovare: a questi due livelli un 40001 è funzionamento normale, non un guasto. SERIALIZABLE usa SSI, che rileva le dipendenze di lettura e scrittura e non prende blocchi in più.

Blocchi e deadlock
Un deadlock viene rilevato dopo deadlock_timeout — 1 s per impostazione predefinita — e il server termina una delle due con SQLSTATE 40P01. Per non aspettare, o per distribuire il lavoro tra consumatori:

SELECT id FROM cuentas ORDER BY id FOR UPDATE SKIP LOCKED;

FOR UPDATE NOWAIT fallisce subito con 55P03 invece di aspettare. Attenzione: quel fallimento annulla anch'esso la transazione.

Transazioni lunghe
Qui una transazione aperta non fa ingrossare un undo log: impedisce a VACUUM di ripulire le versioni morte su tutto il server, e la tabella cresce senza righe nuove. Trovale così:

SELECT pid, state, xact_start, now() - xact_start AS duracion, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

idle_in_transaction_session_timeout vale 0 per impostazione predefinita, cioè senza limite; dargli un valore è la rete che evita che una sessione dimenticata degradi l'intero database.

Consiglio
Transazioni brevi, con la logica applicativa fuori. Se sali a REPEATABLE READ o a SERIALIZABLE, scrivi il ritentativo prima di salire, non dopo il primo 40001 in produzione.

Parole chiave: transazione, commit, rollback, savepoint, isolamento, mvcc, read committed, repeatable read, serializable, ssi, 25P02, 40001, 40P01, deadlock, skip locked, pg_stat_activity

VACUUM, autovacuum e lo spazio che non torna

Perché una tabella cresce senza righe nuove, cosa pulisce davvero ogni operazione, quando i contatori mentono e cos'è il wraparound.

Si applica a: PostgreSQL 13+

In PostgreSQL aggiornare una riga non la modifica: scrive una versione nuova e lascia morta quella vecchia. Nemmeno cancellare libera qualcosa subito. È così che funziona MVCC qui, e VACUUM è ciò che raccoglie dopo. Non ha equivalente in InnoDB, ed è dietro quasi tutte le sorprese di dimensione.

Come si vede
Una tabella di 50 000 righe occupava 12 MB. Un solo UPDATE su tutte le righe l'ha lasciata a 23 MB senza aggiungere una sola riga: le 50 000 versioni vecchie sono ancora nel file. Misurato su PostgreSQL 17.6.

Cosa fa ogni cosa
- VACUUM segna lo spazio morto come riutilizzabile. Non restituisce lo spazio al sistema operativo: dopo il vacuum la tabella dell'esempio occupava ancora 23 MB, solo che le scritture successive ora ci stanno dentro.
- VACUUM FULL riscrive l'intera tabella e lo spazio lo restituisce davvero — è scesa a 11 MB — ma prende un blocco ACCESS EXCLUSIVE: nessuno legge né scrive mentre dura, e serve spazio per una copia completa. Non è la manutenzione di routine, è l'ultima risorsa.
- ANALYZE non pulisce nulla: aggiorna le statistiche del planner.

Autovacuum, che è già acceso
autovacuum arriva on. Una tabella entra in coda quando le righe morte superano autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × righe, cioè 50 + 20 % con i valori predefiniti. Su una tabella da dieci milioni di righe sono due milioni di righe morte prima che si muova qualcosa: sulle tabelle grandi e molto aggiornate si abbassa il fattore per tabella:

ALTER TABLE pedidos SET (autovacuum_vacuum_scale_factor = 0.02);

Controllare che funzioni

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 10;

Attenzione a quel contatore: è una stima e non è istantaneo. Misurato su 17.6, subito dopo aver aggiornato 50 000 righe diceva ancora 0; solo dopo un ANALYZE è passato a 50 000, e dopo il VACUUM è tornato a 0. Se hai appena scritto molto e il numero non si muove, non vuol dire che non ci sia lavoro in attesa.

Un vacuum in corso si segue così:

SELECT pid, relid::regclass AS tabla, phase, heap_blks_scanned, heap_blks_total
FROM pg_stat_progress_vacuum;

Il nemico: la transazione aperta
VACUUM può pulire solo ciò che nessuno può più vedere. Una transazione aperta — o una replica con hot_standby_feedback — congela quell'orizzonte, e allora il vacuum gira, dice di aver finito e non libera nulla. Per questo una sessione dimenticata in idle in transaction fa crescere tabelle che nemmeno tocca.

Il wraparound, che sì è un'emergenza
Gli identificatori di transazione sono a 32 bit e vengono riciclati. Perché nessuna riga finisca nel futuro, il vacuum congela quelle vecchie. autovacuum_freeze_max_age vale 200 000 000 per impostazione predefinita: superata quell'età il server lancia un autovacuum che non si può rimandare, e se comunque si esaurisce smette di accettare scritture. Si sorveglia così:

SELECT datname, age(datfrozenxid) AS edad
FROM pg_database
ORDER BY edad DESC;

Finché quell'età resta molto sotto i duecento milioni non c'è nulla da fare.

Consiglio
Non spegnere autovacuum. Se una tabella cresce senza righe nuove, l'ordine del sospetto è: una transazione aperta, uno scale_factor troppo alto per la sua dimensione, e solo alla fine VACUUM FULL — con finestra di manutenzione, perché blocca l'intera tabella.

Parole chiave: vacuum, autovacuum, bloat, mvcc, n_dead_tup, vacuum full, congelamento, wraparound, pg_stat_user_tables, pg_stat_progress_vacuum, datfrozenxid, hot_standby_feedback, idle in transaction

Lock e deadlock (PostgreSQL)

Che cosa blocca davvero ogni istruzione, perché un ALTER TABLE può fermare i SELECT, come si vede chi aspetta chi e che fare con un 40P01.

Si applica a: PostgreSQL 13+

In PostgreSQL i lock vivono in due posti diversi, e confonderli è ciò che rende un problema introvabile. Quelli di tabella stanno in pg_locks; quelli di riga stanno dentro la riga stessa, nella sua intestazione, quindi non occupano memoria, non escalano mai a lock di tabella e non compaiono in pg_locks. Un milione di righe bloccate non costa più di una sola.

E una regola non ha eccezioni: un SELECT normale non aspetta mai una riga. Legge la sua versione tramite MVCC. L'unica cosa che può fermare un SELECT è un lock di tabella.

Chi prende quale modo di tabella

IstruzioneModo
SELECTACCESS SHARE
SELECT … FOR UPDATE / FOR SHAREROW SHARE
INSERT, UPDATE, DELETEROW EXCLUSIVE
VACUUM, ANALYZE, CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE
CREATE INDEXSHARE
ALTER TABLE, TRUNCATE, DROP TABLE, VACUUM FULLACCESS EXCLUSIVE

I primi tre non si ostacolano fra loro, ed è per questo che il carico normale non si blocca mai. L'ultimo confligge con tutti, SELECT compreso.

La trappola: un ALTER TABLE che aspetta accoda tutto dietro di sé
Quell'ACCESS EXCLUSIVE non salta la fila: ci si mette. E mentre aspetta, tutto ciò che arriva dopo aspetta dietro di lui, anche un SELECT che con l'istruzione davanti non avrebbe avuto alcun problema. Basta una transazione aperta che abbia fatto solo un SELECT perché un ALTER TABLE fermi la tabella per tutti senza aver iniziato a lavorare. Per questo il DDL in produzione si lancia con un tetto e si riprova:

SET lock_timeout = '3s';
ALTER TABLE cuentas ADD COLUMN moneda text;

Quattro modi di riga, non due
Dal più forte al più debole: FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE. Solo tre coppie convivono —i due condivisi fra loro e FOR NO KEY UPDATE con FOR KEY SHARE—; FOR UPDATE confligge con tutti e quattro. Proprio quella coppia strana è quella che conta: un UPDATE che non tocca la chiave prende FOR NO KEY UPDATE, quindi non blocca la verifica di una chiave esterna che punta a quella riga, che è quella che chiede FOR KEY SHARE.

BEGIN;
SELECT saldo FROM cuentas WHERE id = 1 FOR UPDATE;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;

Qui si aspetta per sempre
lock_timeout vale 0 per impostazione predefinita, cioè senza limite: non c'è un equivalente dell'innodb_lock_wait_timeout di MySQL, che taglia a 50 s. Impostarlo —per sessione, prima di un'istruzione rischiosa o nella configurazione— è ciò che trasforma un'attesa infinita in un errore che l'applicazione può riprovare. Quando scatta: SQLSTATE 55P03.

Non aspettare, di proposito

SELECT id FROM cuentas WHERE id = 3 FOR UPDATE NOWAIT;

SELECT id FROM cuentas ORDER BY id FOR UPDATE SKIP LOCKED;

NOWAIT fallisce all'istante con 55P03 —e quel fallimento, come qualsiasi altro, annulla l'intera transazione—. SKIP LOCKED non fallisce: restituisce meno righe. Con la riga 3 bloccata da un'altra sessione, la seconda query ha restituito 1, 2, 4 e 5. È il modo di distribuire una coda di lavoro fra più consumatori senza che si pestino i piedi né si aspettino.

L'abbraccio mortale

-- Sessione A
BEGIN;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 2;

-- Sessione B
BEGIN;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 2;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 1;

Il server uccide una delle due con SQLSTATE 40P01 («deadlock detected»), e il dettaglio dice quale processo e quale riga. Ma non lo rileva all'istante: cerca il ciclo solo quando un'attesa supera deadlock_timeout, 1 s per impostazione predefinita, e la vittima impiega quel secondo a morire. InnoDB lo rileva subito; qui un deadlock si paga con un secondo di attesa. Abbassare deadlock_timeout non è gratis: quel lavoro si spende anche sulle attese normali, che sono la maggioranza.

Un 40P01 non è un guasto del server: è il comportamento corretto, e l'applicazione deve riprovare quella transazione.

Diagnosi
Qui non c'è SHOW ENGINE INNODB STATUS. Ci sono due query, ed entrambe vanno eseguite mentre il lock dura:

SELECT pid, pg_blocking_pids(pid) AS bloqueado_por,
       wait_event_type, wait_event, left(query, 40) AS consulta
  FROM pg_stat_activity
 WHERE cardinality(pg_blocking_pids(pid)) > 0;

SELECT l.pid, c.relname, l.locktype, l.mode, l.granted
  FROM pg_locks l LEFT JOIN pg_class c ON c.oid = l.relation
 WHERE NOT l.granted;

pg_blocking_pids dà l'elenco dei processi che trattengono ciò che l'altro chiede, che è la domanda che ci si pone davvero. Ciò che in pg_locks non vedrai sono i lock di riga: chi aspetta una riga compare in attesa di un transactionid, il numero della transazione che la tiene. E per lasciare traccia di quel che è già successo, log_lock_waits scrive nel registro del server ogni attesa che superi deadlock_timeout.

L'Elenco processi di Calíope mostra quell'attesa nella colonna di stato: una sessione bloccata compare come Lock: transactionid.

Lock consultivi
Nessuna tabella, nessun dato: un numero che il server custodisce per te perché due processi della tua applicazione non facciano la stessa cosa insieme.

SELECT pg_try_advisory_lock(42);
SELECT pg_advisory_unlock(42);

Finché una sessione tiene il 42, pg_try_advisory_lock(42) da un'altra restituisce false invece di aspettare. Attenzione all'ambito: quelli di sessione sopravvivono al COMMIT e si rilasciano solo rilasciandoli o chiudendo la connessione; pg_advisory_xact_lock si rilascia da solo alla fine della transazione, che è quasi sempre ciò che si vuole.

SERIALIZABLE non blocca
A quel livello compaiono in pg_locks lock SIReadLock. Non bloccano nessuno: sono il segno di ciò che la transazione ha letto, e il conflitto arriva come 40001 al commit, non come un'attesa.

Raccomandazione
Toccare sempre le righe nello stesso ordine e transazioni brevi, come in qualsiasi motore. Ciò che è proprio di qui sono due cose: lock_timeout impostato prima di ogni DDL, perché chi aspetta accoda tutti dietro; e il ritentativo scritto prima di andare in produzione, l'unica cosa che rende innocuo un 40P01.

Parole chiave: lock, blocco, deadlock, 40p01, 55p03, pg_locks, pg_blocking_pids, lock_timeout, deadlock_timeout, access exclusive, for update, for key share, skip locked, nowait, lock consultivo, advisory

Indici (PostgreSQL)

I sei metodi di indice e quando serve ciascuno, indici parziali e su espressione, perché un Index Only Scan a volte va comunque alla tabella, e come trovare quelli che non usa nessuno.

Si applica a: PostgreSQL 13+

Un indice accelera le ricerche al prezzo di spazio e di lavoro a ogni scrittura. Ciò che cambia arrivando da MySQL non è quest'idea, ma che qui ci sono sei metodi invece di uno con eccezioni, e che quasi tutto ciò che in MySQL è un'opzione dell'indice —il prefisso, l'invisibilità— qui è un'altra cosa.

I sei metodi

MetodoA che serve
B-treeQuello di sempre: uguaglianza, intervalli, ORDER BY, LIKE 'abc%'. Nel dubbio, questo.
HashSolo uguaglianza. Da PostgreSQL 10 viene replicato e sopravvive a un crash.
GiSTGeometria, intervalli, vicino più prossimo. È la base di PostGIS.
SP-GiSTDati distribuiti male: intervalli che non si sovrappongono, testo per prefissi.
GINMolti valori dentro un campo: jsonb, array, ricerca full-text.
BRINTabelle enormi il cui ordine fisico segue il valore: date di inserimento, serie.

Le dimensioni spiegano la scelta meglio della teoria. Su una tabella di 200 000 righe e 22 MB, con una marca temporale che cresce con l'inserimento:

- B-tree su quella colonna: 4 408 kB.
- BRIN sulla stessa colonna: 24 kB.

BRIN non conserva le righe, ma il minimo e il massimo di ogni blocco: serve quindi solo se l'ordine fisico assomiglia all'ordine del valore —e quando serve, costa quasi nulla—. Nella stessa tabella, un hash sulla colonna cliente ha occupato 7 032 kB e il B-tree su quella colonna 1 400 kB: più piccolo, e per giunta utile per intervalli e ordinamenti. Per questo il B-tree è la risposta predefinita e l'hash un caso specifico.

Indici parziali: metà dell'idea, un decimo della dimensione
Un indice può portare un WHERE, e allora indicizza solo le righe che soddisfano la condizione. Se interroghi la coda dei pendenti e i pendenti sono il 5 %, indicizza il 5 %:

CREATE INDEX idx_pendientes ON pedidos (cliente_id) WHERE estado = 'pendiente';

Misurato su quella stessa tabella: l'indice completo della colonna occupava 1 400 kB e il parziale 88 kB. Il WHERE della query deve implicare quello dell'indice, altrimenti il pianificatore non lo userà.

Qui non ci sono indici di prefisso: ci sono indici su espressione
CREATE INDEX … ON paginas (url(64)) non è sintassi valida; il server legge url(64) come una chiamata di funzione e risponde 42883 function url(integer) does not exist. L'equivalente è indicizzare l'espressione:

CREATE INDEX idx_url ON paginas (left(url, 64));
CREATE INDEX idx_email ON usuarios (lower(email));

E le note in calce: l'indice entra in gioco solo se la query scrive l'espressione allo stesso modo. WHERE lower(email) = 'ana@ejemplo.com' lo usa; WHERE email ILIKE 'Ana@%' no, e si mangia l'intera tabella.

Indici di copertura, e perché a volte non coprono
INCLUDE aggiunge colonne conservate nell'indice ma che non lo ordinano:

CREATE INDEX idx_cobertura ON pedidos (cliente_id) INCLUDE (estado);

Con questo, EXPLAIN mostra Index Only Scan. Ma «only» è una mezza promessa: PostgreSQL non può sapere dall'indice se una riga è visibile alla tua transazione, quindi consulta la mappa di visibilità, che mantiene VACUUM. Misurato: subito dopo aver aggiornato mille righe, lo stesso piano diceva Heap Fetches: 2; dopo un VACUUM, Heap Fetches: 0. Un indice di copertura su una tabella che si scrive e non si pulisce va comunque alla tabella.

La regola del prefisso sinistro non è rigida
In MySQL, un indice su (A, B) non serve per WHERE B = ?. Qui può servire: misurato, una query che filtrava solo sulla seconda colonna si è risolta con un Index Only Scan sull'indice composito. Non è magia né un sostituto dell'indice giusto —lo percorre tutto invece di scenderci—, ma quando l'indice è molto più piccolo della tabella conviene lo stesso. Conseguenza pratica: prima di creare l'indice «che manca», guarda il piano; potrebbe già essercene uno in uso.

GIN per ciò che sta dentro un campo

CREATE INDEX idx_datos ON eventos USING gin (datos);

SELECT count(*) FROM eventos WHERE datos @> '{"tags":["t7"]}';

Senza l'indice quella query è una scansione sequenziale; con esso, un Bitmap Index Scan. Il GIN della prova occupava 864 kB su 200 000 righe. Per jsonb, se interroghi solo con @>, jsonb_path_ops occupa meno: 640 kB contro gli 864 del GIN normale, sugli stessi dati.

Costruire senza fermare la tabella
Un CREATE INDEX normale prende un lock SHARE: lascia leggere e ferma le scritture. CREATE INDEX CONCURRENTLY prende SHARE UPDATE EXCLUSIVE, quindi non ferma nulla, in cambio di due passaggi sulla tabella e di tre regole:

CREATE INDEX CONCURRENTLY idx_pedidos_cliente ON pedidos (cliente_id);

-- Ne è rimasto qualcuno a metà?
SELECT indexrelid::regclass AS indice, indisvalid
  FROM pg_index WHERE NOT indisvalid;

1. Non si può lanciare dentro una transazione — 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
2. Se fallisce, lascia un indice non valido: nessuno lo usa, ma viene mantenuto a ogni scrittura. Va trovato con la query qui sopra ed eliminato.
3. Da PostgreSQL 12 esiste REINDEX INDEX CONCURRENTLY, il modo di ricostruire un indice gonfio senza fermare la tabella.

Gli indici che non usa nessuno

SELECT relname AS tabla, indexrelname AS indice, idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
  FROM pg_stat_user_indexes
 WHERE idx_scan = 0
 ORDER BY pg_relation_size(indexrelid) DESC;

Due cautele prima di cancellare. La prima: il contatore non è istantaneo. Nella prova, tre query che avevano usato l'indice lo hanno lasciato a 0 sul momento e un secondo dopo; solo dopo tre secondi ha detto 3. La seconda: conta dall'ultimo pg_stat_reset(), e su una replica conta il lavoro della replica, quindi un indice usato solo dal report di fine mese sembra morto negli altri 29 giorni.

Raccomandazione
Ogni indice in più si paga a ogni INSERT e a ogni UPDATE delle sue colonne. L'ordine che funziona è: guardare il piano, creare l'indice con CONCURRENTLY, riguardare il piano e rivedere pg_stat_user_indexes un mese dopo. Un indice che nessuno usa non è neutro: costa scrittura, spazio e tempo di VACUUM.

Parole chiave: indice, index, btree, brin, gin, gist, spgist, hash, indice parziale, indice su espressione, include, index only scan, heap fetches, concurrently, reindex, pg_stat_user_indexes, indisvalid, jsonb_path_ops, mappa di visibilità

Prestazioni e diagnosi (PostgreSQL)

Leggere un piano e confrontare la stima con il misurato, statistiche estese per colonne che si implicano, perché work_mem è per operazione e che cosa misura davvero il tasso di successo della cache.

Si applica a: PostgreSQL 13+

Diagnosticare qui significa leggere un piano e confrontare due numeri. Tutto il resto —indici, memoria, statistiche— discende da quel confronto.

EXPLAIN non esegue; EXPLAIN ANALYZE sì
La prima forma chiede solo il piano. La seconda esegue davvero la query per misurarla, e questo include un UPDATE o un DELETE. Se l'istruzione scrive, avvolgila:

BEGIN;
EXPLAIN (ANALYZE) DELETE FROM pedidos WHERE creado < '2020-01-01';
ROLLBACK;

I due numeri che contano
Ogni nodo porta una stima e una misura: rows=… è ciò che il pianificatore ha creduto, actual rows=… ciò che è uscito. Quando si allontanano molto, il piano cattivo è una conseguenza, non la causa.

Esempio misurato, con due colonne che si implicano —città e provincia—:

- Senza aiuto, il pianificatore ha stimato 11 710 righe e ne sono uscite 60 000: ha moltiplicato le due probabilità come se fossero indipendenti.
- Con una statistica estesa, la stima è passata a 59 610.

CREATE STATISTICS st_ciudad_prov (dependencies, ndistinct)
    ON ciudad, provincia FROM pedidos;
ANALYZE pedidos;

È lo strumento che in MySQL non esiste e che risolve l'intera famiglia del «il piano ignora il mio indice»: se il server crede di leggere il 4 % della tabella mentre ne legge il 20 %, sceglierà male per buone ragioni.

BUFFERS, che va chiesto

EXPLAIN (ANALYZE, BUFFERS) SELECT … ;

shared hit sono blocchi già in memoria; shared read, quelli che si sono dovuti andare a prendere. E temp read/written è la spia importante: la query è finita su disco. Inoltre track_io_timing è spento per impostazione predefinita, quindi i tempi di I/O non compaiono finché non lo si accende.

work_mem è per operazione, non per connessione
È l'impostazione che sorprende di più, e quella che si sbaglia più spesso. Ogni ordinamento, ogni hash join e ogni aggregazione per hash può usare fino a work_mem, e una query con tre di quelle operazioni —o con due processi paralleli— ne usa un multiplo. Il valore di fabbrica è 4 MB.

Misurato su 300 000 righe, la stessa query con ORDER BY:

- Con work_mem = 64kB: Sort Method: external merge Disk: 15680kB, e il nodo di ordinamento ha impiegato ~144 ms.
- Con work_mem = 64MB: Sort Method: quicksort Memory: 29627kB, e ha impiegato ~70 ms.

La cifra che dice la verità è Sort Method. Alzare work_mem globalmente moltiplica per connessioni e per operazioni; la mossa prudente è alzarlo nella sessione che ne ha bisogno:

SET work_mem = '64MB';

Quale query costa di più: pg_stat_statements
È l'equivalente del registro delle query lente, ma aggregato: una riga per forma di query, con chiamate, tempo totale e righe.
In Calíope la stessa cosa si legge senza scrivere la query: lo strumento Query lente mostra questo riepilogo, distingue l'estensione mancante dalla libreria non caricata, e porta qualsiasi riga nell'editor.

SELECT calls, round(total_exec_time::numeric, 1) AS ms_total,
       round(mean_exec_time::numeric, 2) AS ms_media, rows, query
  FROM pg_stat_statements
 ORDER BY total_exec_time DESC
 LIMIT 20;

Le note in calce: CREATE EXTENSION non basta. Va aggiunta a shared_preload_libraries e il server va riavviato; se ci si limita a creare l'estensione, la prima query risponde 55000 pg_stat_statements must be loaded via "shared_preload_libraries". Ordina per total_exec_time, non per mean_exec_time: la query che si mangia il pomeriggio di solito è una veloce eseguita un milione di volte.

Il tasso di successo della cache

SELECT blks_hit, blks_read,
       round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS pct
  FROM pg_stat_database
 WHERE datname = current_database();

È il numero che il Cruscotto di Calíope mostra come «Cache dati». Misura quante letture sono state servite senza scendere al file system dall'ultimo azzeramento delle statistiche —non quanta memoria è occupata—, e su un database piccolo esce altissimo per definizione: nella prova, 99,85 %. Un valore basso e persistente sì che indica che shared_buffers è troppo piccolo; uno alto non dimostra che vada tutto bene.

Parallelismo
max_parallel_workers_per_gather vale 2 per impostazione predefinita, e quando il pianificatore lo usa compare un nodo Gather o Gather Merge. Ogni processo ha il proprio work_mem, che è l'altra metà della trappola qui sopra.

Raccomandazione
L'ordine che funziona: trovare la query con pg_stat_statements, guardarla con EXPLAIN (ANALYZE, BUFFERS), confrontare rows con actual rows e solo allora decidere se manca un indice, mancano statistiche o manca memoria. Toccare shared_buffers prima di aver letto un piano è la strada lunga.

Parole chiave: prestazioni, explain, analyze, buffers, piano, pianificatore, stima, create statistics, statistica estesa, work_mem, sort method, external merge, pg_stat_statements, shared_preload_libraries, pg_stat_database, cache, track_io_timing, parallelismo

Configurazione del server (PostgreSQL)

Dove si scrive ogni parametro e quale vince, che cosa richiede il riavvio, perché effective_cache_size non riserva memoria e perché max_connections non si alza.

Si applica a: PostgreSQL 13+

PostgreSQL ha 378 parametri —contati su questo server—, e la buona notizia è che se ne tocca una manciata. La prima cosa da imparare non è quali, ma dove si scrivono e quando entrano in vigore.

Quattro posti, e chi vince lo dice il server
- postgresql.conf — il file di sempre, modificato a mano.
- postgresql.auto.conf — lo scrive ALTER SYSTEM e non si modifica a mano; lo dice lui stesso nella prima riga.
- Per database o per ruolo — ALTER DATABASE … SET, ALTER ROLE … SET.
- Per sessione — SET, che dura quanto la connessione.

Chi ha vinto non si indovina: lo dice la colonna source di pg_settings. Misurato: dopo ALTER DATABASE demo SET work_mem = '32MB', una connessione nuova leggeva 32MB con source = database; dopo il RESET, 4MB con source = default.

SELECT name, setting, unit, context, source, pending_restart
  FROM pg_settings
 WHERE name IN ('shared_buffers','work_mem','max_connections','max_wal_size');

Tre classi di parametro, e quella che fa male
La colonna context dice che cosa serve per cambiarlo:
- user / superuser — basta un SET nella sessione (work_mem, effective_cache_size).
- sighup — serve ricaricare (checkpoint_timeout, max_wal_size, quasi tutto l'autovacuum).
- postmaster — serve riavviare il server. Su questo server sono 65 su 378, fra cui shared_buffers, max_connections, wal_level, shared_preload_libraries e autovacuum_max_workers.

ALTER SYSTEM SET max_wal_size = '4GB';
SELECT pg_reload_conf();

-- È rimasto qualcosa in attesa di un riavvio?
SELECT name, setting FROM pg_settings WHERE pending_restart;

Un dettaglio misurato che risparmia uno spavento: pending_restart non si accende nello stesso istante della ricarica. Subito dopo era ancora false, e mezzo secondo più tardi diceva true. Si consulta dopo, non nella stessa frase.

shared_buffers ed effective_cache_size non sono la stessa cosa, e uno dei due non riserva nulla
- shared_buffers è memoria vera: la cache propria del server. Di fabbrica sono 128 MB, pochi per qualsiasi server dedicato; la regola abituale è il 25 % della RAM.
- effective_cache_size non riserva nulla. È ciò che il pianificatore suppone esista fra la cache di PostgreSQL e quella del sistema operativo, e serve solo a decidere se un indice conviene. Cambiarlo non sposta un byte: cambia i piani.

Confonderli porta ad alzare effective_cache_size sperando in più cache, o ad alzare shared_buffers sperando in un altro piano.

max_connections non si alza: si mette un pool davanti
Vale 100 di fabbrica, e qui ogni connessione è un processo del sistema operativo, non un thread. Portarlo a mille non è un numero più grande: sono mille processi, con la loro memoria e il loro work_mem per operazione. La risposta è un gestore di pool (pgBouncer e simili). Ed è uno di quelli che richiedono il riavvio.

WAL e checkpoint
max_wal_size (1 GB di fabbrica) e checkpoint_timeout (5 min) decidono ogni quanto tutto viene scritto su disco. Se i checkpoint scattano per dimensione invece che per tempo, il server scrive a strappi; lo si vede accendendo log_checkpoints e lo si corregge alzando max_wal_size. checkpoint_completion_target arriva già a 0,9, che è ciò che distribuisce quella scrittura nel tempo invece di concentrarla.

Autovacuum
Qui valgono autovacuum_naptime 60 s, autovacuum_max_workers 3 —questo richiede il riavvio— e autovacuum_vacuum_scale_factor 0,2, cioè una tabella viene pulita quando è cambiato il 20 % delle sue righe. Su una tabella da un miliardo di righe significa aspettare duecento milioni di versioni morte, quindi le tabelle grandi portano la propria impostazione:

ALTER TABLE eventos SET (autovacuum_vacuum_scale_factor = 0.01);

synchronous_commit, l'unico che cambia la promessa
Spegnerlo fa sì che il COMMIT non aspetti che il WAL arrivi al disco: si guadagna latenza e si rischiano le ultime transazioni in caso di black-out —non l'integrità del database, solo gli ultimi commit—. È user, quindi si può spegnere solo dove quel patto è accettato:

SET synchronous_commit = off;

Raccomandazione
Toccarne pochi, uno alla volta e misurando. ALTER SYSTEM invece di modificare file —resta registrato e si annulla con ALTER SYSTEM RESET—, e per database o per ruolo prima che globale: un'impostazione che serve solo al report notturno non deve pagarla il resto della giornata.

Parole chiave: configurazione, postgresql.conf, postgresql.auto.conf, alter system, pg_settings, pending_restart, pg_reload_conf, shared_buffers, effective_cache_size, work_mem, max_connections, pool, wal, checkpoint, autovacuum, synchronous_commit

Ruoli, permessi e sicurezza (PostgreSQL)

Perché l'account non contiene l'host, ruoli che sono utenti e gruppi insieme, la trappola per cui concedere non arriva alle tabelle di domani, e perché la sicurezza di riga può essere accesa e senza effetto.

Si applica a: PostgreSQL 13+

La differenza di fondo con MySQL è che qui l'account non contiene l'host. Non esiste ana@192.168.1.%: esiste il ruolo ana, e da dove può connettersi e come si autentica lo decide un file a parte, pg_hba.conf.

pg_hba.conf: vince la prima riga che corrisponde
Si legge dall'alto in basso e lì si ferma. E non serve aprirlo per vederlo:

SELECT type, database, user_name, address, auth_method, error
  FROM pg_hba_file_rules
 ORDER BY rule_number;

La colonna error dice se una riga è scritta male —è ciò che evita il riavvio in cui il server non torna—. Le modifiche entrano in vigore con SELECT pg_reload_conf(), senza riavviare. Sul server di prova c'erano sette regole, quelle di 127.0.0.1 con trust e l'ultima, per tutto il resto, con scram-sha-256: l'ordine è la politica.

Un ruolo è utente e gruppo insieme
Non ci sono due concetti: CREATE USER è esattamente CREATE ROLE … LOGIN. Ciò che distingue una persona da un gruppo è l'attributo LOGIN, e nient'altro.

CREATE ROLE app_ro;                       -- senza LOGIN: fa da gruppo
CREATE ROLE ana LOGIN PASSWORD 'secreta';
GRANT app_ro TO ana;                      -- ana eredita i diritti di app_ro

I ruoli ereditano per impostazione predefinita, quindi ana usa i privilegi di app_ro senza fare nulla. Con NOINHERIT vanno richiesti con SET ROLE, che è ciò che si usa quando si vuole che il passaggio sia esplicito.

Le password si conservano con scram-sha-256, il valore predefinito da PostgreSQL 14 —il server di prova lo conferma—; md5 esiste ancora e non dovrebbe più servire.

La vera trappola: concedere non arriva al futuro
GRANT … ON ALL TABLES IN SCHEMA concede sulle tabelle che ci sono oggi. Misurato: dopo la concessione, app_ro poteva leggere la tabella esistente e non quella creata un minuto dopo. Ciò che copre il futuro è un'altra istruzione:

GRANT USAGE ON SCHEMA public TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro;          -- quelle di oggi
ALTER DEFAULT PRIVILEGES IN SCHEMA public
      GRANT SELECT ON TABLES TO app_ro;                          -- quelle di domani

E in caratteri piccoli: i privilegi predefiniti sono di chi li dichiara, non dello schema, quindi se le tabelle le crea un altro ruolo vanno dichiarati anche con FOR ROLE. Ciò che è stato dichiarato si vede in pg_default_acl.

Concedere lascia inoltre una traccia che va disfatta: dopo un ON ALL TABLES, eliminare il ruolo fallisce con DependentObjectsStillExist e l'elenco delle tabelle dove è rimasto un privilegio —nella prova sono uscite perfino quelle di PostGIS—. La contraria è REVOKE, o DROP OWNED BY ruolo prima del DROP ROLE.

Lo schema public non è più di tutti
Da PostgreSQL 15, PUBLIC conserva USAGE sullo schema public ma non ha più CREATE. Misurato su 17.6: un ruolo appena creato dà USAGE = true e CREATE = false. Chi porta script da una versione precedente vedrà fallire il primo CREATE TABLE di un utente che prima poteva.

Ruoli predefiniti: sorvegliare senza essere superutente
Il server porta quindici ruoli già pronti. Quelli che evitano un superutente di troppo:
- pg_read_all_data, pg_write_all_data — leggere o scrivere tutto, senza altri poteri.
- pg_monitor — vedere le viste di statistiche complete; include pg_read_all_stats e pg_read_all_settings.
- pg_signal_backend — annullare query e chiudere sessioni altrui.
- pg_maintain (PostgreSQL 17+) — VACUUM, ANALYZE, REINDEX senza essere proprietari.

Sicurezza a livello di riga
Una policy filtra le righe che ogni ruolo vede, dentro la stessa tabella:

ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;

CREATE POLICY solo_lo_mio ON pedidos
    FOR SELECT USING (dueno = current_user);

Ed ecco che cosa misurare prima di fidarsi. Con la stessa policy e la stessa tabella:

- Il ruolo a cui punta la policy ha visto una riga. Corretto.
- Il proprietario della tabella —non superutente— ha visto entrambe: il proprietario non è soggetto alle proprie policy finché non si dichiara ALTER TABLE … FORCE ROW LEVEL SECURITY. Con FORCE, ne ha vista una.
- Il superutente ha visto entrambe anche con FORCE. I superutenti e i ruoli con BYPASSRLS scavalcano sempre le policy.

Ossia: un'applicazione che si connette come proprietaria delle tabelle —per non dire come superutente— ha la sicurezza di riga accesa e senza effetto. La verifica è connettersi con il ruolo reale e contare le righe.

Raccomandazione
Un ruolo per applicazione, senza LOGIN per i gruppi, e nessuno con SUPERUSER tranne quello di amministrazione. ALTER DEFAULT PRIVILEGES nello stesso commit del GRANT, o il permesso durerà fino alla tabella successiva. E annota ciò che concedi in blocco, perché il DROP ROLE fra un anno te lo chiederà.

Parole chiave: sicurezza, ruolo, utente, gruppo, pg_hba.conf, pg_hba_file_rules, scram-sha-256, grant, revoke, alter default privileges, pg_default_acl, public, pg_read_all_data, pg_monitor, rls, row level security, create policy, bypassrls, drop owned by

Backup e ripristino (PostgreSQL)

Logico contro fisico e a che serve ciascuno, che cosa lascia fuori pg_dump lasciando il database senza nessuno che entri, perché il PITR non funziona di fabbrica e quale slot può riempirti il disco.

Si applica a: PostgreSQL 13+

Ci sono due tipi di backup e non servono alla stessa cosa. Sbagliare si scopre il giorno del ripristino.

Logico (pg_dump)Fisico (pg_basebackup)
Che cosa copiaIstruzioni che ricostruiscono i datiI file del cluster così come sono
UnitàUn database, o perfino una tabellaL'intero cluster, tutti i database
Ripristina suUn'altra versione, macchina, sistemaLa stessa versione maggiore
Serve perMigrare, spostare una tabella, leggerlaRecuperare il server, e per il PITR

Backup logico

-- da riga di comando, non nell'editor SQL:
-- pg_dump -d demo -Fc -f demo.dump
-- pg_restore -d demo_nueva -j 4 demo.dump

Il formato -Fc (custom) è quello da usare per impostazione predefinita: sullo stesso database, il dump in testo occupava 3,1 MB e quello custom 905 kB, e porta anche un indice —pg_restore -l ha elencato i trenta blocchi di dati— quindi permette di ripristinare una tabella, e di farlo in parallelo con -j.

Ciò che pg_dump non porta con sé, ed è quello che morde
I ruoli e le impostazioni globali non ci sono dentro. Misurato: il dump del database non aveva un solo CREATE ROLE, mentre pg_dumpall --globals-only ha prodotto i due che c'erano. Ripristinare solo il dump lascia un database perfetto in cui non può entrare nessuno. Un backup logico completo sono due file:
- pg_dumpall --globals-only — ruoli, password e privilegi del cluster.
- pg_dump di ogni database.

pg_dump è coerente —lavora su uno snapshot— e non blocca chi scrive; ma prende un lock ACCESS SHARE, quindi un ALTER TABLE lanciato nello stesso momento si mette ad aspettare, e dietro di lui si forma la coda.

Backup fisico
pg_basebackup copia l'intero cluster. Misurato sul server di prova: 84 MB in 1,3 s, con -X stream, che porta anche il WAL prodotto durante la copia —senza, la copia non è ripristinabile—. Lascia un backup_label che dice da quale punto del WAL riprodurre:

START WAL LOCATION: 0/13000028 (file 000000010000000000000013)

PITR: recuperare fino a un istante
È la ragion d'essere del backup fisico, e non funziona di fabbrica: archive_mode arriva spento, misurato su questo server. Senza archiviazione, un backup fisico ripristina esattamente il momento in cui è stato preso, e non un secondo di più.

Servono tre pezzi:
1. archive_mode = on e un archive_command che copi ogni segmento del WAL in un posto sicuro (o pg_receivewal da un'altra macchina).
2. Un pg_basebackup periodico.
3. Al ripristino: i file del backup, un restore_command che recuperi i segmenti, recovery_target_time = '…' e un file vuoto recovery.signal nella directory dei dati.

Quest'ultimo punto spiazza chi viene dalle versioni vecchie: da PostgreSQL 12 non esiste più recovery.conf; i parametri stanno in postgresql.conf e ciò che dichiara «questo è un ripristino» è il file segnale.

Gli slot di replica sono una lama a doppio taglio
Uno slot garantisce che il server non cancelli il WAL che un consumatore non ha letto. Se il consumatore sparisce e lo slot resta, il WAL si accumula fino a riempire il disco —e un disco pieno è un fermo, non un avviso—. Si sorvegliano così:

SELECT slot_name, active, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retenido
  FROM pg_replication_slots;

max_slot_wal_keep_size mette il tetto: superato quello, il server preferisce invalidare lo slot piuttosto che restare senza disco.

Il backup di Calíope è logico
Ciò che genera lo strumento di backup è SQL —CREATE e INSERT—, della famiglia di pg_dump, non di pg_basebackup. Serve a migrare e a recuperare dati; recuperare un intero server fino a un istante richiede quanto sopra, che è cosa del sistema operativo e non di un client.

Raccomandazione
I due file del backup logico, sempre insieme —--globals-only e il dump— e provare il ripristino, non il backup: un file generato senza errori può non ripristinarsi, e lo si sa solo ripristinandolo su un server vero.

Parole chiave: backup, pg_dump, pg_restore, pg_dumpall, globals, pg_basebackup, wal, archive_mode, archive_command, pitr, recovery_target_time, recovery.signal, slot di replica, wal_status, ripristino

Modifiche di schema a caldo (PostgreSQL)

Il DDL è transazionale, ciò che costa è il lock e non l'ALTER, quali modifiche riscrivono l'intera tabella, e il modello NOT VALID + VALIDATE che evita di fermare il database.

Si applica a: PostgreSQL 13+

Qui il DDL è transazionale. Questo cambia il modo di scrivere le migrazioni ed è la prima cosa da assimilare arrivando da MySQL, dove ogni ALTER conferma per conto suo.

BEGIN;
ALTER TABLE pedidos ADD COLUMN moneda text;
CREATE INDEX idx_moneda ON pedidos (moneda);
CREATE TABLE monedas (codigo text PRIMARY KEY);
ROLLBACK;

Verificato: dopo quel ROLLBACK non restava nessuna delle tre cose. Una migrazione che fallisce a metà non lascia mezzo database; ed è per questo che il modello sano è mettere l'intera migrazione dentro una transazione.

Le eccezioni si contano sulle dita: CREATE INDEX CONCURRENTLY, VACUUM e ALTER SYSTEM non possono stare dentro una transazione.

Ciò che costa non è l'ALTER: è il lock
Quasi ogni ALTER TABLE prende un ACCESS EXCLUSIVE, che confligge perfino con un SELECT. Anche se il cambiamento dura un millisecondo, aspettare il lock può durare ore —e mentre aspetta, tutto ciò che arriva dietro si mette in coda—. Per questo il DDL in produzione si lancia sempre così:

SET lock_timeout = '3s';
ALTER TABLE pedidos ADD COLUMN moneda text;

Se non ottiene il lock, fallisce in tre secondi e si riprova. Senza questo, una migrazione da un millisecondo può fermare l'intera applicazione.

Che cosa riscrive la tabella e che cosa no
Riscrivere significa copiare l'intera tabella: dura in proporzione alla dimensione e richiede il doppio del disco per tutto il tempo. Misurato su 500 000 righe e 32 MB:

IstruzioneTempoRiscrive?
ADD COLUMN c int0,6 msno
ADD COLUMN c int DEFAULT 7 NOT NULL1,6 msno
ALTER COLUMN s TYPE varchar(100) (era 50)1,2 msno
ALTER COLUMN s TYPE varchar(20) (era 50)204 mssì
ALTER COLUMN n TYPE bigint (era int)191 mssì
ALTER COLUMN t TYPE varchar(200) (era text)193 mssì
DROP COLUMN c0,5 msno
ALTER COLUMN c SET NOT NULL17,8 msno (ma percorre la tabella)
ADD CONSTRAINT … CHECK (…)11,6 msno (percorre)
ADD CONSTRAINT … CHECK (…) NOT VALID0,5 msno

La regola che riassume la tabella: allargare è gratis, restringere riscrive. E ADD COLUMN con valore predefinito ha smesso di riscrivere in PostgreSQL 11: non serve più il giro di aggiungere la colonna vuota e riempirla a lotti.

Due avvisi che i tempi non mostrano:
- Un DROP COLUMN è istantaneo perché si limita a marcare la colonna come eliminata: lo spazio non torna finché la tabella non viene riscritta.
- SET NOT NULL e un CHECK normale non riscrivono, ma percorrono l'intera tabella con il lock tenuto. Su una tabella grande è già un fermo.

Il modello per non fermare il database: NOT VALID e poi VALIDATE
Un vincolo si può aggiungere in due tempi: prima si dichiara senza verificare l'esistente —istantaneo—, e poi si valida, che è la parte lenta ma con un lock molto più debole.

ALTER TABLE ddl_hija
    ADD CONSTRAINT fk_p FOREIGN KEY (padre) REFERENCES ddl_demo (id) NOT VALID;

ALTER TABLE ddl_hija VALIDATE CONSTRAINT fk_p;

Misurato: la dichiarazione NOT VALID ha impiegato 0,7 ms con SHARE ROW EXCLUSIVE —che lascia leggere—, e la validazione 67 ms con SHARE UPDATE EXCLUSIVE, che non blocca nemmeno chi scrive. Farlo in un colpo solo è costato lo stesso tempo, ma con il lock forte tenuto per tutta la durata. Su una tabella vera, quella differenza separa un rilascio da un disservizio.

Da quando il vincolo è NOT VALID, il server lo applica alle righe nuove; resta in sospeso solo la verifica di quelle vecchie.

Indici
Il CREATE INDEX normale ferma le scritture; CREATE INDEX CONCURRENTLY non ferma nulla, ma non entra nella transazione della migrazione, quindi va in un passo proprio e si verifica dopo (pg_index.indisvalid).

Raccomandazione
lock_timeout sempre; la migrazione dentro una transazione tranne ciò che non può; NOT VALID + VALIDATE per i vincoli su tabelle grandi; e attenzione ai cambi di tipo, dove si nasconde la riscrittura. Se bisogna restringere un tipo, quasi sempre è meglio aggiungere la colonna nuova, copiare a lotti e rinominare.

Parole chiave: ddl, alter table, migrazione, transazionale, rollback, lock_timeout, access exclusive, riscrittura, relfilenode, add column, drop column, set not null, not valid, validate constraint, create index concurrently

Tipi di dato (PostgreSQL)

Che cosa non esiste e dà errore di sintassi, perché text non è peggio di varchar, numeric contro virgola mobile, che cosa conserva davvero timestamptz e perché jsonb non risparmia spazio.

Si applica a: PostgreSQL 13+

I tipi sono uno dei pochi punti in cui la migrazione da MySQL fallisce al primo tentativo, e meno male: ciò che non esiste dà errore di sintassi invece di essere accettato a metà.

Che cosa non esiste qui
- UNSIGNED — 42601 syntax error at or near "unsigned". Non ci sono interi senza segno; si usa il tipo successivo o un CHECK (n >= 0).
- INT(11) — anche questo 42601. La larghezza di visualizzazione di MySQL non esiste, e non ha mai significato ciò che sembrava.
- TINYINT, DATETIME, DOUBLE con parentesi e i tipi SET/ENUM di MySQL. Gli equivalenti sono smallint, timestamptz, double precision e un vero tipo enum.

Testo: usa text e basta
Misurato, con lo stesso valore 'hola': text ha occupato 5 byte, varchar(50) 5 e char(50) 51. Tutti e tre si conservano allo stesso modo; varchar(n) aggiunge solo un controllo di lunghezza e char(n) riempie di spazi. E quegli spazi cambiano i confronti: 'x' = 'x ' è falso con text e vero con char.

Qui text non è peggio di varchar: non c'è penalità. Si mette varchar(n) quando il limite è una regola di business, e char(n) praticamente mai.

Numeri: numeric per il denaro, e non è una superstizione

SELECT (0.1::float8 + 0.2::float8) = 0.3::float8,   -- false
       (0.1::numeric + 0.2::numeric) = 0.3::numeric; -- true

Misurato: in virgola mobile, 0.1 * 3 ha dato 0.30000000000000004; in numeric, 0.3 esatto. numeric è esatto e a precisione arbitraria, e si paga in spazio e velocità —10 byte contro gli 8 di float8 per quel valore, e aritmetica via software—. Per il denaro e per qualsiasi cifra sommata davanti a un cliente, numeric.

Dimensioni misurate: int 4, bigint 8, boolean 1, uuid 16 —contro i 36 che occuperebbe come testo—.

Date: timestamptz quasi sempre
timestamp e timestamptz occupano gli stessi 8 byte. La differenza non è la dimensione né che uno conservi il fuso: nessuno dei due conserva il fuso. timestamptz conserva un istante —converte in UTC in entrata e nel fuso della sessione in uscita—, e timestamp conserva una lettura di orologio e nient'altro.

Misurato, lo stesso istante con due fusi di sessione:

TimeZonetimestamptztimestamp
Europe/Madrid2026-08-19 13:48:19+022026-08-19 13:48:19
UTC2026-08-19 11:48:19+002026-08-19 11:48:19

È lo stesso momento detto in due modi. Con timestamp non c'è nessuna conversione: ciò che è entrato è ciò che esce, e chi deve sapere a che ora è stato davvero non può ricavarlo. date occupa 4 byte e interval 16.

json contro jsonb: quasi sempre jsonb, e non per la dimensione

SELECT '{"b":1,"a":2,"a":3}'::json::text,   -- {"b":1,"a":2,"a":3}
       '{"b":1,"a":2,"a":3}'::jsonb::text;  -- {"a": 3, "b": 1}

json conserva il testo così com'è: mantiene l'ordine, gli spazi e perfino le chiavi ripetute. jsonb conserva un albero già analizzato: ordina le chiavi, tiene l'ultima ripetuta e normalizza gli spazi. Per questo jsonb si interroga in fretta e si indicizza con GIN, e json serve solo quando bisogna restituire il documento byte per byte come è arrivato.

Ciò che non è vero è che jsonb risparmi spazio: misurato su 200 000 documenti identici, json ha occupato 14 MB e jsonb 16 MB. Si sceglie jsonb per come si interroga, non per quanto pesa.

Array
Un array è un tipo di prima classe, con i suoi operatori —@> per la contenenza, array_length— e il suo indice GIN. È comodo per etichette e liste corte; smette di esserlo appena gli elementi hanno bisogno di attributi propri o vanno uniti a un'altra tabella. Un array non è una tabella risparmiata: è un valore.

serial o IDENTITY
serial non è un tipo: è zucchero che crea una sequenza e ne mette il nextval come valore predefinito. GENERATED ALWAYS AS IDENTITY è la forma standard e protegge anche la colonna: provare a inserirvi un valore a mano ha risposto 428C9 cannot insert a non-DEFAULT value into column. Per le tabelle nuove, IDENTITY.

Raccomandazione
text per il testo, numeric per il denaro, timestamptz per gli istanti, jsonb per i documenti che si interrogano e IDENTITY per le chiavi. E migrando da MySQL, lascia che l'errore di sintassi faccia il suo lavoro: è meglio di un tipo che viene accettato e significa un'altra cosa.

Parole chiave: tipi, text, varchar, char, numeric, float, decimal, timestamptz, timestamp, fuso orario, json, jsonb, array, uuid, serial, identity, unsigned, enum

Errori e SQLSTATE (PostgreSQL)

I codici che si vedono ogni giorno, perché si programma per classe e non per codice, che cosa dicono il DETAIL e l'HINT che quasi nessuno mostra, e dov'è il codice quando la connessione non si apre nemmeno.

Si applica a: PostgreSQL 13+

Qui non ci sono numeri di errore. C'è lo SQLSTATE: cinque caratteri, di cui i primi due sono la classe. E la classe è ciò su cui si programma: dice che cosa fare senza sapere esattamente che cosa è fallito.

Quelli che si vedono ogni giorno

CodiceChe cosa è successo
23505Chiave duplicata — viola un vincolo di unicità
23503Chiave esterna: la riga referenziata non esiste, o si cancella un padre con figli
23502NULL in una colonna NOT NULL
23514Un vincolo CHECK ha detto no
22001Il testo non entra nel tipo
22P02Sintassi di input non valida: 'hola' non è un intero
22012Divisione per zero
42601Errore di sintassi
42703Quella colonna non esiste
42P01Quella tabella non esiste
42P07Quella tabella esiste già
42883Quella funzione o quell'operatore non esiste
42501Permesso negato
25P02La transazione è annullata e non accetta più nulla
40001Non si è potuto serializzare — bisogna riprovare
40P01Deadlock — bisogna riprovare
55P03Lock non ottenuto (NOWAIT o lock_timeout)
57014Query annullata (statement_timeout o qualcuno l'ha annullata)
3D000Quel database non esiste
28000Quel ruolo non esiste

Le classi, che sono ciò da guardare

ClasseSignificaChe fare
08ConnessioneRiconnettersi e riprovare
22DatiCorreggere il valore in ingresso
23IntegritàÈ colpa del dato: dirlo all'utente
25Stato della transazioneROLLBACK e ricominciare
28AutorizzazioneCredenziali; non riprovare
40Ritorno indietroRiprovare l'intera transazione
42Sintassi o accessoÈ un bug del programma: riprovare non risolve
53Risorse insufficientiAspettare o ampliare
55L'oggetto non è prontoDipende; 55P03 è un lock
57Intervento dell'operatoreQualcuno ha annullato, o è scattato un tetto

La conseguenza pratica: un'applicazione riprova la classe 40 e non riprova la 42. E se il ritentativo non distingue, o si perde una transazione legittima o si ripete mille volte una query che non funzionerà mai.

Il messaggio ha tre parti, e la terza è quella utile
MESSAGE dice che cosa è successo, DETAIL dà la riga o il valore, e HINT dice che fare. Misurati:

- 23505 — MESSAGE: duplicate key value violates unique constraint "er_d_pkey"; DETAIL: Key (id)=(1) already exists.
- 42883 — MESSAGE: operator does not exist: text = integer; HINT: No operator matches the given name and argument types. You might need to add explicit type casts.
- 42703 — MESSAGE: column "ids" does not exist; HINT: Perhaps you meant to reference the column "er_d.id".

Un client che mostri solo il MESSAGE butta via metà dell'informazione —e proprio la metà che dice come uscirne—. Calíope compone tutte e tre.

Inoltre l'errore porta campi separati: la tabella, la colonna e il nome del vincolo. Con 23505 è arrivato constraint = er_d_pkey, ed è ciò che permette di tradurlo in «quell'email è già registrata» senza analizzare il testo del messaggio.

Un errore annulla la transazione
Dopo qualsiasi errore dentro un BEGIN, tutto ciò che segue risponde 25P02 finché non si fa ROLLBACK. Non è un difetto del client: è il progetto, e l'uscita elegante sono i punti di salvataggio.

Gli errori di connessione non arrivano nella risposta
Se il ruolo o il database non esistono, o la password è sbagliata, la connessione non si apre nemmeno: il client vede solo «connection failed». Il codice sta nel registro del server, e solo se lo si chiede:

ALTER SYSTEM SET log_error_verbosity = 'verbose';
SELECT pg_reload_conf();

Con questo, il registro è passato da FATAL: database "no_existe" does not exist a FATAL: 3D000: database "no_existe" does not exist —e 28000 per il ruolo inesistente—. È la differenza fra indovinare e sapere quando qualcuno segnala che «non riesce a connettersi».

Raccomandazione
Nel codice dell'applicazione, ramificare per classe e usare il codice completo solo per i messaggi che vede l'utente (23505 → «esiste già»). Salvare sempre lo SQLSTATE nel proprio registro: il testo del messaggio cambia con la lingua del server, il codice no.

Parole chiave: errore, sqlstate, codice, classe, 23505, 23503, 42p01, 42601, 42883, 42501, 25p02, 40001, 40p01, 55p03, 57014, 3d000, 28000, detail, hint, ritentativo, log_error_verbosity

Partizionamento (PostgreSQL)

Partizionamento dichiarativo e che cosa pota davvero il pianificatore, perché non ci sono indici globali né unicità su una sola colonna, il CHECK che trasforma un ATTACH da 68 ms in mezzo, e quanto costa la partizione predefinita.

Si applica a: PostgreSQL 13+

Il partizionamento qui è dichiarativo: si dichiara la chiave e ogni partizione è una tabella vera. Il padre non conserva nemmeno una riga —misurato: 0 byte, con i dati distribuiti fra le figlie—.

CREATE TABLE pt (id bigserial, creado date NOT NULL, importe numeric)
    PARTITION BY RANGE (creado);

CREATE TABLE pt_2024 PARTITION OF pt
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

CREATE TABLE pt_resto PARTITION OF pt DEFAULT;

Ci sono tre forme: RANGE (date, importi), LIST (paese, stato) e HASH (distribuire per distribuire).

La potatura è ciò che si cerca
Misurato su 300 000 righe distribuite in tre anni: una query con WHERE creado BETWEEN '2024-03-01' AND '2024-03-31' ha percorso solo pt_2024. La stessa tabella, filtrando su una colonna che non è la chiave, ha fatto un Parallel Append su tutte.

Ecco la regola che decide il progetto: la chiave di partizione è la colonna per cui filtri quasi sempre. Se le query non la nominano, il partizionamento non risparmia lettura: la distribuisce.

Ciò che non c'è: gli indici globali
Un indice creato sul padre ne crea uno per partizione —misurato: quattro partizioni, quattro indici—. Non esiste un indice unico che copra l'intera tabella, e da lì viene il limite da conoscere prima di progettare:

ALTER TABLE pt ADD CONSTRAINT pt_uni UNIQUE (id, creado);

Una UNIQUE sul solo (id) viene rifiutata con 0A000 unique constraint on partitioned table must include all partitioning columns. L'unicità globale di un identificatore non si può garantire con il partizionamento dichiarativo; la si ottiene da una sequenza, unica per costruzione e non per vincolo.

ATTACH: la differenza fra 68 ms e mezzo
Agganciare una tabella esistente obbliga il server a verificare che tutte le sue righe rientrino nell'intervallo. Misurato su 300 000 righe:

OperazioneTempo
ATTACH senza CHECK preventivo67,9 ms
DETACH0,7 ms
ATTACH con un CHECK equivalente già validato0,6 ms

Ossia: se la tabella porta già un vincolo CHECK che implica l'intervallo, il server salta la scansione. Su una tabella da un miliardo di righe è la differenza fra un ACCESS EXCLUSIVE istantaneo e mezz'ora.

La partizione predefinita non è gratis
DEFAULT raccoglie ciò che non cade in nessun intervallo ed evita l'errore inserendo una data inattesa. In cambio, misurato: ALTER TABLE … DETACH PARTITION … CONCURRENTLY ha risposto 55000 cannot detach partitions concurrently when a default partition exists. E inoltre ogni nuovo ATTACH deve percorrere la partizione predefinita per verificare che non nasconda righe dell'intervallo in arrivo.

Perché si partiziona davvero
Non per la velocità delle query —a quello servono gli indici—, ma per la manutenzione:
- Togliere un intero periodo è un DROP TABLE della sua partizione: misurato, 2,3 ms, e lo spazio torna al file system. Il DELETE equivalente ha impiegato di più e soprattutto lascia righe morte che VACUUM dovrà pulire e spazio che non torna.
- VACUUM e ANALYZE lavorano per partizione, quindi il lavoro di manutenzione smette di crescere con l'intero storico.
- I dati vecchi si possono sganciare e archiviare senza toccare la tabella viva.

Raccomandazione
Partiziona per ciò che cancellerai, non per ciò che interrogherai; e verifica che le tue query portino la chiave nel WHERE guardando il piano, non supponendolo. Prima di partizionare una tabella che esiste già, chiediti se ciò che manca non sia un indice: il partizionamento aggiunge parti mobili in cambio di una manutenzione più economica, e quel conto torna solo oltre una certa dimensione.

Parole chiave: partizionamento, partition, range, list, hash, potatura, pruning, attach, detach, default, indice globale, unique, 0a000, drop partition, manutenzione

Codifica e collazione (PostgreSQL)

Perché qui non esiste la trappola di utf8, in che cosa differiscono codifica e collazione, come cambia l'ordine con ciascuna, e perché un LIKE per prefisso non usa il tuo indice.

Si applica a: PostgreSQL 13+

La trappola che è costata tante migrazioni in MySQL qui non esiste: non c'è un utf8 che non fosse UTF-8. La codifica si dichiara creando il database, e UTF8 è tutto UTF-8.

Misurato, conservando quattro stringhe in una normale colonna text:

ValoreCaratteriByte
normal66
ñandú57
日本語39
un'emoji con modificatore più testo1119

Non è servito dichiarare nulla di speciale. length() conta i caratteri e octet_length() conta i byte, la distinzione che in MySQL bisognava inseguire tipo per tipo.

Codifica e collazione sono due cose diverse
- Codifica — come si conservano i byte. È del database, si fissa alla creazione e non si cambia dopo: per cambiarla bisogna fare il dump e ricreare.
- Collazione — come si ordina e si confronta. Si può fissare per database, per colonna, per espressione e perfino in un ORDER BY.

Sul server di prova: server_encoding = UTF8, e i database con en_US.utf8 del fornitore libc. Ci sono 815 collazioni disponibili, di due fornitori: quelle del sistema (C, POSIX, en_US.utf8) e quelle di ICU (es-ES-x-icu, unicode), che da PostgreSQL 15 possono essere perfino il fornitore predefinito di un database.

Che cosa cambia con la collazione

SELECT array(SELECT s FROM (VALUES ('a'),('B'),('á'),('b'),('A')) v(s) ORDER BY s COLLATE "C"),
       array(SELECT s FROM (VALUES ('a'),('B'),('á'),('b'),('A')) v(s) ORDER BY s COLLATE "es-ES-x-icu");

Misurato, i risultati non si somigliano:

- C — A, B, a, b, á. Ordina per il numero del carattere: tutte le maiuscole prima delle minuscole, e gli accenti in fondo.
- es-ES-x-icu — a, A, á, b, B. Ordina come un dizionario.

E il confronto cambia con essa: 'a' < 'B' è falso con C e vero con la collazione spagnola. Un elenco che «esce ordinato male» non è quasi mai un bug dell'applicazione: è la collazione della colonna.

La collazione decide se un indice serve per LIKE
È il dettaglio pratico più difficile da scoprire da soli. Con una collazione linguistica —quella del database—, un normale indice B-tree non serve per le ricerche per prefisso. Misurato su 200 000 righe:

- WHERE s = 'usuario42' → Index Only Scan.
- WHERE s LIKE 'usuario42%' → Seq Scan, con l'indice lì presente.
- Dopo aver creato l'indice con la classe di operatori giusta, la stessa query è passata a Bitmap Index Scan.

CREATE INDEX idx_prefijo ON ch_like (s text_pattern_ops);

text_pattern_ops confronta byte per byte, che è esattamente ciò di cui ha bisogno LIKE 'qualcosa%'. Con la collazione C sulla colonna non serve, perché confronta già così.

Le collazioni hanno una versione, e conta
Il database registra la versione della collazione con cui sono stati costruiti i suoi indici —misurato: datcollversion = 2.36, quella della libreria di sistema—. Se il sistema operativo viene aggiornato e quella versione cambia, l'ordine può cambiare, e un indice costruito con l'ordine precedente smette di essere corretto: ricerche che non trovano righe che ci sono. PostgreSQL segnala la discrepanza, e la risposta è REINDEX.

È il motivo per cui molti scelgono la collazione C o ICU per i database che devono sopravvivere agli aggiornamenti di sistema: ICU porta la propria versione e non dipende da quella del sistema.

Raccomandazione
UTF8 sempre. La collazione si decide alla creazione del database, perché cambiarla dopo costa caro: C per le colonne che sono codici, identificatori o percorsi —ordina in fretta e va bene con LIKE—, e una collazione linguistica per ciò che legge una persona. E se una query con LIKE 'x%' non usa l'indice, guarda la collazione prima di toccare la query.

Parole chiave: codifica, encoding, utf8, collazione, collation, collate, icu, libc, text_pattern_ops, like, prefisso, order by, datcollversion, reindex, octet_length

Limiti (PostgreSQL)

Quelli che mordono davvero —63 byte di nome, 1 600 colonne, 32 per indice—, perché quello dell'identificatore dà un avviso e non un errore, e che cosa fa TOAST con un valore che non entra nella pagina.

Si applica a: PostgreSQL 13+

I limiti di PostgreSQL non somigliano a quelli di InnoDB, e quelli che mordono ogni giorno non sono i grandi.

Quelli che si toccano davvero

LimiteValoreChe succede superandolo
Lunghezza di un identificatore63 byteViene troncato, con un avviso
Colonne per tabella1 60054011 tables can have at most 1600 columns
Colonne per indice3254011 cannot use more than 32 columns in an index
Dimensione di pagina8 kBFissa, salvo ricompilare il server

I primi tre sono misurati: 1 600 colonne si sono create senza problemi e 1 601 hanno fallito; un indice di 32 colonne è nato e quello di 33 no.

Quello dell'identificatore è l'unico che non dà errore
Un nome di 72 caratteri è stato conservato come uno di 63, e il server l'ha detto con un avviso:

identifier "t_aaa…" will be truncated to "t_aaa…"

Un avviso non è un errore: l'istruzione è andata avanti. Per questo due nomi lunghi che si distinguono solo dal 64° carattere finiscono per essere lo stesso oggetto, e il guasto si vede molto più tardi. Sono i generatori di nomi —indici, vincoli, tabelle temporanee per lotto— a inciampare qui, non la mano di qualcuno.

I grandi, che non sono quasi mai il problema
Non sono misurati qui —servirebbe riempire un disco— e vanno con la loro cifra ufficiale:

- Dimensione massima di una tabella: 32 TB.
- Dimensione massima di un campo: 1 GB.
- Dimensione massima di una riga: 1,6 TB.
- Righe per tabella: nessun limite definito.
- Database per cluster e tabelle per database: nessun limite pratico.

Ciò che si esaurisce molto prima di uno qualsiasi di questi è la manutenzione: VACUUM, backup e ricostruzione di indici su una tabella di terabyte.

TOAST: perché un text da 1 GB non rompe la pagina da 8 kB
Una riga deve stare in una pagina, e una pagina è di 8 kB. I valori grandi vengono compressi e spostati in una tabella laterale —quello che chiamano TOAST—, in automatico e senza dichiarare nulla.

Misurato, conservando 100 000 byte di testo in una colonna text:

- octet_length — 100 000 byte di dato.
- pg_column_size — 1 156 byte, quindi è stato compresso.
- La tabella occupava 8 192 byte e 16 kB contando il suo TOAST.

Da cui una conseguenza pratica: un SELECT * su una tabella con colonne grandi paga la lettura di quelle colonne anche se nessuno le guarda. Chiedere solo le colonne necessarie non è stile, è I/O.

I limiti che invece si configurano
Non sono del motore ma dell'istanza, ed è per questo che si vedono in pg_settings: max_connections (100 di fabbrica), max_locks_per_transaction (64), max_wal_size, work_mem. Sono quelli che si esauriscono su un server vero; quelli della tabella qui sopra, quasi mai.

Raccomandazione
Sorvegliarne solo due: quello dei 63 byte quando qualcosa genera nomi, e quello delle colonne per indice quando qualcuno propone un indice composito con mezzo schema dentro. Del resto si viene a sapere da pg_settings, non dalla documentazione.

Parole chiave: limiti, identificatore, 63 byte, troncato, 1600 colonne, 32 colonne, indice, toast, pagina, 8 kB, 32 tb, 1 gb, pg_settings, max_connections

Codifica e collazioni (SQL Server)

varchar contro nvarchar, il prefisso N, le collazioni _UTF8, cosa fa ogni suffisso, il conflitto di collazione e perché un parametro N'…' può lasciare inutilizzato il tuo indice.

Si applica a: SQL Server 2022+

Se arrivi da MySQL, qui non ci sono né utf8mb4 né SET NAMES. In SQL Server la codifica di un testo non si dichiara a parte: la decide la sua collazione, e ci sono due famiglie di tipi di testo.

varchar e nvarchar sono due codifiche
- nvarchar (e nchar) memorizza UTF-16: qualsiasi carattere Unicode, qualunque sia la collazione.
- varchar (e char) memorizza i byte della code page della sua collazione. Con una collazione classica come Latin1_General_CI_AS è Windows-1252, e ciò che non vi rientra viene memorizzato come ?.
- Con una collazione che termina in _UTF8 (da SQL Server 2019), varchar memorizza UTF-8.

Misurato sul server di prova (SQL Server 2025), con il database in Latin1_General_100_CI_AS_SC_UTF8:

ValoreByte in varcharByte in nvarchar
ñ22
日本語96
un'emoji44

LEN() conta i caratteri e DATALENGTH() i byte. La n di varchar(n) sono byte, e quella di nvarchar(n) coppie di byte, non caratteri. Misurato: un varchar(10) in UTF-8 accetta cinque ñ, e dieci danno Msg 2628 («String or binary data would be truncated»); un nvarchar(10) accetta dieci ñ ma non sei emoji.

Il prefisso N
Un letterale senza N davanti è varchar, e viene convertito nella code page del database prima di raggiungere qualsiasi colonna. Misurato in un database Latin1_General_CI_AS:

- 'Zúrich ✓' → Zúrich ?
- N'Zúrich ✓' → Zúrich ✓

E in una colonna varchar di quel database, anche con la N sul letterale, Zúrich ✓ 日本 è stato memorizzato come Zúrich ? ??. Il dato si perde in scrittura, senza errori né avvisi, e cambiare poi la collazione della colonna non lo recupera: ciò che è memorizzato sono già punti interrogativi.

Cosa dice ogni suffisso

SELECT CASE WHEN 'a' = 'A' COLLATE Latin1_General_100_CI_AS THEN 1 ELSE 0 END AS ci,
       CASE WHEN 'a' = 'A' COLLATE Latin1_General_100_CS_AS THEN 1 ELSE 0 END AS cs,
       CASE WHEN 'e' = 'é' COLLATE Latin1_General_100_CI_AI THEN 1 ELSE 0 END AS ai,
       CASE WHEN 'e' = 'é' COLLATE Latin1_General_100_CI_AS THEN 1 ELSE 0 END AS acc;

Misurato: 1, 0, 1, 0.

- _CI / _CS — non distingue / distingue maiuscole e minuscole.
- _AI / _AS — non distingue / distingue gli accenti.
- _SC — conta come un carattere quelli fuori dal piano di base, come un'emoji. Misurato: LEN(N'😀') dà 1 con _SC e 2 senza, e senza _SC un LIKE N'_' non la trova.
- _UTF8 — varchar in UTF-8.
- _BIN2 — confronta per punto di codice: ha ordinato A, B, a, b, n, o, á, ñ, mentre Latin1_General_100_CI_AS e Modern_Spanish_100_CI_AS hanno dato A, a, á, b, B, n, ñ, o.
- Il 100 è la versione delle tabelle di ordinamento; quelle senza (Latin1_General_CI_AS) sono più vecchie, e quelle che iniziano con SQL_ sono le collazioni ereditate da SQL Server 2000.

Il server di prova offre 5540 collazioni, di cui 1585 _UTF8 (sys.fn_helpcollations()).

Server, database, colonna ed espressione

SELECT SERVERPROPERTY('Collation'), DATABASEPROPERTYEX('demo', 'Collation');

La collazione si fissa a quattro livelli, e ognuno prende quella del livello superiore al momento della creazione:

- Server — quella di master e tempdb, scelta all'installazione.
- Database — CREATE DATABASE … COLLATE ….
- Colonna — nome varchar(40) COLLATE Latin1_General_100_CS_AS.
- Espressione — WHERE a = b COLLATE Latin1_General_100_CI_AS, o dentro un ORDER BY.

ALTER DATABASE … COLLATE non tocca le colonne già esistenti: misurato, il database è passato a _UTF8 e le sue colonne sono rimaste in Latin1_General_CI_AS. Ogni colonna si cambia con il suo ALTER TABLE … ALTER COLUMN.

Il conflitto di collazione
Due testi con collazioni diverse non si confrontano da soli:

- WHERE a = b → Msg 468, «Cannot resolve the collation conflict».
- Un UNION ALL delle due colonne → Msg 457; con testo del catalogo (sys.* è in Latin1_General_CI_AS) → Msg 451.
- Si risolve mettendo COLLATE su un lato.

Il caso più frequente sono le tabelle temporanee: #tmp vive in tempdb e nasce con la collazione del server, non con quella del tuo database. Misurato in un database Latin1_General_100_CI_AS su un server _UTF8: un JOIN con #tmp ha dato Msg 468. La soluzione è dichiararne le colonne con COLLATE DATABASE_DEFAULT.

La collazione e gli indici
Su 100.000 righe con un indice sulla colonna:

- WHERE s = 'usuario42' → Index Seek. Con COLLATE sulla colonna → Index Scan: l'indice viene letto per intero.
- WHERE s LIKE 'usuario42%' → Index Seek, a differenza di PostgreSQL.
- Un parametro N'…' contro una colonna varchar: con una collazione Windows (Latin1_General_100_CI_AS) l'indice è stato ancora usato, con 7 letture logiche; con una collazione SQL_ (SQL_Latin1_General_CP1_CI_AS) l'intera colonna viene convertita in nvarchar e il piano diventa un Index Scan: 334 letture contro 3. Se una ricerca per uguaglianza è lenta e il parametro arriva come nvarchar, controlla la collazione della colonna.

Raccomandazione
Per un nuovo database, una collazione _100_…_SC_UTF8, che rende varchar un UTF-8 completo. Dichiara le colonne delle tabelle temporanee con COLLATE DATABASE_DEFAULT. E decidi la collazione quando crei il database: cambiarla dopo si fa colonna per colonna.

Parole chiave: codifica, encoding, collazione, collation, collate, varchar, nvarchar, utf8, utf-16, prefisso n, unicode, _sc, _bin2, 468, 451, 457, 2628, tempdb, database_default, datalength

Tipi di dati (SQL Server)

datetime contro datetime2, bit al posto di boolean, money contro decimal, GUID che non frammentano, rowversion che non è una data, il limite di 8000 byte e il tipo json del 2025.

Si applica a: SQL Server 2022+

Se vieni da MySQL, diversi tipi hanno nomi simili e si comportano in modo diverso, e due nomi ingannano: timestamp non è una data e datetime arrotonda. Ciò che non esiste dà errore, ed è un bene: non c'è UNSIGNED, tinyint va solo da 0 a 255 (con -1 si ottiene Msg 220, overflow aritmetico) e SELECT TRUE dà Msg 207: TRUE non è una parola del linguaggio.

Date: datetime2, non datetime
datetime è il tipo vecchio e arrotonda a 1/300 di secondo. Misurato:

ScrittoSalvato in datetime
12:34:56.00112:34:56.000
12:34:56.00212:34:56.003
12:34:56.00512:34:56.007
12:34:56.78912:34:56.790
2026-09-30 23:59:59.9992026-10-01 00:00:00.000

L'ultima riga è quella che rompe i report: un WHERE fecha <= '2026-09-30 23:59:59.999' su datetime include la mezzanotte del giorno dopo. Per un giorno intero, con qualsiasi tipo, fecha >= '2026-09-30' AND fecha < '2026-10-01'.

datetime2 salva fino a cento nanosecondi (in datetime2(7) .1234567 è rimasto tale e quale), comincia dall'anno 1 —'1700-01-01' in datetime dà Msg 242, fuori intervallo— e occupa lo stesso o meno: misurato, datetime 8 byte, datetime2(7) 8, datetime2(3) 7 e datetime2(0) 6. E non si mescolano senza pensarci: lo stesso 12:00:00.001 in datetime2(3) e in datetime non è uguale, perché datetime l'aveva già salvato come .000.

Altre dimensioni misurate: date 3 byte, time 5, smalldatetime 4 (al minuto) e datetimeoffset 10.

datetimeoffset conserva lo scostamento
A differenza di timestamptz di PostgreSQL, conserva lo scostamento con cui è stato scritto, e confronta per istante: '2026-09-30T12:00:00+05:30' e '2026-09-30T06:30:00+00:00' sono risultati uguali. SWITCHOFFSET(valore, '+00:00') lo porta in UTC, e AT TIME ZONE 'Central European Standard Time' l'ha dato come 08:30 +02:00, con l'ora legale applicata. Calíope lo mostra nell'ora del dato, con lo scostamento accanto.

Non c'è boolean: c'è bit
bit è un numero che vale 0 o 1, non un valore di verità. Misurato: assegnargli 5, -1 o la stringa 'true' salva 1; un WHERE @b da solo dà Msg 4145 —bisogna scrivere WHERE @b = 1— e bit + bit dà Msg 402. Calíope lo mostra come 1 o 0.

Denaro: decimal, e money con cautela

DECLARE @m money = 100, @m3 money = 3;
DECLARE @d decimal(19,4) = 100, @d3 decimal(19,4) = 3;
SELECT @m / @m3 * @m3 AS con_money,     -- 99.9999
       @d / @d3 * @d3 AS con_decimal;   -- 100.000000

money ha quattro decimali fissi, anche nei risultati intermedi: 1 / 3 ha dato .3333, e moltiplicato per 3 non torna più all'intero. decimal allarga la scala del risultato (ha dato .3333333333333333333). Per i calcoli, decimal; money occupa 8 byte e smallmoney 4. float è binario come ovunque: 0.1 + 0.2 ha dato 0.30000000000000004, e l'uguaglianza con 0.3 è falsa; in decimal, vera.

GUID: uniqueidentifier, e l'ordine conta
Un uniqueidentifier occupa 16 byte. Come chiave primaria clustered, NEWID() sparge le righe a caso nell'indice. Misurato con 20.000 righe inserite una per una:

ChiaveFrammentazionePagineRiempimento medio
NEWID()98 %11662 %
NEWSEQUENTIALID()1 %7299,5 %
int IDENTITY2 %4398 %

NEWSEQUENTIALID() vale solo come DEFAULT di una colonna: SELECT NEWSEQUENTIALID() dà Msg 302. Calíope mostra il GUID in maiuscolo, come SQL Server.

rowversion non è una data
rowversion —il tipo che prima si chiamava timestamp, nome ancora accettato quando si crea una colonna— è un contatore di 8 byte del database che cambia a ogni scrittura della riga. Misurato: due righe sono nate con 0x…07D4 e 0x…07D5, e aggiornare la prima l'ha portata a 0x…07D6. Non salva nessun orario. Serve per la concorrenza ottimistica: si legge la versione e si aggiorna con WHERE ver = @letta. Calíope lo mostra come binario, [8 bytes].

Testo: 8000 byte, o max
Fuori da max, varchar(n) arriva a 8000 e nvarchar(n) a 4000: varchar(8001) dà Msg 131 e nvarchar(4001), Msg 2717. Oltre ci sono varchar(max) e nvarchar(max).

Anche la riga ha un limite: due char(5000) in una tabella danno Msg 1701 —la riga minima sarebbe di 10.007 byte e il massimo è 8060—. Due varchar(8000) pieni ci stanno (misurato: 16.000 byte in una riga), perché i dati variabili escono dalla pagina quando non ci stanno.

E una trappola che non avvisa: REPLICATE('x', 10000) assegnato a un varchar(max) ha dato 8000 caratteri, perché la funzione lavora nel tipo del suo argomento. Con REPLICATE(CAST('x' AS varchar(max)), 10000), 10.000.

JSON: il tipo json è di SQL Server 2025
In SQL Server 2022 il JSON si salva in nvarchar(max) e si valida con CHECK (ISJSON(col) = 1), che rifiuta quello malformato con Msg 547. Dal 2025 esiste il tipo json, che lo rifiuta in scrittura con Msg 13609 e lo salva già analizzato. Misurato su 20.000 copie di un ordine di 142 caratteri, json ha occupato 5,4 MB e nvarchar(max) 6,0 MB; per un documento minimo, {"b":1,"a":2}, al contrario: 60 byte contro 26.

E una differenza che si vede: con una chiave ripetuta, ISJSON accetta {"b":1,"a":2,"a":3}, e il tipo json l'ha restituito come {"b":1,"a":2}: ha tenuto la prima, senza avvisare. Conserva l'ordine delle chiavi. Calíope riceve il json come testo e lo mostra così com'è.

I tipi speciali
- geography e geometry — tipi con un formato binario proprio, che non è WKB. geography::Point(40.4168, -3.7038, 4326) occupa 22 byte e il suo STAsText() dà POINT (-3.7038 40.4168): il costruttore vuole latitudine, longitudine e il testo esce come longitudine, latitudine. Calíope mostra la cella come [22 bytes]; per leggerla, col.STAsText() nella query.
- hierarchyid — un percorso in un albero: '/1/3/' è stato salvato in 2 byte, con livello 2 e genitore /1/. Calíope non lo decodifica e la cella dice tipo non supportato: hierarchyid; con col.ToString() nella query, si legge.
- sql_variant — una colonna che contiene valori di più tipi, ognuno con il suo: SQL_VARIANT_PROPERTY(@v, 'BaseType') ha dato decimal. Calíope lo mostra secondo il suo tipo di base. Ha senso nel catalogo (sys.extended_properties); in una tabella tua di solito è una colonna progettata male.

IDENTITY o SEQUENCE
IDENTITY è una proprietà della colonna, e la protegge: inserire un valore a mano dà Msg 544, salvo con SET IDENTITY_INSERT tabla ON, e dopo il contatore riparte dal più alto (dopo aver inserito 100, la riga successiva è stata la 101). E lascia buchi: un INSERT annullato con ROLLBACK ha consumato il 3, e la riga successiva è stata la 4. Un buco non è un dato perso.

SEQUENCE è un oggetto a parte, che più tabelle possono condividere: con DEFAULT NEXT VALUE FOR sq su due tabelle, i valori si sono divisi in 1 e 3 in una e 2 nell'altra. Si usa quando serve il numero prima di inserire, o condiviso; per la chiave di una tabella, IDENTITY.

Raccomandazione
datetime2 per le letture d'orologio e datetimeoffset per gli istanti, decimal per il denaro, bit sapendo che è un numero, NEWSEQUENTIALID() o un intero se la chiave clustered è un GUID, e il tipo json solo se il tuo server più vecchio è il 2025. Per il testo, «Codifica e collazioni (SQL Server)».

Parole chiave: tipi, datetime, datetime2, datetimeoffset, date, time, bit, boolean, money, decimal, float, uniqueidentifier, guid, newsequentialid, rowversion, timestamp, varchar, nvarchar, max, json, isjson, hierarchyid, geography, sql_variant, identity, sequence, unsigned

Errori (SQL Server)

Come si legge un Msg, cosa dice il suo livello, i numeri che vedi ogni giorno, quali errori annullano la transazione e quali no, TRY…CATCH con THROW, e dove sta il motivo di un 18456.

Si applica a: SQL Server 2022+

Se vieni da MySQL, anche qui ci sono numeri —niente SQLSTATE come in PostgreSQL—, ma ogni errore porta quattro dati e contano tutti. Così lo mostra Calíope, su una riga; sqlcmd lo divide in due:

Msg 2627, Level 14, State 1, Line 1: Violation of PRIMARY KEY constraint 'PK__padre__3213E83F9B0ABF57'. Cannot insert duplicate key in object 'dbo.padre'. The duplicate key value is (1).

- Msg — il numero. È ciò che si cerca e ciò su cui si programma.
- Level — la gravità, da 0 a 25. Dice di chi è la colpa e cosa succede alla connessione.
- State — un sottocodice che il server usa per distinguere cause con lo stesso numero. Per la maggior parte degli errori non conta; per il 18456 è l'unica cosa che dice il perché.
- Line — la riga all'interno del batch (ciò che sta tra due GO), o all'interno della procedura: un errore lì dentro esce come Procedure p_falla, Line 3.

Il livello, la prima cosa da guardare

LivelloCosa significaMisurato
0–10Informativo: non è un erroreRAISERROR('aviso', 10, 1) si stampa senza Msg e non salta al CATCH
11–16Lo corregge l'utente: il dato, la sintassi, il permesso1205 è 13; 2627, 2601, 229 e 916 sono 14; 102 è 15; quasi tutto il resto, 16
17–19Risorse o un guasto interno del server—
20–25Fatale: il server chiude la connessioneRAISERROR(…, 20, 1) WITH LOG ha terminato la sessione (Msg 2745)

Un livello 20 o superiore non si può sollevare a mano senza essere sysadmin e senza WITH LOG: dà Msg 2754. Calíope tratta un livello 20 o superiore come una connessione persa.

Quelli che vedi ogni giorno
Provocati uno per uno contro SQL Server 2025:

MsgCosa è successo
2627Chiave duplicata in una PRIMARY KEY o in un vincolo UNIQUE; il messaggio porta il nome e il valore
2601Chiave duplicata in un indice univoco (non un vincolo)
547Una chiave esterna o un CHECK ha detto no; lo stesso numero per INSERT, DELETE e CHECK
515NULL in una colonna NOT NULL
2628Il testo non ci sta; porta la tabella, la colonna e il valore troncato
8152Lo stesso, senza dettagli: è quello che esce con livello di compatibilità 140 o inferiore
245Conversione impossibile: CAST('hola' AS int)
8134Divisione per zero
208Quell'oggetto non esiste
207Quella colonna non esiste
102Errore di sintassi (Incorrect syntax near 'FORM')
2812«Procedura non trovata»: SELEC 1 lo dà, perché un batch che inizia con una parola isolata è un EXEC
229Permesso negato su un oggetto
916Il login non ha un utente in quel database
911USE di un database che non esiste
1205Vittima di un deadlock: «Rerun the transaction»
1222LOCK_TIMEOUT scaduto aspettando un blocco
3902COMMIT senza BEGIN TRANSACTION

Per vedere il testo di qualsiasi numero: SELECT text FROM sys.messages WHERE message_id = 1205 AND language_id = 1033;. Ci sono 16.785 messaggi in inglese, in 22 lingue.

Cosa annulla ogni errore: la trappola per chi viene da PostgreSQL
In PostgreSQL un errore annulla l'intera transazione. Qui dipende dall'errore, e la maggior parte annulla solo l'istruzione. Misurato, senza attivare niente:

BEGIN TRAN;
INSERT padre VALUES (10, 'd@x.es', 1);
INSERT padre VALUES (10, 'e@x.es', 1);  -- Msg 2627
INSERT padre VALUES (11, 'f@x.es', 1);
COMMIT;
-- sono rimaste le righe 10 e 11

La transazione è rimasta aperta, il COMMIT ha confermato ciò che aveva funzionato e il batch è andato avanti come se niente fosse. Cosa ha fatto ogni errore misurato:

- Solo l'istruzione: 2627, 2601, 547, 515, 2628, 8134, e anche 1222: scaduta l'attesa, @@TRANCOUNT era ancora 1.
- Il batch e la transazione: 245 (conversione) ha annullato la transazione e interrotto il batch senza niente di attivo; 1205 anche, nella vittima; un ROLLBACK dentro un trigger dà Msg 3609 e interrompe il batch.
- La connessione: livello 20 o superiore.

SET XACT_ABORT ON lo cambia: con esso, lo stesso 2627 ha annullato l'intera transazione e interrotto il batch (le righe 20 e 21 non sono rimaste). È ciò che va messo all'inizio di ogni procedura che apre una transazione.

TRY…CATCH, e cosa non cattura

SET XACT_ABORT ON;
BEGIN TRY
  BEGIN TRAN;
  -- …
  COMMIT;
END TRY
BEGIN CATCH
  IF @@TRANCOUNT > 0 ROLLBACK;
  THROW;
END CATCH;

Dentro il CATCH, ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(), ERROR_LINE(), ERROR_PROCEDURE() e ERROR_MESSAGE() restituiscono i dati dell'errore (misurato: 2627, 14, 1, 4, NULL). E XACT_STATE() dice cosa puoi fare con la transazione: ha dato 1 senza XACT_ABORT (si può confermare) e -1 con esso (si può solo annullare).

Cosa un TRY non cattura: un oggetto che non esiste nel suo stesso ambito. SELECT * FROM tabla_que_no_existe dentro un TRY è uscito come Msg 208 senza passare dal CATCH, perché fallisce in compilazione; dentro un EXEC sp_executesql, è stato catturato. Non cattura nemmeno ciò che è di livello 10 o inferiore.

THROW contro RAISERROR
- THROW; senza argomenti, dentro un CATCH, rilancia l'errore originale: il numero arrivato fuori era ancora 8134. Rilanciarlo con RAISERROR(ERROR_MESSAGE(), 16, 1) lo ha trasformato in 50000, e il numero si è perso.
- RAISERROR non ferma il batch: ciò che veniva dopo è stato eseguito. THROW sì.
- THROW 50001, 'testo', 1 solleva un errore tuo, sempre di livello 16 e con un numero da 50000 in su. RAISERROR accetta inoltre un numero registrato con sp_addmessage e ne riempie gli argomenti: RAISERROR(50100, 16, 1, 42) ha dato «El pedido 42 no existe».

Il testo cambia con la lingua; il numero no
Con SET LANGUAGE Spanish, il 8134 è arrivato come «Error de división entre cero.»: lo stesso numero con un altro testo. La lingua è quella della sessione, e Calíope si connette con la lingua predefinita del login. Per questo, nel codice e nei tuoi registri, si confronta il numero, mai il testo.

Il 18456: il motivo è nel registro del server
Un accesso fallito arriva al client sempre uguale: Msg 18456, Level 14, State 1, «Login failed for user '…'». Misurato con tre cause diverse, il client ha visto State 1 tutte e tre le volte. Lo stato vero è solo nel registro del server:

EXEC xp_readerrorlog 0, 1, N'Login failed';
State nel registroMotivo che scrive
5Non esiste alcun login con quel nome
8La password non corrisponde
38Non è stato possibile aprire il database richiesto (non esiste, o il login non vi ha un utente)

Quando il database richiesto alla connessione non esiste, prima del 18456 arriva un 4060 («Cannot open database … requested by the login»), e quello sì dice il motivo. Se qualcuno «non riesce a entrare», il registro del server distingue in un secondo ciò che da fuori è la stessa frase.

Cosa mostra Calíope
Quando un'istruzione fallisce, Calíope mostra nella scheda dei risultati il messaggio del server per intero, nella forma Msg, Level, State, Line di sopra, e tutti i messaggi, uno per riga: un CREATE TABLE con un vincolo ripetuto porta il 2714 e poi il 1750, e tutti e due dicono qualcosa. Quegli errori restano nella loro scheda. Quelli che impediscono di connettersi —il 18456, il 4060— e quelli di livello 20 o superiore, che interrompono la connessione, arrivano inoltre al registro degli errori di Calíope. I messaggi informativi —un PRINT, un RAISERROR di livello 10— non li mostra.

Raccomandazione
SET XACT_ABORT ON e TRY…CATCH con IF @@TRANCOUNT > 0 ROLLBACK; THROW; in ogni procedura che apre una transazione. Nell'applicazione, ritentare il 1205 —l'intera transazione, che il server ha già annullato— e non ritentare i livelli da 14 a 16, che riguardano il dato o il programma. Salvare sempre il numero, mai il testo. E davanti a un 18456, leggere il registro del server prima di toccare la password.

Parole chiave: errore, msg, livello, gravità, severity, level, state, 18456, 1205, 2627, 2601, 547, 208, 102, 2628, 8152, 515, 229, 916, 4060, 1222, try, catch, throw, raiserror, xact_abort, xact_state, error_number, sys.messages, xp_readerrorlog, nuovo tentativo

Indici (SQL Server)

L'indice cluster che è la tabella, il Key Lookup e quando INCLUDE lo elimina, indici filtrati, colonne calcolate invece di indici su espressione, columnstore, FILLFACTOR e frammentazione, e gli indici che nessuno usa.

Si applica a: SQL Server 2022+

Se vieni da MySQL, la cosa più simile è InnoDB: anche qui la tabella è un indice. Cambia che quell'indice —quello cluster— lo scegli tu e non deve per forza essere la chiave primaria, che una tabella può non averlo, e che al posto degli indici di prefisso o invisibili ci sono indici filtrati, colonne calcolate e columnstore.

Tutto quello che segue è misurato su una tabella di 200 000 righe e circa 32 MB.

La tabella è l'indice cluster
PRIMARY KEY crea un indice cluster se la tabella non ne ha ancora uno: le righe vengono salvate ordinate per quella chiave. Una tabella senza indice cluster è un heap (HEAP in sys.indexes): le righe vanno dove c'è posto.

Quello che da MySQL non si vede è il prezzo: la chiave cluster viaggia dentro ogni indice non cluster, perché è così che l'indice trova la riga. Lo stesso indice su cliente_id ha occupato 2 952 kB con una chiave cluster int e 5 416 kB con una uniqueidentifier. Una chiave cluster stretta e crescente rende più economici tutti gli altri indici; perché NEWID() è una cattiva chiave cluster lo racconta l'argomento sui tipi di dati.

Key Lookup, e cosa risolve INCLUDE
Un indice non cluster porta solo le sue colonne e la chiave cluster. Se la query ne chiede un'altra, ogni riga trovata obbliga a tornare alla tabella: questo è il Key Lookup.

CREATE INDEX ix_cliente ON dbo.pedidos (cliente_id);
SELECT cliente_id, total FROM dbo.pedidos WHERE cliente_id = 4242;
-- Index Seek (ix_cliente) + Clustered Index Seek … LOOKUP: 29 letture logiche

CREATE INDEX ix_cliente_inc ON dbo.pedidos (cliente_id) INCLUDE (total);
-- Index Seek (ix_cliente_inc) e nient'altro: 3 letture logiche

INCLUDE salva la colonna nelle foglie dell'indice senza ordinare per essa. Qui l'indice è passato da 2 952 kB a 4 752 kB.

Il Key Lookup si paga riga per riga, quindi smette di convenire molto presto. Misurato: con 992 righe (lo 0,5 % della tabella) il piano usava ancora l'indice e il lookup; con 1 483 (lo 0,7 %) percorreva già tutta la tabella. Un indice «che non si usa» spesso è un indice a cui manca un INCLUDE.

La regola del prefisso sinistro
Con un indice su (fecha, cliente_id) e WHERE cliente_id = 77, SQL Server non può scendere lungo l'indice. Può, come PostgreSQL, percorrerlo tutto se costa meno della tabella: Index Scan e 809 letture logiche. Non è un Index Seek, e l'ordine delle colonne continua a contare.

Indici filtrati
Un indice può avere un WHERE, e allora indicizza solo ciò che lo soddisfa:

CREATE INDEX ix_pend ON dbo.pedidos (cliente_id) WHERE estado = 'pendiente';

Con gli ordini in sospeso al 5 %, ha occupato 160 kB, contro i 2 952 kB dell'indice completo sulla stessa colonna. La trappola è che il piano deve andare bene per qualsiasi valore: con il letterale WHERE estado = 'pendiente' l'ha usato; con una variabile (WHERE estado = @e), no, e forzarlo con WITH (INDEX(ix_pend)) dà Msg 8622.

E un uso che sorprende chi viene da MySQL: in SQL Server un indice univoco ammette un solo NULL. CREATE UNIQUE INDEX ux_email ON dbo.pedidos (email) con due email a NULL dà Msg 1505 («The duplicate key value is (<NULL>)»). La via d'uscita è l'indice filtrato:

CREATE UNIQUE INDEX ux_email ON dbo.pedidos (email) WHERE email IS NOT NULL;

Niente indici su espressione: colonne calcolate
Una funzione sulla colonna del WHERE impedisce di scendere lungo l'indice. Sulle stesse 14 400 righe di un mese:

- WHERE YEAR(fecha) = 2023 AND MONTH(fecha) = 6: Index Scan, 510 letture.
- WHERE fecha >= '2023-06-01' AND fecha < '2023-07-01': Index Seek, 40 letture.

Quando l'espressione non si può riscrivere come intervallo, si indicizza una colonna calcolata:

ALTER TABLE dbo.pedidos ADD email_lower AS LOWER(email);
CREATE INDEX ix_email_lower ON dbo.pedidos (email_lower);

SELECT id FROM dbo.pedidos WHERE LOWER(email) = 'u77@ejemplo.com';
-- Index Seek (ix_email_lower), senza nominare la colonna calcolata

Per creare quell'indice, e poi per scrivere nella tabella, la sessione ha bisogno di QUOTED_IDENTIFIER e ANSI_NULLS attivi; sqlcmd spegne il primo se non gli si passa -I, e allora fallisce con Msg 1934.

Columnstore, per ciò che aggrega
Un indice columnstore salva ogni colonna a parte e compressa. La stessa tabella ha occupato 35 520 kB con il suo indice cluster normale e 7 104 kB come CLUSTERED COLUMNSTORE. Un SELECT estado, SUM(total) … GROUP BY estado ha letto 4 440 pagine e usato 19 ms di CPU sulla prima, e 147 pagine e 2 ms sulla seconda. Per cercare una riga per chiave un indice normale resta migliore; la cosa abituale su una tabella di lavoro è aggiungerle un NONCLUSTERED COLUMNSTORE con le colonne che si aggregano (quello di quattro colonne ha occupato 3 128 kB).

Le chiavi esterne non si indicizzano da sole
InnoDB crea un indice per ogni chiave esterna; SQL Server no. Misurato: dopo cliente_id int REFERENCES dbo.clientes(id), la tabella figlia aveva solo l'indice della sua chiave primaria. Senza quell'indice, ogni DELETE nella tabella padre percorre tutta la tabella figlia per verificare il vincolo.

Frammentazione e FILLFACTOR

SELECT i.name, p.avg_fragmentation_in_percent, p.page_count,
       p.avg_page_space_used_in_percent
  FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.pedidos'), NULL, NULL, 'DETAILED') p
  JOIN sys.indexes i ON i.object_id = p.object_id AND i.index_id = p.index_id
 WHERE p.index_level = 0;

L'indice su cliente_id, con valori casuali, misurato passo per passo:

MomentoFrammentazionePagineRiempimento
Appena creato8 %35298 %
Dopo 50 000 inserimenti99 %69662 %
Dopo REORGANIZE0,5 %43499,6 %
Dopo REBUILD0 %43399,8 %
REBUILD WITH (FILLFACTOR = 70)0 %61870 %
E altri 50 000 inserimenti0 %61884 %

FILLFACTOR lascia spazio in ogni pagina perché gli inserimenti ci stiano senza spezzarla: costa spazio dal primo giorno e risparmia la frammentazione dopo. Resta salvato nell'indice (sys.indexes.fill_factor), quindi i REBUILD successivi lo ripetono. REORGANIZE compatta senza bloccare e si può interrompere; REBUILD rifà l'indice intero e, senza ONLINE, blocca la tabella finché dura.

Ricostruire senza fermare la tabella

ALTER INDEX ix_cliente ON dbo.pedidos REBUILD WITH (ONLINE = ON, RESUMABLE = ON);

ONLINE = ON è dell'edizione Enterprise (e della Developer, che è la stessa, e di Azure SQL): in Standard non esiste, e un REBUILD ferma le scritture finché dura —e anche le letture, se è l'indice cluster—. RESUMABLE = ON permette di metterlo in pausa e riprenderlo dopo, ed esige ONLINE = ON (senza, Msg 11438). E due casi in cui ONLINE = ON fallisce: con un indice spaziale sulla tabella e con ALTER INDEX ALL.

Niente indici invisibili: DISABLE
ALTER INDEX … DISABLE è la cosa più simile, ma non è lo stesso: cancella i dati dell'indice e ne tiene solo la definizione, quindi tornare indietro è un REBUILD completo. E disattivare l'indice cluster rende la tabella illeggibile: SELECT COUNT(*) dà Msg 8655, e di passaggio disattiva tutti gli indici non cluster, con un avviso per ciascuno.

Quelli che nessuno usa, e quelli che mancano

SELECT OBJECT_NAME(i.object_id) AS tabla, i.name AS indice,
       u.user_seeks, u.user_scans, u.user_lookups, u.user_updates
  FROM sys.indexes i
  LEFT JOIN sys.dm_db_index_usage_stats u
    ON u.object_id = i.object_id AND u.index_id = i.index_id
   AND u.database_id = DB_ID()
 WHERE OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1 AND i.index_id > 1
 ORDER BY u.user_seeks + u.user_scans + u.user_lookups;

La cautela è maggiore che in PostgreSQL: questa vista vive in memoria e si svuota al riavvio del server. Misurato: ho riavviato il container e la vista è tornata senza una sola riga della tabella. Anche disattivare l'indice la azzera; un REBUILD no. Un indice che segna 0 letture di lunedì può essere uno che nessuno ha avuto il tempo di usare.

L'altra metà sono quelli che mancano: l'ottimizzatore annota in sys.dm_db_missing_index_details l'indice che avrebbe voluto. Dopo una query su cliente_id ed estado, ha proposto [cliente_id], [estado] con un impatto stimato del 99,6 %. È un indizio, non un ordine: non guarda gli indici che hai già, non pesa le scritture e vive nella stessa memoria che si svuota.

Raccomandazione
Una chiave cluster stretta e crescente; un indice per ogni chiave esterna usata per cercare o per cancellare; INCLUDE prima di un altro indice quando il piano mostra un Key Lookup; e prima di cancellare un indice «inutilizzato», guardare da quanto tempo è partito il server (sqlserver_start_time in sys.dm_os_sys_info).

Parole chiave: indice, indici, index, cluster, clustered, non cluster, nonclustered, heap, key lookup, include, indice filtrato, colonna calcolata, columnstore, fillfactor, frammentazione, reorganize, rebuild, online, resumable, disable, dm_db_index_usage_stats, dm_db_missing_index_details, dm_db_index_physical_stats

Limiti (SQL Server)

I limiti che si toccano davvero —8 060 byte per riga, 900 e 1 700 byte di chiave di indice, 1 024 colonne, 128 caratteri di nome, 2 100 parametri—, quali avvisano soltanto quando si crea la tabella e falliscono dopo, e quelli dell'edizione Express.

Si applica a: SQL Server 2022+

I limiti di SQL Server somigliano poco a quelli di InnoDB, e quelli che mordono ogni giorno non sono i grandi. Li ho provocati uno per uno contro SQL Server 2025.

Quelli che si toccano davvero

LimiteValoreCosa succede superandolo
Byte per riga (dentro la pagina)8 060Msg 1701 se le colonne fisse non ci stanno
Chiave di un indice cluster900 byteAvviso alla creazione; Msg 1946 all'inserimento
Chiave di un indice non cluster1 700 byteAvviso alla creazione; Msg 1946 all'inserimento
Colonne per tabella1 024Msg 1702
Colonne nella chiave di un indice32Msg 1904
Indici non cluster per tabella999Msg 1910
Lunghezza di un nome128 caratteriMsg 103
Nome di una tabella temporanea #116 caratteriMsg 193
Parametri per procedura o richiesta2 100Msg 180
Annidamento di procedure, funzioni e trigger32 livelliMsg 217
Colonne di una SELECT4 096Msg 1056

La riga sta in una pagina, tranne ciò che è variabile
Una pagina è di 8 KB e una riga deve starci: 8 060 byte. Con colonne a larghezza fissa il server lo controlla quando si crea la tabella: due char(5000) danno Msg 1701, che dice che la riga minima sarebbe di 10 007 byte.

Con colonne variabili no. Ho salvato due varchar(8000) pieni nella stessa riga —16 000 byte— senza alcun errore: ciò che non ci sta finisce in pagine a parte, ROW_OVERFLOW_DATA. sys.allocation_units ha mostrato 2 pagine di riga e 2 di overflow. Funziona, ma leggere quella riga costa una lettura in più per ogni colonna in overflow, e lo schema non avvisa che sta succedendo.

SELECT au.type_desc, SUM(au.used_pages) AS pagine
FROM sys.allocation_units AS au
JOIN sys.partitions AS p ON au.container_id = p.partition_id
WHERE p.object_id = OBJECT_ID('dbo.mia_tabella')
GROUP BY au.type_desc;

varchar(max), nvarchar(max) e varbinary(max) arrivano a 2 GB ed escono dalla riga per la stessa strada. Perché varchar(8001) non esiste e bisogna saltare a max lo racconta l'argomento sui tipi di dati.

Il limite che avvisa soltanto: la chiave di indice
È quello che sorprende di più. Un indice su una colonna più larga del limite viene creato, con un avviso:

Warning! The maximum key length for a clustered index is 900 bytes. The index 'cx' has maximum length of 1000 bytes.

Un avviso non ferma un rilascio. La tabella funziona fino al giorno in cui qualcuno salva un valore lungo: 900 byte sono entrati e 901 hanno dato Msg 1946. Su un indice non cluster il limite è di 1 700 byte, e con nvarchar sono 850 caratteri, non 1 700, perché ognuno occupa due byte. Una PRIMARY KEY su varchar(1000) è un indice cluster e si comporta allo stesso modo.

Se una colonna lunga ha bisogno di una ricerca per uguaglianza, ciò che funziona è indicizzare un riassunto: una colonna calcolata con CHECKSUM o HASHBYTES, indicizzata, confrontando anche il valore completo.

Nomi: un errore, non un troncamento
Un nome di 129 caratteri dà Msg 103 e l'istruzione non viene eseguita. PostgreSQL tronca a 63 byte e va avanti; SQL Server non lascia passare il nome. Quello di una tabella temporanea locale è più corto, 116, perché il server aggiunge un suffisso per distinguere le sessioni.

Parametri: 2 100, e 2 098 in pratica
Una procedura accetta 2 100 parametri e una chiamata non può passarne di più. Ma i driver e gli ORM inviano le query parametrizzate tramite sp_executesql, e lì contano anche l'istruzione e la dichiarazione: con 2 098 parametri ha funzionato e con 2 099 ha dato Msg 180. È l'errore tipico di un WHERE id IN (@p1, @p2, …) generato da una lista che un giorno cresce. La via d'uscita non è spezzare la lista: è passarla come tabella (un parametro con valori di tabella, o una temporanea) e fare un JOIN. Una IN con 60 000 letterali scritti nell'istruzione invece ha funzionato.

Più colonne: SPARSE con un set di colonne
1 025 colonne danno Msg 1702. Con colonne SPARSE e un COLUMN_SET FOR ALL_SPARSE_COLUMNS ho creato una tabella di 2 002 colonne; le stesse colonne senza il set hanno dato lo stesso Msg 1702. La documentazione la chiama «tabella larga» e porta il limite a 30 000 colonne, ma la riga deve comunque stare in 8 060 byte.

Annidamento: 32 livelli
Una procedura che chiama sé stessa è arrivata a @@NESTLEVEL 32 e il livello successivo ha dato Msg 217. Procedure, funzioni, trigger e viste contano insieme: un trigger che aggiorna un'altra tabella con trigger consuma livelli senza che si veda.

I grandi, che non sono quasi mai il problema
Non li ho misurati —il testbed è l'edizione Developer e misurarli richiederebbe di riempire un disco—; sono qui con la cifra della documentazione di Microsoft:

- Dimensione di un database: 524 272 TB. Di un file di dati: 16 TB.
- Righe per tabella: nessun limite oltre allo spazio di archiviazione.
- Dimensione di un batch: 65 536 volte la dimensione del pacchetto di rete (4 096 byte di default, cioè 256 MB).
- Connessioni: 32 767 (@@MAX_CONNECTIONS), e user connections a 0 significa che non c'è un altro tetto.

Quelli che mettono le edizioni
Qui ci sono limiti che si toccano, e vengono dalla licenza, non dal motore. Non ho misurato nemmeno questi: il testbed è Developer, che non ne ha nessuno.

- Express: 10 GB per database in SQL Server 2022 e 50 GB nel 2025; circa 1,4 GB di memoria per il buffer pool; il minore tra un socket e quattro core. Raggiunta la dimensione, il database smette di crescere e le scritture falliscono.
- Standard: nessun tetto alla dimensione del database, ma un tetto di memoria e di core, e senza alcune funzioni di Enterprise, come REBUILD con ONLINE = ON.

SELECT SERVERPROPERTY('Edition') dice qual è quella del server a cui sei connesso.

Raccomandazione
Tenerne d'occhio tre: la chiave di indice quando si indicizza una colonna di testo, perché avvisa soltanto; i 2 098 parametri quando qualcosa genera liste IN; e la dimensione del database se il server è Express. Del resto ci si accorge dal numero di errore, che dice sempre qual è il limite.

Parole chiave: limiti, massimo, 8060, riga, pagina, row_overflow_data, overflow, chiave di indice, 900 byte, 1700 byte, 1024 colonne, sparse, column_set, 128 caratteri, identificatore, tabella temporanea, 2100 parametri, sp_executesql, nestlevel, annidamento, express, 10 gb, 50 gb, edizione, msg 1946, msg 1702, msg 180

Lock e deadlock (SQL Server)

Perché qui una SELECT aspetta un UPDATE, cosa cambia READ_COMMITTED_SNAPSHOT, come si vede chi blocca chi, dove resta il grafo di un 1205 e quando i lock di riga diventano un lock di tabella.

Si applica a: SQL Server 2022+

Se vieni da MySQL o da PostgreSQL, questa è la prima cosa che spiazza: in SQL Server, per impostazione predefinita, una SELECT aspetta un UPDATE. InnoDB e PostgreSQL leggono l'ultima versione confermata della riga e vanno avanti; SQL Server, in READ COMMITTED e basta, chiede un lock condiviso e resta in attesa che l'altro confermi o annulli. Tutto ciò che racconto qui l'ho misurato contro SQL Server 2025, con due sessioni aperte insieme.

Una SELECT che aspetta

-- Sessione A
BEGIN TRAN;
UPDATE dbo.cuentas SET saldo = 50 WHERE id = 1;

-- Sessione B
SELECT saldo FROM dbo.cuentas WHERE id = 1;

La sessione B è rimasta ferma 7,5 s, esattamente il tempo che A ha impiegato a fare ROLLBACK, in attesa di LCK_M_S sulla chiave di quella riga. E A non deve per forza fare qualcosa: una sessione che ha aperto una transazione, ha scritto e poi è rimasta ferma —status sleeping, open_transaction_count 1— blocca lo stesso. È il caso di tutti i giorni: un'applicazione che ha dimenticato il COMMIT.

READ_COMMITTED_SNAPSHOT: leggere come gli altri due

ALTER DATABASE mio_db SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

Con questa opzione, READ COMMITTED legge l'ultima versione confermata della riga, e la stessa lettura è tornata subito con il valore di prima (100, non il 50 non confermato). Tre cose prima di accenderla:
- È del database, non della sessione, e nasce spenta: in un database appena creato, in model e nella demo del mio laboratorio, is_read_committed_snapshot_on vale 0.
- Senza WITH ROLLBACK IMMEDIATE, l'ALTER aspetta che non resti nessun'altra sessione nel database: con una collegata, ha aspettato 14,5 s, il tempo che se ne andasse. Con la clausola, butta fuori le altre e annulla le loro transazioni.
- Cambia solo chi legge. Due scritture sulla stessa riga continuano ad aspettarsi: l'UPDATE della sessione B ha aspettato come prima.

Chi blocca chi
Si chiede mentre il blocco dura:

SELECT r.session_id, r.blocking_session_id, r.wait_type,
       r.wait_time, r.wait_resource
FROM sys.dm_exec_requests AS r
WHERE r.blocking_session_id <> 0;

SELECT request_session_id, resource_type, resource_description,
       request_mode, request_status
FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID();

La prima risponde alla domanda che ci si fa davvero: blocking_session_id è la sessione che trattiene ciò che questa chiede. La seconda mostra tutta la scala: chi aggiorna una riga tiene X sulla chiave e IX sulla pagina e sulla tabella, e chi aspetta compare con request_status WAIT. A differenza di PostgreSQL, qui i lock di riga compaiono nell'elenco, uno per riga.

L'elenco dei Processi di Calíope mostra l'attesa nella colonna Stato —una sessione bloccata compare con LCK_M_S, LCK_M_U o LCK_M_X—, ma non chi la blocca: quello lo dà la prima query. E Kill Connection è qui l'unico modo di liberare il lock di un altro: KILL chiude l'intera sessione e annulla la sua transazione, perché SQL Server non permette di annullare solo la query di un'altra sessione.

UPDLOCK, il FOR UPDATE di qui
Non esiste SELECT … FOR UPDATE. Quel lavoro lo fa un hint:

BEGIN TRAN;
SELECT saldo FROM dbo.cuentas WITH (UPDLOCK, ROWLOCK) WHERE id = 1;
UPDATE dbo.cuentas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;

Prende un lock U: una lettura normale di un'altra sessione è passata senza aspettare, e un'altra lettura con UPDLOCK è rimasta in attesa. Ed evita il deadlock più sciocco di tutti: due sessioni in REPEATABLE READ che leggono la stessa riga e poi la aggiornano sono finite in un 1205; con UPDLOCK sulla lettura, sono arrivate in fondo tutte e due, una dopo l'altra.

Anche qui si aspetta per sempre
@@LOCK_TIMEOUT vale -1, cioè senza limite: come il lock_timeout di PostgreSQL, e non come i 50 s di InnoDB. Si imposta per sessione, in millisecondi:

SET LOCK_TIMEOUT 3000;

Quando scatta arriva un Msg 1222, e non annulla nulla: la transazione resta aperta e chi lo riceve deve annullarla da sé. Cosa annulla ogni errore lo racconta l'argomento «Errori (SQL Server)».

Non aspettare, apposta

SELECT saldo FROM dbo.cuentas WITH (NOWAIT) WHERE id = 3;

SELECT id FROM dbo.cuentas WITH (READPAST) WHERE id BETWEEN 1 AND 5;

NOWAIT fallisce subito con lo stesso 1222, e il batch prosegue. READPAST è lo SKIP LOCKED di qui: con la riga 3 bloccata da un'altra sessione, ha restituito 1, 2, 4 e 5. È il modo di distribuire una coda di lavoro fra più consumatori senza che si aspettino.

NOLOCK legge ciò che non è mai esistito

SELECT saldo FROM dbo.cuentas WITH (NOLOCK) WHERE id = 3;

Con un'altra sessione a metà di un UPDATE poi annullato, quella lettura ha restituito 50: un saldo mai confermato. NOLOCK è READ UNCOMMITTED, e non è «leggere più in fretta»: è leggere ciò che un'altra transazione può ancora buttare via. La documentazione aggiunge che, se le pagine si dividono durante la lettura, può saltare righe o leggerle due volte; questo non l'ho riprodotto. Se ciò che si cerca è che leggere non aspetti, la risposta è READ_COMMITTED_SNAPSHOT, che non aspetta e vede solo ciò che è confermato.

L'abbraccio mortale

-- Sessione A
BEGIN TRAN;
UPDATE dbo.cuentas SET saldo = saldo - 10 WHERE id = 1;
UPDATE dbo.cuentas SET saldo = saldo + 10 WHERE id = 2;

-- Sessione B
BEGIN TRAN;
UPDATE dbo.cuentas SET saldo = saldo - 10 WHERE id = 2;
UPDATE dbo.cuentas SET saldo = saldo + 10 WHERE id = 1;

Una delle due riceve Msg 1205 —livello 13, «… has been chosen as the deadlock victim. Rerun the transaction.»— e perde la sua transazione e il resto del batch; l'altra conferma. Non viene rilevato all'istante: in sei prove, la vittima ha impiegato fra 0,1 e 4,8 s a cadere da quando il ciclo si era chiuso. La documentazione spiega quella forbice: un monitor cerca cicli ogni 5 s, e più spesso subito dopo averne trovato uno.

Quale muore si può decidere. Con SET DEADLOCK_PRIORITY HIGH nella sessione A, la vittima è stata B tutte e tre le volte; LOW è ciò che conviene a un processo batch ripetibile. Un 1205 non è un guasto: l'applicazione deve ritentare quella transazione.

Il grafo è già salvato
Non c'è niente da accendere: la sessione Extended Events system_health, attiva di serie, conserva ogni deadlock con il suo grafo —chi è morto, quale istruzione stava eseguendo ciascuno e quale chiave di quale indice aspettava.

SELECT CAST(event_data AS xml) AS grafo
FROM sys.fn_xe_file_target_read_file('system_health*.xel', NULL, NULL, NULL)
WHERE object_name = 'xml_deadlock_report';

Due cose misurate. Il file è in ritardo: subito dopo averne provocati sei, ne aveva due; pochi secondi dopo, tutti. E il ring_buffer della stessa sessione, che li ha subito, vive in memoria: dopo un riavvio del server aveva i nuovi e aveva perso quello di prima, che il file conservava ancora.

Dalla riga alla tabella: l'escalation
Ogni lock occupa memoria, e quando un'istruzione ne accumula troppi su una tabella, SQL Server li sostituisce con uno solo sull'intera tabella. Ho aggiornato le prime N righe di una tabella di 20.000 e ho contato:
- Fino a 6.200 righe, 6.200 lock X di chiave.
- Da 6.300, uno: X sulla tabella, senza passare dalla pagina. E un'altra sessione che andava alla riga 20.000, che nessuno aveva toccato, ha aspettato fino al suo LOCK_TIMEOUT.

La cifra della documentazione è 5.000; qui il salto è arrivato un po' più tardi. Una SELECT di 8.000 righe in REPEATABLE READ è finita allo stesso modo, con un S sulla tabella. Si controlla per tabella:

ALTER TABLE dbo.cuentas SET (LOCK_ESCALATION = DISABLE);

Così lo stesso UPDATE di tutte le 20.000 righe ha tenuto 20.000 lock di chiave e nessuno di tabella. TABLE è il valore predefinito; AUTO, secondo la documentazione, fa escalation sulla partizione in una tabella partizionata. Spegnerla non è gratis —ogni lock è memoria del server—, e la via d'uscita abituale è un'altra: scrivere a blocchi di meno di 5.000 righe.

Lock applicativi
L'equivalente degli advisory lock di PostgreSQL: un nome che il server tiene perché due processi della tua applicazione non facciano la stessa cosa insieme.

DECLARE @r int;
EXEC @r = sp_getapplock @Resource = 'cierre-diario', @LockMode = 'Exclusive',
     @LockOwner = 'Session', @LockTimeout = 0;
-- …
EXEC sp_releaseapplock @Resource = 'cierre-diario', @LockOwner = 'Session';

Con la risorsa in mano a un'altra sessione ha restituito -1 invece di aspettare; 0 significa «concesso». Non solleva alcun errore: bisogna guardare il numero. Con @LockOwner = 'Transaction', il valore predefinito, si libera da solo alla fine della transazione, e chiederlo fuori da una transazione ha restituito -999.

Raccomandazione
Toccare le righe sempre nello stesso ordine e tenere le transazioni brevi, come con qualsiasi motore. Ciò che è proprio di qui sono tre cose: accendere READ_COMMITTED_SNAPSHOT appena l'applicazione lo tollera, perché è ciò che fa smettere alla lettura di aspettare la scrittura e toglie la tentazione di NOLOCK; UPDLOCK sulla lettura che finirà in un UPDATE; e il nuovo tentativo del 1205 scritto prima di andare in produzione.

Parole chiave: lock, blocco, deadlock, stallo, 1205, 1222, lck_m_s, blocking_session_id, dm_tran_locks, dm_exec_requests, read_committed_snapshot, rcsi, nolock, readpast, updlock, nowait, lock_timeout, deadlock_priority, escalation, lock_escalation, system_health, sp_getapplock

Modifiche di schema a caldo (SQL Server)

Il DDL è transazionale e ciò che costa è il lock Sch-M e la coda che forma, quali ALTER riscrivono la tabella, ONLINE, RESUMABLE e WAIT_AT_LOW_PRIORITY, e perché qui WITH NOCHECK non alleggerisce il lock.

Si applica a: SQL Server 2022+

Se vieni da MySQL, prima di tutto: qui il DDL è transazionale, come in PostgreSQL. Se vieni da PostgreSQL, cambiano il nome del lock e un paio di strumenti che lì non ci sono. Tutto ciò che racconto qui l'ho misurato contro SQL Server 2025, su una tabella di 200.000 righe.

BEGIN TRAN;
ALTER TABLE dbo.pedidos ADD moneda char(3) NULL;
CREATE INDEX ix_cliente ON dbo.pedidos (cliente);
CREATE TABLE dbo.monedas (codigo char(3) PRIMARY KEY);
EXEC sp_rename 'dbo.pedidos.nota', 'comentario', 'COLUMN';
ROLLBACK;

Dopo quel ROLLBACK non restava niente: né la colonna, né l'indice, né la tabella, e la colonna si chiamava di nuovo nota. Un'intera migrazione sta in una transazione. Le eccezioni che ho trovato: CREATE DATABASE e ALTER DATABASE (Msg 226) e un indice RESUMABLE (Msg 574) non possono starci dentro.

Ciò che costa è il Sch-M, e la coda che forma
Ogni ALTER TABLE chiede un lock di modifica dello schema, Sch-M, che è incompatibile con tutto, SELECT compresa. Il pericolo non è l'ALTER, che può durare millisecondi: è l'attesa. Con un'altra sessione a metà di una transazione che aveva toccato una riga:
- L'ALTER TABLE … ADD ha aspettato 7 s, con wait_type LCK_M_SCH_M.
- Una SELECT di un'altra riga, lanciata dietro, ha aspettato 6 s con LCK_M_SCH_S, e il suo blocking_session_id non era la transazione: era l'ALTER.

Così una modifica di un millisecondo ferma un'intera applicazione. Il rimedio è lo stesso di PostgreSQL:

SET LOCK_TIMEOUT 3000;
ALTER TABLE dbo.pedidos ADD moneda char(3) NULL;

Dopo 3 s l'ALTER ha ricevuto Msg 1222, e la SELECT dietro è passata nello stesso istante. Si riprova più tardi.

Cosa riscrive la tabella e cosa no
Misurato su 200.000 righe (1.364 pagine). Il testimone è il log delle transazioni che ogni istruzione ha scritto:

IstruzioneTempoLogRiscrive?
ADD c int NULL4 ms1 kBno
ADD c int NOT NULL DEFAULT 720 ms2 kBno
ADD c datetime2 NOT NULL DEFAULT SYSDATETIME()< 1 ms2 kBno
ADD c uniqueidentifier NOT NULL DEFAULT NEWID()259 ms38 MBsì
ALTER COLUMN estado varchar(100) (era 50)4 ms< 1 kBno
ALTER COLUMN estado varchar(20) (era 50)237 ms36 MBsì
ALTER COLUMN estado nvarchar(50) (era varchar)244 ms38 MBsì
ALTER COLUMN n bigint (era int)228 ms36 MBsì
ALTER COLUMN importe decimal(12,2) (era 10,2)99 ms1,5 kBno
ALTER COLUMN importe decimal(20,2) (era 10,2)246 ms38 MBsì
ALTER COLUMN nota varchar(200) NOT NULL (era NULL)241 ms36 MBsì
ALTER COLUMN cliente int NULL (era NOT NULL)4 ms< 1 kBno
DROP COLUMN nota5 ms1 kBno

Come leggerla:
- Un valore predefinito che è lo stesso per tutte le righe —una costante, o SYSDATETIME(), che viene valutata una volta— finisce nei metadati e non tocca le righe. NEWID() ne dà uno diverso a ogni riga, e questo obbliga a scriverle tutte.
- Allargare un varchar è gratis. Restringerlo, cambiargli famiglia (nvarchar, max), ingrandire un intero, passare a NOT NULL o cambiare la dimensione su disco di un decimal riscrivono. decimal(10,2) e decimal(12,2) occupano lo stesso spazio, 9 byte, ed è per questo che quello non ha riscritto niente.
- Ciò che riscrive lascia la tabella grande il doppio. Dopo ognuna di quelle istruzioni la tabella è passata da 1.364 a 2.723 pagine e lì è rimasta: la colonna vecchia continua a occupare il suo posto in ogni riga. Dopo int → bigint, un ALTER TABLE dbo.pedidos REBUILD l'ha riportata a 1.495. E un DROP COLUMN è istantaneo perché non libera nulla: 1.364 pagine prima e dopo, e 1.075 dopo il REBUILD.

Due ostacoli che PostgreSQL non mette:
- Una colonna indicizzata non cambia tipo. Con un indice su estado, passare a varchar(100) ha funzionato, perché non riscrive; varchar(30) e nvarchar(100) hanno dato Msg 5074 («The index 'ix_estado' is dependent on column 'estado'»). Bisogna eliminare l'indice, cambiare la colonna e ricrearlo.
- Un DEFAULT è un vincolo con un nome, e finché esiste la colonna non si può eliminare: lo stesso Msg 5074, che nomina un vincolo DF__pedidos__moneda__… battezzato da SQL Server da solo. Si elimina prima con ALTER TABLE … DROP CONSTRAINT, ed è per questo che conviene dargli un nome quando lo si crea.

La documentazione aggiunge una condizione di edizione: che un ADD COLUMN … NOT NULL DEFAULT tocchi solo i metadati è una funzione di Enterprise; in Standard riscrive la tabella. Il mio laboratorio è Developer, che ha tutto ciò che ha Enterprise, quindi questo non l'ho potuto misurare.

Indici online: ONLINE = ON

ALTER INDEX ALL ON dbo.grande REBUILD WITH (ONLINE = ON);
CREATE INDEX ix_g ON dbo.grande (g) WITH (ONLINE = ON);

Su 3.000.000 di righe la ricostruzione online è durata 47 s, e un UPDATE lanciato a metà è finito in mezzo secondo. Senza ONLINE, la tabella resta bloccata per tutto quel tempo. Anche un ALTER COLUMN lo accetta (ALTER TABLE … ALTER COLUMN n bigint NOT NULL WITH (ONLINE = ON)): ci ha messo 395 ms invece di 228, ma ha lasciato la tabella a 1.483 pagine invece di raddoppiarla.

Tre limiti:
- Con un indice spaziale sulla tabella, ALTER INDEX ALL … WITH (ONLINE = ON) fallisce con Msg 153, e nemmeno l'indice spaziale stesso si ricostruisce online. La chiave primaria e gli indici normali, uno alla volta, sì.
- ONLINE non vuol dire senza lock: all'inizio chiede un lock S sulla tabella e, secondo la documentazione, un breve Sch-M alla fine. Con una transazione aperta sulla tabella, il REBUILD online è rimasto ad aspettare il suo S, e un UPDATE arrivato dietro ha aspettato 6,6 s in coda (LCK_M_IX). Una SELECT invece è passata.
- Secondo la documentazione, ONLINE = ON è di Enterprise: in Standard dà errore.

WAIT_AT_LOW_PRIORITY: aspettare senza formare coda
Questo PostgreSQL non ce l'ha. La ricostruzione aspetta il suo lock in una coda a parte, senza intralciare chi arriva dietro:

ALTER INDEX ALL ON dbo.pedidos REBUILD WITH (ONLINE = ON (
    WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF)));

Con la stessa transazione aperta, l'UPDATE dietro è passato in 0,5 s, e il REBUILD risultava in attesa su LCK_M_S_LOW_PRIORITY. Trascorso MAX_DURATION, decide ABORT_AFTER_WAIT:
- SELF: il REBUILD rinuncia con Msg 1222 e la transazione va avanti. È la scelta prudente.
- BLOCKERS: dopo il minuto, la sessione che bloccava è stata disconnessa («… disconnected because of a high priority DDL operation») e la sua transazione annullata; il REBUILD è arrivato in fondo.
- NONE: aspetta a bassa priorità senza limite.

La accetta ALTER INDEX … REBUILD e, sul mio 2025, anche CREATE INDEX … WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY …)). ALTER TABLE no: con ADD, ALTER COLUMN o ADD CONSTRAINT è un errore di sintassi (Msg 155). Per quelli resta solo LOCK_TIMEOUT.

Indici ripristinabili: RESUMABLE = ON

CREATE INDEX ix_relleno ON dbo.grande (relleno, g) WITH (ONLINE = ON, RESUMABLE = ON);
-- da un'altra sessione:
ALTER INDEX ix_relleno ON dbo.grande PAUSE;
SELECT name, state_desc, percent_complete FROM sys.index_resumable_operations;
ALTER INDEX ix_relleno ON dbo.grande RESUME;

Ho messo in pausa la creazione dopo 4 s: la sessione che l'aveva lanciata è stata disconnessa (Msg 1219), sys.index_resumable_operations diceva PAUSED e 5 %, e l'indice non esisteva ancora —non compariva in sys.indexes—. RESUME ha finito il resto in 45 s. Serve a distribuire un indice enorme su più finestre di manutenzione. Richiede ONLINE = ON (senza, Msg 11438), non può stare in una transazione (Msg 574) e, secondo la documentazione, da SQL Server 2022 vale anche per ALTER TABLE … ADD CONSTRAINT di una chiave primaria o univoca; su 2025 l'ho verificato con una univoca.

WITH NOCHECK: qui non alleggerisce il lock
Lo schema NOT VALID + VALIDATE di PostgreSQL qui ha il suo gemello, ma non fa la stessa cosa. Su 3.000.000 di righe:

ALTER TABLE dbo.grande WITH NOCHECK ADD CONSTRAINT ck_id CHECK (id > 0);  -- 13 ms
ALTER TABLE dbo.grande WITH CHECK CHECK CONSTRAINT ck_id;                 -- 346 ms

Aggiungerlo in un colpo solo ha richiesto 375 ms. E tutti e tre hanno preso Sch-M sulla tabella, validazione compresa: dividere in due distribuisce la scansione, ma per la seconda metà non c'è un lock più debole, come invece c'è in PostgreSQL.

E lascia una trappola: un vincolo aggiunto WITH NOCHECK resta non attendibile (is_not_trusted = 1), e l'ottimizzatore non si fida di lui. Con un CHECK (importe >= 0) attendibile, WHERE importe < 0 non ha nemmeno letto la tabella; non attendibile, 1.364 letture logiche. Succede lo stesso disattivandolo e riattivandolo senza WITH CHECK —ALTER TABLE … CHECK CONSTRAINT ck_importe l'ha lasciato non attendibile—, che è ciò che fanno spesso i caricamenti massivi. Per trovarli:

SELECT name FROM sys.check_constraints WHERE is_not_trusted = 1;
SELECT name FROM sys.foreign_keys WHERE is_not_trusted = 1;

In Calíope
L'operazione OPTIMIZE di Manutenzione tabelle è, in SQL Server, un ALTER INDEX ALL … REBUILD senza ONLINE: blocca la tabella mentre ricostruisce. Su una tabella grande in produzione, è meglio scrivere nell'editor il REBUILD WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY …)).

Raccomandazione
SET LOCK_TIMEOUT prima di ogni ALTER TABLE; la migrazione dentro una transazione; ONLINE = ON con WAIT_AT_LOW_PRIORITY … SELF per gli indici; un nome proprio per ogni DEFAULT; e attenzione ai cambi di tipo, che riscrivono la tabella e la lasciano grande il doppio fino al prossimo REBUILD. Se devi restringere una colonna grande o cambiarle famiglia, di solito conviene aggiungere quella nuova, copiare a blocchi e rinominare con sp_rename.

Parole chiave: ddl, alter table, migrazione, transazionale, rollback, sch-m, lck_m_sch_m, lock_timeout, online, resumable, wait_at_low_priority, abort_after_wait, rebuild, riscrittura, add column, alter column, drop column, with nocheck, is_not_trusted, sp_rename

Partizionamento (SQL Server)

Funzione e schema di partizione, cosa elimina davvero l'ottimizzatore, perché un indice univoco deve contenere la chiave, SWITCH e TRUNCATE per eliminare dati in pochi millisecondi, il CHECK attendibile che serve per caricare dati, e SPLIT e MERGE, gratuiti solo su una partizione vuota.

Si applica a: SQL Server 2022+

Se vieni da MySQL o da PostgreSQL, cambia la forma: qui una partizione non è una tabella ma un pezzo di tabella, e la suddivisione la descrivono due oggetti creati prima della tabella. Tutto quello che racconto qui l'ho misurato su SQL Server 2025, con 300.000 vendite distribuite su tre anni.

CREATE PARTITION FUNCTION pf_anio (date)
    AS RANGE RIGHT FOR VALUES ('2024-01-01', '2025-01-01', '2026-01-01');

CREATE PARTITION SCHEME ps_anio
    AS PARTITION pf_anio ALL TO ([PRIMARY]);

CREATE TABLE dbo.ventas (
    id bigint IDENTITY NOT NULL,
    creado date NOT NULL,
    cliente int NOT NULL,
    importe decimal(10,2) NOT NULL,
    CONSTRAINT pk_ventas PRIMARY KEY CLUSTERED (creado, id)
) ON ps_anio (creado);

La funzione fissa i confini e lo schema dice in quale filegroup vive ogni pezzo. Tre confini danno quattro partizioni: tutto ciò che precede il 2024, un anno in ciascuna delle due successive, e tutto dal 2026 in poi. Non ci sono LIST né HASH: solo intervalli. E non serve prevedere il futuro: ciò che cade prima del primo confine o dopo l'ultimo ha sempre un posto.

RANGE RIGHT o RANGE LEFT: da che parte cade il confine
Con RIGHT, il confine appartiene alla partizione alla sua destra, ed è la scelta naturale con le date: $PARTITION.pf_anio('2023-12-31') ha restituito 1, e '2024-01-01' 2. Con LEFT e gli stessi confini, il 1° gennaio è finito nella partizione del 2023. Non fallisce niente: i dati di un giorno finiscono semplicemente nell'anno sbagliato. $PARTITION serve anche per contare:

SELECT $PARTITION.pf_anio(creado) AS particion, COUNT(*) AS filas
FROM dbo.ventas GROUP BY $PARTITION.pf_anio(creado);

L'eliminazione delle partizioni è ciò che si cerca
Il piano effettivo (SET STATISTICS XML ON, in RunTimePartitionSummary) dice quante partizioni ha letto ogni query:

FiltroPartizioniLetture
creado BETWEEN '2024-03-01' AND '2024-03-31'138
lo stesso intervallo con una variabile o un parametro138
cliente = 424 su 4—
YEAR(creado) = 20244 su 41.238
CAST(creado AS datetime) tra due date4 su 446

Due cose da leggere lì:
- Anche con variabili e parametri l'eliminazione avviene, perché si decide in esecuzione, non in compilazione. Per questo il piano stimato non dà un numero: mostra [PtnId1000] >= RangePartitionNew(…) quando la chiave è nel filtro, e [PtnId1000] >= (1) AND [PtnId1000] <= (4) quando non c'è.
- Una funzione sulla colonna la nasconde, come la nasconde a un indice: YEAR(creado) ha letto tutte e quattro. L'intervallo scritto a mano (>= '2024-01-01' AND < '2025-01-01') ne legge una.

La regola di progetto è la stessa degli altri motori: la chiave di partizione è la colonna su cui filtri quasi sempre. Se le query non la nominano, partizionare non risparmia letture: le distribuisce.

Indici allineati, e l'unicità
Un indice creato senza ON eredita lo schema della tabella: è allineato, con un pezzo per partizione. La conseguenza è quella di PostgreSQL:

CREATE UNIQUE INDEX ux_id ON dbo.ventas (id);

ha restituito Msg 1908: la colonna di partizione deve stare nella chiave di un indice univoco. Qui c'è una via d'uscita che PostgreSQL non ha —crearlo non allineato, ON [PRIMARY]—, ma ha un prezzo: con un indice così, lo SWITCH qui sotto smette di funzionare (Msg 7733). Per questo la chiave primaria di dbo.ventas è (creado, id) e non (id).

SWITCH e TRUNCATE: eliminare un anno in pochi millisecondi
Cancellare tutto il 2023, 100.009 righe, misurato dentro una transazione:

OperazioneTempoLog
DELETE … WHERE creado < '2024-01-01'180 ms21 MB
TRUNCATE TABLE dbo.ventas WITH (PARTITIONS (1))4,9 ms24 kB
ALTER TABLE dbo.ventas SWITCH PARTITION 1 TO dbo.ventas_2023< 1 ms1,7 kB

SWITCH non sposta dati: cambia proprietario a un pezzo intero. Per questo chiede molto alla tabella di destinazione, e ogni requisito ha il suo errore:
- Vuota: con una riga, Msg 4905.
- Stesse colonne e stessi indici: con una colonna in più, Msg 4943.
- E, secondo la documentazione, nello stesso filegroup della partizione.

Per caricare dati serve un CHECK attendibile
Ecco la differenza con PostgreSQL, dove il CHECK risparmia solo una scansione. Rimettere la tabella nella sua partizione senza di esso non richiede più tempo: viene rifiutato, con Msg 4982. Con il vincolo, è entrata in 9,5 ms:

ALTER TABLE dbo.ventas_2023 WITH CHECK
    ADD CONSTRAINT ck_2023 CHECK (creado < '2024-01-01');
ALTER TABLE dbo.ventas_2023 SWITCH TO dbo.ventas PARTITION 1;

E deve essere attendibile: disattivato e riattivato senza WITH CHECK —quello che fanno molti caricamenti massivi—, lo SWITCH ha restituito Msg 4972. Dopo WITH CHECK CHECK CONSTRAINT, è entrata. Così si carica un periodo nuovo senza toccare la tabella viva: si riempie una tabella a parte, le si mette il suo CHECK e si fa lo SWITCH.

SPLIT e MERGE: gratuiti solo su una partizione vuota
I confini si spostano con la funzione:

ALTER PARTITION SCHEME ps_anio NEXT USED [PRIMARY];
ALTER PARTITION FUNCTION pf_anio() SPLIT RANGE ('2027-01-01');
OperazioneTempoLog
SPLIT sulla partizione vuota< 1 ms3,5 kB
SPLIT RANGE ('2024-07-01') su quella del 2024, piena347 ms11 MB
MERGE di quelle due metà167 ms6,2 MB
MERGE con la partizione vuota< 1 ms3,3 kB

Dividere una partizione piena sposta le righe da un lato del confine, e ognuna scrive nel log: il costo cresce con ciò che si sposta. L'abitudine sana è avere sempre una partizione vuota in fondo e dividerla prima che arrivino i dati del periodo successivo. E ogni SPLIT consuma il NEXT USED: senza dichiararlo di nuovo, il successivo ha restituito Msg 7710 e non ha cambiato niente.

Manutenzione per partizione
Si ricostruisce solo la partizione che cambia:

ALTER INDEX pk_ventas ON dbo.ventas REBUILD PARTITION = 2 WITH (ONLINE = ON);

33 ms per una partizione (148 ms online) contro 214 ms per tutte e quattro. Un anno chiuso non riceve più scritture: non c'è motivo di ricostruirlo ogni notte.

L'escalation dei lock, per partizione
Di default (LOCK_ESCALATION = TABLE), un UPDATE di 16.440 righe del 2024 è salito a un lock X su tutta la tabella, e aggiornare una riga del 2025 da un'altra sessione ha restituito Msg 1222. Con

ALTER TABLE dbo.ventas SET (LOCK_ESCALATION = AUTO);

l'escalation si è fermata alla partizione (un X su HOBT), e la riga del 2025 è stata aggiornata in 9 ms. La documentazione avverte che AUTO può portare deadlock tra sessioni che salgono su partizioni diverse; il resto dell'escalation è in «Lock e deadlock (SQL Server)».

Anche SWITCH prende Sch-M
Con una transazione aperta che aveva toccato una sola riga, lo SWITCH è rimasto in attesa del suo Sch-M, e una SELECT di un'altra riga, lanciata dopo, ha aspettato in coda dietro lo SWITCH. Il rimedio è quello degli indici:

ALTER TABLE dbo.ventas SWITCH PARTITION 1 TO dbo.ventas_2023
    WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));

La SELECT dietro è terminata in mezzo secondo, e lo SWITCH ha rinunciato dopo 61 s con Msg 1222, senza toccare la transazione. Il resto di quella coda è in «Modifiche di schema a caldo (SQL Server)».

Limiti ed edizioni
Una funzione ammette 15.000 partizioni: con 15.000 confini, Msg 7719. Secondo la documentazione, il partizionamento è in tutte le edizioni da SQL Server 2016 SP1, Express compresa.

In Calíope
Le righe e la dimensione di una tabella partizionata sommano tutte le sue partizioni. Il CREATE TABLE che scrive Calíope —quello che mostra come DDL della tabella e quello che salva Backup— è preceduto dalla funzione e dallo schema di partizione, ciascuno con IF NOT EXISTS, e termina con ON ps_anio (creado); un indice non allineato, come una chiave primaria non cluster su [PRIMARY], conserva il proprio ON. Ripristinare quel backup in un database vuoto lo lascia partizionato allo stesso modo, con ogni riga nella sua partizione; se la destinazione ha già una funzione con quel nome, si usa quella che c'è, con i suoi limiti. Confronta schemi guarda eccome il partizionamento —dove sta la tabella e i limiti di ogni schema—, ma il suo script non partiziona né toglie le partizioni a una tabella che esiste già: vorrebbe dire ricostruire il suo indice cluster con i dati dentro, e lo lascia scritto come nota perché lo faccia tu. Il piano che mostra Calíope è quello stimato, quindi per le partizioni dà la forma PtnId1000 vista sopra; quante ne ha lette davvero lo dice solo il piano effettivo, SET STATISTICS XML ON.

Raccomandazione
Partiziona in base a ciò che cancellerai: il vero guadagno è eliminare un periodo con SWITCH o TRUNCATE … WITH (PARTITIONS) invece di un DELETE che riempie il log. Chiave primaria e indici univoci con dentro la chiave di partizione, perché tutto resti allineato; RANGE RIGHT con le date; sempre una partizione vuota in fondo, per dividerla gratis; e verifica nel piano effettivo che le tue query eliminino partizioni, invece di darlo per scontato.

Parole chiave: partizionamento, partition, partition function, partition scheme, range right, range left, $partition, eliminazione delle partizioni, allineato, switch, truncate, split range, merge range, next used, lock_escalation, 1908, 4982, 7733

Prestazioni e diagnosi (SQL Server)

Letture logiche invece dei tempi, il piano stimato contro quello reale, il parameter sniffing e i suoi rimedi misurati, la cache dei piani che legge Query lente, Query Store acceso di fabbrica e le attese che dicono davvero qualcosa.

Si applica a: SQL Server 2022+

Se vieni da MySQL o da PostgreSQL, qui cambiano tre cose: il numero che si confronta sono le letture logiche, non il tempo; il piano viene messo in cache e riutilizzato con altri valori, ed è la causa di metà dei «ieri andava bene»; e l'equivalente di pg_stat_statements c'è già senza accendere niente, ma si perde al riavvio. Tutto quello che racconto qui l'ho misurato su SQL Server 2025, con 200.000 ordini.

Misurare una query: le letture logiche

SET STATISTICS IO, TIME ON;
SELECT COUNT(*), SUM(importe) FROM dbo.pedidos WHERE cliente_id = 4243;
SET STATISTICS IO, TIME OFF;

Ogni lettura logica è una pagina da 8 kB letta dalla memoria. È il numero da confrontare, perché il tempo cambia con il carico e con la cache, e lei no. Per i 20 ordini di un cliente:
- Senza indice: 6.085 letture logiche —la tabella intera— e 12 ms.
- Con un indice su cliente_id: 62 letture.

In Calíope quelle righe non compaiono: il server le manda come messaggi informativi, e l'editor mostra righe ed errori, non messaggi. Lo stesso numero si legge dalla cache dei piani, subito dopo aver eseguito la query:

SELECT TOP (5) qs.last_logical_reads, qs.last_elapsed_time AS micros, st.text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE st.text LIKE N'%cliente_id = 4243%'
ORDER BY qs.last_execution_time DESC;

Mi ha restituito 62, come STATISTICS IO.

Il piano stimato e quello reale
SET SHOWPLAN_XML ON chiede il piano senza eseguire la query. SET STATISTICS XML ON la esegue e aggiunge quello che è successo: ActualRows accanto a EstimateRows su ogni operatore. Se l'istruzione scrive, si avvolge in BEGIN TRAN … ROLLBACK, perché viene eseguita davvero.

Visual EXPLAIN, in Calíope, chiede quello stimato: l'albero degli operatori con le righe stimate, senza righe reali, senza tempi e senza costo. Serve a vedere la forma, un Index Scan dove ti aspettavi un Seek; per confrontare la stima con la realtà serve il piano reale.

Il parameter sniffing
La prima volta che una procedura o una query parametrizzata viene eseguita, l'ottimizzatore guarda il valore che porta e compila il piano per quello. Quel piano resta in cache, e le esecuzioni successive lo riutilizzano anche quando portano un altro valore. L'ho riprodotto con una procedura e due clienti: uno con 20 ordini e uno con 100.000.

CREATE OR ALTER PROCEDURE dbo.pedidos_de @cliente int AS
  SELECT SUM(importe) AS total, COUNT(*) AS n
  FROM dbo.pedidos WHERE cliente_id = @cliente;
Compilato conCliente piccoloCliente grande
il piccolo62 letture300.177 letture, 99 ms
il grande6.085 letture6.085 letture

Compilato per il cliente piccolo, il piano cerca nell'indice e va alla tabella riga per riga: perfetto per 20 righe, e 300.000 letture per 100.000, cinquanta volte più che scorrere la tabella intera. Compilato al contrario, il piccolo scorre la tabella intera. Decide l'ordine di arrivo, e un riavvio o un cambio di statistiche lo inverte: da qui il «ieri andava bene».

Il piano reale lo tradisce senza indovinare: nel caso cattivo diceva EstimateRows="20" e ActualRows="100000", e tra i suoi parametri ParameterCompiledValue="(4243)" accanto a ParameterRuntimeValue="(1)".

Quello che ho provato per curarlo:
- OPTION (RECOMPILE) in fondo alla query: 62 e 6.085, ogni valore con il suo piano. Il prezzo è compilare a ogni chiamata (qui 2-3 ms), che per una query eseguita migliaia di volte al secondo non è gratis.
- OPTION (OPTIMIZE FOR UNKNOWN): compila per il valore medio, circa 20 righe, e ha dato 70 e 306.429. Quando c'è un valore enorme, non aiuta.
- EXEC sp_recompile 'dbo.pedidos_de' butta via il piano della procedura: è il rimedio del momento, non la cura.
- Da SQL Server 2022, con livello di compatibilità 160 o superiore, il server può tenere più piani per la stessa query a seconda del valore. È acceso di default, ma con 100.000 righe contro 20 non è intervenuto. Con 200.000 contro 1, sì: due piani. Non contarci.

Quale query costa di più: la cache dei piani
Il server tiene il conto di ogni piano che ha in cache: esecuzioni, tempo, letture e righe. query_hash raggruppa per la forma della query: cinque SELECT che cambiavano solo il numero di cliente, ognuno da solo, hanno lasciato cinque piani in cache (80 kB) e un solo query_hash.

SELECT TOP (20) CONVERT(varchar(20), qs.query_hash, 1) AS forma,
       SUM(qs.execution_count) AS ejecuciones,
       SUM(qs.total_elapsed_time) / 1000 AS ms_total,
       SUM(qs.total_logical_reads) AS lecturas
FROM sys.dm_exec_query_stats AS qs
GROUP BY qs.query_hash
ORDER BY SUM(qs.total_elapsed_time) DESC;

È quello che Calíope mostra in Query lente, vista Per istruzione: una riga per forma, con il testo del suo piano più caro, da tutti i database del server. Serve il permesso VIEW SERVER STATE. E non tiene storia: ho riavviato il server e la vista è passata da 23 righe a 0. Né sopravvive all'uscita del piano dalla cache.

Query Store, che è già acceso
Query Store è la versione che resta: conserva testo, piani e numeri per intervalli dentro il database stesso. In SQL Server 2025 l'ho trovato acceso (READ_WRITE) in demo e in un database appena creato, senza che nessuno l'avesse chiesto. Secondo la documentazione, è così per i database nuovi da SQL Server 2022.

SELECT TOP (20) q.query_id, SUM(rs.count_executions) AS ejecuciones,
       SUM(rs.avg_duration * rs.count_executions) / 1000 AS ms_total,
       COUNT(DISTINCT p.plan_id) AS planes, qt.query_sql_text
FROM sys.query_store_query_text AS qt
JOIN sys.query_store_query AS q ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
GROUP BY q.query_id, qt.query_sql_text
ORDER BY ms_total DESC;

Una colonna planes maggiore di 1 è l'impronta del parameter sniffing. Due sfumature misurate: dopo il riavvio c'erano ancora i testi delle sue 11 query, ma non i numeri di prima, perché li scrive su disco ogni 900 secondi; ed è per database, non per server.

Calíope non lo usa: Query lente legge la cache dei piani, che c'è già per tutto il server. Non lo accende né lo spegne, perché è un ALTER DATABASE di amministrazione.

Le attese: dove va il tempo
sys.dm_os_wait_stats somma, dall'ultimo riavvio, quanto ha aspettato il server e perché. Ha 1.547 tipi, e i primi della lista, senza filtro, sono thread che aspettano lavoro: DISPATCHER_QUEUE_SEMAPHORE, BROKER_TASK_STOP, i SLEEP_…. Vanno scartati. Quelli che dicono davvero qualcosa:
- LCK_M_…: lock. Ne parlo in «Lock e deadlock (SQL Server)».
- PAGEIOLATCH_…: attesa di pagine dal disco. Manca memoria o manca un indice.
- WRITELOG: attesa del log delle transazioni a ogni commit.
- CXPACKET e CXCONSUMER: parallelismo.
- SOS_SCHEDULER_YIELD: CPU.
- RESOURCE_SEMAPHORE: query che aspettano memoria per ordinare o per un hash.

Sono cumulativi, quindi una foto sola serve a poco: se ne prendono due e si sottrae. Dopo il mio riavvio, la somma è scesa da 12,1 a 0,7 milioni di ms.

Parallelismo
Di fabbrica, cost threshold for parallelism vale 5 e max degree of parallelism 0 (tutti i core). La soglia è così bassa che una scansione media può già andare in parallelo: su un'altra tabella da 200.000 righe, una ha usato 16 thread, 2.094 ms di CPU per 152 ms di orologio. Un CXPACKET alto non è un errore; è il segno che quei due valori sono rimasti come sono arrivati.

Quello che è già in altri argomenti
- Un parametro nvarchar contro una colonna varchar con collazione SQL_ passa da 3 letture a 334: «Codifica e collazioni (SQL Server)».
- Key Lookup, quando il piano lascia l'indice per la tabella, YEAR(fecha) contro l'intervallo, e gli indici che al server mancano: «Indici (SQL Server)».
- Un vincolo CHECK non attendibile l'ottimizzatore non lo usa (1.364 letture contro nessuna): «Modifiche di schema a caldo (SQL Server)».
- Quante partizioni ha letto una query lo dice solo il piano reale: «Partizionamento (SQL Server)».

Raccomandazione
L'ordine che funziona: trovare la query in Query lente (o in Query Store, se serve la storia), chiedere il suo piano reale con SET STATISTICS XML ON e confrontare EstimateRows con ActualRows. Se divergono e il valore compilato non è quello di adesso, è parameter sniffing; se divergono con lo stesso valore, sono le statistiche; se coincidono e si legge comunque molto, manca un indice. Toccare la memoria o il parallelismo prima di aver letto un piano è la strada lunga.

Parole chiave: prestazioni, statistics io, statistics time, letture logiche, showplan_xml, statistics xml, piano di esecuzione, piano reale, parameter sniffing, rilevamento dei parametri, recompile, optimize for unknown, sp_recompile, dm_exec_query_stats, query_hash, cache dei piani, query store, dm_os_wait_stats, attese, parallelismo

Backup e ripristino (SQL Server)

I tre modelli di recupero e quale lascia crescere il log, BACKUP DATABASE e BACKUP LOG sul disco del server, la catena del log, tornare al minuto prima del DELETE con STOPAT, COPY_ONLY, e in cosa si distingue il backup di Calíope.

Si applica a: SQL Server 2022+

Se vieni da MySQL, qui non ci sono né mysqldump né un binlog a parte: il backup fisico è un'istruzione T-SQL, e la storia che permette di andare avanti da lì è il log delle transazioni di ogni database. Se vieni da PostgreSQL, la differenza è l'unità: si fa il backup e il ripristino di un database, non del server intero. Tutto quello che racconto qui l'ho misurato su SQL Server 2025.

Il modello di recupero decide quasi tutto
È una proprietà di ogni database, e ce ne sono tre:

ModelloCosa conserva il logRecupero a un istante
SIMPLESi svuota da solo a ogni CHECKPOINTNo: solo all'ultimo backup
FULLTutto, finché non ne fai il backup con BACKUP LOGSì
BULK_LOGGEDTutto tranne i caricamenti massivi, che annota per pagineSì, tranne dentro un caricamento

Un database nuovo copia il modello di model, che su questo server è FULL:

SELECT name, recovery_model_desc, log_reuse_wait_desc
FROM sys.databases;

FULL senza backup del log riempie il disco
Ecco la trappola. Finché il database non ha il suo primo backup completo, FULL si comporta come SIMPLE: ho inserito 20 000 righe da 2 kB, il log è arrivato a 42,2 MB usati e un CHECKPOINT l'ha riportato a 8,1 MB. Dopo il primo BACKUP DATABASE, la stessa cosa non ha più liberato nulla: 55,4 MB, poi 102,8 MB, e il file è cresciuto da 72 a 136 MB. log_reuse_wait_desc dice perché non viene riutilizzato:

- LOG_BACKUP — aspetta un BACKUP LOG. È il motivo di quasi tutti i dischi pieni.
- ACTIVE_TRANSACTION — una transazione aperta che nessuno chiude.
- CHECKPOINT — niente da temere, si libera al prossimo.

BACKUP LOG ha riportato lo spazio usato a 15,1 MB, ma il file è rimasto a 136 MB: fare il backup del log libera spazio al suo interno, non lo restituisce al disco. Se un database è in FULL e nessuno pianifica il BACKUP LOG, o lo si pianifica o si passa a SIMPLE.

Fare il backup

BACKUP DATABASE tienda TO DISK = '/var/opt/mssql/backup/tienda_full.bak'
    WITH CHECKSUM, INIT;

BACKUP DATABASE tienda TO DISK = '/var/opt/mssql/backup/tienda_dif.bak'
    WITH DIFFERENTIAL, CHECKSUM, INIT;

BACKUP LOG tienda TO DISK = '/var/opt/mssql/backup/tienda_0930_1200.trn'
    WITH CHECKSUM, INIT;

Il percorso è sul disco del server, non sul tuo: il file lo scrive il processo di SQL Server. Su Linux la cartella abituale è /var/opt/mssql/backup/; nel container di prova non esisteva, e il primo BACKUP l'ha creata.

- Il completo copia l'intero database: 150 MB nella mia prova.
- Il differenziale copia ciò che è cambiato dall'ultimo completo: 3,1 MB dopo aver modificato 2 000 righe.
- Quello del log copia il log dal precedente, ed è quello che permette di arrivare a un istante.

CHECKSUM non è attivo di default (backup checksum default vale 0), e si nota in verifica: RESTORE VERIFYONLY … WITH CHECKSUM su un backup fatto senza ha dato Msg 3187. Mettilo sempre, e verifica:

RESTORE VERIFYONLY FROM DISK = '/var/opt/mssql/backup/tienda_full.bak'
    WITH CHECKSUM;

La catena del log
Ogni backup del log comincia dove è finito il precedente, e si ripristinano tutti, in ordine. Saltarne uno ha dato Msg 4305, con l'LSN mancante. Due cose la spezzano senza avvisare:

- Passare a SIMPLE e tornare a FULL: dopo, BACKUP LOG ha dato Msg 4214 fino al completo successivo, anche se ce n'era uno di prima.
- Un BACKUP LOG fatto da qualcun altro, verso un file che non conosci. La cronologia è in msdb:

SELECT b.type, b.is_copy_only, b.first_lsn, b.last_lsn,
       b.backup_finish_date, m.physical_device_name
FROM msdb.dbo.backupset b
JOIN msdb.dbo.backupmediafamily m ON m.media_set_id = b.media_set_id
WHERE b.database_name = 'tienda'
ORDER BY b.backup_start_date;

COPY_ONLY: la copia che non disturba
Un completo normale diventa la base dei differenziali successivi. Se ne fai uno isolato —per portare il database su un'altra macchina—, il differenziale di stanotte non combacia più con il completo di domenica. Con WITH COPY_ONLY non succede: il differenziale successivo ha continuato a puntare al completo precedente, e ripristinarlo sopra il COPY_ONLY ha dato Msg 3136. Ogni copia fuori dalla routine, con COPY_ONLY.

Tornare al minuto prima del DELETE
L'ho riprodotto per intero: 500 ordini, un DELETE senza WHERE quattro secondi dopo, e il recupero in un database nuovo, senza toccare l'originale:

RESTORE DATABASE tienda_rec FROM DISK = '/var/opt/mssql/backup/tienda_full.bak'
    WITH NORECOVERY,
    MOVE 'tienda' TO '/var/opt/mssql/data/tienda_rec.mdf',
    MOVE 'tienda_log' TO '/var/opt/mssql/data/tienda_rec_log.ldf';

RESTORE LOG tienda_rec FROM DISK = '/var/opt/mssql/backup/tienda_log1.trn'
    WITH NORECOVERY;

RESTORE LOG tienda_rec FROM DISK = '/var/opt/mssql/backup/tienda_log2.trn'
    WITH STOPAT = '2026-09-30T17:50:09.926', RECOVERY;

Sono tornati tutti e 500. Quello che serve sapere:

- NORECOVERY lascia il database in RESTORING, in attesa del resto: leggerlo dà Msg 927. L'ultimo passo porta RECOVERY.
- MOVE è obbligatorio quando si ripristina con un altro nome, altrimenti va in conflitto con i file dell'originale. I loro nomi logici li dà RESTORE FILELISTONLY.
- STOPAT è l'ora del server, con tre decimali: con sette ha dato Msg 3217.
- Ripristinare sopra il database vivo senza prima fare il backup della coda del log ha dato Msg 3159. La coda è ciò che è successo dall'ultimo BACKUP LOG; se ne fa il backup con BACKUP LOG … WITH NORECOVERY, che in più lascia il database in RESTORING.

BULK_LOGGED e il suo prezzo
Un SELECT … INTO di una tabella da 21 MB ha scritto 22,1 MB di log in FULL e 1,0 MB in BULK_LOGGED. Ma il backup del log successivo ha pesato uguale, 22,1 MB, perché si porta dietro le pagine caricate; e ripristinare quel backup con STOPAT ha dato Msg 4341: dentro un intervallo con un caricamento massivo si può arrivare solo alla fine, non a un istante. Si passa a BULK_LOGGED per il caricamento e si torna a FULL subito dopo, con un BACKUP LOG da ogni lato.

Ripristinare su un altro server
Un backup di database porta con sé i suoi utenti, ma non i login del server, che vivono in master. Ripristinato su un'altra macchina, ogni utente resta orfano del suo login; come trovarli e ricollegarli è spiegato nell'argomento sulla sicurezza di SQL Server.

Cosa fa Calíope
Backup scrive uno script di testo, non un .bak: istruzioni con GO tra i batch, righe in INSERT da mille, date in ISO 8601 e binari come 0x…, perché torni uguale su un altro server, un'altra versione o con la sessione in un'altra lingua. Con Includi le password degli utenti, i login partono come CREATE LOGIN … WITH PASSWORD = 0x… HASHED, e l'utente di ogni database come CREATE USER … FOR LOGIN: ripristinando lo script, nessuno resta orfano. Una tabella partizionata torna partizionata: lo script crea prima, se non esistono, la sua funzione e il suo schema di partizione.

Dall'Editor SQL puoi lanciare un BACKUP DATABASE —è un'istruzione come un'altra—, ma il file resta sul disco del server: Calíope non lo porta qui.

BACKUP DATABASEBackup di Calíope
Cos'èLe pagine del databaseUno script SQL
Dove finisceSul disco del serverDove scegli tu
Si ripristina suLa stessa versione o una più recente, secondo la documentazioneUn altro server o un'altra versione, e si può leggere
A un istanteSì, con il logNo: è il momento del dump
Dimensione e velocitàVeloce a qualsiasi dimensioneLento sui database grandi

Raccomandazione
Decidi il modello per ogni database: SIMPLE dove perdere il lavoro di oggi è accettabile, e FULL con un BACKUP LOG pianificato dove non lo è. Completo settimanale, differenziale giornaliero e log ogni pochi minuti è la routine di sempre, sempre WITH CHECKSUM. Tieni d'occhio log_reuse_wait_desc, fai ogni copia isolata con COPY_ONLY, e ripristina davvero, con un altro nome, ogni tanto: un backup mai ripristinato è un'intenzione. Il backup di Calíope serve a portare un database, o una parte, su un'altra macchina; non sostituisce i backup del server.

Parole chiave: backup, restore, ripristino, recupero, modello di recupero, full, simple, bulk_logged, log delle transazioni, log_reuse_wait_desc, backup log, catena del log, norecovery, stopat, copy_only, differenziale, checksum, verifyonly, punto nel tempo, 3159, 4214, 4341

Login, utenti e permessi (SQL Server)

Login di server e utente di database, perché un utente resta orfano dopo il ripristino su un altro server e come ricollegarlo, concedere per schema, DENY e chi lo scavalca, la catena di proprietà, gli utenti indipendenti, la sicurezza a livello di riga e cosa fare con sa.

Si applica a: SQL Server 2022+

Se vieni da MySQL, qui l'account non porta dentro l'host e, in più, sono due cose: il login, che è del server e con cui si entra, e l'utente, che è di ogni database e che riceve i permessi. Se vieni da PostgreSQL, è come se ogni ruolo con LOGIN dovesse esistere un'altra volta dentro ogni database per poterlo usare. Tutto quello che racconto qui l'ho misurato su SQL Server 2025.

Login e utente

CREATE LOGIN ana WITH PASSWORD = 'Xq7#lunes_pw9';   -- server: entrare
USE tienda;
CREATE USER ana FOR LOGIN ana;                    -- database: starci dentro

Con il login e senza l'utente si entra nel server ma non nel database: connettersi nominandolo ha dato «Cannot open database … The login failed» (4060), e un USE da master ha dato Msg 916. L'utente è legato al suo login dal sid, non dal nome: è da lì che nascono gli orfani.

La password passa per la policy (CHECK_POLICY, attiva di default): otto caratteri di tre classi, e senza il nome del login dentro. 'Xq7#ana_pw9' per il login ana ha dato Msg 33064, anche se rispetta il resto.

Utenti orfani: ripristinare su un altro server
L'ho riprodotto: ho fatto il backup di un database con l'utente ana, ho eliminato il login e l'ho ricreato con lo stesso nome —come sarebbe su un'altra macchina—, e ho ripristinato il database. ana non riusciva più a entrare nella copia: il suo utente conservava il sid vecchio, e il login nuovo ne aveva un altro. Ricrearlo non funzionava nemmeno: Msg 15023, l'utente esiste già. Trovarli:

SELECT dp.name, dp.type_desc
FROM sys.database_principals dp
LEFT JOIN sys.server_principals sp ON sp.sid = dp.sid
WHERE dp.type IN ('S', 'U', 'G')
  AND dp.authentication_type_desc = 'INSTANCE'
  AND sp.sid IS NULL;

E ricollegarli, senza perdere i loro permessi:

ALTER USER ana WITH LOGIN = ana;

Altre due cose. DROP LOGIN non controlla se il login ha utenti: l'ha eliminato senza un avviso e ha lasciato orfano l'utente nel database originale, sullo stesso server. E perché non succeda quando si porta un database su un'altra macchina, il login si crea lì con lo stesso sid (CREATE LOGIN ana WITH PASSWORD = …, SID = 0x1C2F…): con quello, l'utente del database ripristinato è tornato a funzionare senza toccarlo.

Ruoli
Ci sono 18 ruoli fissi di server: gli otto di sempre (sysadmin, serveradmin, securityadmin, processadmin, setupadmin, bulkadmin, diskadmin, dbcreator) e dieci ##MS_…## dal 2022, più fini (##MS_ServerStateReader## per vedere lo stato senza toccare nulla). In ogni database ce ne sono nove fissi: db_owner, db_datareader, db_datawriter, db_ddladmin, db_securityadmin, db_accessadmin, db_backupoperator e i due che negano, db_denydatareader e db_denydatawriter. I ruoli propri si creano con CREATE ROLE e si riempiono con ALTER ROLE … ADD MEMBER.

E public, a cui appartengono tutti. Sul server ha VIEW ANY DATABASE: ana vedeva i nomi dei dieci database del server, anche se poteva entrare solo in quattro. Se i nomi sono un'informazione, lo si toglie a public.

Concedere per schema copre anche le tabelle di domani

GRANT SELECT ON SCHEMA::rrhh TO ana;

Con questo ana ha letto le tabelle di rrhh, e anche quella che ho creato dopo. È il contrario di PostgreSQL, dove ON ALL TABLES raggiunge solo quelle di oggi. E ciò che sta fuori dallo schema resta chiuso: dbo.pedidos ha dato Msg 229.

GRANT, DENY e REVOKE: tre stati, non due
- GRANT concede.
- DENY vieta, e prevale su ogni GRANT: con DENY SELECT su rrhh.nominas, ana ha ricevuto Msg 229 pur avendo lo schema concesso, e anche essendo membro di db_datareader.
- REVOKE cancella quello che c'è, sia un GRANT sia un DENY. Dopo il REVOKE, ana è tornata a leggere, tramite il ruolo.

Chi scavalca un DENY l'ho misurato uno per uno:
- Un membro di db_owner: non lo scavalca, Msg 229.
- Un login con CONTROL SERVER: nemmeno, Msg 229.
- Un membro di sysadmin: sì. Entra in ogni database come dbo (USER_NAME() lo dice) e non gli si controlla nessun permesso, quindi ha letto la tabella negata.

Cosa posso fare, chiesto al server

EXECUTE AS USER = 'ana';
SELECT permission_name, subentity_name
FROM fn_my_permissions('rrhh.nominas', 'OBJECT');
SELECT HAS_PERMS_BY_NAME('rrhh.nominas', 'OBJECT', 'UPDATE');
REVERT;

fn_my_permissions ha restituito SELECT quattro volte: una per la tabella e una per ciascuna delle sue tre colonne. È il modo di controllare un permesso effettivo —con ruoli, schemi e DENY già risolti— senza chiedere la password a nessuno.

La catena di proprietà
Se una vista o una procedura e la tabella che leggono hanno lo stesso proprietario, chi può usare la vista non ha bisogno di permessi sulla tabella. ana, senza nulla su dbo.pedidos, ha letto tramite la vista dbo.v_totales e tramite la procedura dbo.p_total. È il modo buono di dare accesso: solo a ciò che l'oggetto mostra. Ma l'SQL dinamico rompe la catena: la stessa procedura con un EXEC('SELECT …') dentro ha dato Msg 229.

Utenti indipendenti
In un database indipendente l'utente porta con sé la password e non ha bisogno di login, quindi viaggia con il database e non resta mai orfano:

EXEC sp_configure 'contained database authentication', 1;
RECONFIGURE;
CREATE DATABASE tienda CONTAINMENT = PARTIAL;
-- dentro tienda:
CREATE USER carla WITH PASSWORD = 'Rt5%jueves_z2';

L'opzione è spenta di default. Con quella, carla è entrata nominando il database alla connessione; senza nominarlo, «Login failed». In Calíope significa mettere il database nel campo Database della connessione.

Sicurezza a livello di riga
Una policy con un predicato di filtro:

CREATE FUNCTION seg.fn_lo_mio(@dueno sysname)
RETURNS TABLE WITH SCHEMABINDING
AS RETURN SELECT 1 AS ok WHERE @dueno = USER_NAME();

CREATE SECURITY POLICY seg.solo_lo_mio
ADD FILTER PREDICATE seg.fn_lo_mio(dueno) ON dbo.pedidos
WITH (STATE = ON);

ana ha visto la sua riga. E a differenza di PostgreSQL, qui nessuno la scavalca: sa e un sysadmin hanno visto zero righe, perché per loro USER_NAME() è dbo. Decide il predicato, e se l'amministratore deve vedere tutto, deve dirlo il predicato. Inoltre, un filtro filtra solo ciò che si legge: ana ha inserito una riga di un altro proprietario senza errori. Per impedirlo serve un BLOCK PREDICATE.

sa
sa è il login con sid 0x01, membro di sysadmin, e il suo nome lo conosce chiunque provi a entrare. Una password sbagliata dà 18456 sul client, sempre con stato 1; il motivo è solo nel log del server, dove lo stesso tentativo compare con stato 8, password errata. Di solito si crea un login di amministrazione con un nome proprio e poi ALTER LOGIN sa DISABLE.

Cosa non fa Calíope
Calíope parla solo l'autenticazione SQL: né l'autenticazione di Windows né Microsoft Entra ID.

Cosa fa Calíope
Utenti elenca i login del server, con public come un account in più, e sa a parte, segnato come Utente di sistema. I permessi si vedono per livello —server, database, schema, tabella, routine—, e un DENY si disegna come terzo stato: con il suo glifo, barrato e con la parola DENY, non solo con un altro colore. Non si può deselezionare, perché toglierlo non è deselezionare ma un REVOKE. Account bloccato è ALTER LOGIN … DISABLE. Un utente di database senza login, orfano o indipendente, non compare: per quelli, le query qui sopra nell'Editor SQL.

Raccomandazione
Un login per persona o applicazione, e permessi per schema a ruoli, non a persone. sysadmin solo per amministrare, perché nessun DENY lo raggiunge. Quello che si può dare tramite una vista o una procedura, che passi di lì, e non con SQL dinamico dentro. Spostando un database su un'altra macchina, i login con il loro sid, oppure la query degli orfani subito dopo il ripristino. E sa, disattivato.

Parole chiave: sicurezza, login, utente, user, ruolo, role, sysadmin, db_owner, db_datareader, public, grant, deny, revoke, schema, fn_my_permissions, has_perms_by_name, utente orfano, orphaned user, alter user with login, sid, utente indipendente, contained database, catena di proprietà, ownership chaining, row-level security, security policy, sa, check_policy, 229, 916, 4060, 15023, 18456, 33064

Configurazione del server (SQL Server)

sp_configure e RECONFIGURE, perché value e value_in_use non coincidono, le opzioni che richiedono un riavvio, il tetto di memoria che non è impostato, dove vince ogni MAXDOP, la configurazione per database, tempdb e mssql-conf.

Si applica a: SQL Server 2022+

Se vieni da MySQL, qui non ci sono né my.cnf né SET GLOBAL: ciò che riguarda il server si scrive con sp_configure ed entra in vigore con RECONFIGURE, e ciò che riguarda un database, con ALTER DATABASE SCOPED CONFIGURATION. Se vieni da PostgreSQL, sys.configurations è il tuo pg_settings, e la colonna che conta non è source: è la differenza tra value e value_in_use. Tutto quello che racconto qui l'ho misurato su SQL Server 2025.

107 opzioni, e 74 nascoste
sys.configurations ha 107 opzioni su questo server, e sp_configure da solo ne mostra 33. Le altre 74 sono avanzate: finché non si attiva show advanced options non vengono né elencate né si possono cambiare. sp_configure 'max degree of parallelism', 4 ha dato Msg 15123, «does not exist, or it may be an advanced option».

EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

Scritto non è applicato: value e value_in_use
sp_configure si limita a scrivere il valore, e il server resta con quello precedente fino al RECONFIGURE. Dopo sp_configure 'cost threshold for parallelism', 50, sys.configurations diceva value 50 e value_in_use 5, e il server stesso avvisava: «Run the RECONFIGURE statement to install».

SELECT name, value, value_in_use, is_dynamic, is_advanced
FROM sys.configurations
WHERE value <> value_in_use;

Questa query si esegue prima di ogni RECONFIGURE, per due cose che ho misurato:
- RECONFIGURE installa tutto ciò che è in sospeso, non solo il tuo: se qualcuno ha lasciato un valore scritto, entra insieme al tuo.
- Un RECONFIGURE che fallisce lascia in sospeso ciò che è scritto. Con min server memory (MB) a 1.024 e max server memory (MB) a 128 ha dato Msg 5831, e il 1.024 è rimasto in value ad aspettare il successivo.

C'è un caso con lasciapassare: recovery interval (min) a 120 ha dato Msg 5807 («not recommended»), e RECONFIGURE WITH OVERRIDE l'ha applicato lo stesso. WITH OVERRIDE salta proprio il controllo che ha appena avvisato, quindi non si usa per abitudine.

Quelle che richiedono un riavvio
Quelle con is_dynamic = 0 non entrano in vigore con RECONFIGURE: qui sono 23 su 107, tra cui user connections, fill factor (%), locks e priority boost. Con sp_configure 'user connections', 500 e RECONFIGURE, value è passato a 500 e value_in_use è rimasto a 0, fino al riavvio del servizio.

Ogni modifica resta nel log del server, con ora e sessione: EXEC xp_readerrorlog 0, 1, N'Configuration option' le elenca con il loro «changed from 0 to 4».

La memoria: max server memory arriva senza tetto
max server memory (MB) vale 2.147.483.647 di fabbrica, cioè nessuno: SQL Server prende memoria per la sua cache finché il sistema non gli chiede di rilasciarla. Su un server dedicato gli si mette un tetto che lasci spazio al sistema operativo. Il minimo che accetta è 128; con 100 ha dato Msg 15129.

EXEC sp_configure 'max server memory (MB)', 12288;   -- 12 GB su 16
RECONFIGURE;

Su Linux c'è un altro tetto più in basso, memory.memorylimitmb di mssql-conf, che secondo la documentazione di fabbrica è l'80 % della memoria fisica. Questo container vede 6.347 MB, e con max server memory intatto il suo obiettivo (committed_target_kb di sys.dm_os_sys_info) era di 5.139 MB.

Parallelismo: tre livelli, e vince il più vicino
Di fabbrica, max degree of parallelism vale 0 (tutti i processori) e cost threshold for parallelism 5. Ho eseguito la stessa query su 300.000 righe e letto il DegreeOfParallelism del piano reale:
- Con il server a 0: 15 thread, tutti i processori che vede.
- Con il server a 4: 4.
- Con il database a ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 2: 2, anche se il server dice 4.
- Con OPTION (MAXDOP 8) nella query: 8, sopra il database e il server.
- Con cost threshold for parallelism a 50: in serie. La soglia si confronta con il costo del piano in serie (qui 7,7), non con quello del piano parallelo.

Come il parallelismo si vede nelle attese lo racconto in «Prestazioni e diagnosi (SQL Server)».

Configurazione per database
sys.database_scoped_configurations ha 41 opzioni in demo. Oltre a MAXDOP ci sono, tra le altre, LEGACY_CARDINALITY_ESTIMATION (0), PARAMETER_SNIFFING (1) e QUERY_OPTIMIZER_HOTFIXES (0). Sono il modo di cambiare il comportamento di una applicazione senza toccare le altre del server.

SELECT name, value
FROM sys.database_scoped_configurations
WHERE is_value_default = 0;

tempdb
Questo server vede 15 processori, e tempdb ha 8 file di dati da 8 MB, più quello del log, che crescono di 64 MB alla volta: l'installazione ne mette già uno per processore, fino a otto. È il database dietro le tabelle #temporanee, gli ordinamenti che non stanno in memoria e le versioni di riga di READ_COMMITTED_SNAPSHOT.

Ciò che non è sp_configure: mssql-conf, su Linux
I percorsi predefiniti di dati, log e backup, il certificato TLS, il tetto di memoria o la lingua vivono in /var/opt/mssql/mssql.conf, e si cambiano con /opt/mssql/bin/mssql-conf set … e un riavvio. Quello di questo container ha solo due righe: il certificato e la chiave TLS, ciò che SQL Server 2022 e successivi richiedono per il TLS rigoroso (TDS 8.0). Su Windows, la stessa cosa si fa in Gestione configurazione SQL Server.

Cosa mostra Calíope
Informazioni server, in Variabili globali, elenca sys.configurations con il suo value_in_use —ciò che è in funzione, non ciò che è scritto e in sospeso— e le proprietà SERVERPROPERTY.*: versione, edizione, collazione. Variabili di sessione sono le opzioni SET della tua connessione. Calíope le legge e non le cambia: un sp_configure si scrive nell'Editor SQL, e dopo il RECONFIGURE si vede in Informazioni server aggiornando. Query lente mostra nelle sue impostazioni optimize for ad hoc workloads e max server memory (MB). E contained database authentication, l'opzione che ammette utenti con password propria, la racconto in «Login, utenti e permessi (SQL Server)».

Raccomandazione
Poche, e misurando. max server memory sempre, lasciando spazio al sistema; cost threshold for parallelism sopra 5; e MAXDOP per database quando un'applicazione lo chiede, non per tutto il server. Prima di ogni RECONFIGURE, la query value <> value_in_use: così si applica ciò che si vuole e non ciò che un altro ha lasciato a metà.

Parole chiave: configurazione, sp_configure, reconfigure, with override, sys.configurations, value_in_use, show advanced options, is_dynamic, max server memory, min server memory, memorylimitmb, mssql-conf, maxdop, max degree of parallelism, cost threshold for parallelism, database scoped configuration, tempdb, user connections, xp_readerrorlog, 15123, 15129, 5807, 5831

Transazioni e livelli di isolamento (SQL Server)

Perché un BEGIN TRAN dentro un altro non si annida, come tornare indietro con SAVE TRANSACTION, cosa blocca ogni livello, il 3960 di SNAPSHOT e cosa fa al log una transazione dimenticata.

Si applica a: SQL Server 2022+

Una transazione raggruppa più istruzioni in un'unità che si applica per intero o non si applica. Come in MySQL e in PostgreSQL, senza una transazione esplicita SQL Server conferma ogni istruzione per conto suo (autocommit). Quello che cambia sta nei dettagli, e tutto ciò che racconto qui l'ho misurato su SQL Server 2025.

BEGIN TRAN, non BEGIN
Un BEGIN da solo apre un blocco T-SQL, non una transazione: il server ha risposto Msg 102, «Incorrect syntax near 'BEGIN'». Si scrive BEGIN TRAN (o BEGIN TRANSACTION). Un COMMIT senza transazione aperta dà Msg 3902, e un ROLLBACK, Msg 3903.

La modalità implicita
Con SET IMPLICIT_TRANSACTIONS ON, la prima istruzione che tocca i dati apre una transazione che resta aperta fino al COMMIT. L'ho misurato: @@TRANCOUNT valeva 0, un semplice SELECT l'ha portato a 1, e solo il COMMIT l'ha riportato a 0. Una sessione che in questa modalità dimentica di confermare tiene i suoi lock e trattiene il log, come racconto più sotto.

Annidare non annida
Un BEGIN TRAN dentro un altro fa solo salire il contatore @@TRANCOUNT. A decidere è la transazione esterna:
- Il COMMIT interno non conferma niente: ha portato il contatore da 2 a 1, e il ROLLBACK esterno ha annullato entrambi gli UPDATE, anche quello interno.
- Il ROLLBACK interno annulla tutto: ha lasciato @@TRANCOUNT a 0, e il COMMIT esterno ha dato Msg 3902 perché la transazione non c'era più.
- Chiamare per nome quella interna non serve: ROLLBACK TRAN interior ha dato Msg 6401 e il contatore è rimasto a 2. Si può tornare indietro solo fino a un punto di salvataggio.

BEGIN TRAN;                                  -- @@TRANCOUNT = 1
  UPDATE cuentas SET saldo = saldo - 10 WHERE id = 1;
  BEGIN TRAN;                                -- @@TRANCOUNT = 2
    UPDATE cuentas SET saldo = saldo + 10 WHERE id = 2;
  COMMIT;                                    -- @@TRANCOUNT = 1, niente confermato
ROLLBACK;                                    -- annulla entrambi gli UPDATE

Una procedura che termina con un @@TRANCOUNT diverso da quello che aveva all'ingresso dà Msg 266: ne parlo in «Errori (SQL Server)».

Punti di salvataggio: SAVE TRANSACTION
Per annullare una parte senza perdere la transazione, si segna un punto e ci si torna. Qui l'INSERT duplicato è fallito con 2627, sono tornato al punto, e il COMMIT ha confermato la riga di prima e quella di dopo:

BEGIN TRAN;
  INSERT INTO cuentas (id, saldo) VALUES (3, 0);
  SAVE TRANSACTION antes;
  BEGIN TRY
    INSERT INTO cuentas (id, saldo) VALUES (1, 0);   -- fallisce: 2627
  END TRY
  BEGIN CATCH
    ROLLBACK TRANSACTION antes;                      -- @@TRANCOUNT resta a 1
  END CATCH
  INSERT INTO cuentas (id, saldo) VALUES (5, 5);
COMMIT;

Due limiti che ho misurato: SAVE TRANSACTION senza transazione aperta dà Msg 628, e con XACT_ABORT ON lo stesso errore ha lasciato la transazione condannata —XACT_STATE() -1— e tornare al punto ha dato Msg 3931: restava solo annullarla per intero.

Un errore non interrompe quasi mai la transazione
Al contrario di PostgreSQL, un errore di istruzione —una chiave duplicata, un vincolo— interrompe solo quell'istruzione, e la transazione resta viva con quello che ha già fatto. Senza XACT_ABORT, un COMMIT alla fine conferma ciò che viene prima e dopo l'errore. Con SET XACT_ABORT ON, lo stesso errore annulla tutto e interrompe il batch. Quali errori annullano la transazione da soli lo racconto in «Errori (SQL Server)».

I livelli di isolamento
Il predefinito è READ COMMITTED. Ho misurato con due sessioni: la prima legge quattro righe di un intervallo, aspetta quattro secondi e le rilegge; la seconda, nel frattempo, inserisce una riga in quell'intervallo e aggiorna una delle righe lette.
- READ UNCOMMITTED legge ciò che gli altri non hanno confermato: è il NOLOCK di «Lock e deadlock (SQL Server)».
- READ COMMITTED non trattiene i lock di ciò che legge. Senza READ_COMMITTED_SNAPSHOT, che di fabbrica è spento, una lettura aspetta chi scrive; ne parlo in «Lock e deadlock (SQL Server)».
- REPEATABLE READ ha trattenuto cinque lock condivisi (KEY S) fino alla fine: l'UPDATE della riga letta ha aspettato 3 s. Ma l'INSERT è entrato subito, e la rilettura ha contato 5 righe invece di 4: una riga fantasma.
- SERIALIZABLE ha bloccato l'intervallo (KEY RangeS-S): l'INSERT ha aspettato 3 s e la rilettura ha continuato a contare 4.
- SNAPSHOT legge una fotografia dell'inizio della transazione e non blocca nessuno, ma va permesso in ogni database, e di fabbrica è spento: snapshot_isolation_state_desc era OFF in demo, in model e in un database nuovo. Senza permesso, SET TRANSACTION ISOLATION LEVEL SNAPSHOT e BEGIN TRAN sono passati, ed è stato il primo SELECT a dare Msg 3952.

ALTER DATABASE mi_base SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;

SNAPSHOT non aspetta: interrompe con 3960
Con il permesso, la seconda sessione ha aggiornato la riga senza aspettare la prima, che l'aveva solo letta. La prima l'ha riletta e vedeva ancora il valore della sua fotografia, 100, con il 101 dell'altra già confermato. Quando ha voluto aggiornarla, il server l'ha interrotta con Msg 3960 («update conflict»), e XACT_STATE() è rimasto a -1. In SNAPSHOT, un 3960 è funzionamento normale: l'applicazione deve ripetere l'intera transazione. READ_COMMITTED_SNAPSHOT è un'altra cosa: non cambia livello, cambia il modo in cui legge READ COMMITTED, ed è quello che racconto in «Lock e deadlock (SQL Server)».

Una transazione dimenticata trattiene il log
Una sessione addormentata dentro una transazione (status = sleeping, open_transaction_count = 1) impedisce di riutilizzare il log. Ho inserito e cancellato 5.000 righe e lanciato un CHECKPOINT, in un database dove il CHECKPOINT svuota il log. Senza quella sessione sono rimasti in uso 6,5 MB; con lei, 73,8 MB, e log_reuse_wait_desc diceva ACTIVE_TRANSACTION. Dopo averla chiusa, 5,1 MB. Il resto del ciclo del log lo racconto in «Backup e ripristino (SQL Server)». Per trovarla:

DBCC OPENTRAN;   -- la più vecchia: SPID e ora di inizio

SELECT session_id, status, open_transaction_count, last_request_end_time
FROM sys.dm_exec_sessions
WHERE open_transaction_count > 0;

Anche il DDL si annulla
ALTER TABLE, CREATE INDEX e CREATE TABLE stanno dentro la transazione, con le loro eccezioni (CREATE DATABASE dà Msg 226): ne parlo in «Modifiche di schema a caldo (SQL Server)».

Cosa fa Calíope
L'Editor SQL esegue quello che gli dai su una sola connessione, istruzione per istruzione, e si ferma alla prima che fallisce. Senza transazione dal menu, alla fine restituisce la connessione al pool, e se è rimasta aperta una transazione la annulla (IF @@TRANCOUNT > 0 ROLLBACK): un BEGIN TRAN e il suo COMMIT scritti a mano vanno nella stessa esecuzione, e ciò che precedeva un errore viene annullato quando la connessione viene restituita. In sqlcmd o in un'applicazione sarebbe stato confermato.
Per una transazione che duri più esecuzioni c'è il menu Transazione della barra: BEGIN invia BEGIN TRANSACTION e riserva una connessione per la scheda, e ciò che esegui lì resta dentro fino al COMMIT o al ROLLBACK. Chiudere la scheda la annulla. Se il server la annulla da solo — un 245, un 1205 o qualsiasi errore con XACT_ABORT — o la chiude un COMMIT del tuo SQL, l'etichetta Transazione attiva si spegne e la barra di stato lo segnala: da lì in poi, ogni istruzione viene confermata subito.

Raccomandazione
SET XACT_ABORT ON in ogni batch o procedura che scrive, e un TRY…CATCH che finisca con IF @@TRANCOUNT > 0 ROLLBACK; e THROW;. Non annidare BEGIN TRAN aspettandoti che quella interna sia indipendente: a questo servono i punti di salvataggio. Se passi a SNAPSHOT, scrivi la ripetizione del 3960 prima di passare. E ogni tanto, DBCC OPENTRAN: una sessione dimenticata non avvisa finché il disco non è pieno.

Parole chiave: transazione, commit, rollback, begin tran, @@trancount, save transaction, punto di salvataggio, xact_abort, xact_state, implicit_transactions, isolamento, repeatable read, serializable, snapshot, allow_snapshot_isolation, 3960, 3952, 3902, 6401, 3931, active_transaction, dbcc opentran

Tipi di dati (ClickHouse)

Interi con la dimensione nel nome e l'UInt64 che non sta in JSON, Decimal esatto, String come byte, gli intervalli delle date, e Nullable e LowCardinality come involucri che costano.

Si applica a: ClickHouse 25.3+

In ClickHouse il tipo decide quanto spazio occupa ogni valore su disco e come si comprime, quindi lo si sceglie con più cura che in un database transazionale. I nomi contengono la dimensione e distinguono maiuscole e minuscole: UInt64, non uint64.

Interi: la dimensione è nel nome
Da Int8 a Int256 e da UInt8 a UInt256. Uscire dall'intervallo non è un errore: il valore ricomincia dal fondo. toInt8(300) restituisce 44 e toUInt8(-1), 255.

Il massimo di un UInt64 è 18446744073709551615, e in JSON un numero è esatto solo fino a 9007199254740991 (2^53 − 1). Per questo il server invia gli interi a 64 bit tra virgolette nei suoi formati JSON (output_format_json_quote_64bit_integers), e Calíope li legge come testo esatto, mai come numero in virgola mobile.

Decimal: esatto, con scala fissa
Decimal(P, S) conserva P cifre di cui S decimali, senza gli arrotondamenti della virgola mobile. La divisione scarta ciò che non sta nella scala: toDecimal32(1, 2) / 3 dà 0.33. La moltiplicazione somma le scale: due Decimal(9, 2) danno un Decimal(9, 4).

String sono byte, non caratteri
String accetta qualsiasi sequenza di byte, anche una che non è UTF-8. length('ñandú') dà 7 (byte) e lengthUTF8('ñandú'), 5. FixedString(N) riempie con zeri: toFixedString('ab', 4) conserva 61 62 00 00. Quando un testo non è UTF-8 valido, Calíope lo mostra sanificato e segna la cella.

Date: intervalli diversi, e nessun errore quando si esce

TipoIntervalloOccupa
Dateda 1970-01-01 a 2149-06-062 byte
Date32da 0000-01-01 a 9999-12-314 byte
DateTimesecondi dal 19704 byte
DateTime64(P)P decimali, da 0 a 98 byte

Fuori dal suo intervallo, Date resta al limite senza avvisare: toDate('2149-06-07') restituisce 2149-06-06 e toDate('1969-12-31'), 1970-01-01. Per date di nascita o storiche si usa Date32.

Il fuso orario appartiene alla colonna, non a ogni riga: DateTime64(3, 'Asia/Tokyo'). Senza fuso vale quello del server, e Calíope lo scrive nell'intestazione della colonna.

Nullable e LowCardinality: involucri con costi diversi
Nullable(T) tiene a parte una maschera dei nulli. Su un milione di righe, Int64 ha occupato 7,63 MiB non compresso e Nullable(Int64), 8,58 MiB. Inoltre una colonna Nullable non può stare nella chiave di ordinamento senza attivare allow_nullable_key. Se l'assenza si può esprimere con un valore predefinito (0, stringa vuota), è meglio non usare Nullable.

LowCardinality(T) codifica con un dizionario. Con 100 valori distinti in un milione di righe, la colonna String ha occupato 254 KiB compressa e la LowCardinality(String), 7,6 KiB. La documentazione dello stesso server lo consiglia sotto circa 10 000 valori distinti e lo sconsiglia sopra 100 000.

Collezioni e tipi strutturati
- Array(T) parte da 1, e fuori intervallo restituisce il valore predefinito, non un errore: [10,20,30][5] dà 0.
- Map(K, V) ammette chiavi ripetute: map('a',1,'a',2) le conserva entrambe, e ['a'] restituisce la prima.
- Enum8 rifiuta all'inserimento un nome che non è nella sua lista. UUID occupa 16 byte e IPv4, 4.

Calíope mostra le collezioni come JSON compatto, con i numeri esattamente come sono arrivati, e uno stato di aggregazione (AggregateFunction) dice che non si può mostrare come testo.

Parole chiave: tipi, uint64, int64, decimal, string, fixedstring, date, date32, datetime, datetime64, fuso orario, nullable, lowcardinality, array, map, tuple, enum, uuid, ipv4, json, clickhouse

Motori di tabella (ClickHouse)

La famiglia MergeTree e cosa fa ogni variante durante l'unione, le Replicated, i motori che non conservano dati o li tengono in memoria, e perché il motore non si può cambiare.

Si applica a: ClickHouse 25.3+

In ClickHouse ogni tabella dichiara il suo motore, e il motore decide come si conservano i dati, quali indici accetta, se si replica e cosa succede durante l'unione. Non è un dettaglio di prestazioni: è parte dell'identità della tabella. Il server pubblica gli 84 che conosce in system.table_engines, ciascuno con ciò che supporta.

La famiglia MergeTree, quella di quasi sempre
I dati si scrivono in parti ordinate per la chiave di ordinamento, e il server le unisce in background. Le varianti fanno qualcosa in più durante l'unione, e solo allora:

MotoreCosa fa durante l'unione
MergeTreeNient'altro che ordinare e riunire.
ReplacingMergeTree(ver)Tiene una riga per chiave: quella con la versione più alta.
SummingMergeTreeSomma le colonne numeriche delle righe con la stessa chiave.
AggregatingMergeTreeCombina stati di aggregazione (-State / -Merge).
CollapsingMergeTree(sign)Annulla le coppie di righe con segno +1 e −1.

La trappola è quel «solo allora». Misurato con ReplacingMergeTree: due inserimenti della stessa chiave danno 2 righe in un SELECT normale; con FINAL ne danno 1, e dopo OPTIMIZE TABLE … FINAL, 1 senza FINAL. SummingMergeTree si comporta allo stesso modo: due righe ('x', 1) e ('x', 2) restano due fino all'unione, e poi una con 3. Le query su questi motori devono aggregare o usare FINAL, perché l'unione non ha un orario.

Replicated, la stessa famiglia con copie
ReplicatedMergeTree e le sue varianti (ReplicatedReplacingMergeTree…) aggiungono repliche coordinate da ClickHouse Keeper. Sono gli unici della lista che supportano la replica e la deduplicazione degli inserimenti (supports_replication, supports_deduplication).

Quelli che non sono MergeTree
- Memory: in RAM e non compresso. Secondo la sua descrizione sul server, al riavvio la tabella resta vuota.
- Log e famiglia: piccole tabelle a scrittura unica, senza indici.
- Buffer: trattiene in memoria e scarica in un'altra tabella; oggi si consigliano invece gli inserimenti asincroni.
- Distributed: non conserva nulla; distribuisce la query tra gli shard di un cluster.
- Merge: legge più tabelle insieme, e non accetta scritture.
- Dictionary: mostra un dizionario come tabella.
- Null: ciò che si scrive viene scartato. Serve da ingresso a una vista materializzata.

Nessuno di questi accetta indici di salto, proiezioni, chiave di ordinamento né TTL: secondo system.table_engines, tutto ciò appartiene solo alla famiglia MergeTree.

Il motore non si cambia
Non esiste ALTER TABLE … MODIFY ENGINE: il server risponde con un errore di sintassi. Per cambiarlo si crea un'altra tabella con il nuovo motore e si copiano i dati con INSERT INTO … SELECT, oppure si copiano partizioni con ATTACH PARTITION … FROM se colonne e partizionamento coincidono: misurato, da un ReplacingMergeTree a un MergeTree.

In Calíope
Il Builder offre i motori che il server può creare senza argomenti, e quando si modifica una tabella dice che il motore non si può cambiare. Cataloghi mostra il motore di ogni tabella.

Parole chiave: motore di tabella, engine, mergetree, replacingmergetree, summingmergetree, aggregatingmergetree, collapsingmergetree, replicated, memory, log, buffer, distributed, dictionary, merge, null, final, clickhouse

Chiavi e indici di salto (ClickHouse)

La chiave di ordinamento come indice sparso di granuli, la chiave primaria quando è diversa, SAMPLE BY, gli indici di salto e le proiezioni, e perché la chiave non si può cambiare.

Si applica a: ClickHouse 25.3+

In ClickHouse non ci sono indici che puntano a una riga. Quanto si legge lo decide la chiave di ordinamento: i dati si conservano ordinati in base a essa, e l'indice primario è sparso: tiene un segno per ogni blocco di righe, non uno per riga.

I granuli: l'unità di lettura
Ogni blocco si chiama granulo e, per impostazione predefinita, ha 8 192 righe (index_granularity). Il server scarta granuli interi e legge il resto. Su una tabella di un milione di righe con ORDER BY (fecha, cliente, id) ci sono 123 granuli, ed EXPLAIN lo dice:

EXPLAIN indexes = 1
SELECT count() FROM eventos WHERE fecha = '2026-02-01';

Con la prima colonna della chiave, il piano mostra Granules: 3/123 e una ricerca binaria. Filtrando solo per cliente, la seconda colonna, mostra Granules: 101/123: quasi tutta la tabella. L'ordine delle colonne nella chiave conta: prima quelle filtrate più spesso e con meno valori distinti.

La chiave primaria può essere più corta di quella di ordinamento
PRIMARY KEY deve essere un prefisso di ORDER BY. Se non la si scrive, coincidono. Con ORDER BY (fecha, cliente, id) PRIMARY KEY (fecha, cliente) le righe si ordinano per le tre colonne e l'indice in memoria ne tiene solo due: meno memoria, lo stesso ordine su disco. La chiave primaria non è univoca: due righe con la stessa chiave convivono.

SAMPLE BY
Permette di interrogare una frazione dei dati (SELECT … SAMPLE 0.1). L'espressione di campionamento deve stare nella chiave primaria, altrimenti il server rifiuta il CREATE, e di solito è un hash: ORDER BY intHash32(id) SAMPLE BY intHash32(id). Misurato: SAMPLE 0.1 su 100 000 righe ne ha contate 10 039.

Indici di salto: scartano, non cercano
Tengono un riepilogo per gruppo di granuli e servono a saltare quelli che di sicuro non contengono ciò che si cerca:
- minmax: il minimo e il massimo.
- set(N): fino a N valori distinti.
- bloom_filter: appartenenza approssimata; per l'uguaglianza su colonne fuori dalla chiave.
- ngrambf_v1 e tokenbf_v1: per cercare frammenti o parole dentro un testo.

Misurato sulla stessa tabella: cercare un URL preciso con un bloom_filter ha letto 20 granuli su 123 invece di tutti i 123. E un minmax su un importo distribuito in tutta la tabella non ne ha scartato nessuno: un indice di salto serve solo se i valori sono raggruppati lungo la chiave.

Proiezioni: la tabella di nuovo, in un altro ordine
Una proiezione conserva di nuovo i dati, ordinati in un altro modo, e il server la sceglie da solo quando conviene:

ALTER TABLE eventos ADD PROJECTION por_cliente (SELECT * ORDER BY cliente);
ALTER TABLE eventos MATERIALIZE PROJECTION por_cliente;

La stessa query per cliente è passata da 101 a 1 granulo su 123. In cambio, la proiezione ha occupato 5,3 MiB in più, e ogni inserimento scrive i dati due volte.

La chiave di ordinamento non si può cambiare
ALTER TABLE … MODIFY ORDER BY ammette solo l'aggiunta in fondo di colonne create nello stesso ALTER. Riordinare, o inserire una colonna che esisteva già, è un errore (Existing column … is used in the expression that was added to the sorting key). Cambiare davvero la chiave significa creare un'altra tabella e copiare i dati.

In Calíope
In ClickHouse la scheda «Indici» di Cataloghi si chiama «Chiavi» e mostra la chiave di ordinamento, quella di partizione, la primaria se è diversa, quella di campionamento, gli indici di salto e le proiezioni. L'EXPLAIN visuale conta i granuli letti da ogni lettura, e il consulente degli indici propone indici di salto, non CREATE INDEX.

Parole chiave: chiave di ordinamento, order by, primary key, indice primario, granulo, index_granularity, sample by, indice di salto, skip index, minmax, set, bloom_filter, ngrambf_v1, tokenbf_v1, proiezione, projection, explain, clickhouse

Partizioni e TTL (ClickHouse)

PARTITION BY e le parti, perché partizionare per giorno è un errore comune, OPTIMIZE e DROP PARTITION, e il TTL di righe e colonne, che si applica durante l'unione.

Si applica a: ClickHouse 25.3+

In ClickHouse una partizione è un gruppo di parti che condividono il valore di PARTITION BY, e il suo compito principale è la gestione dei dati: eliminare o spostare un mese intero in un colpo. Per accelerare le query c'è già la chiave di ordinamento; partizionare non la sostituisce.

Parti e partizioni
Ogni inserimento scrive almeno una parte per ogni partizione che tocca, e il server le unisce dopo, sempre all'interno della stessa partizione. system.parts le elenca, e contano solo quelle con active = 1: quelle unite restano inattive per un po' prima di essere eliminate.

CREATE TABLE ventas (fecha Date, id UInt64, importe Decimal(10, 2))
ENGINE = MergeTree
PARTITION BY toYYYYMM(fecha)
ORDER BY (fecha, id);

Misurato: due inserimenti di 300 000 righe distribuite su nove mesi hanno lasciato 18 parti attive in 9 partizioni.

Perché partizionare per giorno è un errore comune
Molte partizioni significano molte parti piccole, e le parti costano: oltre 1 000 attive in una partizione il server comincia a rallentare gli inserimenti (parts_to_delay_insert), e a 3 000 li rifiuta (parts_to_throw_insert). Inoltre un solo INSERT non può toccare più di 100 partizioni (max_partitions_per_insert_block). Misurato con PARTITION BY fecha: un INSERT con 200 giorni diversi è fallito con Code: 252 … Too many partitions for single INSERT block (more than 100). La scelta abituale è per mese (toYYYYMM), e nessuna se la tabella è piccola.

Unire ed eliminare per partizione

OPTIMIZE TABLE ventas PARTITION 202603 FINAL;
ALTER TABLE ventas DROP PARTITION 202601;

OPTIMIZE … PARTITION … FINAL unisce in una sola le parti di quella partizione. DROP PARTITION elimina la partizione intera togliendone i file: misurato, 5 ms. Eliminare il mese successivo con ALTER TABLE … DELETE WHERE ha richiesto 54 ms, perché una mutazione riscrive le parti invece di lasciarle andare, e la differenza cresce con le dimensioni.

TTL: scadenza applicata durante l'unione

CREATE TABLE ventas (
    fecha Date, id UInt64,
    nota String TTL fecha + INTERVAL 30 DAY
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(fecha)
ORDER BY (fecha, id)
TTL fecha + INTERVAL 1 YEAR;

Il TTL della tabella elimina le righe scadute, e quello di una colonna riporta il suo valore al predefinito. Entrambe le cose avvengono durante l'unione, non all'inserimento né all'ora esatta. Misurato: una riga di due anni fa, inserita in quella tabella, c'era ancora alla lettura, ed è sparita dopo OPTIMIZE … FINAL. Senza forzarlo, il server controlla la scadenza ogni 4 ore (merge_with_ttl_timeout), quindi un TTL non è un'eliminazione puntuale: i dati scaduti possono restare visibili per un po'.

In Calíope
La scheda «Chiavi» di Cataloghi mostra la chiave di partizione di ogni tabella. Informazioni server, in «Utilizzo HDD», somma le parti attive di system.parts e mostra la compressione. Manutenzione tabelle offre OPTIMIZE limitato a una partizione, con FINAL disattivato per impostazione predefinita perché riscrive l'intera partizione.

Parole chiave: partizione, partition by, parti, system.parts, active, parts_to_throw_insert, max_partitions_per_insert_block, optimize, drop partition, ttl, scadenza, toyyyymm, unione, clickhouse

Viste materializzate (ClickHouse)

Un trigger di inserimento, non una tabella in cache: TO contro la tabella interna, POPULATE, le viste aggiornabili con system.view_refreshes, e perché non vedono né UPDATE né DELETE.

Si applica a: ClickHouse 25.3+

Una vista materializzata di ClickHouse non è una query salvata con il risultato in cache. È un trigger di inserimento: ogni volta che un blocco di righe arriva nella tabella di origine, la vista esegue il suo SELECT su quel blocco e scrive il risultato in una tabella di destinazione. Ciò che c'era prima, e ciò che cambia dopo, non lo vede.

Con TO, o con una tabella interna

CREATE TABLE totales (k String, total UInt64)
ENGINE = SummingMergeTree ORDER BY k;

CREATE MATERIALIZED VIEW mv_totales TO totales AS
SELECT k, sum(v) AS total FROM origen GROUP BY k;

Con TO, la destinazione è una tabella normale, creata prima e con il suo nome. Senza TO, la vista porta il suo ENGINE e il server crea una tabella nascosta chiamata .inner_id.<uuid>, con l'uuid della vista. Misurato: la vista senza TO ha creato .inner_id.ed264ea3-…, e il suo uuid in system.tables era proprio quello. Con TO si sa dove stanno i dati e si può eliminare la vista senza perderli, quindi è il modo consigliato.

Ciò che c'era prima non entra (salvo con POPULATE)
Misurato: con due righe già nell'origine, la vista con TO e quella con tabella interna sono nate vuote. Quella creata con POPULATE è nata con entrambe. Dopo, un nuovo inserimento è arrivato a tutte e tre. La documentazione di ClickHouse avverte che ciò che si inserisce nell'origine mentre POPULATE riempie la vista può andare perso; con traffico, è meglio creare la vista e riempire la destinazione a mano con INSERT INTO … SELECT.

Le mutazioni: la vista non se ne accorge
Una vista reagisce solo agli inserimenti. Misurato: dopo ALTER TABLE origen UPDATE v = 1000 WHERE k = 'a', l'origine sommava 2 000 per a e la destinazione restava a 10. Dopo un DELETE delle righe di c, l'origine le ha perse e la destinazione ha conservato il suo totale di 5. Se si corregge l'origine, la destinazione si ricalcola a mano.

Anche la destinazione si unisce
Se la destinazione è un SummingMergeTree o un AggregatingMergeTree, ogni inserimento lascia righe parziali che si riuniscono durante l'unione. Le query sulla destinazione aggregano (sum(total), -Merge) invece di leggere righe singole.

Le viste aggiornabili: l'altro tipo

CREATE MATERIALIZED VIEW resumen
REFRESH EVERY 1 MINUTE
ENGINE = MergeTree ORDER BY k AS
SELECT k, count() AS c FROM origen GROUP BY k;

Una vista con REFRESH funziona davvero come una tabella in cache: rifà l'intera query a ogni intervallo e sostituisce il risultato, quindi vede tutta l'origine, comprese le modifiche fatte con mutazioni. system.view_refreshes ne indica lo stato (Scheduled, Running…), l'ultimo aggiornamento riuscito e il prossimo. Misurato su ClickHouse 26.8: funziona senza alcuna impostazione sperimentale.

In Calíope
In ClickHouse, Cataloghi ha la scheda «Viste materializzate»: da quale tabella legge ciascuna, in quale scrive (e se è quella interna, con quale nome interrogarla), dimensione e righe della destinazione, l'ultima riga scritta e, se è aggiornabile, la sua programmazione e i suoi aggiornamenti. L'albero dell'editor le raggruppa a parte rispetto alle viste normali.

Parole chiave: vista materializzata, materialized view, to, inner_id, populate, refresh every, aggiornabile, view_refreshes, trigger, summingmergetree, mutazione, clickhouse

Mutazioni ed eliminazioni (ClickHouse)

UPDATE e DELETE come riscrittura asincrona delle parti, system.mutations e il motivo dell'errore, KILL MUTATION, mutations_sync, il DELETE leggero e perché una mutazione non conta le righe.

Si applica a: ClickHouse 25.3+

ClickHouse non modifica una riga sul posto. ALTER TABLE … UPDATE e ALTER TABLE … DELETE sono mutazioni: il server le registra, risponde subito e poi riscrive in background ogni parte interessata, per intero, anche se cambia una sola riga.

Cosa succede quando la si lancia

ALTER TABLE eventos UPDATE estado = 'cerrado' WHERE id < 10;
SELECT mutation_id, parts_to_do, is_done FROM system.mutations WHERE table = 'eventos';

Misurato su una tabella con due parti da 50 000 righe: l'istruzione è tornata in 10 ms con parts_to_do = 2 e is_done = 0. Alla fine, le parti all_1_1_0 e all_2_2_0 erano state sostituite da all_1_1_0_3 e all_2_2_0_3: 50 000 righe riscritte in ciascuna per cambiarne dieci. Le parti vecchie restano sul disco, inattive, finché non passa old_parts_lifetime (480 s per impostazione predefinita).

Aspettare o no
mutations_sync vale 0 per impostazione predefinita: l'istruzione non aspetta. Con 1 aspetta la replica corrente e con 2 tutte. Misurato: con mutations_sync = 2, la SELECT successiva vedeva già il valore nuovo.

Quando una mutazione fallisce
Una mutazione che fallisce non viene annullata né scartata: continua a essere ritentata e blocca quelle che la seguono su quella tabella. Misurato: dopo una mutazione con una divisione per zero, la successiva è rimasta non applicata finché la prima non è stata rimossa. Il motivo è in system.mutations.latest_fail_reason (Code: 153 … Division by zero), e la si rimuove con:

KILL MUTATION WHERE database = 'demo' AND table = 'eventos' AND NOT is_done SYNC;

Ciò che la mutazione aveva già riscritto non torna indietro.

Una mutazione non conta le righe
Il server non conta le righe interessate quando mette in coda una mutazione: in system.query_log compare con written_rows = 0. Qualsiasi numero di righe mostrato per una mutazione sarebbe inventato.

Il DELETE leggero
DELETE FROM tabella WHERE … non riscrive le parti: segna le righe come eliminate, le nasconde nelle query, e vengono rimosse davvero alla fusione successiva. Misurato: su 100 000 righe, DELETE FROM … WHERE id < 3 ne ha lasciate 99 997 visibili, e con apply_deleted_mask = 0 c'erano ancora tutte le 100 000. lightweight_deletes_sync vale 2 per impostazione predefinita: aspetta che il segno arrivi a tutte le repliche.

In Calíope
Modificare una cella o eliminare una riga su ClickHouse scrive ALTER TABLE … UPDATE o ALTER TABLE … DELETE, e l'editor dice che la mutazione è stata messa in coda, con il suo identificativo, invece di un numero di righe. Processi mostra le mutazioni non terminate, con il motivo dell'errore in testo, e permette di annullarle.

Parole chiave: mutazione, mutation, alter table update, alter table delete, system.mutations, latest_fail_reason, kill mutation, mutations_sync, delete from, eliminazione leggera, lightweight delete, lightweight_deletes_sync, parts_to_do

Transazioni e atomicità (ClickHouse)

Non ci sono transazioni: cosa garantisce un INSERT, perché non è atomico tra blocchi, la deduplicazione dei blocchi e come rendere idempotente un caricamento senza BEGIN.

Si applica a: ClickHouse 25.3+

ClickHouse non ha transazioni. BEGIN TRANSACTION risponde Code: 48 … Transactions are not supported (NOT_IMPLEMENTED), e lo stesso vale per implicit_transaction = 1. Non ci sono COMMIT né ROLLBACK: ogni istruzione vale per sé.

Cosa garantisce un INSERT
Un INSERT si scrive per blocchi (max_insert_block_size, 1 048 449 righe per impostazione predefinita), e ogni blocco è atomico: entra intero o non entra. Tra un blocco e l'altro non c'è garanzia. Misurato: un INSERT … SELECT di 400 000 righe in blocchi da 100 000 fallito alla riga 250 000 ha lasciato 100 000 righe scritte. Un INSERT che fallisce può lasciare la tabella a metà, e niente lo annulla.

Deduplicazione dei blocchi
Le tabelle Replicated* ricordano l'hash degli ultimi blocchi inseriti (replicated_deduplication_window, 10 000) e scartano un blocco identico che arriva di nuovo. Misurato: lo stesso INSERT due volte in una ReplicatedMergeTree ha lasciato 2 righe, non 4. In una MergeTree normale la finestra è 0 (non_replicated_deduplication_window), e lo stesso INSERT due volte ne ha lasciate 4.

Come rendere idempotente un caricamento
- Ripetere il lotto con gli stessi blocchi: su Replicated*, la deduplicazione scarta la ripetizione.
- Dare un nome al lotto con insert_deduplication_token. Misurato su una MergeTree con finestra 100: un secondo INSERT con lo stesso token e altri dati non è entrato.
- Caricare in una tabella di appoggio e sostituire l'intera partizione in un colpo con ALTER TABLE … REPLACE PARTITION … FROM (secondo la documentazione del server).
- Usare ReplacingMergeTree e leggere con FINAL quando le righe ripetute sono tollerabili fino alla fusione successiva.

In Calíope
Su ClickHouse l'editor non offre il menu «Transazione»: non c'è nulla da aprire né da annullare, e ogni istruzione è fatta appena viene eseguita.

Parole chiave: transazione, transaction, begin, commit, rollback, atomicità, insert, blocco, max_insert_block_size, deduplicazione, insert_deduplicate, insert_deduplication_token, replicated_deduplication_window, idempotente, not_implemented

Prestazioni (ClickHouse)

PREWHERE, il costo di FINAL, i granuli letti nell'EXPLAIN, max_threads e max_memory_usage, system.query_log con log_comment, e le tre query che non usano la chiave.

Si applica a: ClickHouse 25.3+

Su ClickHouse quasi tutte le prestazioni dipendono da quanto si legge: quanti granuli la chiave scarta e quante colonne bisogna leggere.

I granuli nell'EXPLAIN

EXPLAIN indexes = 1
SELECT count() FROM eventos WHERE fecha BETWEEN '2026-02-01' AND '2026-02-03';

Su un milione di righe con ORDER BY (fecha, cliente, id), il piano ha indicato Granules: 5/123: su 123 granuli ne ha letti 5. Quel rapporto è la prima cosa da guardare.

Le tre query che non usano la chiave
Misurato sulla stessa tabella:
- Filtrare su una colonna che non è nella chiave (url LIKE '%/p/12%'): 123/123.
- Un OR che mescola una colonna della chiave con una che non lo è (fecha = … OR url = …): 123/123.
- Filtrare solo sulla seconda colonna della chiave (cliente = 42): 101/123.

PREWHERE
Con PREWHERE, il server legge prima le colonne del filtro e legge le altre solo per le righe che passano. optimize_move_to_prewhere vale 1 e lo fa da solo. Misurato con un filtro su url che restituisce 20 righe e una colonna da 200 byte nella SELECT: 30 MiB letti con l'ottimizzazione e 204 MiB senza.

FINAL ha un costo
FINAL fonde al volo le righe ripetute di una ReplacingMergeTree. Misurato su due milioni di righe con un milione di chiavi: sum(v) senza FINAL ha impiegato 42 ms e sommava i duplicati; con FINAL, 323 ms e 42 MiB di memoria invece di 5,5.

Thread e memoria
max_threads vale auto(N), uno per core (8 sul server misurato). max_memory_usage vale 0, senza limite per query; con un limite, la query che lo supera fallisce con Code: 241 … Query memory limit exceeded.

system.query_log
Ogni query vi lascia la durata, read_rows, read_bytes e memory_usage, e log_comment la marca per ritrovarla:

SELECT … SETTINGS log_comment = 'informe-diario';
SYSTEM FLUSH LOGS;
SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes), formatReadableSize(memory_usage)
FROM system.query_log WHERE type = 'QueryFinish' AND log_comment = 'informe-diario';

In Calíope
L'EXPLAIN visuale mostra i granuli letti da ogni lettura, in rosso quando non ne scarta nessuno. Le Query lente vengono da system.query_log, con i byte letti e la memoria accanto al tempo, e ciò che lancia Calíope stesso è marcato con log_comment per poterlo nascondere.

Parole chiave: prestazioni, performance, prewhere, final, granulo, granule, explain, indexes, max_threads, max_memory_usage, query_log, read_bytes, memory_usage, log_comment, chiave di ordinamento

Fusioni e OPTIMIZE (ClickHouse)

Le fusioni in background, system.merges e system.parts, OPTIMIZE rispetto a OPTIMIZE FINAL, le soglie delle parti e perché «troppe parti» è l'errore operativo più comune.

Si applica a: ClickHouse 25.3+

Ogni INSERT crea una nuova parte sul disco. In background, il server fonde le parti piccole in parti più grandi: è ciò che tiene la tabella in poche parti e che applica ReplacingMergeTree, i TTL e le eliminazioni leggere.

Guardare le parti e le fusioni

SELECT partition, name, rows, active FROM system.parts WHERE table = 'eventos';
SELECT table, elapsed, progress, num_parts, result_part_name FROM system.merges;

Una parte inattiva è una parte già entrata in una fusione: resta sul disco finché non passa old_parts_lifetime (480 s) e poi viene eliminata.

OPTIMIZE e OPTIMIZE FINAL
OPTIMIZE TABLE chiede una fusione adesso; OPTIMIZE TABLE … FINAL la forza fino a lasciare una parte per partizione, anche se ce n'era già una. Misurato: con le fusioni ferme e cinque INSERT, cinque parti; OPTIMIZE le ha portate a 1, perché erano piccole, e un OPTIMIZE … FINAL su quell'unica parte l'ha riscritta per intero. FINAL riscrive la tabella: su una tabella grande è molto lavoro.

Troppe parti
Se gli INSERT creano parti più in fretta di quanto vengano fuse, il server prima rallenta e poi rifiuta:
- parts_to_delay_insert (1 000 per impostazione predefinita): da lì in poi ritarda ogni INSERT.
- parts_to_throw_insert (3 000): da lì in poi lo rifiuta.

Misurato con la soglia abbassata a 4: il quinto INSERT è fallito con Code: 252 … Too many parts … Merges are processing significantly slower than inserts (TOO_MANY_PARTS). È l'errore operativo più comune, e quasi sempre nasce dall'inserire riga per riga o in lotti molto piccoli, o dal partizionare troppo fine. La soluzione è inserire in lotti di migliaia di righe. Sul server misurato (26.8), async_insert è attivo per impostazione predefinita e, secondo la sua documentazione, riunisce in una parte gli INSERT piccoli che arrivano insieme; tre INSERT eseguiti uno dopo l'altro hanno comunque prodotto tre parti.

In Calíope
Manutenzione tabelle offre OPTIMIZE con l'opzione FINAL, che parte disattivata e avvisa prima di eseguire perché riscrive la tabella, e permette di limitarlo a una partizione. Informazioni server mostra dimensione e compressione di ogni tabella, sommate dalle sue parti attive.

Parole chiave: fusione, merge, optimize, optimize final, system.merges, system.parts, parte, part, parte inattiva, old_parts_lifetime, parts_to_delay_insert, parts_to_throw_insert, too many parts, too_many_parts, async_insert

Replica e Keeper (ClickHouse)

ReplicatedMergeTree e Keeper, le macro {shard} e {replica}, system.replicas, SYNC e RESTART REPLICA, cosa succede senza Keeper, DROP TABLE … SYNC e system.clusters.

Si applica a: ClickHouse 25.3+

Su ClickHouse la replica è per tabella: ogni tabella Replicated* si coordina con le sue copie tramite Keeper (o ZooKeeper), che tiene traccia delle parti esistenti e di cosa resta da copiare. Non c'è un primario: qualsiasi replica accetta scritture.

Creare una tabella replicata

CREATE TABLE ventas (id UInt32, v String)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/ventas', '{replica}')
ORDER BY id;

Il percorso identifica la tabella in Keeper ed è lo stesso su tutte le sue repliche; {shard} e {replica} sono macro che ogni server compila dalla propria configurazione (system.macros). Nella coppia misurata, entrambe le repliche hanno shard = 01, e replica vale r1 su una e r2 sull'altra. La tabella si crea su ogni replica, e le righe arrivano da sole.

system.replicas

SELECT table, is_readonly, is_session_expired, absolute_delay, queue_size, active_replicas, total_replicas FROM system.replicas;

- absolute_delay: secondi di ritardo rispetto alla replica più aggiornata.
- queue_size: attività in sospeso (copiare parti, fondere, mutare).
- is_readonly e is_session_expired: la replica ha perso Keeper.

Misurato: con la copia ferma su r2 (SYSTEM STOP FETCHES) e una riga inserita su r1, r2 è rimasta a 2 righe con queue_size = 1; ripresa la copia, 3.

SYNC e RESTART REPLICA
SYSTEM SYNC REPLICA tabella aspetta che la replica svuoti la coda; misurato, è tornata con tutte e 3 le righe aggiornate. SYSTEM RESTART REPLICA tabella rilegge il suo stato da Keeper; dopo, is_readonly = 0 e active_replicas = 2.

Senza Keeper, sola lettura
Misurato fermando Keeper: la tabella è passata a is_readonly = 1 e is_session_expired = 1, le letture hanno continuato a funzionare e un INSERT ha aspettato 120 s ed è fallito con TIMEOUT_EXCEEDED. Al ritorno di Keeper, la replica si è ripresa da sola.

DROP TABLE … SYNC
Quando si elimina una tabella replicata senza SYNC, la sua traccia in Keeper viene pulita più tardi, e ricrearla subito fallisce con Code: 253 … Replica … already exists (REPLICA_ALREADY_EXISTS) (misurato). Con DROP TABLE … SYNC, l'istruzione aspetta quella pulizia.

system.clusters
I cluster si dichiarano nella configurazione del server, non si creano con SQL, e system.clusters li elenca con i loro shard e repliche. Nel testbed, caliope_par ha uno shard con due repliche: clickhouse-r1 e clickhouse-r2.

In Calíope
Su ClickHouse, il Monitor della replica va tabella per tabella: repliche attive sul totale, quelle mancanti per nome, il ritardo e la coda, e la perdita di Keeper come stato a parte. Topologia disegna ciò che dichiara system.clusters.

Parole chiave: replica, replicazione, replication, replicatedmergetree, keeper, zookeeper, macro, shard, system.replicas, absolute_delay, queue_size, is_readonly, sync replica, restart replica, system.clusters, replica_already_exists

Sicurezza: utenti, ruoli e permessi (ClickHouse)

Gli utenti dei file del server rispetto a quelli creati con SQL, i ruoli, GRANT e le revoche parziali, le quote, i profili di impostazioni, le politiche di riga e perché la password non va nell'URL.

Si applica a: ClickHouse 25.3+

ClickHouse tiene gli utenti in due posti, e da quale dei due viene un utente decide che cosa se ne può fare.

Due archivi di utenti

SELECT name, storage, auth_type FROM system.users;

- users_xml: quelli dichiarati nei file del server (users.xml e users.d/). Si cambiano solo modificando quei file: ALTER USER su uno di loro fallisce con Code: 495 … ACCESS_STORAGE_READONLY (misurato).
- local_directory: quelli creati con CREATE USER. Si gestiscono con SQL e il server li salva sul suo disco.

Per creare utenti con SQL serve un account con la gestione degli accessi (access_management nella sua configurazione, o il permesso ACCESS MANAGEMENT).

Ruoli, GRANT e revoche parziali

CREATE ROLE lectura;
GRANT SELECT ON demo.* TO lectura;
REVOKE SELECT ON demo.eventos FROM lectura;
GRANT lectura TO ana;
SET DEFAULT ROLE lectura TO ana;

Un REVOKE su una tabella di un database concesso per intero non toglie il permesso sul database: lascia una revoca parziale. SHOW GRANTS FOR lectura mostra le due righe, e system.grants segna la seconda con is_partial_revoke = 1 (misurato). Con quel ruolo, leggere demo.productos funziona e leggere demo.eventos fallisce con Code: 497 … ACCESS_DENIED.

Quote, profili e politiche di riga
- Quote (CREATE QUOTA, system.quotas, system.quota_usage): limiti di query, righe o tempo per intervallo. Secondo la documentazione del server, superarle fa rifiutare la query con QUOTA_EXCEEDED.
- Profili di impostazioni (CREATE SETTINGS PROFILE … SETTINGS max_result_rows = 5 TO ana): l'utente li riceve a ogni connessione, e system.settings li mostra con changed = 1 (misurato).
- Politiche di riga (CREATE ROW POLICY … ON demo.eventos USING id <= 3 TO ana): il server aggiunge quel filtro a tutto ciò che l'utente legge. Misurato: count() ha dato 3 per lui e 90 per l'amministratore.

La password non va nell'URL
L'interfaccia HTTP accetta ?user=…&password=… nell'indirizzo, ma un URL finisce nella cronologia del terminale, nei log di qualsiasi proxy e negli strumenti di rete. Il server misurato la nasconde nel suo log (password=[HIDDEN]); gli altri no. Calíope manda le credenziali nelle intestazioni X-ClickHouse-User e X-ClickHouse-Key, e la query nel corpo della richiesta.

In Calíope
Utenti elenca account e ruoli con il loro archivio. Quelli di users_xml compaiono con un lucchetto e non si modificano, e una revoca parziale compare barrata e con la parola REVOKE. Scrivere richiede che l'account collegato abbia la gestione degli accessi; altrimenti i pulsanti si disattivano e una striscia ne dice il motivo.

Parole chiave: sicurezza, utente, ruolo, grant, revoke, revoca parziale, is_partial_revoke, show grants, system.users, users_xml, local_directory, access management, quota, profilo di impostazioni, settings profile, row policy, politica di riga, password, accesso negato

Configurazione del server (ClickHouse)

Le impostazioni di query rispetto a quelle del server, config.d e users.d, come rendere persistente un'impostazione quando si parla via HTTP, e le impostazioni che Calíope manda con ogni richiesta.

Si applica a: ClickHouse 25.3+

ClickHouse ha due tipi di impostazioni, e confonderle è l'errore più comune quando lo si configura.

Impostazioni di query e impostazioni del server

SELECT name, value, changed FROM system.settings WHERE changed;
SELECT name, value, changeable_without_restart FROM system.server_settings WHERE name = 'max_concurrent_queries';

- system.settings (1.817 sul server misurato): valgono per una query. Si fissano nella query (SETTINGS … alla fine), nella sessione, in un profilo o sull'utente.
- system.server_settings (447): valgono per l'intero processo, come max_concurrent_queries (1.000 per impostazione predefinita) o max_server_memory_usage. Si cambiano nella configurazione del server, e la colonna changeable_without_restart dice se si applicano senza riavviarlo: sul server misurato, 339 su 447 richiedono un riavvio.

config.d e users.d
La configurazione sta in /etc/clickhouse-server/: config.xml per il server e users.xml per utenti, profili e quote. Le proprie modifiche vanno in file separati dentro config.d/ e users.d/, che il server fonde con quelli principali; così un aggiornamento non le sovrascrive. L'immagine Docker ufficiale ne porta già uno in ciascuna cartella.

Via HTTP, SET non dura
Ogni richiesta HTTP è indipendente: un SET max_threads = 7 vale per quella richiesta, e la successiva non ce l'ha più (misurato). Un'impostazione si può mandare nella richiesta stessa (?max_threads=5) o rendere persistente sull'utente:

ALTER USER ana MODIFY SETTINGS max_threads = 3;

Misurato: da quel momento ogni nuova connessione di ana la porta, con changed = 1. Senza la parola MODIFY, ALTER USER … SETTINGS sostituisce tutte le impostazioni dell'account. E funziona solo sugli utenti creati con SQL: su uno di users_xml fallisce con ACCESS_STORAGE_READONLY.

Ciò che Calíope manda con ogni richiesta
Cinque impostazioni fisse, per leggere i numeri senza perdere precisione e per riconoscere le proprie query:
- output_format_json_quote_64bit_integers, output_format_json_quote_decimals, output_format_json_quote_64bit_floats e output_format_json_quote_denormals: gli interi a 64 bit, i decimali e valori come inf o nan arrivano tra virgolette. In JSON un intero a 64 bit non entra esatto, e senza queste impostazioni inf e nan arrivano come null.
- log_comment: segna le query di Calíope in system.query_log.

L'editor non usa session_id (solo un report con più istruzioni apre una sessione propria); il motivo è in Limiti (ClickHouse).

In Calíope
Query lente salva la sua soglia con ALTER USER … MODIFY SETTINGS. Se l'account non si può modificare, lo dice in una nota invece di mostrare i controlli.

Parole chiave: configurazione, impostazioni, settings, server settings, system.settings, system.server_settings, config.xml, users.xml, config.d, users.d, alter user, modify settings, set, max_threads, riavvio, log_comment, output_format_json_quote

Limiti (ClickHouse)

max_query_size, la memoria per query, i limiti del risultato, le query simultanee, troppe parti, il blocco di inserimento, e perché l'editor non usa session_id.

Si applica a: ClickHouse 25.3+

Il testo di una query
max_query_size è 262.144 byte per impostazione predefinita. Una query più lunga fallisce con Code: 62 … Max query size exceeded alla posizione 262.145 (misurato). Si alza per singola query con SETTINGS max_query_size = …. Il dump di Calíope raggruppa i suoi INSERT sotto quel tetto.

La memoria
max_memory_usage vale 0 (nessun limite per query) sul server misurato; il tetto vero è max_server_memory_usage, quello del processo. Con un limite basso, la query fallisce con Code: 241 … MEMORY_LIMIT_EXCEEDED, e il messaggio dice quanto stava per usare.

La dimensione del risultato
max_result_rows e max_result_bytes valgono 0 per impostazione predefinita. Con un limite impostato, result_overflow_mode = 'throw' (il valore predefinito) fa fallire la query con Code: 396 … TOO_MANY_ROWS_OR_BYTES, e 'break' restituisce ciò che è stato letto fino a quel punto.

Query contemporanee
max_concurrent_queries è del server (1.000 per impostazione predefinita) e max_concurrent_queries_for_user di ogni utente (0, senza limite). Superandolo, la nuova query fallisce con Code: 202 … TOO_MANY_SIMULTANEOUS_QUERIES (misurato con il limite a 1).

Troppe parti
Ogni INSERT crea una parte. Con 1.000 parti attive in una partizione (parts_to_delay_insert) il server rallenta gli inserimenti, e con 3.000 (parts_to_throw_insert) li rifiuta con TOO_MANY_PARTS. Se ne parla in Fusioni e OPTIMIZE (ClickHouse).

Il blocco di inserimento
Il server raggruppa le righe di un INSERT in blocchi di al massimo max_insert_block_size righe (1.048.449). Un INSERT grande che fallisce a metà lascia scritti i blocchi precedenti: non è atomico. Se ne parla in Transazioni e atomicità (ClickHouse).

Una richiesta, un'istruzione, e niente sessione
Via HTTP ogni richiesta accetta una sola istruzione: due separate da ; falliscono con Multi-statements are not allowed (misurato). L'interfaccia offre session_id per avere una sessione, e con esso un SET dura davvero da una richiesta all'altra. Ma una sessione accetta una sola query alla volta: una seconda con lo stesso session_id, mentre gira la prima, fallisce con Code: 373 … SESSION_IS_LOCKED (misurato). Per questo l'editor non la usa: due schede sulla stessa connessione riceverebbero quell'errore. Solo un report di più istruzioni apre una sessione propria, nuova a ogni esecuzione, che nessun altro condivide. Ogni richiesta porta il suo database e le sue impostazioni.

Parole chiave: limite, max_query_size, max_memory_usage, memoria, max_result_rows, max_result_bytes, result_overflow_mode, max_concurrent_queries, query simultanee, parts_to_throw_insert, too many parts, max_insert_block_size, session_id, sessione, multi-statements

Errori frequenti (ClickHouse)

I codici che si vedono ogni giorno, con numero e nome: che cosa li provoca e che cosa fare.

Si applica a: ClickHouse 25.3+

Ogni errore di ClickHouse porta un numero e un nome: Code: 60 … (UNKNOWN_TABLE). Il numero resta stabile tra le versioni, e il nome è quello da cercare nella documentazione. Via HTTP il numero arriva anche nell'intestazione X-ClickHouse-Exception-Code. Tutti gli errori di questo elenco sono stati provocati contro il server misurato.

60 · UNKNOWN_TABLE
La tabella non esiste in quel database. Via HTTP non c'è USE: il database viaggia con ogni richiesta, quindi conviene controllare il database scelto o scrivere database.tabella.

62 · SYNTAX_ERROR
Un errore di sintassi; il messaggio dice la posizione e che cosa si aspettava. Compare anche con due istruzioni in una sola richiesta (Multi-statements are not allowed) e con una query di più di 262.144 byte (Max query size exceeded).

53 · TYPE_MISMATCH
Una conversione impossibile tra tipi, come CAST(1 AS Array(UInt8)). La soluzione è la funzione di conversione giusta (toString, toUInt8OrNull…).

241 · MEMORY_LIMIT_EXCEEDED
La query ha superato il limite di memoria, e il messaggio dice quanto stava per usare. Aiuta filtrare prima, aggregare meno colonne o, se il server lo regge, alzare max_memory_usage.

202 · TOO_MANY_SIMULTANEOUS_QUERIES
Troppe query contemporanee, per l'intero server o per quell'utente. Basta aspettare; Processi mostra che cosa sta girando.

497 · ACCESS_DENIED
Manca un permesso, e il messaggio dice quale (it's necessary to have the grant SELECT ON demo.eventos). Una revoca parziale lo provoca anche se il database è concesso per intero.

164 · READONLY
La query gira in sola lettura (readonly = 1 sull'utente, sul suo profilo o nella query stessa). Serve un account che possa scrivere.

373 · SESSION_IS_LOCKED
Due richieste contemporanee con lo stesso session_id. Calíope apre una sessione solo per un report di più istruzioni, nuova a ogni esecuzione, quindi se compare viene da un altro client.

395 · FUNCTION_THROW_IF_VALUE_IS_NON_ZERO
Un throwIf della query è scattato, e il messaggio è quello che la query gli ha dato. Se la query aveva già cominciato a restituire righe, l'errore arriva alla fine della risposta: Calíope lo mostra come errore, non come un risultato troncato.

420 · CANNOT_UPDATE_COLUMN
Un ALTER TABLE … UPDATE su una colonna della chiave di ordinamento. La chiave non si cambia: si crea un'altra tabella con la chiave nuova e si copiano i dati.

495 · ACCESS_STORAGE_READONLY
Un tentativo di modificare un utente dichiarato nei file del server (users_xml). Si cambia in users.d/, oppure se ne crea un altro con SQL.

516 · AUTHENTICATION_FAILED
Utente o password sbagliati; il server non dice quale dei due. Via HTTP arriva con lo stato 403.

In Calíope
Quando uno di questi errori compare nell'editor, la striscia Vedi soluzione porta a questo argomento.

Parole chiave: errore, codice, unknown_table, syntax_error, type_mismatch, memory_limit_exceeded, too_many_simultaneous_queries, access_denied, readonly, session_is_locked, throwif, cannot_update_column, access_storage_readonly, authentication_failed, 60, 62, 53, 241, 202, 497, 164, 373, 395, 420, 495, 516

Numeri (Oracle)

NUMBER arrotonda in silenzio alla sua scala e rifiuta ciò che supera la sua precisione; BINARY_DOUBLE è binario.

Si applica a: Oracle 26ai+

In Oracle quasi ogni numero è un NUMBER: decimale, esatto e fino a 38 cifre. INTEGER e SMALLINT esistono solo come nomi —misurato: entrambe le colonne sono state create come NUMBER con scala 0—, e non ci sono né UNSIGNED né tipi per dimensione.

NUMBER(p,s): la scala arrotonda, la precisione rifiuta

CREATE TABLE n (a NUMBER(5,2), c NUMBER(3), e NUMBER(5,-2));
INSERT INTO n (a) VALUES (1.005);   -- 1.01
INSERT INTO n (a) VALUES (123.456); -- 123.46
INSERT INTO n (a) VALUES (1000);    -- ORA-01438
INSERT INTO n (c) VALUES (12.6);    -- 13
INSERT INTO n (e) VALUES (12345);   -- 12300

Ciò che va oltre la scala a destra viene arrotondato senza avviso; ciò che non sta a sinistra dà ORA-01438. Una scala negativa arrotonda a decine o centinaia, e un NUMBER semplice conserva il valore così com’è.

Esatto, e di dimensione variabile
NUMBER memorizza decimali: 0.1 + 0.2 = 0.3 è vero. Occupa in base al valore, non alla dichiarazione: misurato con VSIZE, 1 occupa 2 byte, 123.45 4 e 1234567890123 8.

BINARY_DOUBLE e BINARY_FLOAT sono binari
Sono il double e il float di IEEE 754: più veloci nei calcoli e con i soliti errori. 0.1d + 0.2d ha dato 3.0000000000000004E-001, e il confronto con 0.3d, falso. Un FLOAT semplice, invece, è un NUMBER con la precisione espressa in bit (FLOAT(126)). Per il denaro, NUMBER(p,s).

BOOLEAN, dalla 23ai

CREATE TABLE bo (v BOOLEAN);
INSERT INTO bo VALUES (TRUE), (FALSE), (NULL), ('yes'), (0);
SELECT COUNT(*) FROM bo WHERE v;  -- 2

Accetta anche 'yes' o 0, e si usa senza confronto (WHERE v). Prima della 23ai non esiste, e l’abitudine è NUMBER(1) con un CHECK.

In Calíope
La griglia mostra NUMBER esatto, senza passare dalla virgola mobile, e BOOLEAN come TRUE o FALSE.

Parole chiave: numero, NUMBER, decimale, precisione, scala, BINARY_DOUBLE, BINARY_FLOAT, FLOAT, BOOLEAN, ORA-01438, Oracle

Testo e stringa vuota (Oracle)

VARCHAR2 conta byte se non dici CHAR, ha un tetto di 4000, e la stringa vuota è NULL.

Si applica a: Oracle 26ai+

La stringa vuota è NULL
È ciò che sorprende di più arrivando da un altro motore: in Oracle '' è NULL. Misurato: '' IS NULL è vero, LENGTH('') è NULL e non 0, e inserire '' lascia la colonna a NULL, così COUNT(colonna) non la conta. Una colonna NOT NULL rifiuta '' con ORA-01400.

VARCHAR2(5) sono 5 byte, non 5 lettere
Il database del banco di prova usa AL32UTF8 con NLS_LENGTH_SEMANTICS = BYTE: la lunghezza di una colonna si conta in byte salvo indicazione contraria.

CREATE TABLE t (b VARCHAR2(5), ch VARCHAR2(5 CHAR));
INSERT INTO t (b)  VALUES ('ñandú'); -- ORA-12899 (actual: 7, maximum: 5)
INSERT INTO t (ch) VALUES ('ñandú'); -- OK
SELECT LENGTH('ñandú'), LENGTHB('ñandú') FROM dual; -- 5, 7

Ogni lettera accentata o ñ occupa 2 byte. Per il testo con accenti, VARCHAR2(n CHAR), che è ciò che scrive il Builder di Calíope.

Il tetto: 4000 byte, oppure CLOB
Con MAX_STRING_SIZE = STANDARD, il valore dell’immagine, VARCHAR2(4001) dà ORA-00910, e concatenare più di 4000 byte in SQL dà ORA-01489. Oltre si usa CLOB: concatenando su TO_CLOB(…) sono usciti 8000 caratteri. Passare a EXTENDED è un cambiamento dell’intero database, non un’impostazione di sessione.

CHAR riempie di spazi
CHAR(5) con 'ab' memorizza 'ab ' (LENGTH 5), eppure = 'ab' è vero, perché Oracle riempie il letterale prima di confrontare. CHAR non serve quasi mai: VARCHAR2 non riempie nulla.

NVARCHAR2 e NCLOB
Usano il set nazionale, AL16UTF16 sul banco di prova. Con un database AL32UTF8 servono solo per applicazioni che già li usano.

In Calíope
Un parametro con la stringa vuota viaggia come NULL, che è ciò che farebbe comunque il server.

Parole chiave: testo, VARCHAR2, CHAR, CLOB, NVARCHAR2, stringa vuota, NULL, BYTE, semantica CHAR, AL32UTF8, ORA-12899, ORA-01400, ORA-01489, Oracle

Date e ore (Oracle)

DATE memorizza l’ora, TIMESTAMP le frazioni, e il fuso orario esiste in due tipi diversi.

Si applica a: Oracle 26ai+

DATE ha l’ora
DATE memorizza anno, mese, giorno, ora, minuto e secondo, in 7 byte. Ciò che non memorizza sono le frazioni: TIMESTAMP '2026-10-16 14:30:05.987' in un DATE è diventato 14:30:05. Per questo ciò che segue fallisce senza avviso:

SELECT COUNT(*) FROM f WHERE d = DATE '2026-10-16';   -- 0
SELECT COUNT(*) FROM f WHERE d >= DATE '2026-10-16'
                         AND d <  DATE '2026-10-17';  -- 1

La prima query confronta con la mezzanotte. Per un giorno intero, l’intervallo semiaperto.

TIMESTAMP e i suoi due fusi
- TIMESTAMP(3) memorizza frazioni e arrotonda: .98765 è diventato .988. Occupa 11 byte.
- TIMESTAMP WITH TIME ZONE memorizza l’istante e lo scostamento con cui è stato scritto: 14:30 +05:30 torna come 14:30 +05:30. Occupa 13 byte.
- TIMESTAMP WITH LOCAL TIME ZONE memorizza l’istante e lo restituisce nel fuso della sessione: lo stesso 14:30 +05:30, letto in una sessione a −06:00, ha dato 03:00. Ciò che si vede dipende da chi chiede.

Aritmetica
Sottrarre due DATE dà giorni (DATE '2026-10-17' - DATE '2026-10-16' vale 1), e sommare 1/24 aggiunge un’ora. Sottrarre due TIMESTAMP dà un intervallo. Un mese non è una durata fissa: ADD_MONTHS(DATE '2026-01-31', 1) ha dato 2026-02-28, ma DATE '2026-01-31' + INTERVAL '1' MONTH ha dato ORA-01839.

I letterali, sempre ANSI
Il NLS_DATE_FORMAT della sessione era DD-MON-RR, e TO_DATE('16-OCT-26') senza maschera ha indovinato solo perché coincidevano. Con un altro formato o un’altra lingua di sessione, lo stesso testo fallisce o dà un’altra data. DATE '2026-10-16' e TIMESTAMP '2026-10-16 14:30:05' non dipendono da nulla.

In Calíope
Calíope scrive letterali ANSI quando genera SQL, e la griglia mostra DATE con la sua ora, WITH TIME ZONE con il suo scostamento e WITH LOCAL TIME ZONE nel fuso della sessione.

Parole chiave: data, ora, DATE, TIMESTAMP, WITH TIME ZONE, WITH LOCAL TIME ZONE, INTERVAL, ADD_MONTHS, NLS_DATE_FORMAT, ORA-01839, Oracle

Indici B-tree (Oracle)

L’indice normale di Oracle: quando l’ottimizzatore lo usa e perché una riga senza valore non c’è.

Si applica a: Oracle 26ai+

Un semplice CREATE INDEX crea un indice B-tree. Misurato su una tabella cli di 20 000 righe, con statistiche:

CREATE INDEX ix_cli_email ON cli (email);
-- WHERE email = 'u77@x.es'     → INDEX RANGE SCAN IX_CLI_EMAIL
-- WHERE email LIKE 'u77%'      → INDEX RANGE SCAN IX_CLI_EMAIL
-- WHERE email LIKE '%77@x.es'  → TABLE ACCESS FULL
-- WHERE UPPER(email) = '…'     → TABLE ACCESS FULL

Un LIKE che inizia con un carattere jolly non può scendere lungo l’albero, e nemmeno una funzione sulla colonna: l’indice è ordinato per email, non per UPPER(email). A questo serve l’indice su funzione («Indici su funzione, invisibili e bitmap (Oracle)»).

Le righe senza valore non sono nell’indice
Un B-tree non memorizza le righe la cui chiave è interamente NULL. Con 200 date di uscita e 19 800 NULL, WHERE baja IS NULL è andato in TABLE ACCESS FULL: l’indice non ha quelle righe. In un indice su più colonne basta che una non sia NULL perché la riga entri.

In un indice composto comanda la prima colonna
Un indice su (estado, pais) è ordinato per estado e, all’interno, per pais. Misurato con pais = 'ES' da sola, l’ottimizzatore ha letto tutta la tabella. La colonna su cui si filtra sempre va davanti.

L’ottimizzatore decide con le statistiche
Se la condizione restituisce una parte grande della tabella, leggerla tutta costa meno che andare avanti e indietro dall’indice, e Oracle lo fa. Senza statistiche aggiornate decide male: dopo aver caricato dati, DBMS_STATS.GATHER_TABLE_STATS.

Le chiavi portano il loro indice; le esterne no
Una chiave primaria o univoca crea il suo indice («Chiavi primarie e univoche (Oracle)»). Una chiave esterna no («Chiavi esterne (Oracle)»).

In Calíope
Visual EXPLAIN mostra questi nodi così come sono, Cataloghi elenca gli indici con le loro colonne, e Manutenzione tabelle → ANALYZE raccoglie le statistiche.

Parole chiave: indice, B-tree, CREATE INDEX, INDEX RANGE SCAN, TABLE ACCESS FULL, NULL, composto, ottimizzatore, DBMS_STATS, Oracle

Indici su funzione, invisibili e bitmap (Oracle)

Indicizzare un’espressione, spegnere un indice senza eliminarlo, e quando un bitmap dà fastidio.

Si applica a: Oracle 26ai+

Indice su funzione

CREATE INDEX ix_cli_email_up ON cli (UPPER(email));
-- WHERE UPPER(email) = 'U77@X.ES' → INDEX RANGE SCAN IX_CLI_EMAIL_UP

Indicizza il risultato dell’espressione, e la query deve scrivere la stessa: UPPER(email) lo usa, LOWER(email) no. Oracle lo tiene come una colonna virtuale nascosta —ALL_IND_COLUMNS mostra SYS_NC00005$— e l’espressione si trova in ALL_IND_EXPRESSIONS (UPPER("EMAIL")). È il modo di cercare senza distinguere maiuscole senza toccare i dati.

Indice invisibile
ALTER INDEX ix_cli_email INVISIBLE lo lascia mantenuto ma fuori dalla vista dell’ottimizzatore: misurato, la query per email è passata da INDEX RANGE SCAN a TABLE ACCESS FULL. Serve per verificare se un indice è necessario prima di eliminarlo: se nulla peggiora, si elimina; se qualcosa peggiora, VISIBLE lo riporta subito, senza ricostruirlo.

Indice bitmap
CREATE BITMAP INDEX … (estado) è stato creato su 26ai Free. Memorizza una mappa di bit per valore, molto compatta con pochi valori distinti, e combina più condizioni nelle query di analisi. Ma ogni riga scritta blocca un tratto della mappa che copre molte righe: per tabelle con scritture concorrenti, B-tree.

Che cos’è ciascuno
ALL_INDEXES.INDEX_TYPE dice NORMAL, FUNCTION-BASED NORMAL, BITMAP…, e VISIBILITY dice se è visibile. Gli indici di dominio (testo, spaziali) sono un’altra famiglia, con il loro tipo.

In Calíope
Cataloghi mostra l’indice su funzione con la sua espressione, e quelli di dominio con il loro tipo. Il consulente degli indici propone un B-tree su una colonna, e rinuncia davanti a un LIKE che inizia con un carattere jolly.

Parole chiave: indice, funzione, FUNCTION-BASED, UPPER, INVISIBLE, VISIBLE, BITMAP, dominio, ALL_IND_EXPRESSIONS, Oracle

Indici inutilizzabili e ricostruzione (Oracle)

Spostare una tabella rende i suoi indici UNUSABLE: come vederlo, che cosa rompe e come ripararlo.

Si applica a: Oracle 26ai+

Un indice punta a ogni riga tramite il suo ROWID, che dice dove si trova fisicamente. Tutto ciò che sposta le righe lascia l’indice a puntare nel vuoto, e Oracle lo marca UNUSABLE invece di dare risultati sbagliati.

Che cosa lo provoca

ALTER TABLE cli MOVE;
SELECT index_name, status FROM all_indexes
 WHERE owner = 'DBA_MANUAL' AND table_name = 'CLI';
-- IX_CLI_BAJA UNUSABLE, IX_CLI_EMAIL UNUSABLE, … SYS_C… UNUSABLE

Misurato: tutti e cinque gli indici della tabella, compreso quello della chiave primaria, sono diventati UNUSABLE. Lo stesso fa eliminare o svuotare una partizione senza UPDATE INDEXES («Pruning e manutenzione delle partizioni (Oracle)»).

Che cosa rompe
L’ottimizzatore lo ignora e le query continuano a funzionare, più lentamente: un SELECT per la chiave primaria inutilizzabile ha risposto. Il caso grave è un indice univoco: con la chiave primaria UNUSABLE, un INSERT ha dato ORA-01502. La tabella smette di accettare nuove righe.

Ripararlo
ALTER INDEX ix_cli_email REBUILD lo ricostruisce: misurato, quello è passato a VALID e gli altri sono rimasti UNUSABLE. Si ricostruiscono uno a uno.

Meglio non romperlo: MOVE ONLINE
ALTER TABLE cli MOVE ONLINE sposta la tabella mantenendo gli indici: misurato sulla stessa tabella, tutti e cinque sono rimasti VALID. È il modo di compattare una tabella o di cambiarle tablespace senza lasciare nulla di rotto.

Come sta ciascuno
Con le statistiche, ALL_INDEXES dà NUM_ROWS, DISTINCT_KEYS e LEAF_BLOCKS: IX_CLI_EMAIL aveva 20 000 chiavi distinte in 63 blocchi foglia; IX_CLI_BAJA, 200 righe e una sola chiave, perché i 19 800 NULL non ci sono. La dimensione su disco è in DBA_SEGMENTS.

In Calíope
Cataloghi segna con il suo stato l’indice che non è VALID.

Parole chiave: indice, UNUSABLE, VALID, REBUILD, MOVE, MOVE ONLINE, ROWID, ORA-01502, LEAF_BLOCKS, DISTINCT_KEYS, Oracle

Chiavi primarie e univoche (Oracle)

Ognuna crea il suo indice, una chiave univoca lascia passare più NULL, e un vincolo può aspettare il COMMIT.

Si applica a: Oracle 26ai+

Ogni vincolo, il suo indice
Una PRIMARY KEY o una UNIQUE crea un indice univoco se non ce n’è già uno adatto. Senza nome, il vincolo e il suo indice si chiamano SYS_C… (misurato: SYS_C0010145, UNIQUE). Date loro un nome —CONSTRAINT pk_cli PRIMARY KEY (id)—: è quello che compare negli errori.

UNIQUE e NULL

CREATE TABLE u (a NUMBER, CONSTRAINT uq_u UNIQUE (a));
INSERT INTO u VALUES (NULL); -- OK
INSERT INTO u VALUES (NULL); -- OK
INSERT INTO u VALUES (1);    -- OK
INSERT INTO u VALUES (1);    -- ORA-00001

Più NULL ci stanno, perché le chiavi interamente NULL non entrano nell’indice. Ma in una chiave univoca su più colonne in cui una ha un valore, no: (1, NULL) due volte ha dato ORA-00001.

Vincoli differiti

CREATE TABLE dfr (id NUMBER,
  CONSTRAINT dfr_pk PRIMARY KEY (id) DEFERRABLE INITIALLY DEFERRED);
INSERT INTO dfr VALUES (1);
INSERT INTO dfr VALUES (1);  -- OK
COMMIT;                      -- ORA-02091: transaction rolled back

Un vincolo DEFERRABLE INITIALLY DEFERRED si verifica al commit, non a ogni istruzione, e se fallisce annulla l’intera transazione: misurato, la tabella è rimasta vuota. Serve per scambiare chiavi o caricare tabelle collegate in qualsiasi ordine. Il suo indice non è univoco (NONUNIQUE), perché a metà transazione possono esserci duplicati.

L’errore dice dove
Su 26ai, ORA-00001 nomina il vincolo, la tabella e le colonne: unique constraint (DBA_MANUAL.UQ_U) violated on table DBA_MANUAL.U columns (A).

In Calíope
Il testo dell’errore arriva così com’è nell’editor, con il nome del vincolo.

Parole chiave: chiave primaria, PRIMARY KEY, UNIQUE, NULL, DEFERRABLE, INITIALLY DEFERRED, ORA-00001, ORA-02091, SYS_C, Oracle

Chiavi esterne (Oracle)

Non creano indici, non hanno ON UPDATE, e si spengono e riaccendono senza eliminarle.

Si applica a: Oracle 26ai+

CREATE TABLE ped (id NUMBER PRIMARY KEY,
                  cli_id NUMBER CONSTRAINT fk_ped_cli REFERENCES cli (id));
INSERT INTO ped VALUES (1, 999999); -- ORA-02291
DELETE FROM cli WHERE id = 6;       -- ORA-02292

ORA-02291 è un figlio senza genitore; ORA-02292, eliminare un genitore che ha figli.

Non viene creato alcun indice
Misurato: dopo aver creato ped con la sua chiave esterna, non c’era alcun indice su ped.cli_id, e cercare gli ordini di un cliente (WHERE cli_id = 5) ha letto tutta la tabella. È ciò che fa Oracle per ogni genitore eliminato o la cui chiave cambia. Quasi sempre serve CREATE INDEX ix_ped_cli ON ped (cli_id) dopo ogni chiave esterna.

Niente ON UPDATE
Esistono solo ON DELETE CASCADE e ON DELETE SET NULL; ON UPDATE CASCADE dà ORA-02000: missing DELETE keyword. In Oracle una chiave primaria non si cambia, e se va cambiata lo si fa a mano o con un vincolo differito.

ON DELETE CASCADE
Con la chiave esterna dichiarata così, eliminare il cliente 5 ha eliminato anche il suo ordine, senza chiedere. Comodo e pericoloso: un DELETE di una riga può scendere di più livelli.

Spegnerla senza eliminarla

ALTER TABLE ped DISABLE CONSTRAINT fk_ped_cli;
INSERT INTO ped VALUES (30000, 999999);           -- OK
ALTER TABLE ped ENABLE CONSTRAINT fk_ped_cli;     -- ORA-02298
ALTER TABLE ped ENABLE NOVALIDATE CONSTRAINT fk_ped_cli;
INSERT INTO ped VALUES (30001, 999999);           -- ORA-02291

DISABLE lascia scrivere qualunque cosa; ENABLE verifica tutto ciò che c’è e, con una riga orfana, dà ORA-02298. ENABLE NOVALIDATE la riaccende solo per le righe nuove: resta NOT VALIDATED, e l’orfana successiva fallisce già. Con la chiave esterna attiva, TRUNCATE della tabella genitore dà ORA-02266.

In Calíope
Il Diagramma ER disegna le chiavi esterne, anche quelle tra schemi diversi, e il DDL che scrive Calíope le crea alla fine, dopo tutte le tabelle.

Parole chiave: chiave esterna, FOREIGN KEY, REFERENCES, ON DELETE CASCADE, ON DELETE SET NULL, ON UPDATE, NOVALIDATE, indice, ORA-02291, ORA-02292, ORA-02298, ORA-02266, Oracle

Identità e sequenze (Oracle)

ALWAYS non lascia scrivere il valore, BY DEFAULT sì, e una sequenza lascia buchi per scelta.

Si applica a: Oracle 26ai+

Colonne di identità

CREATE TABLE ia (id NUMBER GENERATED ALWAYS AS IDENTITY, v VARCHAR2(5));
INSERT INTO ia (id, v) VALUES (100, 'b'); -- ORA-32795
UPDATE ia SET id = 5;                      -- ORA-32796

CREATE TABLE ib (id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY,
                 v VARCHAR2(5));
INSERT INTO ib (id, v) VALUES (100, 'b');  -- 100
INSERT INTO ib (id, v) VALUES (NULL, 'c'); -- 1

- ALWAYS: il valore lo mette sempre Oracle; scriverlo dà ORA-32795 e cambiarlo, ORA-32796.
- BY DEFAULT: lo mette Oracle se non lo dai tu; con ON NULL, anche se dai NULL.

Attenzione con BY DEFAULT: il contatore non sa che hai scritto 100, e quando ci arriverà andrà in conflitto con la chiave primaria. Dentro, l’identità è una sequenza di sistema (ISEQ$$_…, in ALL_TAB_IDENTITY_COLS).

Sequenze

CREATE SEQUENCE sq START WITH 1 CACHE 20;
SELECT sq.CURRVAL FROM dual;              -- ORA-08002
SELECT sq.NEXTVAL, sq.NEXTVAL FROM dual;  -- 1, 1
SELECT sq.NEXTVAL FROM dual;              -- 2

- CURRVAL è l’ultimo NEXTVAL della tua sessione: prima del primo dà ORA-08002.
- Due NEXTVAL nella stessa riga danno lo stesso valore.
- DEFAULT sq.NEXTVAL su una colonna la rende un autoincremento, e la sequenza si può condividere tra tabelle.

I buchi sono normali
Un NEXTVAL annullato con ROLLBACK non viene restituito: misurato, è uscito 1, è stato annullato, e il successivo è stato 2. Con CACHE 20, Oracle riserva venti numeri in memoria (LAST_NUMBER segnava già 21 dopo aver usato il 2), e se l’istanza si riavvia, quelli non usati si perdono. Un buco non è un dato perso. Se serve una numerazione senza buchi —le fatture—, non viene da una sequenza.

In Calíope
La casella Identità (IDENTITY) del Builder crea GENERATED BY DEFAULT AS IDENTITY, e Cataloghi elenca le sequenze nella loro scheda, senza quelle interne delle identità.

Parole chiave: identità, IDENTITY, GENERATED ALWAYS, BY DEFAULT, sequenza, SEQUENCE, NEXTVAL, CURRVAL, CACHE, buchi, ORA-32795, ORA-08002, Oracle

Partizionamento per range e intervallo (Oracle)

Una tabella partizionata per data, l’intervallo che crea le partizioni da solo, e che cosa succede a una riga che non ci sta.

Si applica a: Oracle 26ai+

Una tabella partizionata è una sola tabella per chi la interroga e diverse all’interno: ogni partizione è un proprio segmento, che si può leggere, svuotare o eliminare separatamente.

Range con intervallo

CREATE TABLE ventas (id NUMBER, fecha DATE, pais VARCHAR2(2),
                     importe NUMBER(10,2))
  PARTITION BY RANGE (fecha)
  INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
  (PARTITION p_ini VALUES LESS THAN (DATE '2026-01-01'));

Si dichiara solo la prima partizione. Con INTERVAL, Oracle crea una partizione nuova la prima volta che arriva una riga di un mese che non ce l’ha: misurato, 30 000 righe distribuite su dieci mesi hanno lasciato 11 partizioni, l’iniziale e dieci con nomi di sistema (SYS_P696, SYS_P697…), ognuna con il suo limite in HIGH_VALUE.

Ogni partizione è un segmento, e non piccolo
Una partizione con una sola riga ha occupato 8 MB: il primo blocco di spazio di una partizione è molto più grande di quello di una tabella normale. Con partizioni giornaliere o molto fini, lo spazio vuoto si accumula.

Senza intervallo, il buco è un errore
In un range o in una lista senza intervallo, una riga che non cade in nessuna partizione dà ORA-14400: no partition found. Si evita con INTERVAL, con un’ultima partizione VALUES LESS THAN (MAXVALUE) oppure, in una lista, con DEFAULT («Partizionamento per lista, hash e automatico (Oracle)»).

La chiave di partizione non cambia da sola
Un UPDATE della colonna di partizione che sposterebbe la riga in un’altra partizione dà ORA-14402. ALTER TABLE … ENABLE ROW MOVEMENT lo permette: la riga cambia partizione e ROWID.

Dove guardare
ALL_TAB_PARTITIONS: nome, posizione, HIGH_VALUE e, con le statistiche, NUM_ROWS per partizione. ALL_PART_TABLES: il tipo e la chiave.

In Calíope
Utilizzo HDD somma ciò che occupano tutte le partizioni della tabella.

Parole chiave: partizione, RANGE, INTERVAL, NUMTOYMINTERVAL, HIGH_VALUE, MAXVALUE, ROW MOVEMENT, ORA-14400, ORA-14402, ALL_TAB_PARTITIONS, Oracle

Partizionamento per lista, hash e automatico (Oracle)

Lista per valori noti, AUTOMATIC perché cresca da sola, e hash per distribuire.

Si applica a: Oracle 26ai+

Lista

CREATE TABLE vl (id NUMBER, pais VARCHAR2(2))
  PARTITION BY LIST (pais)
  (PARTITION p_es VALUES ('ES'),
   PARTITION p_am VALUES ('MX', 'AR'));
INSERT INTO vl VALUES (1, 'CL'); -- ORA-14400
ALTER TABLE vl ADD PARTITION p_otros VALUES (DEFAULT);
INSERT INTO vl VALUES (1, 'CL'); -- p_otros

Per una colonna con pochi valori noti: paese, regione, stato. Una partizione può avere più valori, e DEFAULT raccoglie il resto.

Lista automatica
PARTITION BY LIST (pais) AUTOMATIC crea una partizione per ogni valore nuovo, come fa l’intervallo con il range: misurato, inserire 'PE' in una tabella che aveva solo p_es l’ha lasciata con due partizioni.

Hash

CREATE TABLE vh (id NUMBER) PARTITION BY HASH (id) PARTITIONS 4;
-- 10000 → 2471, 2527, 2521, 2481
-- PARTITIONS 3 → 2471, 5008, 2521

Distribuisce secondo il valore dell’hash. Con 4 partizioni, 10 000 righe si sono distribuite quasi alla pari; con 3, una partizione ne ha ricevute il doppio delle altre due. Usa un numero di partizioni che sia una potenza di due.

L’hash non serve a eliminare dati vecchi né a leggere solo un intervallo; serve a distribuire una tabella molto scritta, o a unire due tabelle partizionate sulla stessa chiave.

Composte
Si combinano: range per data e, dentro ogni mese, lista o hash (SUBPARTITION BY). Ogni sottopartizione è un segmento, e ogni segmento occupa il suo spazio minimo («Partizionamento per range e intervallo (Oracle)»): mesi per sottopartizioni si moltiplica in fretta.

Quale scegliere
Range o intervallo se si interroga e si elimina per data, che è quasi sempre il caso; lista se si filtra per un valore tra pochi possibili; hash se si vuole solo distribuire.

Parole chiave: partizione, LIST, AUTOMATIC, DEFAULT, HASH, SUBPARTITION, composta, potenza di due, ORA-14400, Oracle

Pruning e manutenzione delle partizioni (Oracle)

Quando Oracle legge una sola partizione, e come eliminare un mese senza lasciare indici inutilizzabili.

Si applica a: Oracle 26ai+

Il pruning
Se la condizione lascia capire quali partizioni possono avere righe, Oracle legge solo quelle. Misurato con l’EXPLAIN di ventas:

SELECT SUM(importe) FROM ventas
 WHERE fecha >= DATE '2026-03-01' AND fecha < DATE '2026-04-01';
-- PARTITION RANGE SINGLE

SELECT SUM(importe) FROM ventas
 WHERE TO_CHAR(fecha, 'YYYY-MM') = '2026-03';
-- PARTITION RANGE ALL

La stessa cosa scritta in un altro modo legge tutte e dieci le partizioni: una funzione sulla colonna di partizione impedisce il pruning, come impedisce di usare un indice. La condizione, direttamente sulla colonna e come intervallo semiaperto.

Eliminare un mese intero
ALTER TABLE ventas DROP PARTITION FOR (DATE '2026-02-15') elimina in un colpo la partizione che contiene quella data, senza un DELETE riga per riga. È il motivo principale per partizionare per data. TRUNCATE PARTITION la svuota e la mantiene.

Gli indici globali diventano inutilizzabili

CREATE INDEX ix_ventas_pais ON ventas (pais);       -- global
CREATE INDEX ix_ventas_id   ON ventas (id) LOCAL;
ALTER TABLE ventas DROP PARTITION FOR (DATE '2026-02-15');
-- IX_VENTAS_PAIS: UNUSABLE
-- IX_VENTAS_ID:   USABLE (10/10)

Un indice locale è partizionato come la tabella, e il suo pezzo se ne va con la partizione. Uno globale le copre tutte, ed eliminarne una lo rende UNUSABLE («Indici inutilizzabili e ricostruzione (Oracle)»). Con UPDATE INDEXES alla fine del DROP o del TRUNCATE, Oracle lo mantiene: misurato, il globale è rimasto VALID. Ci vuole di più, ma non lascia nulla di rotto.

Quale dei due
Locale quando le query filtrano anche per la colonna di partizione; globale quando si cerca per qualcosa che non c’entra, come un identificatore univoco.

In Calíope
Visual EXPLAIN mostra i nodi PARTITION RANGE SINGLE o PARTITION RANGE ALL, che dicono se c’è stato pruning.

Parole chiave: partizione, pruning, PARTITION RANGE SINGLE, DROP PARTITION, TRUNCATE PARTITION, UPDATE INDEXES, LOCAL, globale, UNUSABLE, Oracle

Tablespace e quote (Oracle)

Dove vivono i dati, perché un utente senza quota non può inserire, e come leggere l’utilizzo.

Si applica a: Oracle 26ai+

Un tablespace è il contenitore logico dei dati, con uno o più file sotto. Ogni tabella e ogni indice vive in uno, e ogni utente ne ha uno predefinito.

Quelli del database del banco di prova
Su 26ai Free: SYSTEM e SYSAUX (il dizionario e i componenti di Oracle, da non toccare), UNDOTBS1 (l’annullamento), TEMP (per ordinare ciò che non sta in memoria) e USERS, quello che ricevono gli utenti. I file crescono da soli: DBA_DATA_FILES indicava AUTOEXTENSIBLE = YES per tutti.

La quota
Creare un utente non gli dà spazio. Misurato:

CREATE USER dba_sincuota IDENTIFIED BY "…";
GRANT CREATE SESSION, CREATE TABLE TO dba_sincuota;
CREATE TABLE dba_sincuota.x (id NUMBER);  -- OK
INSERT INTO dba_sincuota.x VALUES (1);    -- ORA-01950

La tabella viene creata perché non occupa ancora nulla («Segmenti ed extent (Oracle)»), e il primo INSERT fallisce con ORA-01950. Si risolve con ALTER USER dba_sincuota QUOTA 500M ON users, o con QUOTA UNLIMITED.

Quanto si usa

SELECT tablespace_name, used_percent
  FROM dba_tablespace_usage_metrics;

USED_PERCENT si calcola rispetto alla dimensione massima a cui il tablespace può crescere, non rispetto al file di oggi: USERS indicava 0,16 % con circa 250 MB usati. Un tablespace che cresce da solo può riempire il disco del server molto prima di arrivare al suo massimo, quindi il disco si guarda a parte. USED_SPACE e TABLESPACE_SIZE sono in blocchi, da 8192 byte su questo database.

In Calíope
La Dashboard mostra Spazio più pieno, il tablespace con il USED_PERCENT più alto, e il Monitor di salute lo raccoglie a ogni ciclo.

Parole chiave: tablespace, quota, QUOTA, USERS, SYSTEM, SYSAUX, TEMP, UNDO, DBA_DATA_FILES, AUTOEXTENSIBLE, DBA_TABLESPACE_USAGE_METRICS, USED_PERCENT, ORA-01950, Oracle

Segmenti ed extent (Oracle)

Ogni tabella, indice, partizione o LOB è un segmento; una tabella vuota non ne ha.

Si applica a: Oracle 26ai+

Un segmento è lo spazio che occupa un oggetto: una tabella, un indice, una partizione o un LOB. Cresce per extent, e ogni extent è fatto di blocchi da 8 KB su questo database.

Una tabella vuota non occupa
Con DEFERRED_SEGMENT_CREATION = TRUE, il valore dell’immagine, creare una tabella non riserva nulla:

CREATE TABLE vacia (id NUMBER);
-- DBA_SEGMENTS: 0
INSERT INTO vacia VALUES (1);
-- DBA_SEGMENTS: 64 KB, 1 extent, 8 blocks

SEGMENT CREATION IMMEDIATE lo riserva alla creazione (anche qui 64 KB). È il motivo per cui una tabella senza quota viene creata e fallisce all’inserimento («Tablespace e quote (Oracle)»).

Una tabella con LOB sono tre segmenti
Una tabella con una colonna CLOB ne ha dati tre: la tabella (TABLE), i dati del LOB (LOBSEGMENT, con nome SYS_LOB…) e il suo indice (LOBINDEX, SYS_IL…). Su 26ai un LOB è SECUREFILE per impostazione predefinita.

Contarlo bene
La dimensione di una tabella è la somma dei suoi segmenti: i suoi, quelli delle sue partizioni, dei suoi LOB e dei suoi indici. DBA_SEGMENTS (o USER_SEGMENTS, per i propri) li dà per SEGMENT_NAME e SEGMENT_TYPE; quelli di un LOB si associano alla loro tabella tramite DBA_LOBS.

Il cestino
DROP TABLE non elimina: rinomina la tabella in BIN$… e la lascia nel cestino con il suo spazio (DBA_RECYCLEBIN la elencava). FLASHBACK TABLE x TO BEFORE DROP l’ha restituita con la sua riga. DROP TABLE x PURGE o PURGE RECYCLEBIN liberano davvero.

In Calíope
Utilizzo HDD somma, per tabella, i suoi segmenti, quelli delle sue partizioni, dei suoi LOB e dei suoi indici: con DBA_SEGMENTS se si può leggere, altrimenti con USER_SEGMENTS.

Parole chiave: segmento, extent, DBA_SEGMENTS, USER_SEGMENTS, DEFERRED_SEGMENT_CREATION, LOBSEGMENT, SECUREFILE, cestino, RECYCLEBIN, FLASHBACK, PURGE, Oracle

Spazio che non torna: DELETE, TRUNCATE e SHRINK (Oracle)

Eliminare righe non restituisce spazio né accelera una lettura completa; svuotare o compattare sì.

Si applica a: Oracle 26ai+

Oracle riempie una tabella fino a un segno, la high water mark, e una lettura completa percorre tutto ciò che sta sotto, che ci siano righe o no.

DELETE non restituisce nulla

-- 50 000 × ~200 bytes → DBA_SEGMENTS: 12 MB, 1536 blocks
DELETE FROM hw;
-- DBA_SEGMENTS: 12 MB, 1536 blocks

Dopo aver eliminato tutte le righe e raccolto le statistiche, ALL_TABLES indicava NUM_ROWS 0 e BLOCKS 1504: un SELECT COUNT(*) continua a leggere quei blocchi vuoti (TABLE ACCESS FULL). Lo spazio si riusa per righe nuove, ma non torna al tablespace.

TRUNCATE sì
TRUNCATE TABLE svuota la tabella e abbassa il segno: misurato, 50 000 righe nuove e un TRUNCATE hanno lasciato il segmento a 0,06 MB e 8 blocchi. È DDL: ROLLBACK non lo annulla, e conferma ciò che la sessione aveva in sospeso (misurato: un INSERT in un’altra tabella è stato confermato). Se una chiave esterna attiva punta alla tabella, fallisce con ORA-02266.

SHRINK SPACE compatta senza svuotare

ALTER TABLE hw ENABLE ROW MOVEMENT;
ALTER TABLE hw SHRINK SPACE;
-- DBA_SEGMENTS: 0.06 MB, 8 blocks

Sposta le righe all’inizio e restituisce il resto. Richiede ROW MOVEMENT perché cambia i ROWID. L’altra strada è ALTER TABLE … MOVE ONLINE, che riscrive la tabella e mantiene gli indici («Indici inutilizzabili e ricostruzione (Oracle)»).

Quando ne vale la pena
Dopo aver eliminato una gran parte di una tabella che non tornerà a crescere, o quando una lettura completa impiega molto più di quanto il suo numero di righe farebbe pensare. Se la tabella si riempie di nuovo, compattarla non serve: lo spazio sarebbe stato riusato comunque.

In Calíope
Utilizzo HDD dà la dimensione dei segmenti, non quella delle righe: una tabella con 0 righe e 12 MB è una tabella svuotata con DELETE. Manutenzione tabelle → ANALYZE raccoglie le statistiche che dicono quante righe restano.

Parole chiave: spazio, high water mark, DELETE, TRUNCATE, SHRINK SPACE, ROW MOVEMENT, MOVE ONLINE, BLOCKS, ORA-02266, Oracle

Confermare, annullare e il DDL che conferma (Oracle)

Oracle non conferma nulla da solo: COMMIT, ROLLBACK, i punti di salvataggio e il DDL che conferma ciò che è in sospeso.

Si applica a: Oracle 26ai+

In Oracle una transazione inizia da sola, con la prima scrittura, e finisce con COMMIT o ROLLBACK. Non esiste BEGIN, e il server non conferma nulla da solo: l'autocommit lo attiva, o no, il client.

Ciò che è in sospeso lo vede solo chi l'ha scritto
Misurato con due sessioni: dopo un INSERT non confermato, l'altra sessione contava 5 righe; dopo il COMMIT, 6. E una sessione che si chiude senza confermare annulla il proprio lavoro: la riga che aveva scritto non è mai esistita per nessuno.

Il DDL conferma ciò che era in sospeso

INSERT INTO cuenta VALUES (7, 70);
CREATE TABLE otra (x NUMBER);  -- COMMIT
ROLLBACK;
SELECT COUNT(*) FROM cuenta WHERE id = 7;  -- 1

CREATE, ALTER, DROP, TRUNCATE e GRANT confermano la transazione aperta prima di eseguirsi. Il ROLLBACK successivo non aveva più nulla da annullare.

Punti di salvataggio

INSERT INTO cuenta VALUES (8, 80);
SAVEPOINT antes_del_9;
INSERT INTO cuenta VALUES (9, 90);
ROLLBACK TO SAVEPOINT antes_del_9;
COMMIT;
SELECT id FROM cuenta WHERE id IN (8, 9);  -- 8

ROLLBACK TO SAVEPOINT annulla ciò che viene dopo il punto e lascia viva la transazione.

Se un'istruzione fallisce, si annulla solo lei

INSERT INTO cuenta VALUES (10, 10);  -- OK
INSERT INTO cuenta VALUES (10, 10);  -- ORA-00001
COMMIT;
SELECT COUNT(*) FROM cuenta WHERE id = 10;  -- 1

L'errore non butta via la transazione: ciò che precede resta in sospeso e il COMMIT lo salva. Chi vuole tutto o niente annulla quando riceve l'errore.

In Calíope
L'Editor SQL conferma ogni esecuzione. Per lavorare in una transazione, Transazione → BEGIN: da lì nulla si conferma fino a COMMIT o ROLLBACK, e se un DDL la chiude a metà, la barra di stato lo dice: «La transazione non è più aperta: l'ha chiusa lo stesso SQL, oppure il server l'ha annullata dopo un errore. Ciò che esegui ora viene confermato subito.». Il suggerimento «Il server non conferma da solo» lo ricorda la prima volta.

Parole chiave: transazione, COMMIT, ROLLBACK, SAVEPOINT, autocommit, DDL, confermare, annullare, Oracle

Lettura coerente (Oracle)

Chi legge non aspetta chi scrive: ogni query vede i dati confermati quando è partita.

Si applica a: Oracle 26ai+

Oracle non blocca le letture. Ogni query vede il database com'era confermato quando la query è partita, anche se un'altra sessione sta cambiando proprio quelle righe.

Chi scrive non ferma chi legge

-- A
UPDATE cuenta SET saldo = 0 WHERE id = 1;
-- B
SELECT saldo FROM cuenta WHERE id = 1;  -- 100 (0.01 s)

Mentre A aveva cambiato la riga senza confermare, B ha letto subito il valore precedente, senza aspettare e senza vedere la modifica a metà. Per darlo, Oracle ricostruisce la versione precedente dall'undo che la transazione di A ha conservato.

Ogni istruzione vede un istante
Una query lunga vede tutta la tabella nello stesso momento: ciò che altri confermano mentre scorre le righe non entra, nemmeno a metà. Due query di seguito in READ COMMITTED —il livello predefinito— possono vedere dati diversi, perché ognuna prende il proprio istante («Livelli di isolamento e SET TRANSACTION (Oracle)»).

Aspettano solo le scritture
Due sessioni che scrivono la stessa riga sì che si aspettano: la seconda resta ferma finché la prima conferma o annulla («Blocchi di riga e chi blocca chi (Oracle)»). Un SELECT normale non blocca e non viene bloccato; SELECT … FOR UPDATE sì, perché riserva le righe per scriverle.

Ciò che non è confermato non esiste per gli altri
In Oracle non esiste la lettura «sporca»: nessuna sessione può vedere una modifica non confermata di un'altra, e nessun livello lo permette. Una sessione che si chiude senza confermare lascia il database com'era.

In Calíope
L'Editor SQL conferma ogni esecuzione, quindi ciò che scrivi è subito visibile agli altri. Dentro Transazione → BEGIN, le tue modifiche sono solo tue fino al COMMIT: un'altra scheda o lo strumento Cataloghi, che usano un'altra connessione, non le vedono.

Parole chiave: lettura coerente, undo, READ COMMITTED, lettura sporca, multiversione, SELECT FOR UPDATE, Oracle

Livelli di isolamento e SET TRANSACTION (Oracle)

Oracle ha due livelli, READ COMMITTED e SERIALIZABLE, e transazioni di sola lettura.

Si applica a: Oracle 26ai+

Oracle accetta due livelli di isolamento, non i quattro dello standard. Misurato: READ UNCOMMITTED e REPEATABLE READ danno ORA-02179 («valid options: ISOLATION LEVEL { SERIALIZABLE | READ COMMITTED }»).

READ COMMITTED, quello di sempre
Ogni istruzione vede ciò che era confermato quando è partita lei. È il livello predefinito e quello che usa quasi ogni applicazione.

SERIALIZABLE: tutta la transazione vede un istante

-- B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT saldo FROM cuenta WHERE id = 2;  -- 100
-- A
UPDATE cuenta SET saldo = 999 WHERE id = 2;
COMMIT;
-- B
SELECT saldo FROM cuenta WHERE id = 2;  -- 100
UPDATE cuenta SET saldo = saldo + 1 WHERE id = 2;  -- ORA-08177

B ha continuato a vedere 100 anche se A aveva già confermato 999: tutta la sua transazione vede il database del momento in cui è iniziata. E provando a cambiare una riga che un altro ha cambiato dopo quel momento, Oracle non le mescola: ORA-08177, e chi lo riceve annulla e ricomincia. Dopo il ROLLBACK, B ha letto 999. Serve per i report che devono quadrare su più query, e per le scritture che non devono sovrascriverne altre, al prezzo di riprovare.

Sola lettura

SET TRANSACTION READ ONLY;
INSERT INTO cuenta VALUES (12, 12);  -- ORA-01456
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;  -- ORA-02179

SET TRANSACTION READ ONLY dà la stessa foto fissa di SERIALIZABLE, senza poter scrivere. È quello che serve per un lungo report di più query che deve quadrare.

Va per primo
SET TRANSACTION deve essere la prima istruzione della transazione e dura fino al suo COMMIT o ROLLBACK. Per tutta la sessione: ALTER SESSION SET ISOLATION_LEVEL = SERIALIZABLE.

In Calíope
L'Editor SQL conferma ogni esecuzione, quindi ogni istruzione è una transazione a sé: un SET TRANSACTION isolato non arriva alla successiva. Perché valga, Transazione → BEGIN, poi il SET TRANSACTION e le query, e alla fine COMMIT o ROLLBACK.

Parole chiave: isolamento, READ COMMITTED, SERIALIZABLE, READ ONLY, SET TRANSACTION, ORA-08177, ORA-02179, ORA-01456, Oracle

Blocchi di riga e chi blocca chi (Oracle)

Oracle blocca righe, non pagine né tabelle, e V$SESSION dice chi aspetta chi.

Si applica a: Oracle 26ai+

Oracle blocca righe. Una riga scritta resta riservata fino al COMMIT o al ROLLBACK di chi l'ha scritta; le altre righe della tabella restano libere, e la lettura non aspetta mai («Lettura coerente (Oracle)»). I blocchi non si estendono: mille righe riservate non diventano un blocco di tabella.

Cosa lascia una scrittura
Misurato dopo l'UPDATE di una riga, senza confermare:

SELECT type, lmode FROM v$lock
 WHERE sid = 50 AND type IN ('TM', 'TX');
-- TM 3, TX 6

TM in modo 3 è il blocco condiviso sulla tabella: impedisce di cambiarne la struttura finché dura la transazione. TX in modo 6 è la transazione: le righe cambiate puntano a lei, e chi vuole scriverle la aspetta.

Chi aspetta chi
Con un'altra sessione che tenta lo stesso UPDATE:

SELECT blocking_session, event, wait_class
  FROM v$session
 WHERE blocking_session IS NOT NULL;
-- 50, enq: TX - row lock contention, Application

BLOCKING_SESSION è il SID della sessione che tiene la riga. Quando ha confermato, quella in attesa è andata avanti da sola: ci ha messo 2.12 s, il tempo che l'altra ha impiegato a confermare.

Cosa fare
Quasi sempre, la sessione che blocca è una transazione che qualcuno ha dimenticato di chiudere: uno strumento con l'autocommit spento, o un programma che scrive e non conferma. La cosa giusta è confermare o annullare da quella sessione. Terminarla annulla tutto ciò che aveva in sospeso.

Le chiavi esterne senza indice
È già raccontato in «Chiavi esterne (Oracle)»: cancellare nella tabella padre con una chiave esterna senza indice blocca tutta la tabella figlia.

In Calíope
Processi mostra «bloccata da 50» sulla sessione in attesa, davanti all'evento di attesa (enq: TX - row lock contention), e la sessione che blocca si può terminare dalla stessa lista.

Parole chiave: blocco, riga, V$LOCK, V$SESSION, BLOCKING_SESSION, TX, TM, enq: TX - row lock contention, Oracle

Non aspettare: NOWAIT, WAIT e SKIP LOCKED (Oracle)

Chiedere una riga senza restare fermi, e perché un DDL su una tabella in uso dà ORA-00054.

Si applica a: Oracle 26ai+

Per impostazione predefinita, chi vuole scrivere una riga riservata aspetta senza limite. SELECT … FOR UPDATE lascia scegliere:

SELECT * FROM cuenta WHERE id = 3 FOR UPDATE NOWAIT;  -- ORA-00054
SELECT * FROM cuenta WHERE id = 3 FOR UPDATE WAIT 2;  -- ORA-00054 (2.01 s)
SELECT id FROM cuenta WHERE id IN (3, 4)
   FOR UPDATE SKIP LOCKED;                            -- 4

NOWAIT fallisce subito con ORA-00054. WAIT n aspetta fino a n secondi: misurato su 26ai, allo scadere ha restituito anch'esso ORA-00054, non un errore diverso. SKIP LOCKED salta le righe riservate e restituisce quelle libere: con la 3 occupata, ha restituito solo la 4.

SKIP LOCKED, per le code
Più processi che si dividono il lavoro possono leggere «la prossima riga in sospeso» senza pestarsi i piedi: ognuno prende una riga diversa e nessuno aspetta. Ciò che restituisce dipende da cosa stanno facendo gli altri, quindi non serve per un report.

Il DDL e le tabelle in uso
Con una riga di cuenta cambiata e non confermata in un'altra sessione:

TRUNCATE TABLE cuenta;                       -- ORA-00054
LOCK TABLE cuenta IN EXCLUSIVE MODE NOWAIT;  -- ORA-00054
ALTER TABLE cuenta ADD nota VARCHAR2(10);    -- OK (3.06 s)

TRUNCATE e LOCK TABLE … NOWAIT hanno bisogno di tutta la tabella e falliscono subito con ORA-00054. L'aggiunta di una colonna, invece, ha aspettato che l'altra sessione confermasse ed è finita bene dopo 3.06 s. Per dare un margine a qualunque DDL: ALTER SESSION SET DDL_LOCK_TIMEOUT = 30 (secondi).

Il messaggio di 26ai
Su 26ai il testo di ORA-00054 spiega cosa era riservato: "TX" è una riga di una transazione, "TM" la tabella intera, con il numero dell'oggetto.

In Calíope
Un ORA-00054 nell'Editor SQL compare nella fascia di errore con il testo completo, e Processi mostra chi tiene la tabella o la riga («Blocchi di riga e chi blocca chi (Oracle)»).

Parole chiave: NOWAIT, WAIT, SKIP LOCKED, FOR UPDATE, ORA-00054, DDL_LOCK_TIMEOUT, TRUNCATE, LOCK TABLE, coda, Oracle

Deadlock (Oracle)

Due transazioni che si aspettano a vicenda: Oracle lo rileva, un'istruzione fallisce e l'altra continua ad aspettare.

Si applica a: Oracle 26ai+

Un deadlock è un cerchio: A aspetta una riga che ha B, e B ne aspetta una che ha A. Nessuna può andare avanti, e Oracle lo risolve da solo.

Misurato

-- A
UPDATE cuenta SET saldo = 1 WHERE id = 4;
-- B
UPDATE cuenta SET saldo = 1 WHERE id = 5;
-- A
UPDATE cuenta SET saldo = 2 WHERE id = 5;
-- B
UPDATE cuenta SET saldo = 2 WHERE id = 4;
-- A: ORA-00060 (5.95 s)

Circa cinque secondi dopo la chiusura del cerchio, l'istruzione di A è fallita con ORA-00060 («deadlock detected while waiting for resource»).

Fallisce l'istruzione, non la transazione
È ciò che confonde di più. Oracle annulla solo l'istruzione che ha ricevuto l'errore: la transazione di A resta aperta e tiene la riga 4. Quindi B stava ancora aspettando —una sessione bloccata in V$SESSION— finché A non ha fatto ROLLBACK; allora B ha finito. Chi riceve ORA-00060 deve annullare la propria transazione (o confermare ciò che ha già fatto), altrimenti il blocco continua.

Chi cade
L'istruzione che fallisce è una delle due del cerchio, quella che Oracle ha rilevato durante il controllo; non si sceglie per costo né per anzianità. Ogni deadlock lascia inoltre un file di trace sul server, con le sessioni e le righe, e una riga nel log degli avvisi.

Come evitarli
Nascono quasi sempre dallo scrivere le stesse righe in ordine diverso. Se tutta l'applicazione aggiorna nello stesso ordine —per chiave, per esempio—, il cerchio non si forma. Le chiavi esterne senza indice sono l'altra causa classica («Chiavi esterne (Oracle)»).

Non è un ORA-00054
ORA-00054 è chiedere una riga occupata con NOWAIT; ORA-00060 è un cerchio vero. Il primo si risolve aspettando; il secondo no.

In Calíope
L'errore compare nell'Editor SQL con il suo testo. Se sei dentro Transazione → BEGIN, ciò che hai fatto prima resta in sospeso: scegli tra COMMIT e ROLLBACK per liberare le righe.

Parole chiave: deadlock, ORA-00060, blocco, ROLLBACK, ordine, trace, Oracle

L'ottimizzatore ed EXPLAIN PLAN (Oracle)

Come Oracle sceglie un piano per costo, come vederlo, e cosa cambia FETCH FIRST con un indice.

Si applica a: Oracle 26ai+

Oracle sceglie il piano per costo: stima quante righe usciranno da ogni passo con le statistiche delle tabelle, e tiene il piano più economico. Il nodo misurato è in modalità ALL_ROWS: ottimizza il tempo per restituire tutte le righe.

Vedere il piano

EXPLAIN PLAN FOR SELECT * FROM ped WHERE cliente = 7;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, NULL, 'BASIC ROWS COST'));
-- 0 SELECT STATEMENT                       10  11
-- 1  TABLE ACCESS BY INDEX ROWID BATCHED   10  11
-- 2   INDEX RANGE SCAN  IX_PED_CLIENTE     10   1

EXPLAIN PLAN salva il piano senza eseguire la query, e DBMS_XPLAN.DISPLAY lo mostra. Si legge dall'interno verso l'esterno: l'indice dà i ROWID e poi si leggono quelle righe. Rows è la stima, non ciò che è uscito; Cost è un'unità interna per confrontare piani, non tempo.

Indice o tabella intera
Con 10 righe su 20 000 è stato l'indice. Con cliente > 10, quasi tutta la tabella, il piano è stato TABLE ACCESS FULL: leggere la tabella di fila costa meno che saltare dall'indice alla tabella 19 900 volte.

FETCH FIRST e l'ordine

SELECT * FROM ped ORDER BY id FETCH FIRST 10 ROWS ONLY;
-- COUNT STOPKEY, INDEX FULL SCAN (3)
SELECT * FROM ped ORDER BY total FETCH FIRST 10 ROWS ONLY;
-- COUNT STOPKEY, SORT ORDER BY STOPKEY, TABLE ACCESS FULL (139)

Con un indice sulla colonna dell'ORDER BY, Oracle legge le prime 10 voci e si ferma (COUNT STOPKEY). Senza indice deve leggere e ordinare tutte le 20 000, anche se tiene solo le 10 migliori: costo 139 contro 3.

Senza statistiche, tira a indovinare
Su una tabella appena caricata, senza statistiche, il piano portava la nota «dynamic statistics used: dynamic sampling (level=2)»: Oracle ha campionato la tabella mentre ottimizzava. Con statistiche aggiornate decide meglio («Statistiche e DBMS_STATS (Oracle)»).

In Calíope
Visual EXPLAIN disegna questo stesso piano come albero, con le righe e il costo di ogni nodo, e il consulente degli indici ne propone uno quando vede un TABLE ACCESS FULL con filtro. Calíope cancella da PLAN_TABLE ciò che ci scrive.

Parole chiave: ottimizzatore, costo, EXPLAIN PLAN, DBMS_XPLAN, piano, TABLE ACCESS FULL, INDEX RANGE SCAN, FETCH FIRST, COUNT STOPKEY, Oracle

Statistiche e DBMS_STATS (Oracle)

L'ottimizzatore decide con le statistiche: come si raccolgono, quando invecchiano e cosa aggiunge un istogramma.

Si applica a: Oracle 26ai+

Le statistiche dicono all'ottimizzatore quante righe ha ogni tabella, quanti valori distinti ha ogni colonna e come si distribuiscono. Oracle le raccoglie da solo nella sua finestra di manutenzione; dopo un grosso caricamento conviene non aspettarla.

Raccoglierle e vedere se sono vecchie

BEGIN
  DBMS_STATS.GATHER_TABLE_STATS(USER, 'PED');
END;
SELECT num_rows, last_analyzed FROM user_tables
 WHERE table_name = 'PED';                -- 20000, 2026-10-16
-- INSERT … 80000
SELECT num_rows FROM user_tables
 WHERE table_name = 'PED';                -- 20000
SELECT stale_stats FROM user_tab_statistics
 WHERE table_name = 'PED';                -- YES

Prima della raccolta, NUM_ROWS e LAST_ANALYZED erano NULL. Dopo aver aggiunto 80 000 righe, NUM_ROWS era ancora 20 000: le statistiche sono una foto, non un contatore. STALE_STATS = 'YES' avvisa che la foto non somiglia più alla tabella; Oracle la segna così quando cambia più del 10 % delle righe.

Istogrammi: quando un valore non è come gli altri
Senza istogramma, l'ottimizzatore suppone che ogni valore compaia con la stessa frequenza. In ped, estado ha due valori: 'RARO' nell'1 % delle righe e 'COMUN' nel resto.

BEGIN
  DBMS_STATS.GATHER_TABLE_STATS(USER, 'PED',
    method_opt => 'FOR COLUMNS estado SIZE 254');
END;
-- ESTADO: FREQUENCY
-- WHERE estado = 'RARO'   → INDEX RANGE SCAN IX_PED_ESTADO (199)
-- WHERE estado = 'COMUN'  → TABLE ACCESS FULL (99800)

Con l'istogramma di frequenza, ogni valore ha il suo piano: l'indice per quello raro, la tabella intera per quello comune.

Cosa si raccoglie
GATHER_TABLE_STATS raccoglie anche quelle dei suoi indici (cascade). Per uno schema intero, DBMS_STATS.GATHER_SCHEMA_STATS. La raccolta legge la tabella e richiede tempo su una grande: misurato, 0.43 s per 20 000 righe.

Calíope non le raccoglie da solo
Nessuno strumento lancia DBMS_STATS senza che tu lo chieda: la raccolta costa e cambia i piani di tutte le sessioni.

In Calíope
Manutenzione tabelle → ANALYZE chiama DBMS_STATS.GATHER_TABLE_STATS con cascade e rilegge il risultato: «OK: 20000 righe secondo le statistiche, raccolte il …». Informazioni server → Utilizzo HDD mostra le righe stimate con la data delle statistiche.

Parole chiave: statistiche, DBMS_STATS, GATHER_TABLE_STATS, NUM_ROWS, LAST_ANALYZED, STALE_STATS, istogramma, ottimizzatore, Oracle

La cache dei cursori e V$SQL (Oracle)

Perché i bind contano, cosa conta V$SQL, e perché Calíope non usa AWR.

Si applica a: Oracle 26ai+

Oracle conserva ogni istruzione che esegue —testo e piano— nella cache dei cursori, e V$SQL la mostra con i suoi contatori: esecuzioni, tempo, CPU, letture.

Letterali o bind

SELECT sql_text, executions FROM v$sql
 WHERE sql_text LIKE '%manual69%';
-- … WHERE id = 1      1
-- … WHERE id = 2      1
-- … WHERE id = 3      1
-- … WHERE id = :id    3

Con il valore scritto nel testo, ogni query è un'altra istruzione: tre testi, tre analisi, tre cursori nella cache. Con un bind (:id), un solo cursore e tre esecuzioni. In un'applicazione con migliaia di valori, i letterali riempiono la cache e consumano CPU per l'analisi. I tre letterali condividono una FORCE_MATCHING_SIGNATURE: la firma del testo senza i suoi valori, utile per raggrupparli. CURSOR_SHARING era EXACT: Oracle non riscrive i letterali da solo.

Comanda il valore del primo bind (bind peeking)

SELECT COUNT(total) FROM ped WHERE estado = :e;
-- :e = 'RARO', 'RARO', 'COMUN', …  (18)
SELECT child_number, executions, is_bind_sensitive, is_bind_aware
  FROM v$sql WHERE sql_text LIKE '%estado = :e%';
-- 0, 18, Y, N  → INDEX RANGE SCAN IX_PED_ESTADO

Oracle ha guardato il primo valore, 'RARO', ha scelto l'indice e l'ha riusato per tutte le 18 esecuzioni, anche con 'COMUN', che è il 99 % della tabella e come letterale andava a TABLE ACCESS FULL («Statistiche e DBMS_STATS (Oracle)»). IS_BIND_SENSITIVE = Y dice che Oracle sa che il valore conta; può finire per creare un altro piano (adaptive cursor sharing), ma nella misura non l'ha fatto.

Niente AWR
DBA_HIST_*, AWR e ASH conservano la storia delle prestazioni, e in Standard ed Enterprise Edition fanno parte del Diagnostics Pack, che si licenzia a parte; interrogarli è usarlo. V$SQL, V$SESSION e V$SYSSTAT non lo richiedono.

In Calíope
Query lente legge V$SQL: l'esecuzione media di ogni cursore sopra la soglia, con chiamate, CPU e letture, e il riepilogo raggruppa per FORCE_MATCHING_SIGNATURE. Calíope non interroga né AWR né ASH.

Parole chiave: V$SQL, cursore, bind, letterale, bind peeking, adaptive cursor sharing, FORCE_MATCHING_SIGNATURE, CURSOR_SHARING, AWR, ASH, Diagnostics Pack, Oracle

Utenti, ruoli e privilegi (Oracle)

In Oracle un utente è anche uno schema; i permessi passano per privilegi di sistema e di oggetto, quasi sempre tramite ruoli.

Si applica a: Oracle 26ai+

In Oracle un utente è uno schema: ciò che crea gli appartiene, e chi vuole usarlo ha bisogno di un permesso. Ci sono due tipi di privilegio: quelli di sistema (CREATE SESSION, CREATE TABLE, ALTER SYSTEM…) e quelli di oggetto (SELECT, INSERT… su una tabella precisa). Senza CREATE SESSION non si entra nemmeno: ORA-01045.

Senza permesso, la tabella non esiste

-- dba_lector
SELECT * FROM dba_manual.cliente;
-- ORA-00942: table or view "DBA_MANUAL"."CLIENTE" does not exist

Oracle non dice «non hai il permesso»: dice che non la trova, per non rivelare cosa c’è in uno schema altrui. Un ORA-00942 su una tabella che sai che esiste è di solito un permesso mancante.

I ruoli raggruppano i permessi

-- system
CREATE ROLE rol_lectura;
GRANT rol_lectura TO dba_lector;
-- dba_manual
GRANT SELECT ON cliente TO rol_lectura;
-- dba_lector
SELECT * FROM dba_manual.cliente;  -- 1, Ana
INSERT INTO dba_manual.cliente VALUES (2, 'Luis');
-- ORA-41900: missing INSERT privilege on "DBA_MANUAL"."CLIENTE"

Il ruolo si concede una volta e porta tutti i suoi permessi. Con il ruolo di lettura si legge, e ciò che non concede viene negato col suo nome: in 26ai, un privilegio di oggetto mancante dà ORA-41900, che dice quale. Un ruolo nuovo non raggiunge una sessione già aperta: quella già connessa ha continuato a ricevere ORA-00942 finché non è rientrata.

Cosa ha la sessione adesso

SELECT role FROM session_roles;       -- ROL_LECTURA
SELECT privilege FROM session_privs;  -- CREATE SESSION
-- system
ALTER USER dba_lector DEFAULT ROLE NONE;
-- dba_lector
SET ROLE rol_lectura;

SESSION_ROLES e SESSION_PRIVS dicono cosa ha davvero la sessione, non cosa è stato concesso. Un ruolo fuori da quelli predefiniti non si attiva all’accesso: va richiesto con SET ROLE.

Gli utenti C## non sono di qui

CREATE USER c##global IDENTIFIED BY "Xx_pass1";
-- ORA-65094: invalid local user or role name

In un database collegabile (FREEPDB1) gli utenti sono locali; quelli comuni (C##) si creano nella radice del container, dove Calíope non entra.

In Calíope
Utenti elenca account e ruoli, con i permessi in quattro schede (sistema, schema, tabella e routine) e l’appartenenza ai ruoli ereditata. Crea utenti, concede e revoca, blocca ed elimina con CASCADE, e prima di scrivere controlla i tuoi permessi: ciò che non puoi fare appare disattivato, con il motivo. Gli account di Oracle stesso si vedono, ma non si toccano.

Parole chiave: Oracle, utente, schema, ruolo, privilegio, GRANT, REVOKE, SESSION_PRIVS, SESSION_ROLES, SET ROLE, ORA-00942, ORA-41900, ORA-01045, C##, CDB, PDB

Profili, password che scadono e account bloccati (Oracle)

Il profilo di un utente decide quanti tentativi falliti tollera, quando scade la sua password e quanto dura il blocco.

Si applica a: Oracle 26ai+

Ogni utente ha un profilo, e il profilo fissa i limiti della sua password. Chi non ne ha un altro usa DEFAULT:

SELECT resource_name, limit FROM dba_profiles
 WHERE profile = 'DEFAULT';
-- FAILED_LOGIN_ATTEMPTS   10
-- PASSWORD_LIFE_TIME      180
-- PASSWORD_GRACE_TIME     7
-- PASSWORD_LOCK_TIME      1
-- INACTIVE_ACCOUNT_TIME   365
-- IDLE_TIME               UNLIMITED

Così arriva in 26ai Free: dieci tentativi falliti bloccano l’account per un giorno, e la password scade dopo 180 giorni con una settimana di tolleranza.

Tentativi falliti e blocco

CREATE PROFILE perfil_manual LIMIT
  FAILED_LOGIN_ATTEMPTS 2 PASSWORD_LOCK_TIME 1;
ALTER USER dba_caduca PROFILE perfil_manual;
-- 2 × ORA-01017; 3: ORA-28000: The account is locked
SELECT account_status FROM dba_users
 WHERE username = 'DBA_CADUCA';  -- LOCKED(TIMED)
ALTER USER dba_caduca ACCOUNT UNLOCK;

Dopo due password sbagliate, anche quella giusta viene rifiutata: ORA-28000 finché non passa il tempo di blocco o qualcuno non lo sblocca. ACCOUNT LOCK blocca a mano, con lo stesso errore.

La password che scade

-- EXPIRED(GRACE):
-- ORA-28098: Profile limits PASSWORD_LIFE_TIME and
-- PASSWORD_GRACE_TIME are not unlimited, password will
-- expire within 0 days.
-- EXPIRED:
-- ORA-28001: The account has expired. The password must be changed.
ALTER USER dba_caduca IDENTIFIED BY "Caduca_pass2";

Nel periodo di tolleranza la sessione si apre, con un avviso: in 26ai è ORA-28098. Finita la tolleranza, ORA-28001, e non si entra finché non viene cambiata. PASSWORD EXPIRE la fa scadere a mano.

Un account per un’applicazione ha di solito un profilo proprio senza scadenza, perché non si fermi di domenica; quello di una persona, il profilo predefinito.

In Calíope
Testa la connessione mostra l’avviso del server quando la password è nel periodo di tolleranza: la connessione si apre comunque. ORA-28000 e ORA-28001 vengono segnalati come accesso negato, con il testo del server. Utenti mostra nell’intestazione di ogni account il suo profilo, il suo stato e quando scade la password, e da lì si cambia la password e si blocca o sblocca. I profili non si creano da Calíope: è CREATE PROFILE nell’Editor SQL.

Parole chiave: Oracle, profilo, PROFILE, password, scadenza, PASSWORD_LIFE_TIME, PASSWORD_GRACE_TIME, FAILED_LOGIN_ATTEMPTS, blocco, ACCOUNT LOCK, ORA-28000, ORA-28001, ORA-28098, ORA-01017

Backup e ripristino: RMAN, flashback e il dump di Calíope (Oracle)

Ciò che ripristina un database Oracle è RMAN; flashback annulla gli errori recenti; il backup di Calíope è un dump SQL di schemi.

Si applica a: Oracle 26ai+

Oracle si salva con RMAN, lo strumento del server stesso: copia i file di dati e il redo archiviato, e con questi ripristina l’intero database a qualunque istante. Calíope non lo sostituisce: non legge i file del server né gestisce copie.

SELECT log_mode, flashback_on FROM v$database;
-- ARCHIVELOG   NO
SELECT value FROM v$parameter WHERE name = 'undo_retention';
-- 900

Il nodo di prova archivia il suo redo (ARCHIVELOG), quindi RMAN potrebbe ripristinarlo a un istante; flashback database è spento.

Flashback: tornare indietro di qualche minuto

DELETE FROM cliente;           -- 2
SELECT COUNT(*) FROM cliente;  -- 0
SELECT COUNT(*) FROM cliente
  AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '2' SECOND);  -- 2
INSERT INTO cliente
  SELECT * FROM cliente
  AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '2' SECOND);  -- 2

Con AS OF TIMESTAMP si legge la tabella com’era poco fa, e così si recupera ciò che è stato cancellato per errore. Viene dall’undo, conservato per circa undo_retention secondi (900 sul nodo): passato quel tempo, lo stato precedente può non esserci più.

Il cestino

DROP TABLE borrable;
SELECT original_name FROM user_recyclebin;  -- BORRABLE
FLASHBACK TABLE borrable TO BEFORE DROP;
SELECT * FROM borrable;  -- 42
DROP TABLE borrable PURGE;
FLASHBACK TABLE borrable TO BEFORE DROP;
-- ORA-38305: object not in RECYCLE BIN

Un DROP TABLE lascia la tabella nel cestino, e FLASHBACK TABLE … TO BEFORE DROP la restituisce con i suoi dati. Con PURGE non passa dal cestino e non si torna indietro.

Il backup di Calíope
Backup scrive un dump SQL degli schemi che scegli: il DDL di ogni oggetto tramite DBMS_METADATA, i dati in INSERT a blocchi e gli utenti. Serve a portare uno schema altrove o a tenere una copia leggibile; non è un ripristino a un istante. Ciò che non copre: i LOB oltre 16 MiB vengono scritti NULL, con un commento che lo dice, e in 26ai il DDL di un utente non porta la sua password, quindi l’account ripristinato nasce senza. Il ripristino esegue il dump istruzione per istruzione nello schema che scegli.

Parole chiave: Oracle, backup, ripristino, RMAN, ARCHIVELOG, flashback, AS OF TIMESTAMP, cestino, recyclebin, FLASHBACK TABLE, PURGE, undo_retention, dump, ORA-38305

Parametri e NLS (Oracle)

I parametri del server sono in V$PARAMETER; gli NLS decidono come si scrivono date e numeri, e ogni sessione può cambiarli.

Si applica a: Oracle 26ai+

La configurazione di Oracle è in V$PARAMETER: il valore di ogni parametro e se può cambiarlo una sessione (ISSES_MODIFIABLE) o solo il sistema intero.

SELECT name, value, isses_modifiable
  FROM v$parameter
 WHERE name IN ('open_cursors', 'optimizer_mode',
                'nls_length_semantics', 'max_string_size');
-- max_string_size       STANDARD  FALSE
-- nls_length_semantics  BYTE      TRUE
-- open_cursors          300       FALSE
-- optimizer_mode        ALL_ROWS  TRUE

open_cursors lo cambia solo il sistema; optimizer_mode e nls_length_semantics, anche una sessione. max_string_size a STANDARD lascia VARCHAR2 a 4000 byte; passarlo a EXTENDED è un’operazione sul database senza ritorno.

Cambiare il sistema richiede un privilegio

-- dba_manual
ALTER SYSTEM SET open_cursors = 301;
-- ORA-01031: insufficient privileges

ALTER SYSTEM è amministrazione. Calíope non cambia i parametri del server: li legge.

NLS: date e numeri

SELECT TO_CHAR(DATE '2026-10-16') FROM dual;  -- 16-OCT-26
ALTER SESSION SET NLS_DATE_FORMAT = 'DD/MM/YYYY';
SELECT TO_CHAR(DATE '2026-10-16') FROM dual;  -- 16/10/2026
ALTER SESSION SET NLS_NUMERIC_CHARACTERS = ',.';
SELECT TO_CHAR(1234.5) FROM dual;             -- 1234,5
SELECT TO_NUMBER('1234.5') FROM dual;         -- ORA-01722

Il formato di una data convertita in testo, e il separatore decimale, vengono dai parametri NLS della sessione. Cambiati, la stessa query scrive altro, e una conversione che funzionava fallisce. Per questo gli esempi del manuale scrivono le date come letterali (DATE '2026-10-16') e le convertono con una maschera esplicita: non dipendono dalla sessione di nessuno.

nls_length_semantics a BYTE fa sì che VARCHAR2(40) siano 40 byte; con testo non ASCII ci stanno meno caratteri. VARCHAR2(40 CHAR) li conta in caratteri.

In Calíope
Informazioni server mostra V$PARAMETER in Variabili globali, e gli NLS della tua sessione con il contesto di SYS_CONTEXT in Variabili di sessione. Entrambe sono di sola lettura. Senza permesso di leggere le viste V$, la scheda dice perché non le legge, con il testo del server.

Parole chiave: Oracle, parametro, V$PARAMETER, ALTER SYSTEM, ALTER SESSION, NLS, NLS_DATE_FORMAT, NLS_NUMERIC_CHARACTERS, nls_length_semantics, max_string_size, ORA-01031, ORA-01722

Memoria, processi e sessioni (Oracle)

La SGA è la memoria condivisa dell’istanza e la PGA quella di ogni sessione; processes e sessions fissano il tetto di connessioni.

Si applica a: Oracle 26ai+

Un’istanza Oracle divide la memoria in due: la SGA, condivisa da tutte le sessioni (la cache dei blocchi, quella dei cursori…), e la PGA, propria di ogni processo per ordinare, unire e tenere variabili.

SELECT name, bytes FROM v$sgainfo
 WHERE name IN ('Maximum SGA Size', 'Buffer Cache Size',
                'Shared Pool Size');
-- Buffer Cache Size     620756992
-- Shared Pool Size      939524096
-- Maximum SGA Size     1603398928
SELECT name, value FROM v$pgastat
 WHERE name IN ('total PGA allocated', 'maximum PGA allocated');
-- total PGA allocated     10366976
-- maximum PGA allocated  755031040

Sul nodo Free la SGA non supera 1,5 GiB, quasi tutta cache dei blocchi e shared pool. La PGA occupava 10 MB a riposo e ha raggiunto 720 MB in un picco.

Processi e sessioni

SELECT name, value FROM v$parameter
 WHERE name IN ('cpu_count', 'processes', 'sessions',
                'pga_aggregate_target', 'pga_aggregate_limit');
-- cpu_count                    2
-- pga_aggregate_limit 2147483648
-- pga_aggregate_target 536870912
-- processes                  200
-- sessions                   322

processes è il tetto di processi del server e sessions quello delle sessioni; raggiunto, una nuova connessione viene rifiutata. pga_aggregate_target è l’obiettivo della PGA, e pga_aggregate_limit il limite che non lascia superare.

Cosa spetta all’applicazione
Ogni connessione aperta è una sessione e un processo, con la sua PGA. Un pool di connessioni piccolo rende più di centinaia di connessioni aperte insieme, e non esaurisce sessions.

In Calíope
Calíope apre, per profilo, un pool di al massimo cinque connessioni, e le restituisce pulite. Informazioni server mostra in Informazioni il numero massimo di sessioni (da sessions), e la Dashboard la memoria della SGA e della PGA e le connessioni aperte. Processi elenca le sessioni utente, e permette di terminarne una o di annullare la sua query.

Parole chiave: Oracle, memoria, SGA, PGA, V$SGAINFO, V$PGASTAT, processes, sessions, pga_aggregate_target, pga_aggregate_limit, connessioni, pool

Limiti (Oracle)

I tetti di Oracle 26ai Free e quelli del database: identificatori di 128 byte, VARCHAR2 di 4000, NUMBER di 38 cifre e 1000 colonne.

Si applica a: Oracle 26ai+

Oracle AI Database 26ai Free è l’edizione gratuita, con limiti d’uso pubblicati da Oracle: fino a 2 thread di CPU, 2 GB di memoria e 12 GB di dati utente. Il nodo di prova lo conferma: cpu_count vale 2 e la SGA non supera 1,5 GiB. Per di più serve un’altra edizione; il protocollo è lo stesso.

Quelli del database, misurati

CREATE TABLE TTT…T (x NUMBER);  -- 128 × T: OK
CREATE TABLE UUU…U (x NUMBER);  -- 129 × U:
-- ORA-00972: The identifier … exceeds the maximum length of 128 bytes.
CREATE TABLE v (a VARCHAR2(4001));  -- ORA-00910
CREATE TABLE n (a NUMBER(39));      -- ORA-01727
CREATE TABLE ancha (c0 NUMBER, …, c1000 NUMBER);
-- ORA-01792: maximum number of columns in a table or view is 1000
SELECT RPAD('x', 4000, 'x') || 'y' FROM dual;  -- ORA-01489

- Un identificatore (tabella, colonna, utente) arriva a 128 byte.
- VARCHAR2 arriva a 4000 byte con max_string_size a STANDARD, e a 32767 con EXTENDED. In PL/SQL una variabile VARCHAR2 arriva sempre a 32767.
- NUMBER ha fino a 38 cifre di precisione.
- Una tabella ha fino a 1000 colonne.
- Concatenare testo in SQL non supera i 4000 byte: ORA-01489. Per di più, CLOB.

In Calíope
Calíope legge un LOB fino a 16 MiB per cella: ciò che va oltre si taglia e la cella dice la dimensione reale. I tipi che non decodifica —oggetti, XMLTYPE, JSON binario, VECTOR, BFILE e cursori— appaiono come «non decodificato», con il nome del loro tipo.

Parole chiave: Oracle, limiti, Free, 26ai, CPU, memoria, 12 GB, identificatore, 128 byte, VARCHAR2, 4000, 32767, NUMBER, 38, colonne, 1000, ORA-00972, ORA-00910, ORA-01727, ORA-01792, ORA-01489

Errori ORA- frequenti (Oracle)

Cosa vuol dire ciascuno degli errori ORA- più comuni, misurato contro Oracle 26ai, e cosa ne fa Calíope.

Si applica a: Oracle 26ai+

Ogni errore di Oracle porta un numero di cinque cifre con il prefisso ORA- e il testo del server, e Calíope lo mostra così com’è, perché è quello che si cerca nella documentazione di Oracle. Quando il server dice dov’è l’errore, l’Editor SQL seleziona quella parola e la barra di stato aggiunge la riga e la colonna; la striscia Vedi soluzione porta qui.

SELECT * FROM no_existe;
-- ORA-00942: table or view "DBA_MANUAL"."NO_EXISTE" does not exist
INSERT INTO cliente VALUES (90, 'X');
-- ORA-00001: unique constraint (DBA_MANUAL.SYS_C0010174) violated
--   on table DBA_MANUAL.CLIENTE columns (ID)
SELECT TO_DATE('16/10/2026', 'YYYY-MM-DD') FROM dual;
-- ORA-01830: Date format picture ends before converting
--   entire input string.

Oggetti e nomi
- ORA-00942 — la tabella o la vista non esiste… oppure esiste e non hai il permesso: Oracle non li distingue.
- ORA-00904 — identificatore non valido: una colonna che non c’è, o scritta male.
- ORA-00955 — il nome è già usato da un altro oggetto dello schema.
- ORA-00972 — un nome di oltre 128 byte.

Dati
- ORA-00001 — chiave univoca ripetuta; il testo nomina il vincolo e le colonne.
- ORA-02291 / ORA-02292 — la chiave esterna non trova il padre / il padre ha dei figli.
- ORA-01400 — NULL in una colonna NOT NULL.
- ORA-12899 — il valore non ci sta; dice la dimensione reale e il massimo.
- ORA-01722 — un testo che non è un numero; anche in un confronto implicito.
- ORA-01843 / ORA-01830 — mese non valido / la maschera della data finisce prima del testo.
- ORA-01489 — una concatenazione di oltre 4000 byte.

Transazioni e blocchi
- ORA-00054 — la riga è presa: con NOWAIT, e in 26ai anche allo scadere di WAIT n.
- ORA-00060 — deadlock: si annulla solo l’istruzione.
- ORA-08177 — conflitto in SERIALIZABLE. ORA-01456 — scrivere in una transazione READ ONLY.
- ORA-01555 — snapshot too old: una query lunga aveva bisogno di un undo già riutilizzato.
- ORA-01013 — la query è stata annullata: l’hai chiesto tu con Arresta.

Permessi e connessione
- ORA-41900 — manca un privilegio di oggetto, e dice quale. ORA-01031 — ne manca uno di sistema.
- ORA-01017 — nome utente o password non validi. ORA-28000 — account bloccato. ORA-28001 — password scaduta. Calíope li segnala come accesso negato.
- ORA-12514 / ORA-12505 — il listener non conosce quel servizio / quel SID: controlla il Servizio del profilo.
- ORA-12170 — la connessione non è arrivata in tempo: rete, firewall o porta.
- ORA-02396 — la sessione ha superato il suo tempo di inattività; Calíope la segnala come connessione persa.
- ORA-38305 — ciò che si vuole recuperare non è nel cestino.

Errori dell’applicazione
ORA-20000 fino a ORA-20999 li solleva il codice PL/SQL con RAISE_APPLICATION_ERROR, con il testo che sceglie: il loro significato lo dà chi l’ha scritto.

Parole chiave: Oracle, errore, ORA-, ORA-00942, ORA-00904, ORA-00001, ORA-02291, ORA-01722, ORA-00054, ORA-00060, ORA-01555, ORA-01017, ORA-28000, ORA-12514, ORA-41900, RAISE_APPLICATION_ERROR

Buone pratiche (Oracle)

Cosa conviene fare con Oracle da Calíope: account con lo stretto necessario, transazioni brevi, bind, statistiche aggiornate e un vero backup.

Si applica a: Oracle 26ai+

Un account per ogni compito, con lo stretto necessario
Non usare SYSTEM né SYS per lavorare sui dati. Ogni applicazione, il suo schema; ogni persona, il suo utente, con i permessi tramite un ruolo di lettura o di scrittura. Se qualcosa dà ORA-00942 o ORA-41900, concedi il permesso che manca, non uno più ampio.

Transazioni brevi, confermate di proposito
Oracle non conferma da solo, e il DDL conferma ciò che è in sospeso. Apri la transazione quando ti serve (Transazione → BEGIN nell’Editor SQL), fai lo stretto necessario e chiudila: una riga presa blocca gli altri fino al COMMIT o al ROLLBACK, e una query lunga su dati che cambiano può finire in ORA-01555.

Bind invece di letterali
Una query con il valore scritto nel testo è un’altra istruzione per ogni valore: un’altra analisi e un altro cursore in cache. Con un bind (:cliente) è una sola. Ciò che un’applicazione ripete, sempre con bind.

Statistiche aggiornate
L’ottimizzatore sceglie il piano in base alle statistiche. Dopo un caricamento grande, raccoglile (Manutenzione tabelle → ANALYZE) e controlla la data in Informazioni server → Utilizzo HDD.

Testo e date
Dichiara il testo in caratteri (VARCHAR2(40 CHAR)) se contiene qualcosa che non è ASCII, e scrivi le date come letterali (DATE '2026-10-16') o con una maschera esplicita: ciò che dipende dagli NLS della sessione cambia da una sessione all’altra.

Un vero backup
Il dump di Backup serve a spostare o conservare uno schema leggibile. Per ripristinare un database, RMAN con il redo archiviato.

Parole chiave: Oracle, buone pratiche, best practice, privilegi, ruoli, SYSTEM, transazione, COMMIT, bind, statistiche, NLS, CHAR, date, RMAN, backup

Modellazione dei dati: incorporare o referenziare

Quando mettere i dati dentro il documento e quando referenziarli: i quattro pattern di MongoDB, l'antipattern dell'array senza tetto e quanto costa ciascuno, misurato.

Si applica a: MongoDB 7.0+

In SQL lo schema deriva dalla normalizzazione: ogni fatto in un solo posto, e le query lo ricompongono con JOIN. In MongoDB deriva dallo schema di accesso: ciò che si legge insieme si salva insieme. La domanda non è più «come evito di ripetere un dato?», ma «che cosa voglio che mi restituisca una sola lettura?».

Incorporare o referenziare

Un ordine può portarsi dentro le sue righe, oppure le righe possono vivere in una collezione propria puntando all'ordine.

// Incorporato: l'ordine contiene le sue righe
db.p_emb.insertOne({ _id: 1, cliente: 7, fecha: new Date(),
  lineas: [ { sku: "A-7", cantidad: 2, precio: 19.9 } ] })
db.p_emb.findOne({ _id: 1 })

// Riferimento: le righe vivono a parte e puntano all'ordine
db.p_ref.insertOne({ _id: 1, cliente: 7, fecha: new Date() })
db.l_ref.insertOne({ pedido: 1, sku: "A-7", cantidad: 2, precio: 19.9 })
db.l_ref.createIndex({ pedido: 1 })
db.p_ref.aggregate([ { $match: { _id: 1 } },
  { $lookup: { from: "l_ref", localField: "_id",
               foreignField: "pedido", as: "lineas" } } ])

Misurato contro MongoDB 8.2 con 100.000 ordini di tre righe: leggere un ordine con le sue righe costa 0,25 ms incorporato e 0,30 ms con $lookup —ma 67 ms se manca l'indice su l_ref.pedido, perché allora ogni ordine percorre tutte le 300.000 righe—. Su disco gli ordini incorporati occupano 2,9 MB contro 1,1 MB + 5,2 MB delle due collezioni separate: qui incorporare è uscito più economico anche in spazio.

Incorporare non rinuncia agli indici: un indice su un campo dentro l'array è multichiave e funziona uguale. Cercare {"lineas.sku": "A-7"} senza di esso è un COLLSCAN di 100.000 documenti in 42 ms; con esso, 2.000 esaminati in 2 ms, per le stesse 2.000 righe. E un documento si modifica in modo atomico senza transazione: l'$inc di un contatore e il $set di uno stato in un solo updateOne entrano insieme o non entra nessuno dei due.

L'antipattern: l'array che non smette di crescere

db.sensor.insertOne({ _id: 1, lecturas: [] })
const tanda = Array.from({ length: 10000 }, (_, i) => ({ t: new Date(), v: i }))
db.sensor.updateOne({ _id: 1 }, { $push: { lecturas: { $each: tanda } } })
db.sensor.stats().avgObjSize   // 288 919 bytes tras 10 000 lecturas

Il documento vuoto è di 29 byte e ogni lettura aggiunge 28,9. A 540.000 misura 16.628.919 byte e la tornata successiva fallisce con il codice 10334: «Resulting document after update is larger than 16777216». Il tetto di 16 MB per documento non si negozia. E fa male già prima di arrivarci: un $set di un campo scalare costa 3,15 ms su quel documento e 0,50 ms su uno di 228 byte, e un $pop dell'array 71,95 ms contro gli 0,15 ms che costa inserire una lettura sciolta. Un array che cresce senza tetto noto è un riferimento messo male.

Bucket: le letture si raggruppano in documenti di N.

const lote = Array.from({ length: 200 }, (_, i) => ({ t: new Date(), v: i }))
db.cubos.insertOne({ sensor: 1, desde: new Date(), n: 200, lecturas: lote })
db.cubos.createIndex({ sensor: 1, desde: -1 })

Le stesse 540.000 letture: sciolte sono 540.000 documenti, 6,5 MB di dati e 8,4 MB di indice; in bucket da 200 sono 2.700 documenti, 4,2 MB di dati e 82 KB di indice. Inserirle costa 203 ms in bucket contro 901 ms sciolte. Leggere l'ultima: 0,20 ms dal bucket, 0,30 ms sciolta e 46,80 ms dall'array incorporato, che va portato via intero.

Riferimento esteso: copiare dentro il figlio la manciata di campi del padre che si mostrano sempre. Elencare 100 ordini con il nome del loro cliente costa 0,35 ms con il nome duplicato dentro e 1,00 ms con $lookup. Si paga in scrittura: rinominare un cliente obbliga a toccare i suoi 1.000 ordini, 2 ms con un indice su cliente. Si duplica ciò che non cambia quasi mai.

Valore calcolato: salvare il totale già sommato invece di ricalcolarlo a ogni lettura. Su un ordine non si nota —0,35 ms letto dal documento, 0,30 ms sommato al volo—; aggregando i 100.000 sì: 14 ms leggendo il campo contro 129 ms ricalcolando.

Sottoinsieme: dentro il documento solo le poche righe che si mostrano, il resto nella sua collezione. 500 prodotti con 500 recensioni ciascuno: incorporate tutte, il documento medio è di 80.738 byte; con le ultime cinque e un contatore, 889 byte, e la collezione scende da 6,1 MB a 60 KB. La scheda passa da 0,55 ms a 0,25 ms, e la pagina di 20 recensioni si chiede a parte, alla sua collezione.

La regola, in una riga: incorpora ciò che si legge con il suo padre, appartiene solo a lui e ha un tetto; referenzia ciò che cresce senza limite, si condivide fra più padri o si consulta per conto proprio.

Parole chiave: modellazione, incorporare, riferimento, documento, pattern, bucket, sottoinsieme, riferimento esteso, valore calcolato, array, schema, 16 MB

Tipi BSON e collazione

Quale tipo MongoDB salva con ogni valore, quanto occupa, come si confrontano tipi diversi fra loro e come si ordina il testo accentato.

Si applica a: MongoDB 7.0+

In SQL il tipo lo dichiara la tabella e tutte le righe lo rispettano. In MongoDB il tipo viaggia con ogni valore: non c'è CREATE TABLE, e due documenti della stessa collezione possono avere un intero e una stringa nello stesso campo. Il formato si chiama BSON, un'estensione binaria di JSON con i tipi che a JSON mancano: interi a 32 e a 64 bit, decimali esatti, date, binari e ObjectId.

_id e ObjectId

Ogni documento ha un _id: unico, immutabile e indicizzato da quando nasce la collezione. Se non lo scrivi tu, il client ci mette un ObjectId: 12 byte, di cui 4 sono il tempo in secondi, 5 sono casuali per processo e 3 sono un contatore. Cresce con l'orologio, quindi serve per intervalli di date senza salvare nessuna data.

const oid = ObjectId()
oid.getTimestamp()            // 2026-09-12T01:28:05.000Z
oid.toString().slice(0, 8)    // "6aa4aaa5": los 4 bytes de tiempo

// Ciò che è stato creato dal 1º settembre, tramite l'indice di _id
const desde = ObjectId.createFromTime(Date.parse("2026-09-01") / 1000)
db.pedidos.countDocuments({ _id: { $gte: desde } })

Tenerlo come stringa costa e non aggiunge nulla: {_id: ObjectId()} pesa 22 byte e lo stesso valore in stringa, 39.

Interi, decimali binari ed esatti

Quattro tipi numerici, e la differenza si vede nel documento: $bsonSize su {_id: 1, a: …} dà 21 byte con int, 25 con long o con double e 33 con decimal. Il denaro va in decimal per lo stesso motivo per cui in SQL va in DECIMAL: sommare 0,1 e 0,2 come double dà 0.30000000000000004, e come decimali dà 0.3.

La trappola sta nel client. mongosh sceglie il tipo guardando il valore, quindi un 7 scritto a mano si salva come int, 3000000000 come double, e 9007199254740993 si salva come 9007199254740992: oltre 2⁵³ bisogna scrivere NumberLong("…").

db.tipos.insertMany([
  { v: 7 }, { v: 7.5 }, { v: 3000000000 },
  { v: 9007199254740993 }, { v: NumberLong("9007199254740993") }
])
db.tipos.aggregate([ { $project: { v: 1, t: { $type: "$v" } } } ])

Per interrogare, invece, i quattro sono un unico numero: con un int, un long, un double e un decimal di valore 7 nella collezione, {v: 7} li trova tutti e quattro, e {v: "7"} non ne trova nessuno. $type sì che li distingue, e "number" li raggruppa di nuovo.

Date

Date sono 8 byte di millisecondi dal 1970, sempre in UTC e senza fuso orario. Qui non esiste la coppia DATETIME / TIMESTAMP: c'è un tipo solo, il fuso lo mette chi legge, e i microsecondi si perdono in scrittura. Il Timestamp di BSON non è per i tuoi dati: è l'orologio interno della replica.

db.eventos.insertOne({ _id: 1, d: new Date("2026-09-11T21:05:00.123Z") })
db.eventos.aggregate([ { $project: {
  utc: { $hour: "$d" },
  mx:  { $hour: { date: "$d", timezone: "America/Mexico_City" } }
} } ])                        // utc 21 · mx 15

Binari e UUID

BinData salva byte con un sottotipo, e UUID() è il sottotipo 4. Un UUID come BinData occupa 29 byte di documento; lo stesso scritto come stringa con trattini, 49.

L'ordine fra tipi diversi

Un sort su un campo con tipi mescolati non fallisce: c'è un ordine totale fra i tipi, e misurato su quattordici documenti è questo.

MinKey → campo assente e null → numeri → stringhe → oggetti → binari → ObjectId → booleani → date → Timestamp → espressioni regolari → MaxKey.

Gli array non compaiono in quell'elenco perché un array si confronta per il suo elemento minore: [9, 10] si ordina fra i numeri. E un campo assente si ordina esattamente come null, al punto che {v: null} trova entrambe le cose; per separarle servono {v: {$type: "null"}} e {v: {$exists: false}}.

UTF-8 e collazione

Non c'è nessun set di caratteri da scegliere. Le stringhe BSON sono UTF-8 e basta: "café ☕ 日本語 👩‍💻" sono 14 caratteri e 31 byte, e torna com'era stata scritta.

Quello che si sceglie è la collazione, binaria per impostazione predefinita: senza collation, cafe e café sono valori diversi, e Árbol si ordina dopo zorro. Una collation con il suo locale e il suo strength — 1 ignora accenti e maiuscole, 2 ignora solo le maiuscole, 3 distingue tutto — cambia insieme confronto e ordine.

db.clientes.find({ n: "cafe" }).collation({ locale: "es", strength: 1 })
db.clientes.find().sort({ n: 1 }).collation({ locale: "es" })

Ed ecco la stessa trappola di SQL: la collazione della query deve essere quella dell'indice. Con un indice normale su n, la query {n: "cafe"} è un IXSCAN che esamina 1 documento; la stessa query con collation ricade in COLLSCAN e ne esamina 7. Il rimedio non è scrivere collation in ogni query, ma darla alla collezione: i suoi indici nascono allora con lei.

db.createCollection("clientes", { collation: { locale: "es", strength: 1 } })
db.clientes.createIndex({ n: 1 }, { unique: true })
db.clientes.insertOne({ n: "cafe" })
db.clientes.insertOne({ n: "CAFÉ" })   // E11000: qui è lo stesso valore

Altri due avvisi, misurati entrambi. $regex ignora la collazione: /^CAF/ non trova cafe nemmeno con strength: 1, mentre {n: "CAFE"} lo trova. E numericOrdering: true fa ordinare le stringhe "1", "2" e "10" come numeri invece che "1", "10", "2".

Parole chiave: bson, tipi, objectid, decimal128, numberlong, data, date, uuid, bindata, collation, collazione, utf-8, accenti, ordinamento

Indici: quale scegliere e la regola ESR

Gli undici tipi di indice di MongoDB e quando serve ciascuno, la regola ESR per ordinare un composto, la query coperta, i tetti e il prezzo in scrittura, tutto misurato con `explain`.

Si applica a: MongoDB 7.0+

Un indice è un albero B sul valore di un campo, e il numero che dice se serve esce da explain("executionStats"): totalDocsExamined contro nReturned. Se il primo è molto maggiore del secondo, il server sta leggendo documenti per buttarli.

const est = ["nuevo", "pagado", "enviado", "cerrado"]
const docs = []
for (let i = 0; i < 20000; i++) docs.push({
  cliente: i % 499, estado: est[i % 4], total: (i % 997) + 0.5,
  fecha: new Date(Date.UTC(2026, 0, 1 + (i % 240)))
})
db.pedidos.insertMany(docs)
const busca = { cliente: 42, estado: "pagado" }

db.pedidos.find(busca).explain("executionStats")   // COLLSCAN · 10 / 20000

db.pedidos.createIndex({ cliente: 1 })
db.pedidos.find(busca).explain("executionStats")   // IXSCAN · 10 / 40

db.pedidos.createIndex({ estado: 1, cliente: 1, fecha: -1 })
db.pedidos.find(busca).explain("executionStats")   // IXSCAN · 10 / 10

La regola ESR

Un indice composto si percorre per prefissi, quindi l'ordine dei suoi campi è la decisione. Prima i campi di uguaglianza (E), poi quello dell'ordinamento (S) e infine quello dell'intervallo (R). Con l'intervallo prima dell'ordinamento, l'indice filtra ma non ordina, e compare una fase SORT che ordina in memoria: 1 826 documenti, nella query qui sotto.

const q = { estado: "pagado", fecha: { $gte: new Date("2026-06-01") } }

db.pedidos.createIndex({ estado: 1, fecha: 1, total: 1 })          // E-R-S
db.pedidos.find(q).sort({ total: 1 }).hint("estado_1_fecha_1_total_1").explain()
// FETCH <- SORT <- IXSCAN

db.pedidos.createIndex({ estado: 1, total: 1, fecha: 1 })          // E-S-R
db.pedidos.find(q).sort({ total: 1 }).hint("estado_1_total_1_fecha_1").explain()
// FETCH <- IXSCAN

Quella fase ha un tetto: internalQueryMaxBlockingSortMemoryUsageBytes vale 104 857 600 byte, e oltre quello la query fallisce se non le si consente il disco.

Query coperta

Se l'indice porta tutti i campi che la query legge, il server non tocca i documenti. Attenzione a _id: entra nella proiezione per impostazione predefinita, non sta nell'indice, e va tolto a mano.

db.pedidos.find({ estado: "pagado" }, { _id: 0, estado: 1, total: 1 })
  .hint("estado_1_total_1_fecha_1").explain("executionStats")
// PROJECTION_COVERED · nReturned 5000 · totalDocsExamined 0

db.pedidos.find({ estado: "pagado" }, { estado: 1, total: 1 })
  .hint("estado_1_total_1_fecha_1").explain("executionStats")
// totalDocsExamined 5000

Gli altri tipi

TipoA cosa serveMisurato
multichiaveun campo che è un arraydue array in un composto: errore 171
testoricerca per paroleuno solo per collezione; il secondo dà 85
2dsphereGeoJSON e $nearsenza di lui $near risponde 291
hasheduguaglianza su chiavi lungheintervallo e sort cadono in COLLSCAN
jolly $**schema apertodelimita un campo per query, non due
TTLfar scadere documentiil raccoglitore passa ogni 60 s
parzialeun sottoinsieme della collezionela query deve ripetere il suo filtro
sparsesaltare quelli senza il campoun sort su di esso perde documenti
univocounicitàdue documenti senza il campo si scontrano
nascostoprovare una rimozione senza rimuoveretorna con collMod

I due che perdono dati in silenzio si vedono insieme:

db.socios.insertMany([{ a: 1 }, { a: 2 }, { b: 9 }, { b: 8 }])
db.socios.createIndex({ a: 1 }, { sparse: true })
db.socios.find().sort({ a: 1 }).hint("a_1")   // 2 / 4

db.correos.createIndex({ correo: 1 }, { unique: true })
db.correos.insertOne({ otro: 1 })
db.correos.insertOne({ otro: 2 })   // E11000 · dup key: { correo: null }

Il nascosto è l'opposto: resta mantenuto, ma il pianificatore non lo guarda. Con hidden: true la stessa query è stata un COLLSCAN su 20 000 documenti; tornato visibile con collMod, un IXSCAN su 1 940.

I tetti

for (let i = 0; i < 70; i++) db.tope.createIndex({ ["c" + i]: 1 })
// 67 CannotCreateIndex · add index fails, too many indexes

const k = {}
for (let i = 0; i < 33; i++) k["g" + i] = 1
db.comp.createIndex(k)   // 13103 · too many compound keys

Il nome di un indice e la dimensione di una chiave non hanno un tetto pratico: 400 caratteri e 2 000 byte sono stati accettati senza protestare.

Il prezzo

Ogni indice si paga a ogni scrittura. Le stesse 20 000 inserzioni hanno impiegato 60 ms senza indici, 101 ms con cinque e 160 ms con dieci. E occupano spazio: i sei indici di pedidos sommano 1,9 MB contro 916 KB di dati.

Per questo non si indicizza tutto. Un campo a bassa cardinalità quasi mai aiuta: estado, con quattro valori distinti, esamina 5 000 documenti per restituirne 5 000. E un indice semplice è di troppo se un composto comincia già con lui: { cliente: 1 } e { cliente: 1, fecha: 1 } esaminano gli stessi 40.

Parole chiave: indice, indici, ESR, composto, multichiave, testo, 2dsphere, hashed, jolly, TTL, parziale, sparse, univoco, nascosto, query coperta, explain, IXSCAN, COLLSCAN, totalDocsExamined, MongoDB

La pipeline di aggregazione: da $match a $merge

Le fasi della pipeline e in che ordine metterle, $lookup come JOIN e quanto costa senza indice, $graphLookup, le funzioni finestra, $facet e $unionWith, e $merge rispetto a $out.

Si applica a: MongoDB 7.0+

Una pipeline è un elenco di fasi, e ognuna riceve i documenti prodotti dalla precedente. L'ordine lo scrivi tu, ed è lì che sta quasi tutta la prestazione.

const est = ["nuevo", "pagado", "enviado", "cerrado"]
const docs = []
for (let i = 0; i < 20000; i++) docs.push({
  cliente: i % 499, estado: est[i % 4], total: (i % 997) + 0.5,
  fecha: new Date(Date.UTC(2026, 0, 1 + (i % 240)))
})
db.pedidos.insertMany(docs)

db.pedidos.aggregate([
  { $match: { estado: "pagado" } },
  { $group: { _id: "$cliente", gastado: { $sum: "$total" }, pedidos: { $sum: 1 } } },
  { $match: { pedidos: { $gte: 11 } } },
  { $sort: { gastado: -1 } },
  { $limit: 3 }
])
// { _id: 37, gastado: 522.5, pedidos: 11 } e altri due

Filtrare per primo, sempre

Solo la prima fase può usare un indice. Con un indice {estado: 1} su quei 20 000 ordini, il $match davanti esamina 5 000 chiavi e 5 000 documenti; lo stesso filtro dietro il $group, nessuna chiave e 20 000 documenti.

$lookup è il JOIN, e senza indice si paga

Unisce un'altra raccolta della stessa base, e ciò che trova arriva come array che quasi sempre si apre con $unwind.

const cl = []
for (let i = 0; i < 499; i++)
  cl.push({ num: i, nombre: "Cliente " + i, pais: ["MX", "ES", "AR", "CO"][i % 4] })
db.clientes.insertMany(cl)

const pais = [
  { $match: { estado: "pagado" } },
  { $lookup: { from: "clientes", localField: "cliente", foreignField: "num", as: "c" } },
  { $unwind: "$c" },
  { $group: { _id: "$c.pais", gastado: { $sum: "$total" } } }
]
db.pedidos.explain("executionStats").aggregate(pais)
// collectionScans 5001 - totalDocsExamined 2495499 - indexesUsed []

db.clientes.createIndex({ num: 1 })
db.pedidos.explain("executionStats").aggregate(pais)
// collectionScans 0 - totalDocsExamined 5001 - indexesUsed num_1

L'explain della fase non lascia margine: senza indice su clientes.num fa una passata intera per ogni documento in ingresso, e con l'indice non ne fa nessuna.

Il ricorsivo e quello di finestra

$graphLookup segue una gerarchia fin dove arriva, e depthField annota a che distanza è rimasto ogni gradino. L'array che restituisce non è ordinato: qui sono usciti Ana, Caro e Beto con i livelli 2, 0 e 1. $setWindowFields (5.0+) è la finestra di SQL: partitionBy è il PARTITION BY e sortBy l'ORDER BY.

db.empleados.insertMany([{ _id: 1, nombre: "Ana", jefe: null },
  { _id: 2, nombre: "Beto", jefe: 1 }, { _id: 3, nombre: "Caro", jefe: 2 },
  { _id: 4, nombre: "Dora", jefe: 3 }])
db.empleados.aggregate([
  { $match: { nombre: "Dora" } },
  { $graphLookup: { from: "empleados", startWith: "$jefe", connectFromField: "jefe",
                    connectToField: "_id", as: "cadena", depthField: "nivel" } }
])   // cadena: Ana(2), Caro(0), Beto(1)

db.pedidos.aggregate([
  { $match: { cliente: 42 } },
  { $setWindowFields: { partitionBy: "$estado", sortBy: { fecha: 1 },
      output: { acumulado: { $sum: "$total", window: { documents: ["unbounded", "current"] } },
                puesto: { $rank: {} } } } },
  { $match: { estado: "pagado" } },
  { $limit: 3 }
])   // acumulado 559.5 - 1113 - 1660.5

Più risposte in una sola passata

$facet esegue sotto-pipeline sullo stesso ingresso e restituisce un solo documento con tutte; al suo interno non vale più nessun indice. $unionWith è l'UNION ALL.

db.devoluciones.insertMany([{ cliente: 42, total: 9.5 }, { cliente: 7, total: 4.25 }])
db.pedidos.aggregate([
  { $match: { cliente: 42, estado: "pagado" } },
  { $project: { _id: 0, cliente: 1, importe: "$total" } },
  { $unionWith: { coll: "devoluciones", pipeline: [
      { $match: { cliente: 42 } },
      { $project: { _id: 0, cliente: 1, importe: { $multiply: ["$total", -1] } } } ] } },
  { $group: { _id: "$cliente", neto: { $sum: "$importe" }, filas: { $sum: 1 } } }
])   // { _id: 42, neto: 5495.5, filas: 11 }

Scrivere il risultato

$merge fonde nella raccolta di destinazione e $out la sostituisce per intero.

db.pedidos.aggregate([
  { $match: { estado: "pagado" } },
  { $group: { _id: "$cliente", gastado: { $sum: "$total" } } },
  { $merge: { into: "resumen", whenMatched: "merge", whenNotMatched: "insert" } }
])
db.pedidos.aggregate([
  { $match: { estado: "enviado" } },
  { $group: { _id: "$cliente", enviado: { $sum: "$total" } } },
  { $merge: { into: "resumen", whenMatched: "merge", whenNotMatched: "insert" } }
])   // resumen: { _id: 42, gastado: 5505, enviado: 515 }

db.pedidos.aggregate([
  { $match: { estado: "nuevo" } },
  { $group: { _id: "$cliente", nuevo: { $sum: "$total" } } },
  { $out: "resumen" }
])   // resumen: { _id: 42, nuevo: 525 } - il contenuto precedente non c'è più

I tetti

Ogni fase che blocca — $group, $sort, $facet, l'intermedio di $lookup — ha 104 857 600 byte, e dalla 6.0 ciò che trabocca va su disco da solo. Ma l'array di un accumulatore non trabocca: un $push su documenti grandi fallisce con 146 ExceededMemoryLimit, e allowDiskUse: true non lo salva.

Parole chiave: aggregazione, pipeline, fase, match, group, project, lookup, join, graphLookup, setWindowFields, finestra, facet, unionWith, merge, out, unwind, explain, allowDiskUse

Transazioni: quando servono e il conflitto 112

Un documento si scrive per intero senza transazione; per più di uno ne serve una, e un replica set. La sessione, readConcern e writeConcern, il WriteConflict 112 e i tre tetti misurati.

Si applica a: MongoDB 7.0+

In MongoDB un documento si scrive per intero o non si scrive, e vale anche se l'updateOne tocca dieci campi e un array annidato. Per cambiare più di un documento alla volta serve una transazione, e una transazione richiede un replica set: su un nodo isolato viene rifiutata, e il messaggio non parla nemmeno di transazioni — dice che il deployment non ammette scritture ripetibili.

db.cuentas.insertMany([{ _id: "A", saldo: 100 }, { _id: "B", saldo: 100 }])
db.cuentas.updateOne({ _id: "A" },
  { $inc: { saldo: -30 }, $set: { ultimo: new Date("2026-09-12") } })
db.cuentas.findOne({ _id: "A" })
// { _id: "A", saldo: 70, ultimo: 2026-09-12 } - los dos campos, o ninguno

La transazione gira su una sessione

Tutto quello che sta dentro passa dall'oggetto della sessione: il db.cuentas di fuori non è nella transazione, per quanto si chiami uguale. Fino al commitTransaction(), quanto scritto lo vede solo chi sta dentro.

const s = db.getMongo().startSession()
const c = s.getDatabase(db.getName()).cuentas
s.startTransaction({ readConcern: { level: "snapshot" },
                     writeConcern: { w: "majority" } })
c.updateOne({ _id: "A" }, { $inc: { saldo: -10 } })
c.updateOne({ _id: "B" }, { $inc: { saldo: 10 } })

c.find().toArray()           // dentro:  A 60, B 110
db.cuentas.find().toArray()  // fuera:   A 70, B 100

s.commitTransaction()
db.cuentas.find().toArray()  // fuera:   A 60, B 110
s.endSession()

abortTransaction() disfa tutto, e non c'è bisogno di chiederlo: se la sessione se ne va o il server riparte, la transazione muore abortita.

readConcern e writeConcern sono due domande diverse

readConcern dice che cosa si legge: local è quello che c'è qui, majority quello che non si può più perdere, snapshot una foto coerente di un istante. writeConcern dice quando si considera scritta: w: 1 è il primario, w: "majority" la maggioranza dell'insieme, e j: true aggiunge il giornale. I valori di fabbrica escono da getDefaultRWConcern: lettura local, scrittura majority.

Il conflitto di scrittura

Due transazioni sullo stesso documento non aspettano: la seconda fallisce all'istante con 112 WriteConflict, e il messaggio lo dice senza giri di parole. Riprovare fa parte del patto, ed è per questo che i driver portano withTransaction, che riprova da solo.

const s1 = db.getMongo().startSession()
const s2 = db.getMongo().startSession()
s1.startTransaction(); s2.startTransaction()
s1.getDatabase(db.getName()).cuentas.updateOne({ _id: "A" }, { $inc: { saldo: 1 } })
s2.getDatabase(db.getName()).cuentas.updateOne({ _id: "A" }, { $inc: { saldo: 1 } })
// 112 WriteConflict - Write conflict during plan execution ... Please retry

s1.commitTransaction()   // la prima passa
s2.abortTransaction()
s1.endSession(); s2.endSession()

Una scrittura da fuori della transazione non riceve il 112: aspetta. Nella misura ha aspettato 76 s, finché il limite di vita non ha abortito la transazione che teneva il documento.

I tetti

Una transazione dura 60 s, e oltre quella riga il commit restituisce 251 NoSuchTransaction con «has been aborted». Dentro, ogni richiesta di blocco aspetta solo 5 ms: una transazione non resta appesa a un lucchetto, preferisce fallire. E un writeConcern che l'insieme non può soddisfare fallisce prima ancora di provarci.

db.adminCommand({ getParameter: 1, transactionLifetimeLimitSeconds: 1 })
// 60
db.adminCommand({ getParameter: 1, maxTransactionLockRequestTimeoutMillis: 1 })
// 5
db.adminCommand({ getDefaultRWConcern: 1 })
// defaultReadConcern local - defaultWriteConcern { w: "majority", wtimeout: 0 }

db.cuentas.updateOne({ _id: "A" }, { $inc: { saldo: 0 } },
  { writeConcern: { w: 2, wtimeout: 1000 } })
// 100 UnsatisfiableWriteConcern - Not enough data-bearing nodes

La regola pratica: se due documenti devono cambiare insieme molte volte al giorno, quasi sempre è il modello a essere sbagliato, e la cosa giusta era annidarli. La transazione è la via d'uscita per ciò che davvero non sta in un documento.

Parole chiave: transazione, transazioni, sessione, startTransaction, commit, abort, WriteConflict, 112, 251, readConcern, writeConcern, majority, snapshot, replica set, atomicità, blocco

Prestazioni: explain, cache dei piani e profiler

I tre livelli di dettaglio di explain e che cosa aggiunge ciascuno, la cache dei piani e quando un piano viene disattivato, il working set dentro la cache di WiredTiger e i tre livelli del profiler.

Si applica a: MongoDB 7.0+

Prima di toccare qualcosa si misura, e lo strumento è explain. Ha tre livelli di dettaglio, e ognuno costa più del precedente: queryPlanner si limita a pianificare — non arriva a eseguire — e mostra il piano vincente e quelli scartati; executionStats esegue il vincente e aggiunge quanto è costato; allPlansExecution aggiunge inoltre quanto è costato ogni candidato durante il periodo di prova, ed è lì che si vede perché ha vinto chi ha vinto.

const est = ["nuevo", "pagado", "enviado", "cerrado"]
const docs = []
for (let i = 0; i < 20000; i++)
  docs.push({ cliente: i % 499, estado: est[i % 4], total: (i % 997) + 0.5 })
db.pedidos.insertMany(docs)
db.pedidos.createIndex({ cliente: 1 })
db.pedidos.createIndex({ cliente: 1, total: -1 })
const q = { cliente: 42, total: { $gt: 100 } }

db.pedidos.find(q).explain()
// queryPlanner: winningPlan FETCH e rejectedPlans con 1 candidato

db.pedidos.find(q).explain("executionStats")
// + executionStats: nReturned 20, docs 20, claves 20, 0 ms

db.pedidos.find(q).explain("allPlansExecution")
// + allPlansExecution: i 2 piani provati, FETCH 20 e FETCH 10

I tre numeri che contano sono nReturned, totalDocsExamined e totalKeysExamined. Se il secondo è molto più grande del primo, il server sta leggendo documenti per buttarli.

La cache dei piani

Il pianificatore non decide di nuovo a ogni query. La prima volta prova i candidati, mette da parte il vincente sotto un planCacheKey e da lì in poi lo riusa; nell'explain si vede come isCached: true. La voce conserva works, il lavoro che è costato, e se un'esecuzione successiva ne spende dieci volte tanto, il piano viene disattivato e si torna a competere. Anche creare o eliminare un indice svuota la cache.

db.pedidos.getPlanCache().clear()
db.pedidos.getPlanCache().list()     // []

db.pedidos.find(q).toArray()
db.pedidos.find(q).toArray()
db.pedidos.getPlanCache().list()     // 1 entrada: works 21, isActive true

db.pedidos.find(q).explain().queryPlanner.winningPlan.isCached   // true

Il working set e la cache di WiredTiger

MongoDB non conserva risultati: quello che conserva sono pagine, nella cache di WiredTiger, e la prestazione dipende dal fatto che il working set — i dati e gli indici davvero toccati — ci stia dentro. Per impostazione predefinita quella cache prende metà di quel che resta della RAM dopo averne messo da parte 1 GB. Nella misura, di 486 864 pagine richieste solo 261 sono dovute arrivare dal disco: una su 1 865.

const w = db.serverStatus().wiredTiger.cache
w["maximum bytes configured"]        // 3621781504 - metà di (RAM - 1 GB)
w["bytes currently in the cache"]    // 18640438 - di tutto il server
w["pages requested from the cache"]  // 486864
w["pages read into cache"]           // 261 - una su 1865

db.pedidos.stats().size              // 1385000 di dati
db.pedidos.stats().totalIndexSize    // 499712 di indici

Il profiler

Tre livelli: 0 spento, 1 solo ciò che supera slowms, e 2 tutto. Due cose misurate che sorprendono: setProfilingLevel restituisce il livello di prima, non quello appena impostato — il nuovo va riletto — e system.profile è una raccolta limitata da 1 MiB, quindi non cresce: si morde la coda.

db.getProfilingStatus()      // { was: 1, slowms: 100, sampleRate: 1 }
db.setProfilingLevel(2)      // { was: 1, ... }  <- il livello di PRIMA

db.pedidos.find({ total: { $gt: 900 } }).toArray()
db.system.profile.find().sort({ ts: -1 }).limit(1)
// query - COLLSCAN - docsExamined 1901 - nreturned 101 - 0 ms

db.system.profile.stats().capped    // true, maxSize 1048576
db.setProfilingLevel(1, { slowms: 100 })

Ogni voce porta planSummary, docsExamined, nreturned e millis, che è esattamente ciò che serve per decidere se conviene un indice. Calíope legge quella raccolta nel suo strumento di profilazione. Il livello 2 in produzione costa caro: si accende per un po' e si riabbassa, non si lascia lì.

Parole chiave: prestazioni, explain, queryPlanner, executionStats, allPlansExecution, cache dei piani, planCacheKey, isCached, WiredTiger, working set, profiler, system.profile, slowms

Configurazione del server: il file e ciò che cambia a caldo

Che cosa gira davvero secondo getCmdLineOpts, le cinque sezioni di mongod.conf che contano, e le tre classi di parametro: quelli che cambiano a caldo, quelli d'avvio e quelli che parametri non sono.

Si applica a: MongoDB 7.0+

La prima domanda su un server che non conosci non è che cosa dice il suo file di configurazione, ma con che cosa sta girando davvero. getCmdLineOpts risponde a entrambe insieme: argv è quello che gli è stato passato sulla riga di comando, e parsed la stessa cosa, già tradotta nel vocabolario del file.

db.adminCommand({ getCmdLineOpts: 1 })
// argv:   ["mongod", "--profile", "1", "--slowms", "100",
//          "--auth", "--bind_ip_all"]
// parsed: { net: { bindIp: "*" },
//           operationProfiling: { mode: "slowOp", slowOpThresholdMs: 100 },
//           security: { authorization: "enabled" } }

Le cinque sezioni che contano

storage dice dove stanno i dati e quanta memoria si prende la cache; net, su quali indirizzi ascolta; security, se bisogna autenticarsi; operationProfiling, che cosa si annota del traffico lento; e replication, a quale insieme appartiene. Il file è YAML, quindi l'indentazione è sintassi.

# mongod.conf - lo stesso di sopra, scritto dove resta
storage:
  dbPath: /data/db
  wiredTiger:
    engineConfig:
      cacheSizeGB: 3.37
net:
  bindIp: 127.0.0.1,10.0.0.5
  port: 27017
security:
  authorization: enabled
operationProfiling:
  mode: slowOp
  slowOpThresholdMs: 100
replication:
  replSetName: rs0
setParameter:
  cursorTimeoutMillis: 300000

I parametri sono di tre classi

Quelli che si cambiano a caldo con setParameter, quelli che si leggono solo all'avvio, e quelli che parametri non sono, per quanto lo sembrino. Tutti e tre si distinguono da quello che risponde il server: il primo restituisce was con il valore precedente — non quello nuovo, quindi per sapere com'è rimasto va riletto; il secondo dà 20 IllegalOperation; e port, che è un'opzione d'avvio e non un parametro, dà 72 InvalidOptions con «unrecognized parameter».

db.adminCommand({ setParameter: 1, cursorTimeoutMillis: 300000 })
// { was: 600000, ok: 1 }   <- restituisce il valore di PRIMA, non il nuovo

db.adminCommand({ setParameter: 1, wiredTigerEngineRuntimeConfig: "cache_size=512M" })
db.serverStatus().wiredTiger.cache["maximum bytes configured"]   // 536870912

db.adminCommand({ setParameter: 1, authenticationMechanisms: ["SCRAM-SHA-256"] })
// 20 IllegalOperation - not allowed to change [...] at runtime

db.adminCommand({ setParameter: 1, port: 27020 })
// 72 InvalidOptions - attempted to set unrecognized parameter [port]

setParameter non resta

Un setParameter vive fino al riavvio successivo e non un secondo di più: con cursorTimeoutMillis messo a 300 000, il server è tornato su a 600 000. Perché resti va scritto nella sezione setParameter: del file, quella in fondo all'esempio.

La cache e la versione di compatibilità

La cache di WiredTiger è la prima cosa che si vuole toccare, e quasi sempre quella da non toccare: per impostazione predefinita si prende metà di quello che resta della RAM dopo averne messo da parte 1 GB. Sul nodo misurato, 7 933 MB di RAM hanno dato 3 621 781 504 byte di cache. E c'è una sesta cosa che nel file non sta: la featureCompatibilityVersion, che decide quali funzioni del binario sono accese — si alza a mano dopo un aggiornamento e si abbassa prima di tornare indietro.

db.hostInfo().system.memSizeMB   // 7933
db.serverStatus().wiredTiger.cache["maximum bytes configured"]
// 3621781504 = metà di (7933 MB - 1 GB)

Object.keys(db.adminCommand({ getParameter: "*" })).length   // 780
db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 })
// { version: "8.2" }

Parole chiave: configurazione, mongod.conf, getCmdLineOpts, setParameter, cacheSizeGB, WiredTiger, bindIp, authorization, operationProfiling, replSetName, featureCompatibilityVersion, FCV

Sicurezza: utenti, ruoli e l'eccezione di localhost

Un utente vive in una base ed è quello il suo cognome, i ruoli integrati non sono gli stessi in admin e altrove, il 13 che riceve una scrittura senza permesso, e l'unica porta che lascia aperta un server appena messo in sicurezza.

Si applica a: MongoDB 7.0+

Senza security.authorization: enabled non esiste niente di tutto questo: il server accetta chiunque arrivi, e con bindIp: "*" può arrivare chiunque. Acceso, la prima domanda è chi sono io.

db.adminCommand({ connectionStatus: 1 }).authInfo
// { authenticatedUsers:     [{ user: "caliope", db: "admin" }],
//   authenticatedUserRoles: [{ role: "root",    db: "admin" }] }

db.adminCommand({ getParameter: 1, authenticationMechanisms: 1 })
// ["MONGODB-X509", "SCRAM-SHA-1", "SCRAM-SHA-256"]

Il meccanismo predefinito è SCRAM-SHA-256, e il server conserva entrambe le versioni: la password dell'utente misurato porta 15 000 iterazioni in SHA-256 e 10 000 in SHA-1, con un sale di 40 caratteri. La password non viaggia, nemmeno cifrata: SCRAM dimostra che la si conosce senza dirla. Il terzo meccanismo, MONGODB-X509, scambia la password con un certificato client, e allora il nome dell'utente è il soggetto del certificato.

Un utente vive in una base, ed è quella la sua altra metà

lector non è un utente: lector di ventas lo è. La base in cui è stato creato è la sua base di autenticazione, e va nominata al collegarsi (--authenticationDatabase). Con quella sbagliata il server non dice che l'utente esiste altrove: dice «Authentication failed» e basta.

I ruoli non sono gli stessi in tutte le basi

Una base normale ha sei ruoli integrati. admin ne ha ventuno, perché lì vivono quelli che arrivano a tutto il server — i …AnyDatabase, root, backup, restore, quelli di cluster. Un ruolo è un elenco di azioni: read ne sono undici, e find è solo una.

db.getSiblingDB("admin").runCommand({ rolesInfo: 1, showBuiltinRoles: true })
// 21 roles: backup, clusterAdmin, clusterManager, clusterMonitor, dbAdmin,
// dbAdminAnyDatabase, dbOwner, read, readAnyDatabase, readWrite,
// readWriteAnyDatabase, restore, root, userAdmin, userAdminAnyDatabase ...

db.getSiblingDB("ventas").runCommand({ rolesInfo: 1, showBuiltinRoles: true })
// 6: dbAdmin, dbOwner, enableSharding, read, readWrite, userAdmin

Che cosa succede quando manca un permesso

Non c'è risposta vuota né riga mancante: c'è un 13 Unauthorized, e il messaggio nomina la base, il comando e persino la raccolta. È un errore da cui si ricava la regola che manca.

db.getSiblingDB("ventas").createUser({
  user: "lector", pwd: "lectorpass",
  roles: [{ role: "read", db: "ventas" }]
})
// mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-256"]

// già connesso come lettore, con --authenticationDatabase ventas:
db.datos.findOne()              // { _id: 1, v: 1 }
db.datos.insertOne({ _id: 2 })
// 13 Unauthorized - not authorized on ventas to execute command { insert: ... }

L'eccezione di localhost

Un server con --auth e senza un solo utente lascia fare a chi arriva dalla macchina stessa esattamente una cosa: creare il primo. Leggere no; creare il secondo, nemmeno. È la rampa per cominciare, e si chiude da sola non appena un utente esiste.

// un nodo con --auth e senza un solo utente, dal nodo stesso:
db.getSiblingDB("prueba").c.findOne()
// 13 Unauthorized

db.getSiblingDB("admin").createUser({ user: "primero", pwd: "x", roles: ["root"] })
// OK - è l'unico che consente

db.getSiblingDB("admin").createUser({ user: "segundo", pwd: "x", roles: ["root"] })
// 13 Unauthorized - Command createUser requires authentication

Parole chiave: sicurezza, autenticazione, SCRAM, SCRAM-SHA-256, x.509, TLS, utente, ruolo, ruoli integrati, read, readWrite, dbAdmin, userAdmin, root, 13, Unauthorized, localhost, authSource

Backup: mongodump, l'oplog e quello che non copre

Che cosa c'è dentro un mongodump e che cosa no, quanto ci mette il ripristino e perché, a che serve --oplog, quanto dura davvero la finestra dell'oplog, e le due cose che un dump non garantisce.

Si applica a: MongoDB 7.0+

mongodump è un backup logico: si collega come un client qualunque, legge i documenti e li scrive in BSON. Questo ha due conseguenze che si vedono nei numeri. La prima: il file misura quanto misurano i documenti, non quanto occupano sul disco — 2 420 000 byte di .bson per una raccolta che sul disco sta compressa. La seconda: compete per la cache con il lavoro normale del server, quindi un dump di una base grande si sente.

mongodump -u caliope -p ... --authenticationDatabase admin \
  --db ventas --out /vol
// writing `ventas.pedidos` to `/vol/ventas/pedidos.bson`
// done dumping `ventas.pedidos` (20000 documents)      26 ms

ls -l /vol/ventas
// pedidos.bson           2420000   <- la dimensione LOGICA dei documenti
// pedidos.metadata.json      255   <- gli indici, senza i loro dati

Degli indici viaggia solo la definizione, nel .metadata.json. Per questo ripristinare costa molto più che scaricare — 91 ms contro 26 in questa misura: il tempo se ne va nel ricostruirli, e su una raccolta vera è quasi tutta l'attesa.

mongorestore -u caliope -p ... --authenticationDatabase admin \
  --nsFrom "ventas.*" --nsTo "copia.*" /vol
// restoring `copia.pedidos` from `/vol/ventas/pedidos.bson`
// finished restoring `copia.pedidos` (20000 documents, 0 failures)
// restoring indexes for collection `copia.pedidos` from metadata
// 20000 document(s) restored successfully.             91 ms

db.getSiblingDB("copia").pedidos.getIndexes()   // _id_ e cliente_1

--archive lascia un solo file invece di un albero di cartelle, e con --gzip è sceso da 2 420 000 a 133 572 byte. Entrambi si possono mandare in una pipe, ed è così che una base si copia da una macchina all'altra senza toccare un disco intermedio.

mongodump ... --db ventas --archive=/vol/ventas.gz --gzip
// 133572 byte, contro i 2420000 del BSON sciolto

L'oplog è ciò che trasforma un backup in un istante

Un dump dura, e intanto la base continua a cambiare: quello che è stato scritto nella raccolta A prima di scaricarla e in B dopo non torna. --oplog salva anche le operazioni avvenute durante lo scarico, e mongorestore --oplogReplay le applica alla fine, così che quanto ripristinato è lo stato di un istante, quello di fine dump. Funziona solo contro un replica set, perché l'oplog è suo.

// questo esiste solo in un replica set:
db.getSiblingDB("local").oplog.rs.stats()
// capped true - maxSize 45822903296 - size 3708051130 - count 318107

rs.printReplicationInfo()
// oplog first event time   Sat Aug 08 2026 17:56:09
// oplog last event time    Sat Sep 12 2026 07:41:07

mongodump --port 27018 --oplog --out /vol
// writing captured oplog to `` - dumped 1 oplog entry

L'oplog è una raccolta limitata, quindi la sua finestra non si misura in byte ma in tempo, e quel tempo dipende da quanto si scrive. Sul nodo misurato, 42 GiB di tetto davano una finestra dall'8 agosto al 12 settembre; con dieci volte il carico sarebbero tre giorni. È il numero da guardare prima di andarsene per il fine settimana.

Quello che un dump non copre

Due cose. Una base di centinaia di gigabyte non si salva leggendola documento per documento: lì si passa alle istantanee del file system, che vanno prese con il giornale incluso o con la base bloccata da fsyncLock. E in un cluster frammentato, un mongodump contro il router non dà un istante comune ai frammenti: bisogna fermare il bilanciatore e prendere un'istantanea per frammento più una dei server di configurazione.

Parole chiave: backup, copia di sicurezza, mongodump, mongorestore, oplog, oplogReplay, archive, gzip, istantanea, snapshot, fsyncLock, ripristino, finestra di ripristino, BSON

Schema: non c'è ALTER, c'è un validatore

Lo schema è quello che portano i documenti, quindi cambiarlo vuol dire scriverli. Il validatore con $jsonSchema, il 121 e il suo errInfo, le quattro combinazioni di validationLevel e validationAction, e la migrazione per versione di documento.

Si applica a: MongoDB 7.0+

Non c'è ALTER TABLE perché non c'è tabella: lo schema di una raccolta è, letteralmente, quello che portano i suoi documenti. Aggiungere un campo ai nuovi non costa nulla e non cambia i vecchi, ed è lì l'inghippo: chi legge deve arrangiarsi con entrambe le forme finché qualcuno non pareggia il passato.

Il validatore è una porta, non uno schema

Quello che esiste è un validator con $jsonSchema: una condizione controllata in scrittura, mai in lettura e mai all'indietro. Rifiuta con 121 DocumentValidationFailure, e la parte buona sta in errInfo.details, che nomina la regola infranta invece di dire «non valido».

db.createCollection("socios", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["nombre", "correo"],
    properties: {
      nombre: { bsonType: "string", minLength: 2 },
      correo: { bsonType: "string", pattern: "^.+@.+$" },
      edad:   { bsonType: "int",    minimum: 18 }
    } } },
  validationLevel: "strict", validationAction: "error"
})

db.socios.insertOne({ nombre: "Ana", correo: "ana@ej.com", edad: 30 })   // entra
db.socios.insertOne({ nombre: "Caro" })
// 121 DocumentValidationFailure
// errInfo.details: required -> missingProperties: ["correo"]

Attenzione ai tipi: mongosh salva come int un numero intero, quindi edad: 30 supera un bsonType: "int" ed edad: 30.5 lo infrange, perché quello sì è un double.

validationLevel e validationAction sono due manopole diverse

Il livello dice quali documenti raggiunge: strict tutti, moderate solo quelli che erano già validi — così si mette un validatore su una raccolta piena di vecchiume senza incepparne gli aggiornamenti. L'azione dice che cosa succede quando fallisce: error rifiuta, warn lascia scrivere e lo annota nel log. E mettere il validatore con collMod non tocca nulla di ciò che c'era già.

db.viejos.insertMany([{ _id: 1, nombre: "Uno" }, { _id: 2, nombre: "Dos" }])
db.runCommand({ collMod: "viejos",
  validator: { $jsonSchema: { bsonType: "object", required: ["nombre", "correo"] } },
  validationLevel: "moderate", validationAction: "error" })
db.viejos.countDocuments()                                  // 2 - non cancella nulla

db.viejos.updateOne({ _id: 1 }, { $set: { nombre: "Uno bis" } })   // moderate: passa
db.viejos.insertOne({ _id: 3, nombre: "Tres" })                    // 121 igual

db.runCommand({ collMod: "viejos", validationLevel: "strict" })
db.viejos.updateOne({ _id: 2 }, { $set: { nombre: "Dos bis" } })   // 121

db.runCommand({ collMod: "viejos", validationAction: "warn" })
db.viejos.insertOne({ _id: 4, nombre: "Cuatro" })      // entra, e avvisa soltanto

Migrare vuol dire scrivere

Senza ALTER, l'equivalente di una colonna nuova è un updateMany con $set, e quello di toglierla, uno con $unset. Costano poco — 20 000 documenti in 61 e 51 ms — ma non sono atomici: vanno documento per documento, quindi durante la migrazione convivono le due forme.

db.pedidos.updateMany({ _v: 1 }, { $set: { moneda: "MXN", _v: 2 } })
// 20000 modificati in 61 ms

db.pedidos.updateMany({}, { $unset: { moneda: "" } })
// 20000 modificati in 51 ms

Per questo il motivo che regge è tenere la versione dentro ogni documento (_v): l'applicazione sa leggere entrambe, la migrazione avanza a lotti o al momento di toccare ogni documento, e il giorno in cui {_v: 1} non restituisce più nulla, il codice vecchio se ne va. È quello che fa anche una migrazione SQL, solo che qui lo stato intermedio è visibile e va scritto.

Parole chiave: schema, validazione, validatore, jsonSchema, 121, DocumentValidationFailure, validationLevel, validationAction, strict, moderate, warn, collMod, migrazione, versione di documento

Sharding: la chiave decide tutto

I tre pezzi di un cluster frammentato, perché una chiave hashed distribuisce e una monotona ammucchia, la differenza misurata fra una query mirata e una diffusa, le zone, e quanto costa cambiare la chiave.

Si applica a: MongoDB 7.0+

Frammentare vuol dire ripartire una raccolta su più macchine, e servono tre pezzi: i frammenti, che tengono i dati e sono replica set; i server di configurazione, che tengono la mappa di quale pezzo sta dove; e mongos, il router, che non tiene nulla ed è quello a cui si collega l'applicazione.

# tre pezzi distinti, e il router non conserva dati
mongod --configsvr --replSet cfg --port 27019
mongod --shardsvr  --replSet sh1 --port 27018
mongod --shardsvr  --replSet sh2 --port 27018
mongos --configdb cfg/qa-cfg:27019

# ya conectado al mongos:
sh.addShard("sh1/qa-sh1:27018")
sh.addShard("sh2/qa-sh2:27018")   // config.shards: ["sh1", "sh2"]

La chiave di frammentazione è l'unica decisione che conta

Da lei escono tre cose insieme: come si distribuiscono i dati, quali query si possono dirigere a un solo frammento, e se c'è un punto caldo. Si chiede cardinalità — molti valori distinti —, frequenza pari, perché nessun valore si prenda la metà, e che non sia monotona, perché una chiave che cresce sempre manda tutte le scritture nuove nello stesso posto.

Quest'ultima non è teoria. Sulla stessa raccolta di 60 000 documenti: una chiave hashed sul cliente ha lasciato il 52,11 % su un frammento e il 47,88 % sull'altro; l'_id di ObjectId, che cresce sempre, ha lasciato il 100 % su uno solo, in un unico pezzo.

sh.enableSharding("ventas")

sh.shardCollection("ventas.pedidos", { cliente: "hashed" })
db.pedidos.getShardDistribution()
// 60000 documentos - sh1 52.11 %, sh2 47.88 % - 2 trozos

sh.shardCollection("ventas.eventos", { _id: 1 })
db.eventos.getShardDistribution()
// 60000 documentos - sh2 100 % - 1 trozo

Mirata o diffusa

Una query che porta la chiave va a un frammento e basta. Una che non la porta viene chiesta a tutti e le risposte si fondono: SINGLE_SHARD contro SHARD_MERGE. La differenza misurata è di 121 documenti esaminati contro 60 000.

db.pedidos.find({ cliente: 42 }).explain()
// SINGLE_SHARD - shards: ["sh2"]
// examinados 121, devueltos 121

db.pedidos.find({ estado: "pagado" }).explain()
// SHARD_MERGE  - shards: ["sh2", "sh1"]
// examinados 60000, devueltos 15000

Le zone, e cambiare chiave

Una zona lega un intervallo della chiave a un frammento, e serve a due cose vere: tenere i dati di un paese su macchine di quel paese, e separare il caldo dal freddo. E dalla 5.0 la chiave si può cambiare con reshardCollection, ma copia la raccolta intera e si sente: su 60 000 documenti stava ancora lavorando dopo due minuti, con la sua raccolta temporanea in bella vista.

sh.addShardToZone("sh1", "MX")
sh.addShardToZone("sh2", "EU")
// config.shards: sh1 tags ["MX"], sh2 tags ["EU"]

db.adminCommand({ reshardCollection: "ventas.eventos", key: { _id: "hashed" } })
// copia l'intera collezione: era ancora in corso dopo due minuti,
// con il suo system.resharding.<uuid> visibile in config.collections

Tre cose che non sono più vere

Si racconta ancora che un updateOne senza la chiave fallisca, che il valore della chiave non si possa cambiare, e che l'indice debba esistere prima di frammentare. Sulla 8.2 tutte e tre sono passate senza protestare.

Parole chiave: frammentazione, sharding, chiave di frammentazione, hashed, chunk, mongos, server di configurazione, zona, bilanciatore, reshardCollection, SINGLE_SHARD, SHARD_MERGE

Errori frequenti: gli otto numeri e che cosa portano dentro

Gli otto codici che escono ogni giorno, provocati uno a uno con il testo che restituisce il server, e i due che portano dentro più del numero: l'11000 con la sua chiave e il 121 con il suo errInfo.

Si applica a: MongoDB 7.0+

Un errore di MongoDB porta un numero, quasi sempre un nome, e a volte qualcosa dentro che vale più di entrambi. Questi sono gli otto che escono ogni giorno, provocati uno a uno contro il server e ricopiati così come sono arrivati.

CodiceNomeChe cosa è successo
11000—chiave duplicata in un indice unico
13Unauthorizedall'utente manca un'azione su quella base
18AuthenticationFailedutente, password o base di autenticazione
26NamespaceNotFoundla raccolta non esiste
50MaxTimeMSExpiredha superato il tempo che gli è stato dato
112WriteConflictun'altra transazione ha toccato quel documento
121—il documento non ha passato il validatore
251NoSuchTransactionla transazione era già abortita

I due senza nome portano qualcosa di meglio

L'11000 e il 121 arrivano senza codeName, e non importa: entrambi portano il dato che serve per aggiustarli. L'11000 nomina la raccolta, l'indice e il valore che ha cozzato, quindi non c'è da indovinare quale di tre indici unici è scattato.

db.correos.createIndex({ correo: 1 }, { unique: true })
db.correos.insertOne({ correo: "a@b.c" })
db.correos.insertOne({ correo: "a@b.c" })
// 11000 :: E11000 duplicate key error collection: ventas.correos
//          index: correo_1 dup key: { correo: "a@b.c" }

Il 121 dice «Document failed validation» e nient'altro nel messaggio, ma e.errInfo.details porta la regola infranta con il suo nome e il valore atteso. È la differenza fra «non valido» e «manca correo».

db.createCollection("socios", {
  validator: { $jsonSchema: { bsonType: "object", required: ["correo"] } } })
db.socios.insertOne({ nombre: "sin correo" })
// 121 :: Document failed validation
// e.errInfo.details:
// { operatorName: "$jsonSchema", schemaRulesNotSatisfied: [
//     { operatorName: "required", specifiedAs: { required: ["correo"] },
//       missingProperties: ["correo"] } ] }

I due di permesso sono diversi

Il 18 è «non so chi sei»: password sbagliata, o — il più delle volte — la base di autenticazione sbagliata, perché un utente di MongoDB è il nome più la base in cui è stato creato. Il 13 è «so chi sei e non puoi»; il suo messaggio nomina la base, il comando e persino la raccolta, quindi il ruolo che manca si legge direttamente lì.

E due avvisi di sintassi

Il 50 non è un errore del server ma il tetto che gli hai messo tu con maxTimeMS. E il 26 spunta dove meno te l'aspetti: collMod e renameCollection su qualcosa che non esiste falliscono, ma drop() su una raccolta inesistente restituisce false e basta — non è un errore, quindi uno script che lo dà per scontato non scopre mai di aver sbagliato nome.

db.gordo.find({ s: /x{100}/ }).maxTimeMS(1).toArray()
// 50 MaxTimeMSExpired :: operation exceeded time limit

db.runCommand({ collMod: "no_existe", validationLevel: "strict" })
// 26 NamespaceNotFound :: ns does not exist

db.no_existe.drop()   // false, e nessun errore

Parole chiave: errori, codici, 11000, duplicate key, 13, Unauthorized, 18, AuthenticationFailed, 26, NamespaceNotFound, 50, MaxTimeMSExpired, 112, WriteConflict, 121, 251, NoSuchTransaction, errInfo

Limiti: gli otto tetti e come avvisa ciascuno

Gli otto tetti che si toccano davvero, provocati uno a uno contro il server, con il codice che restituisce ciascuno e i tre che non stanno dove dice la loro fama.

Si applica a: MongoDB 7.0+

Tutti questi sono stati provocati contro il server, quindi il numero a sinistra è quello che ha davvero rifiutato, non quello della leggenda.

TettoValore misuratoCome avvisa
dimensione di un documento16 777 216 byte10334
profondità di annidamento179 livelli in un insertOne15 Overflow
indici per raccolta64, contando _id_67 CannotCreateIndex
campi di un indice composto3213103
nome di una base63 caratteri73 InvalidNamespace
base + raccolta255 caratteri73 InvalidNamespace
dimensione di un valore indicizzatonessun tetto pratico—
fase bloccante di una pipeline104 857 600 byte146

Quello dei 16 MB è l'unico che si tocca per sbaglio

E quasi sempre per lo stesso motivo: un array che cresce senza freno dentro un documento. Il messaggio porta entrambi i numeri, quello del documento e il massimo, quindi si vede a colpo d'occhio di quanto ha sforato.

db.c.insertOne({ _id: 1, s: "x".repeat(16 * 1024 * 1024 - 200) })   // entra
db.c.insertOne({ _id: 2, s: "x".repeat(16 * 1024 * 1024 + 100) })
// 10334 :: object to insert too large.
//          size in bytes: 16777338, max size: 16777216

I due tetti degli indici si contano male

I 64 indici per raccolta includono _id_, quindi ce ne stanno 63 propri; e non è un tetto che si raggiunge in salute: con 64 indici, ogni scrittura mantiene 64 alberi. Nemmeno i 32 campi di un indice composto sono un obiettivo: oltre i sei o sette, quasi sicuramente servono due indici, non uno più largo.

db.tope.insertOne({ a: 1 })
for (let i = 0; i < 70; i++) db.tope.createIndex({ ["c" + i]: 1 })
// ne crea 63, e il 64º fallisce:  conta anche _id_
// 67 CannotCreateIndex :: add index fails, too many indexes

const k = {}
for (let i = 0; i < 33; i++) k["g" + i] = 1
db.comp.createIndex(k)          // con 32 passa
// 13103 :: too many compound keys

Tre che non stanno dove dice la loro fama

L'annidamento è documentato a 100 livelli e quello che il server ha rifiutato è stato il 180°, perché il tetto è del BSON dell'intero comando e l'involucro dell'insert se ne prende una parte. Il nome lungo non fallisce sulla raccolta ma sulla somma di base e raccolta, cioè lo spazio dei nomi. E il tetto sulla dimensione di una chiave d'indice, che nelle vecchie versioni era 1 024 byte, non esiste più: un valore indicizzato di 50 000 è passato.

db.getSiblingDB("d".repeat(65)).c.insertOne({ a: 1 })
// 73 InvalidNamespace :: db name must be at most 63 characters, found: 65

db.createCollection("z".repeat(260))
// 73 InvalidNamespace :: Fully qualified namespace is too long

db.k.createIndex({ w: 1 })
db.k.insertOne({ w: "y".repeat(50000) })   // entra: non c'è limite di chiave

E quello che non ha tetto

Né il numero di raccolte, né quello delle basi, né quello dei documenti di una raccolta. Quello che finisce per primo non sta in questa tabella: è il disco. E per ciò che davvero non sta in 16 MB — un file — c'è GridFS, che lo taglia in pezzi da 255 KB e conserva ogni pezzo come un documento normale.

Parole chiave: limiti, tetti, 16 MB, dimensione del documento, 10334, annidamento, Overflow, indici per raccolta, 67, CannotCreateIndex, 13103, spazio dei nomi, 73, InvalidNamespace, chiave d'indice

Buone pratiche: sette che si reggono su un numero

Il riassunto del manuale di MongoDB: sette abitudini che valgono la pena, ognuna con la misura che la sostiene, e le cinque righe con cui si prende il polso a un server appena ereditato.

Si applica a: MongoDB 7.0+

Questa è la fine del manuale, e non porta niente di nuovo: raccoglie quello che ogni tema ha dimostrato, nella forma che serve tutti i giorni. Ogni abitudine arriva con il numero che la sostiene, e tutti quei numeri sono stati misurati contro un server vero, non ricopiati.

1. Modella per come leggerai, non per quanto somiglia a una tabella. Quello che si legge insieme si conserva insieme. Il limite di questa regola è duro ed è misurato: un documento non supera i 16 777 216 byte, quindi un array che cresce senza freno finisce in un 10334 un martedì qualunque.

2. Un indice per query frequente, e non uno di più. Ogni indice è un albero da mantenere a ogni scrittura: le stesse 20 000 inserzioni hanno impiegato 60 ms senza indici, 101 con cinque e 160 con dieci. E occupano: nella base d'esempio, una raccolta di 1 148 000 byte di dati ne portava 622 592 di indici.

3. Giudica una query da totalDocsExamined contro nReturned, non dall'orologio. L'orologio dice quanto ci ha messo oggi, con la cache calda e la macchina tranquilla; il rapporto fra quei due numeri dice che cosa succederà quando la raccolta sarà dieci volte più grande.

4. writeConcern: majority per quello che non si può perdere. Su un replica set è già il valore di fabbrica, quindi l'abitudine non è metterlo: è non toglierlo per andare più in fretta.

5. Mai senza autenticazione. È una riga nel file, e l'unica porta che un server appena acceso lascia aperta — creare il primo utente dalla macchina stessa — si chiude da sola non appena quell'utente esiste.

6. Fai il backup con l'oplog, e misura la sua finestra in tempo. mongodump --oplog è ciò che trasforma uno scarico in un istante. E la dimensione dell'oplog non si legge in byte ma in giorni: il nodo misurato ne dava 34, ma dipende da quanto si scrive, quindi è un numero da riguardare quando cambia il carico.

7. Il working set deve stare nella cache. È l'unica regola di prestazione senza trucchi. Nella base d'esempio, 1 150 242 byte di dati contro 3 621 781 504 di cache: spazio di troppo tremila volte. Il giorno in cui non ne avanza, si sente in tutto insieme.

Il polso di un server appena ereditato

Cinque righe rispondono a quello che serve sapere prima di toccare qualcosa: se chiede una password, quando una scrittura si considera scritta, se qualcuno sta guardando le query lente, quanta memoria ha per lavorare e quanti dati deve muovere.

db.adminCommand({ getCmdLineOpts: 1 }).parsed.security
// { authorization: "enabled" }, o undefined - que es la respuesta mala

db.adminCommand({ getDefaultRWConcern: 1 }).defaultWriteConcern
// { w: "majority", wtimeout: 0 }
// su un nodo isolato non esiste nemmeno: "not supported on standalone nodes"

db.getProfilingStatus()      // { was: 1, slowms: 100, sampleRate: 1 }

db.serverStatus().wiredTiger.cache["maximum bytes configured"]   // 3621781504
db.stats().dataSize                                              // 1150242

E un'altra ancora, quella che mostra dove se ne va il disco e, di passaggio, quale raccolta porta più indice che dati.

db.getCollectionInfos({ type: "collection" }).map(i => {
  const s = db.getCollection(i.name).stats()
  return { c: i.name, indices: s.nindexes, datos: s.size, indice: s.totalIndexSize }
})
// { c: "eventos", indices: 3, datos: 1148000, indice: 622592 }
// il filtro per type serve: stats() su una vista fallisce

Parole chiave: buone pratiche, riassunto, modello di accesso, indici, writeConcern, majority, autenticazione, oplog, working set, cache, explain, salute del server

Strutture dati e codifiche

I sei tipi di Redis, come li conserva internamente (`OBJECT ENCODING`), quando cambia la codifica e quali sono i limiti veri.

Si applica a: Redis 7.0+

In Redis non ci sono tabelle né colonne: ogni chiave contiene un valore di un tipo, e il tipo lo decide il primo comando che la scrive. SET crea una stringa, HSET un hash, RPUSH una lista, SADD un insieme, ZADD un insieme ordinato e XADD uno stream. TYPE dice quale, e un comando di un altro tipo su quella chiave risponde WRONGTYPE senza toccare nulla.

I sei tipi, nel testbed

TipoChiave del testbedA cosa serve
stringapp:sesion:1001un valore: testo, numero o byte
hashapp:usuario:1un oggetto con campi (qui, sei)
listapp:cola:pendientesuna coda o una pila, in ordine di arrivo (500 elementi)
setapp:etiquetas:activasmembri unici e senza ordine (300)
zsetapp:ranking:globalmembri unici ordinati per un punteggio (100)
streamapp:eventos:streamun registro di voci con identificatore temporale (50)

OBJECT ENCODING: come è conservato dentro

Il tipo è quello che vedi; la codifica, come lo conserva il server. Redis parte da una forma compatta e passa a quella generale quando la collezione cresce, e quel passaggio è la differenza tra pochi byte e molti. Misurato su un hash temporaneo:

CONFIG GET hash-max-listpack-entries   # 512
HLEN qa:h                      # 512
OBJECT ENCODING qa:h           # listpack
MEMORY USAGE qa:h SAMPLES 0    # 5176
HSET qa:h f512 v
OBJECT ENCODING qa:h           # hashtable
MEMORY USAGE qa:h SAMPLES 0    # 28824

Un campo in più moltiplica la dimensione per più di cinque. Succede lo stesso se un solo valore supera i 64 byte (hash-max-listpack-value): un hash con un campo di 65 byte è già hashtable. E il passaggio non torna indietro: dopo aver cancellato il campo 513, l'hash resta hashtable.

Gli altri tipi seguono la stessa idea, con queste soglie nel testbed. Un insieme di interi è intset, diventa listpack appena entra un membro di testo e hashtable oltre 128 (set-max-listpack-entries). Una lista piccola è listpack e una grande quicklist. Un insieme ordinato passa da listpack a skiplist oltre 128 membri (zset-max-listpack-entries). Per questo l'insieme di 300 etichette è hashtable e la classifica di 100 resta listpack.

Una stringa sono byte, non testo

Una stringa contiene qualsiasi sequenza di byte e il server non la interpreta. Se sta in un intero, la conserva come int (metricas:diarias:2026-09-01); fino a 44 byte come embstr, e da 45 come raw. Può contenere a capo —binario:datos sono 4 byte, a\r\nb— o byte che non sono UTF-8: un SET con \xff\x00\xfe conserva 3 byte, e Calíope li mostra in esadecimale invece di inventarsi un testo.

I limiti veri

Una stringa arriva a 512 MB: è proto-max-bulk-len, che nel testbed vale 536870912, e un SETRANGE oltre risponde string exceeds maximum allowed size. Una collezione ammette fino a 2³²−1 elementi secondo il riferimento dei comandi; ciò che la limita prima è la memoria del server, e quanto costa leggerla tutta in una volta.

HyperLogLog, bitmap e geo non sono tipi nuovi

Sono comandi sui sei tipi qui sopra, e TYPE li tradisce. PFADD conserva un HyperLogLog —un conteggio approssimato degli elementi distinti— in una stringa: con quattro elementi occupa 30 byte. SETBIT scrive bit in una stringa, e il bit 1000 la porta a 126 byte. GEOADD conserva le coordinate come punteggio di uno zset, quindi una chiave geo si cancella, scade e si misura come qualsiasi insieme ordinato.

Parole chiave: redis, tipi, string, hash, list, set, zset, stream, object encoding, listpack, hashtable, intset, quicklist, skiplist, hyperloglog, bitmap, geo, limiti

Chiavi, nomi e scadenza

Perché il server non conosce i prefissi, cosa significano `TTL` −1 e −2, come scade una chiave, e perché `SCAN` e `UNLINK` invece di `KEYS` e `DEL`.

Si applica a: Redis 7.0+

Il nome è una convenzione, non una gerarchia

Di solito le chiavi si nominano con i due punti —app:usuario:1, app:sesion:1001—, e Calíope raggruppa l'elenco per quei prefissi. Ma il server non conosce nessuna gerarchia: per Redis, app:usuario:1 è una stringa di 13 byte come le altre. Non c'è una cartella da elencare o da cancellare, e l'unico modo di trovare le chiavi di un prefisso è percorrerle con SCAN … MATCH app:usuario:*. Il separatore lo scegli tu (in Calíope, : per impostazione predefinita). Il testbed ha cinque prefissi radice: app (254 chiavi), metricas (9), binario, clave e sinprefijo.

TTL ha due risposte negative, e sono diverse

TTL sinprefijo      # -1: esiste e non scade
TTL no:existe       # -2: non esiste
SET k v EX 100
TTL k               # 99
SET k v2            # un SET senza KEEPTTL toglie la scadenza
TTL k               # -1
SET k v EX 100
SET k v3 KEEPTTL    # conserva quella che aveva
TTL k               # 99
PERSIST k           # 1: toglie la scadenza
EXPIRE k -1         # un tempo negativo cancella la chiave
EXISTS k            # 0

Confondere -1 con -2 significa mostrare come eterna una chiave che non c'è più. PTTL dà lo stesso in millisecondi, e in INFO keyspace il campo expires conta le chiavi del database che hanno una scadenza (nel testbed, 40).

Come scade una chiave

In due modi. Se qualcuno la legge dopo la sua ora, la lettura la trova scaduta e la cancella: è la scadenza pigra. E se nessuno la legge, la raccoglie il ciclo attivo, che dieci volte al secondo (hz 10) prova un campione delle chiavi con scadenza. Nel testbed, due chiavi scritte con PX 200 erano sparite mezzo secondo dopo senza che nessuno le leggesse, e expired_keys (in INFO stats, cumulato dall'avvio) è passato da 0 a 2. expire_cycle_cpu_milliseconds dice quanto tempo di CPU ha consumato quel ciclo.

SCAN e non KEYS

KEYS * restituisce tutte le chiavi in un solo comando, e mentre lo esegue il server non serve nessun altro, perché Redis esegue i comandi uno alla volta. Con le 266 chiavi del testbed impiega 49 µs e non si nota; il suo costo cresce con il numero di chiavi del database, e con milioni blocca per secondi. SCAN fa lo stesso percorso a tratti: nel testbed, 27 chiamate da circa 12 µs, tra una e l'altra il server serve gli altri. In cambio, una chiave può uscire due volte e bisogna togliere i doppioni. Per questo la console di Calíope rifiuta KEYS e l'esploratore percorre con SCAN.

UNLINK e non DEL

Cancellano entrambi, ma DEL libera la memoria sul momento e UNLINK in un altro thread. Misurato con un insieme di 200.000 membri (11 MB): DEL ha impiegato 83 ms, durante i quali il server non ha risposto a nessuno, e UNLINK 0,3 ms. Con una chiave piccola non c'è differenza; con una grande, UNLINK è quello che non ferma il server, ed è quello che Calíope usa per cancellare.

RENAME e l'hash tag

RENAME sostituisce la chiave di destinazione se esiste, e il suo valore va perso; RENAMENX non fa nulla e restituisce 0 se la destinazione esiste. In un cluster le due chiavi devono cadere nello stesso slot, altrimenti il comando risponde CROSSSLOT: nel testbed, qa:t67:a cade nello slot 7222 e qa:t67:zz nel 9705. L'unica parte di un nome che il cluster guarda per scegliere lo slot è l'hash tag: se la chiave ha un pezzo tra parentesi graffe, conta solo quel pezzo. usuario:1 e usuario:2 cadono nel 3014 e nel 15269, ma {usuario}:1 e {usuario}:2 cadono entrambe nel 15774; e clave:con:{etiqueta}:hash nel 5693, lo stesso di etiqueta. Graffe vuote (foo{}bar) non contano come tag.

Parole chiave: redis, chiavi, nomi, prefisso, ttl, expire, persist, keepttl, scadenza, scan, keys, unlink, del, rename, hash tag, slot

Memoria ed espulsione

Cosa misura ogni numero di `INFO memory`, perché la somma delle chiavi non è la memoria del server, e cosa fa ogni `maxmemory-policy` quando la memoria è piena.

Si applica a: Redis 7.0+

Più numeri, e non sono lo stesso

INFO memory ne dà diversi, e nel testbed valgono:

CampoCos'èTestbed
used_memoryquanto Redis ha chiesto all'allocatore1,6 MB
used_memory_startupquanto occupa il server vuoto0,9 MB
used_memory_datasetla parte che sono dati0,6 MB
used_memory_rssquanto il sistema operativo attribuisce al processo13 MB
used_memory_peakil massimo che ha mai usato16,6 MB

mem_fragmentation_ratio è used_memory_rss diviso used_memory, e nel testbed vale 8,56. Non è un guasto: poco prima un insieme da 11 MB aveva portato il picco a 16,6 MB, e cancellandolo l'allocatore (jemalloc) ha tenuto le pagine. MEMORY PURGE gli chiede di restituirle, e il rapporto è sceso a 8,13. Un rapporto alto con poca memoria usata è questo; un rapporto alto con molta è frammentazione vera. E uno sotto 1 dice che il sistema ha mandato parte di Redis nello swap, che è peggio.

Perché MEMORY USAGE non somma used_memory

MEMORY USAGE misura una chiave: il suo valore, il suo nome e quello che la circonda. Le 266 chiavi del testbed sommano 54.048 byte, e used_memory è 1,6 MB. La differenza è il server stesso (948.744 byte a vuoto), i buffer dei client e dell'AOF, gli altri database e le tabelle che indicizzano le chiavi. Per questo l'analizzatore di memoria di Calíope mostra quanto pesa ogni prefisso e non promette che la somma sia la memoria del server.

SAMPLES: per una collezione, il numero è una stima

Per una collezione, MEMORY USAGE misura 5 elementi e moltiplica (SAMPLES 5, predefinito); SAMPLES 0 li misura tutti, esatto e costoso. Se gli elementi si somigliano, non cambia nulla: l'insieme di 300 etichette misura 16.216 byte in entrambi i modi. Se no, sbaglia: un hash di 600 campi in cui uno su 50 vale 4.000 byte misura 32.304 con il campione, 56.832 con SAMPLES 100 e 81.360 contato per intero.

maxmemory e cosa succede quando si raggiunge

Senza maxmemory (0, come nel testbed), Redis cresce quanto il sistema glielo permette. Con un tetto, quando lo raggiunge applica maxmemory-policy, che ha otto valori:

PoliticaCosa fa quando è piena
noevictionrifiuta le scritture con OOM; le letture continuano
allkeys-lruespelle le chiavi usate meno di recente, fra tutte
allkeys-lfuespelle le chiavi usate meno spesso, fra tutte
allkeys-randomespelle chiavi a caso, fra tutte
volatile-lrucome allkeys-lru, solo fra quelle che scadono
volatile-lfucome allkeys-lfu, solo fra quelle che scadono
volatile-randoma caso, solo fra quelle che scadono
volatile-ttlquelle che scadono prima

Misurato mettendo il tetto appena sopra l'usato (300 KB con noeviction, 100 KB con allkeys-lru). Con noeviction sono entrate 811 chiavi da 200 byte, la successiva ha risposto OOM command not allowed when used memory > 'maxmemory', e GET continuava a leggere. Con allkeys-lru, quella del testbed, nessuna scrittura è fallita: il server ha espulso 1.309 chiavi per fare spazio, da qualsiasi database —anche dal database 1, quello del generatore di carico—, e non ha toccato nessuna delle 266 chiavi del testbed, appena lette (TOUCH). L'espulsione non avvisa nessuno: la chiave semplicemente non c'è più, e solo evicted_keys la conta. Se Redis è l'unica copia di un dato, la politica è noeviction; se è una cache, una delle allkeys.

La LRU è approssimata: il server confronta maxmemory-samples chiavi (5) ed espelle quella inutilizzata da più tempo. OBJECT IDLETIME dice da quanti secondi una chiave non viene toccata, e OBJECT FREQ risponde solo con una politica LFU.

MEMORY STATS e MEMORY DOCTOR

MEMORY STATS scompone quanto sopra: quanto occupano l'avvio, i client, l'AOF, ogni database e quanti byte per chiave. MEMORY DOCTOR dà una diagnosi a parole, ma non con poca memoria usata: nel testbed risponde che l'istanza è vuota o usa pochissima memoria.

Parole chiave: redis, memoria, used_memory, rss, frammentazione, maxmemory, maxmemory-policy, espulsione, eviction, lru, lfu, noeviction, oom, memory usage, samples, memory stats, memory doctor, memory purge

Persistenza: RDB e AOF

Come Redis salva su disco ciò che vive in memoria, cosa perde ogni combinazione quando si uccide il processo, e cosa significa `loading:1`.

Si applica a: Redis 7.0+

Redis vive in memoria. Ciò che scrive su disco serve a ricaricarlo all'avvio, e ci sono due modi di farlo, che si possono combinare.

RDB: istantanee dell'intero database

BGSAVE scrive l'intero database in dump.rdb da un processo figlio, senza fermare il server; SAVE fa lo stesso fermandolo, ed è per questo che non si usa. La regola save le avvia da sola; quella del testbed è 3600 1 300 100 60 10000: un'istantanea se in un'ora c'è stata almeno una modifica, in cinque minuti cento, o in un minuto diecimila. INFO persistence dice quando è stata l'ultima (rdb_last_save_time, in secondi Unix), se è andata bene (rdb_last_bgsave_status) e quante modifiche andrebbero perse adesso (rdb_changes_since_last_save): dopo un BGSAVE è passato da 71 a 3.

AOF: il registro di ogni scrittura

Con appendonly yes, Redis annota ogni scrittura in un file e la riesegue all'avvio. Il testbed gira così (aof_enabled:1). In Redis 7 l'AOF sono più file in appendonlydir/: una base in formato RDB, un incremento con i comandi da allora e un manifesto che li collega. BGREWRITEAOF lo compatta in una nuova base; aof_rewrites conta quante volte, e aof_rewrite_in_progress dice se ce n'è una in corso.

Quando arriva su disco lo decide appendfsync:

ValoreQuando sincronizzaCosa si può perdere
alwaysa ogni scritturaniente, ed è il più lento
everysecuna volta al secondofino a un secondo, se cade la macchina
noquando lo decide il sistemaquello che il sistema non ha ancora scritto

Il testbed usa everysec, il valore predefinito.

Cosa si perde uccidendo il processo, misurato

Con AOF e everysec, una chiave scritta due secondi prima e un'altra scritta subito prima di docker kill, che uccide il processo senza preavviso, c'erano entrambe all'avvio. Uccidere il processo non perde ciò che è già entrato nell'AOF, perché Redis lo scrive nel file prima di rispondere; ciò che everysec lascia in sospeso è quello che il sistema operativo non ha ancora portato su disco, e si perde solo se cade la macchina, non il processo.

Senza AOF, solo con RDB, in gioco c'è quello che dice rdb_changes_since_last_save: tutto ciò che è stato scritto dall'ultima istantanea si perde uccidendo il processo. Con la regola del testbed, possono essere quasi un'ora di modifiche se sono state meno di cento.

loading:1: il server si sta avviando

All'avvio, Redis legge l'AOF o l'RDB prima di servire chiunque. Con le 266 chiavi del testbed non c'è il tempo di vederlo; con 1,5 milioni di chiavi in più ha impiegato circa 6 secondi, e in quel tempo INFO persistence rispondeva loading:1 e qualsiasi altro comando LOADING Redis is loading the dataset in memory. Calíope lo mostra come una risposta del server e non come un'interruzione: la connessione va bene, e basta aspettare e riprovare.

Parole chiave: redis, persistenza, rdb, aof, bgsave, save, appendonly, appendfsync, everysec, bgrewriteaof, dump.rdb, loading, avvio, perdita di dati

Client e latenza

Chi è connesso e cosa sta facendo, perché un comando lento fa aspettare tutti, e come il server misura la propria latenza.

Si applica a: Redis 7.0+

Redis esegue i comandi uno alla volta, su un solo thread: mentre uno gira, tutti gli altri client aspettano il loro turno. Per questo qui la latenza non riguarda una query, ma tutti.

Chi è connesso: CLIENT LIST

Una riga per connessione, con coppie campo=valore. Le più eloquenti:

CampoChe cos’è
idil numero della connessione, quello che chiede CLIENT KILL ID
addrda dove arriva
nameil nome che si è data con CLIENT SETNAME; vuoto se non ne ha
age / idlesecondi da quando si è connessa, e dal suo ultimo comando
flagsN, una connessione normale; b, una bloccata in attesa (BLPOP); S, una replica; M, il suo primario
cmdl’ultimo comando
tot-memla memoria che occupa la connessione, buffer compresi

Anche Calíope compare nell’elenco: le sue connessioni si chiamano Caliope (name=Caliope, lib-name=caliope), e una sessione ne apre fino a quattro per lavorare, più una a parte per le scansioni lunghe. «Client» le segna come di questa sessione e non propone di disconnetterle.

INFO clients dà i totali: connected_clients, blocked_clients e il tetto, maxclients (10000 nel testbed, il valore predefinito). Con timeout a 0, anch’esso predefinito, il server non chiude mai una connessione inattiva.

Un comando lento fa aspettare tutti, misurato

Nel testbed, uno script durato 1,7 secondi e un PING lanciato subito dopo da un’altra connessione: il PING ha impiegato 1,65 secondi a rispondere PONG. È rimasto in coda per tutto quel tempo, anche se con lo script non c’entrava nulla. Cosa succede quando uno script supera i 5 secondi lo racconta «Transazioni e script».

CLIENT PAUSE: fermare i client apposta

CLIENT PAUSE <ms> WRITE trattiene le scritture di tutti i client per quel tempo e lascia passare le letture; senza WRITE, trattiene tutto. Misurato con due secondi: una lettura ha risposto in 7 ms e una scrittura ha aspettato tutti i 2000 ms. Serve a cambiare primario senza perdere scritture, e CLIENT UNPAUSE toglie la pausa prima.

Il monitor di latenza del server

Redis annota gli episodi lenti se gli si dà una soglia in millisecondi con latency-monitor-threshold; a 0, il valore predefinito, non annota nulla. Con 20 ms per 25 secondi nel testbed, LATENCY LATEST ha dato 7 episodi di tipo command —gli script del generatore di carico—, il peggiore di 76 ms. LATENCY HISTORY command dà ognuno con la sua ora, LATENCY DOCTOR riassume tutto in un testo in inglese con dei consigli, e LATENCY RESET cancella quanto annotato.

Il monitor dice quando c’è stato un episodio; quale comando fosse lo dice il registro dei comandi lenti, SLOWLOG, che Calíope mostra in «Comandi lenti» e che racconta «Prestazioni».

Parole chiave: redis, client, client list, connessioni, latenza, latency doctor, latency history, client pause, maxclients, timeout, blocco, un solo thread

Prestazioni

Il registro dei comandi lenti e la sua soglia in microsecondi, cosa fa risparmiare una pipeline, le chiavi grandi e come leggerle, e cosa dicono i contatori di INFO.

Si applica a: Redis 7.0+

In Redis quasi tutti i comandi durano microsecondi. Quello che costa è ciò che si fa molte volte, o tutto in una volta su qualcosa di grande.

Il registro dei comandi lenti: SLOWLOG

Il server annota ogni comando che dura più di slowlog-log-slower-than e conserva gli ultimi slowlog-max-len. La soglia è in microsecondi —non in millisecondi né in secondi, come in altri motori—: il valore predefinito, 10000, sono 10 ms. Una soglia negativa spegne il registro, e 0 annota tutti i comandi. Ogni voce porta il suo numero, l’ora, quanto è durata in microsecondi, il comando con i suoi argomenti —accorciati se sono lunghi— e l’indirizzo e il nome del client.

È una finestra, non uno storico: con il massimo di 128, la voce 129 caccia la più vecchia. Nel testbed si riempie degli script del generatore di carico, che durano tra 50 e 250 ms. E misura il tempo del comando dentro il server: non conta l’attesa in coda né il viaggio in rete. Calíope lo mostra in «Comandi lenti».

Andata e ritorno: la pipeline

Ogni comando da solo paga un viaggio completo in rete. Misurato contro il server del testbed, in rete: 500 TYPE uno alla volta hanno impiegato tra 1,6 e 2,1 secondi; gli stessi 500 in pipeline —inviati tutti, poi lette le risposte— circa 6 ms. Il server ha fatto lo stesso lavoro; si è risparmiato di aspettare la rete 500 volte. È così che Calíope chiede tipo e scadenza delle chiavi di ogni pagina.

Le chiavi grandi

redis-cli --bigkeys percorre il database con SCAN e indica la più grande di ogni tipo. Nel testbed: una lista di 500 elementi, un set di 300 membri, un sorted set di 100 e uno stream di 50 voci. MEMORY USAGE <chiave> dice quanto occupa una chiave: il set 16216 byte, la lista 6208, un hash di utente 168. Calíope somma la memoria per prefisso in «Memoria».

Una chiave grande non è lenta di per sé; lento è leggerla tutta. HGETALL, SMEMBERS, LRANGE 0 -1 e ZRANGE 0 -1 sono il KEYS delle collezioni: restituiscono tutto in una volta e bloccano il server mentre lo costruiscono. Prima di leggere si chiede la dimensione —HLEN, SCARD, LLEN, ZCARD—, e si legge a pezzi con HSCAN, SSCAN e ZSCAN, o a tratti con LRANGE.

Due numeri di INFO stats

instantaneous_ops_per_sec lo calcola il server sugli ultimi secondi; nel testbed, 13. Il tasso di successo viene da keyspace_hits e keyspace_misses, che si accumulano dall’avvio: 5110 e 1040 nel testbed, l’83 %. Una cifra che si somma da settimane non dice come va oggi, ed è per questo che Calíope la etichetta così in «Server».

OBJECT FREQ

Con una politica di espulsione LFU (allkeys-lfu o volatile-lfu), Redis tiene un contatore logaritmico di accessi per chiave, e OBJECT FREQ lo legge: misurato, 30 letture di un hash lo hanno portato da 0 a 7. Con una politica LRU, quella del testbed, risponde con un errore che lo dice.

Parole chiave: redis, prestazioni, slowlog, comandi lenti, microsecondi, pipeline, bigkeys, memory usage, hgetall, smembers, lrange, operazioni al secondo, tasso di successo, object freq, lfu

Transazioni e script

MULTI ed EXEC, che raggruppano comandi ma non annullano nulla; WATCH come blocco ottimistico; EVAL e le funzioni, e perché uno script blocca il server.

Si applica a: Redis 7.0+

Redis non ha transazioni come quelle di un database relazionale. Ha due modi per eseguire più comandi insieme senza che si infili in mezzo nulla di un altro client: MULTI e gli script.

MULTI ed EXEC: insieme, ma senza tornare indietro

Dopo MULTI, il server non esegue i comandi: risponde QUEUED a ognuno e li tiene da parte. EXEC li esegue tutti di seguito, e DISCARD li butta. Misurato nel testbed:

- Con SET a 1, LPUSH a x e SET b 2 in coda, EXEC ha risposto OK, WRONGTYPE e OK: il LPUSH è fallito e gli altri due sono stati scritti. Non c’è rollback: un errore in esecuzione non annulla nulla.
- Un errore che si vede all’accodamento —un comando con argomenti sbagliati— annulla invece l’intera transazione: EXEC ha risposto EXECABORT e non ne ha eseguito nessuno.
- Dopo DISCARD, la chiave non esisteva.

WATCH <chiave> prima di MULTI è un blocco ottimistico: se un altro client cambia quella chiave prima dell’EXEC, l’EXEC non esegue nulla e restituisce null. Misurato: con un altro client che scriveva la chiave nel frattempo, EXEC ha restituito null e la chiave ha tenuto il valore dell’altro; senza nessuno nel mezzo, l’INCR è stato applicato. Chi usa WATCH ripete l’operazione quando ottiene null.

Una transazione vive su una sola connessione. Per questo «Console» non esegue MULTI, EXEC, DISCARD, WATCH né UNWATCH: manda ogni riga sulla connessione libera, e la transazione potrebbe restare aperta su una mentre il suo EXEC finisce su un’altra.

Gli script: EVAL, EVALSHA e le funzioni

EVAL esegue uno script Lua sul server, per intero e senza interruzioni: nessun altro comando gira nel frattempo. È il modo di raggruppare più comandi da «Console». Le chiavi vanno separate dagli argomenti —EVAL "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 chiave 5—, perché in un cluster il server deve sapere a quale slot va.

SCRIPT LOAD conserva lo script e restituisce la sua impronta SHA1, e EVALSHA <impronta> lo esegue senza rimandare il testo. Se il server non ha quell’impronta, risponde NOSCRIPT e bisogna tornare a EVAL. Le funzioni di Redis 7 sono la stessa cosa con un nome: FUNCTION LOAD carica una libreria, FCALL chiama una delle sue funzioni, FUNCTION LIST le elenca e FUNCTION DELETE le cancella. Misurato: una funzione che raddoppia un numero, chiamata con FCALL su una chiave che valeva 16, ha restituito 32.

Uno script blocca il server finché dura

Misurato nel testbed: con uno script durato 1,7 secondi, un PING da un’altra connessione ha aspettato 1,65 secondi. Gli script del generatore di carico durano circa 50 ms ciascuno, ed è per questo che riempiono «Comandi lenti».

Dopo 5 secondi (busy-reply-threshold, che prima si chiamava lua-time-limit e accetta ancora quel nome), lo script non si ferma: il server continua a eseguirlo, ma a tutto il resto risponde BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE. Misurato con un ciclo lungo: dopo 6 secondi, PING ha ricevuto BUSY; SCRIPT KILL ha risposto OK, il client dello script ha ricevuto ERR Script killed by user with SCRIPT KILL, e il PING successivo, PONG.

SCRIPT KILL funziona solo se lo script non ha scritto nulla. Misurato con uno che scriveva una chiave prima del ciclo: ha risposto UNKILLABLE, e lo script è andato avanti fino alla fine. A quel punto resta solo aspettare o SHUTDOWN NOSAVE, che spegne il server senza salvare.

Parole chiave: redis, transazioni, multi, exec, discard, watch, rollback, eval, evalsha, script load, function, fcall, lua, busy, script kill, lua-time-limit

Repliche e cluster

Come si guarda una replica e perché il suo ritardo si conta in byte; i 16384 slot, MOVED e ASK; cosa mette un cluster in fail; e perché non ci sono Sentinel né cluster tramite tunnel SSH.

Si applica a: Redis 7.0+

Una replica, e come si guarda

Una replica è un altro server che copia tutto quello che scrive il suo primario. Lo si rende replica con REPLICAOF <host> <porta> e lo si libera con REPLICAOF NO ONE. Diventando replica butta i propri dati e carica quelli del primario; per questo la console di Calíope chiede di scrivere il nome del comando prima di inviarlo. ROLE dice in una risposta che cos’è ogni server: master con il suo offset e le sue repliche, o slave con il suo primario e lo stato del collegamento (connected). INFO replication racconta tutto.

Il ritardo si conta in byte

Primario e replica tengono ciascuno un contatore di byte del flusso di replicazione: il primario master_repl_offset, la replica slave_repl_offset. La differenza è ciò che la replica deve ancora applicare. Nel cluster del testbed, a riposo, la differenza è 0 sulle tre repliche, e i due contatori avanzano di 14 byte ogni 10 secondi circa: è il PING del primario alle sue repliche.

Il primario mostra anche, per ogni replica, un offset e un lag, e nessuno dei due è il ritardo di adesso:

- offset è ciò che la replica ha confermato l’ultima volta, e conferma una volta al secondo. Misurato: dopo aver scritto 2 MB, il primario ha mostrato la sua replica indietro di 2.000.070 byte per un secondo, con lag=0; alla lettura successiva, in pari.
- lag sono i secondi da quell’ultima conferma, non quanto le manca: con la replica in pari, oscilla tra 0 e 1.

Calíope legge il contatore di ogni replica sulla replica stessa, e quello del primario due volte per sapere quanto si muove da solo; al di sotto non avvisa. Che una replica segua un primario lo dice il master_replid che condividono, non il master_host che indica la replica, che nel testbed è un indirizzo della rete Docker (172.18.0.32) e non quello che usa il client. Un primario che si riavvia riparte con un master_replid nuovo e da 0: finché la sua replica non si risincronizza, la differenza non significa nulla, e Calíope lascia il ritardo non misurato invece di dire che è in pari.

min-replicas-to-write fa rifiutare le scritture al primario se non ha almeno quel numero di repliche che confermano entro min-replicas-max-lag secondi (10 per impostazione predefinita). Misurato con 1 sul nodo senza repliche del testbed: SET ha risposto NOREPLICAS Not enough good replicas to write. e le letture sono continuate. Nel testbed vale 0, il valore predefinito.

Il cluster: 16384 slot

Un cluster distribuisce le chiavi su 16384 slot, e ogni primario possiede un intervallo; nel testbed 0-5460, 5461-10922 e 10923-16383, ciascuno con una replica. Lo slot viene dalla chiave (CLUSTER KEYSLOT cl:a:1 → 10042), o solo da ciò che sta tra parentesi graffe se la chiave porta un tag: {usuario:1}:perfil e {usuario:1}:carrito cadono entrambe nel 3014.

CLUSTER INFO dà lo stato (cluster_state), gli slot coperti e quanti nodi ci sono; CLUSTER SHARDS dà ogni intervallo con il suo primario e le sue repliche, e l’indirizzo che annuncia ciascuno; CLUSTER NODES dice lo stesso, una riga per nodo.

Un nodo a cui si chiede una chiave che non è sua risponde MOVED <slot> <indirizzo>: misurato, GET cl:a:1 sul primario di 0-5460 ha risposto MOVED 10042 10.100.37.234:7002. Calíope segue questo reindirizzamento e rilegge la mappa. ASK è il reindirizzamento di uno slot a metà trasferimento: vale solo per quel comando, è preceduto da ASKING, e la mappa non cambia. Un comando con chiavi di slot diversi risponde CROSSSLOT: misurato con MGET cl:a:1 cl:a:2, slot 10042 e 5977.

cluster_state:fail, misurato

Il cluster smette di rispondere quando uno slot resta senza proprietario. Nel testbed (cluster-node-timeout di 5 secondi), fermando un primario:

- per circa 5 secondi non cambia nulla: gli altri non lo considerano ancora caduto;
- poi cluster_state:fail, finché la sua replica viene promossa circa 2 secondi dopo e lo stato torna ok;
- fermando anche quella replica, 5462 slot sono rimasti senza proprietario e l’intero cluster ha risposto CLUSTERDOWN The cluster is down, anche per le chiavi degli altri due primari. È ciò che fa cluster-require-full-coverage yes, il valore predefinito.

Al ritorno, il vecchio primario è replica del nuovo, e un CLUSTER FAILOVER su di esso gli restituisce il posto. Calíope mostra ogni nodo, il suo ruolo e il suo ritardo in «Topologia».

Sentinel non c’è in Calíope

Sentinel sorveglia i primari e decide il failover, ma non conserva dati e non ha repliche proprie, quindi Calíope non ne legge la topologia. Per vedere un primario con le sue repliche e il loro ritardo, si apre un profilo che punta al primario.

Un cluster non si apre tramite un tunnel SSH

Ogni nodo del cluster si raggiunge all’indirizzo che annuncia, e un MOVED manda il client a quell’indirizzo. Un tunnel SSH porta a un solo nodo: i reindirizzamenti uscirebbero dal tunnel verso indirizzi che forse non si raggiungono da dove ci si trova. Per questo Calíope non apre tramite un tunnel un profilo che si rivela un cluster: lo dice alla connessione, e l’errore porta a questo argomento. Per aprirlo, si toglie il tunnel dal profilo e ci si connette da una rete che raggiunge ogni nodo all’indirizzo che annuncia.

Parole chiave: redis, replicazione, replica, replicaof, role, ritardo, offset, lag, min-replicas-to-write, cluster, slot, moved, ask, crossslot, clusterdown, fail, sentinel, tunnel ssh

Sicurezza e ACL

Dove ascolta il server, la modalità protetta, la password e gli utenti con permessi (ACL), perché rename-command è peggio di un’ACL, TLS, e cosa resta di una password in SLOWLOG e MONITOR.

Si applica a: Redis 7.0+

Un server Redis appena installato non chiede la password a nessuno. Lo proteggono quattro strati, dall’esterno verso l’interno: dove ascolta, la modalità protetta, chi è ogni utente e cosa può fare, e se la connessione è cifrata. Tutto questo argomento è stato misurato sul testbed (Redis 7.4.11).

Dove ascolta: bind e la modalità protetta

bind dice su quali indirizzi accetta connessioni; il testbed ascolta su tutti (* -::*). Se in più l’utente default non ha password, la modalità protetta (protected-mode yes, che è quella con cui Redis arriva) lascia entrare solo dall’interfaccia locale. Misurato con un server così: da 127.0.0.1, PING ha risposto PONG; dall’indirizzo di rete del container, DENIED Redis is running in protected mode…. È una rete di sicurezza per il server che nessuno ha configurato, non una configurazione: con una password smette di agire.

Password e utenti: requirepass e le ACL

requirepass è la password dell’utente default, l’unico che esiste se nessuno ne crea altri. Da Redis 6 ci sono utenti con nome (ACL), ognuno con i suoi comandi, le sue chiavi e i suoi canali. ACL LIST li elenca: sul testbed c’è solo default, con ~* (tutte le chiavi), &* (tutti i canali) e +@all (tutti i comandi). Senza password, lo stesso utente appare come nopass, e chiunque arrivi alla porta è lui. ACL WHOAMI dice con quale utente sei connesso.

Un utente si crea con ACL SETUSER. Misurato:

ACL SETUSER lector on >lectorpass ~app:* &canal:* -@all +@read +ping +acl|whoami

- GET app:x ha risposto, vuoto perché la chiave non esisteva.
- SET app:x 1 → NOPERM User lector has no permissions to run the 'set' command.
- GET otra:x → NOPERM No permissions to access a key: il modello ~app:* limita le chiavi.
- PUBLISH otro 1 → NOPERM: quel comando non l’aveva.

I permessi vanno per categorie: ACL CAT ne elenca ventuno (read, write, admin, dangerous, blocking…), e quella dei pericolosi (ACL CAT dangerous) contiene 75 comandi. ACL LOG conserva ogni rifiuto con l’utente, cosa gli è stato negato e il client: dopo i tre di sopra aveva tre voci, con reason command o key. ACL DELUSER elimina un utente, e ACL LOG RESET svuota il registro.

rename-command, e perché è peggio di un’ACL

rename-command rinomina un comando all’avvio del server, e con "" lo toglie. Misurato con un server avviato con --rename-command FLUSHALL "" e --rename-command CONFIG CFG_7f3a: FLUSHALL e CONFIG GET hanno risposto ERR unknown command, e CFG_7f3a GET maxmemory ha funzionato. Vale per tutti gli utenti insieme, cambia solo riavviando, e qualsiasi strumento che usi il nome vero smette di funzionare, Calíope compresa. Con un’ACL si toglie a un utente ciò che non deve fare, e gli altri continuano come prima.

TLS

Un server con TLS ascolta su tls-port, e con port 0 chiude la porta non cifrata. Il nodo redis-tls del testbed parte così, sulla 6380. Misurato: sulla 6379, Connection refused; sulla 6380 senza TLS, I/O error; con TLS ma senza il certificato dell’autorità, certificate verify failed; con l’autorità del testbed, PONG. Il certificato del server nomina localhost, 127.0.0.1 e l’IP della macchina del testbed: se ti connetti con un nome che non c’è, la verifica fallisce lo stesso. Calíope verifica il certificato con l’autorità indicata nel profilo.

La password in SLOWLOG e MONITOR

Redis nasconde gli argomenti di AUTH e di HELLO … AUTH. Misurato con slowlog-log-slower-than 0, perché venisse annotato tutto: la voce di AUTH nello SLOWLOG conteneva (redacted) al posto della password. Un MONITOR aperto nello stesso momento ha visto "AUTH" "(redacted)" e "HELLO" "3" "AUTH" "(redacted)" "(redacted)". Calíope fa lo stesso in «Registro dei comandi»: un AUTH resta come AUTH ****.

Parole chiave: redis, sicurezza, acl, utenti, permessi, requirepass, auth, noperm, protected-mode, bind, rename-command, tls, certificato, slowlog, monitor, password

Configurazione del server

CONFIG GET, CONFIG SET e CONFIG REWRITE; cosa sopravvive a un riavvio e cosa no; le impostazioni da guardare per prime, INFO server e INFO stats, e perché DEBUG è disattivato.

Si applica a: Redis 7.0+

Un server Redis prende la configurazione da un file (redis.conf), dagli argomenti con cui viene avviato, o da entrambi, e la riga di comando prevale sul file. I nodi del testbed (Redis 7.4.11) partono solo con argomenti —redis-server --requirepass … --appendonly yes …— e senza file: INFO server mostra config_file: vuoto.

CONFIG GET e CONFIG SET

CONFIG GET <impostazione> legge il valore che il server usa ora, e CONFIG SET <impostazione> <valore> lo cambia senza riavviare. Non tutte si possono cambiare a caldo: misurato, CONFIG SET databases 32 ha risposto ERR CONFIG SET failed (possibly related to argument 'databases') - can't set immutable config.

Cosa sopravvive a un riavvio

Un CONFIG SET vive nella memoria del server. Misurato sul testbed, che non ha file: CONFIG SET timeout 300 → OK; CONFIG REWRITE → ERR The server is running without a config file; e dopo il riavvio del container, timeout valeva di nuovo 0, cioè quello che dicono gli argomenti.

Con un file, CONFIG REWRITE lo salva davvero. Misurato con un server avviato da un file di tre righe: dopo CONFIG SET timeout 300 e CONFIG REWRITE, il file finiva con una sezione # Generated by CONFIG REWRITE con timeout 300, ma anche con quello passato sulla riga di comando (daemonize yes, il logfile) e con l’utente default. Riavviato, timeout valeva 300. CONFIG REWRITE non salva solo quello che hai appena cambiato: riscrive il file con tutto quello che il server ha in funzione.

Le impostazioni da guardare per prime

I valori sono quelli del testbed, che sono quelli predefiniti di Redis salvo dove indicato:

ImpostazioneValoreCos’è
databases16Database numerati, da 0 a 15. In un cluster, 1
maxmemory0Nessun limite di memoria
maxmemory-policyallkeys-lruCosa si cancella al raggiungimento del limite. Redis arriva con noeviction, che è quello di redis-tls
maxclients10000Connessioni contemporanee
timeout0Secondi di inattività dopo i quali si chiude una connessione; 0 è mai
tcp-keepalive300Ogni quanti secondi controlla che l’altro capo ci sia ancora
enable-debug-commandnoDEBUG disattivato

INFO server e INFO stats: il primo sguardo

INFO server dice la versione (redis_version), la modalità (redis_mode:standalone su un server autonomo), la porta, il tempo di attività e il file di configurazione. INFO stats conta quello che è successo dall’avvio: total_connections_received, total_commands_processed, instantaneous_ops_per_sec, rejected_connections (quelle rifiutate per aver superato maxclients), expired_keys e evicted_keys (chiavi scadute e chiavi espulse per mancanza di memoria), keyspace_hits e keyspace_misses, e total_error_replies. Misurato sul nodo redis in un momento qualsiasi: 207.832 successi, 3.188 mancati, nessuna connessione rifiutata e nessuna chiave espulsa. «Server» mostra l’essenziale di tutto questo, e si limita a leggere.

DEBUG, e perché non è in «Console»

DEBUG ha comandi di prova che fermano il server (DEBUG SLEEP) o lo fanno cadere (DEBUG SEGFAULT). Da Redis 7.0 è disattivato. Misurato: DEBUG SLEEP 0 ha risposto ERR DEBUG command not allowed. If the enable-debug-command option is set to "local", you can run it from a local connection, otherwise you need to set this option in the configuration file, and then restart the server. «Console» non lo esegue anche se il server lo ha attivato.

Parole chiave: redis, configurazione, config get, config set, config rewrite, redis.conf, databases, maxmemory, maxclients, timeout, tcp-keepalive, enable-debug-command, info server, info stats, debug

Errori frequenti

Cosa vuol dire ogni prefisso di errore di Redis —WRONGTYPE, NOAUTH, NOPERM, OOM, READONLY, MOVED, CROSSSLOT e gli altri—, cosa l’ha provocato sul testbed e cosa fare.

Si applica a: Redis 7.0+

Una risposta di errore di Redis comincia con una parola in maiuscolo che dice di che tipo è —WRONGTYPE, NOAUTH…— o, se non ce n’è una più precisa, con ERR. È una risposta del server, non un guasto della connessione: la connessione continua, e il comando successivo passa lo stesso. Calíope la mostra così com’è: in «Console», in rosso, con un glifo e il prefisso davanti. Quelli di accesso alla connessione (NOAUTH, WRONGPASS) arrivano in «Errori» come accesso negato.

Tutti quelli di questa tabella sono stati provocati sul testbed (Redis 7.4.11):

PrefissoCosa l’ha provocatoCosa fare
WRONGTYPELPUSH su una chiave che è una stringaGuardare il tipo con TYPE: ogni tipo ha i suoi comandi
NOAUTHUn comando senza AUTH prima, su un server con passwordMettere la password nel profilo
WRONGPASSAUTH con una password sbagliataControllare utente e password; ACL LIST dice se l’utente è on
NOPERMUn utente ACL senza il comando (SET) o senza la chiaveACL LOG dice cosa gli è stato negato
OOMSET con maxmemory 1 e la politica noevictionLiberare memoria, alzare maxmemory o scegliere una politica che espelle
READONLYSET su un server reso replica con REPLICAOFScrivere sul primario
MASTERDOWNQualsiasi comando, PING compreso, su una replica senza collegamento con il suo primario e con replica-serve-stale-data noRipristinare il collegamento, o lasciare che la replica risponda con quello che ha
NOREPLICASSET con min-replicas-to-write 1 e nessuna replicaRipristinare le repliche, o abbassare l’impostazione
LOADINGPING mentre il server caricava 1,5 milioni di chiavi all’avvio, circa 4 secondiAspettare: risponde appena finisce
BUSYQualsiasi comando mentre uno script girava da più di 5 secondiSCRIPT KILL se lo script non ha scritto nulla; se ha scritto, aspettare
MOVEDGET su un nodo del cluster che non ha lo slot della chiaveNiente: Calíope ripete il comando sul nodo giusto
ASKUna chiave di uno slot in migrazione tra due nodiNiente: Calíope la ripete sull’altro nodo con ASKING
CROSSSLOTMGET di due chiavi di slot diversiDar loro un’etichetta comune tra graffe, come {usuario:1}
CLUSTERDOWNQualsiasi comando con un primario e la sua replica caduti insiemeRiavviare i nodi: con cluster-require-full-coverage yes, se manca uno slot non risponde nessuno
ERRUn comando che non esiste (FOO bar), o INCR su un testoLeggere il resto del messaggio: il motivo è lì

In un cluster, scrivere su una replica non risponde READONLY ma MOVED, con l’indirizzo del primario dello slot.

I messaggi, così come sono

Così ha risposto il server. Sono in inglese in ogni lingua, perché li scrive Redis:

WRONGTYPE Operation against a key holding the wrong kind of value
NOAUTH Authentication required.
WRONGPASS invalid username-password pair or user is disabled.
NOPERM User lector has no permissions to run the 'set' command
NOPERM No permissions to access a key
OOM command not allowed when used memory > 'maxmemory'.
READONLY You can't write against a read only replica.
MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'.
NOREPLICAS Not enough good replicas to write.
LOADING Redis is loading the dataset in memory
BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
MOVED 10042 10.100.37.234:7002
CROSSSLOT Keys in request don't hash to the same slot
CLUSTERDOWN The cluster is down
ERR unknown command 'FOO', with args beginning with: 'bar'
ERR value is not an integer or out of range

MOVED e ASK non sono errori tuoi

In un cluster, ogni chiave vive in uno dei 16384 slot, e ogni slot su un primario. Il nodo che non ce l’ha risponde MOVED con lo slot e l’indirizzo di quello che ce l’ha, e Calíope ripete lì il comando e rilegge la mappa del cluster. ASK dice lo stesso mentre lo slot si sposta da un nodo all’altro, e allora la mappa non si rilegge perché non è ancora cambiata. In «Console» nessuno dei due si vede: arriva la risposta del nodo giusto.

Parole chiave: redis, errori, wrongtype, noauth, wrongpass, noperm, oom, readonly, masterdown, noreplicas, loading, busy, moved, ask, crossslot, clusterdown, err

Cosa non fare

KEYS in produzione, leggere una collezione intera, una chiave gigante, un MONITOR aperto, SAVE, FLUSHALL, un database per schema, DEBUG e aspettare senza limite di tempo: quanto costa ognuno, misurato, e cosa Calíope non esegue.

Si applica a: Redis 7.0+

Redis esegue i comandi uno alla volta, su un solo thread: quello che impiega uno, lo aspetta qualsiasi altro client. Quasi tutto in questo elenco si riduce a questo. Le cifre sono state misurate sul testbed (Redis 7.4.11), con un milione di chiavi di prova cancellate dopo.

KEYS *

KEYS percorre tutte le chiavi in una volta. Con un milione, KEYS nada:* —che non ne trova nessuna— ha impiegato 244 ms sul server, e un PING mandato nello stesso momento ha aspettato tra 220 e 280 ms. Percorrere lo stesso con SCAN … COUNT 1000 sono state mille chiamate da 0,46 ms ciascuna: lo stesso lavoro, a pezzi tra cui passano i comandi degli altri. «Console» non esegue KEYS, e «Chiavi» percorre con SCAN.

Leggere una collezione intera, o avere una chiave gigante

HGETALL, SMEMBERS, LRANGE 0 -1 e ZRANGE 0 -1 sono il KEYS di una sola chiave. Misurato con un hash di 500.000 campi, 32 MB secondo MEMORY USAGE: HGETALL ha impiegato 1,4 s ad arrivare, e un PING mandato nello stesso momento ha aspettato 425 ms. Quello che si fa è chiedere prima la dimensione (HLEN, SCARD, LLEN, ZCARD) e leggere a pezzi con HSCAN, SSCAN, ZSCAN o LRANGE a tratti. È quello che fa il visore di «Chiavi», 100 alla volta.

Anche cancellarla costa: DEL di quell’hash ha impiegato 516 ms sul server, e un PING ha aspettato 557 ms; UNLINK di un altro uguale ha impiegato 15 µs, perché libera la memoria in background. «Chiavi» cancella con UNLINK.

Un MONITOR aperto

MONITOR manda a chi lo chiede ogni comando che riceve il server, da tutti i client, e resta aperto finché chi l’ha chiesto non lo chiude. Misurato con redis-benchmark e 100.000 GET: senza nient’altro, 20.417 al secondo con una mediana di 1,46 ms; con un MONITOR aperto, 17.634 al secondo e 1,86 ms. «Console» non lo esegue: non avrebbe modo di fermarlo.

SAVE al posto di BGSAVE

SAVE scrive il dump nello stesso thread dei comandi, e BGSAVE in un processo a parte. Misurato con un milione di chiavi: SAVE ha impiegato 2,5 s, e un PING ha aspettato quei 2,5 s; con BGSAVE, il PING ha risposto subito, e il dump è terminato bene dopo 3 s.

FLUSHALL e FLUSHDB senza pensarci

FLUSHALL svuota tutti i database del server, non solo quello che hai scelto: misurato, con chiavi nei database 0 e 2, INFO keyspace è risultato vuoto. Non c’è cestino né ritorno. «Console» li esegue solo se scrivi il nome del comando in una conferma che nomina il server e il database.

Un database per «schema»

I database numerati non sono schemi né un modo di separare applicazioni: condividono la memoria, la password e FLUSHALL, e con allkeys-lru e poca memoria il server ha espulso chiavi da un database diverso da quello che si stava riempiendo. In un cluster, inoltre, esiste solo lo 0: misurato, SELECT 1 ha risposto ERR SELECT is not allowed in cluster mode, e CONFIG GET databases, 1. Per separare i dati, un prefisso nel nome della chiave (app:, cola:) o un server a parte. «Console» non esegue SELECT: il database lo sceglie il selettore dello strumento.

DEBUG SLEEP e compagnia

DEBUG SLEEP ferma il server, e DEBUG SEGFAULT lo fa cadere. Da Redis 7.0 DEBUG è disattivato —misurato: ERR DEBUG command not allowed…—, e «Console» non lo esegue anche se è attivo.

Aspettare un dato senza limite di tempo

BLPOP, BRPOP, BLMOVE, BZPOPMIN, XREAD … BLOCK e WAIT aspettano che arrivi qualcosa, e con un tempo di 0 aspettano per sempre. Mentre aspettano, la loro connessione non serve a nient’altro: misurato, un BLPOP cola 0 era ancora in CLIENT LIST con flags=b dopo 3 s, finché un CLIENT UNBLOCK non l’ha liberato; un BLPOP cola 1 è tornato vuoto dopo un secondo. Le connessioni di «Console» sono quelle che usano tutti gli strumenti della sessione, quindi la console non esegue un comando così con un tempo di 0 né di più di 10 secondi (più di 10000 millisecondi in XREAD BLOCK, WAIT e WAITAOF).

Cosa «Console» non esegue, e perché

- KEYS: percorre tutte le chiavi in una volta.
- MONITOR: rallenta il server, e la console non potrebbe chiuderlo.
- SUBSCRIBE, PSUBSCRIBE e SSUBSCRIBE: i messaggi arriverebbero mescolati alle risposte.
- SHUTDOWN: spegne il server.
- DEBUG: ferma il server o lo fa cadere.
- RESET, HELLO, CLIENT REPLY, SYNC e PSYNC: cambiano il protocollo della connessione che Calíope sta usando.
- SELECT: l’interfaccia mostrerebbe un database e ne leggerebbe un altro.
- MULTI, EXEC, DISCARD, WATCH e UNWATCH: una transazione vive su una sola connessione.
- I comandi che aspettano un dato, con un tempo di 0 o di più di 10 secondi.

E prima di FLUSHDB, FLUSHALL, CLUSTER RESET, CLUSTER FAILOVER, REPLICAOF e SLAVEOF chiede di scrivere il nome del comando. Questo elenco viene dal codice di Calíope: se cambia, questo argomento cambia con lui.

Parole chiave: redis, buone pratiche, keys, scan, hgetall, chiave grande, del, unlink, monitor, save, bgsave, flushall, flushdb, select, database, debug, blpop, bloccante, console

Tipi: l'affinità, e perché una colonna non obbliga

Il tipo vive nel valore e non nella colonna: le cinque affinità e che cosa converte ciascuna, l'ordine fra classi di memorizzazione, che cosa impedisce davvero STRICT, e perché NOCASE non sa nulla di accenti.

Si applica a: SQLite 3.35+

In SQLite il tipo è del valore, non della colonna. Quello che una colonna dichiara è un'affinità: una preferenza applicata al momento di salvare, che converte se può e lascia passare se non può.

CREATE TABLE t (i INTEGER, r REAL, x TEXT, b BLOB, n NUMERIC);
INSERT INTO t VALUES ('42', '42', 42, 42, '42');
INSERT INTO t VALUES (7.0,  7,   '7', '7', '7.5');

SELECT typeof(i), typeof(r), typeof(x), typeof(b), typeof(n) FROM t;
-- integer | real | text | integer | integer
-- integer | real | text | text    | real

Eccole tutte e cinque in una riga. INTEGER e REAL convertono il testo che sembra un numero; TEXT converte il numero in testo; NUMERIC guarda il valore e decide, quindi nella stessa colonna stanno un integer e un real; e BLOB è quella senza affinità: conserva quello che le danno, così com'è arrivato.

Le classi di memorizzazione sono cinque, e sono ordinate

Una colonna senza tipo dichiarato è legale e accetta tutte e cinque, quindi in una stessa colonna stanno un intero, un reale, un testo, un blob e un nullo. E si possono ordinare, perché fra le classi c'è un ordine fisso: prima i nulli, poi i numeri, poi il testo e infine i blob. Il che significa che un ORDER BY su una colonna sporca non fallisce: raggruppa per tipo senza dirlo a nessuno.

CREATE TABLE libre (v);            -- senza tipo dichiarato: va bene
INSERT INTO libre VALUES (1), (1.5), ('hola'), (x'0001'), (NULL);

SELECT typeof(v) FROM libre ORDER BY v;
-- null | integer | real | text | blob   <- ed è questo l'ordine tra le classi

STRICT impedisce meno di quanto sembri

Dalla 3.37 una tabella si può dichiarare STRICT, e allora accetta solo una manciata di tipi — INT, INTEGER, REAL, TEXT, BLOB e ANY — e rifiuta ciò che non può conservare. Ma continua a convertire: un '42' entra in una colonna INTEGER perché non si perde nulla, e un 42 entra in una TEXT e si conserva come '42'. Quello che rifiuta è ciò che non ha conversione. E c'è un guadagno inatteso: un tipo inventato, che una tabella normale accetta in silenzio, qui viene rifiutato già alla creazione.

CREATE TABLE s (i INTEGER, x TEXT) STRICT;

INSERT INTO s VALUES ('42', 'a');   -- entra: 42, convertibile senza perdere nulla
INSERT INTO s VALUES (1, 42);       -- entra: 42 viene salvato come testo '42'
INSERT INTO s VALUES ('abc', 'a');
-- cannot store TEXT value in INTEGER column s.i

CREATE TABLE s2 (d DATETIME) STRICT;
-- unknown datatype for s2.d: "DATETIME"

Niente data, niente booleano, e la collazione non sa di accenti

TRUE è un integer di valore 1. Una data è quello che decidi tu: date() restituisce text e julianday() restituisce real, e quello che scegli è quello che ordinerai e confronterai per il resto della sua vita. E ci sono solo tre collazioni — BINARY, NOCASE e RTRIM: NOCASE pareggia maiuscole e minuscole dell'ASCII e nient'altro, quindi café e CAFÉ sono valori diversi e upper('café') restituisce CAFé. Il testo è davvero UTF-8: length conta caratteri, e sul blob conta byte.

SELECT typeof(TRUE), TRUE;                    -- integer | 1
SELECT typeof(date('2026-09-12'));            -- text
SELECT typeof(julianday('2026-09-12'));       -- real

CREATE TABLE n (v TEXT COLLATE NOCASE);
INSERT INTO n VALUES ('Cafe'), ('CAFE'), ('café');

SELECT v FROM n WHERE v = 'cafe';             -- Cafe, CAFE
SELECT v FROM n WHERE v = 'CAFÉ';             -- nada
SELECT upper('café'), length('café'), length(CAST('café' AS BLOB));
-- CAFé | 4 | 5

Parole chiave: tipi, affinità, typeof, INTEGER, REAL, TEXT, BLOB, NUMERIC, tipizzazione dinamica, STRICT, booleano, data, julianday, COLLATE, NOCASE, UTF-8, classe di memorizzazione

Transazioni: un solo scrittore, e il 5 che lo dimostra

L'isolamento è serializzabile perché scrive uno solo: i tre modi di BEGIN, lo SQLITE_BUSY 5 e perché busy_timeout lo risolve, il 517 che non risolve, e che cosa cambia davvero la modalità WAL.

Si applica a: SQLite 3.35+

SQLite non ha livelli di isolamento da scegliere, e non è una mancanza: l'isolamento è serializzabile perché in tutta la base scrive uno solo alla volta. Tutto il resto discende da lì.

Due impostazioni lo governano, ed entrambe arrivano di fabbrica con il valore peggiore possibile.

PRAGMA journal_mode;    -- delete: quello di fabbrica, non WAL
PRAGMA busy_timeout;    -- 0: non aspetta per niente

PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 3000;

I tre modi di BEGIN

DEFERRED — quello predefinito — non prende nulla finché non serve: la prima lettura prende una fotografia e la prima scrittura chiede il lucchetto. IMMEDIATE chiede subito il lucchetto di scrittura, nella riga stessa del BEGIN. EXCLUSIVE chiede in più che nessuno legga, e in modalità WAL non fa quasi nulla di diverso da IMMEDIATE. La regola pratica: se la transazione scriverà, BEGIN IMMEDIATE; costa un'attesa all'inizio ed evita l'errore più fastidioso di SQLite, quello più sotto.

SAVEPOINT è il segno intermedio, e ROLLBACK TO ci torna senza chiudere la transazione.

BEGIN;                          -- DEFERRED, quello predefinito
UPDATE t SET v = 10 WHERE i = 1;
SAVEPOINT s1;
UPDATE t SET v = 20 WHERE i = 1;
ROLLBACK TO s1;                 -- annulla fin qui, NON chiude la transazione
SELECT v FROM t WHERE i = 1;    -- 10
RELEASE s1;
COMMIT;

SQLITE_BUSY è il 5, ed è quasi sempre tuo

Quando un altro tiene il lucchetto di scrittura, la risposta è SQLITE_BUSY con il codice 5 e il testo «database is locked». Non è un guasto: è la coda di una risorsa a corsia unica. A renderlo un guasto è che busy_timeout vale 0 di fabbrica, quindi senza toccarlo la risposta è immediata e secca. Con 800 ms impostati, la stessa chiamata ha aspettato 895 prima di arrendersi.

# due connessioni insieme, con timeout=0 perché risponda subito
a = sqlite3.connect(db, isolation_level=None, timeout=0)
b = sqlite3.connect(db, isolation_level=None, timeout=0)

b.execute("BEGIN IMMEDIATE"); b.execute("UPDATE t SET v=1 WHERE i=2")

a.execute("SELECT v FROM t WHERE i=2")   # (0,)  <- legge il valore di prima
a.execute("BEGIN IMMEDIATE")             # SQLITE_BUSY (5) database is locked

a.execute("PRAGMA busy_timeout=800")
a.execute("BEGIN IMMEDIATE")             # aspetta 895 ms e dà di nuovo 5

Quello che busy_timeout non risolve: il 517

Se una transazione DEFERRED legge e poi vuole scrivere, e nel frattempo qualcuno ha confermato, la fotografia presa in lettura non vale più e SQLite restituisce SQLITE_BUSY_SNAPSHOT, il 517. Aspettare non serve: nessuno restituirà quella fotografia. L'unica uscita è ROLLBACK e ricominciare — o, meglio, aver aperto con IMMEDIATE.

-- A:
BEGIN DEFERRED;
SELECT v FROM t WHERE i = 1;    -- qui A si tiene una foto del database

-- B, su un'altra connessione:
BEGIN IMMEDIATE; UPDATE t SET v = 5 WHERE i = 2; COMMIT;

-- A, che ora vuole scrivere:
UPDATE t SET v = 2 WHERE i = 1;
-- SQLITE_BUSY_SNAPSHOT (517), e busy_timeout non lo risolve
ROLLBACK;                       -- l'unica via d'uscita: lasciare e ricominciare

Che cosa cambia WAL, e che cosa no

Con il giornale di annullamento, mentre uno scrive nessuno legge. Con journal_mode = WAL, i lettori continuano a leggere l'ultima versione confermata mentre lo scrittore lavora: misurato con due connessioni, il lettore ha ottenuto il valore precedente senza bloccarsi un istante. Quello che non cambia è il numero di scrittori: resta uno, e il secondo continua a ricevere un 5.

Parole chiave: transazione, BEGIN, DEFERRED, IMMEDIATE, EXCLUSIVE, SAVEPOINT, ROLLBACK TO, SQLITE_BUSY, 5, 517, BUSY_SNAPSHOT, busy_timeout, WAL, journal_mode, blocco, serializzabile

Pragma: quelli del file, quelli della connessione e i comandi

Un pragma non è una cosa sola: alcuni si scrivono dentro il file, altri durano quanto la connessione, altri sono comandi. Quale è quale, i valori di fabbrica misurati, e quello che è spento e non dovrebbe.

Si applica a: SQLite 3.35+

SQLite non ha file di configurazione: ha i pragma. E la prima confusione da togliersi è che non sono tutti la stessa cosa. Alcuni si scrivono dentro il file e valgono per chi lo aprirà dopo; altri durano quanto la connessione e vanno ripetuti ogni volta; e altri non sono impostazioni ma comandi che fanno qualcosa e basta.

Questi sono i valori di fabbrica, letti da una base appena creata.

PRAGMA journal_mode;   -- delete
PRAGMA synchronous;    -- 2 (FULL)
PRAGMA foreign_keys;   -- 0   <- apagadas
PRAGMA cache_size;     -- 2000 paginas
PRAGMA mmap_size;      -- 0
PRAGMA auto_vacuum;    -- 0
PRAGMA page_size;      -- 4096

Quello che è spento e quasi nessuno se lo aspetta

foreign_keys vale 0. Le chiavi esterne si dichiarano, si conservano nello schema, compaiono nel CREATE TABLE… e non si controllano. Un figlio orfano entra senza un fiato. Accenderlo è una riga, ma è della connessione: va messo su ognuna, e accenderlo non guarda indietro — per quello c'è foreign_key_check, che elenca ciò che è già passato.

CREATE TABLE padre (id INTEGER PRIMARY KEY);
CREATE TABLE hijo  (id INTEGER PRIMARY KEY, p INTEGER REFERENCES padre(id));

INSERT INTO hijo VALUES (1, 999);   -- entra: non c'è un padre 999 e non importa

PRAGMA foreign_keys = ON;
INSERT INTO hijo VALUES (2, 999);   -- FOREIGN KEY constraint failed

PRAGMA foreign_key_check;           -- hijo | 1 | padre | 0

Quale resta e quale no

journal_mode e user_version si scrivono nell'intestazione del file e sopravvivono a chiudere e riaprire. Anche page_size e auto_vacuum, ma solo se si mettono prima della prima tabella: su una base che ha già pagine vengono accettati senza errore e non cambiano niente, e lo si è verificato in entrambi i modi. foreign_keys, cache_size, busy_timeout e mmap_size sono della connessione e tornano al valore di fabbrica appena se ne apre un'altra.

-- restano scritti nel file:
PRAGMA journal_mode = WAL;
PRAGMA user_version = 7;
PRAGMA page_size    = 8192;   -- solo su un database ancora VUOTO
PRAGMA auto_vacuum  = FULL;   -- idem

-- sono della connessione, e vanno ripetuti in ognuna:
PRAGMA foreign_keys = ON;
PRAGMA cache_size   = -8000;  -- in negativo sono kibibyte, non pagine
PRAGMA busy_timeout = 3000;

Un dettaglio che spiazza: dopo aver messo journal_mode = WAL, synchronous valeva 1 (NORMAL) senza che nessuno lo toccasse. È voluto — in WAL basta NORMAL per non perdere nulla di confermato — ma insegna che leggere un pragma non dice da dove viene quel valore.

E quelli che sono comandi

wal_checkpoint versa il giornale nella base e, con TRUNCATE, lo lascia a zero byte: si è misurato un -wal di 4 716 016 byte finito a 0. ANALYZE riempie sqlite_stat1 con quello che il pianificatore userà per scegliere un indice. E PRAGMA optimize è quello che conviene lanciare chiudendo una connessione di lunga vita: guarda quali tabelle sono cambiate abbastanza e avvia l'ANALYZE che serve, senza dire niente.

PRAGMA wal_autocheckpoint;         -- 1000 paginas
PRAGMA wal_checkpoint(TRUNCATE);   -- 0 | 0 | 0, e il -wal resta a 0 byte

ANALYZE;
SELECT * FROM sqlite_stat1;        -- t | iv | 20000 20000
PRAGMA optimize;                   -- non restituisce nulla

Parole chiave: pragma, configurazione, journal_mode, WAL, synchronous, foreign_keys, cache_size, mmap_size, auto_vacuum, page_size, user_version, wal_checkpoint, optimize, ANALYZE, sqlite_stat1

Indici: c'è solo l'albero B, e tre parole nel piano

SCAN, SEARCH e COVERING sono tutto il vocabolario di EXPLAIN QUERY PLAN. Gli indici parziali e per espressione e la condizione da ripetere perché servano, che cosa fa risparmiare WITHOUT ROWID e che cosa scrive ANALYZE.

Si applica a: SQLite 3.35+

In SQLite c'è un solo tipo di indice: l'albero B. Niente hash, niente bitmap, niente da scegliere. La ricerca per parole esiste ma non è un indice: è FTS5, una tabella virtuale a parte. Questo semplifica tutto il tema, perché l'unica decisione che resta è su quali colonne e in che ordine.

E si verifica con EXPLAIN QUERY PLAN, il cui vocabolario sta in tre parole: SCAN è leggere la tabella intera, SEARCH è entrare da un indice, e COVERING è che la riga non è stata nemmeno toccata.

-- pedidos(id, cliente, estado, total, correo) con 20 000 righe

EXPLAIN QUERY PLAN
  SELECT * FROM pedidos WHERE cliente = 42 AND estado = 'pagado';
-- SCAN pedidos

CREATE INDEX i_cli ON pedidos(cliente);
-- SEARCH pedidos USING INDEX i_cli (cliente=?)

CREATE INDEX i_cli_est ON pedidos(cliente, estado);
-- SEARCH pedidos USING INDEX i_cli_est (cliente=? AND estado=?)

Il composto si percorre per prefissi, come in qualunque motore: (cliente, estado) serve per cliente da solo e per i due insieme, ma non per estado da solo.

Coprente

Se l'indice porta tutte le colonne che la query legge, la riga non si tocca. È lo stesso indice di prima: quello che cambia è ciò che si chiede.

EXPLAIN QUERY PLAN
  SELECT cliente, estado FROM pedidos WHERE cliente = 42;
-- SEARCH pedidos USING COVERING INDEX i_cli_est (cliente=?)

Parziale e per espressione: entrambi vanno ripetuti

Un indice parziale indicizza solo le righe che soddisfano una condizione, ed è per questo che occupa poco. Il prezzo è che la query deve ripetere quella condizione, parola per parola, o il pianificatore non può usarlo: senza, la stessa query torna a SCAN. Lo stesso con l'indice per espressione: indicizza lower(correo), quindi va scritto lower(correo) nel WHERE; con correo da solo non serve a nulla.

CREATE INDEX i_parcial ON pedidos(total) WHERE estado = 'pagado';

EXPLAIN QUERY PLAN
  SELECT * FROM pedidos WHERE estado = 'pagado' AND total > 900;
-- SEARCH pedidos USING INDEX i_parcial (total>?)

EXPLAIN QUERY PLAN
  SELECT * FROM pedidos WHERE total > 900;
-- SCAN pedidos   <- senza ripetere il filtro, l'indice non esiste

CREATE INDEX i_correo ON pedidos(lower(correo));

EXPLAIN QUERY PLAN
  SELECT * FROM pedidos WHERE lower(correo) = 'u42@ej.com';
-- SEARCH pedidos USING INDEX i_correo (<expr>=?)

WITHOUT ROWID toglie un'indirezione

Una tabella normale conserva le righe sotto un rowid nascosto, quindi la sua chiave primaria è un altro indice che poi deve andare a prendere la riga. Con WITHOUT ROWID la tabella è l'albero della sua chiave primaria: si risparmia il salto e si risparmia spazio — 1 335 296 byte contro 1 675 264 sulla stessa tabella di 20 000 righe, il 20 % in meno — e il piano lo tradisce dicendo USING PRIMARY KEY invece di nominare un indice automatico.

ANALYZE dà numeri, non miracoli

Riempie sqlite_stat1 con il numero di righe e quante ce ne sono per ogni valore dell'indice. Quel secondo numero è quello che dice se un indice serve a qualcosa: 20 000 per valore significa che non distingue nulla. Ma non sempre cambia la scelta: nella misura il pianificatore stava già scegliendo bene prima di lanciarlo, perché senza statistiche usa supposizioni ragionevoli. Lanciare ANALYZE toglie le supposizioni; non promette un piano diverso.

CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;
-- 20 000 righe: 1 335 296 byte, contro 1 675 264 con rowid

EXPLAIN QUERY PLAN SELECT v FROM kv WHERE k = 'clave-000042';
-- SEARCH kv USING PRIMARY KEY (k=?)
-- con rowid avrebbe detto:  USING INDEX sqlite_autoindex_kv_1 (k=?)

ANALYZE;
SELECT tbl, idx, stat FROM sqlite_stat1;
-- t | ia | 20000 20000    <- 20 000 righe, 20 000 per ogni valore di a
-- t | ib | 20000 1        <- 20 000 righe, 1 per ogni valore di b

Parole chiave: indice, indici, albero B, EXPLAIN QUERY PLAN, SCAN, SEARCH, COVERING INDEX, indice parziale, indice per espressione, WITHOUT ROWID, ANALYZE, sqlite_stat1, FTS5

Prestazioni: la transazione vale 440 volte tutto il resto

Le stesse 20 000 inserzioni misurate in sei modi: la differenza fra la migliore e la peggiore non sta in nessun pragma, sta nel fatto che ci sia un BEGIN. E quello che davvero apportano WAL, VACUUM e la dimensione di pagina.

Si applica a: SQLite 3.35+

C'è una cosa sola che conta, e non è un pragma. Le stesse 20 000 inserzioni, stesso schema, stessa macchina:

ComeTempo
una alla volta, senza transazione3 963 ms
una alla volta, con synchronous = OFF2 442 ms
una alla volta, in modalità WAL258 ms
le 20 000 dentro un BEGIN9 ms
dentro un BEGIN, in modalità WAL10 ms

440 volte, e la spiegazione è che senza BEGIN ogni INSERT è la propria transazione: ventimila conferme, ognuna in attesa del disco.

-- 20 000 INSERT, ognuno nella sua transazione:  3963 ms
-- gli stessi 20 000 qui dentro:                          9 ms

BEGIN;
  INSERT INTO t (v) VALUES ('...');   -- x 20 000
COMMIT;

Attenzione a una trappola dello strato di mezzo: che il driver offra una chiamata di «inserimento a lotti» non vuol dire che apra una transazione. L'executemany di Python, senza BEGIN esplicito, ha impiegato 4 128 ms: esattamente quanto il ciclo a mano.

Che cosa apportano gli altri

Spegnere synchronous ha risparmiato il 38 % e in cambio offre di perdere dati confermati in un black-out: il peggior affare della lista. WAL senza transazione è sceso a 258 ms — quindici volte — perché confermare smette di riscrivere la base, e quello sì è un cambiamento che si può lasciare. Ma con entrambi in gioco, il BEGIN si porta via quasi tutto: 9 ms senza WAL e 10 con. Prima si raggruppa, e solo dopo si affina.

VACUUM è quello che restituisce lo spazio

Cancellare non rimpicciolisce il file: le pagine restano in un elenco di libere per essere riusate. Si è misurato un file di 10 813 440 byte a cui è stata cancellata metà delle righe e che ha continuato a misurare esattamente uguale. VACUUM lo riscrive per intero e lo ha lasciato a 5 410 816. Costa una copia della base e un lucchetto esclusivo, quindi non è un lavoro di ogni notte: è quello che si lancia quando una cancellazione grande ha lasciato il file del doppio di quanto gli tocca.

SELECT page_count * page_size
  FROM pragma_page_count(), pragma_page_size();   -- 10813440

DELETE FROM t WHERE i % 2 = 0;
PRAGMA freelist_count;   -- 1 pagina, e il file resta grande uguale

VACUUM;                  -- 5410816 byte, in 12 ms

La dimensione di pagina quasi mai va toccata

Ne sono state misurate tre. Abbassarla a 512 è costato il 20 % di file in più e un percorso misurabile dove le altre due non arrivavano al millisecondo; alzarla a 65 536 non ha guadagnato nulla. Quella di fabbrica — 4 096 — è quella da lasciare, e per giunta si può cambiare solo prima della prima tabella, o dopo passando per un VACUUM.

PRAGMA page_size = 512;     -- 5215744 byte, e la scansione in 3 ms
PRAGMA page_size = 4096;    -- 4333568 byte, e in 0 ms   <- quello di fabbrica
PRAGMA page_size = 65536;   -- 4390912 byte, e in 0 ms

E altre due cose che si misurano da sole

Ogni indice si paga a ogni scrittura: le stesse 20 000 righe hanno impiegato 7 ms senza indici propri, 12 con uno, 16 con due e 20 con tre, e il file è passato da 458 752 a 1 277 952 byte. E un'istruzione con parametro si riusa: 5 000 query con ? hanno impiegato 18 ms, e le stesse con il valore incollato nel SQL, 26 — oltre a essere la porta da cui entra l'iniezione.

Parole chiave: prestazioni, transazione, BEGIN, COMMIT, lotto, executemany, synchronous, WAL, VACUUM, freelist_count, page_size, frammentazione, istruzione preparata

DDL: quattro cose che ALTER sa fare, e il giro per il resto

Quello che ALTER TABLE sa fare sta in quattro righe, e quello che rifiuta aggiungendo e togliendo una colonna è misurato con il suo messaggio. Il giro di creare, copiare, cancellare e rinominare, e perché qui è sicuro.

Si applica a: SQLite 3.35+

ALTER TABLE sa fare quattro cose, e non una di più. Cambiare il tipo di una colonna, toglierle un NOT NULL, aggiungere una chiave esterna: niente di tutto questo esiste, e il tentativo non arriva nemmeno a essere un errore di schema, è un errore di sintassi.

ALTER TABLE t RENAME TO t2;            -- desde siempre
ALTER TABLE t RENAME COLUMN a TO a2;   -- 3.25
ALTER TABLE t ADD COLUMN g TEXT;       -- desde siempre
ALTER TABLE t DROP COLUMN b;           -- 3.35

ALTER TABLE t ALTER COLUMN g TYPE INTEGER;
-- near "ALTER": syntax error   <- non esiste, e non è mai esistito

Che cosa rifiuta ADD COLUMN

I tre rifiuti hanno la stessa causa: la colonna nuova si aggiunge senza toccare le righe già presenti, quindi il valore che ricevono deve potersi decidere senza guardarle. Un DEFAULT che cambia, un UNIQUE che andrebbe verificato e un NOT NULL senza valore non lo soddisfano.

ALTER TABLE t ADD COLUMN i TEXT DEFAULT (datetime('now'));
-- Cannot add a column with non-constant default

ALTER TABLE t ADD COLUMN j TEXT UNIQUE;
-- Cannot add a UNIQUE column

ALTER TABLE t ADD COLUMN k TEXT NOT NULL;
-- Cannot add a NOT NULL column with default value NULL

Che cosa rifiuta DROP COLUMN, e peggio: che cosa permette

Dalla 3.35 una colonna si può togliere, ma non se fa parte della chiave primaria, né se è UNIQUE, né se un indice o una colonna generata la nomina. Fin qui, bene. Il problema è il caso che lascia passare: una colonna usata da una vista si toglie senza un fiato, la vista resta rotta, e PRAGMA integrity_check continua a dire ok perché non guarda dentro le viste. Nessuno avvisa finché qualcuno non interroga.

ALTER TABLE t DROP COLUMN id;   -- cannot drop PRIMARY KEY column: "id"
ALTER TABLE t DROP COLUMN e;    -- cannot drop UNIQUE column: "e"
ALTER TABLE t DROP COLUMN c;    -- error in index i_c after drop column

CREATE VIEW v AS SELECT id, d FROM t;
ALTER TABLE t DROP COLUMN d;    -- PASSA, senza un lamento
SELECT * FROM v;                -- no such column: d
PRAGMA integrity_check;         -- ok

Il giro di sempre

Per tutto il resto la procedura è: creare la tabella nuova, copiare, cancellare la vecchia e rinominare. Sembra pericoloso e qui non lo è, per una cosa che MySQL non ha: il DDL di SQLite è transazionale. Misurato: un CREATE TABLE e un ADD COLUMN dentro un BEGIN, con ROLLBACK alla fine, non hanno lasciato né la tabella né la colonna. Quindi tutto il giro sta in una transazione e, se qualcosa va storto a metà, non resta nulla a metà.

PRAGMA foreign_keys = OFF;
BEGIN;
  CREATE TABLE t_nueva (id INTEGER PRIMARY KEY, a INTEGER NOT NULL);
  INSERT INTO t_nueva (id, a) SELECT id, CAST(a AS INTEGER) FROM t;
  DROP TABLE t;
  ALTER TABLE t_nueva RENAME TO t;
  -- e qui si ricreano indici, trigger e viste
COMMIT;
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;

Tre cautele. Indici, trigger e viste della tabella vecchia se ne vanno con lei e vanno ricreati, perché il DROP TABLE se li porta. Le chiavi esterne si spengono durante il giro e si controllano con foreign_key_check prima di riaccenderle. E la conversione di tipo è affar tuo: un CAST('a' AS INTEGER) restituisce 0, senza una parola di avviso.

Parole chiave: DDL, ALTER TABLE, RENAME TO, RENAME COLUMN, ADD COLUMN, DROP COLUMN, 3.25, 3.35, vista rotta, integrity_check, DDL transazionale, dodici passi, CAST

Backup: copiare il file è il modo di perderlo

Una base in modalità WAL sono tre file e i dati quasi mai stanno nel primo: copiarlo lascia una base vuota che si dichiara sana. I tre modi che funzionano, misurati, e che cosa trova ogni controllo di integrità.

Si applica a: SQLite 3.35+

Una base SQLite sembra un file, ed è lì che cominciano i guai. In modalità WAL sono tre, e quello che porta il nome può non portare nessun dato: dopo aver scritto 30 000 righe, il .sqlite misurava 4 096 byte — l'intestazione e poco altro — e il -wal misurava 3 366 072.

Copiare solo il primo non dà una base rotta. Dà qualcosa di peggio: una base che si apre senza lamentarsi, che non ha una sola tabella, e a cui PRAGMA integrity_check risponde ok. Un backup così passa tutti i controlli e non contiene niente.

PRAGMA journal_mode = WAL;
-- dopo 30 000 righe, i tre file misurano:
--   base.sqlite         4096   <- solo l'intestazione
--   base.sqlite-shm    32768
--   base.sqlite-wal  3366072   <- i dati sono qui

-- copiare solo base.sqlite dà un database che si apre, ed è VUOTO:
SELECT count(*) FROM sqlite_schema;   -- 0
PRAGMA integrity_check;               -- ok

I tre modi che funzionano

VACUUM INTO scrive una copia pulita e deframmentata in un altro file, con la base in uso: 3 338 240 byte in 4 ms, con le 30 000 righe. L'API di backup — il .backup della riga di comando e Connection.backup nei driver — fa lo stesso copiando pagine e può andare a rate: 3 338 240 byte in 3 ms. E .dump scrive il SQL che ricostruisce la base: 4 008 968 byte di testo e 30 003 istruzioni, il 20 % in più del binario, ma è l'unico che si legge con gli occhi e l'unico che sopravvive a un cambio di formato.

VACUUM INTO '/ruta/copia.sqlite';
-- 3338240 byte in 4 ms, con le 30 000 righe dentro
-- e la copia esce in journal_mode delete, non in WAL

Un dettaglio gradito: la copia di VACUUM INTO esce in journal_mode delete, non in WAL. È un solo file, che è esattamente quello che si vuole da un backup.

Copiare i tre file insieme con la base ferma funziona eccome. Il problema sono «insieme» e «ferma»: finché qualcuno scrive non c'è istante in cui i tre tornino, e nessuno strumento di copia lo garantisce.

Controllare quello che si ha

integrity_check percorre la base intera e quick_check salta i controlli incrociati fra indici e tabelle. Su una base sana entrambi hanno detto ok, e sulla stessa base con qualche centinaio di byte rovinati apposta, entrambi hanno detto esattamente la stessa cosa: Tree 2 page 4 cell 35: Rowid 0 out of order. La differenza fra i due si vede solo su una base grande, e nessuno dei due ripara nulla: servono a decidere se tornare al backup.

PRAGMA quick_check;       -- ok
PRAGMA integrity_check;   -- ok

-- con lo stesso database danneggiato apposta, i due rispondono uguale:
-- *** in database main ***
-- Tree 2 page 4 cell 35: Rowid 0 out of order

E quando è già tardi

Ripristinare un .dump è lanciare il suo SQL contro una base vuota. Ripristinare una copia binaria è rimetterla al suo posto, e lì conviene sapere che VACUUM INTO si rifiuta di sovrascrivere: su un file che esiste già risponde «output file already exists», quindi non c'è modo di calpestare il backup di ieri senza accorgersene. E se quello che resta è una base danneggiata e nessun backup, c'è .recover della riga di comando, che percorre le pagine ancora comprensibili e scrive il SQL per ricostruire quel che si può: non promette tutto, promette quel che resta.

Parole chiave: backup, copia di sicurezza, VACUUM INTO, API di backup, dump, WAL, -wal, -shm, integrity_check, quick_check, corruzione, ripristino

Errori: otto codici, e quello a quattro cifre dice di più

Gli otto che escono davvero, provocati uno a uno con il loro testo, e l'aritmetica del codice esteso: il 19 di vincolo diventa 275, 787, 1299, 1555 o 2067 a seconda di che cosa è stato infranto.

Si applica a: SQLite 3.35+

SQLite ha due serie di codici: una base, di una o due cifre, e una estesa, che dice la stessa cosa con più dettaglio. E il rapporto fra le due è aritmetica: l'esteso è il base più 256 per il sottotipo, quindi codice & 255 restituisce sempre il base. Un driver che mostra solo il base te ne nasconde metà.

CodiceNomeChe cosa è successo
5SQLITE_BUSYun'altra connessione tiene il lucchetto di scrittura
6SQLITE_LOCKEDil lucchetto lo tieni tu, in un'altra istruzione
8SQLITE_READONLYfile, cartella o connessione non lasciano scrivere
11SQLITE_CORRUPTil file ha smesso di avere senso
13SQLITE_FULLnon ci sta: il disco, o il max_page_count
19SQLITE_CONSTRAINTed è qui che va guardato l'esteso
21SQLITE_MISUSEl'API è stata usata male
26SQLITE_NOTADBnon è nemmeno una base

Il 19 sono cinque errori diversi

Il base non dice niente di utile, perché un vincolo infranto può essere uno qualunque di cinque. L'esteso sì, e il messaggio aiuta… con un'eccezione: la chiave primaria di un INTEGER PRIMARY KEY dà 1555, ma il suo testo dice «UNIQUE constraint failed». Lì il numero è più preciso della frase.

CREATE TABLE t (id INTEGER PRIMARY KEY, u TEXT UNIQUE, nn TEXT NOT NULL,
                ch INTEGER CHECK (ch > 0), p INTEGER REFERENCES padre(id));

INSERT INTO t VALUES (1,'b','x', 5,   1);   -- 1555  UNIQUE constraint failed: t.id
INSERT INTO t VALUES (2,'a','x', 5,   1);   -- 2067  UNIQUE constraint failed: t.u
INSERT INTO t VALUES (3,'c',NULL,5,   1);   -- 1299  NOT NULL constraint failed: t.nn
INSERT INTO t VALUES (4,'d','x',-1,   1);   --  275  CHECK constraint failed: ch > 0
INSERT INTO t VALUES (5,'e','x', 5, 999);   --  787  FOREIGN KEY constraint failed

Il 5 e il 6 si confondono e non sono la stessa cosa

Il 5 viene da fuori: un'altra connessione sta scrivendo, e si risolve aspettando — è lì che busy_timeout si guadagna il posto. Il 6 viene da dentro: la stessa connessione ha un cursore aperto sulla tabella che vuole cambiare, e aspettare non serve a niente perché chi blocca sei tu. Si risolve chiudendo il cursore.

PRAGMA max_page_count = 20;
INSERT INTO t ... ;      -- 13  SQLITE_FULL      database or disk is full

-- con il database aperto in sola lettura:
INSERT INTO t ... ;      --  8  SQLITE_READONLY  attempt to write a readonly database

-- con un cursore di SELECT ancora aperto, sulla STESSA connessione:
DROP TABLE t;            --  6  SQLITE_LOCKED    database table is locked

Il 13 quasi mai è il disco

SQLITE_FULL suona come una partizione piena ed è spesso il tetto che la base si è messa da sola: max_page_count. Con 20 pagine si provoca in una riga, e il messaggio è lo stesso che darebbe un disco davvero pieno: «database or disk is full».

L'11, il 26 e quello che non si vede

I due codici di file rotto si distinguono per dove sta il danno: se quello che non ha senso è una pagina, SQLITE_CORRUPT con «database disk image is malformed»; se quello che non ha senso è l'intestazione, non ci prova nemmeno e dice SQLITE_NOTADB. E il 21 è quello strano: chiamare l'API in un ordine impossibile. Non si vede quasi mai, perché il driver di turno lo intercetta prima e lancia un errore suo; in Python, per dirne una, esce un ProgrammingError che non porta nemmeno un codice di SQLite.

-- con la pagina dello schema sovrascritta:
SELECT count(*) FROM t;   -- 11  SQLITE_CORRUPT  database disk image is malformed

-- con la dimensione di pagina dell'intestazione sovrascritta:
SELECT count(*) FROM t;   -- 26  SQLITE_NOTADB   file is not a database

Parole chiave: errori, codici, SQLITE_BUSY, 5, SQLITE_LOCKED, 6, SQLITE_READONLY, 8, SQLITE_CORRUPT, 11, SQLITE_FULL, 13, SQLITE_CONSTRAINT, 19, 275, 787, 1299, 1555, 2067, SQLITE_MISUSE, 21, SQLITE_NOTADB, 26

Limiti: gli otto tetti, e dove stanno scritti

I tetti di SQLite non sono del formato ma del binario, e si leggono con PRAGMA compile_options. Gli otto che si toccano davvero, provocati uno a uno, e l'unico che si supera senza una parola.

Si applica a: SQLite 3.35+

I tetti di SQLite hanno una particolarità che nessun altro motore ha: non sono del formato, sono del binario con cui stai parlando. Si fissano alla compilazione, ed è per questo che la risposta a «quanto è il massimo?» comincia con PRAGMA compile_options, che li mostra tutti. Un programma può inoltre abbassarli a caldo con sqlite3_limit, mai alzarli.

Questi sono quelli del binario che porta macOS, e i primi cinque sono stati provocati.

TettoValoreCome avvisa
colonne per tabella2 000too many columns on b
termini di un composto500too many terms in compound SELECT
basi allegate10too many attached databases - max 10
lunghezza di un testo o di un blob1 000 000 000—
dimensione di pagina65 536niente, ed è lì il guaio
parametri di un'istruzione250 000—
profondità di un'espressione1 000—
pagine di una base1 073 741 823SQLITE_FULL
CREATE TABLE b (c0, c1, ... , c2000);
-- too many columns on b

SELECT 1 UNION ALL SELECT 1 UNION ALL ... ;   -- 501 veces
-- too many terms in compound SELECT

ATTACH DATABASE 'x11.sqlite' AS a11;
-- too many attached databases - max 10

Quello che non avvisa

PRAGMA page_size = 131072 non dà errore, non restituisce niente di strano e non cambia niente: la pagina resta a 4 096. Il massimo è 65 536 e quello che si chiede sopra viene scartato in silenzio, quindi l'unico modo di sapere se ha attecchito è rileggerlo. È lo stesso modo di fallire che page_size e auto_vacuum hanno già su una base con tabelle: accettati, e senza effetto.

PRAGMA page_size = 131072;   -- ni error ni aviso
PRAGMA page_size;            -- 4096   <- non l'ha preso
PRAGMA page_size = 65536;    -- este si

Quanto ci sta davvero

La dimensione massima del file non è una costante: è max_page_count per la dimensione di pagina. Con i valori di fabbrica — 1 073 741 823 pagine da 4 096 byte — escono 4 TiB, e alzando la pagina a 65 536, 64 TiB. Molto prima di avvicinarsi, finisce qualcos'altro: un testo non supera i 1 000 000 000 byte, e un SELECT con più di 250 000 parametri non si riesce nemmeno a preparare.

E un avviso sulla tabella qui sopra: è quella di questo binario. Quello di un telefono, quello di una libreria incorporata o quello che qualcuno ha compilato con le proprie opzioni possono portare altri numeri, ed è per questo che la risposta utile non è mai il valore: è il comando che lo chiede.

PRAGMA compile_options;
-- MAX_COLUMN=2000          MAX_COMPOUND_SELECT=500   MAX_ATTACHED=10
-- MAX_LENGTH=1000000000    MAX_PAGE_SIZE=65536       MAX_EXPR_DEPTH=1000
-- MAX_VARIABLE_NUMBER=250000                         MAX_FUNCTION_ARG=1000

PRAGMA max_page_count;   -- 1073741823
-- x 4096 per pagina = 4 TiB di file; x 65536 = 64 TiB

Abbassarli è una difesa

Che sqlite3_limit sappia solo abbassare non è un difetto: è la sua ragione d'essere. Un'applicazione che accetta SQL scritto da altri abbassa LENGTH, COMPOUND_SELECT ed EXPR_DEPTH a quello che le serve davvero, e così una query ostile non può più chiedere un gigabyte di memoria. È la stessa idea del max_page_count del tema degli errori: il tetto che ci si mette da soli avvisa prima di quello del sistema, e avvisa di qualcosa che si può aggiustare.

Parole chiave: limiti, tetti, compile_options, MAX_COLUMN, MAX_COMPOUND_SELECT, MAX_ATTACHED, MAX_LENGTH, MAX_PAGE_SIZE, max_page_count, sqlite3_limit, dimensione massima, colonne, ATTACH

Buone pratiche: sette, e quattro si mettono all'apertura

Il riassunto del manuale di SQLite: sette abitudini con la misura che le sostiene, quattro delle quali nelle righe subito dopo aver aperto la connessione, e le sei con cui si prende il polso a un file altrui.

Si applica a: SQLite 3.35+

Questa è la fine del manuale, e non porta niente di nuovo: raccoglie quello che ogni tema ha lasciato misurato. Quello che colpisce è dove cadono quattro delle sette: nelle righe che si scrivono subito dopo aver aperto la connessione, e che quasi nessun programma scrive.

1. PRAGMA journal_mode = WAL. Resta scritto nel file, quindi basta una volta. Con esso un lettore ha continuato a leggere mentre un altro scriveva, senza bloccarsi un istante. Quello che non risolve è il numero di scrittori: resta uno.

2. PRAGMA foreign_keys = ON, su ogni connessione. È l'unica cosa di questo elenco che cambia quello che la base accetta, e arriva spenta: con essa spenta, un figlio orfano entra senza un fiato. E non resta messa, quindi va nello stesso posto del busy_timeout.

3. PRAGMA busy_timeout, e messo da te. Il motore lo porta a 0 — risponde SQLITE_BUSY all'istante — ma molti driver lo cambiano al collegarsi: quello di Python lo lascia a 5 000 senza dirlo. Quindi il numero che conta non è quello della documentazione: è quello che restituisce PRAGMA busy_timeout sulla tua connessione.

4. Raggruppa le scritture in una transazione. È, di gran lunga, quello che cambia di più: le stesse 20 000 inserzioni hanno impiegato 3 963 ms una alla volta e 9 ms dentro un BEGIN. E occhio allo strato di mezzo: una chiamata di «inserimento a lotti» del driver non apre una transazione da sola.

PRAGMA journal_mode;    -- wal, o delete si nadie lo ha tocado
PRAGMA foreign_keys;    -- 0 quasi sempre, e quasi sempre è un errore
PRAGMA synchronous;     -- 2 con journal, 1 in WAL
PRAGMA busy_timeout;    -- quello della TUA connessione, non del motore
PRAGMA page_count;      -- x page_size = quanto occupa
PRAGMA quick_check;     -- ok

5. Fai il backup con VACUUM INTO, mai copiando il file. In modalità WAL i dati stanno nel -wal, quindi copiare il .sqlite dà una base che si apre, non ha una sola tabella, e a cui integrity_check risponde ok. Un backup che passa tutti i controlli ed è vuoto è peggio che non averne nessuno.

6. Un indice per query frequente, e guarda quanto pesa. dbstat lo dice per oggetto, e sorprende: nella base misurata l'indice occupava 2 056 192 byte contro 1 826 816 della tabella che indicizzava.

SELECT name, SUM(pgsize) AS bytes
  FROM dbstat
 GROUP BY name
 ORDER BY bytes DESC;
-- iv             2056192   <- l'indice pesa più della tabella
-- t              1826816
-- sqlite_schema     4096

7. La sicurezza è del file. Non ci sono utenti, né ruoli, né GRANT: chi può leggere il file può leggere tutto, e chi può scriverlo può cancellarlo. La protezione sono i permessi del sistema, la cifratura del disco e — su un telefono — la classe di protezione dei dati. Tutto il resto di questo manuale è prestazione; questa è l'unica cosa senza sostituto.

E una che non è un'abitudine ma un confine

SQLite regge molto più di quanto la sua fama suggerisca, ma ha un confine che nessuna pratica sposta: scrive uno alla volta. Finché le scritture vengono da un processo, o da più processi che si alternano, il file arriva a limiti che quasi nessuno tocca. Il giorno in cui servono due scrittori veri e simultanei, quello che va cambiato non è un pragma: è il motore.

Parole chiave: buone pratiche, riassunto, WAL, foreign_keys, busy_timeout, transazione a lotti, VACUUM INTO, backup, dbstat, indici, permessi, cifratura, sicurezza

Sicurezza, utenti e ruoli

Privilegio minimo, ruoli, connessioni cifrate e la lista di controllo prima di esporre un server.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

In MySQL e MariaDB l'identità di un utente è fatta di due cose: il nome e l'host da cui si connette. 'app'@'10.0.%' e 'app'@'%' sono account distinti, con password e permessi diversi. La maggior parte degli spaventi di sicurezza comincia dal dimenticarlo.

Privilegio minimo
Concedi ciò che l'applicazione usa, non uno di più, e sull'host più stretto possibile. Un account applicativo non ha quasi mai bisogno di DROP, e mai di SUPER, FILE o GRANT OPTION:

CREATE USER 'app'@'10.0.%' IDENTIFIED BY '...';

GRANT SELECT, INSERT, UPDATE, DELETE ON tienda.* TO 'app'@'10.0.%';

GRANT SELECT ON tienda.pedidos TO 'informes'@'%';

SHOW GRANTS FOR 'app'@'10.0.%';

Ruoli

MySQL 8.0+MariaDB 10.0.5+

Un ruolo è un pacchetto di privilegi concesso a più account. Cambi il ruolo una volta e cambiano tutti. È l'unico modo sensato di amministrare più di una manciata di utenti:

CREATE ROLE 'lectura', 'escritura';

GRANT SELECT ON tienda.* TO 'lectura';
GRANT INSERT, UPDATE, DELETE ON tienda.* TO 'escritura';

GRANT 'lectura' TO 'informes'@'%';
GRANT 'lectura', 'escritura' TO 'app'@'10.0.%';

SET DEFAULT ROLE ALL TO 'app'@'10.0.%';

MariaDB 10.0.5+

In MariaDB GRANT concede un ruolo per istruzione e un account ha un solo ruolo predefinito: GRANT 'lectura', 'escritura' e SET DEFAULT ROLE ALL sono errori di sintassi (misurato sulla 13.0).

GRANT 'lectura' TO 'app'@'10.0.%';
GRANT 'escritura' TO 'app'@'10.0.%';
SET DEFAULT ROLE 'lectura' FOR 'app'@'10.0.%';

Connessioni cifrate
Senza TLS la password e i dati viaggiano leggibili sulla rete. Si può imporre per account o per l'intero server con require_secure_transport. Calíope supporta TLS nel profilo di connessione, e anche il tunnel SSH quando il server non è esposto:

ALTER USER 'app'@'10.0.%' REQUIRE SSL;

SHOW VARIABLES LIKE 'require_secure_transport';

SELECT user, host, ssl_type FROM mysql.user;

Audit rapido
Tre query da far girare su qualsiasi server ereditato. Account senza password, account aperti a qualunque host e privilegi pericolosi distribuiti:

SELECT user, host FROM mysql.user WHERE authentication_string = '';

SELECT user, host FROM mysql.user WHERE host = '%';

SELECT * FROM information_schema.USER_PRIVILEGES
WHERE privilege_type IN ('SUPER', 'FILE', 'PROCESS', 'GRANT OPTION');

Prima di esporre un server
1. Nessun account anonimo né senza password, e nessun database di esempio test.
2. root solo da localhost, con un account amministrativo separato per il resto.
3. bind-address sull'interfaccia giusta — non 0.0.0.0 se dall'esterno non deve arrivare nessuno.
4. TLS obbligatorio per ogni connessione che esce dalla macchina.
5. Password gestite fuori dal codice — Calíope le tiene nel Portachiavi, mai in chiaro.
6. Account separati per applicazione, così una compromissione non si trascina dietro il resto.
7. Rivedere i GRANT periodicamente: i permessi si accumulano e nessuno li toglie.

Raccomandazione
Comincia col revocare invece che col concedere: crea l'account senza nulla e aggiungi privilegi finché l'applicazione funziona. Lo strumento Utenti di Calíope mostra i privilegi concessi per database e per tabella, che è dove di solito saltano fuori le sorprese.

Parole chiave: sicurezza, utente, ruolo, privilegio, grant, revoke, privilegio minimo, ssl, tls, require ssl, hardening, mysql.user, user_privileges, audit

Backup e ripristino a un punto nel tempo

Logico contro fisico, a cosa serve il binlog e come tornare al minuto prima del DELETE.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Un backup che non è mai stato ripristinato non è un backup: è un'intenzione. Qui comandano due numeri: l'RPO (quanti dati accetti di perdere) e l'RTO (quanto puoi restare fermo). Tutto il resto discende da lì.

Logico contro fisico
- Logico (mysqldump, il Backup di Calíope) — genera SQL. Portabile tra versioni e motori, permette di ripristinare una sola tabella ed è lento da ripristinare su grandi volumi.
- Fisico (snapshot del volume, Percona XtraBackup, copia della directory a server fermo) — copia i file. Rapidissimo da ripristinare, ma legato alla versione e all'architettura del server.

Regola pratica: fino a qualche decina di gigabyte, logico; oltre, fisico per la copia completa e logico per i pezzi singoli.

Il binlog è la metà che manca
Il backup ti riporta al momento in cui è stato fatto. Il binary log contiene tutto ciò che è successo dopo, ed è quello che permette di avanzare da lì fino a un secondo prima del disastro. Senza log_bin attivo non c'è ripristino a un punto nel tempo, solo il ritorno all'ultima copia:

SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';

SHOW BINARY LOGS;

Ripristinare a un punto nel tempo
La procedura, sempre su un server a parte e mai su quello in produzione:
1. Ripristina la copia completa più recente precedente all'incidente.
2. Individua il momento esatto dell'errore nel binlog: l'istruzione che ha cancellato troppo, con la sua posizione o il suo timestamp.
3. Riproduci il binlog dalla posizione in cui finiva la copia fino a poco prima di quell'istruzione, con mysqlbinlog e le sue opzioni --start-position e --stop-position (oppure --start-datetime e --stop-datetime).
4. Verifica che i dati ci siano, e solo allora decidi se promuovere quel server o esportarne ciò che manca.

Il Visualizzatore binlog di Calíope serve al passo 2: legge ogni file evento per evento, con la sua posizione, e li filtra per classe —DDL, DML o Sistema— e per testo, che è la parte scomoda da fare a mano.

Trovare la posizione
SHOW MASTER STATUS (SHOW BINARY LOG STATUS nel MySQL attuale, dove la vecchia forma è ormai un errore di sintassi) indica file e posizione correnti; gli eventi di un binlog preciso si elencano così:

SHOW MASTER STATUS;

SHOW BINLOG EVENTS IN 'binlog.000042'
LIMIT 20;

Verificare il ripristino
Ripristinare senza verificare è il modo abituale di scoprire il problema troppo tardi. Un conteggio per database e un CHECKSUM TABLE delle tabelle critiche contro l'origine bastano per dormire tranquilli:

SELECT table_schema, COUNT(*) AS tablas, SUM(table_rows) AS filas
FROM information_schema.TABLES
WHERE table_type = 'BASE TABLE'
GROUP BY table_schema;

CHECKSUM TABLE pedidos, lineas;

Raccomandazione
Pianifica il backup (Calíope lo fa, con ritenzione configurabile), tieni una copia fuori dalla macchina, attiva log_bin con una ritenzione che copra almeno due cicli di copia, e prova almeno una volta un ripristino completo. Il giorno dell'incidente non è il giorno per imparare la procedura.

Aurora

Amazon Aurora porta i suoi. Il cluster copia di continuo sullo storage e permette di recuperare a qualsiasi secondo dentro la finestra di ritenzione senza toccare il binlog: è PITR gestito, e ripristina su un cluster nuovo, non sopra quello esistente. Backtrack va oltre e riavvolge il cluster sul posto di qualche secondo, senza crearne un altro. Niente di tutto ciò sostituisce un mysqldump: le copie di AWS vivono nello stesso account, quindi non ti proteggono dal perderlo né ti danno qualcosa di portabile verso un altro fornitore.

Parole chiave: backup, ripristino, recupero, pitr, punto nel tempo, binlog, mysqldump, mysqlbinlog, rpo, rto, checksum table, snapshot

DDL online: cambiare lo schema senza fermarsi

ALGORITHM, LOCK, metadata lock e quando serve uno strumento esterno.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Un ALTER TABLE su una tabella grande può durare ore e lasciare l'applicazione in attesa. Da MySQL 5.6 e MariaDB 10.0 si può indicare come deve avvenire la modifica, e sapere così in anticipo se farà male.

Chiedere l'algoritmo, non affidarsi al caso
Se dichiari l'algoritmo e il server non può usarlo, l'istruzione fallisce subito invece di bloccarti la tabella per tre ore. È il motivo principale per scriverlo sempre:

ALTER TABLE pedidos
    ADD COLUMN nota VARCHAR(255) NULL,
    ALGORITHM=INSTANT;

ALTER TABLE pedidos
    ADD INDEX idx_fecha (fecha),
    ALGORITHM=INPLACE, LOCK=NONE;

ALTER TABLE pedidos
    MODIFY COLUMN total DECIMAL(12,2) NOT NULL,
    ALGORITHM=COPY, LOCK=SHARED;
AlgoritmoCosa faCosto tipico
INSTANTsolo metadatimillisecondi
INPLACEricostruisce sul postominuti o ore
COPYcopia l'intera tabellaore, con lock

MySQL 8.0+MariaDB 10.3+

ALGORITHM=INSTANT copre l'aggiunta di una colonna in fondo, l'allargamento di un VARCHAR entro la stessa dimensione dei byte di lunghezza (solo MariaDB 10.4+; in MySQL è INPLACE, senza ricostruire la tabella, e chiedere INSTANT dà l'errore 1845), la rinomina di una colonna o il cambio del valore predefinito. È l'unico che non tocca i dati.

La clausola LOCK
- LOCK=NONE — letture e scritture proseguono durante la modifica. Se non è possibile, errore.
- LOCK=SHARED — si legge, non si scrive.
- LOCK=EXCLUSIVE — nessuno tocca la tabella.

Dichiarare LOCK=NONE è il modo di garantire che la migrazione non fermerà la produzione: o gira senza bloccare, o non gira.

Il metadata lock, quello che sorprende
Anche un ALTER istantaneo ha bisogno di un lock esclusivo sui metadati all'inizio e alla fine. Se c'è una vecchia transazione aperta su quella tabella, l'ALTER aspetta — e tutte le query che arrivano dopo si mettono in coda dietro di lui. Una tabella si congela per un ALTER che doveva durare un millisecondo. Prima di toccare lo schema, controlla che non ci siano transazioni lunghe:

SELECT object_name, lock_type, lock_status, owner_thread_id
FROM performance_schema.metadata_locks
WHERE object_schema = DATABASE();

SELECT @@lock_wait_timeout;

Vedere l'avanzamento
Un ALTER di ore non dà segni di vita da solo. performance_schema sì:

SELECT stage, work_completed, work_estimated,
       ROUND(work_completed / work_estimated * 100, 1) AS pct
FROM performance_schema.events_stages_current;

SHOW PROCESSLIST;

Quando serve uno strumento esterno
Se la modifica impone ALGORITHM=COPY su una tabella da decine di gigabyte, nessun LOCK ti salva. Lì entrano pt-online-schema-change (Percona) e gh-ost (GitHub): creano una tabella nuova, copiano a lotti, la tengono sincronizzata con trigger o leggendo il binlog e alla fine fanno lo scambio in un istante. Non arrivano col server; si installano a parte e si eseguono da riga di comando.

Raccomandazione
Scrivi sempre ALGORITHM= e LOCK= nelle tue migrazioni, e provale prima su una copia con dati reali per sapere quanto dureranno. Un ALTER che fallisce dopo un secondo è una buona notizia rispetto a uno che blocca la tabella a metà mattina.

Parole chiave: ddl online, alter table, algorithm, instant, inplace, copy, lock=none, metadata lock, mdl, pt-online-schema-change, gh-ost, migrazione di schema

Set di caratteri e collation

Perché utf8 non è UTF-8, cosa decide una collation e come convertire senza rompere gli indici.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Due concetti che si confondono di continuo: il set di caratteri dice quali caratteri si possono memorizzare, e la collation dice come si confrontano e si ordinano. Il primo incide su cosa ci sta; la seconda, su cosa restituisce un WHERE.

utf8 non è UTF-8
In MySQL utf8 è un alias storico di utf8mb3: solo tre byte per carattere, quindi non può memorizzare emoji né buona parte del cinese, giapponese o coreano moderni. L'UTF-8 vero è utf8mb4. È la trappola più ripetuta del prodotto, e in MySQL 8.0 è ancora viva per compatibilità. Controlla dove sei:

SELECT default_character_set_name, default_collation_name
FROM information_schema.SCHEMATA
WHERE schema_name = DATABASE();

SELECT table_name, column_name, character_set_name, collation_name
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND character_set_name IS NOT NULL
  AND character_set_name <> 'utf8mb4';

SHOW VARIABLES LIKE 'character_set%';

Cosa decide una collation
Il nome dice tutto, se lo sai leggere. In utf8mb4_0900_ai_ci: 0900 è la versione di Unicode, ai significa insensibile agli accenti e ci insensibile alle maiuscole. I loro contrari sono as (sensibile agli accenti) e cs (sensibile alle maiuscole). Esiste anche utf8mb4_bin, che confronta byte per byte e non sa nulla di lingue.

I valori predefiniti differiscono: MySQL 8.0 usa utf8mb4_0900_ai_ci e MariaDB utf8mb4_general_ci oppure utf8mb4_uca1400_ai_ci a seconda della versione. Se sposti dati tra i due, non dare per scontato che ordinino allo stesso modo.

Cosa cambia in pratica
Con una collation ai_ci, café e cafe sono lo stesso valore: un UNIQUE rifiuterà il secondo e un WHERE li troverà entrambi. Può essere proprio ciò che vuoi per cercare nomi, e un disastro per conservare identificatori:

SELECT 'cafe' = 'café' COLLATE utf8mb4_0900_ai_ci AS acentos_iguales,
       'Ana'  = 'ana'  COLLATE utf8mb4_0900_ai_ci AS mayusculas_iguales;

SELECT * FROM clientes
WHERE nombre = 'jose' COLLATE utf8mb4_0900_as_cs;

SHOW COLLATION WHERE charset = 'utf8mb4';

Mescolare collation fa male
Un JOIN tra una colonna utf8mb4_general_ci e una utf8mb4_0900_ai_ci dà l'errore 1267 Illegal mix of collations. E se lo rattoppi avvolgendo la colonna in CONVERT() o in un COLLATE, la query non può più usare l'indice di quella colonna. La correzione giusta non è il COLLATE nella query: è unificare la collation nello schema.

Convertire senza sorprese
ALTER DATABASE cambia il valore predefinito solo per le tabelle future; quelle esistenti vanno convertite una a una. E CONVERT TO CHARACTER SET riscrive l'intera tabella, quindi merita la stessa prudenza di qualsiasi DDL pesante:

ALTER DATABASE tienda
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_0900_ai_ci;

ALTER TABLE clientes
    CONVERT TO CHARACTER SET utf8mb4
    COLLATE utf8mb4_0900_ai_ci;

Raccomandazione
utf8mb4 ovunque — server, database, tabella, colonna e connessione del client — e una sola collation in tutto lo schema. Prima di convertire, guarda gli indici sulle colonne di testo lunghe: passando da utf8mb3 a utf8mb4 ogni carattere può occupare un byte in più, e un indice che ci stava può smettere di starci.

Parole chiave: charset, set di caratteri, collation, utf8, utf8mb4, latin1, emoji, accenti, maiuscole, convert to character set, illegal mix of collations

Errori frequenti e cosa significano

I codici più frequenti — 1045, 1062, 1213, 2006 — e cosa fare con ciascuno.

Si applica a: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

I codici sotto 2000 li emette il server; quelli dal 2000 al 2999, la libreria client, e dal 3000 in su di nuovo il server. Già questa distinzione dice dove guardare: se è un 2xxx, il problema è nella connessione, non nell'SQL.

CodiceMessaggioCos'è di solito
1045Access denied for userutente, password o host che non coincide
1049Unknown databaseil database non esiste, o l'utente non lo vede
1040Too many connectionsmax_connections esaurite
1062Duplicate entryconflitto con una UNIQUE o la chiave primaria
1146Table doesn't existnome scritto male, o maiuscole su Linux
1213Deadlock foundciclo di lock; bisogna riprovare
1205Lock wait timeoutun'altra transazione tiene il lock
1215Cannot add foreign keytipi diversi, o indice mancante sulla destinazione
1267Illegal mix of collationsdue colonne con collation diverse
1406Data too long for columnil valore non entra nel tipo dichiarato
2002Can't connect through socketil server non gira, o il socket non è quello
2006MySQL server has gone awaywait_timeout o max_allowed_packet
2013Lost connection during queryquery uccisa, rete caduta o server riavviato

1045 e 1040: la connessione
Il 1045 non è quasi mai la password: è che l'account esiste per un altro host. Ricorda che 'app'@'localhost' e 'app'@'%' sono account distinti. Il 1040 significa che le connessioni sono finite, e la causa abituale non è la dimensione del pool ma connessioni che nessuno chiude:

SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Max_used_connections';

SHOW VARIABLES LIKE 'wait_timeout';
SHOW VARIABLES LIKE 'max_allowed_packet';

1062: voce duplicata
Il messaggio nomina la chiave violata. Se il duplicato è previsto — un import rieseguito, un upsert — esiste una sintassi per smettere di trattarlo come errore:

SELECT email, COUNT(*) AS repetidos
FROM clientes
GROUP BY email
HAVING repetidos > 1;

INSERT INTO clientes (email, nombre) VALUES ('a@b.c', 'Ana')
ON DUPLICATE KEY UPDATE nombre = VALUES(nombre);

1215: non si può creare la chiave esterna
Questo messaggio è celebre per non dire nulla. Le cause vere sono sempre le stesse quattro: i tipi delle due colonne non coincidono esattamente (segno e lunghezza inclusi), non coincidono i loro set di caratteri, manca un indice sulla colonna referenziata, oppure esistono già righe orfane che il vincolo non ammetterebbe:

SELECT constraint_name, table_name, referenced_table_name
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE constraint_schema = DATABASE();

SELECT l.* FROM lineas l
LEFT JOIN pedidos p ON p.id = l.pedido_id
WHERE p.id IS NULL;

Leggere bene l'errore
Prima di cercare il codice in rete, leggilo tutto: MySQL di solito indica tabella, colonna e valore esatti. E quando un'istruzione restituisce un avviso invece di un errore, SHOW WARNINGS subito dopo mostra cosa ha deciso il server per conto suo — un troncamento silenzioso, per esempio — che è peggio di un fallimento pulito.

Raccomandazione
Calíope mostra codice e messaggio del server così come sono, senza avvolgerli: quel testo è l'indizio migliore e conviene copiarlo per intero quando chiedi aiuto. Il Registro query conserva inoltre l'istruzione che l'ha provocato, con ora e durata.

Parole chiave: errore, codice di errore, 1045, 1049, 1062, 1146, 1213, 1205, 1215, 1267, 1406, 2002, 2006, 2013, 1040, too many connections, gone away, access denied, duplicate entry