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

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

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

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

Глава 44 · После первого соединения и чтения таблицы

MySQL: кодировка, параметры, NULL и проверяемое сохранение

Русский текст и emoji, воспроизводимое повреждение кодировки, prepared statements, ноль изменённых строк и откат незавершённой операции.

Что согласовать между PHP-строкой, соединением и таблицей

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

Договор на каждой границе
  1. Строка PHP: байты UTF-8 и смысл поля
  2. Соединение: charset utf8mb4
  3. SQL: структура отдельно, значения параметрами
  4. Таблица: типы и NULL; транзакция для целого действия
  5. Обратное чтение: сравнить смысл и значения
  6. HTML: htmlspecialchars для безопасного текста

Из официальных Concepts здесь раскрыта кодировка; второй самостоятельный вопрос — буферизованные и потоковые запросы. Таблица не обязана помещаться целиком в PHP-массив. Сначала разберитесь со значениями, затем выбирайте способ чтения большого результата.

Сохранить русское название с emoji без потери байтов

Название товара содержит русский текст и emoji. Нужен не «запрос прошёл», а обратное чтение той же строки. Сохраним Кружка 😀 и увидим 8 символов и 17 байтов; emoji не потеряется.

От действия к результату
  1. Файл PHP в UTF-8 → строка Кружка 😀
  2. set_charset: utf8mb4 у соединения
  3. Столбец VARCHAR: utf8mb4
  4. bind_param: строка передаётся отдельно
  5. SELECT → те же байты / 8 символов / 17 байтов

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

Прочитанное названиеСимволовБайтов
Кружка 😀817
Короткая запись основного действия; настройки и подготовка данных находятся в полном примере
$db->set_charset('utf8mb4');
$name = 'Кружка 😀';
$statement = $db->prepare('INSERT INTO mysql106_unicode (name) VALUES (?)');
$statement->bind_param('s', $name);
$statement->execute();

Кодировка — договор, как байты представляют символы. В строке шесть русских букв по два байта, пробел в один байт и emoji в четыре: всего 17. CHAR_LENGTH считает символы кодировки MySQL, LENGTH — байты; это не универсальный счёт видимых графем сложного emoji. utf8mb4 поддерживает четырёхбайтовые символы, историческое MySQL utf8/utf8mb3 — нет. set_charset задаёт кодировку связи; кодировка столбца задаётся отдельно. В prepare знак ? — место одного значения. bind_param с s связывает строковую переменную с этим местом, execute передаёт значение серверу.

Совпадение charset на входе, в соединении, столбце и выводе проверяется отдельно. MySQL-имя utf8mb4 пишется без дефиса; HTML meta charset="UTF-8" и параметр UTF-8 у htmlspecialchars — другие имена в других интерфейсах. Для PDO добавляйте charset=utf8mb4 в DSN. После соединения можно проверить @@character_set_client, @@character_set_connection, @@character_set_results; эти реальные значения записаны в metrics.json.

Открыть таблицу фактического запуска · Скачать прочитанные значения JSON. Эти файлы создаёт сама программа; JSON не подменяет источник данных и не является базой MySQL.

Сохранить русское название с emoji без потери байтовСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__ . '/mysql106-helper.php';

