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

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

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

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

Глава 104 · После таблиц, связей и первых транзакций

MySQL: конкурирующие транзакции и безопасный повтор заявки

Два настоящих соединения: условный остаток, FOR UPDATE, NOWAIT, очередь SKIP LOCKED, видимость снимков и ключ идемпотентной операции.

Что происходит между двумя соединениями

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

ПонятиеНа примере товара
Соединение с MySQLОтдельный разговор PHP с MySQL со своей транзакцией и настройками. A и B могут видеть разные подтверждённые версии.
ТранзакцияГруппа изменений: COMMIT подтверждает всю группу, ROLLBACK отменяет её незавершённые изменения.
БлокировкаВременное согласование доступа к строкам/индексам. Прочитать старую подтверждённую версию и получить право изменить строку — разные действия.
СнимокНабор подтверждённых версий, который видит согласованное чтение. Это не PHP-массив, заранее скачанный целиком.
Ключ повторной операцииИдентификатор одной заявки, благодаря которому повтор возвращает прежний ответ без нового списания.
Выбираем минимально достаточный механизм
  1. Одна проверка и одно изменение → условие в UPDATE
  2. Несколько чтений/изменений → транзакция и нужные блокировки
  3. Занятую строку нельзя ждать → NOWAIT и отдельный исход busy
  4. Из очереди можно взять другую строку → SKIP LOCKED
  5. Запрос может прийти ещё раз → ключ операции + запись результата в той же транзакции

Основы таблиц, связей и атомарного заказа уже нужны для этой главы. Здесь разбираем следующий вопрос: что меняется, когда с базой работают несколько сессий и часть запросов повторяется. Уровень изоляции не заменяет бизнес-условие, а prepared statement не заменяет транзакцию.

Как запустить и увидеть результат

Все полные программы запускаются из CLI на отдельной локальной учебной базе MySQL 8.4/InnoDB с PHP 8.2+ и mysqli/mysqlnd. Контрольный стенд — PHP 8.5.11 и MySQL 8.4.13. Настройка подключения объясняется в первом подключении. В архив уже включены helper и входные CSV: не нужно восстанавливать неизвестные файлы из соседнего примера.

После распаковки задайте пять переменных окружения PHPMAN_MYSQL_HOST, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER, PHPMAN_MYSQL_PASSWORD и выполните php example.php. Пароль вводится локально и не входит в ZIP. Для собственной учебной базы нужны права чтения/записи, CREATE/DROP и REFERENCES. Результат открывается в result.html, таблицы доступны также в result.json; короткая строка консоли только сообщает, что файлы сохранены.

В коде A — $db, B — $other. Это разные mysqli-соединения к одному локальному серверу, что проверяется по thread_id. Команды чередуются в одном процессе для воспроизводимости. Опыты проверяют поведение сессий, а не HTTP, скорость или тысячу параллельных пользователей.

Не продать последнюю единицу дважды

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

Исходные данные

До покупки

idqty
11

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

Два запроса на один товар

ЗапросИзменено строк
A1
B0

Остаток

idqty
10

Ключевой запрос и его смысл

UPDATE mysql107_tx_conditional_stock
SET qty = qty - 1
WHERE id = 1 AND qty >= 1;

UPDATE находит id=1, проверяет qty≥1 и уменьшает qty на единицу. Значение справа вычисляется сервером из текущей строки, а не из старой PHP-переменной. Для одного такого изменения достаточно одной команды в режиме autocommit: она сама является транзакцией. Здесь не нужен предварительный SELECT.

Часть запросаЗачем
SET qty=qty-1Изменить текущий остаток внутри MySQL.
WHERE id=1 AND qty≥1Затронуть нужный товар, только если товара ещё достаточно.
affected_rows=1Одна строка действительно изменилась: можно продолжать оформление.
affected_rows=0Команда выполнилась, но условие не подошло: товар отсутствует или запаса недостаточно. Это не исключение SQL.

Программа открывает соединения A и B с разными thread_id. A выполняет UPDATE и фиксирует его автоматически; B выполняет такой же запрос и уже не находит qty≥1. Это контролируемое чередование двух реальных сессий, без имитации ответов и без заявления о нагрузочном тесте. При одновременной попытке InnoDB сериализует конфликтующие изменения строки; ожидание или deadlock тоже требуют обработки.

