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

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

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

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

Глава 306 · После запросов PostgreSQL и транзакций

PDO_PGSQL: вложения, замена документа и сигналы после commit

bytea и отдельные Large Objects, точное восстановление файлов, откат замены и уведомление о подтверждённом остатке.

Выбрать договор файла и сигнала

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

Три разных договора данных
  1. Заявка 1: bytea → поток PHP → attachment.bin
  2. Документ 1: строка → OID → отдельный Large Object
  3. Замена версии 1→2: ссылка и байты меняются вместе
  4. Остаток 8→6: commit → сигнал → новый SELECT

PDO позволяет PHP отправить запрос в PostgreSQL и получить нужные строки. Драйвер PDO_PGSQL использует клиентскую библиотеку libpq. Одна строка таблицы, файл в PHP и отдельный объект базы — разные сущности; ниже показано, как сохранить их связь.

Проекты запускаются собственным PHP 8.5.11 с PDO_PGSQL в отдельной учебной базе PostgreSQL. Начало SQL и PDO объясняет таблицы и запросы, а подключение PDO_PGSQL — подготовку окружения и параметры. Сначала рассмотрите маленький вызов, затем скачайте проект с созданием данных и уборкой.

Таблицы в примерах создаются заново и наполняются небольшими данными. Для рабочего сайта постоянную схему обычно создают миграцией. Учебная уборка в finally удаляет только случайно названные объекты своего запуска. Здесь используются транзакции, файлы и потоки и функция обратного вызова; каждый новый метод отдельно разобран перед полным проектом.

bytea: содержимое строки и поток PHP

bytea — тип одной колонки для последовательности байтов. В ней можно хранить нулевые байты и значения, которые не являются текстом UTF-8. Имя файла, MIME-тип и права доступа — отдельные данные приложения; сама колонка не определяет, кто вправе скачать содержимое.

ХранилищеЧто находится в строкеКак читать через PDO_PGSQL
byteaСодержимое вложения как значение колонкиSELECT body → PHP stream → fread/stream_get_contents
Large ObjectОбычно только OID-ссылкаSELECT content_oid → lobOpen внутри транзакции
Файловая система / объектное хранилищеПроверенный ключ или путь, метаданныеОтдельная операция чтения и собственная модель согласованности
$statement = $db->prepare('INSERT INTO attachments(id, body) VALUES (?, ?)');
$statement->bindValue(1, 1, PDO::PARAM_INT);
$statement->bindValue(2, "A\0B", PDO::PARAM_LOB);
$statement->execute();

Здесь строка PHP содержит три байта: A, NUL и B. PARAM_LOB обозначает передачу двоичного содержимого. Вызов bindValue связывает значение с первым или вторым знаком ?. Имена таблиц/колонок остаются частью заранее определённого SQL. Метод execute отправляет эти значения; после успешной записи SELECT octet_length(body) для id=1 вернёт 3. Полный проект ниже создаёт собственную таблицу и дополнительно сравнивает исходные и восстановленные байты.

PDO_PGSQL возвращает bytea как ресурс потока. Это уже средство чтения содержимого, а не номер Large Object. В вопросе о bytea и ошибочном вызове pgsqlLOBOpen ресурс пытались передать вместо OID. В нашем проекте этот поток читается напрямую, без повторного lobOpen и без функций другого расширения pg_*.

Для настоящего HTTP-скачивания перед SELECT проверьте доступ пользователя к конкретному документу. Заголовки Content-Type и Content-Disposition формируйте из проверенных метаданных; не позволяйте присланному имени стать произвольным путём файловой системы. CLI-пример сохраняет фиксированное имя в собственной папке. Закрывайте поток и statement после чтения; SQL NULL проверяйте до stream_get_contents.

Поток в API не доказывает отсутствие буферизации: вход PARAM_LOB и полученный PGresult могут занимать память. Для больших файлов измеряйте именно свой путь и выбирайте подходящее хранилище. Не загружайте все body ради списка имён: отдельный SELECT метаданных и отдельный SELECT одного файла решают разные задачи.

