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

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

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

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

Глава 148 · После атомарной записи одного документа MongoDB

MongoDB: транзакции — заказ, остаток, отмена и повтор запроса

Сохранить несколько связанных документов вместе, отменить отказ и увидеть реальный результат каждого шага.

Когда заказ и остаток должны сохраниться вместе

Покупатель оформил две кружки. Нужно уменьшить остаток с 5 до 3 и создать один заказ. Если записать эти изменения независимо, отказ второй операции может оставить товар списанным без заказа. Транзакция объединяет несколько операций: подтвердить изменения вместе либо завершить работу без их сохранения.

МоментОстаток кружекЗаказовЧто видит обычный внешний запрос
До оформления50Исходные данные
Изменения внутри незавершённой транзакции31Пока по-прежнему 5 и 0
После успешного commit31Сохранённые остаток и заказ
При отмене вместо commit50Исходные данные сохранены

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

Одна группа оформления
  1. Manager соединяет PHP с MongoDB
  2. startSession создаёт контекст
  3. startTransaction объявляет группу
  4. Каждая операция получает ту же Session
  5. Проверки выполнены → commit
  6. Условие не выполнено → abort

Session можно представить как номер группы, который прикладывается к каждому действию. Но это не обычное поле документа: объект драйвера связывает запросы протоколом MongoDB. Без опции session запрос не попадёт в эту группу. MongoDB Session также не имеет отношения к PHP session_start и хранению входа пользователя в браузере.

Минимальная форма: начать и отменить пустую группу

$session = $manager->startSession();
$session->startTransaction();
$session->abortTransaction();
$session->endSession();

$manager — уже подключённый Manager; $session — переменная с созданной логической сессией. -> вызывает метод объекта, скобки содержат его аргументы. startTransaction, commitTransaction и abortTransaction возвращают void: результатом служит изменение состояния, а не число сохранённых заказов. Пустая группа в этом фрагменте не меняет документы; в первом полном примере ниже вместо неё выполняются реальные записи.

Полезные основы: документ и коллекция, Manager, Query и BulkWrite, параметры и возвращаемое значение, исключения и finally. try запускает работу, catch обрабатывает конкретный ожидаемый отказ, finally освобождает свои ресурсы при нормальном завершении или исключении. Неожиданная ошибка в программах ниже не превращается в сообщение об успехе.

Отдельный replica set и самостоятельный запуск

Для этих архивов нужен отдельный локальный replica set: MongoDB 8.0, PHP 8.4+ CLI x64 и расширение mongodb 2.5+. Контрольная среда: MongoDB 8.0.34, PHP 8.5.11, расширение 2.5.5. Composer не требуется. Replica set — набор серверов, работающих с реплицируемыми данными; здесь намеренно один участник для обучения. Он даёт нужный режим транзакций, но не отказоустойчивость.

Обычный standalone MongoDB не выполняет эти многодокументные транзакции. README каждого архива содержит установку бинарников, запуск нового mongod с отдельным dbpath, replSet phpmanRs и bind_ip 127.0.0.1, однократный rs.initiate для этого нового экземпляра и проверку PRIMARY. Рабочую службу, чужие данные и её конфигурацию менять не нужно.

Один запуск после подготовки сервера

# PowerShell в каталоге распакованного архива:
$env:PHPMAN_MONGODB_URI='mongodb://127.0.0.1:27076/?replicaSet=phpmanRs&retryWrites=false'
$env:PHPMAN_MONGODB_DATABASE='php_manual_txn_lab'
php example.php

Откройте result.html: это таблица фактических значений. result.json содержит те же строки; metrics.json — версии, проверки и SHA256 файлов. Helper tx118-helper.php принимает только явно заданный loopback seed без учётных данных и проверяет один PRIMARY набора phpmanRs. Это ограничение учебного стенда, а не рекомендация отключить защиту рабочей базы.

Каждая программа создаёт коллекции mongodb118_txn_… со случайным суффиксом. Владение фиксируется только после успешного create. Индексы и исходные документы готовятся до транзакции; finally закрывает свою Session и удаляет только точные созданные имена. Затем listCollections подтверждает отсутствие. Нужны права создания, чтения, записи, индекса и удаления своей учебной коллекции, listCollections, hello и buildInfo. При принудительном завершении процесса finally не гарантируется; неизвестные объекты по префиксу удалять нельзя.

Session, настройки и разные причины отказа

Каждое чтение и запись внутри группы получают опцию session с тем же объектом. У Session только одна открытая транзакция; операции здесь выполняются последовательно. Для следующих отдельных транзакций можно повторно использовать ещё не закрытую Session.

$manager->executeQuery($namespace, $query, ['session' => $session]);
$manager->executeBulkWrite($namespace, $bulk, ['session' => $session]);

Настройки транзакции выбирают через defaultTransactionOptions в startSession или options в startTransaction. readConcern определяет правила видимости чтения; snapshot выбирает согласованный снимок. readPreference выбирает сервер, и чтения транзакции используют primary. writeConcern определяет подтверждение commit; majority вместе со snapshot обеспечивает соответствующие гарантии большинства. На одном учебном участнике majority не доказывает отказоустойчивость нескольких копий. Подробное сравнение: подтверждение записи и согласованность чтения.

maxCommitTimeMS=1000 ограничивает одну команду commit на сервере. Это не таймер всей работы PHP. Внутренним операциям транзакции не задают отдельный writeConcern. Длительное ожидание пользователя, письмо или вызов платёжного API не нужно помещать в это тело: транзакция MongoDB не умеет откатывать чужой сервис.

СитуацияЧто она означаетДействие приложения
Условие available >= quantity не совпалоЗапрос корректен, но товара недостаточноПроверить matchedCount, отменить группу и объяснить отказ
Duplicate key, код 11000Уникальное правило отклонило запись; транзакция могла быть прекращена серверомНе продолжать как будто запись удалась; разрешить отказ и проверить свой результат
TransientTransactionErrorПопытка транзакции может быть повторенаОграниченный повтор всего тела по метке, без повторения внешних эффектов
UnknownTransactionCommitResultПодтверждение потеряно или исход commit неизвестенРазрешать исход и повторять commit той же транзакции; не начинать заново списание

В этих шести программах нет автоматического цикла повторов и нет принудительного обрыва сети. Ожидаемые бизнес-отказы проверяются отдельно, остальные ошибки выходят наружу. Драйвер может сам повторять commit по своим правилам даже при retryWrites=false; это не равно повтору всего тела приложения. Общая инструкция PHP.net о повторах уточнена по правилам MongoDB Core API. Неизвестный commit нельзя объявлять доказанным rollback.

Сохранить заказ и списать остаток одним решением

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

Входные данные

В stock одна Кружка с available=5. В orders записей нет. Покупатель заказывает 2 штуки; ожидаем остаток 3 и один заказ.

Путь данных в этом сценарии
  1. Session объединяет операции
  2. startTransaction открывает работу
  3. Остаток 5 → 3 внутри Session
  4. Заказ order-118 добавлен в той же Session
  5. Снаружи ещё остаток 5 и нет заказа
  6. commit → снаружи 3 и один заказ

Короткое действие

$session = $manager->startSession();
$session->startTransaction();
// Каждой операции передайте ['session' => $session].
$manager->executeBulkWrite($stockNamespace, $stockChange, ['session' => $session]);
$manager->executeBulkWrite($orderNamespace, $newOrder, ['session' => $session]);
$session->commitTransaction();
// Полная программа ниже проверяет остаток, ловит отказ и закрывает Session.

Фрагмент выделяет действие внутри сценария. Объекты, namespace, данные и helper полностью подготовлены в самостоятельной программе ниже.

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

НаблюдениеОстаток, шт.ЗаказовСостояние
До начала50none
Внутри транзакции31in_progress
Снаружи до commit50отдельное чтение
После commit31committed

Почему получились эти значения

Session — логическая сессия драйвера, не PHP-сессия авторизации и не новая база. Её создаёт Manager. startTransaction начинает многодокументную транзакцию: группу изменений, которые должны сохраниться целиком либо не сохраниться.

Важно не только вызвать startTransaction, но и передать ['session' => $session] каждой операции Manager. Helper передаёт её третьим аргументом executeQuery/executeBulkWrite. Чтение без этой опции в таблице специально наблюдает состояние вне транзакции.

Условие available >= 2 проверяется на сервере вместе с уменьшением. matchedCount=1 означает найденный подходящий документ; если нет, приложение должно отказаться от заказа. Транзакция не заменяет проверку бизнес-условия.