Параметры, возвращаемые значения и варианты API: mysqli_affected_rows, mysqli_execute_query. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.

Ошибки и границы применения

Это защищает одно уменьшение. Если нужно одновременно создать заказ и его позиции, объедините все изменения в транзакцию и проверяйте affected_rows до COMMIT. Для количества из формы используйте два параметра qty-? и qty>=?, предварительно потребовав положительное целое: отрицательная покупка не должна увеличивать склад. CHECK(qty>=0) — дополнительная защита данных, а не замена условию покупки.

Полная самостоятельная программа PHP и все входные файлы
Не продать последнюю единицу дваждыСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql107-transactions-helper.php';
$db=app107Connect(); $other=null; $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
app107Create($db,$created,'mysql107_tx_conditional_stock','id INT PRIMARY KEY, qty INT NOT NULL CHECK(qty>=0)');
$db->query('INSERT INTO mysql107_tx_conditional_stock VALUES (1,1)');
$other=app107Connect();
$sql='UPDATE mysql107_tx_conditional_stock SET qty=qty-1 WHERE id=1 AND qty>=1';
$db->query($sql); $first=$db->affected_rows;
$other->query($sql); $second=$other->affected_rows;
$stock=app107Read($db,'SELECT id,qty FROM mysql107_tx_conditional_stock');
$blocks=[app107Block('Два запроса на один товар',['Запрос','Изменено строк'],[['A',(string)$first],['B',(string)$second]]),app107Block('Остаток',['id','qty'],$stock)];
$checks=['two_sessions'=>$db->thread_id!==$other->thread_id,'first_sold'=>$first===1,'second_not_sold'=>$second===0,'stock_zero'=>$stock===[['1','0']]];
    app107Report('mysql107-tx-conditional-stock',$blocks,$checks,$db,$diagnostics);
} finally {
    app107Cleanup($db,$other,$created);
}
echo "mysql107-tx-conditional-stock: saved result.html and result.json\n";
Ожидаемый результат
mysql107-tx-conditional-stock: saved result.html and result.json

Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.

Открыть сохранённый результат реального запуска · Скачать таблицы JSON.

Попробуйте сами

Замените исходный запас на 2: обе попытки изменят по одной строке, итог останется 0. Затем передайте запрос на 3 единицы через параметры: при запасе 2 он должен вернуть 0 изменённых строк, не меняя остаток.

Прочитать остаток и удержать его до решения

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

Исходные данные

Один товар; A и B хотят по 2

idqty
15

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

Порядок чтений

Шагqty
A: FOR UPDATE5
B: обычный SELECT до COMMIT A5
B: FOR UPDATE после COMMIT A3
После двух покупок1

Ключевой запрос и его смысл

START TRANSACTION;
SELECT qty FROM mysql107_tx_locked_stock WHERE id=1 FOR UPDATE;
-- Проверить условия операции, пока строка удерживается этой транзакцией.
UPDATE mysql107_tx_locked_stock SET qty=qty-2 WHERE id=1;
COMMIT;

START TRANSACTION начинает группу команд. SELECT ... FOR UPDATE читает строку для последующего изменения и удерживает соответствующую блокировку до COMMIT или ROLLBACK. В PHP этим границам соответствуют begin_transaction(), commit() и rollback(). Один SELECT FOR UPDATE вне удерживаемой транзакции не создаёт защиту на время последующего PHP-кода.

Два покупателя и один остаток
  1. A начинает транзакцию и читает 5 с FOR UPDATE
  2. B обычным SELECT видит подтверждённые 5: чтение снимка не равно праву изменить строку
  3. A вычитает 2 и фиксирует 3
  4. B начинает свою транзакцию, читает уже 3 с FOR UPDATE и фиксирует 1

Здесь второй блокирующий SELECT намеренно выполняется после COMMIT A: пример показывает порядок значений, а не измеряет время ожидания. Обычный SELECT B до завершения A читает подтверждённую версию и не проверяет доступность блокировки. Следующий сценарий с NOWAIT действительно обращается к занятой строке.