Восстановить выбранное вложение без потери NUL и FF

Пользователь скачивает одно вложение заявки. Его содержимое включает нулевой байт и FF: преобразование в текст испортило бы файл. Запрос выбирает только id=1, а приложение сохраняет полученный поток в attachment.bin.

Путь данных и полученный результат
  1. Файл MANUAL + NUL/FF → PARAM_LOB
  2. bytea строки id=1
  3. SELECT одной строки → stream
  4. attachment.bin: 12 байт, совпадает SHA256

Минимальный вызов

$statement = $db->prepare('SELECT body FROM attachments WHERE id = ?');
$statement->execute([1]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
$bytes = stream_get_contents($row['body']);

При существующей строке с непустым bytea $row['body'] — поток, а $bytes — байтовая строка. stream_get_contents читает поток с его текущей позиции. Для SQL NULL сначала проверяют значение на null. Это контекстный фрагмент; полный проект ниже создаёт таблицу и три случая.

Что получится после запуска

НаблюдениеРезультат
Файлattachment.bin
Байт12
Шестнадцатеричные данные4d414e55414c00ff3138300a
Пустой файл0
Отсутствующее вложениеSQL NULL
Самостоятельный проект, исходник и архив
Восстановить выбранное вложение без потери NUL и FFСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

    $table=p180OwnTable($db,$owned,'attachments','(id integer PRIMARY KEY, filename text NOT NULL, body bytea NULL)');
    $q=p180Qualified($table);$source="MANUAL\0\xff180\n";
    $insert=$db->prepare("INSERT INTO $q(id,filename,body) VALUES(?,?,?)");
    $input=fopen('php://memory','w+b');fwrite($input,$source);rewind($input);
    try {
        $insert->bindValue(1,1,PDO::PARAM_INT);$insert->bindValue(2,'attachment.bin');
        $insert->bindParam(3,$input,PDO::PARAM_LOB);$insert->execute();
    } finally {p180Release($insert);if(is_resource($input)){fclose($input);}}
    p180Command($db,"INSERT INTO $q VALUES(2,'empty.bin',decode('','hex')),(3,'missing.bin',NULL)");
    $s=p180Request($db,"SELECT filename,body,octet_length(body) AS bytes FROM $q WHERE id=?",[1]);
    $record=$s->fetch();$content=$record['body'];
    if(!is_resource($content)){throw new RuntimeException('Ожидался stream bytea.');}
    $destination=fopen(__DIR__.'/attachment.bin','wb');
    try{$copied=stream_copy_to_stream($content,$destination);}finally{fclose($content);fclose($destination);p180Release($s);}
    $variants=p180Read($db,"SELECT id,body IS NULL AS missing,octet_length(body) AS bytes FROM $q WHERE id IN(2,3) ORDER BY id");
    $bytes=file_get_contents(__DIR__.'/attachment.bin');
    $checks=['selected_one'=>$record['filename']==='attachment.bin','bytea_stream'=>is_int($copied),
      'bytes_equal'=>$bytes===$source,'length_equal'=>$copied===strlen($source)&&(int)$record['bytes']===$copied,
      'hash_equal'=>hash('sha256',$bytes)===hash('sha256',$source),
      'empty_distinct'=>$variants[0]['missing']===false&&(int)$variants[0]['bytes']===0,
      'null_distinct'=>$variants[1]['missing']===true&&$variants[1]['bytes']===null];
    $rows=[['Файл',$record['filename']],['Байт',$copied],['Шестнадцатеричные данные',bin2hex($bytes)],['Пустой файл',0],['Отсутствующее вложение','SQL NULL']];
    $observations=['sha256'=>hash('sha256',$bytes),'empty_and_null'=>$variants,'input'=>'stream PARAM_LOB','output'=>'bytea stream'];

} finally {

    if($db instanceof PDO){try{$removed=p180Cleanup($db,$owned);}finally{p180Close($db);}}
}

