Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 124 · После первого каталога X DevAPI и общей транзакции

MySQL Document Store: два сеанса, блокировки и частичный откат

От одного остатка для двух сотрудников к очереди доступных заданий, точкам сохранения и проверяемому частичному откату.

Одна лампа, два сотрудника и понятный результат

Каталог уже умеет читать и менять документы. Следующая задача появляется при двух сотрудниках: нельзя дважды отдать последнюю лампу или выдать обоим одно задание. А если заказ содержит необязательный комплект, нужен понятный частичный отказ. В этой главе результатом будут сохранённые владельцы, остатки и резервы, а не только сообщение «транзакция прошла».

Склад и участники
  1. LED-1 stock 1 → A удерживает карточку → B получает занятость
  2. A записывает stock 0 + RES-A → commit → B заново видит 0
  3. JOB-1 занят → B получает JOB-2 → владельцы A/B сохранены
  4. Основной резерв → отметка → отказ опции → частичный откат → основной commit

Session — соединение с сервером, а транзакция — ограниченный отрезок команд на нём. Два имени переменных одной ссылки не создают двух участников: нужны два отдельных getSession. Блокировку можно представить временной отметкой «карточка сейчас участвует в решении». Это не запрет смотреть бумагу всем: обычное согласованное чтение базы может видеть свою версию. Отметка нужна, чтобы согласовать конфликтующие изменения.

До этой главы: документ, коллекция и выборка; изменение и границы транзакции. Здесь новые методы разобраны до полной программы. Если непривычны $переменная, -> и try/finally, вернитесь к переменным, объектам и исключениям.

ПотребностьПодход
Прочитать и затем изменить один остатокCollectionFind::lockExclusive на том же Session до commit/rollback.
Нескольким участникам кратко прочитать стабильную карточкуCollectionFind::lockShared; не держать такую транзакцию во время человеческого ожидания.
Не ждать занятые заданияSKIP_LOCKED вместе с exclusive и сохранением состояния claimed.
Отменить только необязательные действияSession::setSavepoint → Session::rollbackTo.
Убрать отработанную отметкуSession::releaseSavepoint; окончательное решение о данных остаётся за commit/rollback.

Полные файлы используют изолированный профиль139: PHP8.3.35, mysql_xdevapi8.0.30, MySQL8.4.13, loopback X Protocol/TLS. Обязательные PHPMAN_MYSQLX_URI и PHPMAN_MYSQLX_SCHEMA описаны в установке и README каждого архива. Создаются только собственные случайно названные mysql140_… коллекции. Для конфликтов используются реально открытые Session с перекрывающимися транзакциями; многопоточный генератор нагрузки не требуется, и его результатов здесь не обещаем.

Ждать, сообщить занятость или пропустить

Сначала выберите, что делать при занятости. Параметр режима передаётся lockShared или lockExclusive; сам запрос отправляется execute. Константа задаёт стратегию одного запроса, а повтор, сообщение пользователю и окончательное решение остаются у приложения.

РежимЧто увидит приложение
MYSQLX_LOCK_DEFAULTОжидание освобождения несовместимой блокировки; возможны серверный тайм-аут или deadlock. Вызов без аргумента выбирает этот режим.
MYSQLX_LOCK_NOWAITНемедленный отказ при встреченном конфликте блокировки; в нашем контракте серверный ER_LOCK_NOWAIT 3572. Это не «документ отсутствует».
MYSQLX_LOCK_SKIP_LOCKEDЗаблокированные строки пропущены. Пустой ответ может означать, что подходящие задания пока удерживаются другими.

В лаборатории DEFAULT берёт свободную точную карточку; временной интервал ожидания не измеряется. NOWAIT и SKIP_LOCKED проверяются при уже удерживаемой блокировке второго Session. Не добавляйте sleep для «доказательства»: он лишь задерживает PHP и сам не говорит, что сервер ожидал блокировку.

Поля и число ответов ограничивайте на сервере. Но filter + limit 1 не гарантирует одну заблокированную запись: сканирование, индекс и уровень изоляции влияют на фактический охват. Сначала продумайте индекс под условие. Урок конкурентные транзакции объясняет общую модель; здесь используется документный CollectionFind.

Отдать последнюю лампу одному покупателю

На складе осталась одна лампа. Два сотрудника читают остаток почти одновременно. Если каждый решит по старой цифре «1», можно подтвердить две выдачи. Нам нужен один сохранённый резерв и явный ответ второму сотруднику: сначала занято, затем товара уже нет.

Изменения в этой программе
  1. A: startTransaction → lockExclusive(LED-1) → читает stock 1
  2. B: обычное чтение видит 1; защищённое NOWAIT-чтение получает конфликт
  3. A: stock 0 + RES-A → commit освобождает блокировку
  4. B: новая транзакция → защищённое чтение stock 0 → RES-B не создаётся

Самое короткое действие

startTransaction начинает общую операцию на этом Session. find выбирает конкретный документ; bind передаёт значение ключа отдельно от выражения. lockExclusive настраивает исключительную блокировку, а execute действительно выполняет запрос. fetchOne даёт PHP-массив с полями. Блокировка остаётся после чтения и до commit/rollback, поэтому проверку остатка и запись делают внутри этой границы. Короткий фрагмент только читает; полный файл ниже выполняет резерв и проверяет две независимые попытки.

$session->startTransaction();
$product = $collection->find('_id = :id')->bind(['id'=>'LED-1'])
    ->lockExclusive()->execute()->fetchOne();
// Решение о резерве принимается здесь, пока транзакция открыта.
$session->commit();

Фрагмент показывает новую операцию в подготовленном окружении. Полный файл ниже создаёт исходные документы, открывает нужные Session, выполняет сценарий и сохраняет результат. В lab.php находятся только конфигурация, имена, отчёт и очистка; предметные запросы остаются в example.php.

Что получится

НаблюдениеРезультат
Остаток в первом защищённом чтении1
Обычное чтение второго сеанса1
Второй получил NOWAIT-конфликтда
После commit первый резерв существуетда
Второй заново прочитал остаток0
Подтверждено резервов1
Итоговый остаток0
Самостоятельная программа и архив с исходными данными
Отдать последнюю лампу одному покупателюСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';