В полном коде известное исходное количество 5 и две покупки по 2 позволяют сосредоточиться на блокировке. В рабочей операции проверяйте прочитанное значение и дополнительные условия; при отказе выполняйте ROLLBACK. Поиск по PRIMARY KEY ограничивает набор подходящих записей. Неэффективный поиск может захватить больше блокировок и увеличить конкуренцию.

Параметры, возвращаемые значения и варианты API: mysqli_begin_transaction, mysqli_commit, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.

Ошибки и границы применения

Не держите транзакцию открытой, пока человек думает над формой или пока внешний сервис отвечает. Блокировка — краткая внутренняя операция сервера. Все участники должны соблюдать один порядок изменения связанных строк: иначе возможны взаимные ожидания. FOR UPDATE не блокирует обычное согласованное чтение старой версии в InnoDB; поэтому «в другой вкладке SELECT сработал» не доказывает отсутствие блокировки.

Полная самостоятельная программа PHP и все входные файлы
Прочитать остаток и удержать его до решенияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql107-transactions-helper.php';
$db=app107Connect(); $other=null; $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
app107Create($db,$created,'mysql107_tx_locked_stock','id INT PRIMARY KEY, qty INT NOT NULL');
$db->query('INSERT INTO mysql107_tx_locked_stock VALUES(1,5)'); $other=app107Connect();
$db->begin_transaction();
$a=app107Read($db,'SELECT qty FROM mysql107_tx_locked_stock WHERE id=1 FOR UPDATE')[0][0];
$ordinary=app107Read($other,'SELECT qty FROM mysql107_tx_locked_stock WHERE id=1')[0][0];
$db->query('UPDATE mysql107_tx_locked_stock SET qty=qty-2 WHERE id=1'); $db->commit();
$other->begin_transaction();
$b=app107Read($other,'SELECT qty FROM mysql107_tx_locked_stock WHERE id=1 FOR UPDATE')[0][0];
$other->query('UPDATE mysql107_tx_locked_stock SET qty=qty-2 WHERE id=1'); $other->commit();
$final=app107Read($db,'SELECT qty FROM mysql107_tx_locked_stock WHERE id=1')[0][0];
$blocks=[app107Block('Порядок чтений',['Шаг','qty'],[['A: FOR UPDATE',$a],['B: обычный SELECT до COMMIT A',$ordinary],['B: FOR UPDATE после COMMIT A',$b],['После двух покупок',$final]])];
$checks=['two_sessions'=>$db->thread_id!==$other->thread_id,'a_reads_five'=>$a==='5','plain_read_sees_committed'=>$ordinary==='5','b_reads_three'=>$b==='3','final_one'=>$final==='1'];
    app107Report('mysql107-tx-for-update',$blocks,$checks,$db,$diagnostics);
} finally {
    app107Cleanup($db,$other,$created);
}
echo "mysql107-tx-for-update: saved result.html and result.json\n";
Ожидаемый результат
mysql107-tx-for-update: saved result.html and result.json

Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.

Открыть сохранённый результат реального запуска · Скачать таблицы JSON.

Попробуйте сами

Откройте полный пример и найдите точные места begin_transaction и commit для A и B. Затем добавьте отказ B при прочитанном остатке меньше 4: B должна вызвать rollback, а окончательное значение остаться 3.

Практический вопрос: SELECT FOR UPDATE is not locking the target rows — how to verify it?. Разработчик пытается проверить блокировку двумя сессиями и путает доступность обычного чтения с правом изменить строку. Наши отдельные сценарии показывают подтверждённое чтение и реальный отказ NOWAIT. Ситуация использована для постановки задачи; правила сверены с первичной документацией, код и данные примера написаны отдельно.

Вернуть «занято» вместо ожидания блокировки

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

Исходные данные

Товар и владелец блокировки

idqtyБлокировка
15Соединение A до COMMIT

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

Попытки B

УсловиеКодSQLSTATEРезультат
A удерживает строку3572HY000Занято
A завершила транзакцию000000Получено qty=5