p180Report('pdo180-bytea-attachment','Восстановить выбранное вложение без потери NUL и FF',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Файл — attachment.bin
Байт — 12
Шестнадцатеричные данные — 4d414e55414c00ff3138300a
Пустой файл — 0
Отсутствующее вложение — SQL NULL

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать восстановленный файл

Границы решения

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

Попробуйте изменить условие

Как вывести перечень имён вложений, не скачивая все тела?

Разбор

Выберите SELECT id,filename,octet_length(body) для нужной страницы с WHERE/ORDER BY/LIMIT. Отдельный запрос body выполняйте при скачивании конкретного вложения.

Отдельный объект: OID, права и срок потока

Представьте каталог документов: карточка содержит название и номер отдельного хранилища. В этой модели номер — OID, а хранилище — Large Object. Аналогия помогает отличить ссылку от содержимого, но OID не является путём на диске, PHP-ресурсом или доказательством права доступа. Его значение назначает PostgreSQL.

Метод PHP 8.4+Параметры и результатОграничение
Pdo\Pgsql::lobCreate(): string|falseБез параметров; новый OID строкой либо falseВыполнять внутри транзакции
Pdo\Pgsql::lobOpen(string $oid, string $mode="rb"): resource|falseOID и режим; открытый поток либо falseДескриптор живёт в текущей транзакции
Pdo\Pgsql::lobUnlink(string $oid): boolOID; true при удалении, false при неуспехеУдаляет самостоятельный объект, не строку-ссылку

При выбранном PDO::ERRMODE_EXCEPTION ошибка драйвера также может прервать работу исключением. Проверяйте возвращённое false, а изменения завершайте commit или rollBack. У lobOpen символ w или + даёт чтение и запись; остальные режимы означают чтение. Флаг b не меняет поведение. Не переносите автоматически смысл fopen("w") на Large Object: открытие не является командой обнулить существующий файл. Для замены здесь создаётся новый объект.

$db->beginTransaction();
$stream = $db->lobOpen($oid, 'rb');
if ($stream === false) {
    throw new RuntimeException('Документ недоступен');
}
$firstBytes = fread($stream, 8);
fclose($stream);
$db->commit();

Для объекта с содержимым Document 180 переменная $firstBytes получит Document. fread принимает открытый поток и максимальное число байтов. Проверка === false отличает неудачу от пустой строки; реальный цикл также учитывает конец файла и ошибки чтения. Все действия с LO-потоком заканчиваются до commit. Открытый ресурс нельзя использовать как действующий дескриптор следующей транзакции.

У современного PostgreSQL Large Object есть владелец и права: SELECT для чтения, UPDATE для записи; удаление доступно владельцу или superuser. Утверждение об отсутствии контроля доступа на некоторых старых страницах PHP нельзя переносить на PostgreSQL 18. Официальное описание владельцев и прав уточняет это поведение с PostgreSQL 9.0. В учебных проектах роль владеет своими LO; права других пользователей не меняются.

LO хранится отдельно от пользовательской таблицы. Удаление последней ссылки само по себе не удаляет его. В приложении задайте правило владения и выполняйте unlink вместе с изменением ссылки. Системные pg_largeobject и pg_largeobject_metadata — устройство PostgreSQL; не редактируйте их вручную вместо API.

Сохранить документ отдельным Large Object и прочитать частями

Документ требуется читать частями и перемещаться по содержимому. Таблица хранит название и OID, а байты находятся в отдельном объекте PostgreSQL. Полный проект создаёт объект, связывает его с документом и восстанавливает document.bin.

Путь данных и полученный результат
  1. Документ id=1 → content_oid
  2. lobCreate → отдельный объект
  3. Транзакция: lobOpen/fwrite/fclose
  4. Новая транзакция: fread → document.bin

Минимальный вызов

$db->beginTransaction();
$oid = $db->lobCreate();
$stream = $db->lobOpen($oid, 'w');
fwrite($stream, 'Document 180');
fclose($stream);
$db->commit();

При успехе $oid — строка с идентификатором, а $stream — открытый ресурс. После commit объект остаётся в базе; дескриптор уже закрыт. В минимальном фрагменте не показаны строка-ссылка и уборка, поэтому для самостоятельного запуска используйте проект ниже.

Что получится после запуска

НаблюдениеРезультат
ДокументИнструкция
Байт22
Чтениепорциями по 5 байт
Содержимое совпалода
СсылкаOID отдельного объекта
Самостоятельный проект, исходник и архив
Сохранить документ отдельным Large Object и прочитать частямиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

    $table=p180OwnTable($db,$owned,'documents','(id integer PRIMARY KEY, title text NOT NULL, content_oid oid NOT NULL)');
    $q=p180Qualified($table);$source="Document 180\n\0\xffpart-2\n";
    $db->beginTransaction();$oid=p180OwnLob($db,$owned);$stream=$db->lobOpen($oid,'w');
    if($stream===false){throw new RuntimeException('LO не открыт для записи.');}
    try {
        for($offset=0;$offset<strlen($source);){$written=fwrite($stream,substr($source,$offset,5));if($written===false||$written===0){throw new RuntimeException('Запись не продвинулась.');}$offset+=$written;}
    } finally {fclose($stream);}
    p180Command($db,"INSERT INTO $q VALUES(1,?,CAST(? AS oid))",['Инструкция',$oid]);$db->commit();
    $db->beginTransaction();$record=p180Read($db,"SELECT title,content_oid::text AS oid FROM $q WHERE id=?",[1])[0];
    $stream=$db->lobOpen($record['oid'],'rb');if($stream===false){throw new RuntimeException('LO не открыт для чтения.');}
    $out=fopen(__DIR__.'/document.bin','wb');$chunks=0;$total=0;
    try {
        while(!feof($stream)){$chunk=fread($stream,5);if($chunk===false){throw new RuntimeException('Ошибка чтения.');}if($chunk===''){break;}
            $written=fwrite($out,$chunk);if($written!==strlen($chunk)){throw new RuntimeException('Неполная запись файла.');}$total+=$written;++$chunks;}
    } finally {fclose($stream);fclose($out);}
    $db->commit();$restored=file_get_contents(__DIR__.'/document.bin');
    $checks=['oid_is_reference'=>$record['oid']===$oid&&ctype_digit($oid),'correct_document'=>$record['title']==='Инструкция',
      'content_equal'=>$restored===$source,'length_equal'=>$total===strlen($source),'multiple_chunks'=>$chunks>1,
      'transaction_closed'=>!$db->inTransaction(),'hash_equal'=>hash('sha256',$restored)===hash('sha256',$source)];
    $rows=[['Документ',$record['title']],['Байт',$total],['Чтение','порциями по 5 байт'],['Содержимое совпало',$restored===$source],['Ссылка','OID отдельного объекта']];
    $observations=['sha256'=>hash('sha256',$restored),'chunks'=>$chunks,'created_oid'=>$oid,'linked_oid'=>$record['oid']];

} finally {

    if($db instanceof PDO){try{$removed=p180Cleanup($db,$owned);}finally{p180Close($db);}}
}