$details = [];
$db = mysql106Connect();
$db->set_charset('utf8mb4');
$db->query('CREATE TEMPORARY TABLE mysql106_unicode (name VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$name = 'Кружка 😀';
$statement = $db->prepare('INSERT INTO mysql106_unicode (name) VALUES (?)');
$statement->bind_param('s',$name);
$statement->execute();
$statement->close();
$row = $db->query('SELECT name,CHAR_LENGTH(name) AS characters,LENGTH(name) AS bytes FROM mysql106_unicode')->fetch_assoc();
$rows = [[$row['name'],(int)$row['characters'],(int)$row['bytes']]];
$checks = ['roundtrip_equal'=>$row['name']===$name,'eight_characters'=>(int)$row['characters']===8,'seventeen_bytes'=>(int)$row['bytes']===17,'client_utf8mb4'=>$db->character_set_name()==='utf8mb4'];
$details = $db->query('SELECT @@character_set_client AS client,@@character_set_connection AS connection_charset,@@character_set_results AS results_charset')->fetch_assoc();
$runtime = mysql106Runtime($db);
$db->close();
mysql106Report('mysql106-utf8mb4-roundtrip', ['Прочитанное название', 'Символов', 'Байтов'], $rows, $checks, $runtime, $details);
echo "utf8mb4: exact round trip; 8 characters, 17 bytes\n";
Ожидаемый результат
utf8mb4: exact round trip; 8 characters, 17 bytes

Полный самостоятельный комплект: example.php, mysql106-helper.php и README.txt. Настройте отдельный локальный MySQL и переменные окружения из инструкции выше, затем выполните php example.php. Код сам создаёт свои TEMPORARY-таблицы и исходные строки; другие примеры предварительно запускать не нужно. Helper читает окружение, устанавливает соединение и сохраняет HTML/JSON; вся SQL-работа видна в example.php. Для повторения достаточно новой сессии; локальные файлы отчёта будут перезаписаны.

Одна настройка collation таблицы не исправляет ошибочный клиент. Collation определяет правила сравнения/сортировки текста, charset — представление символов. SQL SET NAMES не заменяет корректный API-вызов mysqli set_charset для клиентского состояния, от которого зависит и экранирование. Подготовленный запрос не исправит неверно обозначенную кодировку байтов. Предпочитайте настроенные по умолчанию UTF-8 компоненты плюс явное соединение.

Практическая проблема из обсуждения How to store Emoji Character in MySQL Database: Emoji не сохраняется, хотя таблица уже объявлена utf8mb4: нужно проверить и соединение, и байты входа. Вопрос используется как источник ситуации; решение написано заново, контракт проверяется по официальной документации PHP/MySQL.

Увидеть, почему правильной кодировки таблицы недостаточно

Поставщик отдаёт UTF-8, но клиент объявляет latin1. Даже столбец utf8mb4 не спасает: Café превращается в Café. На двух строках одной собственной таблицы сравним неверный договор и правильный.

От действия к результату
  1. UTF-8 bytes C3 A9 у é
  2. Ложное объявление latin1 → два символа Ã и ©
  3. Сохранение в utf8mb4 → Café
  4. Правильное объявление utf8mb4 → Café
  5. Чтение обоих через utf8mb4 показывает различие

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

Настройка соединения при INSERTПрочитано через utf8mb4СимволовБайтов
latin1 при UTF-8 входеCafé57
utf8mb4 при UTF-8 входеCafé45
Короткая запись основного действия; настройки и подготовка данных находятся в полном примере
$db->set_charset('utf8mb4'); // Это объявление кодировки отправляемых байтов.
// PHP-строка и исходный файл программы тоже должны содержать UTF-8.
// Исправление настройки не переписывает уже сохранённые испорченные значения.

set_charset не перекодирует уже существующую PHP-строку. Он сообщает, как сервер должен читать отправляемые байты. Первая подготовленная вставка отправляет UTF-8 при ошибочном latin1; сервер сохраняет другое значение. Затем меняем настройку соединения и вставляем исходный текст второй раз. SELECT выполняется уже в utf8mb4, поэтому честно показывает 5 символов/7 байтов у ошибки и 4/5 у правильного результата.

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

Открыть таблицу фактического запуска · Скачать прочитанные значения JSON. Эти файлы создаёт сама программа; JSON не подменяет источник данных и не является базой MySQL.

Увидеть, почему правильной кодировки таблицы недостаточноСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__ . '/mysql106-helper.php';

$details = [];
$db = mysql106Connect();
$db->query('CREATE TEMPORARY TABLE mysql106_charset (id INT PRIMARY KEY,label VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$text = 'Café';
$db->set_charset('latin1');
$bad = $db->prepare('INSERT INTO mysql106_charset (id,label) VALUES (1,?)');
$bad->bind_param('s',$text); $bad->execute(); $bad->close();
$db->set_charset('utf8mb4');
$good = $db->prepare('INSERT INTO mysql106_charset (id,label) VALUES (2,?)');
$good->bind_param('s',$text); $good->execute(); $good->close();
$rows = [];
foreach ($db->query('SELECT id,label,CHAR_LENGTH(label) AS characters,LENGTH(label) AS bytes FROM mysql106_charset ORDER BY id') as $row) {
    $rows[] = [(int)$row['id']===1 ? 'latin1 при UTF-8 входе' : 'utf8mb4 при UTF-8 входе',$row['label'],(int)$row['characters'],(int)$row['bytes']];
}
$checks = ['wrong_declaration_visible'=>$rows[0][1]==='Café','correct_declaration_preserves'=>$rows[1][1]===$text,'earlier_data_not_repaired'=>$rows[0][1]!==$rows[1][1]];
$runtime = mysql106Runtime($db);
$db->close();
mysql106Report('mysql106-charset-mismatch', ['Настройка соединения при INSERT', 'Прочитано через utf8mb4', 'Символов', 'Байтов'], $rows, $checks, $runtime, $details);
echo "charset mismatch reproduced; correct connection preserves Cafe with accent\n";
Ожидаемый результат
charset mismatch reproduced; correct connection preserves Cafe with accent

Полный самостоятельный комплект: example.php, mysql106-helper.php и README.txt. Настройте отдельный локальный MySQL и переменные окружения из инструкции выше, затем выполните php example.php. Код сам создаёт свои TEMPORARY-таблицы и исходные строки; другие примеры предварительно запускать не нужно. Helper читает окружение, устанавливает соединение и сохраняет HTML/JSON; вся SQL-работа видна в example.php. Для повторения достаточно новой сессии; локальные файлы отчёта будут перезаписаны.

Смена set_charset действует на дальнейший обмен, а не переписывает испорченные строки. Исправление накопленных данных — отдельная миграция с копией и проверкой обратимости. Для текущего соединения после намеренной ошибки восстановлен utf8mb4; постоянные таблицы и серверные настройки не меняются.

Передать апостроф и SQL-похожий текст как данные

Покупатель ищет O'Reilly mug: апостроф должен быть частью названия. Другой ввод похож на SQL. Один подготовленный поиск должен найти кружку в первом случае и ничего во втором, оставив оба товара целыми.

От действия к результату
  1. prepare: WHERE name=? — структура запроса
  2. bind_param: s + переменная search
  3. execute: значение O’Reilly mug → 1 строка
  4. Тот же statement, другое значение → 0 строк
  5. get_result → fetch_assoc → вывод

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

Искомое значениеСовпаденийНазвание
O'Reilly mug1O'Reilly mug
' OR 1=1 -- 0пустая строка
Короткая запись основного действия; настройки и подготовка данных находятся в полном примере
$search = "O'Reilly mug";
$find = $db->prepare('SELECT name FROM mysql106_names WHERE name=?');
$find->bind_param('s', $search);
$find->execute();
$result = $find->get_result();

prepare разбирает SQL с маркерами; ? не заключается в кавычки. bind_param принимает строку типов и переменные по ссылке: i — целое, d — число с плавающей точкой, s — строка, b — двоичные данные с отдельной передачей больших частей. Число букв и переменных должно совпадать с числом параметров. is у INSERT связывает id и name. При повторном execute берутся текущие значения тех же переменных; поэтому смена $name после первого execute создаёт второй товар, а цикл меняет $search и использует один подготовленный запрос.

get_result возвращает обычный mysqli_result (в этом примере используется mysqlnd), который можно читать знакомым fetch_assoc. Вход с апострофом не нужно заранее прогонять через real_escape_string или addslashes: параметр передаётся отдельно от SQL, дополнительное экранирование испортит данные. Проверка типов и бизнес-ограничений по-прежнему нужна: подготовка не решает, имеет ли пользователь право менять этот товар.

Открыть таблицу фактического запуска · Скачать прочитанные значения JSON. Эти файлы создаёт сама программа; JSON не подменяет источник данных и не является базой MySQL.

Передать апостроф и SQL-похожий текст как данныеСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__ . '/mysql106-helper.php';

$details = [];
$db = mysql106Connect();
$db->query('CREATE TEMPORARY TABLE mysql106_names (id INT PRIMARY KEY,name VARCHAR(80) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$insert = $db->prepare('INSERT INTO mysql106_names (id,name) VALUES (?,?)');
$id = 1; $name = "O'Reilly mug";
$insert->bind_param('is',$id,$name); $insert->execute();
$id = 2; $name = 'Plain mug'; $insert->execute(); $insert->close();
$find = $db->prepare('SELECT name FROM mysql106_names WHERE name=? ORDER BY id');
$search = ''; $find->bind_param('s',$search);
$rows = [];
foreach (["O'Reilly mug", "' OR 1=1 -- "] as $search) {
    $find->execute();
    $result = $find->get_result();
    $matches = [];
    while ($row = $result->fetch_assoc()) { $matches[]=$row['name']; }
    $rows[] = [$search,count($matches),implode(', ',$matches)];
    $result->free();
}
$find->close();
$checks = ['apostrophe_found'=>$rows[0][1]===1,'sql_text_is_only_value'=>$rows[1][1]===0,'both_records_remain'=>(int)$db->query('SELECT COUNT(*) AS n FROM mysql106_names')->fetch_assoc()['n']===2];
$runtime = mysql106Runtime($db);
$db->close();
mysql106Report('mysql106-prepared-values', ['Искомое значение', 'Совпадений', 'Название'], $rows, $checks, $runtime, $details);
echo "prepared search: apostrophe=1; SQL-looking text=0; records=2\n";
Ожидаемый результат
prepared search: apostrophe=1; SQL-looking text=0; records=2

Полный самостоятельный комплект: example.php, mysql106-helper.php и README.txt. Настройте отдельный локальный MySQL и переменные окружения из инструкции выше, затем выполните php example.php. Код сам создаёт свои TEMPORARY-таблицы и исходные строки; другие примеры предварительно запускать не нужно. Helper читает окружение, устанавливает соединение и сохраняет HTML/JSON; вся SQL-работа видна в example.php. Для повторения достаточно новой сессии; локальные файлы отчёта будут перезаписаны.

Параметр обозначает значение, не имя таблицы, столбца, ключевое слово или направление ORDER BY. Для выбора сортировки сопоставьте разрешённое имя с фиксированным SQL-фрагментом. LIKE с параметром всё ещё трактует % и _ как шаблон: отдельно решите, нужен поиск по шаблону или буквальному тексту. Подготовленный запрос не обещает ускорения одного короткого SELECT: его ценность здесь — разделение SQL и данных, при повторениях — ещё и повторное использование структуры.

Практическая проблема из обсуждения Reasons for Prepared Statements with Bind Parameters over Interpolated Statements with Escaped / Quoted Parameters: Вопрос о смысле bound parameters по сравнению со склеиванием SQL и ручным экранированием. Вопрос используется как источник ситуации; решение написано заново, контракт проверяется по официальной документации PHP/MySQL.

Различить неизвестное значение, пустую строку и ноль

Форма может не содержать значение вообще, содержать пустой текст или строку "0". Для необязательного поля это разные состояния. Сохраним все три и покажем их после чтения, не уничтожая ноль через empty().

От действия к результату
  1. PHP null / "" / "0"
  2. bind_param → NULL / пустой VARCHAR / символ 0
  3. IS NULL → 1 только у первой строки
  4. LENGTH → NULL / 0 / 1
  5. fetch → PHP null / "" / "0"

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

idvalueIS NULLLENGTH в байтах
1NULL1NULL
2пустая строка00
3001
Короткая запись основного действия; настройки и подготовка данных находятся в полном примере
$value = null; // Неизвестно, а не ноль.
$insert->bind_param('is', $id, $value);
$insert->execute();
$result = $db->query('SELECT value FROM mysql106_optional WHERE value IS NULL');

NULL — отсутствие значения SQL, а не текст "NULL". nullable VARCHAR разрешает такое состояние; NOT NULL запрещает его. Значение PHP null передаётся как SQL NULL даже при строковом параметре s. Для поиска отсутствия используйте IS NULL: сравнение = NULL не даёт обычного true. При чтении проверяйте $value === null; пустая строка проверяется отдельно через === "". Строка "0" содержит один символ и в этом поле остаётся строкой.

Числовые id приводим к int для JSON, значение optional сохраняем без приведения. NULL у LENGTH тоже сохраняется: (int)null превратил бы отсутствие в ноль и скрыл разницу. Обозначение «(пустая строка)» появляется только в HTML-таблице для читателя; result.json хранит настоящий пустой текст. Точно так же слово NULL в HTML — подпись отсутствующего значения, не изменение базы.

Открыть таблицу фактического запуска · Скачать прочитанные значения JSON. Эти файлы создаёт сама программа; JSON не подменяет источник данных и не является базой MySQL.

Различить неизвестное значение, пустую строку и нольСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__ . '/mysql106-helper.php';

$details = [];
$db = mysql106Connect();
$db->query('CREATE TEMPORARY TABLE mysql106_optional (id INT PRIMARY KEY,value VARCHAR(20) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$insert = $db->prepare('INSERT INTO mysql106_optional (id,value) VALUES (?,?)');
$id = 0; $value = null; $insert->bind_param('is',$id,$value);
foreach ([1=>null,2=>'',3=>'0'] as $id=>$value) { $insert->execute(); }
$insert->close();
$rows = [];
foreach ($db->query('SELECT id,value,value IS NULL AS missing,LENGTH(value) AS bytes FROM mysql106_optional ORDER BY id') as $row) {
    $rows[]=[(int)$row['id'],$row['value'],(int)$row['missing'],$row['bytes']===null ? null : (int)$row['bytes']];
}
$missing = (int)$db->query('SELECT COUNT(*) AS n FROM mysql106_optional WHERE value IS NULL')->fetch_assoc()['n'];
$checks = ['three_distinct_values'=>$rows[0][1]===null && $rows[1][1]==='' && $rows[2][1]==='0','one_missing_value'=>$missing===1];
$runtime = mysql106Runtime($db);
$db->close();
mysql106Report('mysql106-null-empty-zero', ['id', 'value', 'IS NULL', 'LENGTH в байтах'], $rows, $checks, $runtime, $details);
echo "optional values: NULL, empty string and string zero remain distinct\n";
Ожидаемый результат
optional values: NULL, empty string and string zero remain distinct

Полный самостоятельный комплект: example.php, mysql106-helper.php и README.txt. Настройте отдельный локальный MySQL и переменные окружения из инструкции выше, затем выполните php example.php. Код сам создаёт свои TEMPORARY-таблицы и исходные строки; другие примеры предварительно запускать не нужно. Helper читает окружение, устанавливает соединение и сохраняет HTML/JSON; вся SQL-работа видна в example.php. Для повторения достаточно новой сессии; локальные файлы отчёта будут перезаписаны.

Форма обычно присылает строки; преобразование в null/число решает приложение по договору поля. Для количества нельзя автоматически считать пустоту нулевым остатком. Для денег не полагайтесь на float; выбирайте DECIMAL или целые минимальные единицы с оговорённым диапазоном. Подробности схемы и ограничений — в главе о типах колонок.

Не считать неизменившееся значение ошибкой сохранения

Пользователь нажал «Сохранить», не изменив остаток 7. UPDATE выполнен, но affected_rows равен 0. Это ещё не ошибка. Два соединения покажут разницу между подсчётом изменённых строк и строк, подошедших под WHERE.

От действия к результату
  1. Новая таблица: id1, stock7
  2. Без флага: 7→7 даёт 0; 7→8 даёт 1
  3. Новое соединение с FOUND_ROWS
  4. 7→7 даёт 1; 7→8 даёт 1
  5. Оба SELECT подтверждают stock8

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

Режим подсчётаОбновлениеaffected_rows
changed7 → 70
changed7 → 81
matched7 → 71
matched7 → 81
Короткая запись основного действия; настройки и подготовка данных находятся в полном примере
$db->query('UPDATE mysql106_counts SET stock=7 WHERE id=1');
$changed = $db->affected_rows;
// 0 может означать: строка найдена, но stock уже равен 7.
// В строгом режиме ошибка запроса выбросила бы исключение.

По умолчанию affected_rows у UPDATE считает реально изменённые строки. MYSQLI_CLIENT_FOUND_ROWS меняет договор на найденные условием строки. Флаг задаётся последним аргументом real_connect, после создания объекта и настройки таймаута; изменить уже полученный результат задним числом он не может. Число сохраняем сразу после UPDATE. Если нужен ответ «сохранено», отделяйте успешное выполнение запроса от вопроса «изменилось ли значение».

Две таблицы имеют одинаковое имя, но принадлежат двум последовательным сессиям; их результаты независимы. Для PDO есть аналогичная настройка PDO::MYSQL_ATTR_FOUND_ROWS при создании соединения, а rowCount читает число затронутых строк. Вопрос Stack Overflow ниже использует именно PDO, но причина того же поведения — серверный договор обновления. Не используйте rowCount как универсальный счётчик результата SELECT: для числа найденных объектов задайте явный COUNT(*) или считайте полученные строки.

Открыть таблицу фактического запуска · Скачать прочитанные значения JSON. Эти файлы создаёт сама программа; JSON не подменяет источник данных и не является базой MySQL.

Не считать неизменившееся значение ошибкой сохраненияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__ . '/mysql106-helper.php';

$details = [];
$rows = []; $details = []; $runtime = [];
foreach (['changed'=>0,'matched'=>MYSQLI_CLIENT_FOUND_ROWS] as $mode=>$flags) {
    $db = mysql106Connect($flags);
    $db->query('CREATE TEMPORARY TABLE mysql106_counts (id INT PRIMARY KEY,stock INT NOT NULL) ENGINE=InnoDB');
    $db->query('INSERT INTO mysql106_counts (id,stock) VALUES (1,7)');
    $db->query('UPDATE mysql106_counts SET stock=7 WHERE id=1');
    $same = $db->affected_rows;
    $db->query('UPDATE mysql106_counts SET stock=8 WHERE id=1');
    $changed = $db->affected_rows;
    $actual = (int)$db->query('SELECT stock FROM mysql106_counts WHERE id=1')->fetch_assoc()['stock'];
    $rows[] = [$mode,'7 → 7',$same];
    $rows[] = [$mode,'7 → 8',$changed];
    $details[$mode.'_final_stock']=$actual;
    $runtime = mysql106Runtime($db);
    $db->close();
}
$checks = ['default_counts_changes'=>$rows[0][2]===0 && $rows[1][2]===1,'flag_counts_matches'=>$rows[2][2]===1 && $rows[3][2]===1,'both_saved_eight'=>$details['changed_final_stock']===8 && $details['matched_final_stock']===8];
mysql106Report('mysql106-update-counts', ['Режим подсчёта', 'Обновление', 'affected_rows'], $rows, $checks, $runtime, $details);
echo "UPDATE: default same=0 changed=1; FOUND_ROWS same=1 changed=1\n";
Ожидаемый результат
UPDATE: default same=0 changed=1; FOUND_ROWS same=1 changed=1

Полный самостоятельный комплект: example.php, mysql106-helper.php и README.txt. Настройте отдельный локальный MySQL и переменные окружения из инструкции выше, затем выполните php example.php. Код сам создаёт свои TEMPORARY-таблицы и исходные строки; другие примеры предварительно запускать не нужно. Helper читает окружение, устанавливает соединение и сохраняет HTML/JSON; вся SQL-работа видна в example.php. Для повторения достаточно новой сессии; локальные файлы отчёта будут перезаписаны.

0 также может означать, что WHERE не нашёл ни одной строки. Если нужно различать «товара нет» и «данные те же», это отдельная политика приложения с учётом конкурентных изменений, а не простая проверка truthiness. -1 у mysqli может означать ошибку в нестрогом режиме или ситуацию с ещё небуферизованной выдачей. Пример сравнивает только успешные UPDATE; ошибки перехватываются отдельно.

Практическая проблема из обсуждения Why does PDO rowCount() return 0 after UPDATE a table without modifying the existing data?: UPDATE вернул ноль для неизменившегося значения; автор принял это за ошибку записи. Вопрос используется как источник ситуации; решение написано заново, контракт проверяется по официальной документации PHP/MySQL.

Сохранить остаток и заказ вместе либо откатить оба

Остаток уменьшили, а запись заказа не завершилась. Нельзя оставлять половину операции. Покажем оба исхода одной пары записей: после отката снова 2 товара и 0 заказов; после подтверждения 1 товар и 1 заказ.

От действия к результату
  1. До: qty2, orders0
  2. begin → UPDATE qty1 → INSERT order
  3. Контролируемый отказ → rollback → qty2/orders0
  4. Новая begin → обе записи → commit
  5. После: qty1/orders1

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

ЭтапОстатокЧисло заказов
До20
После rollback20
После commit11
Короткая запись основного действия; настройки и подготовка данных находятся в полном примере
$db->begin_transaction();
try {
    // UPDATE остатка и INSERT заказа выполняются через это же соединение.
    $db->commit();
} catch (Throwable $error) {
    $db->rollback();
    throw $error;
}

Транзакция объединяет изменения через одно соединение. begin_transaction начинает её; commit подтверждает, rollback отменяет. InnoDB обеспечивает нужную транзакционность этих таблиц. Уменьшение делает сам сервер: SET qty=qty-1 WHERE id=1 AND qty>0. Если affected_rows не равно 1, приложение не продолжает заказ. В первом проходе RuntimeException специально моделирует отказ до commit; во втором проходе нет искусственного отказа. $read — локальная функция, читающая оба значения для наглядной таблицы состояний.

TEMPORARY-таблицы и их схема создаются до начала транзакции. Создание таблиц не следует смешивать с бизнес-операцией: обычный CREATE TABLE может сделать implicit commit, а создание TEMPORARY не откатывается как обычная запись. В первом учебном catch отказ намеренно ожидается и показывается результатом; в рабочем обработчике неожиданный отказ после rollback нужно передать выше, как во втором catch. Не объявляйте заказ успешным только потому, что catch сработал.

Открыть таблицу фактического запуска · Скачать прочитанные значения JSON. Эти файлы создаёт сама программа; JSON не подменяет источник данных и не является базой MySQL.

Сохранить остаток и заказ вместе либо откатить обаСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__ . '/mysql106-helper.php';

$details = [];
$db = mysql106Connect();
$db->query('CREATE TEMPORARY TABLE mysql106_stock (id INT PRIMARY KEY,qty INT NOT NULL) ENGINE=InnoDB');
$db->query('CREATE TEMPORARY TABLE mysql106_orders (id INT PRIMARY KEY,product_id INT NOT NULL) ENGINE=InnoDB');
$db->query('INSERT INTO mysql106_stock (id,qty) VALUES (1,2)');
$read = function(string $stage) use ($db): array {
    $qty = (int)$db->query('SELECT qty FROM mysql106_stock WHERE id=1')->fetch_assoc()['qty'];
    $count = (int)$db->query('SELECT COUNT(*) AS n FROM mysql106_orders')->fetch_assoc()['n'];
    return [$stage,$qty,$count];
};
$rows = [$read('До')];
$controlledFailure = false;
$db->begin_transaction();
try {
    $db->query('UPDATE mysql106_stock SET qty=qty-1 WHERE id=1 AND qty>0');
    if ($db->affected_rows!==1) { throw new RuntimeException('No stock'); }
    $db->query('INSERT INTO mysql106_orders (id,product_id) VALUES (101,1)');
    throw new RuntimeException('Controlled failure before commit');
} catch (Throwable $error) {
    $db->rollback();
    if ($error->getMessage() !== 'Controlled failure before commit') { throw $error; }
    $controlledFailure = true;
}
$rows[] = $read('После rollback');
$db->begin_transaction();
try {
    $db->query('UPDATE mysql106_stock SET qty=qty-1 WHERE id=1 AND qty>0');
    if ($db->affected_rows!==1) { throw new RuntimeException('No stock'); }
    $db->query('INSERT INTO mysql106_orders (id,product_id) VALUES (102,1)');
    $db->commit();
} catch (Throwable $error) { $db->rollback(); throw $error; }
$rows[] = $read('После commit');
$checks = ['controlled_failure_observed'=>$controlledFailure,'rollback_restored_both'=>$rows[1][1]===2 && $rows[1][2]===0,'commit_saved_both'=>$rows[2][1]===1 && $rows[2][2]===1];
$runtime = mysql106Runtime($db);
$db->close();
mysql106Report('mysql106-transaction-recovery', ['Этап', 'Остаток', 'Число заказов'], $rows, $checks, $runtime, $details);
echo "transaction: rollback stock=2/orders=0; commit stock=1/orders=1\n";
Ожидаемый результат
transaction: rollback stock=2/orders=0; commit stock=1/orders=1

Полный самостоятельный комплект: example.php, mysql106-helper.php и README.txt. Настройте отдельный локальный MySQL и переменные окружения из инструкции выше, затем выполните php example.php. Код сам создаёт свои TEMPORARY-таблицы и исходные строки; другие примеры предварительно запускать не нужно. Helper читает окружение, устанавливает соединение и сохраняет HTML/JSON; вся SQL-работа видна в example.php. Для повторения достаточно новой сессии; локальные файлы отчёта будут перезаписаны.

Откат MySQL не отменит уже отправленное письмо, платёж или HTTP-запрос. Этот опыт не вызывает внешних сервисов. Не держите транзакцию открытой во время ожидания пользователя. Модель показывает атомарность данных, но не заменяет отдельное изучение блокировок, повторов при deadlock и идемпотентности. Связи order→items→products и более полный заказ разобраны в SQL-главе о связанных таблицах.

Совместить параметры, правила поля и управление результатом

Упражнение: расширьте параметризованный поиск минимальным остатком. Решение: добавьте в таблицу stock INT NOT NULL, запрос WHERE name=? AND stock>=?, затем bind_param('si',$name,$minimum). Для O'Reilly mug со stock=3 при minimum=4 получите 0 строк, при minimum=3 — одну. Имена колонок остаются фиксированными; параметры обозначают только значения. Проверяйте ноль строк как нормальный результат поиска.

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

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

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

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

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

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