Diese Seite beschreibt Calíope 1.7, die Version, die ich gerade baue. Die 1.6 ist im App Store für Mac, iPad, iPhone, Apple Watch, Apple TV und Apple Vision Pro. Das Changelog sagt, in welcher Version jede Funktion kam.

Fügt jedem Thema einen Button „In Calíope öffnen“ hinzu. Funktioniert nur mit installierter App.

DBA-Handbuch

Datentypen

Wertebereiche, Größe in Bytes und Anwendungsfälle der numerischen, Text-, Datums-/Zeit-, JSON- und räumlichen Typen. Enthält relevante Unterschiede zwischen MySQL und MariaDB.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Die Wahl des Datentyps beeinflusst die Größe auf der Festplatte, die Geschwindigkeit der Indizes und die Integrität der Daten. Dieser Leitfaden fasst die in MySQL und MariaDB am häufigsten verwendeten Typen zusammen — mit ihren Wertebereichen, ihrer Größe in Bytes und den typischen Anwendungsfällen.

Ganzzahlige numerische Typen

- TINYINT — 1 Byte, Wertebereich mit Vorzeichen −128…127 (ohne Vorzeichen 0…255). Nützlich für boolesche Flags oder kleine Zustände.
- SMALLINT — 2 Bytes, −32 768…32 767. Alter, kleine Mengen.
- MEDIUMINT — 3 Bytes, −8 388 608…8 388 607. Einzigartig in MySQL/MariaDB; außerhalb des Ökosystems selten genutzt.
- INT (INTEGER) — 4 Bytes, ±2,1·10⁹. Standardtyp für Primärschlüssel in mittelgroßen Tabellen.
- BIGINT — 8 Bytes, ±9,2·10¹⁸. Primärschlüssel in großen Tabellen, verteilte Bezeichner.

MySQL 8.0+

Seit MySQL 8.0 sind der Modifikator ZEROFILL und die Anzeigebreite (INT(11)) veraltet und werden in den meisten Fällen ignoriert. Nutze sie nicht in neuem Code.

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

Dezimale und Gleitkomma-Typen

- DECIMAL(M, D) — exakte Präzision, M Gesamtstellen und D Nachkommastellen. Pflicht für Geldbeträge.
- FLOAT — 4 Bytes, ca. 7 signifikante Stellen. Näherungsweise.
- DOUBLE — 8 Bytes, ca. 15 Stellen. Näherungsweise.

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

Text und Zeichenketten

- CHAR(N) — feste Länge von N Zeichen, bis zu 255. Schnell, wenn alle Zeilen dieselbe Größe haben (Ländercodes, Hashes).
- VARCHAR(N) — variable Länge, bis zu 65 535 Bytes pro Zeile (geteilt mit den übrigen Spalten). Nutzt 1 oder 2 zusätzliche Bytes für die Länge.
- TEXT, MEDIUMTEXT, LONGTEXT — 64 KiB, 16 MiB, 4 GiB. Werden außerhalb der Zeile gespeichert; können ohne Präfix nicht als Schlüssel verwendet werden (KEY (col(255))).
- BLOB, MEDIUMBLOB, LONGBLOB — binäre Entsprechungen.

Datum und Zeit

- DATE — 3 Bytes, '1000-01-01'…'9999-12-31'.
- TIME — 3 Bytes, '-838:59:59'…'838:59:59'. Ja, es kann größer als 24 Stunden sein (Intervall, keine Tageszeit).
- DATETIME — 5 Bytes, dazu bis zu 3 für Sekundenbruchteile. Ohne Zeitzone und ohne Umwandlung beim Speichern oder Lesen: Du bekommst den Wert zurück, den du gespeichert hast.
- TIMESTAMP — 4 Bytes, Wertebereich 1970…2038 (in MySQL, auch in 8.x) oder 1970…2106 (in MariaDB 11.5+). Wird in UTC gespeichert und in die time_zone der Verbindung umgewandelt.
- YEAR — 1 Byte, 1901…2155.

MySQL 5.7+MariaDB 10.5+

Sowohl DATETIME als auch TIMESTAMP unterstützen Sekundenbruchteil-Präzision: DATETIME(6) speichert Mikrosekunden.

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

Nativer Typ in MySQL 5.7+ und MariaDB 10.2+. Erlaubt das Indizieren von mit JSON_EXTRACT oder ->> extrahierten Feldern und, seit MySQL 8.0, generierte Spalten mit Index (MULTI-VALUED INDEX bei Arrays).

MySQL 5.7+

MySQL speichert JSON in einem optimierten Binärformat (BSON-artig) und validiert die Syntax beim Einfügen.

MariaDB 10.2+

In MariaDB ist JSON ein Alias für LONGTEXT mit einer optionalen Validierung über CHECK (JSON_VALID(col)). Es ist kein Binärtyp; es benötigt mehr Platz auf der Festplatte.

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)
);

Räumliche Typen (GIS)

- POINT, LINESTRING, POLYGON, GEOMETRY, MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION.
- Benötigen einen SPATIAL-Index für effiziente Queries (MBRContains, ST_Distance, ST_Within).
- In MySQL 8.0 ist die SRID Pflicht, um räumliche Indizes zu nutzen.

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 und SET

- ENUM — speichert einen von N vordefinierten Werten (bis zu 65 535). Kompakt (1–2 Bytes), aber starr: Das Ändern der Liste erfordert ein ALTER TABLE.
- SET — Kombination von bis zu 64 Werten als Bitmap. Nützlich für Berechtigungen oder feste Labels.

Vermeide sie, wenn sich die Werteliste häufig ändert; eine Katalogtabelle + Fremdschlüssel ist wartbarer.

Wie du wählst

1. Nutze den kleinsten Typ, der den erwarteten Wertebereich abdeckt. Ein BIGINT, wo INT genügt, vervierfacht den Speicherplatz des Index.
2. Markiere Spalten als UNSIGNED, wenn du keine negativen Werte brauchst: Du verdoppelst den Wertebereich.
3. Vermeide NULL, wo du kannst: Eine Spalte NOT NULL DEFAULT … spart 1 Bit pro Zeile und bei Indizes.
4. VARCHAR(255) ist nicht teurer als VARCHAR(20), wenn die tatsächlichen Daten in 20 passen — nur die deklarierte Länge zählt für Präfix-Indizes.

Stichwörter: Datentypen, INT, BIGINT, VARCHAR, TEXT, JSON, DATETIME, TIMESTAMP, DECIMAL, ENUM, SET, POINT, SPATIAL, BLOB, Wertebereich, Bytes

Index-Typen (B-tree, Hash, Fulltext, räumlich), einfache vs. zusammengesetzte Indizes und Strategien, um das Lesen zu optimieren, ohne das Schreiben zu bestrafen.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Ein Index beschleunigt die Suche zum Preis von Speicherplatz und von Kosten bei jedem INSERT/UPDATE/DELETE. Ein gutes Index-Design ist der Unterschied zwischen einer Query von Millisekunden und einer von Minuten.

Index-Typen

- B-tree — Standard in InnoDB. Unterstützt Suchen nach Gleichheit, Bereich (>, <, BETWEEN), Präfix (LIKE 'abc%') und Sortierung (ORDER BY).
- Hash — Unterstützt nur Gleichheit. Verfügbar in der Engine MEMORY. InnoDB pflegt einen internen adaptive hash index, den du nicht direkt steuerst.
- FULLTEXT — Natürliche und boolesche Textsuche. Verfügbar in InnoDB und MyISAM. Nützlich für lange TEXT-Felder.
- SPATIAL — R-tree für Typen wie POINT, POLYGON usw. Erfordert eine NOT NULL-Spalte.
- Multi-valued — Indiziert Elemente eines JSON-Arrays. Nur in MySQL 8.0.17+.

-- Zusammengesetzter B-Tree-Index
CREATE INDEX idx_pedidos_cliente_fecha
    ON pedidos (cliente_id, fecha_pedido DESC);

-- Volltextindex
ALTER TABLE articulos
    ADD FULLTEXT INDEX ft_titulo_cuerpo (titulo, cuerpo);

-- Räumlicher Index
ALTER TABLE ubicaciones
    ADD SPATIAL INDEX sp_punto (punto);

Einfach vs. zusammengesetzt

Ein zusammengesetzter Index über (A, B, C) deckt die Präfix-Suchen ab: WHERE A = ?, WHERE A = ? AND B = ?, WHERE A = ? AND B = ? AND C = ?, aber nicht WHERE B = ? für sich allein.

Faustregel: Ordne die Spalten des zusammengesetzten Index nach Selektivität (wie viele eindeutige Werte jede hat) und nach der Häufigkeit der Filter.

-- Gut: Index mit der selektivsten Spalte zuerst
CREATE INDEX idx_facturas
    ON facturas (cliente_id, estado, fecha)
    -- cliente_id (alta selectividad) → estado → fecha
;

-- Schlechtes Muster: redundanter Index
-- (cliente_id) ist schon durch (cliente_id, estado, fecha) abgedeckt
DROP INDEX idx_facturas_cliente ON facturas;

Covering Indexes

Ein Index, der alle von einer Query gelesenen Spalten enthält, vermeidet den Zugriff auf die Tabelle. Nutze EXPLAIN und suche nach Using index in der Spalte Extra.

-- Die Abfrage liest nur cliente_id und total → der Index deckt sie ab
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;

Präfix-Indizes

Für lange TEXT- oder VARCHAR-Spalten indiziere nur die ersten N Zeichen. Das reduziert die Größe des Index bei vernünftig bleibender Selektivität.

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

Unsichtbare Indizes

MySQL 8.0+

Ein Index kann als invisible markiert werden: Er existiert und wird gepflegt, aber der Optimierer ignoriert ihn. Nützlich, um die Auswirkung des Löschens eines Index risikofrei zu testen:

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 wird der Index nicht unsichtbar, sondern ignoriert, mit IGNORED (gemessen auf 13.0, wo INVISIBLE ein Syntaxfehler ist), und die Wirkung ist dieselbe:

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

Auswirkung Lesen vs. Schreiben

Jeder zusätzliche Index:
- Beschleunigt Queries, die ihn nutzen.
- Bestraft jedes INSERT, jedes UPDATE, das indizierte Spalten berührt, und jedes DELETE.
- Belegt zusätzlichen Speicher (oft 10 %–40 % der Tabellengröße).

In Tabellen mit massivem Schreiben (Logs, Metriken) halte die Zahl der Indizes auf das unverzichtbare Minimum.

Optimierungsstrategien

1. Beginne mit EXPLAIN — erkenne type: ALL (Full Scan) und key: NULL (kein Index genutzt).
2. Miss vor dem Optimieren — nutze das Slow Query Log, um die teuersten Queries zu finden.
3. Kombiniere Selektivität mit Reihenfolge — der zusammengesetzte Index muss der Reihenfolge der WHERE- und ORDER BY-Klauseln folgen.
4. Vermeide redundante Indizes — (A), (A, B), (A, B, C) sind untereinander redundant; (A, B, C) genügt.
5. Indiziere keine Spalten mit niedriger Kardinalität — ein Index über genero oder activo (1 oder 2 eindeutige Werte) hilft fast nie.

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

-- Nutzungsstatistik der Indizes
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;

Stichwörter: Indizes, Index, B-tree, Hash, Fulltext, Spatial, Covering, zusammengesetzt, Selektivität, EXPLAIN, Prefix, invisible, Kardinalität

Normalisierung

Erste, zweite und dritte Normalform, wann zu denormalisieren ist und wie sich Datenintegrität gegen Leistung abwägen lässt.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

Die Normalisierung ist der Prozess, ein Schema so zu organisieren, dass Redundanz reduziert und Einfüge-, Aktualisierungs- und Löschanomalien vermieden werden. Die Normalformen sind kumulativ: Eine Tabelle in 3NF erfüllt auch 2NF und 1NF.

Erste Normalform (1NF)

- Jede Spalte enthält einen einzigen atomaren Wert (keine Listen und kein verschachteltes JSON, das mehrere Entitäten darstellt).
- Jede Zeile ist über einen Primärschlüssel identifizierbar.
- Keine sich wiederholenden Gruppen in Spalten (tel1, tel2, tel3).

-- Schlecht: drei Spalten, die dasselbe wiederholen "entidad"
CREATE TABLE clientes_v1 (
    id   BIGINT PRIMARY KEY,
    nombre  VARCHAR(120),
    tel1 VARCHAR(20),
    tel2 VARCHAR(20),
    tel3 VARCHAR(20)
);

-- Gut: eine verknüpfte Tabelle
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

Das Beispiel verwendet den natürlichen Schlüssel (cliente_id, telefono), damit es unverändert auf jeder Engine läuft. Wenn du einen Surrogatschlüssel bevorzugst, schreibt man ihn auf MySQL und MariaDB als id BIGINT AUTO_INCREMENT PRIMARY KEY, und die geschlossene Wertemenge wird mit ENUM('movil', 'casa', 'oficina') deklariert.

PostgreSQL 13+

Das Beispiel verwendet den natürlichen Schlüssel (cliente_id, telefono), damit es unverändert auf jeder Engine läuft. In PostgreSQL wird der Surrogatschlüssel als id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY deklariert — BIGSERIAL ist die alte Schreibweise und funktioniert weiterhin —, und für die geschlossene Wertemenge gibt es zwei Wege: ein CHECK (tipo IN ('movil', 'casa', 'oficina')), das mit einem ALTER TABLE geändert wird, oder ein eigener Typ mit CREATE TYPE tipo_tel AS ENUM (…). Vorsicht beim zweiten: gemessen auf PostgreSQL 17.6 funktioniert ALTER TYPE … ADD VALUE, aber ALTER TYPE … DROP VALUE antwortet 0A000, "dropping an enum value is not implemented". Ein Wert, der in einen PostgreSQL-Enum kommt, geht nicht wieder heraus.

SQLite 3.35+

Das Beispiel nutzt den natürlichen Schlüssel (cliente_id, telefono), damit es auf jeder Engine unverändert läuft, und auf SQLite läuft es. Was nicht läuft, ist das daneben, und es scheitert auf zwei sehr verschiedene Arten:

- ENUM('movil', 'casa', 'oficina') ist ein Syntaxfehler. Die geschlossene Menge wird mit CHECK (tipo IN ('movil', 'casa', 'oficina')) deklariert, und diese Liste lässt sich danach nicht ändern: ALTER TABLE … ADD CONSTRAINT und DROP CONSTRAINT sind ebenfalls Syntaxfehler, also baut man die Tabelle neu.
- id BIGINT AUTO_INCREMENT PRIMARY KEY scheitert NICHT, was schlimmer ist. SQLite akzeptiert jeden Typnamen, schluckt also BIGINT AUTO_INCREMENT komplett als Spaltentyp und nummeriert gar nichts: gemessen auf 3.51 lassen zwei Einfügungen id beide Male auf NULL. Und es liegt nicht an AUTO_INCREMENT: id BIGINT PRIMARY KEY tut genau dasselbe, denn nur INTEGER PRIMARY KEY ist ein Alias der rowid und nummeriert von selbst. Wer die Nummerierung will, schreibt als Typ INTEGER, so lang ausgeschrieben.

Und eine Falle, die man erst sieht, wenn die Daten schon falsch sind: Fremdschlüssel sind ab Werk ausgeschaltet. Gemessen auf 3.51 nimmt die Tabelle oben mit PRAGMA foreign_keys auf 0 — dem Standardwert — eine Telefonnummer eines Kunden an, den es nicht gibt, und den Kunden zu löschen löst das ON DELETE CASCADE nicht aus. Mit PRAGMA foreign_keys = ON verhalten sich beide wie auf den anderen Engines. Das PRAGMA gilt pro Verbindung, wird nicht in der Datei gespeichert und tut innerhalb einer Transaktion nichts: es kommt direkt nach dem Öffnen.

-- SQLite: pro Verbindung einschalten, vor der ersten Transaktion
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

Das Beispiel nutzt den natürlichen Schlüssel (cliente_id, telefono), damit es auf jeder Engine unverändert läuft, und auf SQL Server läuft es. Das daneben nicht: AUTO_INCREMENT ist ein Syntaxfehler (Msg 102), genau wie ENUM(…), und die PostgreSQL-Schreibweisen helfen auch nicht — GENERATED ALWAYS AS IDENTITY ergibt Msg 156, und BIGSERIAL ist kein Typ (Msg 2715). Hier wird der Surrogatschlüssel als id BIGINT IDENTITY(1, 1) PRIMARY KEY deklariert und die geschlossene Menge mit einem CHECK. Zwei Dinge, gemessen auf SQL Server 2025:

- Gib dem CHECK einen Namen. Ohne Namen erfindet der Server einen mit einem Suffix, das sich bei jedem Anlegen der Tabelle ändert (CK__clientes_t__tipo__6D0D32F4), und um die Liste zu ändern, muss man ihn unter diesem Namen löschen. Mit eigenem Namen sind es zwei ALTER TABLE, DROP CONSTRAINT und ADD CONSTRAINT, und die neue Einschränkung ist von Anfang an vertrauenswürdig (is_not_trusted = 0).
- IDENTITY hinterlässt Lücken. Ein INSERT, das der CHECK ablehnt (Msg 547), und ein mit ROLLBACK rückgängig gemachtes verbrauchen ihre Nummer trotzdem: nach den Zeilen 1 und 2 kam als nächste die 5. Und den Wert von Hand zu schreiben ergibt Msg 544, außer mit SET IDENTITY_INSERT … ON.

-- SQL Server: Surrogatschlüssel und benannte geschlossene Menge
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
);

-- Die Liste später ändern
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'));

Zweite Normalform (2NF)

- Erfüllt 1NF.
- Jede Nicht-Schlüssel-Spalte hängt vom vollständigen Primärschlüssel ab, nicht von einem Teil davon. Gilt für zusammengesetzte Schlüssel.

Beispiel: Eine Tabelle detalle_pedido (pedido_id, producto_id, cantidad, nombre_producto) verletzt 2NF, weil nombre_producto nur von producto_id abhängt, nicht vom vollständigen Paar.

-- Schlecht: nombre_producto wiederholt sich in jeder Zeile desselben Produkts
CREATE TABLE detalle_pedido_v1 (
    pedido_id        BIGINT,
    producto_id      BIGINT,
    cantidad         INT,
    nombre_producto  VARCHAR(120),
    PRIMARY KEY (pedido_id, producto_id)
);

-- Gut: nombre_producto steht in der Tabelle 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)
);

Dritte Normalform (3NF)

- Erfüllt 2NF.
- Keine Nicht-Schlüssel-Spalte hängt von einer anderen Nicht-Schlüssel-Spalte ab (keine transitiven Abhängigkeiten).

Klassisches Beispiel: empleados (id, nombre, departamento_id, departamento_nombre). Der Name der Abteilung hängt von departamento_id ab, nicht direkt vom Schlüssel des Mitarbeiters.

-- 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 und höhere Normalformen

Die Boyce-Codd-Normalform (BCNF) verschärft 3NF, und 4NF/5NF behandeln mehrwertige und Verbund-Abhängigkeiten. Für die meisten transaktionalen Schemas reicht es aus, sauber 3NF zu erreichen.

Wann denormalisieren

Die bewusste Denormalisierung bricht die Regeln, um Performance zu gewinnen. Sie ist berechtigt, wenn:

1. Massives Lesen, seltenes Schreiben — ein gecachtes Feld in der Tabelle (pedidos.total_pagado) erspart ein wiederkehrendes SUM(...).
2. Reporting / Analytik — Star- oder Snowflake-Schemas denormalisieren absichtlich.
3. Vorberechnete Ergebnisse — materialisierte Views oder Zusammenfassungstabellen.

Trade-offs, die du akzeptierst:

- Aktualisierungsanomalien — wenn sich das denormalisierte Datum ändert, muss es in N Zeilen aktualisiert werden.
- Vorübergehende Inkonsistenz — das gecachte Feld kann veralten, wenn die Aktualisierung mittendrin fehlschlägt.
- Trigger oder Anwendungslogik — du musst das Datum synchron halten.

-- Beispiel: die Bestellsumme zwischenspeichern, um SUM bei jedem Lesen zu vermeiden
ALTER TABLE pedidos
    ADD COLUMN total DECIMAL(12, 2) NOT NULL DEFAULT 0;

MySQL 5.7+MariaDB 10.5+Aurora

DELIMITER ist kein SQL: Es versteht der Client, nicht der Server.

-- Mit einem Trigger pflegen
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+

In PostgreSQL hat der Trigger keinen Rumpf: Er ruft eine Funktion auf, die trigger zurückgibt, es sind also zwei Anweisungen. DELIMITER braucht es auch nicht, das ist Sache des MySQL-Clients; der Rumpf steht zwischen $$.

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();

Gemessen auf PostgreSQL 17.6: nach dem Einfügen von zwei Zeilen, 3 × 25,50 und 2 × 10,00, stand pedidos.total bei 96,50, ohne es anzufassen. RETURN NULL genügt, weil der Trigger AFTER ist; bei einem BEFORE müsste man NEW zurückgeben.

SQLite 3.35+

In SQLite trägt der Trigger seinen Rumpf innen, wie in MySQL, aber ohne DELIMITER — das gehört zum MySQL-Client und ist hier ein Syntaxfehler —, weil BEGIN … END bereits abgrenzt. Es gibt keine eigene Funktion, FOR EACH ROW ist der einzige Modus, den es gibt, und das ; nach END ist immer nötig.

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;

Gemessen auf SQLite 3.51 mit demselben Beispiel: nach dem Einfügen von zwei Zeilen, 3 × 25,50 und 2 × 10,00, stand pedidos.total bei 96,5, ohne es anzufassen.

SQL Server

Auf SQL Server läuft das ALTER TABLE oben nicht: hier heißt es nur ADD, und ADD COLUMN ist ein Syntaxfehler (Msg 156).

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

Und der Trigger ändert seine Form, denn hier gibt es kein FOR EACH ROW: Er feuert einmal pro Anweisung, und die neuen Zeilen kommen gemeinsam in der Tabelle inserted an. Auch NEW gibt es nicht. Also schreibt man ihn für eine Menge von Zeilen:

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;

Gemessen auf SQL Server 2025: Die zwei Zeilen des Beispiels, 3 × 25,50 und 2 × 10,00, in einem einzigen INSERT, setzten pedidos.total auf 96,50, und ein INSERT mit Zeilen zweier verschiedener Bestellungen aktualisierte beide. Die Falle ist, ihn „pro Zeile“ zu schreiben, wie in MySQL, und pedido_id in einer Variablen zu halten: Mit SELECT @pedido = pedido_id FROM inserted aktualisierte ein INSERT zweier Bestellungen eine davon und ließ die andere bei 0,00, ganz ohne Fehler; mit SET @pedido = (SELECT pedido_id FROM inserted) scheiterte er mit Msg 512, und das ganze INSERT wurde rückgängig gemacht.

CREATE TRIGGER muss das Erste in dem sein, was gesendet wird (Msg 111, wenn es nach einer anderen Anweisung kommt), und sein Rumpf enthält ;: In Calíope führst du ihn allein aus oder mit dem Trennzeichen $$.

Für die gecachte Summe gibt es außerdem einen Weg ohne Trigger. SQL Server hat keine materialisierten Views, aber eine indizierte View leistet dasselbe, und der Server hält sie selbst aktuell. Sie verlangt WITH SCHEMABINDING, zweiteilige Namen und ein COUNT_BIG(*) in der Liste — ohne es Msg 10138 beim Anlegen des Index —, und auch CREATE VIEW wird allein gesendet. Gemessen: Nach dem Einfügen einer weiteren Zeile lieferte die View schon die neue Summe, ohne sie anzufassen.

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);

Empfehlung

1. Entwirf standardmäßig in 3NF. Die Integrität dankt es dir.
2. Denormalisiere nur datenbasiert — miss die langsame Query, teste einen Cache und vergleiche.
3. Dokumentiere die Denormalisierung. Ohne einen Kommentar im DDL wird der nächste DBA sie "normalisieren", weil er sie für einen Fehler hält.

Stichwörter: Normalisierung, 1NF, 2NF, 3NF, BCNF, Denormalisierung, Redundanz, funktionale Abhängigkeit, Schlüssel, Integrität, Trigger

INNER, LEFT, RIGHT, CROSS, SELF und FULL OUTER JOIN: wann du welchen einsetzt, mit Beispielen auf einem typischen Bestellschema.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

Ein JOIN kombiniert Zeilen aus zwei oder mehr Tabellen anhand einer Bedingung. Der Join-Typ bestimmt, was mit den Zeilen passiert, die keinen Partner finden.

Die Beispiele verwenden diese Tabellen:

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

Gibt nur die Zeilen mit einem Partner in beiden Tabellen zurück. Es ist der Standard-Join und der am häufigsten verwendete.

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

LEFT JOIN (LEFT OUTER JOIN)

Alle Zeilen der linken Tabelle + die Partner aus der rechten. Felder ohne Partner rechts erscheinen als NULL. Nützlich für "alle X, mit ihrem Y, falls vorhanden".

-- Alle Kunden, ob mit oder ohne Bestellungen
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;

Kunden ohne Bestellungen — klassisches Muster mit 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

Das Gegenstück zum LEFT. Wird fast immer als LEFT JOIN mit vertauschter Reihenfolge geschrieben, das ist lesbarer.

-- Entspricht einem LEFT JOIN mit umgekehrter Reihenfolge
SELECT c.nombre, p.id
FROM pedidos p
RIGHT JOIN clientes c ON p.cliente_id = c.id;

SQLite 3.39+

SQLite hat RIGHT JOIN seit 3.39; davor muss man die Reihenfolge umdrehen und ihn als LEFT JOIN schreiben. Gemessen auf SQLite 3.51 mit 5 Kunden und 6 Bestellungen, verteilt auf drei von ihnen, liefert die Abfrage oben 8 Zeilen: die 6 mit Partner und die 2 Kunden ohne Bestellung, mit NULL in p.id.

CROSS JOIN

Kartesisches Produkt: jede Zeile aus A mit jeder Zeile aus B. Ohne ON-Klausel. Nützlich, um alle Kombinationen zu erzeugen (Kalender × Produkte für Reports).

-- Erzeugt alle Kombinationen (Kunde, Monat) für einen Bericht
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

Dieselbe Tabelle erscheint zweimal mit unterschiedlichen Aliassen. Nützlich für Hierarchien oder Vergleiche zwischen Zeilen derselben Tabelle.

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

-- Jeder Mitarbeiter mit dem Namen seines Chefs
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

Alle Zeilen beider Tabellen; die ohne Partner zeigen NULL auf der fehlenden Seite.

MySQL 5.7+MariaDB 10.5+Aurora

Weder MySQL noch MariaDB kennen FULL OUTER JOIN: Gemessen auf MySQL 8.0.46 und 26.7 und auf MariaDB 13.0 ist es ein Syntaxfehler. Es wird mit UNION emuliert:

-- Nachbildung 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 hat es nativ, und OUTER ist optional: FULL JOIN bedeutet dasselbe. Gemessen auf PostgreSQL 17.6 mit 5 Kunden und 6 Bestellungen, davon 3 ohne Kunden, liefert es alle 8 Zeilen und der Plan ist ein 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+

SQLite hat ihn seit 3.39 ebenfalls nativ, und OUTER ist auch dort optional. Was es nicht gibt, ist ein eigener Planknoten: gemessen auf SQLite 3.51 zeigt EXPLAIN QUERY PLAN den üblichen LEFT-JOIN und darunter einen zweiten Durchlauf, RIGHT-JOIN pedidos, weil die Abfrage als zwei verkettete Durchläufe aufgelöst wird.

-- 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 hat ihn nativ, und OUTER ist optional. Gemessen auf SQL Server 2025 mit 5 Kunden und 6 Bestellungen, 3 davon ohne Kunde: Er liefert die 8 Zeilen. Der Planknoten hängt von der Größe ab. Bei so wenigen Zeilen gibt es keinen eigenen Knoten: Es ist eine Concatenation aus zwei Durchgängen, einem Nested Loops (Left Outer Join) und einem Nested Loops (Left Anti Semi Join) für die Bestellungen ohne Partner. Bei 50.000 Kunden und 200.000 Bestellungen, mit einem Index auf pedidos.cliente_id, ist es ein Merge Join (Full Outer Join).

-- SQL Server nativ
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

Zwingt den Optimierer, die Tabellen in der angegebenen Reihenfolge zu lesen. Nutze es nur, wenn du gemessen hast, dass der automatische Plan schlechter ist:

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

PostgreSQL 13+

PostgreSQL hat kein STRAIGHT_JOIN und keinen anderen Hinweis innerhalb der Abfrage: es zu schreiben ist ein Syntaxfehler (42601). Was es gibt, sind Sitzungsparameter: join_collapse_limit und from_collapse_limit, beide standardmäßig 8, und die Schalter enable_nestloop, enable_hashjoin und enable_mergejoin, alle drei auf on. Sie dienen der Diagnose in deiner Sitzung; eine Methode in der Produktion abzuschalten versteckt das Problem, statt es zu beheben.

SQLite 3.35+

SQLite hat ebenfalls kein STRAIGHT_JOIN: es zu schreiben ist ein Syntaxfehler. Was es gibt, ist eine Möglichkeit, die Reihenfolge in der Abfrage selbst festzulegen — CROSS JOIN ändert das Ergebnis nicht, verbietet dem Planer aber, die Tabellen umzuordnen — dazu zwei Hinweise pro Tabelle, INDEXED BY <Index> und NOT INDEXED. Gemessen auf SQLite 3.51 über 50 000 Kunden und 200 000 Bestellungen: mit JOIN liest der Planer zuerst clientes, mit CROSS JOIN hält er sich an das Geschriebene und liest zuerst pedidos. Vorsicht beim Hinweis: INDEXED BY mit einem Index, den es nicht gibt, lässt die Abfrage scheitern, statt ignoriert zu werden.

-- SQLite: die geschriebene Reihenfolge gilt
SELECT c.nombre, p.id
FROM pedidos p
CROSS JOIN clientes c ON p.cliente_id = c.id;

SQL Server

SQL Server hat auch kein STRAIGHT_JOIN: es zu schreiben ist ein Syntaxfehler (Msg 102). Was es hat, sind Hinweise innerhalb der Abfrage. OPTION (FORCE ORDER) legt die geschriebene Reihenfolge für die ganze Abfrage fest, und LOOP, HASH oder MERGE legen die Methode fest: in OPTION (HASH JOIN) für alle Joins oder zwischen den Wörtern des Joins (INNER HASH JOIN) für nur einen. Vorsicht bei der zweiten Form: Ein Hinweis am Join legt auch die Reihenfolge fest, und der Server meldet es mit Warning: The join order has been enforced because a local join hint is used. Gemessen auf SQL Server 2025 mit 50.000 Kunden und 200.000 Bestellungen: Geschrieben als FROM pedidos p JOIN clientes c liest der Optimierer zuerst clientes; mit OPTION (FORCE ORDER) hält er sich an das Geschriebene und liest zuerst pedidos, und der Merge Join wird zu MANY-TO-MANY. Wie bei den anderen Engines: nur wenn du gemessen hast, dass der automatische Plan schlechter ist.

-- SQL Server: Die geschriebene Reihenfolge gilt
SELECT c.nombre, p.id
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.id
OPTION (FORCE ORDER);

SQL Server

CROSS APPLY und OUTER APPLY

Sie sind das LATERAL von PostgreSQL unter anderem Namen: Die rechte Tabelle ist eine Unterabfrage, die die Spalten der linken Zeile lesen kann. LATERAL gibt es in SQL Server nicht (Msg 156). CROSS APPLY behält nur die linken Zeilen, für die die Unterabfrage etwas liefert, wie ein INNER JOIN; OUTER APPLY behält alle, mit NULL, wie ein LEFT JOIN. Der typische Fall sind die ersten N jeder Gruppe. Gemessen auf SQL Server 2025 mit 5 Kunden, 2 davon mit Bestellungen: CROSS APPLY liefert 2 Zeilen und OUTER APPLY 5.

-- Die teuerste Bestellung jedes Kunden
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;

Der Plan ist ein Nested Loops, der die Unterabfrage einmal pro Kunde ausführt. Bei 50.000 Kunden und 200.000 Bestellungen, ohne passenden Index für die Unterabfrage, baut sich der Optimierer bei jeder Ausführung einen temporären: einen Index Spool über pedidos. Ihn im Plan zu sehen, ist das Zeichen, dass ein Index auf pedidos (cliente_id, …) fehlt.

Anti-Join und Semi-Join

Logische Muster, keine SQL-Schlüsselwörter:

- Semi-Join (es existiert mindestens ein Match) → EXISTS oder IN.
- Anti-Join (es existiert kein Match) → NOT EXISTS oder LEFT JOIN ... WHERE ... IS NULL.

-- Semi-Join: Kunden mit mindestens einer Bestellung
SELECT c.id, c.nombre
FROM clientes c
WHERE EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);

-- Anti-Join: Kunden ohne Bestellungen
SELECT c.id, c.nombre
FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);

NOT IN ist kein Anti-Join. Gibt die Unterabfrage auch nur ein NULL zurück, ist der Vergleich nie wahr und das Ergebnis sind null Zeilen. Gemessen mit 5 Kunden und 6 Bestellungen, davon 3 mit cliente_id auf NULL: NOT EXISTS und LEFT JOIN … IS NULL liefern 2, NOT IN liefert 0. Auf PostgreSQL 17.6, auf MySQL 8.0.46, auf SQLite 3.51 und auf SQL Server 2025 gleichermaßen.

Leistung

1. Die Spalten im ON müssen indiziert sein, besonders auf der Seite des "inneren Joins" (die für jede Zeile des äußeren durchsucht wird).
2. Filtere so viel wie möglich vor dem Join (WHERE in jeder Tabelle, wo anwendbar).
3. Vermeide JOIN über Ausdrücke (ON LOWER(a.cod) = LOWER(b.cod)) — der Index wird nicht genutzt.
4. EXPLAIN zeigt die Lesereihenfolge und die Methode, im Vokabular der jeweiligen Engine.

MySQL 5.7+MariaDB 10.5+Aurora

Nested Loop, Hash Join (MySQL ab 8.0.18; ab 8.0.20 ersetzt er den Block Nested Loop) und in MariaDB Block Nested Loop mit seiner Hash-Variante BNLH.

PostgreSQL 13+

Nested Loop, Hash Join und Merge Join, dazu ein eigener Knoten für einige Muster: Hash Full Join für den FULL OUTER JOIN und Hash Anti Join für das NOT EXISTS.

Der Anti-Join zeigt einen Unterschied, den man sonst nicht sieht: gemessen auf PostgreSQL 17.6 über 50 000 Kunden und 200 000 Bestellungen erzeugt NOT EXISTS einen Parallel Hash Anti Join, das äquivalente LEFT JOIN … WHERE p.id IS NULL dagegen einen Hash Right Join mit einem Filter dahinter — der Planer erkennt es also nicht als Anti-Join. Die Zeiten kamen gleich heraus, 14,98 ms und 13,22 ms, der Unterschied liegt also im Plan und nicht auf der Uhr: schreibe deine Abfrage deswegen nicht um, ohne deine eigene zu messen.

SQLite 3.35+

SQLite hat weder Hash Join noch Merge Join: alle Joins sind verschachtelte Schleifen, und das Einzige, was sich ändert, ist, ob die innere Tabelle ganz durchlaufen oder über einen Index gesucht wird. Den Plan liefert auch nicht EXPLAIN, das den Bytecode der virtuellen Maschine zurückgibt — 19 Zeilen mit addr, opcode, p1… für den einfachsten Join —, sondern EXPLAIN QUERY PLAN, mit drei Wörtern: SCAN (ganz durchlaufen), SEARCH … USING INDEX (gesucht) und USING COVERING INDEX (der Index bringt die Spalten schon mit, die Tabelle wird nicht angefasst).

Hier zeigt der Anti-Join nicht den Unterschied, den PostgreSQL zeigt. Gemessen auf SQLite 3.51 über 50 000 Kunden und 200 000 Bestellungen ergibt NOT EXISTS eine CORRELATED SCALAR SUBQUERY mit einem SEARCH … USING COVERING INDEX darin, 5,53 ms, und das gleichwertige LEFT JOIN … WHERE p.id IS NULL ergibt SEARCH … USING COVERING INDEX … LEFT-JOIN, 8,38 ms: beide über denselben Index, ganz ohne Sonderknoten.

-- So fragt man SQLite nach dem Plan, nicht mit bloßem EXPLAIN
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 und Hash Match, jeweils mit der logischen Operation in Klammern: (Inner Join), (Left Outer Join), (Full Outer Join), (Left Anti Semi Join). SQL Server hat kein EXPLAIN: Der Plan wird mit SET SHOWPLAN_XML ON angefordert, das die Abfrage nicht ausführt, und genau das tut Visual EXPLAIN in Calíope.

Hier zeigt der Anti-Join den Unterschied von PostgreSQL ebenfalls nicht, und aus dem umgekehrten Grund wie bei SQLite: Der Optimierer erkennt beide. Gemessen auf SQL Server 2025 mit 50.000 Kunden und 200.000 Bestellungen liefern NOT EXISTS und das gleichwertige LEFT JOIN … WHERE p.id IS NULL denselben Plan, einen Merge Join (Left Anti Semi Join) über den Index auf pedidos.cliente_id, und gleiche Zeiten: 21,6 und 19,8 ms im Mittel über fünf Durchläufe. Das EXISTS erscheint als Merge Join (Inner Join) mit einem vorgeschalteten Stream Aggregate, das vor dem Verbinden nur eine Bestellung pro Kunde übrig lässt.

Stichwörter: Joins, 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

Serverkonfiguration

Kritische Performance-Variablen: Buffer Pool, Verbindungen, maximales Paket, Query-Cache und wichtige Unterschiede zwischen MySQL und MariaDB.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Die Standardkonfiguration des Servers ist in Produktion fast nie die optimale. Dies sind die Variablen mit dem größten Einfluss auf Leistung und Stabilität.

innodb_buffer_pool_size

Der Hauptcache von InnoDB: Tabellen, Indizes und Daten. Es ist die wichtigste Variable.

- Faustregel: 60 %–80 % des RAM auf einem Server, der MySQL gewidmet ist.
- Empfohlenes Minimum: 1 GiB in Produktion.
- In MySQL 8.0+ und MariaDB 10.5+ lässt es sich im laufenden Betrieb ändern (ohne Neustart).

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

-- Im laufenden Betrieb ändern (8 GB)
SET GLOBAL innodb_buffer_pool_size = 8 * 1024 * 1024 * 1024;

max_connections

Maximale Anzahl gleichzeitiger Verbindungen. Standardmäßig 151, in MySQL wie in MariaDB.

- Jede Verbindung verbraucht Speicher (thread_stack + Puffer pro Sitzung, ~256 KiB).
- Ein zu hoher Wert verschlechtert die Performance unter Last (Contention).
- Miss mit SHOW STATUS LIKE 'Max_used_connections'. Wenn es die Obergrenze erreicht, erhöhe schrittweise.

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

max_allowed_packet

Maximale Größe eines Protokollpakets (ein großes INSERT, ein LOAD DATA, ein BLOB).

- Standardmäßig 64 MiB in MySQL 8.0, 16 MiB in älteren Versionen.
- Wenn eine Operation es überschreitet: Fehler Got a packet bigger than 'max_allowed_packet' bytes.
- Eine Erhöhung auf 256 MiB oder 1 GiB ist bei Lasten mit BLOBs üblich.

SET GLOBAL max_allowed_packet = 256 * 1024 * 1024;
-- Auch der Client muss den Parameter übergeben
-- (auf der Kommandozeile: --max-allowed-packet=256M)

Query Cache

Cacht das vollständige Ergebnis von SELECT-Queries.

MySQL 5.7+

Deprecated in MySQL 5.7, entfernt in MySQL 8.0. Wenn deine Last vom Query Cache profitierte, wird das heute an die Anwendung (Redis, Memcached) oder an materialisierte Views delegiert.

MariaDB 10.5+

In MariaDB weiterhin verfügbar, aber standardmäßig deaktiviert. Nur nützlich bei Lasten mit identischen, sich wiederholenden Queries auf Tabellen, die sich kaum ändern.

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

Logs und Dauerhaftigkeit

innodb_flush_log_at_trx_commit steuert, wann das Redo Log geschrieben wird:

- 1 (Standard) — schreibt und fsync bei jedem Commit. Maximale Dauerhaftigkeit, minimale Geschwindigkeit. Striktes ACID.
- 2 — schreibt bei jedem Commit, fsync jede Sekunde. Ein Absturz des Betriebssystems kann ~1s verlieren. Fast ACID.
- 0 — schreibt und fsync jede Sekunde. Ein Absturz von MySQL kann ~1s verlieren. Kein ACID.

Auf Replikaten oder in Umgebungen, in denen du begrenzten Verlust tolerierst, kann 2 den Throughput um das 3- bis 5-Fache steigern. Ändere es auf dem Primary nicht, ohne das Risiko zu verstehen.

thread_pool

MariaDB 10.5+

MariaDB enthält den Thread Pool nativ (thread_handling = pool-of-threads). Er senkt die Kosten des Thread-Erstellens bei Lasten mit vielen kurzen Verbindungen.

MySQL 5.7+MySQL 8.0+

In MySQL Community existiert er nicht; nur in der MySQL Enterprise Edition.

tmp_table_size / max_heap_table_size

Maximale Größe temporärer Tabellen im Speicher. Wenn eine Operation das Limit überschreitet, verlagert MySQL sie auf die Festplatte und verliert Geschwindigkeit. Halte beide Werte gleich.

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, die InnoDB für die Bereinigung schmutziger Pages und für Purges verbrauchen kann. Standardmäßig 200 / 2000 in MySQL 8.0 und in MariaDB, 10 000 / 20 000 im aktuellen MySQL (gemessen auf 26.7).

- Moderne SSDs: 2000 / 4000 oder mehr.
- HDD: belasse die Standardwerte.

Persistente Konfiguration

MySQL 8.0+

MySQL 8.0 erlaubt es, globale Änderungen ohne Bearbeiten von my.cnf dauerhaft zu speichern:

SET PERSIST innodb_buffer_pool_size = 8589934592;
SET PERSIST_ONLY max_connections = 500; -- gilt erst nach einem Neustart
RESET PERSIST innodb_buffer_pool_size;   -- Persistenz entfernen

In MariaDB erfolgt die Persistenz durch Bearbeiten von my.cnf (/etc/my.cnf.d/) und Neustart oder durch die Verwendung von Includes (!include).

Allgemeine Empfehlung

1. Kenne die Last, bevor du etwas anfasst. Ein OLTP mit intensiven Schreibvorgängen wird anders konfiguriert als ein leseorientiertes Data Warehouse.
2. Ändere eine Variable nach der anderen und miss die Auswirkung.
3. Dokumentiere jede Änderung in my.cnf mit einem Kommentar, der das Warum erklärt.
4. Kopiere keine Konfigurationen aus Blogs, ohne sie zu verstehen — die "optimalen" Werte hängen stark von Hardware und Last ab.

Aurora

Bei Amazon Aurora steht davon nichts in einer Datei. Es gibt kein my.cnf: die Konfiguration liegt in den Parameter Groups von Cluster und Instanz und wird über die AWS-Konsole oder die CLI angewendet. innodb_buffer_pool_size verwaltet AWS nach Instanzgröße — setz es nicht von Hand. Und ein SET GLOBAL hält nur bis zum nächsten Neustart: damit es bleibt, ändere es in der Parameter Group.

Stichwörter: Konfiguration, 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

Limits und Beschränkungen

Maximale Größen von Datenbanken, Tabellen und Spalten, Namenslängen und in Bezeichnern erlaubte Zeichen.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Die Limits der Engine zu kennen, erspart Überraschungen beim Wachsen. Dies sind die praktischen Obergrenzen in modernem MySQL und MariaDB.

Pro Datenbank

- Gesamtgröße: begrenzt durch das Dateisystem. Mit innodb_file_per_table = ON (Standard) ist jede Tabelle eine .ibd-Datei. Auf ext4 / XFS liegt die theoretische Grenze bei Exabytes — das echte Limit setzt dein Storage.
- Tabellen pro Datenbank: praktisch unbegrenzt. Der Katalog (information_schema, mysql.tables) verwaltet mehrere Hunderttausend problemlos. Lasten mit 10 000+ Tabellen erfordern eine Anpassung von table_open_cache.

Pro Tabelle

- Zeilen: theoretisch 2⁶⁴ Zeilen. Praktisch: Hunderte von Milliarden, wenn Schema und Indizes gut sind.
- Maximale Tabellengröße: 64 TiB mit der standardmäßigen INNODB_PAGE_SIZE (16 KiB).
- Spalten: maximal 4 096 pro Tabelle im Server und 1 017 in InnoDB, aber das echte Limit bestimmt die row size, nicht die Anzahl.
- Maximale Zeilengröße: 65 535 Bytes (ohne BLOB/TEXT, die außerhalb der Zeile gespeichert werden).
- Indizes pro Tabelle: 64.
- Spalten pro Index: 16 (B-tree InnoDB).
- Maximale Länge des Schlüssels eines Index: 3072 Bytes mit DYNAMIC/COMPRESSED (Standardformat in MySQL 5.7+ / MariaDB 10.2+).

-- Tabellengröße prüfen
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;

Pro Spalte

TypMaximale Größe
VARCHAR(N)65 535 Bytes (geteilt mit dem Rest der Zeile)
TEXT64 KiB
MEDIUMTEXT16 MiB
LONGTEXT4 GiB
BLOBwie das TEXT-Äquivalent
JSON4 GiB

Bezeichner (Objektnamen)

- Datenbanken, Tabellen, Spalten, Indizes, Views: 64 Zeichen.
- Spalten-Aliasse: 256 Zeichen.
- Funktionen, Prozeduren, Trigger, Events: 64 Zeichen.
- Constraints (FK, CHECK, UNIQUE): 64 Zeichen.

In Bezeichnern erlaubte Zeichen

- Ohne Backticks: ASCII-Buchstaben, Ziffern, _ und $. Sie dürfen nicht mit einer reinen Ziffer beginnen oder nur aus Ziffern bestehen.
- Mit Backticks (`nombre raro`): jedes Unicode-Zeichen außer U+0000 (NUL).

Empfohlene Konvention: snake_case in ASCII (pedido_cliente_id). Vermeide Leerzeichen, Akzente und Großbuchstaben — manche Systeme normalisieren sie zwischen Linux und macOS unterschiedlich.

-- Gültig, aber nicht empfehlenswert
CREATE TABLE `pedidos del año 2024` (`Número de Orden` INT);

-- Recomendado
CREATE TABLE pedidos_2024 (numero_orden INT);

Groß-/Kleinschreibung

lower_case_table_names:

- 0 — die Namen werden so gespeichert, wie sie erstellt wurden, und beachten Groß-/Kleinschreibung. Standard unter Linux.
- 1 — die Namen werden in Kleinbuchstaben gespeichert und Vergleiche ignorieren die Groß-/Kleinschreibung. Standard unter Windows.
- 2 — sie werden unverändert gespeichert, aber Vergleiche ignorieren die Groß-/Kleinschreibung. Nur macOS/Windows; Standard unter macOS.

Diesen Wert in einer bestehenden Installation zu ändern, ist destruktiv. Entscheide dich beim Initialisieren des Servers.

Pro Query

- Verschachtelte Subqueries: bis zu 64 Ebenen.
- UNION: theoretisch unbegrenzt, aber der Optimierer wird oberhalb von mehreren Hundert langsamer.
- Parameter in einem prepared statement: 65 535.
- Zeilen in einem IN(...): praktisch bis zu einigen Tausend; darüber hinaus besser JOIN mit einer temporären Tabelle.

Pro Sitzung

- Session-Variablen (@@SESSION.xxx): können fast jede Runtime-Global setzen.
- Benutzervariablen (@variable): bis zu 64 Zeichen im Namen.

Charset und Collation

- Empfohlenes Charset: utf8mb4 (vollständiges UTF-8, 4 Bytes). Der Alias utf8 ist historisch und auf 3 Bytes begrenzt (ohne Emojis).
- Empfohlene Collation in MySQL 8.0+: utf8mb4_0900_ai_ci (case-insensitive, akzent-insensitiv, basierend auf Unicode 9).
- In MariaDB: utf8mb4_unicode_ci oder 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;

Empfehlungen

1. Plane mit Reserve: Wenn du 10 Millionen Zeilen erwartest, dimensioniere Indizes und Partitionen für 100 M.
2. Nutze BIGINT UNSIGNED bei Primärschlüsseln von Tabellen, die wachsen können. INT ist bei ~2 Milliarden voll.
3. Definiere Charset und Collation explizit beim Erstellen von Datenbanken, Tabellen und Spalten. Das Erben vom Standard kann bei einer Migration fehlschlagen.
4. Dokumentiere die Limits deines Modells (erwartete Zeilen/Jahr, maximale Größe pro Spalte). Das hilft beim Capacity Planning und beim Erkennen anomaler Queries.

Stichwörter: Limits, Maximum, Größe, Spalten, Zeilen, Bezeichner, charset, collation, utf8mb4, lower_case_table_names, Index, row size

Bewährte Praktiken

Schema-Design, Namenskonventionen, Backups, Replikation, Benutzersicherheit, minimale GRANTs und Auditierung.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ SQL Server 2022+

Operative Empfehlungen, die eine Amateur-Datenbank von einer in Produktion wartbaren unterscheiden.

Schema-Design

MySQL 5.7+MariaDB 10.5+Aurora

1. Jede Tabelle hat einen Primärschlüssel. Falls kein natürlicher existiert, füge id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY hinzu.
2. Explizite Typen: Deklariere NOT NULL und DEFAULT, wann immer die Spalte es zulässt. NULL muss "nicht anwendbar" bedeuten, nicht "nicht ausgefüllt".
3. Pflicht-Foreign-Keys zwischen verwandten Tabellen. Du verlierst Mikrosekunden beim Schreiben, gewinnst aber unzerstörbare referenzielle Integrität.
4. Immer InnoDB. MyISAM unterstützt weder FKs noch Transaktionen; es bleibt nur Legacy-Systemen vorbehalten.

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. Jede Tabelle hat einen Primärschlüssel, mit Namen. Gibt es keinen natürlichen, id BIGINT IDENTITY(1, 1): AUTO_INCREMENT und UNSIGNED ergeben Msg 102. Teilen sich mehrere Tabellen eine Nummerierung, eine SEQUENCE mit DEFAULT NEXT VALUE FOR: Zwei Tabellen an derselben Sequenz bekamen 1, 2 und 3, ohne Wiederholung. Ein fehlgeschlagener oder zurückgerollter INSERT verbraucht seine IDENTITY-Nummer, Lücken sind also normal.
2. Explizite Typen: NOT NULL und DEFAULT, wo immer es geht, und Datumswerte in datetime2, nicht in datetime, das auf 1/300 s rundet (das erklärt das Thema „Datentypen (SQL Server)“). Ein ON UPDATE CURRENT_TIMESTAMP gibt es nicht (Msg 156): Das Änderungsdatum setzt die Anwendung oder ein Trigger.
3. Jede Einschränkung bekommt einen Namen (pk_, fk_, df_, ck_). Ohne ihn erfindet SQL Server bei jeder Erstellung einen anderen (CK__<Tabelle>__<Spalte>__<hex>), und ein DEFAULT ohne Namen verhindert später das DROP COLUMN (Msg 5074).
4. Jede Tabelle in ihrem Schema, nicht alle in dbo: Das Schema ist die Grenze der Berechtigungen (weiter unten unter „Benutzer und Berechtigungen“). Eine Tabelle ohne Schema landet im Standardschema dessen, der sie anlegt, und das ist für sa dbo.

-- Das Schema vorher, in einem eigenen Batch: 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);

Namenskonventionen

- Tabellen: snake_case, Plural, wenn sie Sammlungen darstellen (pedidos, clientes).
- Spalten: snake_case, ohne redundantes Präfix (nombre, nicht cliente_nombre innerhalb von clientes).
- Fremdschlüssel: <tabla>_id (cliente_id).
- Indizes: idx_<tabla>_<columnas> oder uq_<tabla>_<columnas> für eindeutige.
- Explizite Foreign Keys: fk_<tabla>_<tabla_destino>.
- Prozeduren / Funktionen: optionales Präfix sp_ / fn_, Verb im Infinitiv (fn_calcular_descuento).

Konsistenz > persönliche Vorliebe. Einigt euch im Team auf die Konvention und wende sie überall einheitlich an.

SQL Server

In SQL Server hat der Name, der zählt, zwei Teile, Schema.Objekt, und du schreibst immer beide, in der DDL wie in den Abfragen. Ein einteiliger Name wird gegen das Standardschema des jeweiligen Benutzers aufgelöst: SELECT … FROM pedidos war ventas.pedidos für einen Benutzer mit DEFAULT_SCHEMA = ventas, und für sa ergab es Msg 208, weil es dbo.pedidos suchte.

Nie das Präfix sp_ für deine Prozeduren: Es gehört den Systemprozeduren. Ein eigenes dbo.sp_who läuft nie: Sowohl EXEC sp_who als auch EXEC dbo.sp_who starteten die des Systems. Nimm ein anderes Präfix oder keines (ventas.calcular_descuento).

Backups

MySQL 5.7+MariaDB 10.5+Aurora

1. 3-2-1-Strategie: 3 Kopien, 2 verschiedene Medien, 1 außer Haus.
2. Typen:
- mysqldump — logisch, portabel, langsam bei der Wiederherstellung (~5–10 MB/s).
- mariabackup / xtrabackup — physisch, viel schneller, erfordert kurzes Anhalten der I/O.
- Filesystem-Snapshot (LVM, ZFS) — sofort, aber an das Dateisystem gebunden.
3. Wiederherstellung testen — ein Backup ohne getestete Wiederherstellung ist kein Backup.
4. Aufbewahrung: täglich 7 Tage + wöchentlich 4 + monatlich 12 ist ein vernünftiger Ausgangspunkt.

Calíope hat ein integriertes Backup-Modul: Hilfe › Backup.

-- Logischer Dump mit transaktionaler Konsistenz
-- (aus der Shell, nicht 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. 3-2-1-Strategie: 3 Kopien, 2 verschiedene Medien, 1 außer Haus.
2. Wiederherstellungsmodell FULL und Log-Backups in jeder Datenbank, in der der Verlust der heutigen Arbeit nicht akzeptabel ist: voll, differenziell und alle paar Minuten BACKUP LOG. In FULL ohne BACKUP LOG wächst das Log endlos; in SIMPLE kommst du nur zum letzten vollständigen Backup zurück.
3. Immer WITH CHECKSUM, danach RESTORE VERIFYONLY … WITH CHECKSUM: Ohne CHECKSUM im Backup ist diese Prüfung nicht möglich (Msg 3187). COMPRESSION – das Express nicht hat – brachte ein vollständiges Backup von 4 056 kB auf 647 kB.
4. Jedes einzelne Zwischen-Backup mit COPY_ONLY, sonst zählen die folgenden differenziellen ab ihm.
5. Die Wiederherstellung testen, unter anderem Namen, ab und zu: VERIFYONLY sagt, dass sich die Datei lesen lässt, nicht, dass die Datenbank zurückkommt.
6. Aufbewahrung: 7 tägliche + 4 wöchentliche + 12 monatliche als Ausgangspunkt, wobei ein Log-Backup nur zusammen mit dem vollständigen davor etwas nützt.

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;

Die Dateien bleiben auf der Platte des Servers. Das Backup von Calíope ist etwas anderes – ein Skript, um die Datenbank oder einen Teil davon auf eine andere Maschine mitzunehmen – und ersetzt diese nicht: Was jedes davon tut, erklärt das Thema „Sicherung und Wiederherstellung (SQL Server)“.

Replikation

MySQL 5.7+MariaDB 10.5+Aurora

- Asynchrone Replikation (Standard) — der Primary wartet nicht auf das Replikat. Risiko: Verlust der letzten Transaktionen, wenn der Primary ausfällt.
- Halbsynchrone Replikation — der Primary wartet auf die Bestätigung mindestens eines Replikats, bevor er dem Client bestätigt.
- Replikationsgruppe (MySQL InnoDB Cluster, MariaDB Galera) — Multi-Primary mit Konsens.

Bewährte Praktiken:

1. GTID aktiviert (gtid_mode = ON) — notwendig für automatisches Failover und für moderne Werkzeuge.
2. binlog_format = ROW — robuster als STATEMENT bei nichtdeterministischen Funktionen.
3. Dedizierte Replikation — ein Benutzer replica mit nur REPLICATION SLAVE, eingeschränkter IP.
4. Überwachter Lag — SHOW REPLICA STATUS (SHOW SLAVE STATUS in älteren Versionen), Alarm, wenn Seconds_Behind_Source > 30.

SQL Server

SQL Server repliziert mit eigenen Werkzeugen – Always-On-Verfügbarkeitsgruppen, Protokollversand (Log Shipping) und Transaktions- oder Mergereplikation –, und keines ähnelt dem Binlog: Es gibt weder GTID noch binlog_format einzustellen. Calíope konfiguriert und überwacht sie nicht: Replikations-Monitor und Topologie erscheinen in einer SQL-Server-Sitzung nicht.

Benutzer und Berechtigungen

Prinzip der minimalen Rechte: Jede Verbindung nutzt den am stärksten eingeschränkten Benutzer, der möglich ist.

MySQL 5.7+MariaDB 10.5+Aurora

-- Anwendungsbenutzer mit eingeschränkten Rechten anlegen
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.%.%';

-- NIE in Produktion
-- GRANT ALL PRIVILEGES ON *.* TO 'app'@'%';
FLUSH PRIVILEGES;

Regeln:

1. Ein Benutzer pro Anwendung / pro Funktion. Erleichtert die Auditierung.
2. Keine *.*-Rechte für Anwendungsbenutzer. Vergib pro Datenbank oder pro Tabelle.
3. Kein Anwendungszugriff über den Benutzer root. Er ist nur für administrative Aufgaben.
4. Rotiere Passwörter und nutze robuste Authentifizierung (caching_sha2_password in MySQL 8, ed25519 in MariaDB).
5. Schränke den Host ein ('app'@'10.0.%.%'), nutze nicht '%'.

SQL Server

Hier sind es zwei Dinge: Das Login kommt in den Server, und der Benutzer existiert in jeder Datenbank. Berechtigungen bekommt der Benutzer, und besser pro Schema als Tabelle für Tabelle: GRANT … ON SCHEMA::ventas erreicht auch die Tabellen, die später angelegt werden.

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;

-- NIEMALS in Produktion
-- ALTER SERVER ROLE sysadmin ADD MEMBER app_pedidos;
-- ALTER ROLE db_owner ADD MEMBER app_pedidos;

Regeln:

1. Ein Login pro Anwendung / pro Funktion, mit CHECK_POLICY = ON, das zum Beispiel ein Passwort ablehnt, das den Login-Namen enthält (Msg 33064).
2. Weder sysadmin noch db_owner für eine Anwendung. Ein DENY hält sysadmin nicht auf: Es kommt als dbo herein und durchläuft keine Prüfung.
3. sa nur zum Administrieren, und deaktiviert (ALTER LOGIN sa DISABLE), sobald es einen anderen Administrator gibt.
4. Die Anwendung legt keine Objekte an: Mit nur Datenberechtigungen auf ihrem Schema ergab ein eigenes CREATE TABLE Msg 262.
5. Beim Umzug einer Datenbank auf einen anderen Server das Login mit seiner ursprünglichen SID anlegen, sonst bleibt der Benutzer verwaist.

Alles dazu erklärt das Thema „Logins, Benutzer und Berechtigungen (SQL Server)“.

SQL Server

Abfragen, Transaktionen und Migrationen

1. Immer Parameter, nie zusammengesetzter Text. Eine IN-Liste aus Parametern hat eine Obergrenze: sp_executesql nahm 2 098 an (mit 2 099 Msg 180); für mehr eine temporäre Tabelle oder ein Tabellenwertparameter.
2. SET XACT_ABORT ON in jedem Batch und jeder Prozedur, die schreibt, und ein TRY…CATCH, das mit IF @@TRANCOUNT > 0 ROLLBACK; THROW; endet. Ohne es bricht ein 2627 nur seine eigene Anweisung ab, und das COMMIT bestätigt alles drumherum.
3. BEGIN TRAN nicht verschachteln: Das innere COMMIT bestätigt nichts, und das innere ROLLBACK macht alles rückgängig. Um nur einen Teil zurückzunehmen, SAVE TRANSACTION.
4. Keine offene Transaktion, die auf einen Menschen wartet: Eine schlafende Sitzung mit offener Transaktion hielt 73,8 MB Log fest. DBCC OPENTRAN findet sie.
5. Miss in logischen Lesevorgängen, nicht in Zeit, und misstraue der Prozedur, die mit manchen Werten gut und mit anderen schlecht läuft: Das ist Parameter-Sniffing. Kompiliert für einen Kunden mit 20 Zeilen, las die des Kunden mit 100 000 Zeilen 300 177 Seiten gegenüber den 6 085 des Scans. OPTION (RECOMPILE) heilt es, um den Preis einer Kompilierung bei jedem Aufruf; OPTIMIZE FOR UNKNOWN heilte es nicht.
6. Bei jeder Migration SET LOCK_TIMEOUT vor dem ALTER TABLE – ohne es wartete ein fremdes SELECT 6 s in der Schlange hinter der Änderung –, ein DEFAULT mit Namen und ein ALTER INDEX … REBUILD nach einem ALTER COLUMN, das die Tabelle neu schreibt, die bis dahin doppelt so viel Platz belegt.
7. Die Tabelle, die endlos wächst, wird partitioniert, bevor sie groß ist, mit ausgerichteten Indizes und immer einer leeren Partition am Ende: Ein Jahr mit SWITCH zu löschen dauerte unter 1 ms, gegenüber 180 ms und 21 MB Log mit DELETE.
8. Die Serverkonfiguration, mit Absicht: max server memory (MB) kommt ohne Obergrenze und bekommt immer eine, und vor einem RECONFIGURE schaust du auf SELECT … FROM sys.configurations WHERE value <> value_in_use, weil es alles Ausstehende installiert, auch das, was jemand anderes dort hinterlassen hat.

Jeder Punkt hat sein Thema mit „(SQL Server)“ im Titel: „Grenzen“, „Transaktionen und Isolationsstufen“, „Leistung und Diagnose“, „Schemaänderungen im laufenden Betrieb“, „Partitionierung“ und „Serverkonfiguration“.

Auditierung

MySQL 8.0+

MySQL Enterprise hat ein Audit-Plugin. Community Edition nicht — man behilft sich üblicherweise mit dem General Log (teuer in der Performance) oder mit externen Plugins.

MariaDB 10.5+

MariaDB enthält das server_audit-Plugin:

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 bringt SQL Server Audit mit: eine Serverüberwachung, die in eine Datei schreibt, und in jeder Datenbank eine Spezifikation, die festlegt, welche Aktionen auf welchen Objekten aufgezeichnet werden. Laut Dokumentation gibt es die Datenbankebene in allen Editionen seit 2016 SP1. Gelesen wird sie mit sys.fn_get_audit_file: Mit der unten kamen ein SELECT und ein UPDATE von app_pedidos als Zeilen SL und UP heraus, mit ihrem 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 führt für jede Verbindungssitzung ein lokales Log der ausgeführten Queries (SQL-Log), unabhängig vom Server-Log.

Minimale Checkliste für Produktion

MySQL 5.7+MariaDB 10.5+Aurora

1. ✅ Automatische Backups + monatlich verifizierte Wiederherstellung.
2. ✅ Replikation mit überwachtem Lag.
3. ✅ Anwendungsbenutzer ohne übermäßige Rechte.
4. ✅ TLS verpflichtend für externe Verbindungen.
5. ✅ Slow Query Log aktiviert (long_query_time = 1).
6. ✅ Überwachung des Festplattenplatzes (Alarm bei 80 %).
7. ✅ Sicherheitsupdates vierteljährlich eingespielt.

SQL Server

1. ✅ Modell FULL mit geplantem BACKUP LOG, WITH CHECKSUM, und jeden Monat eine getestete Wiederherstellung.
2. ✅ log_reuse_wait_desc überwacht: Es sagt, warum sich das Log nicht leert.
3. ✅ Anwendungs-Logins ohne sysadmin und db_owner, und sa deaktiviert.
4. ✅ TLS mit eigenem Zertifikat: Ohne es akzeptiert SQL Server 2022+ kein striktes TLS (TDS 8.0), und Calíope verbindet sich über das von TDS 7.x und sagt es.
5. ✅ Query Store eingeschaltet – bei neuen Datenbanken ist er es ab Werk – und max server memory (MB) begrenzt.
6. ✅ Überwachung des Speicherplatzes, auch für das Log und tempdb (Alarm bei 80 %).
7. ✅ Kumulative Updates (CU) nach Plan eingespielt.

Aurora

Bei Amazon Aurora wird die Replikation innerhalb des Clusters nicht konfiguriert. Die Leseknoten teilen sich das Volume mit dem Schreibknoten, also liegt kein Binlog dazwischen und es gibt kein Seconds_Behind_Source zu beobachten: die Verzögerung steht in information_schema.replica_host_status und liegt meist im Millisekundenbereich. binlog_format und GTID zählen nur, wenn du zusätzlich außerhalb des Clusters replizierst — zu einem anderen Cluster, zu RDS oder zu einem externen MySQL.

Stichwörter: bewährte Praktiken, Design, Konventionen, Backup, Replikation, GTID, binlog, GRANT, Auditierung, Sicherheit, InnoDB, Foreign Key, TLS, SQL Server, IDENTITY, SEQUENCE, Schema, Login, XACT_ABORT, SQL Server Audit

Performance und Optimierung

Analyse von Queries mit EXPLAIN, Slow Query Log, Identifikation von Engpässen, InnoDB-Cache und Nutzung von performance_schema.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Optimierung beginnt mit Messen. Ohne Daten ist Optimieren Raten. Dies sind die grundlegenden Werkzeuge.

EXPLAIN

Zeigt den Plan, den der Optimierer für eine Query gewählt hat. Es führt sie nicht aus — es ist sicher, es in Produktion abzusetzen.

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;

Wichtige Spalten:

- type — Zugriffsmethode. Von best bis schlechtest: system → const → eq_ref → ref → range → index → ALL. ALL = Full Table Scan = schlecht bei großen Tabellen.
- key — gewählter Index. NULL = kein Index genutzt.
- rows — Schätzung der geprüften Zeilen. Wenn sie viel größer ist als die zurückgegebenen Zeilen, gibt es Verbesserungsspielraum.
- Extra — nützliche Hinweise:
- Using index — Covering Index (super).
- Using where — Filter angewendet nach dem Lesen der Zeilen.
- Using temporary — benötigt eine temporäre Tabelle (teuer).
- Using filesort — Sortierung außerhalb des Index (teuer bei großen Tabellen).

EXPLAIN ANALYZE (MySQL 8.0.18+; in MariaDB einfach ANALYZE)

Führt die Query aus und zeigt die echten Zeiten pro Knoten. Teurer als EXPLAIN, aber viel informativer.

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 hat ein integriertes Visual EXPLAIN, das den Baum des Plans rendert: die Schaltfläche Visual EXPLAIN in der Leiste des SQL-Editors, auf der markierten Abfrage.

Slow Query Log

Protokolliert alle Queries, die länger als long_query_time Sekunden dauern.

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';

Analyse des Logs:

- mysqldumpslow — klassisches, mit MySQL mitgeliefertes Werkzeug.
- pt-query-digest (Percona Toolkit) — der De-facto-Standard, gruppiert nach Fingerprint und zeigt Statistiken.

performance_schema

Schema mit detaillierten Statistiken des Servers.

-- Top 10 der Queries nach kumulierter Gesamtzeit
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;

-- Tabellen mit dem meisten 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 hat außerdem die Variable userstat, die eingeschaltet Statistiken pro Benutzer, Index und Tabelle mit in manchen Fällen weniger Overhead als performance_schema hinzufügt.

InnoDB-Cache

- Buffer pool — Daten und Indizes. Schlüsselmetrik: hit ratio (1 − Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests). Ziel: >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;

-- Ohne performance_schema (in MariaDB standardmäßig aus) die beiden Zähler von Hand:
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';

- Adaptive hash index — automatischer Hash über heiße Seiten des Buffer Pools. In MySQL 8.0 standardmäßig an, im aktuellen MySQL und in MariaDB aus (gemessen: 1 auf 8.0.46; 0 auf 26.7 und auf MariaDB 13.0).
- Change buffer — puffert Änderungen an Seiten, die nicht im Buffer Pool vorhanden sind.

Häufige Engpässe

SymptomWahrscheinliche UrsacheAktion
CPU bei 100 %Queries ohne Index oder schlechte SchätzungenEXPLAIN, Slow Log
I/O bei 100 %Buffer Pool zu kleininnodb_buffer_pool_size erhöhen
Verbindungen am LimitConnection Leaks in der AppPool in der Anwendung prüfen
Threads_running hochLock ContentionSHOW ENGINE INNODB STATUS ansehen
tmp_disk_tables wächsttmp_table_size zu kleintmp_table_size erhöhen
Replication LagSingle-Thread oder lange Transaktionenslave_parallel_workers aktivieren

Query-Optimierungen — häufige Muster

1. Wähle nur, was du brauchst. Vermeide SELECT * in Anwendungen.
2. Vermeide Funktionen auf indizierten Spalten:
- Schlecht: WHERE YEAR(fecha) = 2024 → nutzt keinen Index.
- Gut: WHERE fecha >= '2024-01-01' AND fecha < '2025-01-01'.
3. LIMIT mit großem Offset ist teuer — für tiefe Paginierung nutze Keyset Pagination: WHERE id > :last_seen ORDER BY id LIMIT 50.
4. COUNT(*) bei großen Tabellen — InnoDB führt keinen Zähler. Erwäge Zusammenfassungsspalten oder Schätzungen (information_schema.tables.table_rows).
5. Nicht korrelierte Subqueries werden einmal ausgeführt; korrelierte einmal pro äußerer Zeile. Schreibe sie als JOIN um, wenn möglich.

Empfehlung

Erstelle ein grundlegendes Monitoring-Dashboard (Calíope hat eines: Dashboard) mit:

- Verbindungen (Threads_connected, Threads_running).
- Buffer Pool Hit Ratio.
- Langsame Queries pro Minute.
- Replication Lag.
- Festplattenplatz pro Tablespace.

Je früher du eine Verschlechterung erkennst, desto leichter lässt sie sich beheben.

Stichwörter: Performance, Optimierung, EXPLAIN, EXPLAIN ANALYZE, slow query log, performance_schema, buffer pool, filesort, covering index, filter, Engpass, pt-query-digest

Transaktionen und Isolationsstufen

ACID, COMMIT und ROLLBACK, die vier Isolationsstufen und welche Anomalie jede zulässt.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Eine Transaktion fasst mehrere Anweisungen zu einer Einheit zusammen, die entweder ganz oder gar nicht wirkt. In InnoDB läuft jede Anweisung in einer Transaktion: öffnest du keine, öffnet und bestätigt der Server eine pro Anweisung (autocommit = 1).

ACID
- Atomarität — entweder greifen alle Änderungen oder keine.
- Konsistenz — die Datenbank geht von einem gültigen Zustand in den nächsten; Constraints bleiben erfüllt.
- Isolation — nebenläufige Transaktionen sehen einander nie halbfertig.
- Dauerhaftigkeit — was bestätigt ist, übersteht einen Serverabsturz.

Manuelle Steuerung
COMMIT bestätigt, ROLLBACK verwirft alles seit START TRANSACTION.

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

Sicherungspunkte
Ein SAVEPOINT nimmt nur einen Teil zurück, ohne den Rest der Transaktion zu verlieren:

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;

Die vier Stufen

StufeDirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTEDjajaja
READ COMMITTEDneinjaja
REPEATABLE READneinneinnein (InnoDB)
SERIALIZABLEneinneinnein

InnoDBs Standard ist REPEATABLE READ. Dank MVCC und Gap Locks verhindert InnoDB auf dieser Stufe auch Phantom Reads — was der SQL-Standard gar nicht verlangt.

Stufe wechseln

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

SELECT @@transaction_isolation;

Vorsicht bei DDL
CREATE, ALTER, DROP und TRUNCATE lösen einen impliziten Commit aus: mit ROLLBACK ist da nichts mehr zu retten. Eine halb gelaufene Migration lässt die Tabelle so, wie sie gerade ist.

Lange Transaktionen
Eine offene Transaktion zwingt InnoDB, die alten Versionen jeder Zeile für konsistente Lesevorgänge aufzubewahren. Ein vergessenes START TRANSACTION lässt das Undo-Log wachsen und zieht den ganzen Server herunter. So findest du sie:

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

Empfehlung
Kurze Transaktionen, Geschäftslogik draußen, nur SQL drinnen. READ COMMITTED senkt die Sperren und ist das, was viele Webanwendungen nutzen; bleib bei REPEATABLE READ, wenn zwei Lesevorgänge innerhalb derselben Transaktion dasselbe liefern müssen.

Stichwörter: transaktion, commit, rollback, savepoint, isolation, acid, mvcc, read committed, repeatable read, serializable, autocommit, innodb_trx

Sperren und Deadlocks

Was InnoDB sperrt, warum ein Deadlock entsteht und wie man ihn diagnostiziert, statt zu raten.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

InnoDB sperrt Zeilen, keine Tabellen, und tut das beim Schreiben automatisch. Fast jedes Nebenläufigkeitsproblem erklärt sich daraus, welche Zeilen eine Abfrage am Ende gesperrt hat — und das hängt am Index, den sie benutzt hat.

Sperrarten
- Gemeinsam (S) — mehrere Transaktionen dürfen dieselbe Zeile gleichzeitig lesen.
- Exklusiv (X) — nimmt, wer schreibt; niemand sonst darf sie sperrend lesen oder schreiben.
- Gap — sperrt den Raum zwischen zwei Indexwerten, um Einfügungen zu verhindern. Nur in REPEATABLE READ und SERIALIZABLE.
- Next-Key — die Zeile plus die Lücke davor. Das ist InnoDBs Normalmodus beim Durchlaufen eines Index.
- Intention (IS/IX) — markiert auf Tabellenebene, dass innen Zeilensperren liegen; verhindert, dass ein LOCK TABLES dazwischenrutscht.

Die praktische Folge: nutzt die Abfrage keinen Index, durchläuft InnoDB die ganze Tabelle und sperrt jede geprüfte Zeile, nicht nur die passenden. Ein guter Index beschleunigt also nicht nur — er verkleinert auch, was gesperrt wird.

Sperrende Lesevorgänge
Ein normales SELECT sperrt nichts (es liest über MVCC eine konsistente Version). Musst du lesen und danach schreiben, ohne dass jemand dazwischenkommt, fordere die Sperre ausdrücklich mit FOR UPDATE oder FOR SHARE an:

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

Was ein Deadlock ist
Zwei Transaktionen, von denen jede auf eine Sperre der anderen wartet. Keine kommt weiter:

-- 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 erkennt das selbst und beendet die Transaktion, deren Rücknahme am billigsten ist; sie bekommt den Fehler 1213 Deadlock found when trying to get lock. Das ist weder ein Serverfehler noch Korruption, sondern das richtige Verhalten — die Anwendung muss diese Transaktion wiederholen.

Etwas anderes ist Fehler 1205 Lock wait timeout exceeded: da gibt es keinen Zyklus, nur ein Warten, das innodb_lock_wait_timeout überschritten hat (standardmäßig 50 s).

Diagnose
Der Abschnitt LATEST DETECTED DEADLOCK von SHOW ENGINE INNODB STATUS bewahrt den letzten Deadlock mit beiden Transaktionen und den beteiligten Anweisungen auf. Um die Sperren von jetzt zu sehen, hilft performance_schema unter MySQL 8.0+ (MariaDB hat kein data_locks, dort sind es information_schema.INNODB_LOCK_WAITS und INNODB_TRX):

SHOW ENGINE INNODB STATUS;

SELECT * FROM performance_schema.data_locks;

SELECT * FROM performance_schema.data_lock_waits;

SELECT @@innodb_lock_wait_timeout;

Wie man sie vermeidet
1. Immer in derselben Reihenfolge zugreifen — fasst aller Code Tabellen und Zeilen in gleicher Ordnung an, kann kein Zyklus entstehen.
2. Kurze Transaktionen — weniger Zeit mit gehaltenen Sperren, weniger Gelegenheit zum Zusammenstoß.
3. Indizieren, wonach gefiltert wird — verhindert das Sperren von Zeilen, die gar nicht passten.
4. Wiederholen — ein gelegentlicher Deadlock ist in einem nebenläufigen System normal; pack die Transaktion in eine Wiederholung mit wachsender Wartezeit.
5. Unnötige SELECT ... FOR UPDATE vermeiden — wer nicht schreiben will, soll auch nicht sperren.

Empfehlung
Bei Sperren zuerst den Abfrageplan ansehen: die meisten echten Deadlocks verschwinden, sobald der fehlende Index da ist. Die Prozessliste von Calíope zeigt dir, welche Sitzung wartet.

Stichwörter: sperre, lock, deadlock, gap lock, next-key, for update, for share, fehler 1213, innodb status, data_locks, lock wait timeout

Tabellen-Partitionierung

RANGE, LIST, HASH und KEY, Partition Pruning und sofortiges Aufräumen mit DROP PARTITION.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Partitionierung zerlegt eine Tabelle in mehrere physische Teile, die der Server weiterhin als eine einzige Tabelle sieht. Sie macht Abfragen nicht auf magische Weise schnell: was sie bringt, ist Partition Pruning und vor allem die Möglichkeit, Millionen Zeilen in einem Augenblick zu löschen.

Wann es sich lohnt
Der klare Fall ist eine Tabelle, die nach Datum wächst und aus der Altes weggeräumt wird: Logs, Ereignisse, Metriken, Audit. Dort ersetzt DROP PARTITION ein DELETE, das Stunden dauert.

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
);

Die vier Arten
- RANGE — nach Intervallen eines sortierbaren Werts, fast immer ein Datum. Die nützlichste.
- LIST — nach Zugehörigkeit zu einer festen Menge von Werten.
- HASH — gleichmäßige Verteilung über einen ganzzahligen Ausdruck; gut zum Verteilen von Schreibzugriffen, nicht zum Pruning.
- KEY — wie HASH, aber mit der internen Funktion des Servers; nimmt auch nicht-ganzzahlige Spalten.

Die Varianten RANGE COLUMNS und LIST COLUMNS nehmen mehrere Spalten und nicht-ganzzahlige Typen, ohne sie in eine Funktion zu packen:

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')
);

Partition Pruning
Der eigentliche Gewinn: filtert das WHERE über die Partitionsspalte, liest der Server nur die Partitionen, die Treffer enthalten können. Prüf das in der Spalte partitions von EXPLAIN — tauchen alle auf, prunst du gar nichts und die Partitionierung kostet dich nur.

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';

Grenzen, die man vorher kennen sollte
1. Der Partitionierungsschlüssel muss Teil jedes eindeutigen Schlüssels sein, den Primärschlüssel eingeschlossen. Darum trägt das Beispiel PRIMARY KEY (id, ocurrido) und nicht nur id.
2. Keine Fremdschlüssel: eine partitionierte Tabelle kann weder einen FOREIGN KEY haben noch Ziel eines solchen sein.
3. Höchstens 8192 Partitionen je Tabelle, und jede verbraucht Dateideskriptoren.
4. Abfragen, die nicht über den Schlüssel filtern, fassen alle Partitionen an und werden langsamer als ohne Partitionierung.
5. Indizes sind lokal zu jeder Partition: einen globalen Index gibt es nicht.

Wartung
Die Partition der nächsten Periode anlegen und die älteste fallen lassen ist die normale Routine. DROP PARTITION läuft praktisch sofort und gibt den Platz wirklich frei, was ein massenhaftes DELETE nicht tut:

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;

Empfehlung
Partitioniere nur dann nach Datum, wenn du auch nach Datum aufräumst, und lege künftige Partitionen im Voraus an (oder per geplantem Event): passt eine Zeile in keinen Bereich, scheitert das INSERT. Lass immer eine pmax als Netz stehen.

Stichwörter: partitionierung, partition, range, list, hash, key, pruning, drop partition, reorganize, information_schema.partitions, aufräumen

CTEs und Fensterfunktionen

WITH, WITH RECURSIVE und OVER (): das moderne SQL, das verschachtelte Unterabfragen und Temp-Tabellen erspart.

Gilt für: MySQL 8.0+ MariaDB 10.2+ Aurora 3+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

CTEs (WITH) und Fensterfunktionen (OVER ()) kamen mit MySQL 8.0 und MariaDB 10.2; in PostgreSQL gibt es keine noch unterstützte Version ohne sie, und in SQLite liegen beide weit unter der Untergrenze dieses Handbuchs: CTEs seit 3.8.3 und Fenster seit 3.25. Sie lösen in einer lesbaren Abfrage, wofür früher verschachtelte Unterabfragen, temporäre Tabellen oder Sitzungsvariablen nötig waren.

CTE: einem Zwischenschritt einen Namen geben
Eine CTE ist ein benanntes Ergebnis, das nur während der Abfrage lebt. Damit zerlegst du eine lange Abfrage in Schritte und beziehst dich zweimal auf dasselbe Teilergebnis, ohne es zu wiederholen:

MySQL 5.7+MariaDB 10.5+Aurora

Der Monat kommt aus 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 gibt es in PostgreSQL nicht: es antwortet 42883, function date_format(date, unknown) does not exist. Das Äquivalent ist to_char, und zum Gruppieren nach Monat passt date_trunc meist besser, weil es ein Datum statt eines Textes liefert. Nach dem Ausgabe-Alias zu gruppieren funktioniert, genau wie in 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 gibt es in SQLite ebenfalls nicht: gemessen auf 3.51 antwortet es no such function: DATE_FORMAT. Das Gegenstück ist strftime, mit denselben Codes wie %Y-%m. Nach dem Alias der Ausgabe zu gruppieren funktioniert genauso wie bei den anderen beiden Engines.

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 gibt es auch in SQL Server nicht: Gemessen auf 2025 antwortet es 'DATE_FORMAT' is not a recognized built-in function name (Fehler 195). Den Monat liefert FORMAT(fecha, 'yyyy-MM') oder seit 2022 DATETRUNC(month, fecha), das ein Datum zurückgibt. Und hier gruppiert man nicht nach dem Alias der Ausgabe: GROUP BY vendedor_id, mes antwortet Invalid column name 'mes' (Fehler 207), also wird der Ausdruck wiederholt:

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;

Rekursive CTE: Hierarchien
WITH RECURSIVE läuft durch Baumstrukturen — Organigramme, verschachtelte Kategorien, Stücklisten — ganz ohne Schleifen in der Anwendung. Der erste Zweig ist der Basisfall, der zweite wiederholt sich, bis er keine Zeilen mehr liefert:

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+

In PostgreSQL ist RECURSIVE nicht optional, und der Fehler beim Vergessen führt in die Irre: gemessen auf 17.6 antwortet dieselbe Abfrage ohne RECURSIVE mit 42P01, genau dem Fehler, den man bekäme, wenn arbol eine nicht existierende Tabelle wäre.

SQLite 3.35+

In SQLite ist es umgekehrt: RECURSIVE ist optional. Gemessen auf 3.51 liefert dieselbe Abfrage als WITH arbol AS (…) genau dieselben Zeilen wie mit WITH RECURSIVE. Es trotzdem zu schreiben kostet ein Wort und hält die Abfrage für PostgreSQL bereit, das es verlangt; SQL Server dagegen lehnt es ab.

SQL Server

In SQL Server wird RECURSIVE nicht geschrieben: Gemessen auf 2025 ist WITH RECURSIVE arbol AS (…) ein Syntaxfehler (Incorrect syntax near 'arbol', Fehler 102), und WITH arbol AS (…) liefert den Baum. Außerdem bricht die Rekursion nach 100 Ebenen ab (Fehler 530): Für mehr Tiefe OPTION (MAXRECURSION n) am Ende der Abfrage, und 0 hebt die Grenze auf.

Fensterfunktionen: rechnen ohne zu gruppieren
Ein GROUP BY faltet Zeilen zusammen; eine Fensterfunktion rechnet über eine Menge verwandter Zeilen und behält jede Zeile. Genau das macht laufende Summe, gleitenden Durchschnitt oder Platz innerhalb der Gruppe in einem Durchgang möglich:

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;

Die gebräuchlichsten
- ROW_NUMBER() — laufende Nummer innerhalb der Partition, ohne Gleichstand.
- RANK() / DENSE_RANK() — Platz mit Gleichstand; RANK lässt Lücken, DENSE_RANK nicht.
- LAG() / LEAD() — der Wert der vorigen oder nächsten Zeile, ohne Self-Join.
- FIRST_VALUE() / LAST_VALUE() — die Enden des Fensters.
- NTILE(n) — verteilt die Zeilen auf n Eimer, für Quartile und Perzentile.
- Aggregate mit OVER — SUM, AVG, COUNT, MIN, MAX, ohne die Zeilen zusammenzufalten.

Der Rahmen (ROWS BETWEEN ...) legt fest, welche Zeilen in die Berechnung jeder Zeile eingehen. Standardmäßig läuft ein Aggregat mit ORDER BY vom Anfang der Partition bis zur aktuellen Zeile — genau das ergibt die laufende Summe.

PostgreSQL 13+

PostgreSQL bringt außerdem Bausteine mit, die MySQL 8.0.46 noch nicht hat, auf beiden gemessen:

- FILTER (WHERE …) — bedingt ein Aggregat, ohne ein CASE hineinzustopfen: count(*) FILTER (WHERE total > 1000). In MySQL ist das ein Syntaxfehler.
- GROUPS-Rahmen und die EXCLUDE-Klausel, zusätzlich zu ROWS und RANGE. MySQL 8.0.46 antwortet This version of MySQL doesn't yet support 'GROUPS', Fehler 1235.
- DISTINCT ON — eine Zeile je Gruppe, die erste laut ORDER BY, ohne ROW_NUMBER() und ohne CTE. Das hat PostgreSQL und sonst niemand.

Das benannte Fenster — OVER w … WINDOW w AS (…) — gibt es in beiden und erspart, die Definition in jeder Spalte zu wiederholen.

SQLite 3.35+

Von diesen Stücken, die PostgreSQL hat und MySQL nicht, hat SQLite fast alle. Gemessen auf 3.51:

- FILTER (WHERE …) — funktioniert über Aggregaten: count(*) FILTER (WHERE total > 1000).
- GROUPS-Rahmen und die EXCLUDE-Klausel — beide funktionieren, zusätzlich zu ROWS und RANGE.
- Benanntes Fenster — OVER w … WINDOW w AS (…) funktioniert wie bei den anderen beiden.
- DISTINCT ON — gibt es nicht: es ist ein Syntaxfehler. Eine Zeile pro Gruppe holt man mit ROW_NUMBER(), dem Muster gleich hier unten.

SQL Server

SQL Server bleibt näher an MySQL als an PostgreSQL. Gemessen auf 2025:

- FILTER (WHERE …) — gibt es nicht: Syntaxfehler. Man schreibt COUNT(CASE WHEN total > 1000 THEN 1 END).
- GROUPS-Rahmen und EXCLUDE-Klausel — ebenfalls nicht. Und RANGE erlaubt nur UNBOUNDED und CURRENT ROW: RANGE BETWEEN 100 PRECEDING AND CURRENT ROW antwortet mit Fehler 4194.
- Benanntes Fenster — OVER w … WINDOW w AS (…) funktioniert seit 2022.
- DISTINCT ON — gibt es nicht. Eine Zeile pro Gruppe liefert ROW_NUMBER(), das Muster weiter unten.

Das Muster mit dem größten Gewinn: Top N je Gruppe
Die drei meistverkauften Produkte je Kategorie ohne Fenster zu holen, verlangt eine korrelierte Unterabfrage pro Zeile. Mit ROW_NUMBER() geht es direkt:

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;

Performance
Umsonst ist keins von beiden: das Fenster muss innerhalb jeder Partition sortieren, also erspart ein Index, der die Zeilen schon in der Reihenfolge von PARTITION BY plus ORDER BY liefert, genau diese Sortierung. Prüf das mit EXPLAIN, bevor du die hübsche Fassung für gut erklärst.

MySQL 5.7+MariaDB 10.5+Aurora

Diese Sortierung ist der filesort im EXPLAIN. Und Vorsicht bei CTEs: in MySQL 8.0 kann der Optimierer sie in einer temporären Tabelle materialisieren, was manchmal schlechter ausfällt als die entsprechende Unterabfrage.

PostgreSQL 13+

Bei CTEs ist es umgekehrt, und deshalb lässt sich der MySQL-Rat nicht übertragen: seit PostgreSQL 12 wird eine genau einmal verwendete CTE in die Abfrage eingebettet. Gemessen auf 17.6 über 20 000 Zeilen lässt WITH v AS (SELECT * FROM ventas) SELECT * FROM v WHERE vendedor_id = 3 keinen CTE Scan im Plan übrig und nutzt den Index: 0,297 ms. Dieselbe mit AS MATERIALIZED zeichnet den CTE Scan über einem Seq Scan der ganzen Tabelle und steigt auf 1,662 ms. Wird die CTE zweimal oder öfter referenziert, materialisiert sie sich von selbst; und vor der 12 war sie immer eine Schranke für den Optimierer.

SQLite 3.35+

In SQLite erscheint diese Sortierung im Plan als USE TEMP B-TREE FOR ORDER BY, und mit einem Index, der das PARTITION BY schon liefert, sinkt sie auf USE TEMP B-TREE FOR LAST TERM OF ORDER BY. Das ganze Fenster wird innerhalb einer CO-ROUTINE aufgelöst.

Mit CTEs macht es dasselbe wie PostgreSQL, und einen Schritt mehr. Gemessen auf 3.51 über 20 000 Zeilen hinterlässt WITH v AS (SELECT * FROM ventas) SELECT * FROM v WHERE vendedor_id = 3 kein MATERIALIZE im Plan und nutzt den Index: 0,108 ms. Dieselbe mit AS MATERIALIZED — das SQLite seit 3.35 versteht, ebenso wie AS NOT MATERIALIZED — zeichnet das MATERIALIZE über einem SCAN der ganzen Tabelle und steigt auf 2,019 ms. Und hier kommt der Schritt mehr: sie zweimal zu referenzieren materialisiert sie ebenfalls nicht, anders als in PostgreSQL. Sie wird weiter eingeebnet, mit einem indizierten SEARCH pro Zweig, 0,203 ms.

SQL Server

Hier wird eine CTE nie materialisiert, und das wirkt in beide Richtungen: Einmal verwendet, wird sie wie in PostgreSQL in die Abfrage eingefaltet, aber zweimal referenziert wird sie zweimal berechnet. Gemessen auf 2025 über 20.000 Zeilen hinterlässt eine CTE mit GROUP BY, die mit sich selbst verknüpft wird, im Plan zwei Index Scan und zwei Stream Aggregate, einen pro Referenz. Es gibt kein AS MATERIALIZED: Ist das Ergebnis teuer, speichert man es vorher in einer temporären Tabelle (#t).

Empfehlung
Nimm CTEs, damit die Abfrage lesbar bleibt, und Fenster, um nicht in der Anwendung zu tun, was der Server in einem Durchgang erledigt. Läuft dein Server auf MySQL 5.7 oder MariaDB 10.1, gibt es beides nicht: dort regieren weiterhin die Unterabfragen.

Stichwörter: cte, with, with recursive, fensterfunktion, over, partition by, row_number, rank, dense_rank, lag, lead, ntile, frame, hierarchie, top n je gruppe

Transaktionen und Isolationsstufen (PostgreSQL)

Warum ein Fehler die ganze Transaktion blockiert, wie ein Savepoint dich herausholt, was jede Stufe wirklich tut, und DDL, das sich zurückrollen lässt.

Gilt für: PostgreSQL 13+

Eine Transaktion fasst mehrere Anweisungen zu einer Einheit zusammen, die ganz oder gar nicht angewendet wird. Ohne explizites BEGIN bestätigt PostgreSQL jede Anweisung für sich.

Die erste Überraschung, wenn man von MySQL kommt
Ein Fehler bricht die gesamte Transaktion ab. Ab da antwortet jede Anweisung dasselbe — current transaction is aborted, commands ignored until end of transaction block, SQLSTATE 25P02 — bis du ROLLBACK machst. Das ist kein Fehler der Anwendung: es ist das Prinzip, und es verhindert, dass eine Transaktion auf einem Zustand weiterläuft, der nicht mehr der ist, den du dachtest.

Der Ausweg ist ein Savepoint
SAVEPOINT markiert einen Punkt zum Zurückkehren, und ROLLBACK TO SAVEPOINT rettet die Transaktion, ohne das Vorherige zu verlieren:

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

DDL lässt sich zurückrollen
CREATE, ALTER und DROP laufen innerhalb der Transaktion: hier gibt es kein implizites Commit. Eine Migration, die auf halbem Weg scheitert, hinterlässt keine halbe Tabelle.

BEGIN;
ALTER TABLE cuentas ADD COLUMN moneda text;
ROLLBACK;   -- die Spalte hat nie existiert

Die vier Stufen

StufeDirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTEDneinjaja
READ COMMITTEDneinjaja
REPEATABLE READneinneinnein
SERIALIZABLEneinneinnein

READ UNCOMMITTED wird akzeptiert und auch so gemeldet, verhält sich aber wie READ COMMITTED: In PostgreSQL gibt es auf keiner Stufe Dirty Reads. Die Voreinstellung ist READ COMMITTED.

REPEATABLE READ und SERIALIZABLE blockieren nicht: sie brechen ab
Statt zu warten, endet die Transaktion, die sich nicht serialisieren lässt, mit SQLSTATE 40001 (could not serialize access…). Das heißt, die Anwendung muss wiederholen: auf diesen beiden Stufen ist ein 40001 normaler Betrieb, kein Ausfall. SERIALIZABLE verwendet SSI, das Lese-/Schreib-Abhängigkeiten erkennt und keine zusätzlichen Sperren nimmt.

Sperren und Deadlocks
Ein Deadlock wird nach deadlock_timeout erkannt — 1 s in der Voreinstellung — und der Server beendet eine der beiden mit SQLSTATE 40P01. Um nicht zu warten oder um Arbeit unter Konsumenten aufzuteilen:

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

FOR UPDATE NOWAIT scheitert sofort mit 55P03, statt zu warten. Achtung: dieser Fehler bricht die Transaktion ebenfalls ab.

Lange Transaktionen
Hier lässt eine offene Transaktion kein Undo-Log anwachsen: sie hindert VACUUM daran, tote Zeilenversionen aufzuräumen — serverweit —, und die Tabelle wächst ohne neue Zeilen. So findest du sie:

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 steht per Voreinstellung auf 0, also ohne Grenze; ihm einen Wert zu geben ist das Netz, das verhindert, dass eine vergessene Sitzung die ganze Datenbank ausbremst.

Empfehlung
Kurze Transaktionen, mit der Geschäftslogik außerhalb. Wenn du auf REPEATABLE READ oder SERIALIZABLE hochgehst, schreib den Wiederholungsversuch vorher, nicht nach dem ersten 40001 in Produktion.

Stichwörter: Transaktion, commit, rollback, savepoint, Isolation, mvcc, read committed, repeatable read, serializable, ssi, 25P02, 40001, 40P01, Deadlock, skip locked, pg_stat_activity

VACUUM, Autovacuum und der Platz, der nicht zurückkommt

Warum eine Tabelle ohne neue Zeilen wächst, was jede Operation wirklich aufräumt, wann die Zähler lügen und was Wraparound ist.

Gilt für: PostgreSQL 13+

In PostgreSQL ändert ein Update eine Zeile nicht: es schreibt eine neue Version und lässt die alte tot zurück. Löschen gibt ebenfalls nichts sofort frei. So funktioniert MVCC hier, und VACUUM räumt hinterher auf. Dazu gibt es in InnoDB keine Entsprechung, und es steckt hinter fast jeder Überraschung bei der Größe.

Wie das aussieht
Eine Tabelle mit 50 000 Zeilen belegte 12 MB. Ein einziges UPDATE über alle Zeilen ließ sie bei 23 MB zurück, ohne eine einzige Zeile hinzuzufügen: die 50 000 alten Versionen stehen weiter in der Datei. Gemessen auf PostgreSQL 17.6.

Was jede Sache tut
- VACUUM markiert den toten Platz als wiederverwendbar. Es gibt den Platz nicht an das Betriebssystem zurück: nach dem Vacuum belegte die Beispieltabelle weiterhin 23 MB, nur passen die nächsten Schreibvorgänge jetzt hinein.
- VACUUM FULL schreibt die ganze Tabelle neu und gibt den Platz tatsächlich zurück — sie fiel auf 11 MB —, nimmt aber eine ACCESS EXCLUSIVE-Sperre: währenddessen liest und schreibt niemand, und es braucht Platz für eine vollständige Kopie. Das ist keine Routinewartung, sondern das letzte Mittel.
- ANALYZE räumt nichts auf: es frischt die Planer-Statistiken auf.

Autovacuum, das schon an ist
autovacuum kommt mit on. Eine Tabelle kommt in die Warteschlange, wenn ihre toten Zeilen autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × Zeilen überschreiten, also 50 + 20 % mit den Voreinstellungen. Bei einer Tabelle mit zehn Millionen Zeilen sind das zwei Millionen tote Zeilen, bevor sich etwas rührt: bei großen, viel aktualisierten Tabellen senkt man den Faktor pro Tabelle:

ALTER TABLE pedidos SET (autovacuum_vacuum_scale_factor = 0.02);

Prüfen, ob es läuft

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;

Vorsicht mit diesem Zähler: er ist eine Schätzung und nicht sofort aktuell. Gemessen auf 17.6 stand er direkt nach dem Aktualisieren von 50 000 Zeilen noch auf 0; erst nach einem ANALYZE zeigte er 50 000, und nach dem VACUUM ging er wieder auf 0. Wenn du gerade viel geschrieben hast und die Zahl sich nicht bewegt, heißt das nicht, dass keine Arbeit ansteht.

Einem laufenden Vacuum folgt man so:

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

Der Feind: die offene Transaktion
VACUUM kann nur aufräumen, was niemand mehr sehen kann. Eine offene Transaktion — oder ein Replikat mit hot_standby_feedback — friert diesen Horizont ein, und dann läuft das Vacuum, meldet Erfolg und gibt nichts frei. Deshalb lässt eine in idle in transaction vergessene Sitzung Tabellen wachsen, die sie nicht einmal anfasst.

Der Wraparound, der wirklich ein Notfall ist
Transaktions-IDs sind 32 Bit groß und werden wiederverwendet. Damit keine Zeile in der Zukunft landet, friert Vacuum die alten ein. autovacuum_freeze_max_age steht per Voreinstellung auf 200 000 000: jenseits dieses Alters startet der Server ein Autovacuum, das nicht aufgeschoben werden kann, und wenn es trotzdem ausgeht, nimmt er keine Schreibvorgänge mehr an. Überwacht wird das so:

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

Solange dieses Alter weit unter zweihundert Millionen bleibt, ist nichts zu tun.

Empfehlung
Schalte Autovacuum nicht ab. Wenn eine Tabelle ohne neue Zeilen wächst, ist die Reihenfolge des Verdachts: eine offene Transaktion, ein für ihre Größe zu hoher scale_factor, und erst am Ende VACUUM FULL — mit Wartungsfenster, weil es die ganze Tabelle sperrt.

Stichwörter: vacuum, autovacuum, bloat, mvcc, n_dead_tup, vacuum full, einfrieren, wraparound, pg_stat_user_tables, pg_stat_progress_vacuum, datfrozenxid, hot_standby_feedback, idle in transaction

Sperren und Verklemmungen (PostgreSQL)

Was jede Anweisung wirklich sperrt, warum ein ALTER TABLE die SELECTs anhalten kann, wie man sieht, wer auf wen wartet, und was bei einem 40P01 zu tun ist.

Gilt für: PostgreSQL 13+

In PostgreSQL leben Sperren an zwei verschiedenen Orten, und sie zu verwechseln ist der Grund, warum ein Problem unauffindbar wird. Tabellensperren stehen in pg_locks; Zeilensperren stecken in der Zeile selbst, in ihrem Kopf, und deshalb brauchen sie keinen Speicher, eskalieren nie zu einer Tabellensperre und tauchen in pg_locks nicht auf. Eine Million gesperrter Zeilen kostet nicht mehr als eine einzige.

Und eine Regel kennt keine Ausnahme: ein normales SELECT wartet nie auf eine Zeile. Es liest seine Version über MVCC. Das Einzige, was ein SELECT aufhalten kann, ist eine Tabellensperre.

Wer welchen Tabellenmodus nimmt

AnweisungModus
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

Die ersten drei stören einander nicht, und deshalb blockiert die normale Last nie. Der letzte steht mit allen im Konflikt, auch mit SELECT.

Die Falle: ein wartendes ALTER TABLE staut alles hinter sich
Dieses ACCESS EXCLUSIVE drängelt sich nicht vor: es stellt sich an. Und während es wartet, wartet alles, was danach kommt, hinter ihm, sogar ein SELECT, das mit der Anweisung davor kein Problem gehabt hätte. Eine einzige offene Transaktion, die nur ein SELECT ausgeführt hat, genügt, damit ein ALTER TABLE die Tabelle für alle stilllegt, ohne mit der Arbeit begonnen zu haben. Deshalb wird DDL in der Produktion mit einer Obergrenze abgesetzt und wiederholt:

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

Vier Zeilenmodi, nicht zwei
Von stark nach schwach: FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE. Nur drei Paare vertragen sich —die beiden geteilten untereinander und FOR NO KEY UPDATE mit FOR KEY SHARE—; FOR UPDATE kollidiert mit allen vieren. Genau dieses ungewöhnliche Paar ist das wichtige: ein UPDATE, das den Schlüssel nicht anfasst, nimmt FOR NO KEY UPDATE und blockiert damit nicht die Prüfung eines Fremdschlüssels, der auf diese Zeile zeigt — denn die verlangt FOR KEY SHARE.

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

Hier wird ewig gewartet
lock_timeout steht standardmäßig auf 0, also ohne Grenze: es gibt kein Gegenstück zu MySQLs innodb_lock_wait_timeout, das nach 50 s abschneidet. Es zu setzen —pro Sitzung, vor einer riskanten Anweisung oder in der Konfiguration— verwandelt ein endloses Warten in einen Fehler, den die Anwendung wiederholen kann. Wenn er auslöst: SQLSTATE 55P03.

Absichtlich nicht warten

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

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

NOWAIT scheitert sofort mit 55P03 —und dieser Fehler bricht, wie jeder andere, die ganze Transaktion ab—. SKIP LOCKED scheitert nicht: es liefert weniger Zeilen. Mit Zeile 3 durch eine andere Sitzung gesperrt lieferte die zweite Abfrage 1, 2, 4 und 5. So verteilt man eine Arbeitswarteschlange auf mehrere Verbraucher, ohne dass sie sich in die Quere kommen oder aufeinander warten.

Die Verklemmung

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

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

Der Server tötet eine der beiden mit SQLSTATE 40P01 („deadlock detected“), und das Detail nennt Prozess und Zeile. Er erkennt es aber nicht sofort: er sucht den Zyklus erst, wenn ein Warten deadlock_timeout überschreitet — standardmäßig 1 s —, und das Opfer braucht diese Sekunde zum Sterben. InnoDB erkennt es augenblicklich; hier kostet eine Verklemmung eine Sekunde Wartezeit. deadlock_timeout zu senken ist nicht umsonst: diese Arbeit fällt auch bei den normalen Wartezeiten an, und die sind die Mehrheit.

Ein 40P01 ist kein Serverfehler: es ist das korrekte Verhalten, und die Anwendung muss diese Transaktion wiederholen.

Diagnose
Ein SHOW ENGINE INNODB STATUS gibt es hier nicht. Es gibt zwei Abfragen, und beide müssen laufen, solange die Sperre besteht:

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 liefert die Liste der Prozesse, die halten, was der andere verlangt — genau die Frage, die man wirklich hat. Was du in pg_locks nicht sehen wirst, sind die Zeilensperren: wer auf eine Zeile wartet, erscheint wartend auf eine transactionid, die Nummer der Transaktion, die sie hält. Und um eine Spur des bereits Geschehenen zu hinterlassen, schreibt log_lock_waits jedes Warten oberhalb von deadlock_timeout ins Serverprotokoll.

Die Prozessliste von Calíope zeigt dieses Warten in der Statusspalte: eine blockierte Sitzung erscheint als Lock: transactionid.

Beratende Sperren
Keine Tabelle, keine Daten: eine Zahl, die der Server für dich verwahrt, damit zwei Prozesse deiner Anwendung nicht gleichzeitig dasselbe tun.

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

Solange eine Sitzung die 42 hält, gibt pg_try_advisory_lock(42) aus einer anderen false zurück, statt zu warten. Achtung bei der Reichweite: die auf Sitzungsebene überleben das COMMIT und werden nur durch Freigeben oder Schließen der Verbindung gelöst; pg_advisory_xact_lock löst sich am Ende der Transaktion von selbst, was fast immer das Gewünschte ist.

SERIALIZABLE blockiert nicht
Auf dieser Stufe erscheinen in pg_locks Sperren vom Typ SIReadLock. Sie blockieren niemanden: sie sind die Markierung dessen, was die Transaktion gelesen hat, und der Konflikt kommt beim Bestätigen als 40001, nicht als Warten.

Empfehlung
Zeilen immer in derselben Reihenfolge anfassen und Transaktionen kurz halten, wie bei jeder Engine. Eigen ist hier zweierlei: lock_timeout vor jedem DDL gesetzt, weil der Wartende alle hinter sich aufstaut; und der Wiederholungsversuch vor dem Produktivgang geschrieben, das Einzige, was ein 40P01 harmlos macht.

Stichwörter: Sperre, lock, Verklemmung, deadlock, 40p01, 55p03, pg_locks, pg_blocking_pids, lock_timeout, deadlock_timeout, access exclusive, for update, for key share, skip locked, nowait, beratende Sperre, advisory

Indizes (PostgreSQL)

Die sechs Indexmethoden und wann welche passt, partielle und Ausdrucksindizes, warum ein Index Only Scan manchmal doch zur Tabelle geht, und wie man die findet, die niemand nutzt.

Gilt für: PostgreSQL 13+

Ein Index beschleunigt Suchen auf Kosten von Speicherplatz und Arbeit bei jedem Schreibvorgang. Was sich beim Umstieg von MySQL ändert, ist nicht dieser Gedanke, sondern dass es hier sechs Methoden gibt statt einer mit Ausnahmen — und dass fast alles, was in MySQL eine Option des Index ist —das Präfix, die Unsichtbarkeit—, hier etwas anderes ist.

Die sechs Methoden

MethodeWofür
B-BaumDer gewohnte: Gleichheit, Bereiche, ORDER BY, LIKE 'abc%'. Im Zweifel dieser.
HashNur Gleichheit. Seit PostgreSQL 10 wird er repliziert und übersteht einen Absturz.
GiSTGeometrie, Bereiche, nächster Nachbar. Er ist die Grundlage von PostGIS.
SP-GiSTUngleich verteilte Daten: nicht überlappende Bereiche, Text nach Präfixen.
GINViele Werte innerhalb eines Feldes: jsonb, Arrays, Volltextsuche.
BRINRiesige Tabellen, deren physische Reihenfolge dem Wert folgt: Einfügedaten, Zeitreihen.

Die Größen erklären die Wahl besser als die Theorie. Auf einer Tabelle mit 200 000 Zeilen und 22 MB, mit einem Zeitstempel, der mit dem Einfügen wächst:

- B-Baum auf dieser Spalte: 4 408 kB.
- BRIN auf derselben Spalte: 24 kB.

BRIN speichert nicht die Zeilen, sondern Minimum und Maximum jedes Blocks; er hilft also nur, wenn die physische Reihenfolge der Wertereihenfolge ähnelt — und wenn er hilft, kostet er fast nichts. In derselben Tabelle belegte ein Hash auf der Kundenspalte 7 032 kB und der B-Baum auf dieser Spalte 1 400 kB: kleiner, und zusätzlich für Bereiche und Sortierung brauchbar. Deshalb ist der B-Baum die Standardantwort und der Hash ein Sonderfall.

Partielle Indizes: die halbe Idee, ein Zehntel der Größe
Ein Index darf ein WHERE tragen und indiziert dann nur die passenden Zeilen. Wenn du die Warteschlange der offenen Vorgänge abfragst und diese 5 % ausmachen, indizierst du die 5 %:

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

Auf derselben Tabelle gemessen: der vollständige Index der Spalte belegte 1 400 kB, der partielle 88 kB. Das WHERE der Abfrage muss das des Index implizieren, sonst nutzt der Planer ihn nicht.

Hier gibt es keine Präfixindizes, sondern Ausdrucksindizes
CREATE INDEX … ON paginas (url(64)) ist keine gültige Syntax; der Server liest url(64) als Funktionsaufruf und antwortet 42883 function url(integer) does not exist. Das Äquivalent ist, den Ausdruck zu indizieren:

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

Und das Kleingedruckte: der Index greift nur, wenn die Abfrage den Ausdruck genauso schreibt. WHERE lower(email) = 'ana@ejemplo.com' nutzt ihn; WHERE email ILIKE 'Ana@%' nicht — und frisst die ganze Tabelle.

Abdeckende Indizes, und warum sie manchmal nicht abdecken
INCLUDE fügt Spalten hinzu, die im Index gespeichert werden, ihn aber nicht sortieren:

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

Damit zeigt EXPLAIN einen Index Only Scan. Aber „only" ist nur ein halbes Versprechen: PostgreSQL kann dem Index nicht ansehen, ob eine Zeile für deine Transaktion sichtbar ist, und fragt deshalb die Sichtbarkeitskarte, die VACUUM pflegt. Gemessen: unmittelbar nach der Aktualisierung von tausend Zeilen sagte derselbe Plan Heap Fetches: 2; nach einem VACUUM Heap Fetches: 0. Ein abdeckender Index auf einer Tabelle, die beschrieben und nicht aufgeräumt wird, geht trotzdem zur Tabelle.

Die Regel des linken Präfixes ist nicht strikt
In MySQL nützt ein Index auf (A, B) nichts für WHERE B = ?. Hier kann er nützen: gemessen löste eine Abfrage, die nur nach der zweiten Spalte filterte, mit einem Index Only Scan über den zusammengesetzten Index auf. Das ist weder Magie noch ein Ersatz für den richtigen Index —er wird ganz durchlaufen statt hinabgestiegen—, aber wenn der Index viel kleiner ist als die Tabelle, lohnt es sich weiterhin. Praktische Folge: bevor du den „fehlenden" Index anlegst, schau in den Plan; vielleicht wird längst einer genutzt.

GIN für das, was in einem Feld steckt

CREATE INDEX idx_datos ON eventos USING gin (datos);

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

Ohne den Index ist diese Abfrage ein sequentieller Scan; mit ihm ein Bitmap Index Scan. Der GIN im Test belegte 864 kB bei 200 000 Zeilen. Für jsonb belegt jsonb_path_ops weniger, wenn du nur mit @> abfragst: 640 kB gegenüber den 864 des normalen GIN, bei denselben Daten.

Bauen, ohne die Tabelle anzuhalten
Ein gewöhnliches CREATE INDEX nimmt eine SHARE-Sperre: es lässt lesen und hält die Schreibvorgänge an. CREATE INDEX CONCURRENTLY nimmt SHARE UPDATE EXCLUSIVE und hält nichts an — im Tausch gegen zwei Durchläufe der Tabelle und drei Regeln:

CREATE INDEX CONCURRENTLY idx_pedidos_cliente ON pedidos (cliente_id);

-- Ist einer halbfertig liegen geblieben?
SELECT indexrelid::regclass AS indice, indisvalid
  FROM pg_index WHERE NOT indisvalid;

1. Es lässt sich nicht innerhalb einer Transaktion starten — 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
2. Scheitert es, bleibt ein ungültiger Index zurück: niemand nutzt ihn, aber er wird bei jedem Schreibvorgang gepflegt. Man muss ihn mit der obigen Abfrage finden und löschen.
3. Seit PostgreSQL 12 gibt es REINDEX INDEX CONCURRENTLY — so baut man einen aufgeblähten Index neu, ohne die Tabelle anzuhalten.

Die Indizes, die niemand nutzt

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;

Zwei Vorsichtsmaßnahmen vor dem Löschen. Erstens: der Zähler ist nicht sofort da. Im Test ließen drei Abfragen, die den Index nutzten, ihn im Moment und eine Sekunde später auf 0; erst nach drei Sekunden sagte er 3. Zweitens: er zählt seit dem letzten pg_stat_reset(), und auf einem Replikat zählt er die Arbeit des Replikats — ein Index, den nur der Monatsbericht nutzt, wirkt an den übrigen 29 Tagen tot.

Empfehlung
Jeder zusätzliche Index wird bei jedem INSERT und bei jedem UPDATE seiner Spalten bezahlt. Die Reihenfolge, die funktioniert: in den Plan schauen, den Index mit CONCURRENTLY anlegen, erneut in den Plan schauen und einen Monat später pg_stat_user_indexes durchsehen. Ein Index, den niemand nutzt, ist nicht neutral: er kostet Schreibleistung, Platz und VACUUM-Zeit.

Stichwörter: Index, btree, brin, gin, gist, spgist, hash, partieller Index, Ausdrucksindex, include, index only scan, heap fetches, concurrently, reindex, pg_stat_user_indexes, indisvalid, jsonb_path_ops, Sichtbarkeitskarte

Leistung und Diagnose (PostgreSQL)

Einen Plan lesen und Schätzung mit Messung vergleichen, erweiterte Statistiken für einander bedingende Spalten, warum work_mem pro Operation gilt und was die Cache-Trefferquote wirklich misst.

Gilt für: PostgreSQL 13+

Diagnose heißt hier: einen Plan lesen und zwei Zahlen vergleichen. Alles andere —Indizes, Speicher, Statistiken— folgt aus diesem Vergleich.

EXPLAIN führt nicht aus; EXPLAIN ANALYZE schon
Die erste Form fragt nur den Plan ab. Die zweite führt die Abfrage wirklich aus, um sie zu messen, und das schließt ein UPDATE oder ein DELETE ein. Wenn die Anweisung schreibt, klammer sie ein:

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

Die zwei Zahlen, auf die es ankommt
Jeder Knoten trägt eine Schätzung und eine Messung: rows=… ist das, was der Planer glaubte, actual rows=… das, was herauskam. Wenn beide weit auseinanderliegen, ist der schlechte Plan die Folge, nicht die Ursache.

Ein gemessenes Beispiel mit zwei Spalten, die einander bedingen —Stadt und Provinz—:

- Ohne Hilfe schätzte der Planer 11 710 Zeilen, herausgekommen sind 60 000: er multiplizierte beide Wahrscheinlichkeiten, als wären sie unabhängig.
- Mit einer erweiterten Statistik wurde die Schätzung zu 59 610.

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

Das ist das Werkzeug, das MySQL nicht hat, und es behebt die ganze Familie „der Plan ignoriert meinen Index": glaubt der Server, 4 % der Tabelle zu lesen, während er 20 % liest, wählt er aus guten Gründen falsch.

BUFFERS, das man anfordern muss

EXPLAIN (ANALYZE, BUFFERS) SELECT … ;

shared hit sind Blöcke, die im Speicher lagen; shared read die, die geholt werden mussten. Und temp read/written ist der eigentliche Verräter: die Abfrage ist auf die Platte ausgewichen. Außerdem ist track_io_timing standardmäßig aus, die E/A-Zeiten erscheinen also erst, wenn man es einschaltet.

work_mem gilt pro Operation, nicht pro Verbindung
Das ist die Einstellung, die am meisten überrascht und am häufigsten falsch gesetzt wird. Jede Sortierung, jeder Hash-Join und jede Hash-Aggregation darf bis zu work_mem verwenden, und eine Abfrage mit drei solchen Operationen —oder mit zwei parallelen Arbeitern— verwendet ein Vielfaches. Der Werkswert ist 4 MB.

Gemessen an 300 000 Zeilen, dieselbe Abfrage mit ORDER BY:

- Mit work_mem = 64kB: Sort Method: external merge Disk: 15680kB, und der Sortierknoten brauchte ~144 ms.
- Mit work_mem = 64MB: Sort Method: quicksort Memory: 29627kB, und er brauchte ~70 ms.

Die Zahl, die die Wahrheit sagt, ist Sort Method. work_mem global zu erhöhen multipliziert sich über Verbindungen und Operationen; klug ist, es in der Sitzung zu erhöhen, die es braucht:

SET work_mem = '64MB';

Welche Abfrage am meisten kostet: pg_stat_statements
Das Gegenstück zum Protokoll langsamer Abfragen, aber aggregiert: eine Zeile je Abfrageform, mit Aufrufen, Gesamtzeit und Zeilen.
In Calíope lässt sich dasselbe ohne diese Abfrage lesen: das Werkzeug Langsame Abfragen zeigt diese Zusammenfassung, unterscheidet eine fehlende Erweiterung von einer nicht geladenen Bibliothek und schickt jede Zeile in den 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;

Das Kleingedruckte: CREATE EXTENSION genügt nicht. Sie muss in shared_preload_libraries eingetragen werden, und der Server muss neu starten; legt man nur die Erweiterung an, antwortet die erste Abfrage 55000 pg_stat_statements must be loaded via "shared_preload_libraries". Sortier nach total_exec_time, nicht nach mean_exec_time: die Abfrage, die den Nachmittag frisst, ist meist eine schnelle, die millionenfach läuft.

Die Trefferquote des Caches

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();

Das ist die Zahl, die das Dashboard von Calíope als „Datencache" zeigt. Sie misst, wie viele Lesevorgänge ohne Gang zum Dateisystem bedient wurden seit dem letzten Zurücksetzen der Statistiken —nicht, wie viel Speicher belegt ist—, und bei einer kleinen Datenbank fällt sie per Definition sehr hoch aus: im Test 99,85 %. Ein dauerhaft niedriger Wert bedeutet tatsächlich, dass shared_buffers zu klein ist; ein hoher beweist nicht, dass alles in Ordnung ist.

Parallelität
max_parallel_workers_per_gather steht standardmäßig auf 2, und wenn der Planer davon Gebrauch macht, erscheint ein Gather- oder Gather Merge-Knoten. Jeder Arbeiter hat sein eigenes work_mem — die andere Hälfte der obigen Falle.

Empfehlung
Die Reihenfolge, die funktioniert: die Abfrage mit pg_stat_statements finden, sie mit EXPLAIN (ANALYZE, BUFFERS) ansehen, rows mit actual rows vergleichen und erst dann entscheiden, ob ein Index, Statistiken oder Speicher fehlen. An shared_buffers zu drehen, bevor man einen Plan gelesen hat, ist der lange Weg.

Stichwörter: Leistung, explain, analyze, buffers, Plan, Planer, Schätzung, create statistics, erweiterte Statistik, work_mem, sort method, external merge, pg_stat_statements, shared_preload_libraries, pg_stat_database, Cache, track_io_timing, Parallelität

Serverkonfiguration (PostgreSQL)

Wo jeder Parameter steht und welcher gewinnt, was einen Neustart verlangt, warum effective_cache_size keinen Speicher reserviert und warum man max_connections nicht erhöht.

Gilt für: PostgreSQL 13+

PostgreSQL hat 378 Parameter —auf diesem Server gezählt—, und die gute Nachricht ist: man fasst eine Handvoll an. Zuerst zu lernen ist nicht, welche, sondern wo sie geschrieben werden und wann sie greifen.

Vier Orte, und der Server sagt, welcher gewonnen hat
- postgresql.conf — die gewohnte Datei, von Hand bearbeitet.
- postgresql.auto.conf — von ALTER SYSTEM geschrieben und nicht von Hand zu bearbeiten; das sagt sie selbst in ihrer ersten Zeile.
- Pro Datenbank oder pro Rolle — ALTER DATABASE … SET, ALTER ROLE … SET.
- Pro Sitzung — SET, gültig, solange die Verbindung besteht.

Wer gewonnen hat, muss man nicht raten: die Spalte source von pg_settings sagt es. Gemessen: nach ALTER DATABASE demo SET work_mem = '32MB' las eine neue Verbindung 32MB mit source = database; nach dem RESET 4MB mit 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');

Drei Klassen von Parametern, und die schmerzhafte
Die Spalte context sagt, was zum Ändern nötig ist:
- user / superuser — ein SET in der Sitzung genügt (work_mem, effective_cache_size).
- sighup — ein Neuladen ist nötig (checkpoint_timeout, max_wal_size, fast das ganze Autovacuum).
- postmaster — der Server muss neu starten. Auf diesem Server sind das 65 von 378, darunter shared_buffers, max_connections, wal_level, shared_preload_libraries und autovacuum_max_workers.

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

-- Wartet etwas auf einen Neustart?
SELECT name, setting FROM pg_settings WHERE pending_restart;

Ein gemessenes Detail, das einen Schreck erspart: pending_restart leuchtet nicht im selben Augenblick des Neuladens auf. Unmittelbar danach stand es noch auf false, eine halbe Sekunde später auf true. Man fragt es später ab, nicht im selben Atemzug.

shared_buffers und effective_cache_size sind nicht dasselbe, und eines von beiden reserviert nichts
- shared_buffers ist echter Speicher: der eigene Cache des Servers. Ab Werk 128 MB, wenig für einen dedizierten Server; die übliche Regel sind 25 % des RAM.
- effective_cache_size reserviert nichts. Es ist das, was der Planer zwischen PostgreSQLs Cache und dem des Betriebssystems vermutet, und dient nur der Entscheidung, ob sich ein Index lohnt. Es zu ändern bewegt kein Byte: es ändert Pläne.

Die beiden zu verwechseln führt dazu, effective_cache_size zu erhöhen in der Hoffnung auf mehr Cache — oder shared_buffers in der Hoffnung auf einen anderen Plan.

max_connections erhöht man nicht: man stellt einen Pool davor
Ab Werk 100, und hier ist jede Verbindung ein Prozess des Betriebssystems, kein Thread. Auf tausend zu gehen ist keine größere Zahl: es sind tausend Prozesse, mit ihrem Speicher und ihrem work_mem pro Operation. Die Antwort ist ein Verbindungspooler (pgBouncer und ähnliche). Und er gehört zu denen, die einen Neustart verlangen.

WAL und Prüfpunkte
max_wal_size (ab Werk 1 GB) und checkpoint_timeout (5 min) entscheiden, wie oft alles auf die Platte geschrieben wird. Lösen die Prüfpunkte nach Größe statt nach Zeit aus, schreibt der Server stoßweise; man sieht es, indem man log_checkpoints einschaltet, und behebt es, indem man max_wal_size erhöht. checkpoint_completion_target steht bereits auf 0,9, was dieses Schreiben über die Zeit verteilt, statt es zu bündeln.

Autovacuum
Hier gelten autovacuum_naptime 60 s, autovacuum_max_workers 3 —dieser verlangt einen Neustart— und autovacuum_vacuum_scale_factor 0,2: eine Tabelle wird also aufgeräumt, wenn 20 % ihrer Zeilen sich geändert haben. Bei einer Tabelle mit einer Milliarde Zeilen heißt das, auf zweihundert Millionen tote Versionen zu warten; große Tabellen bekommen deshalb ihre eigene Einstellung:

ALTER TABLE eventos SET (autovacuum_vacuum_scale_factor = 0.01);

synchronous_commit, der Einzige, der das Versprechen ändert
Ihn auszuschalten bedeutet, dass COMMIT nicht wartet, bis das WAL die Platte erreicht: man gewinnt Latenz und riskiert bei einem Stromausfall die letzten Transaktionen —nicht die Integrität der Datenbank, nur die zuletzt bestätigten—. Er ist user, kann also nur dort ausgeschaltet werden, wo dieser Handel akzeptiert wird:

SET synchronous_commit = off;

Empfehlung
Wenige anfassen, einzeln und messend. ALTER SYSTEM statt Dateien zu bearbeiten —es wird festgehalten und mit ALTER SYSTEM RESET zurückgenommen—, und pro Datenbank oder Rolle vor global: eine Einstellung, die nur der Nachtbericht braucht, muss nicht den ganzen Tag bezahlt werden.

Stichwörter: Konfiguration, 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

Rollen, Rechte und Sicherheit (PostgreSQL)

Warum das Konto den Host nicht enthält, Rollen als Benutzer und Gruppe zugleich, die Falle, dass Gewähren die Tabellen von morgen nicht erreicht, und warum Zeilensicherheit eingeschaltet und wirkungslos sein kann.

Gilt für: PostgreSQL 13+

Der grundlegende Unterschied zu MySQL ist, dass hier das Konto den Host nicht enthält. Es gibt kein ana@192.168.1.%: es gibt die Rolle ana, und von wo sie sich verbinden darf und wie sie sich ausweist, entscheidet eine eigene Datei, pg_hba.conf.

pg_hba.conf: die erste passende Zeile gewinnt
Sie wird von oben nach unten gelesen und dort endet die Suche. Und man muss sie nicht öffnen, um sie zu sehen:

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

Die Spalte error sagt, ob eine Zeile fehlerhaft ist —das erspart den Neustart, bei dem der Server nicht wiederkommt—. Änderungen greifen mit SELECT pg_reload_conf(), ohne Neustart. Auf dem Testserver gab es sieben Regeln, die für 127.0.0.1 mit trust und die letzte, für alles Übrige, mit scram-sha-256: die Reihenfolge ist die Richtlinie.

Eine Rolle ist Benutzer und Gruppe zugleich
Es gibt nicht zwei Begriffe: CREATE USER ist genau CREATE ROLE … LOGIN. Was eine Person von einer Gruppe unterscheidet, ist das Attribut LOGIN — sonst nichts.

CREATE ROLE app_ro;                       -- ohne LOGIN: dient als Gruppe
CREATE ROLE ana LOGIN PASSWORD 'secreta';
GRANT app_ro TO ana;                      -- ana erbt die Rechte von app_ro

Rollen erben standardmäßig, ana nutzt die Privilegien von app_ro also ohne Zutun. Mit NOINHERIT müssen sie per SET ROLE angefordert werden — das nimmt man, wenn der Schritt ausdrücklich sein soll.

Passwörter werden mit scram-sha-256 gespeichert, dem Standard seit PostgreSQL 14 —der Testserver bestätigt es—; md5 existiert noch und sollte nicht mehr verwendet werden.

Die eigentliche Falle: Gewähren reicht nicht in die Zukunft
GRANT … ON ALL TABLES IN SCHEMA gewährt auf den heute vorhandenen Tabellen. Gemessen: nach der Gewährung konnte app_ro die bestehende Tabelle lesen und nicht die eine Minute später angelegte. Was die Zukunft abdeckt, ist eine andere Anweisung:

GRANT USAGE ON SCHEMA public TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro;          -- die von heute
ALTER DEFAULT PRIVILEGES IN SCHEMA public
      GRANT SELECT ON TABLES TO app_ro;                          -- die von morgen

Und im Kleingedruckten: Standardprivilegien gehören dem, der sie erklärt, nicht dem Schema; werden die Tabellen von einer anderen Rolle angelegt, müssen sie auch mit FOR ROLE erklärt werden. Was erklärt wurde, steht in pg_default_acl.

Gewähren hinterlässt außerdem eine Spur, die rückgängig gemacht werden muss: nach einem ON ALL TABLES scheitert das Löschen der Rolle mit DependentObjectsStillExist und der Liste der Tabellen, auf denen ein Privileg blieb —im Test tauchten sogar die von PostGIS auf—. Das Gegenstück ist REVOKE oder DROP OWNED BY rolle vor dem DROP ROLE.

Das Schema public gehört nicht mehr allen
Seit PostgreSQL 15 behält PUBLIC USAGE auf dem Schema public, hat aber kein CREATE mehr. Gemessen auf 17.6: eine frisch angelegte Rolle liefert USAGE = true und CREATE = false. Wer Skripte aus einer älteren Version mitbringt, sieht das erste CREATE TABLE eines Benutzers scheitern, der es früher durfte.

Vordefinierte Rollen: überwachen ohne Superuser
Der Server bringt fünfzehn fertige Rollen mit. Die, die einen zusätzlichen Superuser ersparen:
- pg_read_all_data, pg_write_all_data — alles lesen oder schreiben, ohne weitere Machtbefugnisse.
- pg_monitor — die vollständigen Statistiksichten sehen; enthält pg_read_all_stats und pg_read_all_settings.
- pg_signal_backend — Abfragen abbrechen und fremde Sitzungen schließen.
- pg_maintain (PostgreSQL 17+) — VACUUM, ANALYZE, REINDEX, ohne Eigentümer zu sein.

Sicherheit auf Zeilenebene
Eine Richtlinie filtert, welche Zeilen jede Rolle innerhalb derselben Tabelle sieht:

ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;

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

Und hier ist, was man messen muss, bevor man sich darauf verlässt. Mit derselben Richtlinie und derselben Tabelle:

- Die Rolle, auf die die Richtlinie zielt, sah eine Zeile. Richtig.
- Der Eigentümer der Tabelle —kein Superuser— sah beide: der Eigentümer unterliegt seinen eigenen Richtlinien erst, wenn ALTER TABLE … FORCE ROW LEVEL SECURITY erklärt ist. Mit FORCE sah er eine.
- Der Superuser sah beide, sogar mit FORCE. Superuser und Rollen mit BYPASSRLS umgehen Richtlinien immer.

Anders gesagt: eine Anwendung, die sich als Eigentümer der Tabellen verbindet —von einem Superuser ganz zu schweigen—, hat die Zeilensicherheit eingeschaltet und wirkungslos. Die Prüfung besteht darin, sich mit der echten Rolle zu verbinden und Zeilen zu zählen.

Empfehlung
Eine Rolle je Anwendung, kein LOGIN für Gruppen und keine mit SUPERUSER außer der Verwaltungsrolle. ALTER DEFAULT PRIVILEGES im selben Commit wie das GRANT, sonst hält die Berechtigung bis zur nächsten Tabelle. Und was pauschal gewährt wird, notieren: das DROP ROLE in einem Jahr wird danach fragen.

Stichwörter: Sicherheit, Rolle, Benutzer, Gruppe, 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

Sicherung und Wiederherstellung (PostgreSQL)

Logisch gegen physisch und wofür jede taugt, was pg_dump auslässt und niemanden mehr hineinlässt, warum PITR nicht ab Werk funktioniert und welcher Slot die Platte füllen kann.

Gilt für: PostgreSQL 13+

Es gibt zwei Arten von Sicherung, und sie taugen nicht für dasselbe. Die falsche Wahl entdeckt man am Tag der Wiederherstellung.

Logisch (pg_dump)Physisch (pg_basebackup)
Was sie kopiertAnweisungen, die die Daten wiederaufbauenDie Dateien des Clusters, wie sie sind
EinheitEine Datenbank, sogar eine TabelleDas ganze Cluster, alle Datenbanken
Stellt wieder her aufAndere Version, Maschine, SystemDieselbe Hauptversion
Gut fürMigrieren, eine Tabelle verschieben, lesenDen Server retten, und für PITR

Logische Sicherung

-- auf der Kommandozeile, nicht im SQL-Editor:
-- pg_dump -d demo -Fc -f demo.dump
-- pg_restore -d demo_nueva -j 4 demo.dump

Das Format -Fc (custom) ist die richtige Voreinstellung: bei derselben Datenbank belegte der Textauszug 3,1 MB und der custom-Auszug 905 kB, und er bringt zudem ein Inhaltsverzeichnis mit —pg_restore -l listete die dreißig Datenblöcke—, sodass sich eine Tabelle wiederherstellen lässt, und das parallel mit -j.

Was pg_dump nicht mitnimmt, und genau das beißt
Rollen und clusterweite Einstellungen sind nicht darin. Gemessen: der Datenbankauszug enthielt kein einziges CREATE ROLE, während pg_dumpall --globals-only die beiden vorhandenen erzeugte. Nur den Auszug zurückzuspielen hinterlässt eine perfekte Datenbank, in die niemand hineinkommt. Eine vollständige logische Sicherung sind zwei Dateien:
- pg_dumpall --globals-only — Rollen, Passwörter und Rechte des Clusters.
- pg_dump jeder Datenbank.

pg_dump ist konsistent —es arbeitet auf einem Snapshot— und blockiert keine Schreiber; aber es nimmt eine ACCESS SHARE-Sperre, sodass ein gleichzeitig gestartetes ALTER TABLE zu warten beginnt und sich alles dahinter staut.

Physische Sicherung
pg_basebackup kopiert das gesamte Cluster. Auf dem Testserver gemessen: 84 MB in 1,3 s, mit -X stream, das auch das während der Kopie erzeugte WAL mitbringt —ohne das ist die Kopie nicht wiederherstellbar—. Es hinterlässt ein backup_label, das sagt, ab welchem Punkt im WAL nachgespielt werden muss:

START WAL LOCATION: 0/13000028 (file 000000010000000000000013)

PITR: bis zu einem Zeitpunkt wiederherstellen
Das ist der Daseinszweck der physischen Sicherung, und es funktioniert nicht ab Werk: archive_mode kommt ausgeschaltet, auf diesem Server gemessen. Ohne Archivierung stellt eine physische Sicherung genau den Moment wieder her, in dem sie genommen wurde, und keine Sekunde mehr.

Drei Teile sind nötig:
1. archive_mode = on und ein archive_command, das jedes WAL-Segment an einen sicheren Ort kopiert (oder pg_receivewal von einer anderen Maschine).
2. Ein regelmäßiges pg_basebackup.
3. Beim Wiederherstellen: die Dateien der Sicherung, ein restore_command, das die Segmente holt, recovery_target_time = '…' und eine leere Datei recovery.signal im Datenverzeichnis.

Der letzte Punkt verwirrt alle, die von alten Versionen kommen: seit PostgreSQL 12 gibt es keine recovery.conf mehr; die Parameter stehen in postgresql.conf, und was „dies ist eine Wiederherstellung" erklärt, ist die Signaldatei.

Replikations-Slots sind ein zweischneidiges Messer
Ein Slot garantiert, dass der Server WAL nicht löscht, das ein Verbraucher nicht gelesen hat. Verschwindet der Verbraucher und bleibt der Slot, staut sich WAL, bis die Platte voll ist —und eine volle Platte ist ein Ausfall, keine Warnung—. So überwacht man sie:

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 setzt die Obergrenze: darüber hinaus entwertet der Server lieber den Slot, als ohne Platte dazustehen.

Das Backup von Calíope ist logisch
Was das Backup-Werkzeug erzeugt, ist SQL —CREATE und INSERT—, aus der Familie von pg_dump, nicht von pg_basebackup. Es taugt zum Migrieren und zum Zurückholen von Daten; einen ganzen Server auf einen Zeitpunkt zurückzuholen verlangt das Obige, und das ist Sache des Betriebssystems, nicht eines Clients.

Empfehlung
Die zwei Dateien der logischen Sicherung immer zusammen —--globals-only und der Auszug— und die Wiederherstellung testen, nicht die Sicherung: eine fehlerfrei erzeugte Datei kann sich nicht zurückspielen lassen, und das weiß man erst, wenn man sie auf einem echten Server zurückspielt.

Stichwörter: Sicherung, backup, pg_dump, pg_restore, pg_dumpall, globals, pg_basebackup, wal, archive_mode, archive_command, pitr, recovery_target_time, recovery.signal, Replikations-Slot, wal_status, Wiederherstellung

Schemaänderungen im laufenden Betrieb (PostgreSQL)

DDL ist transaktional, teuer ist die Sperre und nicht das ALTER, welche Änderungen die ganze Tabelle neu schreiben, und das Muster NOT VALID + VALIDATE, das die Datenbank nicht anhält.

Gilt für: PostgreSQL 13+

Hier ist DDL transaktional. Das ändert, wie Migrationen geschrieben werden, und ist das Erste, was man aus MySQL kommend verinnerlichen muss, wo jedes ALTER für sich bestätigt.

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

Geprüft: nach diesem ROLLBACK blieb keine der drei Sachen übrig. Eine Migration, die auf halbem Weg scheitert, hinterlässt keine halbe Datenbank; deshalb ist das gesunde Muster, die ganze Migration in eine Transaktion zu packen.

Die Ausnahmen sind an einer Hand abzuzählen: CREATE INDEX CONCURRENTLY, VACUUM und ALTER SYSTEM dürfen nicht in einer Transaktion stehen.

Teuer ist nicht das ALTER, sondern die Sperre
Fast jedes ALTER TABLE nimmt ACCESS EXCLUSIVE, das sogar mit einem SELECT kollidiert. Selbst wenn die Änderung eine Millisekunde dauert, kann das Warten auf die Sperre Stunden dauern —und während es wartet, staut sich alles dahinter—. Deshalb wird DDL in der Produktion immer so abgesetzt:

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

Bekommt es die Sperre nicht, scheitert es nach drei Sekunden und wird wiederholt. Ohne das kann eine Millisekunden-Migration die ganze Anwendung anhalten.

Was die Tabelle neu schreibt und was nicht
Neu schreiben heißt, die ganze Tabelle zu kopieren: es dauert mit der Größe und braucht währenddessen doppelt so viel Platte. Gemessen an 500 000 Zeilen und 32 MB:

AnweisungZeitSchreibt neu?
ADD COLUMN c int0,6 msnein
ADD COLUMN c int DEFAULT 7 NOT NULL1,6 msnein
ALTER COLUMN s TYPE varchar(100) (vorher 50)1,2 msnein
ALTER COLUMN s TYPE varchar(20) (vorher 50)204 msja
ALTER COLUMN n TYPE bigint (vorher int)191 msja
ALTER COLUMN t TYPE varchar(200) (vorher text)193 msja
DROP COLUMN c0,5 msnein
ALTER COLUMN c SET NOT NULL17,8 msnein (durchläuft aber die Tabelle)
ADD CONSTRAINT … CHECK (…)11,6 msnein (durchläuft)
ADD CONSTRAINT … CHECK (…) NOT VALID0,5 msnein

Die Regel, die die Tabelle zusammenfasst: Verbreitern ist gratis, Verengen schreibt neu. Und ADD COLUMN mit Vorgabewert schreibt seit PostgreSQL 11 nicht mehr neu: der alte Umweg, die Spalte leer anzulegen und stapelweise zu füllen, ist überflüssig.

Zwei Warnungen, die die Zeiten nicht zeigen:
- Ein DROP COLUMN ist sofort fertig, weil es die Spalte nur als gelöscht markiert: der Platz kommt nicht zurück, bis die Tabelle neu geschrieben wird.
- SET NOT NULL und ein gewöhnliches CHECK schreiben nicht neu, durchlaufen aber die ganze Tabelle mit gehaltener Sperre. Bei einer großen Tabelle ist das bereits ein Ausfall.

Das Muster, das die Datenbank nicht anhält: NOT VALID, dann VALIDATE
Eine Einschränkung lässt sich in zwei Schritten hinzufügen: zuerst wird sie erklärt, ohne den Bestand zu prüfen —sofort—, und dann validiert; das ist der langsame Teil, aber mit einer weit schwächeren Sperre.

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;

Gemessen: die NOT VALID-Erklärung brauchte 0,7 ms mit SHARE ROW EXCLUSIVE —das Lesen erlaubt—, und die Validierung 67 ms mit SHARE UPDATE EXCLUSIVE, das nicht einmal Schreiber blockiert. In einem Zug kostete es gleich viel Zeit, aber mit durchgehend gehaltener starker Sperre. Bei einer echten Tabelle trennt dieser Unterschied ein Deployment von einem Ausfall.

Sobald die Einschränkung NOT VALID ist, erzwingt der Server sie für neue Zeilen; offen bleibt nur die Prüfung der alten.

Indizes
Ein gewöhnliches CREATE INDEX hält die Schreibvorgänge an; CREATE INDEX CONCURRENTLY hält nichts an, passt aber nicht in die Transaktion der Migration und geht deshalb in einen eigenen Schritt, der danach geprüft wird (pg_index.indisvalid).

Empfehlung
Immer lock_timeout; die Migration in einer Transaktion, außer dem, was nicht darf; NOT VALID + VALIDATE für Einschränkungen auf großen Tabellen; und Vorsicht bei Typänderungen, wo sich das Neuschreiben versteckt. Muss ein Typ verengt werden, ist es fast immer besser, die neue Spalte anzulegen, stapelweise zu kopieren und umzubenennen.

Stichwörter: ddl, alter table, Migration, transaktional, rollback, lock_timeout, access exclusive, Neuschreiben, relfilenode, add column, drop column, set not null, not valid, validate constraint, create index concurrently

Datentypen (PostgreSQL)

Was es nicht gibt und einen Syntaxfehler wirft, warum text nicht schlechter ist als varchar, numeric gegen Gleitkomma, was timestamptz wirklich speichert und warum jsonb keinen Platz spart.

Gilt für: PostgreSQL 13+

Typen sind eine der wenigen Stellen, an denen die Migration von MySQL schon beim ersten Versuch scheitert — und das ist gut so: was es nicht gibt, wirft einen Syntaxfehler, statt halb angenommen zu werden.

Was es hier nicht gibt
- UNSIGNED — 42601 syntax error at or near "unsigned". Es gibt keine vorzeichenlosen Ganzzahlen; man nimmt den nächstgrößeren Typ oder ein CHECK (n >= 0).
- INT(11) — ebenfalls 42601. MySQLs Anzeigebreite existiert nicht und bedeutete nie das, wonach sie aussah.
- TINYINT, DATETIME, DOUBLE mit Klammern und MySQLs SET/ENUM. Die Entsprechungen sind smallint, timestamptz, double precision und ein echter enum-Typ.

Text: nimm text, fertig
Gemessen, mit demselben Wert 'hola': text belegte 5 Byte, varchar(50) 5 und char(50) 51. Alle drei werden gleich gespeichert; varchar(n) fügt nur eine Längenprüfung hinzu und char(n) füllt mit Leerzeichen auf. Und diese Leerzeichen verändern Vergleiche: 'x' = 'x ' ist bei text falsch und bei char wahr.

Hier ist text nicht schlechter als varchar: es gibt keine Strafe. varchar(n) nimmt man, wenn die Grenze eine fachliche Regel ist, und char(n) praktisch nie.

Zahlen: numeric für Geld, und das ist kein Aberglaube

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

Gemessen: in Gleitkomma ergab 0.1 * 3 0.30000000000000004; in numeric genau 0.3. numeric ist exakt und beliebig genau, und man bezahlt das mit Platz und Tempo —10 Byte gegenüber den 8 von float8 für diesen Wert, und Arithmetik in Software—. Für Geld und für jede Zahl, die vor einem Kunden addiert wird: numeric.

Gemessene Größen: int 4, bigint 8, boolean 1, uuid 16 —gegenüber den 36 als Text—.

Datumsangaben: fast immer timestamptz
timestamp und timestamptz belegen dieselben 8 Byte. Der Unterschied ist weder die Größe noch, dass eines die Zone speichert: keines speichert die Zone. timestamptz speichert einen Zeitpunkt —beim Hineinschreiben nach UTC, beim Herauslesen in die Zone der Sitzung—, timestamp speichert eine Uhrzeitablesung und sonst nichts.

Gemessen, derselbe Zeitpunkt mit zwei Sitzungszonen:

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

Es ist derselbe Moment, zweimal ausgesprochen. Bei timestamp findet gar keine Umrechnung statt: was hineinging, kommt heraus — und wer wissen muss, wann es wirklich war, kann es nicht mehr herausfinden. date belegt 4 Byte, interval 16.

json gegen jsonb: fast immer jsonb, und nicht wegen der Größe

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 speichert den Text, wie er kam: Reihenfolge, Leerzeichen und sogar doppelte Schlüssel bleiben erhalten. jsonb speichert einen bereits geparsten Baum: es sortiert die Schlüssel, behält den letzten doppelten und normalisiert Leerzeichen. Deshalb lässt sich jsonb schnell abfragen und mit GIN indizieren, und json ist nur richtig, wenn das Dokument Byte für Byte so zurückgegeben werden muss, wie es ankam.

Was nicht stimmt, ist, dass jsonb Platz spart: gemessen an 200 000 gleichen Dokumenten belegte json 14 MB und jsonb 16 MB. Man wählt jsonb dafür, wie es abgefragt wird, nicht dafür, was es wiegt.

Arrays
Ein Array ist ein vollwertiger Typ, mit seinen Operatoren —@> für Enthaltensein, array_length— und seinem GIN-Index. Bequem für Etiketten und kurze Listen; unbequem, sobald die Elemente eigene Attribute brauchen oder mit einer anderen Tabelle verbunden werden müssen. Ein Array ist keine gesparte Tabelle: es ist ein Wert.

serial oder IDENTITY
serial ist kein Typ: es ist Zucker, der eine Sequenz anlegt und deren nextval als Vorgabewert setzt. GENERATED ALWAYS AS IDENTITY ist die Standardform und schützt zusätzlich die Spalte: der Versuch, von Hand einen Wert einzufügen, antwortete 428C9 cannot insert a non-DEFAULT value into column. Für neue Tabellen: IDENTITY.

Empfehlung
text für Text, numeric für Geld, timestamptz für Zeitpunkte, jsonb für Dokumente, die abgefragt werden, und IDENTITY für Schlüssel. Und bei der Migration von MySQL den Syntaxfehler seine Arbeit tun lassen: besser als ein Typ, der angenommen wird und etwas anderes bedeutet.

Stichwörter: Typen, text, varchar, char, numeric, float, decimal, timestamptz, timestamp, Zeitzone, json, jsonb, Array, uuid, serial, identity, unsigned, enum

Fehler und SQLSTATE (PostgreSQL)

Die Codes, die man täglich sieht, warum man gegen die Klasse und nicht gegen den Code programmiert, was DETAIL und HINT sagen, die kaum jemand zeigt, und wo der Code steht, wenn die Verbindung gar nicht erst zustande kommt.

Gilt für: PostgreSQL 13+

Hier gibt es keine Fehlernummern. Es gibt SQLSTATE: fünf Zeichen, von denen die ersten beiden die Klasse sind. Und die Klasse ist das, wogegen man programmiert: sie sagt, was zu tun ist, ohne dass man genau weiß, was fehlschlug.

Die, die man täglich sieht

CodeWas passiert ist
23505Doppelter Schlüssel — verletzt eine Eindeutigkeitsbedingung
23503Fremdschlüssel: die referenzierte Zeile fehlt, oder ein Elternteil mit Kindern soll gelöscht werden
23502NULL in einer NOT NULL-Spalte
23514Eine CHECK-Bedingung sagte nein
22001Der Text passt nicht in den Typ
22P02Ungültige Eingabesyntax: 'hola' ist keine ganze Zahl
22012Division durch null
42601Syntaxfehler
42703Diese Spalte gibt es nicht
42P01Diese Tabelle gibt es nicht
42P07Diese Tabelle gibt es schon
42883Diese Funktion oder diesen Operator gibt es nicht
42501Zugriff verweigert
25P02Die Transaktion ist abgebrochen und nimmt nichts mehr an
40001Konnte nicht serialisiert werden — wiederholen
40P01Verklemmung — wiederholen
55P03Sperre nicht erhalten (NOWAIT oder lock_timeout)
57014Abfrage abgebrochen (statement_timeout oder jemand hat abgebrochen)
3D000Diese Datenbank gibt es nicht
28000Diese Rolle gibt es nicht

Die Klassen, auf die man schauen sollte

KlasseBedeutetWas zu tun ist
08VerbindungNeu verbinden und wiederholen
22DatenDen Eingabewert korrigieren
23IntegritätEs liegt an den Daten: dem Benutzer sagen
25TransaktionszustandROLLBACK und von vorn
28AutorisierungAnmeldedaten; nicht wiederholen
40RücknahmeDie ganze Transaktion wiederholen
42Syntax oder ZugriffEin Programmfehler: Wiederholen hilft nicht
53Zu wenig RessourcenWarten oder vergrößern
55Objekt nicht im richtigen ZustandJe nach Fall; 55P03 ist eine Sperre
57Eingriff des BetreibersJemand hat abgebrochen, oder eine Grenze griff

Die praktische Folge: eine Anwendung wiederholt Klasse 40 und wiederholt Klasse 42 nicht. Unterscheidet die Wiederholung nicht, geht entweder eine berechtigte Transaktion verloren oder eine Abfrage, die nie funktionieren wird, läuft tausendmal.

Die Meldung hat drei Teile, und der dritte ist der nützliche
MESSAGE sagt, was geschah, DETAIL nennt Zeile oder Wert, und HINT sagt, was zu tun ist. Gemessen:

- 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".

Ein Client, der nur die MESSAGE zeigt, wirft die Hälfte der Information weg —und zwar genau die Hälfte, die den Ausweg nennt—. Calíope setzt alle drei zusammen.

Außerdem bringt der Fehler eigene Felder mit: Tabelle, Spalte und Name der Bedingung. Mit 23505 kam constraint = er_d_pkey — das ist es, was daraus ein „diese E-Mail ist bereits registriert" macht, ohne den Meldungstext zu zerlegen.

Ein Fehler bricht die Transaktion ab
Nach jedem Fehler innerhalb eines BEGIN antwortet alles Folgende 25P02, bis ein ROLLBACK kommt. Das ist kein Client-Fehler: das ist die Bauart, und der elegante Ausweg sind Sicherungspunkte.

Verbindungsfehler kommen nicht in der Antwort
Existieren Rolle oder Datenbank nicht, oder ist das Passwort falsch, öffnet sich die Verbindung gar nicht erst: der Client sieht nur „connection failed". Der Code steht im Serverprotokoll, und nur, wenn man ihn anfordert:

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

Damit wurde aus FATAL: database "no_existe" does not exist ein FATAL: 3D000: database "no_existe" does not exist —und 28000 für die nicht existierende Rolle—. Das ist der Unterschied zwischen Raten und Wissen, wenn jemand meldet, er „komme nicht rein".

Empfehlung
Im Anwendungscode nach Klasse verzweigen und den vollständigen Code nur für die Meldungen verwenden, die der Benutzer sieht (23505 → „existiert bereits"). Den SQLSTATE immer im eigenen Protokoll festhalten: der Meldungstext ändert sich mit der Sprache des Servers, der Code nicht.

Stichwörter: Fehler, sqlstate, Code, Klasse, 23505, 23503, 42p01, 42601, 42883, 42501, 25p02, 40001, 40p01, 55p03, 57014, 3d000, 28000, detail, hint, Wiederholung, log_error_verbosity

Partitionierung (PostgreSQL)

Deklarative Partitionierung und was der Planer wirklich beschneidet, warum es keine globalen Indizes und keine Eindeutigkeit auf einer einzelnen Spalte gibt, der CHECK, der aus 68 ms ATTACH eine halbe Millisekunde macht, und was die Standardpartition kostet.

Gilt für: PostgreSQL 13+

Die Partitionierung ist hier deklarativ: man erklärt den Schlüssel, und jede Partition ist eine echte Tabelle. Die Elterntabelle hält keine einzige Zeile —gemessen: 0 Byte, die Daten liegen in den Kindern—.

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;

Es gibt drei Formen: RANGE (Datumsangaben, Beträge), LIST (Land, Status) und HASH (Verteilen um des Verteilens willen).

Das Beschneiden ist der Zweck der Übung
Gemessen an 300 000 Zeilen über drei Jahre: eine Abfrage mit WHERE creado BETWEEN '2024-03-01' AND '2024-03-31' durchlief nur pt_2024. Dieselbe Tabelle, gefiltert nach einer Spalte, die nicht der Schlüssel ist, machte ein Parallel Append über alle.

Daraus folgt die Regel, die den Entwurf entscheidet: der Partitionsschlüssel ist die Spalte, nach der du fast immer filterst. Erwähnen die Abfragen sie nicht, spart die Partitionierung kein Lesen: sie verteilt es.

Was es nicht gibt: globale Indizes
Ein auf der Elterntabelle angelegter Index erzeugt einen je Partition —gemessen: vier Partitionen, vier Indizes—. Es gibt keinen einzelnen Index über die ganze Tabelle, und daraus folgt die Einschränkung, die man vor dem Entwurf kennen muss:

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

Ein UNIQUE nur auf (id) wird mit 0A000 unique constraint on partitioned table must include all partitioning columns abgelehnt. Globale Eindeutigkeit einer Kennung lässt sich mit deklarativer Partitionierung nicht garantieren; sie kommt von einer Sequenz, die durch Konstruktion eindeutig ist, nicht durch eine Bedingung.

ATTACH: der Unterschied zwischen 68 ms und einer halben
Eine bestehende Tabelle anzuhängen zwingt den Server zu prüfen, ob alle ihre Zeilen in den Bereich passen. Gemessen an 300 000 Zeilen:

VorgangZeit
ATTACH ohne vorherigen CHECK67,9 ms
DETACH0,7 ms
ATTACH mit gleichwertigem, bereits validiertem CHECK0,6 ms

Anders gesagt: trägt die Tabelle bereits eine CHECK-Bedingung, die den Bereich impliziert, überspringt der Server den Durchlauf. Bei einer Tabelle mit einer Milliarde Zeilen ist das der Unterschied zwischen einem augenblicklichen ACCESS EXCLUSIVE und einer halben Stunde davon.

Die Standardpartition ist nicht gratis
DEFAULT fängt auf, was in keinen Bereich fällt, und verhindert den Fehler beim Einfügen eines unerwarteten Datums. Im Gegenzug, gemessen: ALTER TABLE … DETACH PARTITION … CONCURRENTLY antwortete 55000 cannot detach partitions concurrently when a default partition exists. Und außerdem muss jedes neue ATTACH die Standardpartition durchlaufen, um zu prüfen, dass sie keine Zeilen des ankommenden Bereichs verbirgt.

Warum man wirklich partitioniert
Nicht wegen der Abfragegeschwindigkeit —dafür sind Indizes da—, sondern wegen der Wartung:
- Eine ganze Periode zu entfernen ist ein DROP TABLE ihrer Partition: gemessen 2,3 ms, und der Platz geht ans Dateisystem zurück. Das gleichwertige DELETE dauerte länger und hinterlässt vor allem tote Zeilen, die VACUUM aufräumen muss, und Platz, der nicht zurückkommt.
- VACUUM und ANALYZE arbeiten je Partition, die Wartungsarbeit wächst also nicht mehr mit der ganzen Historie.
- Alte Daten lassen sich abhängen und archivieren, ohne die lebende Tabelle anzufassen.

Empfehlung
Partitionier nach dem, was du löschen wirst, nicht nach dem, was du abfragen wirst; und prüf am Plan, dass deine Abfragen den Schlüssel im WHERE führen, statt es anzunehmen. Bevor du eine bestehende Tabelle partitionierst, frag dich, ob nicht ein Index das Fehlende ist: Partitionierung fügt bewegliche Teile hinzu und gibt dafür billigere Wartung — und diese Rechnung geht erst ab einer gewissen Größe auf.

Stichwörter: Partitionierung, partition, range, list, hash, pruning, attach, detach, default, globaler Index, unique, 0a000, drop partition, Wartung

Kodierung und Sortierfolge (PostgreSQL)

Warum es die utf8-Falle hier nicht gibt, worin sich Kodierung und Sortierfolge unterscheiden, wie sich die Reihenfolge mit jeder ändert, und warum ein Präfix-LIKE deinen Index nicht nutzt.

Gilt für: PostgreSQL 13+

Die Falle, die so viele MySQL-Migrationen gekostet hat, gibt es hier nicht: es gibt kein utf8, das kein UTF-8 war. Die Kodierung wird beim Anlegen der Datenbank erklärt, und UTF8 ist das ganze UTF-8.

Gemessen, mit vier Zeichenketten in einer gewöhnlichen text-Spalte:

WertZeichenBytes
normal66
ñandú57
日本語39
ein Emoji mit Modifikator plus Text1119

Nichts Besonderes war zu erklären. length() zählt Zeichen und octet_length() zählt Bytes — genau die Unterscheidung, der man in MySQL Typ für Typ nachjagen musste.

Kodierung und Sortierfolge sind zweierlei
- Kodierung — wie die Bytes gespeichert werden. Sie gehört zur Datenbank, wird beim Anlegen festgelegt und danach nicht mehr geändert: dafür muss man auszugsweise sichern und neu anlegen.
- Sortierfolge (Collation) — wie sortiert und verglichen wird. Sie lässt sich pro Datenbank, pro Spalte, pro Ausdruck und sogar im ORDER BY festlegen.

Auf dem Testserver: server_encoding = UTF8, und die Datenbanken mit en_US.utf8 vom Anbieter libc. Es stehen 815 Sortierfolgen zur Verfügung, von zwei Anbietern: die des Systems (C, POSIX, en_US.utf8) und die von ICU (es-ES-x-icu, unicode), die seit PostgreSQL 15 sogar der Standardanbieter einer Datenbank sein können.

Was sich mit der Sortierfolge ändert

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");

Gemessen ähneln sich die Ergebnisse nicht:

- C — A, B, a, b, á. Sortiert nach der Zeichennummer: alle Großbuchstaben vor den Kleinbuchstaben, Akzente ganz hinten.
- es-ES-x-icu — a, A, á, b, B. Sortiert wie ein Wörterbuch.

Und der Vergleich ändert sich mit: 'a' < 'B' ist mit C falsch und mit der spanischen Sortierfolge wahr. Eine Liste, die „falsch sortiert herauskommt", ist fast nie ein Anwendungsfehler: es ist die Sortierfolge der Spalte.

Die Sortierfolge entscheidet, ob ein Index für LIKE taugt
Das ist das praktische Detail, auf das man am schwersten allein kommt. Mit einer sprachlichen Sortierfolge —der der Datenbank— taugt ein gewöhnlicher B-Baum-Index nicht für Präfixsuchen. Gemessen an 200 000 Zeilen:

- WHERE s = 'usuario42' → Index Only Scan.
- WHERE s LIKE 'usuario42%' → Seq Scan, obwohl der Index danebenliegt.
- Nach Anlegen des Index mit der passenden Operatorklasse wurde dieselbe Abfrage zum Bitmap Index Scan.

CREATE INDEX idx_prefijo ON ch_like (s text_pattern_ops);

text_pattern_ops vergleicht Byte für Byte, genau das, was LIKE 'etwas%' braucht. Mit der Sortierfolge C auf der Spalte ist es unnötig, denn so wird ohnehin verglichen.

Sortierfolgen haben eine Version, und das ist wichtig
Die Datenbank hält fest, mit welcher Version der Sortierfolge ihre Indizes gebaut wurden —gemessen: datcollversion = 2.36, die der Systembibliothek—. Wird das Betriebssystem aktualisiert und ändert sich diese Version, kann sich die Reihenfolge ändern, und ein mit der alten Reihenfolge gebauter Index ist nicht mehr korrekt: Suchen finden Zeilen nicht, die da sind. PostgreSQL warnt vor der Abweichung, und die Antwort heißt REINDEX.

Deshalb wählen viele die Sortierfolge C oder ICU für Datenbanken, die Systemaktualisierungen überstehen müssen: ICU bringt seine eigene Version mit und hängt nicht an der des Systems.

Empfehlung
Immer UTF8. Die Sortierfolge wird beim Anlegen der Datenbank entschieden, weil eine spätere Änderung teuer ist: C für Spalten, die Codes, Kennungen oder Pfade sind —sie sortiert schnell und verträgt sich mit LIKE—, und eine sprachliche Sortierfolge für das, was ein Mensch liest. Und wenn eine LIKE 'x%'-Abfrage den Index nicht nutzt, schau auf die Sortierfolge, bevor du die Abfrage anfasst.

Stichwörter: Kodierung, encoding, utf8, Sortierfolge, collation, collate, icu, libc, text_pattern_ops, like, Präfix, order by, datcollversion, reindex, octet_length

Grenzen (PostgreSQL)

Die, die wirklich beißen —63 Byte Name, 1 600 Spalten, 32 je Index—, warum die der Kennung einen Hinweis und keinen Fehler wirft, und was TOAST mit einem Wert macht, der nicht in die Seite passt.

Gilt für: PostgreSQL 13+

Die Grenzen von PostgreSQL ähneln denen von InnoDB nicht, und die, die täglich beißen, sind nicht die großen.

Die, an die man wirklich stößt

GrenzeWertWas beim Überschreiten passiert
Länge einer Kennung63 ByteSie wird abgeschnitten, mit einem Hinweis
Spalten je Tabelle1 60054011 tables can have at most 1600 columns
Spalten je Index3254011 cannot use more than 32 columns in an index
Seitengröße8 kBFest, außer man übersetzt den Server neu

Die ersten drei sind gemessen: 1 600 Spalten ließen sich problemlos anlegen, 1 601 scheiterten; ein Index mit 32 Spalten entstand, der mit 33 nicht.

Die der Kennung ist die einzige, die keinen Fehler wirft
Ein Name mit 72 Zeichen wurde als 63-Zeichen-Name gespeichert, und der Server sagte es per Hinweis:

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

Ein Hinweis ist kein Fehler: die Anweisung lief weiter. Deshalb werden zwei lange Namen, die sich erst ab dem 64. Zeichen unterscheiden, am Ende dasselbe Objekt, und der Fehler zeigt sich viel später. Es sind die Namensgeneratoren —Indizes, Bedingungen, temporäre Tabellen je Stapel—, die darauf stoßen, nicht die Hand eines Menschen.

Die großen, die fast nie das Problem sind
Sie sind hier nicht gemessen —dafür müsste man eine Platte füllen— und stehen mit ihrer offiziellen Zahl:

- Maximale Tabellengröße: 32 TB.
- Maximale Feldgröße: 1 GB.
- Maximale Zeilengröße: 1,6 TB.
- Zeilen je Tabelle: keine definierte Grenze.
- Datenbanken je Cluster und Tabellen je Datenbank: keine praktische Grenze.

Was lange vor jeder davon ausgeht, ist die Wartung: VACUUM, Sicherungen und Indexneubau über eine Tabelle von Terabytes.

TOAST: warum ein text von 1 GB die 8-kB-Seite nicht sprengt
Eine Zeile muss in eine Seite passen, und eine Seite hat 8 kB. Große Werte werden komprimiert und in eine Nebentabelle ausgelagert —das nennt sich TOAST—, automatisch und ohne dass man etwas erklären müsste.

Gemessen, mit 100 000 Byte Text in einer text-Spalte:

- octet_length — 100 000 Byte Daten.
- pg_column_size — 1 156 Byte, es wurde also komprimiert.
- Die Tabelle belegte 8 192 Byte und 16 kB samt ihrem TOAST.

Daraus folgt praktisch: ein SELECT * auf einer Tabelle mit großen Spalten bezahlt das Lesen dieser Spalten, auch wenn niemand hinsieht. Nur die nötigen Spalten anzufordern ist kein Stil, sondern E/A.

Die Grenzen, die sich einstellen lassen
Sie gehören zur Instanz, nicht zur Engine, und stehen deshalb in pg_settings: max_connections (ab Werk 100), max_locks_per_transaction (64), max_wal_size, work_mem. Diese gehen auf einem echten Server aus; die aus der Tabelle oben fast nie.

Empfehlung
Nur zwei im Blick behalten: die 63 Byte, sobald etwas Namen erzeugt, und die Spalten je Index, sobald jemand einen zusammengesetzten Index mit dem halben Schema darin vorschlägt. Vom Rest erfährt man durch pg_settings, nicht durch die Dokumentation.

Stichwörter: Grenzen, Kennung, 63 Byte, abgeschnitten, 1600 Spalten, 32 Spalten, Index, toast, Seite, 8 kB, 32 tb, 1 gb, pg_settings, max_connections

Kodierung und Sortierfolgen (SQL Server)

varchar gegenüber nvarchar, das N-Präfix, _UTF8-Sortierfolgen, was jedes Suffix bewirkt, der Collation-Konflikt und warum ein N'…'-Parameter deinen Index ungenutzt lassen kann.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst: Hier gibt es weder utf8mb4 noch SET NAMES. In SQL Server wird die Kodierung eines Textes nicht separat deklariert – sie ergibt sich aus seiner Collation, und es gibt zwei Familien von Texttypen.

varchar und nvarchar sind zwei Kodierungen
- nvarchar (und nchar) speichert UTF-16: jedes Unicode-Zeichen, unabhängig von der Collation.
- varchar (und char) speichert die Bytes der Codepage seiner Collation. Bei einer klassischen Collation wie Latin1_General_CI_AS ist das Windows-1252, und was dort nicht vorkommt, wird als ? gespeichert.
- Mit einer Collation, die auf _UTF8 endet (seit SQL Server 2019), speichert varchar UTF-8.

Gemessen auf dem Testserver (SQL Server 2025), mit der Datenbank in Latin1_General_100_CI_AS_SC_UTF8:

WertBytes in varcharBytes in nvarchar
ñ22
日本語96
ein Emoji44

LEN() zählt Zeichen und DATALENGTH() Bytes. Das n in varchar(n) sind Bytes, das in nvarchar(n) Bytepaare, keine Zeichen. Gemessen: Ein UTF-8-varchar(10) nimmt fünf ñ auf, zehn ergeben Msg 2628 („String or binary data would be truncated“); ein nvarchar(10) nimmt zehn ñ auf, aber keine sechs Emojis.

Das Präfix N
Ein Literal ohne vorangestelltes N ist varchar und wird in die Codepage der Datenbank umgewandelt, bevor es irgendeine Spalte erreicht. Gemessen in einer Latin1_General_CI_AS-Datenbank:

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

Und in einer varchar-Spalte dieser Datenbank wurde Zúrich ✓ 日本 selbst mit N am Literal als Zúrich ? ?? gespeichert. Die Daten gehen beim Schreiben verloren, ohne Fehler und ohne Warnung, und eine spätere Änderung der Collation der Spalte holt sie nicht zurück: Gespeichert sind bereits Fragezeichen.

Was jedes Suffix bedeutet

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;

Gemessen: 1, 0, 1, 0.

- _CI / _CS – unterscheidet Groß-/Kleinschreibung nicht / schon.
- _AI / _AS – unterscheidet Akzente nicht / schon.
- _SC – zählt Zeichen außerhalb der Basisebene, etwa ein Emoji, als ein Zeichen. Gemessen: LEN(N'😀') ergibt 1 mit _SC und 2 ohne, und ohne _SC findet ein LIKE N'_' es nicht.
- _UTF8 – varchar in UTF-8.
- _BIN2 – vergleicht nach Codepoint: sortierte A, B, a, b, n, o, á, ñ, während Latin1_General_100_CI_AS und Modern_Spanish_100_CI_AS A, a, á, b, B, n, ñ, o lieferten.
- Die 100 ist die Version der Sortiertabellen; die ohne sie (Latin1_General_CI_AS) sind älter, und die mit SQL_ am Anfang sind die aus SQL Server 2000 übernommenen.

Der Testserver bietet 5540 Collations, davon 1585 mit _UTF8 (sys.fn_helpcollations()).

Server, Datenbank, Spalte und Ausdruck

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

Die Collation wird auf vier Ebenen festgelegt, und jede übernimmt bei ihrer Erstellung die der Ebene darüber:

- Server – die von master und tempdb, bei der Installation gewählt.
- Datenbank – CREATE DATABASE … COLLATE ….
- Spalte – name varchar(40) COLLATE Latin1_General_100_CS_AS.
- Ausdruck – WHERE a = b COLLATE Latin1_General_100_CI_AS oder innerhalb eines ORDER BY.

ALTER DATABASE … COLLATE ändert bestehende Spalten nicht: Gemessen wechselte die Datenbank zu _UTF8, und ihre Spalten blieben bei Latin1_General_CI_AS. Jede Spalte wird mit ihrem eigenen ALTER TABLE … ALTER COLUMN geändert.

Der Collation-Konflikt
Zwei Texte mit unterschiedlicher Collation lassen sich nicht von selbst vergleichen:

- WHERE a = b → Msg 468, „Cannot resolve the collation conflict“.
- Ein UNION ALL der beiden Spalten → Msg 457; mit Katalogtext (sys.* ist in Latin1_General_CI_AS) → Msg 451.
- Gelöst wird es mit COLLATE auf einer Seite.

Der häufigste Fall sind temporäre Tabellen: #tmp lebt in tempdb und entsteht mit der Collation des Servers, nicht mit der deiner Datenbank. Gemessen in einer Latin1_General_100_CI_AS-Datenbank auf einem _UTF8-Server: Ein JOIN mit #tmp ergab Msg 468. Der Ausweg ist, ihre Spalten mit COLLATE DATABASE_DEFAULT zu deklarieren.

Collation und Indizes
Bei 100.000 Zeilen mit einem Index auf der Spalte:

- WHERE s = 'usuario42' → Index Seek. Mit COLLATE auf der Spalte → Index Scan: Der Index wird komplett gelesen.
- WHERE s LIKE 'usuario42%' → Index Seek, anders als bei PostgreSQL.
- Ein N'…'-Parameter gegen eine varchar-Spalte: Mit einer Windows-Collation (Latin1_General_100_CI_AS) wurde der Index weiter genutzt, mit 7 logischen Lesevorgängen; mit einer SQL_-Collation (SQL_Latin1_General_CP1_CI_AS) wird die ganze Spalte nach nvarchar umgewandelt, und der Plan wird zum Index Scan: 334 Lesevorgänge statt 3. Wenn eine Gleichheitssuche langsam ist und der Parameter als nvarchar ankommt, prüfe die Collation der Spalte.

Empfehlung
Für eine neue Datenbank eine _100_…_SC_UTF8-Collation, die varchar zu vollständigem UTF-8 macht. Deklariere die Spalten temporärer Tabellen mit COLLATE DATABASE_DEFAULT. Und entscheide die Collation beim Anlegen der Datenbank: Sie später zu ändern geht nur Spalte für Spalte.

Stichwörter: Kodierung, encoding, Sortierfolge, collation, collate, varchar, nvarchar, utf8, utf-16, n-präfix, unicode, _sc, _bin2, 468, 451, 457, 2628, tempdb, database_default, datalength

Datentypen (SQL Server)

datetime gegenüber datetime2, bit statt boolean, money gegenüber decimal, GUIDs, die nicht fragmentieren, rowversion, das kein Datum ist, die 8000-Byte-Grenze und der json-Typ von 2025.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst, heißen mehrere Typen ähnlich und verhalten sich anders, und zwei Namen täuschen: timestamp ist kein Datum und datetime rundet. Was es nicht gibt, gibt einen Fehler, und das ist gut so: Es gibt kein UNSIGNED, tinyint reicht nur von 0 bis 255 (mit -1 kommt Msg 220, arithmetischer Überlauf) und SELECT TRUE gibt Msg 207: TRUE ist kein Wort der Sprache.

Datum: datetime2, nicht datetime
datetime ist der alte Typ und rundet auf 1/300 Sekunde. Gemessen:

GeschriebenGespeichert 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

Die letzte Zeile ist die, die Berichte kaputt macht: Ein WHERE fecha <= '2026-09-30 23:59:59.999' auf datetime schließt Mitternacht des Folgetags ein. Für einen ganzen Tag, bei jedem Typ: fecha >= '2026-09-30' AND fecha < '2026-10-01'.

datetime2 speichert bis auf hundert Nanosekunden (in datetime2(7) blieb .1234567 genau so), beginnt im Jahr 1 —'1700-01-01' in datetime gibt Msg 242, außerhalb des Bereichs— und belegt gleich viel oder weniger: gemessen datetime 8 Bytes, datetime2(7) 8, datetime2(3) 7 und datetime2(0) 6. Und man mischt sie nicht gedankenlos: Dasselbe 12:00:00.001 in datetime2(3) und in datetime ist nicht gleich, weil datetime es schon als .000 gespeichert hatte.

Weitere gemessene Größen: date 3 Bytes, time 5, smalldatetime 4 (minutengenau) und datetimeoffset 10.

datetimeoffset speichert den Versatz
Anders als timestamptz in PostgreSQL speichert es den Versatz, mit dem geschrieben wurde, und vergleicht nach Zeitpunkt: '2026-09-30T12:00:00+05:30' und '2026-09-30T06:30:00+00:00' kamen gleich heraus. SWITCHOFFSET(wert, '+00:00') bringt es nach UTC, und AT TIME ZONE 'Central European Standard Time' lieferte 08:30 +02:00, mit Sommerzeit. Calíope zeigt es in der Zeit des Werts, mit dem Versatz daneben.

Es gibt kein boolean: es gibt bit
bit ist eine Zahl, die 0 oder 1 ist, kein Wahrheitswert. Gemessen: 5, -1 oder die Zeichenkette 'true' zuzuweisen speichert 1; ein nacktes WHERE @b gibt Msg 4145 —man muss WHERE @b = 1 schreiben— und bit + bit gibt Msg 402. Calíope zeigt es als 1 oder 0.

Geld: decimal, und money mit Vorsicht

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 hat vier feste Nachkommastellen, auch in Zwischenergebnissen: 1 / 3 ergab .3333, und mal 3 kommt man nicht mehr auf die ganze Zahl zurück. decimal erweitert die Skala des Ergebnisses (es ergab .3333333333333333333). Zum Rechnen decimal; money belegt 8 Bytes und smallmoney 4. float ist binär wie überall: 0.1 + 0.2 ergab 0.30000000000000004, und die Gleichheit mit 0.3 war falsch; in decimal wahr.

GUIDs: uniqueidentifier, und die Reihenfolge zählt
Ein uniqueidentifier belegt 16 Bytes. Als gruppierter Primärschlüssel verteilt NEWID() die Zeilen zufällig über den Index. Gemessen mit 20.000 einzeln eingefügten Zeilen:

SchlüsselFragmentierungSeitenMittlere Füllung
NEWID()98 %11662 %
NEWSEQUENTIALID()1 %7299,5 %
int IDENTITY2 %4398 %

NEWSEQUENTIALID() geht nur als DEFAULT einer Spalte: SELECT NEWSEQUENTIALID() gibt Msg 302. Calíope zeigt die GUID in Großbuchstaben, wie SQL Server.

rowversion ist kein Datum
rowversion —der Typ, der früher timestamp hieß, ein Name, der beim Anlegen einer Spalte noch akzeptiert wird— ist ein 8-Byte-Zähler der Datenbank, der sich bei jedem Schreiben der Zeile ändert. Gemessen: Zwei Zeilen entstanden mit 0x…07D4 und 0x…07D5, und das Aktualisieren der ersten brachte sie auf 0x…07D6. Er speichert keine Uhrzeit. Er dient der optimistischen Nebenläufigkeit: Man liest die Version und aktualisiert mit WHERE ver = @gelesen. Calíope zeigt ihn als Binärwert, [8 bytes].

Text: 8000 Bytes, oder max
Außerhalb von max reicht varchar(n) bis 8000 und nvarchar(n) bis 4000: varchar(8001) gibt Msg 131 und nvarchar(4001) Msg 2717. Darüber kommen varchar(max) und nvarchar(max).

Auch die Zeile hat eine Grenze: Zwei char(5000) in einer Tabelle geben Msg 1701 —die Mindestzeile wäre 10.007 Bytes und das Maximum sind 8060—. Zwei volle varchar(8000) passen (gemessen: 16.000 Bytes in einer Zeile), weil variable Daten die Seite verlassen, wenn sie nicht hineinpassen.

Und eine Falle ohne Warnung: REPLICATE('x', 10000) in ein varchar(max) zugewiesen ergab 8000 Zeichen, weil die Funktion im Typ ihres Arguments arbeitet. Mit REPLICATE(CAST('x' AS varchar(max)), 10000) 10.000.

JSON: der Typ json ist von SQL Server 2025
In SQL Server 2022 wird JSON in nvarchar(max) gespeichert und mit CHECK (ISJSON(col) = 1) geprüft, das Fehlerhaftes mit Msg 547 ablehnt. Seit 2025 gibt es den Typ json, der es beim Schreiben mit Msg 13609 ablehnt und bereits geparst speichert. Gemessen an 20.000 Kopien einer Bestellung mit 142 Zeichen belegte json 5,4 MB und nvarchar(max) 6,0 MB; bei einem winzigen Dokument, {"b":1,"a":2}, umgekehrt: 60 Bytes gegenüber 26.

Und ein sichtbarer Unterschied: Mit einem doppelten Schlüssel akzeptiert ISJSON {"b":1,"a":2,"a":3}, und der Typ json gab es als {"b":1,"a":2} zurück: Er behielt den ersten, ohne Warnung. Die Reihenfolge der Schlüssel bleibt erhalten. Calíope empfängt json als Text und zeigt es unverändert.

Die besonderen Typen
- geography und geometry — Typen mit eigenem Binärformat, das kein WKB ist. geography::Point(40.4168, -3.7038, 4326) belegt 22 Bytes und sein STAsText() gibt POINT (-3.7038 40.4168): Der Konstruktor will Breite, Länge und der Text kommt als Länge, Breite heraus. Calíope zeigt die Zelle als [22 bytes]; zum Lesen col.STAsText() in der Abfrage.
- hierarchyid — ein Pfad in einem Baum: '/1/3/' wurde in 2 Bytes gespeichert, mit Ebene 2 und Elternteil /1/. Calíope dekodiert ihn nicht, und die Zelle sagt nicht unterstützter Typ: hierarchyid; mit col.ToString() in der Abfrage lässt er sich lesen.
- sql_variant — eine Spalte, die Werte verschiedener Typen hält, jeden mit seinem eigenen: SQL_VARIANT_PROPERTY(@v, 'BaseType') ergab decimal. Calíope zeigt ihn nach seinem Basistyp. Im Katalog (sys.extended_properties) ergibt er Sinn; in einer eigenen Tabelle ist er meist eine schlecht entworfene Spalte.

IDENTITY oder SEQUENCE
IDENTITY ist eine Eigenschaft der Spalte und schützt sie: Einen Wert von Hand einzufügen gibt Msg 544, außer mit SET IDENTITY_INSERT tabla ON, und danach zählt der Zähler vom größten weiter (nach dem Einfügen von 100 war die nächste Zeile 101). Und er hinterlässt Lücken: Ein mit ROLLBACK zurückgenommenes INSERT verbrauchte die 3, und die nächste Zeile war die 4. Eine Lücke ist kein verlorener Datensatz.

SEQUENCE ist ein eigenes Objekt, das sich mehrere Tabellen teilen können: Mit DEFAULT NEXT VALUE FOR sq in zwei Tabellen verteilten sich die Werte auf 1 und 3 in der einen und 2 in der anderen. Man nimmt es, wenn man die Nummer vor dem Einfügen braucht oder geteilt; für den Schlüssel einer Tabelle IDENTITY.

Empfehlung
datetime2 für Uhrzeitablesungen und datetimeoffset für Zeitpunkte, decimal für Geld, bit im Wissen, dass es eine Zahl ist, NEWSEQUENTIALID() oder eine Ganzzahl, wenn der gruppierte Schlüssel eine GUID ist, und den Typ json nur, wenn dein ältester Server 2025 ist. Für Text siehe „Kodierung und Sortierfolgen (SQL Server)“.

Stichwörter: Typen, Datentypen, 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

Fehler (SQL Server)

Wie man eine Msg liest, was ihr Level sagt, die Nummern, die du täglich siehst, welche Fehler die Transaktion abbrechen und welche nicht, TRY…CATCH mit THROW, und wo der Grund für eine 18456 steht.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst: Auch hier gibt es Nummern —kein SQLSTATE wie in PostgreSQL—, aber jeder Fehler bringt vier Angaben mit, und alle zählen. So zeigt ihn Calíope, in einer Zeile; sqlcmd teilt ihn auf zwei auf:

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 — die Nummer. Danach sucht man, und darauf programmiert man.
- Level — der Schweregrad, von 0 bis 25. Er sagt, wessen Schuld es ist und was mit der Verbindung passiert.
- State — ein Untercode, mit dem der Server Ursachen mit derselben Nummer unterscheidet. Bei den meisten Fehlern spielt er keine Rolle; bei 18456 ist er das Einzige, was den Grund verrät.
- Line — die Zeile innerhalb des Batches (was zwischen zwei GO steht) oder innerhalb der Prozedur: Ein Fehler darin erscheint als Procedure p_falla, Line 3.

Das Level, das du dir als Erstes ansiehst

LevelBedeutungGemessen
0–10Informativ: kein FehlerRAISERROR('aviso', 10, 1) wird ohne Msg ausgegeben und springt nicht in den CATCH
11–16Vom Benutzer behebbar: die Daten, die Syntax, die Berechtigung1205 ist 13; 2627, 2601, 229 und 916 sind 14; 102 ist 15; fast alles andere 16
17–19Ressourcen oder ein interner Serverfehler—
20–25Fatal: Der Server schließt die VerbindungRAISERROR(…, 20, 1) WITH LOG hat die Sitzung beendet (Msg 2745)

Ein Level von 20 oder mehr lässt sich nicht von Hand auslösen, ohne sysadmin zu sein und ohne WITH LOG: Das ergibt Msg 2754. Calíope behandelt ein Level von 20 oder mehr als verlorene Verbindung.

Die, die du täglich siehst
Einzeln gegen SQL Server 2025 ausgelöst:

MsgWas passiert ist
2627Doppelter Schlüssel in einem PRIMARY KEY oder einer UNIQUE-Einschränkung; die Meldung bringt den Namen und den Wert mit
2601Doppelter Schlüssel in einem eindeutigen Index (keine Einschränkung)
547Ein Fremdschlüssel oder ein CHECK hat Nein gesagt; dieselbe Nummer für INSERT, DELETE und CHECK
515NULL in einer NOT NULL-Spalte
2628Der Text passt nicht; bringt Tabelle, Spalte und den abgeschnittenen Wert mit
8152Dasselbe, ohne Details: Diese Meldung kommt bei Kompatibilitätsgrad 140 oder niedriger
245Unmögliche Konvertierung: CAST('hola' AS int)
8134Division durch null
208Dieses Objekt existiert nicht
207Diese Spalte existiert nicht
102Syntaxfehler (Incorrect syntax near 'FORM')
2812„Prozedur nicht gefunden“: SELEC 1 liefert sie, weil ein Batch, der mit einem einzelnen Wort beginnt, ein EXEC ist
229Berechtigung für ein Objekt verweigert
916Der Login hat keinen Benutzer in dieser Datenbank
911USE einer Datenbank, die nicht existiert
1205Opfer eines Deadlocks: „Rerun the transaction“
1222LOCK_TIMEOUT beim Warten auf eine Sperre abgelaufen
3902COMMIT ohne BEGIN TRANSACTION

Den Text zu jeder Nummer siehst du mit: SELECT text FROM sys.messages WHERE message_id = 1205 AND language_id = 1033;. Es gibt 16.785 Meldungen auf Englisch, in 22 Sprachen.

Was jeder Fehler rückgängig macht: die Falle für alle, die von PostgreSQL kommen
In PostgreSQL bricht ein Fehler die ganze Transaktion ab. Hier hängt es vom Fehler ab, und die meisten brechen nur die Anweisung ab. Gemessen, ohne etwas einzuschalten:

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;
-- die Zeilen 10 und 11 sind geblieben

Die Transaktion blieb offen, das COMMIT hat bestätigt, was funktioniert hatte, und der Batch lief weiter, als wäre nichts gewesen. Was jeder gemessene Fehler getan hat:

- Nur die Anweisung: 2627, 2601, 547, 515, 2628, 8134, und auch 1222: Nach Ablauf der Wartezeit stand @@TRANCOUNT weiter auf 1.
- Der Batch und die Transaktion: 245 (Konvertierung) hat die Transaktion zurückgerollt und den Batch abgebrochen, ohne dass etwas eingeschaltet war; 1205 ebenso, beim Opfer; ein ROLLBACK in einem Trigger ergibt Msg 3609 und bricht den Batch ab.
- Die Verbindung: Level 20 oder mehr.

SET XACT_ABORT ON ändert das: Damit hat dieselbe 2627 die ganze Transaktion zurückgerollt und den Batch abgebrochen (die Zeilen 20 und 21 sind nicht geblieben). Das gehört an den Anfang jeder Prozedur, die eine Transaktion öffnet.

TRY…CATCH, und was es nicht fängt

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

Im CATCH liefern ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(), ERROR_LINE(), ERROR_PROCEDURE() und ERROR_MESSAGE() die Angaben des Fehlers (gemessen: 2627, 14, 1, 4, NULL). Und XACT_STATE() sagt, was du mit der Transaktion tun kannst: Es lieferte 1 ohne XACT_ABORT (sie lässt sich bestätigen) und -1 damit (sie lässt sich nur zurückrollen).

Was ein TRY nicht fängt: ein Objekt, das in seinem eigenen Gültigkeitsbereich nicht existiert. SELECT * FROM tabla_que_no_existe in einem TRY kam als Msg 208 heraus, ohne durch den CATCH zu gehen, weil es beim Kompilieren scheitert; in einem EXEC sp_executesql wurde es gefangen. Auch was Level 10 oder weniger hat, fängt es nicht.

THROW gegen RAISERROR
- THROW; ohne Argumente in einem CATCH wirft den ursprünglichen Fehler erneut: Die Nummer, die draußen ankam, war weiterhin 8134. Mit RAISERROR(ERROR_MESSAGE(), 16, 1) erneut ausgelöst, wurde daraus 50000, und die Nummer ging verloren.
- RAISERROR hält den Batch nicht an: Was danach kam, wurde ausgeführt. THROW schon.
- THROW 50001, 'Text', 1 löst einen eigenen Fehler aus, immer mit Level 16 und einer Nummer ab 50000. RAISERROR nimmt außerdem eine mit sp_addmessage registrierte Nummer und füllt ihre Argumente: RAISERROR(50100, 16, 1, 42) ergab „El pedido 42 no existe“.

Der Text ändert sich mit der Sprache, die Nummer nicht
Mit SET LANGUAGE Spanish kam die 8134 als „Error de división entre cero.“ an: dieselbe Nummer mit anderem Text. Die Sprache ist die der Sitzung, und Calíope verbindet sich mit der Standardsprache des Logins. Deshalb vergleicht man im Code und in den eigenen Protokollen die Nummer, nie den Text.

Die 18456: Der Grund steht im Serverprotokoll
Eine fehlgeschlagene Anmeldung kommt beim Client immer gleich an: Msg 18456, Level 14, State 1, „Login failed for user '…'“. Mit drei verschiedenen Ursachen gemessen, sah der Client alle drei Male State 1. Der echte State steht nur im Serverprotokoll:

EXEC xp_readerrorlog 0, 1, N'Login failed';
State im ProtokollGrund, den es schreibt
5Es gibt keinen Login mit diesem Namen
8Das Passwort stimmt nicht
38Die angeforderte Datenbank ließ sich nicht öffnen (sie existiert nicht, oder der Login hat dort keinen Benutzer)

Wenn die beim Verbinden angeforderte Datenbank nicht existiert, kommt vor der 18456 eine 4060 („Cannot open database … requested by the login“), und die nennt den Grund. Wenn jemand „nicht reinkommt“, unterscheidet das Serverprotokoll in einer Sekunde, was von außen derselbe Satz ist.

Was Calíope zeigt
Wenn eine Anweisung fehlschlägt, zeigt Calíope im Ergebnis-Tab die Meldung des Servers vollständig, in der Form Msg, Level, State, Line von oben, und alle Meldungen, eine pro Zeile: Ein CREATE TABLE mit einer doppelten Einschränkung bringt die 2714 und danach die 1750, und beide sagen etwas. Diese Fehler bleiben in ihrem Tab. Die, die das Verbinden verhindern —die 18456, die 4060— und die mit Level 20 oder mehr, die die Verbindung trennen, landen zusätzlich im Fehlerprotokoll von Calíope. Informative Meldungen —ein PRINT, ein RAISERROR mit Level 10— zeigt es nicht.

Empfehlung
SET XACT_ABORT ON und TRY…CATCH mit IF @@TRANCOUNT > 0 ROLLBACK; THROW; in jeder Prozedur, die eine Transaktion öffnet. In der Anwendung die 1205 wiederholen —die ganze Transaktion, die der Server schon zurückgerollt hat— und Level 14 bis 16 nicht wiederholen, denn die betreffen die Daten oder das Programm. Immer die Nummer speichern, nie den Text. Und bei einer 18456 erst das Serverprotokoll lesen, bevor du das Passwort anfasst.

Stichwörter: Fehler, msg, Level, Schweregrad, severity, 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, Wiederholung

Indizes (SQL Server)

Der gruppierte Index, der die Tabelle ist, der Key Lookup und wann INCLUDE ihn beseitigt, gefilterte Indizes, berechnete Spalten statt Ausdrucksindizes, Columnstore, FILLFACTOR und Fragmentierung, und die Indizes, die niemand nutzt.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst, ist InnoDB das Ähnlichste: Auch hier ist die Tabelle ein Index. Anders ist, dass du diesen Index —den gruppierten— selbst wählst und er nicht der Primärschlüssel sein muss, dass eine Tabelle auch keinen haben kann, und dass es statt Präfix- oder unsichtbarer Indizes gefilterte Indizes, berechnete Spalten und Columnstore gibt.

Alles Folgende ist an einer Tabelle mit 200 000 Zeilen und rund 32 MB gemessen.

Die Tabelle ist der gruppierte Index
PRIMARY KEY legt einen gruppierten Index an, wenn die Tabelle noch keinen hat: Die Zeilen werden nach diesem Schlüssel sortiert gespeichert. Eine Tabelle ohne gruppierten Index ist ein Heap (HEAP in sys.indexes): Die Zeilen landen, wo Platz ist.

Was man von MySQL aus nicht sieht, ist der Preis: Der gruppierte Schlüssel reist in jedem nicht gruppierten Index mit, denn so findet der Index die Zeile. Derselbe Index auf cliente_id belegte 2 952 kB mit einem gruppierten Schlüssel vom Typ int und 5 416 kB mit einem uniqueidentifier. Ein schmaler, stetig wachsender gruppierter Schlüssel macht alle anderen Indizes billiger; warum NEWID() ein schlechter gruppierter Schlüssel ist, erklärt das Thema über Datentypen.

Key Lookup, und was INCLUDE behebt
Ein nicht gruppierter Index trägt nur seine Spalten und den gruppierten Schlüssel. Fragt die Abfrage nach einer weiteren, zwingt jede gefundene Zeile zum Rückweg in die Tabelle: Das ist der 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 logische Lesevorgänge

CREATE INDEX ix_cliente_inc ON dbo.pedidos (cliente_id) INCLUDE (total);
-- Index Seek (ix_cliente_inc) und sonst nichts: 3 logische Lesevorgänge

INCLUDE speichert die Spalte in den Blättern des Index, ohne nach ihr zu sortieren. Hier wuchs der Index von 2 952 kB auf 4 752 kB.

Der Key Lookup wird Zeile für Zeile bezahlt, deshalb lohnt er sich sehr bald nicht mehr. Gemessen: Bei 992 Zeilen (0,5 % der Tabelle) nutzte der Plan noch Index und Lookup; bei 1 483 (0,7 %) durchlief er schon die ganze Tabelle. Ein Index, der „nicht genutzt wird“, ist oft ein Index, dem ein INCLUDE fehlt.

Die Regel des linken Präfixes
Mit einem Index auf (fecha, cliente_id) und WHERE cliente_id = 77 kann SQL Server nicht im Index absteigen. Er kann ihn, wie PostgreSQL, ganz durchlaufen, wenn das billiger ist als die Tabelle: Index Scan und 809 logische Lesevorgänge. Das ist kein Index Seek, und die Reihenfolge der Spalten zählt weiterhin.

Gefilterte Indizes
Ein Index kann ein WHERE tragen und indiziert dann nur, was es erfüllt:

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

Mit 5 % offenen Bestellungen belegte er 160 kB, gegenüber 2 952 kB für den vollständigen Index auf derselben Spalte. Der Haken: Der Plan muss für jeden Wert taugen. Mit dem Literal WHERE estado = 'pendiente' wurde er genutzt; mit einer Variablen (WHERE estado = @e) nicht, und ihn mit WITH (INDEX(ix_pend)) zu erzwingen, ergibt Msg 8622.

Und etwas, das von MySQL kommend überrascht: In SQL Server lässt ein eindeutiger Index nur ein einziges NULL zu. CREATE UNIQUE INDEX ux_email ON dbo.pedidos (email) mit zwei E-Mails auf NULL ergibt Msg 1505 („The duplicate key value is (<NULL>)“). Der Ausweg ist der gefilterte Index:

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

Keine Ausdrucksindizes: berechnete Spalten
Eine Funktion auf der Spalte im WHERE verhindert das Absteigen im Index. Auf denselben 14 400 Zeilen eines Monats:

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

Lässt sich der Ausdruck nicht als Bereich umschreiben, indiziert man eine berechnete Spalte:

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), ohne die berechnete Spalte zu nennen

Um diesen Index anzulegen, und danach, um in die Tabelle zu schreiben, braucht die Sitzung QUOTED_IDENTIFIER und ANSI_NULLS eingeschaltet; sqlcmd schaltet das erste ab, wenn man ihm nicht -I übergibt, und dann scheitert es mit Msg 1934.

Columnstore, für das, was aggregiert
Ein Columnstore-Index speichert jede Spalte getrennt und komprimiert. Dieselbe Tabelle belegte 35 520 kB mit ihrem normalen gruppierten Index und 7 104 kB als CLUSTERED COLUMNSTORE. Ein SELECT estado, SUM(total) … GROUP BY estado las auf der ersten 4 440 Seiten und brauchte 19 ms CPU, auf der zweiten 147 Seiten und 2 ms. Um eine Zeile über ihren Schlüssel zu finden, ist ein normaler Index weiterhin besser; üblich ist, einer Arbeitstabelle einen NONCLUSTERED COLUMNSTORE mit den Spalten hinzuzufügen, über die aggregiert wird (der mit vier Spalten belegte 3 128 kB).

Fremdschlüssel werden nicht von selbst indiziert
InnoDB legt für jeden Fremdschlüssel einen Index an; SQL Server nicht. Gemessen: Nach cliente_id int REFERENCES dbo.clientes(id) hatte die Kindtabelle nur den Index ihres Primärschlüssels. Ohne diesen Index durchläuft jedes DELETE in der Elterntabelle die ganze Kindtabelle, um die Einschränkung zu prüfen.

Fragmentierung und 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;

Der Index auf cliente_id, mit zufälligen Werten, Schritt für Schritt gemessen:

ZeitpunktFragmentierungSeitenFüllgrad
Frisch angelegt8 %35298 %
Nach 50 000 Einfügungen99 %69662 %
Nach REORGANIZE0,5 %43499,6 %
Nach REBUILD0 %43399,8 %
REBUILD WITH (FILLFACTOR = 70)0 %61870 %
Und weitere 50 000 Einfügungen0 %61884 %

FILLFACTOR lässt in jeder Seite Platz, damit Einfügungen passen, ohne sie zu teilen: Das kostet vom ersten Tag an Platz und erspart danach die Fragmentierung. Er bleibt im Index gespeichert (sys.indexes.fill_factor), also wiederholen ihn die folgenden REBUILDs. REORGANIZE verdichtet ohne zu sperren und lässt sich unterbrechen; REBUILD baut den ganzen Index neu und sperrt ohne ONLINE die Tabelle, solange er läuft.

Neu aufbauen, ohne die Tabelle anzuhalten

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

ONLINE = ON gehört zur Enterprise-Edition (und zur Developer-Edition, die dieselbe ist, und zu Azure SQL): In Standard gibt es das nicht, und ein REBUILD hält die Schreibvorgänge an, solange er läuft —beim gruppierten Index auch die Lesevorgänge—. RESUMABLE = ON erlaubt, ihn anzuhalten und später fortzusetzen, und verlangt ONLINE = ON (ohne ergibt es Msg 11438). Und zwei Fälle, in denen ONLINE = ON scheitert: mit einem räumlichen Index auf der Tabelle und mit ALTER INDEX ALL.

Keine unsichtbaren Indizes: DISABLE
ALTER INDEX … DISABLE kommt dem am nächsten, ist aber nicht dasselbe: Es löscht die Daten des Index und behält nur seine Definition, der Weg zurück ist also ein vollständiges REBUILD. Und den gruppierten Index zu deaktivieren, macht die Tabelle unlesbar: SELECT COUNT(*) ergibt Msg 8655, und nebenbei werden alle nicht gruppierten Indizes deaktiviert, mit einer Warnung für jeden.

Die, die niemand nutzt, und die, die fehlen

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;

Die Vorsicht ist größer als in PostgreSQL: Diese View lebt im Speicher und wird beim Neustart des Servers geleert. Gemessen: Ich habe den Container neu gestartet, und die View kam ohne eine einzige Zeile der Tabelle zurück. Den Index zu deaktivieren setzt sie ebenfalls auf null; ein REBUILD nicht. Ein Index, der an einem Montag 0 Lesevorgänge zeigt, kann einer sein, den noch niemand nutzen konnte.

Die andere Hälfte sind die fehlenden: Der Optimierer notiert in sys.dm_db_missing_index_details den Index, den er gewollt hätte. Nach einer Abfrage auf cliente_id und estado schlug er [cliente_id], [estado] mit geschätzten 99,6 % Wirkung vor. Das ist ein Hinweis, kein Befehl: Er schaut nicht auf die Indizes, die du schon hast, gewichtet keine Schreibvorgänge und lebt im selben Speicher, der geleert wird.

Empfehlung
Ein schmaler, stetig wachsender gruppierter Schlüssel; ein Index für jeden Fremdschlüssel, über den gesucht oder gelöscht wird; INCLUDE vor einem weiteren Index, wenn der Plan einen Key Lookup zeigt; und bevor du einen „ungenutzten“ Index löschst, nachsehen, wie lange der Server schon läuft (sqlserver_start_time in sys.dm_os_sys_info).

Stichwörter: Index, Indizes, gruppiert, clustered, nicht gruppiert, nonclustered, Heap, key lookup, include, gefilterter Index, berechnete Spalte, columnstore, fillfactor, Fragmentierung, reorganize, rebuild, online, resumable, disable, dm_db_index_usage_stats, dm_db_missing_index_details, dm_db_index_physical_stats

Grenzen (SQL Server)

Die Grenzen, an die man wirklich stößt —8 060 Byte pro Zeile, 900 und 1 700 Byte Indexschlüssel, 1 024 Spalten, 128 Zeichen pro Name, 2 100 Parameter—, welche beim Anlegen der Tabelle nur warnen und später scheitern, und die der Express-Edition.

Gilt für: SQL Server 2022+

Die Grenzen von SQL Server ähneln denen von InnoDB kaum, und die, die im Alltag beißen, sind nicht die großen. Ich habe sie einzeln gegen SQL Server 2025 ausgelöst.

Die, an die man wirklich stößt

GrenzeWertWas beim Überschreiten passiert
Byte pro Zeile (innerhalb der Seite)8 060Msg 1701, wenn die festen Spalten nicht passen
Schlüssel eines gruppierten Index900 ByteWarnung beim Anlegen; Msg 1946 beim Einfügen
Schlüssel eines nicht gruppierten Index1 700 ByteWarnung beim Anlegen; Msg 1946 beim Einfügen
Spalten pro Tabelle1 024Msg 1702
Spalten im Schlüssel eines Index32Msg 1904
Nicht gruppierte Indizes pro Tabelle999Msg 1910
Länge eines Namens128 ZeichenMsg 103
Name einer temporären #-Tabelle116 ZeichenMsg 193
Parameter pro Prozedur oder Anfrage2 100Msg 180
Verschachtelung von Prozeduren, Funktionen und Triggern32 EbenenMsg 217
Spalten eines SELECT4 096Msg 1056

Die Zeile passt in eine Seite, außer dem Variablen
Eine Seite hat 8 KB, und eine Zeile muss hineinpassen: 8 060 Byte. Bei Spalten fester Breite prüft der Server das beim Anlegen der Tabelle: zwei char(5000) ergeben Msg 1701, die sagt, dass die minimale Zeile 10 007 Byte groß wäre.

Bei variablen Spalten nicht. Ich habe zwei volle varchar(8000) in derselben Zeile gespeichert —16 000 Byte— ohne jeden Fehler: Was nicht passt, wandert auf eigene Seiten, ROW_OVERFLOW_DATA. sys.allocation_units zeigte 2 Zeilenseiten und 2 Überlaufseiten. Das funktioniert, aber das Lesen dieser Zeile kostet einen Lesevorgang mehr pro übergelaufener Spalte, und am Entwurf sieht man nicht, dass es passiert.

SELECT au.type_desc, SUM(au.used_pages) AS seiten
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.meine_tabelle')
GROUP BY au.type_desc;

varchar(max), nvarchar(max) und varbinary(max) reichen bis 2 GB und verlassen die Zeile auf demselben Weg. Warum es varchar(8001) nicht gibt und man zu max springen muss, erklärt das Thema Datentypen.

Die Grenze, die nur warnt: der Indexschlüssel
Das ist die überraschendste. Ein Index auf eine Spalte, die breiter als die Grenze ist, wird angelegt, mit einer Warnung:

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

Eine Warnung hält kein Deployment auf. Die Tabelle funktioniert bis zu dem Tag, an dem jemand einen langen Wert speichert: 900 Byte gingen durch, 901 ergaben Msg 1946. Bei einem nicht gruppierten Index liegt die Grenze bei 1 700 Byte, und mit nvarchar sind das 850 Zeichen, nicht 1 700, weil jedes zwei Byte belegt. Ein PRIMARY KEY auf varchar(1000) ist ein gruppierter Index und verhält sich genauso.

Braucht eine lange Spalte eine Suche auf Gleichheit, funktioniert es, eine Zusammenfassung zu indizieren: eine berechnete Spalte mit CHECKSUM oder HASHBYTES, indiziert, und zusätzlich den vollständigen Wert vergleichen.

Namen: ein Fehler, kein Abschneiden
Ein Name mit 129 Zeichen ergibt Msg 103, und die Anweisung wird nicht ausgeführt. PostgreSQL schneidet auf 63 Byte ab und macht weiter; SQL Server lässt den Namen nicht durch. Der einer lokalen temporären Tabelle ist kürzer, 116, weil der Server ein Suffix anhängt, um Sitzungen zu unterscheiden.

Parameter: 2 100, und in der Praxis 2 098
Eine Prozedur nimmt 2 100 Parameter an, und ein Aufruf kann nicht mehr übergeben. Aber Treiber und ORMs schicken parametrisierte Abfragen über sp_executesql, und dort zählen die Anweisung und die Deklaration mit: Mit 2 098 Parametern ging es, mit 2 099 kam Msg 180. Das ist der typische Fehler eines WHERE id IN (@p1, @p2, …), das aus einer Liste erzeugt wird, die eines Tages wächst. Der Ausweg ist nicht, die Liste zu zerstückeln, sondern sie als Tabelle zu übergeben (ein tabellenwertiger Parameter oder eine temporäre Tabelle) und einen JOIN zu machen. Ein IN mit 60 000 in die Anweisung geschriebenen Literalen funktionierte dagegen.

Mehr Spalten: SPARSE mit Spaltensatz
1 025 Spalten ergeben Msg 1702. Mit SPARSE-Spalten und einem COLUMN_SET FOR ALL_SPARSE_COLUMNS habe ich eine Tabelle mit 2 002 Spalten angelegt; dieselben Spalten ohne den Satz ergaben dieselbe Msg 1702. Die Dokumentation nennt das eine „breite Tabelle“ und hebt die Grenze auf 30 000 Spalten, aber die Zeile muss weiterhin in 8 060 Byte passen.

Verschachtelung: 32 Ebenen
Eine Prozedur, die sich selbst aufruft, erreichte @@NESTLEVEL 32, und die nächste Ebene ergab Msg 217. Prozeduren, Funktionen, Trigger und Views zählen zusammen: Ein Trigger, der eine andere Tabelle mit Trigger aktualisiert, verbraucht Ebenen, ohne dass man es sieht.

Die großen, die fast nie das Problem sind
Ich habe sie nicht gemessen —das Testbed ist die Developer-Edition, und sie zu messen hieße, eine Platte zu füllen—; sie stehen hier mit der Zahl aus der Dokumentation von Microsoft:

- Größe einer Datenbank: 524 272 TB. Einer Datendatei: 16 TB.
- Zeilen pro Tabelle: keine Grenze außer dem Speicher.
- Größe eines Batches: 65 536-mal die Netzwerkpaketgröße (standardmäßig 4 096 Byte, also 256 MB).
- Verbindungen: 32 767 (@@MAX_CONNECTIONS), und user connections auf 0 heißt, dass es keine weitere Obergrenze gibt.

Die, die die Editionen setzen
Hier gibt es Grenzen, an die man stößt, und sie kommen von der Lizenz, nicht von der Engine. Auch die habe ich nicht gemessen: Das Testbed ist Developer, und die hat keine.

- Express: 10 GB pro Datenbank in SQL Server 2022 und 50 GB in 2025; etwa 1,4 GB Speicher für den Pufferpool; das Kleinere aus einem Sockel oder vier Kernen. Ist die Größe erreicht, wächst die Datenbank nicht mehr, und Schreibvorgänge scheitern.
- Standard: keine Obergrenze für die Datenbankgröße, aber eine für Speicher und Kerne, und ohne einige Enterprise-Funktionen, etwa REBUILD mit ONLINE = ON.

SELECT SERVERPROPERTY('Edition') sagt, welche der Server hat, mit dem du verbunden bist.

Empfehlung
Drei im Auge behalten: den Indexschlüssel, wenn eine Textspalte indiziert wird, weil er nur warnt; die 2 098 Parameter, wenn etwas IN-Listen erzeugt; und die Datenbankgröße, wenn der Server Express ist. Vom Rest erfährt man über die Fehlernummer, die immer sagt, welche Grenze es ist.

Stichwörter: Grenzen, Maximum, 8060, Zeile, Seite, row_overflow_data, Überlauf, Indexschlüssel, 900 Byte, 1700 Byte, 1024 Spalten, sparse, column_set, 128 Zeichen, Bezeichner, temporäre Tabelle, 2100 Parameter, sp_executesql, nestlevel, Verschachtelung, express, 10 gb, 50 gb, Edition, msg 1946, msg 1702, msg 180

Sperren und Verklemmungen (SQL Server)

Warum hier ein SELECT auf ein UPDATE wartet, was READ_COMMITTED_SNAPSHOT ändert, wie man sieht, wer wen blockiert, wo der Graph eines 1205 liegt und wann aus Zeilensperren eine Tabellensperre wird.

Gilt für: SQL Server 2022+

Wenn du von MySQL oder PostgreSQL kommst, stolperst du zuerst darüber: In SQL Server wartet ein SELECT standardmäßig auf ein UPDATE. InnoDB und PostgreSQL lesen die letzte bestätigte Version der Zeile und machen weiter; SQL Server verlangt unter einfachem READ COMMITTED eine gemeinsame Sperre und wartet, bis die andere Seite bestätigt oder zurückrollt. Alles, was ich hier erzähle, habe ich gegen SQL Server 2025 gemessen, mit zwei gleichzeitig offenen Sitzungen.

Ein SELECT, das wartet

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

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

Sitzung B stand 7,5 s still, genau so lange, wie A für das ROLLBACK brauchte, und wartete mit LCK_M_S auf den Schlüssel dieser Zeile. Und A muss dabei nichts tun: Eine Sitzung, die eine Transaktion geöffnet, geschrieben und dann Ruhe gegeben hat —status sleeping, open_transaction_count 1—, blockiert genauso. Das ist der Alltagsfall: eine Anwendung, die ihr COMMIT vergessen hat.

READ_COMMITTED_SNAPSHOT: lesen wie die anderen beiden

ALTER DATABASE meine_db SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

Mit dieser Option liest READ COMMITTED die letzte bestätigte Version der Zeile, und derselbe Lesevorgang kam sofort mit dem alten Wert zurück (100, nicht die unbestätigte 50). Drei Dinge, bevor du sie einschaltest:
- Sie gehört zur Datenbank, nicht zur Sitzung, und ist ab Werk aus: In einer frisch angelegten Datenbank, in model und in der demo meines Labors ist is_read_committed_snapshot_on 0.
- Ohne WITH ROLLBACK IMMEDIATE wartet das ALTER, bis keine andere Sitzung mehr in der Datenbank ist: Mit einer verbundenen wartete es 14,5 s, so lange, bis sie ging. Mit der Klausel wirft es die anderen hinaus und rollt ihre Transaktionen zurück.
- Sie ändert nur, wer liest. Zwei Schreibvorgänge auf dieselbe Zeile warten weiter aufeinander: Das UPDATE von Sitzung B wartete genau wie vorher.

Wer blockiert wen
Gefragt wird, solange die Blockierung andauert:

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();

Die erste beantwortet die Frage, die man wirklich hat: blocking_session_id ist die Sitzung, die festhält, was diese hier anfordert. Die zweite zeigt die ganze Leiter: Wer eine Zeile aktualisiert, hält X auf dem Schlüssel und IX auf der Seite und auf der Tabelle, und wer wartet, erscheint mit request_status WAIT. Anders als in PostgreSQL erscheinen Zeilensperren hier in der Liste, eine pro Zeile.

Die Prozessliste von Calíope zeigt das Warten in der Spalte Status —eine blockierte Sitzung erscheint mit LCK_M_S, LCK_M_U oder LCK_M_X—, aber nicht, wer sie blockiert: Das liefert die erste Abfrage. Und Kill Connection ist hier der einzige Weg, eine fremde Sperre zu lösen: KILL schließt die ganze Sitzung und rollt ihre Transaktion zurück, weil SQL Server nicht erlaubt, nur die Abfrage einer anderen Sitzung abzubrechen.

UPDLOCK, das hiesige FOR UPDATE
Ein SELECT … FOR UPDATE gibt es nicht. Diese Arbeit erledigt ein Hinweis:

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

Er nimmt eine U-Sperre: Ein normaler Lesevorgang einer anderen Sitzung ging ohne Warten durch, ein weiterer mit UPDLOCK blieb stehen. Und er verhindert die dümmste Verklemmung von allen: Zwei Sitzungen unter REPEATABLE READ, die dieselbe Zeile lesen und danach aktualisieren, endeten in einem 1205; mit UPDLOCK beim Lesen liefen beide durch, eine nach der anderen.

Auch hier wird ewig gewartet
@@LOCK_TIMEOUT ist -1, also ohne Grenze: wie lock_timeout in PostgreSQL und nicht wie die 50 s von InnoDB. Gesetzt wird es pro Sitzung, in Millisekunden:

SET LOCK_TIMEOUT 3000;

Wenn es auslöst, kommt ein Msg 1222, und es rollt nichts zurück: Die Transaktion bleibt offen, und wer es empfängt, muss sie selbst zurückrollen. Was jeder Fehler zurückrollt, erzählt das Thema „Fehler (SQL Server)“.

Absichtlich nicht warten

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

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

NOWAIT scheitert sofort mit demselben 1222, und der Batch läuft weiter. READPAST ist das hiesige SKIP LOCKED: Mit Zeile 3 von einer anderen Sitzung gesperrt, lieferte es 1, 2, 4 und 5. So verteilt man eine Arbeitswarteschlange auf mehrere Verbraucher, ohne dass sie aufeinander warten.

NOLOCK liest, was nie existiert hat

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

Mit einer anderen Sitzung mitten in einem UPDATE, das sie danach zurückrollte, lieferte dieser Lesevorgang 50: einen Saldo, der nie bestätigt wurde. NOLOCK ist READ UNCOMMITTED, und das ist nicht „schneller lesen“: Es ist lesen, was eine andere Transaktion noch wegwerfen kann. Die Dokumentation ergänzt, dass es Zeilen überspringen oder doppelt lesen kann, wenn sich Seiten während des Lesens teilen; das habe ich nicht nachgestellt. Wenn du willst, dass Lesen nicht wartet, ist die Antwort READ_COMMITTED_SNAPSHOT: Es wartet nicht und sieht nur Bestätigtes.

Die tödliche Umarmung

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

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

Eine der beiden bekommt Msg 1205 —Level 13, „… has been chosen as the deadlock victim. Rerun the transaction.“— und verliert ihre Transaktion und den Rest des Batches; die andere bestätigt. Erkannt wird das nicht sofort: In sechs Durchläufen brauchte das Opfer zwischen 0,1 und 4,8 s, nachdem sich der Zyklus geschlossen hatte. Die Dokumentation erklärt diese Spanne: Ein Monitor sucht alle 5 s nach Zyklen, und öfter, direkt nachdem er einen gefunden hat.

Welche stirbt, lässt sich festlegen. Mit SET DEADLOCK_PRIORITY HIGH in Sitzung A war B alle drei Male das Opfer; LOW passt zu einem Batch-Job, der wiederholt werden kann. Ein 1205 ist keine Störung: Die Anwendung muss diese Transaktion wiederholen.

Der Graph ist schon gespeichert
Einzuschalten gibt es nichts: Die Extended-Events-Sitzung system_health, die ab Werk läuft, speichert jede Verklemmung mit ihrem Graphen —wer starb, welche Anweisung jede ausführte und auf welchen Schlüssel welches Index sie wartete.

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';

Zwei Dinge habe ich gemessen. Die Datei hinkt hinterher: Direkt nachdem ich sechs ausgelöst hatte, enthielt sie zwei; ein paar Sekunden später alle. Und der ring_buffer derselben Sitzung, der sie sofort hat, lebt im Speicher: Nach einem Neustart des Servers hatte er die neuen und den früheren verloren, den die Datei weiter aufbewahrte.

Von der Zeile zur Tabelle: die Eskalation
Jede Sperre kostet Speicher, und wenn eine Anweisung zu viele auf einer Tabelle ansammelt, tauscht SQL Server sie gegen eine einzige auf der ganzen Tabelle. Ich habe die ersten N Zeilen einer Tabelle mit 20.000 Zeilen aktualisiert und gezählt:
- Bis 6.200 Zeilen: 6.200 X-Schlüsselsperren.
- Ab 6.300: eine, X auf der Tabelle, ohne Umweg über die Seite. Und eine andere Sitzung, die zur Zeile 20.000 wollte, die niemand angefasst hatte, wartete bis zu ihrem LOCK_TIMEOUT.

Die Zahl der Dokumentation ist 5.000; hier kam der Sprung etwas später. Ein SELECT über 8.000 Zeilen unter REPEATABLE READ endete genauso, mit einem S auf der Tabelle. Gesteuert wird das pro Tabelle:

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

Damit hielt dasselbe UPDATE aller 20.000 Zeilen 20.000 Schlüsselsperren und keine auf der Tabelle. TABLE ist der Standard; AUTO eskaliert laut Dokumentation bei einer partitionierten Tabelle auf die Partition. Ausschalten ist nicht umsonst —jede Sperre ist Serverspeicher—, und der übliche Ausweg ist ein anderer: in Stapeln von weniger als 5.000 Zeilen schreiben.

Anwendungssperren
Das Gegenstück zu den Advisory Locks von PostgreSQL: ein Name, den der Server festhält, damit zwei Prozesse deiner Anwendung nicht gleichzeitig dasselbe tun.

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

Mit der Ressource in der Hand einer anderen Sitzung lieferte es -1, statt zu warten; 0 heißt „gewährt“. Es wirft keinen Fehler: Man muss auf die Zahl schauen. Mit @LockOwner = 'Transaction', dem Standard, wird sie am Ende der Transaktion von selbst freigegeben, und außerhalb einer Transaktion angefordert lieferte es -999.

Empfehlung
Zeilen immer in derselben Reihenfolge anfassen und Transaktionen kurz halten, wie bei jeder Engine. Das Eigene hier sind drei Dinge: READ_COMMITTED_SNAPSHOT einschalten, sobald die Anwendung es verträgt, denn das lässt das Lesen nicht mehr aufs Schreiben warten und nimmt die Versuchung von NOLOCK; UPDLOCK beim Lesen, das in einem UPDATE enden wird; und die Wiederholung bei 1205 geschrieben, bevor es in Produktion geht.

Stichwörter: sperre, lock, deadlock, verklemmung, blockierung, 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, eskalation, lock_escalation, system_health, sp_getapplock

Schemaänderungen im laufenden Betrieb (SQL Server)

DDL ist transaktional, teuer ist die Sch-M-Sperre und die Warteschlange, die sie bildet; welche ALTER die Tabelle neu schreiben, ONLINE, RESUMABLE und WAIT_AT_LOW_PRIORITY, und warum WITH NOCHECK die Sperre hier nicht leichter macht.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst, zuerst das: DDL ist hier transaktional, wie in PostgreSQL. Wenn du von PostgreSQL kommst, ändern sich der Name der Sperre und ein paar Werkzeuge, die es dort nicht gibt. Alles, was ich hier erzähle, habe ich gegen SQL Server 2025 gemessen, auf einer Tabelle mit 200.000 Zeilen.

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;

Nach diesem ROLLBACK war nichts mehr übrig: weder die Spalte noch der Index noch die Tabelle, und die Spalte hieß wieder nota. Eine ganze Migration passt in eine Transaktion. Die Ausnahmen, die ich gefunden habe: CREATE DATABASE und ALTER DATABASE (Msg 226) und ein RESUMABLE-Index (Msg 574) dürfen nicht hinein.

Teuer ist das Sch-M, und die Warteschlange, die es bildet
Jedes ALTER TABLE verlangt eine Schemaänderungssperre, Sch-M, die mit allem kollidiert, auch mit einem SELECT. Gefährlich ist nicht das ALTER, das Millisekunden dauern kann, sondern das Warten. Mit einer anderen Sitzung mitten in einer Transaktion, die eine Zeile angefasst hatte:
- Das ALTER TABLE … ADD wartete 7 s, mit wait_type LCK_M_SCH_M.
- Ein SELECT auf eine andere Zeile, dahinter gestartet, wartete 6 s mit LCK_M_SCH_S, und seine blocking_session_id war nicht die Transaktion, sondern das ALTER.

So legt eine Änderung von einer Millisekunde eine ganze Anwendung still. Das Gegenmittel ist dasselbe wie in PostgreSQL:

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

Nach 3 s bekam das ALTER Msg 1222, und das SELECT dahinter lief im selben Moment durch. Später versucht man es noch einmal.

Was die Tabelle neu schreibt und was nicht
Gemessen auf 200.000 Zeilen (1.364 Seiten). Der Zeuge ist das Transaktionsprotokoll, das jede Anweisung geschrieben hat:

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

So liest man sie:
- Ein Standardwert, der für alle Zeilen gleich ist —eine Konstante oder SYSDATETIME(), das einmal ausgewertet wird—, landet in den Metadaten und fasst die Zeilen nicht an. NEWID() gibt jeder Zeile einen anderen, und das zwingt dazu, alle zu schreiben.
- Ein varchar zu verbreitern kostet nichts. Es zu verkleinern, die Familie zu wechseln (nvarchar, max), eine Ganzzahl zu vergrößern, auf NOT NULL zu gehen oder die Größe eines decimal auf der Platte zu ändern schreibt neu. decimal(10,2) und decimal(12,2) belegen gleich viel, 9 Bytes, und deshalb schrieb diese Änderung nichts neu.
- Was neu schreibt, hinterlässt die Tabelle doppelt so groß. Nach jeder dieser Anweisungen ging die Tabelle von 1.364 auf 2.723 Seiten und blieb dort: Die alte Spalte belegt weiter ihren Platz in jeder Zeile. Nach int → bigint brachte ein ALTER TABLE dbo.pedidos REBUILD sie auf 1.495. Und ein DROP COLUMN ist sofort fertig, weil es nichts freigibt: 1.364 Seiten vorher und nachher, und 1.075 nach dem REBUILD.

Zwei Hindernisse, die PostgreSQL dir nicht in den Weg legt:
- Eine indizierte Spalte wechselt den Typ nicht. Mit einem Index auf estado klappte varchar(100), weil es nicht neu schreibt; varchar(30) und nvarchar(100) ergaben Msg 5074 („The index 'ix_estado' is dependent on column 'estado'“). Man löscht den Index, ändert die Spalte und legt ihn neu an.
- Ein DEFAULT ist eine benannte Einschränkung, und solange sie existiert, lässt sich die Spalte nicht löschen: dieselbe Msg 5074, die eine Einschränkung DF__pedidos__moneda__… nennt, die SQL Server selbst getauft hat. Man entfernt sie vorher mit ALTER TABLE … DROP CONSTRAINT, und deshalb lohnt es sich, ihr beim Anlegen einen Namen zu geben.

Die Dokumentation ergänzt eine Editionsbedingung: Dass ein ADD COLUMN … NOT NULL DEFAULT nur die Metadaten anfasst, ist eine Enterprise-Funktion; in Standard schreibt es die Tabelle neu. Mein Labor läuft mit Developer, das alles von Enterprise hat, deshalb konnte ich das nicht messen.

Online-Indizes: ONLINE = ON

ALTER INDEX ALL ON dbo.grande REBUILD WITH (ONLINE = ON);
CREATE INDEX ix_g ON dbo.grande (g) WITH (ONLINE = ON);

Auf 3.000.000 Zeilen dauerte der Online-Neuaufbau 47 s, und ein mittendrin gestartetes UPDATE war in einer halben Sekunde fertig. Ohne ONLINE bleibt die Tabelle die ganze Zeit gesperrt. Auch ein ALTER COLUMN akzeptiert es (ALTER TABLE … ALTER COLUMN n bigint NOT NULL WITH (ONLINE = ON)): Es brauchte 395 ms statt 228, ließ die Tabelle aber bei 1.483 Seiten, statt sie zu verdoppeln.

Drei Grenzen:
- Mit einem räumlichen Index auf der Tabelle scheitert ALTER INDEX ALL … WITH (ONLINE = ON) mit Msg 153, und der räumliche Index selbst lässt sich auch nicht online neu aufbauen. Der Primärschlüssel und die normalen Indizes, einer nach dem anderen, schon.
- ONLINE heißt nicht sperrfrei: Zu Beginn verlangt es eine S-Sperre auf der Tabelle und laut Dokumentation am Ende ein kurzes Sch-M. Mit einer offenen Transaktion auf der Tabelle wartete der Online-REBUILD auf sein S, und ein UPDATE, das dahinter kam, wartete 6,6 s in der Schlange (LCK_M_IX). Ein SELECT kam durch.
- Laut Dokumentation ist ONLINE = ON Enterprise: In Standard ist es ein Fehler.

WAIT_AT_LOW_PRIORITY: warten, ohne eine Schlange zu bilden
Das hat PostgreSQL nicht. Der Neuaufbau wartet auf seine Sperre in einer eigenen Schlange, ohne denen im Weg zu stehen, die hinter ihm kommen:

ALTER INDEX ALL ON dbo.pedidos REBUILD WITH (ONLINE = ON (
    WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF)));

Mit derselben offenen Transaktion lief das UPDATE dahinter in 0,5 s durch, und der REBUILD wartete sichtbar auf LCK_M_S_LOW_PRIORITY. Ist MAX_DURATION abgelaufen, entscheidet ABORT_AFTER_WAIT:
- SELF: Der REBUILD gibt mit Msg 1222 auf, und die Transaktion läuft weiter. Das ist die vorsichtige Wahl.
- BLOCKERS: Nach der Minute wurde die blockierende Sitzung getrennt („… disconnected because of a high priority DDL operation“) und ihre Transaktion zurückgerollt; der REBUILD lief zu Ende.
- NONE: Er wartet ohne Grenze mit niedriger Priorität.

ALTER INDEX … REBUILD akzeptiert es und auf meinem 2025 auch CREATE INDEX … WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY …)). ALTER TABLE nicht: Mit ADD, ALTER COLUMN oder ADD CONSTRAINT ist es ein Syntaxfehler (Msg 155). Für diese bleibt nur LOCK_TIMEOUT.

Fortsetzbare Indizes: RESUMABLE = ON

CREATE INDEX ix_relleno ON dbo.grande (relleno, g) WITH (ONLINE = ON, RESUMABLE = ON);
-- aus einer anderen Sitzung:
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;

Ich habe den Aufbau nach 4 s angehalten: Die Sitzung, die ihn gestartet hatte, wurde getrennt (Msg 1219), sys.index_resumable_operations zeigte PAUSED und 5 %, und den Index gab es noch nicht —er stand nicht in sys.indexes—. RESUME erledigte den Rest in 45 s. So lässt sich ein riesiger Index auf mehrere Wartungsfenster verteilen. Es verlangt ONLINE = ON (ohne das Msg 11438), passt in keine Transaktion (Msg 574) und gilt laut Dokumentation seit SQL Server 2022 auch für ALTER TABLE … ADD CONSTRAINT eines Primär- oder Unique-Schlüssels; auf 2025 habe ich es mit einem Unique-Schlüssel geprüft.

WITH NOCHECK: Hier macht es die Sperre nicht leichter
Das Muster NOT VALID + VALIDATE aus PostgreSQL hat hier seinen Zwilling, aber es tut nicht dasselbe. Auf 3.000.000 Zeilen:

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

Sie in einem Schritt anzulegen dauerte 375 ms. Und alle drei nahmen Sch-M auf der Tabelle, die Prüfung eingeschlossen: Das Aufteilen verteilt den Durchlauf, aber für die zweite Hälfte gibt es keine schwächere Sperre, wie es sie in PostgreSQL gibt.

Und es hinterlässt eine Falle: Eine mit WITH NOCHECK angelegte Einschränkung ist nicht vertrauenswürdig (is_not_trusted = 1), und der Optimierer verlässt sich nicht auf sie. Mit einem vertrauenswürdigen CHECK (importe >= 0) las WHERE importe < 0 die Tabelle gar nicht; ohne Vertrauen 1.364 logische Lesevorgänge. Dasselbe passiert, wenn man sie deaktiviert und ohne WITH CHECK wieder aktiviert —ALTER TABLE … CHECK CONSTRAINT ck_importe ließ sie nicht vertrauenswürdig—, was Massenladevorgänge gern tun. So findest du sie:

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
Die Operation OPTIMIZE in der Tabellenwartung ist in SQL Server ein ALTER INDEX ALL … REBUILD ohne ONLINE: Sie sperrt die Tabelle, solange sie neu aufbaut. Bei einer großen Tabelle in Produktion schreibst du den REBUILD WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY …)) besser selbst im Editor.

Empfehlung
SET LOCK_TIMEOUT vor jedem ALTER TABLE; die Migration in einer Transaktion; ONLINE = ON mit WAIT_AT_LOW_PRIORITY … SELF für Indizes; ein eigener Name für jedes DEFAULT; und Vorsicht bei Typänderungen, die die Tabelle neu schreiben und sie bis zum nächsten REBUILD doppelt so groß lassen. Musst du eine große Spalte verkleinern oder ihre Familie wechseln, ist es meist besser, die neue hinzuzufügen, in Stapeln zu kopieren und mit sp_rename umzubenennen.

Stichwörter: ddl, alter table, migration, transaktional, rollback, sch-m, lck_m_sch_m, lock_timeout, online, resumable, wait_at_low_priority, abort_after_wait, rebuild, neu schreiben, add column, alter column, drop column, with nocheck, is_not_trusted, sp_rename

Partitionierung (SQL Server)

Partitionsfunktion und -schema, was der Optimierer wirklich ausschließt, warum ein eindeutiger Index den Schlüssel enthalten muss, SWITCH und TRUNCATE zum Bereinigen in Millisekunden, der vertrauenswürdige CHECK, den das Laden von Daten verlangt, und SPLIT und MERGE, die nur auf einer leeren Partition kostenlos sind.

Gilt für: SQL Server 2022+

Wenn du von MySQL oder PostgreSQL kommst, ändert sich die Form: Hier ist eine Partition keine Tabelle, sondern ein Stück einer Tabelle, und die Aufteilung beschreiben zwei Objekte, die vor der Tabelle angelegt werden. Alles, was ich hier erzähle, habe ich gegen SQL Server 2025 gemessen, mit 300.000 Verkäufen über drei Jahre.

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);

Die Funktion setzt die Grenzen, und das Schema sagt, in welcher Dateigruppe jedes Stück liegt. Drei Grenzen ergeben vier Partitionen: alles vor 2024, je ein Jahr in den beiden nächsten und alles ab 2026. Es gibt weder LIST noch HASH: nur Bereiche. Und die Zukunft musst du nicht vorhersehen: Was vor der ersten oder nach der letzten Grenze liegt, hat immer einen Platz.

RANGE RIGHT oder RANGE LEFT: auf welcher Seite die Grenze liegt
Mit RIGHT gehört die Grenze zur Partition rechts von ihr, und das ist bei Datumswerten das Natürliche: $PARTITION.pf_anio('2023-12-31') lieferte 1 und '2024-01-01' 2. Mit LEFT und denselben Grenzen landete der 1. Januar in der Partition von 2023. Nichts schlägt fehl: Die Daten eines Tages landen einfach im falschen Jahr. $PARTITION taugt auch zum Zählen:

SELECT $PARTITION.pf_anio(creado) AS particion, COUNT(*) AS filas
FROM dbo.ventas GROUP BY $PARTITION.pf_anio(creado);

Um den Partitionsausschluss geht es
Der tatsächliche Plan (SET STATISTICS XML ON, unter RunTimePartitionSummary) sagt, wie viele Partitionen jede Abfrage gelesen hat:

FilterPartitionenLesevorgänge
creado BETWEEN '2024-03-01' AND '2024-03-31'138
derselbe Bereich mit einer Variablen oder einem Parameter138
cliente = 424 von 4—
YEAR(creado) = 20244 von 41.238
CAST(creado AS datetime) zwischen zwei Datumswerten4 von 446

Zwei Dinge lassen sich daraus lesen:
- Auch mit Variablen und Parametern wird ausgeschlossen, weil das bei der Ausführung entschieden wird, nicht beim Kompilieren. Deshalb nennt der geschätzte Plan keine Zahl: Er zeigt [PtnId1000] >= RangePartitionNew(…), wenn der Schlüssel im Filter steht, und [PtnId1000] >= (1) AND [PtnId1000] <= (4), wenn nicht.
- Eine Funktion auf der Spalte versteckt sie, genau wie vor einem Index: YEAR(creado) las alle vier. Der von Hand geschriebene Bereich (>= '2024-01-01' AND < '2025-01-01') liest eine.

Die Entwurfsregel ist dieselbe wie in den anderen Engines: Der Partitionsschlüssel ist die Spalte, nach der du fast immer filterst. Nennen die Abfragen sie nicht, spart die Partitionierung keine Lesevorgänge: Sie verteilt sie nur.

Ausgerichtete Indizes und Eindeutigkeit
Ein Index ohne ON erbt das Schema der Tabelle: Er ist ausgerichtet, mit einem Stück pro Partition. Die Folge ist dieselbe wie in PostgreSQL:

CREATE UNIQUE INDEX ux_id ON dbo.ventas (id);

lieferte Msg 1908: Die Partitionsspalte muss im Schlüssel eines eindeutigen Index stehen. Hier gibt es einen Ausweg, den PostgreSQL nicht hat —ihn nicht ausgerichtet anzulegen, ON [PRIMARY]—, aber er hat seinen Preis: Mit so einem Index funktioniert der SWITCH weiter unten nicht mehr (Msg 7733). Deshalb ist der Primärschlüssel von dbo.ventas (creado, id) und nicht (id).

SWITCH und TRUNCATE: ein Jahr in Millisekunden bereinigen
Ganz 2023 löschen, 100.009 Zeilen, gemessen innerhalb einer Transaktion:

OperationZeitProtokoll
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 bewegt keine Daten: Es übergibt ein ganzes Stück an einen anderen Besitzer. Deshalb verlangt es viel von der Zieltabelle, und jede Anforderung hat ihren Fehler:
- Leer: mit einer Zeile Msg 4905.
- Gleiche Spalten und gleiche Indizes: mit einer Spalte zu viel Msg 4943.
- Und laut Dokumentation in derselben Dateigruppe wie die Partition.

Zum Laden von Daten braucht es einen vertrauenswürdigen CHECK
Hier liegt der Unterschied zu PostgreSQL, wo der CHECK nur einen Durchlauf spart. Die Tabelle ohne ihn in ihre Partition zurückzuschalten dauert nicht länger: Es wird abgelehnt, mit Msg 4982. Mit der Einschränkung ging es 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;

Und sie muss vertrauenswürdig sein: Deaktiviert und ohne WITH CHECK wieder aktiviert —was viele Massenladevorgänge tun—, lieferte der SWITCH Msg 4972. Nach WITH CHECK CHECK CONSTRAINT ging es. So lädt man einen neuen Zeitraum, ohne die laufende Tabelle anzufassen: eine separate Tabelle füllen, ihr den CHECK geben und den SWITCH ausführen.

SPLIT und MERGE: kostenlos nur auf einer leeren Partition
Die Grenzen verschiebt man über die Funktion:

ALTER PARTITION SCHEME ps_anio NEXT USED [PRIMARY];
ALTER PARTITION FUNCTION pf_anio() SPLIT RANGE ('2027-01-01');
OperationZeitProtokoll
SPLIT auf der leeren Partition< 1 ms3,5 kB
SPLIT RANGE ('2024-07-01') auf der vollen von 2024347 ms11 MB
MERGE dieser beiden Hälften167 ms6,2 MB
MERGE mit der leeren Partition< 1 ms3,3 kB

Eine volle Partition zu teilen verschiebt die Zeilen auf einer Seite der Grenze, und jede schreibt Protokoll: Die Kosten wachsen mit dem, was sich bewegt. Die gesunde Gewohnheit ist, immer eine leere Partition am Ende zu haben und sie zu teilen, bevor die Daten des nächsten Zeitraums kommen. Und jedes SPLIT verbraucht das NEXT USED: Ohne es neu zu deklarieren, lieferte das nächste Msg 7710 und änderte nichts.

Wartung pro Partition
Neu aufgebaut wird nur die Partition, die sich ändert:

ALTER INDEX pk_ventas ON dbo.ventas REBUILD PARTITION = 2 WITH (ONLINE = ON);

33 ms für eine Partition (148 ms online) gegenüber 214 ms für alle vier. Ein abgeschlossenes Jahr bekommt keine Schreibvorgänge mehr: Es gibt keinen Grund, es jede Nacht neu aufzubauen.

Sperrausweitung pro Partition
Standardmäßig (LOCK_ESCALATION = TABLE) weitete ein UPDATE von 16.440 Zeilen aus 2024 auf eine X-Sperre der ganzen Tabelle aus, und das Aktualisieren einer Zeile von 2025 aus einer anderen Sitzung lieferte Msg 1222. Mit

ALTER TABLE dbo.ventas SET (LOCK_ESCALATION = AUTO);

blieb die Ausweitung bei der Partition (ein X auf HOBT), und die Zeile von 2025 wurde in 9 ms aktualisiert. Die Dokumentation warnt, dass AUTO Verklemmungen zwischen Sitzungen bringen kann, die auf verschiedenen Partitionen ausweiten; der Rest zur Ausweitung steht in „Sperren und Verklemmungen (SQL Server)“.

Auch SWITCH nimmt Sch-M
Bei einer offenen Transaktion, die eine einzige Zeile angefasst hatte, wartete der SWITCH auf sein Sch-M, und ein danach gestartetes SELECT einer anderen Zeile wartete in der Schlange hinter dem SWITCH. Das Mittel ist dasselbe wie bei Indizes:

ALTER TABLE dbo.ventas SWITCH PARTITION 1 TO dbo.ventas_2023
    WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));

Das SELECT dahinter war in einer halben Sekunde fertig, und der SWITCH gab nach 61 s mit Msg 1222 auf, ohne die Transaktion anzurühren. Der Rest zu dieser Schlange steht in „Schemaänderungen im laufenden Betrieb (SQL Server)“.

Grenzen und Editionen
Eine Funktion erlaubt 15.000 Partitionen: Mit 15.000 Grenzen kommt Msg 7719. Laut Dokumentation ist die Partitionierung seit SQL Server 2016 SP1 in allen Editionen enthalten, Express eingeschlossen.

In Calíope
Zeilen und Größe einer partitionierten Tabelle summieren alle ihre Partitionen. Dem CREATE TABLE, das Calíope schreibt —dem, das es als DDL der Tabelle zeigt, und dem, das Backup speichert—, gehen Partitionsfunktion und -schema voraus, jeweils mit IF NOT EXISTS, und es endet mit ON ps_anio (creado); ein nicht ausgerichteter Index, etwa ein nicht gruppierter Primärschlüssel auf [PRIMARY], behält sein eigenes ON. Wird dieses Backup in eine leere Datenbank wiederhergestellt, ist sie genauso partitioniert, jede Zeile in ihrer Partition; hat das Ziel schon eine Funktion mit diesem Namen, wird die vorhandene genommen, mit ihren Grenzen. Schemas vergleichen schaut sehr wohl auf die Partitionierung —wo die Tabelle liegt und die Grenzen jedes Schemas—, aber sein Skript partitioniert keine bestehende Tabelle und hebt keine Partitionierung auf: Das hieße, ihren gruppierten Index mit den Daten darin neu aufzubauen, und es schreibt das als Notiz hin, damit du es machst. Der Plan, den Calíope zeigt, ist der geschätzte, also gibt er für Partitionen die PtnId1000-Form von oben; wie viele wirklich gelesen wurden, sagt nur der tatsächliche Plan, SET STATISTICS XML ON.

Empfehlung
Partitioniere nach dem, was du löschen wirst: Der echte Gewinn ist, einen Zeitraum mit SWITCH oder TRUNCATE … WITH (PARTITIONS) zu bereinigen statt mit einem DELETE, das das Protokoll füllt. Primärschlüssel und eindeutige Indizes mit dem Partitionsschlüssel darin, damit alles ausgerichtet bleibt; RANGE RIGHT bei Datumswerten; immer eine leere Partition am Ende, um sie kostenlos zu teilen; und prüfe im tatsächlichen Plan, dass deine Abfragen Partitionen ausschließen, statt es anzunehmen.

Stichwörter: partitionierung, partition, partition function, partition scheme, range right, range left, $partition, partitionsausschluss, ausgerichtet, switch, truncate, split range, merge range, next used, lock_escalation, 1908, 4982, 7733

Leistung und Diagnose (SQL Server)

Logische Lesevorgänge statt Zeiten, der geschätzte gegenüber dem tatsächlichen Plan, Parameter-Sniffing und seine gemessenen Gegenmittel, der Plancache, den Langsame Abfragen liest, ab Werk eingeschalteter Query Store und die Wartezeiten, die wirklich etwas sagen.

Gilt für: SQL Server 2022+

Wenn du von MySQL oder PostgreSQL kommst, ändern sich hier drei Dinge: Die Zahl, die man vergleicht, sind die logischen Lesevorgänge, nicht die Zeit; der Plan wird zwischengespeichert und mit anderen Werten wiederverwendet, und das ist die Ursache der Hälfte aller „gestern ging es noch“; und das Gegenstück zu pg_stat_statements ist schon da, ohne dass man etwas einschaltet, geht aber beim Neustart verloren. Alles, was ich hier erzähle, habe ich an SQL Server 2025 gemessen, mit 200.000 Bestellungen.

Eine Abfrage messen: logische Lesevorgänge

SET STATISTICS IO, TIME ON;
SELECT COUNT(*), SUM(importe) FROM dbo.pedidos WHERE cliente_id = 4243;
SET STATISTICS IO, TIME OFF;

Jeder logische Lesevorgang ist eine 8-kB-Seite, die aus dem Speicher gelesen wird. Das ist die Zahl zum Vergleichen, denn die Zeit ändert sich mit der Last und dem Cache, sie nicht. Für die 20 Bestellungen eines Kunden:
- Ohne Index: 6.085 logische Lesevorgänge —die ganze Tabelle— und 12 ms.
- Mit einem Index auf cliente_id: 62 Lesevorgänge.

In Calíope erscheinen diese Zeilen nicht: Der Server schickt sie als Informationsmeldungen, und der Editor zeigt Zeilen und Fehler, keine Meldungen. Dieselbe Zahl lässt sich aus dem Plancache lesen, direkt nachdem die Abfrage gelaufen ist:

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;

Bei mir kam 62 heraus, dasselbe wie bei STATISTICS IO.

Der geschätzte und der tatsächliche Plan
SET SHOWPLAN_XML ON fordert den Plan an, ohne die Abfrage auszuführen. SET STATISTICS XML ON führt sie aus und fügt hinzu, was passiert ist: ActualRows neben EstimateRows bei jedem Operator. Wenn die Anweisung schreibt, packt man sie in BEGIN TRAN … ROLLBACK, denn sie läuft wirklich.

Visual EXPLAIN fordert in Calíope den geschätzten an: den Operatorbaum mit den geschätzten Zeilen, ohne tatsächliche Zeilen, ohne Zeiten und ohne Kosten. Er zeigt die Form, einen Index Scan, wo du einen Seek erwartet hast; um Schätzung und Wirklichkeit zu vergleichen, braucht es den tatsächlichen Plan.

Parameter-Sniffing
Wenn eine Prozedur oder eine parametrisierte Abfrage zum ersten Mal läuft, schaut der Optimierer auf den mitgebrachten Wert und kompiliert den Plan dafür. Dieser Plan bleibt im Cache, und spätere Ausführungen verwenden ihn wieder, auch wenn sie einen anderen Wert mitbringen. Ich habe es mit einer Prozedur und zwei Kunden nachgestellt: einer mit 20 Bestellungen, einer mit 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;
Kompiliert mitKleiner KundeGroßer Kunde
dem kleinen62 Lesevorgänge300.177 Lesevorgänge, 99 ms
dem großen6.085 Lesevorgänge6.085 Lesevorgänge

Für den kleinen Kunden kompiliert, sucht der Plan im Index und geht Zeile für Zeile zur Tabelle: perfekt für 20 Zeilen, und 300.000 Lesevorgänge für 100.000, fünfzigmal mehr als die ganze Tabelle zu durchlaufen. Andersherum kompiliert, durchläuft der kleine die ganze Tabelle. Die Reihenfolge entscheidet, und ein Neustart oder eine Statistikänderung dreht sie um: daher das „gestern ging es noch“.

Der tatsächliche Plan verrät es ohne Raten: Im schlechten Fall stand dort EstimateRows="20" und ActualRows="100000", und bei seinen Parametern ParameterCompiledValue="(4243)" neben ParameterRuntimeValue="(1)".

Was ich als Gegenmittel ausprobiert habe:
- OPTION (RECOMPILE) am Ende der Abfrage: 62 und 6.085, jeder Wert mit seinem eigenen Plan. Der Preis ist das Kompilieren bei jedem Aufruf (hier 2-3 ms), was bei einer Abfrage, die tausendmal pro Sekunde läuft, nicht gratis ist.
- OPTION (OPTIMIZE FOR UNKNOWN): kompiliert für den Durchschnittswert, etwa 20 Zeilen, und das ergab 70 und 306.429. Wenn es einen riesigen Wert gibt, hilft es nicht.
- EXEC sp_recompile 'dbo.pedidos_de' wirft den Plan der Prozedur weg: das Mittel für den Moment, nicht die Heilung.
- Seit SQL Server 2022 kann der Server ab Kompatibilitätsgrad 160 mehrere Pläne für dieselbe Abfrage je nach Wert behalten. Das ist standardmäßig eingeschaltet, aber bei 100.000 Zeilen gegenüber 20 griff es nicht. Bei 200.000 gegenüber 1 schon: zwei Pläne. Verlass dich nicht darauf.

Welche Abfrage am meisten kostet: der Plancache
Der Server führt Buch über jeden Plan im Cache: Ausführungen, Zeit, Lesevorgänge und Zeilen. query_hash gruppiert nach der Form der Abfrage: Fünf SELECT, die sich nur in der Kundennummer unterschieden, jedes für sich, hinterließen fünf Pläne im Cache (80 kB) und einen einzigen 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;

Das zeigt Calíope unter Langsame Abfragen, Ansicht Nach Anweisung: eine Zeile pro Form, mit dem Text ihres teuersten Plans, aus allen Datenbanken des Servers. Dafür braucht es die Berechtigung VIEW SERVER STATE. Und es gibt keine Historie: Ich habe den Server neu gestartet, und die Ansicht ging von 23 Zeilen auf 0. Auch dass der Plan aus dem Cache fällt, überlebt sie nicht.

Query Store, der schon eingeschaltet ist
Query Store ist die Variante, die bleibt: Er speichert Text, Pläne und Zahlen in Intervallen in der Datenbank selbst. In SQL Server 2025 fand ich ihn eingeschaltet (READ_WRITE), in demo und in einer frisch angelegten Datenbank, ohne dass jemand danach gefragt hätte. Laut Dokumentation gilt das für neue Datenbanken seit 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;

Eine Spalte planes größer als 1 ist die Spur des Parameter-Sniffings. Zwei gemessene Einschränkungen: Nach dem Neustart waren die Texte seiner 11 Abfragen noch da, aber nicht die Zahlen von vorher, weil er sie alle 900 Sekunden auf die Platte schreibt; und er gilt pro Datenbank, nicht pro Server.

Calíope verwendet ihn nicht: Langsame Abfragen liest den Plancache, der für den ganzen Server schon da ist. Es schaltet ihn weder ein noch aus, denn das ist ein administratives ALTER DATABASE.

Wartezeiten: wohin die Zeit geht
sys.dm_os_wait_stats summiert seit dem letzten Neustart, wie lange der Server gewartet hat und warum. Es gibt 1.547 Typen, und die ersten der Liste sind ungefiltert Threads, die auf Arbeit warten: DISPATCHER_QUEUE_SEMAPHORE, BROKER_TASK_STOP, die SLEEP_…. Die muss man aussortieren. Die, die wirklich etwas sagen:
- LCK_M_…: Sperren. Mehr dazu in „Sperren und Verklemmungen (SQL Server)“.
- PAGEIOLATCH_…: Warten auf Seiten von der Platte. Es fehlt Speicher oder ein Index.
- WRITELOG: Warten auf das Transaktionsprotokoll bei jedem Commit.
- CXPACKET und CXCONSUMER: Parallelität.
- SOS_SCHEDULER_YIELD: CPU.
- RESOURCE_SEMAPHORE: Abfragen, die auf Speicher zum Sortieren oder für einen Hash warten.

Das sind Summen, also nützt ein einzelnes Foto wenig: Man nimmt zwei und zieht ab. Nach meinem Neustart fiel die Summe von 12,1 auf 0,7 Millionen ms.

Parallelität
Ab Werk steht cost threshold for parallelism auf 5 und max degree of parallelism auf 0 (alle Kerne). Die Schwelle ist so niedrig, dass schon ein mittlerer Scan parallel laufen kann: Auf einer anderen Tabelle mit 200.000 Zeilen nutzte einer 16 Threads, 2.094 ms CPU für 152 ms Uhrzeit. Ein hohes CXPACKET ist kein Fehler; es ist das Zeichen, dass diese beiden Werte so geblieben sind, wie sie kamen.

Was schon in anderen Themen steht
- Ein nvarchar-Parameter gegen eine varchar-Spalte mit SQL_-Sortierfolge geht von 3 Lesevorgängen auf 334: „Kodierung und Sortierfolgen (SQL Server)“.
- Key Lookup, wann der Plan den Index für die Tabelle aufgibt, YEAR(fecha) gegenüber dem Bereich, und die Indizes, die der Server vermisst: „Indizes (SQL Server)“.
- Eine nicht vertrauenswürdige CHECK-Einschränkung nutzt der Optimierer nicht (1.364 Lesevorgänge gegenüber keinem): „Schemaänderungen im laufenden Betrieb (SQL Server)“.
- Wie viele Partitionen eine Abfrage gelesen hat, sagt nur der tatsächliche Plan: „Partitionierung (SQL Server)“.

Empfehlung
Die Reihenfolge, die funktioniert: die Abfrage in Langsame Abfragen finden (oder in Query Store, wenn du die Historie brauchst), ihren tatsächlichen Plan mit SET STATISTICS XML ON anfordern und EstimateRows mit ActualRows vergleichen. Gehen sie auseinander und ist der kompilierte Wert nicht der aktuelle, ist es Parameter-Sniffing; gehen sie beim selben Wert auseinander, sind es die Statistiken; stimmen sie überein und es wird trotzdem viel gelesen, fehlt ein Index. Speicher oder Parallelität anzufassen, bevor man einen Plan gelesen hat, ist der lange Weg.

Stichwörter: Leistung, statistics io, statistics time, logische Lesevorgänge, showplan_xml, statistics xml, Ausführungsplan, tatsächlicher Plan, Parameter-Sniffing, parameter sniffing, recompile, optimize for unknown, sp_recompile, dm_exec_query_stats, query_hash, Plancache, query store, dm_os_wait_stats, Wartezeiten, Parallelität

Sicherung und Wiederherstellung (SQL Server)

Die drei Wiederherstellungsmodelle und welches das Protokoll wachsen lässt, BACKUP DATABASE und BACKUP LOG auf der Platte des Servers, die Protokollkette, mit STOPAT zur Minute vor dem DELETE zurück, COPY_ONLY, und worin sich das Backup von Calíope unterscheidet.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst: Hier gibt es weder mysqldump noch ein separates Binlog. Die physische Sicherung ist eine T-SQL-Anweisung, und die Historie, mit der man von ihr aus weitergeht, ist das Transaktionsprotokoll jeder Datenbank. Wenn du von PostgreSQL kommst, ist der Unterschied die Einheit: Gesichert und wiederhergestellt wird eine Datenbank, nicht der ganze Server. Alles, was ich hier beschreibe, habe ich auf SQL Server 2025 gemessen.

Das Wiederherstellungsmodell entscheidet fast alles
Es ist eine Eigenschaft jeder Datenbank, und es gibt drei:

ModellWas das Protokoll behältWiederherstellung zu einem Zeitpunkt
SIMPLELeert sich bei jedem CHECKPOINT von selbstNein: nur bis zur letzten Sicherung
FULLAlles, bis du es mit BACKUP LOG sicherstJa
BULK_LOGGEDAlles außer Massenladevorgängen, die es seitenweise vermerktJa, außer innerhalb eines Ladevorgangs

Eine neue Datenbank übernimmt das Modell von model, das auf diesem Server FULL ist:

SELECT name, recovery_model_desc, log_reuse_wait_desc
FROM sys.databases;

FULL ohne Protokollsicherungen füllt die Platte
Das ist die Falle. Solange die Datenbank keine erste vollständige Sicherung hat, verhält sich FULL wie SIMPLE: Ich habe 20 000 Zeilen zu 2 kB eingefügt, das Protokoll erreichte 42,2 MB belegt, und ein CHECKPOINT brachte es auf 8,1 MB herunter. Nach dem ersten BACKUP DATABASE gab dasselbe nichts mehr frei: 55,4 MB, dann 102,8 MB, und die Datei wuchs von 72 auf 136 MB. log_reuse_wait_desc sagt, warum es nicht wiederverwendet wird:

- LOG_BACKUP — wartet auf ein BACKUP LOG. Das ist der Grund für fast jede volle Platte.
- ACTIVE_TRANSACTION — eine offene Transaktion, die niemand schließt.
- CHECKPOINT — nichts zu befürchten, beim nächsten wird es frei.

BACKUP LOG senkte den belegten Platz auf 15,1 MB, aber die Datei blieb bei 136 MB: Das Protokoll zu sichern gibt Platz darin frei, nicht an die Platte zurück. Wenn eine Datenbank in FULL ist und niemand BACKUP LOG einplant, plane es ein oder wechsle zu SIMPLE.

Sichern

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;

Der Pfad liegt auf der Platte des Servers, nicht auf deiner: Die Datei schreibt der SQL-Server-Prozess. Unter Linux ist der übliche Ordner /var/opt/mssql/backup/; im Testcontainer gab es ihn nicht, und das erste BACKUP hat ihn angelegt.

- Die vollständige Sicherung kopiert die ganze Datenbank: 150 MB in meinem Test.
- Die differenzielle kopiert, was sich seit der letzten vollständigen geändert hat: 3,1 MB nach 2 000 geänderten Zeilen.
- Die Protokollsicherung kopiert das Protokoll seit der vorherigen, und sie ist es, die einen Zeitpunkt erreichbar macht.

CHECKSUM ist nicht voreingestellt (backup checksum default ist 0), und das merkt man beim Prüfen: RESTORE VERIFYONLY … WITH CHECKSUM auf einer Sicherung ohne ihn ergab Msg 3187. Setz ihn immer, und prüfe:

RESTORE VERIFYONLY FROM DISK = '/var/opt/mssql/backup/tienda_full.bak'
    WITH CHECKSUM;

Die Protokollkette
Jede Protokollsicherung beginnt, wo die vorherige endete, und sie werden alle, in Reihenfolge wiederhergestellt. Eine auszulassen ergab Msg 4305, mit der fehlenden LSN. Zwei Dinge brechen sie ohne Warnung:

- Zu SIMPLE und zurück zu FULL wechseln: danach ergab BACKUP LOG Msg 4214 bis zur nächsten vollständigen Sicherung, obwohl es eine von vorher gab.
- Ein BACKUP LOG von jemand anderem, in eine Datei, die du nicht kennst. Die Historie steht 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: die Kopie, die nicht stört
Eine normale vollständige Sicherung wird zur Basis der folgenden differenziellen. Machst du eine außer der Reihe —um die Datenbank auf eine andere Maschine mitzunehmen—, passt die differenzielle von heute Nacht nicht mehr zur vollständigen vom Sonntag. Mit WITH COPY_ONLY passiert das nicht: Die nächste differenzielle zeigte weiter auf die vorherige vollständige, und sie über der COPY_ONLY wiederherzustellen ergab Msg 3136. Jede Kopie außerhalb der Routine mit COPY_ONLY.

Zurück zur Minute vor dem DELETE
Ich habe es komplett nachgestellt: 500 Bestellungen, vier Sekunden später ein DELETE ohne WHERE, und die Wiederherstellung in eine neue Datenbank, ohne das Original anzufassen:

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;

Alle 500 waren wieder da. Was du wissen musst:

- NORECOVERY lässt die Datenbank in RESTORING und wartet auf mehr: Sie zu lesen ergibt Msg 927. Der letzte Schritt trägt RECOVERY.
- MOVE ist Pflicht, wenn du unter anderem Namen wiederherstellst, sonst kollidiert sie mit den Dateien des Originals. Ihre logischen Namen liefert RESTORE FILELISTONLY.
- STOPAT ist Serverzeit, mit drei Nachkommastellen: mit sieben ergab es Msg 3217.
- Über die laufende Datenbank wiederherzustellen, ohne vorher das Protokollende zu sichern, ergab Msg 3159. Das Protokollende ist alles seit dem letzten BACKUP LOG; gesichert wird es mit BACKUP LOG … WITH NORECOVERY, das die Datenbank außerdem in RESTORING versetzt.

BULK_LOGGED und sein Preis
Ein SELECT … INTO einer 21-MB-Tabelle schrieb in FULL 22,1 MB Protokoll und in BULK_LOGGED 1,0 MB. Aber die nächste Protokollsicherung wog gleich viel, 22,1 MB, weil sie die geladenen Seiten mitnimmt; und diese Sicherung mit STOPAT wiederherzustellen ergab Msg 4341: In einem Intervall mit Massenladevorgang kommt man nur ans Ende, nicht zu einem Zeitpunkt. Man wechselt für den Ladevorgang zu BULK_LOGGED und gleich danach zurück zu FULL, mit einem BACKUP LOG auf beiden Seiten.

Auf einem anderen Server wiederherstellen
Eine Datenbanksicherung enthält ihre Benutzer, aber nicht die Server-Logins, die in master leben. Auf einer anderen Maschine wiederhergestellt, bleibt jeder Benutzer verwaist von seinem Login; wie man sie findet und wieder verbindet, steht im Thema zur Sicherheit von SQL Server.

Was Calíope macht
Backup schreibt ein Textskript, keine .bak: Anweisungen mit GO zwischen den Batches, Zeilen in INSERTs zu je tausend, Datumswerte in ISO 8601 und Binärwerte als 0x…, damit es auf einem anderen Server, einer anderen Version oder mit der Sitzung in einer anderen Sprache gleich zurückkommt. Mit Benutzerpasswörter einbeziehen gehen die Logins als CREATE LOGIN … WITH PASSWORD = 0x… HASHED mit, und der Benutzer jeder Datenbank als CREATE USER … FOR LOGIN: Beim Wiederherstellen des Skripts bleibt niemand verwaist. Eine partitionierte Tabelle kommt partitioniert zurück: Das Skript legt vorher, falls sie fehlen, ihre Partitionsfunktion und ihr Partitionsschema an.

Aus dem SQL-Editor kannst du ein BACKUP DATABASE starten —es ist eine Anweisung wie jede andere—, aber die Datei bleibt auf der Platte des Servers: Calíope holt sie nicht.

BACKUP DATABASEBackup von Calíope
Was es istDie Seiten der DatenbankEin SQL-Skript
Wo es landetAuf der Platte des ServersWo du willst
Wiederherstellbar aufDerselben oder einer neueren Version, laut DokumentationEinem anderen Server oder einer anderen Version, und es ist lesbar
Zu einem ZeitpunktJa, mit dem ProtokollNein: Es ist der Moment des Dumps
Größe und TempoSchnell bei jeder GrößeLangsam bei großen Datenbanken

Empfehlung
Entscheide das Modell pro Datenbank: SIMPLE, wo der Verlust des heutigen Tages vertretbar ist, und FULL mit geplantem BACKUP LOG, wo nicht. Wöchentlich vollständig, täglich differenziell und alle paar Minuten das Protokoll ist die übliche Routine, immer WITH CHECKSUM. Behalte log_reuse_wait_desc im Blick, mach jede Einzelkopie mit COPY_ONLY, und stelle von Zeit zu Zeit wirklich wieder her, unter anderem Namen: Eine Sicherung, die nie wiederhergestellt wurde, ist eine Absicht. Das Backup von Calíope dient dazu, eine Datenbank oder einen Teil davon auf eine andere Maschine mitzunehmen; es ersetzt nicht die Sicherungen des Servers.

Stichwörter: sicherung, backup, restore, wiederherstellung, wiederherstellungsmodell, full, simple, bulk_logged, transaktionsprotokoll, log_reuse_wait_desc, backup log, protokollkette, norecovery, stopat, copy_only, differenziell, checksum, verifyonly, zeitpunkt, 3159, 4214, 4341

Logins, Benutzer und Berechtigungen (SQL Server)

Server-Login und Datenbankbenutzer, warum ein Benutzer nach dem Wiederherstellen auf einem anderen Server verwaist und wie man ihn wieder verbindet, Berechtigungen pro Schema, DENY und wer daran vorbeikommt, die Besitzverkettung, eigenständige Benutzer, Sicherheit auf Zeilenebene und was mit sa zu tun ist.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst: Hier trägt das Konto den Host nicht in sich und besteht außerdem aus zwei Dingen – dem Login, das zum Server gehört und mit dem man sich anmeldet, und dem Benutzer, der zu jeder Datenbank gehört und die Berechtigungen erhält. Wenn du von PostgreSQL kommst: Es ist, als müsste jede Rolle mit LOGIN in jeder Datenbank noch einmal existieren, um sie benutzen zu können. Alles, was ich hier beschreibe, habe ich gegen SQL Server 2025 gemessen.

Login und Benutzer

CREATE LOGIN ana WITH PASSWORD = 'Xq7#lunes_pw9';   -- Server: hineinkommen
USE tienda;
CREATE USER ana FOR LOGIN ana;                    -- Datenbank: drin sein

Mit dem Login und ohne den Benutzer kommt man in den Server, aber nicht in die Datenbank: Eine Verbindung, die sie nennt, ergab „Cannot open database … The login failed“ (4060), und ein USE aus master ergab Msg 916. Der Benutzer ist mit seinem Login über die sid verbunden, nicht über den Namen – daher kommen die verwaisten Benutzer.

Das Passwort durchläuft die Richtlinie (CHECK_POLICY, standardmäßig an): acht Zeichen aus drei Klassen und ohne den Login-Namen darin. 'Xq7#ana_pw9' für das Login ana ergab Msg 33064, obwohl es den Rest erfüllt.

Verwaiste Benutzer: auf einem anderen Server wiederherstellen
Ich habe es nachgestellt: eine Datenbank mit dem Benutzer ana gesichert, das Login gelöscht und mit demselben Namen neu angelegt – wie es auf einer anderen Maschine wäre – und die Datenbank wiederhergestellt. ana kam nicht mehr in die Kopie: Ihr Benutzer behielt die alte sid, und das neue Login hatte eine andere. Ihn neu anzulegen ging auch nicht: Msg 15023, der Benutzer existiert bereits. So findest du sie:

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;

Und sie wieder verbinden, ohne ihre Berechtigungen zu verlieren:

ALTER USER ana WITH LOGIN = ana;

Zwei Dinge noch. DROP LOGIN prüft nicht, ob das Login Benutzer hat: Es löschte es ohne Warnung und ließ den Benutzer in der ursprünglichen Datenbank verwaist zurück, auf demselben Server. Und damit das beim Umzug einer Datenbank auf eine andere Maschine nicht passiert, wird das Login dort mit derselben sid angelegt (CREATE LOGIN ana WITH PASSWORD = …, SID = 0x1C2F…): Damit funktionierte der Benutzer der wiederhergestellten Datenbank wieder, ohne ihn anzufassen.

Rollen
Es gibt 18 feste Serverrollen: die üblichen acht (sysadmin, serveradmin, securityadmin, processadmin, setupadmin, bulkadmin, diskadmin, dbcreator) und seit 2022 zehn feinere ##MS_…## (##MS_ServerStateReader##, um den Zustand zu sehen, ohne etwas anzufassen). In jeder Datenbank gibt es neun feste: db_owner, db_datareader, db_datawriter, db_ddladmin, db_securityadmin, db_accessadmin, db_backupoperator und die zwei, die verweigern, db_denydatareader und db_denydatawriter. Eigene Rollen legt man mit CREATE ROLE an und füllt sie mit ALTER ROLE … ADD MEMBER.

Und public, zu dem alle gehören. Auf dem Server hat es VIEW ANY DATABASE: ana sah die Namen der zehn Datenbanken des Servers, obwohl sie nur in vier hineinkam. Wenn die Namen eine Information sind, nimmt man es public weg.

Pro Schema zu berechtigen deckt auch die Tabellen von morgen ab

GRANT SELECT ON SCHEMA::rrhh TO ana;

Damit las ana die Tabellen von rrhh, und auch die, die ich danach angelegt habe. Das ist das Gegenteil von PostgreSQL, wo ON ALL TABLES nur die heutigen erreicht. Und was außerhalb des Schemas liegt, bleibt zu: dbo.pedidos ergab Msg 229.

GRANT, DENY und REVOKE: drei Zustände, nicht zwei
- GRANT gewährt.
- DENY verbietet und gilt vor jedem GRANT: Mit DENY SELECT auf rrhh.nominas bekam ana Msg 229, obwohl das Schema gewährt war, und auch als Mitglied von db_datareader.
- REVOKE löscht, was da ist, ob GRANT oder DENY. Nach dem REVOKE konnte ana wieder lesen, über die Rolle.

Wer an einem DENY vorbeikommt, habe ich einzeln gemessen:
- Ein Mitglied von db_owner: nicht, Msg 229.
- Ein Login mit CONTROL SERVER: auch nicht, Msg 229.
- Ein Mitglied von sysadmin: ja. Es betritt jede Datenbank als dbo (USER_NAME() sagt es) und es wird keine Berechtigung geprüft, also las es die verweigerte Tabelle.

Was darf ich, den Server gefragt

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 lieferte viermal SELECT: einmal für die Tabelle und einmal für jede ihrer drei Spalten. So prüft man eine wirksame Berechtigung – mit Rollen, Schemas und DENY schon aufgelöst –, ohne jemanden nach seinem Passwort zu fragen.

Die Besitzverkettung
Wenn eine View oder eine Prozedur und die Tabelle, die sie liest, denselben Besitzer haben, braucht, wer die View benutzen darf, keine Berechtigung auf die Tabelle. ana, ohne irgendetwas auf dbo.pedidos, las über die View dbo.v_totales und über die Prozedur dbo.p_total. Das ist der gute Weg, Zugriff zu geben: nur auf das, was das Objekt zeigt. Aber dynamisches SQL bricht die Kette: Dieselbe Prozedur mit einem EXEC('SELECT …') darin ergab Msg 229.

Eigenständige Benutzer
In einer eigenständigen Datenbank trägt der Benutzer sein Passwort und braucht kein Login, also reist er mit der Datenbank und verwaist nie:

EXEC sp_configure 'contained database authentication', 1;
RECONFIGURE;
CREATE DATABASE tienda CONTAINMENT = PARTIAL;
-- in tienda:
CREATE USER carla WITH PASSWORD = 'Rt5%jueves_z2';

Die Option ist standardmäßig aus. Mit ihr kam carla hinein, indem sie die Datenbank beim Verbinden nannte; ohne sie zu nennen, „Login failed“. In Calíope heißt das, die Datenbank ins Feld Datenbank der Verbindung zu schreiben.

Sicherheit auf Zeilenebene
Eine Richtlinie mit einem Filterprädikat:

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 sah ihre Zeile. Und anders als in PostgreSQL kommt hier niemand daran vorbei: sa und ein sysadmin sahen null Zeilen, weil USER_NAME() für sie dbo ist. Das Prädikat entscheidet, und wenn der Administrator alles sehen soll, muss das Prädikat es sagen. Außerdem filtert ein Filter nur, was gelesen wird: ana fügte ohne Fehler eine Zeile eines anderen Besitzers ein. Das zu verhindern braucht ein BLOCK PREDICATE.

sa
sa ist das Login mit der sid 0x01, Mitglied von sysadmin, und seinen Namen kennt jeder, der hineinzukommen versucht. Ein falsches Passwort ergibt beim Client 18456, immer mit Status 1; der Grund steht nur im Serverprotokoll, wo derselbe Versuch mit Status 8 erscheint, falsches Passwort. Üblich ist, ein Administrations-Login mit eigenem Namen anzulegen und danach ALTER LOGIN sa DISABLE.

Was Calíope nicht tut
Calíope spricht nur SQL-Authentifizierung: weder Windows-Authentifizierung noch Microsoft Entra ID.

Was Calíope tut
Benutzer listet die Logins des Servers, mit public als weiterem Konto, und sa getrennt, markiert als Systembenutzer. Die Berechtigungen werden pro Ebene gezeigt – Server, Datenbank, Schema, Tabelle, Routine –, und ein DENY wird als dritter Zustand gezeichnet: mit eigenem Symbol, durchgestrichen und mit dem Wort DENY, nicht nur in einer anderen Farbe. Man kann es nicht abwählen, denn es zu entfernen ist kein Abwählen, sondern ein REVOKE. Konto gesperrt ist ALTER LOGIN … DISABLE. Ein Datenbankbenutzer ohne Login, verwaist oder eigenständig, erscheint nicht: Für die gibt es die Abfragen oben im SQL-Editor.

Empfehlung
Ein Login pro Person oder Anwendung, und Berechtigungen pro Schema an Rollen, nicht an Personen. sysadmin nur zum Administrieren, weil es kein DENY erreicht. Was sich über eine View oder eine Prozedur geben lässt, soll dort hindurch, und nicht mit dynamischem SQL darin. Beim Umzug einer Datenbank auf eine andere Maschine: die Logins mit ihrer sid, oder die Abfrage der verwaisten Benutzer gleich nach dem Wiederherstellen. Und sa deaktiviert.

Stichwörter: sicherheit, login, benutzer, user, rolle, role, sysadmin, db_owner, db_datareader, public, grant, deny, revoke, schema, fn_my_permissions, has_perms_by_name, verwaister benutzer, orphaned user, alter user with login, sid, eigenständiger benutzer, contained database, besitzverkettung, ownership chaining, row-level security, security policy, sa, check_policy, 229, 916, 4060, 15023, 18456, 33064

Serverkonfiguration (SQL Server)

sp_configure und RECONFIGURE, warum value und value_in_use auseinanderliegen, welche Optionen einen Neustart brauchen, die Speichergrenze, die nicht gesetzt ist, wo welches MAXDOP gewinnt, Konfiguration pro Datenbank, tempdb und mssql-conf.

Gilt für: SQL Server 2022+

Wenn du von MySQL kommst: Hier gibt es weder my.cnf noch SET GLOBAL. Was den Server betrifft, schreibt man mit sp_configure, und es wird mit RECONFIGURE wirksam; was eine Datenbank betrifft, mit ALTER DATABASE SCOPED CONFIGURATION. Wenn du von PostgreSQL kommst: sys.configurations ist dein pg_settings, und die Spalte, auf die es ankommt, ist nicht source, sondern der Unterschied zwischen value und value_in_use. Alles, was ich hier beschreibe, habe ich gegen SQL Server 2025 gemessen.

107 Optionen, davon 74 versteckt
sys.configurations hat auf diesem Server 107 Optionen, und sp_configure allein zeigt 33. Die übrigen 74 sind erweitert: Solange show advanced options nicht eingeschaltet ist, werden sie weder aufgelistet noch lassen sie sich ändern. sp_configure 'max degree of parallelism', 4 ergab Msg 15123, „does not exist, or it may be an advanced option“.

EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

Geschrieben ist nicht angewendet: value und value_in_use
sp_configure schreibt den Wert nur, und der Server bleibt beim alten, bis RECONFIGURE kommt. Nach sp_configure 'cost threshold for parallelism', 50 stand in sys.configurations value 50 und value_in_use 5, und der Server selbst warnte: „Run the RECONFIGURE statement to install“.

SELECT name, value, value_in_use, is_dynamic, is_advanced
FROM sys.configurations
WHERE value <> value_in_use;

Diese Abfrage läuft vor jedem RECONFIGURE, wegen zweier Dinge, die ich gemessen habe:
- RECONFIGURE übernimmt alles Ausstehende, nicht nur deins: Hat jemand einen Wert geschrieben und liegen lassen, geht er mit deinem hinein.
- Ein RECONFIGURE, das scheitert, lässt das Geschriebene ausstehend. Mit min server memory (MB) auf 1.024 und max server memory (MB) auf 128 ergab es Msg 5831, und die 1.024 blieben in value und warteten auf das nächste.

Einen Fall mit Passierschein gibt es: recovery interval (min) auf 120 ergab Msg 5807 („not recommended“), und RECONFIGURE WITH OVERRIDE hat es trotzdem angewendet. WITH OVERRIDE überspringt genau die Prüfung, die gerade gewarnt hat, also nimmt man es nicht aus Gewohnheit.

Die, die einen Neustart brauchen
Optionen mit is_dynamic = 0 werden mit RECONFIGURE nicht wirksam: hier 23 der 107, darunter user connections, fill factor (%), locks und priority boost. Mit sp_configure 'user connections', 500 und RECONFIGURE stand value auf 500 und value_in_use blieb bei 0, bis der Dienst neu startet.

Jede Änderung landet im Serverprotokoll, mit Uhrzeit und Sitzung: EXEC xp_readerrorlog 0, 1, N'Configuration option' listet sie mit ihrem „changed from 0 to 4“ auf.

Speicher: max server memory kommt ohne Obergrenze
max server memory (MB) steht ab Werk auf 2.147.483.647, also auf keiner Grenze: SQL Server nimmt sich Speicher für seinen Cache, bis das System ihn bittet, ihn freizugeben. Auf einem dedizierten Server setzt man eine Grenze, die dem Betriebssystem Platz lässt. Das Minimum ist 128; mit 100 ergab es Msg 15129.

EXEC sp_configure 'max server memory (MB)', 12288;   -- 12 GB von 16
RECONFIGURE;

Unter Linux gibt es darunter eine weitere Grenze, memory.memorylimitmb in mssql-conf, die laut Dokumentation ab Werk 80 % des physischen Speichers beträgt. Dieser Container sieht 6.347 MB, und mit unverändertem max server memory lag sein Ziel (committed_target_kb in sys.dm_os_sys_info) bei 5.139 MB.

Parallelität: drei Ebenen, und die nächste gewinnt
Ab Werk steht max degree of parallelism auf 0 (alle Prozessoren) und cost threshold for parallelism auf 5. Ich habe dieselbe Abfrage über 300.000 Zeilen laufen lassen und DegreeOfParallelism aus dem tatsächlichen Plan gelesen:
- Server auf 0: 15 Threads, alle Prozessoren, die er sieht.
- Server auf 4: 4.
- Datenbank auf ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 2: 2, obwohl der Server 4 sagt.
- OPTION (MAXDOP 8) an der Abfrage: 8, über Datenbank und Server hinweg.
- cost threshold for parallelism auf 50: seriell. Der Schwellenwert wird mit den Kosten des seriellen Plans verglichen (hier 7,7), nicht mit denen des parallelen.

Wie sich Parallelität in den Wartezeiten zeigt, beschreibe ich in „Leistung und Diagnose (SQL Server)“.

Konfiguration pro Datenbank
sys.database_scoped_configurations hat in demo 41 Optionen. Neben MAXDOP gibt es unter anderem LEGACY_CARDINALITY_ESTIMATION (0), PARAMETER_SNIFFING (1) und QUERY_OPTIMIZER_HOTFIXES (0). Damit ändert man das Verhalten einer Anwendung, ohne die anderen auf dem Server anzufassen.

SELECT name, value
FROM sys.database_scoped_configurations
WHERE is_value_default = 0;

tempdb
Dieser Server sieht 15 Prozessoren, und tempdb hat 8 Datendateien zu 8 MB, dazu die Protokolldatei, die in Schritten von 64 MB wachsen: Die Installation legt schon eine pro Prozessor an, bis zu acht. Sie ist die Datenbank hinter #temporären Tabellen, Sortierungen, die nicht in den Speicher passen, und den Zeilenversionen von READ_COMMITTED_SNAPSHOT.

Was nicht sp_configure ist: mssql-conf, unter Linux
Die Standardpfade für Daten, Protokoll und Sicherungen, das TLS-Zertifikat, die Speichergrenze oder die Sprache stehen in /var/opt/mssql/mssql.conf und werden mit /opt/mssql/bin/mssql-conf set … und einem Neustart geändert. Die Datei dieses Containers hat nur zwei Zeilen: TLS-Zertifikat und -Schlüssel, die SQL Server 2022 und später für striktes TLS (TDS 8.0) braucht. Unter Windows erledigt man dasselbe im SQL Server-Konfigurations-Manager.

Was Calíope zeigt
Serverinformationen listet unter Globale Variablen sys.configurations mit seinem value_in_use – was läuft, nicht was geschrieben ist und aussteht – und die Eigenschaften SERVERPROPERTY.*: Version, Edition, Sortierung. Session-Variablen sind die SET-Optionen deiner Verbindung. Calíope liest sie und ändert sie nicht: Ein sp_configure schreibst du im SQL-Editor, und nach dem RECONFIGURE erscheint es in Serverinformationen beim Aktualisieren. Langsame Abfragen zeigt in seinen Einstellungen optimize for ad hoc workloads und max server memory (MB). Und contained database authentication, die Option, die Benutzer mit eigenem Passwort zulässt, beschreibe ich in „Logins, Benutzer und Berechtigungen (SQL Server)“.

Empfehlung
Wenige, und messend. max server memory immer, mit Platz für das System; cost threshold for parallelism über 5; und MAXDOP pro Datenbank, wenn eine Anwendung es verlangt, nicht für den ganzen Server. Vor jedem RECONFIGURE die Abfrage value <> value_in_use: So wendet man an, was man will, und nicht das, was ein anderer halb fertig liegen ließ.

Stichwörter: konfiguration, 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

Transaktionen und Isolationsstufen (SQL Server)

Warum ein BEGIN TRAN in einem anderen nicht verschachtelt, wie man mit SAVE TRANSACTION zurückgeht, was jede Stufe sperrt, der 3960 von SNAPSHOT und was eine vergessene Transaktion mit dem Protokoll macht.

Gilt für: SQL Server 2022+

Eine Transaktion fasst mehrere Anweisungen zu einer Einheit zusammen, die ganz oder gar nicht angewendet wird. Wie in MySQL und PostgreSQL bestätigt SQL Server ohne explizite Transaktion jede Anweisung für sich (Autocommit). Was sich ändert, steckt im Detail, und alles, was ich hier beschreibe, habe ich gegen SQL Server 2025 gemessen.

BEGIN TRAN, nicht BEGIN
Ein alleinstehendes BEGIN öffnet einen T-SQL-Block, keine Transaktion: Der Server antwortete mit Msg 102, „Incorrect syntax near 'BEGIN'“. Man schreibt BEGIN TRAN (oder BEGIN TRANSACTION). Ein COMMIT ohne offene Transaktion ergibt Msg 3902, ein ROLLBACK Msg 3903.

Der implizite Modus
Mit SET IMPLICIT_TRANSACTIONS ON öffnet die erste Anweisung, die Daten berührt, eine Transaktion, die bis zum COMMIT offen bleibt. Ich habe es gemessen: @@TRANCOUNT war 0, ein einfaches SELECT hob ihn auf 1, und erst das COMMIT brachte ihn auf 0 zurück. Eine Sitzung, die in diesem Modus das Bestätigen vergisst, behält ihre Sperren und hält das Protokoll fest, wie ich weiter unten erkläre.

Verschachteln verschachtelt nicht
Ein BEGIN TRAN innerhalb eines anderen erhöht nur den Zähler @@TRANCOUNT. Es entscheidet die äußere Transaktion:
- Das innere COMMIT bestätigt nichts: Es senkte den Zähler von 2 auf 1, und das äußere ROLLBACK machte beide UPDATE rückgängig, auch das innere.
- Das innere ROLLBACK macht alles rückgängig: Es ließ @@TRANCOUNT auf 0, und das äußere COMMIT ergab Msg 3902, weil keine Transaktion mehr da war.
- Die innere beim Namen zu nennen hilft nicht: ROLLBACK TRAN interior ergab Msg 6401, und der Zähler blieb bei 2. Zurückgehen kann man nur bis zu einem Sicherungspunkt.

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, nichts bestätigt
ROLLBACK;                                    -- macht beide UPDATE rückgängig

Eine Prozedur, die mit einem anderen @@TRANCOUNT endet, als sie beim Eintritt hatte, ergibt Msg 266: Das erkläre ich in „Fehler (SQL Server)“.

Sicherungspunkte: SAVE TRANSACTION
Um einen Teil rückgängig zu machen, ohne die Transaktion zu verlieren, markiert man einen Punkt und kehrt zu ihm zurück. Hier schlug das doppelte INSERT mit 2627 fehl, ich kehrte zum Punkt zurück, und das COMMIT bestätigte die Zeile davor und die danach:

BEGIN TRAN;
  INSERT INTO cuentas (id, saldo) VALUES (3, 0);
  SAVE TRANSACTION antes;
  BEGIN TRY
    INSERT INTO cuentas (id, saldo) VALUES (1, 0);   -- schlägt fehl: 2627
  END TRY
  BEGIN CATCH
    ROLLBACK TRANSACTION antes;                      -- @@TRANCOUNT bleibt 1
  END CATCH
  INSERT INTO cuentas (id, saldo) VALUES (5, 5);
COMMIT;

Zwei Grenzen, die ich gemessen habe: SAVE TRANSACTION ohne offene Transaktion ergibt Msg 628, und mit XACT_ABORT ON ließ derselbe Fehler die Transaktion zum Scheitern verurteilt zurück —XACT_STATE() -1—, und die Rückkehr zum Punkt ergab Msg 3931: Es blieb nur, sie ganz rückgängig zu machen.

Ein Fehler bricht die Transaktion fast nie ab
Anders als in PostgreSQL bricht ein Anweisungsfehler —ein doppelter Schlüssel, eine Einschränkung— nur diese Anweisung ab, und die Transaktion lebt mit dem weiter, was sie schon getan hat. Ohne XACT_ABORT bestätigt ein COMMIT am Ende, was vor und nach dem Fehler kam. Mit SET XACT_ABORT ON macht derselbe Fehler alles rückgängig und beendet den Batch. Welche Fehler die Transaktion von sich aus zurückrollen, erkläre ich in „Fehler (SQL Server)“.

Die Isolationsstufen
Voreingestellt ist READ COMMITTED. Ich habe mit zwei Sitzungen gemessen: Die erste liest vier Zeilen eines Bereichs, wartet vier Sekunden und liest sie erneut; die zweite fügt in der Zwischenzeit eine Zeile in diesen Bereich ein und aktualisiert eine der gelesenen.
- READ UNCOMMITTED liest, was andere nicht bestätigt haben: Es ist das NOLOCK aus „Sperren und Verklemmungen (SQL Server)“.
- READ COMMITTED behält die Sperren auf dem Gelesenen nicht. Ohne READ_COMMITTED_SNAPSHOT, das ab Werk aus ist, wartet ein Lesen auf den Schreibenden; das erkläre ich in „Sperren und Verklemmungen (SQL Server)“.
- REPEATABLE READ hielt fünf gemeinsame Sperren (KEY S) bis zum Ende: Das UPDATE der gelesenen Zeile wartete 3 s. Das INSERT kam aber sofort durch, und das erneute Lesen zählte 5 Zeilen statt 4: eine Phantomzeile.
- SERIALIZABLE sperrte den Bereich (KEY RangeS-S): Das INSERT wartete 3 s, und das erneute Lesen zählte weiter 4.
- SNAPSHOT liest eine Momentaufnahme vom Beginn der Transaktion und blockiert niemanden, muss aber in jeder Datenbank erlaubt werden, und ab Werk ist es aus: snapshot_isolation_state_desc war OFF in demo, in model und in einer neuen Datenbank. Ohne Erlaubnis gingen SET TRANSACTION ISOLATION LEVEL SNAPSHOT und BEGIN TRAN durch, und erst das erste SELECT ergab Msg 3952.

ALTER DATABASE mi_base SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;

SNAPSHOT wartet nicht: Es bricht mit 3960 ab
Mit der Erlaubnis aktualisierte die zweite Sitzung die Zeile, ohne auf die erste zu warten, die sie nur gelesen hatte. Die erste las sie erneut und sah weiter den Wert ihrer Momentaufnahme, 100, obwohl die 101 der anderen schon bestätigt war. Als sie die Zeile aktualisieren wollte, brach der Server sie mit Msg 3960 („update conflict“) ab, und XACT_STATE() blieb bei -1. Unter SNAPSHOT ist ein 3960 normaler Betrieb: Die Anwendung muss die ganze Transaktion wiederholen. READ_COMMITTED_SNAPSHOT ist etwas anderes: Es wechselt nicht die Stufe, sondern ändert, wie READ COMMITTED liest, und das erkläre ich in „Sperren und Verklemmungen (SQL Server)“.

Eine vergessene Transaktion hält das Protokoll fest
Eine Sitzung, die innerhalb einer Transaktion schläft (status = sleeping, open_transaction_count = 1), verhindert, dass das Protokoll wiederverwendet wird. Ich habe 5 000 Zeilen eingefügt und gelöscht und einen CHECKPOINT ausgeführt, in einer Datenbank, in der der CHECKPOINT das Protokoll leert. Ohne diese Sitzung blieben 6,5 MB belegt; mit ihr 73,8 MB, und log_reuse_wait_desc sagte ACTIVE_TRANSACTION. Nach dem Schließen 5,1 MB. Den Rest des Protokollzyklus erkläre ich in „Sicherung und Wiederherstellung (SQL Server)“. So findet man sie:

DBCC OPENTRAN;   -- die älteste: SPID und Startzeit

SELECT session_id, status, open_transaction_count, last_request_end_time
FROM sys.dm_exec_sessions
WHERE open_transaction_count > 0;

Auch DDL wird zurückgerollt
ALTER TABLE, CREATE INDEX und CREATE TABLE gehören zur Transaktion, mit ihren Ausnahmen (CREATE DATABASE ergibt Msg 226): Das erkläre ich in „Schemaänderungen im laufenden Betrieb (SQL Server)“.

Was Calíope tut
Der SQL-Editor führt aus, was du ihm gibst, auf einer einzigen Verbindung, Anweisung für Anweisung, und hält bei der ersten an, die fehlschlägt. Ohne Transaktion aus dem Menü gibt er am Ende die Verbindung an den Pool zurück, und wenn eine Transaktion offen geblieben ist, rollt er sie zurück (IF @@TRANCOUNT > 0 ROLLBACK): Ein von Hand geschriebenes BEGIN TRAN und sein COMMIT gehören in dieselbe Ausführung, und was vor einem Fehler kam, wird beim Zurückgeben der Verbindung zurückgerollt. In sqlcmd oder in einer Anwendung wäre es bestätigt worden.
Für eine Transaktion über mehrere Ausführungen gibt es das Menü Transaktion in der Leiste: BEGIN sendet BEGIN TRANSACTION und reserviert eine Verbindung für den Tab, und was du dort ausführst, bleibt darin bis zum COMMIT oder ROLLBACK. Den Tab zu schließen rollt sie zurück. Rollt der Server sie selbst zurück — ein 245, ein 1205 oder jeder Fehler unter XACT_ABORT — oder beendet ein COMMIT in deinem SQL sie, erlischt das Label Aktive Transaktion, und die Statusleiste sagt es: Ab dann wird jede Anweisung sofort festgeschrieben.

Empfehlung
SET XACT_ABORT ON in jedem Batch und jeder Prozedur, die schreibt, und ein TRY…CATCH, das mit IF @@TRANCOUNT > 0 ROLLBACK; und THROW; endet. Verschachtle kein BEGIN TRAN in der Erwartung, dass das innere unabhängig ist: Dafür gibt es Sicherungspunkte. Wenn du auf SNAPSHOT wechselst, schreib die Wiederholung für 3960, bevor du wechselst. Und ab und zu DBCC OPENTRAN: Eine vergessene Sitzung warnt nicht, bis die Platte voll ist.

Stichwörter: Transaktion, commit, rollback, begin tran, @@trancount, save transaction, Sicherungspunkt, xact_abort, xact_state, implicit_transactions, Isolation, repeatable read, serializable, snapshot, allow_snapshot_isolation, 3960, 3952, 3902, 6401, 3931, active_transaction, dbcc opentran

Datentypen (ClickHouse)

Ganzzahlen mit der Größe im Namen und der UInt64, der nicht in JSON passt, exaktes Decimal, String als Bytes, die Bereiche der Datumstypen, und Nullable und LowCardinality als Hüllen, die etwas kosten.

Gilt für: ClickHouse 25.3+

In ClickHouse entscheidet der Typ, wie viel Platz jeder Wert auf der Platte braucht und wie er sich komprimiert; man wählt ihn also sorgfältiger als in einer transaktionalen Datenbank. Die Namen tragen die Größe in sich und unterscheiden Groß- und Kleinschreibung: UInt64, nicht uint64.

Ganzzahlen: die Größe steht im Namen
Von Int8 bis Int256 und von UInt8 bis UInt256. Ein Überlauf ist kein Fehler: Der Wert läuft um. toInt8(300) liefert 44 und toUInt8(-1) liefert 255.

Das Maximum eines UInt64 ist 18446744073709551615, und in JSON ist eine Zahl nur bis 9007199254740991 (2^53 − 1) exakt. Deshalb schickt der Server 64-Bit-Ganzzahlen in seinen JSON-Formaten in Anführungszeichen (output_format_json_quote_64bit_integers), und Calíope liest sie als exakten Text, nie als Gleitkommazahl.

Decimal: exakt, mit fester Skala
Decimal(P, S) speichert P Stellen, davon S Nachkommastellen, ohne die Rundungen der Gleitkommazahlen. Die Division verwirft, was nicht in die Skala passt: toDecimal32(1, 2) / 3 ergibt 0.33. Die Multiplikation addiert die Skalen: Zwei Decimal(9, 2) ergeben ein Decimal(9, 4).

String sind Bytes, keine Zeichen
String nimmt jede Bytefolge an, auch eine, die kein UTF-8 ist. length('ñandú') ergibt 7 (Bytes) und lengthUTF8('ñandú') ergibt 5. FixedString(N) füllt mit Nullen auf: toFixedString('ab', 4) speichert 61 62 00 00. Ist ein Text kein gültiges UTF-8, zeigt Calíope ihn bereinigt an und markiert die Zelle.

Datumstypen: unterschiedliche Bereiche, und kein Fehler beim Überschreiten

TypBereichGröße
Date1970-01-01 bis 2149-06-062 Bytes
Date320000-01-01 bis 9999-12-314 Bytes
DateTimeSekunden seit 19704 Bytes
DateTime64(P)P Nachkommastellen, von 0 bis 98 Bytes

Außerhalb seines Bereichs bleibt Date ohne Warnung an der Grenze stehen: toDate('2149-06-07') liefert 2149-06-06 und toDate('1969-12-31') liefert 1970-01-01. Für Geburtsdaten oder historische Daten nimmt man Date32.

Die Zeitzone gehört zur Spalte, nicht zu jeder Zeile: DateTime64(3, 'Asia/Tokyo'). Ohne Zeitzone gilt die des Servers, und Calíope schreibt sie in den Spaltenkopf.

Nullable und LowCardinality: Hüllen mit unterschiedlichen Kosten
Nullable(T) hält eine eigene Null-Maske. Bei einer Million Zeilen belegte Int64 unkomprimiert 7,63 MiB und Nullable(Int64) 8,58 MiB. Außerdem darf eine Nullable-Spalte nicht im Sortierschlüssel stehen, solange allow_nullable_key nicht eingeschaltet ist. Lässt sich das Fehlen eines Werts mit einem Standardwert ausdrücken (0, leerer String), verzichtet man besser auf Nullable.

LowCardinality(T) kodiert mit einem Wörterbuch. Mit 100 verschiedenen Werten in einer Million Zeilen belegte die String-Spalte komprimiert 254 KiB und die LowCardinality(String)-Spalte 7,6 KiB. Die Dokumentation des Servers selbst empfiehlt es unter etwa 10 000 verschiedenen Werten und rät über 100 000 davon ab.

Sammlungen und strukturierte Typen
- Array(T) beginnt bei 1 und liefert außerhalb des Bereichs den Standardwert, keinen Fehler: [10,20,30][5] ergibt 0.
- Map(K, V) erlaubt wiederholte Schlüssel: map('a',1,'a',2) behält beide, und ['a'] liefert den ersten.
- Enum8 weist beim Einfügen einen Namen ab, der nicht in seiner Liste steht. UUID belegt 16 Bytes, IPv4 4.

Calíope zeigt Sammlungen als kompaktes JSON, mit den Zahlen genau so, wie sie ankamen, und ein Aggregationszustand (AggregateFunction) sagt, dass er sich nicht als Text zeigen lässt.

Stichwörter: Typen, uint64, int64, decimal, string, fixedstring, date, date32, datetime, datetime64, Zeitzone, nullable, lowcardinality, array, map, tuple, enum, uuid, ipv4, json, clickhouse

Tabellen-Engines (ClickHouse)

Die MergeTree-Familie und was jede Variante beim Zusammenführen tut, die Replicated-Engines, die Engines ohne eigene Daten oder mit Daten im Speicher, und warum sich die Engine nicht ändern lässt.

Gilt für: ClickHouse 25.3+

In ClickHouse deklariert jede Tabelle ihre Engine, und die Engine entscheidet, wie die Daten gespeichert werden, welche Indizes sie annimmt, ob sie repliziert und was beim Zusammenführen passiert. Das ist kein Performance-Detail, sondern Teil der Identität der Tabelle. Der Server veröffentlicht die 84, die er kennt, in system.table_engines, jede mit dem, was sie unterstützt.

Die MergeTree-Familie, die man fast immer will
Die Daten werden in Parts geschrieben, sortiert nach dem Sortierschlüssel, und der Server führt sie im Hintergrund zusammen. Die Varianten tun beim Zusammenführen etwas mehr, und nur dann:

EngineWas sie beim Zusammenführen tut
MergeTreeNichts außer sortieren und zusammenlegen.
ReplacingMergeTree(ver)Behält eine Zeile pro Schlüssel: die mit der höchsten Version.
SummingMergeTreeSummiert die numerischen Spalten von Zeilen mit gleichem Schlüssel.
AggregatingMergeTreeKombiniert Aggregationszustände (-State / -Merge).
CollapsingMergeTree(sign)Hebt Zeilenpaare mit Vorzeichen +1 und −1 auf.

Der Haken ist das „nur dann“. Gemessen mit ReplacingMergeTree: Zwei Einfügungen mit demselben Schlüssel ergeben in einem normalen SELECT 2 Zeilen; mit FINAL ergeben sie 1, und nach OPTIMIZE TABLE … FINAL 1 ohne FINAL. SummingMergeTree verhält sich genauso: Zwei Zeilen ('x', 1) und ('x', 2) bleiben zwei bis zum Zusammenführen und werden dann eine mit 3. Abfragen auf diese Engines müssen aggregieren oder FINAL verwenden, denn das Zusammenführen hat keinen Zeitplan.

Replicated, dieselbe Familie mit Kopien
ReplicatedMergeTree und seine Varianten (ReplicatedReplacingMergeTree…) fügen Replikate hinzu, die ClickHouse Keeper koordiniert. Sie sind die einzigen in der Liste, die Replikation und Deduplizierung von Einfügungen unterstützen (supports_replication, supports_deduplication).

Die, die nicht zu MergeTree gehören
- Memory: im RAM und unkomprimiert. Laut ihrer Beschreibung auf dem Server ist die Tabelle nach einem Neustart leer.
- Log und Verwandte: kleine Tabellen zum einmaligen Schreiben, ohne Indizes.
- Buffer: hält Daten im Speicher und schreibt sie in eine andere Tabelle; heute empfiehlt man stattdessen asynchrone Einfügungen.
- Distributed: speichert nichts; verteilt die Abfrage auf die Shards eines Clusters.
- Merge: liest mehrere Tabellen auf einmal und nimmt keine Schreibvorgänge an.
- Dictionary: zeigt ein Wörterbuch als Tabelle.
- Null: Was man schreibt, wird verworfen. Dient als Eingang einer materialisierten View.

Keine davon unterstützt Skip-Indizes, Projektionen, einen Sortierschlüssel oder TTL: Laut system.table_engines gehört all das nur zur MergeTree-Familie.

Die Engine lässt sich nicht ändern
Es gibt kein ALTER TABLE … MODIFY ENGINE: Der Server antwortet mit einem Syntaxfehler. Um sie zu ändern, legt man eine andere Tabelle mit der neuen Engine an und kopiert die Daten mit INSERT INTO … SELECT, oder man kopiert Partitionen mit ATTACH PARTITION … FROM, wenn Spalten und Partitionierung übereinstimmen: gemessen von einem ReplacingMergeTree in einen MergeTree.

In Calíope
Der Builder bietet die Engines an, die der Server ohne Argumente anlegen kann, und beim Ändern einer Tabelle sagt er, dass sich die Engine nicht ändern lässt. Kataloge zeigt die Engine jeder Tabelle.

Stichwörter: Tabellen-Engine, engine, mergetree, replacingmergetree, summingmergetree, aggregatingmergetree, collapsingmergetree, replicated, memory, log, buffer, distributed, dictionary, merge, null, final, clickhouse

Schlüssel und Skip-Indizes (ClickHouse)

Der Sortierschlüssel als dünnbesetzter Index aus Granulen, der Primärschlüssel, wenn er abweicht, SAMPLE BY, Skip-Indizes und Projektionen, und warum sich der Schlüssel nicht ändern lässt.

Gilt für: ClickHouse 25.3+

In ClickHouse gibt es keine Indizes, die auf eine Zeile zeigen. Wie viel gelesen wird, entscheidet der Sortierschlüssel: Die Daten werden danach sortiert gespeichert, und der Primärindex ist dünnbesetzt: Er hält eine Marke pro Zeilenblock, nicht eine pro Zeile.

Granulen: die Leseeinheit
Jeder Block heißt Granule und hat standardmäßig 8 192 Zeilen (index_granularity). Der Server verwirft ganze Granulen und liest den Rest. Bei einer Tabelle mit einer Million Zeilen und ORDER BY (fecha, cliente, id) gibt es 123 Granulen, und EXPLAIN sagt es:

EXPLAIN indexes = 1
SELECT count() FROM eventos WHERE fecha = '2026-02-01';

Mit der ersten Spalte des Schlüssels zeigt der Plan Granules: 3/123 und eine binäre Suche. Filtert man nur nach cliente, der zweiten Spalte, zeigt er Granules: 101/123: fast die ganze Tabelle. Die Reihenfolge der Spalten im Schlüssel zählt: zuerst die, nach denen am häufigsten gefiltert wird und die am wenigsten verschiedene Werte haben.

Der Primärschlüssel kann kürzer sein als der Sortierschlüssel
PRIMARY KEY muss ein Präfix von ORDER BY sein. Schreibt man ihn nicht, sind beide gleich. Mit ORDER BY (fecha, cliente, id) PRIMARY KEY (fecha, cliente) werden die Zeilen nach den drei Spalten sortiert, und der Index im Speicher hält nur zwei: weniger Speicher, dieselbe Reihenfolge auf der Platte. Der Primärschlüssel ist nicht eindeutig: Zwei Zeilen mit demselben Schlüssel bestehen nebeneinander.

SAMPLE BY
Damit fragt man einen Bruchteil der Daten ab (SELECT … SAMPLE 0.1). Der Stichprobenausdruck muss im Primärschlüssel stehen, sonst weist der Server das CREATE ab, und meist ist er ein Hash: ORDER BY intHash32(id) SAMPLE BY intHash32(id). Gemessen: SAMPLE 0.1 über 100 000 Zeilen zählte 10 039.

Skip-Indizes: Sie überspringen, sie suchen nicht
Sie halten eine Zusammenfassung pro Gruppe von Granulen und dienen dazu, die zu überspringen, die das Gesuchte sicher nicht enthalten:
- minmax: das Minimum und das Maximum.
- set(N): bis zu N verschiedene Werte.
- bloom_filter: ungefähre Zugehörigkeit; für Gleichheit auf Spalten außerhalb des Schlüssels.
- ngrambf_v1 und tokenbf_v1: um Fragmente oder Wörter in einem Text zu suchen.

Gemessen auf derselben Tabelle: Die Suche nach einer bestimmten URL mit einem bloom_filter las 20 von 123 Granulen statt aller 123. Und ein minmax auf einem Betrag, der über die ganze Tabelle verteilt ist, verwarf keine einzige: Ein Skip-Index hilft nur, wenn die Werte entlang des Schlüssels gruppiert sind.

Projektionen: die Tabelle noch einmal, in anderer Reihenfolge
Eine Projektion speichert die Daten noch einmal, anders sortiert, und der Server wählt sie selbst, wenn sie hilft:

ALTER TABLE eventos ADD PROJECTION por_cliente (SELECT * ORDER BY cliente);
ALTER TABLE eventos MATERIALIZE PROJECTION por_cliente;

Dieselbe Abfrage nach cliente ging von 101 auf 1 von 123 Granulen zurück. Dafür belegte die Projektion 5,3 MiB mehr, und jede Einfügung schreibt die Daten zweimal.

Der Sortierschlüssel lässt sich nicht ändern
ALTER TABLE … MODIFY ORDER BY erlaubt nur, am Ende Spalten anzuhängen, die im selben ALTER angelegt werden. Umsortieren oder eine bereits vorhandene Spalte aufnehmen ist ein Fehler (Existing column … is used in the expression that was added to the sorting key). Den Schlüssel wirklich zu ändern heißt, eine andere Tabelle anzulegen und die Daten zu kopieren.

In Calíope
In ClickHouse heißt der Tab „Indizes“ der Kataloge „Schlüssel“ und zeigt den Sortierschlüssel, den Partitionsschlüssel, den Primärschlüssel, falls er abweicht, den Stichprobenschlüssel, die Skip-Indizes und die Projektionen. Das visuelle EXPLAIN zählt die Granulen, die jeder Lesevorgang liest, und der Index-Berater schlägt Skip-Indizes vor, nicht CREATE INDEX.

Stichwörter: Sortierschlüssel, order by, primary key, Primärindex, Granule, index_granularity, sample by, Skip-Index, data skipping index, minmax, set, bloom_filter, ngrambf_v1, tokenbf_v1, Projektion, projection, explain, clickhouse

Partitionen und TTL (ClickHouse)

PARTITION BY und die Parts, warum Partitionieren nach Tag ein häufiger Fehler ist, OPTIMIZE und DROP PARTITION, und die TTL von Zeilen und Spalten, die beim Zusammenführen greift.

Gilt für: ClickHouse 25.3+

In ClickHouse ist eine Partition eine Gruppe von Parts, die denselben Wert von PARTITION BY haben, und ihre Hauptaufgabe ist die Datenverwaltung: einen ganzen Monat auf einmal löschen oder verschieben. Um Abfragen zu beschleunigen, gibt es schon den Sortierschlüssel; Partitionieren ersetzt ihn nicht.

Parts und Partitionen
Jede Einfügung schreibt mindestens einen Part pro berührter Partition, und der Server führt sie später zusammen, immer innerhalb derselben Partition. system.parts listet sie auf, und nur die mit active = 1 zählen: Zusammengeführte bleiben eine Weile inaktiv, bevor sie gelöscht werden.

CREATE TABLE ventas (fecha Date, id UInt64, importe Decimal(10, 2))
ENGINE = MergeTree
PARTITION BY toYYYYMM(fecha)
ORDER BY (fecha, id);

Gemessen: Zwei Einfügungen von 300 000 Zeilen über neun Monate hinterließen 18 aktive Parts in 9 Partitionen.

Warum Partitionieren nach Tag ein häufiger Fehler ist
Viele Partitionen bedeuten viele kleine Parts, und Parts kosten: Ab mehr als 1 000 aktiven in einer Partition beginnt der Server, Einfügungen zu verzögern (parts_to_delay_insert), und bei 3 000 weist er sie ab (parts_to_throw_insert). Außerdem darf ein einzelnes INSERT nicht mehr als 100 Partitionen berühren (max_partitions_per_insert_block). Gemessen mit PARTITION BY fecha: Ein INSERT mit 200 verschiedenen Tagen schlug mit Code: 252 … Too many partitions for single INSERT block (more than 100) fehl. Üblich ist nach Monat (toYYYYMM), und gar nicht, wenn die Tabelle klein ist.

Nach Partition zusammenführen und löschen

OPTIMIZE TABLE ventas PARTITION 202603 FINAL;
ALTER TABLE ventas DROP PARTITION 202601;

OPTIMIZE … PARTITION … FINAL führt die Parts dieser Partition zu einem zusammen. DROP PARTITION löscht die ganze Partition, indem es ihre Dateien entfernt: gemessen 5 ms. Den folgenden Monat mit ALTER TABLE … DELETE WHERE zu löschen dauerte 54 ms, weil eine Mutation Parts neu schreibt, statt sie fallen zu lassen, und der Unterschied wächst mit der Größe.

TTL: Ablauf, der beim Zusammenführen greift

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;

Die TTL der Tabelle löscht abgelaufene Zeilen, und die einer Spalte setzt ihren Wert auf den Standardwert zurück. Beides geschieht beim Zusammenführen, weder beim Einfügen noch zur genauen Uhrzeit. Gemessen: Eine zwei Jahre alte Zeile, in diese Tabelle eingefügt, war beim Abfragen noch da und verschwand nach OPTIMIZE … FINAL. Ohne es zu erzwingen, prüft der Server den Ablauf alle 4 Stunden (merge_with_ttl_timeout); eine TTL ist also kein punktgenaues Löschen: Abgelaufene Daten können eine Weile sichtbar bleiben.

In Calíope
Der Tab „Schlüssel“ der Kataloge zeigt den Partitionsschlüssel jeder Tabelle. Serverinformationen summiert unter „HDD-Nutzung“ die aktiven Parts aus system.parts und zeigt die Kompression. Tabellenwartung bietet OPTIMIZE beschränkt auf eine Partition, mit FINAL standardmäßig aus, weil es die ganze Partition neu schreibt.

Stichwörter: Partition, partition by, Parts, system.parts, active, parts_to_throw_insert, max_partitions_per_insert_block, optimize, drop partition, ttl, Ablauf, toyyyymm, Zusammenführen, clickhouse

Materialisierte Views (ClickHouse)

Ein Einfüge-Trigger, keine zwischengespeicherte Tabelle: TO gegenüber der internen Tabelle, POPULATE, aktualisierbare Views mit system.view_refreshes, und warum sie weder UPDATE noch DELETE sehen.

Gilt für: ClickHouse 25.3+

Eine materialisierte View in ClickHouse ist keine gespeicherte Abfrage mit zwischengespeichertem Ergebnis. Sie ist ein Einfüge-Trigger: Jedes Mal, wenn ein Zeilenblock in der Quelltabelle ankommt, führt die View ihr SELECT auf diesem Block aus und schreibt das Ergebnis in eine Zieltabelle. Was vorher da war und was sich später ändert, sieht sie nicht.

Mit TO oder mit einer internen Tabelle

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;

Mit TO ist das Ziel eine gewöhnliche Tabelle, vorher angelegt und mit eigenem Namen. Ohne TO trägt die View ihre eigene ENGINE, und der Server legt eine versteckte Tabelle namens .inner_id.<uuid> an, mit der uuid der View. Gemessen: Die View ohne TO legte .inner_id.ed264ea3-… an, und ihre uuid in system.tables war genau diese. Mit TO weiß man, wo die Daten liegen, und kann die View löschen, ohne sie zu verlieren; deshalb ist das der empfohlene Weg.

Was vorher da war, kommt nicht hinein (außer mit POPULATE)
Gemessen: Mit zwei Zeilen schon in der Quelle entstanden die View mit TO und die mit interner Tabelle leer. Die mit POPULATE angelegte entstand mit beiden. Danach erreichte eine neue Einfügung alle drei. Die ClickHouse-Dokumentation warnt, dass verloren gehen kann, was in die Quelle eingefügt wird, während POPULATE die View füllt; bei laufendem Verkehr legt man besser die View an und füllt das Ziel von Hand mit INSERT INTO … SELECT.

Mutationen: Die View erfährt nichts davon
Eine View reagiert nur auf Einfügungen. Gemessen: Nach ALTER TABLE origen UPDATE v = 1000 WHERE k = 'a' summierte die Quelle für a 2 000, und das Ziel blieb bei 10. Nach einem DELETE der Zeilen von c verlor die Quelle sie, und das Ziel behielt seine Summe von 5. Wird die Quelle korrigiert, berechnet man das Ziel von Hand neu.

Auch das Ziel führt zusammen
Ist das Ziel ein SummingMergeTree oder ein AggregatingMergeTree, hinterlässt jede Einfügung Teilzeilen, die beim Zusammenführen zusammenkommen. Abfragen auf das Ziel aggregieren (sum(total), -Merge), statt einzelne Zeilen zu lesen.

Aktualisierbare Views: die andere Art

CREATE MATERIALIZED VIEW resumen
REFRESH EVERY 1 MINUTE
ENGINE = MergeTree ORDER BY k AS
SELECT k, count() AS c FROM origen GROUP BY k;

Eine View mit REFRESH funktioniert tatsächlich wie eine zwischengespeicherte Tabelle: Sie führt in jedem Intervall die ganze Abfrage neu aus und ersetzt das Ergebnis, sieht also die ganze Quelle, auch Änderungen durch Mutationen. system.view_refreshes nennt ihren Zustand (Scheduled, Running…), die letzte erfolgreiche Aktualisierung und die nächste. Gemessen mit ClickHouse 26.8: Es funktioniert ohne experimentelle Einstellung.

In Calíope
In ClickHouse haben die Kataloge den Tab „Materialisierte Views“: aus welcher Tabelle jede liest, in welche sie schreibt (und, falls es die interne ist, unter welchem Namen man sie abfragt), Größe und Zeilen des Ziels, die zuletzt geschriebene Zeile und, wenn sie aktualisierbar ist, ihr Zeitplan und ihre Aktualisierungen. Der Baum des Editors gruppiert sie getrennt von den gewöhnlichen Views.

Stichwörter: materialisierte View, materialized view, to, inner_id, populate, refresh every, aktualisierbar, view_refreshes, Trigger, summingmergetree, Mutation, clickhouse

Mutationen und Löschen (ClickHouse)

UPDATE und DELETE als asynchrones Neuschreiben von Parts, system.mutations und der Fehlergrund, KILL MUTATION, mutations_sync, das leichte DELETE und warum eine Mutation keine Zeilen zählt.

Gilt für: ClickHouse 25.3+

ClickHouse ändert eine Zeile nicht an Ort und Stelle. ALTER TABLE … UPDATE und ALTER TABLE … DELETE sind Mutationen: Der Server vermerkt sie, antwortet sofort und schreibt danach jeden betroffenen Part im Hintergrund neu, vollständig, auch wenn sich nur eine Zeile ändert.

Was beim Ausführen passiert

ALTER TABLE eventos UPDATE estado = 'cerrado' WHERE id < 10;
SELECT mutation_id, parts_to_do, is_done FROM system.mutations WHERE table = 'eventos';

Gemessen an einer Tabelle mit zwei Parts zu 50 000 Zeilen: Die Anweisung kam nach 10 ms zurück, mit parts_to_do = 2 und is_done = 0. Am Ende waren die Parts all_1_1_0 und all_2_2_0 durch all_1_1_0_3 und all_2_2_0_3 ersetzt: je 50 000 Zeilen neu geschrieben, um zehn zu ändern. Die alten Parts bleiben inaktiv auf der Platte, bis old_parts_lifetime abgelaufen ist (standardmäßig 480 s).

Warten oder nicht
mutations_sync ist standardmäßig 0: Die Anweisung wartet nicht. Mit 1 wartet sie auf das aktuelle Replikat, mit 2 auf alle. Gemessen: Mit mutations_sync = 2 sah das nächste SELECT bereits den neuen Wert.

Wenn eine Mutation fehlschlägt
Eine fehlgeschlagene Mutation wird weder rückgängig gemacht noch verworfen: Sie wird immer wieder versucht und blockiert die nachfolgenden auf dieser Tabelle. Gemessen: Nach einer Mutation mit einer Division durch null blieb die nächste unangewendet, bis die erste entfernt war. Der Grund steht in system.mutations.latest_fail_reason (Code: 153 … Division by zero), und entfernt wird sie mit:

KILL MUTATION WHERE database = 'demo' AND table = 'eventos' AND NOT is_done SYNC;

Was die Mutation schon neu geschrieben hatte, wird nicht zurückgenommen.

Eine Mutation zählt keine Zeilen
Der Server zählt die betroffenen Zeilen nicht, wenn er eine Mutation einreiht: In system.query_log erscheint sie mit written_rows = 0. Jede Zeilenzahl, die für eine Mutation angezeigt würde, wäre erfunden.

Das leichte DELETE
DELETE FROM tabelle WHERE … schreibt keine Parts neu: Es markiert die Zeilen als gelöscht, blendet sie in Abfragen aus, und entfernt werden sie wirklich beim nächsten Merge. Gemessen: Von 100 000 Zeilen ließ DELETE FROM … WHERE id < 3 99 997 sichtbar, und mit apply_deleted_mask = 0 waren noch alle 100 000 da. lightweight_deletes_sync ist standardmäßig 2: Es wartet, bis die Markierung alle Replikate erreicht hat.

In Calíope
Eine Zelle bearbeiten oder eine Zeile löschen schreibt bei ClickHouse ALTER TABLE … UPDATE oder ALTER TABLE … DELETE, und der Editor meldet, dass die Mutation eingereiht wurde, mit ihrer Kennung statt einer Zeilenzahl. Prozesse zeigt nicht abgeschlossene Mutationen mit ihrem Fehlergrund als Text und erlaubt, sie abzubrechen.

Stichwörter: Mutation, alter table update, alter table delete, system.mutations, latest_fail_reason, kill mutation, mutations_sync, delete from, leichtes Löschen, lightweight delete, lightweight_deletes_sync, parts_to_do

Transaktionen und Atomarität (ClickHouse)

Es gibt keine Transaktionen: was ein INSERT garantiert, warum es über Blöcke hinweg nicht atomar ist, die Deduplizierung von Blöcken und wie ein Ladevorgang ohne BEGIN idempotent wird.

Gilt für: ClickHouse 25.3+

ClickHouse hat keine Transaktionen. BEGIN TRANSACTION antwortet mit Code: 48 … Transactions are not supported (NOT_IMPLEMENTED), und implicit_transaction = 1 ebenso. Es gibt weder COMMIT noch ROLLBACK: Jede Anweisung steht für sich.

Was ein INSERT garantiert
Ein INSERT wird in Blöcken geschrieben (max_insert_block_size, standardmäßig 1 048 449 Zeilen), und jeder Block ist atomar: Er kommt ganz hinein oder gar nicht. Über Blöcke hinweg gibt es keine Garantie. Gemessen: Ein INSERT … SELECT von 400 000 Zeilen in Blöcken zu 100 000, das bei Zeile 250 000 fehlschlug, hinterließ 100 000 geschriebene Zeilen. Ein fehlgeschlagenes INSERT kann die Tabelle halb gefüllt lassen, und nichts macht es rückgängig.

Deduplizierung von Blöcken
Replicated*-Tabellen merken sich den Hash der zuletzt eingefügten Blöcke (replicated_deduplication_window, 10 000) und verwerfen einen identischen Block, der noch einmal kommt. Gemessen: Dasselbe INSERT zweimal in eine ReplicatedMergeTree ergab 2 Zeilen, nicht 4. In einer normalen MergeTree ist das Fenster 0 (non_replicated_deduplication_window), und dasselbe INSERT zweimal ergab 4.

Einen Ladevorgang idempotent machen
- Den Stapel mit denselben Blöcken wiederholen: Bei Replicated* verwirft die Deduplizierung die Wiederholung.
- Den Stapel mit insert_deduplication_token benennen. Gemessen an einer MergeTree mit Fenster 100: Ein zweites INSERT mit demselben Token und anderen Daten kam nicht hinein.
- In eine Hilfstabelle laden und die ganze Partition auf einmal mit ALTER TABLE … REPLACE PARTITION … FROM austauschen (laut Serverdokumentation).
- ReplacingMergeTree verwenden und mit FINAL lesen, wenn doppelte Zeilen bis zum nächsten Merge vertretbar sind.

In Calíope
Bei ClickHouse bietet der Editor das Menü „Transaktion“ nicht an: Es gibt nichts zu öffnen oder zurückzunehmen, und jede Anweisung ist erledigt, sobald sie läuft.

Stichwörter: Transaktion, transaction, begin, commit, rollback, Atomarität, insert, Block, max_insert_block_size, Deduplizierung, insert_deduplicate, insert_deduplication_token, replicated_deduplication_window, idempotent, not_implemented

Leistung (ClickHouse)

PREWHERE, die Kosten von FINAL, gelesene Granulen im EXPLAIN, max_threads und max_memory_usage, system.query_log mit log_comment und die drei Abfragen, die den Schlüssel nicht nutzen.

Gilt für: ClickHouse 25.3+

Bei ClickHouse entscheidet sich fast die ganze Leistung daran, wie viel gelesen wird: wie viele Granulen der Schlüssel überspringt und wie viele Spalten geholt werden müssen.

Granulen im EXPLAIN

EXPLAIN indexes = 1
SELECT count() FROM eventos WHERE fecha BETWEEN '2026-02-01' AND '2026-02-03';

Bei einer Million Zeilen mit ORDER BY (fecha, cliente, id) meldete der Plan Granules: 5/123: Von 123 Granulen las er 5. Dieses Verhältnis ist das Erste, was man ansieht.

Die drei Abfragen, die den Schlüssel nicht nutzen
Gemessen an derselben Tabelle:
- Filtern nach einer Spalte, die nicht im Schlüssel ist (url LIKE '%/p/12%'): 123/123.
- Ein OR, das eine Schlüsselspalte mit einer anderen mischt (fecha = … OR url = …): 123/123.
- Filtern nur nach der zweiten Spalte des Schlüssels (cliente = 42): 101/123.

PREWHERE
Mit PREWHERE liest der Server zuerst die Filterspalten und holt die übrigen nur für die Zeilen, die durchkommen. optimize_move_to_prewhere ist 1 und erledigt das selbst. Gemessen mit einem Filter auf url, der 20 Zeilen liefert, und einer 200-Byte-Spalte im SELECT: 30 MiB gelesen mit der Optimierung und 204 MiB ohne.

FINAL kostet
FINAL führt die doppelten Zeilen einer ReplacingMergeTree beim Lesen zusammen. Gemessen an zwei Millionen Zeilen mit einer Million Schlüssel: sum(v) ohne FINAL brauchte 42 ms und zählte die Duplikate mit; mit FINAL 323 ms und 42 MiB Speicher statt 5,5.

Threads und Speicher
max_threads ist auto(N), einer pro Kern (8 auf dem gemessenen Server). max_memory_usage ist 0, ohne Grenze pro Abfrage; mit einer Grenze schlägt die Abfrage, die sie überschreitet, mit Code: 241 … Query memory limit exceeded fehl.

system.query_log
Jede Abfrage hinterlässt dort ihre Dauer, read_rows, read_bytes und memory_usage, und log_comment markiert sie, damit man sie findet:

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
Das visuelle EXPLAIN zeigt die gelesenen Granulen jedes Lesevorgangs, rot, wenn keine übersprungen werden. Langsame Abfragen kommen aus system.query_log, mit gelesenen Bytes und Speicher neben der Zeit, und was Calíope selbst ausführt, ist mit log_comment markiert, damit man es ausblenden kann.

Stichwörter: Leistung, performance, prewhere, final, Granule, explain, indexes, max_threads, max_memory_usage, query_log, read_bytes, memory_usage, log_comment, Sortierschlüssel

Merges und OPTIMIZE (ClickHouse)

Merges im Hintergrund, system.merges und system.parts, OPTIMIZE gegenüber OPTIMIZE FINAL, die Schwellen für Parts und warum „zu viele Parts“ der häufigste Betriebsfehler ist.

Gilt für: ClickHouse 25.3+

Jedes INSERT legt einen neuen Part auf der Platte an. Im Hintergrund führt der Server kleine Parts zu größeren zusammen: Das hält die Tabelle bei wenigen Parts und wendet ReplacingMergeTree, TTLs und leichtes Löschen an.

Parts und Merges ansehen

SELECT partition, name, rows, active FROM system.parts WHERE table = 'eventos';
SELECT table, elapsed, progress, num_parts, result_part_name FROM system.merges;

Ein inaktiver Part ist einer, der schon in einen Merge eingegangen ist: Er bleibt auf der Platte, bis old_parts_lifetime (480 s) abgelaufen ist, und wird dann gelöscht.

OPTIMIZE und OPTIMIZE FINAL
OPTIMIZE TABLE fordert jetzt einen Merge an; OPTIMIZE TABLE … FINAL erzwingt ihn, bis ein Part pro Partition übrig ist, auch wenn es schon nur einer war. Gemessen: Mit angehaltenen Merges und fünf INSERTs fünf Parts; OPTIMIZE brachte sie auf 1, weil sie klein waren, und ein OPTIMIZE … FINAL auf diesen einen Part schrieb ihn vollständig neu. FINAL schreibt die Tabelle neu: Bei einer großen Tabelle ist das viel Arbeit.

Zu viele Parts
Wenn INSERTs schneller Parts anlegen, als sie zusammengeführt werden, bremst der Server zuerst und lehnt dann ab:
- parts_to_delay_insert (standardmäßig 1 000): ab hier verzögert er jedes INSERT.
- parts_to_throw_insert (3 000): ab hier lehnt er es ab.

Gemessen mit auf 4 gesenkter Schwelle: Das fünfte INSERT schlug mit Code: 252 … Too many parts … Merges are processing significantly slower than inserts (TOO_MANY_PARTS) fehl. Das ist der häufigste Betriebsfehler, und er kommt fast immer vom zeilenweisen Einfügen oder von sehr kleinen Stapeln oder von zu feiner Partitionierung. Der Ausweg ist, in Stapeln von Tausenden Zeilen einzufügen. Auf dem gemessenen Server (26.8) ist async_insert standardmäßig eingeschaltet und fasst laut seiner Dokumentation kleine INSERTs, die gleichzeitig ankommen, zu einem Part zusammen; drei nacheinander ausgeführte INSERTs ergaben trotzdem drei Parts.

In Calíope
Tabellenwartung bietet OPTIMIZE mit der Option FINAL an, die anfangs aus ist und vor dem Ausführen warnt, weil sie die Tabelle neu schreibt, und erlaubt, es auf eine Partition zu beschränken. Serverinformationen zeigt Größe und Kompression jeder Tabelle, summiert aus ihren aktiven Parts.

Stichwörter: Merge, Zusammenführung, optimize, optimize final, system.merges, system.parts, Part, inaktiver Part, old_parts_lifetime, parts_to_delay_insert, parts_to_throw_insert, too many parts, too_many_parts, async_insert

Replikation und Keeper (ClickHouse)

ReplicatedMergeTree und Keeper, die Makros {shard} und {replica}, system.replicas, SYNC und RESTART REPLICA, was ohne Keeper passiert, DROP TABLE … SYNC und system.clusters.

Gilt für: ClickHouse 25.3+

Bei ClickHouse erfolgt die Replikation pro Tabelle: Jede Replicated*-Tabelle stimmt sich über Keeper (oder ZooKeeper) mit ihren Kopien ab, der festhält, welche Parts es gibt und was noch zu kopieren ist. Es gibt keinen Primärserver: Jedes Replikat nimmt Schreibvorgänge an.

Eine replizierte Tabelle anlegen

CREATE TABLE ventas (id UInt32, v String)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/ventas', '{replica}')
ORDER BY id;

Der Pfad kennzeichnet die Tabelle in Keeper und ist auf allen ihren Replikaten gleich; {shard} und {replica} sind Makros, die jeder Server aus seiner Konfiguration füllt (system.macros). Im gemessenen Paar haben beide Replikate shard = 01, und replica ist auf dem einen r1 und auf dem anderen r2. Die Tabelle wird auf jedem Replikat angelegt, und die Zeilen kommen von selbst.

system.replicas

SELECT table, is_readonly, is_session_expired, absolute_delay, queue_size, active_replicas, total_replicas FROM system.replicas;

- absolute_delay: Sekunden Rückstand gegenüber dem aktuellsten Replikat.
- queue_size: anstehende Aufgaben (Parts holen, zusammenführen, mutieren).
- is_readonly und is_session_expired: Das Replikat hat Keeper verloren.

Gemessen: Mit angehaltenem Kopieren auf r2 (SYSTEM STOP FETCHES) und einer auf r1 eingefügten Zeile blieb r2 bei 2 Zeilen mit queue_size = 1; nach dem Fortsetzen 3.

SYNC und RESTART REPLICA
SYSTEM SYNC REPLICA tabelle wartet, bis das Replikat seine Warteschlange geleert hat; gemessen kam es mit allen 3 Zeilen aktuell zurück. SYSTEM RESTART REPLICA tabelle liest seinen Zustand neu aus Keeper; danach is_readonly = 0 und active_replicas = 2.

Ohne Keeper nur lesend
Gemessen durch Anhalten von Keeper: Die Tabelle ging auf is_readonly = 1 und is_session_expired = 1, Lesen funktionierte weiter, und ein INSERT wartete 120 s und schlug mit TIMEOUT_EXCEEDED fehl. Als Keeper zurückkam, erholte sich das Replikat von selbst.

DROP TABLE … SYNC
Wird eine replizierte Tabelle ohne SYNC gelöscht, wird ihre Spur in Keeper später aufgeräumt, und sie sofort neu anzulegen schlägt mit Code: 253 … Replica … already exists (REPLICA_ALREADY_EXISTS) fehl (gemessen). Mit DROP TABLE … SYNC wartet die Anweisung auf dieses Aufräumen.

system.clusters
Cluster werden in der Serverkonfiguration deklariert, nicht mit SQL angelegt, und system.clusters listet sie mit ihren Shards und Replikaten. Im Testbed hat caliope_par einen Shard mit zwei Replikaten: clickhouse-r1 und clickhouse-r2.

In Calíope
Bei ClickHouse geht der Replikations-Monitor Tabelle für Tabelle vor: aktive Replikate von allen, die fehlenden mit Namen, Rückstand und Warteschlange, und der Verlust von Keeper als eigener Zustand. Topologie zeichnet, was system.clusters deklariert.

Stichwörter: Replikation, replication, replicatedmergetree, keeper, zookeeper, Makros, shard, replica, system.replicas, absolute_delay, queue_size, is_readonly, sync replica, restart replica, system.clusters, replica_already_exists

Sicherheit: Benutzer, Rollen und Rechte (ClickHouse)

Benutzer aus den Dateien des Servers gegenüber den per SQL angelegten, Rollen, GRANT und teilweiser Entzug, Quoten, Einstellungsprofile, Zeilenrichtlinien und warum das Passwort nicht in die URL gehört.

Gilt für: ClickHouse 25.3+

ClickHouse hält Benutzer an zwei Orten, und woher ein Benutzer stammt, entscheidet, was man mit ihm tun kann.

Zwei Benutzerspeicher

SELECT name, storage, auth_type FROM system.users;

- users_xml: die in den Dateien des Servers deklarierten (users.xml und users.d/). Sie ändern sich nur, wenn man diese Dateien bearbeitet: ALTER USER auf einen von ihnen schlägt mit Code: 495 … ACCESS_STORAGE_READONLY fehl (gemessen).
- local_directory: die mit CREATE USER angelegten. Sie werden per SQL verwaltet, und der Server speichert sie auf seiner Platte.

Um Benutzer per SQL anzulegen, braucht man ein Konto mit Zugriffsverwaltung (access_management in seiner Konfiguration oder das Recht ACCESS MANAGEMENT).

Rollen, GRANT und teilweiser Entzug

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;

Ein REVOKE auf eine Tabelle einer vollständig gewährten Datenbank entfernt das Recht auf die Datenbank nicht: Es hinterlässt einen teilweisen Entzug. SHOW GRANTS FOR lectura zeigt beide Zeilen, und system.grants markiert die zweite mit is_partial_revoke = 1 (gemessen). Mit dieser Rolle funktioniert das Lesen von demo.productos, und das Lesen von demo.eventos schlägt mit Code: 497 … ACCESS_DENIED fehl.

Quoten, Profile und Zeilenrichtlinien
- Quoten (CREATE QUOTA, system.quotas, system.quota_usage): Grenzen für Abfragen, Zeilen oder Zeit pro Intervall. Laut der Dokumentation des Servers wird eine Abfrage bei Überschreitung mit QUOTA_EXCEEDED abgelehnt.
- Einstellungsprofile (CREATE SETTINGS PROFILE … SETTINGS max_result_rows = 5 TO ana): Der Benutzer erhält sie bei jeder Verbindung, und system.settings zeigt sie mit changed = 1 (gemessen).
- Zeilenrichtlinien (CREATE ROW POLICY … ON demo.eventos USING id <= 3 TO ana): Der Server fügt diesen Filter allem hinzu, was der Benutzer liest. Gemessen: count() ergab 3 für ihn und 90 für den Administrator.

Das Passwort gehört nicht in die URL
Die HTTP-Schnittstelle akzeptiert ?user=…&password=… in der Adresse, aber eine URL landet im Verlauf des Terminals, in den Protokollen jedes Proxys und in Netzwerkwerkzeugen. Der gemessene Server verbirgt es in seinem eigenen Protokoll (password=[HIDDEN]); die anderen nicht. Calíope sendet die Zugangsdaten in den Headern X-ClickHouse-User und X-ClickHouse-Key und die Abfrage im Rumpf der Anfrage.

In Calíope
Benutzer listet Konten und Rollen mit ihrem Speicher. Die aus users_xml erscheinen mit einem Schloss und lassen sich nicht bearbeiten, und ein teilweiser Entzug erscheint durchgestrichen und mit dem Wort REVOKE. Schreiben setzt voraus, dass das verbundene Konto die Zugriffsverwaltung hat; sonst werden die Schaltflächen deaktiviert, und ein Banner nennt den Grund.

Stichwörter: Sicherheit, Benutzer, Rolle, grant, revoke, teilweiser Entzug, is_partial_revoke, show grants, system.users, users_xml, local_directory, access management, Quote, quota, Einstellungsprofil, settings profile, row policy, Zeilenrichtlinie, Passwort, Zugriff verweigert

Serverkonfiguration (ClickHouse)

Abfrageeinstellungen gegenüber Servereinstellungen, config.d und users.d, wie man eine Einstellung über HTTP dauerhaft macht, und die Einstellungen, die Calíope mit jeder Anfrage sendet.

Gilt für: ClickHouse 25.3+

ClickHouse hat zwei Arten von Einstellungen, und sie zu verwechseln ist der häufigste Fehler beim Konfigurieren.

Abfrageeinstellungen und Servereinstellungen

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 auf dem gemessenen Server): Sie gelten für eine Abfrage. Man setzt sie in der Abfrage (SETTINGS … am Ende), in der Sitzung, in einem Profil oder am Benutzer.
- system.server_settings (447): Sie gelten für den ganzen Prozess, wie max_concurrent_queries (standardmäßig 1.000) oder max_server_memory_usage. Man ändert sie in der Serverkonfiguration, und die Spalte changeable_without_restart sagt, ob sie ohne Neustart wirken: Auf dem gemessenen Server brauchen 339 der 447 einen Neustart.

config.d und users.d
Die Konfiguration liegt in /etc/clickhouse-server/: config.xml für den Server und users.xml für Benutzer, Profile und Quoten. Eigene Änderungen kommen in eigene Dateien in config.d/ und users.d/, die der Server mit den Hauptdateien zusammenführt; so überschreibt ein Update sie nicht. Das offizielle Docker-Image bringt in jedem Ordner bereits eine mit.

Über HTTP hält SET nicht
Jede HTTP-Anfrage ist unabhängig: Ein SET max_threads = 7 gilt für diese Anfrage, und die nächste hat es nicht mehr (gemessen). Eine Einstellung lässt sich in der Anfrage selbst mitsenden (?max_threads=5) oder am Benutzer dauerhaft machen:

ALTER USER ana MODIFY SETTINGS max_threads = 3;

Gemessen: Ab da bringt jede neue Verbindung von ana sie mit, mit changed = 1. Ohne das Wort MODIFY ersetzt ALTER USER … SETTINGS alle Einstellungen des Kontos. Und es funktioniert nur bei per SQL angelegten Benutzern: Bei einem aus users_xml schlägt es mit ACCESS_STORAGE_READONLY fehl.

Was Calíope mit jeder Anfrage sendet
Fünf feste Einstellungen, um Zahlen ohne Genauigkeitsverlust zu lesen und die eigenen Abfragen wiederzuerkennen:
- output_format_json_quote_64bit_integers, output_format_json_quote_decimals, output_format_json_quote_64bit_floats und output_format_json_quote_denormals: 64-Bit-Ganzzahlen, Dezimalzahlen und Werte wie inf oder nan kommen in Anführungszeichen. In JSON passt eine 64-Bit-Ganzzahl nicht exakt, und ohne diese Einstellungen kommen inf und nan als null.
- log_comment: kennzeichnet die Abfragen von Calíope in system.query_log.

Der Editor verwendet session_id nicht (nur ein Bericht mit mehreren Anweisungen öffnet eine eigene Sitzung); der Grund steht in Grenzen (ClickHouse).

In Calíope
Langsame Abfragen speichert seinen Schwellenwert mit ALTER USER … MODIFY SETTINGS. Lässt sich das Konto nicht ändern, sagt das eine Notiz, statt die Bedienelemente zu zeigen.

Stichwörter: Konfiguration, Einstellungen, settings, server settings, system.settings, system.server_settings, config.xml, users.xml, config.d, users.d, alter user, modify settings, set, max_threads, Neustart, log_comment, output_format_json_quote

Grenzen (ClickHouse)

max_query_size, der Speicher pro Abfrage, die Grenzen des Ergebnisses, gleichzeitige Abfragen, zu viele Parts, der Einfügeblock und warum der Editor session_id nicht verwendet.

Gilt für: ClickHouse 25.3+

Der Text einer Abfrage
max_query_size beträgt standardmäßig 262.144 Bytes. Eine längere Abfrage schlägt mit Code: 62 … Max query size exceeded an Position 262.145 fehl (gemessen). Pro Abfrage lässt sich der Wert mit SETTINGS max_query_size = … erhöhen. Der Dump von Calíope fasst seine INSERT unterhalb dieser Grenze zusammen.

Der Speicher
max_memory_usage ist auf dem gemessenen Server 0 (keine Grenze pro Abfrage); die eigentliche Obergrenze ist max_server_memory_usage, die des Prozesses. Mit einer niedrigen Grenze schlägt die Abfrage mit Code: 241 … MEMORY_LIMIT_EXCEEDED fehl, und die Meldung sagt, wie viel sie verbraucht hätte.

Die Größe des Ergebnisses
max_result_rows und max_result_bytes sind standardmäßig 0. Mit einer gesetzten Grenze lässt result_overflow_mode = 'throw' (der Standard) die Abfrage mit Code: 396 … TOO_MANY_ROWS_OR_BYTES fehlschlagen, und 'break' liefert, was bis dahin gelesen wurde.

Abfragen gleichzeitig
max_concurrent_queries gehört zum Server (standardmäßig 1.000) und max_concurrent_queries_for_user zu jedem Benutzer (0, unbegrenzt). Wird sie überschritten, schlägt die neue Abfrage mit Code: 202 … TOO_MANY_SIMULTANEOUS_QUERIES fehl (gemessen mit der Grenze auf 1).

Zu viele Parts
Jedes INSERT legt einen Part an. Bei 1.000 aktiven Parts in einer Partition (parts_to_delay_insert) bremst der Server das Einfügen, und bei 3.000 (parts_to_throw_insert) lehnt er es mit TOO_MANY_PARTS ab. Das steht in Merges und OPTIMIZE (ClickHouse).

Der Einfügeblock
Der Server fasst die Zeilen eines INSERT zu Blöcken von höchstens max_insert_block_size Zeilen zusammen (1.048.449). Ein großes INSERT, das mittendrin fehlschlägt, lässt die früheren Blöcke geschrieben zurück: Es ist nicht atomar. Das steht in Transaktionen und Atomarität (ClickHouse).

Eine Anfrage, eine Anweisung und keine Sitzung
Über HTTP nimmt jede Anfrage nur eine Anweisung an: Zwei durch ; getrennte schlagen mit Multi-statements are not allowed fehl (gemessen). Die Schnittstelle bietet session_id für eine Sitzung an, und damit hält ein SET tatsächlich von einer Anfrage zur nächsten. Aber eine Sitzung nimmt nur eine Abfrage gleichzeitig an: Eine zweite mit derselben session_id, während die erste läuft, schlägt mit Code: 373 … SESSION_IS_LOCKED fehl (gemessen). Deshalb verwendet der Editor sie nicht: Zwei Tabs auf derselben Verbindung bekämen diesen Fehler. Nur ein Bericht aus mehreren Anweisungen öffnet eine eigene Sitzung, bei jedem Lauf eine neue, die niemand sonst teilt. Jede Anfrage trägt ihre Datenbank und ihre Einstellungen mit.

Stichwörter: Grenze, Limit, max_query_size, max_memory_usage, Speicher, max_result_rows, max_result_bytes, result_overflow_mode, max_concurrent_queries, gleichzeitige Abfragen, parts_to_throw_insert, too many parts, max_insert_block_size, session_id, Sitzung, multi-statements

Häufige Fehler (ClickHouse)

Die Codes, die man täglich sieht, mit Nummer und Namen: was sie auslöst und was zu tun ist.

Gilt für: ClickHouse 25.3+

Jeder Fehler von ClickHouse trägt eine Nummer und einen Namen: Code: 60 … (UNKNOWN_TABLE). Die Nummer bleibt über Versionen gleich, und nach dem Namen sucht man in der Dokumentation. Über HTTP kommt die Nummer zusätzlich im Header X-ClickHouse-Exception-Code. Alle Fehler dieser Liste wurden gegen den gemessenen Server ausgelöst.

60 · UNKNOWN_TABLE
Die Tabelle gibt es in dieser Datenbank nicht. Über HTTP gibt es kein USE: Die Datenbank reist mit jeder Anfrage mit, also lohnt es sich, die gewählte Datenbank zu prüfen oder datenbank.tabelle zu schreiben.

62 · SYNTAX_ERROR
Ein Syntaxfehler; die Meldung nennt die Position und was erwartet wurde. Er erscheint auch bei zwei Anweisungen in einer einzigen Anfrage (Multi-statements are not allowed) und bei einer Abfrage mit mehr als 262.144 Bytes (Max query size exceeded).

53 · TYPE_MISMATCH
Eine unmögliche Umwandlung zwischen Typen, etwa CAST(1 AS Array(UInt8)). Abhilfe schafft die passende Umwandlungsfunktion (toString, toUInt8OrNull …).

241 · MEMORY_LIMIT_EXCEEDED
Die Abfrage hat die Speichergrenze überschritten, und die Meldung sagt, wie viel sie verbraucht hätte. Früher filtern, weniger Spalten aggregieren oder, wenn der Server es hergibt, max_memory_usage erhöhen hilft.

202 · TOO_MANY_SIMULTANEOUS_QUERIES
Zu viele Abfragen gleichzeitig, für den ganzen Server oder für diesen Benutzer. Abwarten genügt; Prozesse zeigt, was läuft.

497 · ACCESS_DENIED
Ein Recht fehlt, und die Meldung sagt welches (it's necessary to have the grant SELECT ON demo.eventos). Ein teilweiser Entzug löst ihn aus, auch wenn die ganze Datenbank gewährt ist.

164 · READONLY
Die Abfrage läuft im Nur-Lese-Modus (readonly = 1 am Benutzer, in seinem Profil oder in der Abfrage selbst). Man braucht ein Konto, das schreiben darf.

373 · SESSION_IS_LOCKED
Zwei Anfragen gleichzeitig mit derselben session_id. Calíope öffnet eine Sitzung nur für einen Bericht aus mehreren Anweisungen, bei jedem Lauf eine neue; taucht der Fehler auf, kommt er von einem anderen Client.

395 · FUNCTION_THROW_IF_VALUE_IS_NON_ZERO
Ein throwIf in der Abfrage hat ausgelöst, und die Meldung ist die, die ihm die Abfrage mitgegeben hat. Hatte die Abfrage schon Zeilen geliefert, kommt der Fehler am Ende der Antwort: Calíope zeigt ihn als Fehler, nicht als abgeschnittenes Ergebnis.

420 · CANNOT_UPDATE_COLUMN
Ein ALTER TABLE … UPDATE auf eine Spalte des Sortierschlüssels. Der Schlüssel lässt sich nicht ändern: Man legt eine neue Tabelle mit dem neuen Schlüssel an und kopiert die Daten.

495 · ACCESS_STORAGE_READONLY
Ein Versuch, einen in den Dateien des Servers deklarierten Benutzer (users_xml) zu ändern. Man ändert ihn in users.d/ oder legt per SQL einen anderen an.

516 · AUTHENTICATION_FAILED
Benutzer oder Passwort falsch; der Server sagt nicht, welches von beiden. Über HTTP kommt der Fehler mit Status 403.

In Calíope
Erscheint einer dieser Fehler im Editor, führt das Banner Lösung anzeigen zu diesem Thema.

Stichwörter: Fehler, Code, 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

Zahlen (Oracle)

NUMBER rundet stillschweigend auf seine Skala und lehnt ab, was seine Genauigkeit übersteigt; BINARY_DOUBLE ist binär.

Gilt für: Oracle 26ai+

In Oracle ist fast jede Zahl ein NUMBER: dezimal, exakt und mit bis zu 38 Stellen. INTEGER und SMALLINT gibt es nur als Namen —gemessen: beide Spalten wurden als NUMBER mit Skala 0 angelegt—, und es gibt weder UNSIGNED noch Typen nach Größe.

NUMBER(p,s): die Skala rundet, die Genauigkeit lehnt ab

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

Was rechts über die Skala hinausgeht, wird ohne Warnung gerundet; was links nicht passt, ergibt ORA-01438. Eine negative Skala rundet auf Zehner oder Hunderter, und ein bloßes NUMBER speichert den Wert unverändert.

Exakt, und mit variabler Größe
NUMBER speichert Dezimalzahlen: 0.1 + 0.2 = 0.3 ist wahr. Die Größe hängt vom Wert ab, nicht von der Deklaration: gemessen mit VSIZE belegt 1 2 Bytes, 123.45 4 und 1234567890123 8.

BINARY_DOUBLE und BINARY_FLOAT sind binär
Es sind double und float nach IEEE 754: schneller zum Rechnen und mit den üblichen Fehlern. 0.1d + 0.2d ergab 3.0000000000000004E-001, und der Vergleich mit 0.3d, falsch. Ein bloßes FLOAT dagegen ist ein NUMBER, dessen Genauigkeit in Bits angegeben wird (FLOAT(126)). Für Geld: NUMBER(p,s).

BOOLEAN, seit 23ai

CREATE TABLE bo (v BOOLEAN);
INSERT INTO bo VALUES (TRUE), (FALSE), (NULL), ('yes'), (0);
SELECT COUNT(*) FROM bo WHERE v;  -- 2

Er akzeptiert auch 'yes' oder 0 und wird ohne Vergleich verwendet (WHERE v). Vor 23ai gibt es ihn nicht, üblich ist dann NUMBER(1) mit einem CHECK.

In Calíope
Das Raster zeigt NUMBER exakt an, ohne Umweg über Gleitkomma, und BOOLEAN als TRUE oder FALSE.

Stichwörter: Zahl, NUMBER, Dezimal, Genauigkeit, Skala, BINARY_DOUBLE, BINARY_FLOAT, FLOAT, BOOLEAN, ORA-01438, Oracle

Text und die leere Zeichenkette (Oracle)

VARCHAR2 zählt Bytes, solange du nicht CHAR sagst, hat eine Obergrenze von 4000, und die leere Zeichenkette ist NULL.

Gilt für: Oracle 26ai+

Die leere Zeichenkette ist NULL
Das überrascht am meisten, wenn man von einer anderen Datenbank kommt: in Oracle ist '' gleich NULL. Gemessen: '' IS NULL ist wahr, LENGTH('') ist NULL und nicht 0, und das Einfügen von '' lässt die Spalte auf NULL, sodass COUNT(spalte) sie nicht mitzählt. Eine NOT NULL-Spalte weist '' mit ORA-01400 ab.

VARCHAR2(5) sind 5 Bytes, nicht 5 Buchstaben
Die Datenbank des Testbeds verwendet AL32UTF8 mit NLS_LENGTH_SEMANTICS = BYTE: Die Länge einer Spalte wird in Bytes gezählt, sofern nichts anderes angegeben ist.

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

Jeder Buchstabe mit Akzent oder ñ belegt 2 Bytes. Für Text mit Akzenten VARCHAR2(n CHAR), und genau das schreibt der Builder von Calíope.

Die Obergrenze: 4000 Bytes, oder CLOB
Mit MAX_STRING_SIZE = STANDARD, dem Wert des Images, ergibt VARCHAR2(4001) ORA-00910, und mehr als 4000 Bytes in SQL zu verketten ergibt ORA-01489. Darüber kommt CLOB: Beim Verketten auf TO_CLOB(…) kamen 8000 Zeichen heraus. Auf EXTENDED umzustellen ist eine Änderung der ganzen Datenbank, keine Sitzungseinstellung.

CHAR füllt mit Leerzeichen auf
CHAR(5) mit 'ab' speichert 'ab ' (LENGTH 5), und trotzdem ist = 'ab' wahr, weil Oracle das Literal vor dem Vergleich auffüllt. CHAR will man fast nie: VARCHAR2 füllt nichts auf.

NVARCHAR2 und NCLOB
Sie verwenden den nationalen Zeichensatz, auf dem Testbed AL16UTF16. Bei einer AL32UTF8-Datenbank braucht man sie nur für Anwendungen, die sie schon verwenden.

In Calíope
Ein Parameter mit der leeren Zeichenkette wird als NULL übertragen, was der Server ohnehin täte.

Stichwörter: Text, VARCHAR2, CHAR, CLOB, NVARCHAR2, leere Zeichenkette, NULL, BYTE, CHAR-Semantik, AL32UTF8, ORA-12899, ORA-01400, ORA-01489, Oracle

Datum und Uhrzeit (Oracle)

DATE speichert die Uhrzeit, TIMESTAMP die Sekundenbruchteile, und die Zeitzone gibt es in zwei verschiedenen Typen.

Gilt für: Oracle 26ai+

DATE hat eine Uhrzeit
DATE speichert Jahr, Monat, Tag, Stunde, Minute und Sekunde, in 7 Bytes. Was es nicht speichert, sind Bruchteile: TIMESTAMP '2026-10-16 14:30:05.987' in einem DATE wurde zu 14:30:05. Deshalb schlägt Folgendes ohne Warnung fehl:

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

Die erste Abfrage vergleicht mit Mitternacht. Für einen ganzen Tag: der halboffene Bereich.

TIMESTAMP und seine zwei Zeitzonen
- TIMESTAMP(3) speichert Bruchteile und rundet: .98765 wurde zu .988. Es belegt 11 Bytes.
- TIMESTAMP WITH TIME ZONE speichert den Zeitpunkt und den Versatz, mit dem er geschrieben wurde: 14:30 +05:30 kommt als 14:30 +05:30 zurück. Es belegt 13 Bytes.
- TIMESTAMP WITH LOCAL TIME ZONE speichert den Zeitpunkt und gibt ihn in der Zeitzone der Sitzung zurück: dasselbe 14:30 +05:30, gelesen in einer Sitzung auf −06:00, ergab 03:00. Was man sieht, hängt davon ab, wer fragt.

Arithmetik
Die Differenz zweier DATE ergibt Tage (DATE '2026-10-17' - DATE '2026-10-16' ist 1), und 1/24 zu addieren addiert eine Stunde. Die Differenz zweier TIMESTAMP ergibt ein Intervall. Ein Monat ist keine feste Dauer: ADD_MONTHS(DATE '2026-01-31', 1) ergab 2026-02-28, aber DATE '2026-01-31' + INTERVAL '1' MONTH ergab ORA-01839.

Literale, immer ANSI
Das NLS_DATE_FORMAT der Sitzung war DD-MON-RR, und TO_DATE('16-OCT-26') ohne Maske lag nur richtig, weil beides übereinstimmte. Mit einem anderen Format oder einer anderen Sprache der Sitzung schlägt derselbe Text fehl oder liefert ein anderes Datum. DATE '2026-10-16' und TIMESTAMP '2026-10-16 14:30:05' hängen von nichts ab.

In Calíope
Calíope schreibt ANSI-Literale, wenn es SQL erzeugt, und das Raster zeigt DATE mit seiner Uhrzeit, WITH TIME ZONE mit seinem Versatz und WITH LOCAL TIME ZONE in der Zeitzone der Sitzung.

Stichwörter: Datum, Uhrzeit, DATE, TIMESTAMP, WITH TIME ZONE, WITH LOCAL TIME ZONE, INTERVAL, ADD_MONTHS, NLS_DATE_FORMAT, ORA-01839, Oracle

B-Tree-Indizes (Oracle)

Der normale Index von Oracle: wann der Optimierer ihn verwendet und warum eine Zeile ohne Wert nicht darin steht.

Gilt für: Oracle 26ai+

Ein bloßes CREATE INDEX legt einen B-Tree-Index an. Gemessen an einer Tabelle cli mit 20 000 Zeilen und Statistiken:

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

Ein LIKE, das mit einem Platzhalter beginnt, kann den Baum nicht hinabsteigen, und eine Funktion auf der Spalte ebenso wenig: Der Index ist nach email sortiert, nicht nach UPPER(email). Dafür gibt es den funktionsbasierten Index („Funktionsbasierte, unsichtbare und Bitmap-Indizes (Oracle)“).

Zeilen ohne Wert stehen nicht im Index
Ein B-Tree speichert keine Zeilen, deren Schlüssel vollständig NULL ist. Bei 200 Austrittsdaten und 19 800 NULL ging WHERE baja IS NULL auf TABLE ACCESS FULL: Der Index hat diese Zeilen nicht. In einem Index über mehrere Spalten reicht es, dass eine nicht NULL ist, damit die Zeile aufgenommen wird.

In einem zusammengesetzten Index bestimmt die erste Spalte
Ein Index auf (estado, pais) ist nach estado und darin nach pais sortiert. Gemessen mit pais = 'ES' allein las der Optimierer die ganze Tabelle. Die Spalte, nach der immer gefiltert wird, kommt nach vorn.

Der Optimierer entscheidet anhand der Statistiken
Liefert die Bedingung einen großen Teil der Tabelle, ist es billiger, sie ganz zu lesen, als über den Index hin und her zu springen, und Oracle tut das. Ohne aktuelle Statistiken entscheidet er schlecht: nach dem Laden von Daten DBMS_STATS.GATHER_TABLE_STATS.

Schlüssel bringen ihren Index mit, Fremdschlüssel nicht
Ein Primär- oder eindeutiger Schlüssel legt seinen Index an („Primärschlüssel und eindeutige Schlüssel (Oracle)“). Ein Fremdschlüssel nicht („Fremdschlüssel (Oracle)“).

In Calíope
Visual EXPLAIN zeigt diese Knoten unverändert, Kataloge listet die Indizes mit ihren Spalten, und Tabellenwartung → ANALYZE erhebt die Statistiken.

Stichwörter: Index, B-Tree, CREATE INDEX, INDEX RANGE SCAN, TABLE ACCESS FULL, NULL, zusammengesetzt, Optimierer, DBMS_STATS, Oracle

Funktionsbasierte, unsichtbare und Bitmap-Indizes (Oracle)

Einen Ausdruck indizieren, einen Index abschalten, ohne ihn zu löschen, und wann ein Bitmap stört.

Gilt für: Oracle 26ai+

Funktionsbasierter Index

CREATE INDEX ix_cli_email_up ON cli (UPPER(email));
-- WHERE UPPER(email) = 'U77@X.ES' → INDEX RANGE SCAN IX_CLI_EMAIL_UP

Er indiziert das Ergebnis des Ausdrucks, und die Abfrage muss denselben schreiben: UPPER(email) verwendet ihn, LOWER(email) nicht. Oracle führt ihn als versteckte virtuelle Spalte —ALL_IND_COLUMNS zeigt SYS_NC00005$—, und der Ausdruck steht in ALL_IND_EXPRESSIONS (UPPER("EMAIL")). So sucht man ohne Unterscheidung von Groß- und Kleinschreibung, ohne die Daten anzufassen.

Unsichtbarer Index
ALTER INDEX ix_cli_email INVISIBLE lässt ihn gepflegt, aber außerhalb der Sicht des Optimierers: gemessen ging die Abfrage nach email von INDEX RANGE SCAN auf TABLE ACCESS FULL. So prüft man, ob ein Index gebraucht wird, bevor man ihn löscht: Wird nichts schlechter, wird er gelöscht; wird etwas schlechter, holt VISIBLE ihn sofort zurück, ohne Neuaufbau.

Bitmap-Index
CREATE BITMAP INDEX … (estado) wurde auf 26ai Free angelegt. Er speichert eine Bitmap pro Wert, sehr kompakt bei wenigen verschiedenen Werten, und kombiniert mehrere Bedingungen in Analyseabfragen. Aber jede geschriebene Zeile sperrt einen Abschnitt der Bitmap, der viele Zeilen abdeckt: Bei Tabellen mit gleichzeitigen Schreibzugriffen B-Tree.

Was jeder ist
ALL_INDEXES.INDEX_TYPE sagt NORMAL, FUNCTION-BASED NORMAL, BITMAP…, und VISIBILITY sagt, ob er sichtbar ist. Domänenindizes (Text, räumlich) sind eine andere Familie mit eigenem Typ.

In Calíope
Kataloge zeigt den funktionsbasierten Index mit seinem Ausdruck und Domänenindizes mit ihrem Typ. Der Index-Berater schlägt einen B-Tree auf einer Spalte vor und lehnt ein LIKE ab, das mit einem Platzhalter beginnt.

Stichwörter: Index, funktionsbasiert, FUNCTION-BASED, UPPER, INVISIBLE, VISIBLE, BITMAP, Domäne, ALL_IND_EXPRESSIONS, Oracle

Unbrauchbare Indizes und Neuaufbau (Oracle)

Eine Tabelle zu verschieben macht ihre Indizes UNUSABLE: wie man es sieht, was es kaputt macht und wie man es repariert.

Gilt für: Oracle 26ai+

Ein Index verweist auf jede Zeile über ihre ROWID, die angibt, wo sie physisch liegt. Alles, was Zeilen verschiebt, lässt den Index ins Leere zeigen, und Oracle markiert ihn als UNUSABLE, statt falsche Ergebnisse zu liefern.

Was es auslöst

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

Gemessen: Alle fünf Indizes der Tabelle, auch der des Primärschlüssels, wurden UNUSABLE. Dasselbe bewirkt das Löschen oder Leeren einer Partition ohne UPDATE INDEXES („Partition Pruning und Partitionswartung (Oracle)“).

Was es kaputt macht
Der Optimierer ignoriert ihn, und Abfragen funktionieren weiter, nur langsamer: Ein SELECT über den unbrauchbaren Primärschlüssel antwortete. Ernst wird es bei einem eindeutigen Index: Mit dem Primärschlüssel auf UNUSABLE ergab ein INSERT ORA-01502. Die Tabelle nimmt keine neuen Zeilen mehr an.

Reparieren
ALTER INDEX ix_cli_email REBUILD baut ihn neu auf: gemessen wurde dieser VALID, die anderen blieben UNUSABLE. Sie werden einzeln neu aufgebaut.

Besser gar nicht kaputt machen: MOVE ONLINE
ALTER TABLE cli MOVE ONLINE verschiebt die Tabelle und pflegt dabei die Indizes: gemessen an derselben Tabelle blieben alle fünf VALID. So verdichtet man eine Tabelle oder verlegt sie in einen anderen Tablespace, ohne etwas kaputt zu hinterlassen.

Wie es um jeden steht
Mit Statistiken liefert ALL_INDEXES NUM_ROWS, DISTINCT_KEYS und LEAF_BLOCKS: IX_CLI_EMAIL hatte 20 000 verschiedene Schlüssel in 63 Blattblöcken; IX_CLI_BAJA 200 Zeilen und einen einzigen Schlüssel, weil die 19 800 NULL nicht darin stehen. Die Größe auf der Platte steht in DBA_SEGMENTS.

In Calíope
Kataloge kennzeichnet einen Index, der nicht VALID ist, mit seinem Status.

Stichwörter: Index, UNUSABLE, VALID, REBUILD, MOVE, MOVE ONLINE, ROWID, ORA-01502, LEAF_BLOCKS, DISTINCT_KEYS, Oracle

Primärschlüssel und eindeutige Schlüssel (Oracle)

Jeder legt seinen Index an, ein eindeutiger Schlüssel lässt mehrere NULL durch, und eine Einschränkung kann bis zum COMMIT warten.

Gilt für: Oracle 26ai+

Jede Einschränkung, ihr Index
Ein PRIMARY KEY oder ein UNIQUE legt einen eindeutigen Index an, wenn es keinen passenden gibt. Ohne Namen heißen Einschränkung und Index SYS_C… (gemessen: SYS_C0010145, UNIQUE). Gib ihnen Namen —CONSTRAINT pk_cli PRIMARY KEY (id)—: Das ist der Name, den die Fehler zeigen.

UNIQUE und 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

Mehrere NULL passen, weil vollständig NULL gesetzte Schlüssel nicht in den Index kommen. In einem eindeutigen Schlüssel über mehrere Spalten, bei dem eine einen Wert hat, aber nicht: (1, NULL) zweimal ergab ORA-00001.

Verzögerte Einschränkungen

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

Eine Einschränkung DEFERRABLE INITIALLY DEFERRED wird beim Bestätigen geprüft, nicht bei jeder Anweisung, und wenn sie fehlschlägt, wird die ganze Transaktion zurückgerollt: gemessen blieb die Tabelle leer. Damit tauscht man Schlüssel oder lädt verknüpfte Tabellen in beliebiger Reihenfolge. Ihr Index ist nicht eindeutig (NONUNIQUE), weil es mitten in der Transaktion Duplikate geben kann.

Der Fehler sagt, wo
Auf 26ai nennt ORA-00001 die Einschränkung, die Tabelle und die Spalten: unique constraint (DBA_MANUAL.UQ_U) violated on table DBA_MANUAL.U columns (A).

In Calíope
Der Fehlertext kommt unverändert im Editor an, mit dem Namen der Einschränkung.

Stichwörter: Primärschlüssel, PRIMARY KEY, UNIQUE, NULL, DEFERRABLE, INITIALLY DEFERRED, ORA-00001, ORA-02091, SYS_C, Oracle

Fremdschlüssel (Oracle)

Sie legen keinen Index an, haben kein ON UPDATE und lassen sich ab- und einschalten, ohne sie zu löschen.

Gilt für: 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 ist ein Kind ohne Elternteil; ORA-02292, das Löschen eines Elternteils, das Kinder hat.

Es wird kein Index angelegt
Gemessen: Nach dem Anlegen von ped mit seinem Fremdschlüssel gab es keinen Index auf ped.cli_id, und die Suche nach den Bestellungen eines Kunden (WHERE cli_id = 5) las die ganze Tabelle. Genau das tut Oracle für jeden Elternteil, der gelöscht wird oder dessen Schlüssel sich ändert. Nach jedem Fremdschlüssel braucht man fast immer CREATE INDEX ix_ped_cli ON ped (cli_id).

Kein ON UPDATE
Es gibt nur ON DELETE CASCADE und ON DELETE SET NULL; ON UPDATE CASCADE ergibt ORA-02000: missing DELETE keyword. In Oracle ändert man einen Primärschlüssel nicht, und wenn doch, dann von Hand oder mit einer verzögerten Einschränkung.

ON DELETE CASCADE
Mit so deklariertem Fremdschlüssel löschte das Löschen von Kunde 5 auch seine Bestellung, ohne Rückfrage. Bequem und gefährlich: Ein DELETE einer Zeile kann mehrere Ebenen hinabreichen.

Abschalten, ohne zu löschen

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 lässt alles schreiben; ENABLE prüft den gesamten Bestand und ergibt bei einer verwaisten Zeile ORA-02298. ENABLE NOVALIDATE schaltet ihn nur für neue Zeilen ein: Er bleibt NOT VALIDATED, und die nächste verwaiste Zeile schlägt schon fehl. Bei aktivem Fremdschlüssel ergibt TRUNCATE der Elterntabelle ORA-02266.

In Calíope
Das ER-Diagramm zeichnet die Fremdschlüssel, auch die schemaübergreifenden, und die DDL, die Calíope schreibt, legt sie zuletzt an, nach allen Tabellen.

Stichwörter: Fremdschlüssel, FOREIGN KEY, REFERENCES, ON DELETE CASCADE, ON DELETE SET NULL, ON UPDATE, NOVALIDATE, Index, ORA-02291, ORA-02292, ORA-02298, ORA-02266, Oracle

Identität und Sequenzen (Oracle)

ALWAYS lässt den Wert nicht schreiben, BY DEFAULT schon, und eine Sequenz hinterlässt Lücken mit Absicht.

Gilt für: Oracle 26ai+

Identitätsspalten

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: Den Wert setzt immer Oracle; ihn zu schreiben ergibt ORA-32795, ihn zu ändern ORA-32796.
- BY DEFAULT: Oracle setzt ihn, wenn du ihn nicht angibst; mit ON NULL auch, wenn du NULL angibst.

Vorsicht mit BY DEFAULT: Der Zähler weiß nicht, dass du 100 geschrieben hast, und wenn er dort ankommt, kollidiert er mit dem Primärschlüssel. Intern ist die Identität eine Systemsequenz (ISEQ$$_…, in ALL_TAB_IDENTITY_COLS).

Sequenzen

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 ist das letzte NEXTVAL deiner Sitzung: vor dem ersten ergibt es ORA-08002.
- Zwei NEXTVAL in derselben Zeile liefern denselben Wert.
- DEFAULT sq.NEXTVAL auf einer Spalte macht sie zu einem Autoinkrement, und die Sequenz lässt sich zwischen Tabellen teilen.

Lücken sind normal
Ein mit ROLLBACK zurückgerolltes NEXTVAL wird nicht zurückgegeben: gemessen kam 1 heraus, wurde zurückgerollt, und der nächste war 2. Mit CACHE 20 reserviert Oracle zwanzig Nummern im Speicher (LAST_NUMBER zeigte nach der 2 schon 21), und startet die Instanz neu, gehen die unbenutzten verloren. Eine Lücke ist kein verlorener Datensatz. Wer eine lückenlose Nummerierung braucht —Rechnungen—, nimmt sie nicht aus einer Sequenz.

In Calíope
Das Kontrollkästchen Identität (IDENTITY) im Builder legt GENERATED BY DEFAULT AS IDENTITY an, und Kataloge listet die Sequenzen in ihrem Reiter, ohne die internen der Identitäten.

Stichwörter: Identität, IDENTITY, GENERATED ALWAYS, BY DEFAULT, Sequenz, SEQUENCE, NEXTVAL, CURRVAL, CACHE, Lücken, ORA-32795, ORA-08002, Oracle

Bereichs- und Intervallpartitionierung (Oracle)

Eine nach Datum partitionierte Tabelle, das Intervall, das Partitionen selbst anlegt, und was mit einer Zeile passiert, die nicht passt.

Gilt für: Oracle 26ai+

Eine partitionierte Tabelle ist für den Abfragenden eine Tabelle und innen mehrere: Jede Partition ist ein eigenes Segment, das sich einzeln lesen, leeren oder löschen lässt.

Bereich mit Intervall

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'));

Nur die erste Partition wird deklariert. Mit INTERVAL legt Oracle eine neue Partition an, sobald zum ersten Mal eine Zeile für einen Monat ohne eigene Partition kommt: gemessen hinterließen 30 000 Zeilen, verteilt auf zehn Monate, 11 Partitionen, die erste und zehn mit Systemnamen (SYS_P696, SYS_P697…), jede mit ihrer Grenze in HIGH_VALUE.

Jede Partition ist ein Segment, und kein kleines
Eine Partition mit einer einzigen Zeile belegte 8 MB: Das erste Stück Speicher einer Partition ist viel größer als das einer normalen Tabelle. Bei täglichen oder sehr feinen Partitionen sammelt sich leerer Platz an.

Ohne Intervall ist die Lücke ein Fehler
Bei einem Bereich oder einer Liste ohne Intervall ergibt eine Zeile, die in keine Partition fällt, ORA-14400: no partition found. Vermeiden lässt sich das mit INTERVAL, mit einer letzten Partition VALUES LESS THAN (MAXVALUE) oder, bei einer Liste, mit DEFAULT („Listen-, Hash- und automatische Partitionierung (Oracle)“).

Der Partitionsschlüssel ändert sich nicht von selbst
Ein UPDATE der Partitionsspalte, das die Zeile in eine andere Partition verschieben würde, ergibt ORA-14402. ALTER TABLE … ENABLE ROW MOVEMENT erlaubt es: Die Zeile wechselt Partition und ROWID.

Wo man nachsieht
ALL_TAB_PARTITIONS: Name, Position, HIGH_VALUE und, mit Statistiken, NUM_ROWS je Partition. ALL_PART_TABLES: Typ und Schlüssel.

In Calíope
HDD-Nutzung summiert, was alle Partitionen der Tabelle belegen.

Stichwörter: Partition, RANGE, INTERVAL, NUMTOYMINTERVAL, HIGH_VALUE, MAXVALUE, ROW MOVEMENT, ORA-14400, ORA-14402, ALL_TAB_PARTITIONS, Oracle

Listen-, Hash- und automatische Partitionierung (Oracle)

Liste für bekannte Werte, AUTOMATIC, damit sie von selbst wächst, und Hash zum Verteilen.

Gilt für: Oracle 26ai+

Liste

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

Für eine Spalte mit wenigen bekannten Werten: Land, Region, Status. Eine Partition kann mehrere Werte enthalten, und DEFAULT nimmt den Rest auf.

Automatische Liste
PARTITION BY LIST (pais) AUTOMATIC legt für jeden neuen Wert eine Partition an, so wie das Intervall beim Bereich: gemessen hinterließ das Einfügen von 'PE' in eine Tabelle, die nur p_es hatte, zwei Partitionen.

Hash

CREATE TABLE vh (id NUMBER) PARTITION BY HASH (id) PARTITIONS 4;
-- 10000 → 2471, 2527, 2521, 2481
-- PARTITIONS 3 → 2471, 5008, 2521

Verteilt nach dem Hashwert. Mit 4 Partitionen verteilten sich 10 000 Zeilen fast gleichmäßig; mit 3 bekam eine Partition doppelt so viele wie die anderen beiden. Verwende eine Anzahl von Partitionen, die eine Zweierpotenz ist.

Hash taugt weder zum Löschen alter Daten noch dazu, nur einen Bereich zu lesen; er dient dazu, eine stark beschriebene Tabelle zu verteilen oder zwei nach demselben Schlüssel partitionierte Tabellen zu verbinden.

Zusammengesetzt
Man kann kombinieren: Bereich nach Datum und innerhalb jedes Monats Liste oder Hash (SUBPARTITION BY). Jede Subpartition ist ein Segment, und jedes Segment belegt seinen Mindestplatz („Bereichs- und Intervallpartitionierung (Oracle)“): Monate mal Subpartitionen vervielfacht sich schnell.

Welche wählen
Bereich oder Intervall, wenn nach Datum abgefragt und gelöscht wird, was fast immer der Fall ist; Liste, wenn nach einem von wenigen möglichen Werten gefiltert wird; Hash, wenn nur verteilt werden soll.

Stichwörter: Partition, LIST, AUTOMATIC, DEFAULT, HASH, SUBPARTITION, zusammengesetzt, Zweierpotenz, ORA-14400, Oracle

Partition Pruning und Partitionswartung (Oracle)

Wann Oracle nur eine Partition liest und wie man einen Monat löscht, ohne unbrauchbare Indizes zu hinterlassen.

Gilt für: Oracle 26ai+

Pruning
Wenn die Bedingung erkennen lässt, welche Partitionen Zeilen enthalten können, liest Oracle nur diese. Gemessen mit dem EXPLAIN von 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

Dasselbe anders geschrieben liest alle zehn Partitionen: Eine Funktion auf der Partitionsspalte verhindert das Pruning, so wie sie die Nutzung eines Index verhindert. Die Bedingung direkt auf die Spalte, als halboffener Bereich.

Einen ganzen Monat löschen
ALTER TABLE ventas DROP PARTITION FOR (DATE '2026-02-15') löscht die Partition, die dieses Datum enthält, auf einen Schlag, ohne zeilenweises DELETE. Das ist der Hauptgrund, nach Datum zu partitionieren. TRUNCATE PARTITION leert sie und behält sie.

Globale Indizes werden unbrauchbar

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)

Ein lokaler Index ist wie die Tabelle partitioniert, und sein Stück geht mit der Partition. Ein globaler deckt alle ab, und das Löschen einer Partition macht ihn UNUSABLE („Unbrauchbare Indizes und Neuaufbau (Oracle)“). Mit UPDATE INDEXES am Ende von DROP oder TRUNCATE pflegt Oracle ihn: gemessen blieb der globale VALID. Es dauert länger, aber es bleibt nichts kaputt.

Welcher von beiden
Lokal, wenn die Abfragen auch nach der Partitionsspalte filtern; global, wenn nach etwas gesucht wird, das nichts mit ihr zu tun hat, etwa einer eindeutigen Kennung.

In Calíope
Visual EXPLAIN zeigt die Knoten PARTITION RANGE SINGLE oder PARTITION RANGE ALL, die sagen, ob Pruning stattfand.

Stichwörter: Partition, Pruning, PARTITION RANGE SINGLE, DROP PARTITION, TRUNCATE PARTITION, UPDATE INDEXES, LOCAL, global, UNUSABLE, Oracle

Tablespaces und Kontingente (Oracle)

Wo die Daten liegen, warum ein Benutzer ohne Kontingent nicht einfügen kann und wie man die Belegung liest.

Gilt für: Oracle 26ai+

Ein Tablespace ist der logische Behälter der Daten, mit einer oder mehreren Dateien darunter. Jede Tabelle und jeder Index liegt in einem, und jeder Benutzer hat einen Standard-Tablespace.

Die der Testbed-Datenbank
Auf 26ai Free: SYSTEM und SYSAUX (das Data Dictionary und die Komponenten von Oracle, nicht anzufassen), UNDOTBS1 (Undo), TEMP (zum Sortieren dessen, was nicht in den Speicher passt) und USERS, den die Benutzer bekommen. Die Dateien wachsen von selbst: DBA_DATA_FILES zeigte bei allen AUTOEXTENSIBLE = YES.

Das Kontingent
Einen Benutzer anzulegen gibt ihm keinen Platz. Gemessen:

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

Die Tabelle wird angelegt, weil sie noch nichts belegt („Segmente und Extents (Oracle)“), und das erste INSERT schlägt mit ORA-01950 fehl. Behoben wird es mit ALTER USER dba_sincuota QUOTA 500M ON users oder mit QUOTA UNLIMITED.

Wie viel belegt ist

SELECT tablespace_name, used_percent
  FROM dba_tablespace_usage_metrics;

USED_PERCENT wird gegen die maximale Größe gerechnet, auf die der Tablespace wachsen kann, nicht gegen die heutige Datei: USERS zeigte 0,16 % bei etwa 250 MB Belegung. Ein selbst wachsender Tablespace kann die Platte des Servers füllen, lange bevor er sein Maximum erreicht; die Platte wird daher getrennt überwacht. USED_SPACE und TABLESPACE_SIZE sind in Blöcken angegeben, auf dieser Datenbank zu 8192 Bytes.

In Calíope
Das Dashboard zeigt Vollster Speicher, den Tablespace mit dem höchsten USED_PERCENT, und der Health-Monitor erfasst ihn in jedem Zyklus.

Stichwörter: Tablespace, Kontingent, QUOTA, USERS, SYSTEM, SYSAUX, TEMP, UNDO, DBA_DATA_FILES, AUTOEXTENSIBLE, DBA_TABLESPACE_USAGE_METRICS, USED_PERCENT, ORA-01950, Oracle

Segmente und Extents (Oracle)

Jede Tabelle, jeder Index, jede Partition und jeder LOB ist ein Segment; eine leere Tabelle hat keins.

Gilt für: Oracle 26ai+

Ein Segment ist der Platz, den ein Objekt belegt: eine Tabelle, ein Index, eine Partition oder ein LOB. Es wächst in Extents, und jedes Extent besteht auf dieser Datenbank aus Blöcken zu 8 KB.

Eine leere Tabelle belegt nichts
Mit DEFERRED_SEGMENT_CREATION = TRUE, dem Wert des Images, reserviert das Anlegen einer Tabelle nichts:

CREATE TABLE vacia (id NUMBER);
-- DBA_SEGMENTS: 0
INSERT INTO vacia VALUES (1);
-- DBA_SEGMENTS: 64 KB, 1 extent, 8 blocks

SEGMENT CREATION IMMEDIATE reserviert ihn beim Anlegen (ebenfalls 64 KB). Deshalb wird eine Tabelle ohne Kontingent angelegt und schlägt beim Einfügen fehl („Tablespaces und Kontingente (Oracle)“).

Eine Tabelle mit LOB sind drei Segmente
Eine Tabelle mit einer CLOB-Spalte ergab drei: die Tabelle (TABLE), die LOB-Daten (LOBSEGMENT, Name SYS_LOB…) und ihren Index (LOBINDEX, SYS_IL…). Auf 26ai ist ein LOB standardmäßig SECUREFILE.

Richtig zählen
Die Größe einer Tabelle ist die Summe ihrer Segmente: ihrer eigenen, derer ihrer Partitionen, ihrer LOBs und ihrer Indizes. DBA_SEGMENTS (oder USER_SEGMENTS für die eigenen) liefert sie nach SEGMENT_NAME und SEGMENT_TYPE; die eines LOB ordnet man über DBA_LOBS ihrer Tabelle zu.

Der Papierkorb
DROP TABLE löscht nicht: Es benennt die Tabelle in BIN$… um und lässt sie mit ihrem Platz im Papierkorb (DBA_RECYCLEBIN listete sie). FLASHBACK TABLE x TO BEFORE DROP brachte sie mit ihrer Zeile zurück. DROP TABLE x PURGE oder PURGE RECYCLEBIN geben wirklich frei.

In Calíope
HDD-Nutzung summiert je Tabelle ihre Segmente, die ihrer Partitionen, ihrer LOBs und ihrer Indizes: mit DBA_SEGMENTS, wenn es lesbar ist, sonst mit USER_SEGMENTS.

Stichwörter: Segment, Extent, DBA_SEGMENTS, USER_SEGMENTS, DEFERRED_SEGMENT_CREATION, LOBSEGMENT, SECUREFILE, Papierkorb, RECYCLEBIN, FLASHBACK, PURGE, Oracle

Platz, der nicht zurückkommt: DELETE, TRUNCATE und SHRINK (Oracle)

Zeilen zu löschen gibt keinen Platz zurück und beschleunigt kein vollständiges Lesen; Leeren oder Verdichten schon.

Gilt für: Oracle 26ai+

Oracle füllt eine Tabelle bis zu einer Marke, der High Water Mark, und ein vollständiges Lesen durchläuft alles darunter, ob Zeilen da sind oder nicht.

DELETE gibt nichts zurück

-- 50 000 × ~200 bytes → DBA_SEGMENTS: 12 MB, 1536 blocks
DELETE FROM hw;
-- DBA_SEGMENTS: 12 MB, 1536 blocks

Nach dem Löschen aller Zeilen und dem Erheben der Statistiken zeigte ALL_TABLES NUM_ROWS 0 und BLOCKS 1504: Ein SELECT COUNT(*) liest diese leeren Blöcke weiterhin (TABLE ACCESS FULL). Der Platz wird für neue Zeilen wiederverwendet, geht aber nicht an den Tablespace zurück.

TRUNCATE schon
TRUNCATE TABLE leert die Tabelle und senkt die Marke: gemessen ließen 50 000 neue Zeilen und ein TRUNCATE das Segment bei 0,06 MB und 8 Blöcken. Es ist DDL: ROLLBACK macht es nicht rückgängig, und es bestätigt, was die Sitzung noch offen hatte (gemessen: Ein INSERT in eine andere Tabelle wurde bestätigt). Zeigt ein aktiver Fremdschlüssel auf die Tabelle, schlägt es mit ORA-02266 fehl.

SHRINK SPACE verdichtet, ohne zu leeren

ALTER TABLE hw ENABLE ROW MOVEMENT;
ALTER TABLE hw SHRINK SPACE;
-- DBA_SEGMENTS: 0.06 MB, 8 blocks

Es rückt die Zeilen an den Anfang und gibt den Rest zurück. Es braucht ROW MOVEMENT, weil es die ROWIDs ändert. Der andere Weg ist ALTER TABLE … MOVE ONLINE, das die Tabelle neu schreibt und die Indizes pflegt („Unbrauchbare Indizes und Neuaufbau (Oracle)“).

Wann es sich lohnt
Nach dem Löschen eines großen Teils einer Tabelle, die nicht wieder wachsen wird, oder wenn ein vollständiges Lesen viel länger dauert, als ihre Zeilenzahl vermuten ließe. Füllt sich die Tabelle wieder, bringt das Verdichten nichts: Der Platz wäre ohnehin wiederverwendet worden.

In Calíope
HDD-Nutzung gibt die Größe der Segmente an, nicht die der Zeilen: Eine Tabelle mit 0 Zeilen und 12 MB ist eine mit DELETE geleerte Tabelle. Tabellenwartung → ANALYZE erhebt die Statistiken, die sagen, wie viele Zeilen noch da sind.

Stichwörter: Platz, High Water Mark, DELETE, TRUNCATE, SHRINK SPACE, ROW MOVEMENT, MOVE ONLINE, BLOCKS, ORA-02266, Oracle

Bestätigen, zurückrollen und die DDL, die bestätigt (Oracle)

Oracle bestätigt nichts von selbst: COMMIT, ROLLBACK, Sicherungspunkte und die DDL, die Offenes bestätigt.

Gilt für: Oracle 26ai+

In Oracle beginnt eine Transaktion von selbst, mit dem ersten Schreibvorgang, und endet mit COMMIT oder ROLLBACK. Es gibt kein BEGIN, und der Server bestätigt nichts von selbst: Autocommit schaltet der Client ein, oder eben nicht.

Offenes sieht nur, wer es geschrieben hat
Gemessen mit zwei Sitzungen: Nach einem unbestätigten INSERT zählte die andere Sitzung 5 Zeilen; nach dem COMMIT 6. Und eine Sitzung, die sich ohne Bestätigen schließt, rollt ihre eigene Arbeit zurück: Die Zeile, die sie geschrieben hatte, gab es für niemanden.

DDL bestätigt, was offen war

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 und GRANT bestätigen die offene Transaktion, bevor sie laufen. Das ROLLBACK danach hatte nichts mehr zurückzurollen.

Sicherungspunkte

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 macht rückgängig, was nach dem Punkt kam, und lässt die Transaktion offen.

Scheitert eine Anweisung, wird nur sie zurückgerollt

INSERT INTO cuenta VALUES (10, 10);  -- OK
INSERT INTO cuenta VALUES (10, 10);  -- ORA-00001
COMMIT;
SELECT COUNT(*) FROM cuenta WHERE id = 10;  -- 1

Der Fehler wirft die Transaktion nicht weg: Was vorher kam, bleibt offen, und das COMMIT speichert es. Wer alles oder nichts will, rollt beim Fehler zurück.

In Calíope
Der SQL-Editor bestätigt jede Ausführung. Um in einer Transaktion zu arbeiten: Transaktion → BEGIN. Ab dann wird nichts bestätigt bis COMMIT oder ROLLBACK, und beendet eine DDL sie unterwegs, sagt es die Statusleiste: „Die Transaktion ist nicht mehr offen: Das SQL selbst hat sie beendet, oder der Server hat sie nach einem Fehler zurückgerollt. Was du jetzt ausführst, wird sofort festgeschrieben.“. Der Tipp „Der Server bestätigt nichts von selbst“ erinnert beim ersten Mal daran.

Stichwörter: Transaktion, COMMIT, ROLLBACK, SAVEPOINT, Autocommit, DDL, bestätigen, zurückrollen, Oracle

Lesekonsistenz (Oracle)

Lesende warten nicht auf Schreibende: Jede Abfrage sieht die Daten, die bei ihrem Start bestätigt waren.

Gilt für: Oracle 26ai+

Oracle sperrt keine Lesevorgänge. Jede Abfrage sieht die Datenbank so, wie sie beim Start der Abfrage bestätigt war, auch wenn eine andere Sitzung genau diese Zeilen ändert.

Der Schreibende hält den Lesenden nicht auf

-- A
UPDATE cuenta SET saldo = 0 WHERE id = 1;
-- B
SELECT saldo FROM cuenta WHERE id = 1;  -- 100 (0.01 s)

Während A die Zeile geändert und nicht bestätigt hatte, las B sofort den vorigen Wert, ohne zu warten und ohne die halbe Änderung zu sehen. Dafür baut Oracle die frühere Version aus dem Undo nach, das die Transaktion von A aufbewahrt hat.

Jede Anweisung sieht einen Augenblick
Eine lange Abfrage sieht die ganze Tabelle im selben Moment: Was andere bestätigen, während sie die Zeilen durchgeht, kommt nicht hinein, auch nicht halb. Zwei Abfragen hintereinander unter READ COMMITTED —der Standardstufe— können verschiedene Daten sehen, weil jede ihren eigenen Augenblick nimmt („Isolationsstufen und SET TRANSACTION (Oracle)“).

Warten müssen nur Schreibvorgänge
Zwei Sitzungen, die dieselbe Zeile schreiben, warten aufeinander: Die zweite bleibt stehen, bis die erste bestätigt oder zurückrollt („Zeilensperren und wer wen blockiert (Oracle)“). Ein normales SELECT blockiert nicht und wird nicht blockiert; SELECT … FOR UPDATE schon, weil es die Zeilen zum Schreiben reserviert.

Unbestätigtes gibt es für andere nicht
In Oracle gibt es kein „schmutziges“ Lesen: Keine Sitzung kann eine unbestätigte Änderung einer anderen sehen, und keine Stufe erlaubt es. Eine Sitzung, die sich ohne Bestätigen schließt, lässt die Datenbank, wie sie war.

In Calíope
Der SQL-Editor bestätigt jede Ausführung, also ist, was du schreibst, sofort für andere sichtbar. Innerhalb von Transaktion → BEGIN gehören deine Änderungen nur dir, bis COMMIT: Ein anderer Tab oder das Werkzeug Kataloge, die eine andere Verbindung nutzen, sehen sie nicht.

Stichwörter: Lesekonsistenz, Undo, READ COMMITTED, schmutziges Lesen, Multiversion, SELECT FOR UPDATE, Oracle

Isolationsstufen und SET TRANSACTION (Oracle)

Oracle hat zwei Stufen, READ COMMITTED und SERIALIZABLE, dazu Nur-Lese-Transaktionen.

Gilt für: Oracle 26ai+

Oracle akzeptiert zwei Isolationsstufen, nicht die vier des Standards. Gemessen: READ UNCOMMITTED und REPEATABLE READ ergeben ORA-02179 („valid options: ISOLATION LEVEL { SERIALIZABLE | READ COMMITTED }“).

READ COMMITTED, das Übliche
Jede Anweisung sieht, was bei ihrem eigenen Start bestätigt war. Es ist die Standardstufe und die, die fast jede Anwendung nutzt.

SERIALIZABLE: Die ganze Transaktion sieht einen Augenblick

-- 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 sah weiter 100, obwohl A schon 999 bestätigt hatte: Die ganze Transaktion sieht die Datenbank vom Moment ihres Beginns. Und beim Versuch, eine Zeile zu ändern, die ein anderer nach diesem Moment geändert hat, mischt Oracle nicht: ORA-08177, und wer es bekommt, rollt zurück und beginnt neu. Nach dem ROLLBACK las B 999. Gedacht ist es für Berichte, die über mehrere Abfragen stimmen müssen, und für Schreibvorgänge, die andere nicht überschreiben dürfen, um den Preis des Wiederholens.

Nur lesen

SET TRANSACTION READ ONLY;
INSERT INTO cuenta VALUES (12, 12);  -- ORA-01456
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;  -- ORA-02179

SET TRANSACTION READ ONLY liefert dasselbe eingefrorene Bild wie SERIALIZABLE, ohne schreiben zu können. Das passt für einen langen Bericht aus mehreren Abfragen, der stimmen muss.

Es kommt zuerst
SET TRANSACTION muss die erste Anweisung der Transaktion sein und gilt bis zu ihrem COMMIT oder ROLLBACK. Für die ganze Sitzung: ALTER SESSION SET ISOLATION_LEVEL = SERIALIZABLE.

In Calíope
Der SQL-Editor bestätigt jede Ausführung, also ist jede Anweisung ihre eigene Transaktion: Ein einzelnes SET TRANSACTION reicht nicht bis zur nächsten. Damit es gilt: Transaktion → BEGIN, dann das SET TRANSACTION und die Abfragen, und am Ende COMMIT oder ROLLBACK.

Stichwörter: Isolation, READ COMMITTED, SERIALIZABLE, READ ONLY, SET TRANSACTION, ORA-08177, ORA-02179, ORA-01456, Oracle

Zeilensperren und wer wen blockiert (Oracle)

Oracle sperrt Zeilen, nicht Seiten oder Tabellen, und V$SESSION sagt, wer auf wen wartet.

Gilt für: Oracle 26ai+

Oracle sperrt Zeilen. Eine geschriebene Zeile bleibt reserviert bis zum COMMIT oder ROLLBACK dessen, der sie geschrieben hat; die übrigen Zeilen der Tabelle bleiben frei, und Lesen wartet nie („Lesekonsistenz (Oracle)“). Sperren eskalieren nicht: Tausend reservierte Zeilen werden nicht zu einer Tabellensperre.

Was ein Schreibvorgang hinterlässt
Gemessen nach einem unbestätigten UPDATE einer Zeile:

SELECT type, lmode FROM v$lock
 WHERE sid = 50 AND type IN ('TM', 'TX');
-- TM 3, TX 6

TM im Modus 3 ist die geteilte Sperre auf die Tabelle: Sie verhindert, dass sich ihre Struktur ändert, solange die Transaktion dauert. TX im Modus 6 ist die Transaktion: Die geänderten Zeilen zeigen auf sie, und wer sie schreiben will, wartet auf sie.

Wer auf wen wartet
Mit einer anderen Sitzung, die dasselbe UPDATE versucht:

SELECT blocking_session, event, wait_class
  FROM v$session
 WHERE blocking_session IS NOT NULL;
-- 50, enq: TX - row lock contention, Application

BLOCKING_SESSION ist die SID der Sitzung, die die Zeile hält. Als sie bestätigt hatte, lief die wartende von selbst weiter: Sie brauchte 2.12 s, so lange, wie die andere zum Bestätigen brauchte.

Was tun
Fast immer ist die blockierende Sitzung eine Transaktion, die jemand zu schließen vergessen hat: ein Werkzeug mit ausgeschaltetem Autocommit oder ein Programm, das schreibt und nicht bestätigt. Richtig ist, von dieser Sitzung aus zu bestätigen oder zurückzurollen. Sie zu beenden rollt alles zurück, was sie offen hatte.

Fremdschlüssel ohne Index
Das steht schon in „Fremdschlüssel (Oracle)“: Löschen in der Elterntabelle mit einem Fremdschlüssel ohne Index sperrt die ganze Kindtabelle.

In Calíope
Prozesse zeigt „blockiert durch 50“ bei der wartenden Sitzung, vor dem Warteereignis (enq: TX - row lock contention), und die blockierende Sitzung lässt sich in derselben Liste beenden.

Stichwörter: Sperre, Zeile, V$LOCK, V$SESSION, BLOCKING_SESSION, TX, TM, enq: TX - row lock contention, Oracle

Nicht warten: NOWAIT, WAIT und SKIP LOCKED (Oracle)

Eine Zeile anfordern, ohne hängen zu bleiben, und warum eine DDL auf einer benutzten Tabelle ORA-00054 ergibt.

Gilt für: Oracle 26ai+

Standardmäßig wartet, wer eine reservierte Zeile schreiben will, ohne Grenze. SELECT … FOR UPDATE lässt dich wählen:

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 scheitert sofort mit ORA-00054. WAIT n wartet bis zu n Sekunden: Auf 26ai gemessen, lieferte es nach Ablauf ebenfalls ORA-00054, keinen anderen Fehler. SKIP LOCKED überspringt reservierte Zeilen und liefert die freien: Mit Zeile 3 belegt, kam nur die 4 zurück.

SKIP LOCKED für Warteschlangen
Mehrere Prozesse, die sich Arbeit teilen, können „die nächste offene Zeile“ lesen, ohne sich zu stören: Jeder nimmt eine andere, und keiner wartet. Was zurückkommt, hängt davon ab, was die anderen gerade tun, also taugt es nicht für einen Bericht.

DDL und benutzte Tabellen
Mit einer Zeile von cuenta, die in einer anderen Sitzung geändert und nicht bestätigt ist:

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 und LOCK TABLE … NOWAIT brauchen die ganze Tabelle und scheitern sofort mit ORA-00054. Das Hinzufügen einer Spalte dagegen wartete, bis die andere Sitzung bestätigte, und endete nach 3.06 s fehlerfrei. Um jeder DDL Spielraum zu geben: ALTER SESSION SET DDL_LOCK_TIMEOUT = 30 (Sekunden).

Die Meldung von 26ai
Auf 26ai erklärt der Text von ORA-00054, was reserviert war: "TX" ist eine Zeile einer Transaktion, "TM" die ganze Tabelle, mit der Objektnummer.

In Calíope
Ein ORA-00054 im SQL-Editor erscheint im Fehlerbanner mit vollem Text, und Prozesse zeigt, wer die Tabelle oder die Zeile hält („Zeilensperren und wer wen blockiert (Oracle)“).

Stichwörter: NOWAIT, WAIT, SKIP LOCKED, FOR UPDATE, ORA-00054, DDL_LOCK_TIMEOUT, TRUNCATE, LOCK TABLE, Warteschlange, Oracle

Deadlocks (Oracle)

Zwei Transaktionen, die aufeinander warten: Oracle erkennt es, eine Anweisung scheitert, und die andere wartet weiter.

Gilt für: Oracle 26ai+

Ein Deadlock ist ein Kreis: A wartet auf eine Zeile, die B hält, und B auf eine, die A hält. Keine kann weiter, und Oracle löst es selbst.

Gemessen

-- 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)

Etwa fünf Sekunden nachdem sich der Kreis geschlossen hatte, scheiterte die Anweisung von A mit ORA-00060 („deadlock detected while waiting for resource“).

Es scheitert die Anweisung, nicht die Transaktion
Das verwirrt am meisten. Oracle rollt nur die Anweisung zurück, die den Fehler bekam: Die Transaktion von A bleibt offen und behält Zeile 4. B wartete also weiter —eine blockierte Sitzung in V$SESSION—, bis A ROLLBACK ausführte; dann war B fertig. Wer ORA-00060 bekommt, muss seine Transaktion zurückrollen (oder bestätigen, was schon getan ist), sonst geht die Sperre weiter.

Wen es trifft
Die scheiternde Anweisung ist eine der beiden im Kreis, die, die Oracle beim Prüfen erkannt hat; sie wird nicht nach Kosten oder Alter gewählt. Jeder Deadlock hinterlässt außerdem eine Trace-Datei auf dem Server, mit den Sitzungen und den Zeilen, und eine Zeile im Alert-Log.

Wie man sie vermeidet
Sie kommen fast immer daher, dass dieselben Zeilen in anderer Reihenfolge geschrieben werden. Aktualisiert die ganze Anwendung in derselben Reihenfolge —etwa nach Schlüssel—, entsteht der Kreis nicht. Fremdschlüssel ohne Index sind die andere klassische Ursache („Fremdschlüssel (Oracle)“).

Es ist kein ORA-00054
ORA-00054 heißt, eine belegte Zeile mit NOWAIT anzufordern; ORA-00060 ist ein echter Kreis. Das Erste löst sich durch Warten, das Zweite nicht.

In Calíope
Der Fehler erscheint im SQL-Editor mit seinem Text. Bist du in Transaktion → BEGIN, bleibt, was du vorher getan hast, offen: Entscheide zwischen COMMIT und ROLLBACK, um die Zeilen freizugeben.

Stichwörter: Deadlock, ORA-00060, Sperre, ROLLBACK, Reihenfolge, Trace, Oracle

Der Optimierer und EXPLAIN PLAN (Oracle)

Wie Oracle einen Plan nach Kosten wählt, wie man ihn sieht und was FETCH FIRST mit einem Index ändert.

Gilt für: Oracle 26ai+

Oracle wählt den Plan nach Kosten: Aus den Tabellenstatistiken schätzt es, wie viele Zeilen jeder Schritt liefert, und nimmt den billigsten Plan. Der gemessene Knoten läuft im Modus ALL_ROWS: Er optimiert die Zeit, alle Zeilen zu liefern.

Den Plan sehen

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 speichert den Plan, ohne die Abfrage auszuführen, und DBMS_XPLAN.DISPLAY zeigt ihn. Man liest von innen nach außen: Der Index liefert die ROWIDs, dann werden diese Zeilen gelesen. Rows ist die Schätzung, nicht das Ergebnis; Cost ist eine interne Einheit zum Vergleichen von Plänen, keine Zeit.

Index oder ganze Tabelle
Bei 10 von 20 000 Zeilen war es der Index. Bei cliente > 10, fast der ganzen Tabelle, war der Plan TABLE ACCESS FULL: Die Tabelle am Stück zu lesen ist billiger, als 19 900 Mal vom Index in die Tabelle zu springen.

FETCH FIRST und die Reihenfolge

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)

Mit einem Index auf der Spalte des ORDER BY liest Oracle die ersten 10 Einträge und hört auf (COUNT STOPKEY). Ohne Index muss es alle 20 000 lesen und sortieren, auch wenn es nur die 10 besten behält: Kosten 139 gegen 3.

Ohne Statistiken rät es
Bei einer frisch geladenen Tabelle ohne Statistiken trug der Plan den Hinweis „dynamic statistics used: dynamic sampling (level=2)“: Oracle hat die Tabelle beim Optimieren stichprobenartig gelesen. Mit aktuellen Statistiken entscheidet es besser („Statistiken und DBMS_STATS (Oracle)“).

In Calíope
Visual EXPLAIN zeichnet denselben Plan als Baum, mit den Zeilen und den Kosten jedes Knotens, und der Index-Berater schlägt einen vor, wenn er ein TABLE ACCESS FULL mit Filter sieht. Calíope löscht aus PLAN_TABLE, was es dort schreibt.

Stichwörter: Optimierer, Kosten, EXPLAIN PLAN, DBMS_XPLAN, Plan, TABLE ACCESS FULL, INDEX RANGE SCAN, FETCH FIRST, COUNT STOPKEY, Oracle

Statistiken und DBMS_STATS (Oracle)

Der Optimierer entscheidet mit Statistiken: wie man sie erhebt, wann sie veralten und was ein Histogramm bringt.

Gilt für: Oracle 26ai+

Statistiken sagen dem Optimierer, wie viele Zeilen jede Tabelle hat, wie viele verschiedene Werte jede Spalte und wie sie verteilt sind. Oracle erhebt sie selbst in seinem Wartungsfenster; nach einem großen Ladevorgang wartet man besser nicht darauf.

Erheben und prüfen, ob sie veraltet sind

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

Vor dem Erheben waren NUM_ROWS und LAST_ANALYZED NULL. Nach 80 000 zusätzlichen Zeilen stand NUM_ROWS weiter bei 20 000: Statistiken sind ein Foto, kein Zähler. STALE_STATS = 'YES' warnt, dass das Foto der Tabelle nicht mehr gleicht; Oracle markiert es so, wenn sich mehr als 10 % der Zeilen ändern.

Histogramme: wenn ein Wert nicht wie die anderen ist
Ohne Histogramm nimmt der Optimierer an, dass jeder Wert gleich oft vorkommt. In ped hat estado zwei Werte: 'RARO' in 1 % der Zeilen und 'COMUN' im Rest.

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)

Mit dem Häufigkeitshistogramm bekommt jeder Wert seinen Plan: den Index für den seltenen, die ganze Tabelle für den häufigen.

Was erhoben wird
GATHER_TABLE_STATS erhebt auch die Statistiken ihrer Indizes (cascade). Für ein ganzes Schema: DBMS_STATS.GATHER_SCHEMA_STATS. Das Erheben liest die Tabelle und braucht bei einer großen seine Zeit: gemessen 0.43 s für 20 000 Zeilen.

Calíope erhebt sie nicht von selbst
Kein Werkzeug startet DBMS_STATS, ohne dass du es verlangst: Das Erheben kostet und ändert die Pläne aller Sitzungen.

In Calíope
Tabellenwartung → ANALYZE ruft DBMS_STATS.GATHER_TABLE_STATS mit cascade auf und liest das Ergebnis zurück: „OK: 20000 Zeilen laut Statistik, erhoben am …“. Serverinformationen → HDD-Nutzung zeigt die geschätzten Zeilen mit dem Datum der Statistik.

Stichwörter: Statistiken, DBMS_STATS, GATHER_TABLE_STATS, NUM_ROWS, LAST_ANALYZED, STALE_STATS, Histogramm, Optimierer, Oracle

Der Cursor-Cache und V$SQL (Oracle)

Warum Binds zählen, was V$SQL zählt und warum Calíope kein AWR nutzt.

Gilt für: Oracle 26ai+

Oracle hält jede Anweisung, die es ausführt —Text und Plan—, im Cursor-Cache, und V$SQL zeigt sie mit ihren Zählern: Ausführungen, Zeit, CPU, Lesevorgänge.

Literale oder Binds

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

Mit dem Wert im Text ist jede Abfrage eine andere Anweisung: drei Texte, drei Analysen, drei Cursor im Cache. Mit einem Bind (:id) ein Cursor und drei Ausführungen. In einer Anwendung mit Tausenden von Werten füllen Literale den Cache und verbrauchen CPU fürs Analysieren. Die drei Literale teilen sich eine FORCE_MATCHING_SIGNATURE: die Signatur des Textes ohne seine Werte, nützlich zum Gruppieren. CURSOR_SHARING stand auf EXACT: Oracle schreibt Literale nicht von selbst um.

Der erste Bind-Wert entscheidet (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 sah sich den ersten Wert an, 'RARO', wählte den Index und verwendete ihn für alle 18 Ausführungen wieder, auch mit 'COMUN', das 99 % der Tabelle ausmacht und als Literal zu TABLE ACCESS FULL führte („Statistiken und DBMS_STATS (Oracle)“). IS_BIND_SENSITIVE = Y sagt, dass Oracle weiß, dass der Wert zählt; es kann am Ende einen zweiten Plan anlegen (Adaptive Cursor Sharing), in der Messung tat es das aber nicht.

Kein AWR
DBA_HIST_*, AWR und ASH speichern den Leistungsverlauf, und in der Standard und Enterprise Edition gehören sie zum Diagnostics Pack, das getrennt lizenziert wird; sie abzufragen heißt, es zu nutzen. V$SQL, V$SESSION und V$SYSSTAT verlangen es nicht.

In Calíope
Langsame Abfragen liest V$SQL: die durchschnittliche Ausführung jedes Cursors über dem Schwellenwert, mit Aufrufen, CPU und Lesevorgängen, und die Übersicht gruppiert nach FORCE_MATCHING_SIGNATURE. Calíope fragt weder AWR noch ASH ab.

Stichwörter: V$SQL, Cursor, Bind, Literal, Bind Peeking, Adaptive Cursor Sharing, FORCE_MATCHING_SIGNATURE, CURSOR_SHARING, AWR, ASH, Diagnostics Pack, Oracle

Benutzer, Rollen und Privilegien (Oracle)

In Oracle ist ein Benutzer zugleich ein Schema; Rechte kommen als System- und Objektprivilegien, fast immer über Rollen.

Gilt für: Oracle 26ai+

In Oracle ist ein Benutzer ein Schema: Was er anlegt, gehört ihm, und wer es nutzen will, braucht ein Recht dazu. Es gibt zwei Arten von Privilegien: Systemprivilegien (CREATE SESSION, CREATE TABLE, ALTER SYSTEM…) und Objektprivilegien (SELECT, INSERT… auf eine bestimmte Tabelle). Ohne CREATE SESSION kommt man nicht einmal hinein: ORA-01045.

Ohne Recht gibt es die Tabelle nicht

-- dba_lector
SELECT * FROM dba_manual.cliente;
-- ORA-00942: table or view "DBA_MANUAL"."CLIENTE" does not exist

Oracle sagt nicht „keine Berechtigung“, sondern dass es die Tabelle nicht findet – damit nicht verraten wird, was in einem fremden Schema liegt. Ein ORA-00942 auf eine Tabelle, die es sicher gibt, ist meist ein fehlendes Recht.

Rollen bündeln Rechte

-- 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"

Die Rolle wird einmal vergeben und bringt alle ihre Rechte mit. Mit der Leserolle wird gelesen, und was sie nicht gewährt, wird mit Namen verweigert: In 26ai ergibt ein fehlendes Objektprivileg ORA-41900, das sagt, welches. Eine neue Rolle erreicht keine bereits offene Session: Die schon verbundene bekam weiter ORA-00942, bis sie sich neu anmeldete.

Was die Session gerade hat

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 und SESSION_PRIVS zeigen, was die Session wirklich hat, nicht was vergeben wurde. Eine Rolle außerhalb der Standardrollen wird bei der Anmeldung nicht aktiviert: Man fordert sie mit SET ROLE an.

C##-Benutzer gehören nicht hierher

CREATE USER c##global IDENTIFIED BY "Xx_pass1";
-- ORA-65094: invalid local user or role name

In einer Pluggable Database (FREEPDB1) sind Benutzer lokal; gemeinsame Benutzer (C##) entstehen im Root des Containers, in den Calíope nicht hineingeht.

In Calíope
Benutzer listet Konten und Rollen, mit den Rechten auf vier Karten (System, Schema, Tabelle und Routine) und der geerbten Rollenzugehörigkeit. Es legt Benutzer an, vergibt und entzieht Rechte, sperrt und löscht mit CASCADE und prüft vor dem Schreiben deine Rechte: Was du nicht darfst, ist deaktiviert und nennt den Grund. Die Konten von Oracle selbst sind sichtbar, werden aber nicht verändert.

Stichwörter: Oracle, Benutzer, Schema, Rolle, Privileg, GRANT, REVOKE, SESSION_PRIVS, SESSION_ROLES, SET ROLE, ORA-00942, ORA-41900, ORA-01045, C##, CDB, PDB

Profile, ablaufende Passwörter und gesperrte Konten (Oracle)

Das Profil eines Benutzers legt fest, wie viele Fehlversuche es duldet, wann sein Passwort abläuft und wie lange eine Sperre dauert.

Gilt für: Oracle 26ai+

Jeder Benutzer hat ein Profil, und das Profil setzt die Grenzen seines Passworts. Wer kein anderes hat, nutzt 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

So wird es in 26ai Free ausgeliefert: Zehn Fehlversuche sperren das Konto einen Tag, und das Passwort läuft nach 180 Tagen mit einer Woche Kulanz ab.

Fehlversuche und Sperre

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;

Nach zwei falschen Passwörtern wird auch das richtige abgelehnt: ORA-28000, bis die Sperrzeit vorbei ist oder jemand das Konto entsperrt. ACCOUNT LOCK sperrt von Hand, mit demselben Fehler.

Das ablaufende Passwort

-- 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";

In der Kulanzzeit öffnet sich die Session, mit einer Warnung: In 26ai ist es ORA-28098. Nach Ablauf der Kulanz ORA-28001, und man kommt nicht mehr hinein, bis es geändert ist. PASSWORD EXPIRE lässt es von Hand ablaufen.

Ein Konto für eine Anwendung bekommt meist ein eigenes Profil ohne Ablauf, damit sie nicht an einem Sonntag stehen bleibt; das Konto einer Person das Standardprofil.

In Calíope
Verbindung testen zeigt die Warnung des Servers, wenn das Passwort in der Kulanzzeit ist: Die Verbindung öffnet sich trotzdem. ORA-28000 und ORA-28001 werden als verweigerter Zugriff gemeldet, mit dem Text des Servers. Benutzer zeigt im Kopf jedes Kontos sein Profil, seinen Status und wann sein Passwort abläuft, und dort ändert man das Passwort und sperrt oder entsperrt. Profile werden nicht in Calíope angelegt: Das ist CREATE PROFILE im SQL-Editor.

Stichwörter: Oracle, Profil, PROFILE, Passwort, Ablauf, PASSWORD_LIFE_TIME, PASSWORD_GRACE_TIME, FAILED_LOGIN_ATTEMPTS, Sperre, ACCOUNT LOCK, ORA-28000, ORA-28001, ORA-28098, ORA-01017

Sicherung und Wiederherstellung: RMAN, Flashback und der Dump von Calíope (Oracle)

Was eine Oracle-Datenbank wiederherstellt, ist RMAN; Flashback macht jüngste Fehler rückgängig; das Backup von Calíope ist ein SQL-Dump von Schemas.

Gilt für: Oracle 26ai+

Oracle wird mit RMAN gesichert, dem Werkzeug des Servers selbst: Es kopiert die Datendateien und das archivierte Redo und stellt damit die ganze Datenbank auf jeden beliebigen Zeitpunkt wieder her. Das ersetzt Calíope nicht: Es liest keine Serverdateien und verwaltet keine Kopien.

SELECT log_mode, flashback_on FROM v$database;
-- ARCHIVELOG   NO
SELECT value FROM v$parameter WHERE name = 'undo_retention';
-- 900

Der Testknoten archiviert sein Redo (ARCHIVELOG), RMAN könnte ihn also auf einen Zeitpunkt wiederherstellen; Flashback Database ist aus.

Flashback: ein paar Minuten zurück

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

Mit AS OF TIMESTAMP liest man die Tabelle so, wie sie vor kurzem war, und holt damit versehentlich Gelöschtes zurück. Das kommt aus dem Undo, das etwa undo_retention Sekunden aufbewahrt wird (900 auf dem Knoten): Danach kann der frühere Stand schon weg sein.

Der Papierkorb

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

Ein DROP TABLE legt die Tabelle in den Papierkorb, und FLASHBACK TABLE … TO BEFORE DROP holt sie mit ihren Daten zurück. Mit PURGE geht sie nicht durch den Papierkorb, und es gibt keinen Weg zurück.

Das Backup von Calíope
Backup schreibt einen SQL-Dump der gewählten Schemas: das DDL jedes Objekts über DBMS_METADATA, die Daten als INSERT in Stapeln und die Benutzer. Er dient dazu, ein Schema woandershin zu bringen oder eine lesbare Kopie aufzuheben; eine Wiederherstellung auf einen Zeitpunkt ist er nicht. Was er nicht abdeckt: LOBs über 16 MiB werden als NULL geschrieben, mit einem Kommentar, der es sagt, und in 26ai bringt das DDL eines Benutzers sein Passwort nicht mit, sodass das wiederhergestellte Konto ohne entsteht. Die Wiederherstellung führt den Dump Anweisung für Anweisung im gewählten Schema aus.

Stichwörter: Oracle, Sicherung, Backup, Wiederherstellung, RMAN, ARCHIVELOG, Flashback, AS OF TIMESTAMP, Papierkorb, recyclebin, FLASHBACK TABLE, PURGE, undo_retention, Dump, ORA-38305

Parameter und NLS (Oracle)

Die Serverparameter stehen in V$PARAMETER; NLS bestimmt, wie Datumswerte und Zahlen geschrieben werden, und jede Session kann es ändern.

Gilt für: Oracle 26ai+

Die Konfiguration von Oracle steht in V$PARAMETER: der Wert jedes Parameters und ob eine Session ihn ändern kann (ISSES_MODIFIABLE) oder nur das ganze System.

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 ändert nur das System; optimizer_mode und nls_length_semantics auch eine Session. max_string_size auf STANDARD lässt VARCHAR2 bei 4000 Bytes; der Wechsel auf EXTENDED ist eine Operation an der Datenbank ohne Rückweg.

Das System zu ändern braucht ein Privileg

-- dba_manual
ALTER SYSTEM SET open_cursors = 301;
-- ORA-01031: insufficient privileges

ALTER SYSTEM ist Administration. Calíope ändert keine Serverparameter: Es liest sie.

NLS: Datumswerte und Zahlen

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

Das Format eines in Text umgewandelten Datums und das Dezimaltrennzeichen kommen aus den NLS-Parametern der Session. Geändert, schreibt dieselbe Abfrage etwas anderes, und eine Umwandlung, die funktionierte, schlägt fehl. Deshalb schreiben die Beispiele des Handbuchs Datumswerte als Literale (DATE '2026-10-16') und wandeln sie mit einer expliziten Maske um: Sie hängen von niemandes Session ab.

nls_length_semantics auf BYTE macht VARCHAR2(40) zu 40 Bytes; mit Text, der nicht ASCII ist, passen weniger Zeichen hinein. VARCHAR2(40 CHAR) zählt in Zeichen.

In Calíope
Serverinformationen zeigt V$PARAMETER unter Globale Variablen, und die NLS deiner Session zusammen mit dem Kontext von SYS_CONTEXT unter Session-Variablen. Beide sind schreibgeschützt. Ohne Recht, die V$-Views zu lesen, sagt der Reiter, warum er sie nicht liest, mit dem Text des Servers.

Stichwörter: Oracle, Parameter, V$PARAMETER, ALTER SYSTEM, ALTER SESSION, NLS, NLS_DATE_FORMAT, NLS_NUMERIC_CHARACTERS, nls_length_semantics, max_string_size, ORA-01031, ORA-01722

Speicher, Prozesse und Sessions (Oracle)

Die SGA ist der gemeinsame Speicher der Instanz und die PGA der jeder Session; processes und sessions setzen die Obergrenze für Verbindungen.

Gilt für: Oracle 26ai+

Eine Oracle-Instanz teilt ihren Speicher in zwei: die SGA, die alle Sessions gemeinsam nutzen (der Block-Cache, der Cursor-Cache…), und die PGA, die jedem Prozess gehört, um zu sortieren, zu verknüpfen und Variablen zu halten.

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

Auf dem Free-Knoten geht die SGA nicht über 1,5 GiB hinaus, fast alles Block-Cache und Shared Pool. Die PGA belegte im Ruhezustand 10 MB und erreichte in einer Spitze 720 MB.

Prozesse und Sessions

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 ist die Obergrenze für Prozesse des Servers und sessions die für Sessions; ist sie erreicht, wird eine neue Verbindung abgelehnt. pga_aggregate_target ist das Ziel der PGA und pga_aggregate_limit die Grenze, über die sie nicht hinauskommt.

Was Sache der Anwendung ist
Jede offene Verbindung ist eine Session und ein Prozess, mit ihrer PGA. Ein kleiner Verbindungs-Pool leistet mehr als Hunderte gleichzeitig offener Verbindungen und schöpft sessions nicht aus.

In Calíope
Calíope öffnet pro Profil einen Pool von bis zu fünf Verbindungen und gibt sie sauber zurück. Serverinformationen zeigt unter Information die Höchstzahl an Sessions (aus sessions), und das Dashboard den Speicher von SGA und PGA und die offenen Verbindungen. Prozesse listet die Benutzersessions und erlaubt, eine zu beenden oder ihre Abfrage abzubrechen.

Stichwörter: Oracle, Speicher, SGA, PGA, V$SGAINFO, V$PGASTAT, processes, sessions, pga_aggregate_target, pga_aggregate_limit, Verbindungen, Pool

Grenzen (Oracle)

Die Obergrenzen von Oracle 26ai Free und die der Datenbank: Bezeichner mit 128 Bytes, VARCHAR2 mit 4000, NUMBER mit 38 Stellen und 1000 Spalten.

Gilt für: Oracle 26ai+

Oracle AI Database 26ai Free ist die kostenlose Edition, mit Nutzungsgrenzen, die Oracle veröffentlicht: bis zu 2 CPU-Threads, 2 GB Speicher und 12 GB Benutzerdaten. Der Testknoten bestätigt es: cpu_count ist 2, und die SGA geht nicht über 1,5 GiB hinaus. Für mehr braucht es eine andere Edition; das Protokoll ist dasselbe.

Die der Datenbank, gemessen

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

- Ein Bezeichner (Tabelle, Spalte, Benutzer) hat höchstens 128 Bytes.
- VARCHAR2 reicht bis 4000 Bytes mit max_string_size auf STANDARD und bis 32767 mit EXTENDED. In PL/SQL reicht eine VARCHAR2-Variable immer bis 32767.
- NUMBER hat höchstens 38 Stellen Genauigkeit.
- Eine Tabelle hat höchstens 1000 Spalten.
- Textverkettung in SQL geht nicht über 4000 Bytes hinaus: ORA-01489. Für mehr CLOB.

In Calíope
Calíope liest einen LOB bis 16 MiB pro Zelle: Was darüber hinausgeht, wird abgeschnitten, und die Zelle nennt die tatsächliche Größe. Die Typen, die es nicht dekodiert –Objekte, XMLTYPE, binäres JSON, VECTOR, BFILE und Cursor–, erscheinen als „nicht dekodiert“, mit dem Namen ihres Typs.

Stichwörter: Oracle, Grenzen, Free, 26ai, CPU, Speicher, 12 GB, Bezeichner, 128 Bytes, VARCHAR2, 4000, 32767, NUMBER, 38, Spalten, 1000, ORA-00972, ORA-00910, ORA-01727, ORA-01792, ORA-01489

Häufige ORA-Fehler (Oracle)

Was die häufigsten ORA-Fehler bedeuten, gemessen gegen Oracle 26ai, und was Calíope mit ihnen macht.

Gilt für: Oracle 26ai+

Jeder Oracle-Fehler trägt eine fünfstellige Nummer mit dem Präfix ORA- und den Text des Servers, und Calíope zeigt ihn unverändert, denn genau danach sucht man in der Oracle-Dokumentation. Wenn der Server sagt, wo der Fehler liegt, markiert der SQL-Editor dieses Wort, und die Statusleiste ergänzt Zeile und Spalte; der Streifen Lösung anzeigen führt hierher.

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.

Objekte und Namen
- ORA-00942 – die Tabelle oder View existiert nicht … oder sie existiert und dir fehlt das Recht: Oracle unterscheidet das nicht.
- ORA-00904 – ungültiger Bezeichner: eine Spalte, die es nicht gibt, oder ein Tippfehler.
- ORA-00955 – der Name wird schon von einem anderen Objekt im Schema verwendet.
- ORA-00972 – ein Name mit mehr als 128 Bytes.

Daten
- ORA-00001 – doppelter eindeutiger Schlüssel; der Text nennt den Constraint und die Spalten.
- ORA-02291 / ORA-02292 – der Fremdschlüssel findet keinen Elterndatensatz / der Elterndatensatz hat Kinder.
- ORA-01400 – NULL in einer NOT NULL-Spalte.
- ORA-12899 – der Wert passt nicht; er nennt die tatsächliche und die maximale Größe.
- ORA-01722 – ein Text, der keine Zahl ist; auch in einem impliziten Vergleich.
- ORA-01843 / ORA-01830 – ungültiger Monat / die Datumsmaske endet vor dem Text.
- ORA-01489 – eine Verkettung von mehr als 4000 Bytes.

Transaktionen und Sperren
- ORA-00054 – die Zeile ist belegt: mit NOWAIT, und in 26ai auch, wenn WAIT n abläuft.
- ORA-00060 – Deadlock: Nur die Anweisung wird zurückgerollt.
- ORA-08177 – ein Konflikt unter SERIALIZABLE. ORA-01456 – Schreiben in einer READ ONLY-Transaktion.
- ORA-01555 – Snapshot too old: Eine lange Abfrage brauchte Undo, das schon wiederverwendet war.
- ORA-01013 – die Abfrage wurde abgebrochen: du hast es mit Anhalten verlangt.

Rechte und Verbindung
- ORA-41900 – ein Objektprivileg fehlt, und er sagt, welches. ORA-01031 – ein Systemprivileg fehlt.
- ORA-01017 – ungültiger Benutzername oder ungültiges Passwort. ORA-28000 – Konto gesperrt. ORA-28001 – Passwort abgelaufen. Calíope meldet sie als verweigerten Zugriff.
- ORA-12514 / ORA-12505 – der Listener kennt diesen Dienst / diese SID nicht: Prüf den Dienst im Profil.
- ORA-12170 – die Verbindung kam nicht rechtzeitig zustande: Netz, Firewall oder Port.
- ORA-02396 – die Session hat ihre Leerlaufzeit überschritten; Calíope meldet sie als verlorene Verbindung.
- ORA-38305 – was wiederhergestellt werden soll, liegt nicht im Papierkorb.

Anwendungsfehler
ORA-20000 bis ORA-20999 löst PL/SQL-Code mit RAISE_APPLICATION_ERROR aus, mit einem frei gewählten Text: Ihre Bedeutung gibt der vor, der den Code geschrieben hat.

Stichwörter: Oracle, Fehler, 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

Bewährte Vorgehensweisen (Oracle)

Was sich mit Oracle in Calíope empfiehlt: Konten mit dem Nötigsten, kurze Transaktionen, Binds, aktuelle Statistiken und eine echte Sicherung.

Gilt für: Oracle 26ai+

Ein Konto pro Aufgabe, mit dem Nötigsten
Arbeite nicht mit SYSTEM oder SYS an Daten. Jede Anwendung ihr eigenes Schema, jede Person ihr eigener Benutzer, die Rechte über eine Lese- oder Schreibrolle. Ergibt etwas ORA-00942 oder ORA-41900, vergib das fehlende Recht, kein größeres.

Kurze Transaktionen, bewusst bestätigt
Oracle bestätigt nichts von selbst, und DDL bestätigt alles Offene. Öffne die Transaktion, wenn du sie brauchst (Transaktion → BEGIN im SQL-Editor), tu das Nötige und schließ sie: Eine belegte Zeile blockiert die anderen bis zum COMMIT oder ROLLBACK, und eine lange Abfrage über sich ändernde Daten kann in ORA-01555 enden.

Binds statt Literale
Eine Abfrage mit dem Wert im Text ist für jeden Wert eine andere Anweisung: eine weitere Analyse und ein weiterer Cursor im Cache. Mit einem Bind (:cliente) ist es nur eine. Was eine Anwendung wiederholt, immer mit Binds.

Aktuelle Statistiken
Der Optimizer wählt den Plan nach den Statistiken. Sammle sie nach einem großen Ladevorgang (Tabellenwartung → ANALYZE) und prüf das Datum unter Serverinformationen → HDD-Nutzung.

Text und Datumswerte
Deklariere Text in Zeichen (VARCHAR2(40 CHAR)), wenn er etwas anderes als ASCII enthält, und schreib Datumswerte als Literale (DATE '2026-10-16') oder mit expliziter Maske: Was vom NLS der Session abhängt, ändert sich von Session zu Session.

Eine echte Sicherung
Der Dump von Backup dient dazu, ein Schema lesbar zu verschieben oder aufzubewahren. Um eine Datenbank wiederherzustellen, RMAN mit dem archivierten Redo.

Stichwörter: Oracle, bewährte Vorgehensweisen, Best Practices, Privilegien, Rollen, SYSTEM, Transaktion, COMMIT, Bind, Statistiken, NLS, CHAR, Datum, RMAN, Backup

Datenmodellierung: einbetten oder referenzieren

Wann die Daten ins Dokument gehören und wann referenziert wird: die vier Muster von MongoDB, das Antimuster des unbegrenzten Arrays und was jedes kostet, gemessen.

Gilt für: MongoDB 7.0+

In SQL ergibt sich das Schema aus der Normalisierung: jede Tatsache an einer einzigen Stelle, und die Abfragen setzen sie mit JOIN wieder zusammen. In MongoDB ergibt es sich aus dem Zugriffsmuster: was zusammen gelesen wird, wird zusammen gespeichert. Die Frage lautet nicht mehr „wie vermeide ich, einen Wert zu wiederholen?“, sondern „was soll mir ein einziger Lesevorgang liefern?“.

Einbetten oder referenzieren

Eine Bestellung kann ihre Positionen in sich tragen, oder die Positionen leben in einer eigenen Sammlung und verweisen auf die Bestellung.

// Eingebettet: die Bestellung trägt ihre Positionen in sich
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 })

// Referenz: die Positionen liegen getrennt und verweisen auf die Bestellung
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" } } ])

Gemessen gegen MongoDB 8.2 mit 100.000 Bestellungen zu je drei Positionen: eine Bestellung mit ihren Positionen zu lesen kostet eingebettet 0,25 ms und mit $lookup 0,30 ms —aber 67 ms, wenn der Index auf l_ref.pedido fehlt, denn dann läuft jede Bestellung durch alle 300.000 Positionen—. Auf der Platte belegen die eingebetteten Bestellungen 2,9 MB gegenüber 1,1 MB + 5,2 MB der beiden getrennten Sammlungen: hier war Einbetten auch im Platz billiger.

Einbetten verzichtet nicht auf Indizes: ein Index auf ein Feld innerhalb des Arrays ist mehrschlüssig und wirkt genauso. {"lineas.sku": "A-7"} ohne ihn zu suchen ist ein COLLSCAN über 100.000 Dokumente in 42 ms; mit ihm 2.000 geprüfte in 2 ms, für dieselben 2.000 Zeilen. Und ein Dokument wird ohne Transaktion atomar geändert: das $inc eines Zählers und das $set eines Status in einem einzigen updateOne greifen gemeinsam oder gar nicht.

Das Antimuster: das Array, das nicht aufhört zu wachsen

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

Das leere Dokument hat 29 Bytes, und jeder Messwert fügt 28,9 hinzu. Bei 540.000 misst es 16.628.919 Bytes, und der nächste Schwung scheitert mit dem Code 10334: „Resulting document after update is larger than 16777216“. Die Grenze von 16 MB je Dokument ist nicht verhandelbar. Und es schmerzt schon vorher: ein $set eines skalaren Feldes kostet auf diesem Dokument 3,15 ms und auf einem von 228 Bytes 0,50 ms, und ein $pop des Arrays 71,95 ms gegenüber den 0,15 ms, einen einzelnen Messwert einzufügen. Ein Array, das ohne bekannte Obergrenze wächst, ist eine falsch platzierte Referenz.

Bucket: die Messwerte werden zu Dokumenten von je N gebündelt.

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 })

Dieselben 540.000 Messwerte: einzeln sind es 540.000 Dokumente, 6,5 MB Daten und 8,4 MB Index; in Buckets zu 200 sind es 2.700 Dokumente, 4,2 MB Daten und 82 KB Index. Das Einfügen kostet 203 ms in Buckets gegenüber 901 ms einzeln. Den letzten lesen: 0,20 ms aus dem Bucket, 0,30 ms einzeln und 46,80 ms aus dem eingebetteten Array, das ganz geholt werden muss.

Erweiterte Referenz: die Handvoll Felder des Elternteils, die immer angezeigt werden, in das Kind kopieren. 100 Bestellungen mit dem Namen ihres Kunden aufzulisten kostet 0,35 ms mit dem Namen innen dupliziert und 1,00 ms mit $lookup. Bezahlt wird beim Schreiben: einen Kunden umzubenennen heißt, seine 1.000 Bestellungen anzufassen, 2 ms mit einem Index auf cliente. Dupliziert wird, was sich fast nie ändert.

Berechneter Wert: die bereits summierte Gesamtsumme speichern, statt sie bei jedem Lesen neu zu berechnen. Bei einer Bestellung merkt man es nicht —0,35 ms aus dem Dokument gelesen, 0,30 ms im Flug summiert—; beim Aggregieren aller 100.000 schon: 14 ms beim Lesen des Feldes gegenüber 129 ms beim Neuberechnen.

Teilmenge: im Dokument nur die wenigen Zeilen, die angezeigt werden, der Rest in einer eigenen Sammlung. 500 Produkte mit je 500 Bewertungen: alle eingebettet ist das mittlere Dokument 80.738 Bytes groß; mit den letzten fünf und einem Zähler 889 Bytes, und die Sammlung sinkt von 6,1 MB auf 60 KB. Die Produktseite geht von 0,55 ms auf 0,25 ms, und die Seite mit 20 Bewertungen wird getrennt aus ihrer eigenen Sammlung geholt.

Die Regel in einer Zeile: bette ein, was mit seinem Elternteil gelesen wird, nur ihm gehört und eine Obergrenze hat; referenziere, was unbegrenzt wächst, von mehreren Eltern geteilt wird oder für sich allein abgefragt wird.

Stichwörter: Datenmodellierung, Einbetten, Referenz, Dokument, Muster, Bucket, Teilmenge, erweiterte Referenz, berechneter Wert, Array, Schema, 16 MB

BSON-Typen und Kollation

Welchen Typ MongoDB bei jedem Wert speichert, wie viel er belegt, wie sich verschiedene Typen untereinander vergleichen und wie Text mit Akzenten sortiert wird.

Gilt für: MongoDB 7.0+

In SQL deklariert die Tabelle den Typ, und jede Zeile hält sich daran. In MongoDB reist der Typ mit jedem Wert: es gibt kein CREATE TABLE, und zwei Dokumente derselben Sammlung dürfen im selben Feld eine Ganzzahl und eine Zeichenkette tragen. Das Format heißt BSON: JSON in binär, mit den Typen, die JSON fehlen — Ganzzahlen mit 32 und 64 Bit, exakte Dezimalzahlen, Datumswerte, Binärdaten und ObjectId.

_id und ObjectId

Jedes Dokument hat ein _id: eindeutig, unveränderlich und indiziert, seit die Sammlung entsteht. Schreibst du es nicht selbst, setzt der Client eine ObjectId ein: 12 Bytes — 4 die Zeit in Sekunden, 5 zufällig pro Prozess, 3 ein Zähler. Sie wächst mit der Uhr und taugt daher für Datumsbereiche, ohne ein Datum zu speichern.

const oid = ObjectId()
oid.getTimestamp()            // 2026-09-12T01:28:05.000Z
oid.toString().slice(0, 8)    // "6aa4aaa5": los 4 bytes de tiempo

// Seit dem 1. September Angelegtes, über den Index von _id
const desde = ObjectId.createFromTime(Date.parse("2026-09-01") / 1000)
db.pedidos.countDocuments({ _id: { $gte: desde } })

Als Zeichenkette abzulegen kostet und bringt nichts: {_id: ObjectId()} wiegt 22 Bytes, derselbe Wert als Zeichenkette 39.

Ganzzahlen, Gleitkommazahlen und Dezimalzahlen

Vier numerische Typen, und der Unterschied zeigt sich im Dokument: $bsonSize über {_id: 1, a: …} ergibt 21 Bytes mit int, 25 mit long oder double und 33 mit decimal. Geld gehört in decimal, aus demselben Grund wie in SQL in DECIMAL: 0,1 plus 0,2 ergibt als Gleitkommazahl 0.30000000000000004 und als Dezimalzahl 0.3.

Die Falle sitzt im Client: mongosh wählt den Typ nach dem Wert. Eine von Hand geschriebene 7 wird int, 3000000000 wird double, und 9007199254740993 wird 9007199254740992 — jenseits von 2⁵³ muss NumberLong("…") geschrieben werden.

db.tipos.insertMany([
  { v: 7 }, { v: 7.5 }, { v: 3000000000 },
  { v: 9007199254740993 }, { v: NumberLong("9007199254740993") }
])
db.tipos.aggregate([ { $project: { v: 1, t: { $type: "$v" } } } ])

Beim Abfragen sind die vier dagegen eine Zahl: liegen int, long, double und decimal mit dem Wert 7 in der Sammlung, findet {v: 7} alle vier und {v: "7"} keines. $type unterscheidet sie sehr wohl, "number" fasst sie wieder zusammen.

Datumswerte

Date sind 8 Bytes Millisekunden seit 1970, immer in UTC und ohne Zeitzone. Das Paar DATETIME / TIMESTAMP gibt es hier nicht: ein einziger Typ, die Zone setzt, wer liest, und Mikrosekunden gehen beim Speichern verloren. Der Timestamp von BSON ist nicht für deine Daten, sondern die interne Uhr der Replikation.

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

Binärdaten und UUID

BinData speichert Bytes mit einem Subtyp; UUID() ist Subtyp 4. Eine UUID als BinData belegt 29 Bytes Dokument, als Zeichenkette mit Bindestrichen 49.

Die Ordnung zwischen verschiedenen Typen

Ein sort über ein Feld mit gemischten Typen scheitert nicht: es gibt eine totale Ordnung zwischen den Typen, über vierzehn Dokumente gemessen.

MinKey → fehlendes Feld und null → Zahlen → Zeichenketten → Objekte → Binärdaten → ObjectId → Wahrheitswerte → Datumswerte → Timestamp → reguläre Ausdrücke → MaxKey.

Arrays fehlen dort, weil ein Array über sein kleinstes Element vergleicht: [9, 10] sortiert sich zwischen die Zahlen. Ein fehlendes Feld sortiert genau wie null, so sehr, dass {v: null} beides findet; zum Trennen dienen {v: {$type: "null"}} und {v: {$exists: false}}.

UTF-8 und Kollation

Es gibt keinen Zeichensatz zu wählen: BSON-Zeichenketten sind UTF-8, Punkt. "café ☕ 日本語 👩‍💻" sind 14 Zeichen und 31 Bytes und kommen genau so zurück, wie sie hineingingen.

Gewählt wird dagegen die Kollation, und voreingestellt ist sie binär: ohne collation sind cafe und café verschiedene Werte, und Árbol sortiert hinter zorro. Eine collation mit ihrem locale und ihrem strength — 1 ignoriert Akzente und Groß-/Kleinschreibung, 2 ignoriert nur die Groß-/Kleinschreibung, 3 unterscheidet alles — ändert Vergleich und Reihenfolge zugleich.

db.clientes.find({ n: "cafe" }).collation({ locale: "es", strength: 1 })
db.clientes.find().sort({ n: 1 }).collation({ locale: "es" })

Und hier lauert dieselbe Falle wie in SQL: die Kollation der Abfrage muss die des Index sein. Mit einem gewöhnlichen Index auf n ist {n: "cafe"} ein IXSCAN, der 1 Dokument prüft; dieselbe Abfrage mit collation fällt auf COLLSCAN und prüft alle 7. Die Lösung ist nicht, collation in jede Abfrage zu schreiben, sondern sie der Sammlung zu geben: deren Indizes entstehen dann mit ihr.

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: hier ist es derselbe Wert

Zwei weitere Warnungen, beide gemessen. $regex ignoriert die Kollation: /^CAF/ findet cafe auch mit strength: 1 nicht, {n: "CAFE"} schon. Und numericOrdering: true sortiert die Zeichenketten "1", "2", "10" als Zahlen statt als "1", "10", "2".

Stichwörter: bson, typen, objectid, decimal128, numberlong, datum, date, uuid, bindata, collation, kollation, utf-8, akzente, sortierung

Indizes: welcher passt und die ESR-Regel

Die elf Indexarten von MongoDB und wann welche passt, die ESR-Regel für die Reihenfolge eines zusammengesetzten Index, die abgedeckte Abfrage sowie Grenzen und Schreibkosten, alles mit `explain` gemessen.

Gilt für: MongoDB 7.0+

Ein Index ist ein B-Baum über den Wert eines Feldes, und die Zahl, die zeigt, ob er hilft, kommt aus explain("executionStats"): totalDocsExamined gegen nReturned. Ist die erste viel größer als die zweite, liest der Server Dokumente nur, um sie wegzuwerfen.

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

Die ESR-Regel

Ein zusammengesetzter Index wird über Präfixe gelesen, also ist die Reihenfolge seiner Felder die Entscheidung. Zuerst die Felder mit Gleichheit (E), dann das der Sortierung (S) und zuletzt das des Bereichs (R). Steht der Bereich vor der Sortierung, filtert der Index, ordnet aber nicht, und es erscheint eine SORT-Stufe, die im Speicher sortiert: 1 826 Dokumente in der Abfrage unten.

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

Diese Stufe hat eine Grenze: internalQueryMaxBlockingSortMemoryUsageBytes steht auf 104 857 600 Bytes, und darüber scheitert die Abfrage, wenn sie nicht auf die Platte ausweichen darf.

Abgedeckte Abfrage

Trägt der Index alle Felder, die die Abfrage liest, rührt der Server die Dokumente nicht an. Achtung bei _id: Es steckt standardmäßig in der Projektion, nicht im Index, und muss von Hand entfernt werden.

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

Die anderen Arten

ArtWofürGemessen
Multikeyein Feld, das ein Array istzwei Arrays in einem Index: Fehler 171
TextSuche nach Wörternnur einer je Sammlung; der zweite gibt 85
2dsphereGeoJSON und $nearohne ihn antwortet $near mit 291
hashedGleichheit über lange SchlüsselBereich und sort fallen auf COLLSCAN
Platzhalter $**offenes Schemaer begrenzt ein Feld je Abfrage, nicht zwei
TTLDokumente verfallen lassender Sammler läuft alle 60 s
partielleine Teilmenge der Sammlungdie Abfrage muss seinen Filter wiederholen
sparseDokumente ohne das Feld auslassenein sort darüber verliert Dokumente
eindeutigEindeutigkeitzwei Dokumente ohne das Feld kollidieren
verstecktein Löschen testen, ohne zu löschener kommt mit collMod zurück

Die beiden, die Daten stillschweigend verlieren, sieht man am besten zusammen:

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 }

Versteckt ist das Gegenteil: Er wird weiter gepflegt, aber der Planer sieht ihn nicht an. Mit hidden: true war dieselbe Abfrage ein COLLSCAN über 20 000 Dokumente; wieder sichtbar mit collMod, ein IXSCAN über 1 940.

Die Grenzen

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

Name und Schlüsselgröße haben keine praktische Grenze: 400 Zeichen und 2 000 Bytes wurden ohne Klage angenommen.

Der Preis

Jeder Index wird bei jedem Schreibvorgang bezahlt. Dieselben 20 000 Einfügungen dauerten 60 ms ohne Index, 101 ms mit fünf und 160 ms mit zehn. Und sie brauchen Platz: Die sechs Indizes von pedidos ergeben zusammen 1,9 MB gegenüber 916 KB an Daten.

Deshalb indiziert man nicht alles. Ein Feld mit geringer Kardinalität hilft selten: estado mit vier Werten prüft 5 000 Dokumente, um 5 000 zu liefern. Und ein einfacher Index ist überflüssig, wenn ein zusammengesetzter schon mit ihm beginnt: { cliente: 1 } und { cliente: 1, fecha: 1 } prüfen dieselben 40.

Stichwörter: Index, Indizes, ESR, zusammengesetzt, Multikey, Text, 2dsphere, hashed, Platzhalter, TTL, partiell, sparse, eindeutig, versteckt, abgedeckte Abfrage, explain, IXSCAN, COLLSCAN, totalDocsExamined, MongoDB

Die Aggregations-Pipeline: von $match bis $merge

Die Stufen der Pipeline und ihre richtige Reihenfolge, $lookup als JOIN und was er ohne Index kostet, $graphLookup, Fensterfunktionen, $facet und $unionWith sowie $merge gegenüber $out.

Gilt für: MongoDB 7.0+

Eine Pipeline ist eine Liste von Stufen, und jede erhält die Dokumente, die die vorige erzeugt hat. Die Reihenfolge schreibst du, und dort steckt fast die gesamte Leistung.

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 } und zwei weitere

Zuerst filtern, immer

Nur die erste Stufe kann einen Index nutzen. Mit einem Index {estado: 1} über diese 20 000 Bestellungen prüft $match vorn 5 000 Schlüssel und 5 000 Dokumente; derselbe Filter hinter dem $group keinen einzigen Schlüssel und 20 000 Dokumente.

$lookup ist der JOIN, und ohne Index wird er bezahlt

Er verbindet mit einer anderen Sammlung derselben Datenbank, und das Gefundene kommt als Array an, das fast immer mit $unwind geöffnet wird.

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

Das explain der Stufe lässt keinen Spielraum: ohne Index auf clientes.num macht sie einen vollständigen Durchlauf pro Eingabedokument, mit dem Index keinen einzigen.

Der rekursive und der mit Fenster

$graphLookup folgt einer Hierarchie, so weit sie reicht, und depthField hält fest, in welcher Entfernung jede Stufe lag. Das zurückgegebene Array ist nicht sortiert: hier kam Ana, Caro und Beto mit den Ebenen 2, 0 und 1. $setWindowFields (5.0+) ist das SQL-Fenster: partitionBy ist PARTITION BY und sortBy ist 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

Mehrere Antworten in einem Durchlauf

$facet führt Unterpipelines über dieselbe Eingabe aus und liefert ein einziges Dokument mit allen; darin zählt kein Index mehr. $unionWith ist das 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 }

Das Ergebnis schreiben

$merge verschmilzt in die Zielsammlung, und $out ersetzt sie vollständig.

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 } - der vorherige Inhalt ist weg

Die Obergrenzen

Jede blockierende Stufe — $group, $sort, $facet, das Zwischenergebnis von $lookup — bekommt 104 857 600 Bytes, und seit 6.0 geht der Überlauf von selbst auf die Platte. Aber das Array eines Akkumulators läuft nicht über: ein $push über große Dokumente scheitert mit 146 ExceededMemoryLimit, und allowDiskUse: true rettet es nicht.

Stichwörter: Aggregation, Pipeline, Stufe, match, group, project, lookup, Join, graphLookup, setWindowFields, Fenster, facet, unionWith, merge, out, unwind, explain, allowDiskUse

Transaktionen: wann sie nötig sind und der Konflikt 112

Ein Dokument wird ohne Transaktion ganz geschrieben; für mehrere braucht es eine und ein Replica Set. Die Sitzung, readConcern und writeConcern, der WriteConflict 112 und die drei gemessenen Obergrenzen.

Gilt für: MongoDB 7.0+

In MongoDB wird ein Dokument ganz geschrieben oder gar nicht, und das gilt auch, wenn das updateOne zehn Felder und ein verschachteltes Array berührt. Um mehr als ein Dokument auf einmal zu ändern, braucht es eine Transaktion, und eine Transaktion verlangt ein Replica Set: auf einem einzelnen Knoten wird sie abgelehnt, und die Meldung spricht nicht einmal von Transaktionen — sie sagt, das Deployment unterstütze keine wiederholbaren Schreibvorgänge.

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

Eine Transaktion läuft über eine Sitzung

Alles darin geht über das Sitzungsobjekt: das db.cuentas von außen steckt nicht in der Transaktion, so gleich es auch heißen mag. Bis zum commitTransaction() sieht das Geschriebene nur, wer drinnen ist.

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() macht alles rückgängig, und man muss nicht darum bitten: geht die Sitzung verloren oder startet der Server neu, stirbt die Transaktion abgebrochen.

readConcern und writeConcern sind zwei verschiedene Fragen

readConcern sagt, was gelesen wird: local ist das, was hier liegt, majority das, was nicht mehr verloren gehen kann, snapshot ein stimmiges Bild eines Augenblicks. writeConcern sagt, wann etwas als geschrieben gilt: w: 1 ist der Primary, w: "majority" die Mehrheit des Sets, und j: true nimmt das Journal dazu. Die Werkseinstellungen liefert getDefaultRWConcern: Lesen local, Schreiben majority.

Der Schreibkonflikt

Zwei Transaktionen auf demselben Dokument warten nicht: die zweite scheitert auf der Stelle mit 112 WriteConflict, und die Meldung sagt es ohne Umschweife. Der Wiederholungsversuch gehört dazu, und deshalb bringen die Treiber withTransaction mit, das von selbst wiederholt.

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()   // die erste geht durch
s2.abortTransaction()
s1.endSession(); s2.endSession()

Ein Schreibvorgang von außerhalb der Transaktion bekommt die 112 nicht: er wartet. In der Messung wartete er 76 s, bis das Lebenszeitlimit die Transaktion abbrach, die das Dokument hielt.

Die Obergrenzen

Eine Transaktion lebt 60 s, und jenseits dieser Linie liefert der Commit 251 NoSuchTransaction mit „has been aborted“. Drinnen wartet jede Sperranforderung nur 5 ms: eine Transaktion hängt sich nicht an ein Schloss, sie scheitert lieber. Und ein writeConcern, das das Set nicht erfüllen kann, scheitert, bevor es überhaupt versucht wird.

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

Die praktische Regel: müssen sich zwei Dokumente viele Male am Tag gemeinsam ändern, stimmt meistens das Modell nicht, und richtig gewesen wäre, sie einzubetten. Die Transaktion ist der Ausweg für das, was wirklich nicht in ein Dokument passt.

Stichwörter: Transaktion, Transaktionen, Sitzung, startTransaction, Commit, Abbruch, WriteConflict, 112, 251, readConcern, writeConcern, majority, snapshot, Replica Set, Atomarität, Sperre

Leistung: explain, Plan-Cache und Profiler

Die drei Ausführlichkeitsstufen von explain und was jede hinzufügt, der Plan-Cache und wann ein Plan deaktiviert wird, das Working Set im WiredTiger-Cache und die drei Stufen des Profilers.

Gilt für: MongoDB 7.0+

Bevor man irgendetwas anfasst, wird gemessen, und das Werkzeug heißt explain. Es hat drei Ausführlichkeitsstufen, und jede kostet mehr als die vorige: queryPlanner plant nur — er führt nie aus — und zeigt den Gewinnerplan und die verworfenen; executionStats führt den Gewinner aus und ergänzt, was er gekostet hat; allPlansExecution ergänzt zusätzlich, was jeder Kandidat während der Probephase gekostet hat, und dort sieht man, warum der Gewinner gewonnen hat.

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 und rejectedPlans mit 1 Kandidaten

db.pedidos.find(q).explain("executionStats")
// + executionStats: nReturned 20, docs 20, claves 20, 0 ms

db.pedidos.find(q).explain("allPlansExecution")
// + allPlansExecution: die 2 getesteten Pläne, FETCH 20 und FETCH 10

Die drei Zahlen, auf die es ankommt, sind nReturned, totalDocsExamined und totalKeysExamined. Ist die zweite viel größer als die erste, liest der Server Dokumente, um sie wegzuwerfen.

Der Plan-Cache

Der Planer entscheidet nicht bei jeder Abfrage neu. Beim ersten Mal probiert er die Kandidaten, legt den Gewinner unter einem planCacheKey ab und nutzt ihn von da an wieder; im explain erscheint das als isCached: true. Der Eintrag hält works fest, den Aufwand, den er gekostet hat, und wenn ein späterer Lauf zehnmal mehr verbraucht, wird der Plan deaktiviert und es wird neu gewetteifert. Auch das Anlegen oder Löschen eines Index leert den 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

Das Working Set und der WiredTiger-Cache

MongoDB hält keine Ergebnisse vor: was es vorhält, sind Seiten, im WiredTiger-Cache, und die Leistung hängt daran, dass das Working Set — die tatsächlich angefassten Daten und Indizes — dort hineinpasst. Standardmäßig nimmt dieser Cache die Hälfte dessen, was nach Abzug von 1 GB vom RAM bleibt. In der Messung mussten von 486 864 angeforderten Seiten nur 261 von der Platte geholt werden: eine von 1 865.

const w = db.serverStatus().wiredTiger.cache
w["maximum bytes configured"]        // 3621781504 - die Hälfte von (RAM - 1 GB)
w["bytes currently in the cache"]    // 18640438 - für den ganzen Server
w["pages requested from the cache"]  // 486864
w["pages read into cache"]           // 261 - eine von 1865

db.pedidos.stats().size              // 1385000 an Daten
db.pedidos.stats().totalIndexSize    // 499712 an Indizes

Der Profiler

Drei Stufen: 0 aus, 1 nur das, was über slowms liegt, und 2 alles. Zwei gemessene Dinge, die überraschen: setProfilingLevel liefert die Stufe von vorher zurück, nicht die gerade gesetzte — die neue muss man nachlesen — und system.profile ist eine gedeckelte Sammlung von 1 MiB, sie wächst also nicht: sie beißt sich in den Schwanz.

db.getProfilingStatus()      // { was: 1, slowms: 100, sampleRate: 1 }
db.setProfilingLevel(2)      // { was: 1, ... }  <- die Stufe von VORHER

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 })

Jeder Eintrag bringt planSummary, docsExamined, nreturned und millis mit, und genau das braucht man, um über einen Index zu entscheiden. Calíope liest diese Sammlung in seinem Profiling-Werkzeug. Stufe 2 ist im Betrieb teuer: man schaltet sie für eine Weile ein und wieder herunter, man lässt sie nicht stehen.

Stichwörter: Leistung, explain, queryPlanner, executionStats, allPlansExecution, Plan-Cache, planCacheKey, isCached, WiredTiger, Working Set, Profiler, system.profile, slowms

Serverkonfiguration: die Datei und was im Betrieb geht

Was laut getCmdLineOpts wirklich läuft, die fünf Abschnitte von mongod.conf, auf die es ankommt, und die drei Arten von Parametern: die im Betrieb änderbaren, die des Starts und die, die gar keine sind.

Gilt für: MongoDB 7.0+

Die erste Frage zu einem Server, den man nicht kennt, ist nicht, was in seiner Konfigurationsdatei steht, sondern womit er tatsächlich läuft. getCmdLineOpts beantwortet beides auf einmal: argv ist das, was ihm auf der Befehlszeile mitgegeben wurde, und parsed dasselbe, schon in das Vokabular der Datei übersetzt.

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" } }

Die fünf Abschnitte, auf die es ankommt

storage sagt, wo die Daten liegen und wie viel Speicher der Cache nimmt; net, auf welchen Adressen er lauscht; security, ob man sich anmelden muss; operationProfiling, was vom langsamen Verkehr festgehalten wird; und replication, zu welchem Set er gehört. Die Datei ist YAML, die Einrückung ist also Syntax.

# mongod.conf - dasselbe wie oben, dort geschrieben, wo es bleibt
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

Parameter gibt es in drei Sorten

Die, die man im Betrieb mit setParameter ändert, die, die nur beim Start gelesen werden, und die, die gar keine Parameter sind, so sehr sie auch danach aussehen. Alle drei erkennt man an der Antwort des Servers: die erste liefert was mit dem vorherigen Wert — nicht dem neuen, man muss ihn also nachlesen; die zweite gibt 20 IllegalOperation; und port, eine Startoption und kein Parameter, gibt 72 InvalidOptions mit „unrecognized parameter“.

db.adminCommand({ setParameter: 1, cursorTimeoutMillis: 300000 })
// { was: 600000, ok: 1 }   <- liefert den Wert von VORHER, nicht den neuen

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 bleibt nicht

Ein setParameter lebt bis zum nächsten Neustart und keine Sekunde länger: mit cursorTimeoutMillis auf 300 000 kam der Server mit 600 000 wieder hoch. Damit es bleibt, muss es in den Abschnitt setParameter: der Datei, den unten im Beispiel.

Der Cache und die Kompatibilitätsversion

Der WiredTiger-Cache ist das Erste, wonach alle greifen, und fast immer das, was man nicht anfassen sollte: standardmäßig nimmt er die Hälfte dessen, was vom RAM übrig bleibt, nachdem 1 GB beiseitegelegt wurde. Auf dem gemessenen Knoten ergaben 7 933 MB RAM 3 621 781 504 Bytes Cache. Und es gibt ein sechstes Ding, das nicht in der Datei steht: die featureCompatibilityVersion, die entscheidet, welche Funktionen des Binärprogramms eingeschaltet sind — sie wird nach einem Upgrade von Hand angehoben und vor einem Rückschritt gesenkt.

db.hostInfo().system.memSizeMB   // 7933
db.serverStatus().wiredTiger.cache["maximum bytes configured"]
// 3621781504 = die Hälfte von (7933 MB - 1 GB)

Object.keys(db.adminCommand({ getParameter: "*" })).length   // 780
db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 })
// { version: "8.2" }

Stichwörter: Konfiguration, mongod.conf, getCmdLineOpts, setParameter, cacheSizeGB, WiredTiger, bindIp, authorization, operationProfiling, replSetName, featureCompatibilityVersion, FCV

Sicherheit: Benutzer, Rollen und die localhost-Ausnahme

Ein Benutzer wohnt in einer Datenbank, und das ist sein Nachname; die eingebauten Rollen sind in admin andere als sonst; die 13, die ein Schreibvorgang ohne Recht bekommt; und die einzige Tür, die ein frisch abgesicherter Server offen lässt.

Gilt für: MongoDB 7.0+

Ohne security.authorization: enabled gibt es nichts davon: der Server nimmt jeden, der auftaucht, und mit bindIp: "*" kann jeder. Eingeschaltet lautet die erste Frage: wer bin ich.

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"]

Der Standardmechanismus ist SCRAM-SHA-256, und der Server hält beide Fassungen vor: das Passwort des gemessenen Benutzers trägt 15 000 Iterationen unter SHA-256 und 10 000 unter SHA-1, mit einem Salt von 40 Zeichen. Das Passwort reist nicht, nicht einmal verschlüsselt: SCRAM beweist, dass man es kennt, ohne es zu nennen. Der dritte Mechanismus, MONGODB-X509, tauscht das Passwort gegen ein Client-Zertifikat, und dann ist der Benutzername der Betreff des Zertifikats.

Ein Benutzer wohnt in einer Datenbank, und das ist seine andere Hälfte

lector ist kein Benutzer: lector von ventas ist einer. Die Datenbank, in der er angelegt wurde, ist seine Authentifizierungsdatenbank, und man muss sie beim Verbinden nennen (--authenticationDatabase). Mit der falschen sagt der Server nicht, dass es den Benutzer anderswo gibt: er sagt „Authentication failed“, mehr nicht.

Die Rollen sind nicht in jeder Datenbank dieselben

Eine normale Datenbank hat sechs eingebaute Rollen. admin hat einundzwanzig, denn dort wohnen die, die den ganzen Server erreichen — die …AnyDatabase, root, backup, restore, die des Clusters. Eine Rolle ist eine Liste von Aktionen: read sind elf davon, und find ist nur eine.

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

Was passiert, wenn ein Recht fehlt

Es gibt keine leere Antwort und keine fehlende Zeile: es gibt eine 13 Unauthorized, und die Meldung nennt die Datenbank, den Befehl und sogar die Sammlung. Aus diesem Fehler lässt sich die fehlende Regel herauslesen.

db.getSiblingDB("ventas").createUser({
  user: "lector", pwd: "lectorpass",
  roles: [{ role: "read", db: "ventas" }]
})
// mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-256"]

// schon als Leser verbunden, mit --authenticationDatabase ventas:
db.datos.findOne()              // { _id: 1, v: 1 }
db.datos.insertOne({ _id: 2 })
// 13 Unauthorized - not authorized on ventas to execute command { insert: ... }

Die localhost-Ausnahme

Ein Server mit --auth und ohne einen einzigen Benutzer lässt den, der von der Maschine selbst kommt, genau eines tun: den ersten anlegen. Lesen nicht mehr; einen zweiten anlegen auch nicht. Es ist die Rampe zum Loslegen, und sie schließt sich von selbst, sobald ein Benutzer existiert.

// ein Knoten mit --auth und ohne einen einzigen Benutzer, vom Knoten selbst aus:
db.getSiblingDB("prueba").c.findOne()
// 13 Unauthorized

db.getSiblingDB("admin").createUser({ user: "primero", pwd: "x", roles: ["root"] })
// OK - das ist das Einzige, was er erlaubt

db.getSiblingDB("admin").createUser({ user: "segundo", pwd: "x", roles: ["root"] })
// 13 Unauthorized - Command createUser requires authentication

Stichwörter: Sicherheit, Authentifizierung, SCRAM, SCRAM-SHA-256, x.509, TLS, Benutzer, Rolle, eingebaute Rollen, read, readWrite, dbAdmin, userAdmin, root, 13, Unauthorized, localhost, authSource

Sicherung: mongodump, das Oplog und was es nicht abdeckt

Was in einem mongodump steckt und was nicht, wie lange das Zurückspielen dauert und warum, wozu --oplog dient, wie lang das Oplog-Fenster wirklich reicht, und die zwei Dinge, die ein Dump nicht garantiert.

Gilt für: MongoDB 7.0+

mongodump ist eine logische Sicherung: es verbindet sich wie jeder andere Client, liest die Dokumente und schreibt sie als BSON. Das hat zwei Folgen, die man an den Zahlen sieht. Die erste: die Datei ist so groß wie die Dokumente, nicht wie ihr Platz auf der Platte — 2 420 000 Bytes .bson für eine Sammlung, die auf der Platte komprimiert liegt. Die zweite: sie konkurriert mit der normalen Arbeit des Servers um den Cache, ein Dump einer großen Datenbank ist also spürbar.

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   <- die LOGISCHE Größe der Dokumente
// pedidos.metadata.json      255   <- die Indizes, ohne ihre Daten

Von den Indizes reist nur die Definition, in der .metadata.json. Deshalb kostet das Zurückspielen weit mehr als das Sichern — 91 ms gegen 26 in dieser Messung: die Zeit geht in den Neuaufbau, und bei einer echten Sammlung ist das fast die ganze Wartezeit.

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_ und cliente_1

--archive hinterlässt eine einzige Datei statt eines Ordnerbaums, und mit --gzip schrumpfte sie von 2 420 000 auf 133 572 Bytes. Beide lassen sich durch eine Pipe schicken, und genau so kopiert man eine Datenbank von einer Maschine auf die andere, ohne eine Platte dazwischen zu berühren.

mongodump ... --db ventas --archive=/vol/ventas.gz --gzip
// 133572 Bytes, gegenüber 2420000 beim losen BSON

Das Oplog macht aus einer Sicherung einen Zeitpunkt

Ein Dump dauert, und währenddessen ändert sich die Datenbank weiter: was in Sammlung A vor ihrem Sichern und in B danach geschrieben wurde, passt nicht zusammen. --oplog sichert zusätzlich die während des Dumps aufgetretenen Operationen, und mongorestore --oplogReplay spielt sie am Ende ein, sodass das Wiederhergestellte der Zustand eines Augenblicks ist, nämlich des Dump-Endes. Das geht nur gegen ein Replica Set, denn das Oplog gehört ihm.

// das gibt es nur in einem 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

Das Oplog ist eine gedeckelte Sammlung, sein Fenster misst sich also nicht in Bytes, sondern in Zeit, und diese Zeit hängt daran, wie viel geschrieben wird. Auf dem gemessenen Knoten ergaben 42 GiB Deckel ein Fenster vom 8. August bis zum 12. September; bei zehnfacher Last wären es drei Tage. Das ist die Zahl, die man ansieht, bevor man ins Wochenende geht.

Was ein Dump nicht abdeckt

Zwei Dinge. Eine Datenbank von Hunderten Gigabytes sichert man nicht, indem man sie Dokument für Dokument liest: dafür gibt es Dateisystem-Snapshots, die mit dem Journal zusammen oder mit einer per fsyncLock gesperrten Datenbank genommen werden müssen. Und in einem gesharddeten Cluster liefert ein mongodump gegen den Router keinen gemeinsamen Zeitpunkt für die Shards: man muss den Balancer anhalten und je Shard einen Snapshot nehmen, dazu einen der Konfigurationsserver.

Stichwörter: Sicherung, Backup, mongodump, mongorestore, Oplog, oplogReplay, archive, gzip, Snapshot, fsyncLock, Wiederherstellung, Wiederherstellungsfenster, BSON

Schema: es gibt kein ALTER, es gibt einen Validator

Das Schema ist das, was die Dokumente mitbringen, es zu ändern heißt also, sie zu schreiben. Der Validator mit $jsonSchema, die 121 und ihr errInfo, die vier Kombinationen aus validationLevel und validationAction, und die Migration nach Dokumentversion.

Gilt für: MongoDB 7.0+

Es gibt kein ALTER TABLE, weil es keine Tabelle gibt: das Schema einer Sammlung ist buchstäblich das, was ihre Dokumente mitbringen. Den Neuen ein Feld hinzuzufügen kostet nichts und ändert die Alten nicht, und genau da liegt die Falle: wer liest, muss mit beiden Formen zurechtkommen, bis jemand die Vergangenheit angleicht.

Der Validator ist eine Tür, kein Schema

Was es gibt, ist ein validator mit $jsonSchema: eine Bedingung, die beim Schreiben geprüft wird, nie beim Lesen und nie rückwirkend. Er lehnt mit 121 DocumentValidationFailure ab, und das Gute steckt in errInfo.details, das die verletzte Regel benennt, statt „ungültig“ zu sagen.

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"]

Achtung bei den Typen: mongosh speichert eine ganze Zahl als int, also besteht edad: 30 ein bsonType: "int" und edad: 30.5 verletzt es, denn das ist wirklich ein double.

validationLevel und validationAction sind zwei verschiedene Regler

Die Stufe sagt, welche Dokumente erfasst werden: strict alle, moderate nur die, die schon gültig waren — so setzt man einen Validator auf eine Sammlung voll altem Schrott, ohne deren Aktualisierungen zu blockieren. Die Aktion sagt, was passiert, wenn es fehlschlägt: error lehnt ab, warn lässt schreiben und vermerkt es im Log. Und den Validator per collMod zu setzen rührt nichts an, was schon da war.

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 - löscht nichts

db.viejos.updateOne({ _id: 1 }, { $set: { nombre: "Uno bis" } })   // moderate: geht durch
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" })      // geht durch und warnt nur

Migrieren heißt schreiben

Ohne ALTER ist das Gegenstück einer neuen Spalte ein updateMany mit $set und das des Entfernens eines mit $unset. Sie sind billig — 20 000 Dokumente in 61 und 51 ms — aber sie sind nicht atomar: sie gehen Dokument für Dokument, während der Migration bestehen also beide Formen nebeneinander.

db.pedidos.updateMany({ _v: 1 }, { $set: { moneda: "MXN", _v: 2 } })
// 20000 geändert in 61 ms

db.pedidos.updateMany({}, { $unset: { moneda: "" } })
// 20000 geändert in 51 ms

Deshalb hält das Muster, die Version in jedem Dokument zu führen (_v): die Anwendung kann beide lesen, die Migration schreitet in Stapeln oder beim Anfassen jedes Dokuments voran, und an dem Tag, an dem {_v: 1} nichts mehr liefert, fliegt der alte Code raus. Genau das tut eine SQL-Migration auch, nur ist hier der Zwischenzustand sichtbar und muss aufgeschrieben werden.

Stichwörter: Schema, Validierung, Validator, jsonSchema, 121, DocumentValidationFailure, validationLevel, validationAction, strict, moderate, warn, collMod, Migration, Dokumentversion

Sharding: der Schlüssel entscheidet alles

Die drei Teile eines gesharddeten Clusters, warum ein gehashter Schlüssel verteilt und ein monotoner anhäuft, der gemessene Unterschied zwischen gezielter und gestreuter Abfrage, Zonen, und was ein Schlüsselwechsel kostet.

Gilt für: MongoDB 7.0+

Sharding heißt, eine Sammlung auf mehrere Maschinen zu verteilen, und dazu gehören drei Teile: die Shards, die die Daten halten und Replica Sets sind; die Konfigurationsserver, die die Karte halten, welcher Chunk wo liegt; und mongos, der Router, der nichts hält und mit dem sich die Anwendung verbindet.

# drei getrennte Teile, und der Router speichert keine Daten
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"]

Der Shard-Schlüssel ist die einzige Entscheidung, auf die es ankommt

Aus ihm folgen drei Dinge auf einmal: wie sich die Daten verteilen, welche Abfragen auf einen einzigen Shard gerichtet werden können, und ob es einen heißen Punkt gibt. Verlangt sind Kardinalität — viele verschiedene Werte —, gleichmäßige Häufigkeit, damit kein Wert die Hälfte bekommt, und dass er nicht monoton ist, denn ein stets wachsender Schlüssel schickt jeden neuen Schreibvorgang an dieselbe Stelle.

Das Letzte ist keine Theorie. Auf derselben Sammlung von 60 000 Dokumenten: ein hashed-Schlüssel auf den Kunden ließ 52,11 % auf dem einen Shard und 47,88 % auf dem anderen; die ObjectId _id, die immer wächst, ließ 100 % auf einem einzigen, in einem einzigen Chunk.

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

Gezielt oder gestreut

Eine Abfrage, die den Schlüssel mitbringt, geht an einen Shard, fertig. Eine ohne ihn wird allen gestellt und die Antworten werden verschmolzen: SINGLE_SHARD gegen SHARD_MERGE. Der gemessene Unterschied sind 121 geprüfte Dokumente gegen 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

Zonen, und den Schlüssel wechseln

Eine Zone bindet einen Bereich des Schlüssels an einen Shard, und dafür gibt es zwei echte Gründe: die Daten eines Landes auf Maschinen dieses Landes zu halten, und Heißes von Kaltem zu trennen. Und seit 5.0 lässt sich der Schlüssel mit reshardCollection wechseln, aber das kopiert die ganze Sammlung, und man merkt es: bei 60 000 Dokumenten arbeitete es nach zwei Minuten noch, mit seiner temporären Sammlung gut sichtbar.

sh.addShardToZone("sh1", "MX")
sh.addShardToZone("sh2", "EU")
// config.shards: sh1 tags ["MX"], sh2 tags ["EU"]

db.adminCommand({ reshardCollection: "ventas.eventos", key: { _id: "hashed" } })
// kopiert die ganze Sammlung: nach zwei Minuten lief es noch,
// mit seinem system.resharding.<uuid> sichtbar in config.collections

Drei Dinge, die nicht mehr stimmen

Man hört immer noch, ein updateOne ohne den Schlüssel scheitere, der Wert des Schlüssels lasse sich nicht ändern, und der Index müsse vor dem Sharding existieren. Auf 8.2 gingen alle drei ohne Murren durch.

Stichwörter: Sharding, Shard-Schlüssel, hashed, Chunk, mongos, Konfigurationsserver, Zone, Balancer, reshardCollection, SINGLE_SHARD, SHARD_MERGE

Häufige Fehler: die acht Nummern und was sie mitbringen

Die acht Codes, die täglich auftauchen, einzeln provoziert samt dem Text, den der Server zurückgibt, und die zwei, die mehr enthalten als die Nummer: die 11000 mit ihrem Schlüssel und die 121 mit ihrem errInfo.

Gilt für: MongoDB 7.0+

Ein MongoDB-Fehler bringt eine Nummer, fast immer einen Namen, und manchmal etwas darin, das mehr wert ist als beides. Dies sind die acht, die täglich auftauchen, einzeln gegen den Server provoziert und so übernommen, wie sie kamen.

CodeNameWas passiert ist
11000—doppelter Schlüssel in einem eindeutigen Index
13Unauthorizeddem Benutzer fehlt eine Aktion auf dieser Datenbank
18AuthenticationFailedBenutzer, Passwort oder Authentifizierungsdatenbank
26NamespaceNotFounddie Sammlung gibt es nicht
50MaxTimeMSExpireddie gegebene Zeit war um
112WriteConflicteine andere Transaktion hat dieses Dokument angefasst
121—das Dokument kam nicht durch den Validator
251NoSuchTransactiondie Transaktion war schon abgebrochen

Die zwei ohne Namen bringen etwas Besseres

Die 11000 und die 121 kommen ohne codeName, und das ist gleichgültig: beide bringen genau die Angabe mit, die man zum Beheben braucht. Die 11000 nennt die Sammlung, den Index und den Wert, der kollidierte, man muss also nicht raten, welcher von drei eindeutigen Indizes ausgelöst hat.

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" }

Die 121 sagt „Document failed validation“ und sonst nichts in der Meldung, aber e.errInfo.details trägt die verletzte Regel mit Namen und erwartetem Wert. Das ist der Unterschied zwischen „ungültig“ und „es fehlt 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"] } ] }

Die zwei Rechte-Fehler sind verschieden

Die 18 heißt „ich weiß nicht, wer du bist“: falsches Passwort, oder — meistens — die falsche Authentifizierungsdatenbank, denn ein MongoDB-Benutzer ist der Name plus die Datenbank, in der er angelegt wurde. Die 13 heißt „ich weiß, wer du bist, und du darfst nicht“; ihre Meldung nennt die Datenbank, den Befehl und sogar die Sammlung, die fehlende Rolle liest man also direkt heraus.

Und zwei Warnungen zur Syntax

Die 50 ist kein Serverfehler, sondern die Obergrenze, die man selbst mit maxTimeMS gesetzt hat. Und die 26 taucht dort auf, wo man sie am wenigsten erwartet: collMod und renameCollection auf etwas Nichtexistierendes scheitern, aber drop() auf eine nicht vorhandene Sammlung liefert false und fertig — es ist kein Fehler, ein Skript, das darauf baut, erfährt also nie, dass es sich im Namen geirrt hat.

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, und kein Fehler

Stichwörter: Fehler, Codes, 11000, duplicate key, 13, Unauthorized, 18, AuthenticationFailed, 26, NamespaceNotFound, 50, MaxTimeMSExpired, 112, WriteConflict, 121, 251, NoSuchTransaction, errInfo

Grenzen: die acht Obergrenzen und wie jede sich meldet

Die acht Obergrenzen, an die man wirklich stößt, einzeln gegen den Server provoziert, mit dem Code, den jede zurückgibt, und den dreien, die nicht dort liegen, wo ihr Ruf sie vermutet.

Gilt für: MongoDB 7.0+

All dies wurde gegen den Server provoziert, die Nummer links ist also die, die tatsächlich abgelehnt hat, nicht die aus der Legende.

ObergrenzeGemessener WertWie sie sich meldet
Größe eines Dokuments16 777 216 Bytes10334
Verschachtelungstiefe179 Ebenen in einem insertOne15 Overflow
Indizes pro Sammlung64, _id_ mitgezählt67 CannotCreateIndex
Felder eines zusammengesetzten Index3213103
Name einer Datenbank63 Zeichen73 InvalidNamespace
Datenbank + Sammlung255 Zeichen73 InvalidNamespace
Größe eines indizierten Wertskeine praktische Grenze—
blockierende Stufe in einer Pipeline104 857 600 Bytes146

Die 16 MB sind die einzige, an die man aus Versehen stößt

Und fast immer aus demselben Grund: ein Array, das ungebremst in einem Dokument wächst. Die Meldung bringt beide Zahlen mit, die des Dokuments und das Maximum, man sieht also auf einen Blick, um wie viel es zu viel war.

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

Die zwei Index-Grenzen werden falsch gezählt

Die 64 Indizes pro Sammlung schließen _id_ ein, es passen also 63 eigene; und es ist keine Grenze, an die man gesund stößt: bei 64 Indizes pflegt jeder Schreibvorgang 64 Bäume. Auch die 32 Felder eines zusammengesetzten Index sind kein Ziel: jenseits von sechs oder sieben braucht man ziemlich sicher zwei Indizes, nicht einen breiteren.

db.tope.insertOne({ a: 1 })
for (let i = 0; i < 70; i++) db.tope.createIndex({ ["c" + i]: 1 })
// legt 63 an, und der 64. scheitert:  _id_ zählt mit
// 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)          // mit 32 geht es durch
// 13103 :: too many compound keys

Drei, die nicht dort liegen, wo ihr Ruf sie vermutet

Die Verschachtelung ist mit 100 Ebenen dokumentiert, und abgelehnt hat der Server die 180., denn die Obergrenze gehört zum BSON des gesamten Befehls, und die insert-Hülle nimmt einen Teil davon. Der lange Name scheitert nicht an der Sammlung, sondern an der Summe aus Datenbank und Sammlung, also am Namensraum. Und die Grenze für die Größe eines Indexschlüssels, in alten Versionen 1 024 Bytes, gibt es nicht mehr: ein indizierter Wert von 50 000 ging durch.

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) })   // geht durch: es gibt keine Schlüsselgrenze

Und was keine Grenze hat

Weder die Zahl der Sammlungen noch die der Datenbanken noch die der Dokumente in einer Sammlung. Das, was zuerst ausgeht, steht nicht in dieser Tabelle: es ist die Platte. Und für das, was wirklich nicht in 16 MB passt — eine Datei —, gibt es GridFS, das sie in Stücke von 255 KB schneidet und jedes Stück als gewöhnliches Dokument ablegt.

Stichwörter: Grenzen, Obergrenzen, 16 MB, Dokumentgröße, 10334, Verschachtelung, Overflow, Indizes pro Sammlung, 67, CannotCreateIndex, 13103, Namensraum, 73, InvalidNamespace, Indexschlüssel

Bewährte Praxis: sieben, die auf einer Zahl stehen

Die Zusammenfassung des MongoDB-Handbuchs: sieben Gewohnheiten, die sich lohnen, jede mit der Messung dahinter, und die fünf Zeilen, mit denen man einem gerade geerbten Server den Puls fühlt.

Gilt für: MongoDB 7.0+

Dies ist das Ende des Handbuchs, und es bringt nichts Neues: es sammelt, was jedes Thema belegt hat, in der Form, die im Alltag nützt. Jede Gewohnheit kommt mit der Zahl, die sie trägt, und all diese Zahlen wurden an einem echten Server gemessen, nicht abgeschrieben.

1. Modelliere danach, wie du lesen wirst, nicht danach, wie es einer Tabelle ähnelt. Was zusammen gelesen wird, wird zusammen gespeichert. Die Grenze dieser Regel ist hart und gemessen: ein Dokument kommt nicht über 16 777 216 Bytes, ein ungebremst wachsendes Array endet also an irgendeinem Dienstag in einer 10334.

2. Ein Index pro häufiger Abfrage, und kein einziger mehr. Jeder Index ist ein Baum, der bei jedem Schreibvorgang gepflegt werden muss: dieselben 20 000 Einfügungen dauerten 60 ms ohne Index, 101 mit fünf und 160 mit zehn. Und sie brauchen Platz: in der Beispieldatenbank trug eine Sammlung mit 1 148 000 Bytes Daten 622 592 an Indizes.

3. Beurteile eine Abfrage nach totalDocsExamined gegen nReturned, nicht nach der Uhr. Die Uhr sagt, was es heute gedauert hat, mit warmem Cache und ruhiger Maschine; das Verhältnis dieser beiden Zahlen sagt, was passiert, wenn die Sammlung zehnmal größer ist.

4. writeConcern: majority für das, was nicht verloren gehen darf. Auf einem Replica Set ist es bereits der Werkswert, die Gewohnheit ist also nicht, es zu setzen: sie ist, es nicht zu entfernen, um schneller zu sein.

5. Nie ohne Authentifizierung. Es ist eine Zeile in der Datei, und die einzige Tür, die ein frisch gestarteter Server offen lässt — den ersten Benutzer von der Maschine selbst anzulegen —, schließt sich von selbst, sobald dieser Benutzer existiert.

6. Sichere mit dem Oplog, und miss sein Fenster in Zeit. mongodump --oplog macht aus einem Dump einen Augenblick. Und die Größe des Oplogs liest man nicht in Bytes, sondern in Tagen: der gemessene Knoten ergab 34, aber das hängt daran, wie viel geschrieben wird, es ist also eine Zahl, die man wieder ansieht, wenn sich die Last ändert.

7. Das Working Set muss in den Cache passen. Es ist die eine Leistungsregel ohne Tricks. In der Beispieldatenbank: 1 150 242 Bytes Daten gegen 3 621 781 504 Cache — dreitausendmal Platz zu viel. An dem Tag, an dem keiner mehr übrig ist, merkt man es überall gleichzeitig.

Der Puls eines gerade geerbten Servers

Fünf Zeilen beantworten, was man wissen muss, bevor man irgendetwas anfasst: ob er ein Passwort verlangt, wann ein Schreibvorgang als geschrieben gilt, ob jemand die langsamen Abfragen beobachtet, wie viel Speicher er zum Arbeiten hat und wie viele Daten er bewegen muss.

db.adminCommand({ getCmdLineOpts: 1 }).parsed.security
// { authorization: "enabled" }, o undefined - que es la respuesta mala

db.adminCommand({ getDefaultRWConcern: 1 }).defaultWriteConcern
// { w: "majority", wtimeout: 0 }
// auf einem einzelnen Knoten gibt es das gar nicht: "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

Und eine weitere, die zeigt, wohin die Platte geht und nebenbei, welche Sammlung mehr Index als Daten trägt.

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 }
// der Filter nach type ist nötig: stats() auf einer View scheitert

Stichwörter: bewährte Praxis, Zusammenfassung, Zugriffsmuster, Indizes, writeConcern, majority, Authentifizierung, Oplog, Working Set, Cache, explain, Serverzustand

Datenstrukturen und Kodierungen

Die sechs Typen von Redis, wie es sie intern speichert (`OBJECT ENCODING`), wann die Kodierung wechselt und wo die echten Grenzen liegen.

Gilt für: Redis 7.0+

Redis hat keine Tabellen und keine Spalten: Jeder Schlüssel enthält einen Wert eines Typs, und den Typ legt der erste Befehl fest, der ihn schreibt. SET legt einen String an, HSET einen Hash, RPUSH eine Liste, SADD ein Set, ZADD ein sortiertes Set und XADD einen Stream. TYPE sagt, welcher es ist, und ein Befehl eines anderen Typs auf diesem Schlüssel antwortet WRONGTYPE, ohne etwas anzufassen.

Die sechs Typen im Testbed

TypSchlüssel im TestbedWofür
stringapp:sesion:1001ein Wert: Text, Zahl oder Bytes
hashapp:usuario:1ein Objekt mit Feldern (hier sechs)
listapp:cola:pendienteseine Warteschlange oder ein Stapel, in Ankunftsreihenfolge (500 Elemente)
setapp:etiquetas:activaseindeutige Mitglieder ohne Reihenfolge (300)
zsetapp:ranking:globaleindeutige Mitglieder, nach einer Punktzahl sortiert (100)
streamapp:eventos:streamein Protokoll von Einträgen mit zeitbasierter ID (50)

OBJECT ENCODING: wie es intern gespeichert wird

Der Typ ist das, was du siehst; die Kodierung, wie der Server ihn speichert. Redis beginnt mit einer kompakten Form und wechselt zur allgemeinen, wenn die Sammlung wächst, und dieser Wechsel ist der Unterschied zwischen wenigen und vielen Bytes. Gemessen an einem temporären Hash:

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

Ein Feld mehr vervielfacht die Größe um mehr als das Fünffache. Dasselbe passiert, wenn ein einzelner Wert 64 Bytes überschreitet (hash-max-listpack-value): Ein Hash mit einem Feld von 65 Bytes ist schon hashtable. Und der Wechsel ist endgültig: Nach dem Löschen von Feld 513 bleibt der Hash hashtable.

Die anderen Typen folgen derselben Idee, mit diesen Schwellen im Testbed. Ein Set aus Ganzzahlen ist intset, wird listpack, sobald ein Textmitglied hinzukommt, und hashtable ab mehr als 128 (set-max-listpack-entries). Eine kleine Liste ist listpack, eine große quicklist. Ein sortiertes Set wechselt ab mehr als 128 Mitgliedern von listpack zu skiplist (zset-max-listpack-entries). Deshalb ist das Set mit 300 Etiketten hashtable, und das Ranking mit 100 bleibt listpack.

Ein String sind Bytes, kein Text

Ein String enthält eine beliebige Bytefolge, und der Server deutet sie nicht. Passt sie in eine Ganzzahl, wird sie als int gespeichert (metricas:diarias:2026-09-01); bis 44 Bytes als embstr, ab 45 als raw. Sie kann Zeilenumbrüche enthalten —binario:datos sind 4 Bytes, a\r\nb— oder Bytes, die kein UTF-8 sind: Ein SET mit \xff\x00\xfe speichert 3 Bytes, und Calíope zeigt sie hexadezimal an, statt einen Text zu erfinden.

Die echten Grenzen

Ein String kann 512 MB erreichen: Das ist proto-max-bulk-len, im Testbed 536870912, und ein SETRANGE darüber hinaus antwortet string exceeds maximum allowed size. Eine Sammlung fasst laut Befehlsreferenz bis zu 2³²−1 Elemente; vorher begrenzen sie der Speicher des Servers und die Kosten, sie auf einmal zu lesen.

HyperLogLog, Bitmaps und Geo sind keine neuen Typen

Es sind Befehle auf den sechs Typen oben, und TYPE verrät sie. PFADD speichert ein HyperLogLog —eine ungefähre Zählung eindeutiger Elemente— in einem String: Mit vier Elementen belegt es 30 Bytes. SETBIT schreibt Bits in einen String, und Bit 1000 macht ihn 126 Bytes groß. GEOADD speichert die Koordinaten als Punktzahl eines zset, also wird ein Geo-Schlüssel gelöscht, läuft ab und wird gemessen wie jedes sortierte Set.

Stichwörter: redis, typen, string, hash, list, set, zset, stream, object encoding, listpack, hashtable, intset, quicklist, skiplist, hyperloglog, bitmap, geo, grenzen

Schlüssel, Namen und Ablauf

Warum der Server keine Präfixe kennt, was `TTL` −1 und −2 bedeuten, wie ein Schlüssel abläuft und warum `SCAN` und `UNLINK` statt `KEYS` und `DEL`.

Gilt für: Redis 7.0+

Der Name ist eine Konvention, keine Hierarchie

Üblicherweise werden Schlüssel mit Doppelpunkten benannt —app:usuario:1, app:sesion:1001—, und Calíope gruppiert die Liste nach diesen Präfixen. Aber der Server kennt keine Hierarchie: Für Redis ist app:usuario:1 eine Zeichenkette von 13 Bytes wie jede andere. Es gibt keinen Ordner zum Auflisten oder Löschen, und der einzige Weg, die Schlüssel eines Präfixes zu finden, ist sie mit SCAN … MATCH app:usuario:* zu durchlaufen. Das Trennzeichen wählst du (in Calíope standardmäßig :). Das Testbed hat fünf Wurzelpräfixe: app (254 Schlüssel), metricas (9), binario, clave und sinprefijo.

TTL hat zwei negative Antworten, und sie sind verschieden

TTL sinprefijo      # -1: existiert und läuft nicht ab
TTL no:existe       # -2: existiert nicht
SET k v EX 100
TTL k               # 99
SET k v2            # ein SET ohne KEEPTTL entfernt den Ablauf
TTL k               # -1
SET k v EX 100
SET k v3 KEEPTTL    # behält den bisherigen
TTL k               # 99
PERSIST k           # 1: entfernt den Ablauf
EXPIRE k -1         # eine negative Zeit löscht den Schlüssel
EXISTS k            # 0

Wer -1 mit -2 verwechselt, zeigt einen Schlüssel als ewig an, den es nicht mehr gibt. PTTL liefert dasselbe in Millisekunden, und in INFO keyspace zählt das Feld expires die Schlüssel der Datenbank mit Ablaufzeit (im Testbed 40).

Wie ein Schlüssel abläuft

Auf zwei Wegen. Liest ihn jemand nach seiner Zeit, findet der Lesevorgang ihn abgelaufen und löscht ihn: das ist der träge Ablauf. Und liest ihn niemand, holt ihn der aktive Zyklus, der zehnmal pro Sekunde (hz 10) eine Stichprobe der Schlüssel mit Ablaufzeit prüft. Im Testbed waren zwei mit PX 200 geschriebene Schlüssel eine halbe Sekunde später verschwunden, ohne dass jemand sie gelesen hatte, und expired_keys (in INFO stats, seit dem Start kumuliert) stieg von 0 auf 2. expire_cycle_cpu_milliseconds sagt, wie viel CPU-Zeit dieser Zyklus verbraucht hat.

SCAN statt KEYS

KEYS * liefert alle Schlüssel in einem einzigen Befehl, und solange er läuft, bedient der Server niemanden sonst, denn Redis führt die Befehle einzeln nacheinander aus. Mit den 266 Schlüsseln des Testbeds dauert er 49 µs und fällt nicht auf; seine Kosten wachsen mit der Zahl der Schlüssel in der Datenbank, und bei Millionen blockiert er Sekunden. SCAN macht denselben Durchlauf in Abschnitten: im Testbed 27 Aufrufe von je etwa 12 µs, zwischen denen der Server die anderen bedient. Dafür kann ein Schlüssel zweimal kommen, und Duplikate müssen entfernt werden. Deshalb lehnt die Konsole von Calíope KEYS ab, und der Browser durchläuft mit SCAN.

UNLINK statt DEL

Beide löschen, aber DEL gibt den Speicher sofort frei und UNLINK in einem anderen Thread. Gemessen mit einem Set von 200.000 Mitgliedern (11 MB): DEL brauchte 83 ms, in denen der Server niemandem antwortete, UNLINK 0,3 ms. Bei einem kleinen Schlüssel gibt es keinen Unterschied; bei einem großen ist UNLINK derjenige, der den Server nicht anhält, und Calíope löscht damit.

RENAME und das Hash-Tag

RENAME ersetzt den Zielschlüssel, falls er existiert, und sein Wert geht verloren; RENAMENX tut nichts und gibt 0 zurück, wenn das Ziel existiert. In einem Cluster müssen beide Schlüssel im selben Slot landen, sonst antwortet der Befehl CROSSSLOT: Im Testbed landet qa:t67:a in Slot 7222 und qa:t67:zz in 9705. Das Einzige, was der Cluster an einem Namen für die Wahl des Slots ansieht, ist das Hash-Tag: Hat der Schlüssel ein Stück in geschweiften Klammern, zählt nur dieses Stück. usuario:1 und usuario:2 landen in 3014 und 15269, aber {usuario}:1 und {usuario}:2 beide in 15774; und clave:con:{etiqueta}:hash in 5693, demselben wie etiqueta. Leere Klammern (foo{}bar) zählen nicht als Tag.

Stichwörter: redis, schlüssel, namen, präfix, ttl, expire, persist, keepttl, ablauf, scan, keys, unlink, del, rename, hash-tag, slot

Speicher und Verdrängung

Was jede Zahl in `INFO memory` misst, warum die Summe der Schlüssel nicht der Speicher des Servers ist und was jede `maxmemory-policy` tut, wenn er voll ist.

Gilt für: Redis 7.0+

Mehrere Zahlen, und sie sind nicht dieselbe

INFO memory liefert mehrere, und im Testbed lauten sie:

FeldWas es istTestbed
used_memorywas Redis beim Allokator angefordert hat1,6 MB
used_memory_startupwas der leere Server belegt0,9 MB
used_memory_datasetder Anteil, der Daten sind0,6 MB
used_memory_rsswas das Betriebssystem dem Prozess zuschreibt13 MB
used_memory_peakdas Höchste, was er je belegt hat16,6 MB

mem_fragmentation_ratio ist used_memory_rss geteilt durch used_memory, und im Testbed beträgt es 8,56. Das ist kein Defekt: Kurz zuvor hatte ein Set von 11 MB die Spitze auf 16,6 MB getrieben, und nach dem Löschen behielt der Allokator (jemalloc) die Seiten. MEMORY PURGE bittet ihn, sie zurückzugeben, und das Verhältnis sank auf 8,13. Ein hohes Verhältnis bei wenig belegtem Speicher ist das; ein hohes bei viel, echte Fragmentierung. Und ein Wert unter 1 sagt, dass das System einen Teil von Redis in den Auslagerungsspeicher verschoben hat, was schlimmer ist.

Warum MEMORY USAGE nicht used_memory ergibt

MEMORY USAGE misst einen Schlüssel: seinen Wert, seinen Namen und was ihn umgibt. Die 266 Schlüssel des Testbeds ergeben 54.048 Bytes, und used_memory ist 1,6 MB. Der Unterschied ist der Server selbst (948.744 Bytes leer), die Puffer der Clients und des AOF, die anderen Datenbanken und die Tabellen, die die Schlüssel indizieren. Deshalb zeigt der Speicheranalysator von Calíope, wie viel jedes Präfix wiegt, und verspricht nicht, dass die Summe der Speicher des Servers ist.

SAMPLES: Bei einer Sammlung ist die Zahl eine Schätzung

Bei einer Sammlung misst MEMORY USAGE 5 Elemente und rechnet hoch (SAMPLES 5, Standard); SAMPLES 0 misst alle, exakt und teuer. Sind die Elemente ähnlich, ist es egal: Das Set mit 300 Etiketten misst auf beide Arten 16.216 Bytes. Sind sie es nicht, liegt es daneben: Ein Hash mit 600 Feldern, in dem jedes fünfzigste 4.000 Bytes groß ist, misst 32.304 mit der Stichprobe, 56.832 mit SAMPLES 100 und 81.360 vollständig gezählt.

maxmemory und was beim Erreichen passiert

Ohne maxmemory (0, wie im Testbed) wächst Redis, so weit das System es lässt. Mit einer Obergrenze wendet es beim Erreichen maxmemory-policy an, das acht Werte hat:

RichtlinieWas sie tut, wenn er voll ist
noevictionlehnt Schreibvorgänge mit OOM ab; Lesen geht weiter
allkeys-lruverdrängt die am längsten nicht benutzten Schlüssel, aus allen
allkeys-lfuverdrängt die am seltensten benutzten Schlüssel, aus allen
allkeys-randomverdrängt zufällige Schlüssel, aus allen
volatile-lruwie allkeys-lru, nur unter denen mit Ablaufzeit
volatile-lfuwie allkeys-lfu, nur unter denen mit Ablaufzeit
volatile-randomzufällig, nur unter denen mit Ablaufzeit
volatile-ttldie, die am frühesten ablaufen

Gemessen mit der Obergrenze knapp über dem Belegten (300 KB mit noeviction, 100 KB mit allkeys-lru). Mit noeviction gingen 811 Schlüssel zu je 200 Bytes hinein, der nächste antwortete OOM command not allowed when used memory > 'maxmemory', und GET las weiter. Mit allkeys-lru, der Richtlinie des Testbeds, schlug kein Schreibvorgang fehl: Der Server verdrängte 1.309 Schlüssel, um Platz zu schaffen, aus jeder Datenbank —auch aus Datenbank 1, der des Lastgenerators—, und rührte keinen der 266 Schlüssel des Testbeds an, die gerade gelesen worden waren (TOUCH). Verdrängen warnt niemanden: Der Schlüssel ist einfach weg, und nur evicted_keys zählt ihn. Ist Redis die einzige Kopie eines Datums, ist die Richtlinie noeviction; ist es ein Cache, eine der allkeys.

Die LRU ist eine Näherung: Der Server vergleicht maxmemory-samples Schlüssel (5) und verdrängt den, der am längsten unbenutzt ist. OBJECT IDLETIME sagt, wie viele Sekunden ein Schlüssel unberührt ist, und OBJECT FREQ antwortet nur unter einer LFU-Richtlinie.

MEMORY STATS und MEMORY DOCTOR

MEMORY STATS schlüsselt das Obige auf: was der Start belegt, die Clients, das AOF, jede Datenbank und wie viele Bytes pro Schlüssel. MEMORY DOCTOR gibt eine Diagnose in Textform, aber nicht bei wenig belegtem Speicher: Im Testbed antwortet er, die Instanz sei leer oder belege sehr wenig Speicher.

Stichwörter: redis, speicher, used_memory, rss, fragmentierung, maxmemory, maxmemory-policy, verdrängung, eviction, lru, lfu, noeviction, oom, memory usage, samples, memory stats, memory doctor, memory purge

Persistenz: RDB und AOF

Wie Redis auf die Platte schreibt, was im Speicher lebt, was jede Kombination verliert, wenn der Prozess beendet wird, und was `loading:1` bedeutet.

Gilt für: Redis 7.0+

Redis lebt im Speicher. Was es auf die Platte schreibt, dient dazu, es beim Start wieder zu laden, und dafür gibt es zwei Wege, die sich kombinieren lassen.

RDB: Momentaufnahmen der ganzen Datenbank

BGSAVE schreibt die ganze Datenbank aus einem Kindprozess nach dump.rdb, ohne den Server anzuhalten; SAVE tut dasselbe, indem es ihn anhält, und wird deshalb nicht verwendet. Die Regel save löst sie selbst aus; die des Testbeds ist 3600 1 300 100 60 10000: eine Aufnahme, wenn es in einer Stunde mindestens eine Änderung gab, in fünf Minuten hundert oder in einer Minute zehntausend. INFO persistence sagt, wann die letzte war (rdb_last_save_time, in Unix-Sekunden), ob sie gelang (rdb_last_bgsave_status) und wie viele Änderungen jetzt verloren gingen (rdb_changes_since_last_save): Nach einem BGSAVE fiel der Wert von 71 auf 3.

AOF: das Protokoll jedes Schreibvorgangs

Mit appendonly yes hängt Redis jeden Schreibvorgang an eine Datei an und spielt ihn beim Start erneut ab. Das Testbed läuft so (aof_enabled:1). In Redis 7 besteht das AOF aus mehreren Dateien in appendonlydir/: einer Basis im RDB-Format, einem Inkrement mit den Befehlen seitdem und einem Manifest, das sie verbindet. BGREWRITEAOF verdichtet es zu einer neuen Basis; aof_rewrites zählt, wie oft, und aof_rewrite_in_progress sagt, ob gerade eine läuft.

Wann es auf die Platte kommt, entscheidet appendfsync:

WertWann es synchronisiertWas verloren gehen kann
alwaysbei jedem Schreibvorgangnichts, und es ist am langsamsten
everyseceinmal pro Sekundebis zu einer Sekunde, wenn die Maschine ausfällt
nowenn das System es entscheidetwas das System noch nicht geschrieben hat

Das Testbed verwendet everysec, den Standardwert.

Was beim Beenden des Prozesses verloren geht, gemessen

Mit AOF und everysec waren ein zwei Sekunden vorher geschriebener Schlüssel und ein weiterer, direkt vor docker kill geschriebener —das den Prozess ohne Vorwarnung beendet— beim Start beide vorhanden. Den Prozess zu beenden verliert nicht, was schon im AOF steht, denn Redis schreibt es in die Datei, bevor es antwortet; was everysec offenlässt, ist das, was das Betriebssystem noch nicht auf die Platte gebracht hat, und das geht nur verloren, wenn die Maschine ausfällt, nicht der Prozess.

Ohne AOF, nur mit RDB, steht auf dem Spiel, was rdb_changes_since_last_save sagt: Alles seit der letzten Aufnahme Geschriebene geht beim Beenden des Prozesses verloren. Mit der Regel des Testbeds kann das fast eine Stunde an Änderungen sein, wenn es weniger als hundert waren.

loading:1: Der Server startet

Beim Start liest Redis das AOF oder das RDB, bevor es jemanden bedient. Mit den 266 Schlüsseln des Testbeds bleibt keine Zeit, es zu sehen; mit 1,5 Millionen zusätzlichen Schlüsseln dauerte es etwa 6 Sekunden, und in dieser Zeit antwortete INFO persistence mit loading:1 und jeder andere Befehl mit LOADING Redis is loading the dataset in memory. Calíope zeigt das als Antwort des Servers und nicht als Verbindungsabbruch: Die Verbindung ist in Ordnung, und es genügt, zu warten und es erneut zu versuchen.

Stichwörter: redis, persistenz, rdb, aof, bgsave, save, appendonly, appendfsync, everysec, bgrewriteaof, dump.rdb, loading, start, datenverlust

Clients und Latenz

Wer verbunden ist und was er tut, warum ein langsamer Befehl alle warten lässt, und wie der Server seine eigene Latenz misst.

Gilt für: Redis 7.0+

Redis führt Befehle einen nach dem anderen aus, in einem einzigen Thread: Solange einer läuft, warten alle anderen Clients, bis sie dran sind. Deshalb betrifft Latenz hier nicht eine Abfrage, sondern alle.

Wer verbunden ist: CLIENT LIST

Eine Zeile pro Verbindung, mit Paaren Feld=Wert. Die aussagekräftigsten:

FeldWas es ist
iddie Nummer der Verbindung, die CLIENT KILL ID verlangt
addrwoher sie kommt
nameder Name, den sie sich mit CLIENT SETNAME gegeben hat; leer, wenn keiner gesetzt ist
age / idleSekunden seit dem Verbindungsaufbau und seit ihrem letzten Befehl
flagsN, eine normale Verbindung; b, eine blockierte, die wartet (BLPOP); S, eine Replica; M, ihr Primary
cmdder letzte Befehl
tot-memder Speicher, den die Verbindung samt Puffern belegt

Auch Calíope steht in der Liste: Seine Verbindungen heißen Caliope (name=Caliope, lib-name=caliope), und eine Sitzung öffnet bis zu vier zum Arbeiten, dazu eine eigene für lange Durchläufe. „Clients“ markiert sie als Verbindungen dieser Sitzung und bietet nicht an, sie zu trennen.

INFO clients liefert die Summen: connected_clients, blocked_clients und die Obergrenze, maxclients (10000 im Testbed, der Standardwert). Mit timeout auf 0, ebenfalls Standard, schließt der Server eine inaktive Verbindung nie.

Ein langsamer Befehl lässt alle warten, gemessen

Im Testbed ein Skript, das 1,7 Sekunden lief, und ein PING, der gleich danach von einer anderen Verbindung kam: Der PING brauchte 1,65 Sekunden bis zum PONG. Er stand die ganze Zeit in der Schlange, obwohl er mit dem Skript nichts zu tun hatte. Was passiert, wenn ein Skript länger als 5 Sekunden läuft, steht im Thema „Transaktionen und Skripte“.

CLIENT PAUSE: Clients absichtlich anhalten

CLIENT PAUSE <ms> WRITE hält die Schreibvorgänge aller Clients so lange an und lässt Lesevorgänge durch; ohne WRITE hält es alles an. Gemessen mit zwei Sekunden: Ein Lesevorgang antwortete nach 7 ms, ein Schreibvorgang wartete die vollen 2000 ms. Das dient dazu, den Primary zu wechseln, ohne Schreibvorgänge zu verlieren, und CLIENT UNPAUSE hebt die Pause früher auf.

Der Latenzmonitor des Servers

Redis notiert langsame Episoden, wenn man ihm mit latency-monitor-threshold eine Schwelle in Millisekunden gibt; bei 0, dem Standardwert, notiert er nichts. Mit 20 ms über 25 Sekunden im Testbed ergab LATENCY LATEST 7 Episoden vom Typ command —die Skripte des Lastgenerators—, die schlimmste mit 76 ms. LATENCY HISTORY command liefert jede mit ihrer Uhrzeit, LATENCY DOCTOR fasst alles in einem englischen Text mit Ratschlägen zusammen, und LATENCY RESET löscht das Notierte.

Der Monitor sagt, wann es eine Episode gab; welcher Befehl es war, steht im Log der langsamen Befehle, SLOWLOG, das Calíope unter „Langsame Befehle“ zeigt und das Thema „Leistung“ erklärt.

Stichwörter: redis, clients, client list, verbindungen, latenz, latency doctor, latency history, client pause, maxclients, timeout, blockieren, ein thread

Das Log der langsamen Befehle und seine Schwelle in Mikrosekunden, was eine Pipeline spart, große Schlüssel und wie man sie liest, und was die Zähler von INFO sagen.

Gilt für: Redis 7.0+

In Redis brauchen fast alle Befehle Mikrosekunden. Teuer ist, was man sehr oft tut, oder auf einen Schlag mit etwas Großem.

Das Log der langsamen Befehle: SLOWLOG

Der Server notiert jeden Befehl, der länger braucht als slowlog-log-slower-than, und behält die letzten slowlog-max-len. Die Schwelle steht in Mikrosekunden —nicht in Millisekunden oder Sekunden wie bei anderen Engines—: Der Standardwert 10000 sind 10 ms. Eine negative Schwelle schaltet das Log aus, und 0 notiert jeden Befehl. Jeder Eintrag trägt seine Nummer, die Uhrzeit, die Dauer in Mikrosekunden, den Befehl mit seinen Argumenten —gekürzt, wenn sie lang sind— sowie Adresse und Namen des Clients.

Es ist ein Fenster, keine Historie: Beim Maximum von 128 verdrängt der 129. Eintrag den ältesten. Im Testbed füllt es sich mit den Skripten des Lastgenerators, die zwischen 50 und 250 ms brauchen. Und es misst die Zeit, die der Befehl im Server brauchte: Die Wartezeit in der Schlange und der Weg über das Netz zählen nicht. Calíope zeigt es unter „Langsame Befehle“.

Hin und zurück: die Pipeline

Jeder einzelne Befehl zahlt einen vollen Weg über das Netz. Gemessen gegen den Server des Testbeds über das Netz: 500 TYPE einzeln dauerten zwischen 1,6 und 2,1 Sekunden; dieselben 500 in einer Pipeline —alle gesendet, danach die Antworten gelesen— etwa 6 ms. Der Server hat dieselbe Arbeit getan; gespart wurde, 500-mal auf das Netz zu warten. So fragt Calíope Typ und Ablauf der Schlüssel jeder Seite ab.

Große Schlüssel

redis-cli --bigkeys durchläuft die Datenbank mit SCAN und nennt den größten Schlüssel jedes Typs. Im Testbed: eine Liste mit 500 Elementen, ein Set mit 300 Mitgliedern, ein Sorted Set mit 100 und ein Stream mit 50 Einträgen. MEMORY USAGE <Schlüssel> sagt, was einer belegt: das Set 16216 Bytes, die Liste 6208, ein Benutzer-Hash 168. Calíope summiert den Speicher nach Präfix unter „Speicher“.

Ein großer Schlüssel ist an sich nicht langsam; langsam ist, ihn ganz zu lesen. HGETALL, SMEMBERS, LRANGE 0 -1 und ZRANGE 0 -1 sind das KEYS der Collections: Sie liefern alles auf einmal und blockieren den Server, während sie es zusammenstellen. Vor dem Lesen fragt man die Größe ab —HLEN, SCARD, LLEN, ZCARD— und liest in Teilen mit HSCAN, SSCAN und ZSCAN oder abschnittsweise mit LRANGE.

Zwei Zahlen aus INFO stats

instantaneous_ops_per_sec berechnet der Server über die letzten Sekunden; im Testbed 13. Die Trefferquote ergibt sich aus keyspace_hits und keyspace_misses, die sich seit dem Start summieren: 5110 und 1040 im Testbed, also 83 %. Eine Zahl, die seit Wochen aufsummiert wird, sagt nicht, wie es heute läuft, und deshalb beschriftet Calíope sie so unter „Server“.

OBJECT FREQ

Mit einer LFU-Verdrängungsrichtlinie (allkeys-lfu oder volatile-lfu) führt Redis pro Schlüssel einen logarithmischen Zugriffszähler, und OBJECT FREQ liest ihn: Gemessen brachten 30 Lesevorgänge auf einen Hash ihn von 0 auf 7. Mit einer LRU-Richtlinie, der des Testbeds, antwortet es mit einem Fehler, der das sagt.

Stichwörter: redis, leistung, slowlog, langsame befehle, mikrosekunden, pipeline, bigkeys, memory usage, hgetall, smembers, lrange, ops pro sekunde, trefferquote, object freq, lfu

Transaktionen und Skripte

MULTI und EXEC, die Befehle bündeln, aber nichts zurücknehmen; WATCH als optimistische Sperre; EVAL und die Funktionen, und warum ein Skript den Server blockiert.

Gilt für: Redis 7.0+

Redis hat keine Transaktionen wie eine relationale Datenbank. Es hat zwei Wege, mehrere Befehle zusammen auszuführen, ohne dass sich etwas von einem anderen Client dazwischenschiebt: MULTI und Skripte.

MULTI und EXEC: zusammen, aber ohne Weg zurück

Nach MULTI führt der Server die Befehle nicht aus: Er antwortet auf jeden mit QUEUED und hebt sie auf. EXEC führt sie alle nacheinander aus, DISCARD verwirft sie. Gemessen im Testbed:

- Mit SET a 1, LPUSH a x und SET b 2 in der Warteschlange antwortete EXEC mit OK, WRONGTYPE und OK: Das LPUSH schlug fehl, und die beiden anderen wurden geschrieben. Es gibt kein Rollback: Ein Fehler bei der Ausführung nimmt nichts zurück.
- Ein Fehler, der sich beim Einreihen zeigt —ein Befehl mit falschen Argumenten—, verwirft dagegen die ganze Transaktion: EXEC antwortete mit EXECABORT und führte keinen aus.
- Nach DISCARD gab es den Schlüssel nicht.

WATCH <Schlüssel> vor MULTI ist eine optimistische Sperre: Ändert ein anderer Client diesen Schlüssel vor dem EXEC, führt das EXEC nichts aus und gibt null zurück. Gemessen: Als ein anderer Client den Schlüssel dazwischen schrieb, gab EXEC null zurück, und der Schlüssel behielt den Wert des anderen; ohne jemanden dazwischen wurde das INCR ausgeführt. Wer WATCH benutzt, wiederholt den Vorgang, wenn er null bekommt.

Eine Transaktion lebt auf einer einzigen Verbindung. Deshalb führt „Konsole“ weder MULTI noch EXEC, DISCARD, WATCH oder UNWATCH aus: Sie schickt jede Zeile über die gerade freie Verbindung, und die Transaktion könnte auf der einen offen bleiben, während ihr EXEC auf einer anderen landet.

Skripte: EVAL, EVALSHA und die Funktionen

EVAL führt ein Lua-Skript auf dem Server aus, vollständig und ohne Unterbrechung: Kein anderer Befehl läuft in der Zwischenzeit. So bündelt man mehrere Befehle aus „Konsole“. Schlüssel stehen getrennt von den Argumenten —EVAL "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 schlüssel 5—, weil der Server in einem Cluster wissen muss, zu welchem Slot es gehört.

SCRIPT LOAD speichert das Skript und gibt seinen SHA1-Hash zurück, und EVALSHA <Hash> führt es aus, ohne den Text erneut zu senden. Kennt der Server diesen Hash nicht, antwortet er mit NOSCRIPT, und man muss zurück zu EVAL. Die Funktionen von Redis 7 sind dasselbe mit Namen: FUNCTION LOAD lädt eine Bibliothek, FCALL ruft eine ihrer Funktionen auf, FUNCTION LIST listet sie auf und FUNCTION DELETE löscht sie. Gemessen: Eine Funktion, die eine Zahl verdoppelt, mit FCALL auf einen Schlüssel mit dem Wert 16 aufgerufen, gab 32 zurück.

Ein Skript blockiert den Server, solange es läuft

Gemessen im Testbed: Bei einem Skript, das 1,7 Sekunden lief, wartete ein PING von einer anderen Verbindung 1,65 Sekunden. Die Skripte des Lastgenerators brauchen je etwa 50 ms und füllen deshalb „Langsame Befehle“.

Nach 5 Sekunden (busy-reply-threshold, früher lua-time-limit genannt und unter diesem Namen weiterhin akzeptiert) hält das Skript nicht an: Der Server führt es weiter aus, antwortet aber auf alles andere mit BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE. Gemessen mit einer langen Schleife: Nach 6 Sekunden bekam PING ein BUSY; SCRIPT KILL antwortete mit OK, der Client des Skripts bekam ERR Script killed by user with SCRIPT KILL, und der nächste PING ein PONG.

SCRIPT KILL wirkt nur, wenn das Skript noch nichts geschrieben hat. Gemessen mit einem Skript, das vor der Schleife einen Schlüssel schrieb: Es antwortete mit UNKILLABLE, und das Skript lief bis zum Ende weiter. Dann bleibt nur Warten oder SHUTDOWN NOSAVE, das den Server ohne Speichern herunterfährt.

Stichwörter: redis, transaktionen, multi, exec, discard, watch, rollback, eval, evalsha, script load, function, fcall, lua, busy, script kill, lua-time-limit

Replicas und Cluster

Wie man eine Replica betrachtet und warum ihr Rückstand in Bytes zählt; die 16384 Slots, MOVED und ASK; was einen Cluster auf fail setzt; und warum es weder Sentinel noch einen Cluster über einen SSH-Tunnel gibt.

Gilt für: Redis 7.0+

Eine Replica, und wie man sie betrachtet

Eine Replica ist ein anderer Server, der alles kopiert, was sein Primary schreibt. Zur Replica macht man ihn mit REPLICAOF <Host> <Port>, und mit REPLICAOF NO ONE gibt man ihn frei. Wird ein Server zur Replica, verwirft er seine eigenen Daten und lädt die des Primarys; deshalb verlangt die Konsole von Calíope, den Namen des Befehls einzutippen, bevor er gesendet wird. ROLE sagt in einer Antwort, was jeder Server ist: master mit seinem Offset und seinen Replicas, oder slave mit seinem Primary und dem Zustand der Verbindung (connected). INFO replication erzählt alles.

Der Rückstand zählt in Bytes

Primary und Replica führen je einen Bytezähler des Replikationsstroms: der Primary master_repl_offset, die Replica slave_repl_offset. Die Differenz ist das, was die Replica noch anwenden muss. Im Cluster des Testbeds ist die Differenz im Leerlauf bei allen drei Replicas 0, und beide Zähler rücken etwa alle 10 Sekunden um 14 Bytes vor: Das ist der PING des Primarys an seine Replicas.

Der Primary zeigt für jede Replica außerdem einen offset und einen lag, und keiner von beiden ist der aktuelle Rückstand:

- offset ist das, was die Replica zuletzt bestätigt hat, und sie bestätigt einmal pro Sekunde. Gemessen: Nach dem Schreiben von 2 MB zeigte der Primary seine Replica eine Sekunde lang 2.000.070 Bytes im Rückstand, mit lag=0; beim nächsten Lesen war sie auf dem Stand.
- lag sind die Sekunden seit dieser letzten Bestätigung, nicht der Rückstand: Bei einer Replica auf dem Stand schwankt er zwischen 0 und 1.

Calíope liest den Zähler jeder Replica auf der Replica selbst und den des Primarys zweimal, um zu wissen, wie weit er sich von allein bewegt; darunter warnt es nicht. Dass eine Replica einem Primary folgt, sagt die gemeinsame master_replid, nicht der master_host, den die Replica meldet und der im Testbed eine Adresse im Docker-Netz ist (172.18.0.32), nicht die, die der Client benutzt. Ein Primary, der neu startet, beginnt mit einer neuen master_replid und bei 0: Bis seine Replica neu synchronisiert hat, bedeutet die Differenz nichts, und Calíope lässt den Rückstand ungemessen, statt zu behaupten, sie sei auf dem Stand.

min-replicas-to-write lässt den Primary Schreibvorgänge ablehnen, wenn er nicht mindestens so viele Replicas hat, die innerhalb von min-replicas-max-lag Sekunden bestätigen (Standard 10). Gemessen mit 1 auf dem Knoten des Testbeds ohne Replicas: SET antwortete mit NOREPLICAS Not enough good replicas to write., und Lesevorgänge liefen weiter. Im Testbed steht es auf 0, dem Standardwert.

Der Cluster: 16384 Slots

Ein Cluster verteilt die Schlüssel auf 16384 Slots, und jeder Primary besitzt einen Bereich; im Testbed 0-5460, 5461-10922 und 10923-16383, jeder mit einer Replica. Der Slot ergibt sich aus dem Schlüssel (CLUSTER KEYSLOT cl:a:1 → 10042) oder nur aus dem Teil in geschweiften Klammern, wenn der Schlüssel ein Tag trägt: {usuario:1}:perfil und {usuario:1}:carrito landen beide in 3014.

CLUSTER INFO liefert den Zustand (cluster_state), die abgedeckten Slots und die Zahl der Knoten; CLUSTER SHARDS liefert jeden Bereich mit seinem Primary und seinen Replicas und der Adresse, die jeder ankündigt; CLUSTER NODES sagt dasselbe in einer Zeile pro Knoten.

Ein Knoten, der nach einem Schlüssel gefragt wird, der nicht ihm gehört, antwortet mit MOVED <Slot> <Adresse>: Gemessen antwortete GET cl:a:1 auf dem Primary von 0-5460 mit MOVED 10042 10.100.37.234:7002. Calíope folgt dieser Umleitung und liest die Karte neu. ASK ist die Umleitung für einen Slot, der gerade verschoben wird: Sie gilt nur für diesen einen Befehl, ihr geht ein ASKING voraus, und die Karte ändert sich nicht. Ein Befehl mit Schlüsseln in verschiedenen Slots antwortet mit CROSSSLOT: Gemessen mit MGET cl:a:1 cl:a:2, Slots 10042 und 5977.

cluster_state:fail, gemessen

Der Cluster bedient nicht mehr, sobald ein Slot ohne Besitzer bleibt. Im Testbed (cluster-node-timeout von 5 Sekunden), beim Anhalten eines Primarys:

- etwa 5 Sekunden lang ändert sich nichts: Die anderen halten ihn noch nicht für ausgefallen;
- danach cluster_state:fail, bis seine Replica etwa 2 Sekunden später befördert wird und der Zustand wieder ok ist;
- als auch diese Replica angehalten wurde, blieben 5462 Slots ohne Besitzer, und der ganze Cluster antwortete mit CLUSTERDOWN The cluster is down, auch für die Schlüssel der beiden anderen Primarys. Das bewirkt cluster-require-full-coverage yes, der Standardwert.

Kommt er zurück, ist der alte Primary Replica des neuen, und ein CLUSTER FAILOVER auf ihm gibt ihm seinen Platz zurück. Calíope zeigt jeden Knoten, seine Rolle und seinen Rückstand unter „Topologie“.

Sentinel gibt es in Calíope nicht

Sentinel überwacht die Primarys und entscheidet über das Failover, speichert aber keine Daten und hat keine eigenen Replicas; deshalb liest Calíope seine Topologie nicht. Um einen Primary mit seinen Replicas und ihrem Rückstand zu sehen, öffnet man ein Profil, das auf den Primary zeigt.

Ein Cluster wird nicht über einen SSH-Tunnel geöffnet

Jeder Knoten des Clusters ist unter der Adresse erreichbar, die er ankündigt, und ein MOVED schickt den Client an diese Adresse. Ein SSH-Tunnel führt zu einem einzigen Knoten: Die Umleitungen würden den Tunnel zu Adressen verlassen, die von dort, wo man sitzt, vielleicht nicht erreichbar sind. Deshalb öffnet Calíope über einen Tunnel kein Profil, das sich als Cluster herausstellt: Es sagt das beim Verbinden, und der Fehler führt zu diesem Thema. Um ihn zu öffnen, entfernt man den Tunnel aus dem Profil und verbindet sich aus einem Netz, das jeden Knoten unter der Adresse erreicht, die er ankündigt.

Stichwörter: redis, replikation, replica, replicaof, role, rückstand, offset, lag, min-replicas-to-write, cluster, slots, moved, ask, crossslot, clusterdown, fail, sentinel, ssh-tunnel

Sicherheit und ACL

Wo der Server lauscht, der geschützte Modus, das Passwort und Benutzer mit Rechten (ACL), warum rename-command schlechter ist als eine ACL, TLS, und was von einem Passwort in SLOWLOG und MONITOR übrig bleibt.

Gilt für: Redis 7.0+

Ein frisch installierter Redis-Server fragt niemanden nach einem Passwort. Vier Schichten schützen ihn, von außen nach innen: wo er lauscht, der geschützte Modus, wer jeder Benutzer ist und was er darf, und ob die Verbindung verschlüsselt ist. Alles in diesem Thema wurde im Testbed gemessen (Redis 7.4.11).

Wo er lauscht: bind und der geschützte Modus

bind legt fest, auf welchen Adressen er Verbindungen annimmt; das Testbed lauscht auf allen (* -::*). Hat der Benutzer default außerdem kein Passwort, lässt der geschützte Modus (protected-mode yes, so wie Redis ausgeliefert wird) nur Verbindungen über die lokale Schnittstelle zu. Gemessen mit so einem Server: von 127.0.0.1 antwortete PING mit PONG; von der Netzadresse des Containers mit DENIED Redis is running in protected mode…. Das ist ein Sicherheitsnetz für den Server, den niemand konfiguriert hat, keine Konfiguration: Mit einem Passwort greift es nicht mehr.

Passwort und Benutzer: requirepass und ACL

requirepass ist das Passwort des Benutzers default, des einzigen, den es gibt, wenn niemand weitere anlegt. Seit Redis 6 gibt es benannte Benutzer (ACL), jeder mit eigenen Befehlen, Schlüsseln und Kanälen. ACL LIST zählt sie auf: Im Testbed gibt es nur default, mit ~* (alle Schlüssel), &* (alle Kanäle) und +@all (alle Befehle). Ohne Passwort erscheint derselbe Benutzer als nopass, und wer den Port erreicht, ist er. ACL WHOAMI sagt, als welcher Benutzer du verbunden bist.

Ein Benutzer wird mit ACL SETUSER angelegt. Gemessen:

ACL SETUSER lector on >lectorpass ~app:* &canal:* -@all +@read +ping +acl|whoami

- GET app:x antwortete, leer, weil der Schlüssel nicht existierte.
- 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: Das Muster ~app:* begrenzt die Schlüssel.
- PUBLISH otro 1 → NOPERM: Diesen Befehl hatte er nicht.

Rechte gehen nach Kategorien: ACL CAT zählt einundzwanzig auf (read, write, admin, dangerous, blocking…), und die der gefährlichen (ACL CAT dangerous) enthält 75 Befehle. ACL LOG hält jede Ablehnung fest, mit dem Benutzer, dem Abgelehnten und dem Client: Nach den drei oben hatte es drei Einträge, mit reason command oder key. ACL DELUSER löscht einen Benutzer, und ACL LOG RESET leert das Protokoll.

rename-command, und warum es schlechter ist als eine ACL

rename-command benennt einen Befehl beim Start des Servers um, und mit "" entfernt es ihn. Gemessen mit einem Server, der mit --rename-command FLUSHALL "" und --rename-command CONFIG CFG_7f3a gestartet wurde: FLUSHALL und CONFIG GET antworteten mit ERR unknown command, und CFG_7f3a GET maxmemory funktionierte. Das gilt für alle Benutzer zugleich, ändert sich nur mit einem Neustart, und jedes Werkzeug, das den echten Namen benutzt, funktioniert nicht mehr, Calíope eingeschlossen. Mit einer ACL nimmst du einem Benutzer, was er nicht darf, und alle anderen machen weiter wie bisher.

TLS

Ein Server mit TLS lauscht auf tls-port, und mit port 0 schließt er den unverschlüsselten Port. Der Knoten redis-tls des Testbeds startet so, auf 6380. Gemessen: auf 6379 Connection refused; auf 6380 ohne TLS I/O error; mit TLS, aber ohne das Zertifikat der Zertifizierungsstelle, certificate verify failed; mit der Zertifizierungsstelle des Testbeds PONG. Das Zertifikat des Servers nennt localhost, 127.0.0.1 und die IP des Testbed-Rechners: Verbindest du dich mit einem Namen, der dort nicht steht, schlägt die Prüfung genauso fehl. Calíope prüft das Zertifikat mit der Zertifizierungsstelle, die im Profil steht.

Das Passwort in SLOWLOG und MONITOR

Redis verdeckt die Argumente von AUTH und von HELLO … AUTH. Gemessen mit slowlog-log-slower-than 0, damit alles aufgezeichnet wird: Der Eintrag von AUTH im SLOWLOG enthielt (redacted) statt des Passworts. Ein gleichzeitig geöffnetes MONITOR sah "AUTH" "(redacted)" und "HELLO" "3" "AUTH" "(redacted)" "(redacted)". Calíope macht es in „Befehlsprotokoll“ genauso: Ein AUTH bleibt als AUTH **** stehen.

Stichwörter: redis, sicherheit, acl, benutzer, rechte, requirepass, auth, noperm, protected-mode, bind, rename-command, tls, zertifikat, slowlog, monitor, passwort

Serverkonfiguration

CONFIG GET, CONFIG SET und CONFIG REWRITE; was einen Neustart überlebt und was nicht; die Einstellungen, die man zuerst ansieht, INFO server und INFO stats, und warum DEBUG ausgeschaltet ist.

Gilt für: Redis 7.0+

Ein Redis-Server nimmt seine Konfiguration aus einer Datei (redis.conf), aus den Argumenten, mit denen er gestartet wird, oder aus beidem, und die Befehlszeile hat Vorrang vor der Datei. Die Knoten des Testbeds (Redis 7.4.11) starten nur mit Argumenten —redis-server --requirepass … --appendonly yes …— und ohne Datei: INFO server zeigt ein leeres config_file:.

CONFIG GET und CONFIG SET

CONFIG GET <Einstellung> liest den Wert, den der Server gerade benutzt, und CONFIG SET <Einstellung> <Wert> ändert ihn ohne Neustart. Nicht alle lassen sich im Betrieb ändern: gemessen, CONFIG SET databases 32 antwortete mit ERR CONFIG SET failed (possibly related to argument 'databases') - can't set immutable config.

Was einen Neustart überlebt

Ein CONFIG SET lebt im Speicher des Servers. Gemessen im Testbed, das keine Datei hat: CONFIG SET timeout 300 → OK; CONFIG REWRITE → ERR The server is running without a config file; und nach dem Neustart des Containers stand timeout wieder auf 0, wie es die Argumente sagen.

Mit Datei speichert CONFIG REWRITE es tatsächlich. Gemessen mit einem Server, der aus einer dreizeiligen Datei gestartet wurde: Nach CONFIG SET timeout 300 und CONFIG REWRITE endete die Datei mit einem Abschnitt # Generated by CONFIG REWRITE mit timeout 300, aber auch mit dem, was auf der Befehlszeile übergeben worden war (daemonize yes, das logfile), und mit dem Benutzer default. Neu gestartet stand timeout auf 300. CONFIG REWRITE speichert nicht nur, was du gerade geändert hast: Es schreibt die Datei mit allem neu, womit der Server gerade läuft.

Die Einstellungen, die man zuerst ansieht

Die Werte sind die des Testbeds, also die Standardwerte von Redis, außer wo anders vermerkt:

EinstellungWertWas sie ist
databases16Nummerierte Datenbanken, 0 bis 15. In einem Cluster 1
maxmemory0Keine Speichergrenze
maxmemory-policyallkeys-lruWas beim Erreichen der Grenze gelöscht wird. Redis wird mit noeviction ausgeliefert, und das hat redis-tls
maxclients10000Gleichzeitige Verbindungen
timeout0Sekunden ohne Aktivität, nach denen eine Verbindung geschlossen wird; 0 heißt nie
tcp-keepalive300Wie oft, in Sekunden, geprüft wird, ob die Gegenseite noch da ist
enable-debug-commandnoDEBUG ausgeschaltet

INFO server und INFO stats: der erste Blick

INFO server nennt die Version (redis_version), den Modus (redis_mode:standalone auf einem eigenständigen Server), den Port, die Laufzeit und die Konfigurationsdatei. INFO stats zählt, was seit dem Start passiert ist: total_connections_received, total_commands_processed, instantaneous_ops_per_sec, rejected_connections (die abgelehnten, weil maxclients überschritten war), expired_keys und evicted_keys (abgelaufene und wegen Speichermangels verdrängte Schlüssel), keyspace_hits und keyspace_misses, und total_error_replies. Gemessen auf dem Knoten redis zu einem beliebigen Zeitpunkt: 207.832 Treffer, 3.188 Fehlgriffe, keine abgelehnte Verbindung und kein verdrängter Schlüssel. „Server“ zeigt das Wesentliche davon und liest nur.

DEBUG, und warum es nicht in „Konsole“ ist

DEBUG hat Testbefehle, die den Server anhalten (DEBUG SLEEP) oder abstürzen lassen (DEBUG SEGFAULT). Seit Redis 7.0 ist es ausgeschaltet. Gemessen: DEBUG SLEEP 0 antwortete mit 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. „Konsole“ führt es nicht aus, auch wenn der Server es eingeschaltet hat.

Stichwörter: redis, konfiguration, config get, config set, config rewrite, redis.conf, databases, maxmemory, maxclients, timeout, tcp-keepalive, enable-debug-command, info server, info stats, debug

Häufige Fehler

Was jedes Fehlerpräfix von Redis bedeutet —WRONGTYPE, NOAUTH, NOPERM, OOM, READONLY, MOVED, CROSSSLOT und die übrigen—, was es im Testbed ausgelöst hat, und was zu tun ist.

Gilt für: Redis 7.0+

Eine Fehlerantwort von Redis beginnt mit einem Wort in Großbuchstaben, das sagt, welcher Art sie ist —WRONGTYPE, NOAUTH…—, oder, wenn es kein genaueres gibt, mit ERR. Es ist eine Antwort des Servers, kein Verbindungsfehler: Die Verbindung bleibt bestehen, und der nächste Befehl geht genauso durch. Calíope zeigt sie so, wie sie kommt: in „Konsole“, in Rot, mit einem Symbol und dem Präfix davor. Die Zugriffsfehler beim Verbinden (NOAUTH, WRONGPASS) landen in „Fehler“ als Zugriff verweigert.

Alle in dieser Tabelle wurden im Testbed ausgelöst (Redis 7.4.11):

PräfixWas ihn ausgelöst hatWas zu tun ist
WRONGTYPELPUSH auf einen Schlüssel, der ein String istDen Typ mit TYPE prüfen: Jeder Typ hat seine Befehle
NOAUTHEin Befehl ohne vorheriges AUTH, auf einem Server mit PasswortDas Passwort im Profil eintragen
WRONGPASSAUTH mit einem falschen PasswortBenutzer und Passwort prüfen; ACL LIST sagt, ob der Benutzer on ist
NOPERMEin ACL-Benutzer ohne den Befehl (SET) oder ohne den SchlüsselACL LOG sagt, was abgelehnt wurde
OOMSET mit maxmemory 1 und der Richtlinie noevictionSpeicher freigeben, maxmemory erhöhen oder eine Richtlinie wählen, die verdrängt
READONLYSET auf einem Server, der mit REPLICAOF zum Replikat gemacht wurdeAuf den Primärserver schreiben
MASTERDOWNJeder Befehl, auch PING, auf einem Replikat ohne Verbindung zu seinem Primärserver und mit replica-serve-stale-data noDie Verbindung wiederherstellen, oder das Replikat mit dem antworten lassen, was es hat
NOREPLICASSET mit min-replicas-to-write 1 und keinem ReplikatDie Replikate wiederherstellen, oder die Einstellung senken
LOADINGPING, während der Server beim Start 1,5 Millionen Schlüssel lud, etwa 4 SekundenWarten: Er antwortet, sobald er fertig ist
BUSYJeder Befehl, während ein Skript schon länger als 5 Sekunden liefSCRIPT KILL, wenn das Skript nichts geschrieben hat; wenn doch, warten
MOVEDGET auf einem Cluster-Knoten, dem der Slot des Schlüssels nicht gehörtNichts: Calíope wiederholt den Befehl auf dem richtigen Knoten
ASKEin Schlüssel in einem Slot, der gerade zwischen zwei Knoten wandertNichts: Calíope wiederholt ihn auf dem anderen Knoten mit ASKING
CROSSSLOTMGET zweier Schlüssel aus verschiedenen SlotsGib ihnen ein gemeinsames Tag in geschweiften Klammern, etwa {usuario:1}
CLUSTERDOWNJeder Befehl, wenn ein Primärserver und sein Replikat gleichzeitig ausgefallen sindDie Knoten wieder hochfahren: Mit cluster-require-full-coverage yes antwortet keiner, wenn ein Slot fehlt
ERREin Befehl, den es nicht gibt (FOO bar), oder INCR auf TextDen Rest der Meldung lesen: Dort steht der Grund

In einem Cluster antwortet ein Schreibversuch auf einem Replikat nicht mit READONLY, sondern mit MOVED und der Adresse des Primärservers des Slots.

Die Meldungen im Wortlaut

So hat der Server geantwortet. Sie sind in jeder Sprache englisch, weil Redis sie schreibt:

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 und ASK sind nicht deine Fehler

In einem Cluster lebt jeder Schlüssel in einem der 16384 Slots, und jeder Slot auf einem Primärserver. Ein Knoten, dem er nicht gehört, antwortet mit MOVED, dem Slot und der Adresse dessen, dem er gehört, und Calíope wiederholt den Befehl dort und liest die Cluster-Karte neu. ASK sagt dasselbe, während der Slot von einem Knoten zum anderen wandert, und dann wird die Karte nicht neu gelesen, weil sie sich noch nicht geändert hat. In „Konsole“ sieht man keins von beiden: Es kommt die Antwort des richtigen Knotens.

Stichwörter: redis, fehler, wrongtype, noauth, wrongpass, noperm, oom, readonly, masterdown, noreplicas, loading, busy, moved, ask, crossslot, clusterdown, err

Was man nicht tun sollte

KEYS in der Produktion, eine ganze Sammlung lesen, ein riesiger Schlüssel, ein offenes MONITOR, SAVE, FLUSHALL, eine Datenbank pro Schema, DEBUG und Warten ohne Zeitgrenze: was jedes kostet, gemessen, und was Calíope nicht ausführt.

Gilt für: Redis 7.0+

Redis führt Befehle einen nach dem anderen aus, in einem einzigen Thread: Was einer dauert, darauf wartet jeder andere Client. Fast alles auf dieser Liste läuft darauf hinaus. Die Zahlen wurden im Testbed gemessen (Redis 7.4.11), mit einer Million Testschlüsseln, die danach gelöscht wurden.

KEYS *

KEYS geht alle Schlüssel auf einmal durch. Bei einer Million brauchte KEYS nada:* —das keinen findet— 244 ms auf dem Server, und ein gleichzeitig geschicktes PING wartete zwischen 220 und 280 ms. Dasselbe mit SCAN … COUNT 1000 waren tausend Aufrufe zu je 0,46 ms: dieselbe Arbeit, in Stücken, zwischen denen die Befehle der anderen durchkommen. „Konsole“ führt KEYS nicht aus, und „Schlüssel“ geht mit SCAN durch.

Eine ganze Sammlung lesen, oder ein riesiger Schlüssel

HGETALL, SMEMBERS, LRANGE 0 -1 und ZRANGE 0 -1 sind das KEYS eines einzelnen Schlüssels. Gemessen mit einem Hash aus 500.000 Feldern, 32 MB laut MEMORY USAGE: HGETALL brauchte 1,4 s, bis es ankam, und ein gleichzeitig geschicktes PING wartete 425 ms. Man fragt stattdessen zuerst nach der Größe (HLEN, SCARD, LLEN, ZCARD) und liest in Teilen mit HSCAN, SSCAN, ZSCAN oder LRANGE abschnittsweise. Das macht die Ansicht in „Schlüssel“, je 100.

Löschen kostet auch: DEL dieses Hashs brauchte 516 ms auf dem Server, und ein PING wartete 557 ms; UNLINK eines gleichen brauchte 15 µs, weil es den Speicher im Hintergrund freigibt. „Schlüssel“ löscht mit UNLINK.

Ein offenes MONITOR

MONITOR schickt dem, der es anfordert, jeden Befehl, den der Server bekommt, von allen Clients, und bleibt offen, bis der Anfordernde es schließt. Gemessen mit redis-benchmark und 100.000 GET: ohne alles 20.417 pro Sekunde bei einem Median von 1,46 ms; mit einem offenen MONITOR 17.634 pro Sekunde und 1,86 ms. „Konsole“ führt es nicht aus: Sie hätte keine Möglichkeit, es anzuhalten.

SAVE statt BGSAVE

SAVE schreibt den Dump im selben Thread wie die Befehle, BGSAVE in einem eigenen Prozess. Gemessen mit einer Million Schlüsseln: SAVE brauchte 2,5 s, und ein PING wartete diese 2,5 s; mit BGSAVE antwortete das PING sofort, und der Dump war nach 3 s fehlerfrei fertig.

FLUSHALL und FLUSHDB ohne nachzudenken

FLUSHALL leert alle Datenbanken des Servers, nicht nur die ausgewählte: gemessen, mit Schlüsseln in den Datenbanken 0 und 2 war INFO keyspace danach leer. Es gibt keinen Papierkorb und kein Zurück. „Konsole“ führt sie nur aus, wenn du den Namen des Befehls in einer Bestätigung eintippst, die Server und Datenbank nennt.

Eine Datenbank pro „Schema“

Nummerierte Datenbanken sind keine Schemas und kein Weg, Anwendungen zu trennen: Sie teilen sich Speicher, Passwort und FLUSHALL, und mit allkeys-lru und wenig Speicher verdrängte der Server Schlüssel aus einer anderen Datenbank als der, die gerade gefüllt wurde. In einem Cluster gibt es außerdem nur die 0: gemessen, SELECT 1 antwortete mit ERR SELECT is not allowed in cluster mode, und CONFIG GET databases mit 1. Um Daten zu trennen, ein Präfix im Schlüsselnamen (app:, cola:) oder ein eigener Server. „Konsole“ führt SELECT nicht aus: Die Datenbank wählt der Selektor des Werkzeugs.

DEBUG SLEEP und Co.

DEBUG SLEEP hält den Server an, und DEBUG SEGFAULT lässt ihn abstürzen. Seit Redis 7.0 ist DEBUG ausgeschaltet —gemessen: ERR DEBUG command not allowed…—, und „Konsole“ führt es auch eingeschaltet nicht aus.

Ohne Zeitgrenze auf Daten warten

BLPOP, BRPOP, BLMOVE, BZPOPMIN, XREAD … BLOCK und WAIT warten, bis etwas ankommt, und mit einer Wartezeit von 0 warten sie ohne Ende. Solange sie warten, taugt ihre Verbindung zu nichts anderem: gemessen, ein BLPOP cola 0 stand nach 3 s noch mit flags=b in CLIENT LIST, bis ein CLIENT UNBLOCK es löste; ein BLPOP cola 1 kam nach einer Sekunde leer zurück. Die Verbindungen von „Konsole“ sind die, die alle Werkzeuge der Sitzung nutzen, daher führt die Konsole so einen Befehl mit einer Wartezeit von 0 oder von mehr als 10 Sekunden nicht aus (mehr als 10000 Millisekunden bei XREAD BLOCK, WAIT und WAITAOF).

Was „Konsole“ nicht ausführt, und warum

- KEYS: geht alle Schlüssel auf einmal durch.
- MONITOR: bremst den Server, und die Konsole könnte es nicht schließen.
- SUBSCRIBE, PSUBSCRIBE und SSUBSCRIBE: Die Nachrichten kämen mit den Antworten vermischt an.
- SHUTDOWN: fährt den Server herunter.
- DEBUG: hält den Server an oder lässt ihn abstürzen.
- RESET, HELLO, CLIENT REPLY, SYNC und PSYNC: ändern das Protokoll der Verbindung, die Calíope gerade nutzt.
- SELECT: Die Oberfläche würde eine Datenbank zeigen und eine andere lesen.
- MULTI, EXEC, DISCARD, WATCH und UNWATCH: Eine Transaktion lebt auf einer einzigen Verbindung.
- Befehle, die auf Daten warten, mit einer Wartezeit von 0 oder von mehr als 10 Sekunden.

Und vor FLUSHDB, FLUSHALL, CLUSTER RESET, CLUSTER FAILOVER, REPLICAOF und SLAVEOF verlangt sie, den Namen des Befehls einzutippen. Diese Liste stammt aus dem Code von Calíope: Ändert sie sich, ändert sich dieses Thema mit.

Stichwörter: redis, best practices, keys, scan, hgetall, großer schlüssel, del, unlink, monitor, save, bgsave, flushall, flushdb, select, datenbanken, debug, blpop, blockierend, konsole

Typen: die Affinität, und warum eine Spalte nichts erzwingt

Der Typ steckt im Wert, nicht in der Spalte: die fünf Affinitäten und was jede umwandelt, die Ordnung zwischen den Speicherklassen, was STRICT tatsächlich verhindert, und warum NOCASE nichts von Akzenten weiß.

Gilt für: SQLite 3.35+

In SQLite gehört der Typ zum Wert, nicht zur Spalte. Was eine Spalte deklariert, ist eine Affinität: eine Vorliebe, die beim Speichern angewandt wird, die umwandelt, wenn sie kann, und durchlässt, wenn sie nicht kann.

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

Da stehen die fünf in einer Zeile. INTEGER und REAL wandeln Text um, der wie eine Zahl aussieht; TEXT wandelt die Zahl in Text; NUMERIC sieht sich den Wert an und entscheidet, in derselben Spalte stehen also ein integer und ein real; und BLOB ist die ohne Affinität: sie speichert, was man ihr gibt, so wie es kam.

Es gibt fünf Speicherklassen, und sie sind geordnet

Eine Spalte ohne deklarierten Typ ist erlaubt und nimmt alle fünf, in einer Spalte können also ein Integer, ein Real, ein Text, ein Blob und ein Null liegen. Und man kann sie sortieren, denn zwischen den Klassen gibt es eine feste Ordnung: zuerst die Nulls, dann die Zahlen, dann der Text und zuletzt die Blobs. Das heißt, ein ORDER BY über eine schmutzige Spalte scheitert nicht: es gruppiert nach Typ, ohne es jemandem zu sagen.

CREATE TABLE libre (v);            -- ohne deklarierten Typ: geht
INSERT INTO libre VALUES (1), (1.5), ('hola'), (x'0001'), (NULL);

SELECT typeof(v) FROM libre ORDER BY v;
-- null | integer | real | text | blob   <- und das ist die Reihenfolge zwischen den Klassen

STRICT verhindert weniger, als es scheint

Seit 3.37 kann eine Tabelle als STRICT deklariert werden, dann nimmt sie nur eine Handvoll Typen — INT, INTEGER, REAL, TEXT, BLOB und ANY — und lehnt ab, was sie nicht speichern kann. Aber sie wandelt weiterhin um: eine '42' geht in eine INTEGER-Spalte, weil nichts verloren geht, und eine 42 geht in eine TEXT-Spalte und wird als '42' abgelegt. Abgelehnt wird, wofür es keine Umwandlung gibt. Und es gibt einen unerwarteten Gewinn: ein erfundener Typ, den eine normale Tabelle stillschweigend annimmt, wird hier schon beim Anlegen abgelehnt.

CREATE TABLE s (i INTEGER, x TEXT) STRICT;

INSERT INTO s VALUES ('42', 'a');   -- geht durch: 42, verlustfrei umwandelbar
INSERT INTO s VALUES (1, 42);       -- geht durch: 42 wird als Text '42' gespeichert
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"

Kein Datum, kein Boolescher Wert, und die Kollation weiß nichts von Akzenten

TRUE ist ein integer mit dem Wert 1. Ein Datum ist das, wofür du dich entscheidest: date() liefert text und julianday() liefert real, und was du wählst, wirst du für den Rest seines Lebens sortieren und vergleichen. Und es gibt nur drei Kollationen — BINARY, NOCASE und RTRIM: NOCASE gleicht Groß- und Kleinschreibung des ASCII an und sonst nichts, café und CAFÉ sind also verschiedene Werte, und upper('café') liefert CAFé. Der Text ist sehr wohl UTF-8: length zählt Zeichen, über dem Blob zählt es Bytes.

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

Stichwörter: Typen, Affinität, typeof, INTEGER, REAL, TEXT, BLOB, NUMERIC, dynamische Typisierung, STRICT, Boolescher Wert, Datum, julianday, COLLATE, NOCASE, UTF-8, Speicherklasse

Transaktionen: ein Schreiber, und die 5, die es beweist

Die Isolation ist serialisierbar, weil nur einer schreibt: die drei BEGIN-Modi, der SQLITE_BUSY 5 und warum busy_timeout ihn löst, die 517, die er nicht löst, und was der WAL-Modus wirklich ändert.

Gilt für: SQLite 3.35+

SQLite hat keine Isolationsstufen zur Auswahl, und das ist keine Lücke: die Isolation ist serialisierbar, weil in der ganzen Datenbank immer nur einer schreibt. Alles Weitere folgt daraus.

Zwei Einstellungen regeln es, und beide kommen ab Werk mit dem denkbar schlechtesten Wert.

PRAGMA journal_mode;    -- delete: der Werkszustand, nicht WAL
PRAGMA busy_timeout;    -- 0: wartet überhaupt nicht

PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 3000;

Die drei BEGIN-Modi

DEFERRED — der Standard — nimmt nichts, bis es sein muss: das erste Lesen nimmt eine Momentaufnahme, das erste Schreiben fordert die Sperre an. IMMEDIATE fordert die Schreibsperre sofort an, in der BEGIN-Zeile selbst. EXCLUSIVE verlangt zusätzlich, dass niemand liest, und im WAL-Modus tut es kaum mehr als IMMEDIATE. Die praktische Regel: wenn die Transaktion schreiben wird, BEGIN IMMEDIATE; es kostet vorn eine Wartezeit und erspart den lästigsten Fehler von SQLite, den weiter unten.

SAVEPOINT ist die Zwischenmarke, und ROLLBACK TO kehrt dorthin zurück, ohne die Transaktion zu schließen.

BEGIN;                          -- DEFERRED, der Standard
UPDATE t SET v = 10 WHERE i = 1;
SAVEPOINT s1;
UPDATE t SET v = 20 WHERE i = 1;
ROLLBACK TO s1;                 -- macht bis hierher rückgängig, schließt die Transaktion NICHT
SELECT v FROM t WHERE i = 1;    -- 10
RELEASE s1;
COMMIT;

SQLITE_BUSY ist die 5, und sie ist fast immer die eigene

Wenn ein anderer die Schreibsperre hält, lautet die Antwort SQLITE_BUSY mit dem Code 5 und dem Text „database is locked“. Das ist kein Defekt: es ist die Schlange vor einer einspurigen Ressource. Zum Defekt macht es, dass busy_timeout ab Werk 0 ist, unangetastet kommt die Antwort also sofort und trocken. Mit 800 ms wartete derselbe Aufruf 895, bevor er aufgab.

# zwei Verbindungen gleichzeitig, mit timeout=0, damit sofort geantwortet wird
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,)  <- liest den vorherigen Wert
a.execute("BEGIN IMMEDIATE")             # SQLITE_BUSY (5) database is locked

a.execute("PRAGMA busy_timeout=800")
a.execute("BEGIN IMMEDIATE")             # wartet 895 ms und liefert wieder 5

Die, die busy_timeout nicht behebt: die 517

Wenn eine DEFERRED-Transaktion liest und dann schreiben will und dazwischen jemand bestätigt hat, taugt die beim Lesen genommene Momentaufnahme nicht mehr, und SQLite liefert SQLITE_BUSY_SNAPSHOT, die 517. Warten hilft nicht: niemand wird diese Aufnahme zurückgeben. Der einzige Ausweg ist ROLLBACK und noch einmal von vorn — oder besser: mit IMMEDIATE geöffnet zu haben.

-- A:
BEGIN DEFERRED;
SELECT v FROM t WHERE i = 1;    -- hier behält A eine Momentaufnahme der Datenbank

-- B, in einer anderen Verbindung:
BEGIN IMMEDIATE; UPDATE t SET v = 5 WHERE i = 2; COMMIT;

-- A, die jetzt schreiben will:
UPDATE t SET v = 2 WHERE i = 1;
-- SQLITE_BUSY_SNAPSHOT (517), und busy_timeout hilft nicht
ROLLBACK;                       -- der einzige Ausweg: loslassen und neu anfangen

Was WAL ändert und was nicht

Mit dem Rollback-Journal liest, während einer schreibt, niemand. Mit journal_mode = WAL lesen die Leser weiterhin die letzte bestätigte Fassung, während der Schreiber arbeitet: gemessen mit zwei Verbindungen, der Leser bekam den vorigen Wert, ohne einen Augenblick zu blockieren. Was sich nicht ändert, ist die Zahl der Schreiber: weiterhin einer, und der zweite bekommt weiterhin eine 5.

Stichwörter: Transaktion, BEGIN, DEFERRED, IMMEDIATE, EXCLUSIVE, SAVEPOINT, ROLLBACK TO, SQLITE_BUSY, 5, 517, BUSY_SNAPSHOT, busy_timeout, WAL, journal_mode, Sperre, serialisierbar

Pragmas: die der Datei, die der Verbindung und die Befehle

Ein Pragma ist nicht eine einzige Sache: manche werden in die Datei geschrieben, manche halten so lange wie die Verbindung, und manche sind Befehle. Was wovon ist, die gemessenen Werkseinstellungen, und das eine, das aus ist und nicht sein sollte.

Gilt für: SQLite 3.35+

SQLite hat keine Konfigurationsdatei: es hat Pragmas. Und die erste Verwirrung, die man loswerden muss, ist, dass sie nicht alle dasselbe sind. Manche werden in die Datei geschrieben und gelten für jeden, der sie später öffnet; manche halten so lange wie die Verbindung und müssen jedes Mal wiederholt werden; und manche sind gar keine Einstellungen, sondern Befehle, die etwas tun und fertig.

Dies sind die Werkseinstellungen, aus einer frisch angelegten Datenbank gelesen.

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

Das eine, das aus ist und kaum jemand erwartet

foreign_keys steht auf 0. Fremdschlüssel werden deklariert, im Schema abgelegt, tauchen im CREATE TABLE auf … und werden nicht geprüft. Ein verwaistes Kind geht ohne einen Mucks hinein. Das Einschalten ist eine Zeile, aber es gehört zur Verbindung: es muss in jeder gesetzt werden, und das Einschalten blickt nicht zurück — dafür gibt es foreign_key_check, das aufzählt, was schon durchgerutscht ist.

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);   -- geht durch: es gibt keinen Elterndatensatz 999, und es ist egal

PRAGMA foreign_keys = ON;
INSERT INTO hijo VALUES (2, 999);   -- FOREIGN KEY constraint failed

PRAGMA foreign_key_check;           -- hijo | 1 | padre | 0

Was bleibt und was nicht

journal_mode und user_version werden in den Dateikopf geschrieben und überleben Schließen und Öffnen. page_size und auto_vacuum auch, aber nur, wenn sie vor der ersten Tabelle gesetzt werden: auf einer Datenbank, die schon Seiten hat, werden sie ohne Fehler angenommen und ändern nichts, und das wurde in beiden Richtungen geprüft. foreign_keys, cache_size, busy_timeout und mmap_size gehören zur Verbindung und fallen auf ihren Werkswert zurück, sobald eine andere geöffnet wird.

-- sie bleiben in der Datei gespeichert:
PRAGMA journal_mode = WAL;
PRAGMA user_version = 7;
PRAGMA page_size    = 8192;   -- nur in einer noch LEEREN Datenbank
PRAGMA auto_vacuum  = FULL;   -- idem

-- sie gehören zur Verbindung und müssen in jeder wiederholt werden:
PRAGMA foreign_keys = ON;
PRAGMA cache_size   = -8000;  -- negativ sind es Kibibyte, keine Seiten
PRAGMA busy_timeout = 3000;

Ein Detail, das verwirrt: nach dem Setzen von journal_mode = WAL stand synchronous auf 1 (NORMAL), ohne dass jemand es angefasst hätte. Das ist Absicht — in WAL reicht NORMAL, um nichts Bestätigtes zu verlieren — aber es zeigt, dass das Lesen eines Pragmas nicht verrät, woher dieser Wert stammt.

Und die, die Befehle sind

wal_checkpoint gießt das Journal in die Datenbank und lässt es mit TRUNCATE bei null Bytes: gemessen wurde ein -wal von 4 716 016 Bytes, das auf 0 fiel. ANALYZE füllt sqlite_stat1 mit dem, was der Planer zur Indexwahl heranzieht. Und PRAGMA optimize ist das, was man beim Schließen einer langlebigen Verbindung laufen lassen sollte: es sieht nach, welche Tabellen sich genug verändert haben, und stößt das nötige ANALYZE an, ohne etwas zu sagen.

PRAGMA wal_autocheckpoint;         -- 1000 paginas
PRAGMA wal_checkpoint(TRUNCATE);   -- 0 | 0 | 0, und die -wal bleibt bei 0 Bytes

ANALYZE;
SELECT * FROM sqlite_stat1;        -- t | iv | 20000 20000
PRAGMA optimize;                   -- liefert nichts

Stichwörter: Pragma, Konfiguration, journal_mode, WAL, synchronous, foreign_keys, cache_size, mmap_size, auto_vacuum, page_size, user_version, wal_checkpoint, optimize, ANALYZE, sqlite_stat1

Indizes: es gibt nur den B-Baum, und drei Wörter im Plan

SCAN, SEARCH und COVERING sind das ganze Vokabular von EXPLAIN QUERY PLAN. Partielle und Ausdrucksindizes und die Bedingung, die man wiederholen muss, damit sie greifen, was WITHOUT ROWID spart und was ANALYZE schreibt.

Gilt für: SQLite 3.35+

In SQLite gibt es nur eine Art von Index: den B-Baum. Kein Hash, kein Bitmap, nichts zu wählen. Die Wortsuche gibt es, aber sie ist kein Index: sie heißt FTS5 und ist eine eigene virtuelle Tabelle. Das vereinfacht das ganze Thema, denn die einzige verbleibende Entscheidung ist, über welche Spalten und in welcher Reihenfolge.

Und geprüft wird mit EXPLAIN QUERY PLAN, dessen Vokabular in drei Wörter passt: SCAN heißt die ganze Tabelle lesen, SEARCH heißt über einen Index hineingehen, und COVERING heißt, dass die Zeile gar nicht angefasst wurde.

-- pedidos(id, cliente, estado, total, correo) mit 20 000 Zeilen

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=?)

Der zusammengesetzte wird über Präfixe gelesen, wie in jeder Maschine: (cliente, estado) taugt für cliente allein und für beide zusammen, aber nicht für estado allein.

Abdeckend

Trägt der Index alle Spalten, die die Abfrage liest, wird die Zeile nicht angefasst. Es ist derselbe Index wie vorher: was sich ändert, ist das, was verlangt wird.

EXPLAIN QUERY PLAN
  SELECT cliente, estado FROM pedidos WHERE cliente = 42;
-- SEARCH pedidos USING COVERING INDEX i_cli_est (cliente=?)

Partiell und Ausdruck: beide muss man wiederholen

Ein partieller Index indiziert nur die Zeilen, die eine Bedingung erfüllen, und braucht deshalb wenig Platz. Der Preis ist, dass die Abfrage diese Bedingung wiederholen muss, Wort für Wort, sonst kann der Planer ihn nicht nutzen: ohne sie ist dieselbe Abfrage wieder ein SCAN. Genauso beim Ausdrucksindex: er indiziert lower(correo), also muss lower(correo) im WHERE stehen; mit bloßem correo nützt er gar nichts.

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   <- ohne den Filter zu wiederholen, gibt es den Index nicht

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 nimmt eine Umleitung weg

Eine normale Tabelle legt ihre Zeilen unter einer verborgenen rowid ab, ihr Primärschlüssel ist also ein weiterer Index, der die Zeile anschließend holen muss. Mit WITHOUT ROWID ist die Tabelle der Baum ihres Primärschlüssels: der Sprung entfällt und Platz wird gespart — 1 335 296 Bytes gegen 1 675 264 bei derselben Tabelle mit 20 000 Zeilen, 20 % weniger —, und der Plan verrät es, indem er USING PRIMARY KEY sagt, statt einen automatischen Index zu nennen.

ANALYZE liefert Zahlen, keine Wunder

Es füllt sqlite_stat1 mit der Zeilenzahl und damit, wie viele es je Wert des Index gibt. Diese zweite Zahl sagt, ob ein Index etwas taugt: 20 000 je Wert heißt, er unterscheidet nichts. Aber die Wahl ändert sich nicht immer: in der Messung wählte der Planer schon vorher richtig, denn ohne Statistiken greift er auf vernünftige Annahmen zurück. ANALYZE nimmt das Raten weg; es verspricht keinen anderen Plan.

CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;
-- 20 000 Zeilen: 1 335 296 Bytes, gegenüber 1 675 264 mit rowid

EXPLAIN QUERY PLAN SELECT v FROM kv WHERE k = 'clave-000042';
-- SEARCH kv USING PRIMARY KEY (k=?)
-- mit rowid hätte es gelautet:  USING INDEX sqlite_autoindex_kv_1 (k=?)

ANALYZE;
SELECT tbl, idx, stat FROM sqlite_stat1;
-- t | ia | 20000 20000    <- 20 000 Zeilen, 20 000 je Wert von a
-- t | ib | 20000 1        <- 20 000 Zeilen, 1 je Wert von b

Stichwörter: Index, Indizes, B-Baum, EXPLAIN QUERY PLAN, SCAN, SEARCH, COVERING INDEX, partieller Index, Ausdrucksindex, WITHOUT ROWID, ANALYZE, sqlite_stat1, FTS5

Leistung: die Transaktion ist 440-mal so viel wert wie alles andere

Dieselben 20 000 Einfügungen auf sechs Arten gemessen: der Unterschied zwischen der besten und der schlechtesten liegt in keinem Pragma, sondern darin, ob es ein BEGIN gibt. Und was WAL, VACUUM und die Seitengröße wirklich beitragen.

Gilt für: SQLite 3.35+

Es gibt nur eine Sache, auf die es ankommt, und sie ist kein Pragma. Dieselben 20 000 Einfügungen, dasselbe Schema, dieselbe Maschine:

WieZeit
eine nach der anderen, ohne Transaktion3 963 ms
eine nach der anderen, mit synchronous = OFF2 442 ms
eine nach der anderen, im WAL-Modus258 ms
die 20 000 in einem BEGIN9 ms
in einem BEGIN, im WAL-Modus10 ms

440-mal, und die Erklärung ist, dass ohne BEGIN jedes INSERT seine eigene Transaktion ist: zwanzigtausend Bestätigungen, jede wartet auf die Platte.

-- 20 000 INSERT, jeder in seiner eigenen Transaktion:  3963 ms
-- dieselben 20 000 hier drin:                          9 ms

BEGIN;
  INSERT INTO t (v) VALUES ('...');   -- x 20 000
COMMIT;

Vorsicht vor einer Falle in der Zwischenschicht: dass der Treiber einen Aufruf für „Stapeleinfügung“ anbietet, heißt nicht, dass er eine Transaktion öffnet. Pythons executemany brauchte ohne ausdrückliches BEGIN 4 128 ms: genau so viel wie die Schleife von Hand.

Was die anderen beitragen

synchronous abzuschalten sparte 38 % und bietet dafür an, bestätigte Daten bei einem Stromausfall zu verlieren: das schlechteste Geschäft der Liste. WAL ohne Transaktion ging auf 258 ms herunter — fünfzehnmal —, weil das Bestätigen die Datenbank nicht mehr neu schreibt, und das ist eine Änderung, die man stehen lassen kann. Aber mit beidem im Spiel holt sich das BEGIN fast alles: 9 ms ohne WAL und 10 mit. Zuerst wird gebündelt, und erst dann feinjustiert.

VACUUM ist das, was den Platz zurückgibt

Löschen verkleinert die Datei nicht: die Seiten bleiben auf einer Freiliste zur Wiederverwendung. Gemessen wurde eine Datei von 10 813 440 Bytes, von der die Hälfte der Zeilen gelöscht wurde und die exakt gleich groß blieb. VACUUM schreibt sie ganz neu und ließ sie bei 5 410 816. Es kostet eine Kopie der Datenbank und eine exklusive Sperre, es ist also keine nächtliche Aufgabe: es ist das, was man laufen lässt, wenn ein großes Löschen die Datei doppelt so groß zurückgelassen hat, wie sie sein müsste.

SELECT page_count * page_size
  FROM pragma_page_count(), pragma_page_size();   -- 10813440

DELETE FROM t WHERE i % 2 = 0;
PRAGMA freelist_count;   -- 1 Seite, und die Datei ist genauso groß

VACUUM;                  -- 5410816 Bytes, in 12 ms

Die Seitengröße muss man fast nie anfassen

Drei wurden gemessen. Sie auf 512 zu senken kostete 20 % mehr Datei und einen messbaren Durchlauf, wo die anderen beiden keine Millisekunde erreichten; sie auf 65 536 zu erhöhen brachte nichts. Die ab Werk — 4 096 — ist die, die man lassen sollte, und außerdem lässt sie sich nur vor der ersten Tabelle ändern oder nachträglich über ein VACUUM.

PRAGMA page_size = 512;     -- 5215744 Bytes, und der Durchlauf in 3 ms
PRAGMA page_size = 4096;    -- 4333568 Bytes, und in 0 ms   <- der Werkszustand
PRAGMA page_size = 65536;   -- 4390912 Bytes, und in 0 ms

Und zwei weitere Dinge, die sich selbst messen

Jeder Index wird bei jedem Schreibvorgang bezahlt: dieselben 20 000 Zeilen dauerten 7 ms ohne eigene Indizes, 12 mit einem, 16 mit zweien und 20 mit dreien, und die Datei wuchs von 458 752 auf 1 277 952 Bytes. Und eine Anweisung mit Parameter wird wiederverwendet: 5 000 Abfragen mit ? dauerten 18 ms, dieselben mit dem Wert im SQL eingeklebt 26 — abgesehen davon, dass dort die Injektion hereinkommt.

Stichwörter: Leistung, Transaktion, BEGIN, COMMIT, Stapel, executemany, synchronous, WAL, VACUUM, freelist_count, page_size, Fragmentierung, vorbereitete Anweisung

DDL: vier Dinge, die ALTER kann, und der Umweg für den Rest

Was ALTER TABLE kann, passt in vier Zeilen, und was es beim Hinzufügen und Entfernen einer Spalte ablehnt, ist samt Meldung gemessen. Der Umweg aus Anlegen, Kopieren, Löschen und Umbenennen, und warum er hier sicher ist.

Gilt für: SQLite 3.35+

ALTER TABLE kann vier Dinge, und nicht mehr. Den Typ einer Spalte ändern, ihr ein NOT NULL nehmen, einen Fremdschlüssel hinzufügen: nichts davon gibt es, und der Versuch kommt nicht einmal bis zu einem Schemafehler, er ist ein Syntaxfehler.

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   <- gibt es nicht, und gab es nie

Was ADD COLUMN ablehnt

Die drei Absagen haben dieselbe Ursache: die neue Spalte wird hinzugefügt, ohne die schon vorhandenen Zeilen anzufassen, der Wert, den sie bekommen, muss sich also entscheiden lassen, ohne sie anzusehen. Ein DEFAULT, der sich ändert, ein UNIQUE, das geprüft werden müsste, und ein NOT NULL ohne Wert erfüllen das nicht.

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

Was DROP COLUMN ablehnt — und schlimmer: was es erlaubt

Seit 3.35 lässt sich eine Spalte entfernen, aber nicht, wenn sie zum Primärschlüssel gehört, nicht wenn sie UNIQUE ist, und nicht, wenn ein Index oder eine generierte Spalte sie nennt. So weit, so gut. Das Problem ist der Fall, den es durchlässt: eine Spalte, die eine View benutzt, wird ohne einen Mucks entfernt, die View bleibt kaputt zurück, und PRAGMA integrity_check sagt weiterhin ok, weil es nicht in Views hineinschaut. Niemand warnt, bis jemand abfragt.

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;    -- GEHT DURCH, ohne eine Beschwerde
SELECT * FROM v;                -- no such column: d
PRAGMA integrity_check;         -- ok

Der übliche Umweg

Für alles Übrige lautet das Verfahren: neue Tabelle anlegen, kopieren, die alte löschen und umbenennen. Es klingt gefährlich und ist es hier nicht, wegen etwas, das MySQL nicht hat: das DDL von SQLite ist transaktional. Gemessen: ein CREATE TABLE und ein ADD COLUMN in einem BEGIN, mit ROLLBACK am Ende, hinterließen weder die Tabelle noch die Spalte. Der ganze Umweg passt also in eine Transaktion, und wenn auf halbem Weg etwas schiefgeht, bleibt nichts halb fertig.

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;
  -- und hier werden Indizes, Trigger und Views neu angelegt
COMMIT;
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;

Drei Vorsichtsmaßnahmen. Indizes, Trigger und Views der alten Tabelle gehen mit ihr und müssen neu angelegt werden, denn das DROP TABLE nimmt sie mit. Fremdschlüssel werden während des Umwegs abgeschaltet und mit foreign_key_check geprüft, bevor man sie wieder einschaltet. Und die Typumwandlung ist deine Sache: ein CAST('a' AS INTEGER) liefert 0, ohne ein Wort der Warnung.

Stichwörter: DDL, ALTER TABLE, RENAME TO, RENAME COLUMN, ADD COLUMN, DROP COLUMN, 3.25, 3.35, kaputte View, integrity_check, transaktionales DDL, zwölf Schritte, CAST

Sicherung: die Datei zu kopieren ist der Weg, sie zu verlieren

Eine Datenbank im WAL-Modus besteht aus drei Dateien, und die Daten liegen fast nie in der ersten: sie zu kopieren hinterlässt eine leere Datenbank, die sich für gesund erklärt. Die drei Wege, die funktionieren, gemessen, und was jede Integritätsprüfung findet.

Gilt für: SQLite 3.35+

Eine SQLite-Datenbank sieht aus wie eine Datei, und da fängt der Ärger an. Im WAL-Modus sind es drei, und die mit dem Namen trägt womöglich gar keine Daten: nach dem Schreiben von 30 000 Zeilen maß die .sqlite 4 096 Bytes — den Kopf und wenig mehr — und die -wal maß 3 366 072.

Nur die erste zu kopieren ergibt keine kaputte Datenbank. Es ergibt etwas Schlimmeres: eine, die klaglos öffnet, keine einzige Tabelle hat, und der PRAGMA integrity_check ein ok gibt. So eine Sicherung besteht jede Prüfung und enthält nichts.

PRAGMA journal_mode = WAL;
-- nach 30 000 Zeilen sind die drei Dateien so groß:
--   base.sqlite         4096   <- nur der Header
--   base.sqlite-shm    32768
--   base.sqlite-wal  3366072   <- hier sind die Daten

-- nur base.sqlite zu kopieren ergibt eine Datenbank, die sich öffnet und LEER ist:
SELECT count(*) FROM sqlite_schema;   -- 0
PRAGMA integrity_check;               -- ok

Die drei Wege, die funktionieren

VACUUM INTO schreibt eine saubere, entfragmentierte Kopie in eine andere Datei, bei laufender Datenbank: 3 338 240 Bytes in 4 ms, mit den 30 000 Zeilen. Die Backup-API — das .backup der Befehlszeile und Connection.backup in den Treibern — macht dasselbe, indem sie Seiten kopiert, und kann in Raten arbeiten: 3 338 240 Bytes in 3 ms. Und .dump schreibt das SQL, das die Datenbank wieder aufbaut: 4 008 968 Bytes Text und 30 003 Anweisungen, 20 % mehr als das Binärformat, aber als Einziges mit den Augen lesbar und als Einziges überlebt es einen Formatwechsel.

VACUUM INTO '/ruta/copia.sqlite';
-- 3338240 Bytes in 4 ms, mit den 30 000 Zeilen darin
-- und die Kopie kommt in journal_mode delete heraus, nicht in WAL

Ein willkommenes Detail: die Kopie von VACUUM INTO kommt im journal_mode delete heraus, nicht in WAL. Es ist eine einzige Datei, und genau das will man von einer Sicherung.

Alle drei Dateien auf einmal zu kopieren, bei angehaltener Datenbank, funktioniert schon. Das Problem sind „auf einmal“ und „angehalten“: solange jemand schreibt, gibt es keinen Augenblick, in dem die drei zusammenpassen, und kein Kopierwerkzeug garantiert einen.

Prüfen, was man hat

integrity_check durchläuft die ganze Datenbank, quick_check überspringt die Querprüfungen zwischen Indizes und Tabellen. Auf einer gesunden Datenbank sagten beide ok, und auf derselben Datenbank mit ein paar Hundert absichtlich zerstörten Bytes sagten beide genau dasselbe: Tree 2 page 4 cell 35: Rowid 0 out of order. Der Unterschied zeigt sich erst bei einer großen Datenbank, und keiner von beiden repariert etwas: sie dienen der Entscheidung, ob man zur Sicherung zurückkehrt.

PRAGMA quick_check;       -- ok
PRAGMA integrity_check;   -- ok

-- mit derselben absichtlich beschädigten Datenbank antworten beide gleich:
-- *** in database main ***
-- Tree 2 page 4 cell 35: Rowid 0 out of order

Und wenn es schon zu spät ist

Ein .dump wiederherzustellen heißt, sein SQL gegen eine leere Datenbank laufen zu lassen. Eine binäre Kopie wiederherzustellen heißt, sie an ihren Platz zu legen, und dabei hilft zu wissen, dass VACUUM INTO sich weigert zu überschreiben: auf eine schon vorhandene Datei antwortet es „output file already exists“, man kann also die Sicherung von gestern nicht versehentlich zertreten. Und wenn man eine beschädigte Datenbank und keine Sicherung hat, bleibt .recover der Befehlszeile, das die noch verständlichen Seiten durchgeht und das SQL schreibt, um zu retten, was zu retten ist: es verspricht nicht alles, es verspricht den Rest.

Stichwörter: Sicherung, Backup, VACUUM INTO, Backup-API, dump, WAL, -wal, -shm, integrity_check, quick_check, Beschädigung, Wiederherstellung

Fehler: acht Codes, und der mit vier Ziffern sagt mehr

Die acht, die wirklich auftauchen, einzeln provoziert samt Text, und die Arithmetik des erweiterten Codes: die 19 der Einschränkung wird zu 275, 787, 1299, 1555 oder 2067, je nachdem, was verletzt wurde.

Gilt für: SQLite 3.35+

SQLite hat zwei Satz Codes: einen einfachen mit ein oder zwei Ziffern und einen erweiterten, der dasselbe genauer sagt. Und die Beziehung zwischen beiden ist Arithmetik: der erweiterte ist der einfache plus 256 mal dem Untertyp, code & 255 gibt also immer den einfachen zurück. Ein Treiber, der nur den einfachen zeigt, verschweigt die Hälfte.

CodeNameWas passiert ist
5SQLITE_BUSYeine andere Verbindung hält die Schreibsperre
6SQLITE_LOCKEDdie Sperre hältst du, in einer anderen Anweisung
8SQLITE_READONLYDatei, Verzeichnis oder Verbindung lassen kein Schreiben zu
11SQLITE_CORRUPTdie Datei ergibt keinen Sinn mehr
13SQLITE_FULLes passt nicht: die Platte, oder max_page_count
19SQLITE_CONSTRAINTund hier muss man den erweiterten ansehen
21SQLITE_MISUSEdie API wurde falsch benutzt
26SQLITE_NOTADBes ist nicht einmal eine Datenbank

Die 19 sind fünf verschiedene Fehler

Der einfache sagt nichts Brauchbares, denn eine verletzte Einschränkung kann jede von fünf sein. Der erweiterte schon, und die Meldung hilft … mit einer Ausnahme: der Primärschlüssel eines INTEGER PRIMARY KEY gibt 1555, sein Text aber sagt „UNIQUE constraint failed“. Dort ist die Zahl also genauer als der Satz.

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

Die 5 und die 6 werden verwechselt und sind nicht dasselbe

Die 5 kommt von außen: eine andere Verbindung schreibt, und es hilft zu warten — dort verdient busy_timeout sein Geld. Die 6 kommt von innen: dieselbe Verbindung hat einen Cursor auf der Tabelle offen, die sie ändern will, und Warten nützt nichts, denn wer blockiert, bist du. Es hilft, den Cursor zu schließen.

PRAGMA max_page_count = 20;
INSERT INTO t ... ;      -- 13  SQLITE_FULL      database or disk is full

-- mit der Datenbank schreibgeschützt geöffnet:
INSERT INTO t ... ;      --  8  SQLITE_READONLY  attempt to write a readonly database

-- mit einem noch offenen SELECT-Cursor, in DERSELBEN Verbindung:
DROP TABLE t;            --  6  SQLITE_LOCKED    database table is locked

Die 13 ist fast nie die Platte

SQLITE_FULL klingt nach voller Partition und ist oft die Grenze, die die Datenbank sich selbst gesetzt hat: max_page_count. Mit 20 Seiten provoziert man sie in einer Zeile, und die Meldung ist dieselbe, die eine wirklich volle Platte gäbe: „database or disk is full“.

Die 11, die 26 und die, die man nie sieht

Die beiden Codes für eine kaputte Datei unterscheiden sich darin, wo der Schaden sitzt: ergibt eine Seite keinen Sinn, SQLITE_CORRUPT mit „database disk image is malformed“; ergibt der Kopf keinen Sinn, versucht es gar nicht erst und sagt SQLITE_NOTADB. Und die 21 ist die seltsame: die API in einer unmöglichen Reihenfolge aufzurufen. Man sieht sie kaum, weil der jeweilige Treiber sie vorher abfängt und einen eigenen Fehler wirft; in Python etwa kommt ein ProgrammingError heraus, der nicht einmal einen SQLite-Code trägt.

-- mit überschriebener Schemaseite:
SELECT count(*) FROM t;   -- 11  SQLITE_CORRUPT  database disk image is malformed

-- mit überschriebener Seitengröße im Header:
SELECT count(*) FROM t;   -- 26  SQLITE_NOTADB   file is not a database

Stichwörter: Fehler, Codes, 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

Grenzen: die acht Obergrenzen, und wo sie stehen

Die Grenzen von SQLite gehören nicht dem Format, sondern dem Binärprogramm, und man liest sie mit PRAGMA compile_options. Die acht, an die man wirklich stößt, einzeln provoziert, und die eine, die man kommentarlos überschreitet.

Gilt für: SQLite 3.35+

Die Grenzen von SQLite haben eine Besonderheit, die kein anderes System hat: sie gehören nicht dem Format, sondern dem Binärprogramm, mit dem man gerade spricht. Sie werden beim Übersetzen festgelegt, und deshalb beginnt die Antwort auf „Wie viel ist das Maximum?“ mit PRAGMA compile_options, das sie alle zeigt. Ein Programm kann sie zur Laufzeit mit sqlite3_limit außerdem senken, nie anheben.

Dies sind die des Binärprogramms, das macOS mitbringt, und die ersten fünf wurden provoziert.

ObergrenzeWertWie sie sich meldet
Spalten pro Tabelle2 000too many columns on b
Terme einer zusammengesetzten Abfrage500too many terms in compound SELECT
angehängte Datenbanken10too many attached databases - max 10
Länge eines Textes oder Blobs1 000 000 000—
Seitengröße65 536nichts, und das ist das Schlimme
Parameter einer Anweisung250 000—
Tiefe eines Ausdrucks1 000—
Seiten einer Datenbank1 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

Die, die sich nicht meldet

PRAGMA page_size = 131072 gibt keinen Fehler, liefert nichts Merkwürdiges und ändert nichts: die Seite bleibt bei 4 096. Das Maximum ist 65 536, und was darüber verlangt wird, wird stillschweigend verworfen; die einzige Möglichkeit zu wissen, ob es griff, ist Nachlesen. Es ist dieselbe Fehlerart, die page_size und auto_vacuum auf einer Datenbank mit Tabellen schon haben: angenommen und wirkungslos.

PRAGMA page_size = 131072;   -- ni error ni aviso
PRAGMA page_size;            -- 4096   <- hat es nicht übernommen
PRAGMA page_size = 65536;    -- este si

Wie viel wirklich hineinpasst

Die maximale Dateigröße ist keine Konstante: sie ist max_page_count mal Seitengröße. Mit den Werkseinstellungen — 1 073 741 823 Seiten zu 4 096 Bytes — kommen 4 TiB heraus, und mit einer Seite von 65 536 64 TiB. Lange bevor man dorthin kommt, geht etwas anderes aus: ein Text kommt nicht über 1 000 000 000 Bytes, und ein SELECT mit mehr als 250 000 Parametern lässt sich nicht einmal vorbereiten.

Und eine Warnung zur Tabelle oben: sie gehört diesem Binärprogramm. Das auf einem Telefon, das in einer eingebetteten Bibliothek oder eines, das jemand mit eigenen Flags übersetzt hat, können andere Zahlen tragen, und deshalb ist die nützliche Antwort nie der Wert: es ist der Befehl, der danach fragt.

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 pro Seite = 4 TiB Datei; x 65536 = 64 TiB

Sie zu senken ist eine Verteidigung

Dass sqlite3_limit nur senken kann, ist kein Mangel: dafür ist es da. Eine Anwendung, die von jemand anderem geschriebenes SQL annimmt, senkt LENGTH, COMPOUND_SELECT und EXPR_DEPTH auf das, was sie wirklich braucht, und damit kann eine feindselige Abfrage kein Gigabyte Speicher mehr anfordern. Es ist dieselbe Idee wie das max_page_count im Fehlerthema: die selbst gesetzte Grenze meldet sich früher als die des Systems, und sie meldet etwas, das man beheben kann.

Stichwörter: Grenzen, Obergrenzen, compile_options, MAX_COLUMN, MAX_COMPOUND_SELECT, MAX_ATTACHED, MAX_LENGTH, MAX_PAGE_SIZE, max_page_count, sqlite3_limit, Maximalgröße, Spalten, ATTACH

Bewährte Praxis: sieben, und vier setzt man beim Öffnen

Die Zusammenfassung des SQLite-Handbuchs: sieben Gewohnheiten mit der Messung dahinter, vier davon in den Zeilen direkt nach dem Öffnen der Verbindung, und die sechs, mit denen man einer fremden Datei den Puls fühlt.

Gilt für: SQLite 3.35+

Dies ist das Ende des Handbuchs, und es bringt nichts Neues: es sammelt, was jedes Thema gemessen hinterlassen hat. Auffällig ist, wo vier der sieben landen: in den Zeilen, die man direkt nach dem Öffnen der Verbindung schreibt und die fast kein Programm schreibt.

1. PRAGMA journal_mode = WAL. Es bleibt in der Datei stehen, einmal genügt also. Damit las ein Leser weiter, während ein anderer schrieb, ohne einen Augenblick zu blockieren. Was es nicht behebt, ist die Zahl der Schreiber: weiterhin einer.

2. PRAGMA foreign_keys = ON, in jeder Verbindung. Es ist das Einzige auf dieser Liste, das ändert, was die Datenbank annimmt, und es kommt ausgeschaltet: ausgeschaltet geht ein verwaistes Kind ohne einen Mucks hinein. Und es bleibt nicht stehen, es gehört also an dieselbe Stelle wie das busy_timeout.

3. PRAGMA busy_timeout, und von dir gesetzt. Die Maschine liefert es mit 0 — sie antwortet auf der Stelle SQLITE_BUSY —, aber viele Treiber ändern es beim Verbinden: der von Python lässt es kommentarlos bei 5 000. Die Zahl, auf die es ankommt, ist also nicht die aus der Dokumentation: es ist die, die PRAGMA busy_timeout auf deiner Verbindung zurückgibt.

4. Bündle die Schreibvorgänge in einer Transaktion. Das ändert mit Abstand am meisten: dieselben 20 000 Einfügungen dauerten einzeln 3 963 ms und in einem BEGIN 9 ms. Und Vorsicht mit der Zwischenschicht: ein „Stapeleinfügung“-Aufruf des Treibers öffnet von sich aus keine Transaktion.

PRAGMA journal_mode;    -- wal, o delete si nadie lo ha tocado
PRAGMA foreign_keys;    -- fast immer 0, und fast immer ein Fehler
PRAGMA synchronous;     -- 2 mit Journal, 1 in WAL
PRAGMA busy_timeout;    -- der DEINER Verbindung, nicht der der Engine
PRAGMA page_count;      -- x page_size = was es belegt
PRAGMA quick_check;     -- ok

5. Sichere mit VACUUM INTO, nie durch Kopieren der Datei. Im WAL-Modus liegen die Daten im -wal, das Kopieren der .sqlite ergibt also eine Datenbank, die öffnet, keine einzige Tabelle hat und der integrity_check ein ok gibt. Eine Sicherung, die jede Prüfung besteht und leer ist, ist schlimmer als gar keine.

6. Ein Index pro häufiger Abfrage, und sieh nach, was er wiegt. dbstat sagt es je Objekt, und es überrascht: in der gemessenen Datenbank nahm der Index 2 056 192 Bytes gegenüber 1 826 816 der Tabelle, die er indizierte.

SELECT name, SUM(pgsize) AS bytes
  FROM dbstat
 GROUP BY name
 ORDER BY bytes DESC;
-- iv             2056192   <- der Index ist größer als die Tabelle
-- t              1826816
-- sqlite_schema     4096

7. Die Sicherheit gehört der Datei. Es gibt keine Benutzer, keine Rollen, kein GRANT: wer die Datei lesen kann, kann alles lesen, und wer sie schreiben kann, kann sie löschen. Der Schutz sind die Berechtigungen des Systems, die Verschlüsselung der Platte und — auf einem Telefon — die Datenschutzklasse. Alles andere in diesem Handbuch ist Leistung; dies ist das Einzige, wofür es keinen Ersatz gibt.

Und eine, die keine Gewohnheit ist, sondern eine Grenze

SQLite hält weit mehr aus, als sein Ruf nahelegt, aber es hat eine Grenze, die keine Praxis verschiebt: es schreibt einer nach dem anderen. Solange die Schreibvorgänge aus einem Prozess kommen oder aus mehreren, die sich abwechseln, reicht die Datei bis zu Grenzen, an die kaum jemand stößt. An dem Tag, an dem zwei echte gleichzeitige Schreiber gebraucht werden, ist nicht ein Pragma zu ändern: es ist die Maschine.

Stichwörter: bewährte Praxis, Zusammenfassung, WAL, foreign_keys, busy_timeout, gebündelte Transaktion, VACUUM INTO, Sicherung, dbstat, Indizes, Berechtigungen, Verschlüsselung, Sicherheit

Sicherheit, Benutzer und Rollen

Minimale Rechte, Rollen, verschlüsselte Verbindungen und die Checkliste vor dem Freigeben eines Servers.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

In MySQL und MariaDB besteht die Identität eines Benutzers aus zwei Dingen: dem Namen und dem Host, von dem er sich verbindet. 'app'@'10.0.%' und 'app'@'%' sind verschiedene Konten mit verschiedenen Passwörtern und Rechten. Die meisten Sicherheitsschrecken fangen damit an, das zu vergessen.

Minimale Rechte
Gewähre, was die Anwendung nutzt, keins mehr, und auf dem engstmöglichen Host. Ein Anwendungskonto braucht kaum je DROP und niemals SUPER, FILE oder 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.%';

Rollen

MySQL 8.0+MariaDB 10.0.5+

Eine Rolle ist ein Bündel von Rechten, das mehreren Konten gewährt wird. Du änderst die Rolle einmal, und alle ändern sich mit. Es ist die einzig vernünftige Art, mehr als eine Handvoll Benutzer zu verwalten:

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 vergibt GRANT eine Rolle pro Anweisung, und ein Konto hat nur eine Standardrolle: GRANT 'lectura', 'escritura' und SET DEFAULT ROLE ALL sind Syntaxfehler (gemessen auf 13.0).

GRANT 'lectura' TO 'app'@'10.0.%';
GRANT 'escritura' TO 'app'@'10.0.%';
SET DEFAULT ROLE 'lectura' FOR 'app'@'10.0.%';

Verschlüsselte Verbindungen
Ohne TLS wandern Passwort und Daten lesbar durchs Netz. Man kann es je Konto oder für den ganzen Server über require_secure_transport verlangen. Calíope unterstützt TLS im Verbindungsprofil und ebenso SSH-Tunnel, wenn der Server nicht offen liegt:

ALTER USER 'app'@'10.0.%' REQUIRE SSL;

SHOW VARIABLES LIKE 'require_secure_transport';

SELECT user, host, ssl_type FROM mysql.user;

Schnelles Audit
Drei Abfragen, die man auf jedem geerbten Server einmal laufen lassen sollte. Konten ohne Passwort, Konten für jeden Host offen und verteilte gefährliche Rechte:

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');

Bevor ein Server nach außen geht
1. Keine anonymen und keine passwortlosen Konten, und keine Beispiel-Datenbank test.
2. root nur von localhost, mit einem getrennten Administrationskonto für alles Weitere.
3. bind-address auf der richtigen Schnittstelle — nicht 0.0.0.0, wenn von außen niemand hinsoll.
4. TLS verpflichtend für jede Verbindung, die die Maschine verlässt.
5. Passwörter außerhalb des Codes verwalten — Calíope legt sie im Schlüsselbund ab, nie im Klartext.
6. Getrennte Konten je Anwendung, damit ein Einbruch nicht alles mitreißt.
7. Die GRANTs regelmäßig durchsehen: Rechte häufen sich an, und niemand nimmt sie zurück.

Empfehlung
Fang mit Entziehen an statt mit Gewähren: leg das Konto ohne alles an und füge Rechte hinzu, bis die Anwendung läuft. Das Benutzer-Werkzeug von Calíope zeigt die gewährten Rechte je Datenbank und je Tabelle — dort tauchen die Überraschungen meist auf.

Stichwörter: sicherheit, benutzer, rolle, privileg, grant, revoke, minimale rechte, ssl, tls, require ssl, härtung, mysql.user, user_privileges, audit

Sicherung und Point-in-Time-Recovery

Logisch gegen physisch, wozu das Binlog dient und wie man zur Minute vor dem DELETE zurückkommt.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung, sondern eine Absicht. Zwei Zahlen bestimmen hier alles: das RPO (wie viele Daten du zu verlieren bereit bist) und das RTO (wie lange du ausfallen darfst). Alles Weitere folgt daraus.

Logisch gegen physisch
- Logisch (mysqldump, das Backup von Calíope) — erzeugt SQL. Übertragbar zwischen Versionen und Engines, erlaubt das Zurückholen einer einzelnen Tabelle und ist bei großen Mengen langsam wiederherzustellen.
- Physisch (Volume-Snapshot, Percona XtraBackup, Kopie des Verzeichnisses bei gestopptem Server) — kopiert die Dateien. Blitzschnell zurückzuspielen, aber an Version und Architektur des Servers gebunden.

Faustregel: bis zu einigen Dutzend Gigabyte logisch; darüber physisch für die Vollkopie und logisch für einzelne Teile.

Das Binlog ist die fehlende Hälfte
Die Sicherung bringt dich zu dem Moment zurück, in dem sie entstand. Das Binary Log enthält alles, was danach geschah, und erst damit kommst du von dort bis eine Sekunde vor die Katastrophe. Ohne aktives log_bin gibt es kein Point-in-Time-Recovery, nur die Rückkehr zur letzten Kopie:

SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';

SHOW BINARY LOGS;

Auf einen Zeitpunkt zurückholen
Der Ablauf, immer auf einem getrennten Server und nie auf dem produktiven:
1. Spiel die jüngste Vollkopie von vor dem Vorfall zurück.
2. Finde den genauen Moment des Fehlers im Binlog: die Anweisung, die zu viel gelöscht hat, und ihre Position oder ihren Zeitstempel.
3. Spiel das Binlog ab von der Position, an der die Kopie endete, bis kurz vor diese Anweisung — mit mysqlbinlog und seinen Optionen --start-position und --stop-position (oder --start-datetime und --stop-datetime).
4. Prüf, dass die Daten da sind, und entscheide erst dann, ob du diesen Server hochziehst oder das Fehlende daraus exportierst.

Der Binlog-Betrachter von Calíope ist für Schritt 2 da: er liest jede Datei Ereignis für Ereignis, mit ihrer Position, und filtert nach Klasse —DDL, DML oder System— und nach Text — genau das, was von Hand mühsam ist.

Die Position finden
SHOW MASTER STATUS (im aktuellen MySQL SHOW BINARY LOG STATUS, dort ist die alte Form ein Syntaxfehler) nennt Datei und Position von jetzt; die Ereignisse eines bestimmten Binlogs listet man so:

SHOW MASTER STATUS;

SHOW BINLOG EVENTS IN 'binlog.000042'
LIMIT 20;

Die Wiederherstellung prüfen
Zurückspielen ohne Prüfen ist der übliche Weg, das Problem zu spät zu bemerken. Eine Zählung je Datenbank und ein CHECKSUM TABLE der kritischen Tabellen gegen die Quelle reichen, um ruhig zu schlafen:

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;

Empfehlung
Plane die Sicherung ein (Calíope kann das, mit einstellbarer Aufbewahrung), halte eine Kopie außerhalb der Maschine, aktiviere log_bin mit einer Aufbewahrung über mindestens zwei Sicherungszyklen, und probe mindestens einmal eine vollständige Wiederherstellung. Der Tag des Vorfalls ist nicht der Tag, an dem man das Verfahren lernt.

Aurora

Amazon Aurora bringt eigene mit. Der Cluster kopiert laufend in den Speicher und kann auf jede Sekunde innerhalb des Aufbewahrungsfensters zurück, ohne das Binlog anzufassen: das ist verwaltetes PITR, und es stellt in einem neuen Cluster wieder her, nicht über dem bestehenden. Backtrack geht weiter und spult den Cluster an Ort und Stelle um einige Sekunden zurück, ohne einen zweiten anzulegen. Nichts davon ersetzt ein mysqldump: die AWS-Kopien liegen im selben Konto, schützen dich also nicht davor, es zu verlieren, und geben dir nichts, was zu einem anderen Anbieter portierbar wäre.

Stichwörter: sicherung, backup, wiederherstellung, pitr, zeitpunkt, binlog, mysqldump, mysqlbinlog, rpo, rto, checksum table, snapshot

Online-DDL: das Schema ohne Stillstand ändern

ALGORITHM, LOCK, Metadatensperren und wann ein externes Werkzeug nötig wird.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Ein ALTER TABLE auf einer großen Tabelle kann Stunden dauern und die Anwendung warten lassen. Seit MySQL 5.6 und MariaDB 10.0 lässt sich vorgeben, wie die Änderung zu geschehen hat — und damit vorher wissen, ob es wehtun wird.

Den Algorithmus verlangen, nicht auf Glück hoffen
Gibst du den Algorithmus an und der Server kann ihn nicht verwenden, scheitert die Anweisung sofort, statt dir die Tabelle drei Stunden zu sperren. Das ist der Hauptgrund, ihn immer hinzuschreiben:

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;
AlgorithmusWas er tutTypischer Aufwand
INSTANTnur MetadatenMillisekunden
INPLACEbaut an Ort und Stelle umMinuten oder Stunden
COPYkopiert die ganze TabelleStunden, mit Sperre

MySQL 8.0+MariaDB 10.3+

ALGORITHM=INSTANT deckt das Anhängen einer Spalte am Ende ab, das Verbreitern eines VARCHAR innerhalb derselben Längenbytegröße (nur MariaDB 10.4+; in MySQL ist das INPLACE, ohne die Tabelle neu aufzubauen, und INSTANT ergibt Fehler 1845), das Umbenennen einer Spalte oder das Ändern eines Standardwerts. Er ist der einzige, der die Daten gar nicht anfasst.

Die LOCK-Klausel
- LOCK=NONE — Lesen und Schreiben laufen während der Änderung weiter. Geht das nicht, Fehler.
- LOCK=SHARED — Lesen erlaubt, Schreiben nicht.
- LOCK=EXCLUSIVE — niemand fasst die Tabelle an.

LOCK=NONE anzugeben ist die Garantie, dass die Migration die Produktion nicht anhält: entweder läuft sie ohne Sperre, oder sie läuft nicht.

Die Metadatensperre, die alle überrascht
Selbst ein sofortiges ALTER braucht am Anfang und am Ende eine exklusive Metadatensperre. Liegt eine alte Transaktion offen auf dieser Tabelle, wartet das ALTER — und jede danach eintreffende Abfrage stellt sich dahinter an. Eine Tabelle friert wegen eines ALTER ein, das eine Millisekunde dauern sollte. Bevor du das Schema anfasst, prüf, ob lange Transaktionen laufen:

SELECT object_name, lock_type, lock_status, owner_thread_id
FROM performance_schema.metadata_locks
WHERE object_schema = DATABASE();

SELECT @@lock_wait_timeout;

Den Fortschritt sehen
Ein stundenlanges ALTER gibt von sich aus kein Lebenszeichen. performance_schema schon:

SELECT stage, work_completed, work_estimated,
       ROUND(work_completed / work_estimated * 100, 1) AS pct
FROM performance_schema.events_stages_current;

SHOW PROCESSLIST;

Wann ein externes Werkzeug nötig wird
Erzwingt die Änderung ALGORITHM=COPY auf einer Tabelle von zig Gigabyte, rettet dich kein LOCK. Da kommen pt-online-schema-change (Percona) und gh-ost (GitHub) ins Spiel: sie legen eine neue Tabelle an, kopieren stapelweise, halten sie per Trigger oder über das Binlog synchron und tauschen am Ende in einem Augenblick. Sie kommen nicht mit dem Server; sie werden getrennt installiert und von der Kommandozeile ausgeführt.

Empfehlung
Schreib in deinen Migrationen immer ALGORITHM= und LOCK=, und probier sie vorher auf einer Kopie mit echten Daten aus, um die Dauer zu kennen. Ein ALTER, das nach einer Sekunde scheitert, ist eine gute Nachricht gegenüber einem, das die Tabelle mitten am Vormittag sperrt.

Stichwörter: online ddl, alter table, algorithm, instant, inplace, copy, lock=none, metadatensperre, mdl, pt-online-schema-change, gh-ost, schemamigration

Zeichensätze und Kollationen

Warum utf8 nicht UTF-8 ist, was eine Kollation entscheidet und wie man ohne kaputte Indizes umstellt.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Zwei Begriffe, die ständig verwechselt werden: der Zeichensatz sagt, welche Zeichen gespeichert werden können, und die Kollation sagt, wie sie verglichen und sortiert werden. Das erste bestimmt, was hineinpasst; das zweite, was ein WHERE zurückgibt.

utf8 ist nicht UTF-8
In MySQL ist utf8 ein historischer Alias für utf8mb3: nur drei Bytes je Zeichen, also keine Emoji und ein guter Teil des modernen Chinesisch, Japanisch und Koreanisch fällt weg. Echtes UTF-8 heißt utf8mb4. Das ist die meistgestellte Falle des Produkts, und in MySQL 8.0 lebt sie aus Kompatibilitätsgründen weiter. Prüf, wo du stehst:

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%';

Was eine Kollation entscheidet
Der Name sagt alles, wenn man ihn lesen kann. In utf8mb4_0900_ai_ci: 0900 ist die Unicode-Version, ai heißt akzentunempfindlich und ci unempfindlich gegenüber Groß- und Kleinschreibung. Die Gegenstücke sind as (akzentempfindlich) und cs (schreibungsempfindlich). Es gibt außerdem utf8mb4_bin, das Byte für Byte vergleicht und von Sprachen nichts weiß.

Die Vorgaben unterscheiden sich: MySQL 8.0 nutzt utf8mb4_0900_ai_ci, MariaDB je nach Version utf8mb4_general_ci oder utf8mb4_uca1400_ai_ci. Wenn du Daten zwischen beiden bewegst, setz nicht voraus, dass sie gleich sortieren.

Was sich praktisch ändert
Mit einer ai_ci-Kollation sind café und cafe derselbe Wert: ein UNIQUE weist den zweiten ab, und ein WHERE findet beide. Für die Namenssuche kann das genau richtig sein, für gespeicherte Bezeichner eine Katastrophe:

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';

Kollationen zu mischen tut weh
Ein JOIN zwischen einer Spalte in utf8mb4_general_ci und einer in utf8mb4_0900_ai_ci gibt den Fehler 1267 Illegal mix of collations. Und flickst du das, indem du die Spalte in CONVERT() oder ein COLLATE packst, kann die Abfrage den Index dieser Spalte nicht mehr nutzen. Die richtige Reparatur ist nicht das COLLATE in der Abfrage, sondern eine einheitliche Kollation im Schema.

Umstellen ohne Überraschungen
ALTER DATABASE ändert nur den Standard für künftige Tabellen; die vorhandenen musst du einzeln umstellen. Und CONVERT TO CHARACTER SET schreibt die ganze Tabelle neu, verdient also dieselbe Vorsicht wie jedes schwere DDL:

ALTER DATABASE tienda
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_0900_ai_ci;

ALTER TABLE clientes
    CONVERT TO CHARACTER SET utf8mb4
    COLLATE utf8mb4_0900_ai_ci;

Empfehlung
utf8mb4 überall — Server, Datenbank, Tabelle, Spalte und Client-Verbindung — und eine einzige Kollation im ganzen Schema. Sieh dir vor der Umstellung die Indizes auf langen Textspalten an: beim Wechsel von utf8mb3 auf utf8mb4 kann jedes Zeichen ein Byte mehr brauchen, und ein Index, der passte, passt vielleicht nicht mehr.

Stichwörter: charset, zeichensatz, kollation, collation, utf8, utf8mb4, latin1, emoji, akzente, groß- und kleinschreibung, convert to character set, illegal mix of collations

Häufige Fehler und was sie bedeuten

Die häufigsten Codes — 1045, 1062, 1213, 2006 — und was jeweils zu tun ist.

Gilt für: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

Codes unter 2000 kommen vom Server, die von 2000 bis 2999 von der Client-Bibliothek, und ab 3000 wieder vom Server. Schon diese Unterscheidung sagt, wo zu suchen ist: ist es ein 2xxx, liegt das Problem in der Verbindung, nicht im SQL.

CodeMeldungWas es meist ist
1045Access denied for userBenutzer, Passwort oder Host passt nicht
1049Unknown databasedie Datenbank gibt es nicht, oder der Benutzer sieht sie nicht
1040Too many connectionsmax_connections aufgebraucht
1062Duplicate entryKonflikt mit einem UNIQUE oder dem Primärschlüssel
1146Table doesn't existName falsch geschrieben, oder Groß- und Kleinschreibung unter Linux
1213Deadlock foundSperrzyklus; wiederholen ist die Antwort
1205Lock wait timeouteine andere Transaktion hält die Sperre
1215Cannot add foreign keyverschiedene Typen, oder fehlender Index am Ziel
1267Illegal mix of collationszwei Spalten mit verschiedenen Kollationen
1406Data too long for columnder Wert passt nicht in den deklarierten Typ
2002Can't connect through socketder Server läuft nicht, oder es ist der falsche Socket
2006MySQL server has gone awaywait_timeout oder max_allowed_packet
2013Lost connection during queryAbfrage beendet, Netz weg oder Server neu gestartet

1045 und 1040: die Verbindung
Der 1045 ist fast nie das Passwort: das Konto existiert für einen anderen Host. Denk daran, dass 'app'@'localhost' und 'app'@'%' verschiedene Konten sind. Der 1040 heißt, die Verbindungen sind alle, und die übliche Ursache ist nicht die Poolgröße, sondern Verbindungen, die niemand schließt:

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: doppelter Eintrag
Die Meldung nennt den Schlüssel, der verletzt wurde. Ist das Duplikat erwartbar — ein erneut laufender Import, ein Upsert — gibt es Syntax, um es nicht mehr als Fehler zu behandeln:

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: der Fremdschlüssel lässt sich nicht anlegen
Diese Meldung ist berühmt dafür, nichts zu sagen. Die echten Ursachen sind immer dieselben vier: die Typen der beiden Spalten stimmen nicht exakt überein (Vorzeichen und Länge eingeschlossen), ihre Zeichensätze stimmen nicht überein, am referenzierten Feld fehlt ein Index, oder es existieren bereits verwaiste Zeilen, die die Bedingung nicht zuließe:

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;

Den Fehler richtig lesen
Bevor du den Code im Netz suchst, lies ihn ganz: MySQL nennt meist genau Tabelle, Spalte und Wert. Und wenn eine Anweisung eine Warnung statt eines Fehlers zurückgibt, zeigt SHOW WARNINGS direkt danach, was der Server eigenmächtig entschieden hat — etwa ein stilles Abschneiden —, und das ist schlimmer als ein sauberer Fehlschlag.

Empfehlung
Calíope zeigt Code und Meldung des Servers unverändert, ohne sie zu verpacken: dieser Text ist der beste Hinweis und sollte vollständig kopiert werden, wenn du um Hilfe bittest. Das Abfrageprotokoll bewahrt zusätzlich die auslösende Anweisung mit Zeit und Dauer auf.

Stichwörter: fehler, fehlercode, 1045, 1049, 1062, 1146, 1213, 1205, 1215, 1267, 1406, 2002, 2006, 2013, 1040, too many connections, gone away, access denied, duplicate entry