Чтобы отклонить сохранение устаревшего документа, включите в условие обновления редакцию, изначально загруженную редактором, а затем атомарно увеличьте её номер. Одного SERIALIZABLE недостаточно, чтобы выразить это требование, если документ был загружен в предыдущей транзакции.
Это руководство предназначено для разработчика, работающего в Терминале на Mac, которому нужно, чтобы редактор статей на PostgreSQL отклонял устаревший замещающий текст. В примере используются синтетические строки статей и полноценная демонстрация с node-postgres. Задача сформулирована точно: сохранять текст, только если сохранённая в базе редакция всё ещё совпадает с той, на основе которой создан черновик.
Почему SERIALIZABLE может разрешить устаревшую замену?
PostgreSQL определяет сериализуемое выполнение как выполнение, результат которого эквивалентен запуску зафиксированных транзакций по одной в некотором порядке. Эта гарантия относится к транзакциям базы данных и их операциям. PostgreSQL: Transaction Isolation.
Рассмотрим наглядную последовательность:
- Редактор загружает статью 1 редакции 1, после чего транзакция чтения завершается.
- Другое сохранение заменяет текст статьи и фиксирует редакцию 2.
- Первый редактор отправляет замещающий текст, основанный на редакции 1.
- Новая транзакция уровня SERIALIZABLE обновляет статью 1, используя только её идентификатор.
Последняя транзакция может завершиться успешно: транзакции уже выполнялись последовательно. В обновлении с условием только по id = 1 ничто не указывает, что отправленный текст требует редакцию 1. Это следует из документированного определения сериализуемости и не является исключением из него.
С перекрывающимися транзакциями ситуация иная. PostgreSQL может отклонить сериализуемую транзакцию, если конкурентное обновление конфликтует с её снимком или если зависимости не позволяют выполнить транзакции последовательно. Снимок устанавливается первым оператором, не управляющим транзакцией; простое выполнение BEGIN ранее не сохраняет снимок прежнего чтения редактора. PostgreSQL: Transaction Isolation.
Поэтому диагностический вопрос таков: Где в сохранении явно требуется редакция, загруженная редактором? Если нигде, смена уровня изоляции не добавит отсутствующее условие.
Какие гарантии должна обеспечивать проверка редакции?
Рассматривайте каждый загруженный черновик как пару: редактируемый текст и базовая редакция. При сохранении передаются идентификатор статьи, замещающий текст и неизменённая базовая редакция. Обновление в базе данных должно проверить совпадение идентификатора и редакции, заменить текст и увеличить сохранённую редакцию в одном операторе.
Например, условие WHERE id = $1 AND revision = $2 задаёт предварительное требование, а SET body = $3, revision = revision + 1 выполняет переход. PostgreSQL обновляет строки, удовлетворяющие условию, а RETURNING возвращает значения из фактически обновлённых строк. Обновление нуля строк считается успешным выполнением SQL-команды, поэтому приложение должно явно обработать этот результат. PostgreSQL: UPDATE.
Этот подход часто называют оптимистичным контролем параллелизма или сравнением с обменом. В описанной здесь схеме каждое действие, изменяющее содержимое статьи, должно увеличивать номер редакции. Храните редакции как значения, допускающие только ненулевое состояние, не сбрасывайте их и не используйте повторно идентификатор статьи со старым токеном редакции. Иначе проверка на равенство может ошибочно показать, что старый черновик всё ещё актуален.
Отдельное чтение с последующим обновлением только по идентификатору не включает это требование в саму запись. Аналогично, получение самой новой редакции непосредственно перед сохранением с подстановкой её вместо отправленной меняет смысл запроса: становится возможной замена версии, которая не использовалась редактором как основа.
Выполните полный синтетический пример на Mac
Подготовьте работающий экземпляр PostgreSQL, существующую тестовую базу данных и текущую или поддерживаемую LTS-версию Node.js с npm. Откройте Terminal, создайте рабочую папку и установите драйвер документированной командой npm install pg. node-postgres: Introduction and Install.
mkdir revision-write-demo
cd revision-write-demo
npm install pg
Сохраните приведённую ниже полную программу в этой папке под именем revision-demo.mjs. В ней используется документированный импорт по умолчанию, совместимый с ESM. Классы Client и Pool могут считывать параметры подключения PostgreSQL из переменных среды; задайте их для существующей тестовой базы данных перед запуском. node-postgres: ESM, node-postgres: Connecting.
Программа сравнивает две синтетические статьи. Для статьи 1 выполняются два безусловных сохранения уровня SERIALIZABLE. Для статьи 2 выполняются два сохранения с проверкой редакции, использующие одну и ту же исходную редакцию. Временная таблица позволяет провести демонстрацию отдельно от постоянных таблиц статей. Синтаксис JavaScript проверен с помощью Node.js; демонстрация с базой данных в исследовательской среде не запускалась.
import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();
async function saveArticle(id, expectedRevision, body) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let active = false;
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
active = true;
const result = await client.query(
`UPDATE revision_demo_articles
SET body = $3, revision = revision + 1
WHERE id = $1 AND revision = $2
RETURNING id, body, revision`,
[id, expectedRevision, body]
);
if (result.rowCount === 0) {
await client.query('ROLLBACK');
active = false;
return { status: 'stale-or-missing' };
}
await client.query('COMMIT');
active = false;
return { status: 'saved', article: result.rows[0] };
} catch (error) {
if (active) await client.query('ROLLBACK');
if (error.code !== '40001' || attempt === 3) throw error;
}
}
}
await client.connect();
try {
await client.query(`
CREATE TEMP TABLE revision_demo_articles (
id integer PRIMARY KEY,
body text NOT NULL,
revision integer NOT NULL CHECK (revision > 0)
)
`);
await client.query(
`INSERT INTO revision_demo_articles (id, body, revision)
VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
);
const loaded = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
const expectedRevision = loaded.rows[1].revision;
// Illustrative blind saves: each is a separate transaction.
for (const body of ['First edit', 'Stale replacement']) {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
try {
await client.query(
`UPDATE revision_demo_articles
SET body = $1, revision = revision + 1 WHERE id = 1`,
[body]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
const first = await saveArticle(2, expectedRevision, 'First edit');
const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
assert.equal(first.status, 'saved');
assert.equal(first.article.revision, 2);
assert.equal(stale.status, 'stale-or-missing');
const final = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
assert.deepEqual(final.rows, [
{ id: 1, body: 'Stale replacement', revision: 3 },
{ id: 2, body: 'First edit', revision: 2 }
]);
console.table(final.rows);
console.log(stale.status);
} finally {
await client.end();
}
Если существующая тестовая база данных называется revision_demo, выполните приведённую ниже команду. Подставьте фактическое имя базы данных и сохраните все параметры подключения, необходимые вашему локальному экземпляру. node-postgres: Connecting.
PGDATABASE=revision_demo node revision-demo.mjs
Ожидаемые итоговые значения:
| Статья | Текст | Редакция | Значение |
|---|---|---|---|
| 1 | Устаревшая замена |
3 | Оба безусловных сохранения зафиксированы |
| 2 | Первая правка |
2 | Второе сохранение с проверкой редакции отклонено |
В качестве итогового статуса должно быть выведено stale-or-missing.
В этой последовательности намеренно нет параллелизма, зависящего от времени выполнения. Она наглядно показывает главное: устаревшая замена может сохраниться, даже если каждая транзакция сохранения выполняется с уровнем SERIALIZABLE, поскольку сохранения выполняются в допустимом последовательном порядке.
Как node-postgres должен обрабатывать каждый результат?
Программа передаёт значения отдельно от SQL-текста, используя документированный API драйвера для параметризованных запросов. Она также проверяет rowCount, который показывает, сколько строк обработало обновление; пустой массив rows сам по себе не является универсальной заменой проверки числа обновлённых строк. node-postgres: Queries, node-postgres: pg.Result.
При адаптации функции используйте следующую таблицу решений:
| Результат | Действие приложения |
|---|---|
| Обновлена одна строка, фиксация прошла успешно | Принять сохранение и сохранить возвращённый номер редакции |
| Обновлено ноль строк | Отклонить сохранение как устаревшее или относящееся к отсутствующей записи; сохранить отправленный черновик |
SQLSTATE 40001 |
Выполнить откат и повторить всю транзакцию в пределах установленного лимита |
| Другая ошибка или превышен лимит повторов | Сообщить об ошибке; не сообщать об успешном сохранении |
Результат с нулём обновлённых строк не позволяет определить, была ли статья удалена или изменилась её редакция. В демонстрации эти случаи намеренно объединены. Если интерфейсу нужны более подробные сведения, после этого загрузите текущую статью и покажите результат как более позднее состояние. Не представляйте его как доказательство точного состояния на момент неудачного обновления.
Ошибки сериализации имеют SQLSTATE 40001. PostgreSQL требует повторять всю транзакцию, включая логику, определяющую операторы и значения. Для завершения может потребоваться несколько попыток, и это не гарантирует успех. В примере в качестве иллюстративной политики приложения используются три попытки. PostgreSQL: Serialization Failure Handling.
Не изменяйте expectedRevision при этих попытках. Этот токен задаёт предварительное условие для отправленного черновика. Если другое сохранение увеличило редакцию, новая транзакция сможет отклонить старый токен, обновив ноль строк. Автоматическая замена токена на самую новую редакцию устранила бы защиту от устаревшей записи.
Функция возвращает успех только после завершения COMMIT. Все операторы транзакции используют один и тот же Client, как того требует node-postgres. При адаптации для пула получите клиент с помощью pool.connect(), используйте его на протяжении всей транзакции и затем освободите. node-postgres: Transactions, node-postgres: pg.Pool.
Нужны ли по-прежнему SERIALIZABLE или SELECT FOR UPDATE?
Для этого узкого случая обновления одной строки с условием проверку необходимой редакции можно выполнить и при Read Committed: после ожидания фиксации конкурентного обновления PostgreSQL повторно проверяет условие обновления для изменённой строки. Это не превращает Read Committed в гарантию для сложных правил, охватывающих несколько строк. PostgreSQL: Transaction Isolation.
Сохраняйте SERIALIZABLE, если окружающая транзакция зависит от более широких операций чтения и записи, для которых нужны его гарантии. Условие редакции выражает предварительное требование редактора; уровень изоляции определяет взаимодействие операций базы данных внутри транзакции. Выбирайте оба параметра с учётом реальной задачи.
SELECT FOR UPDATE может пригодиться транзакции, которой нужно проверить строку и выполнить несколько зависящих друг от друга операций. Блокировка строки не позволяет конкурирующим операциям записи выполняться до завершения транзакции. При уровнях Repeatable Read или Serializable попытка заблокировать строку, изменённую после создания снимка транзакции, может привести к ошибке. PostgreSQL: Explicit Locking.
Даже если блокировка берётся только при поступлении запроса на сохранение, приложение всё равно должно сравнить отправленную базовую редакцию. Блокировка текущей строки не сообщает базе данных, какая прежняя редакция послужила основой черновика. Для показанной здесь замены одной строки условное обновление уже объединяет сравнение и изменение.
Перед внедрением этого подхода проверьте все пути записи содержимого, сохраняйте черновик редактора при отклонении и обновляйте базовую редакцию редактора только после подтверждённого сохранения. Если устаревшее сохранение отклонено, предложите читателю текущую статью вместе с сохранённым черновиком, чтобы он мог согласовать текст и отправить новое сохранение на основе редакции, которую действительно просмотрел.
Источники
- 13.2. Transaction Isolation — Глобальная группа разработчиков PostgreSQL.
- UPDATE — Глобальная группа разработчиков PostgreSQL.
- 13.5. Serialization Failure Handling — Глобальная группа разработчиков PostgreSQL.
- 13.3. Explicit Locking — Глобальная группа разработчиков PostgreSQL.
- node-postgres: Introduction and Install — node-postgres.
- ESM — node-postgres.
- Connecting — node-postgres.
- Queries — node-postgres.
- pg.Result — node-postgres.
- Transactions — node-postgres.
- pg.Pool — node-postgres.