p180Report('pdo180-large-object-file','Сохранить документ отдельным Large Object и прочитать частями',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Документ — Инструкция
Байт — 22
Чтение — порциями по 5 байт
Содержимое совпало — да
Ссылка — OID отдельного объекта

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать восстановленный документ

Границы решения

Large Object требует собственного удаления. DROP таблицы или DELETE строки сами по себе не вызывают lobUnlink. Закрывайте поток до окончания транзакции. Размер порции 5 выбран для видимого обучения на маленьком файле, а не как настройка производительности.

Попробуйте изменить условие

Можно ли хранить один OID в двух строках и удалить объект после удаления первой?

Разбор

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

Ссылка и замена в одной транзакции

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

Один документ — один владелец вложения
  1. SELECT id=1 FOR UPDATE: прочитать текущую ссылку
  2. Создать новый LO и записать все байты
  3. UPDATE ссылки + lobUnlink старого объекта
  4. commit подтверждает оба изменения; rollback отменяет оба

У таблицы нет обычного FOREIGN KEY, который автоматически превратит OID в гарантированную ссылку на живой LO и вызовет нужное удаление. Приложение управляет этим договором. FOREIGN KEY между собственными таблицами документов и владельцев по-прежнему полезен, но не заменяет lifecycle отдельного вложения.

В проекте неправильная версия 0 нарушает CHECK. Новый LO, созданный внутри откатившейся транзакции, исчезает вместе с ней; старое содержимое и прежняя ссылка остаются. Следующая корректная операция создаёт версию 2 и явно удаляет старый LO. Это три выделения объекта за историю, а не три одновременно оставшихся вложения. Итоговый файл можно скачать и сравнить с текстом результата.

Если один объект используют несколько документов, перед удалением нужен иной договор: например, отдельная строка владельца и транзакционный учёт ссылок. Не решайте эту задачу обходом всех OID и массовым удалением. При потере связи во время commit результат может быть неизвестен клиенту: сначала сверяйте версию/ключ операции, затем решайте вопрос повтора.

Заменить вложение атомарно и не оставить лишние объекты

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

Путь данных и полученный результат
  1. Версия 1 → старый OID
  2. Версия 0 запрещена CHECK → rollback
  3. Ссылка и прежние байты сохранены
  4. Версия 2 + новая ссылка + unlink старого → commit

Минимальный вызов

$db->beginTransaction();
// Блокируем строку документа, создаём новое вложение и меняем ссылку.
// После проверки владения удаляем старый объект через lobUnlink().
$db->commit();

beginTransaction/commit обрамляют одну операцию. Если запрос выдаёт исключение, обработчик вызывает rollBack. Точки изменения ссылки и удаления объекта находятся в одной транзакции; полный код ниже показывает конкретные SQL, OID и ошибку CHECK.

Что получится после запуска

НаблюдениеРезультат
Ошибка версии23514
После отката1
Неудачный LO удалён откатомда
После замены2
Старый LO удалёнда
Файлreplacement.txt
Самостоятельный проект, исходник и архив
Заменить вложение атомарно и не оставить лишние объектыСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

    $table=p180OwnTable($db,$owned,'versions','(id integer PRIMARY KEY, version integer NOT NULL CHECK(version>0), content_oid oid NOT NULL)');
    $q=p180Qualified($table);
    $write=function(string $oid,string $text)use($db):void{$s=$db->lobOpen($oid,'w');if($s===false){throw new RuntimeException('LO не открыт.');}try{if(fwrite($s,$text)!==strlen($text)){throw new RuntimeException('Неполная запись.');}}finally{fclose($s);}};
    $read=function(string $oid)use($db):string{$s=$db->lobOpen($oid,'r');if($s===false){throw new RuntimeException('LO не открыт.');}try{$v=stream_get_contents($s);if($v===false){throw new RuntimeException('Ошибка чтения.');}return $v;}finally{fclose($s);}};
    $db->beginTransaction();$old=p180OwnLob($db,$owned);$write($old,"Instruction version 1\n");
    p180Command($db,"INSERT INTO $q VALUES(1,1,CAST(? AS oid))",[$old]);$db->commit();
    $db->beginTransaction();$failed=p180OwnLob($db,$owned);$write($failed,"Invalid version\n");
    $failure=p180Capture(fn()=>p180Command($db,"UPDATE $q SET version=0,content_oid=CAST(? AS oid) WHERE id=1",[$failed]));
    $db->rollBack();
    $afterRollback=p180Read($db,"SELECT version,content_oid::text AS oid FROM $q WHERE id=1")[0];
    $failedAbsent=!p180ObjectExists($db,'LARGE_OBJECT',$failed);
    $db->beginTransaction();$preserved=$read($old);$db->commit();
    $db->beginTransaction();$locked=p180Read($db,"SELECT content_oid::text AS oid FROM $q WHERE id=1 FOR UPDATE")[0];
    $new=p180OwnLob($db,$owned);$write($new,"Instruction version 2\n");
    p180Command($db,"UPDATE $q SET version=2,content_oid=CAST(? AS oid) WHERE id=1",[$new]);
    if(!$db->lobUnlink($locked['oid'])){throw new RuntimeException('Старое вложение не удалено.');}$db->commit();
    $current=p180Read($db,"SELECT version,content_oid::text AS oid FROM $q WHERE id=1")[0];
    $db->beginTransaction();$text=$read($current['oid']);$db->commit();
    file_put_contents(__DIR__.'/replacement.txt',$text);
    $checks=['failure_check'=>$failure['failed']&&$failure['state']==='23514',
      'old_link_preserved'=>$afterRollback['oid']===$old&&(int)$afterRollback['version']===1,
      'old_content_preserved'=>$preserved==="Instruction version 1\n",'rolledback_lob_absent'=>$failedAbsent,
      'new_link'=>$current['oid']===$new&&(int)$current['version']===2,
      'old_lob_unlinked'=>!p180ObjectExists($db,'LARGE_OBJECT',$old),
      'new_content'=>$text==="Instruction version 2\n",'transaction_closed'=>!$db->inTransaction()];
    $rows=[['Ошибка версии',$failure['state']],['После отката',(int)$afterRollback['version']],['Неудачный LO удалён откатом',$failedAbsent],['После замены',(int)$current['version']],['Старый LO удалён',!p180ObjectExists($db,'LARGE_OBJECT',$old)],['Файл','replacement.txt']];
    $observations=['lifecycle'=>[['oid'=>$old,'ending'=>'explicit_unlink'],['oid'=>$failed,'ending'=>'transaction_rollback'],['oid'=>$new,'ending'=>'finally_unlink']],'text'=>$text];

} finally {

    if($db instanceof PDO){try{$removed=p180Cleanup($db,$owned);}finally{p180Close($db);}}
}