Внутренние чтения видят внесённые изменения, внешние до commit — старый остаток и отсутствие заказа. После успешного commit видна согласованная пара. State меняется от none через starting и in_progress к committed; вызов startTransaction сам по себе ещё не отправляет все будущие операции.

Параметры snapshot/primary/majority задаются для транзакции через defaultTransactionOptions Session; startTransaction может переопределить конкретный параметр. getTransactionOptions показывает maxCommitTimeMS=1000. Внутренним записям отдельный writeConcern не задаётся.

Границы и типичные ошибки

Сессия принадлежит создавшему Manager. В одну Session нельзя параллельно поместить несколько открытых транзакций. Не держите её открытой, пока пользователь думает или отвечает внешний HTTP-сервис. Изменения одного документа уже атомарны; транзакция нужна при действительно общей границе нескольких документов.

Полная самостоятельная программа и архив
Сохранить заказ и списать остаток одним решениемСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/tx118-helper.php';
[$manager,$database]=tx118Environment();
$runtime=['php'=>PHP_VERSION,'extension'=>phpversion('mongodb'),'server'=>tx118Command($manager,'admin',['buildInfo'=>1])[0]['version']];
$prefix='mongodb118_txn_'.bin2hex(random_bytes(8));
$collections=[];$owned=[];$session=null;$sessionEnded=false;$details=[];
try {
    foreach(['stock', 'orders'] as $role){
        $name=$prefix.'_'.$role;
        if(tx118Exists($manager,$database,$name)){throw new RuntimeException('Owned fixture collision');}
        tx118Command($manager,$database,['create'=>$name],true);
        $owned[]=$name;$collections[$role]=$name;
    }
$stock=$database.'.'.$collections['stock'];$orders=$database.'.'.$collections['orders'];
    tx118Insert($manager,$stock,[['_id'=>'mug','title'=>'Кружка','available'=>5]]);
    $stockOptions=['projection'=>['_id'=>0,'available'=>1],'limit'=>1];
    $orderOptions=['projection'=>['_id'=>1,'quantity'=>1],'limit'=>10];
    $session=tx118Session($manager);
    $before=tx118Read($manager,$stock,['_id'=>'mug'],$stockOptions)[0]['available'];
    $none=$session->getTransactionState();
    $session->startTransaction();
    $starting=$session->getTransactionState();$options=$session->getTransactionOptions();
    $write=tx118Update($manager,$stock,['_id'=>'mug','available'=>['$gte'=>2]],['$inc'=>['available'=>-2]],$session);
    if($write->getMatchedCount()!==1){throw new DomainException('Insufficient stock');}
    $insert=tx118Insert($manager,$orders,[['_id'=>'order-118','sku'=>'mug','quantity'=>2]],$session);
    $inside=tx118Read($manager,$stock,['_id'=>'mug'],$stockOptions,$session)[0]['available'];
    $outside=tx118Read($manager,$stock,['_id'=>'mug'],$stockOptions)[0]['available'];
    $insideOrders=count(tx118Read($manager,$orders,[],$orderOptions,$session));
    $outsideOrders=count(tx118Read($manager,$orders,[],$orderOptions));
    $inProgress=$session->getTransactionState();$active=$session->isInTransaction();
    $session->commitTransaction();
    $committed=$session->getTransactionState();
    $after=tx118Read($manager,$stock,['_id'=>'mug'],$stockOptions)[0]['available'];
    $afterOrders=count(tx118Read($manager,$orders,[],$orderOptions));
    $rows=[['До начала',$before,0,$none],['Внутри транзакции',$inside,$insideOrders,$inProgress],
        ['Снаружи до commit',$outside,$outsideOrders,'отдельное чтение'],['После commit',$after,$afterOrders,$committed]];
    $checks=['conditional_decrement'=>$write->getMatchedCount()===1 && $write->getModifiedCount()===1,
        'order_inserted'=>$insert->getInsertedCount()===1,'inside_changes_visible'=>$inside===3 && $insideOrders===1,
        'outside_uncommitted_hidden'=>$outside===5 && $outsideOrders===0,
        'committed_pair'=>$after===3 && $afterOrders===1,
        'session_lifecycle'=>$none==='none' && $starting==='starting' && $inProgress==='in_progress' && $committed==='committed',
        'active_only_before_commit'=>$active && !$session->isInTransaction(),
        'transaction_option_inherited'=>is_array($options) && $options['maxCommitTimeMS']===1000];
    $details=['starting_state'=>$starting,'maxCommitTimeMS'=>$options['maxCommitTimeMS']];
} finally {
    try {
        if($session!==null && !$sessionEnded){
            try{if($session->isInTransaction()){$session->abortTransaction();}}
            finally{$session->endSession();$sessionEnded=true;}
        }
    } finally {
        foreach(array_reverse($owned) as $name){tx118Command($manager,$database,['drop'=>$name],true);}
    }
}
$removed=true;foreach($owned as $name){$removed=$removed && !tx118Exists($manager,$database,$name);}
$checks['owned_collections_removed']=$removed;
$checks['session_closed']=$sessionEnded;
tx118Report('mongodb118-transactions-order-stock', ['Наблюдение', 'Остаток, шт.', 'Заказов', 'Состояние'], $rows, $checks, $runtime, $details);
echo 'mongodb118-transactions-order-stock: saved result.html and result.json
';
Ожидаемый результат
mongodb118-transactions-order-stock: saved result.html and result.json

Запустите php example.php на отдельном локальном replica set и откройте result.html. В архиве есть tx118-helper.php и README с подготовкой сервера, расширения и переменных окружения. Программа создаёт и убирает только собственные учебные коллекции.

Открыть фактический результат HTML · Скачать результат JSON

Упражнение с ориентиром результата. Измените количество на 4: после commit ожидаются остаток 1 и один заказ на 4 штуки. Добавьте quantity в видимую таблицу и проверьте, что обе операции получили одну Session.

Практическая проблема: Example of a transaction in MongoDB 4.0 using PHP. Разработчик не видит, как связать PHP-запросы одной транзакцией. Разбор показывает replica set, отдельный объект Session и обязательную передачу session каждой операции. Вопрос задаёт ситуацию; правила сверены с первичными источниками, программа и данные урока оригинальные.

Отменить уже добавленный заказ при нехватке товара

Черновик заказа уже добавлен внутри транзакции, но товара не хватает. Нужно убрать и черновик, а не оставить заказ с несуществующим резервом.

Входные данные

Доступна 1 кружка, заказ запрашивает 3. Пример намеренно создаёт черновик раньше условного списания, чтобы результат abort был виден.

Путь данных в этом сценарии
  1. Создан черновик внутри Session
  2. Попытка available >= 3 при остатке 1
  3. matchedCount = 0, сервер не бросает ошибку
  4. Приложение сообщает нехватку
  5. abort отменяет всю группу
  6. Снаружи остаток 1 и заказов 0

Короткое действие

if ($writeResult->getMatchedCount() !== 1) {
    $session->abortTransaction();
    // Созданный в этой же Session черновик заказа тоже не сохраняется.
}
// Нулевой matchedCount не является исключением сервера: решение принимает приложение.

Фрагмент выделяет действие внутри сценария. Объекты, namespace, данные и helper полностью подготовлены в самостоятельной программе ниже.

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

ШагОстатокКоличествоРезультат
Черновик внутри11ещё не сохранён
Условное списание10совпавших товаров
После abort10Недостаточно товара
SessionNULLNULLaborted

Почему получились эти значения

Нулевое число совпадений — допустимый ответ update. Оно не вызывает исключение MongoDB. PHP проверяет результат и бросает собственный DomainException, то есть сообщает нарушение правила заказа.

catch перехватывает только этот ожидаемый бизнес-отказ и вызывает abortTransaction. Все записи данной транзакции отменяются вместе; вручную удалять черновик отдельным запросом не нужно.

Исключение сети или неправильная команда не подменяются сообщением «товара нет». Такие ошибки здесь остаются ошибками запуска; finally завершает Session и убирает только учебные коллекции.

В реальном оформлении разумно проверять/резервировать остаток до вставки заказа, чтобы раньше отказаться. Обратный порядок в упражнении намеренный: он показывает, почему abort отменяет уже выполненную запись.

Границы и типичные ошибки

Проверка наличия до транзакции отдельно от списания оставляет окно гонки. Условный update в транзакции и проверка matchedCount всё равно нужны. Отмена базы не отправляет и не отменяет уведомления автоматически.