Ключевой запрос и его смысл

START TRANSACTION;
SELECT qty FROM mysql107_tx_nowait_stock
WHERE id=1 FOR UPDATE NOWAIT;
-- При 3572 приложение откатывает свою транзакцию и сообщает «занято».
ROLLBACK;

NOWAIT относится к блокирующему чтению. Если нужная строка удерживается другой транзакцией, MySQL возвращает ошибку вместо ожидания этой блокировки. Это не означает «вернуть строку без блокировки». Код распознаёт конкретный номер 3572; общий SQLSTATE HY000 сам по себе недостаточен, потому что у него много причин.

ШагСоединение AСоединение B
1BEGIN; SELECT FOR UPDATEЕщё не начало транзакцию
2Продолжает удерживать строкуBEGIN; SELECT FOR UPDATE NOWAIT → 3572
3COMMIT освобождает строкуROLLBACK своей неудачной попытки
4ЗавершеноНовая транзакция и тот же SELECT → qty=5; COMMIT

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

Параметры, возвращаемые значения и варианты API: mysqli_begin_transaction, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.

Ошибки и границы применения

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

Полная самостоятельная программа PHP и все входные файлы
Вернуть «занято» вместо ожидания блокировкиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql107-transactions-helper.php';
$db=app107Connect(); $other=null; $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
app107Create($db,$created,'mysql107_tx_nowait_stock','id INT PRIMARY KEY, qty INT NOT NULL');
$db->query('INSERT INTO mysql107_tx_nowait_stock VALUES(1,5)'); $other=app107Connect();
$db->begin_transaction(); app107Read($db,'SELECT qty FROM mysql107_tx_nowait_stock WHERE id=1 FOR UPDATE');
$other->begin_transaction(); $code=0; $state='';
try { app107Read($other,'SELECT qty FROM mysql107_tx_nowait_stock WHERE id=1 FOR UPDATE NOWAIT'); }
catch(mysqli_sql_exception $error) { if($error->getCode()!==3572){throw $error;} $code=$error->getCode(); $state=$error->getSqlState(); }
$other->rollback(); $db->commit();
$other->begin_transaction(); $after=app107Read($other,'SELECT qty FROM mysql107_tx_nowait_stock WHERE id=1 FOR UPDATE NOWAIT')[0][0]; $other->commit();
$blocks=[app107Block('Попытки B',['Условие','Код','SQLSTATE','Результат'],[['A удерживает строку',(string)$code,$state,'Занято'],['A завершила транзакцию','0','00000','Получено qty='.$after]])];
$checks=['two_sessions'=>$db->thread_id!==$other->thread_id,'expected_busy'=>$code===3572 && $state==='HY000','released_row_read'=>$after==='5'];
    app107Report('mysql107-tx-nowait',$blocks,$checks,$db,$diagnostics);
} finally {
    app107Cleanup($db,$other,$created);
}
echo "mysql107-tx-nowait: saved result.html and result.json\n";
Ожидаемый результат
mysql107-tx-nowait: saved result.html and result.json

Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.

Открыть сохранённый результат реального запуска · Скачать таблицы JSON.

Попробуйте сами

Уберите COMMIT A перед второй попыткой B: получите повторный отказ 3572. Верните COMMIT. Для реального интерфейса заранее определите понятный текст busy и способ повторного действия, сохраняя идентификатор операции, если она уже могла завершиться.

Раздать разные задания двум обработчикам

Есть очередь из трёх заданий и два обработчика. A хочет взять два свободных задания; B должен взять оставшееся, пока A ещё удерживает свои строки. Ждать именно задания A ему не нужно.

Исходные данные

Задания

idstatus
1ready
2ready
3ready

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

Распределение

ОбработчикID
A1,2
B3

После резервирования

idstatus
1processing
2processing
3processing

Ключевой запрос и его смысл

START TRANSACTION;
SELECT id FROM mysql107_tx_queue
WHERE status='ready'
ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED;
-- Обновить только полученные ID подготовленным UPDATE.
COMMIT;