p180Report('pdo180-large-object-replacement','Заменить вложение атомарно и не оставить лишние объекты',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Ошибка версии — 23514
После отката — 1
Неудачный LO удалён откатом — да
После замены — 2
Старый LO удалён — да
Файл — replacement.txt

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать новую инструкцию

Границы решения

FOR UPDATE блокирует выбранную строку до завершения транзакции; приложение должно ограничивать ожидание и обрабатывать конфликт. Пример предполагает, что документ единственный владелец LO. Для совместно используемых вложений нужен отдельный договор владения.

Попробуйте изменить условие

Что будет при ошибке после lobUnlink, но до commit?

Разбор

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

Подписка, подтверждение и перечитывание данных

LISTEN/NOTIFY помогает быстро сообщить другому соединению: данные изменились, перечитай их. Это механизм сигналов между соединениями текущей базы, а не JavaScript-событие и не серверный callback, который сам запустит PHP. Слушатель должен быть работающим клиентом с открытым подключением.

ВызовДоговорКак используется
getNotify(int $fetchMode=PDO::FETCH_DEFAULT, int $timeoutMilliseconds=0): array|falseОдно ожидающее уведомление либо falseFETCH_ASSOC: message=канал, pid=отправитель, payload=данные
getPid(): intPID серверного backend текущего соединенияСопоставить отправителя и слушателя; это не PID PHP
setNoticeCallback(?callable $callback): voidОбработчик сообщения string → void; null сбрасываетСобирать NOTICE отдельно от данных и NOTIFY

У getNotify разрешены FETCH_DEFAULT, FETCH_BOTH, FETCH_ASSOC и FETCH_NUM. При числовом формате поля имеют индексы 0, 1, 2; непустой payload передаётся дополнительным полем. Неверный fetchMode и отрицательный timeout вызывают ValueError. Для слишком большого timeout действует предупреждение и предел знакового 32-битного числа миллисекунд. В наших проектах ожидание ограничено 50 или 1000 миллисекундами. false само по себе не означает отказ SQL-запроса: уведомления могло не быть.

LISTEN начинает действовать после подтверждения транзакции подписки. NOTIFY внутри транзакции становится доступен после commit; rollback отменяет сигнал. Долгая транзакция на слушателе также задерживает доставку клиенту. При первом запуске сначала подтвердите LISTEN, затем прочитайте актуальное состояние, затем обрабатывайте уведомления; это помогает не пропустить изменения на границе подписки. Возможны повторные сигналы, поэтому перечитывание и обработка должны допускать повтор.

Для значений используйте SELECT pg_notify(?, ?), чтобы канал и payload передавались параметрами. Сам LISTEN принимает имя канала как идентификатор: оно должно быть фиксированным либо разрешённым и корректно процитированным. В обсуждении GUI и фоновой обработки сформулирована та же задача: сообщить обработчику об изменении таблицы. Здесь мы сохраняем короткий ключ CABLE, а остаток читаем отдельным SELECT.

Содержимое уведомления доступно подписчикам канала в этой базе; не помещайте туда секреты. Размер payload ограничен сервером, а одинаковые сигналы внутри одной транзакции могут объединяться. Очередь уведомлений не служит долговременным хранилищем задач. Для надёжной доставки используйте таблицу outbox и учёт обработки; сигнал лишь ускоряет пробуждение. NOTIFY: ограничения и транзакции описывает детали сервера.

$messages = [];
$db->setNoticeCallback(function (string $message) use (&$messages): void {
    $messages[] = $message;
});
// Здесь команда сервера может выдать NOTICE.
$db->setNoticeCallback(null);

function задаёт функцию, use (&$messages) позволяет ей добавлять строки в внешний массив, а void означает отсутствие возвращаемого значения. Вызов получает серверное диагностическое сообщение, если его допускает client_min_messages. Не путайте этот callback с асинхронной подпиской NOTIFY и не выводите необработанные сообщения на HTML-страницу. В проекте своё контролируемое NOTICE проверяется отдельно; обработчик сбрасывается после использования и в finally.

Получить сигнал после commit и перечитать реальный остаток

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

Путь данных и полученный результат
  1. LISTEN подтверждён на соединении слушателя
  2. UPDATE 8→7 + NOTIFY → rollback: нет сигнала
  3. UPDATE 8→6 + NOTIFY → commit: сигнал CABLE
  4. SELECT по CABLE показывает 6

Минимальный вызов

$listener->exec('LISTEN stock_changed');
$sender->query("SELECT pg_notify('stock_changed', 'CABLE')")->closeCursor();
$message = $listener->getNotify(PDO::FETCH_ASSOC, 1000);

LISTEN подписывает текущее соединение на канал. pg_notify передаёт небольшой сигнал. getNotify ждёт до 1000 миллисекунд и возвращает массив либо false. В минимальном вызове команды вне транзакции подтверждаются автоматически; полный проект сравнивает явные rollback и commit.

Что получится после запуска

НаблюдениеРезультат
Остаток после rollback8
Сообщение после rollbackнет
ТоварCABLE
Остаток после commit6
Сигнал полученда
NOTICE отдельно1
Самостоятельный проект, исходник и архив
Получить сигнал после commit и перечитать реальный остатокСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
$listener=null;
try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

    $table=p180OwnTable($db,$owned,'stock','(sku text PRIMARY KEY,quantity integer NOT NULL CHECK(quantity>=0))');$q=p180Qualified($table);
    p180Command($db,"INSERT INTO $q VALUES('CABLE',8)");
    $listener=p180Connect($config);$channel='p180_stock_'.bin2hex(random_bytes(6));
    $listener->exec('LISTEN '.$listener->escapeIdentifier($channel));
    $listenerPid=$listener->getPid();$senderPid=$db->getPid();
    $notices=[];$db->setNoticeCallback(function(string $message)use(&$notices):void{$notices[]=$message;});
    $db->exec("DO \$\$ BEGIN RAISE NOTICE 'p180 stock preview'; END \$\$");$db->setNoticeCallback(null);
    $db->beginTransaction();p180Command($db,"UPDATE $q SET quantity=7 WHERE sku=?",['CABLE']);
    p180Read($db,'SELECT pg_notify(?,?)',[$channel,'CABLE']);$db->rollBack();
    $rollbackMessage=$listener->getNotify(PDO::FETCH_ASSOC,50);
    $before=(int)p180Read($listener,"SELECT quantity FROM $q WHERE sku=?",['CABLE'])[0]['quantity'];
    $db->beginTransaction();p180Command($db,"UPDATE $q SET quantity=6 WHERE sku=?",['CABLE']);
    p180Read($db,'SELECT pg_notify(?,?)',[$channel,'CABLE']);$db->commit();
    $message=$listener->getNotify(PDO::FETCH_ASSOC,1000);
    if($message===false){throw new RuntimeException('Подтверждённый сигнал не получен.');}
    $stock=p180Read($listener,"SELECT sku,quantity FROM $q WHERE sku=?",[$message['payload']])[0];
    $event=['sku'=>$stock['sku'],'quantity'=>(int)$stock['quantity'],'rollback_notification'=>$rollbackMessage!==false,'commit_notification'=>true];
    file_put_contents(__DIR__.'/stock-event.json',json_encode($event,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n");
    $checks=['different_backends'=>$listenerPid!==$senderPid,'rollback_silent'=>$rollbackMessage===false,
      'rollback_preserved'=>$before===8,'right_channel'=>$message['message']===$channel,'right_sender'=>$message['pid']===$senderPid,
      'right_payload'=>$message['payload']==='CABLE','reread_committed'=>(int)$stock['quantity']===6,
      'notice_separate'=>count($notices)===1&&str_contains($notices[0],'p180 stock preview')];
    $rows=[['Остаток после rollback',$before],['Сообщение после rollback',$rollbackMessage!==false],['Товар',$stock['sku']],['Остаток после commit',(int)$stock['quantity']],['Сигнал получен',true],['NOTICE отдельно',count($notices)]];
    $observations=['event'=>$event,'different_backends'=>$listenerPid!==$senderPid,'notice_contains_marker'=>str_contains($notices[0]??'','p180 stock preview'),'notification_is_durable_queue'=>false];

} finally {
if($listener instanceof PDO){try{$listener->exec('UNLISTEN *');}finally{p180Close($listener);}}
if($db instanceof Pdo\Pgsql){$db->setNoticeCallback(null);}
    if($db instanceof PDO){try{$removed=p180Cleanup($db,$owned);}finally{p180Close($db);}}
}

p180Report('pdo180-stock-notification','Получить сигнал после commit и перечитать реальный остаток',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Остаток после rollback — 8
Сообщение после rollback — нет
Товар — CABLE
Остаток после commit — 6
Сигнал получен — да
NOTICE отдельно — 1

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать состояние после сигнала

Границы решения

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

Попробуйте изменить условие

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

Разбор

Повторно зарегистрируйте LISTEN, затем прочитайте устойчивое состояние или необработанные записи outbox. Используйте идемпотентность; сигнал ускоряет реакцию, а таблица определяет, что ещё требуется выполнить.

Исторические имена и переход на Pdo\Pgsql

Pdo\Pgsql появился в PHP 8.4. Ранее PostgreSQL-методы вызывали на общем PDO с префиксом pgsql. С PHP 8.5 эти девять исторических имён устарели. Существующие карточки остаются доступны по прежним адресам; новый проект создаёт именно Pdo\Pgsql и использует методы без префикса.

Исторический метод PDOСовременный Pdo\PgsqlСмысл перехода
pgsqlLOBCreatelobCreateТот же самостоятельный объект и OID
pgsqlLOBOpenlobOpenOID и режим, не stream из bytea
pgsqlLOBUnlinklobUnlinkЯвное удаление отдельного объекта
pgsqlGetNotifygetNotifyФормат и timeout в миллисекундах
pgsqlGetPidgetPidСерверный PID соединения

Четыре исторических COPY-метода и современные варианты разобраны в главе COPY. Замена имени не меняет автоматически схему данных, права, обработку транзакций или согласованность задач. Не подавляйте предупреждения устаревания вместо плановой миграции. Для общего интерфейса PDO остаются те же prepare/execute/fetch; подробности параметров и атрибутов приведены в главе подключения и запросов.

Соединить сценарии в сервисе

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

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

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

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

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

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