INSERT, UPDATE, DELETE: изменение данных
DML-команды (Data Manipulation Language) — это четыре операции изменения данных: INSERT (добавление), UPDATE (изменение), DELETE (удаление) и MERGE (upsert по стандарту SQL). В PostgreSQL к ним добавляется мощный RETURNING — возврат изменённых строк без дополнительного SELECT-запроса. DML-команды — самые опасные операции в SQL: UPDATE без WHERE меняет все 50 миллионов строк, DELETE без WHERE удаляет всю таблицу. Но при правильном использовании они позволяют реализовывать сложные паттерны: атомарный upsert через ON CONFLICT, soft delete через updated_at, массовую вставку через единственный INSERT, обновление с подзапросом через UPDATE FROM. Понимание MVCC-поведения этих команд критично для работы с высоконагруженными системами.
Почему это важно: DML-команды используются в каждом backend-приложении тысячи раз в день. Каждая неправильно написанная команда — потенциальный инцидент. Три классических катастрофы, которые случаются регулярно: UPDATE users SET role = 'banned' (без WHERE — все пользователи получили ban), DELETE FROM orders (без WHERE — потеря всех заказов), INSERT в цикле (тысяча INSERT вместо одного с VALUES — в 100 раз медленнее). С другой стороны, правильное использование RETURNING делает код атомарным и устраняет race conditions. ON CONFLICT DO UPDATE реализует upsert — атомарную операцию 'вставь или обнови' без риска дублей. Понимание DML-команд в контексте транзакций и MVCC позволяет строить надёжные системы.
Главная идея
Каждая DML-команда в PostgreSQL выполняется в транзакции (явной или автоматической). В контексте MVCC: INSERT создаёт новую версию строки, UPDATE создаёт новую версию и помечает старую удалённой (но физически не удаляет!), DELETE помечает строку удалённой. Физическое место освобождает только VACUUM. Это объясняет почему после миллиона UPDATE таблица 'раздувается' — каждый UPDATE = новая строка в heap. RETURNING — PostgreSQL-расширение, позволяющее получить данные изменённых строк в той же команде. INSERT ... RETURNING id, created_at возвращает id автогенерированного ключа и дату создания без отдельного SELECT. ON CONFLICT (upsert) — атомарная операция: попытка INSERT, при конфликте — UPDATE. Это нельзя безопасно эмулировать двумя отдельными запросами без транзакции.
Как это выглядит на практике
- Простой INSERT с RETURNING: INSERT INTO users (email, full_name) VALUES ('alice@example.com', 'Alice Smith') RETURNING id, created_at. PostgreSQL вставляет строку и сразу возвращает сгенерированный id и время создания. Не нужен отдельный SELECT lastval() или CURRVAL().
- Массовая вставка нескольких строк за один запрос: INSERT INTO tags (name, slug) VALUES ('SQL', 'sql'), ('PostgreSQL', 'postgresql'), ('Backend', 'backend') RETURNING id, name. Один INSERT гораздо быстрее трёх отдельных — одна round-trip, одна транзакция, меньший WAL overhead.
- INSERT из SELECT — вставка результата другого запроса: INSERT INTO archived_orders SELECT * FROM orders WHERE created_at < '2023-01-01'. Полезно для архивирования, копирования данных, создания снимков. RETURNING работает и здесь.
- UPDATE с WHERE — всегда с WHERE: UPDATE users SET full_name = 'Alice Johnson', updated_at = now() WHERE id = 42 RETURNING id, full_name. WHERE id = 42 — обязательное условие. В psql для безопасности: BEGIN; UPDATE users SET ...; SELECT * FROM users WHERE id = 42; — проверить результат, потом COMMIT или ROLLBACK.
- UPDATE нескольких столбцов: UPDATE products SET price = price * 1.1, updated_at = now() WHERE category = 'electronics' AND price < 10000 RETURNING id, name, price. Увеличение цены на 10% для электроники дешевле 10000. RETURNING показывает новые цены.
- UPDATE FROM — обновление с данными из другой таблицы: UPDATE order_items oi SET price_at_purchase = p.price FROM products p WHERE oi.product_id = p.id AND oi.order_id = 42. Обновляем цену в позициях заказа из актуальных цен продуктов.
- Soft delete — мягкое удаление через пометку: UPDATE posts SET deleted_at = now(), deleted_by = 7 WHERE id = 123 AND deleted_at IS NULL RETURNING id. Строка остаётся в БД, но фильтруется WHERE deleted_at IS NULL. Преимущества: восстановление возможно, аудит-история сохранена, нет каскадных удалений.
- DELETE с RETURNING: DELETE FROM sessions WHERE expires_at < now() RETURNING id, user_id. Удаляем устаревшие сессии и получаем список удалённых — например для логирования или уведомлений. DELETE FROM ... USING — аналог UPDATE FROM для удаления с JOIN.
- ON CONFLICT (UPSERT) — вставка или обновление: INSERT INTO user_preferences (user_id, key, value) VALUES (42, 'theme', 'dark') ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now() RETURNING *. EXCLUDED — специальная псевдотаблица с данными которые пытались вставить. Атомарная операция без race conditions.
- ON CONFLICT DO NOTHING — идемпотентная вставка: INSERT INTO event_log (event_id, user_id, type) VALUES ('evt-123', 42, 'login') ON CONFLICT (event_id) DO NOTHING. Повторная вставка одного события ничего не сделает. Полезно для обработки дублей событий в event-driven системах.
- TRUNCATE для быстрой очистки таблицы: TRUNCATE TABLE sessions RESTART IDENTITY CASCADE. TRUNCATE удаляет все строки мгновенно (не построчно), сбрасывает sequences (RESTART IDENTITY), и каскадно очищает дочерние таблицы (CASCADE). НЕ активирует row-level триггеры. Поддерживает ROLLBACK в PostgreSQL (в отличие от некоторых других СУБД).
- COPY для массовой загрузки данных: COPY users (email, full_name, created_at) FROM '/tmp/users.csv' WITH (FORMAT csv, HEADER true, NULL 'null'). COPY — самый быстрый способ загрузки данных в PostgreSQL: в 10-100 раз быстрее INSERT за счёт минимального WAL и отключённых проверок во время загрузки. Из приложения: COPY ... FROM STDIN.
Примеры кода
Безопасный UPDATE/DELETE — транзакция + проверка
-- Золотое правило: перед UPDATE/DELETE в psql
-- сначала смотришь SELECT с тем же WHERE
-- 1. Проверяем что найдём нужные строки
SELECT id, email, role FROM users
WHERE email LIKE '%@old-company.com';
-- Видим: 47 строк — ожидаем именно столько
-- 2. Начинаем транзакцию, делаем UPDATE
BEGIN;
UPDATE users
SET is_active = false, updated_at = now()
WHERE email LIKE '%@old-company.com'
RETURNING id, email, is_active;
-- Видим 47 строк с is_active = false — всё верно
-- 3. Если всё правильно — фиксируем
COMMIT;
-- Если ошибка — откатываем
-- ROLLBACK;
-- Для автоматизации: idle_in_transaction_session_timeout
-- предотвращает зависшие транзакции в psql
SET idle_in_transaction_session_timeout = '5min';
Паттерн 'BEGIN → SELECT → UPDATE → проверить RETURNING → COMMIT/ROLLBACK' — обязательная практика при ручных изменениях данных в продакшене. Одна транзакция гарантирует что UPDATE и SELECT видят одинаковый снимок данных.
ON CONFLICT (UPSERT) — реальные паттерны
-- Паттерн 1: Обновить если уже есть, вставить если нет
INSERT INTO user_settings (user_id, setting_key, setting_value, updated_at)
VALUES (42, 'notifications_email', 'true', now())
ON CONFLICT (user_id, setting_key)
DO UPDATE SET
setting_value = EXCLUDED.setting_value,
updated_at = now()
RETURNING *;
-- Паттерн 2: Счётчик просмотров — инкремент при конфликте
INSERT INTO page_views (page_id, date, view_count)
VALUES (101, CURRENT_DATE, 1)
ON CONFLICT (page_id, date)
DO UPDATE SET
view_count = page_views.view_count + 1;
-- page_views.view_count — текущее значение в таблице
-- EXCLUDED.view_count — значение из INSERT (1)
-- Паттерн 3: Идемпотентная вставка событий
INSERT INTO processed_events (event_id, processed_at, result)
VALUES ('evt-abc123', now(), 'success')
ON CONFLICT (event_id) DO NOTHING
RETURNING id;
-- Если NULL вернулось — событие уже было обработано
-- Паттерн 4: Обновить только если значение изменилось
INSERT INTO product_prices (product_id, price, updated_at)
VALUES (55, 1499.99, now())
ON CONFLICT (product_id)
DO UPDATE SET
price = EXCLUDED.price,
updated_at = now()
WHERE product_prices.price <> EXCLUDED.price;
-- WHERE в DO UPDATE — условие на само обновление
EXCLUDED — псевдотаблица с данными из INSERT-части. product_prices.column — текущие данные в таблице. Можно использовать оба в выражениях DO UPDATE SET и в WHERE.
Массовая вставка и COPY — производительность
-- МЕДЛЕННО: 1000 отдельных INSERT (1000 round-trips)
-- for i in range(1000): execute("INSERT INTO logs VALUES (%s)", i)
-- БЫСТРО: один INSERT с множеством VALUES
INSERT INTO logs (user_id, action, created_at) VALUES
(1, 'login', '2024-03-01 10:00:00'),
(2, 'view', '2024-03-01 10:01:00'),
(3, 'logout', '2024-03-01 10:02:00')
-- ... тысячи строк
RETURNING id;
-- ЕЩЁ БЫСТРЕЕ: INSERT из подзапроса
INSERT INTO monthly_summary (user_id, month, order_count, total_spent)
SELECT
user_id,
DATE_TRUNC('month', created_at)::DATE AS month,
COUNT(*) AS order_count,
SUM(total_amount) AS total_spent
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY user_id, DATE_TRUNC('month', created_at)
ON CONFLICT (user_id, month)
DO UPDATE SET
order_count = EXCLUDED.order_count,
total_spent = EXCLUDED.total_spent;
-- МАКСИМАЛЬНО БЫСТРО для загрузки данных: COPY
-- В psql:
-- \COPY users(email, full_name, created_at) FROM 'users.csv' CSV HEADER;
-- В Python (psycopg2):
-- with conn.cursor() as cur:
-- cur.copy_expert("COPY users FROM STDIN WITH CSV HEADER", file)
Сравнение скорости вставки 100 000 строк: 100K отдельных INSERT ≈ 120 секунд; один INSERT с VALUES ≈ 3 секунды; COPY ≈ 0.5 секунды. COPY минимизирует WAL, отключает некоторые проверки и передаёт данные в бинарном протоколе.
Что происходит под капотом
- INSERT в PostgreSQL: создаёт новый heap tuple в странице данных. xmin = ID текущей транзакции (строка видна только после COMMIT). При вставке также обновляются все индексы таблицы. Если INSERT нарушает ограничение (NOT NULL, UNIQUE, FK, CHECK) — операция отклоняется, строка не создаётся, транзакция может быть прервана в зависимости от настроек.
- UPDATE: физически создаёт НОВУЮ версию строки в heap (новый tuple с новым xmin = текущей транзакции) и помечает СТАРУЮ версию как удалённую (xmax = ID текущей транзакции). Старая версия остаётся в файле данных — это MVCC. Читающие транзакции, начавшиеся до UPDATE, продолжают видеть старую версию. VACUUM потом очистит старые версии.
- HOT (Heap Only Tuple) update: если обновляемый столбец НЕ индексирован и новая версия строки помещается в ту же страницу — PostgreSQL делает HOT update: создаёт новый tuple в той же странице с указателем от старого tuple к новому. Индексы при этом не обновляются. HOT значительно быстрее и производит меньше WAL. Это происходит автоматически — разработчику ничего делать не нужно.
- DELETE: помечает tuple как удалённый (xmax = ID текущей транзакции). Физически строка остаётся в файле до VACUUM. Читающие транзакции с более ранним snapshot продолжают видеть строку. После COMMIT + VACUUM: tuple помечается как доступное для переиспользования (free space). VACUUM FULL переписывает таблицу — освобождает место на диске, но блокирует таблицу.
- RETURNING — PostgreSQL-расширение. Данные возвращаются из уже изменённого состояния строки (для INSERT и UPDATE) или из удалённой строки (для DELETE). RETURNING не выполняет дополнительный SELECT — данные берутся из того же heap scan. Это гарантирует атомарность: нет race condition между INSERT и SELECT id.
- ON CONFLICT (upsert) реализован через speculative insert: PostgreSQL пытается INSERT, если обнаруживает конфликт уникальности — выполняет UPDATE или ничего не делает (DO NOTHING). Это атомарная операция, безопасная при конкурентных вставках. Нельзя безопасно эмулировать через SELECT + INSERT/UPDATE — между двумя операциями другая транзакция может вставить запись.
- Массовый INSERT vs COPY: INSERT INTO t VALUES (...),(...),... парсирует SQL, проверяет ограничения построчно, пишет WAL для каждой строки. COPY использует бинарный протокол, минимизирует парсинг, буферизует WAL. Для загрузки миллионов строк COPY в 5-20 раз быстрее INSERT. COPY FREEZE делает строки сразу замороженными (избегает wraparound scan) — для начальной загрузки данных.
- TRUNCATE vs DELETE: DELETE помечает строки одну за другой через MVCC, обновляет индексы построчно, активирует row-level триггеры, поддерживает WHERE. Это O(N). TRUNCATE удаляет все страницы данных таблицы мгновенно (создаёт новый пустой файл), сбрасывает sequence (RESTART IDENTITY), не активирует row-level триггеры. O(1). В PostgreSQL TRUNCATE поддерживает ROLLBACK (в отличие от Oracle).
- UPDATE FROM — обновление с данными из другой таблицы: UPDATE t1 SET col = t2.val FROM t2 WHERE t1.id = t2.id. PostgreSQL выполняет это как JOIN: строит Hash Join или Nested Loop между t1 и t2, обновляет matching строки в t1. Эффективнее цикла UPDATE по одной строке. Эквивалент MySQL UPDATE t1 JOIN t2.
- Функция триггера BEFORE INSERT/UPDATE позволяет изменить данные перед записью: SET updated_at автоматически через триггер. AFTER INSERT/UPDATE/DELETE — для side effects: обновление кеша, отправка уведомлений, аудит-лог. INSTEAD OF — для модифицируемых представлений (views).
- Параллельный INSERT и lock: несколько процессов могут параллельно делать INSERT в одну таблицу — PostgreSQL удерживает ROW EXCLUSIVE LOCK, который не конфликтует с другими ROW EXCLUSIVE LOCK. Конкуренция появляется только при INSERT в одну страницу heap (buffer pin). ON CONFLICT с конкурентными INSERT безопасен благодаря speculative insert protocol.
Типичные ошибки и заблуждения
- «UPDATE без WHERE меняет одну строку если таблица маленькая». UPDATE без WHERE всегда меняет ВСЕ строки — независимо от размера таблицы. Это не баг, это SQL-стандарт. В psql включи \set AUTOCOMMIT off и всегда используй BEGIN; перед UPDATE/DELETE в продакшене.
- «DELETE освобождает место на диске немедленно». DELETE только помечает строки как невидимые (MVCC). Физическое место освобождает VACUUM (делает страницы доступными для новых данных) или VACUUM FULL (перезаписывает файл, освобождает место ОС). После DELETE без VACUUM таблица не уменьшается на диске.
- «ON CONFLICT можно заменить через SELECT + INSERT/UPDATE». Классическая ошибка: SELECT (не найдено) → INSERT → UNIQUE violation (другой процесс вставил между SELECT и INSERT). ON CONFLICT — атомарная операция, не подверженная race condition. Без ON CONFLICT нужен explicit lock или retry-логика.
- «TRUNCATE не поддерживает ROLLBACK». В PostgreSQL TRUNCATE полностью поддерживает ROLLBACK! BEGIN; TRUNCATE orders; ROLLBACK; — данные не потеряны. В Oracle TRUNCATE — DDL команда, автоматически commit. PostgreSQL DDL транзакционна.
- «Массовый INSERT в Python-цикле — нормальная практика». execute() в цикле = N round-trips к БД + N отдельных транзакций + N WAL flush. Для 1000 строк: ~500мс вместо ~5мс для executemany() или COPY. Используй executemany() (psycopg2) или bulk_create() (Django) или COPY для вставки более 100 строк.
- «RETURNING работает только с PostgreSQL». RETURNING — PostgreSQL-расширение (и SQLite тоже поддерживает). MySQL/MariaDB используют LAST_INSERT_ID(). SQL:2003 стандарт определяет похожую функциональность. При проектировании API: не полагайся на LAST_INSERT_ID() если планируешь поддерживать несколько СУБД — используй UUID вместо SERIAL.
- «Soft delete всегда лучше физического DELETE». Soft delete имеет недостатки: каждый запрос должен добавлять WHERE deleted_at IS NULL, индексы становятся больше (содержат удалённые строки), нарушения UNIQUE ограничений (два 'удалённых' с одним email), нарушение FOREIGN KEY (зависимые записи ссылаются на 'удалённую' запись). Для некоторых данных (сессии, логи, технические записи) физический DELETE лучше.
Ключевые выводы
- UPDATE и DELETE без WHERE изменяют ВСЕ строки таблицы. Используй BEGIN + SELECT с тем же WHERE + UPDATE + проверь RETURNING + COMMIT/ROLLBACK.
- RETURNING заменяет отдельный SELECT после INSERT/UPDATE/DELETE — атомарно и без race condition.
- ON CONFLICT (UPSERT) — единственный безопасный способ атомарного insert-or-update без риска race condition.
- UPDATE физически создаёт новую версию строки (MVCC) — таблица растёт при интенсивных обновлениях; autovacuum убирает мёртвые строки.
- Массовый INSERT через VALUES (...),(...) в 10-100x быстрее цикла отдельных INSERT; COPY ещё в 5-20x быстрее для больших объёмов.
- TRUNCATE O(1) vs DELETE O(N) — для очистки таблицы полностью; поддерживает ROLLBACK в PostgreSQL.
- Soft delete (deleted_at) — хорошо для бизнес-данных с историей; физический DELETE — для технических данных.
Термины урока
Связь с работой backend-разработчика
Три правила безопасной работы с DML: 1) UPDATE и DELETE только с WHERE — всегда проверяй через SELECT с тем же условием до выполнения; 2) Используй транзакции для проверки — BEGIN, делай изменение, смотри RETURNING, COMMIT или ROLLBACK; 3) ON CONFLICT вместо SELECT + INSERT — атомарный upsert без race conditions. Для soft delete создавай partial index: CREATE INDEX ON orders(user_id, created_at) WHERE deleted_at IS NULL — индекс только по активным записям. При массовой загрузке данных > 10K строк — используй COPY из приложения (psycopg2: copy_expert, Django: нет встроенного, используй psycopg2 напрямую). idle_in_transaction_session_timeout = '5min' в postgresql.conf — обязательная настройка, предотвращает зависание транзакций в psql.
Мини-разбор реальной ситуации
Команда разрабатывает систему учёта рабочего времени. Паттерн: каждый раз когда сотрудник приходит или уходит — нужно записать событие. Первая реализация: SELECT → если запись есть, UPDATE; иначе INSERT. Под нагрузкой появляются дубликаты: два параллельных процесса оба делают SELECT (не находят), оба делают INSERT — UNIQUE violation или дублирование данных. Фикс добавляет try/except и retry — код становится сложным. Правильное решение: INSERT INTO attendance (employee_id, date, first_entry, last_exit) VALUES (42, '2024-03-15', '09:00', '09:00') ON CONFLICT (employee_id, date) DO UPDATE SET last_exit = EXCLUDED.last_exit WHERE EXCLUDED.last_exit > attendance.last_exit RETURNING *. Один атомарный запрос без race condition, без retry-логики. Производительность: 3мс против 15мс с retry-логикой. Строк кода: 3 против 20.
Что запомнить
- BEGIN; SELECT WHERE ...; UPDATE WHERE ...; → проверь RETURNING → COMMIT или ROLLBACK. Обязательный паттерн для ручных изменений в продакшене.
- ON CONFLICT для upsert — атомарный и безопасный; SELECT+INSERT/UPDATE без транзакции — race condition.
- RETURNING убирает отдельный SELECT — атомично, без race condition, быстрее.
- Массовая вставка: INSERT VALUES (...),(...) или COPY — никогда не цикл отдельных INSERT.
- Soft delete: WHERE deleted_at IS NULL в каждом запросе + partial index ON table(key) WHERE deleted_at IS NULL.
- TRUNCATE в PostgreSQL поддерживает ROLLBACK — можно безопасно использовать в транзакции.
Итог
DML-команды — это мощь и ответственность одновременно. Правильно использованные RETURNING, ON CONFLICT и транзакции делают изменения данных атомарными, безопасными и эффективными. Неправильно используемые — уничтожают продакшен-данные за одну секунду.