SKIP LOCKED исключает занятые записи из результата блокирующего чтения. Это намеренно неполный вид очереди. WHERE status=ready ограничивает кандидатов; ORDER BY id задаёт порядок; LIMIT 2 ограничивает число полученных заданий. Нужна явная транзакция вокруг выбора и смены статуса.

Резервирование в одном действии
  1. A: BEGIN → SELECT возвращает 1 и 2; блокировки остаются
  2. B: BEGIN → тот же SELECT пропускает 1 и 2, возвращает 3
  3. Каждый обработчик меняет только свои ID на processing
  4. COMMIT сохраняет статусы и освобождает блокировки
  5. Внешняя работа выполняется после короткого резервирования

Если только прочитать ID и выполнить COMMIT без смены статуса, следующий обработчик снова увидит ready и получит те же задания. В коде prepared UPDATE исполняется для реально возвращённых ID; множества A и B проверяются на отсутствие пересечения. Общая таблица обычная InnoDB: TEMPORARY-таблица одной сессии другой сессии не видна.

Параметры, возвращаемые значения и варианты API: mysqli_prepare, mysqli_stmt_execute, mysqli_commit. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.

Ошибки и границы применения

Не применяйте SKIP LOCKED для финансового отчёта или проверки «все строки обработаны»: занятые записи просто скрываются. Для настоящей очереди одного processing недостаточно: нужен владелец, срок аренды, число попыток и восстановление задания после падения обработчика. Это отдельный договор. SKIP LOCKED не исключает все возможные deadlock и не гарантирует справедливую очередность при постоянной конкуренции.

Полная самостоятельная программа PHP и все входные файлы
Раздать разные задания двум обработчикамСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql107-transactions-helper.php';
$db=app107Connect(); $other=null; $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
app107Create($db,$created,'mysql107_tx_queue','id INT PRIMARY KEY, status VARCHAR(20) NOT NULL');
$db->query("INSERT INTO mysql107_tx_queue VALUES(1,'ready'),(2,'ready'),(3,'ready')"); $other=app107Connect();
$db->begin_transaction();
$a=app107Read($db,"SELECT id FROM mysql107_tx_queue WHERE status='ready' ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED");
$other->begin_transaction();
$b=app107Read($other,"SELECT id FROM mysql107_tx_queue WHERE status='ready' ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED");
$stmtA=$db->prepare("UPDATE mysql107_tx_queue SET status='processing' WHERE id=?");
foreach($a as $row){$stmtA->execute([(int)$row[0]]);} $stmtA->close();
$stmtB=$other->prepare("UPDATE mysql107_tx_queue SET status='processing' WHERE id=?");
foreach($b as $row){$stmtB->execute([(int)$row[0]]);} $stmtB->close();
$db->commit(); $other->commit();
$rows=app107Read($db,'SELECT id,status FROM mysql107_tx_queue ORDER BY id');
$blocks=[app107Block('Распределение',['Обработчик','ID'],[['A',implode(',',array_column($a,0))],['B',implode(',',array_column($b,0))]]),app107Block('После резервирования',['id','status'],$rows)];
$checks=['two_sessions'=>$db->thread_id!==$other->thread_id,'a_first_two'=>$a===[['1'],['2']],'b_third'=>$b===[['3']],'disjoint'=>array_intersect(array_column($a,0),array_column($b,0))===[],'all_processing'=>array_column($rows,1)===['processing','processing','processing']];
    app107Report('mysql107-tx-skip-locked',$blocks,$checks,$db,$diagnostics);
} finally {
    app107Cleanup($db,$other,$created);
}
echo "mysql107-tx-skip-locked: saved result.html and result.json\n";
Ожидаемый результат
mysql107-tx-skip-locked: saved result.html and result.json

Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.

Открыть сохранённый результат реального запуска · Скачать таблицы JSON.

Попробуйте сами

Добавьте четвёртое задание ready: A получит 1,2, B — 3,4. После завершения резервирования выполните тот же SELECT в третьей короткой транзакции: ready больше нет. Продумайте, как вернуть processing в ready только после истечения срока аренды, а не сразу при первом сетевом сбое.

