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

Дерево руководства
Глава 105 · После prepared statement и транзакций
MySQL: импорт CSV, частичный откат и восстановление после ошибки
Проверка строк, транзакционные партии, полный откат файла, SAVEPOINT, повтор по хешу и понятные сообщения SQLSTATE с реальными таблицами результата.
Сначала договор импорта, затем цикл запросов
Импорт — это не просто цикл INSERT. Нужно решить, что считать успехом, что делать с ошибочной записью, где фиксировать прогресс и что произойдёт при повторе файла. Эти решения видны пользователю: «принято 3, отклонено 2» и «не принято ничего» — разные полезные результаты.
| Политика | Что будет при сбое | Сценарий ниже |
|---|---|---|
| Проверить и принять хорошие строки | Предыдущие успешные строки остаются | CSV с отчётом отказов |
| Подтверждать партии | Подтверждённые партии остаются; текущая откатывается | 2 + 2 + 1 записи |
| Весь файл атомарно | Изменения файла откатываются целиком | Дубликат на третьей строке |
| Savepoint для независимой записи | Разрешённая к пропуску запись отменяется; хорошие фиксируются в конце | CHECK и повтор SKU |
| Ключ содержимого файла | Тот же подтверждённый файл повторно не применяется | SHA-256 и журнал импортов |
Выбор делается до написания catch. Нельзя сначала проглотить все исключения, а потом пытаться по количеству строк понять, что случилось. В примерах исходные CSV полностью показаны и входят в архив. Значения не передаются конкатенацией в SQL: подготовленный запрос принимает их отдельно; смысл полей проверяется отдельно.
- CSV → проверенный заголовок и массив полей
- Проверка формата и бизнес-ограничений
- Подготовленный INSERT/UPDATE в выбранной транзакционной границе
- COMMIT либо явный ROLLBACK
- Таблица принятых строк, отказов и состояния повторного запуска
Таблицы и ограничения PRIMARY KEY, CHECK, FOREIGN KEY остаются последним рубежом целостности. Предварительная проверка CSV делает сообщение понятнее, но не заменяет ограничение в базе: данные могут поступать и другим путём. Для больших файлов развивайте этот договор в потоковый импорт с ограниченной партией; пять строк здесь нужны, чтобы видеть каждую.
Самостоятельный запуск и исходные CSV
Все полные программы запускаются из 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, скорость или тысячу параллельных пользователей.
Принять хорошие строки CSV и объяснить отказы
Поставщик прислал CSV с товарами. Хорошие записи нужно сохранить, а ошибки вернуть с номером записи и понятной причиной. Нулевой остаток — нормальные данные; отрицательное количество и артикул с пробелом правилам этого каталога не подходят.
Исходные данные
input.csv; первая строка — заголовок
| sku | name | qty |
|---|---|---|
| TEA | Чай | 2 |
| COFFEE | Кофе | 0 |
| BAD | Отрицательное | -1 |
| BAD SKU | Пробел | 5 |
| MUG | Кружка | 3 |
Что получится
Принятые товары
| sku | name | qty |
|---|---|---|
| COFFEE | Кофе | 0 |
| MUG | Кружка | 3 |
| TEA | Чай | 2 |
Отказы по строкам файла
| CSV-строка | sku | Причина |
|---|---|---|
| 4 | BAD | Quantity: integer 0..999999 |
| 5 | BAD SKU | Invalid SKU |
Ключевой запрос и его смысл
INSERT INTO mysql107_import_validated (sku,name,qty)
VALUES (?,?,?);CSV — текстовая таблица с разделителями и кавычками. Не разбирайте её explode(","): запятая может быть частью поля в кавычках. fgetcsv читает одну CSV-запись как массив. В helper явно заданы запятая, двойная кавычка и пустой escape; заголовок строго сравнивается с sku,name,qty. Мы принимаем только UTF-8 без BOM с таким порядком полей.
Значения CSV сначала строки. До приведения qty к int проверяется весь текст: 0 или положительное десятичное число до шести цифр. Поэтому «12товаров», пустота и -1 не становятся молча допустимым числом. Артикул по договору состоит из ASCII A–Z, цифр, подчёркивания и дефиса; имя проверяется на непустоту и максимум 60 UTF-8-байт. Это специально более строгая учебная граница, чем VARCHAR(60), который считает символы.
Подготовленный INSERT создаётся один раз; execute передаёт три значения в позиции ?. Они не становятся частью текста SQL. prepared statement отделяет данные от команды, но не решает, допустимы ли отрицательные остатки или неправильный артикул: это отдельная проверка.
В этом коротком файле записи не содержат переносов внутри кавычек, поэтому счётчик совпадает с физическими номерами строк. Для многострочных полей называйте его номером CSV-записи или ведите отдельный учёт физических строк. Пустая строка тоже не обязана быть концом файла; helper читает до false.
Параметры, возвращаемые значения и варианты API: mysqli_prepare, mysqli_stmt_execute. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.
Ошибки и границы применения
Здесь намеренно выбрана политика частичного приёма, и успешные INSERT фиксируются автоматически. Сбой SQL после принятой строки не удалит предыдущие. Дубликат SKU, кодировка, BOM и неожиданное число полей требуют заранее определённого договора; не маскируйте их INSERT IGNORE. Варианты «всё или ничего» и «пропуск с savepoint» разбираются дальше. Для большого файла читайте поток и копите только текущую партию; helper этого маленького опыта хранит пять записей в массиве.
Полная самостоятельная программа 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_import_validated','sku VARCHAR(20) PRIMARY KEY, name VARCHAR(60) NOT NULL, qty INT NOT NULL CHECK(qty>=0)');
$rows=app107Csv('input.csv',['sku','name','qty']);$rejected=[];$stmt=$db->prepare('INSERT INTO mysql107_import_validated(sku,name,qty) VALUES(?,?,?)');
foreach($rows as $row){
$values=$row['values'];$reason='';
if(count($values)!==3){$reason='Expected 3 columns';}
elseif(!preg_match('/\A[A-Z][A-Z0-9_-]{0,19}\z/D',$values[0])){$reason='Invalid SKU';}
elseif($values[1]==='' || strlen($values[1])>60){$reason='Name: 1..60 UTF-8 bytes';}
elseif(!preg_match('/\A(?:0|[1-9][0-9]{0,5})\z/D',$values[2])){$reason='Quantity: integer 0..999999';}
if($reason!==''){$rejected[]=[(string)$row['line'],$values[0]??'',$reason];continue;}
$stmt->execute([$values[0],$values[1],(int)$values[2]]);
} $stmt->close();
$accepted=app107Read($db,'SELECT sku,name,qty FROM mysql107_import_validated ORDER BY sku');
$blocks=[app107Block('Принятые товары',['sku','name','qty'],$accepted),app107Block('Отказы по строкам файла',['CSV-строка','sku','Причина'],$rejected)];
$checks=['three_accepted'=>count($accepted)===3,'two_rejected'=>count($rejected)===2,'zero_preserved'=>$accepted[0][2]==='0'];
app107Report('mysql107-import-validate',$blocks,$checks,$db,$diagnostics);
} finally {
app107Cleanup($db,$other,$created);
}
echo "mysql107-import-validate: saved result.html and result.json\n";
Ожидаемый результат
mysql107-import-validate: saved result.html and result.json
Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Замените количество у COFFEE с 0 на пустую строку: запись должна попасть в отказ, а не в нулевой остаток. Затем добавьте запятую в название, правильно заключив поле в CSV-кавычки. fgetcsv должен сохранить её как часть одного имени.
Фиксировать импорт небольшими партиями
Импорт большой, и держать одну транзакцию на весь файл неудобно. Разделим его на небольшие партии, сохраняя понятную границу: первые партии уже подтверждены, текущую ещё можно откатить.
Исходные данные
input.csv
| sku | qty |
|---|---|
| A | 1 |
| B | 2 |
| C | 3 |
| D | 4 |
| E | 5 |
Что получится
После каждого COMMIT
| Партия | Строк в партии | Видно второму соединению |
|---|---|---|
| 1 | 2 | 2 |
| 2 | 2 | 4 |
| 3 | 1 | 5 |
Итог
| sku | qty |
|---|---|
| A | 1 |
| B | 2 |
| C | 3 |
| D | 4 |
| E | 5 |
Ключевой запрос и его смысл
START TRANSACTION;
INSERT INTO mysql107_import_batches(sku,qty) VALUES ('A',1);
INSERT INTO mysql107_import_batches(sku,qty) VALUES ('B',2);
COMMIT;
-- Следующая партия — другая транзакция.В примере пять исходных записей разбиваются array_chunk на партии 2,2,1. Для каждой вызывается begin_transaction, затем повторно используется подготовленный INSERT и только после успешной партии — commit. Во втором реальном соединении после каждого COMMIT выполняется COUNT(*): видны 2, затем 4, затем 5 строк.
- A: BEGIN → строки A,B → COMMIT; наблюдатель B видит 2
- A: BEGIN → строки C,D → COMMIT; наблюдатель B видит 4
- A: BEGIN → строка E → COMMIT; наблюдатель B видит 5
Меньшая партия сокращает длительность удержания её блокировок и объём одного отката. Но это другой бизнес-договор: файл уже не атомарен целиком. Если третья партия упадёт, первые четыре записи останутся. Чтобы безопасно продолжить после сбоя, сохраните идентификатор файла, подтверждённую границу и правило повторной обработки; один номер строки без проверки содержимого файла недостаточен.
Размер 2 выбран, чтобы каждый шаг был виден. Универсального «правильного» количества строк нет: учитываются размер строки, индексы, конкуренция, время транзакции и ограничения запроса. Подготовленный INSERT повторяется, но весь файл не собирается в одну огромную SQL-строку. В нашем маленьком helper файл прочитан в массив; на большом входе накапливают ограниченный буфер и освобождают его после COMMIT.
Параметры, возвращаемые значения и варианты API: mysqli_begin_transaction, mysqli_commit, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.
Ошибки и границы применения
Не объявляйте пять строк успешно импортированными до последнего подтверждения. Не пытайтесь одним rollback отменить уже зафиксированные партии: нужны отдельная компенсирующая операция или staging/публикация версии набора. Автоповтор после потери соединения опасен без ключей: COMMIT мог пройти, хотя подтверждение до PHP не дошло.
Полная самостоятельная программа 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_import_batches','sku VARCHAR(20) PRIMARY KEY, qty INT NOT NULL');$other=app107Connect();
$rows=app107Csv('input.csv',['sku','qty']);$stmt=$db->prepare('INSERT INTO mysql107_import_batches(sku,qty) VALUES(?,?)');$history=[];
foreach(array_chunk($rows,2) as $index=>$batch){
$db->begin_transaction();
try{foreach($batch as $row){[$sku,$qty]=$row['values'];$stmt->execute([$sku,(int)$qty]);}$db->commit();}
catch(Throwable $error){$db->rollback();throw $error;}
$seen=app107Read($other,'SELECT COUNT(*) FROM mysql107_import_batches')[0][0];
$history[]=[(string)($index+1),(string)count($batch),$seen];
}$stmt->close();
$stored=app107Read($db,'SELECT sku,qty FROM mysql107_import_batches ORDER BY sku');
$blocks=[app107Block('После каждого COMMIT',['Партия','Строк в партии','Видно второму соединению'],$history),app107Block('Итог',['sku','qty'],$stored)];
$checks=['three_batches'=>count($history)===3,'visible_after_commits'=>array_column($history,2)===['2','4','5'],'five_stored'=>count($stored)===5,'two_sessions'=>$db->thread_id!==$other->thread_id];
app107Report('mysql107-import-batches',$blocks,$checks,$db,$diagnostics);
} finally {
app107Cleanup($db,$other,$created);
}
echo "mysql107-import-batches: saved result.html and result.json\n";
Ожидаемый результат
mysql107-import-batches: saved result.html and result.json
Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Добавьте в пятую запись дубликат A. Третья партия должна откатиться, первые A–D останутся. Затем определите, что пользователь должен увидеть: «4 принято, партия 3 отклонена», а не общий неопределённый результат «ошибка импорта».
Откатить весь файл при ошибке одной строки
Файл представляет одну согласованную загрузку. Если артикул повторяется, нельзя оставить половину его строк: нужно сохранить исходное состояние и сообщить, какой запрос остановил импорт.
Исходные данные
До файла
| sku | qty |
|---|---|
| BASE | 9 |
input.csv
| sku | qty |
|---|---|
| A | 2 |
| B | 4 |
| A | 7 |
Что получится
До импорта
| sku | qty |
|---|---|
| BASE | 9 |
После отказа
| sku | qty |
|---|---|
| BASE | 9 |
Причина остановки
| Попыток INSERT | Код | Политика |
|---|---|---|
| 3 | 1062 | ROLLBACK whole file |
Ключевой запрос и его смысл
START TRANSACTION;
INSERT INTO mysql107_import_atomic(sku,qty) VALUES('A',2);
INSERT INTO mysql107_import_atomic(sku,qty) VALUES('B',4);
INSERT INTO mysql107_import_atomic(sku,qty) VALUES('A',7); -- 1062
ROLLBACK; -- приложение откатывает и успешные A/BСтрока BASE существует до начала транзакции. Внутри одной транзакции вставляются A и B, затем снова A. PRIMARY KEY отклоняет третью команду с 1062. Обработчик вызывает rollback, который отменяет также первые две успешные вставки этой транзакции. BASE остаётся, потому что была подтверждена раньше.
Важная граница: сама ошибка дубликата обычно откатывает ошибочную SQL-команду, а не автоматически всю ранее выполненную работу транзакции. Именно приложение выбирает политику «весь файл» и завершает её rollback. Если после перехвата бездумно дойти до commit, A и B могут сохраниться.
| Состояние | Содержимое |
|---|---|
| До BEGIN | BASE=9 |
| Внутри транзакции после двух INSERT | BASE, A, B видны собственной сессии |
| Третий INSERT | Ошибка 1062; приложение выбирает полный откат |
| После ROLLBACK | Только BASE=9 |
DDL — создание, изменение и удаление обычных таблиц — выполняется до опыта или в окончательной очистке. Не помещайте CREATE/ALTER в середину импорта, рассчитывая откатить их вместе со строками: такие команды могут неявно завершить транзакцию. Полный архив сначала готовит схему, а затем начинает именно транзакцию данных.
Параметры, возвращаемые значения и варианты API: mysqli_begin_transaction, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.
Ошибки и границы применения
Атомарность файла не равна предварительной проверке всех данных. При больших файлах длинная транзакция может быть дорогой; альтернативой служит загрузка во временный набор приложения с последующей проверкой и коротким переключением активной версии. Не путайте ожидаемый дубликат учебного примера с сетевым сбоем: неизвестные ошибки передаются выше после отката.
Полная самостоятельная программа 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_import_atomic','sku VARCHAR(20) PRIMARY KEY, qty INT NOT NULL');
$db->query("INSERT INTO mysql107_import_atomic VALUES('BASE',9)");$before=app107Read($db,'SELECT sku,qty FROM mysql107_import_atomic ORDER BY sku');
$rows=app107Csv('input.csv',['sku','qty']);$stmt=$db->prepare('INSERT INTO mysql107_import_atomic(sku,qty) VALUES(?,?)');$attempted=0;$code=0;
$db->begin_transaction();
try{foreach($rows as $row){++$attempted;[$sku,$qty]=$row['values'];$stmt->execute([$sku,(int)$qty]);}$db->commit();}
catch(mysqli_sql_exception $error){$db->rollback();if($error->getCode()!==1062){throw $error;}$code=$error->getCode();}
$stmt->close();$after=app107Read($db,'SELECT sku,qty FROM mysql107_import_atomic ORDER BY sku');
$blocks=[app107Block('До импорта',['sku','qty'],$before),app107Block('После отказа',['sku','qty'],$after),app107Block('Причина остановки',['Попыток INSERT','Код','Политика'],[[(string)$attempted,(string)$code,'ROLLBACK whole file']])];
$checks=['duplicate_third'=>$attempted===3&&$code===1062,'whole_file_rolled_back'=>$after===$before&&$after===[['BASE','9']]];
app107Report('mysql107-import-atomic',$blocks,$checks,$db,$diagnostics);
} finally {
app107Cleanup($db,$other,$created);
}
echo "mysql107-import-atomic: saved result.html and result.json\n";
Ожидаемый результат
mysql107-import-atomic: saved result.html and result.json
Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Исправьте последнюю строку A,7 на C,7: теперь COMMIT сохранит BASE,A,B,C. Оставьте дубликат, но мысленно уберите rollback из обработчика: объясните, почему одного сообщения «1062» недостаточно для гарантии пустого импорта.
Практический вопрос: Duplicate key error does not cancel/rollback MySQL transaction. Автор ожидает, что повтор ключа отменит всю транзакцию, но после COMMIT остаётся первая вставка. В нашем файле явный rollback отменяет ранее успешные A/B и сохраняет подтверждённую BASE. Ситуация использована для постановки задачи; правила сверены с первичной документацией, код и данные примера написаны отдельно.
Пропустить отдельные ошибки по явной политике
Для независимых товаров разрешён частичный импорт: хорошие строки должны сохраниться, отрицательный остаток и повторный артикул — попасть в отчёт отказов. При этом несколько SQL-команд одной строки можно откатить вместе до её контрольной точки.
Исходные данные
input.csv
| sku | qty |
|---|---|
| A | 2 |
| B | -1 |
| C | 4 |
| A | 7 |
Что получится
Принятые строки
| sku | qty |
|---|---|
| A | 2 |
| C | 4 |
Пропущенные строки
| CSV-строка | sku | Код |
|---|---|---|
| 3 | B | 3819 |
| 5 | A | 1062 |
Ключевой запрос и его смысл
START TRANSACTION;
SAVEPOINT row_guard;
INSERT INTO mysql107_import_savepoints(sku,qty) VALUES('B',-1);
-- Если это разрешённая к пропуску ошибка 3819:
ROLLBACK TO SAVEPOINT row_guard;
RELEASE SAVEPOINT row_guard;
-- Продолжить остальные строки; COMMIT только в конце файла.
COMMIT;SAVEPOINT row_guard ставит именованную точку внутри уже открытой транзакции. При известной ошибке ROLLBACK TO SAVEPOINT row_guard отменяет изменения после этой точки, сохраняя более раннюю работу. RELEASE SAVEPOINT удаляет имя точки; это не COMMIT. Лишь последний commit подтверждает все принятые строки.
PHP вызывает savepoint и release_savepoint через mysqli. Для отката до точки отправляет точную SQL-команду ROLLBACK TO SAVEPOINT row_guard. Имя фиксировано кодом; в него не подставляются данные CSV. В каждой итерации точка создаётся заново, поэтому один идентификатор достаточен.
Вход A=2 проходит. B=-1 нарушает CHECK и получает 3819. C=4 проходит. Повтор A получает 1062. Только эти два номера объявлены допустимыми к пропуску; любое другое исключение выходит во внешний catch, который откатывает весь импорт. Это явная политика качества данных, а не catch всех ошибок с продолжением.
После отката до точки транзакция остаётся открыта. Ранее взятые блокировки в общем случае не освобождаются как при полном rollback; отдельные детали вставленных строк отличаются. Поэтому savepoint не является способом бесконечно держать большую транзакцию, «освобождая» её по одной строке.
Параметры, возвращаемые значения и варианты API: mysqli_savepoint, mysqli_release_savepoint, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.
Ошибки и границы применения
Если строки файла зависят друг от друга, частичный приём может нарушить смысл всего набора даже при соблюдении FK. Решение о пропуске должно принимать правило импорта, а не один технический код. CREATE/ALTER/TRUNCATE в середине могут уничтожить границы savepoint через неявный commit. 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_import_savepoints','sku VARCHAR(20) PRIMARY KEY, qty INT NOT NULL, CONSTRAINT mysql107_import_nonnegative CHECK(qty>=0)');
$rows=app107Csv('input.csv',['sku','qty']);$stmt=$db->prepare('INSERT INTO mysql107_import_savepoints(sku,qty) VALUES(?,?)');$rejects=[];
$db->begin_transaction();
try{
foreach($rows as $row){
$db->savepoint('row_guard');
try{[$sku,$qty]=$row['values'];$stmt->execute([$sku,(int)$qty]);}
catch(mysqli_sql_exception $error){
if(!in_array($error->getCode(),[1062,3819],true)){throw $error;}
$db->query('ROLLBACK TO SAVEPOINT row_guard');
$rejects[]=[(string)$row['line'],$sku,(string)$error->getCode()];
}
$db->release_savepoint('row_guard');
}
$db->commit();
}catch(Throwable $error){$db->rollback();throw $error;}
$stmt->close();$stored=app107Read($db,'SELECT sku,qty FROM mysql107_import_savepoints ORDER BY sku');
$blocks=[app107Block('Принятые строки',['sku','qty'],$stored),app107Block('Пропущенные строки',['CSV-строка','sku','Код'],$rejects)];
$checks=['two_saved'=>$stored===[['A','2'],['C','4']],'known_failures'=>array_column($rejects,2)===['3819','1062']];
app107Report('mysql107-import-savepoints',$blocks,$checks,$db,$diagnostics);
} finally {
app107Cleanup($db,$other,$created);
}
echo "mysql107-import-savepoints: saved result.html and result.json\n";
Ожидаемый результат
mysql107-import-savepoints: saved result.html and result.json
Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Добавьте к успешному INSERT второй запрос аудита в той же итерации. Если он завершится заранее допустимой ошибкой, rollback-to должен отменить оба изменения текущей строки. Сравните это с простым continue после исключения без savepoint.
Не применять тот же файл повторно
Оператор повторно выбрал тот же файл, а затем прислал действительно обновлённый снимок остатков. Первый повтор должен ничего не менять, новый файл — заменить количества, а повтор старого уже обработанного файла — не откатить каталог назад.
Исходные данные
input.csv
| sku | qty |
|---|---|
| A | 2 |
| B | 4 |
changed.csv
| sku | qty |
|---|---|
| A | 5 |
| B | 4 |
Что получится
История импорта
| Файл | Результат | qty A |
|---|---|---|
| input.csv | applied | 2 |
| input.csv | already imported | 2 |
| changed.csv | applied | 5 |
| input.csv | already imported | 5 |
Текущие товары
| sku | qty |
|---|---|
| A | 5 |
| B | 4 |
Различных принятых файлов
| count |
|---|
| 2 |
Ключевой запрос и его смысл
START TRANSACTION;
INSERT INTO mysql107_import_files(file_hash,rows_count) VALUES (?,?);
INSERT INTO mysql107_import_snapshot(sku,qty) VALUES (?,?)
ON DUPLICATE KEY UPDATE qty=?;
COMMIT;Перед записью вычисляется SHA-256 байтов входного файла. Этот отпечаток — PRIMARY KEY журнала файлов. В одной транзакции сначала вставляется отпечаток, затем применяются строки. Если отпечаток уже есть, 1062 приводит к rollback текущей попытки и результату already imported. При сбое применения новый отпечаток откатится вместе со строками, позволяя осознанно повторить исправленную операцию.
ON DUPLICATE KEY UPDATE используется здесь по артикулу: новый товар вставляется, существующий получает qty из файла. Это снимок количества, не команда «добавить количество». Для нового A=2 получаем 2, для обновлённого A=5 — 5, а не 7. Третий параметр execute содержит новое qty ещё раз; deprecated VALUES(qty) не используется.
Журнал считает два разных содержимых файла. Итоговые строки сверяются непосредственно SELECT, а не суммой affected_rows: для upsert число затронутых строк зависит от вставки, изменения и отсутствия изменения. Имя файла не является ключом: новый экспорт может называться так же, а одинаковые байты — по-разному.
Параметры, возвращаемые значения и варианты API: mysqli_stmt_execute, mysqli_commit, mysqli_rollback. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.
Ошибки и границы применения
Хеш отличает байты, не смысл. BOM, порядок строк или окончания строк меняют его даже при тех же товарах. Это не универсальная дедупликация и не защита от позднего впервые увиденного старого файла; для этого нужна версия/время источника и договор приоритета. Учебный вход доверенный и маленький; в рабочем импорте сначала примените валидацию из предыдущего сценария. Исчезнувшие из снимка товары здесь не удаляются: это отдельное правило, которое нельзя молча выводить из отсутствия строки.
Полная самостоятельная программа 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_import_files','file_hash CHAR(64) PRIMARY KEY, rows_count INT NOT NULL');
app107Create($db,$created,'mysql107_import_snapshot','sku VARCHAR(20) PRIMARY KEY, qty INT NOT NULL');
function import107(mysqli $db,string $filename): string {
$hash=hash_file('sha256',__DIR__.'/'.$filename);$rows=app107Csv($filename,['sku','qty']);
$db->begin_transaction();
try{
try{$s=$db->prepare('INSERT INTO mysql107_import_files(file_hash,rows_count) VALUES(?,?)');$s->execute([$hash,count($rows)]);$s->close();}
catch(mysqli_sql_exception $error){if($error->getCode()!==1062){throw $error;}$db->rollback();return 'already imported';}
$s=$db->prepare('INSERT INTO mysql107_import_snapshot(sku,qty) VALUES(?,?) ON DUPLICATE KEY UPDATE qty=?');
foreach($rows as $row){[$sku,$qty]=$row['values'];$s->execute([$sku,(int)$qty,(int)$qty]);}$s->close();
$db->commit();return 'applied';
}catch(Throwable $error){$db->rollback();throw $error;}
}
$history=[];
foreach(['input.csv','input.csv','changed.csv','input.csv'] as $filename){
$status=import107($db,$filename);$qty=app107Read($db,"SELECT qty FROM mysql107_import_snapshot WHERE sku='A'")[0][0];$history[]=[$filename,$status,$qty];
}
$stored=app107Read($db,'SELECT sku,qty FROM mysql107_import_snapshot ORDER BY sku');$count=app107Read($db,'SELECT COUNT(*) FROM mysql107_import_files')[0][0];
$blocks=[app107Block('История импорта',['Файл','Результат','qty A'],$history),app107Block('Текущие товары',['sku','qty'],$stored),app107Block('Различных принятых файлов',['count'],[[$count]])];
$checks=['two_applied'=>array_column($history,1)===['applied','already imported','applied','already imported'],'latest_a_five'=>$stored===[['A','5'],['B','4']],'two_hashes'=>$count==='2'];
app107Report('mysql107-import-replay',$blocks,$checks,$db,$diagnostics);
} finally {
app107Cleanup($db,$other,$created);
}
echo "mysql107-import-replay: saved result.html and result.json\n";
Ожидаемый результат
mysql107-import-replay: saved result.html and result.json
Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Скопируйте input.csv под другим именем без изменения байтов: должен получиться already imported. Затем поменяйте порядок двух строк: хеш изменится. Определите, нужен ли вашему приложению байтовый ключ или нормализованное содержимое с явной версией поставщика.
Отделить понятную ошибку от внутренней диагностики
Пользователю нужен понятный отказ, разработчику — точная причина. Показывать сырой текст MySQL прямо на странице нельзя: там могут оказаться имена схемы, значения и внутреннее устройство запроса.
Исходные данные
Родители
| id |
|---|
| 1 |
Существующие товары
| sku | parent_id |
|---|---|
| A | 1 |
Что получится
Безопасный ответ приложения
| Сценарий | Код | SQLSTATE | Сообщение | Автоповтор |
|---|---|---|---|---|
| duplicate | 1062 | 23000 | Артикул уже существует | no |
| parent | 1452 | 23000 | Родительская запись не найдена | no |
| column | 1054 | 42S22 | Ошибка структуры запроса; передано разработчику | no |
Исходная запись сохранена
| sku | parent_id |
|---|---|
| A | 1 |
Ключевой запрос и его смысл
try {
$statement->execute($values);
} catch (mysqli_sql_exception $error) {
$code = $error->getCode();
$state = $error->getSqlState();
// Из известных кодов строится безопасное сообщение; сырой текст остаётся в журнале.
}В строгом режиме mysqli_report(MYSQLI_REPORT_ERROR|MYSQLI_REPORT_STRICT) сбой SQL становится mysqli_sql_exception. getCode() возвращает номер ошибки MySQL, getSqlState() — пятисимвольную категорию SQLSTATE. Дубликат и отсутствующий родитель здесь оба имеют 23000, но действия пользователя различаются: одного SQLSTATE недостаточно.
| Ошибка | Что произошло | Действие |
|---|---|---|
| 1062 / 23000 | Нарушен уникальный артикул | Показать конфликт и предложить согласованный вариант исправления. |
| 1452 / 23000 | Нет нужной родительской записи FK | Уточнить справочник/порядок импорта. |
| 1054 / 42S22 | Код запросил неизвестный столбец | Передать разработчику; повтор теми же данными запрос не исправит. |
Программа действительно выполняет три ошибочных запроса над собственными таблицами, распознаёт конкретные код и состояние и сохраняет публичную таблицу безопасных сообщений. В отдельном локальном diagnostics.json остаётся server_message с контекстом. Этот файл не выводится в публичный результат; в рабочем сервисе журнал должен лежать вне доступного каталога сайта и иметь ограниченный доступ. Данные учебные, реквизитов соединения в журнале нет.
Видимый фрагмент PHP — схема обработчика, где statement и values созданы ранее; это не отдельная запускаемая программа. Полный архив ниже содержит создание таблиц, три реальные ошибки, чтение сохранённой записи и очистку. Неизвестное исключение не заменяется молчаливым «успехом».
Параметры, возвращаемые значения и варианты API: mysqli_report, mysqli_stmt_sqlstate, mysqli_stmt_errno. В этой главе используется объектная запись тех же операций; справочник объясняет и процедурную форму. В карточках отдельно указана граница проверки каждого старого примера.
Ошибки и границы применения
Не объявляйте все ошибки класса 23000 повторяемыми и не выполняйте бесконечный retry. Deadlock/lock timeout требуют другого ограниченного алгоритма повторения всей бизнес-транзакции; потеря соединения около COMMIT оставляет неопределённость, решаемую ключом операции. Ошибка имени колонки требует исправления кода. Записи журнала тоже могут содержать пользовательские данные: ограничивайте их состав и срок хранения.
Полная самостоятельная программа 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_import_error_parent','id INT PRIMARY KEY');
app107Create($db,$created,'mysql107_import_error_items','sku VARCHAR(20) PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT mysql107_import_parent_fk FOREIGN KEY(parent_id) REFERENCES mysql107_import_error_parent(id)');
$db->query('INSERT INTO mysql107_import_error_parent VALUES(1)');$db->query("INSERT INTO mysql107_import_error_items VALUES('A',1)");
$cases=[['duplicate',"INSERT INTO mysql107_import_error_items VALUES('A',1)",1062,'23000','Артикул уже существует'],['parent',"INSERT INTO mysql107_import_error_items VALUES('B',999)",1452,'23000','Родительская запись не найдена'],['column','SELECT missing_column FROM mysql107_import_error_items',1054,'42S22','Ошибка структуры запроса; передано разработчику']];
$rows=[];$diagnostics=[];$all=true;
foreach($cases as [$context,$sql,$expected,$state,$safe]){
try{$db->query($sql);throw new RuntimeException('Expected SQL error missing');}
catch(mysqli_sql_exception $error){
if($error->getCode()!==$expected || $error->getSqlState()!==$state){throw $error;}
$rows[]=[$context,(string)$error->getCode(),$error->getSqlState(),$safe,'no'];
// Local private evidence. This file is deliberately not linked as a public result.
$diagnostics[]=['context'=>$context,'code'=>$error->getCode(),'sqlstate'=>$error->getSqlState(),'server_message'=>$error->getMessage()];
}
}
$stored=app107Read($db,'SELECT sku,parent_id FROM mysql107_import_error_items');
$blocks=[app107Block('Безопасный ответ приложения',['Сценарий','Код','SQLSTATE','Сообщение','Автоповтор'],$rows),app107Block('Исходная запись сохранена',['sku','parent_id'],$stored)];
$public=json_encode($blocks,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
$hasRaw=false;foreach($diagnostics as $record){if(str_contains($public,$record['server_message'])){$hasRaw=true;}}
$checks=['three_actual_errors'=>count($rows)===3,'only_original_row'=>$stored===[['A','1']],'no_raw_server_message_in_public'=>!$hasRaw];
app107Report('mysql107-import-errors',$blocks,$checks,$db,$diagnostics);
} finally {
app107Cleanup($db,$other,$created);
}
echo "mysql107-import-errors: saved result.html and result.json\n";
Ожидаемый результат
mysql107-import-errors: saved result.html and result.json
Программа создаёт показанные данные в своей учебной таблице, выполняет действия и сохраняет result.html/result.json. Архив содержит mysql107-transactions-helper.php, README.txt и нужные входные CSV. Таблицы результата показаны выше; сообщения консоли — только уведомление о сохранённых файлах.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Для третьего случая исправьте имя столбца и убедитесь, что больше нет причины выводить этот отказ. Затем разработайте безопасное сообщение для неизвестной ошибки с идентификатором события; сырой текст должен оставаться только в защищённом журнале, а не попадать в JSON API или HTML.
Что расширять в рабочем импорте
Добавьте явно выбранный источник, версию набора, хранение отчёта, предел размера и правила доступа. Для большого экспорта/чтения нужен ограниченный или потоковый результат. Для повторной бизнес-заявки используйте ключ операции, а не слепой повтор INSERT после любой ошибки. Проверяйте нужное конечное состояние, а не только отсутствие исключения.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