Полная самостоятельная программа и архив
Отменить уже добавленный заказ при нехватке товараСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/tx118-helper.php';
[$manager,$database]=tx118Environment();
$runtime=['php'=>PHP_VERSION,'extension'=>phpversion('mongodb'),'server'=>tx118Command($manager,'admin',['buildInfo'=>1])[0]['version']];
$prefix='mongodb118_txn_'.bin2hex(random_bytes(8));
$collections=[];$owned=[];$session=null;$sessionEnded=false;$details=[];
try {
    foreach(['stock', 'orders'] as $role){
        $name=$prefix.'_'.$role;
        if(tx118Exists($manager,$database,$name)){throw new RuntimeException('Owned fixture collision');}
        tx118Command($manager,$database,['create'=>$name],true);
        $owned[]=$name;$collections[$role]=$name;
    }
$stock=$database.'.'.$collections['stock'];$orders=$database.'.'.$collections['orders'];
    tx118Insert($manager,$stock,[['_id'=>'mug','available'=>1]]);
    $session=tx118Session($manager);$reason='';$insideCount=0;$matched=-1;
    $session->startTransaction();
    try{
        tx118Insert($manager,$orders,[['_id'=>'draft-118','sku'=>'mug','quantity'=>3]],$session);
        $insideCount=count(tx118Read($manager,$orders,[],['projection'=>['_id'=>1],'limit'=>10],$session));
        $result=tx118Update($manager,$stock,['_id'=>'mug','available'=>['$gte'=>3]],['$inc'=>['available'=>-3]],$session);
        $matched=$result->getMatchedCount();
        if($matched!==1){throw new DomainException('Недостаточно товара');}
        $session->commitTransaction();
    }catch(DomainException $failure){
        $reason=$failure->getMessage();$session->abortTransaction();
    }
    $state=$session->getTransactionState();
    $left=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1])[0]['available'];
    $count=count(tx118Read($manager,$orders,[],['projection'=>['_id'=>1],'limit'=>10]));
    $rows=[['Черновик внутри',1,$insideCount,'ещё не сохранён'],['Условное списание',1,$matched,'совпавших товаров'],
        ['После abort',$left,$count,$reason],['Session',null,null,$state]];
    $checks=['draft_existed_inside'=>$insideCount===1,'condition_matched_zero'=>$matched===0,
        'reason_is_business'=>$reason==='Недостаточно товара','stock_unchanged'=>$left===1,
        'draft_rolled_back'=>$count===0,'transaction_aborted'=>$state==='aborted' && !$session->isInTransaction()];
} finally {
    try {
        if($session!==null && !$sessionEnded){
            try{if($session->isInTransaction()){$session->abortTransaction();}}
            finally{$session->endSession();$sessionEnded=true;}
        }
    } finally {
        foreach(array_reverse($owned) as $name){tx118Command($manager,$database,['drop'=>$name],true);}
    }
}
$removed=true;foreach($owned as $name){$removed=$removed && !tx118Exists($manager,$database,$name);}
$checks['owned_collections_removed']=$removed;
$checks['session_closed']=$sessionEnded;
tx118Report('mongodb118-transactions-business-abort', ['Шаг', 'Остаток', 'Количество', 'Результат'], $rows, $checks, $runtime, $details);
echo 'mongodb118-transactions-business-abort: saved result.html and result.json
';
Ожидаемый результат
mongodb118-transactions-business-abort: saved result.html and result.json

Запустите php example.php на отдельном локальном replica set и откройте result.html. В архиве есть tx118-helper.php и README с подготовкой сервера, расширения и переменных окружения. Программа создаёт и убирает только собственные учебные коллекции.

Открыть фактический результат HTML · Скачать результат JSON

Упражнение с ориентиром результата. Поставьте запрос 1 штука: бизнес-отказ исчезнет, сработает commit и останется 0 кружек с одним заказом. Обновите ожидаемый результат и объясните, почему прежняя таблица abort теперь не подходит.

Откатить списание после ошибки уникального ключа

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

Входные данные

У orders есть уникальный индекс request_key; запись 900 уже содержит used-request. Остаток 5. Новая попытка уменьшает его до 4 и пытается записать заказ 901 с тем же ключом.

Путь данных в этом сценарии
  1. Индекс и исходные данные созданы заранее
  2. В транзакции остаток 5 → 4
  3. Вставка повторного request_key
  4. Сервер сообщает код 11000
  5. Ошибка транзакции обработана, commit не вызывается
  6. Снаружи снова остаток 5 и только заказ 900

Короткое действие

try {
    $manager->executeBulkWrite($orders, $insertWithDuplicateKey, ['session' => $session]);
} catch (MongoDB\Driver\Exception\BulkWriteException $failure) {
    if ($session->isInTransaction()) { $session->abortTransaction(); }
    throw $failure; // Нельзя игнорировать ошибку и затем считать commit успешным.
}

Фрагмент выделяет действие внутри сценария. Объекты, namespace, данные и helper полностью подготовлены в самостоятельной программе ниже.

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

ЭтапОстатокЗаказ/код
До транзакции5900
После списания внутри4новый заказ ещё не записан
Сервер отклонил дубликатNULL11000
После отказа5900

Почему получились эти значения

Уникальный индекс — правило сервера, запрещающее одинаковые значения ключа в разных документах. Он создан до транзакции. Проверка «сначала поиск, потом вставка» сама по себе не заменяет индекс при двух одновременных запросах.

Ошибка BulkWriteException содержит WriteResult и ошибки записи. Программа принимает ожидаемым только единственный код 11000; любой другой отказ пробрасывается. Текст ошибки не сравнивается целиком, поскольку он зависит от версии и случайного имени коллекции.

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

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

Границы и типичные ошибки

ordered=false не превращает транзакцию в пакет с частичным успехом. Ошибка сервера отличается от ожидаемого matchedCount=0. Эта программа не моделирует WriteConflict или разрыв сети и не доказывает алгоритм повторов при конкурентной нагрузке.

Полная самостоятельная программа и архив
Откатить списание после ошибки уникального ключаСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/tx118-helper.php';
[$manager,$database]=tx118Environment();
$runtime=['php'=>PHP_VERSION,'extension'=>phpversion('mongodb'),'server'=>tx118Command($manager,'admin',['buildInfo'=>1])[0]['version']];
$prefix='mongodb118_txn_'.bin2hex(random_bytes(8));
$collections=[];$owned=[];$session=null;$sessionEnded=false;$details=[];
try {
    foreach(['stock', 'orders'] as $role){
        $name=$prefix.'_'.$role;
        if(tx118Exists($manager,$database,$name)){throw new RuntimeException('Owned fixture collision');}
        tx118Command($manager,$database,['create'=>$name],true);
        $owned[]=$name;$collections[$role]=$name;
    }
$stock=$database.'.'.$collections['stock'];$orders=$database.'.'.$collections['orders'];
    tx118Command($manager,$database,['createIndexes'=>$collections['orders'],
        'indexes'=>[['key'=>['request_key'=>1],'name'=>'request_key_unique','unique'=>true]]],true);
    tx118Insert($manager,$stock,[['_id'=>'mug','available'=>5]]);
    tx118Insert($manager,$orders,[['_id'=>900,'request_key'=>'used-request','quantity'=>1]]);
    $session=tx118Session($manager);$codes=[];$changedInside=-1;
    $session->startTransaction();
    try{
        $change=tx118Update($manager,$stock,['_id'=>'mug','available'=>['$gte'=>1]],['$inc'=>['available'=>-1]],$session);
        if($change->getMatchedCount()!==1){throw new RuntimeException('Fixture stock mismatch');}
        $changedInside=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1],$session)[0]['available'];
        tx118Insert($manager,$orders,[['_id'=>901,'request_key'=>'used-request','quantity'=>1]],$session);
        throw new RuntimeException('Expected duplicate key did not occur');
    }catch(MongoDB\Driver\Exception\BulkWriteException $failure){
        $codes=array_map(fn($error)=>$error->getCode(),$failure->getWriteResult()->getWriteErrors());
        if($codes!==[11000]){throw $failure;}
        if($session->isInTransaction()){$session->abortTransaction();}
    }
    $left=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1])[0]['available'];
    $saved=tx118Read($manager,$orders,[],['projection'=>['_id'=>1],'sort'=>['_id'=>1],'limit'=>10]);
    $rows=[['До транзакции',5,'900'],['После списания внутри',$changedInside,'новый заказ ещё не записан'],
        ['Сервер отклонил дубликат',null,(string)$codes[0]],['После отказа',$left,implode(', ',array_column($saved,'_id'))]];
    $checks=['decrement_was_inside'=>$changedInside===4,'duplicate_code'=>$codes===[11000],
        'decrement_rolled_back'=>$left===5,'only_original_order'=>array_column($saved,'_id')===[900],
        'no_active_transaction'=>!$session->isInTransaction()];
} finally {
    try {
        if($session!==null && !$sessionEnded){
            try{if($session->isInTransaction()){$session->abortTransaction();}}
            finally{$session->endSession();$sessionEnded=true;}
        }
    } finally {
        foreach(array_reverse($owned) as $name){tx118Command($manager,$database,['drop'=>$name],true);}
    }
}
$removed=true;foreach($owned as $name){$removed=$removed && !tx118Exists($manager,$database,$name);}
$checks['owned_collections_removed']=$removed;
$checks['session_closed']=$sessionEnded;
tx118Report('mongodb118-transactions-duplicate-rollback', ['Этап', 'Остаток', 'Заказ/код'], $rows, $checks, $runtime, $details);
echo 'mongodb118-transactions-duplicate-rollback: saved result.html and result.json
';
Ожидаемый результат
mongodb118-transactions-duplicate-rollback: saved result.html and result.json