Практический вопрос: Unable to select the correct row when SKIP LOCKED is used. При резервировании строк несколькими обработчиками выбор свободной записи нужно связать со сменой статуса. Учебный пример удерживает две реальные транзакции и подтверждает непересекающиеся ID после смены статуса. Ситуация использована для постановки задачи; правила сверены с первичной документацией, код и данные примера написаны отдельно.

Понять, почему повторный SELECT показывает старое значение

Вы видите подтверждение UPDATE в одном соединении, но SELECT в другом по-прежнему возвращает старое число. Это может быть не кеш PHP и не потерянная запись, а согласованный снимок открытой транзакции.

Исходные данные

Исходное значение

idqty
15

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

Значения обычных SELECT

Режим/моментПервоеПовторное
REPEATABLE READ; B записала 655
После COMMIT A66
READ COMMITTED; B записала 767

Ключевой запрос и его смысл

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT qty FROM mysql107_tx_visibility WHERE id=1;
-- Другое соединение записывает 6 и фиксирует изменение.
SELECT qty FROM mysql107_tx_visibility WHERE id=1;
COMMIT;

Обычное согласованное чтение InnoDB использует версии записей. В REPEATABLE READ первый такой SELECT создаёт снимок для последующих чтений той же транзакции. Сам begin_transaction без чтения не означает, что снимок уже установлен. Наш A сначала действительно читает 5; затем B фиксирует 6; A снова читает прежние 5. После COMMIT A новое чтение видит 6.

READ COMMITTED выбирает новый подтверждённый снимок для каждого обычного SELECT. Поэтому A сначала видит 6, а после отдельного UPDATE B — 7, оставаясь в той же транзакции. Уровень задаётся SET SESSION до начала транзакции. Команда меняет только собственное соединение, не глобальную настройку сервера.

Нужен результатПодход
Несколько согласованных обычных чтенийПонимать границы снимка REPEATABLE READ.
Каждый SELECT должен видеть свежие подтвержденияРассмотреть READ COMMITTED с учётом бизнес-требований.
Прочитать и затем изменить защищённую строкуБлокирующее чтение FOR UPDATE в короткой транзакции.

Снимок не является универсальной копией всей базы для любых команд. UPDATE и locking read работают по своим правилам; собственные изменения транзакция тоже видит. В примере намеренно нет смешивания этих случаев: все показанные чтения обычные SELECT, а изменения делает только B.

Параметры, возвращаемые значения и варианты API: mysqli_begin_transaction, mysqli_commit. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.

Ошибки и границы применения

Не исправляйте любой «старый результат» глобальной сменой изоляции. Проверьте наличие незавершённой транзакции, момент первого чтения и реальные commit. Длинные снимки мешают очистке старых версий и увеличивают нагрузку. Разные запросы веб-страницы часто используют разные транзакции: один экран сам по себе не гарантирует единого снимка.

Полная самостоятельная программа PHP и все входные файлы
Понять, почему повторный SELECT показывает старое значениеСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql107-transactions-helper.php';
$db=app107Connect(); $other=null; $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
app107Create($db,$created,'mysql107_tx_visibility','id INT PRIMARY KEY, qty INT NOT NULL');
$db->query('INSERT INTO mysql107_tx_visibility VALUES(1,5)'); $other=app107Connect();
$db->query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); $db->begin_transaction();
$rr1=app107Read($db,'SELECT qty FROM mysql107_tx_visibility WHERE id=1')[0][0];
$other->query('UPDATE mysql107_tx_visibility SET qty=6 WHERE id=1');
$rr2=app107Read($db,'SELECT qty FROM mysql107_tx_visibility WHERE id=1')[0][0]; $db->commit();
$outside=app107Read($db,'SELECT qty FROM mysql107_tx_visibility WHERE id=1')[0][0];
$db->query('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED'); $db->begin_transaction();
$rc1=app107Read($db,'SELECT qty FROM mysql107_tx_visibility WHERE id=1')[0][0];
$other->query('UPDATE mysql107_tx_visibility SET qty=7 WHERE id=1');
$rc2=app107Read($db,'SELECT qty FROM mysql107_tx_visibility WHERE id=1')[0][0]; $db->rollback();
$blocks=[app107Block('Значения обычных SELECT',['Режим/момент','Первое','Повторное'],[['REPEATABLE READ; B записала 6',$rr1,$rr2],['После COMMIT A',$outside,$outside],['READ COMMITTED; B записала 7',$rc1,$rc2]])];
$checks=['two_sessions'=>$db->thread_id!==$other->thread_id,'rr_snapshot'=>$rr1==='5'&&$rr2==='5','new_transaction_sees_six'=>$outside==='6','rc_refreshes'=>$rc1==='6'&&$rc2==='7'];
    app107Report('mysql107-tx-visibility',$blocks,$checks,$db,$diagnostics);
} finally {
    app107Cleanup($db,$other,$created);
}
echo "mysql107-tx-visibility: saved result.html and result.json\n";
Ожидаемый результат
mysql107-tx-visibility: saved result.html and result.json

Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.