$config = x140Config();
$session = mysql_xdevapi\getSession($config['uri']);
$schema = null;
$peers = [];
$owned = [];
$removed = [];
$rows = $checks = $observations = [];
try {
    $schema = $session->getSchema($config['schema']);
    if (!$schema->existsInDatabase()) {
        throw new RuntimeException('Учебная схема не существует: подготовьте её отдельно.');
    }
    $runtime = ['php'=>PHP_VERSION, 'extension'=>phpversion('mysql_xdevapi'),
        'server_version'=>$session->getServerVersion(), 'schema'=>$config['schema'],
        'host'=>$config['host'], 'port'=>$config['port']];

$name = x140Name('exclusive_reservation');
$handle = $schema->getCollection($name);
$existed = $handle->existsInDatabase();
if ($existed) {throw new RuntimeException('Случайное имя уже занято.');}
$collection = $schema->createCollection($name);
$owned[] = $name;

    $collection->add(['_id'=>'LED-1','kind'=>'product','stock'=>1])->execute();
    $second = mysql_xdevapi\getSession($config['uri']); $peers[] = $second;
    $other = $second->getSchema($config['schema'])->getCollection($name);

    $session->startTransaction();
    $firstRead = $collection->find('_id = :id')->bind(['id'=>'LED-1'])
        ->lockExclusive()->execute()->fetchOne();
    // Обычное согласованное чтение не является конкурирующей блокировкой.
    $ordinary = $other->getOne('LED-1');
    $second->startTransaction();
    $conflict = [];
    try {
        $other->find('_id = :id')->bind(['id'=>'LED-1'])
            ->lockExclusive(MYSQLX_LOCK_NOWAIT)->execute();
    } catch (mysql_xdevapi\Exception $error) {
        $conflict = x140ErrorChain($error);
        if (!x140HasCode($conflict, 3572)) {throw $error;}
    }
    $second->rollback();
    if (!x140HasCode($conflict, 3572)) {throw new RuntimeException('Не получен ожидаемый NOWAIT-конфликт.');}
    if ($firstRead['stock'] < 1) {throw new RuntimeException('Исходный товар отсутствует.');}
    $updated = $collection->modify('_id = :id AND stock >= 1')->bind(['id'=>'LED-1'])
        ->set('stock', $firstRead['stock'] - 1)->execute();
    if ($updated->getAffectedItemsCount() !== 1) {throw new RuntimeException('Основное списание не подтверждено.');}
    $collection->add(['_id'=>'RES-A','kind'=>'reservation','product'=>'LED-1','owner'=>'A','qty'=>1])->execute();
    $session->commit();

    $second->startTransaction();
    $secondRead = $other->find('_id = :id')->bind(['id'=>'LED-1'])
        ->lockExclusive(MYSQLX_LOCK_NOWAIT)->execute()->fetchOne();
    if ($secondRead['stock'] >= 1) {
        $secondUpdate = $other->modify('_id = :id AND stock >= 1')->bind(['id'=>'LED-1'])
            ->set('stock', $secondRead['stock'] - 1)->execute();
        if ($secondUpdate->getAffectedItemsCount() !== 1) {throw new RuntimeException('Второе списание не подтверждено.');}
        $other->add(['_id'=>'RES-B','kind'=>'reservation','product'=>'LED-1','owner'=>'B','qty'=>1])->execute();
    }
    $second->commit();
    $saved = $collection->getOne('LED-1');
    $reservations = $collection->find('kind = :kind')->bind(['kind'=>'reservation'])
        ->fields(['_id','owner','qty'])->sort('_id ASC')->execute()->fetchAll();
    $rows = [['Остаток в первом защищённом чтении',$firstRead['stock']],
        ['Обычное чтение второго сеанса',$ordinary['stock']],
        ['Второй получил NOWAIT-конфликт',x140HasCode($conflict,3572)],
        ['После commit первый резерв существует',($collection->getOne('RES-A') ?? null)!==null],
        ['Второй заново прочитал остаток',$secondRead['stock']],
        ['Подтверждено резервов',count($reservations)],['Итоговый остаток',$saved['stock']]];
    $checks = ['initial_stock_one'=>$firstRead['stock']===1,'ordinary_read_allowed'=>$ordinary['stock']===1,
        'actual_nowait_conflict'=>x140HasCode($conflict,3572), 'one_item_changed'=>$updated->getAffectedItemsCount()===1,
        'second_reads_current_zero'=>$secondRead['stock']===0,'only_first_reserved'=>count($reservations)===1 && $reservations[0]['_id']==='RES-A' && $reservations[0]['owner']==='A',
        'no_second_reservation'=>($collection->getOne('RES-B') ?? null)===null,'stock_not_negative'=>$saved['stock']===0];
    $observations = ['first_locked_read'=>$firstRead,'ordinary_read'=>$ordinary,'nowait_exception_chain'=>$conflict,
        'second_locked_read_after_commit'=>$secondRead,'saved_product'=>$saved,'saved_reservations'=>$reservations];

} finally {
    // Сначала освобождаем блокировки всех участников, затем удаляем свою коллекцию.
    try {
        foreach (array_reverse($peers) as $peer) {
            try {$peer->rollback();} finally {$peer->close();}
        }
    } finally {
        try {
            $session->rollback();
            if ($schema instanceof mysql_xdevapi\Schema) {
                $removed = x140Cleanup($schema, $owned);
            }
        } finally {$session->close();}
    }
}