Запустите php example.php на отдельном локальном replica set и откройте result.html. В архиве есть tx118-helper.php и README с подготовкой сервера, расширения и переменных окружения. Программа создаёт и убирает только собственные учебные коллекции.

Открыть фактический результат HTML · Скачать результат JSON

Упражнение с ориентиром результата. Замените второй request_key на new-request и завершите ветку успешным commit: получите остаток 4 и два разных заказа. Сравните этот результат с отменой всей группы при сохранённом дубликате.

Практическая проблема: insert_many mongoDB and how to ignore duplicate inserts in a session transaction. Попытка проигнорировать duplicate key и продолжить прежнюю транзакцию не гарантирует успех. В уроке код 11000 ожидается явно, ранее выполненное списание не остаётся сохранённым. Вопрос задаёт ситуацию; правила сверены с первичными источниками, программа и данные урока оригинальные.

Перенести целые копейки между двумя документами

Внутренние учебные кошельки должны согласованно перенести 2500 копеек: уменьшить A, увеличить B и сохранить квитанцию. Отсутствующий получатель не должен оставлять одно списание.

Входные данные

A=10000 копеек, B=3000. Первая попытка переводит 2500 в B, вторая — 1000 в несуществующий missing. Все суммы небольшие целые, внешней оплаты нет.

Путь данных в этом сценарии
  1. Условное уменьшение A
  2. Проверка существования B и увеличение
  3. Квитанция в той же Session
  4. commit → 7500 + 5500 = 13000
  5. Вторая попытка не находит получателя
  6. abort возвращает её списание

Короткое действие

// Оба изменения и квитанция получают одну и ту же Session.
$debit = ['$inc' => ['balance_kopecks' => -2500]];
$credit = ['$inc' => ['balance_kopecks' => 2500]];
// Только если оба matchedCount === 1:
$session->commitTransaction();
// При отсутствии получателя откатываем и уже сделанное списание.

Фрагмент выделяет действие внутри сценария. Объекты, namespace, данные и helper полностью подготовлены в самостоятельной программе ниже.

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

ОперацияКошелёк A, коп.Кошелёк B, коп.Всего, коп.Квитанций
До перевода100003000130000
Переведено 250075005500130001
Отказ: получатель отсутствует75005500130001

Почему получились эти значения

Функция transfer принимает получателя, положительное количество копеек и ID квитанции. Замыкание получает Manager, namespace и Session через use. Для каждой попытки startTransaction открывает новую транзакцию в той же незакрытой Session.

Первое изменение включает условие достаточного баланса; второе проверяет matchedCount получателя. Только после двух совпадений пишется квитанция и вызывается commit. Сумма 13000 служит наглядной проверкой сохранения общего количества копеек.

Во второй попытке списание уже произошло внутри транзакции, но получателя нет. DomainException приводит к abort: состояние обоих кошельков и число квитанций остаются как после первой попытки.

commitStarted отделяет ошибку тела от ошибки подтверждения. Если commit начат и выброшено исключение, программа не сообщает «деньги возвращены» и не начинает новый перевод автоматически. Ошибка идёт вызывающему коду, который должен разрешить неизвестный исход по правилам приложения.

Границы и типичные ошибки

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

Полная самостоятельная программа и архив
Перенести целые копейки между двумя документамиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/tx118-helper.php';
[$manager,$database]=tx118Environment();
$runtime=['php'=>PHP_VERSION,'extension'=>phpversion('mongodb'),'server'=>tx118Command($manager,'admin',['buildInfo'=>1])[0]['version']];
$prefix='mongodb118_txn_'.bin2hex(random_bytes(8));
$collections=[];$owned=[];$session=null;$sessionEnded=false;$details=[];
try {
    foreach(['wallets', 'receipts'] as $role){
        $name=$prefix.'_'.$role;
        if(tx118Exists($manager,$database,$name)){throw new RuntimeException('Owned fixture collision');}
        tx118Command($manager,$database,['create'=>$name],true);
        $owned[]=$name;$collections[$role]=$name;
    }
$wallets=$database.'.'.$collections['wallets'];$receipts=$database.'.'.$collections['receipts'];
    tx118Insert($manager,$wallets,[['_id'=>'A','balance_kopecks'=>10000],['_id'=>'B','balance_kopecks'=>3000]]);
    $session=tx118Session($manager);
    $transfer=function(string $to,int $amount,string $receiptId) use($manager,$wallets,$receipts,$session): void {
        if($amount<=0 || $amount>10000 || $to==='A'){throw new DomainException('Недопустимый перевод');}
        $session->startTransaction();
        $commitStarted=false;
        try{
            $debit=tx118Update($manager,$wallets,['_id'=>'A','balance_kopecks'=>['$gte'=>$amount]],['$inc'=>['balance_kopecks'=>-$amount]],$session);
            if($debit->getMatchedCount()!==1){throw new DomainException('Недостаточно средств');}
            $credit=tx118Update($manager,$wallets,['_id'=>$to],['$inc'=>['balance_kopecks'=>$amount]],$session);
            if($credit->getMatchedCount()!==1){throw new DomainException('Получатель отсутствует');}
            tx118Insert($manager,$receipts,[['_id'=>$receiptId,'from'=>'A','to'=>$to,'kopecks'=>$amount]],$session);
            $commitStarted=true;$session->commitTransaction();
        }catch(Throwable $failure){
            if(!$commitStarted && $session->isInTransaction()){$session->abortTransaction();}
            throw $failure;
        }
    };
    $transfer('B',2500,'transfer-1');
    $committed=$session->getTransactionState();
    $afterSuccess=tx118Read($manager,$wallets,[],['projection'=>['_id'=>1,'balance_kopecks'=>1],'sort'=>['_id'=>1],'limit'=>10]);
    $refused='';
    try{$transfer('missing',1000,'transfer-2');}
    catch(DomainException $failure){$refused=$failure->getMessage();}
    $afterFailure=tx118Read($manager,$wallets,[],['projection'=>['_id'=>1,'balance_kopecks'=>1],'sort'=>['_id'=>1],'limit'=>10]);
    $records=tx118Read($manager,$receipts,[],['projection'=>['_id'=>1],'limit'=>10]);
    $rows=[['До перевода',10000,3000,13000,0],
        ['Переведено 2500',$afterSuccess[0]['balance_kopecks'],$afterSuccess[1]['balance_kopecks'],array_sum(array_column($afterSuccess,'balance_kopecks')),1],
        ['Отказ: получатель отсутствует',$afterFailure[0]['balance_kopecks'],$afterFailure[1]['balance_kopecks'],array_sum(array_column($afterFailure,'balance_kopecks')),count($records)]];
    $checks=['success_balances'=>array_column($afterSuccess,'balance_kopecks')===[7500,5500],
        'sum_preserved'=>array_sum(array_column($afterSuccess,'balance_kopecks'))===13000,
        'success_committed'=>$committed==='committed','failed_recipient'=>$refused==='Получатель отсутствует',
        'failed_debit_rolled_back'=>$afterSuccess===$afterFailure,
        'only_one_receipt'=>array_column($records,'_id')===['transfer-1'],
        'second_transaction_aborted'=>$session->getTransactionState()==='aborted'];
} finally {
    try {
        if($session!==null && !$sessionEnded){
            try{if($session->isInTransaction()){$session->abortTransaction();}}
            finally{$session->endSession();$sessionEnded=true;}
        }
    } finally {
        foreach(array_reverse($owned) as $name){tx118Command($manager,$database,['drop'=>$name],true);}
    }
}
$removed=true;foreach($owned as $name){$removed=$removed && !tx118Exists($manager,$database,$name);}
$checks['owned_collections_removed']=$removed;
$checks['session_closed']=$sessionEnded;
tx118Report('mongodb118-transactions-wallet-transfer', ['Операция', 'Кошелёк A, коп.', 'Кошелёк B, коп.', 'Всего, коп.', 'Квитанций'], $rows, $checks, $runtime, $details);
echo 'mongodb118-transactions-wallet-transfer: saved result.html and result.json
';
Ожидаемый результат
mongodb118-transactions-wallet-transfer: saved result.html and result.json