Открыть сохранённый результат реального запуска · Скачать таблицы JSON.

Попробуйте сами

В первом опыте переместите UPDATE B перед первым SELECT A: A увидит 6 уже при первом чтении и продолжит видеть 6. Так можно отличить момент begin_transaction от момента создания снимка. Не меняйте остальные команды одновременно.

Повторить заявку, не списав товар ещё раз

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

Исходные данные

До операции

ТоварqtyЗаявка
110order-2026-42, quantity=3; журнал пуст

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

Повторный запрос

ПопыткаСостояниеСохранённый остаток
A: количество 3applied7
B: то же количествоreplayed7
B: тот же ключ, количество 4conflict7

Журнал операции

request_keyquantityremaining
order-2026-4237

Текущий остаток

qty
7

Ключевой запрос и его смысл

START TRANSACTION;
INSERT INTO mysql107_tx_requests(request_key,quantity)
VALUES ('order-2026-42',3);
UPDATE mysql107_tx_request_stock SET qty=qty-3 WHERE id=1 AND qty>=3;
-- Если UPDATE не изменил одну строку: ROLLBACK.
-- Сохранить ответ remaining в той же транзакции.
COMMIT;

Идемпотентная операция допускает повтор без повторного бизнес-эффекта. Ключ request_key обозначает одну конкретную заявку. Он записывается в таблицу с PRIMARY KEY вместе с количеством; уменьшение остатков и сохранённый ответ remaining фиксируются той же транзакцией. Только такая связка исключает состояние «ключ записан, а товар ещё не списан» после обычного отката.

Ключ и изменение связаны транзакцией
  1. Попробовать INSERT request_key
  2. Новый ключ → условно уменьшить остаток → сохранить ответ → COMMIT
  3. Дубликат 1062 → ROLLBACK текущей попытки → прочитать подтверждённую старую заявку
  4. Тот же payload → вернуть сохранённый ответ; другой payload → конфликт

Предварительный SELECT «нет ли ключа» не является защитой: две сессии могут одновременно не найти его. Решение принимает уникальный ключ при INSERT. В нашей таблице других уникальных ограничений нет, поэтому 1062 здесь означает именно повтор request_key. После отката читается сохранённое quantity: ключ с количеством 4 нельзя считать успешным повтором заявки на 3.

Первый ответ applied/7 и второй replayed/7 относятся к одному списанию. Третий conflict/7 ничего не применяет. remaining хранится как ответ той заявки; если позже склад изменится другими операциями, сохранённый ответ не обязан равняться текущему остатку.

Параметры, возвращаемые значения и варианты API: mysqli_stmt_execute, mysqli_stmt_affected_rows, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.

Ошибки и границы применения

Ключ должен быть связан с пользователем/типом операции и полным значимым содержимым запроса; здесь для наглядности только количество и один товар. Запись журнала нужно хранить не меньше согласованного окна повторов. При одновременной вставке одинакового ключа возможны ожидания/timeout: обрабатывайте их отдельно. Эта MySQL-транзакция не делает внешний платёж или отправку письма «ровно один раз»; для внешних эффектов нужны свои ключи и согласованный механизм доставки.

