Diese Seite beschreibt Calíope 1.5, die Version, die ich gerade baue. Die 1.4 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.
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 — 8 Bytes, ohne Zeitzone, ohne Umwandlung beim Speichern/Lesen. Speichert die literale Zeichenkette.
- TIMESTAMP — 4 Bytes, Wertebereich 1970…2038 (in MySQL 5.7) oder 1970…2106 (in MariaDB 10.4+). 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 (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.
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+.
-- Índice B-tree compuesto
CREATE INDEX idx_pedidos_cliente_fecha
ON pedidos (cliente_id, fecha_pedido DESC);
-- Índice de texto completo
ALTER TABLE articulos
ADD FULLTEXT INDEX ft_titulo_cuerpo (titulo, cuerpo);
-- Índice espacial
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 nichtWHERE 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.
-- Bueno: índice sobre la columna más selectiva primero
CREATE INDEX idx_facturas
ON facturas (cliente_id, estado, fecha)
-- cliente_id (alta selectividad) → estado → fecha
;
-- Mal patrón: índice redundante
-- (cliente_id) ya está cubierto por (cliente_id, estado, fecha)
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.
-- La consulta solo lee cliente_id y total → el índice la cubre
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+MariaDB 10.6+
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
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';
-- Estadísticas de uso de índices
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;
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).
-- Mala: tres columnas que repiten la misma "entidad"
CREATE TABLE clientes_v1 (
id BIGINT PRIMARY KEY,
nombre VARCHAR(120),
tel1 VARCHAR(20),
tel2 VARCHAR(20),
tel3 VARCHAR(20)
);
-- Buena: una tabla relacionada
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 CASCADEnicht 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.
-- Mal: nombre_producto se repite en cada línea del mismo producto
CREATE TABLE detalle_pedido_v1 (
pedido_id BIGINT,
producto_id BIGINT,
cantidad INT,
nombre_producto VARCHAR(120),
PRIMARY KEY (pedido_id, producto_id)
);
-- Bien: nombre_producto vive en la tabla 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.
-- Ejemplo: cachear el total del pedido para evitar SUM en cada lectura
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.
-- Mantenerlo con un trigger
DELIMITER //
CREATE TRIGGER detalle_pedido_after_insert
AFTER INSERT ON detalle_pedido
FOR EACH ROW
BEGIN
UPDATE pedidos
SET total = (SELECT COALESCE(SUM(cantidad * precio_unit), 0)
FROM detalle_pedido
WHERE pedido_id = NEW.pedido_id)
WHERE id = NEW.pedido_id;
END//
DELIMITER ;
PostgreSQL 13+
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 Sichten, aber eine indizierte Sicht 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 Sicht 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.
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".
-- Todos los clientes, hayan hecho o no pedidos
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.
-- Equivalente a LEFT JOIN con orden invertido
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).
-- Genera todas las combinaciones (cliente, mes) para un reporte
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)
);
-- Cada empleado con el nombre de su jefe
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.
MariaDB 10.5+
MariaDB unterstützt FULL OUTER JOIN seit 10.5 nativ.
-- MariaDB nativo
SELECT c.nombre, p.id
FROM clientes c
FULL OUTER JOIN pedidos p ON p.cliente_id = c.id;
MySQL 5.7+MySQL 8.0+
MySQL unterstützt FULL OUTER JOIN nicht — nicht einmal in 8.0. Es wird mit UNION emuliert:
-- Emulación en 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: clientes con al menos un pedido
SELECT c.id, c.nombre
FROM clientes c
WHERE EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id);
-- Anti-join: clientes sin pedidos
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.
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
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';
-- Cambiar en caliente (8 GB)
SET GLOBAL innodb_buffer_pool_size = 8 * 1024 * 1024 * 1024;
max_connections
Maximale Anzahl gleichzeitiger Verbindungen. Standardmäßig 151 in MySQL, 100 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;
-- El cliente también debe pasar el parámetro
-- (en línea de comandos: --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.
- 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; -- solo aplica al reiniciar
RESET PERSIST innodb_buffer_pool_size; -- quitar la persistencia
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.
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, 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+).
-- Inspeccionar tamaño de tablas
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;
- 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.
-- Válido pero no recomendable
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 macOS und Windows.
- 2 — sie werden unverändert gespeichert, aber Vergleiche ignorieren die Groß-/Kleinschreibung. Nur macOS/Windows.
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
- Sitzungsvariablen (@@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.
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 sadbo.
-- 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.
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 sind für MySQL und MariaDB und 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
-- Crear usuario de aplicación con permisos limitados
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.%.%';
-- NUNCA en producción
-- 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 TABLEMsg 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 MigrationSET 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.
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+ / MariaDB 10.1+)
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: Workspace › SQL-Analyse › Visual EXPLAIN.
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 queries por tiempo total acumulado
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;
-- Tablas con más 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 enthält außerdem das Plugin userstat, das 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 (Innodb_buffer_pool_read_requests / (reads + reads_from_disk)). Ziel: >99 %.
SELECT ROUND(
(1 - (
VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads'
) / (
VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests'
)) * 100, 2) AS buffer_pool_hit_pct;
-- (Versión que sí parsea, con dos consultas):
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
- Adaptive hash index — automatischer Hash über heiße Seiten des Buffer Pools. Standardmäßig aktiviert.
- Change buffer — puffert Änderungen an Seiten, die nicht im Buffer Pool vorhanden sind.
Häufige Engpässe
Symptom
Wahrscheinliche Ursache
Aktion
CPU bei 100 %
Queries ohne Index oder schlechte Schätzungen
EXPLAIN, Slow Log
I/O bei 100 %
Buffer Pool zu klein
innodb_buffer_pool_size erhöhen
Verbindungen am Limit
Connection Leaks in der App
Pool in der Anwendung prüfen
Threads_running hoch
Lock Contention
SHOW ENGINE INNODB STATUS ansehen
tmp_disk_tables wächst
tmp_table_size zu klein
tmp_table_size erhöhen
Replication Lag
Single-Thread oder lange Transaktionen
slave_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.
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
Stufe
Dirty Read
Non-Repeatable Read
Phantom Read
READ UNCOMMITTED
ja
ja
ja
READ COMMITTED
nein
ja
ja
REPEATABLE READ
nein
nein
nein (InnoDB)
SERIALIZABLE
nein
nein
nein
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.
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:
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
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.
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 RECURSIVEnicht 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
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
Stufe
Dirty Read
Non-repeatable Read
Phantom Read
READ UNCOMMITTED
nein
ja
ja
READ COMMITTED
nein
ja
ja
REPEATABLE READ
nein
nein
nein
SERIALIZABLE
nein
nein
nein
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.
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 FULLschreibt 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 eine Replik 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.
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
Anweisung
Modus
SELECT
ACCESS SHARE
SELECT … FOR UPDATE / FOR SHARE
ROW SHARE
INSERT, UPDATE, DELETE
ROW EXCLUSIVE
VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY
SHARE UPDATE EXCLUSIVE
CREATE INDEX
SHARE
ALTER TABLE, TRUNCATE, DROP TABLE, VACUUM FULL
ACCESS 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 Sie in pg_locksnicht sehen werden, 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 Sie verwahrt, damit zwei Prozesse Ihrer Anwendung nicht gleichzeitig dasselbe tun.
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.
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
Methode
Wofür
B-Baum
Der gewohnte: Gleichheit, Bereiche, ORDER BY, LIKE 'abc%'. Im Zweifel dieser.
Hash
Nur Gleichheit. Seit PostgreSQL 10 wird er repliziert und übersteht einen Absturz.
GiST
Geometrie, Bereiche, nächster Nachbar. Er ist die Grundlage von PostGIS.
SP-GiST
Ungleich verteilte Daten: nicht überlappende Bereiche, Text nach Präfixen.
GIN
Viele Werte innerhalb eines Feldes: jsonb, Arrays, Volltextsuche.
BRIN
Riesige 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 Sie die Warteschlange der offenen Vorgänge abfragen und diese 5 % ausmachen, indizieren Sie 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 Ihre 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 VACUUMHeap 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 Sie den „fehlenden" Index anlegen, schauen Sie 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 Sie nur mit @> abfragen: 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 einer Replik zählt er die Arbeit der Replik — 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.
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, klammern Sie 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". Sortieren Sie 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.
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 RESET4MB 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_restartleuchtet 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_sizereserviert 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_naptime60 s, autovacuum_max_workers3 —dieser verlangt einen Neustart— und autovacuum_vacuum_scale_factor0,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
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 PUBLICUSAGE 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 16+) — 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
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 kopiert
Anweisungen, die die Daten wiederaufbauen
Die Dateien des Clusters, wie sie sind
Einheit
Eine Datenbank, sogar eine Tabelle
Das ganze Cluster, alle Datenbanken
Stellt wieder her auf
Andere Version, Maschine, System
Dieselbe Hauptversion
Gut für
Migrieren, eine Tabelle verschieben, lesen
Den 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:
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.
Die Sicherung von Calíope ist logisch
Was das Sicherungswerkzeug 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.
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 SYSTEMdü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:
Anweisung
Zeit
Schreibt neu?
ADD COLUMN c int
0,6 ms
nein
ADD COLUMN c int DEFAULT 7 NOT NULL
1,6 ms
nein
ALTER COLUMN s TYPE varchar(100) (vorher 50)
1,2 ms
nein
ALTER COLUMN s TYPE varchar(20) (vorher 50)
204 ms
ja
ALTER COLUMN n TYPE bigint (vorher int)
191 ms
ja
ALTER COLUMN t TYPE varchar(200) (vorher text)
193 ms
ja
DROP COLUMN c
0,5 ms
nein
ALTER COLUMN c SET NOT NULL
17,8 ms
nein (durchläuft aber die Tabelle)
ADD CONSTRAINT … CHECK (…)
11,6 ms
nein (durchläuft)
ADD CONSTRAINT … CHECK (…) NOT VALID
0,5 ms
nein
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
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: nehmen Sie 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 textfalsch und bei charwahr.
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
Gemessen: in Gleitkomma ergab 0.1 * 30.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, uuid16 —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:
TimeZone
timestamptz
timestamp
Europe/Madrid
2026-08-19 13:48:19+02
2026-08-19 13:48:19
UTC
2026-08-19 11:48:19+00
2026-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
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 json14 MB und jsonb16 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.
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
Code
Was passiert ist
23505
Doppelter Schlüssel — verletzt eine Eindeutigkeitsbedingung
23503
Fremdschlüssel: die referenzierte Zeile fehlt, oder ein Elternteil mit Kindern soll gelöscht werden
23502
NULL in einer NOT NULL-Spalte
23514
Eine CHECK-Bedingung sagte nein
22001
Der Text passt nicht in den Typ
22P02
Ungültige Eingabesyntax: 'hola' ist keine ganze Zahl
22012
Division durch null
42601
Syntaxfehler
42703
Diese Spalte gibt es nicht
42P01
Diese Tabelle gibt es nicht
42P07
Diese Tabelle gibt es schon
42883
Diese Funktion oder diesen Operator gibt es nicht
42501
Zugriff verweigert
25P02
Die Transaktion ist abgebrochen und nimmt nichts mehr an
40001
Konnte nicht serialisiert werden — wiederholen
40P01
Verklemmung — wiederholen
55P03
Sperre nicht erhalten (NOWAIT oder lock_timeout)
57014
Abfrage abgebrochen (statement_timeout oder jemand hat abgebrochen)
3D000
Diese Datenbank gibt es nicht
28000
Diese Rolle gibt es nicht
Die Klassen, auf die man schauen sollte
Klasse
Bedeutet
Was zu tun ist
08
Verbindung
Neu verbinden und wiederholen
22
Daten
Den Eingabewert korrigieren
23
Integrität
Es liegt an den Daten: dem Benutzer sagen
25
Transaktionszustand
ROLLBACK und von vorn
28
Autorisierung
Anmeldedaten; nicht wiederholen
40
Rücknahme
Die ganze Transaktion wiederholen
42
Syntax oder Zugriff
Ein Programmfehler: Wiederholen hilft nicht
53
Zu wenig Ressourcen
Warten oder vergrößern
55
Objekt nicht im richtigen Zustand
Je nach Fall; 55P03 ist eine Sperre
57
Eingriff des Betreibers
Jemand 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.
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 Sie fast immer filtern. 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:
Vorgang
Zeit
ATTACH ohne vorherigen CHECK
67,9 ms
DETACH
0,7 ms
ATTACH mit gleichwertigem, bereits validiertem CHECK
0,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 ATTACHdie 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
Partitionieren Sie nach dem, was Sie löschen werden, nicht nach dem, was Sie abfragen werden; und prüfen Sie am Plan, dass Ihre Abfragen den Schlüssel im WHERE führen, statt es anzunehmen. Bevor Sie eine bestehende Tabelle partitionieren, fragen Sie sich, 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.
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 Ihren 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:
Wert
Zeichen
Bytes
normal
6
6
ñandú
5
7
日本語
3
9
ein Emoji mit Modifikator plus Text
11
19
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 Cfalsch 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, schauen Sie auf die Sortierfolge, bevor Sie die Abfrage anfassen.
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
Grenze
Wert
Was beim Überschreiten passiert
Länge einer Kennung
63 Byte
Sie wird abgeschnitten, mit einem Hinweis
Spalten je Tabelle
1 600
54011 tables can have at most 1600 columns
Spalten je Index
32
54011 cannot use more than 32 columns in an index
Seitengröße
8 kB
Fest, 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.
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 varcharUTF-8.
Gemessen auf dem Testserver (SQL Server 2025), mit der Datenbank in Latin1_General_100_CI_AS_SC_UTF8:
Wert
Bytes in varchar
Bytes in nvarchar
ñ
2
2
日本語
9
6
ein Emoji
4
4
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_ASA, 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()).
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.
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:
Geschrieben
Gespeichert in datetime
12:34:56.001
12:34:56.000
12:34:56.002
12:34:56.003
12:34:56.005
12:34:56.007
12:34:56.789
12:34:56.790
2026-09-30 23:59:59.999
2026-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.
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üssel
Fragmentierung
Seiten
Mittlere Füllung
NEWID()
98 %
116
62 %
NEWSEQUENTIALID()
1 %
72
99,5 %
int IDENTITY
2 %
43
98 %
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 json5,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)“.
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
Level
Bedeutung
Gemessen
0–10
Informativ: kein Fehler
RAISERROR('aviso', 10, 1) wird ohne Msg ausgegeben und springt nicht in den CATCH
11–16
Vom Benutzer behebbar: die Daten, die Syntax, die Berechtigung
1205 ist 13; 2627, 2601, 229 und 916 sind 14; 102 ist 15; fast alles andere 16
17–19
Ressourcen oder ein interner Serverfehler
—
20–25
Fatal: Der Server schließt die Verbindung
RAISERROR(…, 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:
Msg
Was passiert ist
2627
Doppelter Schlüssel in einem PRIMARY KEY oder einer UNIQUE-Einschränkung; die Meldung bringt den Namen und den Wert mit
2601
Doppelter Schlüssel in einem eindeutigen Index (keine Einschränkung)
547
Ein Fremdschlüssel oder ein CHECK hat Nein gesagt; dieselbe Nummer für INSERT, DELETE und CHECK
515
NULL in einer NOT NULL-Spalte
2628
Der Text passt nicht; bringt Tabelle, Spalte und den abgeschnittenen Wert mit
8152
Dasselbe, ohne Details: Diese Meldung kommt bei Kompatibilitätsgrad 140 oder niedriger
245
Unmögliche Konvertierung: CAST('hola' AS int)
8134
Division durch null
208
Dieses Objekt existiert nicht
207
Diese Spalte existiert nicht
102
Syntaxfehler (Incorrect syntax near 'FORM')
2812
„Prozedur nicht gefunden“: SELEC 1 liefert sie, weil ein Batch, der mit einem einzelnen Wort beginnt, ein EXEC ist
229
Berechtigung für ein Objekt verweigert
916
Der Login hat keinen Benutzer in dieser Datenbank
911
USE einer Datenbank, die nicht existiert
1205
Opfer eines Deadlocks: „Rerun the transaction“
1222
LOCK_TIMEOUT beim Warten auf eine Sperre abgelaufen
3902
COMMIT 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 TRYnicht 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 CATCHwirft 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.
- RAISERRORhä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 Protokoll
Grund, den es schreibt
5
Es gibt keinen Login mit diesem Namen
8
Das Passwort stimmt nicht
38
Die 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.
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:
Zeitpunkt
Fragmentierung
Seiten
Füllgrad
Frisch angelegt
8 %
352
98 %
Nach 50 000 Einfügungen
99 %
696
62 %
Nach REORGANIZE
0,5 %
434
99,6 %
Nach REBUILD
0 %
433
99,8 %
REBUILD WITH (FILLFACTOR = 70)
0 %
618
70 %
Und weitere 50 000 Einfügungen
0 %
618
84 %
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 Sicht lebt im Speicher und wird beim Neustart des Servers geleert. Gemessen: Ich habe den Container neu gestartet, und die Sicht 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).
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
Grenze
Wert
Was beim Überschreiten passiert
Byte pro Zeile (innerhalb der Seite)
8 060
Msg 1701, wenn die festen Spalten nicht passen
Schlüssel eines gruppierten Index
900 Byte
Warnung beim Anlegen; Msg 1946 beim Einfügen
Schlüssel eines nicht gruppierten Index
1 700 Byte
Warnung beim Anlegen; Msg 1946 beim Einfügen
Spalten pro Tabelle
1 024
Msg 1702
Spalten im Schlüssel eines Index
32
Msg 1904
Nicht gruppierte Indizes pro Tabelle
999
Msg 1910
Länge eines Namens
128 Zeichen
Msg 103
Name einer temporären #-Tabelle
116 Zeichen
Msg 193
Parameter pro Prozedur oder Anfrage
2 100
Msg 180
Verschachtelung von Prozeduren, Funktionen und Triggern
32 Ebenen
Msg 217
Spalten eines SELECT
4 096
Msg 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 Sichten 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.
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 —statussleeping, 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_statusWAIT. 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.
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.
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_typeLCK_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 ALTERMsg 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:
Anweisung
Zeit
Protokoll
Neu geschrieben?
ADD c int NULL
4 ms
1 kB
nein
ADD c int NOT NULL DEFAULT 7
20 ms
2 kB
nein
ADD c datetime2 NOT NULL DEFAULT SYSDATETIME()
< 1 ms
2 kB
nein
ADD c uniqueidentifier NOT NULL DEFAULT NEWID()
259 ms
38 MB
ja
ALTER COLUMN estado varchar(100) (war 50)
4 ms
< 1 kB
nein
ALTER COLUMN estado varchar(20) (war 50)
237 ms
36 MB
ja
ALTER COLUMN estado nvarchar(50) (war varchar)
244 ms
38 MB
ja
ALTER COLUMN n bigint (war int)
228 ms
36 MB
ja
ALTER COLUMN importe decimal(12,2) (war 10,2)
99 ms
1,5 kB
nein
ALTER COLUMN importe decimal(20,2) (war 10,2)
246 ms
38 MB
ja
ALTER COLUMN nota varchar(200) NOT NULL (war NULL)
241 ms
36 MB
ja
ALTER COLUMN cliente int NULL (war NOT NULL)
4 ms
< 1 kB
nein
DROP COLUMN nota
5 ms
1 kB
nein
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 … REBUILDohneONLINE: 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
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:
Filter
Partitionen
Lesevorgänge
creado BETWEEN '2024-03-01' AND '2024-03-31'
1
38
derselbe Bereich mit einer Variablen oder einem Parameter
1
38
cliente = 42
4 von 4
—
YEAR(creado) = 2024
4 von 4
1.238
CAST(creado AS datetime) zwischen zwei Datumswerten
4 von 4
46
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:
Operation
Zeit
Protokoll
DELETE … WHERE creado < '2024-01-01'
180 ms
21 MB
TRUNCATE TABLE dbo.ventas WITH (PARTITIONS (1))
4,9 ms
24 kB
ALTER TABLE dbo.ventas SWITCH PARTITION 1 TO dbo.ventas_2023
< 1 ms
1,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 SWITCHMsg 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');
Operation
Zeit
Protokoll
SPLIT auf der leeren Partition
< 1 ms
3,5 kB
SPLIT RANGE ('2024-07-01') auf der vollen von 2024
347 ms
11 MB
MERGE dieser beiden Hälften
167 ms
6,2 MB
MERGE mit der leeren Partition
< 1 ms
3,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
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 mit
Kleiner Kunde
Großer Kunde
dem kleinen
62 Lesevorgänge
300.177 Lesevorgänge, 99 ms
dem großen
6.085 Lesevorgänge
6.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.
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:
Modell
Was das Protokoll behält
Wiederherstellung zu einem Zeitpunkt
SIMPLE
Leert sich bei jedem CHECKPOINT von selbst
Nein: nur bis zur letzten Sicherung
FULL
Alles, bis du es mit BACKUP LOG sicherst
Ja
BULK_LOGGED
Alles außer Massenladevorgängen, die es seitenweise vermerkt
Ja, 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 LOGMsg 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_LOGGED1,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 DATABASE
Backup von Calíope
Was es ist
Die Seiten der Datenbank
Ein SQL-Skript
Wo es landet
Auf der Platte des Servers
Wo du willst
Wiederherstellbar auf
Derselben oder einer neueren Version, laut Dokumentation
Einem anderen Server oder einer anderen Version, und es ist lesbar
Zu einem Zeitpunkt
Ja, mit dem Protokoll
Nein: Es ist der Moment des Dumps
Größe und Tempo
Schnell bei jeder Größe
Langsam 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.
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. Sie finden:
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 LOGINprü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.
- DENYverbietet und gilt vor jedem GRANT: Mit DENY SELECT auf rrhh.nominas bekam anaMsg 229, obwohl das Schema gewährt war, und auch als Mitglied von db_datareader.
- REVOKElö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 Sicht oder eine Prozedur und die Tabelle, die sie liest, denselben Besitzer haben, braucht, wer die Sicht benutzen darf, keine Berechtigung auf die Tabelle. ana, ohne irgendetwas auf dbo.pedidos, las über die Sicht 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 sid0x01, 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 Sicht 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.
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“.
Geschrieben ist nicht angewendet: value und value_in_use sp_configureschreibt den Wert nur, und der Server bleibt beim alten, bis RECONFIGURE kommt. Nach sp_configure 'cost threshold for parallelism', 50 stand in sys.configurationsvalue 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 demo41 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 Variablensys.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
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 ROLLBACKMsg 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 COMMITbestä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 ROLLBACKmacht 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.
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.
// Embebido: el pedido lleva sus líneas dentro
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 })
// Referencia: las líneas viven aparte y apuntan al pedido
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 $lookup0,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
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.
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.
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
// Lo creado desde el 1 de septiembre, por el índice de _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.
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.
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.
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: aquí es el mismo valor
Zwei weitere Warnungen, beide gemessen. $regexignoriert 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".
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.
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.
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.
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.
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 } y dos mas
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.
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.
$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.
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.
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() // la primera sí pasa
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.
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.
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 y rejectedPlans con 1 candidato
db.pedidos.find(q).explain("executionStats")
// + executionStats: nReturned 20, docs 20, claves 20, 0 ms
db.pedidos.find(q).explain("allPlansExecution")
// + allPlansExecution: los 2 planes probados, FETCH 20 y 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.
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 des RAM minus 1 GB. 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 - la mitad de la RAM menos 1 GB
w["bytes currently in the cache"] // 18640438 - de todo el servidor
w["pages requested from the cache"] // 486864
w["pages read into cache"] // 261 - una de cada 1865
db.pedidos.stats().size // 1385000 de datos
db.pedidos.stats().totalIndexSize // 499712 de indices
Der Profiler
Drei Stufen: 0 aus, 1 nur das, was über slowms liegt, und 2alles. 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.
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.
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.
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 - lo mismo de arriba, escrito donde se queda
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 } <- devuelve el valor de ANTES, no el nuevo
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.
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.
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.
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"]
// ya conectado como lector, con --authenticationDatabase ventas:
db.datos.findOne() // { _id: 1, v: 1 }
db.datos.insertOne({ _id: 2 })
// 13 Unauthorized - not authorized on ventas to execute command { insert: ... }
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.
// un nodo con --auth y sin un solo usuario, desde el propio nodo:
db.getSiblingDB("prueba").c.findOne()
// 13 Unauthorized
db.getSiblingDB("admin").createUser({ user: "primero", pwd: "x", roles: ["root"] })
// OK - este es el unico que deja
db.getSiblingDB("admin").createUser({ user: "segundo", pwd: "x", roles: ["root"] })
// 13 Unauthorized - Command createUser requires authentication
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 <- el tamano LOGICO de los documentos
// pedidos.metadata.json 255 <- los indices, sin sus datos
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_ y 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, frente a 2420000 del BSON suelto
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.
// esto sólo existe en un conjunto de réplicas:
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.
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.
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.
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 modificados en 61 ms
db.pedidos.updateMany({}, { $unset: { moneda: "" } })
// 20000 modificados en 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.
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.
# tres piezas distintas, y el enrutador no guarda datos
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.
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.
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" } })
// copia la coleccion entera: seguia en marcha a los dos minutos,
// con su system.resharding.<uuid> visible en 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.
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.
Code
Name
Was passiert ist
11000
—
doppelter Schlüssel in einem eindeutigen Index
13
Unauthorized
dem Benutzer fehlt eine Aktion auf dieser Datenbank
18
AuthenticationFailed
Benutzer, Passwort oder Authentifizierungsdatenbank
26
NamespaceNotFound
die Sammlung gibt es nicht
50
MaxTimeMSExpired
die gegebene Zeit war um
112
WriteConflict
eine andere Transaktion hat dieses Dokument angefasst
121
—
das Dokument kam nicht durch den Validator
251
NoSuchTransaction
die 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.
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».
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, y ningun error
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.
Obergrenze
Gemessener Wert
Wie sie sich meldet
Größe eines Dokuments
16 777 216 Bytes
10334
Verschachtelungstiefe
179 Ebenen in einem insertOne
15 Overflow
Indizes pro Sammlung
64, _id_ mitgezählt
67 CannotCreateIndex
Felder eines zusammengesetzten Index
32
13103
Name einer Datenbank
63 Zeichen
73 InvalidNamespace
Datenbank + Sammlung
255 Zeichen
73 InvalidNamespace
Größe eines indizierten Werts
keine praktische Grenze
—
blockierende Stufe in einer Pipeline
104 857 600 Bytes
146
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.
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 })
// crea 63, y el 64 falla: el _id_ tambien cuenta
// 67 CannotCreateIndex :: add index fails, too many indexes
const k = {}
for (let i = 0; i < 33; i++) k["g" + i] = 1
db.comp.createIndex(k) // con 32 pasa
// 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) }) // entra: no hay tope de clave
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.
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 }
// en un nodo suelto ni existe: "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 }
// el filtro por type hace falta: stats() sobre una vista falla
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); -- sin tipo declarado: vale
INSERT INTO libre VALUES (1), (1.5), ('hola'), (x'0001'), (NULL);
SELECT typeof(v) FROM libre ORDER BY v;
-- null | integer | real | text | blob <- y ese es el orden entre clases
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'); -- entra: 42, convertible sin perder nada
INSERT INTO s VALUES (1, 42); -- entra: el 42 se guarda como texto '42'
INSERT INTO s VALUES ('abc', 'a');
-- cannot store TEXT value in INTEGER column s.i
CREATE TABLE s2 (d DATETIME) STRICT;
-- unknown datatype for s2.d: "DATETIME"
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
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: el de fábrica, no WAL
PRAGMA busy_timeout; -- 0: no espera nada
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, el de por omision
UPDATE t SET v = 10 WHERE i = 1;
SAVEPOINT s1;
UPDATE t SET v = 20 WHERE i = 1;
ROLLBACK TO s1; -- deshace hasta aqui, NO cierra la transaccion
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.
# dos conexiones a la vez, con timeout=0 para que conteste en el acto
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,) <- lee el valor de antes
a.execute("BEGIN IMMEDIATE") # SQLITE_BUSY (5) database is locked
a.execute("PRAGMA busy_timeout=800")
a.execute("BEGIN IMMEDIATE") # espera 895 ms y vuelve a dar 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; -- aqui A se queda con una foto de la base
-- B, en otra conexion:
BEGIN IMMEDIATE; UPDATE t SET v = 5 WHERE i = 2; COMMIT;
-- A, que ahora quiere escribir:
UPDATE t SET v = 2 WHERE i = 1;
-- SQLITE_BUSY_SNAPSHOT (517), y busy_timeout no lo arregla
ROLLBACK; -- la unica salida: soltar y volver a empezar
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.
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.
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); -- entra: no hay padre 999 y da igual
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.
-- se quedan escritos en el archivo:
PRAGMA journal_mode = WAL;
PRAGMA user_version = 7;
PRAGMA page_size = 8192; -- solo en una base todavia VACIA
PRAGMA auto_vacuum = FULL; -- idem
-- son de la conexion, y hay que repetirlos en cada una:
PRAGMA foreign_keys = ON;
PRAGMA cache_size = -8000; -- en negativo son kibibytes, no paginas
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, y el -wal queda en 0 bytes
ANALYZE;
SELECT * FROM sqlite_stat1; -- t | iv | 20000 20000
PRAGMA optimize; -- no devuelve nada
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) con 20 000 filas
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 <- sin repetir el filtro, el indice no existe
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 ROWIDist 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 filas: 1 335 296 bytes, frente a 1 675 264 con rowid
EXPLAIN QUERY PLAN SELECT v FROM kv WHERE k = 'clave-000042';
-- SEARCH kv USING PRIMARY KEY (k=?)
-- con rowid habria dicho: USING INDEX sqlite_autoindex_kv_1 (k=?)
ANALYZE;
SELECT tbl, idx, stat FROM sqlite_stat1;
-- t | ia | 20000 20000 <- 20 000 filas, 20 000 por cada valor de a
-- t | ib | 20000 1 <- 20 000 filas, 1 por cada valor de b
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:
Wie
Zeit
eine nach der anderen, ohne Transaktion
3 963 ms
eine nach der anderen, mit synchronous = OFF
2 442 ms
eine nach der anderen, im WAL-Modus
258 ms
die 20 000 in einem BEGIN
9 ms
in einem BEGIN, im WAL-Modus
10 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, cada uno con su propia transaccion: 3963 ms
-- los mismos 20 000 aqui dentro: 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 pagina, y el archivo sigue igual de grande
VACUUM; -- 5410816 bytes, en 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, y el recorrido en 3 ms
PRAGMA page_size = 4096; -- 4333568 bytes, y en 0 ms <- el de fabrica
PRAGMA page_size = 65536; -- 4390912 bytes, y en 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.
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 <- no existe, y nunca ha existido
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 Sicht benutzt, wird ohne einen Mucks entfernt, die Sicht bleibt kaputt zurück, und PRAGMA integrity_check sagt weiterhin ok, weil es nicht in Sichten 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; -- PASA, sin una queja
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;
-- y aqui se vuelven a crear indices, disparadores y vistas
COMMIT;
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;
Drei Vorsichtsmaßnahmen. Indizes, Trigger und Sichten 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 Sicht, 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;
-- tras 30 000 filas, los tres archivos miden:
-- base.sqlite 4096 <- solo la cabecera
-- base.sqlite-shm 32768
-- base.sqlite-wal 3366072 <- aqui estan los datos
-- copiar solo base.sqlite da una base que abre, y esta VACIA:
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 en 4 ms, con las 30 000 filas dentro
-- y la copia sale en journal_mode delete, no en 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
-- con la misma base danada a proposito, los dos contestan igual:
-- *** 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.
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.
Code
Name
Was passiert ist
5
SQLITE_BUSY
eine andere Verbindung hält die Schreibsperre
6
SQLITE_LOCKED
die Sperre hältst du, in einer anderen Anweisung
8
SQLITE_READONLY
Datei, Verzeichnis oder Verbindung lassen kein Schreiben zu
11
SQLITE_CORRUPT
die Datei ergibt keinen Sinn mehr
13
SQLITE_FULL
es passt nicht: die Platte, oder max_page_count
19
SQLITE_CONSTRAINT
und hier muss man den erweiterten ansehen
21
SQLITE_MISUSE
die API wurde falsch benutzt
26
SQLITE_NOTADB
es 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
-- con la base abierta en modo solo lectura:
INSERT INTO t ... ; -- 8 SQLITE_READONLY attempt to write a readonly database
-- con un cursor de SELECT todavia abierto, en la MISMA conexion:
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.
-- con la pagina del esquema machacada:
SELECT count(*) FROM t; -- 11 SQLITE_CORRUPT database disk image is malformed
-- con el tamano de pagina de la cabecera machacado:
SELECT count(*) FROM t; -- 26 SQLITE_NOTADB file is not a database
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.
Obergrenze
Wert
Wie sie sich meldet
Spalten pro Tabelle
2 000
too many columns on b
Terme einer zusammengesetzten Abfrage
500
too many terms in compound SELECT
angehängte Datenbanken
10
too many attached databases - max 10
Länge eines Textes oder Blobs
1 000 000 000
—
Seitengröße
65 536
nichts, und das ist das Schlimme
Parameter einer Anweisung
250 000
—
Tiefe eines Ausdrucks
1 000
—
Seiten einer Datenbank
1 073 741 823
SQLITE_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 <- no lo cogio
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 de pagina = 4 TiB de archivo; 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.
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 BEGIN9 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; -- 0 casi siempre, y casi siempre es un error
PRAGMA synchronous; -- 2 con diario, 1 en WAL
PRAGMA busy_timeout; -- el de TU conexion, no el del motor
PRAGMA page_count; -- x page_size = lo que ocupa
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 <- el indice pesa mas que la tabla
-- 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.
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.%';
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 wirksamen Rechte je Datenbank und je Tabelle — dort tauchen die Überraschungen meist auf.
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, die Sicherung 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 filtert Ereignisse nach Datum, Datenbank und Operationsart — genau das, was von Hand mühsam ist.
Die Position finden SHOW MASTER STATUS 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.
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;
Algorithmus
Was er tut
Typischer Aufwand
INSTANT
nur Metadaten
Millisekunden
INPLACE
baut an Ort und Stelle um
Minuten oder Stunden
COPY
kopiert die ganze Tabelle
Stunden, 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, 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.
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
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 ab 2000 von der Client-Bibliothek. Schon diese Unterscheidung sagt, wo zu suchen ist: beginnt die Zahl mit 2, liegt das Problem in der Verbindung, nicht im SQL.
Code
Meldung
Was es meist ist
1045
Access denied for user
Benutzer, Passwort oder Host passt nicht
1049
Unknown database
die Datenbank gibt es nicht, oder der Benutzer sieht sie nicht
1040
Too many connections
max_connections aufgebraucht
1062
Duplicate entry
Konflikt mit einem UNIQUE oder dem Primärschlüssel
1146
Table doesn't exist
Name falsch geschrieben, oder Groß- und Kleinschreibung unter Linux
1213
Deadlock found
Sperrzyklus; wiederholen ist die Antwort
1205
Lock wait timeout
eine andere Transaktion hält die Sperre
1215
Cannot add foreign key
verschiedene Typen, oder fehlender Index am Ziel
1267
Illegal mix of collations
zwei Spalten mit verschiedenen Kollationen
1406
Data too long for column
der Wert passt nicht in den deklarierten Typ
2002
Can't connect through socket
der Server läuft nicht, oder es ist der falsche Socket
2006
MySQL server has gone away
wait_timeout oder max_allowed_packet
2013
Lost connection during query
Abfrage 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.