Запустите php example.php на отдельном локальном replica set и откройте result.html. В архиве есть tx118-helper.php и README с подготовкой сервера, расширения и переменных окружения. Программа создаёт и убирает только собственные учебные коллекции.

Открыть фактический результат HTML · Скачать результат JSON

Упражнение с ориентиром результата. Измените первую сумму на 1000: ожидаются A=9000, B=4000, общий итог 13000. Затем проверьте отказ суммы больше доступного баланса: обе стороны и квитанции должны остаться прежними.

Посмотреть предварительное изменение и закрыть Session без сохранения

Нужно разобраться, что происходит с незавершённой работой при закрытии Session. Предварительное изменение покажет состояние внутри транзакции и подтвердит отсутствие сохранения после endSession.

Входные данные

Остаток 8, предварительное уменьшение на 3. Session создаётся без defaultTransactionOptions; явные настройки передаются в startTransaction. Это короткая серверная демонстрация, не удержание корзины на время чтения страницы.

Путь данных в этом сценарии
  1. none: транзакции нет, options=null
  2. startTransaction → starting
  3. Запись с Session → in_progress
  4. Внутри видно 5 вместо 8
  5. endSession отменяет незавершённую работу
  6. Новое внешнее чтение снова показывает 8

Короткое действие

$session->startTransaction($options);
$settings = $session->getTransactionOptions();
$state = $session->getTransactionState();
// Сделали предварительную запись в этой Session, посмотрели результат.
$session->endSession(); // Активная транзакция отменяется.
// Больше не обращаемся к закрытой Session; читаем результат отдельным запросом.

Фрагмент выделяет действие внутри сценария. Объекты, namespace, данные и helper полностью подготовлены в самостоятельной программе ниже.

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

ШагgetTransactionStateisInTransactionmaxCommitTimeMSОстаток
До startTransactionnonefalseNULL8
После startTransactionstartingtrue10008
Предварительное списаниеin_progresstrue10005
После endSessionSession закрытане вызываемне вызываем8

Почему получились эти значения

isInTransaction отвечает логическим true/false, getTransactionState — более подробной строкой состояния. До начала getTransactionOptions даёт null; после startTransaction — массив параметров, включающий объекты ReadConcern/ReadPreference/WriteConcern и число maxCommitTimeMS.

starting означает, что транзакция объявлена в Session, но ещё не выполнялась серверная операция. После записи состояние in_progress. Это полезная диагностика жизненного цикла, а не подсчёт запросов или пользовательских заказов.

endSession закрывает сессию и отменяет связанную незавершённую транзакцию. После него нельзя обращаться к другим методам этого Session. Поэтому последняя строка таблицы подписана «не вызываем», а итог читается без закрытого объекта.

Для обычной отмены бизнес-операции понятнее явный abortTransaction. endSession здесь нужен, чтобы изучить закрытие ресурса; сборка мусора тоже закрывает Session, но явный finally делает границу учебного кода заметной.

Границы и типичные ошибки

Не используйте длинную транзакцию как интерактивный предварительный просмотр для пользователя: она удерживает ресурсы. Для реального интерфейса сначала рассчитайте предложение чтением, а подтверждение оформляйте короткой условной записью/транзакцией. Строка локального состояния не доказывает серверный исход после неизвестного commit.

Полная самостоятельная программа и архив
Посмотреть предварительное изменение и закрыть Session без сохраненияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/tx118-helper.php';
[$manager,$database]=tx118Environment();
$runtime=['php'=>PHP_VERSION,'extension'=>phpversion('mongodb'),'server'=>tx118Command($manager,'admin',['buildInfo'=>1])[0]['version']];
$prefix='mongodb118_txn_'.bin2hex(random_bytes(8));
$collections=[];$owned=[];$session=null;$sessionEnded=false;$details=[];
try {
    foreach(['stock'] as $role){
        $name=$prefix.'_'.$role;
        if(tx118Exists($manager,$database,$name)){throw new RuntimeException('Owned fixture collision');}
        tx118Command($manager,$database,['create'=>$name],true);
        $owned[]=$name;$collections[$role]=$name;
    }
$stock=$database.'.'.$collections['stock'];
    tx118Insert($manager,$stock,[['_id'=>'mug','available'=>8]]);
    $session=$manager->startSession(['causalConsistency'=>false]);
    $none=$session->getTransactionState();$noTransaction=!$session->isInTransaction();
    $beforeOptions=$session->getTransactionOptions();
    $session->startTransaction(tx118Options());
    $starting=$session->getTransactionState();$currentOptions=$session->getTransactionOptions();
    $active=$session->isInTransaction();
    tx118Update($manager,$stock,['_id'=>'mug','available'=>['$gte'=>3]],['$inc'=>['available'=>-3]],$session);
    $preview=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1],$session)[0]['available'];
    $inProgress=$session->getTransactionState();
    $session->endSession();$sessionEnded=true;
    // После endSession методы этой Session больше не вызываем.
    $outside=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1])[0]['available'];
    $rows=[['До startTransaction',$none,'false','NULL',8],
        ['После startTransaction',$starting,$active?'true':'false',(string)$currentOptions['maxCommitTimeMS'],8],
        ['Предварительное списание',$inProgress,'true',(string)$currentOptions['maxCommitTimeMS'],$preview],
        ['После endSession','Session закрыта','не вызываем','не вызываем',$outside]];
    $checks=['initial_state'=>$none==='none' && $noTransaction,'initial_options_null'=>$beforeOptions===null,
        'starting_and_active'=>$starting==='starting' && $active,'in_progress_after_write'=>$inProgress==='in_progress',
        'option_contract'=>$currentOptions['maxCommitTimeMS']===1000
            && $currentOptions['readConcern'] instanceof MongoDB\Driver\ReadConcern
            && $currentOptions['writeConcern'] instanceof MongoDB\Driver\WriteConcern
            && $currentOptions['readPreference'] instanceof MongoDB\Driver\ReadPreference,
        'preview_changed'=>$preview===5,'end_session_aborted'=>$outside===8];
} finally {
    try {
        if($session!==null && !$sessionEnded){
            try{if($session->isInTransaction()){$session->abortTransaction();}}
            finally{$session->endSession();$sessionEnded=true;}
        }
    } finally {
        foreach(array_reverse($owned) as $name){tx118Command($manager,$database,['drop'=>$name],true);}
    }
}
$removed=true;foreach($owned as $name){$removed=$removed && !tx118Exists($manager,$database,$name);}
$checks['owned_collections_removed']=$removed;
$checks['session_closed']=$sessionEnded;
tx118Report('mongodb118-transactions-checkout-preview', ['Шаг', 'getTransactionState', 'isInTransaction', 'maxCommitTimeMS', 'Остаток'], $rows, $checks, $runtime, $details);
echo 'mongodb118-transactions-checkout-preview: saved result.html and result.json
';
Ожидаемый результат
mongodb118-transactions-checkout-preview: saved result.html and result.json

Запустите php example.php на отдельном локальном replica set и откройте result.html. В архиве есть tx118-helper.php и README с подготовкой сервера, расширения и переменных окружения. Программа создаёт и убирает только собственные учебные коллекции.

Открыть фактический результат HTML · Скачать результат JSON

Упражнение с ориентиром результата. Завершите предварительное изменение явным commit перед endSession: последнее внешнее чтение должно показать 5. При abort перед закрытием останется 8. Не вызывайте getTransactionState после endSession.