Полная самостоятельная программа PHP и все входные файлы
Повторить заявку, не списав товар ещё разСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql107-transactions-helper.php';
$db=app107Connect(); $other=null; $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
app107Create($db,$created,'mysql107_tx_request_stock','id INT PRIMARY KEY, qty INT NOT NULL');
app107Create($db,$created,'mysql107_tx_requests','request_key VARCHAR(64) PRIMARY KEY, quantity INT NOT NULL, remaining INT NULL');
$db->query('INSERT INTO mysql107_tx_request_stock VALUES(1,10)'); $other=app107Connect();
function reserve107(mysqli $db,string $key,int $quantity): array {
    if($quantity<1){throw new InvalidArgumentException('Positive quantity required');}
    $db->begin_transaction();
    try {
        try {$s=$db->prepare('INSERT INTO mysql107_tx_requests(request_key,quantity) VALUES(?,?)'); $s->execute([$key,$quantity]); $s->close();}
        catch(mysqli_sql_exception $e) {
            if($e->getCode()!==1062){throw $e;} $db->rollback();
            $s=$db->prepare('SELECT quantity,remaining FROM mysql107_tx_requests WHERE request_key=?'); $s->execute([$key]); $old=$s->get_result()->fetch_assoc();$s->close();
            if($old===null){throw new RuntimeException('Stored request unavailable');}
            return [(int)$old['quantity']===$quantity?'replayed':'conflict',(string)$old['remaining']];
        }
        $s=$db->prepare('UPDATE mysql107_tx_request_stock SET qty=qty-? WHERE id=1 AND qty>=?'); $s->execute([$quantity,$quantity]); $changed=$s->affected_rows;$s->close();
        if($changed!==1){throw new RuntimeException('Insufficient stock');}
        $remaining=app107Read($db,'SELECT qty FROM mysql107_tx_request_stock WHERE id=1')[0][0];
        $s=$db->prepare('UPDATE mysql107_tx_requests SET remaining=? WHERE request_key=?');$s->execute([(int)$remaining,$key]);$s->close();
        $db->commit();return ['applied',$remaining];
    } catch(Throwable $error){$db->rollback();throw $error;}
}
$a=reserve107($db,'order-2026-42',3);$b=reserve107($other,'order-2026-42',3);$c=reserve107($other,'order-2026-42',4);
$stock=app107Read($db,'SELECT qty FROM mysql107_tx_request_stock WHERE id=1');$ledger=app107Read($db,'SELECT request_key,quantity,remaining FROM mysql107_tx_requests');
$blocks=[app107Block('Повторный запрос',['Попытка','Состояние','Сохранённый остаток'],[['A: количество 3',...$a],['B: то же количество',...$b],['B: тот же ключ, количество 4',...$c]]),app107Block('Журнал операции',['request_key','quantity','remaining'],$ledger),app107Block('Текущий остаток',['qty'],$stock)];
$checks=['two_sessions'=>$db->thread_id!==$other->thread_id,'first_applied'=>$a===['applied','7'],'same_replayed'=>$b===['replayed','7'],'different_conflict'=>$c===['conflict','7'],'one_ledger_row'=>count($ledger)===1,'single_decrement'=>$stock===[['7']]];
    app107Report('mysql107-tx-idempotency',$blocks,$checks,$db,$diagnostics);
} finally {
    app107Cleanup($db,$other,$created);
}
echo "mysql107-tx-idempotency: saved result.html and result.json\n";
Ожидаемый результат
mysql107-tx-idempotency: saved result.html and result.json

Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.

Открыть сохранённый результат реального запуска · Скачать таблицы JSON.

Попробуйте сами

Повторите заявку с новым ключом order-2026-43: это другая операция, остаток станет 4. Затем повторите старый ключ 42: его сохранённый ответ всё ещё 7, но текущее состояние склада уже 4. Разделите в API «ответ операции» и «состояние товара сейчас».

Повтор операции и восстановление файла

Когда изменения затрагивают CSV или пакет товаров, к транзакции добавляется правило частичного успеха. Продолжайте в импорте и восстановлении. Если нужно сначала сузить данные, перечитайте выборку для нужного экрана: широкая выборка и длинная транзакция увеличивают работу, даже когда блокировки формально корректны.

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

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

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

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

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