wer aenderte was, wann, und wie man es zuverlaessig festhaelt
Ein Audit Trail beantwortet die Frage, wer wann welchen Wert in welcher Zeile geaendert hat, und Anwendungslogs allein koennen diese Frage selten zuverlaessig beantworten. Dieser Beitrag zeigt drei etablierte Muster fuer Change-History-Tabellen in SQL, von Shadow Tables mit Triggern ueber eine zentrale Audit-Tabelle bis zu nativen System-Versioned Tables, inklusive Performance-Kosten und Aufbewahrungsstrategien.
Inhaltsverzeichnis
- 1. Was ein Audit Trail ist und warum Anwendungslogs nicht reichen
- 2. Muster 1: Shadow Table pro Tabelle mit Trigger
- 3. Muster 2: zentrale Audit-Tabelle mit JSON-Payload
- 4. Muster 3: Temporal Tables nach SQL-Standard
- 5. Was in jedem Audit-Eintrag stehen muss
- 6. Performance-Auswirkungen von Triggern auf die Schreiblast
- 7. Aufbewahrung, Partitionierung und Archivierung von History-Daten
- 8. Audit Trails und Compliance-Anforderungen
- 9. Die drei Muster im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Audit Trail ist und warum Anwendungslogs nicht reichen
Ein Audit Trail ist eine lueckenlose, unveraenderliche Aufzeichnung aller Aenderungen an einem Datensatz: wer hat wann welchen Wert von was auf was geaendert. Anders als ein gewoehnliches Anwendungslog, das meist als Textzeile in einer Datei oder einem Log-Aggregator landet, ist ein Audit Trail strukturiert, abfragbar und typischerweise in derselben Datenbank verankert wie die Daten selbst, damit er dieselben Transaktions- und Integritaetsgarantien geniesst.
Anwendungslogs allein scheitern aus mehreren Gruenden: Sie erfassen nur Aenderungen, die ueber die geloggte Anwendungsschicht laufen, waehrend direkte Datenbankzugriffe, Batch-Jobs oder manuelle Admin-Skripte oft unbemerkt vorbeigehen. Sie sind selten strukturiert genug, um praezise nach "alle Aenderungen an Feld X in den letzten 30 Tagen" zu filtern. Und sie werden haeufig nach einer Rotationsfrist geloescht, waehrend ein Audit Trail aus rechtlichen oder fachlichen Gruenden oft jahrelang aufbewahrt werden muss.
Typische Anwendungsfaelle fuer einen Audit Trail sind Finanzsysteme, in denen jede Aenderung an einem Kontostand nachvollziehbar sein muss, Personalverwaltungssysteme mit Gehaltsaenderungen, oder E-Commerce-Plattformen, bei denen Preisaenderungen im Nachhinein erklaerbar sein muessen. Die folgenden Abschnitte zeigen drei Muster, mit denen sich diese Anforderung direkt in der Datenbank abbilden laesst.
2. Muster 1: Shadow Table pro Tabelle mit Trigger
Das erste Muster legt fuer jede zu ueberwachende Tabelle eine strukturell fast identische Shadow-Tabelle an, die jede Version einer Zeile als eigene History-Zeile speichert. Ein Trigger auf INSERT, UPDATE und DELETE schreibt bei jeder Aenderung automatisch eine Kopie des alten oder neuen Zustands in diese Shadow-Tabelle, vollstaendig transparent fuer die Anwendung.
-- Shadow Table fuer die products-Tabelle
CREATE TABLE products_history (
history_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL,
name VARCHAR(255) NOT NULL,
operation CHAR(1) NOT NULL, -- 'I', 'U', 'D'
changed_by INTEGER NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Trigger-Funktion (PostgreSQL, plpgsql)
CREATE OR REPLACE FUNCTION log_products_change() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO products_history (product_id, price, name, operation, changed_by)
VALUES (OLD.product_id, OLD.price, OLD.name, 'D', current_setting('app.user_id')::int);
RETURN OLD;
ELSE
INSERT INTO products_history (product_id, price, name, operation, changed_by)
VALUES (NEW.product_id, NEW.price, NEW.name,
CASE WHEN TG_OP = 'INSERT' THEN 'I' ELSE 'U' END,
current_setting('app.user_id')::int);
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER products_audit
AFTER INSERT OR UPDATE OR DELETE ON products
FOR EACH ROW EXECUTE FUNCTION log_products_change();
Der Vorteil dieses Musters: Jede Shadow-Tabelle hat exakt dieselbe Struktur wie ihre Quelltabelle, was Abfragen intuitiv und typsicher macht. Der Nachteil zeigt sich bei Schema-Aenderungen: Jede neue Spalte in products erfordert eine parallele Aenderung in products_history und eine Anpassung der Trigger-Funktion, was bei vielen ueberwachten Tabellen schnell zu Pflegeaufwand fuehrt.
3. Muster 2: zentrale Audit-Tabelle mit JSON-Payload
Das zweite Muster verzichtet auf eine Shadow-Tabelle pro Quelltabelle und sammelt stattdessen alle Aenderungen aller ueberwachten Tabellen in einer einzigen zentralen Audit Trail-Tabelle. Alte und neue Werte werden dabei nicht in typisierten Spalten, sondern als JSON- beziehungsweise JSONB-Dokument gespeichert, wodurch die Struktur unabhaengig vom Schema der Quelltabelle bleibt.
-- Zentrale Audit-Tabelle fuer alle ueberwachten Tabellen
CREATE TABLE audit_log (
audit_id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
record_id INTEGER NOT NULL,
operation CHAR(1) NOT NULL, -- 'I', 'U', 'D'
old_values JSONB,
new_values JSONB,
changed_by INTEGER NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Generische Trigger-Funktion, funktioniert fuer beliebige Tabellen
CREATE OR REPLACE FUNCTION audit_generic() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_by)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
LEFT(TG_OP, 1),
CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) ELSE NULL END,
CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) ELSE NULL END,
current_setting('app.user_id')::int
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Nur eine Zeile Trigger-Definition pro neuer Tabelle noetig
CREATE TRIGGER orders_audit AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_generic();
Dieses Muster skaliert deutlich besser auf viele Tabellen, weil dieselbe Trigger-Funktion universell wiederverwendet wird und Schema-Aenderungen an der Quelltabelle automatisch im JSON-Dokument erscheinen, ohne die Audit-Tabelle selbst anzupassen. Der Preis ist Abfragekomplexitaet: Filtern nach einem einzelnen Feldwert erfordert JSON-Operatoren wie old_values->>'price' statt eines einfachen Spaltenvergleichs, was ohne passende GIN-Indizes auf JSONB-Spalten langsam werden kann.
4. Muster 3: Temporal Tables nach SQL-Standard
Das dritte Muster nutzt native Unterstuetzung fuer System-Versioned Tables, wie sie der SQL-Standard SQL:2011 vorsieht und SQL Server sowie MariaDB direkt implementieren. Die Datenbank verwaltet hierbei automatisch eine zweite Tabelle mit allen historischen Zeilenversionen, ganz ohne manuell geschriebene Trigger.
-- SQL Server: System-Versioned Table, komplett ohne eigenen Trigger-Code
CREATE TABLE contracts (
contract_id INT PRIMARY KEY,
customer_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
valid_from DATETIME2 GENERATED ALWAYS AS ROW START,
valid_to DATETIME2 GENERATED ALWAYS AS ROW END,
PERIOD FOR SYSTEM_TIME (valid_from, valid_to)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.contracts_history));
-- Aenderungen laufen wie gewohnt, Historie entsteht automatisch
UPDATE contracts SET amount = 4200.00 WHERE contract_id = 501;
-- Zustand zu einem bestimmten Zeitpunkt abfragen, ganz ohne eigene Logik
SELECT * FROM contracts
FOR SYSTEM_TIME AS OF '2026-05-01T00:00:00'
WHERE contract_id = 501;
-- MariaDB: sehr aehnliche Syntax mit "WITH SYSTEM VERSIONING"
-- CREATE TABLE contracts (...) WITH SYSTEM VERSIONING;
Der grosse Vorteil von Temporal Tables ist, dass die Datenbank die gesamte Versionierungslogik uebernimmt, inklusive automatischer Erzeugung der History-Tabelle und der Zeitreise-Abfragen per FOR SYSTEM_TIME AS OF. Der Nachteil ist eingeschraenkte Portabilitaet, weil dieses Feature stark vom eingesetzten Datenbanksystem abhaengt und PostgreSQL beispielsweise keine native Implementierung bietet, sondern auf Erweiterungen oder manuelle Muster wie Shadow Tables angewiesen ist.
5. Was in jedem Audit-Eintrag stehen muss
Unabhaengig vom gewaehlten Muster braucht ein vollstaendiger Audit Trail-Eintrag mindestens fuenf Informationen: wer die Aenderung ausgeloest hat, wann sie stattfand, welche Art von Operation es war, den Zustand vor der Aenderung und den Zustand danach. Fehlt eines dieser Felder, verliert der Audit Trail seinen Wert fuer Nachvollziehbarkeit und Forensik erheblich.
Ein oft vergessenes Detail ist der Kontext der Aenderung: War es ein direkter Anwendungsaufruf, ein Batch-Job oder ein manueller Admin-Eingriff? Ein zusaetzliches Feld source oder context mit Werten wie 'app', 'batch' oder 'admin_console' beantwortet diese Frage, ohne die Kernstruktur zu verkomplizieren, und ist bei Sicherheitsvorfaellen oder Support-Anfragen oft entscheidend fuer die Einordnung.
6. Performance-Auswirkungen von Triggern auf die Schreiblast
Jeder Trigger-basierte Audit Trail erhoeht die Latenz jeder einzelnen Schreiboperation, weil zusaetzlich zur eigentlichen Aenderung ein Insert in die History- oder Audit-Tabelle innerhalb derselben Transaktion erfolgt. Bei Tabellen mit sehr hoher Schreiblast, etwa mehreren tausend Updates pro Sekunde, kann dieser Overhead spuerbar werden, insbesondere wenn die Audit-Tabelle eigene Indizes pflegen muss.
-- Messung des Trigger-Overheads (PostgreSQL)
EXPLAIN ANALYZE
UPDATE products SET price = price * 1.05 WHERE product_id = 42;
-- Mit aktivem Audit-Trigger enthaelt der Plan den zusaetzlichen INSERT
-- in die audit_log-Tabelle als Teil derselben Ausfuehrung
-- Reduzierung des Overheads durch selektives Auditing:
-- nur tatsaechlich geaenderte Spalten pruefen statt jede UPDATE-Anweisung
CREATE OR REPLACE FUNCTION audit_if_changed() RETURNS TRIGGER AS $$
BEGIN
IF NEW.price IS DISTINCT FROM OLD.price THEN
INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_by)
VALUES ('products', NEW.product_id, 'U',
jsonb_build_object('price', OLD.price),
jsonb_build_object('price', NEW.price),
current_setting('app.user_id')::int);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Eine Strategie zur Reduzierung dieses Overheads ist selektives Auditing: Statt jede Spaltenaenderung zu erfassen, protokolliert der Trigger nur tatsaechlich fachlich relevante Spalten, etwa Preis oder Status, waehrend rein technische Felder wie updated_at ausgenommen werden. Asynchrones Auditing ueber Change Data Capture, bei dem die Aenderung aus dem Transaktionslog statt per Trigger gelesen wird, reduziert die Latenz im kritischen Schreibpfad zusaetzlich, erhoeht aber die architektonische Komplexitaet.
7. Aufbewahrung, Partitionierung und Archivierung von History-Daten
Ein Audit Trail waechst monoton und ohne natuerliche Obergrenze, solange Daten geaendert werden. Ohne Partitionierungsstrategie wird die Audit-Tabelle nach einigen Jahren zur groessten Tabelle im gesamten System, was sowohl Backups als auch Abfragen gegen aktuelle Eintraege verlangsamt. Partitionierung nach Monat oder Quartal, basierend auf dem changed_at-Zeitstempel, haelt einzelne Partitionen klein und erlaubt, alte Partitionen unabhaengig zu archivieren oder auf guenstigeren Speicher zu verschieben.
Eine gaengige Strategie kombiniert Partitionierung mit einer Aufbewahrungsrichtlinie: Die juengsten zwoelf Monate bleiben in performanten Partitionen auf schnellem Speicher, aeltere Partitionen werden monatlich auf ein guenstigeres Cold-Storage-System verschoben oder komprimiert exportiert. Wichtig dabei: Ein Audit Trail darf, anders als operative Daten, praktisch nie geloescht werden, solange gesetzliche Aufbewahrungsfristen greifen, weshalb Archivierung stets vor Loeschung stehen sollte.
8. Audit Trails und Compliance-Anforderungen
Regulierungen wie die DSGVO in Europa oder branchenspezifische Vorgaben im Finanz- und Gesundheitswesen verlangen haeufig explizit einen nachvollziehbaren Audit Trail fuer Aenderungen an personenbezogenen oder finanziell relevanten Daten. Gleichzeitig verlangt dieselbe DSGVO ein "Recht auf Loeschung", was in Konflikt mit einem unveraenderlichen Audit Trail geraten kann, wenn ein Nutzer die vollstaendige Entfernung seiner Daten verlangt.
Ein praxistauglicher Kompromiss ist Pseudonymisierung statt Loeschung im Audit-Kontext: Personenbezogene Felder im old_values/new_values-JSON werden bei einer Loeschanfrage durch einen Hash oder Platzhalter ersetzt, waehrend die Tatsache und der Zeitpunkt der urspruenglichen Aenderung nachvollziehbar bleiben. Diese Entscheidung sollte fruehzeitig mit Datenschutzbeauftragten abgestimmt werden, weil sie direkten Einfluss auf das Schema der Audit-Tabelle hat und nachtraeglich schwer zu aendern ist.
9. Die drei Muster im Vergleich
Die folgende Tabelle vergleicht Shadow Table, zentrale JSON-Audit-Tabelle und Temporal Tables nach den wichtigsten Entscheidungskriterien fuer einen Audit Trail.
| Muster | Skalierung auf viele Tabellen | Abfrage-Ergonomie | Portabilitaet |
|---|---|---|---|
| Shadow Table | Hoher Pflegeaufwand pro Tabelle | Typisiert, sehr komfortabel | Datenbankunabhaengig |
| Zentrale JSON-Tabelle | Eine Trigger-Funktion fuer alle | JSON-Operatoren noetig | Benoetigt JSON/JSONB-Support |
| Temporal Table | Kein eigener Trigger-Code | FOR SYSTEM_TIME nativ | Stark DB-spezifisch |
10. Zusammenfassung
Ein Audit Trail beantwortet zuverlaessig, wer welchen Wert wann geaendert hat, eine Aufgabe, die Anwendungslogs allein selten vollstaendig und dauerhaft erfuellen. Shadow Tables mit Triggern bieten typisierte, komfortable Abfragen auf Kosten von Pflegeaufwand bei Schema-Aenderungen, eine zentrale Audit-Tabelle mit JSON-Payload skaliert besser ueber viele Tabellen hinweg, und native Temporal Tables reduzieren eigenen Code auf null, koppeln das Projekt aber enger an ein spezifisches Datenbanksystem.
Unabhaengig vom Muster gilt: Ein vollstaendiger Audit Trail-Eintrag braucht Nutzer, Zeitpunkt, Operation, alten und neuen Zustand sowie idealerweise den Aenderungskontext. Partitionierung nach Zeit haelt die Tabelle performant, und Compliance-Anforderungen wie das DSGVO-Loeschrecht sollten von Anfang an in die Schema-Entscheidung einfliessen, statt nachtraeglich nachgeruestet zu werden.
Audit Trails und Change History Tabellen, das Wichtigste auf einen Blick
Shadow Table
Strukturell identische History-Tabelle pro Quelltabelle, typisiert und komfortabel, aber pflegeintensiv.
Zentrale JSON-Tabelle
Eine universelle Trigger-Funktion fuer alle Tabellen, alte und neue Werte als JSONB gespeichert.
Temporal Tables
Native Versionierung ohne eigenen Trigger-Code, aber stark abhaengig vom Datenbanksystem.
Aufbewahrung
Partitionierung nach Zeit haelt Audit-Tabellen performant, Loeschung nur nach Ablauf gesetzlicher Fristen.