Практическая проблема: Is Possible Mongodb Transaction progress without Replica Set?. Один сервер ошибочно приравнивают к standalone без возможности транзакций. Инструкция различает одиночный процесс в replica set и standalone; первая учебная конфигурация не обещает отказоустойчивость. Вопрос задаёт ситуацию; правила сверены с первичными источниками, программа и данные урока оригинальные.

Повторить ключ запроса без второго списания товара

Клиент повторил запрос оформления, потому что не увидел ответ. Один и тот же ключ должен вернуть прежний заказ, а не списать ещё две кружки. Изменённое содержимое под старым ключом должно быть отклонено.

Входные данные

request-118-001 означает заказ двух кружек. Начальный остаток 5. Последовательно идут первый запрос, точный повтор и конфликтный повтор на три штуки.

Путь данных в этом сценарии
  1. Уникальный _id = ключ запроса
  2. В транзакции ищем сохранённый результат
  3. Нет → заказ и списание, commit
  4. Повтор → сравниваем состав и возвращаем заказ
  5. Другой состав → отказ
  6. Остаток 3, один заказ

Короткое действие

// Ключ _id запроса уникален. В транзакции сначала ищем готовый результат.
if ($existingOrder !== []) {
    // Сравнить sku/quantity с исходным запросом.
    $session->abortTransaction(); // Ничего нового не записывали.
    return $existingOrder[0];
}
// Иначе вставить заказ и списать остаток с той же Session, затем commit.
// Не вызывайте здесь реальный платёж: откат базы не отменит внешний сервис.

Фрагмент выделяет действие внутри сценария. Объекты, namespace, данные и helper полностью подготовлены в самостоятельной программе ниже.

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

ПопыткаОтветЗаказОстаток
Первый запрос, 2 шт.созданorder-40013
Тот же ключ, 2 шт.повтор: существующий заказorder-40013
Тот же ключ, 3 шт.Ключ уже связан с другим составомне создан3

Почему получились эти значения

Идемпотентность в этой задаче означает: повтор одной логической команды не повторяет её эффект. Ключ назначает клиент или ваш сервер по договору; он должен быть стабилен при повторе и связан с теми же разрешёнными параметрами.

Поле _id уже уникально. Оно используется как ключ запроса в orders. Сначала читается возможный результат, затем либо возвращается существующий order_id, либо в той же транзакции создаётся заказ и уменьшается остаток.

На точном повторе записей нет: транзакция чтения завершается abort, а ответ берётся из сохранённого документа. При quantity=3 сравнение обнаруживает, что старый ключ связан с другим составом, и не разрешает новую операцию.

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

order-4001 — фиксированный учебный номер одного заказа, не генератор номеров для рабочего сервиса. Здесь нет потерянного сетевого ответа: повтор задаётся явно после успешного commit. Неизвестный commit не следует называть ни подтверждённым успехом, ни доказанным откатом.

Границы и типичные ошибки

Сохранённый ключ должен учитывать пользователя/арендатора и неизменный состав операции. Для внешней оплаты нужна согласованная идемпотентность провайдера и процесс доставки, например отдельная очередь/outbox; rollback базы не отменяет уже отправленный платёж или письмо. В этом примере внешних эффектов нет.

Полная самостоятельная программа и архив
Повторить ключ запроса без второго списания товараСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/tx118-helper.php';
[$manager,$database]=tx118Environment();
$runtime=['php'=>PHP_VERSION,'extension'=>phpversion('mongodb'),'server'=>tx118Command($manager,'admin',['buildInfo'=>1])[0]['version']];
$prefix='mongodb118_txn_'.bin2hex(random_bytes(8));
$collections=[];$owned=[];$session=null;$sessionEnded=false;$details=[];
try {
    foreach(['stock', 'orders'] as $role){
        $name=$prefix.'_'.$role;
        if(tx118Exists($manager,$database,$name)){throw new RuntimeException('Owned fixture collision');}
        tx118Command($manager,$database,['create'=>$name],true);
        $owned[]=$name;$collections[$role]=$name;
    }
$stock=$database.'.'.$collections['stock'];$orders=$database.'.'.$collections['orders'];
    tx118Insert($manager,$stock,[['_id'=>'mug','available'=>5]]);
    $session=tx118Session($manager);
    $place=function(string $key,int $quantity) use($manager,$stock,$orders,$session): array {
        if(!preg_match('/\Arequest-[A-Za-z0-9-]{1,40}\z/D',$key) || $quantity<1 || $quantity>10){throw new DomainException('Неверный запрос');}
        $session->startTransaction();$commitStarted=false;
        try{
            $existing=tx118Read($manager,$orders,['_id'=>$key],['projection'=>['_id'=>1,'order_id'=>1,'sku'=>1,'quantity'=>1],'limit'=>1],$session);
            if($existing!==[]){
                if($existing[0]['sku']!=='mug' || $existing[0]['quantity']!==$quantity){throw new DomainException('Ключ уже связан с другим составом');}
                $session->abortTransaction(); // В этой попытке записей нет.
                return ['status'=>'повтор: существующий заказ','order_id'=>$existing[0]['order_id']];
            }
            // _id уникален: request key не может стать двумя заказами.
            tx118Insert($manager,$orders,[['_id'=>$key,'order_id'=>'order-4001','sku'=>'mug','quantity'=>$quantity]],$session);
            $debit=tx118Update($manager,$stock,['_id'=>'mug','available'=>['$gte'=>$quantity]],['$inc'=>['available'=>-$quantity]],$session);
            if($debit->getMatchedCount()!==1){throw new DomainException('Недостаточно товара');}
            $commitStarted=true;$session->commitTransaction();
            return ['status'=>'создан','order_id'=>'order-4001'];
        }catch(Throwable $failure){
            if(!$commitStarted && $session->isInTransaction()){$session->abortTransaction();}
            throw $failure;
        }
    };
    $first=$place('request-118-001',2);
    $firstStock=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1])[0]['available'];
    $second=$place('request-118-001',2);
    $secondStock=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1])[0]['available'];
    $refused='';
    try{$place('request-118-001',3);}catch(DomainException $failure){$refused=$failure->getMessage();}
    $finalStock=tx118Read($manager,$stock,['_id'=>'mug'],['projection'=>['available'=>1],'limit'=>1])[0]['available'];
    $saved=tx118Read($manager,$orders,[],['projection'=>['_id'=>1,'quantity'=>1],'limit'=>10]);
    $rows=[['Первый запрос, 2 шт.',$first['status'],$first['order_id'],$firstStock],
        ['Тот же ключ, 2 шт.',$second['status'],$second['order_id'],$secondStock],
        ['Тот же ключ, 3 шт.',$refused,'не создан',$finalStock]];
    $checks=['first_committed'=>$first['status']==='создан' && $firstStock===3,
        'repeated_result'=>$second['status']==='повтор: существующий заказ' && $second['order_id']===$first['order_id'],
        'no_second_debit'=>$secondStock===3 && $finalStock===3,
        'payload_conflict'=>$refused==='Ключ уже связан с другим составом',
        'single_order'=>count($saved)===1 && $saved[0]['_id']==='request-118-001' && $saved[0]['quantity']===2,
        'no_active_after_resolution'=>!$session->isInTransaction()];
} finally {
    try {
        if($session!==null && !$sessionEnded){
            try{if($session->isInTransaction()){$session->abortTransaction();}}
            finally{$session->endSession();$sessionEnded=true;}
        }
    } finally {
        foreach(array_reverse($owned) as $name){tx118Command($manager,$database,['drop'=>$name],true);}
    }
}
$removed=true;foreach($owned as $name){$removed=$removed && !tx118Exists($manager,$database,$name);}
$checks['owned_collections_removed']=$removed;
$checks['session_closed']=$sessionEnded;
tx118Report('mongodb118-transactions-idempotent-order', ['Попытка', 'Ответ', 'Заказ', 'Остаток'], $rows, $checks, $runtime, $details);
echo 'mongodb118-transactions-idempotent-order: saved result.html and result.json
';
Ожидаемый результат
mongodb118-transactions-idempotent-order: saved result.html and result.json

Запустите php example.php на отдельном локальном replica set и откройте result.html. В архиве есть tx118-helper.php и README с подготовкой сервера, расширения и переменных окружения. Программа создаёт и убирает только собственные учебные коллекции.

Открыть фактический результат HTML · Скачать результат JSON

Упражнение с ориентиром результата. Повторите ключ ещё раз с quantity=2: получите тот же order-4001 и остаток 3. Затем используйте новый ключ и другой учебный order_id: это уже новый заказ, а не повтор прежнего.