x140Report('mysql140-x-exclusive-reservation', 'Отдать последнюю лампу одному покупателю', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Остаток в первом защищённом чтении — 1
Обычное чтение второго сеанса — 1
Второй получил NOWAIT-конфликт — да
После commit первый резерв существует — да
Второй заново прочитал остаток — 0
Подтверждено резервов — 1
Итоговый остаток — 0

result.html показывает ведомость, result.json содержит реальные документы и цепочки исключений, metrics.json — проверки. После чтения результата finally освобождает транзакции и удаляет только свою коллекцию.

Таблица фактического запуска · JSON документов и наблюдений.

Почему это важно в работе

NOWAIT — не «товара нет», а «сейчас выбранные данные заняты». Пример признаёт ожидаемым только серверный 3572 в цепочке исключений; иной сбой пробрасывается. После отказа второй сеанс откатывается и начинает новую попытку с новым чтением. Не удерживайте транзакцию, пока сотрудник думает, вводит данные или идёт HTTP-запрос оплаты. Условие stock >= 1 дополнительно защищает предметную запись; при простом списании без связанных действий возможна одна условная modify и проверка счётчика. Внешнюю отправку письма rollback не отменит.

Автор ожидал, что блокировка запретит всякое чтение. В нашем сценарии обычное чтение и конкурирующее защищённое чтение намеренно показаны отдельно; решение основано на контракте MySQL, а не на ответе форума. Обсуждение: Почему SELECT FOR UPDATE не запрещает обычное чтение.

Измените требование

Измените исходный stock на2. Что должен сделать второй участник после освобождения блокировки?

Разбор следующего шага

Он прочитает 1 и внутри той же транзакции уменьшит его до 0, затем сохранит RES-B. В программе эта ветка уже проверяет квитанцию списания перед созданием второго резерва. Для изменённого исходного stock обновите ожидаемую ведомость: два резерва, второй защищённый read показывает 1, итоговый остаток 0. Нельзя просто подменить проверку одного резерва, не проверив реальный состав двух записей.

Дать двум проверяющим читать одну версию карточки

Два коротких автоматических правила проверяют одну версию карточки, прежде чем отдельный этап разрешит её изменение. Им можно одновременно читать, но нельзя допустить подмену карточки между проверкой и завершением текущих транзакций. Посмотрим, как разделяемые блокировки совместимы друг с другом.

Изменения в этой программе
  1. A: shared CARD-1/version 1
  2. B: shared той же CARD-1/version 1 — успешно
  3. C: exclusive NOWAIT → отказ; после commit A отказ остаётся из-за B
  4. B: rollback → C получает exclusive → version 2/status approved → commit

Самое короткое действие

Shared означает совместное удержание для чтения. В отличие от exclusive, другой участник может взять такую же shared-блокировку. Закончить чтение можно commit или rollback: оба завершают транзакцию и освобождают её блокировки. В полном файле три Session соответствуют трём участникам, хотя PHP исполняет их команды по очереди. На сервере транзакции пересекаются по времени.

$session->startTransaction();
$card = $collection->find('_id = :id')->bind(['id'=>'CARD-1'])
    ->lockShared()->execute()->fetchOne();
// Короткая проверка полей карточки.
$session->rollback();

Фрагмент показывает новую операцию в подготовленном окружении. Полный файл ниже создаёт исходные документы, открывает нужные Session, выполняет сценарий и сохраняет результат. В lab.php находятся только конфигурация, имена, отчёт и очистка; предметные запросы остаются в example.php.

Что получится

НаблюдениеРезультат
Версия для проверяющего A1
Версия для проверяющего B1
Запись запрещена при двух shared-lockда
После commit A блокировку ещё держит Bда
После rollback B сохранена версия2
Сохранённый статусapproved
Самостоятельная программа и архив с исходными данными
Дать двум проверяющим читать одну версию карточкиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';

$config = x140Config();
$session = mysql_xdevapi\getSession($config['uri']);
$schema = null;
$peers = [];
$owned = [];
$removed = [];
$rows = $checks = $observations = [];
try {
    $schema = $session->getSchema($config['schema']);
    if (!$schema->existsInDatabase()) {
        throw new RuntimeException('Учебная схема не существует: подготовьте её отдельно.');
    }
    $runtime = ['php'=>PHP_VERSION, 'extension'=>phpversion('mysql_xdevapi'),
        'server_version'=>$session->getServerVersion(), 'schema'=>$config['schema'],
        'host'=>$config['host'], 'port'=>$config['port']];

$name = x140Name('shared_review');
$handle = $schema->getCollection($name);
$existed = $handle->existsInDatabase();
if ($existed) {throw new RuntimeException('Случайное имя уже занято.');}
$collection = $schema->createCollection($name);
$owned[] = $name;

    $collection->add(['_id'=>'CARD-1','version'=>1,'status'=>'review'])->execute();
    $second = mysql_xdevapi\getSession($config['uri']); $peers[] = $second;
    $third = mysql_xdevapi\getSession($config['uri']); $peers[] = $third;
    $readerB = $second->getSchema($config['schema'])->getCollection($name);
    $writer = $third->getSchema($config['schema'])->getCollection($name);
    $session->startTransaction();
    $a = $collection->find('_id = :id')->bind(['id'=>'CARD-1'])->lockShared()->execute()->fetchOne();
    $second->startTransaction();
    $b = $readerB->find('_id = :id')->bind(['id'=>'CARD-1'])
        ->lockShared(MYSQLX_LOCK_NOWAIT)->execute()->fetchOne();
    $third->startTransaction();
    $bothHeld = [];
    try {
        $writer->find('_id = :id')->bind(['id'=>'CARD-1'])
            ->lockExclusive(MYSQLX_LOCK_NOWAIT)->execute();
    } catch (mysql_xdevapi\Exception $error) {
        $bothHeld = x140ErrorChain($error);
        if (!x140HasCode($bothHeld,3572)) {throw $error;}
    }
    $third->rollback();
    $session->commit();
    // Участник B ещё держит свою разделяемую блокировку.
    $third->startTransaction();
    $onlyBHeld = [];
    try {
        $writer->find('_id = :id')->bind(['id'=>'CARD-1'])
            ->lockExclusive(MYSQLX_LOCK_NOWAIT)->execute();
    } catch (mysql_xdevapi\Exception $error) {
        $onlyBHeld = x140ErrorChain($error);
        if (!x140HasCode($onlyBHeld,3572)) {throw $error;}
    }
    $third->rollback();
    $second->rollback();
    $third->startTransaction();
    $free = $writer->find('_id = :id')->bind(['id'=>'CARD-1'])
        ->lockExclusive(MYSQLX_LOCK_NOWAIT)->execute()->fetchOne();
    $writer->modify('_id = :id')->bind(['id'=>'CARD-1'])->set('version',2)->set('status','approved')->execute();
    $third->commit();
    $saved = $collection->getOne('CARD-1');
    $rows = [['Версия для проверяющего A',$a['version']],['Версия для проверяющего B',$b['version']],
        ['Запись запрещена при двух shared-lock',x140HasCode($bothHeld,3572)],
        ['После commit A блокировку ещё держит B',x140HasCode($onlyBHeld,3572)],
        ['После rollback B сохранена версия',$saved['version']],['Сохранённый статус',$saved['status']]];
    $checks = ['shared_reads_compatible'=>$a['version']===1 && $b['version']===1,
        'exclusive_conflicts_with_two'=>x140HasCode($bothHeld,3572),
        'one_remaining_shared_still_blocks'=>x140HasCode($onlyBHeld,3572),
        'lock_after_both_released'=>$free['version']===1,
        'new_version_persisted'=>$saved['version']===2 && $saved['status']==='approved'];
    $observations = ['reader_a'=>$a,'reader_b'=>$b,'both_shared_exception_chain'=>$bothHeld,
        'remaining_shared_exception_chain'=>$onlyBHeld,'exclusive_after_release'=>$free,'saved'=>$saved];

} finally {
    // Сначала освобождаем блокировки всех участников, затем удаляем свою коллекцию.
    try {
        foreach (array_reverse($peers) as $peer) {
            try {$peer->rollback();} finally {$peer->close();}
        }
    } finally {
        try {
            $session->rollback();
            if ($schema instanceof mysql_xdevapi\Schema) {
                $removed = x140Cleanup($schema, $owned);
            }
        } finally {$session->close();}
    }
}

x140Report('mysql140-x-shared-review', 'Дать двум проверяющим читать одну версию карточки', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Версия для проверяющего A — 1
Версия для проверяющего B — 1
Запись запрещена при двух shared-lock — да
После commit A блокировку ещё держит B — да
После rollback B сохранена версия — 2
Сохранённый статус — approved

result.html показывает ведомость, result.json содержит реальные документы и цепочки исключений, metrics.json — проверки. После чтения результата finally освобождает транзакции и удаляет только свою коллекцию.

Таблица фактического запуска · JSON документов и наблюдений.

Почему это важно в работе

Это не инструмент длительного человеческого согласования: держать соединение и блокировку часы нельзя. Для формы согласования обычно сохраняют номер версии и проверяют его при записи. Попытки двух shared-читателей одновременно превратиться в писателей могут привести к deadlock: если заранее известно изменение, берите exclusive и держите общий порядок доступа. Обычное согласованное чтение не обязано ожидать shared/exclusive; видимость также зависит от изоляции.

Измените требование

Замените завершение первого чтения на rollback. Должен ли C уже получить разрешение на запись?

Разбор следующего шага

Нет: B всё ещё удерживает shared. Способ завершения A не отменяет чужую транзакцию. После завершения B следующий exclusive-запрос проходит; проверяйте оба конфликта и итоговую версию.

Выдать комплектовщикам разные свободные задания

В очереди есть три задания. Первый комплектовщик уже удерживает JOB-1; второму не нужно ждать, если доступно JOB-2. Выберем одну доступную работу, сохраним владельца и завершим короткую транзакцию. Само физическое комплектование происходит после выдачи.

Изменения в этой программе
  1. Очередь: JOB-1, JOB-2, JOB-3 — ready
  2. A держит JOB-1 в своей транзакции
  3. B: порядок _id + limit 1 + SKIP_LOCKED → JOB-2
  4. B сохраняет claimed/B; A сохраняет claimed/A; JOB-3 остаётся ready

Самое короткое действие

sort задаёт порядок кандидатов, limit ограничивает число возвращённых документов, а SKIP_LOCKED меняет поведение при конфликте блокировки: занятый кандидат пропускается. Наличие JOB-1 не отрицается; он просто недоступен этому запросу. Состояние claimed сохраняется до commit: после снятия временной блокировки другой запрос фильтрует уже сохранённый статус.

$session->startTransaction();
$job = $collection->find('state = :state')->bind(['state'=>'ready'])
    ->sort('_id ASC')->limit(1)
    ->lockExclusive(MYSQLX_LOCK_SKIP_LOCKED)->execute()->fetchOne();
// Если $job не null, назначить владельца до commit.
$session->commit();

Фрагмент показывает новую операцию в подготовленном окружении. Полный файл ниже создаёт исходные документы, открывает нужные Session, выполняет сценарий и сохраняет результат. В lab.php находятся только конфигурация, имена, отчёт и очистка; предметные запросы остаются в example.php.

Что получится

НаблюдениеРезультат
Задание работника AJOB-1
Задание работника BJOB-2
JOB-1: сохранённый владелецA
JOB-2: сохранённый владелецB
JOB-3: сохранённое состояниеready
Число заданий после выдачи3
Самостоятельная программа и архив с исходными данными
Выдать комплектовщикам разные свободные заданияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';

$config = x140Config();
$session = mysql_xdevapi\getSession($config['uri']);
$schema = null;
$peers = [];
$owned = [];
$removed = [];
$rows = $checks = $observations = [];
try {
    $schema = $session->getSchema($config['schema']);
    if (!$schema->existsInDatabase()) {
        throw new RuntimeException('Учебная схема не существует: подготовьте её отдельно.');
    }
    $runtime = ['php'=>PHP_VERSION, 'extension'=>phpversion('mysql_xdevapi'),
        'server_version'=>$session->getServerVersion(), 'schema'=>$config['schema'],
        'host'=>$config['host'], 'port'=>$config['port']];

$name = x140Name('picking_queue');
$handle = $schema->getCollection($name);
$existed = $handle->existsInDatabase();
if ($existed) {throw new RuntimeException('Случайное имя уже занято.');}
$collection = $schema->createCollection($name);
$owned[] = $name;

    $collection->add(['_id'=>'JOB-1','state'=>'ready','owner'=>''],
        ['_id'=>'JOB-2','state'=>'ready','owner'=>''],['_id'=>'JOB-3','state'=>'ready','owner'=>''])->execute();
    $second = mysql_xdevapi\getSession($config['uri']); $peers[] = $second;
    $queueB = $second->getSchema($config['schema'])->getCollection($name);
    $session->startTransaction();
    // Точный _id удерживает первую задачу без широкого сканирования очереди.
    $jobA = $collection->find('_id = :id')->bind(['id'=>'JOB-1'])->lockExclusive()->execute()->fetchOne();
    $second->startTransaction();
    $jobB = $queueB->find('state = :state')->bind(['state'=>'ready'])->sort('_id ASC')->limit(1)
        ->lockExclusive(MYSQLX_LOCK_SKIP_LOCKED)->execute()->fetchOne();
    if ($jobB === null) {throw new RuntimeException('Нет доступного задания для второго работника.');}
    $queueB->modify('_id = :id AND state = :state')->bind(['id'=>$jobB['_id'],'state'=>'ready'])
        ->set('state','claimed')->set('owner','B')->execute();
    $second->commit();
    $collection->modify('_id = :id AND state = :state')->bind(['id'=>$jobA['_id'],'state'=>'ready'])
        ->set('state','claimed')->set('owner','A')->execute();
    $session->commit();
    $saved = $collection->find()->fields(['_id','state','owner'])->sort('_id ASC')->execute()->fetchAll();
    $byId = array_column($saved,null,'_id');
    $rows = [['Задание работника A',$jobA['_id']],['Задание работника B',$jobB['_id']],
        ['JOB-1: сохранённый владелец',$byId['JOB-1']['owner']],['JOB-2: сохранённый владелец',$byId['JOB-2']['owner']],
        ['JOB-3: сохранённое состояние',$byId['JOB-3']['state']],['Число заданий после выдачи',count($saved)]];
    $checks = ['first_job_held'=>$jobA['_id']==='JOB-1','second_skips_held'=>$jobB['_id']==='JOB-2',
        'different_ids'=>$jobA['_id']!==$jobB['_id'],
        'owners_persisted'=>$byId['JOB-1']['state']==='claimed' && $byId['JOB-1']['owner']==='A' && $byId['JOB-2']['state']==='claimed' && $byId['JOB-2']['owner']==='B',
        'unclaimed_job_preserved'=>$byId['JOB-3']['state']==='ready' && $byId['JOB-3']['owner']==='',
        'all_jobs_remain'=>count($saved)===3];
    $observations = ['worker_a_locked'=>$jobA,'worker_b_skipped_first'=>$jobB,'readback_after_commits'=>$saved];

} finally {
    // Сначала освобождаем блокировки всех участников, затем удаляем свою коллекцию.
    try {
        foreach (array_reverse($peers) as $peer) {
            try {$peer->rollback();} finally {$peer->close();}
        }
    } finally {
        try {
            $session->rollback();
            if ($schema instanceof mysql_xdevapi\Schema) {
                $removed = x140Cleanup($schema, $owned);
            }
        } finally {$session->close();}
    }
}

x140Report('mysql140-x-picking-queue', 'Выдать комплектовщикам разные свободные задания', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Задание работника A — JOB-1
Задание работника B — JOB-2
JOB-1: сохранённый владелец — A
JOB-2: сохранённый владелец — B
JOB-3: сохранённое состояние — ready
Число заданий после выдачи — 3

result.html показывает ведомость, result.json содержит реальные документы и цепочки исключений, metrics.json — проверки. После чтения результата finally освобождает транзакции и удаляет только свою коллекцию.

Таблица фактического запуска · JSON документов и наблюдений.

Почему это важно в работе

Пустой ответ здесь означает «сейчас нет доступной строки по этому запросу», а не обязательно «в очереди вообще нет работ». Это неполное представление и не годится для финансовой сводки. Сортировка не гарантирует справедливость между работниками, а limit 1 не ограничивает автоматически число просмотренных/заблокированных записей: важны индекс и план. После сбоя работника нужен отдельный контракт восстановления claimed-заданий, сроки аренды и идемпотентное выполнение. Урок проверяет выдачу, не обещает exactly-once для внешнего действия.

Вопрос возник из очереди: один занятый элемент задерживает свободные. Наш пример выдаёт JOB-2 при удерживаемом JOB-1 и отдельно объясняет, почему такая выборка не является полной сводкой. Обсуждение: Как получить свободные строки, пропустив уже заблокированную.

Измените требование

Как показать оператору отдельно доступные и уже выданные задания?

Разбор следующего шага

Сделайте обычный читающий экран с явными фильтрами state и выбранными полями; не используйте его количество как гарантию следующего захвата. Захват оставьте короткой транзакцией SKIP_LOCKED с сохранением владельца. Для пустого ответа предложите повтор позднее, без бесконечного активного цикла.

Отменить дополнительный комплект, сохранив основной резерв

Заказ состоит из обязательной лампы и необязательных креплений. Отказ от креплений не должен уничтожить основной резерв, но должен вернуть их остаток. Точка сохранения позволяет отменить только последние действия внутри ещё незавершённой транзакции.

Изменения в этой программе
  1. Начало: LED-1 stock 1, CLIP-1 stock 2
  2. Основная часть: LED-1 stock 0 + RES-MAIN → setSavepoint(main_reserved)
  3. Опция: CLIP-1 stock 1 + RES-OPTION → известный отказ
  4. rollbackTo(main_reserved): опция исчезла, CLIP-1 снова2; основная часть остаётся
  5. releaseSavepoint удаляет отметку; commit подтверждает оставшуюся основную часть

Самое короткое действие

Savepoint — именованная отметка внутри одной транзакции, не отдельная вложенная транзакция. setSavepoint возвращает имя; переменная $point передаёт его следующим методам. rollbackTo отменяет изменения после отметки, но не завершает транзакцию. releaseSavepoint удаляет возможность вернуться именно к этой отметке. Только commit подтверждает оставшиеся изменения. Комментарии короткой схемы заменены реальными документами в самостоятельном файле.

$session->startTransaction();
// Сохранить основную часть заказа.
$point = $session->setSavepoint('main_reserved');
// Попытаться добавить необязательную часть.
$session->rollbackTo($point);
$session->releaseSavepoint($point);
$session->commit();

Фрагмент показывает новую операцию в подготовленном окружении. Полный файл ниже создаёт исходные документы, открывает нужные Session, выполняет сценарий и сохраняет результат. В lab.php находятся только конфигурация, имена, отчёт и очистка; предметные запросы остаются в example.php.

Что получится

НаблюдениеРезультат
Имя точки откатаmain_reserved
Основной резерв сохранёнда
Дополнительный резерв отсутствуетда
Остаток ламп0
Остаток комплектующих2
Основной резерв был доступен до commitда
Самостоятельная программа и архив с исходными данными
Отменить дополнительный комплект, сохранив основной резервСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';

$config = x140Config();
$session = mysql_xdevapi\getSession($config['uri']);
$schema = null;
$peers = [];
$owned = [];
$removed = [];
$rows = $checks = $observations = [];
try {
    $schema = $session->getSchema($config['schema']);
    if (!$schema->existsInDatabase()) {
        throw new RuntimeException('Учебная схема не существует: подготовьте её отдельно.');
    }
    $runtime = ['php'=>PHP_VERSION, 'extension'=>phpversion('mysql_xdevapi'),
        'server_version'=>$session->getServerVersion(), 'schema'=>$config['schema'],
        'host'=>$config['host'], 'port'=>$config['port']];

$name = x140Name('optional_reservation_ste');
$handle = $schema->getCollection($name);
$existed = $handle->existsInDatabase();
if ($existed) {throw new RuntimeException('Случайное имя уже занято.');}
$collection = $schema->createCollection($name);
$owned[] = $name;

    $collection->add(['_id'=>'LED-1','kind'=>'product','stock'=>1],
        ['_id'=>'CLIP-1','kind'=>'product','stock'=>2])->execute();
    $session->startTransaction();
    $collection->modify('_id = :id')->bind(['id'=>'LED-1'])->set('stock',0)->execute();
    $collection->add(['_id'=>'RES-MAIN','kind'=>'reservation','product'=>'LED-1','qty'=>1])->execute();
    $savepoint = $session->setSavepoint('main_reserved');
    $rollbackType = '';
    try {
        $collection->modify('_id = :id')->bind(['id'=>'CLIP-1'])->set('stock',1)->execute();
        $collection->add(['_id'=>'RES-OPTION','kind'=>'reservation','product'=>'CLIP-1','qty'=>1])->execute();
        throw new DomainException('Покупатель отказался от дополнительного комплекта.');
    } catch (DomainException $businessRefusal) {
        // Известный предметный отказ: серверная транзакция остаётся пригодной.
        $rolled = $session->rollbackTo($savepoint);
        $rollbackType = get_debug_type($rolled);
    }
    $inside = ['main'=>$collection->getOne('RES-MAIN'),
        'option'=>$collection->getOne('RES-OPTION') ?? null,'clip'=>$collection->getOne('CLIP-1')];
    $released = $session->releaseSavepoint($savepoint);
    $releaseType = get_debug_type($released);
    $session->commit();
    $main = $collection->getOne('RES-MAIN');
    $option = $collection->getOne('RES-OPTION') ?? null;
    $lamp = $collection->getOne('LED-1');
    $clip = $collection->getOne('CLIP-1');
    $rows = [['Имя точки отката',$savepoint],['Основной резерв сохранён',$main!==null],
        ['Дополнительный резерв отсутствует',$option===null],['Остаток ламп',$lamp['stock']],
        ['Остаток комплектующих',$clip['stock']],['Основной резерв был доступен до commit',$inside['main']!==null]];
    $checks = ['named_savepoint_returned'=>$savepoint==='main_reserved',
        'partial_rollback_inside'=>$inside['main']!==null && $inside['option']===null && $inside['clip']['stock']===2,
        'main_persisted'=>$main['product']==='LED-1' && $main['qty']===1,
        'option_absent'=>$option===null,'inventory_consistent'=>$lamp['stock']===0 && $clip['stock']===2];
    $observations = ['savepoint'=>$savepoint,'rollback_return_type'=>$rollbackType,'release_return_type'=>$releaseType,
        'inside_before_commit'=>$inside,'main_after_commit'=>$main,'option_after_commit'=>$option,
        'lamp_after_commit'=>$lamp,'clip_after_commit'=>$clip];

} finally {
    // Сначала освобождаем блокировки всех участников, затем удаляем свою коллекцию.
    try {
        foreach (array_reverse($peers) as $peer) {
            try {$peer->rollback();} finally {$peer->close();}
        }
    } finally {
        try {
            $session->rollback();
            if ($schema instanceof mysql_xdevapi\Schema) {
                $removed = x140Cleanup($schema, $owned);
            }
        } finally {$session->close();}
    }
}

x140Report('mysql140-x-optional-reservation-step', 'Отменить дополнительный комплект, сохранив основной резерв', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Имя точки отката — main_reserved
Основной резерв сохранён — да
Дополнительный резерв отсутствует — да
Остаток ламп — 0
Остаток комплектующих — 2
Основной резерв был доступен до commit — да

result.html показывает ведомость, result.json содержит реальные документы и цепочки исключений, metrics.json — проверки. После чтения результата finally освобождает транзакции и удаляет только свою коллекцию.

Таблица фактического запуска · JSON документов и наблюдений.

Почему это важно в работе

Пример намеренно ловит известный DomainException, а не любой серверный сбой. После deadlock или разрыва соединения нельзя без проверки считать транзакцию пригодной и продолжать commit: нужна политика полного отката/повтора целой операции. rollbackTo не обязан освобождать все InnoDB-блокировки, взятые после отметки. Создание коллекций выполняется до транзакции, потому что DDL может неявно её завершить. Для конкурентного резерва добавьте защищённое чтение остатков из первого сценария; здесь проверяется именно частичный откат на изолированном наборе.

Измените требование

Сделайте крепления обязательными. Где должна проходить граница отказа?

Разбор следующего шага

Тогда отказ отменяет всю операцию: вызывайте rollback всей транзакции, не commit основной части. После чтения с нового состояния должны отсутствовать оба резерва, LED-1 иметь stock 1, CLIP-1 — stock 2. Точка сохранения оправдана только когда частичное сохранение разрешено требованиями.

Проверить имя точки и не спутать release с commit

После нескольких этапов импорта легко перепутать имя отметки и подтверждение всей работы. Проследим один stock: исходно 5, затем4,3,2,1,0. Повторная отметка stage стоит уже при2. Откат должен вернуть именно2, а освобождение отметки не должно показать2 другому сеансу как подтверждённое значение.

Изменения в этой программе
  1. stock 5 → транзакция →4 → автоматическая отметка (реальное имя сохраняется)
  2. 3 → stage →2 → stage повторно →1 → later →0
  3. rollbackTo(STAGE) →2; later удалена, stage ещё работает
  4. release(stage): внутри 2, внешнее чтение5; полный rollback →5

Самое короткое действие

Без аргумента API возвращает автоматически выбранное имя — сохраняйте его, не пишите вручную предполагаемое SAVEPOINT1. Повтор setSavepoint с именем stage переносит одноимённую отметку к текущему состоянию. rollbackTo нечувствителен к регистру имени на этом MySQL-контракте: STAGE выбирает stage. После отката более поздняя later исчезает, а выбранная stage остаётся. Release удаляет только названную отметку, а не изменения данных.

$point = $session->setSavepoint();
// Изменения внутри уже начатой транзакции.
$session->rollbackTo($point);
$session->releaseSavepoint($point);
// Это ещё не commit: оставшиеся изменения можно отменить целиком.
$session->rollback();

Фрагмент показывает новую операцию в подготовленном окружении. Полный файл ниже создаёт исходные документы, открывает нужные Session, выполняет сценарий и сохраняет результат. В lab.php находятся только конфигурация, имена, отчёт и очистка; предметные запросы остаются в example.php.

Что получится

НаблюдениеРезультат
Автоматическое имя сохранено как непустая строкада
Повтор имени stage вернул то же имяда
Откат к повторно поставленной stage: stock2
Более поздняя later уже недоступнада
Повторный rollbackTo(stage) оставил stock2
Освобождённая stage уже недоступнада
После release: stock внутри транзакции2
После release: stock у внешнего читателя5
После полного rollback: stock5
Самостоятельная программа и архив с исходными данными
Проверить имя точки и не спутать release с commitСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';

$config = x140Config();
$session = mysql_xdevapi\getSession($config['uri']);
$schema = null;
$peers = [];
$owned = [];
$removed = [];
$rows = $checks = $observations = [];
try {
    $schema = $session->getSchema($config['schema']);
    if (!$schema->existsInDatabase()) {
        throw new RuntimeException('Учебная схема не существует: подготовьте её отдельно.');
    }
    $runtime = ['php'=>PHP_VERSION, 'extension'=>phpversion('mysql_xdevapi'),
        'server_version'=>$session->getServerVersion(), 'schema'=>$config['schema'],
        'host'=>$config['host'], 'port'=>$config['port']];

$name = x140Name('savepoint_lifetime');
$handle = $schema->getCollection($name);
$existed = $handle->existsInDatabase();
if ($existed) {throw new RuntimeException('Случайное имя уже занято.');}
$collection = $schema->createCollection($name);
$owned[] = $name;

    $collection->add(['_id'=>'CARD-1','stock'=>5])->execute();
    $second = mysql_xdevapi\getSession($config['uri']); $peers[] = $second;
    $observer = $second->getSchema($config['schema'])->getCollection($name);
    $session->startTransaction();
    $collection->modify('_id = :id')->bind(['id'=>'CARD-1'])->set('stock',4)->execute();
    $generated = $session->setSavepoint();
    $collection->modify('_id = :id')->bind(['id'=>'CARD-1'])->set('stock',3)->execute();
    $named = $session->setSavepoint('stage');
    $collection->modify('_id = :id')->bind(['id'=>'CARD-1'])->set('stock',2)->execute();
    $repeated = $session->setSavepoint('stage');
    $collection->modify('_id = :id')->bind(['id'=>'CARD-1'])->set('stock',1)->execute();
    $later = $session->setSavepoint('later');
    $collection->modify('_id = :id')->bind(['id'=>'CARD-1'])->set('stock',0)->execute();
    $rolled = $session->rollbackTo('STAGE');
    $afterNamed = $collection->getOne('CARD-1');
    $laterError = [];
    try {$session->rollbackTo($later);} catch (mysql_xdevapi\Exception $error) {
        $laterError = x140ErrorChain($error);
        if (!x140HasCode($laterError,1305)) {throw $error;}
    }
    // Сама целевая точка после rollbackTo остаётся доступной.
    $again = $session->rollbackTo('stage');
    $afterAgain = $collection->getOne('CARD-1');
    $released = $session->releaseSavepoint('stage');
    $releasedError = [];
    try {$session->rollbackTo('stage');} catch (mysql_xdevapi\Exception $error) {
        $releasedError = x140ErrorChain($error);
        if (!x140HasCode($releasedError,1305)) {throw $error;}
    }
    $session->releaseSavepoint($generated);
    $inside = $collection->getOne('CARD-1');
    $outside = $observer->getOne('CARD-1');
    $session->rollback();
    $afterRollback = $observer->getOne('CARD-1');
    $rows = [['Автоматическое имя сохранено как непустая строка',is_string($generated) && $generated!==''],
        ['Повтор имени stage вернул то же имя',$named===$repeated],
        ['Откат к повторно поставленной stage: stock',$afterNamed['stock']],
        ['Более поздняя later уже недоступна',x140HasCode($laterError,1305)],
        ['Повторный rollbackTo(stage) оставил stock',$afterAgain['stock']],
        ['Освобождённая stage уже недоступна',x140HasCode($releasedError,1305)],
        ['После release: stock внутри транзакции',$inside['stock']],
        ['После release: stock у внешнего читателя',$outside['stock']],
        ['После полного rollback: stock',$afterRollback['stock']]];
    $checks = ['generated_name_real'=>is_string($generated) && $generated!=='',
        'duplicate_name_moves_point'=>$named==='stage' && $repeated==='stage' && $afterNamed['stock']===2,
        'later_point_discarded'=>x140HasCode($laterError,1305),
        'target_point_survives'=>$afterAgain['stock']===2,
        'released_point_rejected'=>x140HasCode($releasedError,1305),
        'release_does_not_commit'=>$inside['stock']===2 && $outside['stock']===5,
        'full_rollback_restores'=>$afterRollback['stock']===5];
    $observations = ['generated_name'=>$generated,'first_named'=>$named,'repeated_named'=>$repeated,
        'rollback_return_type'=>get_debug_type($rolled),'second_rollback_return_type'=>get_debug_type($again),
        'release_return_type'=>get_debug_type($released),'after_named_rollback'=>$afterNamed,
        'later_exception_chain'=>$laterError,'released_exception_chain'=>$releasedError,
        'after_repeat_rollback'=>$afterAgain,'inside_after_release'=>$inside,'outside_after_release'=>$outside,
        'after_full_rollback'=>$afterRollback];

} finally {
    // Сначала освобождаем блокировки всех участников, затем удаляем свою коллекцию.
    try {
        foreach (array_reverse($peers) as $peer) {
            try {$peer->rollback();} finally {$peer->close();}
        }
    } finally {
        try {
            $session->rollback();
            if ($schema instanceof mysql_xdevapi\Schema) {
                $removed = x140Cleanup($schema, $owned);
            }
        } finally {$session->close();}
    }
}

x140Report('mysql140-x-savepoint-lifetime', 'Проверить имя точки и не спутать release с commit', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Автоматическое имя сохранено как непустая строка — да
Повтор имени stage вернул то же имя — да
Откат к повторно поставленной stage: stock — 2
Более поздняя later уже недоступна — да
Повторный rollbackTo(stage) оставил stock — 2
Освобождённая stage уже недоступна — да
После release: stock внутри транзакции — 2
После release: stock у внешнего читателя — 5
После полного rollback: stock — 5

result.html показывает ведомость, result.json содержит реальные документы и цепочки исключений, metrics.json — проверки. После чтения результата finally освобождает транзакции и удаляет только свою коллекцию.

Таблица фактического запуска · JSON документов и наблюдений.

Почему это важно в работе

MySQL сообщает 1305 для отсутствующей точки; программа ищет этот код в реальной цепочке, а не подавляет все Throwable. Полный commit/rollback уничтожает точки текущей транзакции. При autocommit без явного startTransaction отметка не переживает свою команду; следующий rollbackTo не найдёт её. Имена задавайте внутри приложения. Это метки состояния на сервере, не снимки PHP-массивов: изменение локальной переменной rollbackTo не отменяет. Не переносите поведение release поздних точек из PostgreSQL на MySQL.

Вопрос на примере mysqli показывает путаницу между ошибкой запроса и неявным commit от DDL. В этой лаборатории коллекция создаётся до транзакции; механизм границ подтверждается документацией сервера, независимо от PHP-драйвера. Обсуждение: Почему исчезли savepoint после CREATE TABLE.

Измените требование

Уберите повторный setSavepoint(stage) после значения2. Каким станет stock после rollbackTo?

Разбор следующего шага

Он станет3 — состоянием при первой stage. Значение2 было записано позже этой отметки и также отменится. Внешнее чтение до commit всё равно увидит исходное 5 в нашем режиме изоляции; после полного rollback останется5.

Выбрать метод по задаче

МетодРезультат
CollectionFind::lockSharedУдержать документы для совместного чтения несколькими транзакциями.
CollectionFind::lockExclusiveПрочитать документ для решения и изменения внутри одной транзакции.
Session::setSavepointПоставить именованную отметку внутри открытой транзакции.
Session::rollbackToОтменить изменения после отметки, оставив транзакцию открытой.
Session::releaseSavepointУбрать ненужную отметку, не подтверждая изменения транзакции.

Собрать короткую предметную транзакцию

Для выдачи последней единицы соедините защищённое чтение, проверку остатка, изменение и квитанцию в одной короткой транзакции. Для очереди сохраните владельца до снятия блокировки. Для необязательной части заказа ставьте точку только там, где частичный успех действительно разрешён.

Следующая самостоятельная задача: собрать резерв лампы с необязательными креплениями из первого и четвёртого сценариев. Все участники берут товары в одинаковом порядке ключей; отказ опции откатывается к отметке, отказ основного товара — полностью. Проверьте документы и остатки после commit/rollback. Не держите блокировку на время оплаты или переписки; для внешних действий потребуются отдельные механизмы повторов и идемпотентности.

Для сводки по категориям вернитесь к вычислениям и индексам каталога. Конкурирующий запрос не должен сначала выгружать всю базу в PHP и выбирать нужное в массиве; условие, порядок, проекция и предел задаются серверу, а решение о блокировке принимается для текущей задачи.

Функции и методы этой темы

Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.

mysql_xdevapi\CollectionFind::lockSharedУдержать документы для совместного чтения несколькими транзакциями.

PHP Manual не указывает первую версию метода; учебный runtime mysql_xdevapi8.0.30 · mysql_xdevapi

public mysql_xdevapi\CollectionFind::lockShared(int $lock_waiting_option = MYSQLX_LOCK_DEFAULT): mysql_xdevapi\CollectionFind

Удержать документы для совместного чтения несколькими транзакциями.

Место действия в программе
  1. Session с явной транзакцией → выбранный документ либо именованная отметка
  2. CollectionFind::lockShared → настройка запроса или команда текущей транзакции
  3. Прочитанные поля и отдельная квитанция → решение приложения → commit либо rollback

Сигнатура и аргументы

public mysql_xdevapi\CollectionFind::lockShared(int $lock_waiting_option = MYSQLX_LOCK_DEFAULT): mysql_xdevapi\CollectionFind

Необязательный режим: MYSQLX_LOCK_DEFAULT ждёт освобождения несовместимой блокировки; MYSQLX_LOCK_NOWAIT сообщает конфликт; MYSQLX_LOCK_SKIP_LOCKED пропускает занятые документы. Это глобальные константы расширения: пишите MYSQLX_LOCK_NOWAIT без префикса mysql_xdevapi\. Внутри собственного namespace допустима явная глобальная форма \MYSQLX_LOCK_NOWAIT.

Возвращаемое значение

Возвращает CollectionFind — настраиваемый запрос. Это ещё не документ и не уже установленная блокировка: отправка происходит в execute, чтение — через DocResult.

Правила

Разделяемые блокировки совместимы с такими же блокировками других транзакций. Изменение либо запрос исключительной блокировки конфликтует до окончания удерживающих транзакций. Используйте явный startTransaction; commit и rollback завершают удержание. При DEFAULT незавершённая чужая запись может заставить запрос ждать, после её завершения используется актуальное доступное состояние.

Две проверки одной версии

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Две проверки одной версии.

$card = $collection->find('_id = :id')->bind(['id'=>'CARD-1'])
    ->lockShared()->execute()->fetchOne();

Внутри явной транзакции оба проверяющих читают version 1; C получает NOWAIT-конфликт, пока не завершились оба чтения.

Проверка перед совместным действием

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Проверка перед совместным действием.

$card = $collection->find('_id = :id')->bind(['id'=>'LED-1'])
    ->lockShared(MYSQLX_LOCK_NOWAIT)->execute()->fetchOne();

Это вариант короткой проверки без изменения. Если текущий A уже держит exclusive LED-1, NOWAIT откажет; если нужно списать товар, выбирайте exclusive из полного сценария, а не shared с последующим повышением.

Ошибки, ограничения и выбор

Не берите shared, если следующим действием заведомо будет запись: одновременное повышение блокировок двумя участниками создаёт риск deadlock. Обычный consistent read не превращается в защищённое чтение только потому, что другой участник держит shared. Фактический охват блокировок зависит от индекса, условия и изоляции.

Связанные действия: CollectionFind::lockExclusive, Session::setSavepoint, Session::rollbackTo, Session::releaseSavepoint. Начало и конец: Session::startTransaction, Session::commit, Session::rollback.

mysql_xdevapi\CollectionFind::lockExclusiveПрочитать документ для решения и изменения внутри одной транзакции.

PHP Manual не указывает первую версию метода; учебный runtime mysql_xdevapi8.0.30 · mysql_xdevapi

public mysql_xdevapi\CollectionFind::lockExclusive(int $lock_waiting_option = MYSQLX_LOCK_DEFAULT): mysql_xdevapi\CollectionFind

Прочитать документ для решения и изменения внутри одной транзакции.

Место действия в программе
  1. Session с явной транзакцией → выбранный документ либо именованная отметка
  2. CollectionFind::lockExclusive → настройка запроса или команда текущей транзакции
  3. Прочитанные поля и отдельная квитанция → решение приложения → commit либо rollback

Сигнатура и аргументы

public mysql_xdevapi\CollectionFind::lockExclusive(int $lock_waiting_option = MYSQLX_LOCK_DEFAULT): mysql_xdevapi\CollectionFind

Тот же необязательный lock_waiting_option: DEFAULT, NOWAIT или SKIP_LOCKED. Режим влияет на встреченный конфликт, но не задаёт бизнес-политику повторов или срок хранения задания.

Возвращаемое значение

CollectionFind для продолжения цепочки. После execute получается DocResult; fetchOne/fetchAll читают документы. Ошибка выполнения приходит исключением, а не пустым документом.

Правила

Исключительное удержание сериализует конкурирующий доступ для изменения выбранных записей: другой writer или shared-lock ждёт либо получает поведение выбранного режима. Согласованные чтения могут читать версию из своего read view. Поэтому утверждение «никто не может прочитать карточку» неверно. Совмещайте защищённое чтение и modify на том же Session до завершения транзакции.

Последний остаток

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Последний остаток.

$product = $collection->find('_id = :id')->bind(['id'=>'LED-1'])
    ->lockExclusive()->execute()->fetchOne();

После чтения1 участник A сохраняет stock 0 и RES-A; второй не создаёт RES-B по старому значению.

Следующее доступное задание

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Следующее доступное задание.

$job = $collection->find('state = :state')->bind(['state'=>'ready'])
    ->sort('_id ASC')->limit(1)
    ->lockExclusive(MYSQLX_LOCK_SKIP_LOCKED)->execute()->fetchOne();

При удерживаемом JOB-1 второй участник получает JOB-2 и записывает владельца B до commit.

Ошибки, ограничения и выбор

При NOWAIT покажите занятость или выполните ограниченный повтор целой предметной попытки с новым чтением. SKIP_LOCKED подходит для выдачи независимых работ, но исключает часть данных из ответа. DEFAULT не проверяется по sleep: ожидание надо наблюдать отдельным конкурентным процессом. Эти программы не измеряют время ожидания.

Связанные действия: CollectionFind::lockShared, Session::setSavepoint, Session::rollbackTo, Session::releaseSavepoint. Начало и конец: Session::startTransaction, Session::commit, Session::rollback.

mysql_xdevapi\Session::setSavepointПоставить именованную отметку внутри открытой транзакции.

PHP Manual не указывает первую версию метода; учебный runtime mysql_xdevapi8.0.30 · mysql_xdevapi

public mysql_xdevapi\Session::setSavepoint(string $name = ?): string

Поставить именованную отметку внутри открытой транзакции.

Место действия в программе
  1. Session с явной транзакцией → выбранный документ либо именованная отметка
  2. Session::setSavepoint → настройка запроса или команда текущей транзакции
  3. Прочитанные поля и отдельная квитанция → решение приложения → commit либо rollback

Сигнатура и аргументы

public mysql_xdevapi\Session::setSavepoint(string $name = ?): string

Необязательное имя. Знак ? в записи Manual — не буквальный аргумент; можно вызвать без параметров. Возвращённое автоматически созданное имя сохраните в переменной. Повтор заданного имени заменяет прежнюю отметку.

Возвращаемое значение

Строка имени точки. Manual приводит последовательные SAVEPOINT1, SAVEPOINT2 как пример генерации; прикладной код не должен угадывать имя. Серверный отказ обрабатывается как ошибка, а не как подтверждение этапа.

Правила

Точка принадлежит текущей транзакции этого Session. Это не новая транзакция и не сохранение на диске с независимым commit. Перед вызовом начните явную транзакцию: с autocommit без такого блока точка не сохранится для следующей команды. Не вставляйте между действиями DDL с неявным завершением.

Граница обязательного и необязательного

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Граница обязательного и необязательного.

$point = $session->setSavepoint('main_reserved');

Отметка ставится после RES-MAIN, поэтому отказ опции не удаляет основной резерв.

Имя без предположений

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Имя без предположений.

$point = $session->setSavepoint();
// Сохранить возвращённую строку для rollbackTo($point).

Реальное имя попадает в result.json; повтор stage отдельно показывает перенос границы к stock 2.

Ошибки, ограничения и выбор

PHP Manual помечает страницу неполной: список аргументов дополнен здесь жизненным циклом из X DevAPI Guide и серверного Manual. Не переносите имя между Session. Полный commit/rollback уничтожает точки; после этого откат к ним невозможен.

Связанные действия: CollectionFind::lockShared, CollectionFind::lockExclusive, Session::rollbackTo, Session::releaseSavepoint. Начало и конец: Session::startTransaction, Session::commit, Session::rollback.

mysql_xdevapi\Session::rollbackToОтменить изменения после отметки, оставив транзакцию открытой.

PHP Manual не указывает первую версию метода; учебный runtime mysql_xdevapi8.0.30 · mysql_xdevapi

public mysql_xdevapi\Session::rollbackTo(string $name): void // запись сигнатуры PHP Manual; см. оговорку возврата

Отменить изменения после отметки, оставив транзакцию открытой.

Место действия в программе
  1. Session с явной транзакцией → выбранный документ либо именованная отметка
  2. Session::rollbackTo → настройка запроса или команда текущей транзакции
  3. Прочитанные поля и отдельная квитанция → решение приложения → commit либо rollback

Сигнатура и аргументы

public mysql_xdevapi\Session::rollbackTo(string $name): void // запись сигнатуры PHP Manual; см. оговорку возврата

Обязательное имя существующей точки текущей транзакции; сопоставление имени нечувствительно к регистру. Не передавайте произвольное имя из формы: приложение управляет своим жизненным циклом.

Возвращаемое значение

В Manual есть противоречие: сигнатура void, раздел Return Values обещает SqlStatementResult. В исходнике PECL8.0.30 результат SQL-команды передаётся PHP; оба выполненных сценария savepoint сохранили тип mysql_xdevapi\SqlStatementResult в observations. Это наблюдение выбранной версии. Предметный успех дополнительно проверяется прочитанными данными.

Правила

Отменяются действия после точки, сама транзакция продолжается. Целевая отметка остаётся, созданные позднее отметки исчезают. Полный rollback — отдельная операция. Откат данных не означает освобождение всех InnoDB-блокировок; отдельное исключение касается блокировок новых вставленных строк, отменённых undo.

Отказ от креплений

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Отказ от креплений.

$session->rollbackTo($point);

RES-OPTION исчезает, CLIP-1 возвращается к 2, а RES-MAIN остаётся для итогового commit.

Возврат к повторной отметке

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Возврат к повторной отметке.

$session->rollbackTo('STAGE');
$session->rollbackTo('stage');

Оба успешных вызова возвращают кstock 2; более поздняя later уже не существует.

Ошибки, ограничения и выбор

Отсутствующая/освобождённая точка даёт серверный 1305, который может быть внутри цепочки исключений расширения. Не выдавайте ошибку отката за успешное восстановление. После серьёзного сбоя не предполагайте, что точка и транзакция ещё существуют; локальный rollbackTo предназначен для сознательно выбранной политики.

Связанные действия: CollectionFind::lockShared, CollectionFind::lockExclusive, Session::setSavepoint, Session::releaseSavepoint. Начало и конец: Session::startTransaction, Session::commit, Session::rollback.

mysql_xdevapi\Session::releaseSavepointУбрать ненужную отметку, не подтверждая изменения транзакции.

PHP Manual не указывает первую версию метода; учебный runtime mysql_xdevapi8.0.30 · mysql_xdevapi

public mysql_xdevapi\Session::releaseSavepoint(string $name): void // запись сигнатуры PHP Manual; см. оговорку возврата

Убрать ненужную отметку, не подтверждая изменения транзакции.

Место действия в программе
  1. Session с явной транзакцией → выбранный документ либо именованная отметка
  2. Session::releaseSavepoint → настройка запроса или команда текущей транзакции
  3. Прочитанные поля и отдельная квитанция → решение приложения → commit либо rollback

Сигнатура и аргументы

public mysql_xdevapi\Session::releaseSavepoint(string $name): void // запись сигнатуры PHP Manual; см. оговорку возврата

Имя ранее поставленной и ещё существующей точки. Удаляется именно отметка с этим именем; это не команда удаления документа и не общий rollback.

Возвращаемое значение

У Manual те же разночтения void/SqlStatementResult, что у rollbackTo. В двух выполненных сценариях PECL8.0.30 вернул mysql_xdevapi\SqlStatementResult; фактический get_debug_type сохранён в лабораторном JSON. Не используйте возврат как признак commit: такого действия метод не выполняет.

Правила

Существующие изменения остаются частью текущей незавершённой транзакции. Их можно затем подтвердить commit либо отменить rollback. После release вернуться к удалённой точке нельзя. Снятие точки не снимает все блокировки транзакции и не завершает её.

Опция завершена

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Опция завершена.

$session->releaseSavepoint($point);
$session->commit();

Сначала убираем уже не нужную отметку, затем отдельным commit подтверждаем основной резерв.

Отказ от всей операции после release

Фрагмент внутри уже открытой транзакции; полный запуск и исходные данные: Отказ от всей операции после release.

$session->releaseSavepoint('stage');
$session->rollback();

Даже после release полный rollback возвращает stock 5; внутри 2 до этого не становилось постоянной записью.

Ошибки, ограничения и выбор

Вызов для неизвестной точки является ошибкой. Не используйте release как «сохранить этот этап навсегда»: внешний читатель в лаборатории видит прежнее 5, пока текущая транзакция держит2. В MySQL release одной точки не следует путать с правилами других СУБД о вложенных точках.

Связанные действия: CollectionFind::lockShared, CollectionFind::lockExclusive, Session::setSavepoint, Session::rollbackTo. Начало и конец: Session::startTransaction, Session::commit, Session::rollback.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.