Восемь методов Session: начало, результат и завершение

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

МетодНазначение
MongoDB\Driver\Manager::startSessionСоздать логическую Session, связывающую последовательные операции одного Manager.
MongoDB\Driver\Session::startTransactionНачать многодокументную транзакцию в имеющейся Session.
MongoDB\Driver\Session::commitTransactionПодтвердить группу изменений и завершить транзакцию.
MongoDB\Driver\Session::abortTransactionЗавершить транзакцию без сохранения её изменений.
MongoDB\Driver\Session::isInTransactionПроверить, считает ли Session транзакцию начатой и ещё не завершённой.
MongoDB\Driver\Session::endSessionЗакрыть Session; незавершённая связанная транзакция отменяется.
MongoDB\Driver\Session::getTransactionStateПолучить строковую фазу транзакции для диагностики жизненного цикла.
MongoDB\Driver\Session::getTransactionOptionsПрочитать настройки текущей транзакции, чтобы видеть применённый договор.

Выбрать границу одной бизнес-операции

Выберите одно проверяемое бизнес-правило: заказ не существует без списания, перенос не меняет общую сумму, повтор ключа не повторяет эффект. Определите документы, выполняйте нужные условные операции с одной Session и проверяйте их результат до commit. Получайте из базы конкретный документ или нужные поля, а не весь каталог для последующего поиска в PHP.

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

Эта глава раскрывает восемь перечисленных методов и шесть ограниченных сценариев. Полные стратегии повторов с deadline, восстановление неизвестного исхода, конкурентные первые запросы, outbox, sharded transactions, переключение primary, серверные лимиты большой нагрузки и остальные методы Session требуют отдельных разделов. Один работающий checkout не означает, что все эти темы уже объяснены или проверены.

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

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

MongoDB\Driver\Manager::startSessionСоздать логическую Session, связывающую последовательные операции одного Manager.

Метод с mongodb 1.4.0; defaultTransactionOptions добавлен в 1.5.0, его maxCommitTimeMS — в 1.6.0, snapshot — в 1.11.0. · mongodb

final public MongoDB\Driver\Manager::startSession(?array $options = null): MongoDB\Driver\Session

Создать логическую Session, связывающую последовательные операции одного Manager.

final public MongoDB\Driver\Manager::startSession(?array $options = null): MongoDB\Driver\Session

Параметры

Аргумент или опцияСмысл
causalConsistency: boolПричинный порядок операций в Session; default true. Сам по себе не превращает операции в транзакцию.
defaultTransactionOptions: arrayПараметры новых транзакций по умолчанию; default []. Конкретные значения переопределяются в startTransaction. Доступно с сервером MongoDB 4.0+.
defaultTransactionOptions.maxCommitTimeMS: intМаксимальное время одной команды commit в миллисекундах. По документации — неотрицательное знаковое 32-битное целое; 0 не задаёт конечного серверного предела. Это не общий бюджет всей транзакции.
defaultTransactionOptions.readConcern: MongoDB\Driver\ReadConcernУровень чтения транзакции. Сам readConcern доступен с MongoDB 3.2; неподдерживаемая версия вызывает ошибку при выполнении. Для рассматриваемых транзакций нужен replica set MongoDB 4.0+; в архиве 8.0.
defaultTransactionOptions.readPreference: MongoDB\Driver\ReadPreferenceВыбор сервера. Транзакция с чтениями использует primary; не отправляйте её отдельные чтения на secondary.
defaultTransactionOptions.writeConcern: MongoDB\Driver\WriteConcernТребование подтверждения commit. Задаётся на транзакции, не на каждой её внутренней записи.
snapshot: boolDefault false. С MongoDB 5.0+ первый поддерживаемый find/aggregate/unsharded distinct получает timestamp, последующие чтения используют snapshot этого момента. Нельзя сочетать с транзакциями, записью или causalConsistency=true. При snapshot=true causalConsistency по умолчанию становится false.

Результат

MongoDB\Driver\Session. Её можно использовать только с тем Manager, который её создал; объект передаётся опцией session при командах, чтениях и записях.

Ошибки и границы

InvalidArgumentException: разбор аргументов либо causalConsistency=true вместе со snapshot=true. RuntimeException: Session не удалось создать, например нет требуемой поддержки crypto в libmongoc. Session отличается от PHP session_start; создание Session не сохраняет заказ.

Версии

Метод с mongodb 1.4.0; defaultTransactionOptions добавлен в 1.5.0, его maxCommitTimeMS — в 1.6.0, snapshot — в 1.11.0.

Применение 1: Настроить общую работу заказа и склада

$session = $manager->startSession([
    'causalConsistency' => false,
    'defaultTransactionOptions' => tx118Options(),
]);

Два namespace получают один объект Session. У startTransaction наследуется maxCommitTimeMS=1000; заказ и остаток становятся видимыми после общего commit.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Открыть Session для короткого предварительного изменения

$session = $manager->startSession(['causalConsistency' => false]);
$session->startTransaction(tx118Options());

Сначала транзакции нет, затем явные настройки начинают предварительную работу. Закрытие Session отменяет изменение остатка 8→5.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::startTransactionНачать многодокументную транзакцию в имеющейся Session.

С mongodb 1.5.0; maxCommitTimeMS добавлен в 1.6.0. Многодокументные транзакции появились в MongoDB 4.0 для replica set, в 4.2 для sharded cluster; standalone их не поддерживает. · mongodb

final public MongoDB\Driver\Session::startTransaction(?array $options = null): void

Начать многодокументную транзакцию в имеющейся Session.

final public MongoDB\Driver\Session::startTransaction(?array $options = null): void

Параметры

Аргумент или опцияСмысл
options: ?arrayКаждый переданный параметр заменяет одноимённый defaultTransactionOptions этой Session. В одной Session допускается только одна открытая транзакция.
maxCommitTimeMS: intМаксимальное время одной команды commit в миллисекундах. По документации — неотрицательное знаковое 32-битное целое; 0 не задаёт конечного серверного предела. Это не общий бюджет всей транзакции.
readConcern: MongoDB\Driver\ReadConcernУровень чтения транзакции. Сам readConcern доступен с MongoDB 3.2; неподдерживаемая версия вызывает ошибку при выполнении. Для рассматриваемых транзакций нужен replica set MongoDB 4.0+; в архиве 8.0.
readPreference: MongoDB\Driver\ReadPreferenceВыбор сервера. Транзакция с чтениями использует primary; не отправляйте её отдельные чтения на secondary.
writeConcern: MongoDB\Driver\WriteConcernТребование подтверждения commit. Задаётся на транзакции, не на каждой её внутренней записи.

Результат

Возвращаемого значения нет. Операции входят в транзакцию только при явной передаче session. Завершение — commitTransaction или abortTransaction; закрытие Session также отменяет незавершённую работу.

Ошибки и границы

InvalidArgumentException при разборе аргументов; CommandException при серверном отказе начать работу, например проблеме получения блокировки; RuntimeException при невозможном запуске, например транзакция уже начата. Ошибка может проявиться на первой серверной операции: starting не доказывает выполненную запись.

Версии

С mongodb 1.5.0; maxCommitTimeMS добавлен в 1.6.0. Многодокументные транзакции появились в MongoDB 4.0 для replica set, в 4.2 для sharded cluster; standalone их не поддерживает.

Применение 1: Объединить две коллекции

$session->startTransaction();
// Далее обе операции получают ['session' => $session].

После объявленного starting запись переводит Session в in_progress. Снаружи до commit остаются 5 кружек и 0 заказов.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Открыть новую попытку переноса копеек

$session->startTransaction();
// Условное списание, зачисление и квитанция — одна группа.

Первая транзакция сохраняет 7500/5500 и квитанцию. Новая попытка в той же Session с отсутствующим получателем отменяется.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::commitTransactionПодтвердить группу изменений и завершить транзакцию.

С mongodb 1.5.0. При повторе commit драйвер применяет свои правила подтверждения; retryWrites=false не означает отключение внутренних повторов commit. Сетевой отказ и failover в пакете не имитируются. · mongodb

final public MongoDB\Driver\Session::commitTransaction(): void

Подтвердить группу изменений и завершить транзакцию.

final public MongoDB\Driver\Session::commitTransaction(): void

Параметры

Параметров нет. Метод вызывается у Session, созданной соответствующим Manager.

Результат

Возвращаемого значения нет. Успешное завершение сохраняет изменения группы; до commit её записи не видны вне транзакции.

Ошибки и границы

InvalidArgumentException при разборе аргументов; CommandException при серверном отказе commit, конфликте/проблеме сети; RuntimeException при невозможном состоянии, например транзакция не начата. Метки ошибок проверяют через hasErrorLabel, не по тексту. Важное уточнение по MongoDB Core API: TransientTransactionError допускает ограниченный повтор всего тела, UnknownTransactionCommitResult требует разрешать исход и повторять commit той же транзакции, а не безусловно снова переводить деньги. Неизвестный результат не доказывает rollback. В этих программах автоматического повтора нет.

Версии

С mongodb 1.5.0. При повторе commit драйвер применяет свои правила подтверждения; retryWrites=false не означает отключение внутренних повторов commit. Сетевой отказ и failover в пакете не имитируются.

Применение 1: Сохранить заказ вместе с остатком

$session->commitTransaction();

Внешнее чтение теперь показывает один order-118 и остаток 3. До подтверждения внешний запрос видел 0 и 5.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Закончить перенос между двумя документами

$commitStarted = true;
$session->commitTransaction();

Только после выполненных списания, зачисления и квитанции подтверждается перенос. Сумма двух учебных балансов осталась 13000 копеек.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::abortTransactionЗавершить транзакцию без сохранения её изменений.

С mongodb 1.5.0. · mongodb

final public MongoDB\Driver\Session::abortTransaction(): void

Завершить транзакцию без сохранения её изменений.

final public MongoDB\Driver\Session::abortTransaction(): void

Параметры

Параметров нет. Метод вызывается у Session, созданной соответствующим Manager.

Результат

Возвращаемого значения нет; изменения операций этой транзакции отменяются. Отдельные операции без её Session и внешние HTTP-эффекты не входят в эту группу.

Ошибки и границы

InvalidArgumentException при разборе аргументов; RuntimeException, если отмена невозможна, например транзакция не была начата. Нельзя вызывать abort после подтверждённого commit как «отмену заказа»: для этого нужна новая бизнес-операция. Ошибка сервера может уже завершить транзакцию; ошибка commit не всегда означает доказанную отмену.

Версии

С mongodb 1.5.0.

Применение 1: Убрать черновик при нехватке товара

$session->abortTransaction();

Черновик был виден внутри Session, но после отказа остались 1 кружка и 0 заказов. Нулевой matchedCount обработан приложением.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Завершить чтение при точном повторе запроса

if ($existing !== []) {
    $session->abortTransaction();
    return ['order_id' => $existing[0]['order_id']];
}

Повторная попытка ничего не записывала: возвращён order-4001, остаток остался 3. Этот abort завершает новую читающую транзакцию, а не отменяет прежний заказ.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::isInTransactionПроверить, считает ли Session транзакцию начатой и ещё не завершённой.

С mongodb 1.6.0. · mongodb

final public MongoDB\Driver\Session::isInTransaction(): bool

Проверить, считает ли Session транзакцию начатой и ещё не завершённой.

final public MongoDB\Driver\Session::isInTransaction(): bool

Параметры

Параметров нет. Метод вызывается у Session, созданной соответствующим Manager.

Результат

true после начала до commit/abort, false в остальных случаях. Более подробную фазу даёт getTransactionState.

Ошибки и границы

Документация указывает InvalidArgumentException при разборе аргументов. Это состояние объекта драйвера, не запрос аудита к серверу и не доказательство конечного исхода потерянного commit. После endSession объект больше не используют.

Версии

С mongodb 1.6.0.

Применение 1: Отметить границу подтверждения

$active = $session->isInTransaction();
$session->commitTransaction();
$after = $session->isInTransaction();

Во время работы active=true, после успешного commit — false. Видимость заказа проверяется отдельным чтением.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Завершить состояние после отказа уникальности

if ($session->isInTransaction()) {
    $session->abortTransaction();
}

Ожидаемый код 11000 не проглатывается как успех. После обработки исходный заказ 900 остаётся единственным, остаток возвращён к 5.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::endSessionЗакрыть Session; незавершённая связанная транзакция отменяется.

С mongodb 1.5.0. · mongodb

final public MongoDB\Driver\Session::endSession(): void

Закрыть Session; незавершённая связанная транзакция отменяется.

final public MongoDB\Driver\Session::endSession(): void

Параметры

Параметров нет. Метод вызывается у Session, созданной соответствующим Manager.

Результат

Возвращаемого значения нет. Сессия закрыта, после этого другие методы данного Session вызывать нельзя.

Ошибки и границы

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

Версии

С mongodb 1.5.0.

Применение 1: Освободить Session после завершения работы

try {
    // Операции и успешный commit.
} finally {
    $session->endSession();
}

После commit сохраняются заказ и остаток; закрытие Session завершает ресурс. В полном архиве наконец удаляются только учебные коллекции.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Отменить незавершённое предварительное изменение

$session->endSession();
$sessionEnded = true;

Внутри было 5 кружек, после закрытия внешнее чтение видит исходные 8. Методы закрытой Session не вызываются.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::getTransactionStateПолучить строковую фазу транзакции для диагностики жизненного цикла.

С mongodb 1.7.0. Написание состояний дополнительно сверено с исходником расширения 2.5.5; не путайте их с кодами ошибок сервера. · mongodb

final public MongoDB\Driver\Session::getTransactionState(): string

Получить строковую фазу транзакции для диагностики жизненного цикла.

final public MongoDB\Driver\Session::getTransactionState(): string

Параметры

Параметров нет. Метод вызывается у Session, созданной соответствующим Manager.

Результат

Строка текущего состояния. В используемом драйвере 2.5.5: none, starting, in_progress, committed, aborted. Это диагностическая фаза Session; исход при потерянном подтверждении нужно разрешать отдельно.

Ошибки и границы

Документация указывает InvalidArgumentException при разборе аргументов. Не подменяйте этим методом проверку бизнес-результата в документах. starting предшествует первой связанной серверной операции; getTransactionOptions раскрывает настройки, isInTransaction — логическую активность.

Версии

С mongodb 1.7.0. Написание состояний дополнительно сверено с исходником расширения 2.5.5; не путайте их с кодами ошибок сервера.

Применение 1: Увидеть продвижение подтверждённого заказа

$phase = $session->getTransactionState();

В программе последовательно получены none, starting, in_progress и committed. Таблица связывает фазы с видимостью остатка и заказа.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Отследить предварительную запись

$phase = $session->getTransactionState();

До операции starting, после записи in_progress. После endSession чтение состояния намеренно не выполняется, вместо этого проверяется фактический остаток.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

MongoDB\Driver\Session::getTransactionOptionsПрочитать настройки текущей транзакции, чтобы видеть применённый договор.

С mongodb 1.7.0. · mongodb

final public MongoDB\Driver\Session::getTransactionOptions(): ?array

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

final public MongoDB\Driver\Session::getTransactionOptions(): ?array

Параметры

Параметров нет. Метод вызывается у Session, созданной соответствующим Manager.

Результат

Массив настроек текущей транзакции; если транзакции нет, возвращается null. Значения readConcern/readPreference/writeConcern представлены соответствующими объектами, maxCommitTimeMS — числом. Набор включает применённые параметры, поэтому не обращайтесь к произвольному ключу без проверки.

Ошибки и границы

Документация указывает InvalidArgumentException при разборе аргументов. Возвращённый массив предназначен для чтения: изменение его PHP-элемента не меняет уже работающую транзакцию. Настройки выбирают при startTransaction/defaultTransactionOptions; диагностика не создаёт новую транзакцию.

Версии

С mongodb 1.7.0.

Применение 1: Проверить унаследованный предел commit

$options = $session->getTransactionOptions();
$maxCommitMs = $options['maxCommitTimeMS'];

После startTransaction без аргумента получено 1000 из defaultTransactionOptions. Проверка не выдаёт его за лимит всей операции заказа.

Пояснение и полная программа · Фактическая таблица результата

Применение 2: Сравнить отсутствие транзакции и явные настройки

$before = $session->getTransactionOptions(); // null
$session->startTransaction(tx118Options());
$current = $session->getTransactionOptions();

Перед началом null; после начала есть 1000 и объекты трёх политик. Таблица показывает изменение вместе с состоянием Session.

Пояснение и полная программа · Фактическая таблица результата

Короткие фрагменты показывают вызов в контексте связанных программ. Manager, Session, данные и helper подготовлены в полном архиве. Каждый архив запускается самостоятельно; фрагменты не выданы за отдельные программы.

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

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

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

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

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